Spring Study Guide – Data Integration

August 8, 202612 min readUpdated 8/18/2026

Three separate topics that are always examined together: how Spring talks to a database, how it manages transactions, and how JPA and Spring Data sit on top of both.

Exceptions

What is the difference between a checked and an unchecked exception?

A checked exception extends Exception and the compiler forces you to catch or declare it. An unchecked exception extends RuntimeException and it does not.

Why does Spring prefer unchecked exceptions?

Because a checked exception you cannot do anything about is pure noise. SQLException is the classic case: your service has no way to recover from "the connection dropped", so the catch block either logs and rethrows or, worse, swallows it. Making it unchecked lets the code that can handle it do so, and lets everything in between stay clean.

What is the data access exception hierarchy?

Spring translates vendor-specific errors — SQLException with a MySQL error code, Hibernate's own exceptions — into one consistent tree rooted at DataAccessException, which is unchecked.

DataAccessException
├── DataIntegrityViolationException      constraint violated
│   └── DuplicateKeyException            unique index hit
├── EmptyResultDataAccessException       queryForObject found nothing
├── IncorrectResultSizeDataAccessException
├── OptimisticLockingFailureException
├── CannotAcquireLockException
└── ...

The point is portability: DuplicateKeyException means the same thing whether the database was MySQL, PostgreSQL or Oracle, so your service layer does not have to know which. The translation is switched on by @Repository — one more reason the stereotype annotations are not interchangeable.

DataSource

How do you configure a DataSource?

In Boot, by setting properties. It auto-configures a connection pool — HikariCP — from them.

spring.datasource.url=jdbc:mysql://127.0.0.1:3306/pizza?useSSL=false&connectionTimeZone=LOCAL
spring.datasource.username=root
spring.datasource.password=

For tests and demos, EmbeddedDatabaseBuilder builds an in-memory H2 or HSQL database and runs schema and data scripts into it. Useful, but be aware you are then testing against a different database than you deploy on — Testcontainers is the modern answer to that.

JdbcTemplate

What is the template pattern, and what is the JdbcTemplate?

The template pattern puts the fixed part of an algorithm in one place and lets callers supply the varying part. For JDBC the fixed part is: get a connection, create a statement, bind parameters, execute, iterate the results, close everything in the right order even on failure, translate the exception. The varying part is the SQL and the row mapping. JdbcTemplate owns the fixed part; you supply the rest.

What are the callback interfaces used with queries?

CallbackCalledUse for
RowMapper<T>once per row, returns an objectthe normal case
ResultSetExtractor<T>once for the whole ResultSetbuilding one object from many rows
RowCallbackHandleronce per row, returns nothingstreaming — writing a file, accumulating state
@Component
public class TopProductRowMapper implements RowMapper<ReportDTOs.TopProduct> {

    @Override
    public ReportDTOs.TopProduct mapRow(ResultSet rs, int rowNum) throws SQLException {
        return new ReportDTOs.TopProduct(
                rs.getString("product_name"),
                rs.getLong("units_sold"),
                RowValues.money(rs.getBigDecimal("revenue")));
    }
}

Can you run plain SQL, and how does it return objects?

// A list of mapped objects
List<ReportDTOs.RevenueByDay> rows =
        jdbcTemplate.query(sql, Map.of("from", from), revenueByDayRowMapper);

// Exactly one - throws EmptyResultDataAccessException if there are none,
// IncorrectResultSizeDataAccessException if there are two
ReportDTOs.Summary summary =
        jdbcTemplate.queryForObject(sql, Map.of("from", from), reportSummaryRowMapper);

// A single scalar
Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM customer_order", Map.of(), Long.class);

// Anything that is not a query
jdbcTemplate.update("UPDATE product SET active = :active WHERE id = :id", params);

When does the JdbcTemplate acquire and release its connection?

Per method call — it borrows from the pool, runs, and returns it. Unless a transaction is already in progress, in which case it uses the one bound to the current thread and does not close it. That is what allows a JdbcTemplate call and a JPA call inside the same @Transactional method to be part of the same transaction.

Always use named or indexed parameters, never string concatenation. :from and :limit are bound by the driver, so nothing a caller passes can change the shape of the statement. Concatenating a value into SQL is how injection happens.

When is JdbcTemplate the right choice over JPA? Reporting and aggregation. JPA's job is to map rows onto an object graph and track changes to it; a GROUP BY that returns nine rows of totals wants neither. In the pizza API the reports are plain SQL with explicit RowMappers for exactly that reason.

Transactions

What is a transaction?

A unit of work that is ACID — Atomic, Consistent, Isolated, Durable. All of it commits or none of it does.

Local versus global?

A local transaction involves one resource — a single database — and is managed by that resource. A global (distributed, XA) transaction spans several resources and needs a transaction manager to coordinate a two-phase commit. Global transactions are expensive and rare; almost everything you write is local.

Is a transaction a cross-cutting concern? How does Spring implement it?

Yes — and Spring implements it with AOP. @Transactional puts a proxy around the bean; the proxy starts a transaction before the method, commits after it returns, rolls back if it throws. Everything in the AOP lesson applies here, self-invocation included.

What is the PlatformTransactionManager?

The abstraction that actually does the work, with one implementation per technology — DataSourceTransactionManager for plain JDBC, JpaTransactionManager for JPA, JtaTransactionManager for global transactions. Boot picks the right one; the annotation stays the same regardless, which is the whole point of the abstraction.

What does @EnableTransactionManagement do?

Registers the infrastructure that finds @Transactional and creates the proxies. Spring Boot switches it on for you.

Declaring a transaction

@Service
public class ProductServiceImpl implements ProductService {

    // readOnly lets Hibernate skip dirty checking and tells the driver this will not
    // write - cheaper, and an accidental write fails loudly.
    @Override
    @Transactional(readOnly = true)
    public List<ProductDTO> getMenu() {
        return mapper.mapProductsToProductDTOs(productDAO.findActiveMenu());
    }

    @Override
    @Transactional
    public ProductDTO createProduct(ProductCreateDTO dto) { ... }
}

What does declarative transaction management mean? That you declare the boundary with an annotation and the framework applies it, instead of writing begin/commit/rollback by hand. The programmatic alternative — TransactionTemplate — is still there for the cases where the boundary is not a whole method.

Where can @Transactional go? On a method or on a class. On a class it applies to every public method, with a method-level annotation overriding it. Put it on the service layer: that is where a business operation begins and ends. A transaction per repository call gives you one transaction per statement, which is no transaction at all.

Use the Spring annotation (org.springframework.transaction.annotation.Transactional), not jakarta.transaction.Transactional. Both compile. Only the Spring one supports readOnly, isolation, timeout and the rollback rules.

Propagation

What does propagation mean? What happens when a transactional method is called by another transactional method.

PropagationExisting transactionNo transaction
REQUIRED (default)join itstart one
REQUIRES_NEWsuspend it, start a new onestart one
SUPPORTSjoin itrun without
NOT_SUPPORTEDsuspend it, run withoutrun without
MANDATORYjoin itthrow
NEVERthrowrun without
NESTEDsavepoint inside itstart one

REQUIRES_NEW is the one with a real use case and a real trap. The use case: writing an audit record that must survive the caller rolling back. The trap: it holds two connections at once, so a pool with fewer connections than nesting depth deadlocks.

Isolation

What is an isolation level? How much one in-flight transaction can see of another's uncommitted work. Weaker is faster and admits more anomalies.

LevelDirty readNon-repeatable readPhantom read
READ_UNCOMMITTEDpossiblepossiblepossible
READ_COMMITTEDnopossiblepossible
REPEATABLE_READnonopossible
SERIALIZABLEnonono
  • Dirty read — you read a row another transaction has not committed yet.
  • Non-repeatable read — you read the same row twice and get different values.
  • Phantom read — you run the same query twice and get a different set of rows.

Spring adds DEFAULT, meaning "whatever the database's own default is" — which is REPEATABLE_READ on MySQL/InnoDB and READ_COMMITTED on PostgreSQL and Oracle. Worth knowing, because the same code behaves differently on the two.

Rollback

What is the default rollback policy?

Roll back on RuntimeException and Error. Commit on a checked exception.

That surprises people every time, and it is worth being able to say why: Spring treats an unchecked exception as an unexpected failure and a checked one as an anticipated outcome the caller is expected to handle. Override it explicitly when your domain disagrees:

@Transactional(rollbackFor = Exception.class)          // roll back on checked too
@Transactional(noRollbackFor = ProductNotFoundException.class)

And if you catch the exception yourself, there is no rollback — the proxy never sees it. Catching an exception inside a transactional method and returning normally commits everything done so far. If you catch it and still want the rollback, mark it yourself:

TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();

What happens when a @Transactional method calls another @Transactional method on the same object?

The second annotation is ignored entirely — including its propagation. Self-invocation does not go through the proxy. A REQUIRES_NEW called that way just runs in the caller's transaction, which is precisely the opposite of what was asked for. Move it to another bean.

Why is "unit of work" important, and why does JDBC autocommit violate it?

Because a business operation is usually several statements and is only correct if all of them happen. With autocommit on, every statement commits by itself, so a failure halfway leaves the database in a state that no business rule permits — an order row with no line items. Spring turns autocommit off for the duration of a transaction.

JPA

What do JPA and ORM stand for?

Jakarta Persistence API — a specification for object-relational mapping; Hibernate is the usual implementation. ORM is object-relational mapping: representing rows as objects and associations as references.

Benefits and drawbacks?

You write far less boilerplate, get dirty checking, caching, lazy loading and portability across databases. In exchange you get a large abstraction whose failure modes — N+1 queries, LazyInitializationException, surprising flush ordering — require you to know the SQL it generates. ORM does not save you from understanding your database; it saves you from typing it.

What is the persistence context, and what is the EntityManager?

The persistence context is a first-level cache of managed entities plus their loaded state. The EntityManager is the API you use to interact with it. One persistence context is normally scoped to one transaction.

What it does for you: entities are unique within it (find twice returns the same instance), and at flush time it compares each managed entity against its loaded state and issues UPDATEs for what changed. That is dirty checking — and it is why this works with no save() call at all:

@Override
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public AdminUserDTO changeRole(String actingAdminEmail, UUID userId, UserRole role) {
    User target = requireUser(userId);   // managed from here on
    target.setRole(role);                // dirty checking will flush this
    return toDto(userDAO.save(target));  // explicit save, but not strictly required
}

The four entity states: new/transient (never persisted, not managed), managed (in the persistence context, changes tracked), detached (was managed, the context has closed), removed (scheduled for deletion at flush).

Touching a lazy association on a detached entity throws LazyInitializationException. That single sentence explains the majority of JPA questions people arrive with.

Why is @Entity needed and where does it go? On the class, to mark it as mapped. It needs a no-arg constructor and an @Id. Field access is inferred from where @Id is placed — annotate the fields, or annotate the getters, but never mix the two in one class.

@Entity
@Table(name = "product")
@SQLRestriction("deleted = false")   // Hibernate adds this to every query for this entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Two identifiers on purpose: id is the FK target and never leaves the server,
    // publicId is what the API exposes. Sequential ids let anyone walk /api/products/1,2,3.
    @JdbcTypeCode(SqlTypes.CHAR)
    @Column(name = "public_id", nullable = false, unique = true, length = 36)
    private UUID publicId;

    @ToString.Exclude
    @EqualsAndHashCode.Exclude
    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true,
               fetch = FetchType.LAZY)
    private List<ProductSize> sizes = new ArrayList<>();
}

The two exclusions are load-bearing. Lombok's @Data would otherwise walk the collection in toString and equals — forcing a lazy load on every log line, and recursing forever through the child's back-reference. It is the most common way Lombok and JPA go wrong together.

What do you need to configure to use JPA with Spring, and how does Boot make it easier?

By hand: a DataSource, a LocalContainerEntityManagerFactoryBean, a JpaVendorAdapter, a JpaTransactionManager and a persistence.xml or a packages-to-scan list. With spring-boot-starter-data-jpa: a URL, a username and a password.

# Liquibase owns the schema, so Hibernate must not touch it. `validate` makes the app
# refuse to start if the entities and the tables have drifted apart - a loud failure at
# boot beats a silent mismatch discovered in production.
spring.jpa.hibernate.ddl-auto=validate

# Turn this OFF. Left on (the default), the persistence context stays open for the whole
# request, so lazy loads succeed in the view layer and hide N+1 queries until production.
spring.jpa.open-in-view=false

What does @PersistenceContext do? Injects a container-managed EntityManager — actually a proxy that resolves to the one bound to the current transaction, which is what makes it safe to hold in a singleton. With Spring Data you rarely need it.

Spring Data repositories

What is an "instant repository", and why is it an interface?

An interface you declare and never implement — Spring Data generates the implementation at runtime as a proxy. It is an interface precisely so that it can be proxied: there is no class to write, so there is no class to get wrong.

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Derived from the method name: no body, no @Query.
    List<Product> findByActiveTrueOrderByTypeAscDisplayOrderAsc();

    Optional<Product> findByPublicId(UUID publicId);

    boolean existsByNameIgnoreCase(String name);

    // @EntityGraph fetches the sizes in the SAME query. Without it, listing 14 products
    // is 1 query for the products plus 14 more for their sizes - the N+1 problem.
    @EntityGraph(attributePaths = "sizes")
    List<Product> findByTypeAndActiveTrueOrderByDisplayOrderAsc(ProductType type);

    @EntityGraph(attributePaths = "sizes")
    @Query("select p from Product p")
    List<Product> findAllWithSizes();
}

What is the naming convention for finder methods?

A prefix — findBy, readBy, getBy, countBy, existsBy, deleteBy — then property names joined by And or Or, then optional keywords: Between, LessThan, Like, Containing, IgnoreCase, OrderBy…Asc/Desc, True, False, In, IsNull. The property names must match the entity's fields — a typo is a startup failure, not a runtime one, which is a feature.

How are they implemented at runtime?

Spring Data creates a JDK dynamic proxy per repository interface, backed by SimpleJpaRepository. Each call is routed to a query derived from the method name, a declared @Query, or a named query — in that order of precedence.

What is @Query for? Writing the query yourself when the method name would be unreadable or the query is not expressible by derivation. JPQL by default; nativeQuery = true for real SQL.

Return Optional<T> for single results. A repository method returning T returns null when there is no row, and the NullPointerException then surfaces somewhere unrelated.

⚠️ One trap worth the whole lesson

@SQLRestriction("deleted = false") on the entity filters soft-deleted rows out of every JPA query automatically. It does not apply to SQL you write yourself — Hibernate adds it when it builds a query from the entity model, and JdbcTemplate never goes near the entity model.

In the pizza API, omitting AND deleted = 0 from the reporting SQL counted cancelled-and-deleted orders as revenue. Nothing failed. The reports stayed entirely plausible and were simply wrong, which is why it took a while to notice.

What to remember

  • Spring's data exceptions are unchecked and vendor-neutral; @Repository enables the translation.
  • @Transactional goes on the service layer, is implemented with a proxy, and rolls back on unchecked exceptions only.
  • Catching the exception yourself, or calling the method from a sibling, means no rollback and no transaction.
  • Seven propagation modes; REQUIRED is the default and REQUIRES_NEW costs a second connection.
  • Lazy loading on a detached entity is LazyInitializationException. Set open-in-view=false and fetch what you need with @EntityGraph.
  • Repository method names are checked at startup.