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.

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.
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:
@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.
By default, rollback happens for uncaught runtime exceptions. For checked exceptions, the rollbackFor configuration is required:
@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 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.@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 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.
@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.
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:
@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.
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.
Flyway manages schema changes as versioned SQL scripts:
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:
./mvnw spring-boot:runWhen 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.
Alternatively, Liquibase uses a YAML- or SQL-based changelog with more granular control:
databaseChangeLog:
- changeSet:
id: 1-buat-tabel-items
author: arman
changes:
- createTable:
tableName: items
columns:
- column:
name: id
type: bigint
autoIncrement: trueBoth 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.
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.rollbackFor for checked exceptions that should cancel the transaction.REQUIRES_NEW propagation is useful for audit logs that must stay saved.READ_COMMITTED default isolation fits the majority of applications.LazyInitializationException with JOIN FETCH inside a transaction.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.