Example: Auction Domain
Complete walkthrough of an auction aggregate with time-based validation, the Clock pattern, event upcasting, and projections.
This example walks through the auction domain sample. It demonstrates infrastructure injection via the Clock pattern, multi-validation decide handlers, rejection events, no-op evolve handlers, event upcasting, and projections with ViewStore.
Full source: samples/sample-auction — clone and run locally with
npm start.Stack: Prisma + SQLite
Domain Overview
The auction domain models a simple auction that supports:
- Auction creation — Creating an auction with an item, starting price, and end time
- Bidding — Placing bids with multiple validation rules
- Closing — Closing the auction and determining the winner
- Event upcasting — Evolving the BidPlaced schema from v1 (no timestamp) to v2 (with timestamp)
- Auction summary projection — A read model tracking current high bid, leader, and status
Events
import { DefineEvents } from "@noddde/core";
export 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 };
}>;Note BidRejected — this is a rejection event, not an exception. The domain explicitly records that a bid was attempted and rejected, including the reason.
Commands
import { DefineCommands } from "@noddde/core";
export type AuctionCommand = DefineCommands<{
CreateAuction: { item: string; startingPrice: number; endsAt: Date };
PlaceBid: { bidderId: string; amount: number };
CloseAuction: void;
}>;State
export interface AuctionState {
item: string;
startingPrice: number;
endsAt: Date;
status: "open" | "closed";
highestBid: { bidderId: string; amount: number } | null;
bidCount: number;
}highestBid is nullable — no bids have been placed initially. bidCount is a derived counter maintained for convenience.
Infrastructure: The Clock Pattern
The auction needs to check the current time. Instead of calling new Date() directly (which would be non-deterministic), we inject a Clock:
export interface Clock {
now(): Date;
}
export class SystemClock implements Clock {
now(): Date {
return new Date();
}
}
export interface AuctionInfrastructure {
clock: Clock;
}See The Clock Pattern for the full rationale.
The Aggregate
The recommended pattern extracts decide handlers and evolve handlers into separate files, typed with InferDecideHandler and InferEvolveHandler. The aggregate definition then imports and wires them together.
Decide handlers (extracted)
Each decide handler is a standalone, independently testable function. The type is inferred from the aggregate's type bundle (AuctionDef) and the command name.
// deciders/decide-create-auction.ts
import type { InferDecideHandler } from "@noddde/core";
import type { AuctionDef } from "../auction";
export const decideCreateAuction: InferDecideHandler<
AuctionDef,
"CreateAuction"
> = (command) => ({
name: "AuctionCreated",
payload: command.payload,
});// deciders/decide-place-bid.ts
import type { InferDecideHandler } from "@noddde/core";
import type { AuctionDef } from "../auction";
export 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" },
};
}
if (now > state.endsAt) {
return {
name: "BidRejected",
payload: { bidderId, amount, reason: "Auction has ended" },
};
}
const minimumBid = state.highestBid?.amount ?? state.startingPrice;
if (amount <= minimumBid) {
return {
name: "BidRejected",
payload: {
bidderId,
amount,
reason: `Bid must exceed ${minimumBid}`,
},
};
}
return {
name: "BidPlaced",
payload: { bidderId, amount, timestamp: now },
};
};The CloseAuction handler follows the same pattern. Each file imports only InferDecideHandler and the type bundle — no runtime dependencies beyond the function itself.
Evolve handlers (extracted)
Evolve handlers are also extracted. They are pure functions — no infrastructure access, no async.
// evolvers/evolve-auction-created.ts
import type { InferEvolveHandler } from "@noddde/core";
import type { AuctionDef } from "../auction";
export const evolveAuctionCreated: InferEvolveHandler<
AuctionDef,
"AuctionCreated"
> = (event) => ({
item: event.item,
startingPrice: event.startingPrice,
endsAt: event.endsAt,
status: "open" as const,
highestBid: null,
bidCount: 0,
});// evolvers/evolve-bid-placed.ts
import type { InferEvolveHandler } from "@noddde/core";
import type { AuctionDef } from "../auction";
export const evolveBidPlaced: InferEvolveHandler<AuctionDef, "BidPlaced"> = (
event,
state,
) => ({
...state,
highestBid: { bidderId: event.bidderId, amount: event.amount },
bidCount: state.bidCount + 1,
});// evolvers/evolve-bid-rejected.ts
import type { InferEvolveHandler } from "@noddde/core";
import type { AuctionDef } from "../auction";
export const evolveBidRejected: InferEvolveHandler<
AuctionDef,
"BidRejected"
> = (_event, state) => state; // No state changeThe AuctionClosed evolve handler follows the same pattern.
Aggregate definition (wiring)
The aggregate definition imports all handlers and wires them together. This file is thin — it contains no business logic, only composition.
// auction.ts
import { defineAggregate } from "@noddde/core";
type AuctionDef = {
state: AuctionState;
events: AuctionEvent;
commands: AuctionCommand;
infrastructure: AuctionInfrastructure;
};
export const Auction = defineAggregate<AuctionDef>({
initialState: initialAuctionState,
decide: {
CreateAuction: decideCreateAuction,
PlaceBid: decidePlaceBid,
CloseAuction: decideCloseAuction,
},
evolve: {
AuctionCreated: evolveAuctionCreated,
BidPlaced: evolveBidPlaced,
BidRejected: evolveBidRejected,
AuctionClosed: evolveAuctionClosed,
},
});Key Patterns Demonstrated
Multi-Validation Decide Handlers
The PlaceBid handler checks three conditions in sequence. Each validation path returns a different event. This is the recommended pattern — validate early, return rejection events for failures.
Rejection Events vs. Exceptions
Instead of throwing new Error("Auction is closed"), the handler returns a BidRejected event with a reason field. This approach:
- Records the rejection in the event stream (useful for auditing)
- Keeps the handler as a pure decision-maker (no exceptions)
- Allows downstream consumers (projections, notifications) to react to rejections
No-Op Evolve Handlers
BidRejected does not change state:
BidRejected: (_event, state) => state,The aggregate records that the bid happened (as an event) but the state remains unchanged. This is perfectly valid — not every event represents a state change.
Nullable State Fields
highestBid starts as null and becomes populated after the first valid bid. The decide handler uses optional chaining (state.highestBid?.amount ?? state.startingPrice) to handle both cases cleanly.
Running the Example
import { defineDomain, wireDomain } from "@noddde/engine";
const auctionDomain = defineDomain({
writeModel: { aggregates: { Auction } },
readModel: { projections: {} },
});
const domain = await wireDomain(auctionDomain, {
infrastructure: () => ({ clock: new SystemClock() }),
aggregates: {
persistence: () => new InMemoryEventSourcedAggregatePersistence(),
},
buses: () => ({
commandBus: new InMemoryCommandBus(),
eventBus: new EventEmitterEventBus(),
queryBus: new InMemoryQueryBus(),
}),
});
const auctionId = randomUUID();
await domain.dispatchCommand({
name: "CreateAuction",
targetAggregateId: auctionId,
payload: {
item: "Vintage Guitar",
startingPrice: 500,
endsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
await domain.dispatchCommand({
name: "PlaceBid",
targetAggregateId: auctionId,
payload: { bidderId: "alice", amount: 550 },
});
await domain.dispatchCommand({
name: "PlaceBid",
targetAggregateId: auctionId,
payload: { bidderId: "bob", amount: 600 },
});
// This bid will be rejected — below current highest
await domain.dispatchCommand({
name: "PlaceBid",
targetAggregateId: auctionId,
payload: { bidderId: "charlie", amount: 580 },
});
await domain.dispatchCommand({
name: "CloseAuction",
targetAggregateId: auctionId,
});
// Winner: bob with $600