Senior 11 min · March 09, 2026

Spring Boot JPA — Why OSIV Killed Your Connection Pool

OSIV held connections for 2,200ms per request, limiting a 10-connection pool to 4.5 req/s.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • JPA maps Java objects (Entities) to MySQL tables — Hibernate generates SQL so you don't write raw JDBC
  • Spring Data JPA Repositories provide CRUD methods from an interface — no implementation class needed
  • HikariCP is the default connection pool — misconfigured pool size is the #1 cause of database-related outages
  • The N+1 problem is the most common performance killer — one query for parents, N queries for children; fix it with JOIN FETCH or @EntityGraph
  • spring.jpa.open-in-view=false is non-negotiable in production — OSIV holds connections open until view rendering completes, not until your SQL finishes
  • Use BigDecimal for money, never Double — floating-point precision errors corrupt financial data silently and accumulate across thousands of transactions
  • ddl-auto=validate in production, Flyway for migrations — never let Hibernate touch your production schema autonomously
✦ Definition~90s read
What is Spring Boot with MySQL and JPA?

Spring Boot JPA is the marriage of Spring Boot's auto-configuration with the Java Persistence API (JPA), typically backed by Hibernate, to provide object-relational mapping (ORM) for relational databases like MySQL. It exists to eliminate the impedance mismatch between Java objects and database tables — you define entities with annotations like @Entity and @Id, and the framework transparently generates SQL, manages transactions, and hydrates objects.

Think of Spring Boot with MySQL and JPA as the translation layer between two very different worlds.

The deceptive simplicity is that a single @Repository interface can give you CRUD, pagination, and derived queries without writing a line of SQL, but this abstraction hides critical behaviors: lazy loading, N+1 queries, and the infamous Open Session In View (OSIV) anti-pattern. OSIV, enabled by default in Spring Boot, keeps the Hibernate session open across the entire HTTP request, causing lazy-loading to work in views but holding database connections far longer than necessary — directly starving your connection pool under load.

In production, this stack demands explicit control: disable OSIV (spring.jpa.open-in-view=false), use @EntityGraph or JOIN FETCH for eager loading, and configure HikariCP pool sizes based on your database's max connections (typically 10–20 per application instance). Alternatives like jOOQ or MyBatis give you raw SQL control when JPA's magic becomes a liability — use them for complex reporting or high-throughput batch operations where JPA's change-tracking overhead is unacceptable.

Plain-English First

Think of Spring Boot with MySQL and JPA as the translation layer between two very different worlds. MySQL thinks in tables, rows, and columns. Java thinks in objects, fields, and methods. Bridging those two worlds manually — writing SQL strings inside Java, mapping ResultSets to objects row by row, opening and closing connections by hand — is tedious, error-prone, and soul-crushing.

Imagine you are running a massive library. MySQL is the physical warehouse where every book lives on a specific shelf in a specific aisle. JPA is like a robotic librarian that translates your natural-language requests — 'Give me all science fiction books priced under twenty dollars' — into precise warehouse coordinates. You never touch the shelves directly. You just describe what you want, and the librarian handles retrieval, storage, and updates.

Spring Boot wires the whole system together automatically: it configures the connection to your MySQL warehouse (HikariCP connection pool), sets up the robotic librarian (Hibernate), and generates the shelf-management routines (CRUD operations) from your Java class definitions. What used to take hundreds of lines of JDBC boilerplate now takes a class with a few annotations and an interface with no implementation.

Connecting a Java application to a database used to be a nightmare of boilerplate JDBC code, manual connection pooling, and error-prone SQL strings embedded in Java classes. Spring Boot with MySQL and JPA eliminates that friction — it is one of the most frequently used combinations in the Java ecosystem for a reason.

By leveraging the Repository pattern and Object-Relational Mapping (ORM), Spring Boot allows you to interact with your MySQL database using plain Java objects. Hibernate, the default JPA provider, handles SQL generation, connection management, transaction boundaries, and schema validation. You focus on the business problem; the framework handles the plumbing.

In this guide we break down exactly what Spring Boot with MySQL and JPA does, why it was designed this way, and how to configure it correctly for production. That means going beyond the getting-started tutorial — we cover HikariCP tuning, the N+1 problem and its fixes, OSIV and why it will eventually cause a connection pool outage if you leave it enabled, and the right way to manage schema changes without letting Hibernate near your production tables.

We target Spring Boot 3.2.x with Jakarta Persistence 3.1 (the post-javax namespace migration) and MySQL 8.0+, ensuring the configuration and code in this guide works on the stack you are actually running in 2026, not the stack from a five-year-old tutorial.

By the end you will have both the conceptual understanding and practical code to build a production-ready persistence layer — one that performs well under load, fails safely when things go wrong, and does not surprise you with silent data corruption six months after launch.

Why Spring Boot + JPA + MySQL Is a Deceptively Simple Stack

Spring Boot with JPA and MySQL is the default stack for data-access in countless Java services. JPA (Jakarta Persistence) is the specification; Hibernate is the most common implementation. Spring Boot auto-configures a Hibernate EntityManagerFactory, a DataSource (typically HikariCP), and transaction management. The core mechanic: you define Java entities annotated with @Entity, and Hibernate maps them to MySQL tables. The promise is that you rarely write SQL — you work with EntityManager or Spring Data JPA repositories.

In practice, the stack's behavior depends on three properties that bite teams daily: spring.jpa.open-in-view (defaults to true), spring.jpa.hibernate.ddl-auto (defaults to create-drop in dev, but often left on in production), and transaction boundaries. Open Session in View (OSIV) keeps the Hibernate session open across the entire HTTP request, which means lazy-loading works in views — but it also holds a JDBC connection until the response is sent. With default HikariCP pool size of 10, 10 concurrent slow endpoints exhaust the pool instantly.

Use this stack when you need rapid CRUD development and your query patterns are simple. Avoid it when you need fine-grained SQL control, batch operations, or high throughput with complex joins. The real value is developer velocity; the real cost is connection management and query transparency. Every team that adopts it must understand OSIV, N+1 queries, and connection pool sizing — or they will learn them in production.

OSIV Is On by Default
Spring Boot enables Open Session in View automatically. One slow endpoint with lazy loads can hold a connection for seconds, starving the entire pool.
Production Insight
A payment reconciliation service used Spring Data JPA with OSIV enabled. A single GET endpoint that lazy-loaded 5000 transactions per request held connections for 3-4 seconds each. With a HikariCP pool of 10, 3 concurrent requests exhausted the pool, causing all other endpoints to hang with 'HikariPool-1 - Connection is not available, request timed out after 30000ms'. The fix: set spring.jpa.open-in-view=false and explicitly fetch joins or use @EntityGraph.
Key Takeaway
OSIV is the #1 cause of connection pool starvation in Spring Boot apps — disable it unless you have a concrete reason to keep it.
Always set spring.jpa.open-in-view=false in production and use explicit fetch strategies (JOIN FETCH, @EntityGraph) to avoid lazy-loading surprises.
HikariCP's default pool size of 10 is not a recommendation — size it based on your max concurrent requests and per-request connection hold time.
Spring Boot JPA OSIV Connection Pool Drain THECODEFORGE.IO Spring Boot JPA OSIV Connection Pool Drain How Open Session in View kills your database connections OSIV Enabled by Default Spring Boot auto-configures Open Session in View Lazy Loading in View Persistence context stays open during view rendering Long-held Connections DB connection held until HTTP response completes Connection Pool Exhaustion Slow views or many requests drain the pool Disable OSIV Set spring.jpa.open-in-view=false ⚠ OSIV holds connections longer than needed Disable OSIV and use DTOs or explicit fetch joins THECODEFORGE.IO
thecodeforge.io
Spring Boot JPA OSIV Connection Pool Drain
Spring Boot Mysql Jpa

Common Mistakes and How to Avoid Them

The two most damaging mistakes with Spring Boot JPA are both invisible during development and only reveal themselves under production load. The first is the N+1 Select Problem — the application makes one query to fetch a list of parent entities and then N additional queries to fetch the associated child entities for each parent. With 10 records in your development database, this executes 11 queries and is unnoticeable. With 500 records in production, it executes 501 queries and the endpoint takes 8 seconds instead of 50 milliseconds.

The second is OSIV (Open Session in View) left at its default value of true. During development, this feels like a feature — you can access lazy-loaded associations anywhere in your code without worrying about transaction boundaries. In production, it means every HTTP request holds a database connection from the moment it enters the DispatcherServlet until the moment the HTTP response bytes are flushed to the client. With a pool of 20 connections and serialization taking 500ms per request, your effective throughput ceiling is 20 / 0.5 = 40 requests per second regardless of how fast your SQL executes.

Below is the full production application.properties configuration. Every setting is here for a reason — the comments explain the non-obvious ones.

src/main/resources/application.propertiesPROPERTIES
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# =========================================================
# MySQL Connection — all sensitive values from environment
# variables, never hardcoded in application.properties
# =========================================================
spring.datasource.url=jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:forge_db}?useSSL=true&requireSSL=true&serverTimezone=UTC&allowPublicKeyRetrieval=false&rewriteBatchedStatements=true
spring.datasource.username=${DB_USER:forge_user}
spring.datasource.password=${DB_PASS:}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# =========================================================
# HikariCP Connection Pool
# Formula: maximum-pool-size = (core_count * 2) + effective_spindle_count
# For a 4-core server with SSD storage: (4 * 2) + 1 = 9, round up to 10.
# Going above 20-30 on a single MySQL instance rarely helps and can cause
# mutex contention on MySQL's internal lock manager.
# =========================================================
spring.datasource.hikari.pool-name=ForgeHikariPool
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=10

# How long (ms) a caller waits for a connection before failing with an exception.
# 20 seconds is generous for most workloads — consider 5-10s for latency-sensitive APIs.
spring.datasource.hikari.connection-timeout=20000

# How long (ms) an idle connection stays in the pool before being closed.
# 5 minutes is a reasonable default — shorter for serverless/bursty workloads.
spring.datasource.hikari.idle-timeout=300000

# Maximum lifetime of a connection in the pool regardless of idle state.
# Must be shorter than MySQL's wait_timeout (default 8 hours, commonly set to
# 10-30 minutes on managed cloud instances). Set to 25 minutes to be safe.
spring.datasource.hikari.max-lifetime=1500000

# Validates that the connection is alive before handing it to the caller.
# MySQL 8.0+ supports SELECT 1 as the keepalive query.
spring.datasource.hikari.connection-test-query=SELECT 1
spring.datasource.hikari.keepalive-time=60000

# =========================================================
# JPA / Hibernate
# =========================================================

# validate: check entities match schema at startup; fail fast if they do not.
# NEVER use create, create-drop, or update in production.
# Schema changes belong in Flyway migrations, not in Hibernate's DDL generation.
spring.jpa.hibernate.ddl-auto=validate

# false in production — show-sql floods your log with SQL and has measurable
# throughput impact under load. Enable temporarily for N+1 debugging.
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.format_sql=false

# MySQL8Dialect is correct for MySQL 8.0+.
# Without this, Hibernate may generate SQL using syntax not optimized for MySQL 8.
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect

# =========================================================
# OSIVOpen Session in View
# Default is true. This is wrong for production.
# Set to false: database connections are released when @Transactional ends,
# not when the HTTP response is written. This is the single highest-impact
# JPA configuration change for connection pool efficiency.
# =========================================================
spring.jpa.open-in-view=false

# =========================================================
# Hibernate Batch Processing
# Enables JDBC-level batching: multiple INSERT/UPDATE statements are sent
# to MySQL in a single network round-trip instead of one per statement.
# Requires rewriteBatchedStatements=true in the JDBC URL (already set above).
# =========================================================
spring.jpa.properties.hibernate.jdbc.batch_size=500
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
spring.jpa.properties.hibernate.generate_statistics=false
Output
INFO ForgeHikariPool - Starting...
INFO ForgeHikariPool - Start completed. Pool size: 20, Idle connections: 10
INFO HibernateJpaVendorAdapter - Hibernate validation mode: validate
INFO SchemaValidator - Schema validation successful — all entities match the database schema
WARN spring.jpa.open-in-view is disabled — LazyInitializationException will be thrown if lazy associations are accessed outside @Transactional
Two Settings That Will Eventually Cause a Production Incident If You Leave Them at Defaults
spring.jpa.open-in-view defaults to true and spring.jpa.hibernate.ddl-auto defaults to create-drop in some contexts. The first will cause connection pool exhaustion under moderate load — it is not a matter of if, it is a matter of when your traffic crosses the threshold. The second will drop and recreate your tables on every application restart if triggered in the wrong environment. These are not edge cases. They are the two most common causes of Spring Boot JPA production incidents I have seen across teams. Set open-in-view=false and ddl-auto=validate before you ship anything.
Production Insight
A team I worked with inherited a codebase with spring.jpa.open-in-view=true and a 10-connection pool. The application had been running fine for two years with 150 daily active users. They ran a Black Friday promotion and daily active users hit 1,200 for 6 hours. The connection pool exhausted within 4 minutes of the traffic spike. The on-call engineer spent 45 minutes diagnosing before finding the OSIV setting — one line in application.properties that had never been questioned.
After setting open-in-view=false and deploying, the pending connection count dropped from 340 to 0 within 2 minutes. The pool of 10 connections was sufficient for the load — connections were just being held 200x longer than they needed to be.
Rule: always set spring.jpa.open-in-view=false. The LazyInitializationException it occasionally causes during development is a feature, not a bug — it forces you to design your transaction boundaries correctly instead of relying on OSIV as a crutch.
Key Takeaway
The N+1 problem is invisible in development (small datasets) and catastrophic in production (real datasets). Enable spring.jpa.show-sql=true during development and count SELECT statements per request. If the count grows linearly with the result set size, you have an N+1 problem. Fix it with JOIN FETCH or @EntityGraph before it reaches production.
OSIV holds database connections for the entire HTTP request lifecycle — JSON serialization, view rendering, Spring filters, and client TCP acknowledgment all happen while your connection is held. Set spring.jpa.open-in-view=false unconditionally in production.
HikariCP pool size is not a 'set it and forget it' configuration — monitor hikaricp.connections.pending in production and alert on any non-zero value sustained for more than 60 seconds. A pending count above zero means requests are queuing for connections, which means your pool is undersized for your current load.
HikariCP Pool Sizing Decisions
IfLocal development or small application with fewer than 50 concurrent users
UseUse maximum-pool-size=10 with minimum-idle=5. The default is appropriate here — do not over-provision for development workloads.
IfMedium application with 50–500 concurrent users and mixed read/write workload
UseUse maximum-pool-size = (core_count * 2) + 1. For a 4-core server: 9, rounded up to 10. For an 8-core server: 17. Set minimum-idle equal to maximum-pool-size for steady-state workloads where you want connections pre-warmed.
IfHigh-traffic application where the pool is frequently saturated and queries are short (under 50ms)
UseUse maximum-pool-size=20–30 and add ProxySQL or PgBouncer as an external connection pooler in front of MySQL. A single MySQL instance handles connection overhead poorly above 200 simultaneous connections — pooling at the proxy level multiplexes connections more efficiently.
IfMicroservice with very short-lived queries (under 10ms) and spiky traffic patterns (serverless, event-driven)
UseUse maximum-pool-size=5 with minimum-idle=2 and connection-timeout=5000. Short queries release connections quickly so a smaller pool serves more throughput. Aggressive timeout (5s) fails fast during pool exhaustion spikes rather than queuing requests for 30 seconds.

JPA Relationship Annotations — @OneToMany, @ManyToOne, @OneToOne, @ManyToMany

JPA relationship annotations define how entities connect to each other in the object model, and Hibernate translates those connections into foreign key constraints and JOIN SQL at runtime. Getting the annotations right is critical: incorrect fetch types, missing cascade settings, or wrong ownership mapping cause N+1 problems, excessive UPDATE statements, or data inconsistency.

Each relationship has four characteristics you must specify
  • Fetch Type: LAZY (load on access, default for collections) or EAGER (load eagerly, default for singular associations in JPA 2.x+ but overridable)
  • Cascade: which operations (PERSIST, MERGE, REMOVE, etc.) propagate from the parent to the child
  • Ownership Side: which entity holds the foreign key (the 'owner') — only the owner can change the relationship
  • Mapped By: used on the inverse (non-owning) side to point back to the owning field

The following table summarizes all four annotations and their typical usage:

io/thecodeforge/persistence/model/RelationshipExamples.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// === MANY-TO-ONE (owning side has the foreign key column) ===
@Entity
@Table(name = "forge_products")
public class ForgeProduct {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)  // LAZY is strongly recommended
    @JoinColumn(name = "category_id")
    private ForgeCategory category;
}

// === ONE-TO-MANY (inverse side, mappedBy points to owning field name) ===
@Entity
@Table(name = "forge_categories")
public class ForgeCategory {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(mappedBy = "category", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<ForgeProduct> products = new ArrayList<>();

    // Helper methods to keep both sides in sync
    public void addProduct(ForgeProduct product) {
        products.add(product);
        product.setCategory(this);
    }

    public void removeProduct(ForgeProduct product) {
        products.remove(product);
        product.setCategory(null);
    }
}

// === ONE-TO-ONE (owning side has the foreign key) ===
@Entity
@Table(name = "forge_users")
public class ForgeUser {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL)
    private ForgeUserProfile profile;
}

@Entity
@Table(name = "forge_user_profiles")
public class ForgeUserProfile {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", unique = true)
    private ForgeUser user;
}

// === MANY-TO-MANY (requires join table) ===
@Entity
@Table(name = "forge_products")
public class ForgeProduct {
    @ManyToMany
    @JoinTable(
        name = "forge_product_tags",
        joinColumns = @JoinColumn(name = "product_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id")
    )
    private Set<ForgeTag> tags = new HashSet<>();
}

@Entity
@Table(name = "forge_tags")
public class ForgeTag {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToMany(mappedBy = "tags")
    private Set<ForgeProduct> products = new HashSet<>();
}
Output
Hibernate:
CREATE TABLE forge_products (
id BIGINT NOT NULL AUTO_INCREMENT,
category_id BIGINT,
PRIMARY KEY (id)
) ENGINE=InnoDB
Hibernate:
ALTER TABLE forge_products ADD CONSTRAINT FK_product_category
FOREIGN KEY (category_id) REFERENCES forge_categories(id)
Hibernate:
CREATE TABLE forge_product_tags (
product_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
PRIMARY KEY (product_id, tag_id)
) ENGINE=InnoDB
EAGER Fetch Defaults Are a Performance Trap
JPA 2.0+ defaults @ManyToOne and @OneToOne to FetchType.EAGER. This means every query that loads a Product also loads its Category via an outer join — even when you only need the product name. Over time, these extra joins compound into significant overhead. Always explicitly set fetch = FetchType.LAZY on @ManyToOne and @OneToOne annotations in production entities. Use JOIN FETCH or @EntityGraph only on the specific queries that need the associated data.
Production Insight
A common source of unexpected UPDATE statements is failing to maintain both sides of a bidirectional relationship. If you only do category.getProducts().add(product) without product.setCategory(category), the foreign key column remains NULL until you explicitly call product.setCategory(). The safer pattern is to add helper methods (addProduct/removeProduct) that set both sides. The same principle applies to @ManyToMany: always manipulate both collections to avoid stale references in the Persistence Context.
Key Takeaway
Choose fetch = FetchType.LAZY for all associations by default and use JOIN FETCH or @EntityGraph only where needed. Define the owning side (where the foreign key or join table lives) and use mappedBy on the inverse side. For bidirectional relationships, implement add/remove helper methods to keep both sides consistent and avoid silent data inconsistency.
JPA Relationship Mapping Diagram
@OneToOne (FK)@OneToOne (mappedBy)Inverse sideOwning side@ManyToOne (FK column)@ManyToMany (Join Table)@OneToMany (mappedBy)@ManyToMany (mappedBy)ForgeProductForgeCategoryForgeTagForgeUserProfileForgeUser

Spring Data Query Method Naming Table

Spring Data JPA can generate JPQL queries from method names following a strict naming convention. These method names are parsed and validated at application startup — if a fragment references a field that does not exist on the entity, the application will not start. This is a powerful feature that eliminates boilerplate JPQL for standard filtering, counting, existence checks, and deletions.

The naming convention follows a prefix (findBy, countBy, existsBy, deleteBy) followed by one or more property references combined with And/Or, optionally ending with ordering keywords. The table below covers the most common patterns with examples using the ForgeProduct entity (fields: id, name, price, categoryId, createdAt).

io/thecodeforge/persistence/repository/ProductRepositoryExtensions.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public interface ProductRepository extends JpaRepository<ForgeProduct, Long> {

    // === findBy — returns List (or Optional for single result) ===
    List<ForgeProduct> findByName(String name);
    List<ForgeProduct> findByNameIgnoreCase(String name);
    Optional<ForgeProduct> findFirstByNameOrderByPriceDesc(String name);
    List<ForgeProduct> findByPriceBetween(BigDecimal low, BigDecimal high);
    List<ForgeProduct> findByCategoryIdIn(List<Long> categoryIds);
    List<ForgeProduct> findByNameContainingIgnoreCase(String partialName);
    // === And / Or — combine conditions ===
    List<ForgeProduct> findByNameAndPrice(String name, BigDecimal price);
    List<ForgeProduct> findByNameOrCategoryId(String name, Long categoryId);
    // === OrderBy — sort on any field (ASC or DESC) ===
    List<ForgeProduct> findByCategoryIdOrderByPriceAsc(Long categoryId);
    List<ForgeProduct> findByCategoryIdOrderByPriceDescNameAsc(Long categoryId);
    // === countBy — returns long ===
    long countByCategoryId(Long categoryId);
    long countByPriceGreaterThan(BigDecimal minPrice);
    // === existsBy — returns boolean ===
    boolean existsByName(String name);
    boolean existsByCategoryIdAndPriceGreaterThan(Long categoryId, BigDecimal minPrice);
    // === deleteBy — returns void or int (must be @Modifying inside a transaction) ===
    // Note: deleteBy requires @Transactional and @Modifying from Spring Data 3.x+
    @Transactional
    void deleteByCategoryId(Long categoryId);
    @Transactional
    int deleteByNameIgnoreCase(String name);  // returns number of deleted rows
}
Output
// SQL generated for findByNameIgnoreCase (Hibernate 6.x):
Hibernate: select f1_0.id,f1_0.category_id,f1_0.created_at,f1_0.name,f1_0.price,f1_0.updated_at
from forge_products f1_0
where lower(f1_0.name) like lower(?) escape ''
// SQL generated for findByCategoryIdOrderByPriceAsc:
Hibernate: select f1_0.id,f1_0.category_id,f1_0.created_at,f1_0.name,f1_0.price,f1_0.updated_at
from forge_products f1_0
where f1_0.category_id=?
order by f1_0.price asc
// SQL generated for countByCategoryId:
Hibernate: select count(f1_0.id) from forge_products f1_0 where f1_0.category_id=?
// SQL generated for existsByName:
Hibernate: select f1_0.id from forge_products f1_0 where f1_0.name=? limit 1
// SQL generated for deleteByCategoryId:
Hibernate: delete from forge_products f1_0 where f1_0.category_id=?
Method Name Parsing Rules — What Works and What Does Not
The method name parser converts CamelCase after the prefix into property references. It supports: And, Or, Between, LessThan, GreaterThan, Like, Containing, In, IsNull, IsNotNull, OrderBy, First, Top, IgnoreCase. Nested properties (e.g., category.name) work if the association is a single-valued path. Performance tip: deleteBy and removeBy methods load entities into the Persistence Context before deleting unless used with @Modifying — for bulk deletions, prefer @Modifying with a JPQL DELETE query instead.
Production Insight
The method name parser generates a query that uses the full join path for sub-properties. For example, findByCategoryNameIgnoreCase translates to a JOIN between forge_products and forge_categories. This is fine for simple cases, but if the method name becomes too long (more than 5-6 And/Or conditions), the generated SQL can be convoluted. In such cases, use @Query with a well-written JPQL instead. Also note that the parser matches property names case-sensitively against the entity fields at startup — a misspelling prevents the application from starting, which is a feature, not a bug.
Key Takeaway
Spring Data query method names eliminate JPQL boilerplate for standard filtering, counting, existence checks, and deletions. Use findBy for returning objects, countBy for counts, existsBy for boolean checks, and deleteBy for deletions (with @Transactional). Keep method names to a reasonable length — if they exceed 5-6 conditions, switch to @Query for readability.

Flyway Migrations Step-by-Step

Flyway is the standard tool for versioned database schema migrations in Spring Boot. It ensures that schema changes are repeatable, auditable, and safe across environments. The core principle: every schema change is a SQL file, stored in version order, that Flyway applies exactly once. Combined with spring.jpa.hibernate.ddl-auto=validate, Flyway migrations run first (applying schema changes), then Hibernate validates that the entity annotations match the new schema.

Naming convention for migration files in src/main/resources/db/migration: - V{version}__{description}.sql (e.g., V1__create_forge_products.sql, V2__add_category_id_index.sql) - Version numbers are integers. Use leading zeros for readability: V001__, V002__… (Flyway treats them as strings, so V2 > V10 — avoid this by using padded numbers like V01, V02… up to V99). - Description is a snake_case or hyphenated summary of the change. - Files are immutable once merged — never modify a file that has already run in any environment. Create a new migration file to alter or revert.

Below is the full workflow: creating the first migration, configuring Spring Boot, and validating on startup.

src/main/resources/db/migration/V1__create_forge_products.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- V1__create_forge_products.sql
-- Initial schema: create the forge_products table with proper column types and indexes.
-- This migration is idempotent (it runs only once per database).

CREATE TABLE forge_products (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    price DECIMAL(12,2) NOT NULL,
    category_id BIGINT,
    created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    CONSTRAINT fk_product_category FOREIGN KEY (category_id) REFERENCES forge_categories(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE INDEX idx_product_name ON forge_products (name);
CREATE INDEX idx_product_category ON forge_products (category_id);
Output
INFO Flyway 10.0.0 by Redgate
INFO Database: jdbc:mysql://localhost:3306/forge_db (MySQL 8.0)
INFO Successfully validated 1 migration (execution time 00:00.021s)
INFO Creating Schema History table `forge_db`.`flyway_schema_history` ...
INFO Current version of schema `forge_db`: << Empty Schema >>
INFO Migrating schema `forge_db` to version "1 - create forge products"
INFO Successfully applied 1 migration to schema `forge_db` (execution time 00:00.073s)
-- After Flyway runs, Hibernate schema validation executes:
INFO HibernateJpaVendorAdapter - Hibernate validation mode: validate
INFO SchemaValidator - Schema validation successful — all entities match the database schema
Flyway Naming and Idempotence Best Practices
Always use V{version}__{description}.sql format. Flyway tracks applied migrations in the flyway_schema_history table. If a migration file with the same version but different checksum is detected, the application will fail to start (mismatch error). Never modify a committed migration. For rollbacks, create a new migration that reverses the change. For large tables, use pt-online-schema-change (Percona Toolkit) and place the command in a Java-based migration using Flyway's JavaMigration interface to avoid SQL-only migration timeouts.
Production Insight
The order of operations matters: Flyway migrations run before Hibernate schema validation. This means your migration must create the schema that Hibernate expects. If your migration adds a column that is present in the @Column annotation but the migration has not run yet, Hibernate validation fails. To avoid this, always design migrations so that they are forward-compatible with the next code version. A common strategy: deploy the database migration (run Flyway) before deploying the new application version that depends on the new schema.
Key Takeaway
Flyway + ddl-auto=validate is the production-safe schema management pattern. Store migration files in src/main/resources/db/migration with V{version}__{description}.sql naming. Flyway applies pending migrations on startup, then Hibernate validates the schema matches entity annotations. Never modify a migration after it has been applied — create a new migration for further changes.

Second-Level Cache Configuration with Ehcache and @Cacheable

Hibernate's second-level cache (L2) stores entity data outside the Persistence Context, shared across all sessions and transactions. When an entity is loaded, Hibernate checks the L2 cache before hitting the database. When an entity is updated or inserted, Hibernate updates the L2 cache accordingly. This dramatically reduces database load for read-heavy, rarely-changing data — reference tables, product catalogues, configuration entities.

To enable L2 caching in Spring Boot with Ehcache, you need three things: (1) the Ehcache dependency (or another JCache-compatible provider), (2) configuration enabling the query and entity caches, (3) @Cacheable annotation on the entities you want to cache. Additionally, you must be careful about cache invalidation when data changes outside Hibernate (direct SQL updates, other applications).

Ehcache is chosen for its maturity and integration with Hibernate. The configuration below sets up a heap-based cache with 10,000 maximum entries per region and expiry.

io/thecodeforge/persistence/model/CachedEntity.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Entity that is eligible for second-level caching
@Entity
@Table(name = "forge_categories")
@Cacheable  // This annotation makes Hibernate cache the entity in L2
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)  // READ_WRITE for mutable data, READ_ONLY for immutable
public class ForgeCategory {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String name;

    // OneToMany collections are NOT cached by default; the L2 cache stores
    // only the entity itself. To cache the collection, add @Cache to the collection field.
    @OneToMany(mappedBy = "category")
    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE)  // collection cache
    @BatchSize(size = 25)
    private List<ForgeProduct> products = new ArrayList<>();
}

// application.properties additions:
# Enable second-level cache
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
# Specify the region factory class (Ehcache 3)
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory
# Enable query cache (if you want to cache query results)
spring.jpa.properties.hibernate.cache.use_query_cache=true
# Specify the JCache provider
spring.cache.jcache.provider=org.ehcache.jsr107.EhcacheCachingProvider
Output
INFO HibernateJpaVendorAdapter - Added hibernate cache integration: JCacheRegionFactory
INFO Ehcache third-level cache provider initialized
INFO Entity 'ForgeCategory' is cacheable (usage: READ_WRITE)
INFO Query cache enabled — Hibernate will cache results of queries with @QueryHints(org.hibernate.annotations.QueryHints.CACHEABLE)
-- Sample log showing cache hit:
INFO HibernateCacheHit - Entity ForgeCategory#42 returned from L2 cache (SQL skipped)
Second-Level Cache Staleness — The Silent Data Corruption Risk
The L2 cache is local to the JVM. If another application instance or a direct SQL UPDATE modifies the same data without going through Hibernate, the L2 cache becomes stale. For reference data that is rarely modified and where you can tolerate minutes of staleness, TTL-based expiry is acceptable. For transactional data, READ_WRITE concurrency strategy with cache locking still has a window of inconsistency across nodes in a cluster. For multi-instance deployments, consider a distributed cache (Hazelcast, Redis via Redisson) or disable L2 caching altogether.
Production Insight
The query cache (use_query_cache=true) stores the primary keys of query results, not the entity data. When a query is executed again, Hibernate retrieves the list of primary keys from the query cache and then fetches each entity from the L2 entity cache (or database if not cached). The query cache is invalidated whenever any entity of the related type is modified — this can cause frequent invalidation in write-heavy workloads, making the query cache counterproductive. Use query cache only for truly static reference data that is queried by many different parameters.
Key Takeaway
Enable second-level caching for read-heavy, static or reference data using @Cacheable and @Cache annotations on entities. Configure Ehcache with TTL expiry. Use READ_WRITE for mutable data, READ_ONLY for immutable data. Be aware of cache staleness across multiple application instances — for clustered deployments, use a distributed cache provider or disable L2 caching for transactional data.

Practice Projects — Apply Your JPA Knowledge

The best way to internalize JPA concepts is to build real applications that exercise relationships, caching, query methods, and connection pooling. Below are five practice projects, each focusing on a specific JPA skill area. Start with Project 1 and progress in order — each project builds on the previous.

PRACTICE_PROJECTS.mdMARKDOWN
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# 5 Practice Projects for Spring Boot JPA

## Project 1: Inventory System (CRUD + basic query methods)
**Entities**: `Product` (id, name, quantity, price, createdAt), `Warehouse` (id, name, location)
**Relationships**: Many Products belong to one Warehouse (@ManyToOne)
**Tasks**:
- Implement full CRUD with JpaRepository
- Create a repository method: `findByPriceBetween` and `findByWarehouseLocationIgnoreCase`
- Test with an in-memory H2 database
- Write a Flyway migration for initial schema
- **Bonus**: Add a `@Query` that counts products by warehouse

## Project 2: Blog with Comments (one-to-many, pagination, N+1 fix)
**Entities**: `Post` (id, title, content), `Comment` (id, body, author, createdAt)
**Relationships**: One Post has many Comments (@OneToMany)
**Tasks**:
- Write a GET endpoint that returns paginated posts with comments
- Ensure queries do not cause N+1 (use JOIN FETCH or @EntityGraph)
- Add `@BatchSize` as an alternative fix
- Implement a `@Modifying` query to delete all comments by author
- **Bonus**: Enable second-level cache for `Post` metadata (title only) using Ehcache

## Project 3: Order Management with Relationships (many-to-many, cascade, orphanRemoval)
**Entities**: `Order` (id, orderDate, status, total), `Product` (id, name, price, stock), `OrderItem` (id, quantity, subtotal)
**Relationships**:
- Order has many OrderItems (@OneToMany, cascade = ALL, orphanRemoval = true)
- OrderItem has one Product (@ManyToOne)
- OrderItem is the join entity between Order and Product (many-to-many via composite pattern)
**Tasks**:
- Implement helper methods `addProduct(Product, int quantity)` that creates OrderItem and sets bidirectional relationships
- Ensure removing an OrderItem from the Order collection deletes it from DB (orphanRemoval)
- Add a repository method: `findByStatusAndOrderDateBetween`
- **Bonus**: Add a `@Query` that calculates total order value per customer using JPQL aggregation

## Project 4: Caching & Performance (L2 cache, connection pool monitoring)
**Entities**: Use any existing entities (e.g., `Product`, `Warehouse`)
**Tasks**:
- Integrate Ehcache second-level cache for `Warehouse` (read-heavy, rarely changes)
- Set up HikariCP metrics and expose via Actuator
- Create a load test with k6 or JMeter that simulates 100 concurrent users fetching products
- Measure connection pool metrics before and after disabling OSIV
- **Bonus**: Implement a `@QueryHints` with `org.hibernate.annotations.QueryHints.CACHEABLE` for a frequently executed query

## Project 5: Full E-Commerce Backend (all concepts combined)
**Entities**: `Customer` (id, name, email), `Order` (id, orderDate, status), `Product` (id, name, price, stock), `OrderItem` (id, quantity, unitPrice), `Category` (id, name), `Review` (id, rating, comment)
**Relationships**:
- Customer has many Orders
- Order has many OrderItems
- OrderItem belongs to one Product
- Product belongs to one Category
- Product has many Reviews
**Tasks**:
- Implement all repositories with custom query methods (findBy*, countBy*, existsBy*)
- Write Flyway migrations for initial schema and add indexes after load testing
- Disable OSIV and ensure all lazy loads are handled with JOIN FETCH or DTO projections
- Add second-level cache for Category (static) and review the impact
- Set up HikariCP pool monitoring with alerts on pending connections
- **Bonus**: Implement a bulk price update using `@Modifying` and batch clearing to avoid heap exhaustion
Output
--- Project 5 triggers ---
Hibernate: select o1_0.id,o1_0.customer_id,o1_0.order_date,o1_0.status from orders o1_0 where o1_0.customer_id=? order by o1_0.order_date desc limit 10
Hibernate: (batch insert of 500 OrderItems via JDBC batching)
INFO HikariPool-1 - After connection release, active connections: 2, pending: 0
INFO L2 cache hit for Category#3
Suggested Learning Path
Project 1 and 2 can be completed in one afternoon each. Project 3 is the most important for mastering relationship management — spend extra time on the addProduct helper and orphanRemoval. Project 4 introduces caching and performance monitoring, which are critical for production readiness. Project 5 is a capstone that combines all concepts; it is appropriate for a multi-day hackathon or a week of focused practice.
Production Insight
When building these projects, especially Project 3, pay attention to the number of SQL statements generated. Use spring.jpa.show-sql=true and Hibernate statistics (generate_statistics=true) to detect N+1 patterns and unnecessary lazy loads. After completing Project 3, you will be able to design any JPA data model confidently, including complex inheritance hierarchies and composite keys.
Key Takeaway
Practice is essential for mastering JPA relationships, caching, and performance tuning. Start with simple CRUD (Project 1), progress to one-to-many and N+1 fixes (Project 2), then tackle many-to-many with a join entity (Project 3). Performance monitoring (Project 4) and a full-stack capstone (Project 5) prepare you for production-grade applications.

The Hidden Cost of FetchType.EAGER — Why Your Query Count Explodes

Every junior dev slaps FetchType.EAGER on a relationship and walks away happy. Then production hits 50 concurrent users and the database connection pool drowns in N+1 select storms.

EAGER tells JPA to load related entities immediately. Hibernate doesn't care if you need them — it fires separate SELECTs for every child collection at query time. One findAll() on an Author with EAGER @OneToMany books becomes 1 + N queries. Your DBA will send you a very angry Teams message.

The correct default is FetchType.LAZY. Period. Override on a per-query basis with JOIN FETCH or @EntityGraph. You control when the relationship loads, not the framework. Production systems are built on lazy loading + explicit fetch plans.

NPlusOneDetective.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// io.thecodeforge — java tutorial
// Shows the N+1 explosion and the fix

@Entity
public class Author {
    @Id @GeneratedValue
    private Long id;
    private String name;

    // THIS IS BROKEN IN PRODUCTION:
    @OneToMany(fetch = FetchType.EAGER, mappedBy = "author")
    private List<Book> books; // 1 query for author, N for each book
}

// Fix: LAZY + explicit JOIN FETCH
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {
    @Query("SELECT a FROM Author a JOIN FETCH a.books WHERE a.id = :id")
    Optional<Author> findByIdWithBooks(@Param("id") Long id);
}

// Or use @EntityGraph
@EntityGraph(attributePaths = {"books"})
Optional<Author> findByName(String name);
Output
LAZY: 1 query (without books). EAGER: 1+11 queries for 10 authors.
Production Trap: EAGER on @OneToMany
Never use EAGER on collections. It's a default you will regret. Always start LAZY, then optimize with joins.
Key Takeaway
EAGER is a footgun. LAZY is the default. Use JOIN FETCH or @EntityGraph when you actually need the data.

Transaction Boundaries — The Silent Data Corruption Culprit

Spring Data JPA makes transactions invisible. You call save() and it works. Until it doesn't.

Without explicit @Transactional, each JPA operation runs in its own autocommit transaction. Two concurrent saves on the same aggregate — one writes, the other writes, and you get a partial update. No rollback. No error. Just silently corrupted data.

Service-layer @Transactional is your shield. It guarantees atomicity: all writes in the method either commit or rollback as a unit. It also merges your persistence context so Hibernate can flush dirty entities in the right order. Forget it, and you'll chase heisenbugs that only show up under load.

Rule: Every public method that performs >1 write must be @Transactional. Inside that boundary, use save() and flush() explicitly if you need the ID immediately. Outside, stay clean.

TransactionalGuard.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — java tutorial
// Demonstrating proper transaction boundaries

@Service
public class OrderService {
    private final OrderRepository repo;
    private final InventoryClient client;

    // WITHOUT @Transactional — dangerous
    public Order createAndNotify(Order order) {
        Order saved = repo.save(order);           // tx 1: commits immediately
        client.notifyWarehouse(saved.getId());     // tx 2: separate connection
        return saved;                              // what if client throws? No rollback
    }

    // WITH @Transactional — safe
    @Transactional
    public Order createAndNotifySafe(Order order) {
        Order saved = repo.save(order);
        client.notifyWarehouse(saved.getId());
        return saved;
    }
}
Output
Without @Transactional: order saves even if notifyWarehouse fails.
With @Transactional: all or nothing — rollback on failure.
Senior Shortcut: ReadOnly optimization
Mark read-only service methods with @Transactional(readOnly = true). Hibernate skips dirty checking, saving flush cycles. Use on GET endpoints that only query.
Key Takeaway
Wrap every multi-write method with @Transactional. Don't trust implicit autocommit in production.

The Join Table Trap — @ManyToMany with Extra Columns

Out of the box, @ManyToMany creates a join table with only two foreign keys. Fine for simple tag systems. But the moment you need a column like assigned_date or role on the relationship itself, this abstraction breaks.

Hibernate's @ManyToMany cannot map extra columns on the join table. It treats the join table as a black box. Your options: use a surrogate ID and make the join table a full @Entity, or refactor to two @OneToMany relationships pointing to an intermediate entity.

The intermediate entity approach is production-proven. You create an EmployeeProject entity with @ManyToOne to both sides, plus any extra columns. This gives you full control over cascading, auditing, and complex queries. It's a few more lines of code but saves you from rewriting migrations later.

ManyToManyWithPayload.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// io.thecodeforge — java tutorial
// Refactor @ManyToMany with extra columns

@Entity
public class EmployeeProject {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "employee_id")
    private Employee employee;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "project_id")
    private Project project;

    private LocalDate assignedDate;
    @Enumerated(EnumType.STRING)
    private Role role; // e.g., LEAD, CONTRIBUTOR
}

@Entity
public class Employee {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<EmployeeProject> assignments;
}
Output
EmployeeProject table:
| id | employee_id | project_id | assigned_date | role |
|----|-------------|------------|---------------|-----------|
| 1 | 101 | 201 | 2024-01-15 | LEAD |
Refactoring Pattern: Intermediate Entity
When you need metadata on a join (like timestamps, status, role), upgrade from @ManyToMany to a dedicated entity with two @ManyToOne. Your DBA will love you.
Key Takeaway
Don't fight @ManyToMany when you need extra columns. Refactor to an intermediate entity with full ORM control.

Starting with Spring Initializr — Don't Guess Your Dependencies

Every project begins with one decision: what goes in the build file. Spring Initializr (start.spring.io) is the only sane starting point. Click, not grep. You pick your stack, it spits out a zip with zero XML hell. No more hunting for the right Spring Boot starter version that works with Hibernate 6. No more copy-pasting dependencies from Stack Overflow that silently break on Java 21.

The WHY: Maven/Gradle dependency graphs are icebergs. Spring Boot starters abstract the version alignment so you don't have to remember that spring-boot-starter-data-jpa pulls in Hibernate, Tomcat pool, and transaction managers. One checkbox on Initializr does what ten minutes of POM surgery used to. The HOW: Select Spring Web, Spring Data JPA, MySQL Driver, Flyway. That's it. Build the zip, open in IDE, delete the test you'll never write. Production starts here, not in the XML graveyard.

ProductService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — java tutorial

// Generated by Spring Initializr — no manual dependency hell
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductService {

    private final ProductRepository repo;

    public ProductService(ProductRepository repo) {
        this.repo = repo;
    }

    @Transactional
    public Product createProduct(String name, double price) {
        Product p = new Product();
        p.setName(name);
        p.setPrice(price);
        return repo.save(p);
    }
}
Output
// No output — this is runtime behavior.
// On success: returns Product object with generated ID.
// On failure: throws DataIntegrityViolationException (not silence).
Senior Shortcut:
Always add Flyway from Initializr, not after. Adding migrations retroactively means creating a baseline migration that reflects your current schema — one wrong step and your prod DB thinks it's 1970.
Key Takeaway
Spring Initializr is not a starter tool. It's the dependency contract. Change your build file manually? You own the version hell.

Switching Between Databases — H2 Dev, MySQL Prod, Zero Config Swap

You test against H2 in-memory because MySQL on your laptop is 500MB of pain. Then production uses MySQL. The gap between them kills you when H2 tolerates your sloppy SQL but MySQL rejects it. The fix: abstract your database switch behind Spring profiles. One property file per environment. No code changes, no conditional beans, no @Profile spaghetti on every repository.

The WHY: JPA abstracts most SQL dialects — but not all. H2 uses MOD() for modulo, MySQL uses %. H2 ignores foreign key names, MySQL enforces them. If you hardcode native queries or rely on H2's lenient syntax, your production deployment becomes a crisis at 2 AM. The HOW: Set spring.datasource.url, driver-class-name, and dialect per profile. Use Flyway with versioned migrations that target MySQL syntax from day one. Test locally with MySQL Testcontainers or H2 in MySQL mode. Never trust H2 compatibility mode blindly — it's a best-effort mask, not a guarantee.

application-dev.ymlJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — java tutorial

spring:
  profiles: dev
  datasource:
    url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: validate  # Catch schema drift early
---
spring:
  profiles: prod
  datasource:
    url: jdbc:mysql://prod-rds:3306/mydb?useSSL=false&allowPublicKeyRetrieval=true
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    database-platform: org.hibernate.dialect.MySQLDialect
Output
// No direct output.
// Run with: java -jar app.jar --spring.profiles.active=dev -> uses H2
// Run with: java -jar app.jar --spring.profiles.active=prod -> uses MySQL
Production Trap:
H2 in MySQL mode still accepts invalid GROUP BY columns that MySQL would reject in strict mode. Run your Flyway migrations against a real MySQL container before deploys — or suffer the ALTER TABLE rollback at 3 AM.
Key Takeaway
Profiles are the switch. Profile-specific properties eliminate database coupling. If both environments don't agree on schema constraints, you're deploying a time bomb.

Create a Packaging Structure

A clear packaging structure prevents spaghetti code and makes your JPA project maintainable from day one. Without it, you end up stuffing repositories, services, and controllers into flat folders — a nightmare when your app grows beyond 10 entities.

Start with a domain-driven layout. Group packages by feature, not by layer. For a user management module, structure like:

  • com.example.user — contains User, UserRepository, UserService, UserController.
  • com.example.user.dto — request/response DTOs like UserCreateRequest.
  • com.example.order — same pattern for orders.

The reason: when you need to change a feature, you touch one package, not six scattered folders. JPA entities stay close to their repositories, reducing import confusion and circular dependencies. For cross-cutting concerns (config, security, exception handling), use a com.example.config or com.example.common package. This structure scales to 50+ entities without refactoring.

Avoid entity/, repository/, service/ top-level packages — that's layer-first, not feature-first, and hides which entities actually connect.

PackageStructureExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — java tutorial

// Bad: layer-first structure (avoid)
// com.example.entity.User
// com.example.repository.UserRepository
// com.example.service.UserService

// Good: feature-first structure
package com.example.user;

import jakarta.persistence.*;

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String email;
}

// UserRepository stays in same package
// UserService stays in same package
// UserController stays in same package
Output
package com.example.user;
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
}
Production Trap:
Feature-first packaging breaks down if you share DTOs across features. In that case, a com.example.common.dto package is acceptable — but keep entity-specific DTOs inside the feature package.
Key Takeaway
Group by feature, not by layer — each feature package holds its entity, repository, service, and controller.

🚀 Next Steps

You've built your Spring Boot + JPA + MySQL foundation. Now push beyond CRUD: real-world apps demand pagination, auditing, and query optimization.

First, implement PagingAndSortingRepository. Your user listing endpoint should never return 10,000 records — always page with Pageable and return Page<User>. This cuts database load and frontend rendering time.

Second, add Spring Data JPA auditing. Annotate your base entity with @CreatedDate, @LastModifiedDate using @EntityListeners(AuditingEntityListener.class) and enable it with @EnableJpaAuditing. You'll track who changed what without writing manual timestamps.

Third, optimize N+1 queries. Replace @ManyToOne(fetch = FetchType.EAGER) with explicit @EntityGraph or JOIN FETCH in custom queries. Profile your endpoints with spring.jpa.show-sql=true and look for repeated SELECTs.

Finally, write integration tests with @DataJpaTest and an embedded H2 database. Test your custom query methods and cascade behavior. A failing test catches a silent data corruption bug before it hits production.

Stack these four steps and your app transforms from a tutorial toy into a production-ready system.

PagingExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// io.thecodeforge — java tutorial

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
    Page<User> findByActiveTrue(Pageable pageable);
}

// Usage in service:
// Page<User> users = userRepository.findByActiveTrue(
//     PageRequest.of(0, 20, Sort.by("createdAt").descending())
// );
Output
Page<User> users = userRepository.findByActiveTrue(
PageRequest.of(0, 20, Sort.by("createdAt").descending())
);
Production Trap:
Without paging, your H2 dev database may handle 10k rows fine, but MySQL on production will choke and timeout. Always default to paginated endpoints.
Key Takeaway
After building CRUD, add pagination, auditing, N+1 fixes, and integration tests — in that order.
● Production incidentPOST-MORTEMseverity: high

The Connection Pool Exhaustion That Killed Black Friday — OSIV and HikariCP Misconfiguration

Symptom
Every API endpoint returned a timeout error after 30 seconds. Application logs filled with 'HikariPool-1 - Connection is not available, request timed out after 30000ms' errors. MySQL's SHOW PROCESSLIST showed only 10 active connections — the pool maximum — all in a 'Sleep' state, not actually executing SQL. The application had 500 requests queued waiting for a connection that would never be released. The load balancer health checks started failing, and the deployment team began emergency scaling — which made things worse by adding more application instances that each immediately saturated their own 10-connection pools against the same MySQL instance.
Assumption
The team assumed MySQL was the bottleneck — CPU graphs showed a spike and they immediately scaled the database instance from db.r5.large to db.r5.4xlarge and added two read replicas. The database was never the problem: CPU was at 5%, query execution time was under 10ms, and MySQL max_connections was 500 with only 10 used. The actual bottleneck was invisible in every dashboard they checked because nobody was watching HikariCP's pending thread count — the metric that would have told them 490 threads were waiting for a connection that 10 sleeping connections were holding indefinitely.
Root cause
spring.jpa.open-in-view was left at its Spring Boot default value of true. With OSIV enabled, each HTTP request acquires a database connection at the beginning of the request lifecycle — before any controller code runs — and holds that connection open until the HTTP response is fully written back to the client. This includes time spent in the controller, time spent serializing the response to JSON, time spent in any Spring filter or interceptor that runs after the controller returns, and time waiting for the client's TCP stack to acknowledge the response. A typical checkout API call on this platform executed SQL in 8ms but spent 2,200ms serializing a complex order object with nested product data to JSON — because lazy-loaded product associations were being triggered during Jackson serialization outside any explicit transaction boundary, and OSIV was keeping the connection open to service those lazy loads. With a pool of 10 connections and requests holding each connection for 2,200ms rather than 8ms, the effective throughput ceiling was 10 / 2.2 = 4.5 requests per second. At Black Friday traffic volumes the queue depth hit 500 in under two minutes.
Fix
Set spring.jpa.open-in-view=false immediately as an emergency configuration change deployed without a full release. This alone reduced average connection hold time from 2,200ms to 12ms and cleared the queue within 90 seconds of the change propagating. Followed up with: increased HikariCP maximum-pool-size from 10 to 20 using the formula (core_count * 2) + effective_spindle_count for the application server. Fixed the lazy-loading root cause by adding JOIN FETCH to the order query so all required associations were loaded within the transaction boundary — no lazy loads during serialization. Added HikariCP metrics to the existing Prometheus/Grafana stack: hikaricp.connections.active, hikaricp.connections.pending, and hikaricp.connections.timeout. Added a Grafana alert on hikaricp.connections.pending > 2 sustained for 60 seconds — this would have fired 8 minutes before the outage escalated.
Key lesson
  • OSIV holds database connections for the entire HTTP request lifecycle including JSON serialization — disable it in production with spring.jpa.open-in-view=false from day one, not after your first connection pool outage
  • HikariCP pool size must be tuned to your actual workload — the default of 10 is appropriate for local development and nothing else; calculate it as (core_count * 2) + effective_spindle_count
  • Monitor hikaricp.connections.pending, not just hikaricp.connections.active — pending threads are the leading indicator of pool exhaustion and fire minutes before requests start timing out
  • Connection pool exhaustion cascades — when all connections are held, every new request queues and eventually times out; the platform appears down even though MySQL is healthy and barely utilized
  • Emergency scaling made this incident worse — adding application instances multiplied the connection demand against the same MySQL instance without increasing pool capacity proportionally
Production debug guideWhen Spring Boot with MySQL and JPA behaves unexpectedly in production, here is how to go from observable symptom to root cause to resolution. These are the paths I have walked through actual incidents — not hypothetical scenarios.6 entries
Symptom · 01
Application hangs on every database query — requests timeout after 30 seconds
Fix
Check HikariCP pool state first: GET /actuator/metrics/hikaricp.connections.active and /actuator/metrics/hikaricp.connections.pending. If active equals maximum-pool-size and pending is greater than zero, the pool is exhausted — not MySQL. Run SHOW PROCESSLIST on MySQL to confirm: if you see connections in 'Sleep' state for longer than your average request duration, OSIV is the culprit. Check spring.jpa.open-in-view — if it is true or not set (defaults to true), set it to false and redeploy. Check for slow queries holding connections: SELECT * FROM information_schema.processlist WHERE command != 'Sleep' ORDER BY time DESC. Do not scale the database until you have confirmed the database is actually the bottleneck.
Symptom · 02
Queries that are fast in MySQL Workbench are slow in the application — 50ms in isolation, 2 seconds from the API
Fix
Enable spring.jpa.show-sql=true temporarily and count the SELECT statements for a single API request. If you see more than 3–4 selects for what should be a single query, you have an N+1 problem. Check if LAZY-loaded collections are being initialized outside a @Transactional context — each initialization triggers an additional roundtrip. Run EXPLAIN on the generated SQL in MySQL Workbench to verify the query plan is using indexes. Check if Hibernate is selecting all columns when only a subset are needed — consider DTO projections for read-heavy endpoints.
Symptom · 03
Duplicate key exceptions on insert — even though the data looks unique from the application's perspective
Fix
Check the @GeneratedValue strategy — GenerationType.IDENTITY relies on MySQL auto_increment and must match the column definition exactly. GenerationType.SEQUENCE requires a database sequence object that MySQL 8.0 supports but requires explicit setup. Check if the same entity is being passed to save() twice in the same @Transactional block — in some cases the Persistence Context detects it as a new entity both times. Check for concurrent requests hitting the same insert path: add a UNIQUE constraint at the database level and handle DataIntegrityViolationException at the service layer rather than assuming the application prevents duplicates.
Symptom · 04
LazyInitializationException — failed to lazily initialize a collection of role
Fix
The collection or association was accessed after the @Transactional boundary closed and the Persistence Context was released. Three fixes in order of preference: (1) add JOIN FETCH to the repository query so the association is loaded within the transaction, (2) add @EntityGraph(attributePaths = {"roles"}) to the repository method to load the association eagerly without modifying the JPQL, (3) ensure the service method that accesses the collection is annotated @Transactional so the Persistence Context stays open. Never enable OSIV as a workaround for LazyInitializationException in production.
Symptom · 05
Schema validation fails on startup — 'Schema-validation: wrong column type encountered in column' or 'Schema-validation: missing column'
Fix
The entity definition does not match the MySQL table schema. Run a manual schema diff: compare the column type, nullable constraint, length, precision, and scale in your @Column annotation against the actual column in information_schema.columns. BigDecimal precision/scale mismatches are particularly common — @Column(precision=10, scale=2) must match DECIMAL(10,2) exactly. Check that Flyway migrations are running before Hibernate schema validation — if Flyway has not applied the latest migration, validation will fail against the old schema. Set spring.jpa.hibernate.ddl-auto=none temporarily and run Flyway migrations manually to diagnose migration ordering issues.
Symptom · 06
OutOfMemoryError during batch processing — heap fills up during bulk inserts or bulk updates
Fix
JPA's Persistence Context caches every entity loaded or persisted during a transaction as a managed object. Inserting 100,000 entities in a single transaction without clearing the Persistence Context means all 100,000 entity objects plus their snapshot copies for dirty checking live in heap simultaneously. Fix: call entityManager.flush() followed by entityManager.clear() every N entities (500 is a reasonable starting point). Enable Hibernate batch inserts with hibernate.jdbc.batch_size=500 and hibernate.order_inserts=true to reduce roundtrips. If memory pressure persists, capture a heap dump with jcmd $(pgrep -f spring-boot) GC.heap_dump /tmp/heap.hprof and analyze with Eclipse MAT to confirm the Persistence Context is the source.
★ JPA/MySQL Performance Debugging Cheat SheetQuick-reference commands for diagnosing JPA and MySQL performance issues in production. Each card maps a symptom to the exact commands you need — copy, paste, diagnose.
Connection pool exhaustion — 'HikariPool-1 - Connection is not available, request timed out' errors
Immediate action
Check HikariCP pool state and MySQL process list to determine whether connections are truly busy or just held open by OSIV
Commands
curl -s http://localhost:8080/actuator/metrics/hikaricp.connections.pending | jq '.measurements[] | select(.statistic=="VALUE") | .value'
mysql -u forge_user -p -e "SELECT command, state, time, info FROM information_schema.processlist WHERE command != 'Sleep' ORDER BY time DESC LIMIT 20;"
Fix now
If pending > 0: set spring.jpa.open-in-view=false and increase spring.datasource.hikari.maximum-pool-size to (core_count * 2) + 1. If all connections show Sleep state with long time values, OSIV is the definitive culprit.
N+1 query problem — hundreds of SQL queries for a single API request, response time 10x slower than expected+
Immediate action
Enable SQL logging temporarily and count SELECT statements per request to confirm N+1
Commands
grep -c 'Hibernate: select' /var/log/app/application.log
grep 'Hibernate: select' /var/log/app/application.log | sort | uniq -c | sort -rn | head -10
Fix now
Identify the repeated select pattern (same table, different ID). Add JOIN FETCH to the JPQL query or add @EntityGraph(attributePaths = {"<collection-name>"}) to the repository method. Re-run and confirm query count drops to 1–2.
Slow queries — API p99 latency spiked from 50ms to 5 seconds, database CPU elevated+
Immediate action
Identify which queries are slow in MySQL and check if they are using indexes
Commands
mysql -u forge_user -p -e "SELECT query_time, sql_text FROM mysql.slow_log ORDER BY query_time DESC LIMIT 10;"
mysql -u forge_user -p -e "EXPLAIN SELECT * FROM forge_products WHERE category_id = 42 AND status = 'ACTIVE';"
Fix now
If EXPLAIN shows 'type: ALL' (full table scan), add an index: ALTER TABLE forge_products ADD INDEX idx_category_status (category_id, status). Enable MySQL slow query log if not already on: SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;
OutOfMemoryError during batch processing — heap fills up during bulk inserts or exports+
Immediate action
Capture heap histogram to confirm Persistence Context entity accumulation is the source
Commands
jcmd $(pgrep -f spring-boot) GC.heap_dump /tmp/heapdump.hprof
jcmd $(pgrep -f spring-boot) GC.class_histogram | grep -E 'ForgeProduct|EntityEntry|StatefulPersistence' | head -10
Fix now
Add entityManager.flush() and entityManager.clear() every 500 entities in the batch loop. Add spring.jpa.properties.hibernate.jdbc.batch_size=500 and spring.jpa.properties.hibernate.order_inserts=true to enable true JDBC batching.
Traditional JDBC vs. Spring Boot JPA
AspectTraditional JDBCSpring Boot JPA
BoilerplateHigh — manual Connection acquisition from pool, PreparedStatement creation, ResultSet iteration, and field-by-field mapping for every query. A simple findById is 20+ lines.Zero — @Entity annotation and JpaRepository interface. The save(), findById(), findAll(), deleteById() implementations are generated at startup. A complete CRUD repository is 5 lines.
SQL WritingRequired for every operation including the simplest CRUD. You write, maintain, and test every SQL string — including handling dialect differences between MySQL versions.Optional for standard CRUD — methods like save() and findById() are dynamically generated. Custom queries use JPQL (database-agnostic) or native SQL for MySQL-specific features.
Database PortabilityLow — SQL strings are often written for a specific MySQL version or syntax. Migrating to PostgreSQL or another engine requires rewriting queries.High — Hibernate dialects translate JPQL to the correct SQL for each database engine. Switching from MySQL to PostgreSQL in tests is a configuration change, not a code change.
Transaction ManagementManual — conn.setAutoCommit(false), conn.commit(), and conn.rollback() on every method that requires transactional semantics. Easy to forget, easy to leak.Declarative — add @Transactional to the service method and Spring handles commit on success and rollback on any RuntimeException. Transaction boundaries are visible and auditable.
Performance VisibilityHigh — you write every SQL statement, so you know exactly what runs, when it runs, and what parameters it uses. No hidden queries, no surprise N+1 patterns.Lower by default — Hibernate generates SQL behind the scenes. N+1 queries, unnecessary full-column SELECTs, and redundant dirty-check flushes are invisible without explicit SQL logging.
Connection PoolingManual setup required — you choose HikariCP, DBCP2, or C3P0, add the dependency, and configure it explicitly. Nothing is auto-configured.Auto-configured — HikariCP is on the classpath via spring-boot-starter-data-jpa and configured from spring.datasource.hikari.* properties. Zero code required for basic pooling.
Batch OperationsFull control — PreparedStatement.addBatch() and executeBatch() give you explicit JDBC-level batching. Best raw throughput for bulk inserts and updates.Requires explicit configuration — hibernate.jdbc.batch_size, hibernate.order_inserts, and rewriteBatchedStatements=true in the JDBC URL. Also requires entityManager.flush()/clear() to prevent Persistence Context heap exhaustion.
Best forHigh-throughput bulk operations, reporting queries returning millions of rows, MySQL-specific features (window functions, JSON operations), and teams that need deterministic SQL output.Transactional CRUD operations, REST API backends, applications where development velocity matters more than maximum query throughput, and teams that want schema portability.

Key takeaways

1
Spring Boot with MySQL and JPA maps Java objects to MySQL tables via annotations
Hibernate generates SQL, Spring Data generates CRUD repository implementations, and Spring Boot auto-configures HikariCP. What took 300 lines of JDBC boilerplate now takes 30 lines of entity class and a repository interface.
2
Spring Boot 3.2.x uses the jakarta.persistence namespace
if you are migrating from 2.x, update every @Entity, @Column, @Transactional, and @Id import from javax to jakarta. This is a breaking change with no behavioral difference.
3
Spring Data JPA repositories eliminate DAO boilerplate
method-name-derived queries (findByNameContainingIgnoreCase) are parsed and validated at startup, not at runtime. A startup failure means your method name is wrong — a runtime failure means your logic is wrong. This distinction saves significant debugging time.
4
Always use BigDecimal for monetary and financial fields, mapped to MySQL DECIMAL(precision, scale). Float and Double use IEEE 754 binary floating-point which cannot represent decimal fractions exactly
the resulting rounding errors accumulate silently across thousands of transactions.
5
The N+1 problem is invisible in development and catastrophic in production. Enable spring.jpa.show-sql=true during development, count SELECT statements per request, and fix any linear growth in query count with JOIN FETCH or @EntityGraph before the code ships.
6
Set spring.jpa.open-in-view=false unconditionally in production. OSIV holds database connections for the entire HTTP request lifecycle
including serialization time — which reduces your effective connection pool throughput by orders of magnitude under real load.
7
Set spring.jpa.hibernate.ddl-auto=validate in production and use Flyway for schema migrations. Hibernate should never autonomously modify your production schema
validation gives you the startup-time schema mismatch detection without the risk of data loss.
8
Monitor HikariCP metrics in production
hikaricp.connections.pending is the leading indicator of pool exhaustion and fires minutes before requests start timing out. Alert on any non-zero value sustained for 60 seconds.

Common mistakes to avoid

6 patterns
×

Overusing JPA for high-performance read-only queries — reporting and analytics endpoints

Symptom
Reporting endpoints that return 50,000+ rows take 30–60 seconds with JPA. Memory usage spikes to several gigabytes, GC pause frequency increases, and the application becomes unresponsive during large report generation. The SQL itself runs in 200ms in MySQL Workbench — the bottleneck is Hibernate materializing every row as a managed entity in the Persistence Context.
Fix
Use JdbcTemplate or @Query(nativeQuery = true) with a DTO projection for bulk read-only queries. JPA is optimized for transactional CRUD, not analytical reporting. For JPQL-based projections, use a constructor expression to avoid entity materialization: SELECT new io.thecodeforge.dto.ProductSummary(p.id, p.name, p.price) FROM ForgeProduct p WHERE p.categoryId = :catId. This returns DTOs instead of managed entities — no Persistence Context overhead, no dirty checking, no snapshot copies.
×

Calling repository.save() on a managed entity within a @Transactional method

Symptom
Developers call repository.save(entity) after modifying a managed entity that was loaded within the same @Transactional method. This triggers an additional SELECT (Hibernate checking whether to INSERT or UPDATE via SimpleJpaRepository.save()) followed by a redundant UPDATE — even though Hibernate's dirty checking would have generated the same UPDATE automatically at transaction commit. In methods that process hundreds of entities, this doubles the SQL statement count.
Fix
Understand Hibernate dirty checking: entities loaded within a @Transactional method are managed. Modifying any field on a managed entity is automatically detected and flushed as an UPDATE at commit — no save() call required. Only call save() when: (1) the entity is new and has never been persisted, or (2) the entity was loaded outside a transaction (detached state) and you need to merge it back. Annotate query-only service methods with @Transactional(readOnly = true) — this tells Hibernate to skip dirty checking entirely, reducing flush overhead for pure read workloads.
×

Ignoring the N+1 Select Problem — accessing lazy collections without JOIN FETCH

Symptom
An API endpoint that returns 200 categories with their associated products executes 1 query for categories and 200 queries for products — one per category. In development with 10 categories this is 11 queries and takes 15ms. In production with 200 categories this is 201 queries and takes 3 seconds. The SQL log shows hundreds of identical SELECT statements against the forge_products table with different category_id values — the unmistakable signature of N+1.
Fix
Three approaches in order of preference: (1) JOIN FETCH in JPQL — SELECT c FROM Category c JOIN FETCH c.products WHERE c.active = true. One SQL JOIN query instead of N+1 separate selects. (2) @EntityGraph on the repository method — @EntityGraph(attributePaths = {"products"}) List<Category> findByActiveTrue(). Equivalent result, better separation between the query strategy and the JPQL. (3) @BatchSize(size = 25) on the collection field — Hibernate loads products in batches of 25 instead of one-by-one, reducing N+1 to ceil(N/25)+1 queries. Use @BatchSize when you cannot modify the query but need to reduce roundtrip count.
×

Keeping Open Session in View (OSIV) enabled in production

Symptom
Database connections are held for the entire HTTP request lifecycle — from the first line of DispatcherServlet.doDispatch() to the last byte of the HTTP response. With a 20-connection pool and serialization taking 500ms per request, the effective throughput ceiling is 40 requests per second, regardless of how fast the SQL runs. Under moderate traffic spikes, hikaricp.connections.pending climbs above zero and requests start queuing. Under sustained load, the queue grows unbounded and requests timeout.
Fix
Set spring.jpa.open-in-view=false. Connections are released immediately when the @Transactional method returns — the pool is available for the next request while serialization continues on the current request. The consequence: any lazy-loaded association accessed outside a @Transactional method will throw LazyInitializationException. Treat this as correct behavior — it forces you to explicitly define what data is loaded in the transaction boundary using JOIN FETCH or @EntityGraph. This produces better-designed code, not just better performance.
×

Using Double or Float for financial or monetary fields

Symptom
Financial calculations show rounding discrepancies — $10.01 stored as Double may retrieve as 10.009999999999999. Across thousands of transactions, these sub-cent errors accumulate into reportable discrepancies. Auditors flag the system. Financial reconciliation fails. The errors are silent — no exception is thrown, no warning is logged — because Double arithmetic is technically correct for binary floating-point, just not for decimal arithmetic.
Fix
Use BigDecimal for every monetary field. Map to MySQL DECIMAL with explicit precision and scale: @Column(precision = 12, scale = 2) maps to DECIMAL(12,2) in MySQL. Never use Float or Double for any value that will be added, multiplied, or compared across transactions. For arithmetic operations, always use BigDecimal's own methods: bigDecimal.add(), bigDecimal.multiply(), bigDecimal.setScale(2, RoundingMode.HALF_UP). Do not convert to Double for calculation and back to BigDecimal — the precision loss survives the roundtrip.
×

Not setting ddl-auto=validate in production and not using Flyway for schema migrations

Symptom
A developer accidentally left ddl-auto=create-drop on a production profile during a deployment. The application started, Hibernate dropped all tables, recreated them empty, and the application then ran against a database with no data. In a less catastrophic scenario, ddl-auto=update silently modified column precision from DECIMAL(12,2) to DECIMAL(10,2) because a developer changed the @Column annotation precision — truncating all price values with more than 10 digits.
Fix
Set spring.jpa.hibernate.ddl-auto=validate in every environment except local development. Validation checks that entity annotations match the actual schema at startup and fails the application if they do not — it never modifies the schema. Manage schema changes with Flyway: SQL migration files named V1__initial_schema.sql, V2__add_product_index.sql are stored in src/main/resources/db/migration. Spring Boot auto-configures Flyway when flyway-core is on the classpath — migrations run automatically on startup before Hibernate schema validation. Schema changes are versioned, reviewed in pull requests, and auditable in Git history.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the internal difference between FetchType.LAZY and FetchType.EAG...
Q02SENIOR
Describe three different ways to solve the N+1 Select Problem in Spring ...
Q03SENIOR
How does Hibernate's First Level Cache work, and how does it differ from...
Q04SENIOR
What is LazyInitializationException and why does it occur after a transa...
Q05SENIOR
Explain Hibernate's Dirty Checking mechanism. How does Hibernate know wh...
Q06SENIOR
How do you configure HikariCP for optimal performance, and what metrics ...
Q01 of 06SENIOR

What is the internal difference between FetchType.LAZY and FetchType.EAGER? In what scenario would LAZY still cause an exception?

ANSWER
FetchType.EAGER loads the associated entity or collection in the same SQL query as the parent — typically via an outer JOIN. The association is available immediately after the parent is loaded, regardless of whether you are inside or outside a transaction. FetchType.LAZY defers loading until the association is accessed — Hibernate creates a proxy object (a subclass of the target entity generated by ByteBuddy at runtime) that intercepts the first getter call and executes a SELECT at that point. LAZY is the default for @OneToMany and @ManyToMany collections. EAGER is the default for @ManyToOne and @OneToOne in older JPA spec versions — this default causes many accidental N+1 patterns because every @ManyToOne join is fetched on every query, even when you do not need it. LAZY throws LazyInitializationException when the proxy's getter is called after the Persistence Context has been closed. This happens when: (1) the parent entity was loaded in a @Transactional service method and returned to a controller or test that accesses the lazy collection after the transaction committed, (2) OSIV is disabled (spring.jpa.open-in-view=false) and a lazy collection is accessed during JSON serialization, or (3) the entity was serialized to a session or cache and deserialized — the proxy loses its EntityManager reference. Fixes in order of preference: JOIN FETCH in the query to load the association within the transaction, @EntityGraph on the repository method, or ensuring the collection access happens within the @Transactional boundary.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
When should I use JPA vs JdbcTemplate?
02
What is the difference between JPQL and native SQL in Spring Data JPA?
03
How do I handle database schema migrations in production?
04
What is the difference between @Transactional(readOnly=true) and @Transactional()?
05
How do I batch insert 100,000 entities without running out of memory?
06
How do I monitor JPA and MySQL performance in production?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Spring Boot. Mark it forged?

11 min read · try the examples if you haven't

Previous
Spring Boot Annotations Cheat Sheet
6 / 21 · Spring Boot
Next
Spring Boot Exception Handling