Repositories
Storage is a port: generic gestures, no flavour of domain. "The
loud readings" is not one of them, so that query ends up spelled at the call site, in the
middle of the calculation it feeds. A query with no home squats in the answer.
Repository(Entity) gives it one, and is that entity's storage — every gesture of the
port, plus whatever you name on top:
// repositories/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(); } // the port's own gestures, forwarded
}
repositories/ is scanned like services/ — the class registers as a provider, and its
constructor dependencies resolve by type as anywhere else.
Write none and you lose nothing
Every entity gets a repository whether or not you wrote the file: the boot registers the
guarded port under <Entity>Repository. So a handler may ask for one before anyone writes
the class, and declaring one later wins under that key — exactly as a Crud operation
redefined in a subclass wins over the prefab's.
The two forms answer the same names, which is what makes the convention true: list(),
create() and the rest are there either way, and a named query is the only difference.
With no file written, there is no class to point at, so name the shape:
constructor(private nodes: RepositoryOf<Node>) {}
RepositoryOf<Node> is to NodeRepository what Storage<Node> is to NodeStorage: one
spelling of one container key.
<Entity>Repository. A declared class must therefore be called ReadingRepository —
not Readings, not ReadingQueries — or nothing resolves it.The port is not a word of your vocabulary
A handler, a presenter and a collector cannot ask for Storage<E>. The boot refuses it
by name and points at the repository:
PostHandler asks for PostStorage. Storage is reached through a repository, never through
the port:
constructor(private post: PostRepository) {}
One way in, and one word for it. What that buys beyond tidiness is the paragraph below: the day an entity belongs to an aggregate, no call site moves, because none of them ever spelled the storage.
A holder is the exception, and it is the same rule read the other way: a class a prefab
built over its own entity may name that entity's port, which is how a
Mirror writes the copy it owns. A door judges and projects; a holder
keeps the storage.
From two entities on, it owns them
balance and the sum of a ledger's amount are one fact. Nothing could say so: the judge
reads one row, and a frame makes two writes atomic without saying
which ones may happen at all. Debit 100 and journal 50 and the database takes it.
The missing half was never a checker. It was a door: as long as Storage<Ledger> is handed
to whoever asks, no file can be the only way in. Name both entities and it becomes one:
// repositories/AccountRepository.ts
export default class AccountRepository extends Repository(Account, Ledger) {
async withdraw(id: string, amount: number) {
const [accounts, ledger] = this.storages;
const account = await accounts.findById(id);
if (account!.balance < amount) throw new Error('insufficient funds');
await accounts.update(id, { balance: account!.balance - amount });
await ledger.create({ account: id, amount: -amount });
}
}
The rule is ordinary TypeScript, in the method that does the writing. Nothing was added to express it — what was added is that nowhere else can write those two tables.
The arity is the declaration. One entity is a place to name questions; two or more is a boundary. There is no flag, and a repository of one owns nothing — a boundary between one thing and nothing is not a boundary, and the judge already sits on that entity's rows.
Three things follow, and each is the shape refusing rather than a rule written down:
- No default repository is registered for any member.
LedgerRepositorydoes not exist, so nothing can ask for it. - No gesture is forwarded. Which entity would
createwrite to? So the only surface is what you name. An owned entity therefore has no automatic CRUD —Crud(Ledger)has no storage to run on — and the boot says so by name rather than letting the app start and fail at its first request. - Two aggregates cannot claim one entity. Refused at boot naming both, for the reason
ports:refuses two implementations: whichever won would depend on scan order, and one of the two boundaries would be silently unenforced.
The boundary is not the unit of work
An aggregate says the rules of these entities hold together. A frame says these writes land together. The two lists coincide often, and they are not the same statement — so a frame is asked for here as anywhere else:
export default class AccountRepository extends Repository(Account, Ledger) {
constructor(
accounts: AccountStorage,
ledger: LedgerStorage,
private frame: Together<[Account, Ledger]>,
) { super(accounts, ledger); }
async withdraw(id: string, amount: number) {
return this.frame.run(async ([accounts, ledger]) => { /* both, or neither */ });
}
}
Deriving the frame from the membership would have made a read-only aggregate carry one —
refusals included — and put Together<[Account, Ledger], [RateMirror]> out of reach forever.
The port's names come back in that constructor, and only there: this class is the holder, which is the one case the rule above allows.
It is not a door
A repository has no façade, so nothing it carries is reachable from the wire. That is what makes it free to hold whatever the domain asks — but it also means a judge does not belong here. Rules about who may act stay in the handler, the one place a refusal cannot be walked around. What belongs here is the other kind: a rule about what the data may be.
Writes are still judged, because they go through the port: a value the entity forbids is refused whether a repository or a handler issued it.
Next: Ports.