Files
clean-architecture-backend-…/docs/jpa/postgresql-extensions.md
T
DongHyeonkaandClaude Opus 5 0e61f86eb5 feat(jpa): implement the JPA relational persistence platform
Implements the Stable and Experimental JPA persistence platform designs
against real PostgreSQL, adapted to this repository's fail-closed 19-leaf
registry.

The design models the platform as 25 Gradle projects. `src/settings.gradle`
throws unless the registry holds exactly 19 leaves, so the plan's modules
become packages inside `:adapter:outbound:persistence-jpa` (starter in
`:app-bootstrap`, testkit in its own source set). The full mapping, the
renames this repository's naming gate required, and every deliberate
substitution are recorded in `docs/jpa/repository-adaptation.md`.

Seven Docker-backed lanes replace the plan's seven JVM test suites. Each
fails closed: a lane that discovers nothing, or a container that cannot
start, is an error rather than a skip.

Three defects the contracts found against a real server:

- `CommitFailureClassifier` treated only SQLSTATE 40003, class 08, and
  transport breaks as completion-unknown. A backend terminated mid-commit
  reports 57P01, and the commit record may already be in the WAL — so a
  possibly-committed transaction could be re-run. 57P01/57P02/57P03 now
  classify as completion-unknown.
- `SchemaTenantMigrationOrchestrator` recorded `MigrateResult`'s target
  version, which is empty for a tenant already current, reporting migrated
  tenants as unmigrated during a partial rollout. It now reads the applied
  version back from the tenant's schema history.
- `JpaStreamExecutor` checked only the declared return type for reactive
  publishers, and `RegisteredPostgreSqlCopyLoader` passed the COPY timeout
  to `SET`, which is parsed before parameter binding.

`JpaModuleBoundaryTest` enforces the plan's module map as package rules;
`verifyCleanArchitectureDependencies` governs edges between leaves and
cannot see these. Its first assertion is that the import is non-empty,
because every rule under it is a `noClasses()` rule and would pass
vacuously on an empty import.

Verified: 128 container tests across all seven lanes, 1183 unit tests,
`:adapter:outbound:persistence-jpa:check`, `:app-bootstrap:check`,
`verifyCleanArchitectureDependencies`, `verifyOneTypePerFile`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:06:18 +09:00

3.7 KiB

PostgreSQL Extensions

Design §8.3, §21, §30. What the platform uses beyond portable JPA, and what each is guarded by.

Everything here is core PostgreSQL. No server extension is required.

Locking

SELECT ... FOR UPDATE with a finite bound, always. PostgreSqlLockOptions refuses an unbounded lock request because it waits as long as the holder holds it, turning one slow transaction into a pile-up of blocked connections.

NOWAIT and a wait timeout are separate requests, not two spellings of one — modelling them as a single field with a magic zero is how "no wait" becomes "wait forever".

55P03 (lock not available) and 40P01 (deadlock) drive opposite recovery and are never collapsed: the first leaves the transaction alive and the caller in control; the second has already been rolled back by the server.

Work claims

FOR UPDATE SKIP LOCKED is reachable only through a registered WorkQueueName, never as a repository flag. It deliberately returns an incomplete view of the table: correct for handing disjoint work to competing workers, silently wrong for anything that needs to see every matching row. A registered claim statement must skip locked rows and impose a deterministic ORDER BY.

Upserts

INSERT ... ON CONFLICT ... RETURNING under a registered NativeWriteName with a fixed conflict target and update column set. The conflict target cannot be a bound parameter, so accepting one from a caller would mean building SQL from input.

An upsert is the correct answer to a create race precisely because the database decides. Read-then-write cannot be made correct: another transaction can commit between the read and the write. (xmax = 0) AS inserted in the RETURNING list is what lets the platform report insert-versus-update without a second query.

The executor flushes before and clears after: a native write is invisible to the Persistence Context, so a pending managed change would otherwise overwrite it, and a managed entity loaded beforehand would keep serving pre-upsert values.

JSONB

JsonDocument carries a schema name and version alongside the payload. A JSONB column is schemaless at the database level, so without an envelope the only record of what a stored document means is the code that wrote it — and a document written two releases ago is indistinguishable from a current one.

The payload never carries a Java class name. Type metadata in a JSONB column is a deserialization gadget: whoever can write a row chooses the class the reader instantiates.

Query paths are registered. A JSON path is part of the SQL text and cannot be bound, so forwarding a request field into one is concatenating untrusted input into a statement. Values are always bound.

Arrays and ranges

Arrays are built with Connection.createArrayOf, never by formatting a literal — hand-formatting is where quoting bugs live, and a tag containing a comma changes the array's shape rather than its content.

PgRange models both endpoints as independently optional and independently inclusive, because that is what a PostgreSQL range is. Whether [09:00, 10:00) and [10:00, 11:00) overlap depends on the bracket, not the values, and a pair of timestamptz columns cannot express it.

COPY (J4 admin)

COPY bypasses the Persistence Context, entity callbacks, version checks, and Envers entirely. That is why it is fast and why it is an admin capability with a registered statement, a bounded stream, a row and byte cap, a finite server-side statement_timeout, and a named operator.

The registry accepts only COPY ... FROM STDIN. COPY ... FROM '/path' reads a file on the database server as the server's OS user; it is superuser-only for exactly that reason and does not belong behind an application API.