ORM is Evil, but I Have No Proof

Table of Contents

A Confession During Therapy

I use ORMs. Every day. For years. Sometimes I even think they’re convenient. Forgive me, Father, for I have mapped. Last week I actually muttered “let the framework handle it” with a straight face – that’s when my team staged an intervention. My therapist suggested I write down my feelings instead of laughing manically at Hibernate stack‑traces, so here we are.

This is not a sweeping manifesto. It’s just one Kotlin‑writing, caffeine‑operated engineer coping with the gap between promise and reality. Consider it a field report from the trenches – the ones dug with annotations and provisional EntityManager hacks.


Quick Refresher: What Is an ORM and Why Were We Lied To?

An Object‑Relational Mapper is a magical system that tries to convince you SQL is obsolete. Instead of typing SELECT * FROM users, you now need three layers of abstraction, two proxy classes and a misplaced YAML file that probably forgot the production password.

We were told the ORM would “simplify” data access. Like how a Rube Goldberg machine “simplifies” pouring cereal. It shields us from the horrors of SQL – until the abstraction frays and you Google “how to disable lazy loading globally but only on Thursdays”.


The Seven Deadly Sins of ORM: A Journey Through Mapping Hell

N+1 Queries – the surprise you didn’t order

Let’s make the pain concrete. Below is the archetypal “Author has many Books” demo that every tutorial claims will scale just fine.

// --- Entities ---
@Entity
data class Author(
@Id @GeneratedValue
val id: Long = 0,

val name: String,

@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
val books: List<Book> = emptyList()
)

@Entity
data class Book(
@Id @GeneratedValue
val id: Long = 0,

val title: String,

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
val author: Author? = null
)
// --- Spring Data repository ---
interface AuthorRepository : JpaRepository<Author, Long>

And the seemingly innocent service call:

fun printCatalogue(repo: AuthorRepository) {
// 1 query here …
val authors = repo.findAll()

authors.forEach { author ->
// +1 query per author (books collection is still lazy)
val titles = author.books.joinToString { it.title }
println("${author.name}: $titles")
}
}

Runtime translation

  • You: “Give me all authors.”
  • ORM: “Sure thing, boss… also, mind if I fetch each author’s books one‑by‑one like it’s dial‑up 1996?”
select * from author;                -- 1
select * from book where author_id=? -- n
select * from book where author_id=? -- n+1
...

With 500 authors you just executed 501 queries. The database admin feels a disturbance in the force.

Three ways to cancel your surprise order:

@EntityGraph – when you don’t hate annotations that much

interface AuthorRepository : JpaRepository<Author, Long> {

@EntityGraph(attributePaths = ["books"])
fun findAllWithBooks(): List<Author>
}

fun printCatalogueFast(repo: AuthorRepository) {
val authors = repo.findAllWithBooks() // single JOIN fetch
authors.forEach { println("${it.name}: ${it.books.joinToString { b -> b.title }}") }
}

Generates one glorious JOIN query – DBA buys you coffee.

JPQL join fetch – because explicit is sometimes nicer than magic

@Query(
"select distinct a from Author a " +
"left join fetch a.books"
)
fun findAllWithBooksJPQL(): List<Author>

Same result, fewer annotations, more SQL‑like spell‑casting.

Batch size tuning – for times you can’t touch the repo

@BatchSize(size = 50)
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
val books: List<Book> = emptyList()

Hibernate will prefetch in chunks of 50. Still some extra queries, but at least you’re not DDoS‑ing your own RDS instance.

Take‑away: Lazy loading plus careless loops equals N+1 shock. Either tell the ORM exactly what to fetch, or accept that every forEach is a secret performance‐stress test.

Leaky abstractions – like your upstairs neighbor’s ceiling

An ORM promises database agnosticism the way a tarp promises to keep the rain out after your upstairs neighbor’s aquarium experiment goes wrong. Sure – until that first drip.

Below are three typical leaks I’ve mopped up.

Leak #1 – Vendor‑specific column types (aka “It worked in H2”)

@Entity
@Table(name = "documents")
data class Document(
@Id @GeneratedValue
val id: Long = 0,

@Column(columnDefinition = "jsonb") // totally generic, right?
val metadata: Map<String, Any>? = null
)
  • Local tests run on H2 in memory – green.
  • CI pipeline also H2 – greener.
  • Prod on PostgreSQL: perfect, JSONB exists.
  • Staging on MySQL: damn java.sql.SQLSyntaxErrorException: Unknown column type jsonb

Moral – the abstraction ends where the column definition begins. Either pin the project to one RDBMS or maintain separate scripts/dialects. Your choice of misery.

Leak #2 – Dialect functions hiding in Criteria API

fun ordersByMonth(month: Int): List<OrderStat> {
val cb = em.criteriaBuilder
val root = cb.createQuery(OrderStat::class.java).from(Order::class.java)

// "Portable" date bucket … ha!
val monthExpr = cb.function("date_trunc", Date::class.java, cb.literal("month"), root.get<Date>("createdAt"))
// PostgreSQL-only. On MySQL you just summoned a SEGV demon.
}

You feel clever avoiding raw SQL, but the moment QA switches the Docker image to MariaDB, Hibernate throws:

org.hibernate.QueryException: No function found for rendering date_trunc

Fix?

@Repository
interface OrderRepo : JpaRepository<Order, Long> {

@Query(
value = """
SELECT date_trunc('month', created_at) AS bucket,
COUNT(*) AS orders
FROM orders
GROUP BY bucket
""",
nativeQuery = true
)
fun statsPostgres(): List<OrderStat>
}

Yes, it’s native – and yes, it actually works. Sometimes the nicest abstraction is none.

Leak #3 – Transaction boundaries and the LazyInitialization geyser

// Service layer
@Transactional
fun findInvoice(id: Long): Invoice = invoiceRepo.getReferenceById(id)

// Controller layer
@GetMapping("/invoice/{id}")
fun getInvoice(@PathVariable id: Long): InvoiceDTO =
invoiceMapper.toDto(invoiceService.findInvoice(id)) // 💥

At runtime the controller sits outside the @Transactional method. Invoice.lineItems is still lazy, so while Jackson serializes the DTO, Hibernate notices the session is closed and erupts:

org.hibernate.LazyInitializationException:
failed to lazily initialize a collection of role: Invoice.lineItems

Options

  1. Stop the leak – widen the @Transactional scope to the controller (careful, you just tied HTTP thread lifetime to database connection lifetime).
  2. Prefetch@EntityGraph(attributePaths = ["lineItems"]) on the service call.
  3. Accept dampness – map only the fields you eagerly fetched and never expose entities over REST.

Your ORM can’t suspend the laws of physics (or ANSI SQL). Whenever you:

  • Declare exotic column types
  • Rely on DB‑specific functions
  • Smuggle lazy entities across transaction borders

…expect the ceiling to drip. Keep buckets (logs), towels (tests on the real DB), and a plumber’s number (plain SQL) handy.

“Business logic in models” sounds great until you debug it

Putting domain behaviour inside entities feels elegant – until the framework calls your code at 2 AM without asking first.

Exhibit A – The helpful cancel() method

@Entity
@Table(name = "orders")
class Order(

@Id @GeneratedValue
val id: Long = 0,

@Enumerated(EnumType.STRING)
var status: Status = Status.NEW,

@OneToMany(mappedBy = "order", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
val lines: MutableList<OrderLine> = mutableListOf()

) {

fun cancel(inventory: InventoryService) {
if (status != Status.NEW) {
throw IllegalStateException("Only NEW orders can be cancelled")
}
status = Status.CANCELLED

// side‑effect: restock items
lines.forEach { inventory.restock(it.sku, it.qty) }
}
}

Looks fine – the rules live with the data. Problem? Every framework in the stack thinks Kotlin property getters are fair game.

@GetMapping("/orders/{id}")
fun get(@PathVariable id: Long): OrderDto =
orderRepo.getReferenceById(id) // returns a Hibernate proxy
.also { logger.info("status = ${it.status}") } // Triggers lazy load

Jackson then serialises the proxy, which calls getStatus(). Someone adds this inside getStatus for convenience:

get() {
if (status == Status.CANCELLED) { // harmless check?
cancel(fakeInventory) // infinite recursion, database writes during GET
}
return field
}

Congratulations – a read‑only endpoint now mutates state (and allocates 50 useless restock events).

Exhibit B – Lazy traps in calculated properties

val total: BigDecimal
get() = lines.sumOf { it.price * it.qty }

Harmless until total is evaluated outside the transaction that loaded the order:

org.hibernate.LazyInitializationException:
failed to lazily initialize a collection of role: Order.lines

Debug session enters the property getter → triggers lazy load → session closed → you awaken ops.


Exhibit C – Business rules hiding in equals

override fun equals(other: Any?): Boolean =
other is Order && other.id == id && status == other.status

Hibernate proxies subclass Order, override equals, and may auto‑flush pending changes mid‑comparison. When the rule engine compares two orders, you just sent a surprise UPDATE to the database.

Four survival patterns

PatternWhat it buys youKotlin sketch
Anemic entities + servicesClear boundaries; no surprises during serializationOrderService.cancel(orderId)
Domain eventsBusiness logic decoupled from persistence contextapplicationEventPublisher.publish(Cancelled(orderId))
DTO mappingNever expose entities to the web tier; getters stay privatemapStruct/Kotlinx.serialization DTOs
Explicit fetch plansCalculated props work because data is loaded intentionally@EntityGraph(attributePaths = ["lines"])

“Rich domain models” work only when you control every call‑site. In a typical Spring/Hibernate app, dozens of invisible proxies, reflection calls and JSON libraries poke your entities. Each poke may:

  • Open a new transaction
  • Trigger an update in a GET request
  • Pull 10 000 lazy rows into memory

So keep critical logic somewhere safer – service layer, command handler, dedicated domain module – and let the entity stick to state. Your debugger (and your junior dev on call) will thank you.

SQL queries from the abyss

Ever opened your database logs and wondered whether Cthulhu had taken an internship? Below are a few real‑world‑ish snippets that summoned Lovecraftian SQL from my Kotlin codebase — plus the salt circles I now draw to keep the horrors at bay.


Abyss #1 – Innocent Kotlin projection → 17‑table JOIN

// Kotlin interface‑based projection
interface OrderSummary {
val id: Long
val customerName: String
val total: BigDecimal
}

interface OrderRepository : JpaRepository<Order, Long> {

// Sounds harmless, right?
fun findByStatus(status: Status): List<OrderSummary>
}

Generated SQL (abridged)

select o.id          as col_0_0_,
c.name as col_1_0_,
sum(l.price *
l.qty) as col_2_0_,
...
from orders o
left join customer c on c.id = o.customer_id
left join order_line l on l.order_id = o.id
left join product p on p.id = l.product_id
left join inventory i on i.sku = p.sku
left join pricing pr on pr.product_id = p.id
left join tax_rule t on t.region = c.region
left join ... -- eleven more joins
where o.status = ?
group by o.id, c.name

All I wanted was three fields; Hibernate brought the entire supply chain. The query planner cried for help.

Counter‑spell

@Query(
"""
select new com.example.OrderSummaryImpl(
o.id,
c.name,
sum(l.price * l.qty)
)
from Order o
join o.customer c
join o.lines l
where o.status = :status
group by o.id, c.name
"""
)
fun findSummaries(@Param("status") status: Status): List<OrderSummary>

A hand‑written JPQL projection generates exactly one join per need, nothing more. Yes, I typed SQL‑ish syntax by hand. Yes, the database stopped screaming.


Abyss #2 – Pageable + fetch join = Cartesian nightmare

interface InvoiceRepository : JpaRepository<Invoice, Long> {

@Query(
"""
select i from Invoice i
left join fetch i.items
"""
)
fun findAllWithItems(page: Pageable): Page<Invoice>
}

Spring Data wants two queries for pagination: one for the count, one for the slice. But the fetch join prevents splitting, so Hibernate murders performance by fetching every row, then doing in‑memory paging:

2025-06-17 10:15:24 ... select count(i.id) from invoice i        -- fast
2025-06-17 10:15:24 ... select i.*, items.* from invoice i ... -- retrieves 250 000 rows

CPU spikes, GC panics, ops phones you.

Counter‑spell

Separate count query:

@Query("select count(i) from Invoice i where i.status = :status")
fun countByStatus(@Param("status") status: Status): Long

Slice without fetch join; batch the collection later:

fun pageInvoices(page: Pageable, status: Status): Page<Invoice> {
val invoices = invoiceRepo.findByStatus(status, page) // simple query
Hibernate.initialize(invoices.flatMap { it.items }) // batch fetch
return invoices
}

Not as elegant, but the GC stopped writing passive‑aggressive notes.


Abyss #3 – Criteria API function explosion

fun activeCustomers(since: LocalDate): List<Customer> {
val cb = em.criteriaBuilder
val query = cb.createQuery(Customer::class.java)
val root = query.from(Customer::class.java)

// Auto‑generated CASE/COALESCE carnival
query.where(
cb.greaterThan(
cb.function("coalesce", LocalDate::class.java,
root.get("lastLogin"), root.get("created")),
since
)
)
return em.createQuery(query).resultList
}

Hibernate’s translator decided to inline every function argument:

select customer.*
from customer
where coalesce(customer.last_login, customer.created) > ?

Fine on Postgres, dies on Oracle (index can’t be used).

Counter‑spell

Move logic to SQL‑level view or server function you control, then call it plainly.

@Query("select * from v_active_customers(:since)", nativeQuery = true)
fun activeCustomers(@Param("since") since: LocalDate): List<Customer>

Yes, it’s native SQL. It also uses the right index and finishes before lunch.


Abyss #4 – Automatic JOINs on Kotlin data class equality

data class PriceId(val productId: Long, val region: String)

@Entity
@IdClass(PriceId::class)
class Price(
@Id val productId: Long,
@Id val region: String,
val amount: BigDecimal
)

Query‑by‑example:

val probe = Price(productId = 42, region = "EU", amount = BigDecimal.ZERO)
val match = Example.of(probe)
priceRepo.findAll(match)

Hibernate can’t match composite IDs cleanly, so it matérialises the entire table with:

select p.*
from price p

Then filters in Java land. Table has 9 million rows. Laptop fans achieve liftoff.

Counter‑spell

@Query(
"select p from Price p " +
"where p.productId = :productId and p.region = :region"
)
fun findOne(@Param("productId") id: Long, @Param("region") region: String): Price?

Simple, explicit, index‑friendly, no surprise vacuum of memory.

Your DB schema has its own life. You’re just a spectator.

Rename a column in Kotlin, forget the flyway, push on Friday. Watch prod sprout ghost columns that haunt BI reports for quarters. Archaeologists will carbon‑date the old_old_backup_temp tables.

Migrations: because pain should be automated

Auto‑migration once helpfully dropped a column after I flipped nullable = false. Half the data evaporated faster than budget at Q4 planning. On the bright side, our analytics ran 50 % faster.

Wait, that’s not supported in PostgreSQL 15?

The spec said “dialect‑independent”. The stack‑trace said “feature not supported”. I said words HR will redact. Good times.


Why We Do Love ORMs (Until We Start Profiling)

Fair is fair. ORMs excel when:

  • You’re scaffolding a CRUD prototype before the coffee cools.
  • Data model fits on a napkin.
  • Boilerplate reduction matters more than query‑perf (read: internal tools, hack‑days, side projects you’ll abandon by spring).

An ORM is like Tesla’s autopilot: works great until it rains, or is foggy, or the road bends, or there’s a stop sign, or… well, you get it. But during the demo? Flawless.


My Relationship With ORMs: A Love‑Hate Story

Act I – Honeymoon

Spring Boot. Kotlin data classes. Five endpoints. Performance charts so green they could photosynthesize. I bragged at meetups.

Act II – Reality

Traffic grew, queries multiplied like tribbles. Profilers lit up red. Stakeholders asked why the settings page loaded slower than the finance department’s approval cycle.

Act III – Reconciliation

Now I treat the ORM like a smart intern: give it simple tasks, supervise anything complex. Read models, write basic inserts, never generate DDL without a chaperone. For gnarly reporting queries, I write SQL in plain text – fight me.

Zen lesson: don’t fight the ORM, tame it. Keep it on a leash long enough to remember you can still type JOIN manually.


Conclusion: ORM Isn’t Evil, It’s Just Too Human

The real villain isn’t the code – it’s the belief we can escape understanding. Abstraction is a ladder; useful, until you forget which rung you’re standing on. Sometimes you have to drop down, get dirt under your fingernails, and write the query yourself.

Sarcastic Checklist

If your ORM:

  • fires 42 queries per page load,
  • builds a 5‑table join for one field,
  • and triggers an existential crisis in your junior dev –

…then congratulations, you’ve achieved industry standard.

Now if you’ll excuse me, I have a session with my therapist. Topic: “Accepting EntityManager.clear() as a valid coping mechanism.”

Leave a Reply

Your email address will not be published. Required fields are marked *