Files
clean-architecture-backend-…/docs/jpa/repository-adaptation.md
T

165 lines
15 KiB
Markdown

# JPA Relational Persistence Platform — Repository Adaptation Contract
**Design source:** `docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md`
**Stable plan source:** `docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md`
**Experimental plan source:** `docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md`
The design package states its own adaptation rule (§3.2): the assumed package paths and Gradle
structure are explicit implementation *assumptions* made because the real Backend Skeleton
repository was not supplied. Before implementing, paths are adjusted to the repository's existing
conventions and root package while the public contracts and policy semantics are preserved.
This file is the single record of *how* that mapping was performed. Only paths, build DSL, and
composition-root ownership changed. Public contracts, policy order, retry semantics, and error
semantics are implemented as specified.
## 1. Why the module layout differs
The plan assumes a greenfield library with 18 Stable Gradle projects under `modules/jpa/` plus 7
Experimental projects under `modules/jpa-experimental/`. This repository is a Clean Architecture
template whose **fail-closed registry** (`src/config/architecture/modules.json`, enforced by
`src/settings.gradle` and `verifyCleanArchitectureDependencies`) declares **exactly 19 leaf
identities**, and `src/settings.gradle` throws when the registry does not contain exactly 19
modules. Creating 25 more Gradle projects would violate HARD-STOP #5 in `AGENTS.md`.
Therefore the plan's library modules become **package boundaries inside the registered leaf**
`:adapter:outbound:persistence-jpa`, with two exceptions driven by this repository's own rules.
This is the same adaptation already applied to the HTTP client platform
(`docs/httpclient/repository-adaptation.md`).
| Plan module | Repository home | Reason |
|---|---|---|
| `jpa-spring-boot-starter` | `:app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.jpa`) | This repository's composition root owns wiring, startup validation, and actuator surface; an adapter leaf must not auto-configure itself. `AGENTS.md` assigns composition to `app-bootstrap`. |
| `jpa-testkit`, `jpa-testkit-postgresql`, `jpa-testkit-migration`, `jpa-testkit-queryplan` | `:adapter:outbound:persistence-jpa` `src/testkit/java/**/testkit` | The plan forbids production modules depending on the testkit. A source set whose dependencies are declared only on test configurations gives the same guarantee without a new Gradle project, and more than one lane consumes it. |
The package boundary is enforced by `JpaModuleBoundaryTest`. It holds a closed catalog of the
production root's direct child packages, compares that catalog against the tree for exact equality,
checks every observed top-level edge against the declared ones, and rejects cycles.
This used to be a stronger claim than the test. The catalog listed thirteen packages while the tree
held twenty-two, so nine — `audit`, `config`, `failure`, `fileserver`, `h2`, `idempotency`, `lock`,
`notification`, `outbox` — were governed by nothing, and a `transaction → postgresql` /
`postgresql → transaction` cycle passed. Both are closed now, and the catalog's exact-equality check
is what keeps a new package from being green by omission.
**Known gap.** The catalog governs top-level packages. Sub-package edges inside one top-level
package are not checked, and the target tree in the review's JPA-023 (a `capability/*` layout) is
not implemented — the notification configuration facade is the first step toward it.
## 2. Package mapping
Root package: `io.backend.skeleton.jpa``dev.caskeleton.adapter.outbound.persistence`.
| Plan module | Plan package | Repository package |
|---|---|---|
| `jpa-core-api` | `…jpa.api` (+ `.capability`, `.error`, `.query`, `.transaction`) | `dev.caskeleton.adapter.outbound.persistence.api` (+ same subpackages) |
| `jpa-transaction` | `…jpa.transaction` | `…persistence.transaction` |
| `jpa-spring-data` | `…jpa.springdata` | `…persistence.springdata` |
| `jpa-querydsl` | `…jpa.querydsl` | `…persistence.querydsl` |
| `jpa-hibernate` | `…jpa.hibernate` (+ `.batch`, `.bulk`, `.stateless`) | `…persistence.hibernate` (+ same subpackages) |
| `jpa-postgresql` | `…jpa.postgresql` (+ `.error`, `.lock`, `.constraint`, `.json`, `.array`, `.range`, `.write`) | `…persistence.postgresql` (+ same subpackages) |
| `jpa-postgresql-copy` | `…jpa.postgresql.copy` | `…persistence.postgresql.copy` |
| `jpa-migration-flyway` | `…jpa.migration` | `…persistence.migration` |
| `jpa-auditing` | `…jpa.auditing` | `…persistence.auditing` |
| `jpa-envers` | `…jpa.envers` | `…persistence.envers` |
| `jpa-cache-hibernate` | `…jpa.cache` | `…persistence.cache` |
| `jpa-observability` | `…jpa.observation` | `…persistence.observation` |
| `jpa-security` | `…jpa.security` | `…persistence.security` |
| `jpa-spring-boot-starter` | `…jpa.autoconfigure` | `dev.caskeleton.bootstrap.autoconfigure.jpa` |
| `jpa-testkit*` | `…jpa.testkit` (+ `.id`, `.mapping`, `.lifecycle`, `.query`, `.fetch`, `.postgresql`, `.migration`, `.queryplan`, `.failure`, `.pool`, `.release`) | `…persistence.testkit` (+ same subpackages), `testkit` source set |
| `jpa-experimental/*` | `…jpa.experimental` (+ `.tenant`, `.rls`, `.schema`, `.database`, `.replica`, `.next`) | `…persistence.experimental` (+ same subpackages) |
The existing `…persistence.transaction` and `…persistence.postgresql` packages already hold this
leaf's `TransactionPort` implementation and PostgreSQL vendor composition. The platform types are
**additive**: no existing type was renamed, moved, or replaced, and no plan type collides with an
existing name.
## 3. Test-suite mapping
The plan declares seven JVM test suites (`test`, `integrationTest`, `contractTest`,
`migrationTest`, `failureTest`, `performanceTest`, `compatibilityTest`). This leaf already owns a
Docker-backed `postgresqlIntegrationTest` source set and its readiness Gradle tasks are registered
in a fail-closed contract (`verifyJpaReadinessRegistry` in `src/build.gradle`).
| Plan suite | Repository lane |
|---|---|
| `test` | `src/test` — hermetic unit lane, `./gradlew :adapter:outbound:persistence-jpa:test` |
| `contractTest`, `integrationTest`, `migrationTest`, `failureTest`, `compatibilityTest` | `src/postgresqlIntegrationTest` — real PostgreSQL containers; selected by the `jpaPlatform*` Gradle tasks |
| `performanceTest` | `src/jpaPlatformPerformanceTest` — pool and `REQUIRES_NEW` connection behaviour, run by `jpaPlatformPoolContractTest`; never part of `check`. The source set keeps the plan's name; the lane asserts behaviour rather than measuring, and no numeric performance bound is claimed anywhere from it. |
Docker-dependent lanes fail closed rather than skipping, matching the existing
`PostgreSqlReadinessSupport.assertDockerAvailable()` convention in this leaf.
## 4. Other deliberate substitutions
| Plan assumption | Repository reality | Adaptation |
|---|---|---|
| Gradle Kotlin DSL, `build-logic` convention plugin, `jpa-library-conventions.gradle.kts` | Groovy DSL, root `src/build.gradle` conventions (spotless google-java-format, checkstyle, SpotBugs + FindSecBugs, ErrorProne, `-Werror`, one-type-per-file), `LockMode.STRICT` dependency locking | Source sets and dependencies declared in `src/adapter/outbound/persistence-jpa/build.gradle`; `gradle.lockfile` regenerated with `resolveAndLockAll --write-locks`. |
| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.0 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). |
| Hibernate ORM 7.4 is the Stable provider | Boot 4.0.0 resolves `org.hibernate.orm:hibernate-core:7.1.8.Final` | The *declared* Stable provider baseline of the design stays 7.4 in `HibernateProviderPolicy`; the runtime provider version is read from Hibernate itself and reported. The collection-fetch-pagination gate runs against whatever provider the BOM resolves, and `HibernateProviderPolicy.driftsFromDeclaredBaseline()` makes the difference visible instead of hiding it behind a green check. |
| PostgreSQL 16·17·18 Stable matrix | This leaf's existing evidence image is `postgres:16-alpine` | `PostgreSqlVersion` declares exactly PG 16, 17, 18. The default lane runs the repository's existing 16 image; 17 and 18 are selected by `-Pjpa.matrix.versions=16,17,18`, and an unknown or empty selection is an error rather than a skip. |
| `settings.gradle.kts` module registration | Fail-closed 19-leaf registry | No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged. |
| `infra/jpa/{postgres,roles,toxiproxy}` | Repository already owns `infra/` | Created at the same repository-relative paths. |
| `docs/jpa/**`, `docs/adr/ADR-JPA-*`, `.github/workflows/jpa-*.yml` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. |
| `build.gradle.kts` release aggregate `jpaReleaseGate` | Root is `src/build.gradle` | Registered there against the repository lane names in §3. |
| Per-task `git add` + `git commit` | `AGENTS.md`: commit policy is `human-only`; agents do not stage, commit, amend, or push | Implementation is delivered unstaged. This is the only plan step intentionally not executed, and it is recorded here. |
| Querydsl as an optional module dependency | Querydsl is not part of this repository's dependency set | `querydsl` is implemented against the plan's contracts with the Querydsl types kept behind `compileOnly`, so the Stable runtime classpath never carries Querydsl and a deployment opting in adds the artifact itself. |
| Hibernate Envers as a module dependency | Envers is not part of this repository's dependency set | Same treatment as Querydsl: `compileOnly` + explicit opt-in, matching the plan's "Envers is opt-in and never enabled by a global base class". |
| `build-logic/src/test/kotlin/JpaModuleBoundaryTest.kt` | There is no `build-logic` project and no Kotlin source set; module boundaries are enforced by the registry itself | `verifyCleanArchitectureDependencies` plus `:app-bootstrap:test --tests '*CleanArchitectureTest'` assert the same property against `src/config/architecture/modules.json`, which is the authority the plan's test would have had to duplicate. |
| `PostgreSqlRuntimeRoleVerifierIntegrationTest` (Task 45) | The security lane is one suite in this leaf rather than a per-module `integrationTest` | `PostgreSqlSecurityContractTest` (tag `jpa-security`) exercises `PostgreSqlRuntimeRoleVerifier.verify` and `.requireSafe` against a real restricted role on a real server. |
| `JpaSafetyProperties`, `JpaDataSourceProperties` | `NamingConventionTest` requires every `@ConfigurationProperties` type to end in `Settings` or `Policy` | Renamed to `JpaSafetySettings` and `JpaDataSourceSettings`. The bound property prefixes and every field are unchanged; only the class names move to this repository's convention. |
### Types relocated to keep the dependency direction legal
The plan's module map forbids `jpa-core-api` from depending on any other platform module. Three
value-only types the design places in a downstream module are consumed by a core contract, so they
live in the core here instead. Each is a pure value with no framework dependency, so the relocation
costs nothing and the alternative — a core contract importing an adapter package — would break the
boundary the module map exists to hold.
| Type | Plan module | Repository package | Consumed by |
|---|---|---|---|
| `TransactionCompletionEvidence` | `jpa-transaction` | `…persistence.api.transaction` | `TransactionCompletionUnknownException` (design §17.3 types the field) |
| `ConstraintCode` | `jpa-postgresql` | `…persistence.api.error` | `ConstraintViolationDetails` (design §22.4) |
| `SqlStateResolver`, `SqlExceptionSqlStateResolver` | `jpa-transaction` | `…persistence.api.error` | both the transaction module's commit classifier and the PostgreSQL translator |
The ArchUnit rule pack (`JpaArchitectureRules`, `EntityMappingCondition`, `EntityExposureCondition`)
is placed in the `testkit` source set rather than in `…persistence.security` production code. ArchUnit
is a test library; putting the rule pack in `main` would drag it onto every deployment's runtime
classpath to serve code that only ever runs in a test.
### Findings the contracts produced against a real server
Two of the design's rules turned out to be stated slightly wrong, and the container lanes are what
showed it. Both are recorded here because the design text still reads the old way.
- **§17.2 commit ambiguity is not only SQLSTATE class `08`.** `pg_terminate_backend` on a backend
with a commit in flight reports `57P01` (`admin_shutdown`), not a connection-class state — and the
commit record may already be in the WAL when it arrives. `CommitFailureClassifier` now treats
`57P01`/`57P02`/`57P03` as completion-unknown alongside `40003`, class `08`, and transport breaks.
`CommitAmbiguityContractTest` asserts the SQLSTATE directly so the rule cannot silently narrow
again.
- **Schema-per-tenant status must be read back, not inferred from the run.** `MigrateResult`'s
target version is empty for a tenant that was already current, so recording it reported migrated
tenants as unmigrated during a partial rollout. `SchemaTenantMigrationOrchestrator` now reads the
applied version from the tenant's schema history.
## 5. What is unchanged from the design
- Domain owns Entity, Embeddable, Repository, Query, index requirements, lock/soft-delete/audit
policy. No `GenericRepository<T, ID>` and no Spring Data CRUD re-implementation exists.
- Application Service owns the transaction boundary; OSIV is false in every runtime profile.
- `TransactionCompletionUnknownException` always reports `completionUnknown=true`,
`retryable=false`, and is never automatically retried — reconciliation handles it.
- Retry re-executes the whole use case in a new transaction and a new Persistence Context.
- SQLSTATE classification is structural (`40001`, `40003`, `40P01`, `23505`, `23503`, `23514`,
`55P03`) and never parses localized message text.
- Flyway is the source of truth for production schema change; Hibernate only validates;
`ddl-auto` never mutates a deployed schema.
- `CREATE INDEX CONCURRENTLY` requires an explicit non-transactional migration marker.
- Metric labels and ordinary logs never carry SQL parameters, entity IDs, tenant IDs, or PII.
- Experimental features (multi-tenancy, RLS, schema/database tenancy, read replica, JPA 4,
Hibernate 8, PostgreSQL 19) stay behind `backend.jpa.experimental.*` flags and never enter the
Stable composition.