When implementing DDD, there's something I often get stuck on.

That is the question of where to put logic that spans multiple aggregates.

For example, consider the process of creating a reservation.

Look at User
Look at Slot
Create Reservation
To decide whether a reservation is possible, you need to look at both User and Slot.

Or in order processing, you might have something like this.

Create Order
Reduce Inventory
Mark Coupon as used
Create Payment

At this point it becomes unclear how much to write in the UseCase, where to split out into a Domain Service, and what to hand off to a Domain Event.

In this article, I'll try to sort out, in my own way, where logic spanning multiple aggregates should be placed.

Conclusion

To state the conclusion first, I think the criteria are these four.

Aggregate:
  Protects the invariants of that aggregate itself

UseCase / Application Service:
  Handles the flow of processing, retrieval, persistence, transactions, external integration

Domain Service / Policy / Specification:
  Handles business rules that can't be decided without looking at multiple aggregates

Domain Event Handler:
  Handles side effects on other aggregates that may be delayed

Put even more briefly:

Flow goes in the UseCase, decisions in the Domain Service / Policy, state consistency in the Aggregate, side effects in Domain Events.

Keeping this in mind makes it much harder to get lost even in processes that span multiple aggregates.

An Aggregate Is a Consistency Boundary

First, as a premise, an Aggregate in DDD is not just a group of objects.

An Aggregate is a boundary for maintaining consistency synchronously.

That is, inside an aggregate, the invariants that the aggregate must protect always hold.

For example, for a Reservation aggregate, rules like the following might be something Reservation itself should protect.
A cancelled reservation cannot be cancelled again
The reservation date and time must be in the future
The reservation status can only transition in the defined order
These are rules closed over the state of Reservation, so they're naturally the aggregate's responsibility. On the other hand, rules like the following can't be decided by Reservation alone.
A suspended user cannot make reservations
A full slot cannot be reserved
The same user cannot make multiple reservations in the same time period
In this case, you need to look at multiple pieces of information such as User, Slot, and existing Reservations.

The question that arises here is where to put such logic.

The UseCase Handles the Flow of Processing

A UseCase, or Application Service, handles the flow of processing as an application.

For a reservation creation UseCase, what it does is as follows.

Receive input
Fetch User
Fetch Slot
Start a transaction
Determine whether a reservation is possible
Generate Reservation
Save
Send notifications or publish events if needed

This is precisely the UseCase's job.

Just because a UseCase depends on multiple repositories doesn't by itself make it a bad design.

Fetching multiple aggregates and proceeding through processing in order is a natural responsibility of an Application Service.

The problem is writing too much business decision-making directly inside the UseCase.

It gets dangerous when code like the following increases.

if (user.isSuspended()) {
  throw new Error("Suspended users cannot make reservations")
}

if (!slot.isOpen()) {
  throw new Error("This slot cannot be reserved")
}

if (user.hasReservationAt(slot.timeRange)) {
  throw new Error("There is already a reservation in the same time period")
}

Of course, it works like this at first.

But as conditions increase, the UseCase quickly becomes a dumping ground for ifs.

When that happens, the names of business rules are lost.

The domain-level decision "whether it can be reserved" gets buried in the UseCase's procedure.

A Domain Service Handles Business Decisions Spanning Multiple Aggregates

Business rules that can't be decided without looking at multiple aggregates are easier to organize when extracted into a Domain Service.

For example, for deciding whether a reservation is possible, you could name it something like ReservationPolicy.
export class ReservationPolicy {
  assertCanReserve(user: User, slot: Slot): void {
    if (user.isSuspended()) {
      throw new Error("Suspended users cannot make reservations")
    }

    if (!slot.isOpen()) {
      throw new Error("This slot cannot be reserved")
    }

    if (!slot.canAcceptReservation()) {
      throw new Error("This slot is full")
    }
  }
}

The UseCase side becomes this.

export class CreateReservationUseCase {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly slotRepository: SlotRepository,
    private readonly reservationRepository: ReservationRepository,
    private readonly reservationPolicy: ReservationPolicy,
    private readonly tx: TransactionManager,
  ) {}

  async execute(input: CreateReservationInput): Promise<ReservationId> {
    return this.tx.run(async () => {
      const user = await this.userRepository.findById(input.userId)
      const slot = await this.slotRepository.findById(input.slotId)

      this.reservationPolicy.assertCanReserve(user, slot)

      const reservation = Reservation.create({
        userId: user.id,
        slotId: slot.id,
      })

      await this.reservationRepository.save(reservation)
      return reservation.id
    })
  }
}

With this shape, responsibilities become quite clear.

UseCase:
  Retrieval, ordering, transactions, persistence

ReservationPolicy:
  Business decision on whether a reservation is possible

Reservation:
  Creation of the reservation itself and its invariants

The UseCase can keep the flow of processing easy to read.

Meanwhile, the rules for reservation eligibility get a name: ReservationPolicy.

This "business decisions get a name" aspect is significant.

The Name "Policy" Is Easy to Use in Practice

Even for something you could call a Domain Service in DDD terminology, naming it Policy is sometimes clearer in practice.

Names like these.

ReservationPolicy
MissionCreationPolicy
DiscountPolicy
AssignmentPolicy
CancellationPolicy

The name Policy conveys "this is not a procedure but a cluster of decisions."

It goes especially well with method names like these.

reservationPolicy.assertCanReserve(user, slot)
missionCreationPolicy.assertCanCreate({ drone, pilot, route, slot })
discountPolicy.calculateFor(order, customer)
Using the assertCan... or can... form also reads well from the UseCase side.
this.missionCreationPolicy.assertCanCreate({
  drone,
  pilot,
  route,
  slot,
})

Just reading this one line, you can tell that "this is where we decide whether a mission can be created."

Compared to lining up ifs in the UseCase, the domain intent comes to the front.

A Specification Treats Conditions as Objects

Similar to Policy is Specification.

A Specification is, roughly, a pattern that represents "whether a candidate satisfies a condition" as an object.

Typically it takes the following form.

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

In a drone operations example, you can write it like this.

export class PilotQualifiedForRouteSpecification
  implements Specification<{ pilot: Pilot; route: Route }>
{
  isSatisfiedBy(candidate: { pilot: Pilot; route: Route }): boolean {
    return candidate.pilot.hasLicenseFor(candidate.route.requiredLicense)
  }
}

Specifications are convenient when you want to compose conditions.

Is a valid user
The reservation slot is open
There is no reservation in the same time period

If you separate such conditions into individual Specifications, you can combine them as needed.

However, there are cautions in practice too.

Specifications come in two kinds: Query Specifications that represent repository search conditions, and Business Rule Specifications that represent business rules.

Mixing these two makes things confusing.

Query Specification:
  Expresses what to search for in the DB

Business Rule Specification:
  Expresses whether a domain condition is satisfied
Even under the same name Specification, the purposes differ.

If you use it in an article or code, it's better to be clear about which meaning you're using.

Personally, I think it's easiest to first group business decisions as a Policy, and then split the internals into Specifications when conditions grow and you want to reuse them.

The Problem of Wanting to Update Multiple Aggregates at Once

So far, the discussion has been about cases that reference multiple aggregates and mainly create or update one.

In practice, however, you sometimes want to update multiple aggregates at once.

Order processing, for example.

Create Order
Reduce Inventory
Mark Coupon as used
Create Payment

As a matter of DDD principle, you'd want to avoid updating multiple aggregates in one transaction.

Since an Aggregate is a consistency boundary, the basic line is to handle updates across boundaries with eventual consistency or Domain Events.

But treating this as an absolute rule is painful in practice.

In a monolith or modular monolith, where everything is in the same DB and strong consistency is needed as a single operation, deciding to bundle it into a single ACID transaction in the UseCase is entirely plausible.

Don't do it unconsciously; choose it as a trade-off. That's the crux, I think.

It completes within the same DB
It's part of the same user operation
If it fails, you want to roll back everything
It's not a distributed system

Under these conditions, bundling into a single transaction is often simpler.

Conversely, in cases like the following, consider Domain Events or a Saga.

It spans other services
It spans other DBs
It's fine if reflected with some delay
It's close to a side effect, like notifications, analytics, or billing integration

However, a Saga is not a magic bullet.

The costs of compensation, idempotency, retries, monitoring, and debugging increase.

I think you should bring it in only when you truly need distributed consistency.

Use Domain Events for Deferrable Side Effects

A Domain Event represents an occurrence that is meaningful in the domain.

For example, the following.

ReservationCreated
OrderPlaced
PaymentCompleted
MissionCreated

Domain Events are suited for side effects that occur after the main processing.

A reservation was created, so send an email
An order was confirmed, so start inventory allocation
A mission was created, so send a notification
A payment was completed, so create billing history

These don't necessarily have to be executed directly inside the aggregate's methods.

Rather, it's easier to work with a form where the aggregate records events and the UseCase or Unit of Work processes them before or after commit.

const reservation = Reservation.create({
  userId: user.id,
  slotId: slot.id,
})

reservation.recordEvent(new ReservationCreated(reservation.id))

The handler side performs notifications and reflection into other aggregates.

export class SendReservationCreatedEmailHandler {
  async handle(event: ReservationCreated): Promise<void> {
    await this.mailer.sendReservationCreated(event.reservationId)
  }
}

Whether to process synchronously within the same process or to push it onto a message queue and process asynchronously depends on requirements.

If you move it out to a distributed system, you also need to consider the Outbox Pattern and idempotency as a set.

Signs That the Aggregate Boundary Needs Rethinking

If you find yourself wanting to update multiple aggregates at once with strong consistency every time, it may be a sign to rethink the aggregate boundary.

For example, if Order and OrderLine are separate aggregates but are always updated together whenever an order is updated, it may be more natural to make them the same aggregate. Conversely, Order and Payment have different lifecycles and responsibilities, so it may be more natural to make them separate aggregates and integrate them through events.

The criteria are as follows.

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, move them into the same aggregate.

If the lifecycles differ and delayed reflection is acceptable, connect them as separate aggregates via Domain Events.

If you don't do this rethinking and try to absorb it forcibly with just UseCases and Domain Services, the design gets more and more strained.

Thinking About It in Drone Operations Management

For example, consider the case of creating a mission in drone operations management.

Drone
Pilot
Route
DeliverySlot
Mission

Whether a mission can be created can't be decided without looking at multiple pieces of information.

Is the drone available?
Does the pilot hold the qualification?
Can the route be flown?
Is the time slot open?
Does it conflict with other missions?
In this case, the UseCase fetches each aggregate and delegates the decision to MissionCreationPolicy.
export class CreateMissionUseCase {
  constructor(
    private readonly droneRepository: DroneRepository,
    private readonly pilotRepository: PilotRepository,
    private readonly routeRepository: RouteRepository,
    private readonly deliverySlotRepository: DeliverySlotRepository,
    private readonly missionRepository: MissionRepository,
    private readonly missionCreationPolicy: MissionCreationPolicy,
    private readonly tx: TransactionManager,
  ) {}

  async execute(input: CreateMissionInput): Promise<MissionId> {
    return this.tx.run(async () => {
      const drone = await this.droneRepository.findById(input.droneId)
      const pilot = await this.pilotRepository.findById(input.pilotId)
      const route = await this.routeRepository.findById(input.routeId)
      const slot = await this.deliverySlotRepository.findById(
        input.deliverySlotId,
      )

      this.missionCreationPolicy.assertCanCreate({
        drone,
        pilot,
        route,
        slot,
      })

      const mission = Mission.create({
        droneId: drone.id,
        pilotId: pilot.id,
        routeId: route.id,
        deliverySlotId: slot.id,
      })

      await this.missionRepository.save(mission)
      return mission.id
    })
  }
}

The responsibilities here are as follows.

CreateMissionUseCase:
  Retrieval, transactions, persistence, processing order

MissionCreationPolicy:
  Overall decision on whether a mission can be created

DroneAvailability:
  Decisions about aircraft state

PilotQualification:
  Decisions about piloting qualifications

RouteConflictChecker:
  Decisions about route conflicts

Mission:
  The mission's own invariants and state transitions
If you want to change the state of Drone or the assignment state of Pilot at the same time, consider whether that's truly needed in the same transaction. If it can be delayed, hand it off to a MissionCreated event.

If strong consistency is required every time, revisit the aggregate boundary or transaction design.

Decision Flow

Finally, here's a summary of the decision flow for placement.

1. Is the rule closed over the state of a single aggregate?
   yes -> Put it in the Aggregate

2. Is it a business rule that can't be decided without looking at multiple aggregates?
   yes -> Put it in a Domain Service / Policy / Specification

3. Is it retrieval, persistence, transactions, external integration, or processing order?
   yes -> Put it in the UseCase / Application Service

4. Is it a side effect on other aggregates that may be delayed?
   yes -> Put it in a Domain Event Handler

5. Do you want to update multiple aggregates with strong consistency every time?
   yes -> Revisit the aggregate boundary, or consciously choose a single transaction

With this flow, the responsibilities of UseCase, Domain Service, Aggregate, and Domain Event become harder to mix up.

Summary

When writing logic that spans multiple aggregates in DDD, putting everything in the UseCase becomes too procedural.

On the other hand, pushing everything into the Domain Service tends to turn the Domain Service into a huge god class.

What matters is to look at "what that code is doing."

Is it the flow of processing?
Is it a business decision?
Is it the aggregate's own invariant?
Is it a deferrable side effect?

This makes it much easier to decide where to put things.

The following organization feels the most right to me.

Aggregate:
  Protects its own invariants

UseCase / Application Service:
  Handles flow, retrieval, persistence, transactions, external integration

Domain Service / Policy:
  Handles business decisions spanning multiple aggregates

Specification:
  Represents reusable conditions as objects

Domain Event Handler:
  Handles deferrable side effects

And if you find yourself wanting to update multiple aggregates at once with strong consistency every time, first doubt the aggregate boundary.

If it's still simpler to handle it in a single transaction as a monolith, choose that as an explicit trade-off.

DDD principles matter, but in practice, following the principles is not itself the goal.

What's important is to make clear where the business rules are, which consistency needs to be protected, and how much should be handled synchronously.