DDD With Functional Programming

Why I model DDD aggregates as pure (State, Command) to (NewState, Events) functions with Result types, instead of mutable OO aggregates that throw.

The third aggregate I rewrote that week had four overlapping try/catch blocks, a private setter doing state mutation in five places, and a comment that said “do not call this directly” above a method called twelve times. I was a few weeks into a portfolio-wide DDD migration at the London agency I worked at, and the pattern was getting clear. Mutable aggregates and thrown exceptions had been fine when the code was a few hundred lines per service. They were not fine when we had a portfolio of legacy projects to drag into bounded contexts on a deadline.

So I started writing aggregates as pure functions. (State, Command) -> Result<{ state, events }, DomainError>. No mutation, no thrown exceptions, no side effects inside the aggregate. The application layer at the edge did the messy stuff. Honestly, once I’d done it on two aggregates I never wanted to write the OO version again.

This is the version of DDD I keep reaching for now. Tactical patterns stay. Aggregates and bounded contexts and the ubiquitous language all stay. What changes is the inside of the aggregate.

Aggregates that lie about their state

Here’s the kind of code I was untangling. Imagine an Order aggregate that exposes a confirm method.

// src/orders/domain/order.ts (the before)
export class Order {
  private _status: OrderStatus = 'draft';
  private _items: OrderItem[] = [];
  private _confirmedAt?: Date;

  confirm(now: Date): void {
    if (this._status !== 'draft') {
      throw new DomainError('order_already_confirmed');
    }
    if (this._items.length === 0) {
      throw new DomainError('empty_order');
    }
    this._status = 'confirmed';
    this._confirmedAt = now;
  }
}

Looks innocent. It isn’t. Three things bite you. The _status field is a hidden state machine and you can only see the transitions by reading every method. The throw couples error handling to whoever called confirm, three layers up. And the moment you persist events, you need to know what confirm produced, not just that it mutated.

Now multiply that by an aggregate with eight commands, retries on a queue, and an event store you actually want to replay. The mutable version cannot tell you, from its signature, what happens when you call it. You read the body or you guess.

Result types beat exceptions in the domain

I lean on neverthrow for this. dry-monads does the same job if you’re on Ruby. The point isn’t the library, it’s that failure is a value, not control flow.

// src/orders/domain/order.ts (the after)
import { Result, ok, err } from 'neverthrow';

export type OrderState = Readonly<{
  id: OrderId;
  status: 'draft' | 'confirmed' | 'cancelled';
  items: ReadonlyArray<OrderItem>;
  confirmedAt?: Date;
}>;

export type ConfirmOrder = { kind: 'ConfirmOrder'; now: Date };
export type OrderConfirmed = {
  kind: 'OrderConfirmed';
  orderId: OrderId;
  at: Date;
};

export type DomainError =
  | { code: 'order_already_confirmed' }
  | { code: 'empty_order' };

export const confirm = (
  state: OrderState,
  cmd: ConfirmOrder,
): Result<{ state: OrderState; events: OrderConfirmed[] }, DomainError> => {
  if (state.status !== 'draft') return err({ code: 'order_already_confirmed' });
  if (state.items.length === 0) return err({ code: 'empty_order' });

  const next: OrderState = { ...state, status: 'confirmed', confirmedAt: cmd.now };
  const event: OrderConfirmed = { kind: 'OrderConfirmed', orderId: state.id, at: cmd.now };
  return ok({ state: next, events: [event] });
};

The signature does the work. You read confirm and you know exactly two things can happen: a new state plus an event list, or a DomainError you have to handle. No third option. No hidden throw four functions deep.

Railway-oriented commands and events

Once commands return Result<{ state, events }, DomainError>, composing them gets cheap. You chain with andThen. The happy path is one straight line. The error path bails out without a try/catch.

// src/orders/application/checkout.use-case.ts
import { Result, ok } from 'neverthrow';
import { confirm } from '../domain/order';
import { applyDiscount } from '../domain/discount';
import { reserveInventory } from '../domain/inventory';

type ApplyError = DomainError | DiscountError | InventoryError;

export const runCheckout = (
  state: OrderState,
  discountCode: string | null,
  now: Date,
): Result<{ state: OrderState; events: DomainEvent[] }, ApplyError> => {
  const start: { state: OrderState; events: DomainEvent[] } = { state, events: [] };

  return ok(start)
    .andThen(({ state, events }) =>
      reserveInventory(state).map((r) => ({ state: r.state, events: [...events, ...r.events] })),
    )
    .andThen(({ state, events }) =>
      discountCode
        ? applyDiscount(state, { kind: 'ApplyDiscount', code: discountCode }).map((r) => ({
            state: r.state,
            events: [...events, ...r.events],
          }))
        : ok({ state, events }),
    )
    .andThen(({ state, events }) =>
      confirm(state, { kind: 'ConfirmOrder', now }).map((r) => ({
        state: r.state,
        events: [...events, ...r.events],
      })),
    );
};

Yeah, it’s a little verbose. Real code has a tiny chain helper that hides the spread. But the shape is the point. Each step takes a state and returns a state plus more events. The events accumulate. Nothing throws. If reserveInventory returns err, the rest doesn’t run. The application layer reads the same way an event store will replay it.

A nice side effect: aggregates are now trivial to unit test. You pass in a state, you pass in a command, you assert on the returned Result. No mocks. No spies. No “given the order has a status of…”. The function is the test fixture.

Side effects live at the edge

The domain layer never touches the database or the event bus. The application layer does. That’s where I’d put the controller, the transaction boundary, and any idempotency check.

// src/orders/application/checkout.controller.ts (NestJS)
import { Controller, Post, Body, Headers } from '@nestjs/common';
import { OrdersRepository } from '../infrastructure/orders.repo';
import { EventBus } from '../infrastructure/event-bus';
import { Idempotency } from '../infrastructure/idempotency';
import { runCheckout } from './checkout.use-case';

@Controller('orders')
export class CheckoutController {
  constructor(
    private readonly orders: OrdersRepository,
    private readonly bus: EventBus,
    private readonly idem: Idempotency,
  ) {}

  @Post(':id/checkout')
  async checkout(
    @Headers('idempotency-key') key: string,
    @Body() body: { discountCode?: string },
  ) {
    const cached = await this.idem.get(key);
    if (cached) return cached;

    const state = await this.orders.load(body['orderId']);
    const result = runCheckout(state, body.discountCode ?? null, new Date());

    if (result.isErr()) {
      const response = { ok: false, error: result.error };
      await this.idem.put(key, response);
      return response;
    }

    const { state: next, events } = result.value;
    await this.orders.save(next, events);
    await this.bus.publishAll(events);
    const response = { ok: true, orderId: next.id };
    await this.idem.put(key, response);
    return response;
  }
}

That idempotency layer isn’t decorative. I once watched Apple’s SubscriptionRenewal server-to-server notification get retried after our handler returned 200 OK a hair after their 30s deadline. The handler had no idempotency key, so every retry created a new subscription row. A few thousand customers ended up double-billed. The structural fix was a database-level unique constraint on (apple_original_transaction_id, notification_uuid) and a Sidekiq job that returned 200 OK within five seconds. The lesson was the same one as the migration story: failure modes should be visible. Apple is going to retry. So treat every external write as if it’ll arrive twice and bake that into the edge, not the aggregate.

The domain is pure. The edge is idempotent. That split is the whole game.

Where I would not bother

Small CRUD bounded contexts: skip this. If your “domain” is a settings page and three forms, mutable aggregates and try/catch are fine. The ceremony of Result types and pure handlers costs more than it saves there.

The places it earns its keep: aggregates with non-trivial invariants, aggregates that emit events you replay, anything where you’ve already hit “I have no idea what this method does without reading the body”. For us on that portfolio migration, the rule of thumb ended up being: if the aggregate has more than three commands or any state machine longer than a flat enum, go pure. Otherwise leave it.

And start with one aggregate. One bounded context. Prove the pattern on something small, ship it, see how the team feels. Adopting this across an entire codebase in one sweep is how teams burn out on DDD and quietly go back to fat services.

Takeaways

  • Aggregates should be pure: (State, Command) -> Result<{ state, events }, DomainError>.
  • Use neverthrow or dry-monads so failure modes live in the signature.
  • Compose commands with andThen. Side effects sit at the edge, not in the aggregate.
  • Idempotency keys belong on the controller and the event handler, not inside the domain.
  • Adopt this on one aggregate first. Resist rewriting the world.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.