DEV Community

Cover image for Native SQL in Java without JDBC boilerplate — meet Ujorm3
Pavel Ponec
Pavel Ponec

Posted on

Native SQL in Java without JDBC boilerplate — meet Ujorm3

If you've ever written raw JDBC, you know what's coming. Open a connection, create a
PreparedStatement, set parameters by index (hope you counted right), iterate a
ResultSet, close everything in a finally block, declare SQLException on every
method signature… It's a lot of ceremony for "give me some rows."

I've been experimenting with Ujorm3,
a new lightweight ORM library for Java 17+. Here's a realistic example — a JOIN query
that maps results including a nested relation:

static final ResultSetMapper<Employee> EMPLOYEE_MAPPER =
        ResultSetMapper.of(Employee.class);

List<Employee> findEmployees(Connection connection, Long minId) {
    return SqlQuery.run(connection, query -> query
            .sql("""
                    SELECT e.id, e.name, c.name AS "city.name"
                    FROM employee e
                    JOIN city c ON c.id = e.city_id
                    WHERE e.id >= :minId
                    """)
            .bind("minId", minId)
            .toStream(EMPLOYEE_MAPPER.mapper())
            .toList());
}
Enter fullscreen mode Exit fullscreen mode

Let me walk through what makes this tick.


Fluent API

The whole operation is one readable chain. No juggling Statement objects, no passing
things between methods — you declare the SQL, bind parameters, specify the mapper,
and collect. Done.


Named parameters instead of positional ?

Classic JDBC:

stmt.setLong(1, minId); // hope you counted correctly
Enter fullscreen mode Exit fullscreen mode

Ujorm3:

.bind("minId", minId)
Enter fullscreen mode Exit fullscreen mode

You reference parameters by name in the SQL (:minId) and bind them by name. No
counting, no off-by-one errors when you insert a new parameter in the middle of a
query, and the SQL stays readable.


No checked exceptions

SQLException is a checked exception, so vanilla JDBC forces you to handle or
rethrow it everywhere — even when there's nothing useful to say. Ujorm3 wraps these
internally, so your methods stay clean:

// JDBC — forced to declare or catch
List<Employee> findEmployees(Connection c, Long minId) throws SQLException { ... }

// Ujorm3 — nothing to declare
List<Employee> findEmployees(Connection connection, Long minId) { ... }
Enter fullscreen mode Exit fullscreen mode

Smart object mapping — including relations

ResultSetMapper is a thread-safe class that prepares its mapping model on first
use and reuses it across all subsequent calls. This significantly reduces overhead
when processing a large number of queries.

Mapping is inferred automatically by default. You can optionally annotate your domain
classes with standard jakarta.persistence annotations (@Table, @Column, @Id)
for explicit control, but they're not required.

The interesting bit is how it handles relations. The aliased column "city.name" uses
dot notation to map directly into a nested object — no extra configuration needed:

-- maps to employee.getCity().getName() automatically
c.name AS "city.name"
Enter fullscreen mode Exit fullscreen mode

The library supports M:1 relations. 1:M collections are intentionally left out — a
deliberate design choice to avoid hidden queries and N+1 problems.

Want compile-time safety? There's a metamodel for that.

The string-based alias approach works great for getting started, but if you want the
compiler to catch typos in column mappings, the optional APT plugin generates Meta*
classes from your domain objects. The query then looks like this:

List<Employee> findEmployees(Connection connection, Long minId) {
    return SqlQuery.run(connection, query -> query
            .sql("""
                    SELECT e.id  AS ${e.id}
                    , e.name     AS ${e.name}
                    , c.name     AS ${c.name}
                    FROM employee e
                    JOIN city c ON c.id = e.city_id
                    WHERE e.id >= :id
                    """)
            .label("e.id",   MetaEmployee.id)
            .label("e.name", MetaEmployee.name)
            .label("c.name", MetaEmployee.city, MetaCity.name)
            .bind("id", minId)
            .toStream(EMPLOYEE_MAPPER.mapper())
            .toList());
}
Enter fullscreen mode Exit fullscreen mode

The ${placeholder} syntax in the SQL template and the label() method work together
— the metamodel keys are type-parameterized descriptors that resolve column labels at
runtime and carry full type information.


Automatic resource management

SqlQuery.run(...) handles closing the underlying PreparedStatement and ResultSet
for you. No try-with-resources, no resource leaks if mapping throws partway through.


There's more than just SqlQuery

The library offers three levels of abstraction — pick what fits your use case:

  • EntityManager — the fastest path for CRUD on a single table using a primary key; generates the SQL itself.
  • SelectQuery — for fetching data including relations; supports type-safe Criterion filters composable with AND/OR operators; JOIN type (INNER vs LEFT) is inferred automatically from the nullable property of @Column.
  • SqlQuery — low-level, full native SQL control; what we've been looking at above.

Performance

Instead of reflection, the library generates and compiles its own bytecode at runtime
for reading and writing domain object fields — performance comparable to handwritten
code. In benchmark comparisons against Hibernate, Jdbi, MyBatis, and others (running
on PostgreSQL and H2) it performs very well. The entire compiled module including
Ujorm3 itself is under 3 MB, which is nice for microservices.


What this is NOT

Not Hibernate. No entity scanning, no session factory, no proxy objects, no lazy
loading surprises. You write SQL, you get objects back.

Not jOOQ either — there's no Java DSL for building queries. You write plain SQL
strings, which means you get full access to any database-specific syntax: window
functions, CTEs, vendor extensions, whatever your DB supports.


Getting started

Java 17+, final version 3.0.0 available on Maven Central:

<dependency>
    <groupId>org.ujorm</groupId>
    <artifactId>ujo-core</artifactId>
    <version>3.0.0</version>
</dependency>
<dependency>
    <groupId>org.ujorm</groupId>
    <artifactId>ujorm-orm</artifactId>
    <version>3.0.0</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Optional APT plugin for metamodel generation:

<annotationProcessorPaths>
    <path>
        <groupId>org.ujorm</groupId>
        <artifactId>ujorm-meta-processor</artifactId>
        <version>3.0.0</version>
    </path>
</annotationProcessorPaths>
Enter fullscreen mode Exit fullscreen mode

Integration tests cover PostgreSQL, MySQL, MariaDB, Oracle, and MS SQL Server
(all via Docker).


When does this make sense?

If you need JPA portability across databases or your company mandates a standard ORM,
use Hibernate. If you want full SQL control, transparent behavior, and no hidden magic
— and you'd rather not write raw JDBC — this hits a nice sweet spot.

Useful links:

Curious whether others are using similar lightweight wrappers, or if you've landed on
a different approach for native SQL without going full ORM.

Top comments (0)