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());
}
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
Ujorm3:
.bind("minId", minId)
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) { ... }
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"
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());
}
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-safeCriterionfilters composable with AND/OR operators; JOIN type (INNER vs LEFT) is inferred automatically from thenullableproperty 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>
Optional APT plugin for metamodel generation:
<annotationProcessorPaths>
<path>
<groupId>org.ujorm</groupId>
<artifactId>ujorm-meta-processor</artifactId>
<version>3.0.0</version>
</path>
</annotationProcessorPaths>
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)