CRTP-first, reactive persistence toolkit for GuicedEE services. Provides a fluent entity and query-builder DSL on top of Vert.x 5, Hibernate Reactive 7, and Mutiny, with PostgreSQL as the default driver via Vert.x reactive SQL clients. Domain entities and repositories become expressive, type-safe, and truly non-blocking.
Built on Hibernate Reactive · Vert.x SQL Client · Google Guice · Mutiny · JPMS module com.entityassist · Java 25+
<dependency>
<groupId>com.entityassist</groupId>
<artifactId>entity-assist-reactive</artifactId>
</dependency>Gradle (Kotlin DSL)
implementation("com.entityassist:entity-assist-reactive:2.0.0-SNAPSHOT")- CRTP-shaped entities — extend
BaseEntity<J, Q, I>for self-referencing fluent setters and automatic query builder linkage - Fluent query builder DSL — composable
where(),or(),orderBy(),groupBy(),join(), and aggregate projections with full static typing - Reactive CRUD with Mutiny —
persist(),update(),delete(),get(),getAll(),getCount()all returnUni<T> - Dot-notation path filters —
where("roles.name", Equals, "ADMIN")resolves relationship paths without explicitJoinExpression - Pagination and result limiting —
setFirstResults()/setMaxResults()for offset-based pagination - Aggregate projections —
selectMin(),selectMax(),selectSum(),selectAverage(),selectCount(),selectCountDistinct()with optional aliases - Join support —
INNER,LEFT,RIGHTjoins with on-clause builders and nested join expressions - Criteria delete and update — bulk
delete()andtruncate()via JPA Criteria API, with safety guards against unfiltered deletes - Stateless session support —
builder(StatelessSession)for high-throughput bulk operations - Jakarta Bean Validation —
validateEntity()returns constraint violations before persistence - JPA Attribute Converters — built-in
LocalDate,LocalDateTime, andLocalDate↔Timestampconverters ActiveFlaglifecycle enum — rich status model with ranged query helpers (getActiveRange(),getVisibleRangeAndUp(), etc.)- Cache integration —
setCacheRegion()/setCacheName()for second-level cache hints on queries - JPMS / SPI ready — fits GuicedEE bootstrap and lifecycle; ServiceLoader-driven module discovery
cp .env.example .env # update DB credentials + toggles
mvn -B clean verify # compilation + tests (uses Testcontainers)IRootEntity IQueryBuilderRoot
└─ IDefaultEntity └─ IDefaultQueryBuilder
└─ IBaseEntity └─ IQueryBuilder
↑ ↑
RootEntity<J,Q,I> QueryBuilderRoot<J,E,I>
└─ DefaultEntity<J,Q,I> └─ DefaultQueryBuilder<J,E,I>
└─ BaseEntity<J,Q,I> └─ QueryBuilder<J,E,I>
↑ ↑
Your Entity Your QueryBuilder
Every entity class binds to a matching query builder via CRTP generics — the entity knows its builder type and vice-versa.
Entity.builder(session)
└─ Guice.get(QueryBuilderClass)
├─ setSession(session)
├─ setEntity(this)
└─ return builder
├─ where / or / join / orderBy / groupBy …
├─ select / selectMin / selectMax …
└─ get() / getAll() / getCount() / delete() / persist() / update()
└─ Returns Uni<T>
com.entityassist
├── com.guicedee.persistence (DatabaseModule, SessionFactory wiring)
├── com.guicedee.client (IGuiceContext, Guice injection)
├── jakarta.persistence (JPA Criteria API, @Entity, @Table)
├── org.hibernate.reactive (Mutiny.Session / SessionFactory)
├── org.hibernate.orm.core (Hibernate metamodel, CriteriaBuilder)
├── io.smallrye.mutiny (Uni / Multi reactive types)
├── io.vertx.sql.client.pg (Vert.x PostgreSQL reactive driver)
├── jakarta.xml.bind (JAXB for XML binding)
└── lombok (compile-time only)
@Entity
@Accessors(chain = true)
@Table(name = "entity_class")
public class EntityClass
extends BaseEntity<EntityClass, EntityClass.EntityClassQueryBuilder, String> {
@Id
@Column(name = "id", nullable = false)
@Getter @Setter
private String id;
@Column(name = "name")
@Getter @Setter
private String name;
@Column(name = "description")
@Getter @Setter
private String description;
@Override
public String getId() { return id; }
@Override
public EntityClass setId(String id) {
this.id = id;
return this;
}
public static class EntityClassQueryBuilder
extends QueryBuilder<EntityClassQueryBuilder, EntityClass, String> {
@Override
public boolean isIdGenerated() {
return false;
}
}
}Entities with relationships work the same way:
@Entity
@Accessors(chain = true)
@Table(name = "entity_class_two")
public class EntityClassTwo
extends BaseEntity<EntityClassTwo, EntityClassTwo.EntityClassTwoQueryBuilder, String> {
@Id
@Column(name = "id", nullable = false)
@Getter @Setter
private String id;
@Column(name = "name")
@Getter @Setter
private String name;
@Column(name = "value")
@Getter @Setter
private Integer value;
@ManyToOne
@JoinColumn(name = "entity_class_id")
@Getter @Setter
private EntityClass entityClass;
@Override
public String getId() { return id; }
@Override
public EntityClassTwo setId(String id) {
this.id = id;
return this;
}
public static class EntityClassTwoQueryBuilder
extends QueryBuilder<EntityClassTwoQueryBuilder, EntityClassTwo, String> {
@Override
public boolean isIdGenerated() {
return false;
}
}
}sessionFactory.withSession(session ->
session.withTransaction(tx ->
entity.builder(session)
.persist(entity)
)
).await().indefinitely();sessionFactory.withSession(session ->
new EntityClass()
.builder(session)
.find("test1")
.get() // Uni<EntityClass>
).await().indefinitely();sessionFactory.withSession(session -> {
var qb = new EntityClass().builder(session);
return qb
.where(qb.getAttribute("name"), Operand.Like, "A%")
.or(qb.getAttribute("name"), Operand.Equals, "Bob")
.orderBy(qb.getAttribute("name"), OrderByType.ASC)
.setMaxResults(50)
.getAll(); // Uni<List<EntityClass>>
});sessionFactory.withSession(session -> {
var qb = new EntityClassTwo().builder(session);
return qb
.where("entityClass.name", Operand.Equals, "Parent Entity")
.where("value", Operand.GreaterThan, 10)
.getAll();
});sessionFactory.withSession(session -> {
var qb = new EntityClass().builder(session);
return qb
.where(qb.getAttribute("name"), Operand.Like, "A%")
.orderBy(qb.getAttribute("name"), OrderByType.ASC)
.setFirstResults(0)
.setMaxResults(20)
.getAll();
});sessionFactory.withSession(session -> {
var qb = new EntityClass().builder(session);
return qb
.where(qb.getAttribute("name"), Operand.Like, "A%")
.getCount(); // Uni<Long>
});sessionFactory.withSession(session -> {
var qb = new EntityClassTwo().builder(session);
return qb
.selectMax(qb.getAttribute("value"))
.get(Integer.class); // Uni<Integer>
});Available aggregates: selectMin(), selectMax(), selectSum(), selectSumAsDouble(), selectSumAsLong(), selectAverage(), selectCount(), selectCountDistinct(), selectColumn().
sessionFactory.withSession(session -> {
var parent = new EntityClass().builder(session);
var child = new EntityClassTwo().builder(session);
return child
.join(child.getAttribute("entityClass"), parent, JoinType.INNER)
.where(parent.getAttribute("name"), Operand.Equals, "Parent Entity")
.getAll();
});sessionFactory.withSession(session ->
session.withTransaction(tx -> {
var qb = new EntityClass().builder(session);
return qb
.where(qb.getAttribute("name"), Operand.Equals, "obsolete")
.delete(); // Uni<Integer> — rows affected
})
);sessionFactory.withSession(session ->
session.withTransaction(tx ->
entity.builder(session)
.delete(entity) // Uni<EntityClass>
)
);entity.setName("Updated Name");
sessionFactory.withSession(session ->
session.withTransaction(tx ->
entity.builder(session)
.update() // Uni<EntityClass>
)
);For high-throughput bulk operations where managed state tracking is unnecessary:
sessionFactory.withStatelessSession(session ->
entity.builder(session) // uses Mutiny.StatelessSession
.persist(entity)
);sessionFactory.withSession(session ->
session.withTransaction(tx ->
new EntityClass().builder(session)
.persist(new EntityClass().setId("b1").setName("Bob"))
.chain(() ->
new EntityClass().builder(session)
.find("b1")
.get()
)
.invoke(found -> log.info("Created and retrieved: {}", found.getName()))
)
);Database connections are configured via GuicedEE DatabaseModule subclasses annotated with @EntityManager.
See src/test/java/com/test/EntityAssistReactiveDBModule.java for a complete template:
@EntityManager(value = "entityAssistReactive", defaultEm = true)
public class EntityAssistReactiveDBModule
extends DatabaseModule<EntityAssistReactiveDBModule>
implements IGuiceModule<EntityAssistReactiveDBModule> {
@Override
protected String getPersistenceUnitName() {
return "entityAssistReactive";
}
@Override
protected ConnectionBaseInfo getConnectionBaseInfo(
PersistenceUnitDescriptor unit, Properties filteredProperties) {
PostgresConnectionBaseInfo connectionInfo = new PostgresConnectionBaseInfo();
connectionInfo.setServerName("localhost");
connectionInfo.setPort("5432");
connectionInfo.setDatabaseName("mydb");
connectionInfo.setUsername(System.getenv("DB_USER"));
connectionInfo.setPassword(System.getenv("DB_PASSWORD"));
connectionInfo.setDefaultConnection(true);
connectionInfo.setReactive(true);
return connectionInfo;
}
@Override
protected String getJndiMapping() {
return "jdbc:entityAssistReactive";
}
}module my.app {
requires com.entityassist;
requires com.guicedee.persistence;
opens my.app.entities to org.hibernate.orm.core, com.google.guice, com.entityassist;
provides com.guicedee.client.services.lifecycle.IGuiceModule
with my.app.MyDatabaseModule;
}Copy .env.example to .env for local development. Keep secrets out of version control.
| Variable | Purpose | Default |
|---|---|---|
DB_HOST |
Database hostname | localhost |
DB_PORT |
Database port | 5432 |
DB_NAME |
Database name | — |
DB_USER |
Database username | — |
DB_PASSWORD |
Database password | — |
ENVIRONMENT |
Runtime environment | dev |
PORT |
Application port | 8080 |
TRACING_ENABLED |
Enable distributed tracing | false |
ENABLE_DEBUG_LOGS |
Enable debug logging | false |
TEST_DB_CONTAINER_IMAGE |
Testcontainers Postgres image | postgres:latest |
SKIP_INTEGRATION_TESTS |
Skip integration tests | true |
CI secrets (SONA_USERNAME, SONA_PASSWORD, GPG_PRIVATE_KEY, GPG_PASSPHRASE, GITHUB_ACTOR, GITHUB_TOKEN) are managed via GitHub Actions repository/environment secrets.
The test module uses Testcontainers to spin up a PostgreSQL instance automatically:
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class EntityAssistReactiveTest {
private Mutiny.SessionFactory sessionFactory;
@BeforeAll
public void setup() {
IGuiceContext.instance();
JtaPersistService ps = (JtaPersistService) IGuiceContext.get(
Key.get(PersistService.class, Names.named("entityAssistReactive")));
ps.start();
sessionFactory = IGuiceContext.get(
Key.get(Mutiny.SessionFactory.class, Names.named("entityAssistReactive")));
}
@AfterAll
public void teardown() {
JtaPersistService ps = (JtaPersistService) IGuiceContext.get(
Key.get(PersistService.class, Names.named("entityAssistReactive")));
ps.stop();
}
@Test
void roundTrip() {
EntityClass entity = new EntityClass()
.setId("test1")
.setName("Test Entity")
.setDescription("Round-trip test");
sessionFactory.withSession(session ->
session.withTransaction(tx ->
entity.builder(session).persist(entity)
).chain(() ->
new EntityClass().builder(session)
.find("test1").get()
).invoke(found -> {
assertNotNull(found);
assertEquals("test1", found.getId());
})
).await().indefinitely();
}
}The EntityAssistReactiveDBModule test module starts the container and wires connection info via PostgresConnectionBaseInfo:
@EntityManager(value = "entityAssistReactive", defaultEm = true)
public class EntityAssistReactiveDBModule extends DatabaseModule<EntityAssistReactiveDBModule>
implements IGuiceModule<EntityAssistReactiveDBModule> {
private static final PostgreSQLContainer<?> postgresContainer =
new PostgreSQLContainer<>("postgres:latest")
.withDatabaseName("entityassist_test")
.withUsername("postgres")
.withPassword("postgres");
static { postgresContainer.start(); }
// ... getConnectionBaseInfo() reads host/port/credentials from the container
}Register the test module via JPMS provides in the test module-info.java:
module entity.assist.test {
requires com.entityassist;
requires com.guicedee.persistence;
requires org.junit.jupiter.api;
requires org.testcontainers;
opens com.test to org.junit.platform.commons, org.hibernate.orm.core,
com.google.guice, net.bytebuddy, com.entityassist;
provides IGuiceModule with com.test.EntityAssistReactiveDBModule;
}| Class / Interface | Purpose |
|---|---|
RootEntity<J,Q,I> |
Root CRTP entity — builder(), persist(), update(), validateEntity(), property map |
DefaultEntity<J,Q,I> |
Intermediate layer between Root and Base (extension point) |
BaseEntity<J,Q,I> |
Primary superclass for user entities; wires JSON auto-detect and builder linkage |
IRootEntity / IDefaultEntity / IBaseEntity |
SPI interfaces for the entity hierarchy |
| Class / Interface | Purpose |
|---|---|
QueryBuilderRoot<J,E,I> |
Root builder — CriteriaBuilder, session management, persist(), update(), getAttribute() |
DefaultQueryBuilder<J,E,I> |
Fluent DSL — where(), or(), join(), orderBy(), groupBy(), selects, aggregates, find(), in(), reset() |
QueryBuilder<J,E,I> |
Primary superclass for user builders — get(), getAll(), getCount(), delete(), truncate(), getResultStream(), cache support |
IQueryBuilderRoot / IDefaultQueryBuilder / IQueryBuilder |
SPI interfaces for the builder hierarchy |
| Enum | Values |
|---|---|
Operand |
Like, NotLike, Equals, NotEquals, Null, NotNull, LessThan, LessThanEqualTo, GreaterThan, GreaterThanEqualTo, InList, NotInList |
OrderByType |
ASC, DESC |
SelectAggregrate |
None, Min, Max, Count, CountDistinct, Sum, SumLong, SumDouble, Avg |
GroupedFilterType |
And, Or |
ActiveFlag |
Unknown → Deleted → Active → Permanent (with ranged helpers like getActiveRange(), getVisibleRangeAndUp()) |
| Class | Purpose |
|---|---|
WhereExpression |
Resolves a single where predicate from attribute + operand + value |
GroupedExpression |
Groups where / or predicates with AND / OR logic |
JoinExpression |
Defines a join: attribute, type, optional on-clause builder, optional executor builder |
SelectExpression |
Column selection with optional aggregate function and alias |
OrderByExpression |
Column + direction for ORDER BY |
GroupByExpression |
Column for GROUP BY |
| Converter | Mapping |
|---|---|
LocalDateAttributeConverter |
LocalDate ↔ java.sql.Date |
LocalDateTimeAttributeConverter |
LocalDateTime ↔ java.sql.Timestamp |
LocalDateTimestampAttributeConverter |
LocalDate ↔ java.sql.Timestamp |
| Exception | When |
|---|---|
EntityAssistException |
Builder instantiation failure, general entity errors |
QueryBuilderException |
Query construction errors |
- Always run in a Vert.x context (event loop or worker) when interacting with reactive drivers
- Prefer projections (
selectColumn,selectMax, etc.) for read-heavy paths to reduce entity materialization costs - Use
setFirstResults()/setMaxResults()for pagination; avoid unbounded loads - Keep transactions short; chain
Unicalls and reuse a single session withinwithTransaction - Bulk
delete()requires at least one filter — calltruncate()explicitly if you intend to remove all rows - Use
Mutiny.StatelessSessionviabuilder(statelessSession)for bulk inserts where change tracking is unnecessary - Revisit diagrams in
docs/architecture/when changing relationships or loading strategies
- Architecture —
docs/architecture/README.mdindexes the C4/sequence/ERD diagrams - Prompt Reference —
docs/PROMPT_REFERENCE.md
Issues and pull requests are welcome.
- All pull requests should include documentation updates when behavior changes