Skip to content
Commitwise
Menu
Java Backend

Transactions

Keep data consistent with @Transactional in Spring or Jakarta EE, know exactly when a transaction rolls back, prevent lost updates with optimistic locking, and keep slow calls and messages out of your transactions.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

Money leaves one account and never arrives in the other. The last item in stock is sold twice. An order confirmation email goes out for an order that doesn’t exist. Each of these is a transaction bug: work that should have happened all together or not at all happened halfway, or two requests stepped on each other.

Spring and Jakarta EE both make transactions look like one annotation. This tutorial is about what that annotation does, what it doesn’t, and the defaults that surprise almost everyone once.

New to some of these? Open what you need, or skip ahead.

Basics:What are databases, tables and SQL?

A database stores an app’s data so it survives restarts and can be searched quickly. A relational database (PostgreSQL, MySQL) keeps data in tables: rows (one per book, user or order) and columns (id, title, year).

You talk to it with SQL: SELECT title FROM books WHERE year > 2000 reads, INSERT, UPDATE and DELETE change data. Rows refer to other tables through foreign keys (book.author_id points to a row in author), and indexes make lookups by a column fast.

Basics:What are errors and exceptions?

When code can’t do its job (a file is missing, a network request fails), it can throw an error (Swift) or exception (Kotlin, Java). Throwing stops the current function and jumps to the nearest code that catches it:

Swift:   do { try save() } catch { print("Couldn't save") }
Kotlin:  try { save() } catch (e: IOException) { println("Couldn't save") }
Java:    try { save(); } catch (IOException e) { System.out.println("Couldn't save"); }

If nothing catches it, the app crashes (or the server request fails). The Error handling tutorial covers this in depth.

Basics:What is Spring Boot?

Spring Boot is the most popular framework for Java and Kotlin backends. You write classes and mark them with annotations; Spring creates the objects (called beans), connects them to each other (dependency injection: a controller gets the service it needs through its constructor) and runs a web server that sends HTTP requests to your methods.

Starters (spring-boot-starter-web, spring-boot-starter-data-jpa…) add features by adding a dependency, with sensible defaults you can change in application.yml.

Basics:What is Jakarta EE?

Jakarta EE (formerly Java EE) is a set of standard APIs for Java backends: Jakarta REST for HTTP endpoints, CDI for creating objects and connecting them (dependency injection), Jakarta Persistence (JPA) for databases, and Jakarta Transactions for @Transactional.

A runtime provides the implementation: an application server such as WildFly, Payara or Open Liberty, or a lighter runtime built on the same APIs, such as Quarkus or Helidon.

Basics:What are threads and the main thread?

A thread is a line of work the computer runs; a program can run several at the same time. Apps have one special thread, the main thread (also called the UI thread), which draws the screen and handles taps.

If code on the main thread waits for something slow, like the network, the screen freezes: no scrolling, no animations. On Android, a few seconds of that shows the “App isn’t responding” dialog. That’s why slow work must happen without blocking the main thread.

Basics:How do HTTP requests work?

Apps and servers talk with HTTP. A request has a method that says what to do (GET to read, POST to create, PUT to replace, DELETE to remove), a URL (https://api.example.com/books/42), headers (extra information, like who you are) and sometimes a body (the data, usually JSON).

The server sends back a response with a status code that says how it went (200 OK, 201 created, 404 not found, 500 server error), headers, and usually a body. Codes starting with 4 mean the client asked for something wrong; codes starting with 5 mean the server failed.

Part Not started

All or nothing

Think of a bank transfer: take 100 EUR from Ana, add 100 EUR to Marko. If the server crashes between the two steps, the money must not vanish. A transaction groups several database changes into one unit:

  • All or nothing (atomic): either every change is saved, or none is. If something fails, the database rolls back to how it was before.
  • Consistent: rules like “balance can’t be negative” hold before and after.
  • Isolated: other requests don’t see your half-done work.
  • Durable: once it says “saved” (commit), it survives a crash.

Those four are often called ACID. In Spring and in Jakarta EE, you mark a service method @Transactional, and everything it does in the database becomes one transaction.

@Transactional wraps a method in a proxy that binds a connection to the thread, begins a transaction, and commits on normal return or rolls back on failure, according to rollback rules (by default only unchecked exceptions and Errors). Nested calls join the outer transaction (REQUIRED), so the outermost boundary decides.

The interesting parts are what the annotation can’t do: it doesn’t prevent concurrent anomalies (lost updates and write skew under the default READ COMMITTED), it ignores self-invocation, a caught inner failure can poison the whole transaction, and anything outside the database (HTTP calls, emails, Kafka) isn’t rolled back. The Detailed version covers locking, isolation levels, propagation and the transactional outbox.

@Transactional (Jakarta Transactions, jakarta.transaction) is a CDI interceptor: it begins a JTA transaction, or joins the caller’s (TxType.REQUIRED), and commits on normal return or rolls back on failure. The default rollback rules match Spring’s: unchecked exceptions roll back, checked ones commit, unless you list them in rollbackOn / dontRollbackOn. There’s no readOnly, timeout or isolation attribute: isolation is configured on the datasource. EJB @Stateless beans are transactional by default, with similar rules.

The interesting parts are what the annotation can’t do: it doesn’t prevent concurrent anomalies (lost updates and write skew under the default READ COMMITTED), a caught inner failure can poison the whole transaction, and anything outside the database (HTTP calls, emails, Kafka) isn’t rolled back. The Detailed version covers locking, isolation levels, propagation and the transactional outbox.

@Service
class TransferService(private val accounts: AccountRepository) {

    @Transactional
    fun transfer(fromId: Long, toId: Long, amountCents: Long) {
        val from = accounts.findById(fromId).orElseThrow()
        val to = accounts.findById(toId).orElseThrow()
        if (from.balanceCents < amountCents) {
            throw InsufficientFundsException(fromId)          // a RuntimeException: rolls back
        }
        from.withdraw(amountCents)                            // both changes are written at commit,
        to.deposit(amountCents)                               // or neither is
    }
}
@Service
public class TransferService {
    private final AccountRepository accounts;

    public TransferService(AccountRepository accounts) {
        this.accounts = accounts;
    }

    @Transactional
    public void transfer(long fromId, long toId, long amountCents) {
        Account from = accounts.findById(fromId).orElseThrow();
        Account to = accounts.findById(toId).orElseThrow();
        if (from.getBalanceCents() < amountCents) {
            throw new InsufficientFundsException(fromId);    // a RuntimeException: rolls back
        }
        from.withdraw(amountCents);                           // both changes are written at commit,
        to.deposit(amountCents);                              // or neither is
    }
}

Spring starts a transaction when transfer is called from another bean (a controller, say) and ends it when the method returns:

  • returns normally → commit: both balances are saved,
  • throws a RuntimeException → rollback: neither is saved, even if withdraw already ran in memory.

Put @Transactional on service methods that represent one business action, not on controllers or on every repository call.

@ApplicationScoped
public class TransferService {

    @PersistenceContext
    EntityManager em;

    @Transactional                                            // jakarta.transaction.Transactional
    public void transfer(long fromId, long toId, long amountCents) {
        Account from = em.find(Account.class, fromId);
        Account to = em.find(Account.class, toId);
        if (from.getBalanceCents() < amountCents) {
            throw new InsufficientFundsException(fromId);    // a RuntimeException: rolls back
        }
        from.withdraw(amountCents);                           // both changes are written at commit,
        to.deposit(amountCents);                              // or neither is
    }
}

Kotlin note

Jakarta EE examples are in Java, the language almost all Jakarta EE code is written in. The same annotations work from Kotlin with the allopen (for CDI beans) and kotlin-jpa compiler plugins. Kotlin has no checked exceptions, but the rollback rules below still look at the exception’s class.

The runtime starts a transaction when transfer is called and ends it when the method returns:

  • returns normally → commit: both balances are saved,
  • throws a RuntimeException → rollback: neither is saved, even if withdraw already ran in memory.

Put @Transactional on service methods that represent one business action, not on REST resources or on every query.

Propagation and boundaries

  • REQUIRED (default) joins an existing transaction: an inner failure that escapes a @Transactional boundary marks the whole transaction rollback-only, even if the outer code catches it (puzzle below).
  • REQUIRES_NEW suspends the outer transaction and opens a second one on a second connection: right for audit logs that must survive a rollback, risky under load (each request needs two pooled connections, which can deadlock the pool).
  • Self-invocation and visibility: annotations work only through the proxy, on calls from other beans (Spring 6 proxies also handle protected and package-private methods, but never this.method()).
  • Keep transactions short: no remote calls, no file uploads, no user think time inside. A transaction holds a pooled connection and row locks for its whole duration.
  • Side effects outside the database aren’t rolled back: emails, HTTP calls and Kafka messages need @TransactionalEventListener(phase = AFTER_COMMIT) or a transactional outboxTransactional outboxSaving an outgoing message in the same database transaction as the data change, and publishing it afterwards.Open in glossary → (the exercise below).
  • TxType.REQUIRED (default) joins an existing transaction: a RuntimeException that escapes an inner @Transactional method marks the whole transaction rollback-only, even if the outer code catches it (puzzle below).
  • TxType.REQUIRES_NEW suspends the outer transaction and runs a second one, on a second connection: right for audit logs that must survive a rollback, risky under load (each request needs two pooled connections, which can exhaust the pool).
  • Self-invocation: like Spring’s proxies, standard CDI applies interceptors only to calls that come through the bean’s proxy, from other beans: this.method() skips @Transactional. Quarkus also intercepts self-invocation, as a documented non-standard feature, so code that relies on it breaks when it moves to another runtime. Private methods are never intercepted.
  • Keep transactions short: no remote calls, no file uploads, no user think time inside. A transaction holds a pooled connection and row locks for its whole duration. JTA transactions also have a timeout (often 300 seconds by default), configured on the runtime.
  • Side effects outside the database aren’t rolled back: emails, HTTP calls and Kafka messages need a transactional observer, @Observes(during = TransactionPhase.AFTER_SUCCESS) on a CDI event, or a transactional outboxTransactional outboxSaving an outgoing message in the same database transaction as the data change, and publishing it afterwards.Open in glossary → (the exercise below).

A caught exception that still rolls back

Predict the output

loyalty.addPoints is @Transactional in another bean and throws IllegalStateException. What happens?

@Service
class OrderService(private val orders: OrderRepository, private val loyalty: LoyaltyService) {
    @Transactional
    fun place(order: Order) {
        orders.save(order)
        try {
            loyalty.addPoints(order)               // @Transactional in LoyaltyService
        } catch (e: IllegalStateException) {
            log.warn("No loyalty points this time", e)
        }
    }
}
@Service
public class OrderService {
    @Transactional
    public void place(Order order) {
        orders.save(order);
        try {
            loyalty.addPoints(order);              // @Transactional in LoyaltyService
        } catch (IllegalStateException e) {
            log.warn("No loyalty points this time", e);
        }
    }
}

Predict the output

loyalty.addPoints is @Transactional in another bean and throws IllegalStateException. What happens?

@ApplicationScoped
public class OrderService {
    @Inject LoyaltyService loyalty;
    @PersistenceContext EntityManager em;

    @Transactional
    public void place(Order order) {
        em.persist(order);
        try {
            loyalty.addPoints(order);              // @Transactional in LoyaltyService
        } catch (IllegalStateException e) {
            log.warn("No loyalty points this time", e);
        }
    }
}
  • Default propagation joins the caller’s transaction, so an inner failure marks the whole transaction rollback-only, even if the outer method catches it. REQUIRES_NEW suspends and starts a separate one.
  • Transactions apply through a proxy: self-invocation skips them (Spring and standard CDI).

Check this part

  1. What does "atomic" mean for a transaction?

  2. Where should @Transactional usually go?

  3. An inner @Transactional bean throws, and the outer method catches it. What happens at the outer commit?

  4. An audit entry must be saved even when the business action fails. Which propagation does the audit method need?

A transaction makes one business action atomic: all of its changes are saved, or none. Put @Transactional on the service method that represents the action; nested calls join the same transaction by default.

Part Not started

The exception that doesn’t roll back

Spot the bugStock reserved for orders that failed

Tap the line with the bug.

@Transactional
fun placeOrder(cart: Cart): Order {
    inventory.reserve(cart.items)                      // decrements stock
    val order = orders.save(Order.from(cart))
    order.applyDiscount(coupons.validate(cart.couponCode))
    return order
}

class CouponExpiredException(code: String) : Exception("Coupon $code expired")
@Transactional
public Order placeOrder(Cart cart) throws CouponExpiredException {
    inventory.reserve(cart.items());                   // decrements stock
    Order order = orders.save(Order.from(cart));
    order.applyDiscount(coupons.validate(cart.couponCode()));
    return order;
}

public class CouponExpiredException extends Exception { … }

Spot the bugStock reserved for orders that failed

Tap the line with the bug.

@Transactional
public Order placeOrder(Cart cart) throws CouponExpiredException {
    inventory.reserve(cart.items());                   // decrements stock
    Order order = Order.from(cart);
    em.persist(order);
    order.applyDiscount(coupons.validate(cart.couponCode()));
    return order;
}

public class CouponExpiredException extends Exception { … }

Catching inside the transaction

What if you catch an exception yourself, inside the transactional method?

Predict the output

5 rows are imported; row 3 has a negative price, so Product.from throws for it. How many products are in the database afterwards?

@Transactional
fun importProducts(rows: List<ProductRow>) {
    for (row in rows) {
        try {
            products.save(Product.from(row))       // from() validates and throws IllegalArgumentException
        } catch (e: IllegalArgumentException) {
            log.warn("Skipping row {}: {}", row.number, e.message)
        }
    }
}
@Transactional
public void importProducts(List<ProductRow> rows) {
    for (ProductRow row : rows) {
        try {
            products.save(Product.from(row));      // from() validates and throws IllegalArgumentException
        } catch (IllegalArgumentException e) {
            log.warn("Skipping row {}: {}", row.number(), e.getMessage());
        }
    }
}

Predict the output

5 rows are imported; row 3 has a negative price, so Product.from throws for it. How many products are in the database afterwards?

@Transactional
public void importProducts(List<ProductRow> rows) {
    for (ProductRow row : rows) {
        try {
            em.persist(Product.from(row));         // from() validates and throws IllegalArgumentException
        } catch (IllegalArgumentException e) {
            log.warnf("Skipping row %d: %s", row.number(), e.getMessage());
        }
    }
}

A slow call inside the transaction

Spot the bugCheckout stalls everything during a payment outage

Tap the line with the bug.

@Transactional
fun checkout(orderId: Long): Receipt {
    val order = orders.findById(orderId).orElseThrow()
    val payment = paymentClient.charge(order.totalCents, order.cardToken)
    order.markPaid(payment.id)
    return Receipt.of(order)
}
@Transactional
public Receipt checkout(long orderId) {
    Order order = orders.findById(orderId).orElseThrow();
    PaymentResult payment = paymentClient.charge(order.getTotalCents(), order.getCardToken());
    order.markPaid(payment.id());
    return Receipt.of(order);
}

Spot the bugCheckout stalls everything during a payment outage

Tap the line with the bug.

@Transactional
public Receipt checkout(long orderId) {
    Order order = em.find(Order.class, orderId);
    PaymentResult payment = paymentClient.charge(order.getTotalCents(), order.getCardToken());
    order.markPaid(payment.id());
    return Receipt.of(order);
}

Exercisemedium

The transactional outbox

When an order is placed, the service must save the order and publish an OrderPlaced event to Kafka for shipping, email and analytics. Today it does:

@Transactional
public Order place(Cart cart) {
    Order order = orders.save(Order.from(cart));
    kafka.send("orders", new OrderPlaced(order.getId(), order.getTotalCents()));
    return order;
}

Explain what can go wrong, then design a solution that guarantees: no event for an order that wasn’t saved, and no saved order without its event.

›

Two systems, one transaction: Kafka isn’t part of the database transaction. Think about the send succeeding and the commit failing, and the commit succeeding and the process dying before the send.

›

Make the event part of the database transaction: write it to an outbox table in the same transaction as the order, then publish from that table separately.

SolutionTry it yourself first

What goes wrong with the current code:

  • Event without order: kafka.send succeeds, then the commit fails (constraint, deadlock, timeout). Shipping prepares a parcel for an order that doesn’t exist.
  • Order without event: moving send after the commit (or into an AFTER_COMMIT listener) fixes that, but now a crash or a Kafka outage between commit and send loses the event forever. The customer never gets an email, and the warehouse never ships.
@Entity
@Table(name = "outbox")
public class OutboxMessage {
    @Id private UUID id = UUID.randomUUID();
    private String topic;
    private String messageKey;                  // the order id: keeps per-order ordering in Kafka
    @Column(columnDefinition = "jsonb") private String payload;
    private Instant createdAt = Instant.now();
    private Instant sentAt;                     // null until published
    // constructor, getters …
}

@Service
public class OrderService {
    @Transactional
    public Order place(Cart cart) {
        Order order = orders.save(Order.from(cart));
        outbox.save(new OutboxMessage("orders", order.getId().toString(),
            json.write(new OrderPlaced(order.getId(), order.getTotalCents()))));
        return order;                            // order and message commit together, or neither does
    }
}

@Component
public class OutboxRelay {
    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publishBatch() {
        // FOR UPDATE SKIP LOCKED: several app instances can run the relay without sending twice concurrently
        List<OutboxMessage> batch = outbox.lockNextUnsent(100);
        for (OutboxMessage m : batch) {
            kafka.send(m.getTopic(), m.getMessageKey(), m.getPayload()).join();   // wait for the broker's ack
            m.markSent(Instant.now());
        }
    }
}

public interface OutboxRepository extends JpaRepository<OutboxMessage, UUID> {
    @Query(value = """
        select * from outbox where sent_at is null
        order by created_at limit :n
        for update skip locked
        """, nativeQuery = true)
    List<OutboxMessage> lockNextUnsent(@Param("n") int n);
}
@Entity
@Table(name = "outbox")
public class OutboxMessage {
    @Id private UUID id = UUID.randomUUID();
    private String topic;
    private String messageKey;                  // the order id: keeps per-order ordering in Kafka
    @Column(columnDefinition = "jsonb") private String payload;
    private Instant createdAt = Instant.now();
    private Instant sentAt;                     // null until published
    // constructor, getters …
}

@ApplicationScoped
public class OrderService {
    @PersistenceContext EntityManager em;

    @Transactional
    public Order place(Cart cart) {
        Order order = Order.from(cart);
        em.persist(order);
        em.persist(new OutboxMessage("orders", order.getId().toString(),
            json.write(new OrderPlaced(order.getId(), order.getTotalCents()))));
        return order;                            // order and message commit together, or neither does
    }
}

@Singleton                                       // an EJB timer; Quarkus has @Scheduled(every = "0.5s")
public class OutboxRelay {
    @PersistenceContext EntityManager em;

    @Schedule(second = "*", minute = "*", hour = "*", persistent = false)
    public void publishBatch() {                  // EJB methods are transactional by default
        // FOR UPDATE SKIP LOCKED: several app instances can run the relay without sending twice concurrently
        @SuppressWarnings("unchecked")
        List<OutboxMessage> batch = em.createNativeQuery("""
                select * from outbox where sent_at is null
                order by created_at limit 100
                for update skip locked
                """, OutboxMessage.class)
            .getResultList();
        for (OutboxMessage m : batch) {
            kafka.send(m.getTopic(), m.getMessageKey(), m.getPayload()).join();   // wait for the broker's ack
            m.markSent(Instant.now());
        }
    }
}

Why it works, and what it costs:

  • Atomicity comes from the database: the order and its message are one transaction. There’s no state where one exists without the other.
  • Delivery is at least once: if the relay crashes after Kafka acknowledged but before sent_at committed, the message is sent again. Consumers must be idempotent (deduplicate by event id or make handlers naturally idempotent), which they need anyway with Kafka.
  • Ordering: using the order id as the Kafka key keeps all events of one order in one partition, in order. Across orders there’s no global order, and there doesn’t need to be.
  • Operations: index outbox(sent_at, created_at), delete or archive sent rows, and alert on the age of the oldest unsent message, which is the outbox’s lag.
  • Alternative: change data capture (Debezium reading the database’s write-ahead log and publishing outbox rows) removes the polling relay and the extra writes, at the cost of running Kafka Connect.
  • Checked exceptions commit by default, in Spring and Jakarta EE: use rollbackFor / rollbackOn, or unchecked exceptions.
  • No HTTP calls inside transactions: they hold a pooled connection and locks, and a rollback can’t undo them. Split into short transactions with a pending state, and use the transactional outbox for events (at-least-once, idempotent consumers).

Check this part

  1. A @Transactional method throws an exception that extends Exception (not RuntimeException). What happens by default?

  2. How do you make a checked exception roll the transaction back?

  3. Why should an HTTP call to a payment provider not run inside a transaction?

  4. What does the transactional outbox guarantee?

By default only unchecked exceptions roll a transaction back; checked ones commit. Keep slow external calls out of transactions, and publish events through an outbox written in the same transaction as the data.

Part Not started

Two people, one record

Transactions keep one request’s changes together. They don’t stop two requests from overwriting each other. Two admins open the same product (price 20), one changes the price to 25, the other the description; whoever saves second silently undoes the first person’s change. Optimistic lockingOptimistic lockingDetecting that someone else changed a record since you read it, and failing your update instead of overwriting theirs.Open in glossary → detects this, step by step:

  1. Add a version column: @Version private long version; on the entity.
  2. Hibernate increments it on every update and adds it to the WHERE clause: update product set …, version = 8 where id = 42 and version = 7.
  1. If someone else updated the row in between, the version is no longer 7, zero rows are updated, and Hibernate throws OptimisticLockingFailureException.
  1. If someone else updated the row in between, the version is no longer 7, zero rows are updated, and JPA throws OptimisticLockException.
  1. Handle it: return 409 Conflict so the client can reload and retry, or retry automatically when the operation is safe to repeat.
@Entity
class Product(
    var name: String,
    var priceCents: Long,
    var stock: Int,
    @Id @GeneratedValue var id: Long? = null,
) {
    @Version
    var version: Long = 0                          // managed by Hibernate
}

@RestControllerAdvice
class ConflictHandler {
    @ExceptionHandler(OptimisticLockingFailureException::class)
    fun conflict(): ProblemDetail = ProblemDetail.forStatusAndDetail(
        HttpStatus.CONFLICT, "Someone else changed this item. Reload and try again.",
    )
}
@Entity
public class Product {
    @Id @GeneratedValue private Long id;
    private String name;
    private long priceCents;
    private int stock;

    @Version
    private long version;                          // managed by Hibernate
}

@RestControllerAdvice
class ConflictHandler {
    @ExceptionHandler(OptimisticLockingFailureException.class)
    ProblemDetail conflict() {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
            "Someone else changed this item. Reload and try again.");
    }
}

Without a version, check-then-act code is a race. Spot it:

@Entity
public class Product {
    @Id @GeneratedValue private Long id;
    private String name;
    private long priceCents;
    private int stock;

    @Version
    private long version;                          // managed by Hibernate
}

@Provider
public class ConflictMapper implements ExceptionMapper<OptimisticLockException> {
    @Override
    public Response toResponse(OptimisticLockException e) {
        return Response.status(Response.Status.CONFLICT)
            .entity(new Problem("about:blank", "Conflict", 409, "Someone else changed this item. Reload and try again."))
            .build();
    }
}

One catch: the version check runs when Hibernate writes, usually at commit, after your method has returned. Then the interceptor reports a failed commit instead (a RollbackException inside a TransactionalException), and this mapper never sees it. Call em.flush() at the end of the method to get the OptimisticLockException directly, or map the wrapper too.

Without a version, check-then-act code is a race. Spot it:

Spot the bugThe last item, sold twice

Tap the line with the bug.

@Transactional
fun reserve(productId: Long, quantity: Int) {
    val product = products.findById(productId).orElseThrow()
    if (product.stock < quantity) throw OutOfStockException(productId)
    product.stock -= quantity
}
@Transactional
public void reserve(long productId, int quantity) {
    Product product = products.findById(productId).orElseThrow();
    if (product.getStock() < quantity) throw new OutOfStockException(productId);
    product.setStock(product.getStock() - quantity);
}

Spot the bugThe last item, sold twice

Tap the line with the bug.

@Transactional
public void reserve(long productId, int quantity) {
    Product product = em.find(Product.class, productId);
    if (product.getStock() < quantity) throw new OutOfStockException(productId);
    product.setStock(product.getStock() - quantity);
}

Exerciseeasy

Book a seat

A cinema app has a seat table: id, screening_id, row, number, booked_by (null if free). Write book(seatId, userId) so that:

  • a seat can never be booked by two users, even with simultaneous requests,
  • booking an already booked seat throws SeatTakenException (and the API returns 409),
  • it uses one database round trip for the booking itself.
›

The condition “the seat is free” and the change “set booked_by” must happen in one statement, so no other request can sneak in between.

›

update Seat s set s.bookedBy = :user where s.id = :id and s.bookedBy is null returns the number of updated rows: 1 if you got the seat, 0 if someone else did.

SolutionTry it yourself first
interface SeatRepository : JpaRepository<Seat, Long> {
    @Modifying
    @Query("update Seat s set s.bookedBy = :userId where s.id = :seatId and s.bookedBy is null")
    fun bookIfFree(@Param("seatId") seatId: Long, @Param("userId") userId: Long): Int
}

@Service
class BookingService(private val seats: SeatRepository) {
    @Transactional
    fun book(seatId: Long, userId: Long) {
        if (seats.bookIfFree(seatId, userId) == 0) throw SeatTakenException(seatId)
    }
}

@ResponseStatus(HttpStatus.CONFLICT)
class SeatTakenException(seatId: Long) : RuntimeException("Seat $seatId is already booked")
public interface SeatRepository extends JpaRepository<Seat, Long> {
    @Modifying
    @Query("update Seat s set s.bookedBy = :userId where s.id = :seatId and s.bookedBy is null")
    int bookIfFree(@Param("seatId") long seatId, @Param("userId") long userId);
}

@Service
public class BookingService {
    private final SeatRepository seats;

    public BookingService(SeatRepository seats) { this.seats = seats; }

    @Transactional
    public void book(long seatId, long userId) {
        if (seats.bookIfFree(seatId, userId) == 0) {
            throw new SeatTakenException(seatId);
        }
    }
}

@ResponseStatus(HttpStatus.CONFLICT)
public class SeatTakenException extends RuntimeException {
    public SeatTakenException(long seatId) { super("Seat " + seatId + " is already booked"); }
}
@ApplicationScoped
public class BookingService {

    @PersistenceContext
    EntityManager em;

    @Transactional
    public void book(long seatId, long userId) {
        int updated = em.createQuery(
                "update Seat s set s.bookedBy = :userId where s.id = :seatId and s.bookedBy is null")
            .setParameter("userId", userId)
            .setParameter("seatId", seatId)
            .executeUpdate();
        if (updated == 0) {
            throw new SeatTakenException(seatId);
        }
    }
}

// A WebApplicationException carries its own status: the API answers 409 without a mapper.
public class SeatTakenException extends ClientErrorException {
    public SeatTakenException(long seatId) {
        super("Seat " + seatId + " is already booked", Response.Status.CONFLICT);
    }
}

The database serializes the two UPDATEs on the same row: the second waits for the first to commit, then finds booked_by no longer null and updates nothing. No version column, no retry loop, no window between check and write. Test it for real: two threads (or CompletableFutures) calling book with Testcontainers’ PostgreSQL, asserting exactly one success, because an H2 in-memory test can behave differently under concurrency.

Isolation levels and anomalies

LevelPreventsStill allowsDefault in
READ COMMITTEDdirty readsnon-repeatable reads, lost updates (read-modify-write), write skewPostgreSQL, Oracle, SQL Server
REPEATABLE READ+ non-repeatable reads (PostgreSQL: snapshot, aborts some lost updates)write skew (PostgreSQL); phantoms varyMySQL InnoDB
SERIALIZABLEall anomalies— (transactions may fail and must be retried)—

Practical rules: keep READ COMMITTED, and protect specific invariants explicitly with atomic conditional updates, optimistic locking (@Version, for user edits and low contention), or pessimistic locks (@Lock(LockModeType.PESSIMISTIC_WRITE) → SELECT … FOR UPDATE, for short, high-contention critical sections, always with a timeout and a consistent lock order to avoid deadlocks). Write skew (two doctors both going off call because each saw the other on call) needs SERIALIZABLE or a lock on the rows that define the invariant. Use SERIALIZABLE selectively, with retry on serialization failures.

Practical rules: keep READ COMMITTED, and protect specific invariants explicitly with atomic conditional updates, optimistic locking (@Version, for user edits and low contention), or pessimistic locks (em.find(Product.class, id, LockModeType.PESSIMISTIC_WRITE) → SELECT … FOR UPDATE, for short, high-contention critical sections, always with a timeout, the jakarta.persistence.lock.timeout hint, and a consistent lock order to avoid deadlocks). Write skew (two doctors both going off call because each saw the other on call) needs SERIALIZABLE or a lock on the rows that define the invariant. Jakarta’s @Transactional has no isolation attribute: set the level on the datasource (or per connection), and use SERIALIZABLE selectively, with retry on serialization failures.

Deep dives

Deep diveWrite skew: the anomaly snapshot isolation allowsInterview favorite

Two doctors are on call; the rule is “at least one must stay on call”. Both check at the same time (each sees two on call), both remove themselves, both commit: nobody is on call. Neither transaction wrote a row the other wrote, so a database using snapshot isolation lets both commit. That’s write skew.

PostgreSQL’s REPEATABLE READ is snapshot isolation and allows it; so does MySQL’s default. The fixes: SERIALIZABLE (PostgreSQL detects the conflict and aborts one transaction, which you must retry), locking the rows the decision depends on with SELECT … FOR UPDATE, or turning the rule into a constraint the database enforces. It’s the classic follow-up to “what does each isolation level prevent?”.

ConcernPractice
Boundaries@Transactional on service methods for one business action; called from another bean
Rollback rulesdefault: RuntimeException and Error only; rollbackFor = Exception.class or unchecked domain exceptions
Read-only@Transactional(readOnly = true): no dirty checking, may route to replicas
Lost updatesatomic conditional UPDATE … WHERE stock >= :q, @Version, or PESSIMISTIC_WRITE with timeout
Isolationkeep READ COMMITTED; SERIALIZABLE + retry only where write skew matters
PropagationREQUIRED shares rollback-only; REQUIRES_NEW uses a second connection
Side effects@TransactionalEventListener(AFTER_COMMIT); outbox for guaranteed messages
Remote callsnever inside a transaction; split around them with an idempotency key

Gotchas: checked exceptions commit, caught failures from inner @Transactional beans throw UnexpectedRollbackException, self-invocation skips the proxy, long transactions exhaust the connection pool, H2 doesn’t behave like PostgreSQL under concurrency.

@Transactional(rollbackFor = [Exception::class], timeout = 5)
fun reserve(productId: Long, quantity: Int) {
    if (products.decrementStock(productId, quantity) == 0) {       // atomic conditional update
        throw OutOfStockException(productId)
    }
    events.publishEvent(StockReserved(productId, quantity))        // handled AFTER_COMMIT
}
@Transactional(rollbackFor = Exception.class, timeout = 5)
public void reserve(long productId, int quantity) {
    if (products.decrementStock(productId, quantity) == 0) {       // atomic conditional update
        throw new OutOfStockException(productId);
    }
    events.publishEvent(new StockReserved(productId, quantity));   // handled AFTER_COMMIT
}
ConcernPractice
Boundaries@Transactional (jakarta.transaction) on service methods for one business action; EJB @Stateless beans are transactional by default
Rollback rulesdefault: unchecked exceptions only; rollbackOn = Exception.class, dontRollbackOn, or unchecked domain exceptions
Read-only, timeout, isolationnot on the annotation: Hibernate read-only hints, the runtime’s transaction timeout, isolation on the datasource
Lost updatesatomic conditional UPDATE … WHERE stock >= :q, @Version (OptimisticLockException, often at commit), or PESSIMISTIC_WRITE with jakarta.persistence.lock.timeout
Isolationkeep READ COMMITTED; SERIALIZABLE + retry only where write skew matters
PropagationTxType.REQUIRED shares rollback-only; REQUIRES_NEW uses a second connection
Side effects@Observes(during = TransactionPhase.AFTER_SUCCESS); outbox for guaranteed messages
Remote callsnever inside a transaction; split around them with an idempotency key

Gotchas: checked exceptions commit, caught failures from inner @Transactional beans fail the outer commit, self-invocation skips the interceptor (except on Quarkus), optimistic-lock conflicts surface at commit wrapped in a RollbackException, long transactions exhaust the connection pool, H2 doesn’t behave like PostgreSQL under concurrency.

@Transactional(rollbackOn = Exception.class)
public void reserve(long productId, int quantity) {
    int updated = em.createQuery(
            "update Product p set p.stock = p.stock - :q where p.id = :id and p.stock >= :q")
        .setParameter("q", quantity).setParameter("id", productId)
        .executeUpdate();                                          // atomic conditional update
    if (updated == 0) throw new OutOfStockException(productId);
    stockReserved.fire(new StockReserved(productId, quantity));    // observed AFTER_SUCCESS
}

Check this part

  1. What does @Version do?

  2. Two requests read stock = 1 and both decrement it. What prevents overselling?

  3. What does READ COMMITTED, the usual default, still allow?

  4. Two on-call doctors each check "someone else is on call" and remove themselves. Which isolation level in PostgreSQL prevents it?

Transactions don't stop two requests from overwriting each other. Detect conflicting edits with @Version, protect invariants like stock with atomic conditional updates or locks, and know which anomalies your isolation level still allows.

Key takeaways

  • A transaction saves all its changes or none; @Transactional on a service method makes it one unit.
  • By default, Spring and Jakarta EE roll back only for unchecked exceptions; validate before writing and set rollbackFor (Spring) or rollbackOn (Jakarta EE) when needed.
  • Transactions don’t stop two requests from overwriting each other: use atomic updates or @Version.
  • Exceptions you catch inside your own transactional method don’t roll anything back.
  • Protect invariants explicitly: conditional updates, optimistic or pessimistic locks, SERIALIZABLE with retries for write skew.
  • Understand propagation: joined transactions share rollback-only; REQUIRES_NEW costs a second connection.
  • Keep transactions short and free of remote calls; split around them with pending states and idempotency keys.
  • Database and messages can’t share a transaction: use after-commit listeners or observers for best effort and an outbox for guarantees.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.