Pure TypeScript Domains: Swap CRUD for Event Sourcing, Zero Rewrites
Use noDDDe's Decider pattern to build pure function-based aggregates decoupled from persistence—test without mocks and switch from SQL state storage to event sourcing by changing one config line.
Decider Pattern Builds Infrastructure-Agnostic Aggregates
Define aggregates as pure functions via defineAggregate, separating decide (command validation emitting 0+ events) from evolve (folding events into state). Start with a type contract bundling commands, events, state, and infrastructure needs:
type AuctionCommand = DefineCommands<{
CreateAuction: { item: string; startingPrice: number; endsAt: Date };
PlaceBid: { bidderId: string; amount: number };
CloseAuction: void;
}>;
type AuctionEvent = DefineEvents<{
AuctionCreated: { item: string; startingPrice: number; endsAt: Date };
BidPlaced: { bidderId: string; amount: number; timestamp: Date };
BidRejected: { bidderId: string; amount: number; reason: string };
AuctionClosed: { winnerId: string | null; winningBid: number | null };
}>;
interface AuctionState {
item: string;
startingPrice: number;
endsAt: Date;
status: "open" | "closed";
highestBid: { bidderId: string; amount: number } | null;
bidCount: number;
}
Set initialAuctionState to zero values (e.g., empty item, status "open"). Implement deciders like decidePlaceBid, which checks rules (auction open, not ended, bid > min) and returns events or rejections:
const decidePlaceBid: InferDecideHandler<AuctionDef, "PlaceBid"> = (
command,
state,
{ clock }
) => {
const { bidderId, amount } = command.payload;
const now = clock.now();
if (state.status === "closed") {
return { name: "BidRejected", payload: { bidderId, amount, reason: "Auction is closed" } };
}
// Similar checks for end time and min bid
return { name: "BidPlaced", payload: { bidderId, amount, timestamp: now } };
};
Evolvers update state immutably, e.g., evolveBidPlaced sets highestBid and increments bidCount. Wire via Auction = defineAggregate({ initialState, decide: { PlaceBid: decidePlaceBid, ... }, evolve: { ... } }). No database imports—logic stays pure, persistence is pluggable.
This avoids OOP pitfalls (mutable classes, decorators) and Event Sourcing overkill (event stores for 95% of projects), enabling DDD without framework lock-in.
Given-When-Then Tests Run in Milliseconds, No Mocks Needed
testAggregate simulates full lifecycle: .given(past events for state), .when(command), .withInfrastructure({ clock }), .execute() yields events and state for assertions.
const { events, state } = await testAggregate(Auction)
.given([{ name: "AuctionCreated", payload: { item: "Vintage Watch", startingPrice: 100, endsAt: futureDate } }])
.when({ name: "PlaceBid", targetAggregateId: "auction-1", payload: { bidderId: "alice", amount: 150 } })
.withInfrastructure(clockAt(now))
.execute();
expect(events[0]!.name).toBe("BidPlaced");
expect(state.highestBid).toEqual({ bidderId: "alice", amount: 150 });
Purity eliminates DB setup or mocks; tests validate rules like bid rejections for closed auctions or insufficient amounts directly.
Swap Persistence Strategies Without Touching Logic
Define domain with defineDomain({ writeModel: { aggregates: { Auction } } }). Wire via createEngine and adapters like createDrizzleAdapter(db).
For CRUD (state-stored): { aggregates: { persistence: () => stateStoredPersistence } }. Dispatch commands—engine loads state, runs deciders/evolvers, saves new state:
await auctionRuntime.dispatchCommand({
name: "PlaceBid",
targetAggregateId: auctionId,
payload: { bidderId: "bob", amount: 600 },
});
Handles sequences: Alice bids 550 (accepted), Bob 600 (accepted), Charlie 580 (rejected as <600), then close reveals Bob winner.
For audit trails (event-sourced): Swap to { persistence: () => eventSourcedPersistence }. Engine now loads event stream, reduces to state, appends events—no logic changes, tests unchanged. Adapts to needs like finance audits without rewrites, unlike rigid CRUD or full Event Sourcing commits.