15 KiB
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.8 | 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_backendon a backend with a commit in flight reports57P01(admin_shutdown), not a connection-class state — and the commit record may already be in the WAL when it arrives.CommitFailureClassifiernow treats57P01/57P02/57P03as completion-unknown alongside40003, class08, and transport breaks.CommitAmbiguityContractTestasserts 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.SchemaTenantMigrationOrchestratornow 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.
TransactionCompletionUnknownExceptionalways reportscompletionUnknown=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-autonever mutates a deployed schema. CREATE INDEX CONCURRENTLYrequires 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.