C
Spring Boot/JPA/Lesson 08

JPA — *Working with Databases Using Java Objects*

60 min·theory
This chapter
1/2

JPA — *Working with Databases Using Java Objects*

🎯 After reading this lesson

Once you finish this lesson, you will be able to confidently do the following three things.

  • ✅ Implement CRUD with zero lines of code by simply extending JpaRepository
  • ✅ Fix the N+1 problem using @EntityGraph or JOIN FETCH
  • ✅ Understand Lazy vs Eager loading — and why Lazy is always the right choice in production

Keep the learning objectives as a checklist, and close the lesson only once you can answer all of them.

What is JPA — *Using a Database Without Writing SQL*

The One-Line Summary

JPA (Java Persistence API) = the standard for automatically mapping Java objects to database tables. You can perform full CRUD with nothing but Java method calls — no raw SQL required.

The Old Way — The Pain of JDBC

java
// Fetching a single user
Connection conn = DriverManager.getConnection(...);
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
ps.setLong(1, 42L);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
    User u = new User();
    u.setId(rs.getLong("id"));
    u.setName(rs.getString("name"));
    u.setEmail(rs.getString("email"));
    // ... 30 lines
}
rs.close(); ps.close(); conn.close();

Every single time: manage the connection, write the SQL, map the results — all by hand. With 500 tables that means tens of thousands of lines of repetitive code.

The New Way — The Magic of JPA

java
@Entity
@Table(name = "users")
public class User {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private String email;
}

// Usage
User u = entityManager.find(User.class, 42L);     // 1 line
u.setName("Updated Name");                         // setter call schedules an UPDATE
// Automatically applied when the transaction ends

With no SQL at all, you work exclusively with Java objects and the database stays in sync by itself. "Work in objects, forget about the DB" — the core philosophy of JPA.

Hibernate vs JPA

  • JPA — the standard specification (interfaces). The javax.persistence package
  • Hibernate — the implementation. The most popular one (95%+ market share). EclipseLink and OpenJPA also exist

Spring Data JPA = an abstraction layer from Spring that makes JPA even easier to use. The de facto standard in production.

Entity Mapping — Pairing Objects with Tables

java
@Entity                              // Managed by JPA
@Table(name = "users")                // Target table name
public class User {

    @Id                              // Primary key
    @GeneratedValue(strategy = IDENTITY)  // Auto-increment
    private Long id;

    @Column(nullable = false, length = 100)
    private String name;

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

    @Enumerated(EnumType.STRING)      // enum → string column
    private UserStatus status;

    @CreationTimestamp                 // Auto-set on creation
    private LocalDateTime createdAt;

    @UpdateTimestamp                   // Auto-set on update
    private LocalDateTime updatedAt;
}

Relationship Mapping — Turning Foreign Keys into Object References

java
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne                        // Many-to-one (many orders → one user)
    @JoinColumn(name = "user_id")     // FK column
    private User user;
}

@Entity
public class User {
    @OneToMany(mappedBy = "user")     // One-to-many (one user → many orders)
    private List<Order> orders;
}

The foreign key relationship in the database is translated into Java object references. Navigate naturally, like order.getUser().getName().

The Persistence Context — JPA's Secret Weapon

JPA maintains a Persistence Context — a first-level cache. Within a single transaction, the same entity is fetched from the DB only once.

java
@Transactional
public void update(Long id) {
    User u1 = userRepo.findById(id).get();    // DB SELECT
    User u2 = userRepo.findById(id).get();    // Cache hit, no DB call
    System.out.println(u1 == u2);              // true (same object)

    u1.setName("Changed");
    // No explicit .save() needed → UPDATE fires automatically at transaction end
    // This is "dirty checking"
}

Dirty Checking — JPA compares the object state at the start vs at the end of a transaction and UPDATEs only the changed fields. A setter call is all it takes to persist the change automatically.

Summary

  • JPA = standard for automatic mapping between Java objects and database tables
  • Hibernate = the most popular implementation
  • Spring Data JPA = Spring's abstraction that makes it even easier
  • Annotations handle mapping; relationships become object references; changes are detected automatically

Spring Data JpaRepository — *Automatic Query Generation*

The One-Line Summary

Just define an interface that extends JpaRepository and basic CRUD methods are generated automatically. On top of that, the framework infers and generates queries just from the method name.

Basic CRUD — Ready to Use Immediately

java
public interface UserRepository extends JpaRepository<User, Long> {
}

This single line gives you all of the following methods:

java
userRepo.save(user);              // INSERT or UPDATE
userRepo.findById(42L);           // SELECT WHERE id=?
userRepo.findAll();               // SELECT *
userRepo.delete(user);            // DELETE
userRepo.count();                 // COUNT(*)
userRepo.existsById(42L);         // EXISTS

Full CRUD with zero queries written. Almost like magic.

Query Methods — The Method Name Is the Query

java
public interface UserRepository extends JpaRepository<User, Long> {
    // findBy + field name → auto-generated query
    Optional<User> findByEmail(String email);
    // → SELECT * FROM users WHERE email = ?

    List<User> findByAgeGreaterThan(int age);
    // → SELECT * FROM users WHERE age > ?

    List<User> findByStatusAndCreatedAtAfter(Status s, LocalDateTime t);
    // → SELECT * FROM users WHERE status = ? AND created_at > ?

    boolean existsByEmail(String email);
    long countByStatus(Status s);
}

Rule: starts with find, exists, count, or delete + By + field name and condition keywords. Spring parses the name and generates the query for you.

@Query — Complex Queries

When the naming convention isn't expressive enough, write it directly:

java
public interface UserRepository extends JpaRepository<User, Long> {

    @Query("SELECT u FROM User u WHERE u.email LIKE %:keyword% OR u.name LIKE %:keyword%")
    List<User> search(@Param("keyword") String keyword);

    @Modifying       // Required for INSERT, UPDATE, or DELETE
    @Query("UPDATE User u SET u.status = :status WHERE u.lastLogin < :date")
    int deactivateInactive(@Param("status") Status status, @Param("date") LocalDateTime date);
}

JPQL (JPA Query Language) — similar to SQL but uses entity names instead of table names. Database-agnostic.

Native SQL is also supported:

java
@Query(value = "SELECT * FROM users WHERE ...", nativeQuery = true)

QueryDSL — The Standard for Dynamic Queries

@Query has its limits too — when conditions are dynamic (e.g., search filters). That is where QueryDSL comes in:

java
QUser u = QUser.user;
BooleanBuilder where = new BooleanBuilder();
if (keyword != null) where.and(u.name.contains(keyword));
if (status != null)  where.and(u.status.eq(status));

List<User> result = queryFactory.selectFrom(u).where(where).fetch();

Type-safe and validated at compile time. The industry standard alongside Spring Data JPA.

Pagination — Built-In Support

java
Page<User> page = userRepo.findByStatus(Status.ACTIVE, PageRequest.of(0, 20));

page.getContent();          // Results for the current page
page.getTotalElements();    // Total record count
page.getTotalPages();        // Total number of pages

Just accept a Pageable argument and LIMIT/OFFSET handling is automatic. Pagination metadata is also returned automatically.

Summary

  • Extending JpaRepository gives you basic CRUD for free
  • Method names generate queries automatically
  • For complex cases use @Query or QueryDSL
  • Pagination and sorting in one line with Pageable

The N+1 Problem — *The Most Common JPA Pitfall*

The Problem

java
@Transactional
public void printAllOrders() {
    List<User> users = userRepo.findAll();      // 1 query: SELECT * FROM users
    for (User u : users) {
        System.out.println(u.getOrders().size());  // N queries: SELECT * FROM orders WHERE user_id = ?
    }
}

With 100 users that is 101 queries total. With 10,000 users it is 10,001 queries. Page response time explodes from 5 seconds to 50 seconds.

This is the N+1 problem — a classic pitfall where JPA's Lazy Loading behaves differently from what you intended.

Why It Happens

JPA loads associated objects as Lazy by default. The additional DB query fires the first time you call user.getOrders(). This is intentional — fetching everything upfront could pull in a lot of unnecessary data, so it fetches only when needed.

But when called inside a loop, a new query fires every iteration → N+1.

Solution 1 — Fetch Join

java
@Query("SELECT u FROM User u JOIN FETCH u.orders")
List<User> findAllWithOrders();

Fetches everything at once with a JOIN. Done in 1 query.

Generated SQL:

sql
SELECT u.*, o.*
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;

Solution 2 — @EntityGraph

java
public interface UserRepository extends JpaRepository<User, Long> {
    @EntityGraph(attributePaths = "orders")
    List<User> findAll();
}

Applies eager loading only for a specific method. Cleaner than Fetch Join.

Solution 3 — @BatchSize

Fetches associated data in batches of N instead of N+1 individual queries.

java
@Entity
public class User {
    @OneToMany
    @BatchSize(size = 100)
    private List<Order> orders;
}

1,000 users → 10 queries (100 at a time). Not a complete fix, but a significant improvement.

Two Collection Fetch Joins — Dangerous

java
@Query("SELECT u FROM User u JOIN FETCH u.orders JOIN FETCH u.reviews")
List<User> findAllFull();
// MultipleBagFetchException!

Fetch-joining two collections at the same time causes data to explode as a Cartesian product (M × N). Solutions:

  • Fetch Join one, apply BatchSize to the rest
  • Or use DTO Projection to fetch only what you need

DTO Projection — Deeper Optimization

Instead of the full entity, fetch only the fields you need:

java
public interface UserSummary {
    Long getId();
    String getName();
    Long getOrderCount();
}

@Query("SELECT u.id AS id, u.name AS name, COUNT(o) AS orderCount " +
       "FROM User u LEFT JOIN u.orders o " +
       "GROUP BY u.id, u.name")
List<UserSummary> findSummaries();

Completely bypasses entity mapping and Lazy loading. The fastest option available.

Diagnosis — Verify with Logs

yaml
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true

Or use the more powerful P6Spy — it shows actual parameter values too. Watch every query with your own eyes and catch N+1 issues the moment they appear.

Summary

  • N+1 is the result of Lazy loading meeting a loop
  • Fix it with: Fetch Join > @EntityGraph > @BatchSize > DTO Projection
  • Always trace queries with show-sql
  • Fetch-joining two collections is dangerous — stick to one

🤖 Try asking AI like this

Knowing the concepts in this lesson lets you give AI specific, precise instructions. Not a vague "fix this" but a request backed by vocabulary — that is where token savings begin.

  • "This findAll + loop is causing N+1 — fix it with @EntityGraph"
  • "Add a static factory method to convert this Entity to a UserResponse DTO"
  • "Add a findByEmailAndActive method signature to JpaRepository"

Why this saves tokens

Without understanding the concepts, you receive an AI answer and still have to ask "What does that mean?" again. That follow-up question is what burns tokens. Learn the concept once and the conversation finishes in a single exchange.

JPA — Handling DB with Java Objects - Spring Boot