Senior 6 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.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
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
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.

What Is Spring Boot with MySQL and JPA and Why Does It Exist?

Spring Boot with MySQL and JPA exists to solve the 'Impedance Mismatch' — the fundamental structural difference between how Java models data (objects with fields and relationships) and how MySQL stores data (tables with rows and foreign keys). Bridging these two models manually through JDBC is possible but expensive: you write SQL strings, manage ResultSet iteration, map columns to fields, handle null values explicitly, manage connection lifecycle, and implement transactions by hand. Every CRUD operation is dozens of lines of infrastructure code that contributes nothing to the business problem.

JPA — the Java Persistence API, implemented by Hibernate — makes this mapping declarative. You annotate a Java class with @Entity and its fields with @Column, and Hibernate generates the SQL at startup. Spring Data JPA wraps Hibernate with a Repository abstraction that generates CRUD operations from an interface definition with zero implementation code. Spring Boot auto-configures HikariCP (the fastest connection pool in the JVM ecosystem), a Hibernate SessionFactory, and the transaction manager — all from the properties you provide in application.properties.

The result: what took 300 lines of JDBC boilerplate takes 30 lines of annotated entity class and a one-line repository interface. That is the reason this stack is the default choice for Java database persistence in 2026.

With Spring Boot 3.2.x, the namespace has migrated from javax.persistence to jakarta.persistence — all imports in this guide use the jakarta package. If you are migrating from Spring Boot 2.x, this is a breaking change that requires updating every @Entity, @Column, @Transactional, and @Id import in your codebase. The behavior is identical; only the package name changed.

io/thecodeforge/persistence/model/ForgeProduct.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
84
85
86
87
88
89
90
91
92
package io.thecodeforge.persistence.model;

import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(
    name = "forge_products",
    indexes = {
        // Declare indexes here so Hibernate schema validation knows about them.
        // For production, create indexes in Flyway migrations, not via ddl-auto.
        @Index(name = "idx_product_name", columnList = "name"),
        @Index(name = "idx_product_category", columnList = "category_id")
    }
)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ForgeProduct {

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

    // nullable = false maps to NOT NULL in the schema — Hibernate validates this
    @Column(nullable = false, length = 100)
    private String name;

    // ALWAYS BigDecimal for money — never Double or Float.
    // precision = total digits, scale = digits after decimal point.
    // This maps to DECIMAL(12,2) in MySQL.
    @Column(nullable = false, precision = 12, scale = 2)
    private BigDecimal price;

    @Column(name = "category_id")
    private Long categoryId;

    // @CreationTimestamp — Hibernate sets this on INSERT, never on UPDATE.
    // updatable = false enforces this at the JPA column level too.
    @CreationTimestamp
    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    // @UpdateTimestamp — Hibernate updates this on every UPDATE automatically.
    @UpdateTimestamp
    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;
}

// --- Repository Interface ---
// No implementation class. Spring Data generates the implementation at startup
// via JDK dynamic proxies. The method names are parsed into JPQL at boot time.

package io.thecodeforge.persistence.repository;

import io.thecodeforge.persistence.model.ForgeProduct;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;

@Repository
public interface ProductRepository extends JpaRepository<ForgeProduct, Long> {

    // Method-name-derived query — Spring Data parses this at startup.
    // Generates: SELECT * FROM forge_products WHERE LOWER(name) LIKE LOWER(?)
    List<ForgeProduct> findByNameContainingIgnoreCase(String name);

    // Custom JPQL for anything method names cannot express cleanly.
    // JPQL uses entity class names and field names, not table/column names.
    @Query("SELECT p FROM ForgeProduct p WHERE p.price > :minPrice ORDER BY p.price DESC")
    List<ForgeProduct> findExpensiveProducts(@Param("minPrice") BigDecimal minPrice);

    // Use @Modifying for UPDATE and DELETE — without it, Hibernate treats the
    // query as a SELECT and throws an exception.
    // clearAutomatically = true evicts affected entities from the Persistence
    // Context so subsequent reads reflect the updated values.
    @Modifying(clearAutomatically = true)
    @Query("UPDATE ForgeProduct p SET p.price = p.price * :multiplier WHERE p.categoryId = :categoryId")
    int applyPriceMultiplierByCategory(
        @Param("multiplier") BigDecimal multiplier,
        @Param("categoryId") Long categoryId
    );
}
Output
INFO HikariPool-1 - Starting...
INFO HikariPool-1 - Start completed. Pool size: 20, Idle connections: 10
INFO Hibernate: create table forge_products (
id bigint not null auto_increment,
name varchar(100) not null,
price decimal(12,2) not null,
category_id bigint,
created_at datetime(6) not null,
updated_at datetime(6) not null,
primary key (id)
) engine=InnoDB
INFO Hibernate: create index idx_product_name on forge_products (name)
INFO Hibernate: create index idx_product_category on forge_products (category_id)
JPA Is a Leaky Abstraction — Know What Happens Under the Hood
  • @Entity maps a Java class to a MySQL table — field names become column names by default, @Column overrides naming and adds constraints
  • JpaRepository gives you save(), findById(), findAll(), deleteById() and pagination for free — Spring Data generates the implementation at startup via reflection
  • Method-name-derived queries (findByNameContainingIgnoreCase) are parsed and validated at startup — if the field name does not exist on the entity, the application will not start
  • @Query with JPQL operates on entity class names and field names, not table and column names — it is database-agnostic but Hibernate still validates it at startup
  • The Persistence Context (first-level cache) tracks managed entities — every entity you load or save within a @Transactional method is tracked, and changes are flushed automatically at commit via dirty checking
  • @Modifying is required for UPDATE and DELETE queries — without it, Hibernate refuses to execute the mutation and throws an exception at runtime
Production Insight
A team inherited a codebase where a senior developer had used Double for the price field on a product entity — the reasoning at the time was 'it is just a display value.' Six months later, financial reconciliation reports began showing discrepancies. The gap between what the application calculated and what accounting expected totaled $4,300 across 18,000 transactions. No single transaction was wrong by more than a few cents, which is why it was invisible in testing.
Double uses IEEE 754 floating-point representation. The decimal value 0.1 cannot be represented exactly in binary floating-point — it is a repeating fraction. Add, multiply, and round enough of these inexact values and the errors accumulate. BigDecimal stores the exact decimal value without binary approximation, which is why every financial system in the world uses it.
Rule: any field that represents money, price, quantity, or any value that will be added or multiplied across many records uses BigDecimal with explicit precision and scale. No exceptions. The extra verbosity is worth exactly $4,300 per six months of production.
Key Takeaway
JPA bridges the object-relational impedance mismatch — @Entity annotations define the mapping, Hibernate generates the SQL, and Spring Data generates the CRUD implementations from an interface with no code.
Spring Boot 3.2.x uses the jakarta.persistence namespace — update all imports from javax.persistence when migrating from Spring Boot 2.x.
JPA is a leaky abstraction. Monitor the generated SQL with spring.jpa.show-sql=true during development. If you cannot describe the SQL a repository method generates, you cannot predict its performance under production load.
Choosing Between JPQL, Native SQL, and Method-Name Queries
IfSimple filtering on one or two fields with standard comparisons (equals, contains, in, between)
UseUse method-name-derived queries — findByNameContainingIgnoreCase, findByStatusIn, findByCategoryIdAndPriceBetween. Zero JPQL to write, validated at startup, readable without context.
IfJoins across entities, aggregations (COUNT, SUM), subqueries, or ORDER BY with complex logic
UseUse @Query with JPQL — SELECT p FROM ForgeProduct p JOIN FETCH p.category WHERE p.price > :min ORDER BY p.name. JPQL is database-agnostic and validated by Hibernate at startup.
IfMySQL-specific features: FULLTEXT search, JSON_EXTRACT, window functions (ROW_NUMBER, RANK), or performance-critical queries where you need full control over the execution plan
UseUse @Query(nativeQuery = true) with a SQL string — SELECT * FROM forge_products WHERE MATCH(name, description) AGAINST(:term IN BOOLEAN MODE). Test the query directly in MySQL Workbench first and verify the EXPLAIN plan before wiring it into the repository.
IfBulk UPDATE or DELETE that should not load entities into the Persistence Context
UseUse @Modifying + @Query — UPDATE ForgeProduct p SET p.price = p.price * :multiplier WHERE p.categoryId = :cat. Add clearAutomatically = true to evict affected entities from the first-level cache. Without @Modifying, Hibernate will throw an exception at runtime.

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.

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.
● 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?
🔥

That's Spring Boot. Mark it forged?

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

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