Appearance
Coding Patterns & Known Issues
Hibernate Reactive Pitfalls
HR000069: Session Closed on Wrong Thread
Problem: Hibernate Reactive requires all operations on a session to happen on the same Vert.x event loop thread. If a Uni chain switches threads (e.g. via emitOn, runSubscriptionOn, or calling a blocking operation), the session closes and throws HR000069.
Solution: Use DTO projection to fetch all needed data before leaving the reactive context. Don't pass Panache entities across thread boundaries.
java
// BAD — entity crosses thread boundary
return viewRepository.findById(id)
.onItem().transformToUni(view -> {
// This might switch threads
return someBlockingOperation(view.getName());
});
// GOOD — project to DTO first
return viewRepository.findById(id)
.onItem().transform(view -> new ViewDTO(view.getName(), view.getUuid()))
.onItem().transformToUni(dto -> {
return someBlockingOperation(dto.name());
});Lazy Collection Fetching
Problem: Panache lazy collections can't be eagerly fetched after a thread switch. Accessing a lazy collection on a different thread from where the session was opened throws HR000069.
Solutions:
- DTO transformation (recommended) — map entity to DTO while still on the event loop
- JOIN FETCH in HQL — load collections eagerly in the query itself
java
// Eager fetch via HQL
@Query("SELECT v FROM View v LEFT JOIN FETCH v.columns WHERE v.uuid = ?1")
Uni<View> findByUuidWithColumns(UUID uuid);Non-Blocking Password Verification
Password hashing (bcrypt) is CPU-intensive and blocks the event loop. Run it on a worker thread:
java
return Uni.createFrom().item(() -> BCrypt.checkpw(password, hash))
.runSubscriptionOn(Infrastructure.getDefaultWorkerPool());HQL Guidelines
Use Named Parameters
java
// GOOD
"SELECT v FROM View v WHERE v.workspace.uuid = :workspaceUuid"
// BAD — positional params are fragile
"SELECT v FROM View v WHERE v.workspace.uuid = ?1"Avoid N+1 Queries
Use JOIN FETCH for associations you know you'll access:
java
"SELECT DISTINCT w FROM Workspace w " +
"LEFT JOIN FETCH w.databaseConfig " +
"WHERE w.organisation.id = :orgId"Filter in HQL, Not Java
java
// GOOD — database does the filtering
"SELECT m FROM WorkspaceMembership m WHERE m.workspace.id = :wsId AND m.role = :role"
// BAD — loads all members then filters in Java
workspace.getMemberships().stream().filter(m -> m.getRole() == role)Mutiny Exception Handling
Chain Error Recovery
java
return service.doSomething()
.onFailure(NotFoundException.class).recoverWithNull()
.onFailure(ForbiddenException.class).recoverWithUni(
ex -> Uni.createFrom().failure(new WebApplicationException(403))
);Don't Swallow Errors
java
// BAD — silently ignores failures
.onFailure().recoverWithNull()
// GOOD — log and rethrow or recover with meaningful default
.onFailure().invoke(ex -> log.error("Operation failed", ex))
.onFailure().recoverWithUni(ex -> Uni.createFrom().failure(
new ServiceException("Context about what failed", ex)
))Schema Import Merge Strategy
The schema import uses a MERGE strategy (not DELETE+INSERT) to preserve user configurations:
- Existing views are updated in place (preserving UUIDs and user-configured column settings)
- New tables create new views
- Removed tables delete views (with CASCADE to columns, presets, memberships)
- Column changes are applied incrementally
This was changed after the DELETE+INSERT approach caused constraint violations and lost user configurations (column display names, positions, widths, visibility settings).
API Structure
The backend uses a flat REST API structure (not nested):
/api/views/... (not /api/workspaces/:id/views/...)
/api/columns/... (not /api/views/:id/columns/...)
/api/workspaces/...
/api/organisation-members/...
/api/workspace-members/...
/api/filter-presets/...
/api/relationships/...
/api/data/...
/api/sessions/...
/api/notifications/...
/api/2fa/...
/sse/member-events
/sse/task-completionsThe flat structure was adopted after a complete restructure (438/438 tests passing). All endpoints are scoped via path parameters or authentication context rather than nested URL hierarchies.