norm4j (Not an ORM for Java) is a lightweight, SQL-centric alternative to JPA, built for developers who want more control and better performance without giving up the productivity benefits of annotation-driven programming.
While inspired by JPA to simplify migration and ease adoption, norm4j breaks away from traditional ORM patterns. It does not load object graphs by default. Instead, entities can define relationships using familiar annotations, but related data is only loaded explicitly when needed—providing fine-grained control over performance and memory usage.
norm4j focuses on records rather than object hierarchies. There's no inheritance, no automatic eager-loading, and no deep object references by default. However, developers can build wrapper layers or proxy objects on top of norm4j records to simulate object-oriented patterns if desired. This design gives you full control over what gets loaded, when, and how. RecordMapper and RecordMapperBuilder provide a type‑safe bridge from records to fully populated domain objects—so you control what gets mapped and when.
Built with Jakarta EE in mind, norm4j integrates naturally into modern enterprise stacks. It uses CDI for dependency injection and is designed to work with JTA transactions, interceptors, and connection pooling—making it easy to slot into existing JPA-based applications or microservices.
Despite looking like an ORM, it’s not. norm4j is about control, performance, and staying close to core database concepts like primary keys, foreign keys, and native SQL. It provides just enough abstraction to avoid boilerplate—while keeping your hands on the actual SQL when needed.
Support is available for PostgreSQL, SQL Server, MariaDB, and Oracle (Oracle not yet tested—feedback welcome!).
We’d love your feedback, ideas, and help with testing across different platforms.
Add the following to your pom.xml:
<dependency>
<groupId>org.norm4j</groupId>
<artifactId>norm4j-core</artifactId>
<version>1.1.17</version>
</dependency>PostgreSQL
<dependency>
<groupId>org.norm4j</groupId>
<artifactId>norm4j-postgresql</artifactId>
<version>1.1.17</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.2</version>
</dependency>MariaDB
<dependency>
<groupId>org.norm4j</groupId>
<artifactId>norm4j-mariadb</artifactId>
<version>1.1.17</version>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.5.2</version>
</dependency>SQL Server
<dependency>
<groupId>org.norm4j</groupId>
<artifactId>norm4j-sqlserver</artifactId>
<version>1.1.17</version>
</dependency>
<dependency>
<groupId>com.microsoft.sqlserver</groupId>
<artifactId>mssql-jdbc</artifactId>
<version>12.10.2.jre11</version>
</dependency>Oracle
<dependency>
<groupId>org.norm4j</groupId>
<artifactId>norm4j-oracle</artifactId>
<version>1.1.17</version>
</dependency>To automatically generate DDL scripts and a dialect‑agnostic schema description from your @Table classes at build time, you can use the norm4j Maven plugin.
Example configuration (single dialect, PostgreSQL):
<build>
<plugins>
<plugin>
<groupId>org.norm4j</groupId>
<artifactId>norm4j-maven-plugin</artifactId>
<version>1.1.17</version>
<configuration>
<!-- Packages to scan for @Table-annotated classes -->
<packages>
<package>com.mycompany.app.records</package>
</packages>
<!-- Dialects for which to generate DDL -->
<dialects>
<dialect>postgresql</dialect>
</dialects>
<!-- Schema version label used in the folder path -->
<version>v0.1</version>
<!-- Optional: Base output directory (per project) -->
<outputDirectory>${project.basedir}/src/main/resources/db</outputDirectory>
</configuration>
<executions>
<execution>
<!-- Optional: VS Code / m2e hint to execute in IDE builds -->
<?m2e execute onConfiguration,onIncremental?>
<id>generate-ddl</id>
<!-- Run after classes are compiled -->
<phase>process-classes</phase>
<goals>
<goal>generate-ddl</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>This will generate, for each build:
src/main/resources/db/<version>/schema.json
src/main/resources/db/<version>/<dialect>/ddl.sql
For the example above:
src/main/resources/db/v0.1/schema.json
src/main/resources/db/v0.1/postgresql/ddl.sql
schema.jsonis a dialect‑independent snapshot of the schema (tables, columns, joins, generators, …).ddl.sqlis the concrete DDL for the given dialect.
schema.json is used by SchemaSynchronizer for automatic schema creation/migration (see below). ddl.sql remains available if you prefer executing raw SQL instead.
For tests or multi-dialect scenarios, you can configure several dialects and a test resources output directory, for example:
<plugin>
<groupId>org.norm4j</groupId>
<artifactId>norm4j-maven-plugin</artifactId>
<version>1.1.17</version>
<configuration>
<packages>
<package>org.norm4j.tests.test15</package>
</packages>
<dialects>
<dialect>mariadb</dialect>
<dialect>oracle</dialect>
<dialect>postgresql</dialect>
<dialect>sqlserver</dialect>
</dialects>
<version>v0.1</version>
<outputDirectory>${project.basedir}/src/test/resources/db</outputDirectory>
</configuration>
<executions>
<execution>
<?m2e execute onConfiguration,onIncremental?>
<id>generate-ddl</id>
<phase>process-test-classes</phase>
<goals>
<goal>generate-ddl</goal>
</goals>
</execution>
</executions>
</plugin>which will generate:
src/test/resources/db/v0.1/schema.json
src/test/resources/db/v0.1/mariadb/ddl.sql
src/test/resources/db/v0.1/oracle/ddl.sql
src/test/resources/db/v0.1/postgresql/ddl.sql
src/test/resources/db/v0.1/sqlserver/ddl.sql
Use annotations to define database tables and relationships.
@Table(name = "author")
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column(nullable = false)
private String name;
// Getters and Setters ...
}@Table(name = "book")
@Join(
columns = "author_id",
reference = @Reference(table = Author.class, columns = "id")
)
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column(nullable = false)
private String name;
@Column(name = "author_id")
private int authorId;
// Getters and Setters ...
}MetadataManager metadataManager = new MetadataManager();
metadataManager.registerTable(Book.class);
metadataManager.registerTable(Author.class);
metadataManager.createTables(getDataSource());
TableManager tableManager = new TableManager(getDataSource(), metadataManager);or
MetadataManager metadataManager = new MetadataManager();
metadataManager.registerPackage("org.norm4j.tests.test11");
metadataManager.createTables(getDataSource());
TableManager tableManager = new TableManager(getDataSource(), metadataManager);Persist
Author author = new Author();
author.setName("Author A");
tableManager.persist(author);
Book book = new Book();
book.setName("Book A");
book.setAuthorId(author.getId());
tableManager.persist(book);Find
Author loadedAuthor = tableManager.find(Author.class, author.getId());Merge
loadedAuthor.setName("Author A+");
tableManager.merge(loadedAuthor);Remove
tableManager.remove(book);
tableManager.remove(Author.class, loadedAuthor.getId());Author bookAuthor = tableManager.joinOne(book, Author.class);List<Book> books = tableManager.joinMany(author, Book.class);List<Book> books = tableManager.joinMany(author, Author::getId, Book.class, Book::getAuthorId);List<Book> books = tableManager.createSelectQueryBuilder()
.select(Book.class)
.from(Book.class)
.innerJoin(Author.class)
.where(Book::getAuthorId, "=", author.getId())
.orderBy(Book::getName)
.getResultList(Book.class);List<Book> books = tableManager.createSelectQueryBuilder()
.select(Book.class)
.from(Book.class)
.where(q -> q.condition(Book::getId, "=", 1)
.or(Book::getId, "=", 2))
.and(Book::getAuthorId, "=", author.getId())
.orderByDesc(Book::getName)
.getResultList(Book.class);tableManager.createUpdateQueryBuilder()
.update(Book.class)
.set(Book::getBookType, BookType.Documentation)
.where(Book::getId, "=", book1.getId())
.executeUpdate();tableManager.createDeleteQueryBuilder()
.from(Book.class)
.where(Book::getId, "=", book1.getId())
.executeUpdate();Query query = tableManager.createQuery("SELECT * FROM book WHERE id = ?");
query.setParameter(1, book.getId());
List<Book> books = query.getResultList(Book.class);AuthorDTO authorDTO;
authorDTO = RecordMapper.from(Author.class, AuthorDTO.class).map(author);One to Many
RecordMapper<Author, AuthorDTO> authorMapper;
List<BookDTO> books;
authorMapper = RecordMapperBuilder.from(Author.class, AuthorDTO.class)
.map(Author::getId).to(AuthorDTO::getId) // Automactivally mapped, but can be overriden
.join(AuthorDTO::getBooks, Book.class, BookDTO.class)
.endJoin()
.build(tableManager);
authorDTO = authorMapper.map(author);
books = authorDTO.getBooks();Many to One
RecordMapper<Author, AuthorDTO> authorMapper;
AuthorDTO authorDTO;
BookDTO bookDTO;
bookMapper = RecordMapperBuilder.from(Book.class, BookDTO.class)
.join(BookDTO::getAuthor, Author.class, AuthorDTO.class)
.endJoin()
.build(tableManager);
bookDTO = bookMapper.map(book);
authorDTO = bookDTO.getAuthor();Map a specific field of the related object
RecordMapper<Author, AuthorDTO> authorMapper;
List<UUID> bookIds;
authorMapper = RecordMapperBuilder.from(Author.class, AuthorDTO.class)
.join(AuthorDTO::getBookIds, Book.class, UUID.class)
.map(Book::getId).toObject()
.build(tableManager);
authorDTO = authorMapper.map(author);
bookIds = authorDTO.getBookIds();norm4j provides a small helper, SchemaSynchronizer, to manage schema versions in a code-first way.
- Each version has a unique name (
"v0.1","v0.2","add-orders-2025-11-17", …). - Applied versions are stored in a
schema_versiontable (or a custom table name). - A version can:
- execute one or more SQL statements or
Queryobjects, - load SQL from classpath resources (e.g. DDL generated by the norm4j Maven plugin),
- optionally restrict execution to a specific dialect,
- define special statements that are only executed when this version is the initial version applied on an empty database,
- optionally use
schema.jsonsnapshots (generated by the Maven plugin) to auto‑create or auto‑migrate the schema.
- execute one or more SQL statements or
You can safely run the synchronizer on every startup; already-applied versions are automatically skipped based on their name. All operations for all versions are executed in a single transaction, and a lock is taken on the schema_version table to avoid concurrent startups applying the same version twice.
The SchemaSynchronizer execution model is:
-
By default,
startFromFirstVersion(false)(the default):-
If no version has been applied yet (i.e.
schema_versionis empty):- the database is considered fresh,
- only the last defined version is applied,
- if that version’s
schema().enableAutoCreation(true)is set, itsschema.jsonis used to create the schema, - that version’s
executeIfInitial(...)/executeResourceIfInitial(...)statements are executed, - the version is recorded in
schema_version.
-
If at least one version exists in
schema_version:- all configured versions are scanned in order,
- for each version that is not yet in
schema_version:- if it is the first missing version and
schema().enableAutoCreation(true)is set, itsschema.jsonis used to create the schema, - otherwise, if
schema().enableAutoMigration(true)is set, norm4j computes a diff between the previous version’sschema.jsonand the current one and generates DDL to migrate, - its normal
execute(...)/executeResource(...)statements are executed, - the version is recorded in
schema_version.
- if it is the first missing version and
-
-
If you call
startFromFirstVersion(true):- even on an empty database, norm4j will start from the first configured version, applying auto‑creation from its
schema.json, then auto‑migrating between subsequent versions whereenableAutoMigration(true)is set, plus anyexecute(...)statements. - In this mode,
executeIfInitial*statements are not used; migrations are driven by the schema snapshots and normalexecute*calls.
- even on an empty database, norm4j will start from the first configured version, applying auto‑creation from its
This gives you two main workflows:
- Fast bootstrap (default): on a fresh DB, jump directly to the last version and run only its initial logic.
- Full history replay: when
startFromFirstVersion(true), rebuild the schema from version 1, replaying migrations between versions.
On SchemaSynchronizer itself:
schemaVersionTable(String tableName)– override the defaultschema_versiontable name.schema(String schemaName)– optionally force a schema for theschema_versiontable.databaseResourcePath(String path)– base classpath directory forschema.jsonand DDL files; defaults to"db"(so norm4j looks fordb/<version>/schema.json).version(String name)– start configuring a schema version with the given uniquenameand return aVersionBuilder.startFromFirstVersion(boolean flag)– control whether a fresh database should start from the first version (true) or only from the last version (false, default).
From a VersionBuilder you can:
description(String description)– optional human-readable description.
Normal (upgrade) statements:
execute(String sql)– execute a raw SQL string for all dialects.execute(String sql, Class<? extends SQLDialect> dialect)– execute SQL only for a specific dialect.execute(Query query)– execute aQueryobject for all dialects.execute(Query query, Class<? extends SQLDialect> dialect)– execute aQueryonly for a specific dialect.executeResource(String resourcePath)– load SQL from a classpath resource and execute it for all dialects.executeResource(String resourcePath, Class<? extends SQLDialect> dialect)– execute a resource only for a specific dialect.
Initial-only statements (only when this version is applied first on an empty DB and startFromFirstVersion(false)):
executeIfInitial(String sql)executeIfInitial(String sql, Class<? extends SQLDialect> dialect)executeIfInitial(Query query)executeIfInitial(Query query, Class<? extends SQLDialect> dialect)executeResourceIfInitial(String resourcePath)executeResourceIfInitial(String resourcePath, Class<? extends SQLDialect> dialect)
These executeIfInitial* methods are only used when:
schema_versionis currently empty,startFromFirstVersion(false)(default) is in effect, and- this version is the last version in your
SchemaSynchronizerconfiguration.
SchemaBuilder API (auto‑creation / auto‑migration):
Each version has an associated SchemaBuilder, accessed via:
schema()– returns aSchemaBuilderfor this version.- On
SchemaBuilder:enableAutoCreation(boolean enable)– iftrue, norm4j will use<databaseResourcePath>/<version>/schema.jsonto create the schema when this version is used as the base.enableAutoMigration(boolean enable)– iftrue, norm4j will diff<previousVersion>/schema.json→<thisVersion>/schema.jsonand generate DDL to migrate when upgrading from the previous version.endSchema()– return back to theVersionBuilder.
At the end of a version:
endVersion()– closes the current version and returns theSchemaSynchronizer.
A typical setup using the Author/Book example and PostgreSQL might look like this:
-
Configure the Maven plugin to generate both
schema.jsonand DDL:<plugin> <groupId>org.norm4j</groupId> <artifactId>norm4j-maven-plugin</artifactId> <version>1.1.17</version> <configuration> <packages> <package>com.mycompany.app.records</package> </packages> <dialects> <dialect>postgresql</dialect> </dialects> <version>v0.1</version> <outputDirectory>${project.basedir}/src/main/resources/db</outputDirectory> </configuration> <executions> <execution> <?m2e execute onConfiguration,onIncremental?> <id>generate-ddl</id> <phase>process-classes</phase> <goals> <goal>generate-ddl</goal> </goals> </execution> </executions> </plugin>
This will produce:
src/main/resources/db/v0.1/schema.json src/main/resources/db/v0.1/postgresql/ddl.sql -
Use
SchemaSynchronizerat startup to apply the schema and seed initial data:TableManager tableManager = new TableManager(getDataSource(), metadataManager); new SchemaSynchronizer(tableManager) // Optional: override the base resource path (defaults to "db") .databaseResourcePath("db") // Default is false; shown here explicitly for clarity .startFromFirstVersion(false) .version("v0.1") .description("Initial author & book schema") .schema() .enableAutoCreation(true) // use v0.1/schema.json on fresh DB .endSchema() .endVersion() .version("v0.2") .description("Seed initial author & book data") .schema() // You could enable autoMigration here if you also generate v0.2/schema.json .enableAutoMigration(false) .endSchema() // When this version is used as the first version on a brand-new DB: .executeResourceIfInitial("db/v0.2/postgresql/ddl.sql", PostgreSQLDialect.class) .executeIfInitial("insert into author (name) values ('Author A');") .executeIfInitial("insert into book (name, author_id) values ('Book A', 1);") // When upgrading from v0.1 to v0.2: .execute("insert into author (name) values ('Author A');") .execute("insert into book (name, author_id) values ('Book A', 1);") .endVersion() .apply();
In this example:
-
On a fresh database (no rows in
schema_version), withstartFromFirstVersion(false):- only the last version (
v0.2) is applied, - its
schema().enableAutoCreation(true)is ignored (because creation is delegated tov0.2in this mode only if you configure it so), - only its
executeIfInitial*statements are executed, v0.2is recorded inschema_version.
- only the last version (
-
On an existing database where
v0.1is already recorded:v0.2is applied using itsexecute(...)statements,- its
executeIfInitial*statements are ignored.
If instead you set startFromFirstVersion(true) and provide both v0.1/schema.json and v0.2/schema.json with enableAutoCreation(true) on v0.1 and enableAutoMigration(true) on v0.2, norm4j will:
- create the schema from
v0.1/schema.jsonon a fresh DB, - auto‑migrate from
v0.1→v0.2using the schema diff, - then run
v0.2’sexecute(...)statements.
@ApplicationScoped
public class TableManagerFactory
{
@Resource(name = "jdbc/norm_test")
private DataSource dataSource;
private TableManager tableManager;
public TableManagerFactory()
{
}
@PostConstruct
public void initialize()
{
MetadataManager metadataManager;
metadataManager = new MetadataManager();
metadataManager.registerTable(Book.class);
metadataManager.registerTable(Author.class);
// or register all tables in a package
// metadataManager.registerPackage("com.company.records");
metadataManager.createTables(dataSource);
tableManager = new TableManager(dataSource, metadataManager);
}
@Produces
public TableManager getTableManager()
{
return tableManager;
}
}@Stateless
public class AuthorService
{
@Inject
private TableManager tableManager;
public AuthorService()
{
}
public Author findById(int id)
{
return tableManager.find(Author.class, id);
}Instead of calling createTables(...) manually, you can let SchemaSynchronizer manage schema versions and DDL execution:
@ApplicationScoped
public class TableManagerFactory {
@Resource(name = "jdbc/norm_test")
private DataSource dataSource;
private TableManager tableManager;
@PostConstruct
public void initialize() {
MetadataManager metadataManager;
metadataManager = new MetadataManager();
metadataManager.registerPackage("com.mycompany.app.records");
tableManager = new TableManager(dataSource, metadataManager);
new SchemaSynchronizer(tableManager)
.version("v0.1")
.description("Initial schema")
.schema()
.enableAutoCreation(true)
.endSchema()
.endVersion()
.apply();
}
@Produces
public TableManager getTableManager() {
return tableManager;
}
}- Schema Versioning & Migrations with
SchemaSynchronizer - Composite Primary Keys via
@IdClass - Join with Multiple Columns using
@Join(columns = {...}) - Enumerated Fields with
@Enumerated(EnumType.STRING|ORDINAL) - Date/Time Mapping via
@Temporal - Array Fields using
@Array(type = ArrayType.Vector/Array) - Join without Referencial Integrity (No Foreign Key) using
@Join(referencialIntegrity = false)
AUTOIDENTITYSEQUENCETABLEUUID
-
Why Norm4j takes a Code-First Approach (vs. Database-First Tools Like jOOQ)
-
Norm4j: Simplifying Entity-to-DTO Mapping with a Powerful DSL
-
Schema Versioning in norm4j: Keeping Your SQL-First World in Sync
-
How to Build a Lightweight Java REST API with TomEE and norm4j
Edit the file:
norm4j-test/src/test/resources/application-test.properties
datasource.driver=org.postgresql.Driver
datasource.url=jdbc:postgresql://localhost:5432/norm_test
datasource.username=test
datasource.password=passwordCREATE USER test WITH PASSWORD 'password';
CREATE DATABASE norm_test;
CREATE EXTENSION vector;
ALTER DATABASE norm_test OWNER TO test;
CREATE SCHEMA test1;
CREATE SCHEMA test2;
CREATE SCHEMA test3;
CREATE SCHEMA test4;
CREATE SCHEMA test5;
GRANT ALL PRIVILEGES ON SCHEMA test1 TO test;
GRANT ALL PRIVILEGES ON SCHEMA test2 TO test;
GRANT ALL PRIVILEGES ON SCHEMA test3 TO test;
GRANT ALL PRIVILEGES ON SCHEMA test4 TO test;
GRANT ALL PRIVILEGES ON SCHEMA test5 TO test;
GRANT ALL PRIVILEGES ON SCHEMA public TO test;datasource.driver=org.mariadb.jdbc.Driver
datasource.url=jdbc:mariadb://localhost:3306/norm_test
datasource.username=test
datasource.password=passwordCREATE USER 'test'@'%' IDENTIFIED BY 'password';
CREATE DATABASE norm_test;
GRANT ALL PRIVILEGES ON norm_test.* TO 'test'@'%';
FLUSH PRIVILEGES;datasource.driver=com.microsoft.sqlserver.jdbc.SQLServerDriver
datasource.url=jdbc:sqlserver://localhost;encrypt=false;database=norm_test;
datasource.username=test
datasource.password=passwordCREATE LOGIN [test] WITH PASSWORD=N'password'
CREATE DATABASE norm_test
CREATE SCHEMA test1 AUTHORIZATION test
CREATE SCHEMA test2 AUTHORIZATION test
CREATE SCHEMA test3 AUTHORIZATION test
CREATE SCHEMA test4 AUTHORIZATION test
CREATE SCHEMA test5 AUTHORIZATION testcd norm4j-test
mvn clean testnorm4j is actively looking for feedback and contributors!
If you test with Oracle or other platforms, please share your experience!