Files
clean-architecture-backend-…/docs/jpa/transaction-guide.md
T
DongHyeonkaandClaude Opus 5 0e61f86eb5 feat(jpa): implement the JPA relational persistence platform
Implements the Stable and Experimental JPA persistence platform designs
against real PostgreSQL, adapted to this repository's fail-closed 19-leaf
registry.

The design models the platform as 25 Gradle projects. `src/settings.gradle`
throws unless the registry holds exactly 19 leaves, so the plan's modules
become packages inside `:adapter:outbound:persistence-jpa` (starter in
`:app-bootstrap`, testkit in its own source set). The full mapping, the
renames this repository's naming gate required, and every deliberate
substitution are recorded in `docs/jpa/repository-adaptation.md`.

Seven Docker-backed lanes replace the plan's seven JVM test suites. Each
fails closed: a lane that discovers nothing, or a container that cannot
start, is an error rather than a skip.

Three defects the contracts found against a real server:

- `CommitFailureClassifier` treated only SQLSTATE 40003, class 08, and
  transport breaks as completion-unknown. A backend terminated mid-commit
  reports 57P01, and the commit record may already be in the WAL — so a
  possibly-committed transaction could be re-run. 57P01/57P02/57P03 now
  classify as completion-unknown.
- `SchemaTenantMigrationOrchestrator` recorded `MigrateResult`'s target
  version, which is empty for a tenant already current, reporting migrated
  tenants as unmigrated during a partial rollout. It now reads the applied
  version back from the tenant's schema history.
- `JpaStreamExecutor` checked only the declared return type for reactive
  publishers, and `RegisteredPostgreSqlCopyLoader` passed the COPY timeout
  to `SET`, which is parsed before parameter binding.

`JpaModuleBoundaryTest` enforces the plan's module map as package rules;
`verifyCleanArchitectureDependencies` governs edges between leaves and
cannot see these. Its first assertion is that the import is non-empty,
because every rule under it is a `noClasses()` rule and would pass
vacuously on an empty import.

Verified: 128 container tests across all seven lanes, 1183 unit tests,
`:adapter:outbound:persistence-jpa:check`, `:app-bootstrap:check`,
`verifyCleanArchitectureDependencies`, `verifyOneTypePerFile`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:06:18 +09:00

67 lines
3.1 KiB
Markdown

# Transaction Guide
Design §15-§20. What owns a transaction, what may be retried, and what must never be.
## The application service owns the boundary
Repository adapters do not open transactions. The use case does, through `TransactionPort` or
`JpaTransactionExecutor`, because the unit of work is a business decision and only the use case
knows where it starts and ends.
Open Session In View is off in every runtime profile. It is on by default in Spring Boot, which is
why `JpaDangerousConfigurationGuard` fails startup rather than trusting configuration review.
## Profiles
A `TransactionProfile` fixes propagation, isolation, timeout, read-only, and the retry budget. A
write profile must carry a positive finite timeout — the type refuses to represent one without —
because an unbounded write transaction holds a connection, its locks, and its row versions for as
long as one stuck statement takes.
`REQUIRES_NEW` is opt-in. It acquires a second physical connection while pinning the first, so a
profile using it must be paired with the pool-pressure evidence in design §38:
```text
maximumPoolSize >= (concurrent_threads x (1 + max_requires_new_depth)) + 1
```
## Retry is per use case, never per statement
`FullTransactionRetryCoordinator` re-enters the executor, which produces a new transaction and a new
Persistence Context for every attempt. That granularity is the whole point: an optimistic conflict
means the state the attempt computed against is no longer the committed state, so re-issuing the
same statement would compute the same wrong answer. The domain rules have to run again against
reloaded data.
Retryable: serialization failure (`40001`), deadlock (`40P01`), optimistic conflict.
Not retryable: constraint violations, schema mismatch, query timeout, and anything unclassified.
Two additional refusals, independent of budget:
- An attempt that declared an irreversible external effect through `IrreversibleSideEffectContext`.
Rollback reverses database work only; an email or a card charge has already changed the world.
- Anything completion-unknown.
## Completion unknown
`TransactionCompletionUnknownException` is never retried, and the type system enforces it twice:
`JpaFailureContext` refuses to represent a retryable completion-unknown failure, and the exception
rebuilds its context through the safe factory whatever it is handed.
`EvidenceAwareJpaTransactionManager` marks the phase `COMMITTING` immediately before delegating to
the provider commit and never after. If the network, the JVM, or the server dies inside that call,
the last thing written is "we asked, we do not know" — which is exactly the state that must not be
mistaken for a rollback.
Recovery is reconciliation, not retry:
```text
record the transaction key -> check the idempotency record
-> check the business row
-> check the outbox
-> still undetermined? reconciliation queue
```
`CompletionUnknownRecorder` writes that record through a channel outside the unknown transaction.
Writing it through the same connection would make the audit trail share the failure it documents.