9.6 KiB
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 가 모아둔다 (이 문서는 모듈 규칙 SSOT).
Responsibility
- Use case inbound ports (
CommandUseCase,QueryUseCase) and their command / query contracts. - Outbound ports (
*Portinterfaces) the use cases depend on. - Application exceptions and policy types.
- Coordinate domain models through ports.
- Own application transaction boundaries through the
TransactionPortabstraction.
Allowed
:domain-core:shared-contractorg.springframework.boot:spring-boot-starter— so use cases may opt into@Service/@ComponentDI registration (D13). Spring core (spring-context/spring-beans) is intentionally kept on the compile classpath because the alternative — manual@Configurationper 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(useTransactionPortinstead — 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#getBeansOfTyperemain ArchUnit's static-analysis blind spot per D12 — guard via code review checklist.- Lombok (
lombok..) — also forbidden indomain-core. Withinapplication-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 withQuery.
Canonical use case shape
@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-KeyHTTP header, assembled byadapter:inbound:web'sIdempotencyKeySupportinto anIdempotencyScopeof(authenticatedPrincipal, idempotencyKey, useCaseName)(tenant 4-tuple when active). - Storage: a DB table (
IdempotencyStoreport →adapter-persistenceIdempotencyStoreAdapteroveridempotency_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
QueryPortMUST return application-layer projection DTOs only — never a domain aggregate, JPA entity, or web type, including through generic type arguments (List<DomainType>). Enforced by ArchUnitquery_ports_do_not_leak_domain_jpa_or_web_types. Projection usage itself is opt-in, not a forced default; the demo lives insample-portfolio(WorkLogSummaryQueryPort/WorkLogSummary). - D3 — Strict ceremony: every read goes through a
QueryUseCasebean. There is no thin web→read-port path — that would bypass the mandatory@UseCaseCapabilityfitness function. - D4 — transaction: reads default to
TransactionPort.inRead. A no-tx (autocommit) read is an opt-in only whenspring.jpa.open-in-view=falseis confirmed and the read is projection-only (no lazy access) and a single statement; otherwise keepinRead. - 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) stayRepositoryAccess.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_transportapplication_does_not_use_spring_transactional_annotationapplication_does_not_depend_on_application_context(D11)inbound_port_implementations_end_with_use_caseinbound_port_implementations_declare_capabilityquery_ports_do_not_leak_domain_jpa_or_web_types(query-bypass D1 —*QueryPortreturn purity)
Test
cd src
./gradlew :application-core:test
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'