When designing with DDD, I keep getting stuck in the same place, no matter how many times I've been there. Where, exactly, is logic that spans multiple aggregates supposed to live? It's not unlike tidying a room and standing there holding something that fits on none of the shelves.

Take the process of accepting an order on an e-commerce site. To decide whether the order can be placed at all, you have to look at both Customer and Product before creating an Order. And once the order is confirmed, you reduce inventory, mark the coupon as used, and create a payment.

At that point, things suddenly get murky. How much belongs in the UseCase? What should be carved out into a Domain Service, and what ought to be handed off to a Domain Event?

After a fair amount of back and forth, I've settled on this. Flow goes in the UseCase, decisions in a Domain Service or Policy, state consistency in the Aggregate, and side effects in Domain Events. What follows is how I got there.

The Aggregate as a Consistency Boundary

To begin with, an Aggregate in DDD is not merely a pile of objects. It is a boundary for maintaining consistency synchronously. Inside that boundary, the invariants the aggregate is responsible for must always hold.

For an Order aggregate, a cancelled order cannot be cancelled again. The quantity must be at least 1. The status can only move in a defined order. All of these can be decided from the state of Order alone, so they sit comfortably as the aggregate's responsibility. Things change, however, with rules like these. A suspended customer cannot place orders. A discontinued product cannot be ordered. A members-only product cannot be sold to regular customers. No amount of staring at Order will settle these; you have to go look at Customer and Product. The real question is where logic like this should go.

The UseCase Carries the Flow

The job of a UseCase, or Application Service, is to assemble the flow of processing as an application. For order creation, it first receives input and fetches Customer and Product. Then it starts a transaction, checks whether the order can be placed, creates the Order, and saves it. Notifications and event publishing, if needed, happen here as well.

A UseCase depending on several repositories is not, in itself, a sign of bad design. Fetching multiple aggregates and moving through steps in order is a perfectly legitimate responsibility of an Application Service. The trouble starts when business decisions begin to be written directly inside the UseCase.

if (customer.isSuspended()) {
  throw new Error("Suspended customers cannot place orders")
}

if (!product.isOnSale()) {
  throw new Error("This product has been discontinued")
}

if (product.isMembersOnly() && !customer.isPremiumMember()) {
  throw new Error("This product is for members only")
}
Naturally, this works at first. But as conditions pile up, the UseCase quickly turns into a storage closet for if statements. The business rules lose their names, and the domain decision of "can this order be placed" gets buried in procedure. Whoever opens this code six months later will be opening unlabeled boxes one at a time, trying to tell which if is a business rule and which is just a defensive check.

A Domain Service Gives the Decision a Name

So decisions that can't be made without looking at multiple aggregates get carved out into a Domain Service. In effect, you put up a shelf just for decisions. For deciding whether an order can be placed, a name like OrderPlacementPolicy fits nicely.
export class OrderPlacementPolicy {
  assertCanPlace(customer: Customer, product: Product): void {
    if (customer.isSuspended()) {
      throw new Error("Suspended customers cannot place orders")
    }

    if (!product.isOnSale()) {
      throw new Error("This product has been discontinued")
    }

    if (product.isMembersOnly() && !customer.isPremiumMember()) {
      throw new Error("This product is for members only")
    }
  }
}

All the UseCase has left is fetching, saving, and delegating the decision.

export class PlaceOrderUseCase {
  constructor(
    private readonly customerRepository: CustomerRepository,
    private readonly productRepository: ProductRepository,
    private readonly orderRepository: OrderRepository,
    private readonly orderPlacementPolicy: OrderPlacementPolicy,
    private readonly tx: TransactionManager,
  ) {}

  async execute(input: PlaceOrderInput): Promise<OrderId> {
    return this.tx.run(async () => {
      const customer = await this.customerRepository.findById(input.customerId)
      const product = await this.productRepository.findById(input.productId)

      this.orderPlacementPolicy.assertCanPlace(customer, product)

      const order = Order.create({
        customerId: customer.id,
        productId: product.id,
        quantity: input.quantity,
      })

      await this.orderRepository.save(order)
      return order.id
    })
  }
}
Now the roles separate cleanly. The UseCase handles retrieval, ordering, transactions, and persistence. OrderPlacementPolicy handles the business decision of whether an order can be placed, and Order handles its own creation and invariants. The flow stays readable, and the rules for placing an order get a name: OrderPlacementPolicy. The shelf gets a label, and that does far more than it looks like it would.

Calling It a Policy

Even when DDD terminology says it should be called a Domain Service, in practice naming it a Policy often lands better. OrderPlacementPolicy, PointRewardPolicy, DiscountPolicy, CancellationPolicy, and so on. Merely calling it a Policy tells the reader "this is a cluster of decisions, not a procedure." It also pairs remarkably well with method names like assertCan... or can....
orderPlacementPolicy.assertCanPlace(customer, product)
cancellationPolicy.assertCanCancel(order, now)
discountPolicy.calculateFor(order, customer)
Say a UseCase contains the line this.orderPlacementPolicy.assertCanPlace(customer, product). The reader knows immediately that this is where the order's eligibility is decided. The domain intent comes forward far more clearly than with three if statements lined up in a row.

Specification: Conditions as Objects

A close relative of Policy is Specification. Roughly speaking, it's a pattern that represents "does this candidate satisfy a condition" as an object.

export interface Specification<T> {
  isSatisfiedBy(candidate: T): boolean
}

Whether a coupon can be used for a given product could be written like this.

export class CouponApplicableToProductSpecification
  implements Specification<{ coupon: Coupon; product: Product }>
{
  isSatisfiedBy(candidate: { coupon: Coupon; product: Product }): boolean {
    return candidate.coupon.appliesTo(candidate.product.categoryId)
  }
}

Specification really shows its worth when you want to combine conditions. The customer is not suspended. The product is on sale. The customer's membership tier meets the purchase condition. Split each of these into its own Specification, and you can recombine them freely depending on the situation.

That said, there's one caveat in practice. Specifications come in two kinds: Query Specifications, which express repository search conditions, and Business Rule Specifications, which express business rules. The former says what to pull from the DB; the latter says whether a domain condition is satisfied. Same name, entirely different purpose. Mix the two and things get murky fast, so it's worth being explicit about which one you mean.

Personally, I find it easiest to first gather decisions into a Policy. Once the conditions multiply and you want to reuse them, split the internals into Specifications. Designing around composed Specifications from day one tends to be a bit overblown.

When You Want to Update Several Aggregates at Once

So far we've been talking about referencing multiple aggregates while mainly creating or updating just one. In practice, though, the moment always comes when you want to update several aggregates together. The order confirmation mentioned at the beginning is exactly that. Alongside creating the Order, you want to reduce inventory, mark the coupon as used, and create a payment.

By DDD principle, you'd want to avoid updating multiple aggregates in a single transaction. An Aggregate is a consistency boundary, so the baseline is to handle cross-boundary updates with eventual consistency or Domain Events.

Treat that as an absolute commandment, however, and you'll find yourself unable to move. Say you have a monolith or modular monolith where everything lives in the same DB. You want a single user operation to roll back entirely on failure. Then bundling it into one ACID transaction in the UseCase is an entirely reasonable call. If it's not a distributed system, that's often far simpler. What matters is not doing it unconsciously, but choosing it as a trade-off.

Conversely, there are times to consider Domain Events or a Saga. When things span other services or other DBs. When some delay in reflection is acceptable. When it's closer to a side effect, like notifications, analytics, or billing integration. That said, a Saga is no magic wand. Compensation, idempotency, retries, monitoring, debugging — the load you take on jumps all at once. It's something to bring in only when you genuinely need distributed consistency.

Deferrable Side Effects Go to Domain Events

A Domain Event represents something meaningful that happened in the domain. OrderPlaced and PaymentCompleted are typical. The order was confirmed, so send a confirmation email or start allocating inventory. The payment was completed, so create a billing record. They're well suited to side effects like these, which follow the main processing.

These side effects don't necessarily need to run directly inside the aggregate's methods. It's actually easier to have the aggregate merely record the event, and let the UseCase or Unit of Work process it before or after commit.

const order = Order.create({
  customerId: customer.id,
  productId: product.id,
  quantity: input.quantity,
})

order.recordEvent(new OrderPlaced(order.id))

Notifications and updates to other aggregates happen on the handler side.

export class SendOrderConfirmationEmailHandler {
  async handle(event: OrderPlaced): Promise<void> {
    await this.mailer.sendOrderConfirmation(event.orderId)
  }
}

Whether to process it synchronously in the same process or push it onto a message queue for asynchronous handling depends on the requirements. And if you're sending it out into a distributed system, the Outbox Pattern and idempotency come as a package deal. Either way, the policy of moving side effects out of the aggregate stays the same.

When to Doubt the Aggregate Boundary

If you find yourself wanting to update multiple aggregates with strong consistency nearly every time, that may be a sign the aggregate boundary needs rethinking. In tidying terms, the problem may not be where you put things but where the shelf dividers are.

For instance, suppose Order and OrderLine are separate aggregates. If you always touch both whenever an order is updated, making them one aggregate is probably more natural. On the other hand, Order and Payment differ in both lifecycle and responsibility. Connecting them as separate aggregates via events feels right.

There are several axes for judging. Do they need to protect the same invariant? Do they change on the same lifecycle? Are they always read and written together? Can one exist independently of the other? Can delayed reflection be tolerated?

If they protect the same invariant, pull them into one aggregate. If their lifecycles differ and delay is acceptable, connect them as separate aggregates via Domain Events. Skip this reassessment and try to absorb it all with UseCases and Domain Services alone, and the design grows more strained by the day.

Thinking It Through with a Coupon Order

As an example with more inputs to the decision, consider placing an order with a coupon. There are four players: Customer, Product, Coupon, and Order. Deciding whether the order can go through means checking five things: that the customer is allowed to order, the product is on sale, the coupon is within its validity period, it applies to this product, and the customer hasn't already used it. Even here, the UseCase simply fetches each aggregate and leaves the decision to OrderPlacementPolicy.
export class PlaceOrderWithCouponUseCase {
  constructor(
    private readonly customerRepository: CustomerRepository,
    private readonly productRepository: ProductRepository,
    private readonly couponRepository: CouponRepository,
    private readonly orderRepository: OrderRepository,
    private readonly orderPlacementPolicy: OrderPlacementPolicy,
    private readonly tx: TransactionManager,
  ) {}

  async execute(input: PlaceOrderWithCouponInput): Promise<OrderId> {
    return this.tx.run(async () => {
      const customer = await this.customerRepository.findById(input.customerId)
      const product = await this.productRepository.findById(input.productId)
      const coupon = await this.couponRepository.findByCode(input.couponCode)

      this.orderPlacementPolicy.assertCanPlaceWithCoupon({
        customer,
        product,
        coupon,
      })

      const order = Order.create({
        customerId: customer.id,
        productId: product.id,
        quantity: input.quantity,
        couponId: coupon.id,
      })

      await this.orderRepository.save(order)
      return order.id
    })
  }
}
OrderPlacementPolicy acts purely as the front desk for the overall decision. Behind it, three parts handle their own checks. CustomerEligibility looks at the customer's status, ProductAvailability at the sales status, and CouponEligibility at the coupon's conditions of use. Order, as ever, only needs to care about its own invariants and state transitions. If you also want to mark the Coupon as used or allocate inventory in the same go, stop for a moment. Does that truly need to happen in the same transaction? If it can wait, hand it off to an OrderPlaced event. If strong consistency is required every single time, it's the aggregate boundary or transaction design itself that needs revisiting.

Deciding Where Things Go

Finally, here is the order in which I decide placement. First, if the rule is closed over the state of a single aggregate, the Aggregate owns it. Next, a business decision that can't be made without looking at multiple aggregates goes to a Domain Service, Policy, or Specification. Retrieval, persistence, transactions, external integration, and processing order are the UseCase's turf.

Side effects on other aggregates that can be delayed go to a Domain Event Handler. And if you want to update multiple aggregates with strong consistency every time, revisit the aggregate boundary. Or else, consciously choose a single transaction.

Write everything in the UseCase and it becomes far too procedural. Push everything into a Domain Service, though, and that grows into a giant god class. You set out to clear the storage closet and end up building a second one next door.

Ultimately, what you need to look at is what the code is actually doing. Is it the flow of processing, a business decision, the aggregate's own invariant, or a deferrable side effect? Once you can tell that, the placement more or less decides itself.

That's where my thinking currently stands on placing logic that spans multiple aggregates. DDD principles matter, but following them is not the goal in itself. Where do the business rules live, which consistency must be protected, and how much should be handled synchronously? A design where you can explain, in your own words, which shelf holds what — that is what makes a good design.