Lesson 25 / 30
Active Record and Data Mapper
Where to place the conversion between an object and its stored record: comparing active record, which places the conversion on the object, against data mapper, which hands it to a separate mapper; counting the persistence trace and import closure in the domain module; testing the two versions for round-trip equality; and adding a second storage form without touching the domain module.
Contents
The previous lesson compared two arrangements of business logic and left both of them in memory. The moment a shipment starts being stored, a conversion appears between the object and the stored record: the object’s fields must become a row, and the row must become an object again. Where this conversion is placed produces two patterns. Active Record places the conversion and the storage access on the object itself; the object reads and writes its own row. Data Mapper hands the conversion to a separate object; the domain object stays unaware that it is being stored.
These two patterns were established in The Data Access Layer and Business Logic course, and measured there by the number of queries produced. Here the measure is a design measure: how many names the domain module has to know about persistence, the size of its import closure, and the number of files touched when the storage form changes. Connection pooling, query plans, and indexing detail belong to that course; here, a plain in-memory repository stands in for storage.
mkdir -p active mapper
// memory-repository.mjs — simple object store: keeps rows by table name and id const TABLES = new Map(); const table = (name) => TABLES.get(name) ?? TABLES.set(name, new Map()).get(name); export const repository = { read: (name, id) => table(name).get(id) ?? null, write: (name, row) => { table(name).set(row.id, { ...row }); }, count: (name) => table(name).size, };
Active Record
The pattern’s solution is one sentence: the class that carries the row’s fields also carries the methods that read and write that row.
// active/shipment.mjs — the object reads and writes its own record import { repository } from "../memory-repository.mjs"; const TIER = [[1, 3900], [5, 6400], [Infinity, 11800]]; const MINIMUM_FEE = 3990; export class Shipment { constructor(row) { Object.assign(this, row); } net() { const base = TIER.find(([cap]) => this.weight <= cap)[1]; return Math.max(Math.round(base * this.coefficient * (1 - this.discount)), MINIMUM_FEE); } save() { repository.write("shipment", { id: this.id, weight: this.weight, coefficient: this.coefficient, discount: this.discount }); return this; } static find(id) { const row = repository.read("shipment", id); return row === null ? null : new Shipment(row); } }
The gain is in the file count: one concept, one class, one file. The loss is that the rule and persistence stand in the same file.
Data Mapper
The same work splits into two files. The domain object carries only the rule.
// mapper/shipment.mjs — domain object: only the rule, no persistence name const TIER = [[1, 3900], [5, 6400], [Infinity, 11800]]; const MINIMUM_FEE = 3990; export class Shipment { constructor({ id, weight, coefficient, discount }) { Object.assign(this, { id, weight, coefficient, discount }); } net() { const base = TIER.find(([cap]) => this.weight <= cap)[1]; return Math.max(Math.round(base * this.coefficient * (1 - this.discount)), MINIMUM_FEE); } }
// mapper/shipment-mapper.mjs — mapper that carries the conversion and repository access import { Shipment } from "./shipment.mjs"; import { repository } from "../memory-repository.mjs"; export const shipmentMapper = { toObject: (row) => new Shipment(row), toRow: (s) => ({ id: s.id, weight: s.weight, coefficient: s.coefficient, discount: s.discount }), find(id) { const row = repository.read("shipment", id); return row === null ? null : this.toObject(row); }, save(s) { repository.write("shipment", this.toRow(s)); return s; }, };
The dependency arrow changed direction: the domain object does not know the mapper, the mapper knows the domain object.
Counting the Trace and the Closure
The measure is three numbers. The first is the persistence trace: how many times the
repository name, the table name, and the word row appear in the domain module. The second is
the domain module’s import closure: how many other modules must load for that module to
load. The third is the number of files that carry both the rule and repository access.
// count-traces.mjs — persistence trace, import closure, and repository-linked file count in the domain module import { readFileSync } from "node:fs"; import { dirname, join, normalize } from "node:path"; const TRACE = { "repository name": /\brepository\b/g, "table name": /"shipment"/g, "row word": /\brow\b/g }; const IMPORT = /from "([^"]+)"/g; function closure(start) { const seen = new Set(), stack = [normalize(start)]; while (stack.length > 0) { const file = stack.pop(); if (seen.has(file)) continue; seen.add(file); for (const [, path] of readFileSync(file, "utf8").matchAll(IMPORT)) { if (path.startsWith(".")) stack.push(normalize(join(dirname(file), path))); } } seen.delete(normalize(start)); return seen; } const VERSION = { "active record": { domain: "active/shipment.mjs", files: ["active/shipment.mjs"] }, "data mapper": { domain: "mapper/shipment.mjs", files: ["mapper/shipment.mjs", "mapper/shipment-mapper.mjs"], }, }; for (const [name, v] of Object.entries(VERSION)) { const text = readFileSync(v.domain, "utf8"); const traces = Object.entries(TRACE).map(([k, r]) => `${k} = ${[...text.matchAll(r)].length}`); const linked = v.files.filter((f) => readFileSync(f, "utf8").includes("memory-repository.mjs")); const ruleFile = v.files.filter((f) => readFileSync(f, "utf8").includes("MINIMUM_FEE")); const both = ruleFile.filter((f) => linked.includes(f)); console.log(name); console.log(` domain module (${v.domain}) traces: ${traces.join(", ")}`); console.log(` domain module import closure = ${closure(v.domain).size}`); console.log(` files linked to repository = ${linked.length} / ${v.files.length} (${linked.join(", ")})`); console.log(` files carrying both rule and repository = ${both.length}`); }
node count-traces.mjs
active record domain module (active/shipment.mjs) traces: repository name = 4, table name = 2, row word = 5 domain module import closure = 1 files linked to repository = 1 / 1 (active/shipment.mjs) files carrying both rule and repository = 1 data mapper domain module (mapper/shipment.mjs) traces: repository name = 0, table name = 0, row word = 0 domain module import closure = 0 files linked to repository = 1 / 2 (mapper/shipment-mapper.mjs) files carrying both rule and repository = 0
In active record, the domain module carries the word repository 4 times, the table name 2
times, and the word row 5 times; in the mapper version all three are 0. The import closure
drops from 1 to 0: loading the rule file does not require loading the repository module. The
last lines name the difference: in active record, the rule and repository access sit in the same
file; in the mapper version they sit in separate files.
Round-Trip Equality
Whether the two patterns do the same job is tested.
// round-trip.mjs — do the two versions give the same net fee after writing and reading the same rows import { Shipment as ActiveShipment } from "./active/shipment.mjs"; import { Shipment as DomainShipment } from "./mapper/shipment.mjs"; import { shipmentMapper } from "./mapper/shipment-mapper.mjs"; const ROWS = [ { id: "G1", weight: 0.6, coefficient: 1, discount: 0 }, { id: "G2", weight: 3, coefficient: 1.35, discount: 0.1 }, { id: "G3", weight: 12, coefficient: 1.8, discount: 0.4 }, ]; let diverged = 0; for (const row of ROWS) { new ActiveShipment(row).save(); const a = ActiveShipment.find(row.id).net(); shipmentMapper.save(new DomainShipment(row)); const m = shipmentMapper.find(row.id).net(); if (a !== m) diverged += 1; console.log(`${row.id} active record ${String(a).padStart(6)} data mapper ${String(m).padStart(6)}`); } console.log(`diverged result = ${diverged} / ${ROWS.length}`);
node round-trip.mjs
G1 active record 3990 data mapper 3990 G2 active record 7776 data mapper 7776 G3 active record 12744 data mapper 12744 diverged result = 0 / 3
All three rows give the same result. The difference is not in correctness, it is in where the storage decision is written.
The Second Storage Form
The payoff for paying the cost of separating persistence appears here. A second repository is added: one that appends writes to a log and returns the last row on read.
// log-repository.mjs — second storage form: every write is a log row, reads return the last row const LOG = []; export const logRepository = { write: (table, row) => { LOG.push({ table, row: { ...row } }); }, read: (table, id) => [...LOG].reverse().find((entry) => entry.table === table && entry.row.id === id)?.row ?? null, length: () => LOG.length, };
// mapper/log-mapper.mjs — the same domain object, a second mapper writing to the second repository import { Shipment } from "./shipment.mjs"; import { logRepository } from "../log-repository.mjs"; export const logMapper = { toRow: (s) => ({ id: s.id, weight: s.weight, coefficient: s.coefficient, discount: s.discount }), find(id) { const row = logRepository.read("shipment", id); return row === null ? null : new Shipment(row); }, save(s) { logRepository.write("shipment", this.toRow(s)); return s; }, };
// second-repository.mjs — can the domain object be written to a second repository unchanged import { readFileSync } from "node:fs"; import { Shipment } from "./mapper/shipment.mjs"; import { shipmentMapper } from "./mapper/shipment-mapper.mjs"; import { logMapper } from "./mapper/log-mapper.mjs"; import { logRepository } from "./log-repository.mjs"; const ROW = { id: "G2", weight: 3, coefficient: 1.35, discount: 0.1 }; const s = new Shipment(ROW); shipmentMapper.save(s); logMapper.save(s); logMapper.save(new Shipment({ ...ROW, discount: 0.2 })); console.log(`net from the memory repository = ${shipmentMapper.find("G2").net()}`); console.log(`net from the log repository = ${logMapper.find("G2").net()}`); console.log(`log rows = ${logRepository.length()}`); const domain = readFileSync("mapper/shipment.mjs", "utf8"); console.log(`repository name in domain module = ${[...domain.matchAll(/\brepository\b/gi)].length}`);
node second-repository.mjs
net from the memory repository = 7776 net from the log repository = 6912 log rows = 2 repository name in domain module = 0
The same domain object was written to two repositories and read from both; the repository name in
the domain module is still 0. Adding the second storage form edited 0 existing files and added 2
files. In active record, the same requirement would require editing active/shipment.mjs, and
because that file also carries the rule, the rule would have to be re-verified as well.
When It Does Not Apply
The data mapper’s cost can be counted. The active record version holds 1 file; the mapper version holds 3 files together with the repository. A write is 1 call in active record, split into 2 calls in the mapper: first converting to a row, then writing. The number of files that must be traced to read a record and obtain the object rises from 1 to 2.
If the row and the object overlap one to one, and the rule consists of a single-row computation,
the mapper has nothing to convert: the toRow method copies the fields under the same names. In
this case, the 2 extra files and 1 extra level of indirection paid go unmatched by any gain;
active record does the same job with fewer parts.
Active record, in turn, grows expensive once the object model and the row layout diverge. If the rule produces its decision from a combination of multiple rows, the object no longer represents a single row, and which rows the save method should write becomes ambiguous. This divergence was named in The Data Access Layer and Business Logic course: object–relational impedance mismatch. As the divergence grows, the persistence code swells inside the rule file, and the measurement pushes the trace counts from the first output upward.
Summary
- Active record places the conversion and storage access on the domain object; data mapper hands them to a separate object and turns the dependency arrow outward from the domain object.
- In the domain module, the persistence trace came out as repository name 4, table name 2, row word 5 in active record, and all three at 0 in the mapper version; the import closure dropped from 1 to 0.
- On all three rows, the two versions gave the same net fee; the pattern choice does not change correctness, it changes which file the storage decision stands in.
- The second storage form was wired in for the mapper version by editing 0 existing files and adding 2 files; the repository name in the domain module stayed at 0.
- The mapper’s cost is 2 extra files, 1 extra call per write, and 1 extra file to read; if the row and the object overlap one to one, this cost goes unmatched.
Next Step
This lesson’s mapper converts a single object to a single row. A real scenario does not touch a single object: saving a shipment also writes its route and discount rows, and refreshes the discount total on the shipment row. If the calling side has to know which objects to save in which order, the persistence decision leaks back upward. The next lesson builds the repository pattern, which grants access in the domain’s own language, and the unit of work, which collects the changed rows into a single write point; it compares this against saving by hand, and counts the write points in the application layer and the number of write calls the same scenario produces across the three arrangements.
To keep your progress and take notes, Log in
My notes
Log in to take notes.