165 lines
15 KiB
Markdown
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.
|