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

Source: https://voxgig.com/howto/reconcile-an-extracted-model-with-a-domain-model

- Audience: platform-team
- Level: advanced
- Languages: typescript
- Verified: 2026-09-25
- Published: 2026-09-25

## 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 diagram[^1] or an [event storming](https://www.eventstorming.com/) 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](https://www.eventstorming.com/) | 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](https://learn.microsoft.com/en-us/openapi/kiota/experience) | 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](https://www.stainless.com/docs/reference/config/) | 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.

```ts title="extract.ts"
// 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.

```bash
node extract.ts
```

```text output
{
  "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.

```json title="domain.json"
    "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.

```json title="decisions.json"
  {
    "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.

```ts title="reconcile.ts"
  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.

```bash
node reconcile.ts
```

```text output
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.

```bash
node reconcile.ts decisions.partial.json
```

```text output
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](https://nodejs.org/api/test.html) pin the extractor's
naming, the four undecided rows in the partial file, and the empty undecided list in the full
one.

```ts title="reconcile.test.ts"
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'],
  )
})
```

```bash
node --test reconcile.test.ts
```

```text output
# 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](https://semver.org/#spec-item-8) 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

- [Enforce a naming convention across 40 microservice specs](/howto/enforce-naming-conventions-across-many-specs)

- [Tell an entity-shaped SDK from an endpoint-shaped one](/howto/measure-entity-versus-endpoint-shaped-output)

- [Decide if an endpoint is an entity operation or an action](/howto/entity-operation-or-standalone-action)

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

[^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](https://dspace.mit.edu/handle/1721.1/47432)
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.

[^2]: [Semantic Versioning 2.0.0](https://semver.org/#spec-item-8) 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.

[^3]: Kiota's [design overview](https://learn.microsoft.com/en-us/openapi/kiota/design) 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](https://learn.microsoft.com/en-us/openapi/kiota/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.