Backend · Reliability
Two Accounts, One Transaction,
Five Different Answers
Writing two Aggregates atomically, in a single transaction, looks like a solved problem in every framework. The mechanism is different in each one, and so is the place it breaks without a sound. A transfer between two Accounts needs exactly that, and building it in five languages gave five answers. One language had that mechanism fully working. One had a naive fix sitting one edit away from a silent regression. One had a doc contradicting its own code. One had never needed the capability at all, until this feature made it the first caller.
The code is my example project, which implements the same backend design (DDD, CQRS, Outbox) in five languages side by side. One known gap had been sitting there for a while: Go had no multi-Repository transaction propagation, tracked, unresolved. A recurring-transfer feature had already validated the design in an earlier experiment, and been thrown away afterward, because that experiment's copy of the code was disposable and the main branch had no use case that needed it yet. Building an account-to-account transfer for real, across all five languages rather than just Go, gave every language's transaction mechanism a production caller, in more than one case its first.
The shape was the same everywhere: POST /accounts/{sourceId}/transfer, and a TransferEligibilityService that checks same-account, both accounts' active status, currency match, and sufficient balance, fully, on both sides, before either account is touched, so a rejection can never leave one side withdrawn with the other side not yet deposited. A rejection reuses the same error withdraw/deposit already throw for that condition, not a new one, since Transfer has no persisted aggregate of its own to record a rejected state on. No new table, either: two correlated transaction rows, one withdrawal and one deposit, share a single fresh id as their reference_id, with no suffix. That was deliberate, after an earlier experiment's suffixed id overflowed a VARCHAR(36) column and this feature had no interest in repeating it.
NestJS: The One That Already Worked
NestJS had a working TransactionManager built on AsyncLocalStorage, already wired and already used elsewhere. Zero infrastructure changes; both saveAccount calls just needed wrapping in one .run().
Go: A Regression Waiting One Edit Inside the Obvious Fix
Go's internal/infrastructure/database/ (WithTx, TxFromContext, QuerierFrom, Manager) got built for real, closing that gap. The obvious next step looked simple: make SaveAccount always fetch its querier through QuerierFrom. It would have broken every existing single-account caller's atomicity without a sound, because QuerierFrom returns the raw *sql.DB whenever there's no transaction already on the context, turning what used to be one atomic write (account row, transaction row, outbox row together) into three separately auto-committed statements. The fix has SaveAccount check TxFromContext itself and decide whether it owns the commit, with the SQL body extracted into a shared private function so both paths, the new transfer call and every pre-existing single-account call, run the same code:
func (r *AccountRepository) SaveAccount(ctx context.Context, a *account.Account) error {
if tx, ok := database.TxFromContext(ctx); ok {
// An ambient transaction already owns the commit — just run inside it.
return r.saveAccount(ctx, tx, a)
}
// No ambient transaction: this call owns its own commit, exactly as it always did.
return database.WithTx(ctx, r.db, func(tx *sql.Tx) error {
return r.saveAccount(ctx, tx, a)
})
}A second version of the same shape of mistake showed up in the same change: an early draft cleared the in-memory pending-transaction and pending-event buffers before confirming the transaction committed. If a commit failed after that clear, every existing caller's retry path would have lost data it thought it still had. Caught before it landed, by gating the clear on confirmed commit success rather than on the write call simply returning.
Neither Go bug lived in the transfer feature's own code. Both lived in what a plausible-looking rewrite would have done to callers that already existed and already worked. Adding shared infrastructure under an established function is the moment every one of its existing callers is retested, whether anyone remembers to think of it that way or not.
Java and Kotlin: The Same Shape, and a Doc That Had Been Wrong to Itself
Both added AccountRepository.saveAccounts(source, target) with @Transactional at the Repository boundary, matching how the rest of each codebase already did it, and extracted a shared private saveAccountInternal so the new two-account path and the existing single-account path share one implementation. Deciding where @Transactional belongs forced a doc to be read closely enough to notice it disagreed with itself. Java's own design-principles.md said the annotation belongs on the Command/Query Service, directly contradicting persistence.md's explicit warning that putting it back there is a regression, and contradicting the code, which had it on the Repository the whole time. The design-principles line was wrong, not the code; fixed to match reality.
Kotlin's persistence.md had its own version of the same problem: an illustrative, never-implemented code sample showing @Transactional on a hypothetical Service-level TransferService, following Java's incorrect doc rather than Kotlin's own Repository-level convention. With the feature now real, the plan was to replace that illustrative snippet with the now-implemented code.
FastAPI: The Gap Nothing Had Ever Exercised
No new Repository method was even needed. A shared AsyncSession cached per request via Depends already makes two save_account calls atomic by construction. What surfaced instead was a latent gap in get_session(): no except, no rollback, on exception. Nothing had ever needed it, because nothing before this feature had saved two different Aggregate instances in the same request. Transfer is the first handler in the codebase to make that missing rollback load-bearing rather than theoretical.
Saying a Fix Landed Isn't the Same as It Landing
A follow-up documentation audit, run the same day specifically to look for anything the feature had made false, turned up nine stale-doc issues across all five languages, mostly docs that had described the pre-transfer state as current, now falsified by the feature shipping. One of the nine was uncomfortable in a different way. Kotlin's persistence.md fix (the one described two sections up, replacing the illustrative snippet with the implemented code) had been written down as done in the work's own summary. The edit had never happened. It surfaced only because a separate audit pass re-read the file afterward instead of trusting the earlier narration.
check_docs_drift.py reported zero findings the entire time. It's a path-existence checker, structurally blind to a doc's prose being wrong about what a file contains, as opposed to whether the file exists. What found the nine issues was grepping every language's docs for the "doesn't exist yet" phrasing those docs kept reusing and manually checking each hit against current reality — the same method, run one level more skeptically, catching not just what the feature had changed but what a summary had merely claimed to change.
One requirement, five already-different transaction conventions, and in every language but one the riskiest part wasn't writing the new code. It was what the new code, sitting next to the old code, revealed the old code had never been tested against.
transaction.go (Go's transaction manager implementation, in backend-service-playbook, my example project that implements the same backend design in five languages side by side) · docs/architecture/persistence.md (the root transaction-boundary principle every language's version answers to)