Skip to content
academia.sh

Lesson 28 / 30

Data Transfer Objects and Mappers

Taking cross-layer data transport up at the pattern level: counting how direct-reading clients break when the internal model's shape changes against how clients behind a mapper do not, the number of internal field names in client code, and the mapping's line cost per field.

Contents

The four patterns so far arranged the boundary between business logic and persistence. The domain object carries the rule, does not know about persistence, and is unique in memory. This object will also step outside the library: an interface will list it, an invoice statement will be produced from it, a log row will record it. Handing the object out as is turns its internal structure into an external contract.

Data Transfer Object defines this contract as a separate, flat record; a mapper produces that record from the internal model. The pattern was established in The Data Access Layer and Business Logic course, and measured there by the number of fields that leaked into the external contract. Here the measure is on the side of change: how many client outputs break when the internal model’s shape changes, how many internal field names the client code carries, and how many lines the mapping costs per field.

Problem: If the Internal Shape Is Read From Outside

The internal model can hold the same information in two different shapes. In the second version, the fee-related fields have been moved into a nested object; the information carried is the same, the address is different.

// domain.mjs — two internal models of the same information: in the second, the fee fields moved into a nested object
export const versionOne = () => ({
  id: "G7", weight: 3, postalCode: "06800", notes: "contracted customer",
  raw: 8640, discounts: [{ name: "contract", rate: 0.15 }, { name: "volume", rate: 0.1 }],
});

export const versionTwo = () => ({
  id: "G7", weight: 3, postalCode: "06800", notes: "contracted customer",
  fee: { base: 8640, discounts: [{ name: "contract", rate: 0.15 }, { name: "volume", rate: 0.1 }] },
});

Three clients read this object directly and produce their own format.

// without-dto/clients.mjs — three clients read the internal model's fields directly and format them
const CAP = 0.4;
const rate = (s) => Math.min(s.discounts.reduce((t, d) => t + d.rate, 0), CAP);
const net = (s) => Math.round(s.raw * (1 - rate(s)));

export const list = (s) => `${s.id}  ${s.weight} kg  ${net(s)}`;
export const invoice = (s) =>
  `${s.id}: base ${s.raw}, ${s.discounts.map((d) => `${d.name} ${-Math.round(s.raw * d.rate)}`).join(", ")} = ${net(s)}`;
export const log = (s) => `${s.id} net=${net(s)} discounts=${s.discounts.length}`;

All three clients know the names raw and discounts. The assumption that the internal model carries these two names has thus spread to three separate places.

Solution: Defining the Contract as a Separate Record

The mapper produces the external contract from the internal model. There are two production functions for the two internal versions, but the records they produce are identical.

// dto/mapper.mjs — produces the same three data transfer objects for each version of the internal model
const CAP = 0.4;

const build = (id, weight, base, discounts) => {
  const rate = Math.min(discounts.reduce((t, d) => t + d.rate, 0), CAP);
  const net = Math.round(base * (1 - rate));
  return {
    list: { identity: id, weight, net },
    invoice: { identity: id, base, items: discounts.map((d) => ({ name: d.name, amount: -Math.round(base * d.rate) })), total: net },
    log: { identity: id, net, discountCount: discounts.length },
  };
};

export const mapperOne = (s) => build(s.id, s.weight, s.raw, s.discounts);
export const mapperTwo = (s) => build(s.id, s.weight, s.fee.base, s.fee.discounts);
// dto/clients.mjs — three clients read only the data transfer object's fields
export const list = (d) => `${d.list.identity}  ${d.list.weight} kg  ${d.list.net}`;
export const invoice = (d) =>
  `${d.invoice.identity}: base ${d.invoice.base}, ${d.invoice.items.map((i) => `${i.name} ${i.amount}`).join(", ")} = ${d.invoice.total}`;
export const log = (d) => `${d.log.identity} net=${d.log.net} discounts=${d.log.discountCount}`;

Notice that the contract names differ from the internal names: id inside, identity outside. The distinction is deliberate; making the two names the same might look convenient, but it would tie the two sides back together.

Counting the Spread of Change

The measurement moves the internal model from the first version to the second, and counts what happens in the two arrangements. An output is counted as broken if it contains undefined or NaN, or if it throws.

// count-spread.mjs — number of broken client outputs when the internal model changes, and internal field names in the client
import { readFileSync } from "node:fs";
import { versionOne, versionTwo } from "./domain.mjs";
import * as direct from "./without-dto/clients.mjs";
import { mapperOne, mapperTwo } from "./dto/mapper.mjs";
import * as dto from "./dto/clients.mjs";

const NAMES = ["list", "invoice", "log"];
const attempt = (f, input) => { try { return f(input); } catch { return "ERROR"; } };
const sound = (s) => /undefined|NaN|ERROR/.test(s) === false;

function run(name, first, second) {
  const before = NAMES.map((n) => attempt(first[0][n], first[1]));
  const after = NAMES.map((n) => attempt(second[0][n], second[1]));
  const broken = after.filter((s) => sound(s) === false).length;
  const changed = after.filter((s, i) => s !== before[i]).length;
  console.log(`${name}: broken client output = ${broken} / ${NAMES.length}, changed output = ${changed} / ${NAMES.length}`);
  return after;
}

console.log("internal model version 1 -> version 2");
run("without dto", [direct, versionOne()], [direct, versionTwo()]);
const result = run("with dto   ", [dto, mapperOne(versionOne())], [dto, mapperTwo(versionTwo())]);
for (const r of result) console.log(`  ${r}`);

const INTERNAL_FIELD = /\bs\.(raw|discounts|notes)\b|\bfee\.(base|discounts)\b/g;
for (const file of ["without-dto/clients.mjs", "dto/clients.mjs", "dto/mapper.mjs"]) {
  const m = readFileSync(file, "utf8");
  console.log(`${file.padEnd(24)} internal field name = ${[...m.matchAll(INTERNAL_FIELD)].length}`);
}

const dtoFields = Object.values(mapperOne(versionOne())).reduce((t, d) => t + Object.keys(d).length, 0);
const lines = readFileSync("dto/mapper.mjs", "utf8").split("\n").filter((s) => s.trim() !== "").length;
console.log(`dto field count = ${dtoFields}, mapper lines = ${lines}, lines per field = ${(lines / dtoFields).toFixed(2)}`);
node count-spread.mjs
internal model version 1 -> version 2
without dto: broken client output = 3 / 3, changed output = 3 / 3
with dto   : broken client output = 0 / 3, changed output = 0 / 3
  G7  3 kg  6480
  G7: base 8640, contract -1296, volume -864 = 6480
  G7 net=6480 discounts=2
without-dto/clients.mjs  internal field name = 6
dto/clients.mjs          internal field name = 0
dto/mapper.mjs           internal field name = 4
dto field count = 10, mapper lines = 13, lines per field = 1.30

When the internal model’s shape changed, all three clients that read it directly broke. None of the three clients behind the mapper broke, and all three outputs stayed identical to the first version’s — the output lines show this.

The source of the number sits in the next three lines: the client file carries 6 internal field names in the direct-read arrangement, and 0 in the mapper arrangement. Knowledge about the internal structure is gathered into 4 names in a single file, inside the mapper. The spread of change dropped from a product that grows with the client count to a constant tied to a single file.

Counting the Cost

The last line gives the cost. The three contracts carry 10 fields in total, and the mapper produces them in 13 lines: 1.3 lines per field. These lines have no functional counterpart of their own; they do nothing but tie an internal name to an external one. Two extra costs come alongside: 2 new files and 1 extra call on every crossing of the boundary.

The second cost is on the maintenance side. Adding a field to the contract creates work in two places: the mapper must produce that field, and the client must read it. If the field is not produced, the client sees undefined, and the breakage counter in the measurement catches this. For this reason, the record the mapper produces should be locked down with a test; without a contract test, the mapper can stay silently incomplete.

When It Does Not Apply

The pattern’s gain is the product of two numbers: the number of clients bound to the internal structure, and the frequency at which the internal structure changes. If either is low, the gain drops below the cost.

At a single-client boundary, a change to the internal model already concerns only that one client; the mapper protects it from 0 files and, in exchange, charges 13 lines and 2 files. The same holds for calls that stay within the same process and are maintained by the same team: passing the domain object directly costs 0 lines.

There is one more boundary in the opposite direction. If the contract copies the internal model’s field names one for one, the data transfer object does not build an isolation, it only adds a copying layer. In the measurement, this shows up as follows: when the internal model changes, the mapper changes too, and the breakage count climbs back to 3, because the contract has become a mirror of the internal structure. For the pattern to work, the contract must be defined by a decision independent of the internal model. This distinction was named in the Design Principles course: policy and detail separation.

Summary

  • The data transfer object defines the external contract as a separate flat record; the mapper produces that record from the internal model and gathers knowledge of the internal structure into a single file.
  • When the internal model’s shape changed, 3 of the 3 clients that read it directly broke; 0 of the 3 clients behind the mapper broke, and all three outputs stayed identical.
  • The client file carried 6 internal field names in the direct-read arrangement, and 0 in the mapper arrangement; the knowledge was gathered into the mapper under 4 names.
  • The cost was counted: 13 mapper lines for 10 contract fields (1.3 lines per field), 2 new files, and 1 extra call on every crossing of the boundary.
  • The gain is the product of the client count and how often the internal model changes; at a single client, or when the contract copies the internal names one for one, the pattern turns into a copying layer.

Next Step

This lesson’s clients never asked which door they entered the library through; they took the record the mapper produced ready-made. Who produces the record, in which order the scenario runs, and where the unit of work opens and closes are still unsettled. If four clients each repeat the same scenario on their own, the steps get written four times. The next lesson defines the application boundary with the service layer, measures the number of types the presentation side has to know about and the depth of the access chain, and counts the number of repeated scenario steps once a second client is added.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close