The extractor read your OpenAPI document and produced four entities. The whiteboard from the last
modeling session has four too, and only one name is on both lists. The document invented
billing from a shared prefix, split Order in two by version, and calls placedAt created.
Whichever list the SDK generator is given, the other team will say the SDK is wrong.
What you get
You will end up with three files: the extracted model as sorted JSON, the domain model as entities and attributes, and a decisions file with one row per mismatch. A script fails when a mismatch has no row. This is for you if a platform team owns the model an SDK is generated from.
Short answer
Emit the extracted model and the domain model as sorted JSON, diff entity names, then attribute names, and record every disagreement as a row with a decision and a date. There are four kinds to work separately. An entity the API never exposes, an entity invented from a shared path prefix, one concept split by a version segment, and an attribute the payload spells differently. Fail the check when a domain entity has neither a counterpart nor a row.
You will need
Node 22 or later, an OpenAPI document, and a domain model written down. That means the entities, their attributes, what identifies each one, and the relationships between them, taken from an entity relationship diagram1 or an event storming session. Verified 2026-09-25 against Node 22.22.2. The scripts are TypeScript run directly by Node, with no compile step.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A hand-drawn ERD or event-storming domain model | The team already has the drawing and a reviewer who knows both it and the API | A picture nobody diffs, so a mismatch is found in review or by a user rather than in CI | The API has more entities than a reviewer holds in their head |
| Kiota URL-tree request builders | You accept the URL hierarchy as the model and want nothing to maintain | Every mistake in the URL design becomes a method chain, and the domain model is never written down | Your paths carry version or vendor segments that are not concepts |
| The Stainless config resources block | You want the grouping declared, reviewed, and version-controlled beside the spec | Correct on the day it is written, and silent when a path is added that the file does not mention | Nobody will own the file after the first SDK ships |
The three sit on one axis. Inferred structure costs nothing to maintain and encodes whatever the URL design already got wrong. Declared structure is right when written and drifts the next time somebody adds a path. The drawing is the most expressive of the three and the only one no machine reads. The reconciliation file below is a declared grouping with a check attached.
Extract the model and write the domain down
The extractor here is deliberately small: an entity per first literal path segment, attributes
from the schema its GET operations return. Its two naive rules are the two the page is about.
// The version prefix and the first literal segment of a path.
function split(path: string): { version: string; base: string } {
const literal = path.split('/').filter(Boolean).filter((s) => !PARAM.test(s))
const version = VERSION.test(literal[0]) ? literal.shift()! : ''
if (!literal[0]) throw new Error(`${path} has no literal segment to name an entity by`)
return { version, base: singular(literal[0]) }
}
A base that appears under two versions keeps the version in its name, because the two collections return different schemas. A literal prefix that several paths share becomes an entity of its own, with every schema under it folded in.
node extract.ts
{
"entities": {
"billing": {
"paths": [
"/v2/billing/invoices",
"/v2/billing/invoices/{id}",
"/v2/billing/settings"
],
"attributes": [
"amount_cents",
"currency",
"id",
"invoice_prefix",
"issued",
"order_id"
],
"operations": [
"get_billing_settings",
"get_invoice",
"list_invoices"
]
},
billing is an invoice with a currency and an invoice_prefix glued on, which is what a
shared prefix looks like once it has been promoted to a thing. The domain model is the other
input, written as data rather than drawn, with the spellings from the whiteboard kept.
"Order": {
"identity": "id",
"attributes": ["id", "customer", "total", "currency", "placedAt", "status"],
"relationships": { "placedBy": "Customer", "invoicedBy": "Invoice", "shippedFrom": "Warehouse" }
},
"Invoice": {
"identity": "id",
"attributes": ["id", "order", "amount", "issuedAt"],
"relationships": { "for": "Order" }
},
"Warehouse": {
"identity": "code",
"attributes": ["code", "region"],
"relationships": { "ships": "Order" }
}
Everything is sorted before it is compared, so the diff is the same for two people on two days. Sorting is also what makes the extracted file reviewable in a pull request, which is the reason to commit it beside the document rather than regenerate it in memory.
Give every disagreement a row
A decision names both sides, says what was decided, and says when it takes effect. now means
the SDK ships this way and the model records why. next major means the rename is agreed and
waits for a version that may break callers.
{
"kind": "domain entity not exposed",
"domain": "Warehouse",
"extracted": null,
"decision": "Stays in the domain model only. No path exposes it, so the SDK gets no Warehouse, and the model says why.",
"when": "now"
},
The script pairs entities first. A domain entity with a same-named extracted entity is matched. One whose name appears in a decision is paired with whatever the decision names. One with a decision that it is not exposed is decided, and anything else is undecided.
for (const name of Object.keys(domain.entities).sort()) {
if (Object.hasOwn(extracted.entities, snake(name))) {
rows.push({ level: 'entity', domain: name, extracted: snake(name), status: 'matched', when: '', decision: '' })
claimed.add(snake(name))
pairs.push([name, snake(name)])
continue
}
const mapped = live.filter((x) => x.domain === name && x.extracted !== null && !x.domain?.includes('.'))
if (mapped.length) {
for (const m of mapped) {
rows.push(row('entity', name, m.extracted!, m))
claimed.add(m.extracted!)
if (!m.alias_of) pairs.push([name, m.extracted!])
}
continue
}
rows.push(row('entity', name, '-', find(name, null)))
}
Attributes are compared only inside a pair, after placedAt has been spelled placed_at, and a
deprecated alias such as v1_order contributes no attribute rows of its own. Its fields are the
old payload, and the decision that names it says so.
node reconcile.ts
node 22.22.2
domain model: event storming, 2026-09-12, orders and fulfilment, 4 entities
extracted model: openapi.json, 4 entities
level domain extracted status when decision
entity Customer customer matched
entity Invoice billing decided next major Invoice is served by the billing paths. The SDK keeps billing.invoices until the next major, then Invoice.
entity Order v1_order decided next major Same concept as Order, in the old payload. Deprecated; its methods keep their names until the next major, and its fields are reconciled through v2_order.
entity Order v2_order decided next major This is Order. The prefix goes at the next major; shipped SDKs call v2_order methods today.
entity Warehouse - decided now Stays in the domain model only. No path exposes it, so the SDK gets no Warehouse, and the model says why.
attribute Customer.id customer.id matched
attribute Customer.email customer.email_address decided now Keep email_address. The payload is the contract; record the alias for the docs.
attribute Customer.name customer.display_name decided now Keep display_name. The domain word is ambiguous between legal and display names.
attribute Invoice.id billing.id matched
attribute Invoice.order billing.order_id decided now Keep order_id, as for customer_id.
attribute Invoice.amount billing.amount_cents decided now Keep amount_cents, as for total_cents.
attribute Invoice.issuedAt billing.issued decided next major Rename issued to issued_at at the next major, with placed_at.
attribute Order.id v2_order.id matched
attribute Order.customer v2_order.customer_id decided now Keep customer_id. The suffix says reference, which the diagram drew as an arrow.
attribute Order.total v2_order.total_cents decided now Keep total_cents. The unit in the name is the thing the whiteboard left out.
attribute Order.currency v2_order.currency matched
attribute Order.placedAt v2_order.created decided next major Rename created to placed_at at the next major. created is a row timestamp, not the business event.
attribute Order.status v2_order.status matched
in the payload, not in the domain model: billing.currency, billing.invoice_prefix
18 rows: 6 matched, 12 decided, 0 UNDECIDED
Read the when column as a release plan. Seven rows are settled now, mostly by accepting the
payload’s spelling and recording the domain word as an alias. Five wait for the next major,
because every one of those would change a method name or a field name in a shipped SDK.
The last line before the totals lists two payload fields the domain never drew, which is
information for the modeling team rather than a failure.
Fail when a row is missing
decisions.partial.json is the same file with four rows removed: the one saying Warehouse is not
exposed, the two about billing, and the one about placedAt. The check reports exactly those
holes and exits non-zero.
node reconcile.ts decisions.partial.json
in the payload, not in the domain model: v2_order.created
15 rows: 5 matched, 6 decided, 4 UNDECIDED
no decision recorded for Invoice
no decision recorded for Warehouse
no decision recorded for billing
no decision recorded for Order.placedAt
Invoice appears because the row that paired it with billing was among the four removed, so
the domain entity lost its counterpart at the same moment the extracted entity lost its
explanation. Both sides of one hole are reported, which is the behavior to want: a reviewer who
fixes one and not the other has not closed it.
Check it worked
Nine tests with the Node test runner pin the extractor’s naming, the four undecided rows in the partial file, and the empty undecided list in the full one.
test('a domain entity with no counterpart and no decision fails, on both sides of the diff', () => {
const rows = reconcile(extracted, domain, partial)
assert.deepEqual(
undecided(rows).map((r) => (r.domain === '-' ? r.extracted : r.domain)),
['Invoice', 'Warehouse', 'billing', 'Order.placedAt'],
)
})
node --test reconcile.test.ts
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
The check belongs in the pipeline that regenerates the SDK, before generation. A path added to the document with no row in the decisions file fails there, on the branch that added it. That is the one moment somebody remembers what the path is for.
When it goes wrong
Every entity comes out prefixed with a version. The document has one collection under two versions, so the extractor keeps the prefix for that collection only. An extractor that prefixes everything is applying the rule to paths rather than to collections. Compare bases across versions, not paths.
An attribute matches that should not. Order.status and v2_order.status are the same word and
may not be the same field, and a name match is all the script has. Add a row anyway when the
meanings differ, with the decision spelling out the difference, and let the row override the
match.
A decision names a field the document has dropped. The check counts that stale row as undecided.
A decided row disappears on the next regeneration. Decisions live in a file the generator never
writes, and the reconciliation runs against the freshly extracted model each time, so nothing
regenerated can remove a row. If rows vanish, somebody edited decisions.json by hand and the
review missed it, which is what the diff in the pull request is for.
The alias is reconciled twice. A decision that maps a deprecated entity to a current one carries alias_of.
Without it, the script reconciles the old payload’s fields against the domain and reports three
holes that are not holes. Mark the alias.
When not to do this
Do not rename an extracted entity to match the domain model on the branch where you found the
mismatch, once an SDK has shipped. v2_order is the wrong name and it is the name every caller’s
code contains, because a generator turned it into a class and a method prefix. Record the row,
set when to the next major, and rename there, which is what
semantic versioning reserves the major version for.2
Do not treat either model as ground truth before the table is empty of undecided rows. The extracted model is faithful to a URL design that may be wrong, and the domain model is faithful to a whiteboard that never saw a payload. Since each corrects the other, neither is the reference until every disagreement has a decision beside it.
Do not fold the decisions into the domain model or into the document. A row that lives in the document is regenerated away; a row that lives in the drawing is never read by a machine. The decisions file is the third artifact because it has to survive both.
Do not accept the URL tree as the model on the grounds that it needs no maintenance. It needs
none because it decides nothing, and the version segment in v1_order is the kind of decision it
declines to make.3
Related how-tos
Last verified
Verified 2026-09-25 against Node 22.22.2. Every output block is what the command preceding it printed, run in the page’s code directory. The document, the domain model, and the decisions are local stand-ins for a platform team’s files, so nothing here ran against a live API.
Footnotes
-
The diagram has a paper, and the paper has a provenance. MIT’s library holds Peter Chen’s The entity-relationship model: toward a unified view of data as Sloan working paper 913-77, dated 1977. The record notes that the article appeared in ACM Transactions on Database Systems, volume 1, number 1, March 1976. A version was presented at the Very Large Data Bases conference in September 1975. What everyone kept from three years and three venues is one diagram. ↩︎ Back to text
-
Semantic Versioning 2.0.0 puts the rule in one sentence: the major version must be incremented if any backward incompatible changes are introduced to the public API. It adds that the major version may also include minor and patch changes, and that the minor and patch numbers must reset to zero when it moves. A generated SDK inherits its method names from the model, so a rename in the model is a public API change in every language the generator emits. The sentence applies to all of them at once. ↩︎ Back to text
-
Kiota’s design overview says that HTTP APIs scale in size because of the hierarchical nature of the URL path. It adds that OpenAPI descriptions do not naturally represent that hierarchy, so it must be constructed, by walking the path items and building a resource hierarchy. The client experience page shows the result: the client object is a request builder that forms the root of a hierarchy of request builders. The hierarchy is real. It belongs to the URL, which is a different thing from the domain. ↩︎ Back to text