noddde

Why Extract Handlers?

Why noddde recommends extracting decide handlers, evolve handlers, saga handlers, and projection handlers into standalone functions in separate files.

The Decision

noddde recommends extracting all handlers -- decide handlers, evolve handlers, saga on-entries, projection event handlers, and query handlers -- into standalone functions in separate files, typed with the Infer*Handler utilities.

// 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 },
) => {
  // business logic here
};

The aggregate (or saga, or projection) definition becomes a clean wiring file:

// auction.ts
export const Auction = defineAggregate<AuctionDef>({
  initialState,
  decide: {
    CreateAuction: decideCreateAuction,
    PlaceBid: decidePlaceBid,
    CloseAuction: decideCloseAuction,
  },
  evolve: {
    AuctionCreated: evolveAuctionCreated,
    BidPlaced: evolveBidPlaced,
    BidRejected: evolveBidRejected,
    AuctionClosed: evolveAuctionClosed,
  },
});

Inline handlers are still valid TypeScript and will compile, but extracted handlers are the recommended default.

The Problem

Inline handlers concentrate all business logic in a single file. As aggregates grow, this leads to:

  • Monolithic files. A booking aggregate with 8 decide handlers and 8 evolve handlers means hundreds of lines of logic in one file. Finding the handler you need requires scrolling.
  • Manual type annotations. Without extraction, developers who want separate files must manually reconstruct the handler signature -- importing payload types, state types, infrastructure types, and event unions individually.
  • Coupled tests. Testing a single handler requires importing the entire aggregate and either calling the handler through the aggregate object or duplicating its setup.

How Extraction Solves It

Testability

Each handler is a plain function that can be tested in isolation. Pass in a command, a state snapshot, and (for decide handlers) an infrastructure stub -- assert on the returned events or new state. No aggregate wiring needed:

import { decidePlaceBid } from "./deciders/decide-place-bid";

it("should reject bids below the starting price", () => {
  const event = decidePlaceBid(
    {
      name: "PlaceBid",
      targetAggregateId: "a-1",
      payload: { bidderId: "b-1", amount: 5 },
    },
    { status: "open", startingPrice: 100, highestBid: null /* ... */ },
    { clock: { now: () => new Date() } },
  );
  expect(event).toEqual({
    name: "BidRejected",
    payload: expect.objectContaining({ reason: expect.any(String) }),
  });
});

Readability

The aggregate definition becomes a table of contents. You can see at a glance which command maps to which decide handler and which event maps to which evolve function, without reading through business logic.

Type safety

InferDecideHandler<Def, "PlaceBid"> derives the full function signature from your AggregateTypes bundle. The command is narrowed to the specific variant, the state is typed, and infrastructure is correctly scoped with FrameworkInfrastructure merged in. The same pattern applies to all handler kinds:

UtilityHandler kind
InferDecideHandler<T, K>Aggregate decide handler
InferEvolveHandler<T, K>Aggregate evolve handler
InferSagaOnEntry<T, K>Saga on-entry ({ id, handle })
InferSagaEventHandler<T, K>Saga event handler (just the function)
InferProjectionEventHandler<T, K>Projection event handler ({ id?, reduce })
InferProjectionQueryHandler<T, K>Projection query handler

Maintainability

Changes to one handler do not require reading through a large file. Each handler file has a single responsibility, making code review and git history cleaner.

When Inline Is Fine

For aggregates with 1-2 trivial handlers (e.g., a no-op evolve handler or a simple pass-through command), inline is acceptable:

export const Counter = defineAggregate<CounterDef>({
  initialState: { count: 0 },
  decide: {
    Increment: (cmd) => ({
      name: "Incremented",
      payload: { amount: cmd.payload.amount },
    }),
  },
  evolve: {
    Incremented: (event, state) => ({ count: state.count + event.amount }),
  },
});

The guideline is not "never inline" but "extract by default, inline when the aggregate is small enough that extraction adds more noise than it removes."

On this page