skip to content

In Domain-Driven Design tactical design, why is it considered a red flag for one aggregate root's method to directly call methods on another aggregate root within the same operation, and what should happen instead?

level: seniorimportance: should knowfreq 55%

answer

  1. aggregate methods take IDs/values, not other aggregate instances
  2. coordination lives in application service, not inside aggregates
  3. hidden coupling: cross-aggregate call = hidden shared transaction risk
  4. ambiguous failure ownership is the tell
  5. test in isolation without mocking other aggregates

basics

~20 s

If one aggregate's code directly calls another aggregate during an operation, you've merged their boundaries and lost the point of splitting them. Coordination should instead happen one level up, in application code that orchestrates the use case.

solid answer

~40 s

An aggregate method calling into another aggregate root (e.g., order.ship() internally calling warehouse.decrementStock()) couples their invariants, transactions, and load/lock lifecycles together — the very thing separate aggregates exist to avoid — and makes it ambiguous which aggregate 'owns' the failure if the second call fails. The convention is that aggregates never call each other directly; instead, an application service (use-case orchestrator) loads each aggregate it needs, calls one method on each in sequence, and persists each through its own repository/transaction, OR the interaction is deferred to eventual consistency (a domain event) when strict same-transaction coordination isn't required. Coupling still leaks in more subtle ways too — passing a live aggregate reference as a method argument, or an aggregate querying another aggregate's repository internally — and both should be avoided for the same reason.

go deeper

for a junior

May not yet see why calling another aggregate's method from inside one aggregate is different from calling it from application code.

for a middle

Recognizes that aggregate methods shouldn't take other aggregate instances as parameters, can name the application service as the right place for coordination.

for a senior

Identifies subtler coupling (read-only aggregate arguments, ambiguous failure ownership) and structures orchestration and compensation logic explicitly in the application layer.

for a principal

Sets architectural conventions/lint rules preventing this coupling at scale, and decides case by case when synchronous orchestration versus asynchronous event-driven coordination is the right tool.

## Why the rule earns its own topic The rule that an aggregate root should never directly call methods on a different aggregate root is really an extension of the same **consistency-boundary** logic that motivates keeping aggregates small and referencing them by ID rather than object pointer — but it's worth treating as its own topic because the coupling it prevents is easy to reintroduce accidentally even by engineers who already know the ID-reference rule. ## The shortcut that looks harmless Concretely, imagine an `Order.ship()` method that, as part of shipping, needs to decrement stock in a `Warehouse` aggregate. The tempting shortcut is to have `Order.ship()` accept a `Warehouse` object and call `warehouse.decrementStock(lineItems)` internally. This looks harmless because it still goes "through the root" of `Warehouse` — it isn't reaching into Warehouse's private fields. But it silently merges two things that were deliberately kept separate: - **the transaction** (does saving `Order` and saving `Warehouse` now need to succeed or fail together, and if so, which repository is responsible for that?), and - **the invariant ownership** (if `decrementStock` throws because stock is insufficient, is that a failure of shipping the order, of the warehouse, or of the orchestration between them — and which layer is supposed to catch it and decide what to do?). Every one of these questions has a clean answer only if the coordination logic lives outside both aggregates, in the application/use-case layer. ## Put the coordination one layer up The mechanism that replaces direct aggregate-to-aggregate calls is **orchestration at the application service level**: a use-case handler (sometimes called an **application service** or **command handler**) loads `Order` via its repository, loads `Warehouse` via its repository, calls exactly one relevant method on each, and is the single place responsible for deciding what "success" and "failure" of the combined operation mean, including what to do if the second call fails after the first succeeded. This keeps each aggregate's own code testable and reasoned about purely in terms of its own invariants — Order's tests never need a real or mocked `Warehouse`, and vice versa — while the cross-aggregate coordination logic, which is genuinely more complex and use-case-specific, is isolated in one place designed to hold that complexity. ## Three prices for skipping it Why this matters in practice comes down to three costs of skipping it. 1. **First, testability collapses**: if aggregates call each other, unit-testing `Order` in isolation now requires either a real `Warehouse` or an elaborate mock passed into a method, which defeats much of the point of keeping `Order` simple and independently verifiable. 2. **Second, hidden transactional coupling creeps in**: a method that internally calls another aggregate's method is one refactor away from someone wrapping both in a single database transaction "to be safe," which reintroduces the locking/contention problems that motivated splitting the aggregates in the first place. 3. **Third, and most insidiously, ownership of failure handling becomes ambiguous over time** — six months later, when `decrementStock` starts throwing under a new edge case, the engineer fixing it has to first figure out whether Order's `ship()` method, some caller of it, or `Warehouse` itself is supposed to own the retry/compensation logic, because the original coupling never made that a well-defined question. ## How reviewers catch the drift The failure mode this produces in real codebases is a slow accumulation of aggregates that reference each other in a tangled call graph rather than a clean **load-orchestrate-save** shape — often starting from one "just this once" convenience call that gets copied as a precedent. It's usually caught in code review by a rule of thumb: - an aggregate root method's parameters should be **primitives, value objects, or IDs**, never another aggregate root instance; - if a method seems to need another aggregate's live state, that's a signal the logic belongs in the orchestrating application service instead, or that the interaction should be deferred to an **asynchronous domain event** handled outside the request that mutated the first aggregate (the eventually-consistent path, which is a distinct topic in its own right). ## A fulfillment flow with one obvious owner A concrete illustration: in an order-fulfillment flow, a well-structured application service does something like — load `Order`, call `order.markReadyToShip()`, save `Order`; separately, load `Warehouse`, call `warehouse.reserve(items)`, save `Warehouse`; and if the warehouse reservation fails after the order was already marked ready, the application service (not either aggregate) is responsible for compensating, e.g., by calling `order.revertToPending()` and saving again. Neither aggregate ever sees or calls the other directly, and every failure branch has one obvious owner: the orchestrating code.

  • What's the difference between an application service calling methods on two aggregates versus one aggregate directly calling a method on another?
    In the first case, the orchestration logic and failure-handling decisions live in one dedicated place outside both aggregates, and each aggregate can be tested and reasoned about without knowing the other exists. In the second case, one aggregate's code has an implicit dependency on another's API and lifecycle, which means testing, transaction boundaries, and failure ownership all get entangled inside what was supposed to be an independent, self-contained unit.
  • If passing a live aggregate reference as a method argument is a red flag, what should an aggregate method accept instead when it needs data that conceptually 'belongs' to another aggregate?
    It should accept primitives, value objects, or a snapshot/DTO of just the data it needs — computed and passed in by the calling application service, which is responsible for loading the other aggregate and extracting the relevant values. This keeps the aggregate's method signature free of any dependency on another aggregate's type or lifecycle.
  • When is it acceptable to loosen this rule for performance reasons, and what should you watch for if you do?
    It's occasionally pragmatic to pass a lightweight, already-loaded read-only value (not the aggregate root itself) into a method purely as a data source to avoid a redundant query, but the watch-out is that this must stay strictly read-only and never let the called method invoke behavior back on the passed-in aggregate, or the same coupling and testability problems return through the back door.

Like two separate departments in a company that should communicate through a manager who coordinates both, rather than one department head walking into the other department and rewriting their procedures directly.

saying these in an interview costs you the question

  • has an aggregate root method accept another aggregate root instance as a parameter
  • can't say who is responsible for compensating/handling failure when a multi-aggregate operation partially succeeds
  • unit tests for one aggregate require mocking or instantiating a different aggregate
  • wraps saves for two different aggregates in a single database transaction as a default habit
  • doesn't distinguish 'application service orchestrates' from 'aggregate calls aggregate'

context