Skip to content

Command Handling

A command is an intention to run business logic: add an item to a cart, confirm an order. Handling it means loading the stream, rebuilding the current state from its events, running your decision, and appending the events the decision returns under an optimistic concurrency check. This guide shows you how to set up a command handler, write the decision that holds your business logic, and control the concurrency, retries, and HTTP layer around it. For the full API surface, see the Command Handler reference.

Create a Handler

Configure a handler once with evolve and initialState, then reuse it across the application:

ts
import { CommandHandler } from '@event-driven-io/emmett';
import { evolve, initialState } from './shoppingCart';

export const handle = CommandHandler({ evolve, initialState });

Write the Decision

A decision receives the current state and the command, and returns the events to append. It holds your business logic: it decides what happens, and it is the one place a business rule is enforced.

Return one event

When a command produces a single outcome, return one event:

ts
const addProductItem = (
  command: AddProductItem,
  _state: ShoppingCart,
): ShoppingCartEvent => {
  return {
    type: 'ProductItemAdded',
    data: { productItem: command.data.productItem },
  };
};

Return several events

Return an array when one command produces several outcomes. The handler appends them together in a single write, so the stream cannot be left holding only some of them:

ts
const addProductItemWithDiscount = (
  command: AddProductItem,
  _state: ShoppingCart,
): ShoppingCartEvent[] => {
  return [
    {
      type: 'ProductItemAdded',
      data: { productItem: command.data.productItem },
    },
    { type: 'DiscountApplied', data: { percent: defaultDiscount } },
  ];
};

Guard against an invalid state

Throw IllegalStateError before building any event, so an invalid transition never produces one. The same decision returns an empty array when the state leaves nothing to do, which keeps re-sending the command safe:

ts
const confirm = (
  command: ConfirmShoppingCart,
  state: ShoppingCart,
): ShoppingCartEvent[] => {
  // Already confirmed: nothing left to do, so append nothing
  if (state.status === 'Confirmed') return [];

  if (state.productItems.length === 0)
    throw new IllegalStateError('Cannot confirm an empty shopping cart');

  return [
    { type: 'ShoppingCartConfirmed', data: { confirmedAt: command.data.now } },
  ];
};

Validate the input

Throw ValidationError when the command carries bad input, again before producing any event:

ts
const applyDiscount = (
  command: ApplyDiscount,
  _state: ShoppingCart,
): ShoppingCartEvent => {
  // Reject invalid input before producing any event
  if (command.data.percent <= 0 || command.data.percent > 1)
    throw new ValidationError('Discount percent has to be between 0 and 1');

  return { type: 'DiscountApplied', data: { percent: command.data.percent } };
};

Handle a Command

Call the handler with the event store, the stream id, and your decision. It loads the stream, runs the decision, and appends the result, returning nextExpectedStreamVersion to carry into the next call:

ts

import { getInMemoryEventStore } from '@event-driven-io/emmett';

const eventStore = getInMemoryEventStore();

const command: AddProductItemToShoppingCart = {
  type: 'AddProductItemToShoppingCart',
  data: {
    shoppingCartId,
    productItem,
  },
};

const { nextExpectedStreamVersion } = await handle(
  eventStore,
  shoppingCartId,
  (state) => addProductItem(command, state),
);

Run Several Decisions in Order

Pass an array of decisions to run them in sequence. Each runs on the state left by the ones before it, and all their events are appended in a single write:

ts
const { newState, newEvents, nextExpectedStreamVersion } =
  await handleCommand(eventStore, shoppingCartId, [
    (state) => addProductItem(addProduct, state),
    (state) => confirm(confirmCart, state),
  ]);

Control Concurrency

By default the handler reuses the version it read from the stream, so you do not pass one and concurrent writers cannot overwrite each other:

ts
const { newState: state1, nextExpectedStreamVersion } = await handleCommand(
  eventStore,
  shoppingCartId,
  (state) => addProductItem(command, state),
);

Guard with a client's version

When the version comes from outside, such as a client's If-Match header, pass it as expectedStreamVersion. The append then succeeds only if the stream is still at that version:

ts
const { nextExpectedStreamVersion: version2 } = await handleCommand(
  eventStore,
  shoppingCartId,
  (state) => addProductItem(command, state),
  { expectedStreamVersion: nextExpectedStreamVersion },
);

Create a stream exactly once

Pass STREAM_DOES_NOT_EXIST so the append succeeds only when the stream does not exist yet:

ts
const { nextExpectedStreamVersion, newState, newEvents, createdNewStream } =
  await handleCommand(
    eventStore,
    shoppingCartId,
    (state) => addProductItem(command, state),
    { expectedStreamVersion: 'STREAM_DOES_NOT_EXIST' },
  );

A stale version throws ExpectedVersionConflictError. See Optimistic Concurrency in the reference for the full behaviour.

Retry on Conflict

Set retry so Emmett re-runs the handler when a version conflict occurs. { onVersionConflict: true } applies the default policy:

ts
const { newEvents } = await handleCommand(
  eventStore,
  shoppingCartId,
  (state) => addProductItem(command, state),
  { retry: { onVersionConflict: true } },
);

For a different backoff, or to retry on errors other than version conflicts, pass your own shouldRetryError:

ts
const handle = CommandHandler<ShoppingCart, ShoppingCartEvent>({
  evolve,
  initialState,
  retry: {
    retries: 5,
    minTimeout: 50,
    factor: 2,
    shouldRetryError: (error) => error instanceof ConcurrencyError,
  },
});

Integrate with Express

emmett-expressjs wraps a handler with on. Fetch any data the decision needs, such as the unit price, before calling the handler and pass it into the command, which keeps the decision pure. The first write creates the stream:

ts
// Add Product Item
router.post(
  '/clients/:clientId/shopping-carts/current/product-items',
  on(async (request: AddProductItemRequest) => {
    const shoppingCartId = getShoppingCartId(
      assertNotEmptyString(request.params.clientId),
    );
    const productId = assertNotEmptyString(request.body.productId);

    const command: AddProductItemToShoppingCart = {
      type: 'AddProductItemToShoppingCart',
      data: {
        shoppingCartId,
        productItem: {
          productId,
          quantity: assertPositiveNumber(request.body.quantity),
          unitPrice: await getUnitPrice(productId),
        },
      },
      metadata: { now: getCurrentTime() },
    };

    await handle(eventStore, shoppingCartId, (state) =>
      addProductItem(command, state),
    );

    return NoContent();
  }),
);

Carry the version in an ETag

To expose optimistic concurrency over HTTP, read the client's version from the If-Match header with getETagValueFromIfMatch, pass it as expectedStreamVersion, and return the new version with toWeakETag. A stale If-Match maps to HTTP 412 Precondition Failed:

ts
// Add a product item, guarded by the version carried in the If-Match header
router.post(
  '/clients/:clientId/shopping-carts/:shoppingCartId/product-items',
  on(async (request: AddProductItemRequest) => {
    const shoppingCartId = assertNotEmptyString(
      request.params.shoppingCartId,
    );
    const productItem: ProductItem = {
      productId: assertNotEmptyString(request.body.productId),
      quantity: assertPositiveNumber(request.body.quantity),
    };
    const unitPrice = dummyPriceProvider(productItem.productId);

    const command: AddProductItemToShoppingCart = {
      type: 'AddProductItemToShoppingCart',
      data: { shoppingCartId, productItem: { ...productItem, unitPrice } },
    };

    const result = await handle(
      eventStore,
      shoppingCartId,
      (state) => decide(command, state),
      {
        expectedStreamVersion: assertUnsignedBigInt(
          getETagValueFromIfMatch(request),
        ),
      },
    );

    return NoContent({
      eTag: toWeakETag(result.nextExpectedStreamVersion),
    });
  }),
);

Best Practices

Keep the Decision Pure

A decision that only reads state and returns events is easy to test and safe to retry. When it needs external data, such as a price or an exchange rate, fetch it before the handler and pass it in. Avoid I/O inside the decision: on a version conflict the whole handler re-runs, so the call fires again on every retry:

ts
const { newState, newEvents } = await handleCommand(
  eventStore,
  shoppingCartId,
  async (state) => {
    // ❌ Avoid: on a version-conflict retry the whole handler re-runs, so this call fires again
    const price = await getPrice(command.data.productItem.productId);
    return addProductItem(
      {
        ...command,
        data: { productItem: { ...command.data.productItem, price } },
      },
      state,
    );
  },
);

Type Events as a Discriminated Union

Declare your events as a discriminated union and pass it as the handler's StreamEvent. evolve and every decision are then checked against it, and unknown event types surface at compile time:

ts
type ShoppingCartEvent =
  | ProductItemAdded
  | DiscountApplied
  | ShoppingCartConfirmed
  | ShoppingCartCancelled;

Troubleshooting

Version Conflict on Every Write

If every write throws ExpectedVersionConflictError, the expected version you pass is stale. When you track the version yourself, pass the nextExpectedStreamVersion from the previous result into the next call. When a client supplies it, return the new version as an ETag so the next request carries the current one. To let Emmett recover on its own, set retry.

Nothing Is Appended

If a command runs without error yet no events land, the decision returned an empty array. That is the no-op path, taken when the state leaves nothing to do, such as confirming an already-confirmed cart. Check the guard that returns [].

A Side Effect Fires Twice

If an external call inside a decision fires more than once, a version conflict retried the handler and re-ran the decision. Move the I/O out of the decision and pass its result in, as Keep the Decision Pure shows.

Further Readings