DEV Community

Mohamed El Laithy
Mohamed El Laithy

Posted on

The Spring Data JPA Mistakes That Don't Show Up Until Production


Part 2 of Spring Boot Complete Notes — Data & Reliability

JPA is the easiest part of Spring Boot to get working and one of the easiest to get wrong in a way that only shows up once real traffic hits it. None of these four things will fail your build. All four will page you at 2 a.m.

  1. Returning an entity is how you get infinite recursion

A Department holds a list of Employees. Each Employee holds a reference back to its Department. Perfectly normal bidirectional mapping.

@Entity
class Department {
    @OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Employee> employees = new ArrayList<>();
}

@Entity
class Employee {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "department_id")
    private Department department;
}
Enter fullscreen mode Exit fullscreen mode

Return either one directly from a @RestController and Jackson serializes forever: Department → Employees → Department → Employees → ... until you get a StackOverflowError or an OOM, depending on how patient the JVM is.

The instinct is to slap @JsonIgnore on one side. The actual fix is that entities never cross the controller boundary — map to a DTO at the edge, every time. It also solves the two other things exposing entities breaks: a lazy association throwing LazyInitializationException when Jackson touches it outside a transaction, and your database schema becoming your public API contract whether you meant it to or not.

  1. CascadeType.ALL is a loaded gun
@OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Employee> employees;
Enter fullscreen mode Exit fullscreen mode

CascadeType.ALL includes REMOVE. Delete a Department and every Employee in it goes with it — no warning, no confirmation, just gone. This is correct behavior for a true parent-owns-child relationship (an Order and its OrderLines, say). It is almost never correct across what's actually an aggregate boundary, where Department and Employee are two independent things that happen to reference each other.

Cascade from parent to child only, and only when the child genuinely has no independent lifecycle. When in doubt, list the cascade types explicitly (PERSIST, MERGE) instead of reaching for ALL.

  1. save() is not always an INSERT
userRepository.save(user);
Enter fullscreen mode Exit fullscreen mode

If user has an id that already exists in the database, Spring Data JPA treats this as a merge, not an insert. No duplicate-key exception, no error — it silently becomes an UPDATE. This surprises almost everyone the first time it happens, usually while debugging why a "new" record overwrote an old one. If you need insert-or-fail semantics, that's a different method entirely, not save().

  1. The N+1 you won't notice in dev
List<Department> departments = departmentRepository.findAll();
for (Department d : departments) {
    d.getEmployees().size(); // one query per department
}
Enter fullscreen mode Exit fullscreen mode

50 departments, 50 lazy-loaded employee lists touched in a loop, 51 SQL statements for what should have been one. In development, with ten rows of seed data, this is invisible. In production, with real volume, it's the single most common cause of a "why is this endpoint suddenly slow" incident.

Turn on spring.jpa.show-sql=true in dev and you'll spot it immediately — one list endpoint producing 51 statements for 50 rows is the signature. The fix is a fetch join or @EntityGraph so the related data comes back in one query instead of N extra ones:

j

@Query("select d from Department d join fetch d.employees where d.id = :id")
Optional<Department> withEmployees(@Param("id") Long id);
Enter fullscreen mode Exit fullscreen mode

A better habit than remembering to check: assert the query count in a test. A tool like datasource-proxy fails the build when an endpoint's statement count regresses, which is far more reliable than remembering to look at the console.

These four are a small sample of what's in Part 2 — Data & Reliability, one part of a four-part series (Spring Boot Complete Notes) covering the core web layer, the data layer, security, and production performance — written from scratch and checked line by line against current Spring Boot (3.5.x) and Spring Security 6, with a page in every part correcting the specific mistakes that keep circulating in outdated cheat sheets.

📄 Read Part 2 in full: Spring Boot Complete Notes — Part 2 (PDF)

The rest of the series (Core & Web Layer, Security & Observability, Performance & Delivery) is here: mellaithy.gumroad.com

Top comments (0)