noddde

Standalone Command Handlers

Commands that operate outside of aggregates for cross-cutting orchestration, integrations, and process coordination.

What Are Standalone Commands?

Most commands in noddde target a specific aggregate instance via targetAggregateId. But some operations do not belong to any single aggregate. Standalone commands handle these cases: cross-cutting workflows, integration points, and simple orchestration.

A standalone command is a plain Command -- it has a name and an optional payload, but no targetAggregateId.

For stateful, multi-step workflows that need to track progress and handle compensation, see Sagas instead. That page also includes a detailed comparison table between the two approaches.

The StandaloneCommand Type

StandaloneCommand is an alias for the base Command interface:

type StandaloneCommand = Command;

// Which is:
interface Command {
  name: string;
  payload?: any;
}

The naming distinction exists to make intent clear in your codebase. When you see StandaloneCommand, you know the command is not routed to an aggregate.

StandaloneCommandHandler

Standalone command handlers have a different signature from aggregate command handlers. Instead of receiving aggregate state, they receive the full CQRS infrastructure:

type StandaloneCommandHandler<
  TInfrastructure extends Infrastructure,
  TCommand extends StandaloneCommand,
> = (
  command: TCommand,
  infrastructure: TInfrastructure & CQRSInfrastructure,
) => void | Promise<void>;
ParameterDescription
commandThe incoming standalone command
infrastructureYour domain infrastructure merged with CQRSInfrastructure (commandBus, eventBus, queryBus)

Key differences from aggregate command handlers:

  • No state parameter. Standalone handlers do not have an aggregate state to inspect.
  • No event return value. The handler returns void. If it needs to produce side effects, it does so explicitly through the infrastructure (e.g., dispatching commands to other aggregates via the command bus).
  • Access to CQRSInfrastructure. The handler receives commandBus, eventBus, and queryBus directly, enabling it to orchestrate across aggregates.

Registering Standalone Command Handlers

Standalone command handlers are registered in the writeModel.standaloneCommandHandlers map of your domain configuration:

import { configureDomain } from "@noddde/engine";

const domain = await configureDomain<BankingInfrastructure>({
  writeModel: {
    aggregates: {
      BankAccount,
    },
    standaloneCommandHandlers: {
      TransferFunds: async (command, infrastructure) => {
        const { fromAccountId, toAccountId, amount } = command.payload;

        // Debit the source account
        await infrastructure.commandBus.dispatch({
          name: "AuthorizeTransaction",
          targetAggregateId: fromAccountId,
          payload: { amount: -amount, merchant: `Transfer to ${toAccountId}` },
        });

        // Credit the destination account
        await infrastructure.commandBus.dispatch({
          name: "AuthorizeTransaction",
          targetAggregateId: toAccountId,
          payload: { amount, merchant: `Transfer from ${fromAccountId}` },
        });
      },
    },
  },
  readModel: {
    projections: {},
  },
  infrastructure: {
    // ...
  },
});

Use Cases

Simple Orchestration

Standalone commands work for stateless, fire-and-forget orchestration:

standaloneCommandHandlers: {
  TransferFunds: async (command, { commandBus }) => {
    const { fromAccountId, toAccountId, amount } = command.payload;

    await commandBus.dispatch({
      name: "AuthorizeTransaction",
      targetAggregateId: fromAccountId,
      payload: { amount: -amount, merchant: `Transfer to ${toAccountId}` },
    });

    await commandBus.dispatch({
      name: "AuthorizeTransaction",
      targetAggregateId: toAccountId,
      payload: { amount, merchant: `Transfer from ${fromAccountId}` },
    });
  },
},

For stateful multi-step workflows that need to track progress, handle failures, and coordinate compensation across aggregates, consider using Sagas instead. Sagas are event-driven, persist their state, and return commands declaratively -- making them testable without mocking buses.

Integration Commands

Use standalone handlers to bridge your domain with external systems:

standaloneCommandHandlers: {
  SyncWithExternalCRM: async (command, infrastructure) => {
    const { customerId, data } = command.payload;

    // Call external system through infrastructure
    await infrastructure.crmClient.syncCustomer(customerId, data);

    // Dispatch a domain command if the sync triggers domain logic
    await infrastructure.commandBus.dispatch({
      name: "MarkCustomerSynced",
      targetAggregateId: customerId,
    });
  },
},

Notification Dispatching

Standalone handlers can coordinate notifications that pull data from multiple sources:

standaloneCommandHandlers: {
  SendOrderConfirmation: async (command, infrastructure) => {
    const { orderId, customerId } = command.payload;

    // Query read model for display data
    const orderDetails = await infrastructure.queryBus.dispatch({
      name: "GetOrderDetails",
      payload: { orderId },
    });

    // Send via infrastructure
    await infrastructure.emailService.send({
      to: orderDetails.customerEmail,
      template: "order-confirmation",
      data: orderDetails,
    });
  },
},

Standalone vs Aggregate Commands

AspectAggregate CommandStandalone Command
Has targetAggregateIdYesNo
Handler receives stateYesNo
Handler returns eventsYes (one or more)No (returns void)
Has access to CQRSInfrastructureNo (only domain infrastructure)Yes (commandBus, eventBus, queryBus)
PurposeDomain logic within a single aggregateCross-aggregate orchestration

Guidelines

  • Use standalone commands for orchestration, not for domain logic. Business rules and invariants should live in aggregate command handlers where they have access to consistent state.
  • Keep standalone handlers thin. They should coordinate and delegate, not contain complex logic.
  • When a standalone handler dispatches multiple aggregate commands, consider what happens if one of them fails. For complex failure scenarios, consider a saga instead.
  • Standalone handlers are a good fit for reacting to external triggers (webhooks, scheduled jobs) that need to dispatch commands into the domain.

Next Steps

  • Sagas -- Stateful, event-driven workflow coordination
  • Domain Configuration -- How standalone handlers fit into the domain setup

On this page