C
Spring Boot/JPA/Lesson 09

Spring Data JPA in Practice — Repository · N+1 · DTO Mapping

1 hr·theory
This chapter
2/2

Spring Data JPA in Practice — Repository · N+1 · DTO Mapping

🎯 What you'll be able to do after this lesson

After finishing this lesson, you'll be able to confidently do the following three things.

  • ✅ Implement full CRUD with zero lines of code by simply extending JpaRepository
  • ✅ Solve the N+1 problem using @EntityGraph or JOIN FETCH
  • ✅ Understand Lazy vs Eager loading — and why real-world code always uses Lazy

Keep these goals as a checklist, and close the lesson once you can answer all of them.

JpaRepository — *Just name the method and you're done*

The biggest magic trick

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

Declare just one interface line — and Spring auto-generates the implementation at runtime. Methods available immediately:

  • save(entity) — INSERT or UPDATE
  • findById(id) — returns Optional
  • findAll() — fetch all records
  • count() — count records
  • deleteById(id) — delete a record
  • existsById(id) — check existence

Generating queries from method names

java
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    List<User> findByNameContaining(String keyword);
    long countByCreatedAtAfter(LocalDateTime date);
    boolean existsByEmail(String email);
    List<User> findByAgeGreaterThanOrderByNameAsc(int age);
}

Follow the method naming rules and Spring generates the SQL automatically. A clear win for both readability and performance.

Keywords: findBy, countBy, existsBy, deleteBy + field name + (Containing, GreaterThan, In, Between, OrderBy...Asc/Desc ...)

Writing JPQL directly — @Query

For complex queries, write them in JPQL (Java Persistence Query Language):

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

    @Query("SELECT u FROM User u WHERE u.email = :email AND u.active = true")
    Optional<User> findActiveByEmail(@Param("email") String email);

    @Query("SELECT u FROM User u JOIN u.orders o WHERE o.amount > :min")
    List<User> findBigSpenders(@Param("min") int min);

    @Modifying
    @Query("UPDATE User u SET u.lastLogin = :now WHERE u.id = :id")
    int updateLastLogin(@Param("id") Long id, @Param("now") LocalDateTime now);
}

JPQL looks similar to SQL, but is written using entity names — not table names.

Pagination — Pageable

java
Page<User> page = userRepository.findAll(
    PageRequest.of(0, 20, Sort.by("createdAt").descending())
);

List<User> content = page.getContent();   // current page
long total = page.getTotalElements();      // total count
int totalPages = page.getTotalPages();

Infinite scroll and page navigation are both handled with this.

The N+1 Problem — The most common trap in production

The scenario

java
@Entity
class User {
    @Id Long id;
    @OneToMany(mappedBy = "user")
    List<Order> orders;
}

@Entity
class Order { @Id Long id; @ManyToOne User user; int amount; }
java
List<User> users = userRepository.findAll();   // (1) SELECT * FROM users  ← 1 query
for (User u : users) {
    System.out.println(u.getOrders().size());   // (N) SELECT * FROM orders WHERE user_id = ?  ← once per user
}

100 users → 101 queries. Performance degrades exponentially as data grows. This is the N+1 problem.

Solution 1 — @EntityGraph (simplest)

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

    @EntityGraph(attributePaths = {"orders"})
    List<User> findAll();
}

Resolved in a single JOIN query. In SQL:

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

Solution 2 — JOIN FETCH (manual JPQL)

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

DISTINCT matters — without it, you get duplicate rows.

Solution 3 — Batch Size (@BatchSize)

Not a complete fix, but reduces N+1 to N/batchSize+1. Useful when dealing with multiple associations combined with pagination.

java
@Entity
class User {
    @OneToMany(mappedBy = "user")
    @BatchSize(size = 100)        // IN query in batches of 100
    List<Order> orders;
}

Lazy vs Eager — When to use which

java
@ManyToOne(fetch = FetchType.LAZY)   // default. Loads *only when needed*
@OneToMany(fetch = FetchType.LAZY)   // default.
@ManyToOne(fetch = FetchType.EAGER)  // eager loading — *don't use this*

Production rule: always LAZY. EAGER is the root cause of unpredictable query explosions. Use @EntityGraph or JOIN FETCH on demand when you need eager behavior.

Debugging N+1 — turn on show-sql

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

During development, actual SQL is printed to the console. If the same query repeats dozens of times, that's a red flag for N+1.

Entity → DTO Mapping — *Why you must keep them separate*

Why you must not return an Entity directly

java
// ❌ Returning the Entity as-is
@GetMapping("/users/{id}")
public User get(@PathVariable Long id) {
    return userRepository.findById(id).orElseThrow();
}

Four problems:

1. Circular reference: Bidirectional mapping between UserOrder causes infinite JSON serialization → StackOverflow.
2. Sensitive data exposure: Fields like password and internalNote get exposed automatically.
3. Coupling API schema to DB schema: Renaming a DB column breaks the API too.
4. Unnecessary queries: Jackson tries to serialize Lazy fields, triggering extra queries.

The DTO pattern

java
@Getter @Builder
public class UserResponse {
    private Long id;
    private String email;
    private String name;
    private int orderCount;

    public static UserResponse from(User u) {
        return UserResponse.builder()
            .id(u.getId())
            .email(u.getEmail())
            .name(u.getName())
            .orderCount(u.getOrders().size())
            .build();
    }
}

@GetMapping("/{id}")
public UserResponse get(@PathVariable Long id) {
    User u = userRepository.findById(id).orElseThrow();
    return UserResponse.from(u);
}

The static factory method (from) owns the mapping responsibility — keeping the Entity and DTO unaware of each other.

Separate DTOs for input as well

java
@Getter @Setter
public class UserCreateRequest {
    @Email
    @NotBlank
    private String email;

    @Size(min = 2, max = 50)
    private String name;
}

@PostMapping
public UserResponse create(@Valid @RequestBody UserCreateRequest req) {
    User u = User.builder().email(req.getEmail()).name(req.getName()).build();
    return UserResponse.from(userRepository.save(u));
}

@Valid automatically runs Bean Validation (@Email, @NotBlank, @Size). On validation failure, a 400 Bad Request is returned automatically.

MapStruct — Auto-generate mapping code

As the number of DTOs grows, so does the number of from() methods. MapStruct generates mapping code at compile time — eliminating boilerplate.

java
@Mapper(componentModel = "spring")
public interface UserMapper {
    UserResponse toResponse(User u);
    User toEntity(UserCreateRequest req);
}

The standard approach for medium-to-large projects. Start by writing it manually, then introduce MapStruct once you're comfortable.

🤖 Try asking AI like this

Knowing the concepts in this lesson lets you give specific instructions to AI. Not a vague 'fix this' — but a vocabulary-driven request. That's where token savings begin.

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

Why this reduces tokens

Without understanding the concept, you receive an AI answer and still have to ask 'What does that mean?' again. That follow-up question is what consumes tokens. Learn the concept once, and the conversation ends in one turn.

Spring Data JPA in Practice — Repository · N+1 · DTO Mapping - Spring Boot