This page documents the specific decisions made throughout this template and basically describes:
Think of it as the “read this before you copy anything” companion to the code and javadocs.
Translating a conceptual domain model directly into tables, before normalization, produces schemas that seem fine on a whiteboard but break down the moment JPA tries to map them.
Common results from translating conceptual domain model without doing normalization first are:
The cost of fixing a bad schema after entity classes exist is high, you’re changing both the migration script and the mapping annotations at the same time. Normalizing first (identifying the real cardinality, where the FK lives, whether a junction table is needed) means entity classes are modeling something that already makes structural sense.
See the use-case writeup for a concrete before/after of what this looks like in practice →
In a bidirectional relationship, both sides know about each other, the parent has a List<Child>, the child has a reference back to the parent. In a unidirectional relationship, only the owning side (usually the child) knows about the other.
Why unidirectional is easier to manage here:
parent.addChild(child); child.setParent(parent)), forgetting one side leaves the in-memory object graph inconsistent even before a flush.Parent → children → each Child's parent → back to Parent), which is a common silent failure in auto-generated REST endpoints where you don’t control the serialization.Enrollment, not navigation through Student.enrollments.If you do need bidirectional (e.g. for a specific query pattern), treat it as a deliberate exception, always set mappedBy on the non-owning side and @JsonIgnore on one direction.
@MapsId for strict 1:1 relationships, don’t give the child its own auto-increment keyWhen two entities have a strict 1:1 relationship (one Profile per Student, always), the child entity doesn’t need its own independently generated primary key. @MapsId lets the child derive its primary key directly from the parent’s, Profile.id becomes both the PK and the FK, shared with Student.id.
What this eliminates:
profiles.id that exists purely as a technical artifact.student_id) alongside the PK index, they’re the same column now, so there’s only one index to maintain.Profile row existing with a student_id that doesn’t match its own id (impossible by construction when they’re the same column).The trade-off to be aware of: this makes Profile impossible to create without an already-persisted Student. The auto-generated add/update/delete endpoints from REST Data Panache don’t handle this correctly, see point 8 below.
A direct many-to-many (placing course_id in students or student_id in courses) can’t work in a relational database: a single column can’t hold multiple values without breaking first normal form. The correct model is a dedicated junction entity (Enrollment) that turns the M:M into two 1:M relationships.
Beyond just “it works”:
give me all enrollments for course X), not just a hidden join table.@JsonIgnore the relation field on the childWhen REST Data Panache serializes an entity to JSON, it calls all getters. If a getter touches a FetchType.LAZY field, Hibernate initializes the proxy right there, firing an extra query per object, per request. Whether this causes a LazyInitializationException or a silent N+1 depends on whether the Hibernate session is still open at that point, which isn’t guaranteed.
The pattern that reliably works:
@JsonIgnore on the child entity, Jackson never touches it, so the lazy proxy is never triggered through the serialization path.JOIN FETCH (see point 6).This is one of the most common ways auto-generated CRUD silently misbehaves, the endpoint returns data, tests pass, and the N+1 only surfaces in production under load.
See the N+1 demo for a measured side-by-side →
JOIN FETCH from the child entity to get a complete parent+child record in a single queryWhen you need both a child entity and its parent in a single response, don’t rely on lazy loading to fetch the parent after the child is loaded. Fetch them together explicitly:
public static Profile findByIdWithStudent(Long id) {
return find("FROM Profile p LEFT JOIN FETCH p.student WHERE p.id = ?1", id)
.firstResult();
}
Why this matters:
The LEFT JOIN (not INNER JOIN) is intentional, it handles the case where the relation might be nullable without silently dropping the child from the result set.
For junction entities like Enrollment that have FK relations as their core data, exposing the full Student or Course object in the request/response body is both verbose and problematic (triggers lazy loading, exposes data the consumer didn’t ask for). Instead:
student, course) with @JsonIgnore, hidden from both request and response.getStudentId() → this.student.id.setStudentId(Long id) sets this.student = new Student(); this.student.id = id;.Jackson treats any getX()/setX() pair as a property named x, so studentId and courseId appear naturally in the Swagger schema as plain Long fields, exactly matching what the database table actually stores, and what the consumer actually needs to send.
Important: the getter must be read-only, don’t initialize this.student inside the getter as a side effect. Getters are called by serializers, debuggers, and logging tools; a getter that mutates state will create Student stubs in unexpected places and cause silent persistence bugs.
add/update/delete don’t work for shared-primary-key entities, use getReference() for the fixFor child entities using @MapsId, the auto-generated endpoints try to persist the child with an independently assigned ID, which conflicts with the derived PK strategy and fails at the database level. The fix for add and update is set a reference for the parent entity field:
profile.student = Profile.getEntityManager().getReference(Student.class, id);
profile.persist();
getReference() builds a lazy proxy using only the ID, no SELECT against the parent table, just the FK value needed to satisfy @MapsId. The trade-off: if the ID doesn’t correspond to a real parent row, the failure surfaces at persist() time as a ConstraintViolationException (FK violation), not at the point where the reference is created. That returns a raw 500 in this template since there’s no exception mapper, a known limitation documented in the relevant javadocs.
For delete, a plain deleteById on the child also won’t work if the parent must be deleted together (the shared PK means “delete just the child, keep the parent” is structurally valid but semantically wrong here). Use an aggregate endpoint that deletes both in one transaction, see ProfileResource#deleteAggregate.
count(), unbounded COUNT is expensive and out of scope for a database wrapperREST Data Panache exposes a GET /entity/count endpoint by default. For large tables, an unbounded COUNT(*) can be slow and tie up a connection thread for the duration of the scan. More importantly, in a database wrapper service, the consumer shouldn’t be making aggregate analytical queries, those belong in reporting layers or directly in the DBMS.
Disabling it is one line:
@Override
@MethodProperties(exposed = false)
@Operation(hidden = true)
long count();
If you genuinely need a count, query it directly via your DBMS rather than exposing it as an API endpoint.
The auto-generated OpenAPI spec from REST Data Panache is approximate: for example it documents status codes that never actually occur (e.g. 201 on a PUT that only returns 204) and omits codes that do occur (e.g. 404 on a single-resource GET). Left as-is, Swagger becomes misleading as a contract reference.
An OASFilter (see OASFilter.java + OASFilterHelperPanache.java) post-processes the generated spec at startup to correct these. It doesn’t fix everything, structured error response bodies (validation failures, constraint violations) still can’t be documented from a filter like this, since REST Data Panache doesn’t expose typed error responses through the OpenAPI generation pipeline. That’s a known limitation: Swagger here is reliable for testing happy-path CRUD, not as an exhaustive error contract.
PanacheEntityResource, API versioning is the signalPanacheEntityResource trades architectural separation for speed. That trade-off pays off in a narrow, stable database wrapper service. The moment you need to version your API (e.g. /v1/students vs /v2/students with a different response shape), the trade-off stops paying:
At that point, move to standard Jakarta REST endpoints (@GET, @POST, etc.) with Java records as DTOs. This decouples the database schema from the HTTP contract, you can rename a column without changing the API, or change the API response shape without touching the database.
If you’re not worried about versioning yet, PanacheEntityResource is fine. The moment that concern appears, it’s time to migrate.
quarkus-hibernate-validator is a required dependency, without it, @NotNull/@Size are inert metadataThe jakarta.validation.constraints.* annotations (@NotNull, @Size, @NotBlank, @Positive, etc.) are just annotations, they don’t do anything without a validation engine to read and enforce them. Quarkus doesn’t bundle Hibernate Validator by default; it’s a separate extension:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
Without this dependency, a @NotBlank String name will happily persist a blank string with a 201 and no error. With it, the same request returns a 400 with a structured violation body, automatically, without any custom exception mapper.
@NotNull alone doesn’t cascade into nested objects, add @Valid too for thatWhen validating a record or object that contains other objects, @NotNull only checks that the field itself isn’t null. It doesn’t trigger validation of the nested object’s own constraints. To cascade:
public record ProfileStudentDto(
@NotNull @Valid Profile profile,
@NotNull @Valid Student student
) implements Serializable {}
@NotNull → “this field must be present”.
@Valid → “and once confirmed present, also validate its internal constraints”.
Without @Valid, a ProfileStudentDto with a non-null Student whose name is blank will pass the DTO-level validation and only fail later at persist(), deeper in the call stack, with a less helpful error context.