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.
get(Entity.class, id)returningOptional<Entity>findOne(Entity.class, "fieldName", value)using mapped Java field namesfindOne(Entity.class, Map.of(...))for equality predicates joined withAND- inserts with assigned or JDBC-generated IDs
- full-row updates by ID
- deletion by an entity's ID
- loading one explicitly configured child
Listwith 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_casecolumn names inferred from Java field namesidand<entityName>IdID 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.
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.
| 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 |
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.
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.
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.
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.
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:testLicensed under the Apache License 2.0. See Third-Party Notices for build and test dependencies.