How does defaulting to REQUIRED shape where you place transaction boundaries in a layered/modular service architecture, and what pitfalls arise from the shared-transaction model at scale?
answer
- Boundary = outermost @Transactional = the use case
- Never open tx at controller
- Tx lives as long as its slowest step -> keep DB-only
- External I/O -> outbox / AFTER_COMMIT
- Failure domains: REQUIRED core vs REQUIRES_NEW/NESTED carve-outs
basics
~20 sBecause REQUIRED joins any active transaction, the boundary is set by the outermost @Transactional call — usually the top-level service method (the unit of work). Everything it calls shares that transaction, so keep those methods focused and avoid slow I/O inside them.
solid answer
~50 sREQUIRED means the effective transaction boundary is wherever the first @Transactional is entered, so you deliberately put the boundary at the top-level use-case/service method that represents one unit of work; lower-level services and repositories stay REQUIRED and simply participate. The whole call graph then shares one Connection and one persistence context, which gives clean atomicity but means the transaction lives as long as the slowest thing inside it. Pitfalls at scale: holding the Connection across remote calls, message publishing, or long computations starves the pool and lengthens lock hold times; the rollback-only trap makes 'catch and continue' unsafe across participants; and cross-module calls that all join one transaction can blur module ownership. Principled design keeps transactions short and DB-only, pushes external I/O outside the boundary (or into an outbox committed in the same transaction), reserves REQUIRES_NEW/NESTED for explicit independent-commit or partial-rollback needs, and never opens transactions at the controller layer.
code
java · 23 lines@Service
public class CheckoutService {
private final PricingClient pricing; // remote call
private final OrderRepository orders;
private final OutboxRepository outbox;
private final ApplicationEventPublisher events;
// Do slow/remote work BEFORE opening the transaction
public OrderId checkout(CheckoutCommand cmd) {
Quote quote = pricing.quote(cmd); // no transaction held here
return persist(cmd, quote);
}
// Boundary = one use case; short, DB-only, everything below joins (REQUIRED)
@Transactional
OrderId persist(CheckoutCommand cmd, Quote quote) {
Order order = orders.save(Order.from(cmd, quote));
outbox.save(OutboxEvent.orderPlaced(order)); // side effect committed atomically
events.publishEvent(new OrderPlaced(order.id())); // AFTER_COMMIT listeners fire post-commit
return order.id();
}
}go deeper
Know the boundary should be a service method, not everywhere.
Explain that the outermost @Transactional sets the boundary and everything below joins it.
Keep transactions short/DB-only, move I/O out, and pick propagation per failure domain.
Treat transaction boundaries, failure domains, outbox/after-commit side effects, connection-pool sizing, and cross-module event modeling as first-class architectural decisions driven by REQUIRED's shared-transaction semantics.
## The boundary is implicit — own it Because REQUIRED **joins whatever is active**, the real transaction boundary is the **outermost** `@Transactional` on the call stack. If you don't consciously decide where that is, you'll get accidental boundaries. The standard discipline: - **Open the transaction at the application/service method** that represents exactly **one use case / unit of work** (e.g. `OrderService.placeOrder`). This is the demarcation point. - **Repositories and lower-level services stay REQUIRED** (the default) and simply participate — they don't each open transactions. - **Do not annotate controllers** — the web layer should not own transactions (an open-session/transaction at the web layer encourages the Open-Session-In-View anti-pattern and stretches the boundary across serialization). One boundary → one physical transaction → one Connection → one JPA persistence context for the whole use case. That's the atomicity you want. ## Consequences of 'everything shares one transaction' ### 1. The transaction lives as long as its slowest step Every participant runs on the single Connection, held from the first `@Transactional` entry until the outermost commit. Anything slow inside — a REST/gateway call, sending an email, a big computation, `Thread.sleep`, a queue publish — **holds the Connection and any DB locks** for that entire duration. At scale this causes: - **Connection-pool exhaustion** (HikariCP: threads block waiting for a Connection). - **Lock contention / lock timeouts / deadlocks** because row locks are held longer. - **Cascading latency** under load. **Design rule:** keep transactions short and **DB-only**. Do external I/O *before* opening or *after* committing the transaction. For 'must happen atomically with the DB change' side effects, use the **transactional outbox** (write an outbox row in the same transaction; a separate poller publishes) or `TransactionSynchronization`/`@TransactionalEventListener(phase = AFTER_COMMIT)` to run side effects after commit. ### 2. The rollback-only trap constrains error handling Since all participants share one transaction, a rollback-triggering failure in **any** participant marks the whole transaction rollback-only (`globalRollbackOnParticipationFailure`), and a later commit throws `UnexpectedRollbackException`. So 'call a sub-operation, catch its failure, and keep going' is **not** safe across REQUIRED participants. If you need that, the failing operation must be **REQUIRES_NEW** (own Connection) or **NESTED** (savepoint). Architecturally this means: decide your **failure domains** up front — which operations are part of the atomic unit vs. independently committed. ### 3. Module ownership blurs under one transaction In a modular monolith (e.g. Spring Modulith), a top-level method calling into several modules makes them all share one physical transaction. That's often desirable for consistency, but it couples their commit fate and their Connection. When modules should be independently consistent (or you're heading toward extraction/services), model the cross-module interaction as **events/outbox** rather than a synchronous call sharing one transaction — so each side owns its own commit. ### 4. Read-only optimization Mark query-only use-case methods `@Transactional(readOnly = true)` at the boundary. For JPA this can set the Hibernate flush mode to MANUAL (skipping dirty-check/flush) and hint the driver/replica routing. Because it must be set where the physical transaction **begins**, it belongs on the outermost method — consistent with 'own the boundary.' ### 5. Propagation choices as architecture - **REQUIRED** (default): cohesive unit of work. - **REQUIRES_NEW**: independent commit (audit/outbox/attempt logging) — but budget the extra Connection and mind self-invocation. - **NESTED**: partial rollback via savepoint within the same transaction. - **MANDATORY**: assert a caller already opened a transaction (guards a method that must never run standalone). - **SUPPORTS / NOT_SUPPORTED / NEVER**: fine-grained cases. ## Proxy realities that shape design All of this is enforced by the **AOP proxy**, so: **self-invocation** within a bean bypasses `@Transactional`; only `public` methods are advised by default; and the boundary is per-thread (a new thread/async task doesn't inherit the transaction). Architects place demarcation on cross-bean, public entry points precisely so the proxy is always in the path. ## Summary of the principled stance 1. One boundary per use case, at the service layer, not the controller. 2. Keep transactions short and DB-only; push external I/O out or into an outbox / after-commit hook. 3. Choose failure domains explicitly: REQUIRED for the atomic core, REQUIRES_NEW/NESTED for carve-outs. 4. Model cross-module consistency with events/outbox when independent commit is desired. 5. Size the connection pool for the real nesting/hold profile, and monitor for pool waits and lock timeouts.
- Why is opening a transaction at the controller layer discouraged?It stretches the transaction across request handling/serialization (Open-Session-In-View), holding the Connection and locks far longer than needed and muddying the unit-of-work boundary. Demarcate at the service method instead.
- You must publish a Kafka message only if the DB change commits. How do you do it without holding the Connection during the publish?Use a transactional outbox: write an outbox row in the same REQUIRED transaction, and let a separate poller publish after commit. Or publish in a @TransactionalEventListener(phase = AFTER_COMMIT) handler.
- In a modular monolith, when should a cross-module call NOT share the caller's transaction?When the modules should commit independently or you're preparing for extraction — model it as a domain event/outbox so each module owns its own commit rather than joining one physical transaction.
saying these in an interview costs you the question
- Putting @Transactional on controllers or annotating every repository method with its own boundary.
- Doing REST calls, messaging, or sleeps inside a transaction and holding the Connection/locks.
- Assuming 'catch and continue' works across REQUIRED participants (ignores the rollback-only trap).
- Reaching for REQUIRES_NEW everywhere without accounting for connection-pool cost.
- Relying on @Transactional across a self-invocation or a new async thread.