Skip to content

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:

  1. DTO transformation (recommended) — map entity to DTO while still on the event loop
  2. 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);

MultipleBagFetchException (Quarkus 3.30+)

Problem: Hibernate 6.x rejects simultaneous JOIN FETCH of two List (bag) collections in the same query. This throws MultipleBagFetchException.

java
// BAD — two bags fetched simultaneously
"SELECT v FROM View v " +
"LEFT JOIN FETCH v.viewColumns vc " +         // List = bag
"LEFT JOIN FETCH vc.relationshipPaths rp "     // List = bag → exception!

Solution: Use @Fetch(FetchMode.SUBSELECT) on the nested collection. Hibernate loads the outer collection via JOIN FETCH and the inner one via a separate subselect automatically.

java
// On the nested entity:
@OneToMany(mappedBy = "viewColumn", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("position ASC")
@org.hibernate.annotations.Fetch(org.hibernate.annotations.FetchMode.SUBSELECT)
private List<ViewColumnPath> relationshipPaths = new ArrayList<>();

Then remove the nested JOIN FETCH from the query — the SUBSELECT handles it:

java
// GOOD — only fetch the outer bag
"SELECT DISTINCT v FROM View v " +
"LEFT JOIN FETCH v.viewColumns vc " +
"LEFT JOIN FETCH vc.column " +
"WHERE v.uuid = ?1"

Other Hibernate 6.x breaking changes:

  • @OrderColumn on non-List fields is rejected — use @Column for position fields on child entities
  • Native queries return java.time.LocalDateTime instead of java.sql.Timestamp

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-completions

The 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.

SchemaStack Internal Developer Documentation