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.

Summarized by x-ai/grok-4.1-fast via openrouter

6423 input / 1712 output tokens in 16151ms

© 2026 Edge