Lesson 18 / 21
Data Transfer Objects
Separating the outer contract from the inner model: producing two different views from the same domain object, testing that the outer contract does not change when a field is added to the inner model, counting the fields returning the domain object directly leaks, and mass assignment in the incoming direction.
Contents
In the previous lesson’s corrected version, the domain layer returned an object shaped
{ result, reason }, and the presentation layer turned it into an HTTP body. The
translation had three fields and was nearly one-to-one; the middle layer looked like an
unnecessary handoff.
The question sharpens once the record grows. Is the shape of the loan record returned to the outside the same thing as the domain model itself? When a new field is added to the domain model, should the body the client sees change? This lesson’s subject is the thing placed between the two sides: a data transfer object — a plain record produced for talking to the outside, carrying no domain behavior.
One Record, Two Contracts
The loan record’s domain model carries the data and the decisions that derive from that data. The class below holds eleven fields; two of them produce derived information. The lesson’s files sit in two directories.
mkdir -p domain presentation
// domain/loan.mjs — the loan record's domain model export class Loan { constructor(v) { this.loanId = v.loanId; this.bookId = v.bookId; this.bookTitle = v.bookTitle; this.memberId = v.memberId; this.memberName = v.memberName; this.memberEmail = v.memberEmail; this.pickupDate = v.pickupDate; this.dueDate = v.dueDate; this.returnDate = v.returnDate; this.staffNote = v.staffNote; this.recordVersion = v.recordVersion; } get isOpen() { return this.returnDate === null; } overdueDays(today) { const end = this.returnDate ?? today; const diff = (Date.parse(end) - Date.parse(this.dueDate)) / 86400000; return Math.max(0, Math.round(diff)); } status(today) { if (!this.isOpen) return "returned"; return this.overdueDays(today) > 0 ? "overdue" : "open"; } }
Three fields do not belong to the outside. memberEmail is personal data shown only to
certain roles; staffNote is internal correspondence; recordVersion is the version
column from the Optimistic and Pessimistic Locking lesson itself — a persistence detail.
The outside does not want a single shape either. The list screen is satisfied with four fields per row; the detail screen wants to see the relations too.
// presentation/views.mjs — two different outer contracts from the same domain object export const listView = (o, today) => ({ loanId: o.loanId, book: o.bookTitle, dueDate: o.dueDate, status: o.status(today), }); export const detailView = (o, today) => ({ loanId: o.loanId, book: { bookId: o.bookId, title: o.bookTitle }, member: { memberId: o.memberId, name: o.memberName }, pickupDate: o.pickupDate, dueDate: o.dueDate, returnDate: o.returnDate, status: o.status(today), overdueDays: o.overdueDays(today), });
The mapping does two jobs: it leaves the internal fields out and adds the derived
fields. The second job is the one that goes unnoticed most often. status and
overdueDays are not values stored on the domain model, they are computed values;
letting the client compute them itself would mean copying the business rule to the
client.
// view.mjs — one domain object, two outer contracts import { Loan } from "./domain/loan.mjs"; import { listView, detailView } from "./presentation/views.mjs"; const TODAY = "2025-06-20"; const record = new Loan({ loanId: 7, bookId: 5, bookTitle: "Blindness", memberId: 2, memberName: "Alice Kane", memberEmail: "[email protected]", pickupDate: "2025-06-01", dueDate: "2025-06-15", returnDate: null, staffNote: "called by phone", recordVersion: 3, }); console.log("list :", JSON.stringify(listView(record, TODAY))); console.log("detail:", JSON.stringify(detailView(record, TODAY)));
node view.mjs
list : {"loanId":7,"book":"Blindness","dueDate":"2025-06-15","status":"overdue"}
detail: {"loanId":7,"book":{"bookId":5,"title":"Blindness"},"member":{"memberId":2,"name":"Alice Kane"},"pickupDate":"2025-06-01","dueDate":"2025-06-15","returnDate":null,"status":"overdue","overdueDays":5}
Same object, two bodies. The list body flattened the relations, the detail body grouped them. This is not a formatting choice, it is two different contracts: the list screen can change without the detail contract being affected.
Isolating the Contract from Internal Change
The real reason for the separation sits here. What happens when a new field is added to the domain model? The file below is the same model after two internal fields have been added.
// domain/loan-2.mjs — the same model after two internal fields are added export class Loan { constructor(v) { this.loanId = v.loanId; this.bookId = v.bookId; this.bookTitle = v.bookTitle; this.memberId = v.memberId; this.memberName = v.memberName; this.memberEmail = v.memberEmail; this.pickupDate = v.pickupDate; this.dueDate = v.dueDate; this.returnDate = v.returnDate; this.staffNote = v.staffNote; this.recordVersion = v.recordVersion; this.renewalCount = v.renewalCount ?? 0; // new this.lastReminderDate = v.lastReminderDate ?? null; // new } get isOpen() { return this.returnDate === null; } overdueDays(today) { const end = this.returnDate ?? today; const diff = (Date.parse(end) - Date.parse(this.dueDate)) / 86400000; return Math.max(0, Math.round(diff)); } status(today) { if (!this.isOpen) return "returned"; return this.overdueDays(today) > 0 ? "overdue" : "open"; } }
A test compares the bodies the two versions produce.
// view.test.mjs — tests that the outer contract is unaffected by internal model change import { test } from "node:test"; import assert from "node:assert/strict"; import { Loan as Loan1 } from "./domain/loan.mjs"; import { Loan as Loan2 } from "./domain/loan-2.mjs"; import { listView, detailView } from "./presentation/views.mjs"; const TODAY = "2025-06-20"; const BASE = { loanId: 7, bookId: 5, bookTitle: "Blindness", memberId: 2, memberName: "Alice Kane", memberEmail: "[email protected]", pickupDate: "2025-06-01", dueDate: "2025-06-15", returnDate: null, staffNote: "called by phone", recordVersion: 3, }; // Version 2 adds two internal fields to the same record. const BASE2 = { ...BASE, renewalCount: 2, lastReminderDate: "2025-06-18" }; const LIST_CONTRACT = ["loanId", "book", "dueDate", "status"]; const DETAIL_CONTRACT = ["loanId", "book", "member", "pickupDate", "dueDate", "returnDate", "status", "overdueDays"]; const INTERNAL_FIELDS = ["memberEmail", "staffNote", "recordVersion", "renewalCount", "lastReminderDate"]; test("list view produces the fields in the contract", () => { assert.deepEqual(Object.keys(listView(new Loan1(BASE), TODAY)), LIST_CONTRACT); }); test("detail view produces the fields in the contract", () => { assert.deepEqual(Object.keys(detailView(new Loan1(BASE), TODAY)), DETAIL_CONTRACT); }); test("internal fields do not appear in either view", () => { const text = JSON.stringify(listView(new Loan2(BASE2), TODAY)) + JSON.stringify(detailView(new Loan2(BASE2), TODAY)); for (const field of INTERNAL_FIELDS) assert.equal(text.includes(field), false, field); }); test("list view stays identical once the internal model gains a field", () => { assert.deepEqual(listView(new Loan2(BASE2), TODAY), listView(new Loan1(BASE), TODAY)); }); test("detail view stays identical once the internal model gains a field", () => { assert.deepEqual(detailView(new Loan2(BASE2), TODAY), detailView(new Loan1(BASE), TODAY)); });
node --test view.test.mjs
✔ list view produces the fields in the contract (0.872625ms) ✔ detail view produces the fields in the contract (0.076625ms) ✔ internal fields do not appear in either view (0.350875ms) ✔ list view stays identical once the internal model gains a field (0.062292ms) ✔ detail view stays identical once the internal model gains a field (0.06175ms) ℹ tests 5 ℹ suites 0 ℹ pass 5 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 36.116458
The durations in parentheses change on every run; what matters is the pass 5 line.
Between the two versions, the outer bodies came out identical under deepEqual. The
internal model changed, the outer contract did not. This is the in-application
counterpart of the backward compatibility rule from the Web API Design course: the
contract changing has to be a decision, not a side effect.
The Cost of Returning Directly
The reverse direction can be measured. If the endpoint returns the domain object directly, a two-way gap opens between the outer body and the contract.
// count-leak.mjs — what happens to the outer contract when the domain object is returned directly import { Loan as Loan1 } from "./domain/loan.mjs"; import { Loan as Loan2 } from "./domain/loan-2.mjs"; import { listView } from "./presentation/views.mjs"; const TODAY = "2025-06-20"; const BASE = { loanId: 7, bookId: 5, bookTitle: "Blindness", memberId: 2, memberName: "Alice Kane", memberEmail: "[email protected]", pickupDate: "2025-06-01", dueDate: "2025-06-15", returnDate: null, staffNote: "called by phone", recordVersion: 3, }; const BASE2 = { ...BASE, renewalCount: 2, lastReminderDate: "2025-06-18" }; // The body the client sees is the object serialized to JSON. const externalFields = (object) => Object.keys(JSON.parse(JSON.stringify(object))).sort(); const diff = (a, b) => a.filter((x) => !b.includes(x)); for (const [name, record] of [["version 1", new Loan1(BASE)], ["version 2", new Loan2(BASE2)]]) { const mapped = externalFields(listView(record, TODAY)); const direct = externalFields(record); console.log(`${name} mapped=${mapped.length} fields direct=${direct.length} fields`); console.log(` leaked (${diff(direct, mapped).length}): ${diff(direct, mapped).join(", ")}`); console.log(` missing (${diff(mapped, direct).length}): ${diff(mapped, direct).join(", ")}`); }
node count-leak.mjs
version 1 mapped=4 fields direct=11 fields leaked (9): bookId, bookTitle, memberEmail, memberId, memberName, pickupDate, recordVersion, returnDate, staffNote missing (2): book, status version 2 mapped=4 fields direct=13 fields leaked (11): bookId, bookTitle, lastReminderDate, memberEmail, memberId, memberName, pickupDate, recordVersion, renewalCount, returnDate, staffNote missing (2): book, status
Three findings stand out. The first is the leak itself: the list contract has four
fields, and the body returned directly came out with eleven, nine of which are not in
the contract. Among them, memberEmail is personal data, staffNote is internal
correspondence, recordVersion is a persistence detail.
The second is that the growth happens on its own: adding two internal fields pushed the directly-returned body to thirteen fields, while the mapped body stayed at four. On the directly-returned endpoint, every model change is a contract change, and nobody decided on it.
The third is loss in the other direction. The directly-returned body has no status or
book fields, because these are derived, not stored. The extra bytes measured in the
Over-Fetching lesson gain a second cost here: the body is both larger than it needs to
be and short on the information the client actually needs.
The Incoming Direction
The same boundary is needed in the other direction too. If the request body is assigned directly to the inner model, the client determines fields it should not be able to set. The comparison below counts this.
// incoming-direction.mjs — what the client determines when the request body is assigned directly const DEFAULTS = { loanId: null, bookId: null, bookTitle: null, memberId: null, memberName: null, memberEmail: null, pickupDate: null, dueDate: null, returnDate: null, staffNote: null, recordVersion: 1, }; // The body the client sent: three legitimate fields and three extras. const REQUEST = { bookId: 5, memberId: 2, pickupDate: "2025-06-01", loanId: 1, recordVersion: 99, staffNote: "mark the record closed", }; const unprotected = (request) => ({ ...DEFAULTS, ...request }); const mapped = (request) => ({ ...DEFAULTS, bookId: request.bookId, memberId: request.memberId, pickupDate: request.pickupDate, }); const determinedByClient = (object) => Object.keys(object).filter((f) => JSON.stringify(object[f]) !== JSON.stringify(DEFAULTS[f])); for (const [name, build] of [["unprotected", unprotected], ["mapped", mapped]]) { const fields = determinedByClient(build(REQUEST)); console.log(`${name.padEnd(10)} fields determined by client = ${fields.length}: ${fields.join(", ")}`); }
node incoming-direction.mjs
unprotected fields determined by client = 6: loanId, bookId, memberId, pickupDate, staffNote, recordVersion mapped fields determined by client = 3: bookId, memberId, pickupDate
In the unprotected version, the client determined the record’s identity, the version counter, and the staff note. The version counter coming from the client makes optimistic locking useless: conflict detection depends on the version number the server knows. This behavior is called mass assignment, and it silently widens as fields are added to the body.
In the mapped version, the request body is a data transfer object; a field not named in it cannot reach the inner model. This is the same principle as the identifier allowlist in the Query Objects and Specifications lesson: turning an incoming name into internal structure rests on a fixed list.
The Cost and Limit of Mapping
The separation is not free. Every view means extra code, extra testing, and extra maintenance every time the model changes. As the field count grows the mapping functions grow too, and part of them just copy the field name as it is.
The payoff is clearly collected in three cases: when the outer contract needs to change at a different pace than the inner model, when the inner model has a field that should not be shown to the outside, and when more than one view is produced from the same record. The role separation from the Authentication and Authorization course feeds the third case directly: the same loan record is visible to the member, to staff, and to the reporting interface with different field sets; the place carrying that difference is the view functions.
The opposite case also exists. In an admin screen with no domain model, one that just reads a table and lists it, a separate data transfer object layer produces nothing but a copy. The test is this: if the mapping function hides no field, derives no field, and renames no field, that mapping is not yet carrying a contract.
Summary
- A data transfer object is a plain record produced for talking to the outside; the domain model carries the data and the decisions that derive from it.
- Two separate contracts, list and detail, were produced from the same domain object; the mapping both hid internal fields and added derived fields.
- When two fields were added to the internal model, both views produced identical bodies; all five tests passed.
- When the domain object was returned directly, nine fields leaked relative to the list contract and two derived fields went missing; once the model grew, the leaked field count rose to eleven.
- In the incoming direction, assigning the request body directly to the inner model let the client determine six fields; a mapping that names the fields brought that number down to three.
Next Step
In this lesson’s incoming-direction example, the fields in the request body were
accepted with no check at all: is bookId a number, is pickupDate a valid date, does
the member actually exist, is that member currently eligible to borrow? All of these
questions get filed under “validation”, but they do not belong in the same place. One
concerns the shape of the body and can be answered without looking at the data; the
other is the organization’s rule and cannot be answered without looking at the database.
The next lesson runs the same faulty input through both layers, shows the difference
between the responses they produce, and lays out why the domain rule cannot be reduced
to input validation.
To keep your progress and take notes, Log in
My notes
Log in to take notes.