Skip to content
academia.sh

Lesson 26 / 30

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.

Contents

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.

mkdir -p mapper manual repository
// 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; },
};
// 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.

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

// 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;
  },
});
// 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.

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

// 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(", ")}`);
}
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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close