init: 클린 아키텍처 백엔드
This commit is contained in:
@@ -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'
|
||||
```
|
||||
Reference in New Issue
Block a user