# 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` | Base type for inbound ports. Concrete inbound ports MUST extend `CommandUseCase` or `QueryUseCase`. | | `usecase.CommandUseCase` | Inbound port for write use cases. Implementations MUST be annotated `@UseCaseCapability`. | | `usecase.QueryUseCase` | 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 { 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` / `Runnable` and cannot throw checked exceptions. This matches Spring's `TransactionCallback` 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`). 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' ```