Learn Spring Boot - Advanced Transactions & Persistence
Episode 10 of 24

Learn Spring Boot - Advanced Transactions & Persistence

This episode deepens persistence: transaction management with @Transactional, propagation and isolation levels, lazy loading and fetch strategies for JPA performance, and schema migration with Flyway or Liquibase.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Storing data isn't just about calling repository methods. Interdependent data must change together in a single transaction, queries must be efficient so they don't slow the application down, and the database schema must evolve safely. Episode 10 covers all three.

You'll master @Transactional, understand propagation and isolation levels, avoid lazy loading traps, and use Flyway and Liquibase for controlled schema migrations.

Transaction Management with @Transactional

Why Transactions Matter

A transaction guarantees that a sequence of database operations runs atomically: all of them succeed or all of them are undone. The classic example is a money transfer — the debit and the credit must happen together. Spring manages transactions with the @Transactional annotation:

A transaction with @Transactional
@Service
public class TransferService {
 
    private final AccountRepository repository;
 
    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = repository.findById(fromId).orElseThrow();
        Account to = repository.findById(toId).orElseThrow();
        from.debit(amount);
        to.credit(amount);
        // if either fails, both are rolled back
    }
}

If the method throws an exception, all changes inside the transaction are rolled back automatically. Note: @Transactional works through a proxy, so method calls between beans (self-invocation) won't trigger a transaction.

Rollback Behavior

By default, rollback happens for uncaught runtime exceptions. For checked exceptions, the rollbackFor configuration is required:

Set rollback for specific exceptions
@Transactional(rollbackFor = InvoiceException.class)
public void generateInvoice(Order order) throws InvoiceException {
    // operations that can throw InvoiceException
}

Setting rollbackFor correctly prevents a transaction from being committed when it should be undone — one of the most common transaction bugs in real applications.

Propagation and Isolation Levels

Propagation

Propagation defines how a method interacts with an ongoing transaction. The most commonly used values:

  • REQUIRED (default) — joins an existing transaction, or creates a new one if none exists.
  • REQUIRES_NEW — always creates a new transaction, suspending the one in progress.
  • SUPPORTS — joins if one exists, runs without a transaction if not.
A separate transaction
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAudit(AuditEntry entry) {
    auditRepository.save(entry);
}

The example above uses REQUIRES_NEW so audit records stay saved even when the main transaction is rolled back — a common audit pattern.

Isolation Levels

Isolation controls how transactions see each other's uncommitted changes. The four standard levels: READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, and SERIALIZABLE. The higher the level, the stronger the consistency but the more expensive the performance.

Set isolation per transaction
@Transactional(isolation = Isolation.READ_COMMITTED)

PostgreSQL and most modern databases use READ_COMMITTED as the default — a balanced choice. Use higher levels only for cases that genuinely need them.

Lazy Loading and Fetch Strategies

Lazy Loading and LazyInitializationException

Relationships between entities like @OneToMany are lazy by default: child data is only loaded when accessed. The problem arises when access happens outside a transaction — the LazyInitializationException error is the bane of JPA developers.

The correct solution: load the needed data inside a transaction with fetch = FetchType.EAGER, or use a join query:

Join query to avoid lazy problems
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
 
    @Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
    Optional<Order> findByIdWithItems(@Param("id") Long id);
}

A JOIN FETCH query loads the order along with its items in a single database trip. Avoid FetchType.EAGER globally — it easily triggers N+1 queries.

Solving the N+1 Query Problem

The N+1 problem happens when one query fetches a list, then one extra query per row fetches relationships. Its symptom: sudden slowness as data grows. Use JOIN FETCH, batch fetching, or an EntityGraph to fix it.

Schema Migration with Flyway and Liquibase

Flyway

Flyway manages schema changes as versioned SQL scripts:

Migration V1__buat_tabel_items.sql
CREATE TABLE items (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    price NUMERIC(12,2) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

Add the dependency and put the scripts in src/main/resources/db/migration:

Enable Flyway
./mvnw spring-boot:run

When the application starts, Flyway runs the migrations that haven't been executed and records them in the flyway_schema_history table. Scripts must not be modified after they're applied — create a new version for subsequent changes. At any time, the ./mvnw flyway:info command shows which migration versions have been applied and which haven't.

Liquibase

Alternatively, Liquibase uses a YAML- or SQL-based changelog with more granular control:

Liquibase changelog
databaseChangeLog:
  - changeSet:
      id: 1-buat-tabel-items
      author: arman
      changes:
        - createTable:
            tableName: items
            columns:
              - column:
                  name: id
                  type: bigint
                  autoIncrement: true

Both guarantee that the production schema always stays in sync with the code. With migrations in place, ddl-auto in production only needs to be set to validate.

Closing

Episode 10 equipped you with advanced persistence: transactions with @Transactional along with propagation and isolation levels, fetch strategies to avoid lazy loading and N+1 queries, and controlled schema migration with Flyway or Liquibase.

Key takeaways:

  • @Transactional makes a sequence of database operations atomic with automatic rollback.
  • Set rollbackFor for checked exceptions that should cancel the transaction.
  • REQUIRES_NEW propagation is useful for audit logs that must stay saved.
  • The READ_COMMITTED default isolation fits the majority of applications.
  • Avoid LazyInitializationException with JOIN FETCH inside a transaction.
  • Use Flyway or Liquibase for safe, versioned schema migrations.

In the next episode, episode 11, we'll discuss batch processing and scheduling — Spring Batch with jobs, steps, readers, processors, and writers; scheduled tasks with @Scheduled and cron expressions; and asynchronous task execution with a task executor.

Learn Spring Boot - Advanced Transactions & Persistence | Learn Spring Boot