Skip to main content

How JPA Became Evil

· 8 min read
Linh Nguyen
T-90MS Main Battle Tank
thumbnail

Remember when you first learned JDBC? Yeah, neither do I. I've blocked it out like a traumatic childhood memory. Then JPA arrived like a hero we deserved. Or did we?

A Trip Down the Memory Lane

But let me refresh your pain: you wrote SQL queries by hand (fine, actually good), executed them, and then manually mapped every single column from the ResultSet to your Java POJOs. Every. Single. Column.

ResultSet rs = statement.executeQuery("SELECT * FROM users");

while (rs.next()) {
User user = new User();

user.setId(rs.getLong("id"));
user.setName(rs.getString("name"));
user.setEmail(rs.getString("email"));
user.setCreatedAt(rs.getTimestamp("created_at"));
// ... 100+ more fields because your PM keeps adding "just one more thing"
}

It was like being a human for-loop. You'd add a new column to the database, then play the exciting game of "find all 23 places in the codebase where I need to map this field." Miss one? Cool, enjoy that NullPointerException in prod at 2 AM.

The Hero We Thought We Needed

Then some absolute genius was like, "Hey, what if we just... used reflection? And annotations? The computer could do all this tedious garbage for us."

And thus JPA (Java Persistent API) was born. Not an actual implementation, mind you! Just a specification. A set of APIs that framework developers could implement. Hibernate said "bet", EclipseLink said "sure", and suddenly we were living in the future.

You just slap @Entity on your class, sprinkle some @Column annotations (or not, if the frameworks magically convert your camelCase to snake_case), and boom!

No more copying column values like a medieval scribe.

@Entity
public class User {

@Id
private Long id;
private String name;
private String email;
// Look ma, no manual mapping!
}

"But reflection is slow!" cried the performance nerds. And they were right. Reflection has overhead. But you know what else is slow? Network calls. Database queries. That microservice your teammate wrote that somehow takes 3 seconds to return a user's name. Your app's bottleneck wasn't going to be annotation scanning: it was going to be everything else.

The performance cost was like paying 50 cents for shipping. It is there, but at least it is convenient.

The Golden Years

JPA kept evolving. Someone realized, "Hey, databases have relationships. Objects have relationships. What if we just... pretended they're the same thing?"

And that's how we got @OneToMany, @ManyToOne, @ManyToMany (which nobody uses correctly), and @OneToOne (which is a lie, because there's always a foreign key on one side).

@Entity
public class User {

@Id
private Long id;

@OneToMany(mappedBy = "user")
private List<Order> orders;
// Relationship mapping! What could go wrong?
}

Your User object could have multiple Order ones! Your Order object could have many OrderItem inside! It was OOP paradise! We were modeling the real world, baby!

Then they gave us JPQL: Java Persistence Query Language. It was like SQL, but worse! Just kidding. Sort of. The idea was brilliant: write queries that look vaguely like SQL but work on your Java entities instead of database tables. Now when your CEO inevitably decides to migrate from Oracle to Postgres because they read a Medium article at 3 AM, your code won't have a meltdown.

Portability! Abstraction! Buzzwords!

Oh, and speaking of Oracle: can we talk about the javax to jakarta namespace change? Thanks for that, Oracle lawyers. Nothing says "I love developers" like forcing everyone to spend an afternoon doing find-and-replace when upgrading from Spring Boot 2 to Boot 3. My Ctrl+F key hasn't been the same since.

The Descent to Madness Complexity

Everything was great. Your code was clean. Your entities were modeling real business domains. You felt like a proper software engineer.

Then one day you looked at your codebase and had a moment of existential horror.

What... what happened here?

Your entities weren't clean POJOs anymore. They were annotation soup. A Jackson Pollock painting made entirely of Java metadata:

Not for faint hearts
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_email", columnList = "email"),
@Index(name = "idx_created", columnList = "created_at")
})
@NamedEntityGraph(
name = "User.orders",
attributeNodes = @NamedAttributeNode("orders")
)
@SQLDelete(sql = "UPDATE users SET deleted = true WHERE id = ?")
@Where(clause = "deleted = false")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq")
@SequenceGenerator(
name = "user_seq",
sequenceName = "user_sequence",
allocationSize = 50,
initialValue = 1000
)
private Long id;

@Column(name = "email", nullable = false, unique = true, length = 255)
@Email(message = "Email must be valid")
private String email;

@OneToMany(
mappedBy = "user",
fetch = FetchType.LAZY,
cascade = {CascadeType.PERSIST, CascadeType.MERGE},
orphanRemoval = true
)
@Fetch(FetchMode.SUBSELECT)
@BatchSize(size = 25)
@OrderBy("createdAt DESC")
private List<Order> orders = new ArrayList<>();

@Temporal(TemporalType.TIMESTAMP)
@Column(name = "created_at", nullable = false, updatable = false)
@CreationTimestamp
private Date createdAt;

@CreatedDate // Modern JPA Auditing
private Instant iAmModern;
}

This isn't a domain object. This is a configuration file that accidentally became sentient.

But indeed, this is way better than trying to achieve the same using XMLs! But we just get a lesser evil, not a good one.

And the performance issues. Oh boy, the performance issues.

Remember when you innocently did user.getOrders() in a loop and suddenly your app was making 10,000 database queries? That's the N+1 query problem, and it's JPA's way of saying "gotcha!".

So you learn about Entity Graphs. Great! Now you have to declare every possible loading scenario upfront like you're planning a heist. And sometimes, funny OutOfMemoryError appears out of nowhere and breaks your production environment, even with premium hardware configurations (I've worked with one).

Then you try JOIN FETCH. Sometimes it works. Sometimes it creates a Cartesian product that makes your DBA cry.

Then you discover second-level caching, which sounds great until you realize you now need to understand cache invalidation, and as we all know, cache invalidation is one of the two hard problems in computer science (along with naming things and off-by-one errors).

And JPQL? That beautiful, database-agnostic query language? Half your repository now looks like this:

@Query(value = 
"""
SELECT u.* FROM users u
WHERE u.created_at > NOW() - INTERVAL '1 day'
AND u.status = 'ACTIVE'
AND EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id
AND o.total > 1000
)
""", nativeQuery = true)
List<User> findHighValueRecentUsers();

Native SQL. So much for portability. But hey, at least it's fast.

You realize you now need to be an expert in:

  • SQL (obviously)

  • JPA annotations (there are like 200 of them)

  • Hibernate session management

  • Transaction boundaries

  • Lazy loading vs eager loading vs entity graphs vs batch fetching

  • First-level cache vs second-level cache vs query cache

  • When Hibernate flushes (spoiler: sometimes when you least expect it)

  • Why your @Transactional annotation didn't work (it's on a private method, isn't it?)

The framework that was supposed to abstract away database complexity now requires you to have a PhD in both databases AND the framework.

The Uncomfortable Truth

JPA didn't start out evil. It had noble goals. Manual JDBC mapping genuinely sucked. Database portability is genuinely valuable. The problem is that JPA tried to hide complexity instead of managing it, and hidden complexity has a way of jumping out and biting you when you least expect it.

It's like those easy meal kit services. Sure, they send you pre-measured ingredients and a recipe card, but somehow you still end up with more than 200 dishes to wash and a vague sense that you could've just ordered takeout.

Look, JPA isn't all bad. For CRUD apps, internal tools, and MVPs, it's fine. Great, even. You can get a lot done quickly without thinking too hard.

But the moment your app gets real traffic, real complexity, real performance requirements? That's when JPA shows its true colors. That's when you're up at 2 AM debugging why a simple user lookup is generating 1000+ SQL queries. That's when you're reading Vlad Mihalcea's blog for the tenth time trying to understand the difference between FetchMode.SELECT and FetchMode.SUBSELECT.

The Finishing Touches

JPA has lived long enough to see itself become the villain. It went from "this will make your life easier" to "you need a 400-page book to use this correctly."

Should you use it? Maybe. Depends on your app. Just go in with your eyes open.

Know that @ManyToOne defaults to eager loading (why?).

Know that @OneToMany defaults to lazy loading (okay, that makes sense).

Know that lazy loading outside a transaction throws LazyInitializationException (fun times).

Know that your simple entity graph will be ignored if you accidentally used JOIN FETCH in your JPQL.

And for the love of everything holy, please monitor your SQL queries in production. JPA will happily generate the most cursed SQL you've ever seen while smiling innocently.

Stay vigilant out there. The ORM may be convenient, but it's also watching. Waiting. Ready to hit you with an N+1 when you least expect it.

You've been warned.