Domain, backend and persistence#
Panache active-record entities#
Decision. Entities extend PanacheEntityBase, expose public fields and carry their own
queries. No repository layer.
Why. It is the idiomatic Quarkus style, and Hibernate rewrites field access into accessor calls at build time, so the public fields are not the encapsulation hole they look like. A repository layer over two entities would be ceremony.
Cost. Checkstyle's VisibilityModifier check had to be switched off, and the domain
objects know about persistence.
Liquibase, not Hibernate-managed schema#
Decision. quarkus.hibernate-orm.schema-management.strategy=validate, with Liquibase
changelogs applied at startup.
Why. Schema changes should be reviewable artifacts checked in beside the code that
needs them, and the application should fail fast on a schema it does not recognise rather
than quietly adapting. The todoitem → task rename is the case that proves the point —
Hibernate would have created a second table and left the first.
Rejected. import.sql plus a generated schema, which was the starting point.
The due-date rule applies to filing a task, not to changing one#
Decision. @FutureOrPresent lives on TaskCreateRequest alone. It is gone from the
Task entity and absent from TaskUpdateRequest, so an overdue task can be edited and
completed while a new one still cannot be filed in the past.
Why. The old rule made a task un-editable the day after it came due. It sat on the entity
as well as the request, and Bean Validation runs on flush, so a stored task silently became
invalid as time passed — no edit required. Because the update endpoint replaces the whole task
rather than patching it, that took the state change down with it: ticking off something late,
which is the single most common thing anyone wants to do with a board, returned 400. Confirmed
by writing the test first and watching it fail with Expected status code <200> but was <400>.
Why two records rather than validation groups. @Valid validates the Default group only.
Putting @FutureOrPresent(groups = OnCreate.class) on a shared record and annotating create
with @ConvertGroup(from = Default.class, to = OnCreate.class) would validate the date rule and
silently stop validating @NotBlank, @Size and the @NotNulls. That failure is invisible —
the endpoint still works, it just stops rejecting rubbish. TaskCreateRequest and
TaskUpdateRequest duplicate four field declarations and cannot fail that way.
Cost. Two records to keep in step, and a TaskFields interface so apply still takes one
parameter type. Nothing stops a client backdating an existing task, which is a feature more
than a risk: correcting a date you typed wrong is now possible.
The database defines the enums, and the changelogs were squashed to say so#
Decision. state and importance are native PostgreSQL enum types, task_state and
task_importance, created in 001-baseline.xml. That file is the whole schema as one
changelog, replacing the three that had built it up.
Why an enum type rather than VARCHAR. The column was VARCHAR(16) with no constraint, so
the database would have accepted 'BANANA'. What stood between it and a bad value was Jackson's
deserialisation, Bean Validation, and the frontend's generated schema — all of them in the
application. That is fine while this application is the only writer, and it stops being fine the
moment anything else writes: a SQL client, a fix applied by hand, a second service. A column
whose legal values are written down in the database does not depend on who is doing the writing.
Why the changelogs were squashed. The sequence was todoitem, then app_user, then the
replacement of todoitem by task. No database it applied to outlived it — every environment here
is built from scratch — so the only thing the history added was having to read three files to
learn the shape of two tables. It cost a one-off reset, which Troubleshooting records,
because Liquibase refuses to run against a databasechangelog naming changesets that no longer
exist.
What the mapping needs, and why both halves. @JdbcTypeCode(SqlTypes.NAMED_ENUM) makes the
driver send the enum rather than a varchar parameter, which PostgreSQL will not assign to an
enum column without a cast. @Column(columnDefinition = "task_state") names the type, because
Hibernate otherwise derives it from the Java class and looks for taskstate. Neither is
optional, and dropping either leaves an application that still compiles.
The cost, stated rather than discovered. Adding a value later is a new changeSet with
ALTER TYPE ... ADD VALUE, which PostgreSQL allows. Renaming or removing one is not: it needs a
new type and a column rewrite. That asymmetry is the price of the database enforcing the values,
and it is worth paying for a set of three that describes a workflow.
The type and the Java enum are declared twice, so a test holds them together.
TaskEnumColumnTest asserts that both columns really are enum types and that the type's labels
match the Java constants exactly, in order. Drift fails the build rather than the first request
that uses the new value — checked by adding a constant on one side and watching it fail, not
assumed.
Rejected: a CHECK constraint. It would have enforced the values with none of the
asymmetry above, and ALTER TABLE ... DROP CONSTRAINT would make changes easier. It was
rejected because a check constraint states the values in a condition rather than as a type:
nothing else can refer to it, information_schema does not describe it as a domain of values,
and every table wanting the same set repeats the condition. The enum is the thing PostgreSQL
has for this.
Rejected: leaving it as VARCHAR and relying on the application. That is what was there,
and the argument for it — only this application writes — is an argument that holds until it
does not.
The schema carries the constraints, and every string has a bound#
Decision. Jakarta Bean Validation annotations on the response records, not only on the requests, with these bounds:
| Field | Max | Why that number |
|---|---|---|
description |
255 | The column width; the two must agree or a valid value is a 500 |
email |
254 | RFC 5321: a 64-character local part, an at sign, a domain of up to 255 |
| display name | 255 | Not stored, so only the schema bounds it |
| any URL | 2048 | The practical ceiling browsers have long enforced |
| provider id | 64 | Our own configuration key, and short |
| provider label | 100 | A word or two on a button |
Why bound them at all. The frontend compiles these schemas into the validators it checks
every response with, so a bound written here is enforced in the browser. Before this, every
string on the wire was unbounded: description could exceed the column that stores it, and
nothing said an email was an email.
Why standards-based rather than tighter. A bound that rejects a legitimate value is worse than one that is loose. A provider's picture URL carries size and crop parameters and is routinely a few hundred characters; 2048 bounds it without guessing at a provider's habits.
@NotNull replaced the hand-written required lists, except where required and non-null
differ. AuthProviderResponse.loginUrl is required and null for an unusable provider, and
available is a primitive, so that record still lists its required properties on the type.
Rejected: Optional<T> for the optional fields, which issue #70 asked for. Measured rather
than assumed: a single Optional component costs the operation its schema reference. The
component schema survives, but /api/auth/me stops declaring what it returns, so Redoc and any
generator lose the link. Optional is used in service signatures instead, where it helps and
touches no schema.
@Email turned out to be runtime-only. SmallRye emits nothing in the schema for it, so the
document says what the string is through @Schema(format = "email"). The frontend registers
email as a regular expression, deliberately as loose as the backend's own constraint: a
stricter pattern would reject an address the backend had already accepted and stored.
A side effect worth recording: no operation had declared its response schema. Every
@APIResponse that specified @Content for its examples had suppressed the schema SmallRye
would otherwise have derived, so the document described five endpoints that returned something
unspecified. OpenApiContractTest now asserts the reference exists.
The one cost. maxLength makes Ajv's compiled validators call into ajv/dist/runtime,
which the generator previously refused outright. The check now allows Ajv's own helpers, which
Vite bundles at build time, and still refuses any other package: adding format through
ajv-formats would land there, which is why date and email are regular expressions. ajv
remains a devDependency and dependencies is still React alone. The bundle grew by 2.5 kB.
Wire identifiers are constants, and a GET's query count is asserted#
Decision. The OpenID Connect claim names this application reads live in OidcClaims, and
TaskQueryCountTest holds the list endpoint to a constant number of database statements.
Why the claim names. They were literals at each call site: jwt.getClaim("email"),
jwt.getClaim("picture"). A claim name is a wire identifier, and misspelling one compiles,
passes a test that stubs the token with the same misspelling, and surfaces only as an empty
field in the browser.
What the standard library actually covers, since the issue asked. MicroProfile JWT's
Claims enum names each claim by its own name(), so Claims.email.name() is "email" and
is used. It has no picture. It does have full_name, which is not OpenID Connect's
name claim — the constant is called full_name and so is the claim it stands for. So one of
the three is covered by the standard and two are declared here, with that written down so the
search is not repeated.
Why the query count is a test rather than a rule. The rule is that a GET runs a constant number of statements — one ideally, two or three acceptable — and never a number that grows with the rows returned. Nothing else notices when that breaks: the responses stay correct, the tests stay green, and only the statement count moves. The change that causes it is usually somewhere else entirely, so the count is what has to be asserted.
The test's limits, stated rather than overread. Task.owner cannot produce an N+1 however
it is fetched, because every task in one response belongs to the same user and that user is
already in the persistence context from resolving the caller — touching it per row costs
nothing. The test was validated against genuine per-row work instead, which took the count from
three to twelve and failed both assertions. Its value is in guarding what comes later: a
collection on Task, an association added to a response.
Rejected: extracting every repeated literal. The component names inside
@Schema(requiredProperties = ...) are repeated, and turning them into constants would make
them harder to read while protecting nothing a rename would not also break. The better answer
was removing most of those lists, which @NotNull on the components already did.
Rejected: enabling Hibernate statistics everywhere. Collecting them costs something and the
production application has no use for the numbers, so
%test.quarkus.hibernate-orm.statistics=true is test-only.