Arrange Act Assert

Jag Reehals thinking on things, mostly product development

How fn(args, deps) supports DDD and aggregate roots in TypeScript

18 Mar 2026

Domain-Driven Design can arrive wrapped in a lot of vocabulary: ubiquitous language, aggregates, repositories, domain services, anemic models, bounded contexts, and more.

But the core idea is simpler than the terminology makes it sound. DDD is about putting domain behavior and domain boundaries at the center of the code, instead of letting infrastructure and framework concerns drive the design.

fn(args, deps) does not replace DDD. It is a practical function shape that can make several DDD ideas easier to express in TypeScript: explicit use cases, explicit dependencies, and clearer boundaries around where domain rules live.

fn(args, deps) and Domain-Driven Design

DDD as domain pressure, not framework

A lot of discussions around Domain-Driven Design get pulled into implementation details too early.

People hear DDD and think of large layered systems, lots of abstractions, or class-heavy codebases full of patterns used for their own sake.

That is not the useful part.

The useful pressure from DDD is much more direct:

That pressure matters whether your code uses classes, plain objects, pure functions, or a mix.

This is one reason fn(args, deps) fits well here.

It does not try to be DDD. It gives you a simple and repeatable shape for application-level code so that domain decisions stay visible and infrastructure gets pushed to the edges.

The point is to express the model in code, where the rules and constraints of the domain stay visible.

Aggregates and invariants in TypeScript

![fn(args, deps) and Domain-Driven Design Aggregates and invariants in TypeScript](/static/images/fn-args-deps-and-domain-driven-design-aggregates-and invariants.png)

In plain terms, an aggregate is a consistency boundary.

It is a boundary around related domain state that must remain consistent through rules enforced by a single root. The aggregate root is the main entry point through which those invariants are protected.

That sounds abstract, so here is a small example.

Imagine an order that can contain line items, can be cancelled, and can be shipped. One invariant might be that a cancelled order cannot be shipped.

Another might be that you cannot add lines once the order has shipped.

In TypeScript, that does not require a giant inheritance hierarchy. The domain model can stay small and direct.

type OrderId = string;
type OrderStatus = "draft" | "placed" | "cancelled" | "shipped";

type OrderLine = {
  productId: string;
  quantity: number;
};

type Order = {
  id: OrderId;
  status: OrderStatus;
  lines: OrderLine[];
};

const addLine = (order: Order, line: OrderLine): Order => {
  if (order.status === "shipped") {
    throw new Error("Cannot add lines to a shipped order");
  }

  if (order.status === "cancelled") {
    throw new Error("Cannot add lines to a cancelled order");
  }

  if (line.quantity <= 0) {
    throw new Error("Quantity must be greater than zero");
  }

  return {
    ...order,
    lines: [...order.lines, line],
  };
};

const ship = (order: Order): Order => {
  if (order.status === "cancelled") {
    throw new Error("Cannot ship a cancelled order");
  }

  if (order.lines.length === 0) {
    throw new Error("Cannot ship an order with no lines");
  }

  return {
    ...order,
    status: "shipped",
  };
};

This is already recognizable as domain logic.

The important point is not whether this is written as a class or as pure functions over a value.

The important point is that the rules live with the domain behavior, not buried in controllers, handlers, or database code.

Where fn(args, deps) lives in a DDD layered model

In many DDD-style systems, fn(args, deps) fits naturally in the application layer.

That means it acts like a use case or application service. It coordinates domain behavior. It loads an aggregate, invokes domain logic, persists the result, and deals with side effects.

That matters only if the application layer remains a coordinator. The model should still carry the meaning of the business rules.

A simple shipOrder(args, deps) might look like this:

type ShipOrderArgs = {
  orderId: string;
};

type ShipOrderDeps = {
  orders: {
    getById(id: string): Promise<Order | null>;
    save(order: Order): Promise<void>;
  };
  domainEvents: {
    publish(events: { type: string; orderId: string }[]): Promise<void>;
  };
};

export const shipOrder = async (
  args: ShipOrderArgs,
  deps: ShipOrderDeps,
): Promise<void> => {
  const order = await deps.orders.getById(args.orderId);

  if (!order) {
    throw new Error("Order not found");
  }

  const shippedOrder = ship(order);

  await deps.orders.save(shippedOrder);

  await deps.domainEvents.publish([
    {
      type: "OrderShipped",
      orderId: shippedOrder.id,
    },
  ]);
};

There is a clean split here:

That lines up well with common DDD concerns.

The use case is explicit. The dependencies are explicit. The domain rule is still owned by domain behavior.

Avoiding anemic domain models without overstuffed entities

One of the standard criticisms in DDD discussions is the anemic domain model: domain objects that only carry data while all important decisions live somewhere else.

That criticism is still useful. It is close to the usual critique behind the anemic domain model: the structure may look domain-oriented, but the important behavior lives elsewhere.

But there is also a common overcorrection where people assume every bit of behavior must live on a large entity class or the design has somehow failed.

fn(args, deps) helps with the first problem, but does not force the second.

Here is the weak version:

type CancelOrderArgs = {
  orderId: string;
};

type CancelOrderDeps = {
  orders: {
    getById(id: string): Promise<Order | null>;
    save(order: Order): Promise<void>;
  };
};

export const cancelOrder = async (
  args: CancelOrderArgs,
  deps: CancelOrderDeps,
): Promise<void> => {
  const order = await deps.orders.getById(args.orderId);

  if (!order) {
    throw new Error("Order not found");
  }

  if (order.status === "shipped") {
    throw new Error("Cannot cancel a shipped order");
  }

  if (order.lines.length === 0) {
    throw new Error("Cannot cancel an empty order");
  }

  await deps.orders.save({
    ...order,
    status: "cancelled",
  });
};

This works, but the use case is making the domain decisions directly.

The order is mostly a data bag.

When that happens repeatedly, the code may use DDD vocabulary without really letting the domain model do domain work.

A better version moves the decision back into domain behavior:

const cancel = (order: Order): Order => {
  if (order.status === "shipped") {
    throw new Error("Cannot cancel a shipped order");
  }

  if (order.lines.length === 0) {
    throw new Error("Cannot cancel an empty order");
  }

  return {
    ...order,
    status: "cancelled",
  };
};

export const cancelOrder = async (
  args: CancelOrderArgs,
  deps: CancelOrderDeps,
): Promise<void> => {
  const order = await deps.orders.getById(args.orderId);

  if (!order) {
    throw new Error("Order not found");
  }

  await deps.orders.save(cancel(order));
};

That is the distinction that matters.

fn(args, deps) is not a license to move all business logic into orchestration functions.

It is a way to make dependencies honest while leaving room for actual domain behavior to stay where it belongs.

Aggregate boundaries and explicit dependencies

One nice side effect of fn(args, deps) is that aggregate boundaries become easier to see.

In DDD-style codebases, repositories are often modeled around aggregate roots. That means the dependency list for a use case often acts as feedback on the shape of the model.

For example:

type ApproveInvoiceDeps = {
  invoices: InvoiceRepository;
  customers: CustomerRepository;
  creditPolicies: CreditPolicyService;
  outbox: OutboxWriter;
};

Sometimes that is perfectly reasonable.

Sometimes it is a warning sign.

If a single use case keeps needing multiple repositories, cross-aggregate reads, extra coordination services, and transaction workarounds, the code is often telling you something about the boundary you picked.

This is one of the more practical benefits of the pattern.

Because dependencies are explicit, awkward boundaries often become easier to spot.

Instead of hiding the complexity behind injected class state or framework wiring, the use case signature keeps that complexity in view.

That is useful in reviews.

It is useful in refactoring.

And it is useful when a team is still learning where aggregate boundaries really belong.

Domain events and outbox patterns via deps

Domain events are another place where fn(args, deps) fits naturally.

Some teams model domain events as part of the domain result. Others derive them in the application layer after a domain operation. Either way, fn(args, deps) gives you a clear place to persist state and then hand those events to an explicit dependency such as an event publisher or outbox writer.

type SaveResult = {
  order: Order;
  events: { type: string; orderId: string }[];
};

const shipWithEvents = (order: Order): SaveResult => {
  const shippedOrder = ship(order);

  return {
    order: shippedOrder,
    events: [{ type: "OrderShipped", orderId: shippedOrder.id }],
  };
};

type ShipOrderWithOutboxDeps = {
  orders: {
    getById(id: string): Promise<Order | null>;
    save(order: Order): Promise<void>;
  };
  outbox: {
    append(events: { type: string; orderId: string }[]): Promise<void>;
  };
};

export const shipOrderWithOutbox = async (
  args: ShipOrderArgs,
  deps: ShipOrderWithOutboxDeps,
): Promise<void> => {
  const order = await deps.orders.getById(args.orderId);

  if (!order) {
    throw new Error("Order not found");
  }

  const result = shipWithEvents(order);

  await deps.orders.save(result.order);
  await deps.outbox.append(result.events);
};

The eventing concern is still explicit, but it stays outside the domain model itself.

That keeps infrastructure concerns visible without forcing them into every domain type.

How this helps teams doing DDD

The biggest value here is not theoretical purity.

It is that the shape is easy for teams to use consistently.

Every use case looks roughly the same:

useCase(args, deps);

That gives teams a few practical advantages.

First, it creates shared entry points. New people can scan the codebase and find where domain actions begin.

Second, it makes boundaries visible. Repositories, clocks, id generators, policy services, and event publishers are all visible in deps, instead of being hidden in ambient framework state.

Third, it improves testing. Domain rules can be tested directly at the aggregate level, and application orchestration can be tested by swapping deps with fakes.

Fourth, it helps surface anemic designs. If every use case keeps reimplementing the same decisions outside the model, the repetition becomes obvious.

None of that means the function shape is DDD.

It means the function shape is a good delivery mechanism for several DDD ideas in day-to-day TypeScript code.

Where this pattern stops

It is worth being precise here.

fn(args, deps) does not solve strategic DDD.

It does not define bounded contexts for you.

It does not create a ubiquitous language for the team.

It does not tell you where aggregate boundaries should be.

And it definitely does not guarantee a good domain model just because the function signatures look tidy.

Those things still require modeling effort, domain knowledge, and iteration.

What this pattern does give you is a low-ceremony way to express those decisions in code once you have started making them.

It also scales well.

You can use fn(args, deps) in code that is barely influenced by DDD at all.

Then, as the domain model becomes more intentional, the same shape still holds.

Why fn(args, deps) works well with DDD and aggregate roots

DDD is about domain focus, domain language, and domain boundaries.

fn(args, deps) is not a replacement for that work.

What it gives you is a practical way to keep use cases explicit, keep infrastructure at the edge, and keep dependencies honest.

That is especially helpful in TypeScript, where teams often want the benefits of clear application and domain boundaries without committing themselves to heavy frameworks or class-first designs.

So the real case for fn(args, deps) is not that it makes code look functional or fashionable.

It is that it can give teams a simple way to keep the model visible in code, keep infrastructure from taking over, and make domain decisions easier to see, test, and evolve.

And that is the point.

fn(args, deps) Series

  1. If You Only Enforce One Rule for AI Code, Make It fn(args, deps)
  2. Why Matt Pocock Is Right About Making Codebases AI Agents Love
  3. Composition Roots and fn(args, deps)
  4. fn(args, deps) and Hexagonal Architecture
  5. How fn(args, deps) supports SOLID-style design in TypeScript
  6. How fn(args, deps) supports DDD and aggregate roots in TypeScript (this post)
  7. fn(args, deps) — Bringing Order to Chaos Without Breaking Anything
typescript coding architecture ddd di