Entities that protect themselves
A Domain Entity owns its state. External code can read it, but cannot mutate it directly. The entity is the single authority on what it can and cannot do.
No more validation logic scattered across services and controllers. The rules live where they belong.
class BankAccount extends DomainEntity<State> {
deposit(amount: number): MoneyDeposited {
this.state.balance += amount;
return new MoneyDeposited(this.id(), { amount });
}
withdraw(amount: number)
: Result<MoneyWithdrawn, InsufficientFunds> {
if (this.state.balance < amount) {
return err(new InsufficientFunds({
available: this.state.balance,
requested: amount,
}));
}
this.state.balance -= amount;
return ok(new MoneyWithdrawn(this.id(), { amount }));
}
}
History you can trust
A Domain Event is a record that something meaningful happened — past tense, immutable, and named after a business fact.
Events are a versioned contract. Once consumers depend on them, they are public API. The Outbox pattern ensures every saved event is eventually delivered, with no risk of silent loss.
class MoneyWithdrawn extends DomainEvent<
"MONEY_WITHDRAWN",
1,
{ amount: number }
> {
constructor(entityId: string, payload: { amount: number }) {
super({ name: "MONEY_WITHDRAWN", version: 1,
entityId, payload });
}
}
// Payload is deep-cloned at construction — immutable
const event = new MoneyWithdrawn(account.id(), { amount: 200 });
event.payload.amount = 0; // has no effect
Rules that never sleep
An invariant is a rule that must always be true — not just after certain operations, but at all times, including when loading data from the database.
Invariants are checked on every state read. Corrupted data is caught the moment it enters the system, before it can cause any damage.
const balanceIsPositive =
new BaseDomainInvariant<BankAccountState>(
"Balance must be positive",
(state) => state.balance >= 0
);
const balanceIsUnderLimit =
new BaseDomainInvariant<BankAccountState>(
"Balance is under limit",
(state) => state.balance <= 1_000_000
);
// Compose with logical operators
const validBalance =
balanceIsPositive.and(balanceIsUnderLimit);
Failures with meaning
Not all failures are unexpected. In a business domain, some failures are entirely normal — and the caller should be expected to handle them.
The Result pattern makes this explicit. Domain failures are typed return values, not hidden exceptions. The type system forces callers to deal with every outcome.
class InsufficientFunds extends DomainError<
"INSUFFICIENT_FUNDS",
{ available: number; requested: number }
> {}
// The caller cannot ignore the failure case
const result = account.withdraw(500);
if (result.isErr()) {
console.log(result.error.context.available);
return;
}
// TypeScript knows we succeeded here
await repository.saveWithEvents(account, [result.value]);
Persistence without compromise
The Repository hides all database details behind a clean, domain-friendly interface. Your entities stay free of infrastructure concerns and remain easy to test.
Saving an entity and its events in the same transaction guarantees consistency. You get both, or neither — never a state change without a record of what happened.
// Extend the built-in generic — no boilerplate needed
class BankAccountRepository
extends InMemoryRepository<BankAccount> {
constructor() {
super(BankAccount.fromState);
}
}
// Ready for tests and prototyping instantly
const repository = new BankAccountRepository();
// Entity state and event saved atomically
await repository.saveWithEvents(account, result.value);