skip to content

Consider transferring money between two Account aggregates, where withdrawing from a source account and depositing into a target account must jointly enforce a rule like 'the source must have sufficient funds.' Walk through why this is best modeled as a Domain Service rather than as a method directly on the Account entity, and what trade-offs that choice introduces.

level: seniorimportance: must knowfreq 60%

answer

  1. withdraw/deposit stay on Account
  2. transfer = cross-aggregate rule
  3. reference by ID, not by object
  4. one-aggregate-per-transaction tension
  5. saga/compensation when distributed

basics

~20 s

Each Account should only protect its own balance rule, like not going negative. A transfer touches two accounts, so a separate FundsTransferService coordinates withdraw on one and deposit on the other, rather than one account controlling another.

solid answer

~50 s

If transferTo(otherAccount, amount) were a method on Account, the source would need a direct reference to another aggregate root and would enforce invariants on both sides - breaking the idea that each aggregate protects only its own consistency boundary. Instead, Account keeps two focused methods, withdraw(amount) and deposit(amount), each enforcing its own local invariant, and a FundsTransferService coordinates: load both accounts, call withdraw on the source, call deposit on the target, knowing the cross-account rule ('sufficient funds before either action'). The trade-off: this coordination isn't a single atomic entity operation anymore - since DDD favors one aggregate per transaction, the service (or the layer above it) must explicitly handle what happens if deposit fails after withdrawal succeeds, via one database transaction spanning both in a simple system, or a compensating saga in a distributed one.

go deeper

for a junior

Should be able to say that Account keeps withdraw/deposit and a separate service coordinates the two calls, even without the transactional detail.

for a middle

Should explain the aggregate-boundary reasoning for why Account shouldn't reference another Account directly.

for a senior

Should proactively raise the atomicity/failure-handling trade-off and propose a concrete answer (single transaction vs. saga) appropriate to the system's topology.

for a principal

Should reason about this pattern at the level of bounded-context and infrastructure design - when a single-transaction shortcut is acceptable versus when it demands an explicit saga/compensation architecture, and the operational cost of getting it wrong.

## Where the responsibility line falls The core design tension in this scenario is where to draw the responsibility line between 'what one `Account` entity protects on its own' and 'what needs a coordinator that understands both accounts at once.' An `Account` aggregate's job, in DDD terms, is to guard its own invariants - rules that must always hold true about its own state, like 'balance never goes negative.' That's naturally expressed as two methods on `Account` itself: - `withdraw(Money amount)`, which checks the balance is sufficient and decrements it - and `deposit(Money amount)`, which increments it (and perhaps checks a maximum-balance rule) Neither of these needs any awareness that a transfer is happening; each just protects its own aggregate's state, which is exactly the level of responsibility an aggregate is supposed to have. ## Why the transfer itself belongs elsewhere The transfer itself, though, is a rule that spans two aggregates: 'the source account must have sufficient funds' is meaningful only in the context of the pairing, and the action of moving money is really one business event, not two independent ones that happen to occur together. If you tried to express this as `Account.transferTo(Account target, Money amount)`, the source `Account` would need a live reference to another aggregate root and would be responsible for calling mutating methods on it: - this **violates the standard DDD guidance** that aggregates should reference each other by identity (an `AccountId`) rather than by direct object reference, precisely so that one aggregate never reaches in and mutates another's internals directly - it also means the `Account` entity's **test surface and reasoning burden balloon**: instead of testing 'withdraw enforces sufficient funds' in isolation, you'd have to test the two-account interaction as part of Account's own unit tests, which muddies what the entity is actually responsible for ## The cleaner design The cleaner design keeps `Account` narrow (withdraw and deposit, each enforcing only its own local rule) and introduces a `FundsTransferService` domain service that is given both `Account` aggregates (loaded by the caller, typically an application service) and the amount, and it does exactly three things: 1. check the cross-account precondition (source has sufficient funds) 2. call `source.withdraw(amount)` 3. then call `target.deposit(amount)` This is a textbook example of the pattern - it's stateless, named after the exact business process it performs, and holds the one piece of logic ('sufficient funds, across this specific pairing') that doesn't belong to either account individually. ## The trade-off: atomicity and failure handling The trade-off this design introduces is around atomicity and failure handling. In DDD's typical guidance, a single transaction should touch at most one aggregate, because that's the unit the aggregate boundary is designed to protect. A transfer necessarily mutates two aggregates, so something above the pure domain-service call has to decide how the two mutations become consistent together. | Where it runs | How the two mutations are made consistent | |---|---| | **In a single-database monolith** | the pragmatic and common answer is to wrap both saves in one database transaction managed by the calling application service - which technically bends the one-aggregate-per-transaction guideline but is an accepted, well-understood compromise for two aggregates in the same bounded context and the same database | | **In a distributed system** | where the two accounts might live in different services or databases, that shortcut isn't available, and the transfer has to be modeled explicitly as a multi-step process with compensation: withdraw first, then attempt deposit, and if the deposit fails, run a compensating action that credits the amount back to the source - the saga pattern - or use an eventually-consistent approach where the transfer request is recorded as an event and the two mutations are applied asynchronously with reconciliation for failures | ## The failure mode to watch for The failure mode to watch for in either version is a transfer service that performs the withdrawal and the deposit as two entirely separate, uncoordinated operations with no shared transaction and no compensation logic - if the process crashes between the two steps, or the second call throws, money vanishes from the source account without ever appearing in the target, which is exactly the kind of defect that's invisible in happy-path testing and expensive when it surfaces in production (a customer support ticket about a missing balance, reconciled by hand). A well-designed `FundsTransferService` makes this failure mode visible in its own structure: it either wraps both mutations in one transaction when that's available, or it explicitly documents and implements the compensating step when it isn't, rather than silently hoping both calls succeed. This exact scenario - fund transfer as the paradigm case for a domain service - originates in Eric Evans' original description of the pattern and remains one of the clearest illustrations of why domain services exist at all: some business rules are inherently about a relationship between two things, not a property of either one alone.

  • Should the FundsTransferService itself manage the database transaction boundary?
    Typically no - transaction management is usually the responsibility of the application service layer that calls the domain service, since transactions are an infrastructure/orchestration concern. The domain service stays focused on the business rule and the sequence of domain operations, while the caller decides how those operations map to a database transaction.
  • What happens if the withdrawal succeeds but the deposit fails due to a validation rule on the target account, like a maximum balance cap?
    The whole operation must be rolled back or compensated so the source account isn't left short: in a single-transaction setup the database rollback handles this automatically, while in a distributed setup the service (or an orchestrating saga) has to explicitly credit the amount back to the source and communicate the failure to the caller.
  • Why shouldn't the Account entity hold a direct object reference to another Account for a transfer?
    Direct references between aggregate roots let one aggregate reach in and mutate another's state, which breaks the isolation an aggregate boundary is meant to provide and makes it hard to reason about or test either aggregate independently; referencing by ID (AccountId) instead keeps aggregates decoupled and lets a coordinating service load only what it needs, when it needs it.

It's like a bank teller (the domain service) moving cash between two customers' safe-deposit boxes: each box owner controls what goes in or out of their own box, but only the teller, standing between both boxes, enforces the rule 'don't hand out cash you don't have before you put it in the other box.'

saying these in an interview costs you the question

  • Puts transferTo(otherAccount, amount) directly on the Account entity with a live object reference to the other account
  • Assumes the transfer is automatically atomic without naming how the two mutations are made consistent
  • Has no answer for what happens if the deposit step fails after the withdrawal succeeded
  • Thinks aggregates should hold direct references to each other rather than by identity
  • Can't articulate why 'sufficient funds' is a cross-account rule rather than a property of the source account alone

context