Skip to content
academia.sh

Lesson 03 / 21

Repository Pattern

The repository pattern, which gathers the persistence detail behind a single interface: testing business rules with a fake repository and no database, applying the same contract to two implementations, and measuring the persistence leak per module.

Contents

The previous lesson showed that the object model and the relational model do not line up at four points, and named the decision that closes each mismatch. Unless these decisions are written down somewhere, they are made again at every call site that writes a query: which strategy brought down the inheritance hierarchy, which side owns a relation, which field is embedded — this information scatters through the code.

This lesson’s subject is the interface that gathers that information in one place. A repository is a boundary that speaks to the caller only in domain concepts and hides how persistence actually happens. Whether the boundary is truly closed is not shown by asserting it, but by testing it: if the business rules run without a database, the boundary is closed.

The Repository’s Promise

A repository is not a tool for writing queries. The caller does not ask it for a table, a column, or a join; it asks for a loan record. The five operations below are the entire set of points where the loan service needs data.

Operation Meaning
find(loanId) Returns the loan record with the given identifier, or null
booksOpenRecord(bookId) Returns the book’s unreturned record, if one exists
membersOpenRecords(memberId) The list of the member’s unreturned records
add(record) Stores a new record and returns its identifier
markReturned(loanId, date) Moves the record into the closed state

There is no SELECT in this list, no connection, no row. This is the repository’s definition: the interface is written in the language of the domain, the implementation in the language of persistence.

The Rules Do Not Depend on the Repository

Issuing a loan carries two rules: a book already on loan cannot be issued again, and a member cannot hold more than three books at once. These rules contain no persistence detail whatsoever.

// loan-service.mjs — business rules; contains no SQL, no driver name
export const MEMBER_LIMIT = 3;

export class LoanService {
  constructor(repository) { this.repository = repository; }

  issue(memberId, bookId, date) {
    if (this.repository.booksOpenRecord(bookId) !== null) {
      return { status: "rejected", reason: "book is already on loan" };
    }
    const open = this.repository.membersOpenRecords(memberId);
    if (open.length >= MEMBER_LIMIT) {
      return { status: "rejected", reason: `member limit exceeded (${MEMBER_LIMIT})` };
    }
    const id = this.repository.add({ bookId, memberId, pickupDate: date, returnDate: null });
    return { status: "issued", loanId: id };
  }

  takeReturn(loanId, date) {
    const record = this.repository.find(loanId);
    if (record === null) return { status: "not found" };
    if (record.returnDate !== null) return { status: "already returned" };
    this.repository.markReturned(loanId, date);
    return { status: "returned" };
  }
}

The service receives its repository through the constructor. It does not build a repository itself and does not know which one will arrive. This detail carries the whole lesson: receiving the repository from the outside makes it possible to hand it a different repository during testing.

Fake Repository

The smallest implementation that satisfies the contract is an array.

// memory-store.mjs — fake implementation of the repository; no database
export class MemoryLoanRepository {
  constructor(initial = []) {
    this.records = initial.map((k) => ({ ...k }));
    this.nextId = this.records.length + 1;
  }
  find(loanId) {
    return this.records.find((k) => k.loanId === loanId) ?? null;
  }
  booksOpenRecord(bookId) {
    return this.records.find((k) => k.bookId === bookId && k.returnDate === null) ?? null;
  }
  membersOpenRecords(memberId) {
    return this.records.filter((k) => k.memberId === memberId && k.returnDate === null);
  }
  add(record) {
    const loanId = this.nextId++;
    this.records.push({ loanId, ...record });
    return loanId;
  }
  markReturned(loanId, date) {
    const k = this.find(loanId);
    if (k !== null) k.returnDate = date;
  }
}

With the fake repository, the business rules can be tested directly. The test file uses the loan-service.mjs and memory-store.mjs modules defined by the previous two blocks.

// service.test.mjs — business rules are tested with the fake repository
import { test } from "node:test";
import assert from "node:assert/strict";
import { LoanService } from "./loan-service.mjs";
import { MemoryLoanRepository } from "./memory-store.mjs";

test("a book with no open loan is issued", () => {
  const service = new LoanService(new MemoryLoanRepository());
  assert.deepEqual(service.issue(1, 5, "2025-07-01"), { status: "issued", loanId: 1 });
});

test("a book already on loan is rejected", () => {
  const repository = new MemoryLoanRepository([
    { loanId: 1, bookId: 5, memberId: 2, pickupDate: "2025-06-01", returnDate: null },
  ]);
  const result = new LoanService(repository).issue(1, 5, "2025-07-01");
  assert.equal(result.status, "rejected");
  assert.match(result.reason, /already on loan/);
});

test("exceeding the member limit is rejected", () => {
  const repository = new MemoryLoanRepository([
    { loanId: 1, bookId: 1, memberId: 4, pickupDate: "2025-06-01", returnDate: null },
    { loanId: 2, bookId: 2, memberId: 4, pickupDate: "2025-06-02", returnDate: null },
    { loanId: 3, bookId: 3, memberId: 4, pickupDate: "2025-06-03", returnDate: null },
  ]);
  const result = new LoanService(repository).issue(4, 7, "2025-07-01");
  assert.equal(result.status, "rejected");
  assert.equal(result.reason, "member limit exceeded (3)");
});

test("a returned book can be issued again", () => {
  const repository = new MemoryLoanRepository([
    { loanId: 1, bookId: 5, memberId: 2, pickupDate: "2025-06-01", returnDate: null },
  ]);
  const service = new LoanService(repository);
  assert.equal(service.takeReturn(1, "2025-06-20").status, "returned");
  assert.equal(service.issue(1, 5, "2025-07-01").status, "issued");
});
node --test service.test.mjs
✔ a book with no open loan is issued (0.905208ms)
✔ a book already on loan is rejected (0.105625ms)
✔ exceeding the member limit is rejected (0.050667ms)
✔ a returned book can be issued again (0.059709ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 37.269625

The durations in parentheses and the duration_ms field change on every run; what matters is the pass 4 and fail 0 lines. Four rules were tested without a database file, without setting up a schema, and without opening a single connection. The test’s setup step comes down to writing an array of records; the starting state that feeds the rules can be read directly.

The Fake’s Fidelity

A fake repository carries a risk: if it behaves differently from the real implementation, the tests pass but the application does not work. The method that closes this risk is applying the same test suite to both implementations. The real implementation comes first.

// sqlite-store.mjs — real implementation of the repository; all SQL is gathered here
const TO_RECORD = (s) => (s === undefined ? null : {
  loanId: s.loan_id, bookId: s.book_id, memberId: s.member_id,
  pickupDate: s.pickup_date, returnDate: s.return_date,
});

export class SqliteLoanRepository {
  constructor(db) { this.db = db; }
  find(loanId) {
    return TO_RECORD(this.db.prepare("SELECT * FROM loan WHERE loan_id = ?").get(loanId));
  }
  booksOpenRecord(bookId) {
    return TO_RECORD(this.db.prepare(
      "SELECT * FROM loan WHERE book_id = ? AND return_date IS NULL").get(bookId));
  }
  membersOpenRecords(memberId) {
    return this.db.prepare(
      "SELECT * FROM loan WHERE member_id = ? AND return_date IS NULL ORDER BY loan_id")
      .all(memberId).map(TO_RECORD);
  }
  add(record) {
    const result = this.db.prepare(
      "INSERT INTO loan (book_id, member_id, pickup_date, return_date) VALUES (?,?,?,?)")
      .run(record.bookId, record.memberId, record.pickupDate, record.returnDate);
    return Number(result.lastInsertRowid);
  }
  markReturned(loanId, date) {
    this.db.prepare("UPDATE loan SET return_date = ? WHERE loan_id = ?").run(date, loanId);
  }
}

The implementation also receives its connection from the outside. If it kept the file name inside itself, it could not be pointed at an in-memory database during testing.

// contract.test.mjs — the same test suite is applied to both implementations
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { DatabaseSync } from "node:sqlite";
import { MemoryLoanRepository } from "./memory-store.mjs";
import { SqliteLoanRepository } from "./sqlite-store.mjs";

const INITIAL = [
  { loanId: 1, bookId: 5, memberId: 2, pickupDate: "2025-06-01", returnDate: null },
  { loanId: 2, bookId: 6, memberId: 2, pickupDate: "2025-06-02", returnDate: "2025-06-25" },
];

const setUpMemory = () => new MemoryLoanRepository(INITIAL);

function setUpSqlite() {
  const db = new DatabaseSync(":memory:");
  db.exec(`CREATE TABLE loan (loan_id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL,
           member_id INTEGER NOT NULL, pickup_date TEXT NOT NULL, return_date TEXT)`);
  for (const k of INITIAL) {
    db.prepare("INSERT INTO loan VALUES (?,?,?,?,?)")
      .run(k.loanId, k.bookId, k.memberId, k.pickupDate, k.returnDate);
  }
  return new SqliteLoanRepository(db);
}

for (const [name, setUp] of [["memory", setUpMemory], ["sqlite", setUpSqlite]]) {
  describe(`repository contract: ${name}`, () => {
    test("an open record is found, a closed record is not", () => {
      const d = setUp();
      assert.equal(d.booksOpenRecord(5).loanId, 1);
      assert.equal(d.booksOpenRecord(6), null);
    });
    test("a member's open records are only the open ones", () => {
      assert.deepEqual(setUp().membersOpenRecords(2).map((k) => k.loanId), [1]);
    });
    test("an added record gets a new identifier and can be found", () => {
      const d = setUp();
      const id = d.add({ bookId: 7, memberId: 3, pickupDate: "2025-07-01", returnDate: null });
      assert.equal(d.find(id).bookId, 7);
    });
    test("marking a return closes the open record", () => {
      const d = setUp();
      d.markReturned(1, "2025-07-02");
      assert.equal(d.booksOpenRecord(5), null);
    });
    test("a nonexistent identifier returns null", () => {
      assert.equal(setUp().find(999), null);
    });
  });
}
node --test contract.test.mjs
▶ repository contract: memory
  ✔ an open record is found, a closed record is not (0.665792ms)
  ✔ a member's open records are only the open ones (0.293875ms)
  ✔ an added record gets a new identifier and can be found (0.079417ms)
  ✔ marking a return closes the open record (0.080666ms)
  ✔ a nonexistent identifier returns null (0.049959ms)
✔ repository contract: memory (1.656583ms)
▶ repository contract: sqlite
  ✔ an open record is found, a closed record is not (0.361625ms)
  ✔ a member's open records are only the open ones (0.122083ms)
  ✔ an added record gets a new identifier and can be found (0.135166ms)
  ✔ marking a return closes the open record (0.08875ms)
  ✔ a nonexistent identifier returns null (0.093833ms)
✔ repository contract: sqlite (0.925875ms)
ℹ tests 10
ℹ suites 2
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 38.271041

Ten tests, two implementations, zero failures. This test suite is the repository’s contract; it reaffirms on every run that the fake stays faithful to the real thing. When a new implementation is added — another data store, a caching layer, a version that keeps records in a file — the same suite is applied to it too.

The contract test has a limit. Isolation level, locking behavior, and concurrent update conflicts cannot be produced inside an in-memory array; these are tested only against the real implementation. The fake repository’s scope is the business rules, not persistence behavior.

Measuring the Leak

How closed the boundary is can be counted. How many times, and in which module, do persistence traces — SQL keywords, the driver type, the prepared-statement call — show up? For comparison, a version that does the same job without a repository is also worth writing.

// leaky-service.mjs — version that uses a direct connection instead of the repository
export class LeakyLoanService {
  constructor(db) { this.db = db; }
  issue(memberId, bookId, date) {
    const openBook = this.db.prepare(
      "SELECT 1 FROM loan WHERE book_id = ? AND return_date IS NULL").get(bookId);
    if (openBook !== undefined) return { status: "rejected", reason: "book is already 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 >= 3) return { status: "rejected", reason: "member limit exceeded (3)" };
    const result = this.db.prepare(
      "INSERT INTO loan (book_id, member_id, pickup_date, return_date) VALUES (?,?,?,NULL)")
      .run(bookId, memberId, date);
    return { status: "issued", loanId: Number(result.lastInsertRowid) };
  }
}
// leak-measure.mjs — how many persistence traces are in which module
import { readFileSync } from "node:fs";

const TRACES = /\b(SELECT|INSERT|UPDATE|DELETE|prepare|DatabaseSync)\b/g;

for (const file of ["loan-service.mjs", "leaky-service.mjs", "sqlite-store.mjs", "memory-store.mjs"]) {
  const trace = readFileSync(file, "utf8").match(TRACES) ?? [];
  console.log(`${file.padEnd(22)} persistence trace = ${trace.length}`);
}
node leak-measure.mjs
loan-service.mjs       persistence trace = 0
leaky-service.mjs      persistence trace = 6
sqlite-store.mjs       persistence trace = 10
memory-store.mjs       persistence trace = 0

The numbers show the distribution. In the service that uses the repository, the trace count is zero; all of persistence is gathered into a single module, as ten traces. In the leaky service, six traces are mixed in among the business rules. These six traces are the reason that service cannot be tested with a fake repository: the prepare call asks for a capability an array has no counterpart for.

The measurement is a blunt instrument, not conclusive proof. A module can bind itself to persistence without writing SQL; a find call relying on the database’s default ordering for the order it returns rows in is a dependency too. The number’s job is to make where the boundary is drawn visible to the eye.

The Repository’s Boundary and Query Proliferation

Running the same service with the real repository asks for no further code change.

// run-with-real.mjs — the same service, this time with a repository connected to the database
import { DatabaseSync } from "node:sqlite";
import { LoanService } from "./loan-service.mjs";
import { SqliteLoanRepository } from "./sqlite-store.mjs";

const repository = new SqliteLoanRepository(new DatabaseSync("library.db"));
const service = new LoanService(repository);

console.log("book 1 (has an open record):", JSON.stringify(service.issue(6, 1, "2025-07-15")));
console.log("book 7 (free):              ", JSON.stringify(service.issue(6, 7, "2025-07-15")));
console.log("member 4 open record count: ", repository.membersOpenRecords(4).length);

This block expects the library.db file carrying the library schema from the SQL Fundamentals course. The command below sets up the schema and the rows this lesson runs against.

rm -f library.db
sqlite3 library.db <<'SQL'
CREATE TABLE loan (loan_id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL,
                    member_id INTEGER NOT NULL, pickup_date TEXT NOT NULL, return_date TEXT);
INSERT INTO loan VALUES (1,1,1,'2025-01-10','2025-01-24'),(2,2,1,'2025-02-02','2025-02-20'),
  (3,1,2,'2025-02-11',NULL),(4,3,3,'2025-03-01','2025-03-15'),(5,4,3,'2025-03-18','2025-04-02'),
  (6,1,4,'2025-04-05','2025-04-19'),(7,5,4,'2025-04-21',NULL),(8,2,5,'2025-05-02','2025-05-30'),
  (9,7,1,'2025-05-14','2025-05-28'),(10,3,5,'2025-06-03',NULL),(11,6,2,'2025-06-11','2025-06-25'),
  (12,4,4,'2025-06-20','2025-07-04');
SQL
node run-with-real.mjs
book 1 (has an open record): {"status":"rejected","reason":"book is already on loan"}
book 7 (free):               {"status":"issued","loanId":13}
member 4 open record count:  1

Book 1’s request was rejected because it has an open record; the free book 7 was issued and got the identifier thirteen. Not a single line changed in the service code.

The repository pattern’s cost shows up here. Because the interface is written in the domain’s language, every new question asks for a new method: overdue records, open records at a given branch, records issued within a date range, both overdue and at a given branch. Because criteria can be combined, the method count grows with the combination of criteria. Past a certain point, the repository interface starts to resemble the query language it was trying to hide.

Summary

  • A repository is an interface that speaks to the caller in domain concepts; it keeps the query, the connection, and the row-to-object conversion inside itself.
  • The service receiving its repository from the outside makes it possible to hand it an in-memory fake repository during testing; four business rules were tested without a database.
  • The same contract test was applied to both implementations, and all ten tests passed; this is the continuous verification that the fake stays faithful to the real thing.
  • Counting persistence traces gave zero in the service that uses the repository and six in the leaky service; this is how the boundary’s closure is made visible.
  • Isolation and concurrency behavior cannot be produced in the fake repository; the fake’s scope is the business rules.

Next Step

As the repository’s interface closed off, the number of questions to ask grew, and every question asked for a new method. Overdue records, records filtered by branch, records limited to a date range, and their combinations bloat the repository when they are each written out separately. The problem is not in the criteria themselves; it is in the criteria being embedded into fixed method names on the interface. The next lesson turns a criterion into an object: each criterion produces its own SQL fragment and bound parameters, criteria are combined with “and”, “or”, and “not”, and the repository meets every combination through a single method.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close