Skip to content

Repository files navigation

Minimal Java ORM

A deliberately small, convention-first ORM built directly on JDBC. Domain models do not need annotations or any dependency on this library.

The library itself has no runtime dependency outside the JDK. Its default path favors ordinary Java classes and inferred mappings; programmatic mappings stay in persistence code when conventions are insufficient. It deliberately keeps operation and transaction boundaries explicit instead of emulating a persistence context.

Capabilities

  • get(Entity.class, id) returning Optional<Entity>
  • findOne(Entity.class, "fieldName", value) using mapped Java field names
  • findOne(Entity.class, Map.of(...)) for equality predicates joined with AND
  • inserts with assigned or JDBC-generated IDs
  • full-row updates by ID
  • deletion by an entity's ID
  • loading one explicitly configured child List with its parent entity
  • atomic parent-and-collection inserts
  • atomic parent-and-collection updates
  • optimistic revision checks for concurrent updates
  • table names inferred from entity names
  • snake_case column names inferred from Java field names
  • id and <entityName>Id ID conventions
  • standard SQL types inferred from Java types
  • persistence-layer mappings for scalar and record value objects
  • optional annotations for custom table names, column names, SQL types, and IDs
  • inherited mapped fields
  • basic JDBC-to-Java value conversion
  • metadata caching and prepared statements
  • callback-scoped transactions from a DataSource
  • caller-owned connections for explicit JDBC transaction scopes
  • custom SQL list mapping to entities or constructor-based record projections
  • scalar custom-SQL mapping for counts and similar single-value queries

It intentionally does not provide automatic relationship discovery, dirty tracking, lazy loading, a persistence context, migrations, Spring integration, or JPA compatibility.

Validation projects

The flagship accounting contention demo uses the ORM in a Java 25/PostgreSQL transaction-processing system. It maps validating account numbers, multi-column money values, insert-only journal entries with ordered lines, and joined statement projections. The same domain posting operation runs behind pessimistic, optimistic, and durable asynchronous strategies, with source-pinned benchmark evidence.

The RealWorld Conduit validation implements the full article, comment, favorite, tag, profile, and authentication slices with interchangeable ORM and direct-JDBC adapters. Its upstream API contracts and deterministic read benchmark provide a conventional web application comparison.

Code tour

Concern Starting point
Public operations and transaction scopes JdbcOrm
Entity and child-list configuration EntityMapping
Scalar and record value-object configuration ValueMapping, OrmConfiguration
Convention and reflection metadata TableModelFactory, MappedMembersFactory
Prepared row and collection operations JdbcRows, JdbcCollections
JDBC value conversion JdbcValues
Focused aggregate examples examples

Example

public class Person {
    private Long id;
    private String displayName;

    public Person() {
    }
}

DataSource dataSource = /* your JDBC DataSource */;
JdbcOrm orm = new JdbcOrm(dataSource);

Optional<Person> person = orm.get(Person.class, 42L);

This maps Person to table Person, id to column id, and displayName to column display_name. The ID is bound as Types.BIGINT and the string maps to Types.VARCHAR.

When conventions do not match the database, use optional overrides:

@Table(name = "people")
public class Person {
    @Id
    @Column(name = "person_key", sqlType = Types.BIGINT)
    private long key;

    @Column(name = "display_name")
    private String name;

    public Person() {
    }
}

Models can remain annotation-free by configuring overrides in the persistence layer:

EntityMapping<Person> mapping = EntityMapping.builder(Person.class)
        .table("people")
        .column("displayName", "display_name", Types.VARCHAR)
        .build();

JdbcOrm orm = new JdbcOrm(dataSource, mapping);
Person person = new Person();
// Set the fields required by your table.
Person created = orm.insert(person);
Optional<Person> found = orm.findOne(Person.class, "displayName", "Ada");
orm.update(found.orElseThrow());
orm.delete(found.orElseThrow());

Use inTransaction when several ORM operations must commit or roll back together:

boolean created = orm.inTransaction(transaction -> {
    transaction.insert(article);
    transaction.insert(tag);
    return true;
});

The callback receives an ORM bound to one transaction connection and sharing the original ORM's explicit mappings. Returning commits; throwing rolls back.

Alternatively, bind an ORM instance to a caller-owned connection. The caller then remains responsible for commit, rollback, and close:

try (Connection connection = dataSource.getConnection()) {
    connection.setAutoCommit(false);
    JdbcOrm transaction = new JdbcOrm(connection);
    transaction.insert(article);
    transaction.insert(tag);
    connection.commit();
}

Multiple mapped fields can identify a row without adding relationship semantics to the ORM:

Optional<Follow> follow = orm.findOne(Follow.class, Map.of(
        "followerId", followerId,
        "followedId", followedId
));

Predicate keys are mapped Java field names, not database column names. An empty predicate map, unknown field, or null predicate value is rejected.

A null boxed ID is omitted from an insert and populated from JDBC generated keys. A non-null ID, including any primitive ID value, is treated as assigned.

Domain values and read models

Scalar domain values can be converted at the JDBC boundary without adding persistence code to the domain:

ValueMapping<AccountNumber> accountNumbers = ValueMapping.scalar(
        AccountNumber.class,
        String.class,
        AccountNumber::value,
        AccountNumber::new
);

ValueMapping<Currency> currencies = ValueMapping.scalar(
        Currency.class,
        String.class,
        Types.CHAR,
        Currency::getCurrencyCode,
        Currency::getInstance
);

A record mapping flattens one domain value over several columns:

record Money(long minor, Currency currency) {
}

EntityMapping<Account> accounts = EntityMapping.builder(Account.class)
        .version("revision")
        .column("balance.minor", "current_balance_minor")
        .column("balance.currency", "current_balance_currency")
        .build();

OrmConfiguration configuration = OrmConfiguration.builder()
        .value(accountNumbers)
        .value(currencies)
        .value(ValueMapping.record(Money.class))
        .entity(accounts)
        .build();

JdbcOrm orm = new JdbcOrm(dataSource, configuration);

Without overrides, Money balance maps to balance_minor and balance_currency. A null scalar column maps to a null value. A composite value is null when all its columns are null; a partially null composite is rejected. Nested composite records are deliberately not supported in this first version. IDs and revision fields must still occupy one SQL column.

Caller-supplied SQL can map directly to an unregistered record:

record LedgerEntryRow(
        AccountNumber accountNumber,
        String accountName,
        Money amount) {
}

List<LedgerEntryRow> rows = orm.query(
        LedgerEntryRow.class,
        """
        SELECT
            account_number,
            account_name,
            amount_minor,
            amount_currency
        FROM GeneralLedger
        WHERE account_number = ?
        ORDER BY posted_at, journal_entry_id, line_number
        """,
        statement -> statement.setString(1, requestedNumber.value())
);

Record component names use snake_case JDBC labels. Composite components use the component name as their prefix, such as amount_minor. Extra selected columns are ignored; missing or duplicate required labels fail before rows are mapped. Records are read-only projections and are never treated as writable entities.

Entities stored across multiple rows

A plain entity can contain explicitly configured sibling child lists:

public class Promotion {
    private Long id;
    private String code;
    private int claimLimit;
    private long revision;
    private List<Claim> claims = new ArrayList<>();

    public Promotion() {
    }
}

The persistence layer supplies the mapping:

EntityMapping<Promotion> mapping = EntityMapping.builder(Promotion.class)
        .version("revision")
        .collection("claims", Claim.class)
        .build();

JdbcOrm orm = new JdbcOrm(dataSource, mapping);
Promotion promotion = orm.get(Promotion.class, promotionId).orElseThrow();
promotion.claim(customerId, now);
orm.update(promotion);

get reads the promotion and its claims. update advances the revision and replaces the stored claim rows in one transaction. If another write has already advanced the revision, update throws OptimisticLockException without changing the collection.

An aggregate that is an immutable record after insertion can declare that instead of carrying an unused revision:

EntityMapping<JournalEntry> mapping =
        EntityMapping.builder(JournalEntry.class)
                .insertOnly()
                .orderedCollection(
                        "lines", JournalLine.class, "line_number")
                .build();

Insert-only entities and their collections can be inserted and loaded normally. Calling update or delete rejects the operation. An insert-only mapping cannot also configure a version field.

By convention, child table rows use a generated id column and a parent foreign key named from the parent type, such as promotion_id. Those persistence columns need not be fields on the child model. insert writes the parent first and supplies its ID when writing each child in the same transaction. Explicit child ID and foreign-key fields remain supported when the model needs them.

This implementation supports one child List level, including multiple sibling lists, and complete collection replacement. Deleting a mapped parent deletes its configured child rows and parent row in one transaction. When a revision is configured, a stale delete throws OptimisticLockException and rolls back the child deletions.

See the runnable promotion claim example, including its concurrent capacity test.

The runnable payment settlement example maps both transfers and fees under one settlement. It closes only when the transfer total minus the fee total matches the expected net amount, and demonstrates that one revision check protects changes across both child collections.

An optional fourth collection argument maps Java List order to an additional SQL column:

EntityMapping<Route> mapping = EntityMapping.builder(Route.class)
        .version("revision")
        .orderedCollection("stops", Stop.class, "position")
        .build();

position is a SQL column name, not a field on Stop. The ORM writes each element's zero-based list index and orders loaded rows by that column. Reordering the list and updating the route rewrites the indexes. See the runnable delivery route example.

Mapped entities need a no-argument constructor. Mapped fields must be mutable, non-static, and non-transient. Static and transient fields are ignored. Table and column identifiers are restricted to unquoted SQL identifiers (schema-qualified table names such as app.people are accepted).

The JDBC driver is an application concern and is not pulled into the library. The runnable example domain modules use Lombok only at compile time to generate constructors, getters, and field-name constants; their built artifacts have no Lombok runtime dependency.

Runtime failures

JdbcOrm does not expose checked SQLException declarations. JDBC failures are wrapped with their original cause:

OrmException
├── MappingException
├── DatabaseException
│   └── ConstraintViolationException
├── OptimisticLockException
├── TransactionRequiredException
├── NoResultException
└── NonUniqueResultException

DatabaseException exposes the original SQLException, SQL state, and vendor error code. A unique-constraint SQL state is translated to ConstraintViolationException with kind UNIQUE, allowing persistence adapters to handle expected conflicts without inspecting vendor exceptions. StatementBinder.bind still permits SQLException so binding lambdas can call JDBC methods directly; query and scalar wrap any such failure before returning to the caller.

Build

The project requires Java 25 or newer. With SDKMAN:

sdk env
./gradlew test \
  :examples:promotion-claims:domain:test \
  :examples:promotion-claims:persistence:test \
  :examples:payment-settlement:domain:test \
  :examples:payment-settlement:persistence:test \
  :examples:delivery-route:domain:test \
  :examples:delivery-route:persistence:test

License

Licensed under the Apache License 2.0. See Third-Party Notices for build and test dependencies.

About

A minimal JDBC-only ORM for Java

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages