Most bugs I've seen in production weren't caused by wrong algorithms or bad infrastructure. They were caused by invalid state — an order with no items, a paid order without an invoice ID, a payment that spent more than expected.
The code was syntactically correct. TypeScript was happy. But the business rules were silently violated.
This post explores a few patterns that help you build domain models that make invalid states impossible — and when something does go wrong, make failures explicit.
The problem with implicit rules
Consider a typical order entity:
class Order {
status: "DRAFT" | "PLACED" | "PAID";
items: OrderItem[];
invoiceId?: string;
}
Nothing stops you from creating a PAID order with no invoiceId. Nothing stops you from removing the last item. The rules exist in your head (or a comment somewhere), not in the code.
You might add validation in one place — a use case, a controller — but nothing prevents another developer (or future you) from mutating state from a different path and bypassing it entirely.
Invariants: rules that are always true
An invariant is a business rule that must hold at all times, not just after specific operations.
Instead of validating in one place and hoping nothing else touches the state, you declare the rule once and it is checked every time state is read:
// This rule is always enforced — no matter how state was mutated
const paidOrderHasInvoiceId = new BaseDomainInvariant<OrderState>(
"Paid Order Has Invoice Id",
(state) => {
if (state.status === "PAID") {
return state.invoiceId !== undefined;
}
return true;
}
);
The entity registers its invariants in the constructor:
class Order extends DomainEntity<OrderState> {
private constructor(id: string, state: OrderState) {
super(id, state);
this.addInvariant(orderHasAtLeastOneItem);
this.addInvariant(paidOrderHasInvoiceId);
}
}
Any time you call order.readState(), the invariants are checked. If any is violated, it throws "Corrupted state detected". You can never silently read a broken entity.
Invariants are also composable:
const complexRule = invariantA.and(invariantB).or(invariantC);
Explicit failures with the Result pattern
TypeScript lets you throw anything, anywhere. But throw is invisible in function signatures — callers have no idea what can go wrong unless they read the implementation (or discover it in prod).
The Result pattern makes failures a first-class return value:
lockCredits(params: { amount: number }): Result<CreditLocked, NotEnoughFunds> {
if (this.state.subCreditBalance < params.amount) {
return err(new NotEnoughFunds(
`Not enough credits`,
{ available: this.state.subCreditBalance, amount: params.amount }
));
}
this.state.lockedBalance += params.amount;
return ok(new CreditLocked(this.id(), { amount: params.amount }));
}
The return type tells you everything: this operation either succeeds with a CreditLocked event, or fails with a NotEnoughFunds domain error. No surprise exceptions. The caller is forced to handle both cases:
const result = creditBalance.lockCredits({ amount: 100 });
if (result.isErr()) {
// result.error is typed as NotEnoughFunds
console.log(result.error.context.available);
} else {
// result.value is typed as CreditLocked
await repository.saveWithEvents(creditBalance, result.value);
}
Typed errors also carry structured context, not just a message string — making logging and debugging much more useful.
Domain events: immutable facts
Every state change produces an event — an immutable, versioned record of what happened:
export class OrderPaid extends DomainEvent<"ORDER_PAID", 1, { invoiceId: string }> {
constructor(entityId: string, payload: { invoiceId: string }) {
super({ name: "ORDER_PAID", version: 1, entityId, payload });
}
}
Operations return their event:
pay(params: { invoiceId: string }): Result<OrderPaid, InvalidStatusTransition> {
if (this.state.status !== "PLACED") {
return err(new InvalidStatusTransition(...));
}
this.state.status = "PAID";
this.state.invoiceId = params.invoiceId;
return ok(new OrderPaid(this.id(), { invoiceId: params.invoiceId }));
}
You persist both the entity state and its events atomically:
const result = order.pay({ invoiceId: "INV-123" });
if (result.isOk()) {
await repository.saveWithEvents(order, result.value);
}
This gives you a full audit trail with no extra effort. Events are also great for triggering side effects (emails, webhooks, projections) in a decoupled way.
Putting it together: a use case
Here's a complete payOrder use case that composes all these patterns:
export async function payOrderUseCase(
repository: OrderRepository,
id: string,
invoiceId: string,
): Promise<Result<OrderState, InvalidStatusTransition | EntityNotFound>> {
const getResult = await repository.getById(id);
if (getResult.isErr()) throw getResult.error; // infra error, not domain
const order = getResult.value;
if (order === undefined) {
return err(new EntityNotFound("Order not found", { entityId: id }));
}
const payResult = order.pay({ invoiceId });
if (payResult.isErr()) return err(payResult.error);
const saveResult = await repository.saveWithEvents(order, payResult.value);
if (saveResult.isErr()) throw saveResult.error;
return ok(order.readState());
}
Notice how each kind of failure is handled differently:
-
Infrastructure errors (DB down):
throw— these are exceptional, not domain logic -
Domain failures (wrong status): returned as typed
Result— callers handle them
The library
All of these patterns are available in Ontologic — a small TypeScript toolkit that provides DomainEntity, BaseDomainInvariant, DomainEvent, DomainError, Result, and a Repository interface.
npm install ontologic
It doesn't try to be a framework — no decorators, no magic, no IoC container required. Just plain TypeScript classes and types that you can drop into any project.
Full docs and examples (including the Order and CreditBalance aggregates shown above) are at ontologic.site.
These patterns aren't new — they come from Domain-Driven Design and functional programming. But they're underused in TypeScript codebases, where it's easy to reach for throw and optional fields instead.
The payoff is code that reads like business rules, fails loudly when something is wrong, and makes it hard to put entities into states that shouldn't exist.
Top comments (0)