How-to › Describe your API

How to reconcile an extracted entity model with a domain model#

Put the entity model extracted from the spec beside the one your team drew, give each disagreement a row and a decision, and fail the build when one has neither.

Audience
Platform team
Level
advanced
Topic
Extract a semantic model from a spec
Languages
TypeScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A hand-drawn ERD or event-storming domain modelThe team already has the drawing and a reviewer who knows both it and the APIA picture nobody diffs, so a mismatch is found in review or by a user rather than in CIThe API has more entities than a reviewer holds in their head
Kiota URL-tree request buildersYou accept the URL hierarchy as the model and want nothing to maintainEvery mistake in the URL design becomes a method chain, and the domain model is never written downYour paths carry version or vendor segments that are not concepts
The Stainless config resources blockYou want the grouping declared, reviewed, and version-controlled beside the specCorrect on the day it is written, and silent when a path is added that the file does not mentionNobody 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

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

  1. 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

  2. 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

  3. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.