Enterprise Transactions
Purpose
Put the transaction boundary where the business's unit of work is, and be explicit about
what happens at every edge that boundary cannot cross. Most production transaction bugs are
not exotic: the boundary is in the wrong layer, it silently did not start, it covers work
that should have been outside it, or it is expected to cover work no transaction can reach.
Where the boundary belongs
Controller / consumer / job usually outside; may own a boundary for a
message/job unit when it is the use-case entry
│
Application service (use case) common boundary for business atomicity
│
Domain unaware of transactions
│
Repository / mapper usually participates; may own a local operation
Keep the business atomic unit within one transaction. Repository-local transactions alone
do not combine several calls atomically. A controller boundary may include unnecessary
work, but interception normally covers the method call, not automatically all request
parsing or later response rendering. Inspect the actual call and resource lifecycle.
Inspect the target JDK, Spring/provider versions, transaction manager, datasource routing,
database engine/isolation and proxy mode before applying examples. This skill's Java snippets
are partial imperative examples, not a complete application; they do not describe reactive
transaction-context propagation. Do not upgrade the project to match an example.
Workflow
- State the unit of work in business terms. "Reserve stock and record the order" is
one; "record the order and email the customer" is not — the email is not transactional
and pretending otherwise is where the bug will be.
- Prefer the application service for business atomicity, while allowing repository-local
read/write operations and listener/job entrypoints to demarcate when they are the actual unit.
- Push non-transactional work out. Network calls, message publication, file writes,
long computations and anything waiting on a human. Each of those inside a transaction
can extend acquired connection/lock occupancy; lazy acquisition and read-only work differ.
- Choose isolation deliberately, once, and record why if it is not the default.
Raising isolation to fix a specific race is legitimate; raising it globally because a
race exists somewhere is how throughput disappears.
- Verify rollback actually happens for the failures you care about. By default Spring's
transaction interceptor rolls back on
RuntimeException/Error, not checked exceptions.
Self-invocation in proxy mode does not apply the inner method's transaction attributes; it may
still execute inside the caller's existing transaction.
- Identify every enlisted resource and external effect. A local transaction does not
cover an ordinary remote API. Choose durable recovery or a supported distributed
transaction from the actual contract (
distribution-boundaries).
Return the atomic unit, actual transaction entry/exit and participating resources, the
failure or race being addressed, and the check proving the intended commit/rollback outcome.
When runtime evidence is missing, name the integration test needed instead of claiming
that an annotation proves atomicity.
Decision rules
Two or more writes to one database that must both happen or neither
→ one transaction, demarcated at the use case. Straightforward.
A write plus a message or an HTTP call to another system
→ not atomic under an ordinary local transaction. Choose: outbox (write the intent in the
same transaction, relay after commit), or make the remote call
idempotent and retry, or compensate. Publishing inside the
transaction does not enlist the remote effect. Explicit XA participation differs.
A read-only query or a report
→ choose snapshot/consistency needs first. readOnly is a provider-dependent
hint, not portable write enforcement or automatic replica routing.
A long batch over many rows
→ many transactions, one per chunk, with restartability. One
transaction over a million rows holds locks and undo for its
duration and rolls back the whole unit on failure. Chunking requires
accepting partial progress and recording durable checkpoints.
A lock must survive a user's thinking time
→ do not keep a database transaction open across human delay.
Use an offline concurrency protocol (offline-concurrency-control).
A race that isolation could fix (lost update, phantom)
→ prefer a targeted mechanism: a unique constraint, a version
column, SELECT ... FOR UPDATE on the one path. Global isolation
escalation costs every other path.
Nested use cases where the inner must survive the outer's rollback
→ REQUIRES_NEW, deliberately, knowing it takes a second
connection and can deadlock against the outer transaction.
Rules
- A transaction is not a concurrency design. It gives atomicity and an isolation level;
it does not automatically validate a stale observation from an earlier transaction, and it does not
make an operation safe to retry (
offline-concurrency-control, idempotency).
- Measure transaction duration alongside acquired connections, locks and retained versions.
Long transactions can exhaust pools or delay other work; establish the mechanism from
pool/lock/transaction evidence before diagnosing "the database is slow"
(
architecture-and-performance).
- Avoid holding a database transaction across a network call because timeout and retry behavior
extend lock/connection occupancy. Where correctness requires validation under a lock and no
non-atomic redesign is acceptable, bound the call, model pool/lock capacity and test failure;
document the deliberate coupling.
- Rollback rules are a contract you must inspect. Spring's ordinary default rolls back on
unchecked exceptions and
Error, not checked exceptions. Explicit rules and configured
defaults can override this; Spring 6.2+ supports an all-exceptions default. A checked
exception can leave work committed if no rollback rule or rollback-only state prevents it.
- Self-invocation bypasses interception in Spring's default proxy mode: the callee inherits whatever
transaction context the caller already has, but its own propagation/isolation/rollback attributes
are not applied. Method visibility/finality constraints depend on JDK versus class proxies and
Spring version;
static methods are not instance-proxied. AspectJ mode differs.
readOnly = true is not portable enforcement. Spring/provider integrations may adjust flush mode
and pass a JDBC read-only hint; replica routing requires separate routing configuration. Treat it as an optimisation
and a documentation of intent, never as a safety mechanism.
- Isolation levels are defined by the anomalies they prevent, not by intuition, and
engines interpret them differently — notably,
REPEATABLE READ means different things in
different databases, and SERIALIZABLE is implemented by locking in some and by
optimistic conflict detection with retry in others. Test the behaviour, do not assume it.
- A local transaction is not a distributed transaction. XA/two-phase commit can coordinate enlisted
resources but adds coordinator/recovery coupling and can block during failures. Sagas/outboxes
trade immediate atomicity for explicit intermediate states and idempotent recovery; select from
actual resource support and consistency requirements.
@Transactional around one repository call can still document application semantics, configure
isolation/read-only/timeout, or remain stable as orchestration grows. Remove it only when its
behavior is truly identical to the repository boundary and the convention is clear.
- If an inner participating boundary marks the shared transaction rollback-only, catching
its exception does not restore commitability. The outer commit attempt can raise
UnexpectedRollbackException; verify persisted state from outside that transaction.
References
- Boundaries and propagation — the propagation
modes with what each actually does to connections and rollback, self-invocation and the
other silent no-ops, batch chunking, the outbox at a network edge, and how to verify at
runtime which transaction a piece of code ran in. Read when demarcating, or when a
rollback did not happen.
- Isolation, anomalies and recovery — the anomaly
ladder stated as what a client can observe, what each level costs in blocking or in retry,
engine differences that break portable assumptions, deadlock and serialisation-failure
handling, and choosing between isolation and a targeted mechanism. Read when a race is
being fixed or an isolation level is being changed.
1---2name: enterprise-transactions3description: Transaction boundaries as an architectural decision: where a transaction starts and ends, what isolation level actually buys, how propagation and rollback rules behave in practice, the costs of spanning a network call or a user's thinking time, and how to handle effects outside its atomic scope. Use when a use case writes twice and nobody can say whether it is atomic, when @Transactional sits on a repository or a controller, when a transaction stays open across an HTTP call or a message publish, when a rollback did not happen because the exception was checked or the call was self-invoked, when isolation is being raised to fix a race, when a read-only flag is added without knowing what it does, when a long-running batch holds locks, or when a transaction is expected to cover two services. Does not cover locks held across user think time (offline-concurrency-control), what a client may observe across replicas (consistency-models), repeat-safety of an operation (idempotency), or database-specific lock behaviour.4---56# Enterprise Transactions78## Purpose910Put the transaction boundary where the business's unit of work is, and be explicit about11what happens at every edge that boundary cannot cross. Most production transaction bugs are12not exotic: the boundary is in the wrong layer, it silently did not start, it covers work13that should have been outside it, or it is expected to cover work no transaction can reach.1415## Where the boundary belongs1617```text18Controller / consumer / job usually outside; may own a boundary for a19 message/job unit when it is the use-case entry20 │21Application service (use case) common boundary for business atomicity22 │23Domain unaware of transactions24 │25Repository / mapper usually participates; may own a local operation26```2728Keep the business atomic unit within one transaction. Repository-local transactions alone29do not combine several calls atomically. A controller boundary may include unnecessary30work, but interception normally covers the method call, not automatically all request31parsing or later response rendering. Inspect the actual call and resource lifecycle.3233Inspect the target JDK, Spring/provider versions, transaction manager, datasource routing,34database engine/isolation and proxy mode before applying examples. This skill's Java snippets35are partial imperative examples, not a complete application; they do not describe reactive36transaction-context propagation. Do not upgrade the project to match an example.3738## Workflow39401. **State the unit of work in business terms.** "Reserve stock and record the order" is41 one; "record the order and email the customer" is not — the email is not transactional42 and pretending otherwise is where the bug will be.432. **Prefer the application service for business atomicity**, while allowing repository-local44 read/write operations and listener/job entrypoints to demarcate when they are the actual unit.453. **Push non-transactional work out.** Network calls, message publication, file writes,46 long computations and anything waiting on a human. Each of those inside a transaction47 can extend acquired connection/lock occupancy; lazy acquisition and read-only work differ.484. **Choose isolation deliberately, once**, and record why if it is not the default.49 Raising isolation to fix a specific race is legitimate; raising it globally because a50 race exists somewhere is how throughput disappears.515. **Verify rollback actually happens** for the failures you care about. By default Spring's52 transaction interceptor rolls back on `RuntimeException`/`Error`, not checked exceptions.53 Self-invocation in proxy mode does not apply the inner method's transaction attributes; it may54 still execute inside the caller's existing transaction.556. **Identify every enlisted resource and external effect.** A local transaction does not56 cover an ordinary remote API. Choose durable recovery or a supported distributed57 transaction from the actual contract (`distribution-boundaries`).5859Return the atomic unit, actual transaction entry/exit and participating resources, the60failure or race being addressed, and the check proving the intended commit/rollback outcome.61When runtime evidence is missing, name the integration test needed instead of claiming62that an annotation proves atomicity.6364## Decision rules6566```text67Two or more writes to one database that must both happen or neither68 → one transaction, demarcated at the use case. Straightforward.6970A write plus a message or an HTTP call to another system71 → not atomic under an ordinary local transaction. Choose: outbox (write the intent in the72 same transaction, relay after commit), or make the remote call73 idempotent and retry, or compensate. Publishing inside the74 transaction does not enlist the remote effect. Explicit XA participation differs.7576A read-only query or a report77 → choose snapshot/consistency needs first. readOnly is a provider-dependent78 hint, not portable write enforcement or automatic replica routing.7980A long batch over many rows81 → many transactions, one per chunk, with restartability. One82 transaction over a million rows holds locks and undo for its83 duration and rolls back the whole unit on failure. Chunking requires84 accepting partial progress and recording durable checkpoints.8586A lock must survive a user's thinking time87 → do not keep a database transaction open across human delay.88 Use an offline concurrency protocol (offline-concurrency-control).8990A race that isolation could fix (lost update, phantom)91 → prefer a targeted mechanism: a unique constraint, a version92 column, SELECT ... FOR UPDATE on the one path. Global isolation93 escalation costs every other path.9495Nested use cases where the inner must survive the outer's rollback96 → REQUIRES_NEW, deliberately, knowing it takes a second97 connection and can deadlock against the outer transaction.98```99100## Rules101102- **A transaction is not a concurrency design.** It gives atomicity and an isolation level;103 it does not automatically validate a stale observation from an earlier transaction, and it does not104 make an operation safe to retry (`offline-concurrency-control`, `idempotency`).105- Measure transaction duration alongside acquired connections, locks and retained versions.106 Long transactions can exhaust pools or delay other work; establish the mechanism from107 pool/lock/transaction evidence before diagnosing "the database is slow"108 (`architecture-and-performance`).109- Avoid holding a database transaction across a network call because timeout and retry behavior110 extend lock/connection occupancy. Where correctness requires validation under a lock and no111 non-atomic redesign is acceptable, bound the call, model pool/lock capacity and test failure;112 document the deliberate coupling.113- Rollback rules are a contract you must inspect. Spring's ordinary default rolls back on114 unchecked exceptions and `Error`, not checked exceptions. Explicit rules and configured115 defaults can override this; Spring 6.2+ supports an all-exceptions default. A checked116 exception can leave work committed if no rollback rule or rollback-only state prevents it.117- Self-invocation bypasses interception in Spring's default proxy mode: the callee inherits whatever118 transaction context the caller already has, but its own propagation/isolation/rollback attributes119 are not applied. Method visibility/finality constraints depend on JDK versus class proxies and120 Spring version; `static` methods are not instance-proxied. AspectJ mode differs.121- `readOnly = true` is not portable enforcement. Spring/provider integrations may adjust flush mode122 and pass a JDBC read-only hint; replica routing requires separate routing configuration. Treat it as an optimisation123 and a documentation of intent, never as a safety mechanism.124- Isolation levels are defined by the anomalies they prevent, not by intuition, and125 engines interpret them differently — notably, `REPEATABLE READ` means different things in126 different databases, and `SERIALIZABLE` is implemented by locking in some and by127 optimistic conflict detection with retry in others. Test the behaviour, do not assume it.128- A local transaction is not a distributed transaction. XA/two-phase commit can coordinate enlisted129 resources but adds coordinator/recovery coupling and can block during failures. Sagas/outboxes130 trade immediate atomicity for explicit intermediate states and idempotent recovery; select from131 actual resource support and consistency requirements.132- `@Transactional` around one repository call can still document application semantics, configure133 isolation/read-only/timeout, or remain stable as orchestration grows. Remove it only when its134 behavior is truly identical to the repository boundary and the convention is clear.135- If an inner participating boundary marks the shared transaction rollback-only, catching136 its exception does not restore commitability. The outer commit attempt can raise137 `UnexpectedRollbackException`; verify persisted state from outside that transaction.138139## References140141- [Boundaries and propagation](references/boundaries-and-propagation.md) — the propagation142 modes with what each actually does to connections and rollback, self-invocation and the143 other silent no-ops, batch chunking, the outbox at a network edge, and how to verify at144 runtime which transaction a piece of code ran in. Read when demarcating, or when a145 rollback did not happen.146- [Isolation, anomalies and recovery](references/isolation-and-recovery.md) — the anomaly147 ladder stated as what a client can observe, what each level costs in blocking or in retry,148 engine differences that break portable assumptions, deadlock and serialisation-failure149 handling, and choosing between isolation and a targeted mechanism. Read when a race is150 being fixed or an isolation level is being changed.