In this article I will cover about transactions and show some examples on how to use it with Spring boot effectively. Transactions can be tricky, but let me simplify it! What are Transactions Let's skip the Alice and Bob example, it is 2026. Suppose you recently bought GTA 6 on PS5, imagine a scenario: Payments table entry is added for the purchase. Library table fails to insert the game for your account. Now we are in a broken state: the game is purchased but not added to the library. This property is called atomicity, and transactions help maintain it. When we have multiple operations, we want all of them to succeed, or all of them to fail and revert. Phases in a Transaction Begin: prepares a new transaction. Execute: run the different queries. Commit / Rollback: On success, commit the changes. On application or DB errors, rollback the changes (like editing a doc, then pressing Ctrl+Z instead of saving). @Transactional Annotation Now that we have a basic idea of what transactions are, let's see how Spring handles those three phases for us automatically. To pull this off, Spring wraps your service bean inside a proxy, basically a wrapper object that intercepts the method call before it actually runs. The proxy begins the transaction, lets your real method execute, then commits or rolls back based on how it went. Your code stays untouched, the proxy does all the transaction plumbing around it. The Four Flavors of Isolation Isolation basically means: how do transactions running in parallel affect each other? Imagine two transactions trying to update the exact same row at the same time. Who wins, and what does the other one see while it's happening? That's what isolation decides. Without isolation, concurrent transactions run into three problems: Dirty Read: reading uncommitted changes from another transaction. Non-Repeatable Read: re-reading the same row and getting a different value because someone else updated it. Phantom Read: running a query twice and seeing new "phantom" rows inserted by another transaction. Spring lets you configure how strictly to prevent these using @Transactional(isolation = ...). From fastest/loosest to slowest/strictest: READ_UNCOMMITTED: the wild west, allows all anomalies. (Note: modern DBs like Postgres ignore this and upgrade it, which honestly tells you everything about how safe it is.) READ_COMMITTED (default): you only see committed data. Prevents dirty reads. REPEATABLE_READ: your transaction gets a frozen snapshot of the data. Prevents dirty and non-repeatable reads. SERIALIZABLE: safest but slowest. Transactions execute as if in a strict, single-file line. Prevents all anomalies. I've used this maybe twice in production, both times for money. Using Isolation Levels in Spring @Transactional(isolation = Isolation.READ_COMMITTED) public void purchaseGame(Long userId, Long gameId) { paymentService.recordPayment(userId, gameId); libraryService.addToLibrary(userId, gameId); } Enter fullscreen mode Exit fullscreen mode A few notes: If you don't set one, Spring uses your database's default (READ_COMMITTED for Postgres and MySQL). The isolation level is set once, at the start of the outermost transaction. Not all databases support all levels. Postgres treats READ_UNCOMMITTED as READ_COMMITTED since it uses MVCC. Stricter levels mean more locking, so use SERIALIZABLE only when you really need it. The readOnly flag @Transactional(readOnly = true) public GameDto getGameDetails(Long gameId) { return gameRepository.findById(gameId) .map(GameMapper::toDto) .orElseThrow(); } Enter fullscreen mode Exit fullscreen mode This is a hint, not an enforced restriction: Lets Spring skip dirty checking on entities. Can route reads to a replica depending on your setup. Documents that the method shouldn't mutate state. Good default for query only methods. Adding a timeout @Transactional( isolation = Isolation.REPEATABLE_READ, timeout = 5 ) public void reserveLimitedEditionCopy(Long userId, Long gameId) { // ... } Enter fullscreen mode Exit fullscreen mode Useful on contended rows, so a stuck transaction doesn't hold a lock forever. Who Joins the Party: Propagation Isolation is about how parallel transactions affect each other. Propagation is a different question, and honestly the one that confused me the most when I started. What happens when a @Transactional method calls another @Transactional method? Does the inner method join the same transaction, start a fresh one, or run without one at all? Back to our GTA 6 purchase. purchaseGame() calls recordPayment() and addToLibrary(). Should those two share one transaction with purchaseGame(), or run independently? Spring lets you decide this using @Transactional(propagation = ...). The two you'll actually use most often: REQUIRED (default): joins the current transaction if one exists, otherwise starts a new one. Most methods just use this. REQUIRES_NEW: always starts a new, independent transaction, pausing the current one if there is one. Useful when part of the work should commit even if the rest fails, like logging a purchase attempt regardless of whether the payment succeeds. The rest are less common, but good to know: NESTED: like a mini transaction inside the current one, using a savepoint. If it fails, only that part rolls back. MANDATORY / NEVER: opposite guardrails. MANDATORY errors if there's no transaction already running, NEVER errors if there is one. SUPPORTS / NOT_SUPPORTED: flexible middle ground. SUPPORTS joins one if it exists, NOT_SUPPORTED always runs outside one. Example with the GTA 6 purchase @Transactional public void purchaseGame(Long userId, Long gameId) { paymentService.recordPayment(userId, gameId); libraryService.addToLibrary(userId, gameId); } Enter fullscreen mode Exit fullscreen mode With the default REQUIRED, both recordPayment and addToLibrary join the same transaction started by purchaseGame. If addToLibrary fails, the whole thing rolls back, including the payment. That is what we want here, since a payment with no game added is a broken state. Now say you also want to log every purchase attempt, even failed ones, for auditing: @Transactional(propagation = Propagation.REQUIRES_NEW) public void logPurchaseAttempt(Long userId, Long gameId) { auditRepository.save(new PurchaseAttempt(userId, gameId)); } Enter fullscreen mode Exit fullscreen mode Since this uses REQUIRES_NEW, the log entry gets its own transaction. Even if purchaseGame rolls back later, the audit log entry still gets committed, because it already ran and committed independently. Things That Will Bite You Self invocation doesn't work, and this one will get you. Calling an @Transactional method from another method in the same class skips the proxy entirely, so no transaction ever starts. @Service public class LibraryService { public void purchaseFlow() { this.addToLibrary(); // proxy skipped, no transaction } @Transactional public void addToLibrary() { ... } } Enter fullscreen mode Exit fullscreen mode Here's the annoying part: nothing errors, nothing warns you. The code compiles, runs, looks completely normal in review. Then one day addToLibrary() throws halfway through, and instead of rolling back, it just leaves a half inserted row sitting in the table. You'll lose a good chunk of an afternoon before you remember: proxies only intercept calls that come from outside the object. Calling this.something() walks straight past the proxy. The fix: move addToLibrary() to a different class (a different Spring bean) and call it from there, so the call actually goes through Spring's proxy. Only public methods get proxied. Private, protected, or package private methods silently skip @Transactional. Same story as above: no error, no warning, just a method that quietly runs without the transaction you thought it had. Checked exceptions don't trigger rollback by default. Spring only rolls back on unchecked (runtime) exceptions unless you tell it otherwise. So if your PaymentFailedException is a checked exception, Spring will happily commit the transaction even though the payment failed: @Transactional(rollbackFor = PaymentFailedException.class) public void purchaseGame(...) { ... } Enter fullscreen mode Exit fullscreen mode Keep transactions short. Every open transaction holds a database connection from the pool. If you call a slow external service, like a payment gateway or an email API, while the transaction is still open, that connection sits reserved the entire time. Do a handful of these under load and you can exhaust your connection pool. Do the slow, external stuff before or after the transactional method, not inside it. Wrapping Up That's the core of transactions in Spring. @Transactional gives you atomicity for free through commit/rollback, isolation controls how much concurrent transactions are allowed to step on each other, and propagation controls how nested @Transactional calls join or split from the transaction they're called in. So, that's it if you found this article helpful, please send me a copy of GTA 6 whenever it releases, just kidding. Share to someone, if you feel this will be helpful. Bonus: What Isolation Levels Actually Cost Everything above tells you what the isolation levels do, not what they cost at runtime. So I built a small playground to measure it: spring-transactions-deepdive, a Spring Boot + Postgres app that hammers a single Order row concurrently at each isolation level and reports throughput and conflicts. Each combination is run 5 times and averaged. scenario isolation throughput (ops/s) conflicts (retries) read READ_COMMITTED 4011 ± 377 0 write-contention READ_COMMITTED 2123 ± 197 0 read REPEATABLE_READ 2539 ± 237 0 write-contention REPEATABLE_READ 545 ± 69 15440 ± 434 read SERIALIZABLE 2755 ± 188 0 write-contention SERIALIZABLE 539 ± 51 15575 ± 326 Reads cost about the same at every level. Writes are where it gets expensive: REPEATABLE_READ/SERIALIZABLE throughput drops 3-4x under contention, not because the isolation level does more work per query, but because concurrent writers to the same row trigger Postgres serialization failures (40001) that get retried. READ_COMMITTED avoids this cost by not detecting the conflict at all, it just lets the last write win, a silent lost update. So the real finding: isolation cost isn't a flat tax, it's a contention tax you only pay when transactions actually collide. Worth flagging: this is a worst case, 32 threads hammering one row is heavier contention than most tables ever see, so treat the 3-4x number as illustrative, not universal. If your write path really does look like this, you need retry logic around SERIALIZABLE/REPEATABLE_READ writes. Full setup and more scenarios are in the repo.
Spring Deep Dive: Transactions
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.