init: 클린 아키텍처 백엔드

This commit is contained in:
DongHyeonka
2026-07-24 14:29:36 +09:00
parent 9eed16d097
commit 821fe00c32
971 changed files with 74769 additions and 1 deletions
@@ -0,0 +1,194 @@
# adapter:outbound:persistence-jpa — JPA/PostgreSQL persistence adapter
## Registered identity
- Module ID: `adapter-outbound-persistence-jpa`
- Gradle path: `:adapter:outbound:persistence-jpa`
- Focused test: `./gradlew :adapter:outbound:persistence-jpa:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `.harness/project/modules.yaml`.
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` |
| `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.
### `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:
```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
```