← Spring Boot®: applications, contracts and operations
05 / 10 · 60 MIN

Persistence and transaction boundaries

Define units of work, rollback rules and resource participation; distinguish Java calls from effective transactional boundaries.

Design the unit of work

A reservation that reduces stock and creates a record is complete only when both writes represent the same decision. Two repository calls with independent transactions can commit separately. A boundary on the coordinating service can cover the whole operation if the calls participate in the same transactional resource.

Write the invariant before the annotation: either the matching reduction and reservation exist, or neither change from this attempt commits. Identify the manager and DataSource. Being in the same application or class does not prove two writes share a physical transaction.

Interception and self-invocation

In proxy mode, transactional configuration applies when an eligible call crosses the managed proxy. An annotation on an object created with new, or on a method called through this, creates no such crossing. If an outer transaction already exists, self-invocation can continue using its resources without applying the declared inner propagation.

For example, this.audit() with REQUIRES_NEW does not guarantee an independent transaction. If separation is actually needed, put the operation on a bean called through its proxy or use an explicit programmatic boundary. Avoid treating the symptom merely by changing method name or visibility.

Exceptions are part of the contract

Under the stated defaults, escaping RuntimeException and Error trigger rollback; a checked exception does not automatically do so. The course distinguishes that default from a global ALL_EXCEPTIONS setting or custom rules. Use rollbackFor with a concrete type when a checked exception should roll back the unit of work.

A catch can prevent an exception reaching advice, but does not erase a rollback-only marker already set by a participating scope. Decide what outcome will be returned and what state can actually commit. Log messages and success responses do not recover a transaction marked for rollback.

Participation and independence

REQUIRED can create a logical scope participating in the outer physical transaction. An inner rollback-only marker prevents shared commit; an outer commit attempt can produce UnexpectedRollbackException. That prevents a caller from interpreting a rolled-back operation as committed.

REQUIRES_NEW uses an independent transaction when interception applies. An attempt record can commit and survive order rollback, but does not prove order success. While the outer transaction retains resources, the inner one may need another connection. Pool capacity and record meaning must accompany the propagation decision.

Limits of participating resources

readOnly expresses intent and can enable optimizations, but should not be presented as a universal write prohibition. Behavior depends on manager, driver and database. Authorization controls and data invariants still require their own implementation.

Nor should a local transaction be extended to external effects by assumption. If a supplier already accepted an HTTP call, JDBC rollback does not automatically undo that acceptance. Identity, delivery state, reconciliation or compensation must be designed according to the contract. The annotation creates no coordination with a system outside the manager’s scope.

Read the atomic reservation

The example defines the template and inventoryTx over the same DataSource and enables transaction management. Tables and constraints are prerequisites, not created by the excerpt. The call must reach the managed service through its proxy. UPDATE combines the capacity condition and reduction in one statement using bound values.

If no row satisfies sku and capacity, changed is zero and the reservation is rejected. If the later insertion fails, the unchecked exception escapes and the reduction also rolls back. Unique request_id detects collisions, but this code does not turn a retry into an idempotent response: request comparison and an explicit prior-outcome response would be needed.

Interpret the timeline

Compare three illustrative sequences: two writes and commit; first write followed by exception and rollback; independently committed audit followed by business rollback. Expected final state differs in each. Record the error boundary, resource participation and observed outcome without treating every audit record as commercial confirmation.

Questions ask you to interpret these sequences, not execute a database. In a real review, a method called reserve or a test checking only normal return does not establish rollback of the first write. Evidence must cover the intended invariant and failure branch.

// Separate files inside the application scan root.
// One transactional JDBC DataSource; schema prepared separately.
// stock(sku UNIQUE, available); reservation(request_id PRIMARY KEY, sku, units).
// InventoryConfiguration.java
package com.acme.inventory;
import javax.sql.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.JdbcTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement
public class InventoryConfiguration {
  @Bean
  JdbcTemplate inventoryJdbc(DataSource source) {
    return new JdbcTemplate(source);
  }
  @Bean("inventoryTx")
  JdbcTransactionManager inventoryTx(DataSource source) {
    return new JdbcTransactionManager(source);
  }
}

// ReservationService.java
package com.acme.inventory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ReservationService {
  private final JdbcTemplate jdbc;
  public ReservationService(JdbcTemplate jdbc) { this.jdbc = jdbc; }

  @Transactional(transactionManager = "inventoryTx")
  public void reserve(String requestId, String sku, int units) {
    if (requestId == null || requestId.isBlank() || sku == null || sku.isBlank()
        || units <= 0) throw new IllegalArgumentException("Invalid reservation input");
    int changed = jdbc.update(
        "update stock set available = available - ? where sku = ? and available >= ?",
        units, sku, units);
    if (changed != 1) throw new IllegalStateException("Reservation condition failed");
    int inserted = jdbc.update(
        "insert into reservation (request_id, sku, units) values (?, ?, ?)",
        requestId, sku, units);
    if (inserted != 1) throw new IllegalStateException("Reservation was not recorded");
  }
}
IN PRACTICE

Read reserve as an external proxy call, with transactional tables and the same DataSource in the template and manager. If insertion fails with DataAccessException, the exception escapes and the stock reduction also rolls back. The example does not implement an idempotent response for repeated requests.

This is a JDBC example separate from the next lesson’s JPA configuration.

Common pitfalls

Relying on the annotation alone; assuming self-invocation activates REQUIRES_NEW; catching exceptions without reviewing rollback-only; using readOnly as access control; treating HTTP effects as automatic participants in JDBC commit.

Related topics: Application context and dependencies · Queries, concurrency and data access · Test scope and integration evidence

Take this idea with you

The unit of work depends on call path, manager and participating resources. Define the outcome to commit and rollback conditions before choosing propagation or exception handling.

Create account

Reference: Transactional annotation and proxy boundary · Spring Boot 4.1.1 / Spring Framework 7.0.9 / Java 25