Architecture: consistency and evolution · 3 MIN READ
A use case owns its transaction, not its HTTP handler
Keep the business invariant and transaction boundary in the application service.
A transport handler can parse a request correctly while the application still commits only half of a business operation. Place the invariant in one use case and make its transaction boundary explicit. The adapter translates HTTP and errors; it does not independently commit each repository call. A Go interface alone cannot make two stores atomic.
system-design · A MAP OF THE IDEA
- Parse input
- Validate invariant
- One transaction
- Map result
Useful when
- Keep the business invariant and transaction boundary in the application service.
- Makes the owner and the failure boundary explicit.
Watch out
- A mutex can protect an in-memory invariant but cannot make process memory durable or roll back an external payment.
- Run native service and failure tests in your own development environment.
Implementation & operation cost: Additional state and tests must be justified by the demonstrated failure; this lesson is not a requirement for every Go program.
The contract and the failure
A transport handler can parse a request correctly while the application still commits only half of a business operation. Place the invariant in one use case and make its transaction boundary explicit. The adapter translates HTTP and errors; it does not independently commit each repository call. A Go interface alone cannot make two stores atomic.
Work through the example
Trace validation, ownership and commit separately. In a SQL implementation, begin once in the use case, pass the same *sql.Tx to participating operations, defer Rollback and check Commit. Do not call the original *sql.DB from a repository that was supposed to join the transaction. Keep HTTP status mapping outside the use case.
package main
import "fmt"
type Account struct{ Balance int }
func transfer(from, to *Account, amount int) error {
if from == to || amount <= 0 || from.Balance < amount {
return fmt.Errorf("invalid transfer")
}
// Single-owner, in-memory teaching model, not a database transaction.
from.Balance -= amount
to.Balance += amount
return nil
}
func main() {
a, b := Account{10}, Account{4}
fmt.Println(transfer(&a, &b, 7), a.Balance, b.Balance)
fmt.Println(transfer(&a, &b, 7), a.Balance, b.Balance)
}The first call validates before changing either account and prints balances 3 and 11. The second call fails before changing either value. This is safe only under the explicitly stated single owner; concurrent callers and crashes require a real storage transaction or an equivalent concurrency protocol.
Production decisions
A mutex can protect an in-memory invariant but cannot make process memory durable or roll back an external payment. Under concurrent SQL writes, test isolation, row predicates and lock ordering. An interface named UnitOfWork is not evidence that all its methods share the same transaction. Define whether a failed commit is known to have aborted or has an unknown outcome.
Explain it in an interview
Two repository calls must succeed together. Who owns their transaction? The application use case owns one transaction and passes its handle to every participating storage operation; HTTP mapping stays in the adapter. Describe the state before the operation, its owner, the failure between steps and the evidence you would test. Distinguish what this example demonstrates from the infrastructure a production service still needs.
TAKE THIS WITH YOU
Keep the business invariant and transaction boundary in the application service. The application use case owns one transaction and passes its handle to every participating storage operation; HTTP mapping stays in the adapter.
Check your understanding
Two repository calls must succeed together. Who owns their transaction?
Go to the source
Progress stays in this browser. No account needed.