Two Aggregates, One Method
Wallet.TransferFunds mutates a second aggregate root inside the first one's method - the textbook DDD violation, committed for a good reason. The reason survives the domain layer and does not survive the handler.
“One transaction, one aggregate” is the rule everyone quotes and nobody enjoys. It is the reason a bank transfer in a well-behaved DDD codebase becomes a saga: debit the sender, publish an event, credit the receiver, hope, compensate. Every one of those steps is a place where the money can be somewhere neither wallet claims it, and the machinery you build to close those windows is larger than the feature.
Part 3 left the wallet with a range check it can walk through. This part is about the method that breaks the more famous rule, and — unusually — does it well.
The violation
public IReadOnlyCollection<Transfer> TransferFunds(Wallet receiver, Amount amount, DateTime createdAt)
{
var outTransferId = new TransferId();
var inTransferId = new TransferId();
var outTransfer = DeductFunds(outTransferId, amount, createdAt,
metadata: GetMetadata(outTransferId, receiver.Id));
var inTransfer = receiver.AddFunds(inTransferId, amount, createdAt,
metadata: GetMetadata(inTransferId, Id));
return new List<Transfer> { outTransfer, inTransfer };
static TransferMetadata GetMetadata(TransferId referenceId, WalletId walletId)
=> new($"{{\"referenceId\": \"{referenceId}\", \"walletId\": \"{walletId}\"}}");
}
(src\Modules\Wallets\Inflow.Modules.Wallets.Core\Wallets\Entities\Wallet.cs, branch master.)
Wallet is the only AggregateRoot<T> in the module. This method takes a second one as a parameter and mutates it. That is the violation, stated plainly and without hedging.
Now the defence, because it is a real one.
Conservation of value becomes a property of the compiler rather than of your infrastructure. The debit and the credit are two adjacent statements with no branch, no await, no message, and no failure mode between them. There is no state of the program in which the sender has lost the money and the receiver has not gained it, because there is no instruction between the two calls at which the process can be interrupted in a way that produces one without the other. The saga version of this feature has at least four such states and needs a compensation path for each.
The order is right, and the order is load-bearing. DeductFunds runs first, and DeductFunds is the operation that can refuse — its insufficient-funds guard is the only real money invariant in the estate. Had the credit been written first, an overdrawn sender would have left the receiver mutated in memory before the throw. The version in the repository fails before it has changed anything on the receiver.
And the author knows the orthodox move. One file away, WalletConfiguration maps the wallet-to-owner relationship as builder.HasOne<Owner>().WithMany().HasForeignKey(x => x.OwnerId) — no navigation property on either side, aggregates referenced by id only, exactly by the book. The rule was not forgotten in TransferFunds. It was spent.
Where the guarantee stops
The domain method hands TransferFundsHandler a pair of transfers that are, by construction, balanced. Here is what the handler does with them:
var now = _clock.CurrentDate();
var transfers = ownerWallet.TransferFunds(receiverWallet, amount, now);
var outgoingTransfer = transfers.OfType<OutgoingTransfer>().Single();
var incomingTransfer = transfers.OfType<IncomingTransfer>().Single();
await _walletRepository.UpdateAsync(ownerWallet);
await _walletRepository.UpdateAsync(receiverWallet);
await _messageBroker.PublishAsync(new IMessage[]
{
new FundsDeducted(/* ... */),
new FundsAdded(/* ... */)
}, cancellationToken);
(Wallets.Application\Wallets\Commands\Handlers\TransferFundsHandler.cs.)
Two UpdateAsync calls. And in this estate, UpdateAsync is not a staging operation:
public async Task UpdateAsync(Wallet wallet)
{
_wallets.Update(wallet);
await _context.SaveChangesAsync();
}
Every repository method in both modules calls SaveChangesAsync itself. So the debit is flushed to PostgreSQL, and then, as a separate round trip, the credit is. The in-memory atomicity that TransferFunds worked so hard for is handed to persistence as two writes.
That is fine — provided something outside wraps them in a transaction. The estate has exactly the machinery for it:
public async Task ExecuteAsync(Func<Task> action)
{
await using var transaction = await _dbContext.Database.BeginTransactionAsync();
try
{
await action();
await transaction.CommitAsync();
}
catch (Exception)
{
await transaction.RollbackAsync();
throw;
}
}
(Shared.Infrastructure\Postgres\PostgresUnitOfWork.cs.)
TransactionalCommandHandlerDecorator<T> resolves a unit-of-work type by the command's module name and runs the handler inside that. WalletsUnitOfWork exists, is registered, and is entered into UnitOfWorkTypeRegistry at boot by AddUnitOfWork<WalletsUnitOfWork>(). Everything is built and wired.
And the decorators are never installed. The registration is a single extension method:
public static IServiceCollection AddTransactionalDecorators(this IServiceCollection services)
{
services.TryDecorate(typeof(ICommandHandler<>), typeof(TransactionalCommandHandlerDecorator<>));
services.TryDecorate(typeof(IEventHandler<>), typeof(TransactionalEventHandlerDecorator<>));
return services;
}
A repository-wide search for AddTransactionalDecorators on master returns exactly one hit: the line above, its own declaration. No module calls it, and the shared infrastructure's own AddPostgres() does not. Nothing in this estate runs inside a database transaction. The unit-of-work registry is populated at startup and never read, because the only code that would read it is not in the container.
I want to flag this explicitly, because my own source notes for this series recorded the opposite — that atomicity here “rests entirely on the outer decorator”. The code disagrees with the note, and the code wins. The decorator would have provided the guarantee if it were registered; it is not, and a transfer is therefore two independent commits with a window between them. A process crash in that window leaves the sender debited and the receiver uncredited, with no outbox record either, because the outbox ships disabled too.
What the rule is actually for
It is worth asking what “one transaction, one aggregate” is protecting, because the answer decides how bad the violation is here.
The rule exists for two reasons. The first is consistency boundaries: an aggregate is the unit within which invariants hold synchronously, and touching two means you are asserting an invariant that spans them — which, for a transfer, is exactly what you want to assert. The second is contention: in a system where aggregates might live in different stores, different shards, or different services, a two-aggregate write is a distributed transaction wearing a method call.
Neither concern applies with full force in this module. Both wallets live in the same PostgreSQL schema, behind the same DbContext, in the same process. There is no store boundary to cross. The invariant being asserted — value in equals value out — is a genuine business rule that has no natural home in either wallet alone. When both aggregates are in one store, the one-aggregate rule is guidance about contention, not a correctness law, and trading it for compiler-guaranteed conservation is a defensible bargain.
The concern that does apply is contention, and it arrives via the concurrency token. Each Wallet carries a Version marked IsConcurrencyToken(), so each of the two writes can fail independently with a DbUpdateConcurrencyException if another request touched that wallet in between. Inside a transaction that is fine — the whole thing rolls back. Outside one, a concurrency failure on the second wallet leaves the first already committed, and the sender has lost money that the receiver never gained. The busier the receiving wallet, the likelier it is. A popular destination wallet is precisely the one most likely to be updated concurrently, which makes this the failure mode that shows up first under load and never in a demo.
What that costs, precisely
Being fair about severity: for a teaching repository with no deployment, “no transactions” is a shipped default, not an incident. It is also the single most consequential thing I found in the module, and it is invisible from every file you would look at while reading the feature. TransferFunds looks atomic. TransferFundsHandler looks like it is inside a unit of work. WalletsUnitOfWork exists. Only the absence of one call site anywhere in the solution tells you otherwise, and absences are the hardest thing to see in a code review.
Two smaller notes from the same method, both worth a line:
The return type flattens a known pair into a bag. TransferFunds statically knows it produced one OutgoingTransfer and one IncomingTransfer, returns IReadOnlyCollection<Transfer>, and the handler immediately unflattens it with two .OfType<T>().Single() calls. A record TransferPair(OutgoingTransfer Out, IncomingTransfer In) costs one line and removes two runtime searches and two possible exceptions from the caller.
The currency check lives entirely in the handler. TransferFundsHandler rejects a mismatch between the command's currency and either wallet; Wallet.TransferFunds never looks at receiver.Currency at all. Since receiver.AddFunds stamps the transfer with the receiver's currency, a caller who reached the domain method directly could move 100 GBP out of one wallet and into a wallet holding PLN, at par. Nothing in the estate does that today — but the invariant that prevents it is in the application layer, guarding a domain method that would otherwise happily perform an exchange at a rate of one. There is no rate table, no rate provider and no ExchangeRate type anywhere in the repository, which makes this the right place for the rule and the wrong layer for it.
The rule of thumb I take from TransferFunds: breaking the one-aggregate rule buys you in-memory atomicity, and in-memory atomicity is worth nothing unless the write path preserves it. If you are going to spend the rule, spend it inside a transaction — otherwise you have paid the modelling cost and kept the failure mode.
There is one more thing wrong with those two transfers, and it is in the metadata string the method builds for each of them. Next, the reference that points at itself.