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);

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