9.6 KiB
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. 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.
TransactionPortimplementation (SpringTransactionPort) — the bridge between application transactional intent and Spring'sPlatformTransactionManager.- 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, orapp-bootstrap.- Presentation DTOs.
- Business policy decisions.
- Use case orchestration hidden inside persistence adapters.
- Repository adapters owning
@Transactionalboundaries — the application use case owns the transaction viaTransactionPort(see application-core/CLAUDE.md). - DB drivers (
org.postgresql..) ororg.flywaydb.database.postgresql..— those are vendor-specific and belong only in this module's.postgresqlpackage; NoSQL-specific dependencies belong only in their own future modules (persistence-multi-db-extensibility D3). This is enforced by ArchUnitpersistence_rdbms_stays_vendor_neutralinCleanArchitectureTest. - 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
DataAccessExceptioncallstranslator.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 genericINTERNALenvelope (no leak). - The category SSOT is the 10-value
Categoryenum — there is noPERSISTENCEcategory (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) andAuditContextPort(D5) and stamps audit onsave: INSERT (null version) →initializeAudit(now, actor); UPDATE (non-null version) → carry the persistedcreated_*forward +applyModification(now, actor). This mirrors theIdempotencyStoreAdapterprecedent.created_*isupdatable = 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 toString. - Excluded (D6). Infra/immutable entities such as
IdempotencyRecordEntity(owncreated_at, noupdated_at) do NOT extendAuditableEntity.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,DateTimeProviderwrapping the sameClock,AuditorAware<String>delegating toAuditContextPort) when manual set risks omission. Bulk/native@QueryUPDATEs 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:
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 usesorg.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:
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
cd src
./gradlew :adapter:outbound:persistence-jpa:test --console=plain