Files
tech-log-backend/src/adapter/outbound/persistence-jpa/CLAUDE.md
T

9.6 KiB
Raw Blame History

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.
  • 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).
  • 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<String> 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:

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:

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