DTOs and mappers at the boundaries
Name every field that leaves; a type is not a filter, and a removal list is out of date at the next migration.
Predict first
Before you read: predict
ExamHall's attempt API builds its response with toAttemptResponse. Tuesday's migration added a column to attempts; nobody touched the mapper. The build runs tsc and stops on any error. The files are below.
Which of the row's fields does main.ts print?
// attempt-row.ts (the ORM's row type, regenerated after Tuesday's migration)export interface AttemptRow { id: string; answer: string; score: number | null; internalNotes: string; proctorFlags: string[] } // attempt-response.tsimport type { AttemptRow } from './attempt-row';export interface AttemptResponse { id: string; answer: string; score: number | null }export function toAttemptResponse(row: AttemptRow): AttemptResponse { const { internalNotes, ...rest } = row; return rest;} // main.tsimport type { AttemptRow } from './attempt-row';import { toAttemptResponse } from './attempt-response';const row: AttemptRow = { id: 'a-7', answer: 'B', score: 80, internalNotes: 'recheck q3', proctorFlags: ['tab-switch'] };console.log(Object.keys(toAttemptResponse(row)).join(', '));The 30-second version
Keep three shapes apart: what a client may send, what it may read, and the row as stored, with the rules in a domain model between them. Map at the edges. A response mapper lists the fields it sends; anything that removes fields instead sends whatever the table gains. Values the server owns come from the domain.
Name every field that leaves; a type is not a filter, and a removal list is out of date at the next migration.
How it works
Step 1 of 13
In the kitchen
- a caller's report, the log and the statement are three sheets, each with its own headings → request DTO, row and response DTO (step 1)
Analogy: A mountain rescue team's press officer
| At the rescue base | In code |
|---|---|
| the call-out log stays at the base; the press officer writes a statement for the papers | mappers at the edges (step 3) |
| the statement template has its own headings, date, area and outcome, and she fills only those | listing what leaves (step 4) |
| the old habit: photocopy the log page and black out the columns named on a card | a removal list (step 5) |
| the log gains a "next of kin" column; nobody updates the card | a migration the removal list does not know (step 5) |
| a caller's report, the log and the statement are three sheets, each with its own headings | request DTO, row and response DTO (step 1) |
In real terms: before shipping a response, read the mapper and check that every field it sends is one you named.
Breaks down when: a press officer notices a name in an odd column and stops; a mapper never notices anything it was not told.
Contrasting case: a GraphQL client names the fields it wants, but that list is the client's: the server still decides, field by field, which properties this user may read.
Examples, run for real
4.1 ExamHall: the controller that returned the row
EXH-4170: ExamHall's GET /attempts/:id returned the ORM row as it came, so the response carried every column of the attempts table:
// attempts.controller.ts (as shipped)
getAttempt(id: string): AttemptRow { return findAttempt(id); }
MR !4470 adds toAttemptResponse, which lists the five fields the candidate's screen reads, and the controller returns its result:
// attempt-response.ts (MR !4470)
export function toAttemptResponse(row: AttemptRow): AttemptResponse {
return { id: row.id, questionId: row.questionId, answer: row.answer, status: row.status, deadlineAt: row.deadlineAt.toISOString() };
}
4.1 EXH-4170 as shipped: keys sent 7 (id, questionId, answer, status, deadlineAt, internalNotes, proctorFlags) · tsc no diagnostics
4.1 MR !4470: keys sent 5 (id, questionId, answer, status, deadlineAt) · deadlineAt sent as 2026-10-14T09:00:00.000Z · tsc no diagnostics
4.1 MR !4470 after a migration adds reviewerId: keys sent 5, unchanged · tsc no diagnostics
As shipped, both internal columns went out (step 8). After the MR, the response holds the five listed fields (step 4); the new review column stays inside, and the date leaves as the API's string, not the row's Date (steps 1, 3).
4.2 What the MR does not decide
The request side is the pipe's: SubmitAnswerDto { questionId, answer } is whitelisted as nest-validation-pipes steps 6 and 10 teach, and grading, not the request, sets score (step 11). Which fields the candidate's screen should read is a product question; the mapper only makes the answer explicit.
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A box office's mobile app reads tickets from GET /tickets/:id. ticket-row.ts, db.ts and main.ts are the same in every version; ticket-api.ts comes in three versions. Tuesday's migration renames the column seatCode to seatLabel in the database and in ticket-row.ts. The team then runs tsc and, wherever it points at seatCode, writes seatLabel instead, until tsc is clean. The files are below.
Which version sends the app the same keys after the migration as before it?
// ticket-row.ts (the ORM's row type, before the migration)export interface TicketRow { id: string; seatCode: string; priceCents: number; holdToken: string } // db.tsimport type { TicketRow } from './ticket-row';const rows: TicketRow[] = [{ id: 't-1', seatCode: 'B12', priceCents: 4500, holdToken: 'hx-9' }];export const findTicket = (id: string): TicketRow => rows.find((r) => r.id === id)!; // ticket-api.ts (version 1)import type { TicketRow } from './ticket-row';import { findTicket } from './db';export const getTicket = (id: string): TicketRow => findTicket(id); // ticket-api.ts (version 2)import { findTicket } from './db';export interface TicketResponse { id: string; seatCode: string; priceCents: number }export const getTicket = (id: string): TicketResponse => { const row = findTicket(id); return { id: row.id, seatCode: row.seatCode, priceCents: row.priceCents };}; // ticket-api.ts (version 3)import type { TicketRow } from './ticket-row';import { findTicket } from './db';const SENT: (keyof TicketRow)[] = ['id', 'seatCode', 'priceCents'];export const getTicket = (id: string): Partial<TicketRow> => { const row = findTicket(id); return Object.fromEntries(SENT.map((k) => [k, row[k]]));}; // main.tsimport { getTicket } from './ticket-api';console.log(Object.keys(getTicket('t-1')).join(', '));Question 2 of 8
A box office's NestJS API registers ClassSerializerInterceptor for every route (NestJS 12.1.2, class-transformer 0.5.1), and the project compiles with experimentalDecorators, as Nest projects do. The ticket service returns what the ORM gives it. The files are below.
Which keys does the serialized response to getTicket('t-1') hold?
// ticket.entity.tsimport { Exclude } from 'class-transformer';export class TicketEntity { id!: string; seatLabel!: string; @Exclude() holdToken!: string;} // tickets.service.ts (the ORM returns rows as plain objects)import type { TicketEntity } from './ticket.entity';const rows = [{ id: 't-1', seatLabel: 'B12', holdToken: 'hx-9' }];export const findTicket = (id: string): TicketEntity => rows.find((r) => r.id === id)!; // tickets.controller.tsimport { findTicket } from './tickets.service';export const getTicket = (id: string) => findTicket(id);Question 3 of 8
A box office takes orders from a resale partner as queue messages. Partners send their own quoted total, which the box office logs for reconciliation; the message has already passed its schema check. The stored order's total must come from the box office's own prices. The files are below.
Which line, in place of // the missing line, stores the box office's own total for any quote?
// order.ts (the domain model)const PRICE_CENTS: Record<string, number> = { B12: 4500 };export class Order { private constructor(readonly seat: string, readonly quantity: number, readonly totalCents: number) {} static place(seat: string, quantity: number): Order { return new Order(seat, quantity, PRICE_CENTS[seat] * quantity); } static restore(seat: string, quantity: number, totalCents: number): Order { return new Order(seat, quantity, totalCents); }} // partner-message.tsexport interface PartnerOrderMessage { seat: string; quantity: number; quotedTotalCents: number } // to-order.tsimport { Order } from './order';import type { PartnerOrderMessage } from './partner-message';export function toOrder(msg: PartnerOrderMessage): Order { // the missing line} // main.tsimport { toOrder } from './to-order';console.log(toOrder({ seat: 'B12', quantity: 2, quotedTotalCents: 1 }).totalCents);Question 4 of 8
Before you write the function, list your tests for toTicketResponse(row), which turns a box office's ticket row (id, seatLabel, eventId, validFrom as a Date, scannedAt as a Date or null, holdToken, resaleNote) into the response the door scanners read (id, seatLabel, eventId, validFrom as an ISO string, scannedAt as an ISO string or null).
Name at least four tests, each with its input and what it expects.
Question 5 of 8
Now write it, in 10 minutes, from a blank file that exports toTicketResponse.
Write toTicketResponse in full.
Question 6 of 8
A box office's order response shows the buyer's id and name. Next month's migration adds email to buyers. The mapper already lists the order's own fields; the rest is the buyer line. The build runs tsc and stops on any error. The files are below.
Which line, in place of /* the buyer line */, sends the buyer's id and name and nothing else of the buyer, both today and after the migration?
// order-row.ts (the ORM's row types, before next month's migration)export interface BuyerRow { id: string; name: string; phone: string }export interface OrderRow { id: string; seatLabel: string; buyer: BuyerRow } // order-response.tsimport type { BuyerRow, OrderRow } from './order-row';export interface OrderResponse { id: string; seatLabel: string; buyer: { id: string; name: string } }export function toOrderResponse(row: OrderRow): OrderResponse { return { id: row.id, seatLabel: row.seatLabel, /* the buyer line */ };} // main.tsimport type { OrderRow } from './order-row';import { toOrderResponse } from './order-response';const row: OrderRow = { id: 'o-5', seatLabel: 'B12', buyer: { id: 'u-2', name: 'Ana', phone: '555-0101' } };console.log(Object.keys(toOrderResponse(row).buyer).join(', '));Question 7 of 8
A ledger's NestJS API registers ClassSerializerInterceptor for every route (NestJS 12.1.2, class-transformer 0.5.1) and compiles with experimentalDecorators. The handler returns the ORM's plain row; last week's migration added payerIban to payments. The files are below.
Which keys does the serialized response to getPayment('p-1') hold?
// payment.entity.tsimport { Exclude } from 'class-transformer';export class PaymentEntity { id!: string; amountCents!: number; @Exclude() riskNote!: string;} // payments.controller.ts (the ORM returns rows as plain objects)import { SerializeOptions } from '@nestjs/common';import { PaymentEntity } from './payment.entity';const rows = [{ id: 'p-1', amountCents: 1200, riskNote: 'manual review', payerIban: 'XX00TEST0001' }];export class PaymentsController { @SerializeOptions({ type: PaymentEntity }) getPayment(id: string) { return rows.find((r) => r.id === id)!; }}Question 8 of 8
A chat API's NestJS app registers ClassSerializerInterceptor for every route (NestJS 12.1.2, class-transformer 0.5.1) and compiles with experimentalDecorators. The handler returns the ORM's plain row, with the message's author joined in. The files are below.
Which fields does the response body to getMessage('m-1') hold?
// user.entity.tsimport { Exclude } from 'class-transformer';export class UserEntity { id!: string; name!: string; @Exclude() email!: string;} // message.entity.tsimport { UserEntity } from './user.entity';export class MessageEntity { id!: string; body!: string; author!: UserEntity;} // messages.controller.ts (the ORM returns rows as plain objects)import { SerializeOptions } from '@nestjs/common';import { MessageEntity } from './message.entity';const rows = [{ id: 'm-1', body: 'hi', author: { id: 'u-9', name: 'Ola', email: '[email protected]' } }];export class MessagesController { @SerializeOptions({ type: MessageEntity }) getMessage(id: string) { return rows.find((r) => r.id === id)!; }}Go deeper
Pro tips
- Read a response mapper for
.... A spread or a rest pattern in a response mapper sends fields nobody named; a list offield: row.fieldlines does not. When: reviewing any MR that adds or changes an endpoint's response (steps 4–5). Verify (RAN,tips.mjs): over MR !4470's mapper and the alternate probe's, a search for...finds 1 line, the alternate probe's removal list; MR !4470's mapper has none. - Diff the response keys in a test. Assert the exact key list of each response, so a migration that adds a column fails a test instead of shipping a field. When: any endpoint backed by an ORM row (steps 5, 8). Verify (RAN,
tips.mjs): the key-list assertion passes for MR !4470's mapper on the migrated row, and fails for the shipped controller's response with 2 extra keys.
Source
- Martin Fowler, Data Transfer Object (P of EAA catalog; the row's first source): https://martinfowler.com/eaaCatalog/dataTransferObject.html · fetched 2026-10-14. Teaching only; every key here is executed. "An object that carries data between processes in order to reduce the number of method calls." "Usually an assembler is used on the server side to transfer data between the DTO and any domain objects." (steps 1, 3). Valid as of 2026-10-14.
- OWASP, API3:2023 Broken Object Property Level Authorization (the row's second source): https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/, read from the OWASP repo's Markdown at the commit in
sources/raw/owasp.sha. "The API endpoint exposes properties of an object that are considered sensitive and should not be read by the user." "Avoid using generic methods such asto_json()andto_string(). Instead, cherry-pick specific object properties you specifically want to return." (steps 4, 13). Valid as of 2026-10-14. - NestJS docs, Validation (the row's third source): https://docs.nestjs.com/techniques/validation, read from the docs repo. Valid as of 2026-10-14.
- NestJS docs, Serialization (not named by the row; teaching only, step 10): https://docs.nestjs.com/techniques/serialization, read from the same repo and commit. "The handler must return an instance of the class." "The
typeoption of@SerializeOptions()converts responses into instances of the specified class before they are serialized". P2's key comes from running the interceptor, not from this page. Valid as of 2026-10-14.
Related lessons
- Read first: Validation at the boundary: ValidationPipe, whitelisting, mass assignment
- Read first: Contract evolution: breaking changes, spec diffs, versioning and deprecation
- Related: Types vanish at runtime, validate at the boundary
- Related: The repository pattern over an ORM
- Related: An anti-corruption layer translates a whole foreign model
- Related: Clean architecture layers in a NestJS feature
Checked against the tools and docs as of 2026-10-14.