Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close