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.