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