RequestsresponsesDTOserializationstabilityinternal leakageids

Response Contracts Are Not Database Rows

Serializing the ORM model is the fastest way to ship an endpoint and the most expensive way to own one. A response model is a purpose-built shape — stable ids, explicit types, computed fields, nothing accidental — that lets the table change without the contract noticing.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
What does this response promise consumers — and how much of that promise is really just the current shape of a table that nobody intended to freeze?
Consumers
Every client that parses a response and builds on it: a mobile app that stores the JSON in a local cache, a partner that maps your fields into their CRM, a generated SDK that turns every property into a typed accessor, and the analytics pipeline that discovered your `internal_flags` column and now depends on it.
The promise
A designed response model contains exactly the fields consumers need, each with a stable name, type and meaning independent of storage; ids are opaque, timestamps and money are unambiguous, and nothing internal leaks by accident.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

The row becomes the contract without anyone deciding

return jsonify(user) ships in one line and silently makes a promise about every column on the table: password_hash (a security incident), created_by_admin_id (an internal id now used by a partner), legacy_tier (a column you meant to drop last quarter), is_deleted (a soft-delete flag consumers now filter on themselves). None of these was designed into the API; all of them are now load-bearing, because consumers depend on whatever they can see (Hyrum's law applies to fields just as it does to behavior).

The cost arrives when storage changes. Normalizing address_line_1, address_line_2, city into an addresses table — a database decision with no product meaning — breaks every client reading those flat fields. Renaming a column for clarity is a breaking change. Splitting a table across two services means the "row" no longer exists, and the endpoint must now reassemble a shape that only ever existed because of how one table happened to be laid out. See Normalization: 1NF to BCNF and Denormalization on Purpose for how often storage shape *should* change — the response contract is what lets it.

The alternative is a deliberate translation step: database row → domain object → API response model. The response model is a type in its own right, owned by the API, with a reviewer's question attached to every field: *which consumer task needs this?* Fields without an answer do not ship. This is the same boundary Software Design calls a DTO; the API version has the added constraint that once shipped, the shape is a promise to people who cannot see your code.

repository / ORMexplicit mapping — the contract boundaryserializerDatabase rowDomain objectAPI response modelJSON on the wire
UserLLMAgentToolDataDecisionHumanGuardrail

What a designed response model contains

Ids are opaque strings with a type prefix (usr_7f9c, proj_18a2) — not the auto-increment integer, which leaks row counts, invites enumeration (see Broken Access Control (IDOR / BOLA)) and welds the contract to one table's primary key. Timestamps are RFC 3339 UTC strings everywhere, never a mix of epoch seconds in one endpoint and milliseconds in another. Money is an integer in minor units plus a currency code, because a float 19.99 is a bug waiting for a rounding decision (One Vocabulary: Naming and Consistency holds the full vocabulary).

Computed and derived fields belong in the response when consumers would otherwise compute them wrong: can_cancel: true derived from the Resources Have State Machines rules is better than making every client re-implement the transition table; display_name assembled server-side beats three clients concatenating first and last names in three orders. The rule: if the derivation encodes business policy, ship the result; if it is presentation (date formatting, pluralization), leave it to the client.

Relationships are the design decision that most affects Over-Fetching and Under-Fetching: embed the related object (owner: { id, name }), link it (owner_id plus a URL), or expand on request (?expand=owner). Embedding a *summary* — the two or three fields every consumer needs — with an id to fetch the rest is the compromise that keeps the common case one round trip without dragging the full owner record into every project response.

The row, serialized: everything the table knows, nothing the consumer asked for
1GET /users/42
2200 OK
3{
4 "id": 42,
5 "email": "a@b.c",
6 "password_hash": "$argon2id$…",
7 "created_by_admin_id": 7,
8 "legacy_tier": 3,
9 "is_deleted": 0,
10 "created": 1724580000,
11 "last_login": "2026-08-25 10:14:03",
12 "balance": 19.99,
13 "org_id": 1181
14}
A response model: chosen fields, stable types, opaque ids, one vocabulary
1GET /users/usr_7f9c
2200 OK
3{
4 "id": "usr_7f9c",
5 "email": "a@b.c",
6 "display_name": "A. Bee",
7 "created_at": "2026-08-25T09:20:00Z",
8 "last_login_at": "2026-08-25T10:14:03Z",
9 "balance": { "amount": 1999, "currency": "EUR" },
10 "organization": { "id": "org_4a1", "name": "Bee Co" }
11}
12# password_hash, admin ids, legacy_tier, is_deleted: never existed in the contract.

The second shape can survive a table split, a column rename, a move to another service and a soft-delete redesign without a single consumer noticing. The first shape freezes all of those decisions the moment a partner writes code against it.

Stability is a property you have to maintain

A response model is only stable if nothing accidental can enter it. Allowlist serialization — the model declares its fields and the serializer emits only those — is the mechanism; denylist serialization ("everything except password_hash") fails the first time someone adds a sensitive column and forgets the list. The security domain's Error Handling and Information Leakage and Sensitive Data Classification lessons cover why the leak direction matters; the contract argument is simpler: fields that were never promised cannot be depended on.

Envelopes are part of the model. Whether a list returns a bare array or { "data": [...], "next_cursor": ... } decides whether Pagination: Choosing How Lists End can be added without breaking clients (a bare array cannot grow a cursor; an envelope can). Whether a single resource is wrapped in { "data": {...} } decides whether metadata (request_id, deprecation notices) has anywhere to live. Choose once, apply everywhere.

The last stability rule concerns absence. A field that is sometimes present and sometimes missing — because the serializer skips nulls, or because a feature flag adds it for some tenants — is a field consumers must code defensively around. Either it is always present (possibly null) or it is behind an explicit expansion. "Sometimes" is the shape of a future incident, and the Backward Compatibility: The Real Rules rules only work when the baseline shape is deterministic.

  • Allowlist fields in an explicit response type; the ORM model is never the serializer input.
  • Opaque, prefixed ids; RFC 3339 UTC timestamps; money as minor-unit integer plus currency.
  • Derived business facts (can_cancel, display_name) in the response; presentation left to clients.
  • Embedded summaries for the common relationship, ids or ?expand= for the rest.
  • Deterministic shape: every field always present (nullable if needed), envelopes chosen once.

Key points

  • Serializing the row makes every column a promise; consumers depend on whatever they can see, including the columns you meant to drop.
  • A response model is a separate, allowlisted type owned by the API — the boundary that lets storage change without breaking the contract.
  • Opaque prefixed ids, RFC 3339 timestamps and minor-unit money are the difference between a shape and a set of bugs waiting for a partner.
  • Ship derived business facts consumers would otherwise compute wrong; leave presentation to the client.
  • Deterministic shape and a chosen envelope are what make additive evolution possible later.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → endpoint: returns the ORM object directly to hit a deadline; the review says "we will clean it up".
  2. 2
    Partner → integration: maps created_by_admin_id and legacy_tier into their CRM because they were there.
  3. 3
    Analytics → pipeline: filters on is_deleted = 0 client-side; the field is now load-bearing outside the company.
  4. 4
    Database → migration: normalizes addresses into their own table; the flat address fields vanish from the response.
  5. 5
    On-call → incident: three external integrations break on a schema migration that had no product meaning.
What breaks
  • Storage refactors become breaking API changes; the database schema is frozen by consumers who never saw it.
  • Sensitive or internal columns leak the first time someone adds one without updating a denylist.
  • Integer ids invite enumeration and cross-tenant probing, and later block sharding or service splits that need different id spaces.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Define an explicit response type per resource with allowlisted fields; map domain → model in one place, reviewed like any contract change.
  • • Use opaque prefixed string ids from day one; never expose auto-increment keys or foreign-key columns.
  • • Standardize timestamps, money, enums and envelopes across the API (see [[naming-and-consistency]]) and enforce with a schema linter.
  • • Include derived business facts (`can_*`, computed status) so consumers do not re-implement policy.
  • • Make every field deterministic: always present, nullable when needed, or behind an explicit `?expand=`.
Observe in production
  • • Contract tests that diff the serialized shape against the published schema catch accidental new fields before consumers do (see [[api-testing]]).
  • • Per-field access telemetry (which consumers read which fields) shows which accidental fields became load-bearing — the input for [[removing-fields]].
  • • Sequential-id probing patterns in access logs (`/users/41`, `/42`, `/43`) show the enumeration cost of integer ids.
Evolve without breaking
  • • Adding a field to an allowlisted model is additive; renaming means shipping both names through a deprecation window.
  • • Moving from integer to opaque ids mid-life is a migration: accept both on input for a window, emit only the new form, track consumers still sending integers.
  • • Introducing an envelope on a bare-array list is breaking; do it at a version boundary, or add a parallel endpoint and migrate ([[api-migration]]).
What it costs
  • • A separate response type is a mapping layer to write and keep in sync — real work, and the reason the shortcut is tempting.
  • • Opaque ids cost a lookup or an encoding scheme; integer ids are simpler until the first enumeration or shard.
  • • Embedding summaries makes the common case fast and the payload bigger; every embedded field is another thing that cannot be removed lightly.

Misconceptions

Claim
“A separate DTO layer is duplication; the model already has the fields.”
Reality
It is the only thing that lets the model change. Without it, every column is a promise, and normalizing a table becomes a breaking API change with a deprecation window. The mapping code is the price of owning the storage schema.
Claim
“Consumers only use the fields we document.”
Reality
Consumers use the fields they can see. Undocumented fields become dependencies the moment a partner's test passes with them; removing them later breaks integrations nobody knew existed.
Claim
“Integer ids are fine; we check authorization anyway.”
Reality
Authorization is necessary but ids also leak growth rates, invite scanning, and tie the contract to one table's key space. Opaque ids cost little at the start and are painful to retrofit.

Apply it