Why We Built ExoQuery
I’ve spent years managing thousands of SQL queries. Queries that span a page, sometimes a page and a half. Queries where a single misplaced join condition can silently corrupt your data for weeks before anyone notices. Queries where you stare at nested subqueries at 2 AM wondering: where did this column even come from?
You might think that sounds like a horrifying way to live. It was.
This is the story of why we built ExoQuery—and why we believe it represents a fundamental shift in how developers should interact with databases.
The Death Trap
Let me paint you a picture. You inherit a codebase. There’s a query that looks something like this:
SELECT DISTINCT
account.name, alias,
CASE WHEN code = 'EV'
THEN cast(account.number AS VARCHAR)
ELSE cast(account.number AS VARCHAR) + substring(alias, 1, 2)
END AS OFFICIAL_IDENTITY,
CASE WHEN order_permission IN ('A', 'S') THEN 'ST' ELSE 'ENH' END
FROM (
SELECT DISTINCT mc.alias, mc.code, order_permission, mc.account_tag
FROM MERCHANT_CLIENTS mc
JOIN REGISTRY r ON r.alias = mc.alias
WHERE r.market = 'us' AND r.record_type = 'M'
UNION ALL
SELECT DISTINCT sc.alias, 'EV' AS code, part.order_permission, sc.account_tag
FROM SERVICE_CLIENTS sc
JOIN REGISTRY r ON r.alias = sc.alias
AND r.record_type = 'S' AND r.market = 'us'
JOIN PARTNERSHIPS part ON part.id = sc.partnership_fk
) client
INNER JOIN (
dbo.ACCOUNTS account
INNER JOIN ACCOUNT_TYPES accountType ON account.type = accountType.account_type
LEFT JOIN DEDICATED_ACCOUNTS dedicated ON dedicated.account_number = account.number
) ON (accountType.mapping_type = 0)
OR (accountType.mapping_type = 2 AND account.tag = client.account_tag)
OR (accountType.mapping_type = 1 AND dedicated.client_alias = client.alias)
You look at this the first time and you’re like: what? Where does this code variable come from? We have MERCHANT_CLIENTS matching to a registry, SERVICE_CLIENTS matching to two other tables… where does anything come from?
It’s like a giant Rube Goldberg machine. Where is it wrong? Where is it breaking? You get this thing in production and something fails, and you spend hours and hours trying to figure out what’s happening.
And then, after you finally understand it, the business comes to you with new requirements. A new European business unit. A Canadian division. Different join conditions. Different data sources.
Your codebase triples. Your technical debt scales with O(Business Units). Your sanity erodes.
This is the death trap of raw SQL.
The Three False Promises
Working with databases typically involves one of three approaches. Each promises salvation. Each delivers its own unique form of suffering.
1. Raw SQL Strings
val sql = """
SELECT u.id, u.name, u.email, o.order_id, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = ?
AND u.created_at > ?
AND (o.total > ? OR o.status IN (?, ?, ?))
ORDER BY u.created_at DESC
"""
// Now maintain this across 47 different business rules
// Good luck when you rename a column
The appeal is obvious: you write SQL, you know exactly what runs. But as your application grows, you accumulate hundreds of these strings scattered across your codebase. Rename a column? Grep and pray. Change a business rule? Hope you found every query that needs updating. Typo in a column name? You’ll find out in production.
I’ve watched teams spend entire sprints hunting down queries broken by a simple schema migration. The debugging is archaeological—you’re sifting through layers of concatenated strings, dynamic conditions, and hand-rolled parameter binding, trying to reconstruct what SQL actually executed.
2. Heavy ORMs: The Siren Song
“Just use an ORM!” they say. “Let Hibernate handle the SQL!”
So you model your entities. You annotate your relationships. You write beautiful object-oriented code:
@Entity
class User(
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
val orders: List<Order>,
@ManyToMany
val permissions: Set<Permission>
)
// Looks clean, right?
fun getActiveUsersWithOrders(): List<User> {
return userRepository.findByStatus("active")
}
And then you deploy to production.
Your monitoring lights up. Database CPU at 100%. Response times through the roof. You open your query logs and discover the horror: the N+1 problem has come for you.
What you thought was one query is actually hundreds. For each user, another query fetches orders. For each order, another query fetches items. You’re caught in what Philip Wadler called “Scylla and Charybdis”—the twin monsters of database access. On one side, you execute dozens, hundreds, thousands of queries as you traverse your object hierarchy. Round and round and round you go—when it stops, nobody knows.
On the other side, you try to eagerly fetch everything, and your single “simple” query joins seventeen tables and returns a Cartesian product that melts your database server.
The fundamental problem is this: object hierarchies point forward (a User has Orders), but databases point backward (an Order has a user_id). Navigating between these paradigms has a cost, and that cost is paid in queries—lots of them—or in exploding result sets.
No amount of @BatchSize annotations or fetch graph wizardry fully solves this. It’s intrinsic to the paradigm mismatch.
3. Type-Safe DSL Builders: So Close, Yet So Far
Then came the type-safe query builders. CriteriaBuilder, Exposed, jOOQ, QueryDSL—they promised compile-time safety without the ORM baggage:
Users
.join(Orders, JoinType.LEFT) { Users.id eq Orders.userId }
.select { Users.name and Orders.total }
.where { (Users.status eq "active") and (Orders.total greater 1000) }
.orderBy(Users.createdAt, SortOrder.DESC)
Better! You get type safety. Rename a column, and the compiler catches it. But you’ve traded one problem for another:
- Steep learning curve: Every DSL is its own language.
eqinstead of==.andinstead of&&. Custom join syntax. Framework-specific aggregation APIs. - Verbose boilerplate: What should be
user.age > 18becomesUsers.age greater 18. Simple conditionals become ceremony. - Mental context switching: You think in Kotlin, then translate to DSL, then wonder what SQL actually runs.
- Limited composability: Try abstracting a common join pattern or reusing a complex filter across queries. You’ll quickly hit the walls of the DSL’s design.
You’ve essentially learned a new programming language—one that exists only to generate SQL you could have written yourself.
What We Actually Needed
After years of these approaches, a realization crystallized: we needed the abstraction power of a programming language applied to SQL itself.
Not an ORM that hides SQL behind object graphs. Not a DSL that invents new syntax for existing concepts. We needed a way to:
- Write queries using the language we already know
- Get compile-time guarantees that our queries are valid
- Compose and abstract query fragments like any other code
- See exactly what SQL runs—no hidden queries, no surprises
What if you could define a function that takes a query and returns a query? What if you could pass a table into a “UDF” and get back a joined, filtered result? What if the type system itself ensured your unions had compatible columns?
In SQL, you cannot abstract around a join. You cannot parameterize a query by the tables it uses. You cannot compose subqueries like functions.
But in a real programming language, you can.
The ExoQuery Approach
ExoQuery lets you write SQL using the Kotlin you already know:
val activeAdultUsers = sql {
Table<User>().filter { user ->
user.age > 18 && user.status == "active"
}
}
This generates:
SELECT id, name, age, status FROM users WHERE age > 18 AND status = 'active'
No eq. No greater. No custom DSL syntax. Just Kotlin. Use == for equality. Use if for conditionals. Use when for case expressions. Use && and || for boolean logic.
Composition That Actually Works
Here’s where it gets powerful. Remember that nightmare query with merchant clients and service clients? Here’s how you’d write it:
// Define reusable query fragments
@SqlFragment
fun merchants(market: String) = sql.select {
val mc = from(Table<MerchantClient>())
val r = join(Table<Registry>()) { it.alias == mc.alias }
where { r.market == param(market) && r.recordType == "M" }
Client(mc.alias, mc.code, mc.orderPermission, mc.accountTag)
}
@SqlFragment
fun services(market: String) = sql.select {
val sc = from(Table<ServiceClient>())
val r = join(Table<Registry>()) { it.alias == sc.alias }
val p = join(Table<Partnership>()) { it.id == sc.partnershipFk }
where { r.market == param(market) && r.recordType == "S" }
Client(sc.alias, "EV", p.orderPermission, sc.accountTag)
}
// Compose with account mapping
@SqlFragment
fun clientAccounts(clients: SqlQuery<Client>) = sql.select {
val c = from(clients)
val a = join(Table<Account>()) { /* mapping conditions */ }
val at = join(Table<AccountType>()) { it.accountType == a.type }
ClientAccount(a.name, c.alias, /* ... */)
}
// Use it
val usAccounts = sql { clientAccounts(merchants("us").union(services("us"))) }
val euAccounts = sql { clientAccounts(services("eu")) }
val caAccounts = sql { clientAccounts(merchants("ca")) }
Three lines instead of three copies of a page-long query. The abstraction is a function. The composition is function application. The type system ensures your queries are valid.
New business unit? Add one line. Change the account mapping logic? Change it once. Rename a column? The compiler tells you everywhere it’s used.
This is what controllable technical debt looks like.
Compile-Time SQL Generation
Every query is transformed into SQL at compile time. You see the generated SQL in your build logs. You can hand it to your DBA and say: “This is exactly what will execute.”
No runtime surprises. No hidden query generation. No “why is this query different in production?”
The Emotional Core
I’ll be honest about something. Before finding this approach, I genuinely struggled. Managing thousands of SQL queries across multiple business units, debugging production issues at midnight, watching queries subtly break after schema changes—it wears on you.
The moment I realized that query composition could work like function composition, everything changed. Suddenly, the complexity was manageable. The abstractions held. The types caught my mistakes before production did.
ExoQuery isn’t just a library. It’s the crystallization of years of learning what doesn’t work and finally building what does.
Real-World Impact
Teams using this approach have reported:
- Dramatically faster onboarding—new developers read Kotlin, not a custom DSL
- Fearless refactoring—the compiler catches query breakage
- Controllable complexity—queries compose instead of duplicate
Most importantly: developers stop dreading database code. That might be the biggest win of all.
Looking Forward
ExoQuery is just getting started. We’re working on:
- Advanced join patterns (outer joins, lateral joins)
- Window functions and complex aggregations
- Multi-database dialect support
- Query performance analysis and optimization hints
- IDE integration for SQL preview
The goal isn’t just better tooling. It’s fundamentally changing the relationship between application developers and databases—from adversarial to collaborative, from fearful to confident.
Get Involved
ExoQuery is open source. We’ve felt the pain, we’ve built the solution, and now we want to share it with everyone who’s ever stared at a broken query at 2 AM.
Learn how ExoQuery works with our Interactive Kotlin Playground. Then click the “Download as Gradle Project” button to get started with a sample project you can run locally in IntelliJ.
Because nobody should have to suffer through another page-long SQL query alone.