Blog/Stop hiding my SQL

Stop hiding my SQL

Somewhere along the way, hiding SQL became the default definition of a good ORM. It is worth questioning that, because SQL was rarely the part that caused the pain.

January 20, 2026SQL4 min read

The second query language problem

To hide SQL, an ORM gives you something to write instead: JPQL, a criteria builder, or a fluent DSL that models joins and predicates as method calls. A DSL is a good thing to have, and ST/ORM has one. The problem starts when it becomes the only safe path and SQL becomes the unsafe escape hatch, because that path always ends somewhere. You hit an aggregate, a window function, a recursive CTE, or a database-specific feature the abstraction never modeled, and you drop to a native query. At which point you are writing SQL anyway, except now it is a raw string with no type safety, because the safe path only covered the cases that did not need it.

SQL was never the weak point

SQL is declarative, portable enough for real work, and understood by almost every backend engineer who works near a relational database. It is one of the most durable interfaces in our field. The weak point in hand-written database code was never the SQL itself. It was the stuff around it: column names as bare strings that no compiler checks, parameters concatenated into the query by hand, result sets mapped to objects by index one brittle line at a time, and mappings that drift silently when the query changes.

So fix that part. Leave the SQL alone.

Type the SQL, do not bury it

ST/ORM's SQL templates, the piece that started the whole project, let you write real SQL with type-safe interpolation and automatic result mapping. Column references go through the generated metamodel, so a renamed field, a broken path, or a wrong type is a compile error, not a runtime surprise. Interpolated values become bind parameters, so a value is never concatenated into the SQL text. Results map to plain data classes and records. Take a window function, the kind of query a DSL will not express for you, and none of the safety goes away:

ReportService.kt Kotlin · ST/ORM
1
2
3
4
5
6
7
8
// Only RANK() OVER is raw SQL; the columns and table are typed
data class RankedCity(val name: String, val rank: Long)

val ranked = orm.query { """
    SELECT ${City_.name}, RANK() OVER (ORDER BY ${City_.population} DESC)
    FROM ${City::class}
    WHERE ${City_.country} = $country
""" }.resultList<RankedCity>()

For the boring, high-volume queries there is a concise DSL, so you are not writing SQL for a find-by-id. The DSL is there for the common queries; the template is there when SQL is the clearest language for the job. It is not a trapdoor you fall through when the abstraction fails but a first-class way to work, sitting right next to the DSL, so the full power of your database is always one line away. You get typed references, safe parameters, and automatic mapping, while the SQL stays visible.