ADR-0011: DTO design — complete top-level representations, slim embedded children
- Status: Accepted
- Date: 2026-06-05
- Scope: core (any backend exposing a REST API)
Context
Two recurring DTO temptations corrode an API: trimming fields from a response
because one client doesn’t use them today (breaking the next client), and
embedding full child DTOs inside parent responses (duplicating shared context on
every list item — payment method, category, currency repeated N times on an
invoice with N expenses).
Decision
- Top-level DTOs are complete domain representations. Never remove fields
from a top-level response just because a particular client doesn’t currently
use them. REST APIs serve contracts, not individual consumers. - Embedded child lists use a slim, context-specific DTO named
<Parent><Child>Response, living in the parent feature’sdto/package.
It omits exactly the fields the parent already provides. - Create a slim embedded DTO when all three hold:
- a parent response embeds a
List<SomeFullResponse>whose items contain
nested objects; - those nested objects are identical across items (they come from the parent
context); - the list can grow large.
- a parent response embeds a
- Response DTOs are constructed from domain entities (constructor mapping,
no mapper classes); request DTOs are records carrying validation annotations. - Frontends mirror the backend DTO names exactly in their shared types, so
a name is a cross-repo search key.
Examples (Expenses): InvoiceExpenseResponse inside
InvoiceDetailResponse.expenses[]; InstallmentExpenseResponse inside
InstallmentSeriesResponse.installments[].
Consequences
- Adding a client never requires re-litigating what’s in a response.
- Embedded lists stay light without inventing per-screen endpoints.
- One more class per embedded list (cheap, and its name documents its context).
Related
- 0002-clean-architecture-and-use-cases — DTOs live in presentation; use
cases return entities, never DTOs
Amendment — 2026-09-10: a collection returns summaries
Decided while building dracomania’s decks. It is recorded here, in core, because the
pattern already runs across the repos without having been written down. Expenses has
ConversationSummaryResponse / ConversationDetailResponse and
PaymentWebhookEventSummaryResponse / …DetailResponse.
- A collection endpoint returns a summary of each item; the full representation lives
at the item’s own endpoint.GET /players/{playerId}/decksreturns each deck’s identity,
name and card count.GET /players/{playerId}/decks/{deckId}returns the deck with its
cards. A client that needs all of one item fetches that item. - This is not the trimming the rule above forbids. The summary is a representation
of its own, with its own contract, sized for what a list is for. It is not a full DTO
with fields removed because one client ignores them. The no-trimming rule applies to
each representation separately: neither the summary nor the detail loses a field
because a client doesn’t read it today. - A summary omits what its collection’s context already provides, the same way a
slim embedded child does: a player’s decks don’t repeat the owner id. - Nested resources inside the full representation still use slim embedded DTOs, per
the rule above. - Trigger: use a summary when the full representation carries nested collections or
heavy fields that a list does not render. When a collection’s items are already small
and flat (a set’s card catalogue, which clients fetch whole and cache), return the full
items; there is nothing to summarise. - Naming:
<Entity>SummaryResponsefor a list item,<Entity>DetailResponsefor the
full representation. A project with a recorded naming deviation maps the pair onto its
own scheme. Dracomania usesDeckSummaryDTO/DeckDTO.