---
title: 'Data Transfer Objects'
source: 'https://academia.sh/en/courses/data-access-layer/data-transfer-objects'
course: 'The Data Access Layer and Business Logic'
language: en
updated: '2026-08-19T05:19:33+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```sh
mkdir -p domain presentation
```

```js
// 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.

```js
// 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.

```js
// 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: "alice@example.test", 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)));
```

```sh
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.

```js
// 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.

```js
// 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: "alice@example.test", 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));
});
```

```sh
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.

```js
// 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: "alice@example.test", 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(", ")}`);
}
```

```sh
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.

```js
// 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(", ")}`);
}
```

```sh
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.
