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
+187
View File
@@ -0,0 +1,187 @@
# application-core — application use cases
## Registered identity
- Module ID: `application-core`
- Gradle path: `:application-core`
- Focused test: `./gradlew :application-core: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.application`.
코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈 규칙 SSOT).
## Responsibility
- Use case inbound ports (`CommandUseCase`, `QueryUseCase`) and their command / query contracts.
- Outbound ports (`*Port` interfaces) the use cases depend on.
- Application exceptions and policy types.
- Coordinate domain models through ports.
- Own application transaction boundaries through the `TransactionPort` abstraction.
## Allowed
- `:domain-core`
- `:shared-contract`
- `org.springframework.boot:spring-boot-starter` — so use cases may opt into
`@Service` / `@Component` DI registration (D13). Spring core (`spring-context` /
`spring-beans`) is intentionally kept on the compile classpath because the
alternative — manual `@Configuration` per use case — explodes boilerplate.
## Forbidden
- `adapter-*` implementation classes.
- `app-bootstrap`.
- Controller request/response DTOs.
- JPA entities and Spring Data repositories.
- HTTP status, transport types (`org.springframework.web..`).
- `org.springframework.transaction.annotation.Transactional` (use `TransactionPort` instead — D3).
- `org.springframework.context.ApplicationContext` — direct dependency forbidden
(`getBean(Class)` reflection-style bypass blocked by ArchUnit D11). String-key
bean lookup / `Class.forName(String)` / `BeanFactory#getBeansOfType` remain
ArchUnit's static-analysis blind spot per D12 — guard via code review checklist.
- Lombok (`lombok..`) — also forbidden in `domain-core`. Within `application-core`,
Lombok is currently not in scope for the contract; if you intend to use it,
weigh the bytecode opacity cost first.
- Persistence-layer transaction annotations of any kind inside this module.
## Contract types
| Type | Purpose |
|---|---|
| `usecase.UseCase<I, O>` | Base type for inbound ports. Concrete inbound ports MUST extend `CommandUseCase` or `QueryUseCase`. |
| `usecase.CommandUseCase<C extends Command, R>` | Inbound port for write use cases. Implementations MUST be annotated `@UseCaseCapability`. |
| `usecase.QueryUseCase<Q extends Query, R>` | Inbound port for read-only use cases. Implementations MUST declare `transactionMode = READ_ONLY` and `repositoryAccess = READ_REPOSITORY`. |
| `command.Command` | Marker for write intents. Plain immutable types built from domain values. |
| `query.Query` | Marker for read intents. Plain immutable types built from domain values. |
| `transaction.TransactionPort` | Outbound port for transactional boundaries. Implemented by `adapter-persistence`. |
| `transaction.TransactionMode` | `WRITE` / `READ_ONLY` / `REQUIRES_NEW`. `NESTED` and `NEVER` are intentionally absent. |
| `transaction.Isolation` | `READ_COMMITTED` (pinned default) / `REPEATABLE_READ` / `SERIALIZABLE`. `READ_UNCOMMITTED` is forbidden (not declared); the vendor default is never used (engine defaults differ — PostgreSQL READ COMMITTED vs MySQL InnoDB REPEATABLE READ). Routing the stricter levels through `TransactionPort` is a `planned` joint change with `feature-application-port-usecase-contract`; the shipped call path pins `READ_COMMITTED`. |
| `capability.UseCaseCapability` | Mandatory annotation on every concrete use case: declares `transactionMode`, `idempotency`, `repositoryAccess`, `externalOutboundAllowed`. |
| `capability.Idempotency` | `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`. |
| `capability.RepositoryAccess` | `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`. |
## Naming convention
- Inbound port implementations end with `UseCase` (e.g. `RegisterUserUseCase`). Enforced by ArchUnit.
- Outbound port interfaces end with `Port` (e.g. `NotificationPort`).
- Command records end with `Command`; query records end with `Query`.
## Canonical use case shape
```java
@Service
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public final class RegisterUserUseCase implements CommandUseCase<RegisterUserCommand, User> {
private final UserRepository users;
private final TransactionPort tx;
public RegisterUserUseCase(UserRepository users, TransactionPort tx) {
this.users = users;
this.tx = tx;
}
@Override
public User handle(RegisterUserCommand cmd) {
return tx.inWrite(() -> {
// ... domain coordination
});
}
}
```
## Allowed transactional shapes
| Use case shape | `transactionMode` | TransactionPort call | When |
|---|---|---|---|
| Write command | `WRITE` | `tx.inWrite(...)` | Default for `CommandUseCase`. |
| Read-only query | `READ_ONLY` | `tx.inRead(...)` | Default for `QueryUseCase`. |
| Outbox / audit / compensation | `REQUIRES_NEW` | `tx.inNew(...)` | Only when the use case MUST commit independently of the caller. |
`NESTED` and `NEVER` propagation are forbidden.
### Callback signature contract (D11)
`TransactionPort` callbacks are `Supplier<T>` / `Runnable` and cannot throw checked
exceptions. This matches Spring's `TransactionCallback<T>` constraint. Wrap domain
checked exceptions into `RuntimeException` subclasses
(`DomainException extends RuntimeException`); `IOException``UncheckedIOException`;
`SQLException` is auto-translated by Spring's `DataAccessException` hierarchy.
### `inNew` pool-sizing constraint (D12)
`inNew` acquires a new physical JDBC connection. Pool size MUST satisfy:
```
hikari.maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1
```
**Forbidden**: calling `tx.inNew(...)` inside a loop over many records — pool
exhaustion + deadlock risk. Batch records inside ONE `inNew` call, or move the
loop outside the transaction boundary.
## Idempotency (KEYED) — feature-rate-limit-idempotency-contract
`@UseCaseCapability(idempotency = Idempotency.KEYED)` is now **supported** (the D14
freeze is lifted). A KEYED use case wraps its work with the `idempotency`-package
`IdempotencyExecutor`:
- **Key source**: the `Idempotency-Key` HTTP header, assembled by `adapter:inbound:web`'s
`IdempotencyKeySupport` into an `IdempotencyScope` of
`(authenticatedPrincipal, idempotencyKey, useCaseName)` (tenant 4-tuple when active).
- **Storage**: a DB table (`IdempotencyStore` port → `adapter-persistence`
`IdempotencyStoreAdapter` over `idempotency_record`); in-memory prod storage is forbidden.
- **TTL**: `APP_IDEMPOTENCY_TTL` (default 24h, ≤72h override).
- Concurrency (200ms in-flight wait → 409) and fingerprint mismatch (SHA-256 → 422)
are enforced by the executor; the codes live in `OperationalError`.
The former ArchUnit freeze rule `inbound_port_implementations_do_not_declare_keyed_idempotency`
and its fixture were removed when this branch merged.
## Read / query path (feature-application-query-bypass-contract)
The read side has two equally-valid shapes; pick per read, do not force one:
| Shape | Returns | When | How |
|---|---|---|---|
| **Through-aggregate** (default for simple reads) | domain aggregate via a `*Repository` port | read shape == write aggregate **and** the aggregate is the minimal invariant boundary (no lazy collections needed) | `QueryUseCase` → repository port → `WorkLog` |
| **Projection (CQRS-lite)** | application-layer projection DTO via a `*QueryPort` | read shape ≠ write, or to skip aggregate hydration / lazy-collection joins | `QueryUseCase``*QueryPort``WorkLogSummary` (record); query via JPQL `SELECT new` / JdbcTemplate |
- **D1 — purity guardrail (core, machine-enforced):** a read port whose simple name ends
with `QueryPort` MUST return application-layer projection DTOs only — never a domain
aggregate, JPA entity, or web type, **including through generic type arguments**
(`List<DomainType>`). Enforced by ArchUnit `query_ports_do_not_leak_domain_jpa_or_web_types`.
Projection usage itself is **opt-in**, not a forced default; the demo lives in
`sample-portfolio` (`WorkLogSummaryQueryPort` / `WorkLogSummary`).
- **D3 — Strict ceremony:** every read goes through a `QueryUseCase` bean. There is no thin
web→read-port path — that would bypass the mandatory `@UseCaseCapability` fitness function.
- **D4 — transaction:** reads default to `TransactionPort.inRead`. A no-tx (autocommit) read
is an opt-in only when `spring.jpa.open-in-view=false` is confirmed **and** the read is
projection-only (no lazy access) **and** a single statement; otherwise keep `inRead`.
- **D5 — capability:** a repository-backed projection read is still
`repositoryAccess = READ_REPOSITORY`. "Projection vs aggregate" is the return *shape* axis,
orthogonal to the repository-access *level* axis — no new enum. Outbound-HTTP reads (no
repository) stay `RepositoryAccess.NONE`.
- Full CQRS with a separate physical read store (**D2**) is out of scope — escalation only.
## ArchUnit guardrails (enforced)
- `application_does_not_depend_on_adapters_or_transport`
- `application_does_not_use_spring_transactional_annotation`
- `application_does_not_depend_on_application_context` (D11)
- `inbound_port_implementations_end_with_use_case`
- `inbound_port_implementations_declare_capability`
- `query_ports_do_not_leak_domain_jpa_or_web_types` (query-bypass D1 — `*QueryPort` return purity)
## Test
```bash
cd src
./gradlew :application-core:test
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'
```