---
title: 'Factories and Repositories'
source: 'https://academia.sh/en/courses/domain-driven-design/factories-and-repositories'
course: 'Domain-Driven Design'
language: en
updated: '2026-08-23T07:01:19+00:00'
license: 'CC BY-SA 4.0'
---

# Factories and Repositories

Fitting creation and access to the domain model: counting how many of an invalid shipment's four creation paths can produce it, this count dropping to zero in the model that passes through a factory, measuring the column name, operator, and threshold count that leaks into the call site when the repository interface is written in the query language, and showing that the two repositories give the same result.

The previous lesson's application service read the shipment from a repository and asked it
for the tariffs. Which words were written on that repository's interface was not asked. The
same question holds for creation as well: is the shipment built directly through the
constructor, or does it pass through something that secures its invariant at the moment of
construction?

The **factory** and **repository** patterns were established in the Design Patterns course;
one said where the responsibility for creation is placed, the other said where access to the
aggregate passes through. This lesson does not retell the patterns. It asks a single
question: is each of them written in the domain language? For the factory, this means the
invariant is secured at the moment of construction; for the repository, it means domain
words appear on the interface and column names do not.

## The Validity Rule

Both models are subject to the same rule: the shipment number is a non-empty string, the
weight is a positive integer, and the zone code is two digits.

```sh
mkdir -p common query domain
```

```js
// common/rule.mjs — the validity rule both models are subject to
export const isValid = (g) =>
  typeof g.shipmentNo === "string" && g.shipmentNo.length > 0
  && Number.isInteger(g.weightGrams) && g.weightGrams > 0
  && /^\d{2}$/.test(g.zoneCode);
```

## Creation: Where the Rule Is Enforced

In the first version, the constructor is public, the fields are writable, and the rule is
enforced nowhere.

```js
// query/shipment.mjs — constructor is public, fields are writable, the rule is enforced nowhere
export class Shipment {
  constructor(shipmentNo, weightGrams, zoneCode, contractNo = null) {
    this.shipmentNo = shipmentNo;
    this.weightGrams = weightGrams;
    this.zoneCode = zoneCode;
    this.contractNo = contractNo;
  }
}

export const fromRow = (row) =>
  new Shipment(row.shipment_no, row.weight_grams, row.zone_code, row.contract_no);
```

In the second version, the constructor can only be entered with a token that only the
factory knows. There are two factory methods — one for domain calls, one for building from a
raw row — and both enforce the same rule. The object is frozen at construction.

```js
// domain/shipment.mjs — factory: validity is secured at construction, the object is frozen
import { isValid } from "../common/rule.mjs";

const FACTORY = Symbol("factory");

export class Shipment {
  constructor(key, shipmentNo, weightGrams, zoneCode, contractNo) {
    if (key !== FACTORY) throw new TypeError("Shipment can only be built through the factory");
    this.shipmentNo = shipmentNo;
    this.weightGrams = weightGrams;
    this.zoneCode = zoneCode;
    this.contractNo = contractNo;
    Object.freeze(this);
  }
  static create(shipmentNo, weightGrams, zoneCode, contractNo = null) {
    const candidate = { shipmentNo, weightGrams, zoneCode, contractNo };
    if (!isValid(candidate)) throw new RangeError(`invalid shipment: ${shipmentNo}`);
    return new Shipment(FACTORY, shipmentNo, weightGrams, zoneCode, contractNo);
  }
  static fromRow(row) {
    return Shipment.create(row.shipment_no, row.weight_grams, row.zone_code,
      row.contract_no);
  }
}
```

The measurement is the creation-side counterpart of the reachability measure from the
previous lesson: in how many of four paths can an invalid shipment be produced?

```js
// factory-measure.mjs — how many of the four paths produce an invalid shipment
import { isValid } from "./common/rule.mjs";
import { Shipment as QueryShipment, fromRow } from "./query/shipment.mjs";
import { Shipment as DomainShipment } from "./domain/shipment.mjs";

const BROKEN_ROW = { shipment_no: "G-4099", weight_grams: -50, zone_code: "3",
  contract_no: null };

const QUERY_PATHS = {
  "directly through the constructor": () => new QueryShipment("", -3, "3"),
  "assigning the field afterward": () => {
    const g = new QueryShipment("G-4098", 2400, "34");
    g.weightGrams = -3;
    return g;
  },
  "bulk assignment": () => Object.assign(new QueryShipment("G-4098", 2400, "34"),
    { zoneCode: "abc" }),
  "building from a raw row": () => fromRow(BROKEN_ROW),
};
const DOMAIN_PATHS = {
  "directly through the constructor": () => new DomainShipment("", -3, "3"),
  "assigning the field afterward": () => {
    const g = DomainShipment.create("G-4098", 2400, "34");
    g.weightGrams = -3;
    return g;
  },
  "bulk assignment": () => Object.assign(DomainShipment.create("G-4098", 2400, "34"),
    { zoneCode: "abc" }),
  "building from a raw row": () => DomainShipment.fromRow(BROKEN_ROW),
};

for (const [name, paths] of Object.entries({ query: QUERY_PATHS, domain: DOMAIN_PATHS })) {
  let invalid = 0;
  let errors = 0;
  for (const [path, attempt] of Object.entries(paths)) {
    let result;
    try {
      const g = attempt();
      result = isValid(g) ? "valid" : "INVALID OBJECT";
      if (result !== "valid") invalid += 1;
    } catch {
      result = "error";
      errors += 1;
    }
    console.log(`  ${name.padEnd(6)} ${path.padEnd(34)} ${result}`);
  }
  console.log(`${name}: of 4 paths, ${invalid} produced an invalid object, ${errors} raised an error`);
}
```

```sh
node factory-measure.mjs
```

```
  query  directly through the constructor   INVALID OBJECT
  query  assigning the field afterward      INVALID OBJECT
  query  bulk assignment                    INVALID OBJECT
  query  building from a raw row            INVALID OBJECT
query: of 4 paths, 4 produced an invalid object, 0 raised an error
  domain directly through the constructor   error
  domain assigning the field afterward      error
  domain bulk assignment                    error
  domain building from a raw row            error
domain: of 4 paths, 0 produced an invalid object, 4 raised an error
```

All four paths produce an invalid shipment in the first model, and none of them raises a
warning. In the model that passes through the factory, all four raise an error. The gain is
not how many times the rule is written — the rule could have been written once in either
model — it is that no path is left that bypasses the rule.

## Access: Which Language Does the Repository Speak

Raw rows coming from the persistence side carry column names.

```js
// common/rows.mjs — raw rows coming from the persistence side
export const ROWS = [
  { shipment_no: "G-4001", weight_grams: 2400, zone_code: "34", contract_no: "S-77" },
  { shipment_no: "G-4002", weight_grams: 7200, zone_code: "06", contract_no: null },
  { shipment_no: "G-4003", weight_grams: 400, zone_code: "65", contract_no: "S-77" },
  { shipment_no: "G-4004", weight_grams: 12000, zone_code: "34", contract_no: null },
  { shipment_no: "G-4005", weight_grams: 5600, zone_code: "06", contract_no: "S-77" },
];
```

The first repository gives a general query interface: the caller builds the condition and
receives raw rows.

```js
// query/repository.mjs — repository written in the query language: the caller builds the condition
const COMPARATORS = {
  "=": (a, b) => a === b,
  ">": (a, b) => a > b,
  "<": (a, b) => a < b,
};

export const repository = (rows) => ({
  find: (condition) => rows.filter((r) => condition.every(([field, op, v]) => COMPARATORS[op](r[field], v))),
});
```

```js
// query/calls.mjs — column names, operators, and thresholds are written at the call site
export const heavyShipments = (r) => r.find([["weight_grams", ">", 5000]]);
export const inZone = (r, zone) => r.find([["zone_code", "=", zone]]);
export const uncontractedHeavy = (r) =>
  r.find([["weight_grams", ">", 5000], ["contract_no", "=", null]]);
export const zoneWeights = (r, zone) =>
  r.find([["zone_code", "=", zone]]).map((row) => row.weight_grams);
export const lightContracted = (r) =>
  r.find([["weight_grams", "<", 1000], ["contract_no", "=", "S-77"]]);
```

The second repository gives domain words as method names, passes the raw row through the
factory, and returns a domain object. What the "heavy" and "light" thresholds are also stays
here.

```js
// domain/repository.mjs — repository written in the domain language: column name, threshold, and operator stay here
import { Shipment } from "./shipment.mjs";

const HEAVY_LIMIT_GRAMS = 5000;
const LIGHT_LIMIT_GRAMS = 1000;
const build = (rows) => rows.map((r) => Shipment.fromRow(r));

export const repository = (rows) => ({
  heavyShipments: () => build(rows.filter((r) => r.weight_grams > HEAVY_LIMIT_GRAMS)),
  inZone: (zone) => build(rows.filter((r) => r.zone_code === zone)),
  uncontractedHeavy: () =>
    build(rows.filter((r) => r.weight_grams > HEAVY_LIMIT_GRAMS && r.contract_no === null)),
  lightContracted: (contractNo) => build(rows.filter((r) =>
    r.weight_grams < LIGHT_LIMIT_GRAMS && r.contract_no === contractNo)),
});
```

```js
// domain/calls.mjs — only domain words appear at the call site
export const heavyShipments = (r) => r.heavyShipments();
export const inZone = (r, zone) => r.inZone(zone);
export const uncontractedHeavy = (r) => r.uncontractedHeavy();
export const zoneWeights = (r, zone) => r.inZone(zone).map((g) => g.weightGrams);
export const lightContracted = (r) => r.lightContracted("S-77");
```

```js
// repository-measure.mjs — does a column name or operator leak to the call site, do the two repositories agree
import { readFileSync } from "node:fs";
import { ROWS } from "./common/rows.mjs";
import { repository as queryRepository } from "./query/repository.mjs";
import { repository as domainRepository } from "./domain/repository.mjs";
import * as queryCalls from "./query/calls.mjs";
import * as domainCalls from "./domain/calls.mjs";

const COLUMN = /weight_grams|zone_code|contract_no/g;
const OPERATOR = /"[=<>]"/g;
const THRESHOLD = /5000|1000/g;
const MODELS = {
  query: { calls: "query/calls.mjs", boundary: ["query/repository.mjs"] },
  domain: { calls: "domain/calls.mjs", boundary: ["domain/repository.mjs", "domain/shipment.mjs"] },
};

for (const [name, { calls, boundary }] of Object.entries(MODELS)) {
  const text = readFileSync(calls, "utf8");
  const count = (r) => (text.match(r) ?? []).length;
  const atBoundary = boundary.map((d) => (readFileSync(d, "utf8").match(COLUMN) ?? []).length)
    .reduce((a, b) => a + b, 0);
  console.log(`${name}: at call site, column name ${count(COLUMN)}, operator ${count(OPERATOR)}, ` +
    `threshold ${count(THRESHOLD)}`);
  console.log(`  column name at boundary files ${atBoundary} [${boundary.join(" ")}]`);
}

const q = queryRepository(ROWS);
const d = domainRepository(ROWS);
const ids = (list) => list.map((x) => x.shipment_no ?? x.shipmentNo ?? x).join(",");
let diverged = 0;
for (const [operation, x, y] of [
  ["heavyShipments", queryCalls.heavyShipments(q), domainCalls.heavyShipments(d)],
  ["inZone", queryCalls.inZone(q, "34"), domainCalls.inZone(d, "34")],
  ["uncontractedHeavy", queryCalls.uncontractedHeavy(q), domainCalls.uncontractedHeavy(d)],
  ["zoneWeights", queryCalls.zoneWeights(q, "06"), domainCalls.zoneWeights(d, "06")],
  ["lightContracted", queryCalls.lightContracted(q), domainCalls.lightContracted(d)],
]) {
  if (ids(x) !== ids(y)) diverged += 1;
  console.log(`  ${operation.padEnd(20)} ${ids(x)}`);
}
console.log(`diverged results = ${diverged} / 5`);
```

```sh
node repository-measure.mjs
```

```
query: at call site, column name 8, operator 7, threshold 3
  column name at boundary files 0 [query/repository.mjs]
domain: at call site, column name 0, operator 0, threshold 0
  column name at boundary files 9 [domain/repository.mjs domain/shipment.mjs]
  heavyShipments       G-4002,G-4004,G-4005
  inZone               G-4001,G-4004
  uncontractedHeavy    G-4002,G-4004
  zoneWeights          7200,5600
  lightContracted      G-4003
diverged results = 0 / 5
```

In the repository written in the query language, the call site carries eight column names,
seven comparison operators, and three thresholds. In the repository written in the domain
language, all three are zero. The column names did not disappear; they were gathered into
two boundary files across nine occurrences.

What is gathered belongs to three separate kinds of decision. The column name is a
persistence decision; the operator is a query decision; the "heavy" threshold is a domain
decision. In the query model, all three sit on the same line, inside the application layer.
Lowering the threshold from 5000 grams to 4000 grams requires touching two call sites there,
and those two places cannot be found by searching, because the domain vocabulary has no
concept called "heavy." In the domain model, the same change touches the
`HEAVY_LIMIT_GRAMS` constant.

The last line again says what the measurement is not: in all five queries, the two
repositories return the same shipments.

## The Limit of Writing the Repository in the Domain Language

The cost of a repository written in the domain language is the number of methods. Every new
domain question opens a new method, and the interface grows; in the general query interface,
the interface stays at a single method, and what grows is the conditions at the call sites.

The measurement therefore looks at how often a question repeats. If the same question is
asked from more than one place, turning it into a method produces a gain. For questions
asked from only one place, never to be asked again — a one-off reconciliation report, for
instance — the general interface is cheaper; the query objects and specifications pattern
was established in the Data Access Layer and Business Logic course for exactly this case.

The second limit is in the factory. The factory's gain comes from leaving no path unguarded;
this requires closing off access to the constructor and freezing the object. An object with
no invariant has no return for this cost: freezing means producing a new object on every
update, and an object with no rule has nothing to protect.

## Summary

- Factory and repository were treated in this lesson not as patterns, but by whether they
  fit the domain language.
- In the model with a public constructor, an invalid shipment was produced on 4 of 4
  creation paths and none raised a warning; in the model that passes through the factory, it
  was produced on 0, and all four raised an error.
- The factory's gain is not how many times the rule is written, but that no path bypassing
  the rule is left.
- The call site of the repository written in the query language carried 8 column names, 7
  operators, and 3 thresholds; in the repository written in the domain language, all three
  were 0 at the call site, and the column names were gathered into two boundary files.
- In the query model, persistence, query, and domain decisions mix on the same line; the
  "heavy" threshold is a domain decision, yet its name is not found in the domain
  vocabulary.
- In all five queries, the two repositories gave the same result; the cost of the repository
  written in the domain language is that the interface grows with the number of questions.

## Next Step

All the measurements up to this point were about the model's state: which name shows which
concept, which invariant lives inside which boundary, by which path the object is
constructed. The domain also has things that happen: a shipment was repriced, a contract
discount was removed, an amount was recalculated because the tariff changed. These facts may
have a name in the code, or they may all pass under the one label "record updated." The next lesson
tries to answer the domain expert's questions over an event log: when event names come from
the domain language, how many questions are answered with a single filter, and where they do
not, how many events' fields have to be compared.
