# adapter:outbound:persistence-jpa — JPA/PostgreSQL persistence adapter ## Registered identity - Module ID: `adapter-outbound-persistence-jpa` - Gradle path: `:adapter:outbound:persistence-jpa` - Focused test (derived from Gradle path): `./gradlew :adapter:outbound:persistence-jpa:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. - Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.adapter.outbound.persistence`. Design decisions previously kept as code comments (transaction templates, auditing capture, failure-translation SPI, idempotency/outbox concurrency, distributed-lock TTL) live in [README.md](README.md). This file stays the SSOT for module rules and contract tables. This module is the RDBMS/JPA implementation base. It is not a datastore-neutral core for MongoDB, Redis, DynamoDB, or other NoSQL stores. Future NoSQL persistence adapters implement application/domain ports directly and must not depend on this module. ## Responsibility - JPA entities. - Spring Data repositories. - Persistence mappers. - Repository adapter implementations. - `TransactionPort` implementation (`SpringTransactionPort`) — the bridge between application transactional intent and Spring's `PlatformTransactionManager`. - Audit-metadata base + actor seam (`audit/AuditableEntity`, `audit/AuditContextPort`, `audit/DomainContextAuditContextPort`) — see "Persistence auditing contract" below. - Vendor SPI extension points shared by all RDBMS vendors: - `outbox/OutboxClaimRepository` — vendor module implements claim strategy (e.g. FOR UPDATE SKIP LOCKED). - `failure/SqlStateErrorMapping` — vendor module contributes vendor-specific SQLState rows. ## Allowed - `:application-core` - `:domain-core` - `:shared-contract` - Spring Data JPA and Spring transaction. ## Forbidden - `adapter-web`, `adapter-outbound`, or `app-bootstrap`. - Presentation DTOs. - Business policy decisions. - Use case orchestration hidden inside persistence adapters. - Repository adapters owning `@Transactional` boundaries — the application use case owns the transaction via `TransactionPort` (see [application-core/CLAUDE.md](../application-core/CLAUDE.md)). - **DB drivers** (`org.postgresql..`) or **`org.flywaydb.database.postgresql..`** — those are vendor-specific and belong only in this module's `.postgresql` package; NoSQL-specific dependencies belong only in their own future modules (persistence-multi-db-extensibility D3). This is enforced by ArchUnit `persistence_rdbms_stays_vendor_neutral` in `CleanArchitectureTest`. - NoSQL adapter code. MongoDB/Redis/DynamoDB adapters are sibling modules, not children of this module. - Any sibling persistence or inbound/outbound adapter not allowed by the registry. ## TransactionPort implementation contract `SpringTransactionPort` pre-builds one `TransactionTemplate` per mode: | Mode | Propagation | Isolation | Read-only | |---|---|---|---| | `inWrite` | `REQUIRED` | `READ_COMMITTED` | `false` | | `inRootWrite` | `REQUIRED` | `READ_COMMITTED` | `false` | | `inRead` | `REQUIRED` | `READ_COMMITTED` | `true` | | `inNew` | `REQUIRES_NEW` | `READ_COMMITTED` | `false` | Pre-built templates are immutable after construction so concurrent callers cannot observe each other's reconfiguration. `inRootWrite` reuses the pre-built write template, but first checks `TransactionSynchronizationManager.isActualTransactionActive()`. When an actual ambient transaction exists it MUST throw `NestedRootTransactionRejectedException` before invoking either the action or the `PlatformTransactionManager`. It MUST NOT use `NEVER` or `REQUIRES_NEW`. `inRootWrite` returns its action value only after `TransactionTemplate.execute` has committed. A commit failure propagates the transaction exception and no success value is returned to the caller. ### `inNew` pool-sizing constraint (D12 of feature-application-port-usecase-contract) `REQUIRES_NEW` acquires a NEW physical JDBC connection while pinning the outer transaction's connection. Provision the pool to satisfy: ``` hikari.maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1 ``` Loop-per-record `inNew` calls are forbidden (pool exhaustion + deadlock risk). Batch records inside ONE `inNew`, or move the loop outside the transaction. ## Persistence failure translation contract (feature-persistence-failure-baseline D1) A raw Spring `DataAccessException` (and the JPA exception / SQLState / constraint name inside it) must never reach the presentation layer. The `failure/PersistenceExceptionTranslator` classifies a `DataAccessException` by its SQLState against the §SQLState → Error Code Matrix and returns a framework-neutral `shared.error.PersistenceFailureException` carrying one of the `DB_*` `OperationalError` codes. **Standard rows (core):** | SQLState | code | category | http | retryable | |---|---|---|---|---| | `08*` | `DB_UNAVAILABLE` | `TRANSIENT_DEPENDENCY` | 503 | true | | `40001` | `DB_SERIALIZATION_FAILURE` | `CONFLICT` | 409 | true | | `23502` | `DB_NULL_VIOLATION` | `DATA_INTEGRITY` | 409 | false | | `23503` | `DB_FK_VIOLATION` | `DATA_INTEGRITY` | 409 | false | | `23505` | `DB_UNIQUE_VIOLATION` | `CONFLICT` | 409 | false | | `23514` | `DB_CHECK_VIOLATION` | `DATA_INTEGRITY` | 409 | false | **Vendor-specific rows (contributed by vendor module via `SqlStateErrorMapping` SPI):** | SQLState | code | vendor | |---|---|---| | `40P01` | `DB_DEADLOCK` | PostgreSQL (`.postgresql` package) | | `25P03` | `DB_IDLE_IN_TX_TIMEOUT` | PostgreSQL | | `57014` | `DB_QUERY_CANCELED` | PostgreSQL | - A repository adapter that catches a `DataAccessException` calls `translator.translate(ex)` and rethrows the carrier (`ifPresent(e -> { throw e; })`); an empty result means an unknown SQLState — rethrow the original so the web catch-all answers a generic `INTERNAL` envelope (no leak). - The category SSOT is the 10-value `Category` enum — there is **no** `PERSISTENCE` category (branch-note §Audit CATEGORY_DRIFT). ## Persistence auditing contract (feature-persistence-auditing-contract) Audit metadata (`created_at` / `updated_at` / `created_by` / `updated_by`, D3) is an infrastructure concern that must never reach `domain-core` (D2). It lives only on the `audit/AuditableEntity` `@MappedSuperclass`; a domain aggregate persistence entity opts in by extending it (D6 — e.g. the sample `WorkLogEntity`). The domain aggregate itself carries zero audit fields, enforced by ArchUnit `domain_is_pure` (no `jakarta.persistence..`) plus `domain_entities_do_not_carry_audit_fields` (no `createdAt`/`updatedAt`/`createdBy`/`updatedBy` fields under `..domain..`). - **Capture = Manual explicit-set (D1 current default).** The repository adapter constructor-injects `Clock` (D4) and `AuditContextPort` (D5) and stamps audit on `save`: INSERT (null version) → `initializeAudit(now, actor)`; UPDATE (non-null version) → carry the persisted `created_*` forward + `applyModification(now, actor)`. This mirrors the `IdempotencyStoreAdapter` precedent. `created_*` is `updatable = false`. - **Actor seam.** `AuditContextPort.currentActor()` reads the runtime-context-propagation seam and falls back to `"system"` when no principal is bound (scheduler / Flyway / anonymous). The actor's value semantics are owned by feature-authentication-authorization-contract (UNSUPPORTED here); the type is fixed to `String`. - **Excluded (D6).** Infra/immutable entities such as `IdempotencyRecordEntity` (own `created_at`, no `updated_at`) do NOT extend `AuditableEntity`. `version`/optimistic-lock is owned by feature-persistence-failure-baseline / feature-transaction-concurrency-contract, not by this audit base. - **Growth path (D1, deferred).** Migrate to Spring Data JPA Auditing (`@EntityListeners(AuditingEntityListener)` + `@CreatedDate`/`@LastModifiedDate`/… on the base, `@EnableJpaAuditing(dateTimeProviderRef, auditorAwareRef)` in the composition root, `DateTimeProvider` wrapping the same `Clock`, `AuditorAware` delegating to `AuditContextPort`) when manual set risks omission. Bulk/native `@Query` UPDATEs bypass both capture paths — stamp audit explicitly there if added. ## MapStruct generated mapper exemption (D9 of feature-architecture-enforcement-rules) If MapStruct is introduced for persistence mappers, the generated mapper class will be annotated with `javax.annotation.processing.Generated`. Architecture rules that forbid mapper boundary violations MUST exempt generated code via ArchUnit predicate: ```java import javax.annotation.processing.Generated; classes() .that().resideInAPackage("..adapter.persistence.mapper..") .and().areNotAnnotatedWith(Generated.class) .should() /* ... boundary rule ... */; ``` > Note the annotation FQN: MapStruct uses > `javax.annotation.processing.Generated`. Spring AOT uses > `org.springframework.aot.generate.Generated` — do **not** mix the two. The > exemption MUST scope to the specific annotation expected for the build step > being exempted. Current ca-tmpl mappers are hand-written so no MapStruct exemption is wired into ArchUnit yet — when generation is added, follow the predicate above and add a red/green test using a fixture mapper. ## NoSQL extension rule Do not create `adapter-persistence-nosql-core` preemptively. NoSQL stores have different models and operational contracts. When a real MongoDB, Redis, or DynamoDB adapter is needed, create a sibling module: ```text adapter-persistence-mongodb adapter-persistence-redis adapter-persistence-dynamodb ``` Such modules implement application/domain ports directly and must not depend on `adapter:outbound:persistence-jpa`. ## Test ```bash cd src ./gradlew :adapter:outbound:persistence-jpa:test --console=plain ```