Three Models, Not One
The database row, the domain object and the API response answer to different owners and change for different reasons. Collapsing them is a decision, not a default.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
Why should the shape a client receives be different from the shape the database stores?
The mobile team needs an order endpoint. There is an orders table, an Order class in the service layer, and a JSON body going over the wire. How many shapes is that?
One shape. The table is the model, the model is the response, and mapping code between identical structures is ceremony that adds files and hides nothing.
A migration renames total to total_minor for correctness. Every client breaks, and the migration cannot ship until three client releases have gone out (Expand and Contract Migrations).
- A migration renames
totaltototal_minorfor correctness. Every client breaks, and the migration cannot ship until three client releases have gone out (Expand and Contract Migrations). - Someone adds
internal_risk_scoreto the table. It appears in the public API on the next deploy, because the response is whatever the row is (Schema Leakage). - The mobile client needs the customer's display name inline. The table has a foreign key, so either the response leaks a join shape or the endpoint grows a special case the "one model" idea cannot express.
- A field must be removed from the API for privacy. It cannot be, because it is a column the internal jobs depend on, and no seam exists to hide it in.
- Two consumers want different shapes of the same entity. With one model, the endpoint grows flags —
?expand=,?fields=— that are a projection layer built by accident.
What is actually happening
- They change for different reasons. The database shape changes for storage and query reasons: indexes, normalisation, partitioning, column types. The domain shape changes when the business rules change. The API shape changes when a consumer's needs change. Three change frequencies, three sets of stakeholders.
- They have different lifetimes. You can change a column tomorrow. A public response field can outlive the team, because you do not control when clients upgrade — a mobile app in the field is a consumer you cannot deploy.
- They have different vocabularies. A database has foreign keys and nullable columns; a domain has invariants and value objects; an API has stable string enums, pagination envelopes and no nulls-that-mean-two-things.
- The mapping is where the difference is stated. A mapping function is not ceremony; it is the only place in the codebase that says "these two shapes are related, and here is exactly how".
- A DTO is not a layer, it is a boundary type. It exists at exactly the points where data crosses out of your control: into a response, into a queue message, into an event.
Three shapes, three owners, three clocks
The argument for separation is not aesthetic. It is that these three shapes are pushed around by different people on different schedules, and a shape that has to satisfy all three simultaneously satisfies none of them well.
The row below that matters most is the last one: how long a change takes to become safe. A column rename is an afternoon. A public field rename is a deprecation cycle measured in client release trains you do not control.
| Database model | Domain model | API response | |
|---|---|---|---|
| Answers to | The query planner and the storage engine | The business rules | The consumer |
| Changes when | Indexes, normalisation, partitioning, types | A rule or invariant changes | A consumer needs something different |
| Vocabulary | Columns, foreign keys, nullable, ordinals | Value objects, invariants, aggregates | Stable strings, envelopes, explicit nulls |
| Who can break it | A migration | A refactor | Any deploy, if it is a passthrough |
| Cost of a change | A migration, possibly online | A refactor with tests | A deprecation cycle you do not control |
| Nulls mean | Unknown, or not applicable, or nobody backfilled | Usually an invariant violation you disallow | Something the contract must state explicitly |
What the mapping function is for
A mapping function looks redundant on the day it is written, when all three shapes agree. Its value is entirely in the future: it is the file that has to be edited before a database change can reach a client, and it is the list that answers "what does this endpoint actually return".
Note the two things the mapper does here that a field-by-name copy cannot: it renames a column deliberately, and it converts an internal representation into the wire representation the contract promised.
1export interface OrderResponse {2 id: string3 status: 'pending' | 'paid' | 'shipped' | 'cancelled'4 total: { amount: string; currency: string }5 placedAt: string // RFC 3339, UTC6 customer: { id: string; displayName: string }7}8 9export function toOrderResponse(o: Order, c: Customer): OrderResponse {10 return {11 id: o.id.toString(), // 64-bit id as a string12 status: o.status, // stable strings, not ordinals13 total: { amount: o.total.toDecimalString(), currency: o.total.currency },14 placedAt: o.placedAt.toISOString(),15 customer: { id: c.id.toString(), displayName: c.displayName },16 }17 // internalRiskScore, costBasis, deletedAt, tenantId: not here, so not sent18}The comment on the last line is the whole lesson. A field is in the API because this function put it there, and adding a column cannot change that.
When one model is genuinely enough
Separating models has a cost and there are real situations where it is not worth paying. The failure this lesson is trying to prevent is not "someone used one model"; it is "nobody decided".
Whichever option you take, write down what would make you change your mind. For most teams the trigger is the second consumer, or the first consumer you cannot deploy.
Who consumes this, and can you deploy them?
when A prototype, or an internal service with exactly one consumer released in the same pipeline.
cost Every column is public. The bill arrives with the second consumer or the first privacy review (Schema Leakage).
when The common case: a service with real clients and no separate rich domain layer.
cost A mapping function per resource, and the discipline to update it.
when Real invariants, a domain worth modelling, more than one storage or transport.
cost Two mappings and a team that agrees which layer owns what — this is where ceremony genuinely creeps in.
when Consumers with sharply different needs — a mobile client on a metered network and an internal admin tool (Backend for Frontend in API Design).
cost Shapes multiply; each needs its own contract test and its own deprecation path.
How to build it
Most important first.
- Define response types per endpoint, or per resource where several endpoints genuinely share one. Write them by hand or generate them, but enumerate the fields somewhere (Response Contracts Are Not Database Rows in API Design owns the contract rules).
- Map explicitly in one direction: entity to response, and input type to command. Avoid reflective auto-mappers that make the mapping invisible again — the enumeration was the point.
- Put the mapping at the transport edge, so the service layer returns domain objects and the handler decides what leaves the process (Transport, Application, Domain, Infrastructure).
- Keep response types free of backreferences and lazy relations, so encoding cannot trigger a query (What Serialization Costs).
- Version the response type, not the entity. Two response shapes over one domain object is exactly what the separation is for (Running Two API Versions in One Service).
- Be honest about when one model is enough: an internal service with one consumer you deploy together, or a prototype whose lifetime is measured in weeks. Say it is a deliberate choice, and note what it costs when it stops being true.
What can go wrong
- The mapper is written once and then forgotten: new fields are added to the entity and never to the response, so the API silently lags the domain.
- A reflective mapper copies by name, which re-creates leakage exactly — a new column with a matching name appears in the response with no code change.
- DTOs multiply until there is one per method, and each is a copy that must be updated together. The separation stops carrying meaning and becomes duplication.
- The response type is defined in the domain package, so the domain now depends on the transport and the seam is decorative.
- A field is removed from the response type for privacy but still selected, logged and cached — the leak moves rather than closes (Secrets in Logs).
- Positive control is the security argument: with an explicit response type, a new column cannot reach a client without someone editing the response type. With a passthrough, silence is publication.
- Input types are the mirror image — they bound what a caller can write, which is the mass-assignment defence (Deserialization: Bytes to Objects).
- Response types are also where per-audience redaction lives: the same order for the buyer, the seller and an internal admin is three shapes, and hiding a field in the UI is not one of them (Object-Level Authorization).
- "DTOs are enterprise ceremony." The ceremony version is the one with a class per method and no behaviour difference. The useful version is one type per boundary that enumerates what leaves the process.
- "We can add the DTO layer later." You can, but by then the current response shape is the contract, and adding the layer starts with reproducing it exactly.
- "The domain model is the API model if the design is good." They are shaped by different forces. A good domain model has invariants and value objects a JSON client cannot express.
- "An auto-mapper gives us the separation for free." It gives you the files. Field-by-name copying reproduces the coupling it was meant to break.
Operating it
- A contract test that asserts the exact key set of a response for each endpoint. It fails when someone adds a field, which is precisely when you want to be asked (Contract Tests Between Services).
- A generated schema checked into the repository, so a diff shows contract changes in review rather than in a client's crash report (OpenAPI: Describing the Contract, Not Designing It in API Design).
- Response-size-per-route as a proxy signal: a step change usually means an entity grew and a passthrough carried it.
- At small scale one model is cheap and honest. The cost arrives with the second consumer, and it arrives all at once.
- With clients you cannot deploy — mobile apps, partner integrations, SDKs in the wild — the API shape becomes effectively permanent and the separation stops being optional.
- At many services, response types become the shared vocabulary between teams, and the mapping layer is what lets each side refactor without a cross-team release train.
- Mapping code is real work, real files and a real place to forget a field. The payment is that database and API changes stop being the same change.
- Three models mean three places to add a field, and a genuine risk of ceremony when all three are identical — which for a small internal service they may honestly be.
- Generated response types remove the boilerplate and reintroduce coupling to whatever they are generated from; generating them from the database is the leak wearing a build step.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe three-forces argument holds for any service with a consumer it does not deploy in lockstep.
- SCALE-SPECIFICBelow roughly one consumer and one team that ships everything together, a single model is defensible and the mapping is genuine overhead. Above a consumer you cannot force-upgrade — a mobile app, a partner, a published SDK — the API shape is effectively permanent and the separation stops being a preference.
- FRAMEWORK-SPECIFICFrameworks with serializer layers (Django REST Framework, Rails ActiveModel::Serializers, Spring with Jackson views) give you a place to declare the response shape, which makes the separation cheap; a bare Express or Flask handler has no such place, so passthrough is the path of least resistance and the discipline must be yours.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — contract tests that assert an exact response key set, which is what turns the separation from a convention into a check.