Architecture#
The pieces#
Three deployable components and one external dependency:
| Component | What it is | Where it lives |
|---|---|---|
| Frontend | React 19 + TypeScript single-page app, served as static files by Apache httpd | frontend/ |
| Backend | Quarkus 3.25 REST API on Java 25, also the OIDC client | backend/ |
| Database | PostgreSQL 17, schema managed by Liquibase | — |
| Identity provider | Google in production; a Keycloak container in development and tests | keycloak/ |
How a request travels#
browser ──▶ httpd ──▶ Quarkus ──▶ PostgreSQL
(static files, and a
reverse proxy for /api)
In production the browser talks to a single origin. httpd serves the built assets on
:8080 and proxies everything under /api to the backend, so the SPA makes same-origin
requests and needs no CORS handling and no API base URL. In development Vite's dev server
plays the same role: it serves the app on :5173 and proxies /api to the backend on
:8080.
Both proxies must preserve the original Host header — Vite with changeOrigin: false,
httpd with ProxyPreserveHost On. The backend builds the OIDC redirect_uri from that
header, so a rewritten host sends the browser to the wrong port after sign-in.
FallbackResource /index.html is what makes a deep link work: any path that is not a real
file is a route the app resolves, not a 404. It is scoped to the document root, and
ProxyPass is matched first, so an unknown /api path still returns the backend's own 404
rather than the index page.
Why the backend is a backend-for-frontend#
The backend is the OIDC client, not a resource server that validates bearer tokens the
frontend obtained for itself. Quarkus OIDC runs in web-app mode: the backend performs
the authorization code exchange and keeps the resulting tokens in the encrypted
q_session cookie. The frontend never holds a token, and never sees one.
The consequences are worth stating, because they explain several shapes elsewhere:
- The frontend's only notion of identity is
GET /api/auth/me, which returns an email. - Sign-in is a full-page navigation to
/api/auth/login, not a fetch — the flow needs redirects the browser follows itself. fetchcalls sendX-Requested-With: JavaScript, which the backend pairs withquarkus.oidc.authentication.java-script-auto-redirect=falseto answer 401 instead of redirecting a background request.
authentication.md covers the flow in detail.
Backend structure#
Three packages, and no more layering than the application earns:
api— JAX-RS resources. Each owns its own request and response records, so the persistence entities never reach the wire.model— Panache active-record entities and the domain enums. See domain-model.md.service— the little application logic that is not a resource concern. Currently onlyUserService, which owns the create-a-user-on-first-sight rule.
Panache active records mean entities expose public fields and carry their own queries.
That is idiomatic Quarkus rather than an encapsulation lapse: Hibernate rewrites field
access into accessor calls at build time. It is why Checkstyle's VisibilityModifier
check is switched off here.
API documentation#
Every JAX-RS resource carries MicroProfile OpenAPI annotations (@Tag, @Operation,
@APIResponse), and the request/response records carry @Schema examples, so the
generated spec has real descriptions and sample payloads rather than bare paths. Three ways
to see it:
- Live, including Swagger UI's interactive "Try it out":
./mvnw quarkus:dev, thenhttp://localhost:8080/q/swagger-ui(the raw spec is at/q/openapi). - As a build output: every build writes
target/openapi/openapi.{json,yaml}(quarkus.smallrye-openapi.store-schema-directory). - As the committed contract:
doc/api/holds that document plus one standalone JSON Schema per type underapi/schema/, regenerated bypython3 doc/api/generate.pyand checked by Backend CI, which fails if the committed copy has drifted from the code. - Published, with a rendered reference, at
guhilling.github.io/ai-assisted-todolist:
/api/main/tracks the current code,/api/v1.2.3/is each release and/api/latest/the newest. Every release also attaches the same documents as downloadable assets.
The spec is OpenAPI 3.1, and that is load-bearing rather than incidental: only from 3.1
onwards are the component schemas JSON Schema documents, which is what lets the frontend
compile them into runtime validators for every response it parses. OpenApiContractTest
asserts the version, and asserts that the response records mark their fields required — a
field the spec left optional would be a field the frontend accepted as missing.
Deployment#
Locally and in the end-to-end stack, httpd serves the built SPA and reverse-proxies /api, which
is what makes the application same-origin. deployment.md plans the AWS shape,
where that job moves to a CloudFront distribution with two origins — S3 for the SPA, the load
balancer for /api — precisely so the same-origin arrangement the OIDC flow depends on survives.
Nothing in it is built yet.
Schema management#
Liquibase owns the schema; Hibernate is set to validate and never generates DDL. Every
schema change is its own changelog under backend/src/main/resources/db/changes/, checked
in with the code that needs it, and applied at startup by
quarkus.liquibase.migrate-at-start. The application therefore fails fast on a schema it
does not recognise rather than quietly adapting to it.
001-baseline.xml is the whole schema as one changelog. It replaced the three that had built
it up, because no database they applied to outlived them and reading three files to learn the
shape of two tables was the only thing that history added.
state and importance are native PostgreSQL enum types, task_state and
task_importance, not VARCHAR. The database rejects a value the application does not define
rather than storing it for someone else to find. The entity says so with
@JdbcTypeCode(SqlTypes.NAMED_ENUM) and a columnDefinition naming the type; without the
first, the driver sends a varchar parameter that PostgreSQL will not assign to an enum
column, and without the second, Hibernate looks for a type named after the Java class.
The cost is that the type and the Java enum are declared in two places. TaskEnumColumnTest
is what keeps them honest: it asserts that both columns really are enum types and that their
labels match the Java constants exactly, so adding a value to one and not the other fails the
build rather than the first request that uses it. Adding a value later is
ALTER TYPE ... ADD VALUE, which PostgreSQL allows; renaming or removing one needs a new type
and a column rewrite.
Configuration#
One application.properties with profile prefixes (%prod., %dev,test., %qa.) rather
than separate files per environment, so the differences between environments are visible
side by side. Secrets are never checked in: production reads Google credentials from
environment variables, and .env.example documents which ones. The dev and test profiles
need no secrets at all, because they point at a local Keycloak with throwaway credentials.
Dev and test deliberately leave the datasource URL and the OIDC issuer unset, which is what makes Quarkus Dev Services start PostgreSQL and Keycloak automatically.
Containers and deployment#
The backend image is built by Jib rather than from a Dockerfile — ./mvnw package
-Dquarkus.container-image.build=true — on an explicitly pinned eclipse-temurin:25-jre
base. The pin matters: Jib's default base ships JDK 21 and cannot load this code's class
files. There are no Dockerfiles under backend/ at all: the four Quarkus generated were
unused, two of them JDK 17 based, and all four were deleted.
The frontend image is a two-stage Dockerfile at frontend/docker/Dockerfile: build with
Node, serve with httpd.
deployment/ holds everything that deploys the app. deployment/docker/ has the Compose
stacks that wire the pieces together — local-development.md
describes both — and deployment/aws-tofu/ has the AWS infrastructure, written in
OpenTofu; deployment.md is the plan it implements. Nothing about the
application assumes AWS: it is a stateless container reading its configuration from the
environment, plus a PostgreSQL it connects to by URL.