---
title: 'Repository and Unit of Work'
source: 'https://academia.sh/en/courses/design-patterns/repository-and-unit-of-work'
course: 'Design Patterns'
language: en
updated: '2026-08-23T07:01:14+00:00'
license: 'CC BY-SA 4.0'
---

# Repository and Unit of Work

The access and write boundary of an object set held together: the application layer calling mappers by hand, against the repository that grants access to the aggregate in the domain's language and the unit of work that collects the changed rows into a single point; comparing write call, write point, and find call counts across three arrangements.

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. These three rows are not independent of each other; together
they form a set that carries meaning, and this set is called an **aggregate**.

The aggregate's existence raises two questions. The first is a question of access: will the
calling side look up the aggregate's parts one by one, or ask for the whole thing. The second is
a question of writing: who knows which rows to write in which order. The first question is
answered by **Repository**, the second by **Unit of Work**. Both patterns were established in
The Data Access Layer and Business Logic course, where transaction boundaries, isolation levels,
and locking behavior were measured. Here the measure is a design measure: the number of
persistence names the application layer carries, the number of write points, and the number of
write calls the same scenario produces.

```sh
mkdir -p mapper manual repository
```

```js
// memory-repository.mjs — simple object store; also counts the writes made
const TABLES = new Map();
let writes = 0;
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) => { writes += 1; table(name).set(row.id, { ...row }); },
  writeCount: () => writes,
  reset: () => { writes = 0; },
};
```

```js
// mapper/table-mapper.mjs — small mapper that sets up the same conversion per table
import { repository } from "../memory-repository.mjs";

export const mapper = (table) => ({
  find: (id) => repository.read(table, id),
  save: (row) => repository.write(table, row),
});
```

## Saving by Hand

In the first arrangement, the application layer uses the three mappers itself. It also decides
for itself, at every step, which tables changed.

```js
// manual/scenario.mjs — the application layer decides itself which table to write in which order
import { mapper } from "../mapper/table-mapper.mjs";

const shipmentM = mapper("shipment"), routeM = mapper("route"), discountM = mapper("discount");
const summary = (a) => ({ ...a.shipment, discountTotal: a.discounts.reduce((s, d) => s + d.rate, 0) });

export function create(aggregate) {
  shipmentM.save(summary(aggregate));
  routeM.save(aggregate.route);
  for (const d of aggregate.discounts) discountM.save(d);
  return aggregate;
}

export function addTransfer(aggregate, point) {
  aggregate.route.transfers.push(point);
  routeM.save(aggregate.route);
  return aggregate;
}

export function addDiscount(aggregate, discount) {
  aggregate.discounts.push(discount);
  discountM.save(discount);
  shipmentM.save(summary(aggregate));
  return aggregate;
}

export function gather(id) {
  const shipment = shipmentM.find(id);
  const route = routeM.find(`R-${id}`);
  const discounts = [discountM.find(`D-${id}`)].filter((x) => x !== null);
  return { shipment, route, discounts };
}
```

This file holds two kinds of information: the scenario's order and the structure of persistence.
The second kind of information includes a decision written for the third time — the decision
that the shipment row is also refreshed when a discount is added.

## Repository

The repository gives the calling side the whole aggregate. It does not perform the write itself,
though; it delegates it to a **writer** function it is given, which is what makes it possible for
the unit of work to step in later.

```js
// repository/shipment-repository.mjs — repository that grants access to the whole aggregate in the domain's language
import { repository } from "../memory-repository.mjs";

const summary = (a) => ({ ...a.shipment, discountTotal: a.discounts.reduce((s, d) => s + d.rate, 0) });

export const shipmentRepository = (writer) => ({
  find(id) {
    const shipment = repository.read("shipment", id);
    if (shipment === null) return null;
    const discounts = [repository.read("discount", `D-${id}`)].filter((x) => x !== null);
    return { shipment, route: repository.read("route", `R-${id}`), discounts };
  },
  save(aggregate) {
    writer("shipment", summary(aggregate));
    writer("route", aggregate.route);
    for (const d of aggregate.discounts) writer("discount", d);
    return aggregate;
  },
});
```

```js
// repository/scenario.mjs — the same scenario: the application layer only saves the aggregate
import { shipmentRepository } from "./shipment-repository.mjs";

export const scenario = (writer) => {
  const source = shipmentRepository(writer);
  return {
    create: (aggregate) => source.save(aggregate),
    addTransfer(aggregate, point) {
      aggregate.route.transfers.push(point);
      return source.save(aggregate);
    },
    addDiscount(aggregate, discount) {
      aggregate.discounts.push(discount);
      return source.save(aggregate);
    },
    gather: (id) => source.find(id),
  };
};
```

No table name remains in the scenario file, and every step ends with a single save call. In
exchange, the repository writes the whole aggregate on every save — including the rows that did
not change.

## Unit of Work

The unit of work opens a third path between these two extremes. Instead of writing immediately,
it records which rows became dirty, and writes each row exactly once at the end.

```js
// repository/unit-of-work.mjs — tracks the changed rows, collects the write into a single point
import { repository } from "../memory-repository.mjs";

export function unitOfWork() {
  const dirty = new Map();
  return {
    markDirty: (table, row) => { dirty.set(`${table}:${row.id}`, { table, row }); },
    tracked: () => dirty.size,
    commit() {
      for (const { table, row } of dirty.values()) repository.write(table, row);
      const n = dirty.size;
      dirty.clear();
      return n;
    },
  };
}
```

The key is the `table:id` pair; even if the same row is marked dirty three times, it sits in the
map once. When `markDirty` is given to the repository as its writer, the write behavior changes
without the repository's code changing at all.

## Counting the Three Arrangements

The same three steps — create the aggregate, add a transfer point to the route, add a discount —
run in three arrangements, and two kinds of numbers come out: the write calls made in the run,
and the table name, write point, and find call counts sitting in the source files.

```js
// count-writes.mjs — the same three steps in three arrangements: write calls, write points, and find calls
import { readFileSync } from "node:fs";
import { repository } from "./memory-repository.mjs";
import * as manual from "./manual/scenario.mjs";
import { scenario } from "./repository/scenario.mjs";
import { unitOfWork } from "./repository/unit-of-work.mjs";

const newAggregate = (id) => ({
  shipment: { id, weight: 3, coefficient: 1.35 },
  route: { id: `R-${id}`, shipmentId: id, transfers: ["34"] },
  discounts: [],
});
const discount = (id) => ({ id: `D-${id}`, shipmentId: id, name: "contract", rate: 0.15 });
const reader = scenario(() => {});

function measure(name, id, steps) {
  repository.reset();
  const extra = steps(id);
  const a = reader.gather(id);
  console.log(`${name}: write calls = ${repository.writeCount()}, discount total = ${a.shipment.discountTotal}${extra}`);
}

measure("manual mapper              ", "G1", (id) => {
  manual.addDiscount(manual.addTransfer(manual.create(newAggregate(id)), "06"), discount(id));
  return "";
});

measure("repository, direct write   ", "G2", (id) => {
  const s = scenario(repository.write);
  s.addDiscount(s.addTransfer(s.create(newAggregate(id)), "06"), discount(id));
  return "";
});

measure("repository and unit of work", "G3", (id) => {
  const unit = unitOfWork();
  const s = scenario(unit.markDirty);
  s.addDiscount(s.addTransfer(s.create(newAggregate(id)), "06"), discount(id));
  return `, tracked dirty rows = ${unit.tracked()}, commit() wrote = ${unit.commit()}`;
});

const COUNT = { "table name": /"(shipment|route|discount)"/g, "write point": /\b(save|markDirty)\(/g, "find call": /\.find\(/g };
for (const file of ["manual/scenario.mjs", "repository/scenario.mjs"]) {
  const m = readFileSync(file, "utf8");
  const fields = Object.entries(COUNT).map(([k, r]) => `${k} = ${[...m.matchAll(r)].length}`);
  console.log(`${file}: ${fields.join(", ")}`);
}
```

```sh
node count-writes.mjs
```

```
manual mapper              : write calls = 5, discount total = 0.15
repository, direct write   : write calls = 7, discount total = 0.15
repository and unit of work: write calls = 3, discount total = 0.15, tracked dirty rows = 3, commit() wrote = 3
manual/scenario.mjs: table name = 3, write point = 6, find call = 3
repository/scenario.mjs: table name = 0, write point = 3, find call = 1
```

All three arrangements read the same discount total: the behavior did not change. What changed
are these numbers.

The persistence knowledge the application layer carried was erased along with the repository:
table name dropped from 3 to 0, write points from 6 to 3, find calls from 3 to 1. The identity
scheme the application layer knew, in order to find the aggregate's parts — the fact that the
route is derived with an `R-` prefix — now sits inside the repository.

The write call count moved opposite to what might be expected. The manual arrangement produced 5
writes, the repository 7; because the repository writes the whole aggregate at every step,
including the route row that did not change. Once the unit of work was added, the count dropped
to 3: three dirty rows, three writes. The repository earns a single write point; the unit of work
lowers how many times that point fires. The two solve separate problems, and used together, the
count both collapses to a single point and drops to the minimum.

## Cost and When It Does Not Apply

The repository adds 2 files and places 1 level of indirection on aggregate access; the unit of
work adds 1 more file and removes when the write actually happens from the call site. This second
cost does not show up inside the numbers: because nothing is written when `save` is called, an
error surfaces at the `commit` line, and the call chain that must be traced grows by two steps.

The repository is unmatched by any gain for a record that has no aggregate. For a lookup record
made of a single table, the repository only wraps the mapper under another name; in that case the
measurement drops table name from 1 to 0, not from 3 to 0, and 1 file is paid for it. Reporting
reads, in the same way, do not follow the aggregate boundary: a repository interface grows by six
methods to cover six different filter combinations. This boundary is the subject of the Query
Objects and Specifications lesson in The Data Access Layer and Business Logic course.

The unit of work, in turn, does not improve the measure in scenarios that write a single row: the
dirty row count becomes 1, the write count is already 1, and the gain stays at 0. Its gain grows
with the row count and with the number of steps that touch the same row.

## Summary

- An aggregate is a set of rows that carries meaning together; the repository grants access to
  this set in the domain's language, the unit of work manages when the changes in the set get
  written.
- Once the repository was applied, table name in the application layer dropped from 3 to 0, write
  points from 6 to 3, find calls from 3 to 1; the identity-derivation rule moved inside the
  repository.
- The same three steps produced 5 write calls in the manual arrangement, 7 with the repository,
  and 3 with the repository and unit of work together; the repository earns a single write point,
  the unit of work lowers how often it fires.
- All three arrangements read the same discount total; the patterns did not change the behavior,
  they changed which file the persistence decision stands in and how many times it fires.
- For a single-table record with no aggregate, the repository turns into a wrapper; in a scenario
  that writes a single row, the unit of work turns into an indirection with no gain.

## Next Step

The unit of work collected dirty rows under the `table:id` key; even when the same row was
marked dirty three times, it was written once. That is a uniqueness problem solved on the write
side. On the read side, the same problem is still open: when the same shipment id is looked up
twice, two separate objects are born; one gets changed, the other is left holding stale values,
and if it is then saved, it overwrites the first. The next lesson builds the identity map, which
makes sure the same identity is represented by a single object, and counts the number of separate
instances produced and the number of places forced to write equality by identity.
