DEV Community

zoro ma
zoro ma

Posted on

Army vs jOOQ

Project on GitHub: github.com/PillArmy/army

Every code sample in this article is copied (with minor import trimming) from Army's own source tree — either the army-example module or generated build output. Every measurement of jOOQ was taken by me on a local checkout of the current jOOQ trunk. You can reproduce all of it.

A SQL DSL where JDBC is just one executor: studying Army against jOOQ

We have had type-safe SQL DSLs in Java for a long time. QueryDSL appeared in 2007, jOOQ in 2009. You would be forgiven for thinking the design space was closed — that everything left to do was polish, dialects, and code generation.

Army is a small, pre-1.0 (0.6.x), single-maintainer Java ORM that disagrees. After reading through its ~237,000 lines of main source across 16 Maven modules, I think it makes four design decisions worth studying even if you never adopt it — including one architecture-level bet I have not seen anywhere else in the Java ORM space.

This is not a "jOOQ is bad" article. jOOQ is an excellent, mature, commercially proven product. The interesting question is narrower: given identical language features, which trade-offs did each project choose, and what do those choices cost?


1. JDBC is not the foundation. It is one pluggable executor

This is the single most important thing in the codebase.

In MyBatis, a session holds a java.sql.Connection. In jOOQ, JDBC is the center of the universe; R2DBC support was added years later as a secondary adapter. In Army:

$ grep -rn "import java.sql" army-core/src/main/java | wc -l
       0
Enter fullscreen mode Exit fullscreen mode

The entire core module contains zero JDBC imports, and its pom.xml has no JDBC dependency. The executor contract is driver-neutral by construction. From the actual Javadoc of io.army.executor.ExecutorFactory in army-core:

/// For example:
///
/// - JDBC
/// - JDBD
/// - ODBC
///
/// @return driver spi name
String driverSpiName();

/// @return JDBC always return false, JDBD always return true.
boolean isResultItemDriverSpi();


/// For example: io.army.jdbc or io.army.jdbd
/// @return executor vendor
String executorVendor();
Enter fullscreen mode Exit fullscreen mode

JDBC is listed as one driver SPI alongside JDBD (a reactive driver) and ODBC. The blocking JDBC implementation — all 3,566 lines of JdbcExecutor — lives in a separate module, army-jdbc, and can be replaced without touching the SQL engine, the dialect layer, or the session API.

Why does this matter? Two reasons:

  1. Reactive was never "a thread pool around JDBC". Army's git history contains a full reactive implementation (reactive session impl v1v3, typed reactor sessions, reactive transaction management, reactive MySQL/optimistic-lock test cases) targeting a genuine reactive driver, later removed in two commits (drop reactor module, drop reactive package) when the maintainer could no longer afford to maintain both stacks. Removing the module did not damage core — which is the proof that the abstraction is real rather than decorative.
  2. SQL generation is decoupled from the wire protocol. The dialect renders SQL against an internal context; the executor binds and ships it. The same rendered statement can be consumed by a blocking driver today and a reactive driver tomorrow.

Contrast this with the cost of retrofitting reactiveness onto a JDBC-centric design, and you see why this is a bet, not a decoration.


2. A compile-time clause state machine — hidden behind exactly one public entry point

Here is real user-facing query code, from army-example/.../bank/dao/sync/region/StandardRegionDao.java:

final Select stmt;
stmt = SQLs.query()
        .select(ChinaRegion_.id)
        .from(ChinaRegion_.T, AS, "t")
        .where(ChinaRegion_.name.equal(SQLs::param, regionName))
        .and(ChinaRegion_.regionType.equal(SQLs::literal, regionType))
        .asQuery();

return this.sessionContext.currentSession().queryOne(stmt, Long.class);
Enter fullscreen mode Exit fullscreen mode

SQLs.query() does not return a "wide query object". It returns StandardQuery.WithSpec<Select> — a narrow entry type. Each clause method's return type is parameterized to the next legal stage. From army-core/.../criteria/standard/StandardQuery.java:

// the ONLY public entry; Javadoc: "public interface that developer can directly use"
interface WithSpec<I extends Item> extends _StandardDynamicWithClause<SelectSpec<I>>,
        _StandardStaticWithClause<SelectSpec<I>>,
        SelectSpec<I> {
}

// select() lands you here...
interface _StandardSelectClause<I extends Item>
        extends _ModifierListSelectClause<SQLs.Modifier, _StandardSelectCommaClause<I>>,
        _DynamicModifierSelectClause<SQLs.Modifier, _FromSpec<I>> {
}

// ...from() lands you here, and joins branch/merge as a type-level graph:
interface _JoinSpec<I extends Item> extends _StandardJoinClause<_JoinSpec<I>, _OnClause<_JoinSpec<I>>>,
        _JoinCteClause<_OnClause<_JoinSpec<I>>>,
        _CrossJoinCteClause<_OnClause<_JoinSpec<I>>>,
        _WhereSpec<I> {
}

interface _WhereSpec<I extends Item>
        extends Statement._QueryWhereClause<_GroupBySpec<I>, _WhereAndSpec<I>>, _GroupBySpec<I>> {
}
Enter fullscreen mode Exit fullscreen mode

Follow the chain and you get the complete SQL grammar:

WithSpec → SelectSpec → _FromSpec → _JoinSpec → _WhereSpec
        → _GroupBySpec → _HavingSpec → _WindowSpec
        → _OrderBySpec → _LimitSpec → _LockSpec → terminal
Enter fullscreen mode Exit fullscreen mode

Write a clause out of order and the compiler rejects it, with the same strictness jOOQ offers. The branching is real too: _JoinSpec extends both further-join capability and _WhereSpec, so "keep joining or move to WHERE" is expressed in types, not runtime checks.

The twist: every intermediate spec is a nested interface whose Javadoc reads:

Application developer isn't allowed to directly use this interface... army don't guarantee compatibility to future distribution.

The user sees one entry (WithSpec) and four terminal statement families (Query, Insert, Update, Delete). The ~15-stage state machine exists in full — it is just locked in a black box.

How jOOQ encodes the same grammar

jOOQ uses a public linear inheritance chain. Walking the actual extends clauses on a current trunk checkout:

SelectSelectStep → SelectDistinctOnStep → SelectIntoStep → SelectFromStep
→ SelectWhereStep → SelectConnectByStep → SelectGroupByStep → SelectHavingStep
→ SelectWindowStep → SelectQualifyStep → SelectOrderByStep → SelectLimitStep
→ SelectForUpdateStep → SelectForStep → SelectOptionStep → SelectUnionStep
→ SelectCorrelatedSubqueryStep → SelectFinalStep → Select
Enter fullscreen mode Exit fullscreen mode

That is 19 levels of public interface. There are 68 Select*Step source files. And the god-entry-point DSLContext.java measures:

16,556 lines
  ~848 method declarations
  280 distinct method base names
Enter fullscreen mode Exit fullscreen mode

(The fetch* family alone includes fetch, fetchSingle, fetchOne, fetchLazy, fetchAsync, fetchStream, fetchOptional, fetchGroups, fetchMap... each with many overloads.)

This is not a mistake by stupid people — it is a precise trade-off. The public chain gives human developers the most aggressively narrowed IDE autocomplete in the industry, and every one of those 68 types is a compatibility contract that paying enterprise customers depend on. jOOQ even did one breaking rewrite (3.0, splitting Factory into DSL/DSLContext) and kept the Step chain — they are not incapable of surgery; the chain is load-bearing.

The difference is purely in where the state machine lives:

QueryDSL jOOQ Army
Clause order enforced At runtime Compile time (inheritance chain) Compile time (generic spec graph)
State machine visibility User's problem 68 public Steps, all contracts ~15 specs, all forbidden; 1 public entry
Cost of adding a clause Add a method Insert a level, change all return types Compose a spec into the right stages

A 2009 fork, not a 2023 discovery

It is tempting to excuse this as "Java didn't allow anything else back then". It did. Generics shipped in 2004; package-private types and multiple interface composition existed since Java 1; Army's clause layer itself uses zero default methods and zero sealed keywords — it would compile on Java 5.

And the alternative was already running in production: QueryDSL (created 2007) shipped a wide-interface fluent SQL API. Lukas Eder himself wrote in 2014: "In the beginning of jOOQ in 2009, QueryDSL was ahead of us." (blog.jooq.org, QueryDSL 1.2.0 manual, ©2007–2009)

Three roads were on the table simultaneously in 2009: wide interfaces (QueryDSL), public inheritance steps (jOOQ), and composed generic specs (the construction Army later chose). All three were legal then.


3. The parameter/literal/type decision happens at expression-node creation

Look again at that WHERE clause:

.where(ChinaRegion_.name.equal(SQLs::param, regionName))
.and(ChinaRegion_.regionType.equal(SQLs::literal, regionType))
Enter fullscreen mode Exit fullscreen mode

equal(SQLs::param, x) binds x as a JDBC ? parameter. equal(SQLs::literal, x) inlines x as a SQL literal. The choice is made where the expression node is born, and the node carries its mapping type with it. There is no separate, parallel list of parameter mappings walking alongside rendered text (as in MyBatis' BoundSql + ParameterMapping) and no eq(Object) escape hatch that silently accepts anything.

This matters most where the database type changes the SQL syntax. A PostgreSQL jsonb value may need a cast, an array literal needs ARRAY[...] construction, an enum has three different storage conventions. Army models those as first-class mapping types in army-core/.../mapping/:

NameEnumType      CodeEnumType      LabelEnumType
JsonType / JsonbType / JsonMappingType / JsonbMappingType
ArrayMappingType  CompositeType     VectorType     XmlType ...
Enter fullscreen mode Exit fullscreen mode

In jOOQ, enums are second-class citizens handled by configuring a Converter/EnumConverter (forced types); the rendering knowledge is not intrinsic to the expression node. And while Field<T>.eq(T) is type-safe, jOOQ's wide surface also offers eq(Object), plain-SQL fields, and DSL.field(String, DataType) — paths through which the wrong operand type compiles and fails at bind time. The generic parameter largely decides what you get out of a fetch; it does not fully police what goes into the AST.

The generated metamodel reinforces this at compile time. This is actual annotation-processor output from the project's build directory:

@Generated(value = "io.army.modelgen.ArmyMetaModelDomainProcessor")
public abstract class ProductInfo_ {

    private ProductInfo_() { throw new UnsupportedOperationException(); }

    public static final CompositeType T;

    static {
        T = CompositeType.from(ProductInfo.class);
        final int fieldSize = T.fieldList().size();
        if (fieldSize != 8) {
            throw _TableMetaFactory.compositeFieldSizeError(ProductInfo.class, fieldSize);
        }
    }

    public static final CompositeField productId   = T.field("productId");
    public static final CompositeField productName = T.field("productName");
    public static final CompositeField price       = T.field("price");
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The processor also rejects illegal table names (camelCase) and index declarations referencing non-existent fields — schema mistakes that cannot reach a running database.


4. The public API is interfaces, sealed; implementations are package-private

Building a factory, verbatim from army-example/src/test/java/io/army/session/FactoryUtils.java:

public static SyncSessionFactory createArmyBankSyncFactory(final Database database) {
    return SyncFactoryBuilder.builder()
            .name(mapDatabaseToFactoryName(database))
            .packagesToScan(Collections.singletonList("io.army.example.bank.domain"))
            .datasource(DataSourceUtils.createDataSource(database))
            .environment(createEnvironment(database))
            .jsonCodec(FastJsonCodec.getInstance())
            .fieldGeneratorFactory(new SimpleFieldGeneratorFactory())
            .build();
}
Enter fullscreen mode Exit fullscreen mode

Static factory entry, fluent options, final immutable factory. The interfaces are sealed and permit exactly one package-private implementation:

public sealed interface SyncSessionFactory permits ArmySyncSessionFactory { ... }
public sealed interface SyncFactoryBuilder  permits ArmySyncFactoryBuilder { ... }

final class ArmySyncFactoryBuilder extends ... { ... }   // package-private
final class ArmySyncSessionFactory implements ... { ... } // package-private
Enter fullscreen mode Exit fullscreen mode

You cannot instantiate the implementation, you cannot write your own implements SyncSessionFactory (the permits clause forbids it), and you cannot subclass the builder. There is no public DefaultXxx class doubling as unofficial API — compare with MyBatis, where DefaultSqlSessionFactory and its constructor are both public under a defaults package that is only a gentleman's agreement.

(Small honest wart: the builder's datasource(...) parameter is typed Object so it can accept both javax.sql.DataSource and Army's own read/write-splitting datasource. That is a real, if small, compile-time-safety leak.)

Executing statements stays narrow too — the whole sync session surface is ~63 methods, with query consumption unified behind ResultItem (rows, update counts, and metadata as one numbered stream), instead of hundreds of fetchXxx variants:

SyncLocalSession session = factory.localSession();

Long id = session.queryOne(stmt, Long.class);                       // one value
List<Map<String, Object>> rows = session.queryObjectList(stmt, HashMap::new);
session.update(insertStmt);                                         // DML
session.save(domain);                                               // active-record-style
Enter fullscreen mode Exit fullscreen mode

5. Why I think this matters for AI — beyond hype

The point is not "AI likes Army". It is that AI coding agents are essentially program transformers under compiler supervision, and their failure mode is generating structurally plausible text.

Facing the three SQL-DSL architectures, an agent gets structurally different feedback:

  • Wide interfaces (QueryDSL-style): every method is callable in every state; wrong clause order fails latest — at runtime.
  • 19 public Step types (jOOQ-style): autocomplete narrowing is superb for humans, but those 68 SelectConnectByAfterStartWithConditionStep-shaped names are vanishingly rare in training data; an agent must reason about which step am I on across a huge public type graph.
  • One entry + hidden generic specs (Army-style): the public vocabulary the model must hold is tiny (one entry, four terminal statements, a handful of clause functions), while wrong order is still killed by the compiler. Full constraint strength, roughly an order of magnitude fewer public types to reason about.

When the expression node also carries its mapping type, the agent doesn't merely emit text that looks like PostgreSQL — the renderer produces the required cast/array/enum syntax deterministically. The type system is, in effect, a cheap, unforgiving environment: a compiler error is feedback that does not depend on anyone's language.


6. The honest counter-section

You should not read this as "Army beats jOOQ". It does not, on most axes that matter for production:

  • Maturity: jOOQ has ~17 years of releases, 30+ dialects, commercial support, enormous manuals, and tens of thousands of deployments. Army is 0.6.x with essentially one user.
  • Dialect depth is concentrated: PostgreSQL and MySQL are deep (single files like PostgreDocumentFunctions.java run over 6,000 lines), SQLite/H2 are thinner, and Oracle is a five-file skeleton.
  • Tests are thin relative to scope: ~20.8k lines of test code against ~237k lines of main code. jOOQ's test matrix is larger than its main source.
  • The reactive stack was removed, not merely unbuilt — consciously, for lack of maintainer resources — so today only the blocking/SPI story is shipped.
  • Risk: single maintainer, no commercial entity, pre-1.0 API churn is possible despite the sealing discipline.

What Army does provide is existence proof: that a Java SQL DSL can (1) treat JDBC as a swappable detail, (2) enforce full clause order at compile time through a single public entry, (3) bind parameter/literal/mapping semantics into the expression node, and (4) seal the entire runtime surface — all using language features that have existed since 2004.

The strongest sentence I am willing to write is this: jOOQ optimized its public surface for human discovery across two decades of compatibility; Army optimized its public surface for minimality and compiler-enforced structure. In a world where an increasing share of SQL is written by agents and judged by compilers, the second bet deserves to be seen.


Appendix: how to verify

# jOOQ (current trunk checkout)
wc -l jOOQ/src/main/java/org/jooq/DSLContext.java
ls jOOQ/src/main/java/org/jooq | grep -c '^Select.*Step'

# Army
grep -rn "import java.sql" army-core/src/main/java | wc -l        # 0
find . -path '*/src/main/java/*.java' | wc -l                     # 1361
Enter fullscreen mode Exit fullscreen mode

Measurements taken September 2026 on jOOQ trunk and Army 0.6.x source. I have no affiliation with either project.

Top comments (0)