---
title: 'Active Record and Data Mapper'
source: 'https://academia.sh/en/courses/design-patterns/active-record-and-data-mapper'
course: 'Design Patterns'
language: en
updated: '2026-08-23T07:01:14+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```sh
mkdir -p active mapper
```

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

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

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

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

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

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

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

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

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

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

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

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