---
title: 'Persistence-Ignorant Domain Model'
source: 'https://academia.sh/en/courses/data-access-layer/persistence-ignorant-domain-model'
course: 'The Data Access Layer and Business Logic'
language: en
updated: '2026-08-19T05:19:34+00:00'
license: 'CC BY-SA 4.0'
---

# Persistence-Ignorant Domain Model

Writing business rules as pure functions: the decision equality of a database-free version and a database-coupled version, the duration ratio across a 64-state scan, and counting the persistence trace left in the domain module.

The four lessons in this topic isolated the domain layer step by step: separated from
infrastructure by the dependency direction rule, from the outer contract by data
transfer objects, from the input's shape by the validation split, from side effects by
events.

One last link remains. The rules still take a repository and still need a database to be
tested. A repository is a contract; but as long as the rule **calls** it, which queries
fire in which order stays part of the rule. This lesson cuts that last link: the rule
does not ask for the data itself, the data is handed to it.

## The Rule's Input Is the State Itself

In the module below the rule does not take a repository; it takes a plain object that
carries all the information needed to make the decision. It matches the definition of a
**pure function** set out in the Component-Based Interface Development course: same input
gives the same output every time, and it changes nothing outside itself. The lesson's
files sit in two directories.

```sh
mkdir -p domain coupled
```

```js
// domain/loan-rules.mjs — business rules as pure functions
export const MEMBER_LIMIT = 3;
export const LOAN_DAYS = 14;
export const DAILY_FINE = 2;

export function addDays(date, days) {
  return new Date(Date.parse(date) + days * 86400000).toISOString().slice(0, 10);
}

// state: { memberExists, memberStatus, memberOpenCount, bookExists, bookOnLoan }
export function loanDecision(state, today) {
  if (!state.memberExists) return { result: "rejected", reason: "member_not_found" };
  if (state.memberStatus === "suspended") return { result: "rejected", reason: "member_suspended" };
  if (!state.bookExists) return { result: "rejected", reason: "book_not_found" };
  if (state.bookOnLoan) return { result: "rejected", reason: "book_on_loan" };
  if (state.memberOpenCount >= MEMBER_LIMIT) return { result: "rejected", reason: "member_limit" };
  return { result: "accepted", dueDate: addDays(today, LOAN_DAYS) };
}

// record: { dueDate, returnDate }
export function overdueDays(record, today) {
  const end = record.returnDate ?? today;
  return Math.max(0, Math.round((Date.parse(end) - Date.parse(record.dueDate)) / 86400000));
}

export function fine(record, today) {
  return overdueDays(record, today) * DAILY_FINE;
}
```

Two details matter. Today's date comes from outside too; the rule does not read the
system clock, or its output would depend on when it happens to run. The `state` object
is a **snapshot**: gathering the data is the application layer's job, the rule
interprets data already gathered.

## Testing Without a Database

Testing the rule requires nothing to be set up.

```js
// pure.test.mjs — the rules are tested without a database
import { test } from "node:test";
import assert from "node:assert/strict";
import { loanDecision, overdueDays, fine, addDays } from "./domain/loan-rules.mjs";

const ELIGIBLE = { memberExists: true, memberStatus: "active", memberOpenCount: 0,
                   bookExists: true, bookOnLoan: false };
const TODAY = "2025-06-20";

test("in the eligible state the loan is issued and the due date is computed", () => {
  assert.deepEqual(loanDecision(ELIGIBLE, TODAY),
                   { result: "accepted", dueDate: "2025-07-04" });
});

test("a suspended member cannot borrow", () => {
  assert.equal(loanDecision({ ...ELIGIBLE, memberStatus: "suspended" }, TODAY).reason, "member_suspended");
});

test("a book with an open record cannot be issued again", () => {
  assert.equal(loanDecision({ ...ELIGIBLE, bookOnLoan: true }, TODAY).reason, "book_on_loan");
});

test("rejected once the member limit is exceeded, accepted below the limit", () => {
  assert.equal(loanDecision({ ...ELIGIBLE, memberOpenCount: 3 }, TODAY).reason, "member_limit");
  assert.equal(loanDecision({ ...ELIGIBLE, memberOpenCount: 2 }, TODAY).result, "accepted");
});

test("rejection reasons are given in priority order", () => {
  const allBroken = { memberExists: true, memberStatus: "suspended", memberOpenCount: 9,
                      bookExists: false, bookOnLoan: true };
  assert.equal(loanDecision(allBroken, TODAY).reason, "member_suspended");
});

test("overdue days and the fine are computed from the return date", () => {
  const record = { dueDate: "2025-06-15", returnDate: null };
  assert.equal(overdueDays(record, TODAY), 5);
  assert.equal(fine(record, TODAY), 10);
  assert.equal(fine({ ...record, returnDate: "2025-06-14" }, TODAY), 0);
});

test("adding days crosses a month boundary", () => {
  assert.equal(addDays("2025-06-25", 14), "2025-07-09");
});
```

```sh
node --test pure.test.mjs
```

```
✔ in the eligible state the loan is issued and the due date is computed (1.209292ms)
✔ a suspended member cannot borrow (0.061792ms)
✔ a book with an open record cannot be issued again (0.048ms)
✔ rejected once the member limit is exceeded, accepted below the limit (0.300042ms)
✔ rejection reasons are given in priority order (0.042834ms)
✔ overdue days and the fine are computed from the return date (0.054958ms)
✔ adding days crosses a month boundary (0.032375ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 33.189166
```

The duration fields depend on the machine and the load at that moment; what matters is
the `pass 7` line. Every test's setup is a single object literal.

## The Same Rule, Coupled to the Database

The same rules were also written as a version that pulls the data itself.

```js
// coupled/loan-rules.mjs — the same rules, directly coupled to the database
import { DatabaseSync } from "node:sqlite";

export const MEMBER_LIMIT = 3;
export const LOAN_DAYS = 14;
export const DAILY_FINE = 2;

export class CoupledRules {
  constructor(file) { this.db = new DatabaseSync(file); }

  loanDecision(memberId, bookId, today) {
    const member = this.db.prepare("SELECT status FROM member WHERE member_id = ?").get(memberId);
    if (member === undefined) return { result: "rejected", reason: "member_not_found" };
    if (member.status === "suspended") return { result: "rejected", reason: "member_suspended" };
    const book = this.db.prepare("SELECT book_id FROM book WHERE book_id = ?").get(bookId);
    if (book === undefined) return { result: "rejected", reason: "book_not_found" };
    const open = this.db.prepare(
      "SELECT loan_id FROM loan WHERE book_id = ? AND return_date IS NULL").get(bookId);
    if (open !== undefined) return { result: "rejected", reason: "book_on_loan" };
    const count = this.db.prepare(
      "SELECT count(*) AS n FROM loan WHERE member_id = ? AND return_date IS NULL").get(memberId).n;
    if (count >= MEMBER_LIMIT) return { result: "rejected", reason: "member_limit" };
    return { result: "accepted", dueDate: this.db.prepare(
      "SELECT date(?, ?) AS t").get(today, `+${LOAN_DAYS} days`).t };
  }

  fine(loanId, today) {
    const l = this.db.prepare(
      "SELECT due_date, return_date FROM loan WHERE loan_id = ?").get(loanId);
    const days = this.db.prepare("SELECT max(0, julianday(coalesce(?, ?)) - julianday(?)) AS d")
      .get(l.return_date, today, l.due_date).d;
    return Math.round(days) * DAILY_FINE;
  }
}
```

When the same seven tests are written for this version, the setup grows: each test sets
up a schema and writes rows.

```js
// coupled.test.mjs — same rules; each test sets up the schema and rows first
import { test } from "node:test";
import assert from "node:assert/strict";
import { DatabaseSync } from "node:sqlite";
import { rmSync } from "node:fs";
import { CoupledRules } from "./coupled/loan-rules.mjs";

const TODAY = "2025-06-20";
const SCHEMA = `
CREATE TABLE member (member_id INTEGER PRIMARY KEY, name TEXT NOT NULL, status TEXT NOT NULL);
CREATE TABLE book (book_id INTEGER PRIMARY KEY, title TEXT NOT NULL);
CREATE TABLE loan (loan_id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL,
                    member_id INTEGER NOT NULL, pickup_date TEXT NOT NULL,
                    due_date TEXT NOT NULL, return_date TEXT);`;

let counter = 0;
function setup(rows = []) {
  const file = `test-${counter++}.db`;
  rmSync(file, { force: true });
  const db = new DatabaseSync(file);
  db.exec(SCHEMA);
  db.exec(`INSERT INTO member VALUES (1,'Alice Kane','active'),(2,'Ben Ortiz','suspended');
           INSERT INTO book VALUES (1,'Blindness'),(2,'The Disconnected'),(3,'The Book of Sand'),
                                    (4,'Puslu Kitalar');`);
  for (const r of rows) {
    db.prepare(`INSERT INTO loan (book_id, member_id, pickup_date, due_date, return_date)
                VALUES (?,?,?,?,?)`).run(...r);
  }
  return new CoupledRules(file);
}

test("in the eligible state the loan is issued and the due date is computed", () => {
  assert.deepEqual(setup().loanDecision(1, 1, TODAY),
                   { result: "accepted", dueDate: "2025-07-04" });
});

test("a suspended member cannot borrow", () => {
  assert.equal(setup().loanDecision(2, 1, TODAY).reason, "member_suspended");
});

test("a book with an open record cannot be issued again", () => {
  const r = setup([[1, 2, "2025-06-01", "2025-06-15", null]]);
  assert.equal(r.loanDecision(1, 1, TODAY).reason, "book_on_loan");
});

test("rejected once the member limit is exceeded, accepted below the limit", () => {
  const three = [[1, 1, "2025-06-01", "2025-06-15", null], [2, 1, "2025-06-02", "2025-06-16", null],
                 [3, 1, "2025-06-03", "2025-06-17", null]];
  assert.equal(setup(three).loanDecision(1, 4, TODAY).reason, "member_limit");
  assert.equal(setup(three.slice(0, 2)).loanDecision(1, 4, TODAY).result, "accepted");
});

test("rejection reasons are given in priority order", () => {
  const r = setup([[1, 2, "2025-06-01", "2025-06-15", null]]);
  assert.equal(r.loanDecision(2, 9, TODAY).reason, "member_suspended");
});

test("overdue days and the fine are computed from the return date", () => {
  const r = setup([[1, 1, "2025-06-01", "2025-06-15", null],
                   [1, 1, "2025-06-01", "2025-06-15", "2025-06-14"]]);
  assert.equal(r.fine(1, TODAY), 10);
  assert.equal(r.fine(2, TODAY), 0);
});

test("adding days crosses a month boundary", () => {
  assert.equal(setup().loanDecision(1, 1, "2025-06-25").dueDate, "2025-07-09");
});
```

```sh
node --test coupled.test.mjs
```

```
✔ in the eligible state the loan is issued and the due date is computed (2.566458ms)
✔ a suspended member cannot borrow (1.380542ms)
✔ a book with an open record cannot be issued again (1.280917ms)
✔ rejected once the member limit is exceeded, accepted below the limit (3.412958ms)
✔ rejection reasons are given in priority order (1.320708ms)
✔ overdue days and the fine are computed from the return date (1.708833ms)
✔ adding days crosses a month boundary (1.189375ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 48.288083
```

The two totals are close, because in both cases most of the time is the runtime
starting up. The difference is in the individual tests: the pure tests run under a tenth
of a millisecond, the coupled tests between one and three milliseconds. At seven tests
this is negligible; the real question is what happens once the count grows.

## Scanning All States

The rule's five inputs combine into sixty-four states. The pure version can produce
these states directly; the coupled version has to bring the database into each state.
The script below runs both, compares the decisions, and measures the duration.

```js
// scan.mjs — the same 64 states scanned with both versions: decision equality and duration
import { DatabaseSync } from "node:sqlite";
import { rmSync } from "node:fs";
import { loanDecision } from "./domain/loan-rules.mjs";
import { CoupledRules } from "./coupled/loan-rules.mjs";

const TODAY = "2025-06-20";
const STATES = [];
for (const memberExists of [true, false])
  for (const memberStatus of ["active", "suspended"])
    for (const bookExists of [true, false])
      for (const bookOnLoan of [true, false])
        for (const memberOpenCount of [0, 1, 2, 3])
          STATES.push({ memberExists, memberStatus, bookExists, bookOnLoan, memberOpenCount });

rmSync("scan.db", { force: true });
const db = new DatabaseSync("scan.db");
db.exec(`CREATE TABLE member (member_id INTEGER PRIMARY KEY, status TEXT NOT NULL);
         CREATE TABLE book (book_id INTEGER PRIMARY KEY, title TEXT NOT NULL);
         CREATE TABLE loan (loan_id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL,
                             member_id INTEGER NOT NULL, pickup_date TEXT NOT NULL,
                             due_date TEXT NOT NULL, return_date TEXT);`);
const coupled = new CoupledRules("scan.db");

function setupState(s) {
  db.exec("DELETE FROM loan; DELETE FROM member; DELETE FROM book;");
  if (s.memberExists) db.prepare("INSERT INTO member VALUES (1,?)").run(s.memberStatus);
  if (s.bookExists) db.prepare("INSERT INTO book VALUES (1,'Blindness')").run();
  const add = db.prepare(`INSERT INTO loan (book_id, member_id, pickup_date,
                           due_date, return_date)
                           VALUES (?,?,'2025-06-01','2025-06-15',NULL)`);
  if (s.bookOnLoan) add.run(1, 2);                        // book 1 held by another member
  for (let i = 0; i < s.memberOpenCount; i++) add.run(100 + i, 1);
}

const measure = (task) => { const t = performance.now(); task(); return performance.now() - t; };

let pureDecisions, coupledDecisions;
const pureDuration = measure(() => { pureDecisions = STATES.map((s) => loanDecision(s, TODAY)); });
const coupledDuration = measure(() => {
  coupledDecisions = STATES.map((s) => { setupState(s); return coupled.loanDecision(1, 1, TODAY); });
});

const diverged = STATES.filter((_, i) =>
  JSON.stringify(pureDecisions[i]) !== JSON.stringify(coupledDecisions[i]));

console.log(`state count = ${STATES.length}`);
console.log(`states where the two versions diverge = ${diverged.length}`);
console.log(`pure version   : ${pureDuration.toFixed(1)} ms  (${(pureDuration / STATES.length).toFixed(4)} ms per state)`);
console.log(`coupled version: ${coupledDuration.toFixed(1)} ms  (${(coupledDuration / STATES.length).toFixed(4)} ms per state)`);
console.log(`ratio = ${(coupledDuration / pureDuration).toFixed(0)}x`);
```

```sh
node scan.mjs
```

```
state count = 64
states where the two versions diverge = 0
pure version   : 0.6 ms  (0.0090 ms per state)
coupled version: 68.5 ms  (1.0707 ms per state)
ratio = 119x
```

The first result is about correctness: none of the sixty-four states saw the two
versions diverge. The pure version is not a simplification, it is another way of
writing the same rule.

The second is about duration. The absolute numbers depend on the machine and the file
system; a second run of the same script gave a ratio of 131x. What stays constant is the
order of magnitude: the per-state cost differs by two orders of magnitude. The practical
effect is that the number of states that can be tested is set free. As the rule grows
more complex the state count grows combinatorially, and this difference starts to decide
which states even get tested.

## Measuring the Persistence Trace

The independence claim gets audited by scanning. The count from the Repository Pattern
lesson is split into three measures here: SQL statement, table name, and driver import.

```js
// trace-scan.mjs — per-module persistence trace: SQL, table name, driver import
import { readFileSync } from "node:fs";

const SQL = /\b(SELECT|INSERT INTO|UPDATE|DELETE FROM|CREATE TABLE)\b/g;
const TABLE = /\b(?:FROM|INTO|UPDATE|TABLE)\s+([a-z_]+)/g;
const DRIVER = /node:sqlite/g;

const FILES = ["domain/loan-rules.mjs", "pure.test.mjs",
               "coupled/loan-rules.mjs", "coupled.test.mjs"];

console.log("file                          SQL  table  driver  table names");
for (const file of FILES) {
  const text = readFileSync(file, "utf8");
  const sql = (text.match(SQL) ?? []).length;
  const tables = [...new Set([...text.matchAll(TABLE)].map((m) => m[1]))].sort();
  const driver = (text.match(DRIVER) ?? []).length;
  console.log(`${file.padEnd(28)}${String(sql).padStart(3)}  ${String(tables.length).padStart(5)}` +
              `  ${String(driver).padStart(6)}  ${tables.join(", ")}`);
}
```

```sh
node trace-scan.mjs
```

```
file                          SQL  table  driver  table names
domain/loan-rules.mjs         0      0       0  
pure.test.mjs                 0      0       0  
coupled/loan-rules.mjs        7      3       1  book, loan, member
coupled.test.mjs              6      3       1  book, loan, member
```

All three measures are zero in the domain module. That the test file is also zero
matters in its own right: testing the rule needed no persistence knowledge. In the
coupled version, table names have spread into both the rule module and the test file;
adding a column to the `loan` table in the schema affects both files at once.

The measurement is a blunt tool. A module can be coupled to persistence without writing
any SQL: assuming that row order is the order the database returns, or expecting the
identifier to be generated by the database, are each a form of dependency too. What the
count does is make how far the coupling reaches visible.

## The Limit of the Snapshot

The pure rule is not free; it moves the responsibility for gathering the snapshot onto
the application layer. It has two traps: pulling fields the decision will not use — the
cost measured in the Over-Fetching lesson — and the snapshot going stale, the state read
at one moment having changed by the time the write happens. The second is the
check-then-write gap from the Validation Layers lesson; the fix is the same: the
decision and the write stay in the same transaction boundary, and critical invariants
are guaranteed by a database constraint.

The opposite end also exists. If the domain model is reduced to a shell that only
carries data and makes no decisions, the rules scatter back out to every call site. The
test is this: **the code that makes the decision and the code that defines the data the
decision rests on should live in the same module.**

## Summary

- The rule takes a snapshot that carries the information needed to decide, instead of
  calling a repository; today's date also comes from outside, so the function stays
  pure.
- Seven tests ran with no database, schema, or connection; every setup was a single
  object literal.
- Across all sixty-four states the pure version and the coupled version gave the same
  decision; the pure version is not a simplification, it is another way of writing the
  same rule.
- The per-state cost came out two orders of magnitude apart (measured ratios of 119x and
  131x); absolute durations depend on the environment, the order of magnitude does not.
- SQL statement, table name, and driver import counts are all zero in the domain module
  and its test; in the coupled version, table names have spread into both the rule file
  and the test file.

## Course Wrap-Up

The course took on data access with four questions and answered each one by measuring
it.

**Data Access Approaches** asked where the query gets written: the trade-off between a
direct query and a mapper, the object–relational impedance mismatch, the repository that
gathers decisions in one place, specifications that turn the criterion into an object,
the saturation behavior of a connection pool, and the expand–contract migration.

**Transactions and Consistency** asked where the unit of work's boundary sits: keeping
the boundary in the service layer, the effect isolation levels have as seen from the
application, the trial-and-duration difference between optimistic and pessimistic
locking, atomicity's inability to cross a service boundary, and the idempotent forms
that make retrying safe.

**Performance Problems** asked what the layer's operation costs: the N+1 query problem,
over-fetching, batch operations, the query plan as seen from the application, and the
cost of deep pagination.

**Business Logic Placement** asked where the rule should stand. The dependency direction
was audited through the import graph, the outer contract was separated from the inner
model, input validation and the domain rule were split apart by the response and the
cost each produces, side effects were bound to events, and the rules were cut loose from
persistence.

One assumption is left standing where the course leaves off: that every request goes to
the source of the data, and that every task finishes inside the request. Neither always
holds. If the same query's answer gets produced over and over, it can be cached; work
like sending a notification, generating a report, or processing a file can run without
making the request wait for its response. The outbox in the Domain Events lesson was the
first step toward this.

The next course, **Caching, Queues and Asynchronous Processing**, takes on these two
paths: caching layers and invalidation strategies, the difference between a message
queue and a stream, and building background jobs that hold up under retries and a
dead-letter queue. The concepts measured in this course reappear there: the transaction
boundary, idempotence, and persistence-ignorant rules are the precondition for
asynchronous processing.
