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.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
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.
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.
1GET /users/422→ 200 OK3{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": 118114}1GET /users/usr_7f9c2→ 200 OK3{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.
- 1Team → endpoint: returns the ORM object directly to hit a deadline; the review says "we will clean it up".
- 2Partner → integration: maps
created_by_admin_idandlegacy_tierinto their CRM because they were there. - 3Analytics → pipeline: filters on
is_deleted = 0client-side; the field is now load-bearing outside the company. - 4Database → migration: normalizes addresses into their own table; the flat address fields vanish from the response.
- 5On-call → incident: three external integrations break on a schema migration that had no product meaning.
- 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.
- • 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=`.
- • 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.
- • 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]]).
- • 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.