Files
clean-architecture-backend-…/src/application-core/CLAUDE.md
T

9.6 KiB
Raw Blame History

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 (*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

@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); IOExceptionUncheckedIOException; 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*QueryPortWorkLogSummary (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

cd src
./gradlew :application-core:test
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'