Repository

A repository holds the rows of one entity, and gives a name to the questions your domain asks of them. "The loud readings" is not a gesture any storage knows; without a home, that query ends up spelled in the middle of the code that needs it. A repository is its home:

// services/ReadingRepository.ts
export default class ReadingRepository extends Repository<Reading> {
  loud(): Promise<Reading[]> {
    return this.findAllBy({ loud: true });
  }
}

The handler asks the question and never spells the storage:

// handlers/ReadingHandler.ts
export default class ReadingHandler {
  constructor(private readings: ReadingRepository) {}

  async loud() { return this.readings.loud(); }
  async all()  { return this.readings.list(); }   // every gesture of the rows is there too
}

A repository lives in services/; extends Repository<Reading> is what makes it one.

Every entity has one

You do not have to write the file. A class asking for Repository<Reading> receives the rows of Reading, guarded:

constructor(private nodes: Repository<Node>) {}

Write the class later and no call site changes: a class extending Repository<Reading> answers Repository<Reading>, whatever its name — and its own name too, which is where TypeScript sees its queries. Two classes extending it refuse the boot, naming both.

What a repository can do: reads — list(options?), findById(id), findBy(criteria), findAllBy(criteria), findByKeys(keys); writes — create, update, upsert, delete. output(view) gives the same rows, cut to a view's fields.

await this.posts.findBy({ slug });                    // the one row matching
await this.posts.findAllBy({ authorId: user.id });    // every row matching
await this.posts.list({ where: { status: 'published' }, limit: 20, orderBy: 'createdAt' });

One way to the rows

Underneath is Storage<Post>, the gestures Post's rows answer in whichever engine holds them. A handler, a presenter or a collector never asks for it; the boot refuses it and points at the repository:

[storage-outside-repository] PostHandler asks for Storage<Post>. Ask for the repository instead:
  constructor(private post: Repository<Post>) {}.

One way in means that the day an entity joins an aggregate (below), no call site moves. The one class that may name Storage<E> is a class built on E — a repository, a Mirror — because it holds those rows.

Writes through a repository are still checked: a value the entity forbids is refused whoever wrote it.

Two entities, one owner

balance and the sum of a ledger's amount are one fact, and no single row can say so: debit 100, journal 50, and the database takes both. What makes the rule hold is having one way in. Name both entities, and the repository becomes their only owner:

// services/AccountRepository.ts
export default class AccountRepository extends Repository<Account, Ledger> {
  async withdraw(id: string, amount: number) {
    const [accounts, ledger] = this.storages;
    await accounts.update(id, (account) => {
      if (account.balance < amount) throw new Error('insufficient funds');

      return { balance: account.balance - amount };
    });
    await ledger.create({ account: id, amount: -amount });
  }
}

The rule is ordinary TypeScript in the method that writes. The number of entities is the whole declaration: one is a place to name queries, two to four make an owner. From two on:

  • no default repository exists for a member — nothing can ask for Repository<Ledger>;
  • no gesture is forwarded — which table would create write to? — so an owned entity has no automatic CRUD, and the boot says so rather than failing at the first request;
  • two owners cannot claim one entity — the boot refuses both, by name.

An owner says the rules of these entities hold together. That their writes land together is a second statement, a frame, asked for like anywhere else:

export default class AccountRepository extends Repository<Account, Ledger> {
  constructor(
    accounts: Storage<Account>,
    ledger: Storage<Ledger>,
    private frame: Together<[Account, Ledger]>,
  ) { super(); }

  async withdraw(id: string, amount: number) {
    return this.frame.run(async ([accounts, ledger]) => { /* both, or neither */ });
  }
}

A Together naming an owned entity is refused anywhere but in its owner (aggregate-storage-reached).

Not a facade

Nothing outside the process can call a repository, which is why it may hold anything the domain needs. It is also why rules about who may act belong in the handler, the one place a call cannot go around. A repository holds the other kind: what the data may be.

Put it to work

Built with Fougere and Nuxt, hosted on GitHub Pages.