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);
Multi-step processes, type-checked end to end
A Workflow chains typed steps into a resumable pipeline. Each step's output flows into the next step's input, and the type system enforces the order reordering the chain is a compile error.
Plug in a repository and the state is persisted in any case. A crashed run resumes exactly where it left off, with no re-execution of work that already succeeded.
const workflow = new WorkflowBuilder<SepaPaymentRequest>({
id: randomUUID(),
name: "SEPA Payment",
input: { accountId, receiverIban, amount },
})
.addStep(checkAccountBalance)
.addStep(checkReceiverIsValid)
.addStep(checkAmlRisk)
.addStep(createSepaTransfer);
// Persist the state, resume after a crash
const transfer = await workflow.execute(repository);
Built-in Outbox Pattern
Deliver events to your system
The Event Bus handles delivery of domain events to the rest of your system. A pluggable connector interface keeps the publisher and listener logic independent of any specific broker.
Swap the connector to target SQS, Kafka, RabbitMQ, Redis, or any other broker. In-memory connectors are included for tests and local prototyping.
const listener = new DomainEventBusListener<OrderEvents>({
listenerConnector: myConnector,
options: { validator: parseOrderEvents },
});
listener.listenTo("ORDER_PLACED", async (event, metadata) => {
// event is a real OrderPlaced instance
await notifyWarehouse(event.payload.orderId);
});
listener.listenTo("PAYMENT_RECEIVED", async (event, metadata) => {
await generateInvoice(event.payload);
});
await listener.start();
Deliver every event, survive every failure
The Message Relay reads events from the outbox table and forwards them to the event bus. It tracks exactly what has been published, so after a crash it resumes precisely where it left off.
Each event is checkpointed individually. A failure mid-batch never causes events to be skipped or the relay to restart from scratch.
const relay = new MessageRelay(
repository,
new InMemoryMessageRelayStateRepository(),
"Order",
publisher,
);
relay.onError((error) => {
logger.error("relay error", { error });
});
// Trigger the relay whenever an entity is saved
repository.onChanges(relay.handler);