Backend · Reliability
Two Accounts, One Transaction,
Five Different Answers
A transfer between two Accounts needs exactly one thing: writing two Aggregates atomically, in a single transaction. One language had that mechanism fully working. One had a naive fix sitting one edit away from a silent regression. One had a doc quietly contradicting its own code. One had never needed the capability at all — until this feature made it the first caller.
One open issue 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 benchmark round — and been thrown away afterward, because the benchmark's worktree was disposable and main had no real use case that actually 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 an actual production caller — in more than one case, its first.
The shape was the same everywhere: POST /accounts/{sourceId}/transfer, 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 exact 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, sharing a single fresh id as their reference_id, with no suffix — deliberately, after an earlier benchmark'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 real 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 the open issue. The obvious next step looked simple: make SaveAccount always fetch its querier through QuerierFrom. It would have silently broken every existing single-account caller's atomicity, 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 actual fix has SaveAccount check TxFromContext itself and decide whether it owns the commit, with the real 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 actually committed. If a commit failed after that clear, every existing caller's retry path would have silently 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 exactly 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 exactly 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 real 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 actual Repository-level convention. With the feature now real, the plan was to replace that illustrative snippet with the genuine, 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 quietly 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 actually 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 real implemented code — had been written down as done in the session's own summary. The edit had never actually 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 actually found the nine issues was grepping every language's docs for this repository's own recurring "doesn't exist yet" phrasing 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 actually been tested against.
transaction.go — Go's transaction manager, the real implementation · docs/architecture/persistence.md — the root transaction-boundary principle every language's version answers to