noddde

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 change

The 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

On this page