Spring's transaction mechanisms (declarative @Transactional vs programmatic TransactionTemplate, propagation, isolation, rollback rules), external/JTA vs local transactions, precise transaction design for e-commerce (and sagas across services), wiring multiple DataSources with separate EntityManagers and TransactionManagers, zero-downtime schema migration (expand/contract), and optimising a slow complex query.
Published September 25, 2026
These are the "you've been in production" questions. The strongest answers show:
@Transactional sometimes silently does nothing;Short answer: Spring's PlatformTransactionManager abstraction (JpaTransactionManager, DataSourceTransactionManager, JtaTransactionManager, and the reactive ReactiveTransactionManager), used in two ways:
@Transactional on methods or classes. An AOP proxy begins the transaction before the method, and commits or rolls back afterwards.TransactionTemplate (or TransactionManager directly), for fine-grained boundaries inside a method.Boot auto-configures the right transaction manager for your data access stack.
Key points to cover:
REQUIRED (the default) joins an existing transaction.REQUIRES_NEW suspends the current one and starts a new one, which is useful for audit logs that must persist even if the caller rolls back.NESTED uses savepoints.MANDATORY, SUPPORTS, NOT_SUPPORTED and NEVER.READ_COMMITTED (the usual database default), REPEATABLE_READ and SERIALIZABLE.Errors. Checked exceptions commit, unless you set rollbackFor = Exception.class.readOnly = true (Hibernate skips dirty checking, and some drivers route the query to a replica), and timeout.Learn it in depth → @Transactional Deep Dive
Short answer: Both are possible:
JpaTransactionManager) are the norm, and the fastest.JtaTransactionManager, and your @Transactional code doesn't change.Key points to cover:
Learn it in depth → Two-Phase Commit
Short answer:
@Transactional for most methods, and programmatic TransactionTemplate where one method needs several independent commits.@Version), or SELECT … FOR UPDATE for stock.@Service
public class CheckoutService {
private final TransactionTemplate tx;
private final PaymentGateway payments;
// ...
public OrderId checkout(CheckoutRequest req) {
Order order = tx.execute(s -> { // transaction 1: reserve + PENDING order
inventory.reserve(req.items()); // @Version on Stock detects races
return orders.save(Order.pending(req));
});
ChargeResult charge = payments.charge(order.total(), req.paymentToken(), order.idempotencyKey()); // no transaction held
tx.executeWithoutResult(s -> { // transaction 2: final state + outbox event
if (charge.approved()) { order.markPaid(charge.reference()); outbox.add(new OrderPaid(order.id())); }
else { order.markFailed(charge.reason()); inventory.release(req.items()); }
orders.save(order);
});
return order.id();
}
}
Common trap: the source calls @Transactional "programmatic". It's declarative. Programmatic means TransactionTemplate or PlatformTransactionManager in code.
Short answer:
DataSource, LocalContainerEntityManagerFactoryBean and PlatformTransactionManager (or a JdbcTemplate).@Primary, so auto-wiring without a qualifier works.@EnableJpaRepositories(basePackages, entityManagerFactoryRef, transactionManagerRef).@Qualifier, and use @Transactional("reportingTransactionManager") for the secondary database.@Configuration
@EnableJpaRepositories(basePackages = "com.shopin.reporting.repo",
entityManagerFactoryRef = "reportingEmf", transactionManagerRef = "reportingTx")
class ReportingDbConfig {
@Bean @ConfigurationProperties("app.datasource.reporting")
DataSourceProperties reportingProps() { return new DataSourceProperties(); }
@Bean DataSource reportingDataSource() { return reportingProps().initializeDataSourceBuilder().build(); }
@Bean LocalContainerEntityManagerFactoryBean reportingEmf(EntityManagerFactoryBuilder b) {
return b.dataSource(reportingDataSource()).packages("com.shopin.reporting.domain").persistenceUnit("reporting").build();
}
@Bean PlatformTransactionManager reportingTx(@Qualifier("reportingEmf") EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
}
// The primary database gets the same trio, annotated @Primary, pointing at com.shopin.orders.*
Key points to cover:
DataSource switches off Boot's auto-configured one, so the primary must be defined explicitly too.@Transactional method using the primary transaction manager does not cover writes to the secondary database. Writes to both aren't atomic without JTA, so design for that (the outbox pattern, or idempotent retries).AbstractRoutingDataSource (a lookup key from a thread-local), usually wrapped in LazyConnectionDataSourceProxy.Short answer: Separate configuration classes, each producing its own DataSource, EntityManagerFactory and TransactionManager; @Primary on the default; @Qualifier at injection points; and separate repository packages bound through @EnableJpaRepositories. Each DataSource has its own connection pool (HikariCP), so size each pool for its own workload.
Learn it in depth → Connection Pooling
Short answer: Use expand/contract (parallel change), with versioned migrations (Flyway or Liquibase), and backward-compatible steps, so that the old and new versions of the application can run at the same time during a rolling deployment.
Key points to cover:
ADD COLUMN with a constant default is instant, but CREATE INDEX without CONCURRENTLY locks writes.Short answer: Measure first, then fix the biggest cost.
pg_stat_statements or the slow query log, and Hibernate statistics. Is it one slow query, or an N+1 pattern of many small ones?EXPLAIN ANALYZE). Look for sequential scans on large tables, bad row estimates, nested loops over big sets, sorts spilling to disk, and filtering after joins.% wildcards.OR chains or correlated subqueries with joins, EXISTS or UNION ALL.OFFSET.JOIN FETCH or @EntityGraph for N+1 problems.@Cacheable or Redis).Learn it in depth → Query Execution Plans
Q: Why doesn't @Transactional work on a private method, or when it's called from the same class?
A: It's applied by a proxy. Private methods can't be proxied (in Spring 6, with class-based proxies, only public and protected methods are intercepted), and this.method() calls bypass the proxy entirely. Move the method to another bean, or use TransactionTemplate.
Q: What happens if a REQUIRES_NEW method throws inside an outer transaction?
A: The inner transaction rolls back. If the exception propagates, the outer one rolls back too, unless the outer code catches it. If the inner method catches an exception from a REQUIRED (joined) call, the shared transaction is already marked rollback-only, and the outer commit fails with UnexpectedRollbackException.
Q: How do you avoid long-running transactions in batch jobs?
A: Process in chunks, each with its own transaction (Spring Batch chunk processing, or TransactionTemplate per batch). Use StatelessSession or clear the persistence context periodically, and avoid remote calls inside transactions.
Q: How would you do read/write splitting to a replica?
A: Use an AbstractRoutingDataSource that chooses "replica" when the current transaction is read-only (TransactionSynchronizationManager.isCurrentTransactionReadOnly()), wrapped in LazyConnectionDataSourceProxy, so the choice is made after the transaction attributes are known. Beware replication lag for read-after-write flows.