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’s dto/ package.
    It omits exactly the fields the parent already provides.
  • Create a slim embedded DTO when all three hold:
    1. a parent response embeds a List<SomeFullResponse> whose items contain
      nested objects;
    2. those nested objects are identical across items (they come from the parent
      context);
    3. the list can grow large.
  • 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).

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}/decks returns 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>SummaryResponse for a list item, <Entity>DetailResponse for the
    full representation. A project with a recorded naming deviation maps the pair onto its
    own scheme. Dracomania uses DeckSummaryDTO / DeckDTO.