You're modeling a 'FundsTransfer' operation between two Account aggregates in a banking domain. Walk through the reasoning for why this operation should become a domain service rather than a method on the Account entity, and what the domain service's method signature and statelessness constraints should look like.
answer
- no single aggregate owns it
- one transaction = one aggregate rule
- stateless = pure function of inputs
- delegates invariants back to entities
- service doesn't call repository.save itself
basics
~20 sNeither Account owns a transfer between two accounts, so it doesn't fit as one entity's method. A stateless domain service takes both accounts and the amount, applies debit/credit rules, and returns the results — holding no data between calls.
solid answer
~40 sA transfer touches two separate aggregates, and DDD's guideline is that one transaction should modify one aggregate instance at a time for consistency — so 'transfer' can't cleanly be a mutating method on one Account reaching into another. A domain service like FundsTransferService.transfer(source, target, amount) holds the coordination rule: validate sufficient balance, call source.withdraw(amount) and target.deposit(amount) — each still enforcing its own aggregate invariants — and return the updated accounts. The service itself holds no state between calls and doesn't call repository.save(); the calling application service loads both accounts, invokes the domain service inside one transaction, then saves both and commits. This keeps persistence and transaction boundaries in the application layer and makes the transfer logic trivially unit-testable in isolation.
go deeper
Can recognize that a transfer touches two accounts and isn't obviously one account's job.
Can name the domain service as the right pattern and describe passing both aggregates in as parameters.
Can explain the one-aggregate-per-transaction rule as the reason, keep persistence out of the domain service, and reason about statelessness/testability trade-offs.
Can compare the domain-service (same-transaction) approach against an event-driven double-dispatch alternative and articulate when each is appropriate, including cross-bounded-context scenarios.
## Two concepts to unpack The account-transfer example is one of the canonical illustrations of when a domain service is the right tool, and unpacking why requires two DDD concepts: - **aggregate boundaries** - **business logic ownership** ## Aggregate boundaries An aggregate is a cluster of objects (here, a single `Account` with its balance and transaction history) treated as one consistency boundary — the rule of thumb in tactical DDD is that a single transaction should load and modify exactly one aggregate instance, because aggregates are the unit across which invariants are guaranteed to be enforced atomically, and letting one transaction mutate two aggregate instances directly makes it easy to violate that guarantee under concurrent access. Given that rule, a transfer between `Account` A and `Account` B genuinely can't be "a method on `Account`" in the naive sense, because: - it needs to mutate two aggregate instances as a coordinated pair - and it isn't obviously A's responsibility versus B's; there's no principled reason to hang the whole operation off one side ## The domain service that owns the operation This is exactly the situation a domain service exists for: a business operation that is real domain logic (it enforces rules — sufficient funds, maybe a daily transfer limit, maybe currency conversion) but doesn't have a single natural owner among the entities/aggregates involved. So you introduce `FundsTransferService` whose method takes both aggregates plus the transfer amount as explicit parameters: `transfer(source: Account, target: Account, amount: Money): TransferResult`. Inside, the service still delegates as much as possible to the aggregates themselves: - it calls `source.withdraw(amount)`, which enforces the aggregate's own invariant (can't go below zero, or below an overdraft limit) and throws or returns a failure if violated - and `target.deposit(amount)`, which enforces target-side invariants (e.g., account must be active, not frozen) The domain service's own added logic is the coordination and any transfer-specific rule that isn't naturally either account's business — for instance, "a transfer above a certain amount requires additional compliance flagging" is a rule about the transfer as a concept, not about either account individually, so it lives in the service. ## Statelessness, and the two reasons for it Statelessness is the other half of the design. A domain service should hold no mutable instance fields that persist between calls — every call is a pure function of its explicit arguments (plus any constant configuration injected at construction, like a `PricingPolicy` object, which is fine as long as it doesn't change per-call). This matters for two operational reasons. - **First, thread safety**: if the service is a long-lived singleton and it held per-request state, concurrent requests would corrupt each other's data; statelessness sidesteps this entirely, no locking needed. - **Second, testability**: because the only inputs are the explicit parameters and the only outputs are the return value (plus mutations to the passed-in aggregates), a unit test can construct two in-memory `Account` instances, call `transfer`, and assert on their resulting balances — no database, no framework context, no mocking required. This is a meaningful trade-off versus embedding the transfer logic in a repository or application service that already has a database connection open: the domain service version is slightly slower to write initially (you have to explicitly pass everything in) but dramatically faster and more reliable to test, and it's reusable from any orchestration context — a REST endpoint, a scheduled batch job, or an event handler — without duplicating logic. ## Who actually persists the two accounts Who actually persists the two accounts is the important boundary to get right: the domain service returns the mutated (or new) aggregate instances but does not call a repository's save method itself. That's the calling application service's job: 1. it loads both accounts via their repositories 2. calls `fundsTransferService.transfer(source, target, amount)` inside a single database transaction 3. then saves both accounts and commits This keeps the domain service infrastructure-free and keeps the transaction boundary exactly where it belongs — in the application layer, wrapping both the domain-service call and the two saves as one atomic unit of work. ## The failure mode to watch for The failure mode to watch for: teams sometimes let the domain service reach into a repository directly ("let me just load the target account by ID inside the transfer service to save the caller a lookup"), which blurs the transaction boundary — now the service is doing I/O the application service doesn't control, and it becomes hard to guarantee both accounts are saved in the same transaction, or to unit-test the service without a real or mocked repository. A real-world instance of this exact pattern shows up in most textbook explanations of DDD tactical patterns — Eric Evans' original Domain-Driven Design book uses funds transfer as its running domain-service example precisely because it cleanly demonstrates the "operation with no natural owning entity" case.
- Should FundsTransferService.transfer() call accountRepository.save() itself?No — persistence should stay in the application service that opened the transaction, so both account saves happen atomically within one unit of work. If the domain service saved internally, the two accounts could end up persisted separately or partially, breaking the atomicity guarantee the transaction boundary exists to provide.
- What happens if you skip the domain service and just put the debit/credit logic directly in the application service?You'd get a transaction-script style method mixing orchestration (transaction, loading) with business rules (balance checks, limits) in one place, which is harder to unit test in isolation (you'd need a database or heavy mocking) and harder to reuse if a second use case (e.g., a scheduled recurring transfer job) needs the exact same transfer rule.
- Could you instead model this with a domain event and double-dispatch, e.g. account.initiateTransferTo(otherAccount, amount)?Yes, some teams model cross-aggregate coordination as a method on one aggregate that emits a domain event another aggregate's handler reacts to (eventual consistency), avoiding a domain service entirely; this trades immediate consistency for eventual consistency and adds event-handling infrastructure, so it's a legitimate but different design choice, typically preferred when strict same-transaction atomicity isn't required or achievable, such as across bounded contexts.
A domain service here is like a referee at a trade negotiation between two independent parties (the accounts) — the referee enforces the rules of a fair trade (sufficient funds, limits) but doesn't live inside either party's own rulebook, and the referee doesn't personally file the paperwork afterward (persistence) — that's someone else's job.
saying these in an interview costs you the question
- Suggests putting the whole transfer as a mutating method directly on one Account that reaches into another Account instance
- Thinks the domain service should call repository.save()
- Doesn't mention the one-transaction-per-aggregate consistency rule
- Assumes domain services can safely hold mutable per-call state as instance fields