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'
```
+480
View File
@@ -0,0 +1,480 @@
# application-core — 설계 결정 참조
애플리케이션 유스케이스 계층. 패키지 루트: `dev.caskeleton.application`.
허용/금지 의존, 유스케이스 형태, 트랜잭션 모드, 명명 규칙 같은 **모듈 규칙**은
[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를
모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할 때 본다. 아래 설명은 별도
추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로 풀어 둔다.
이 계층을 관통하는 큰 원칙 하나: **application-core 는 프레임워크-free 다.** Spring/JPA/HTTP
타입을 직접 들이지 않고, 필요한 인프라 능력(트랜잭션·락·인가·알림 등)은 전부 `*Port`
인터페이스로 추상화한다. 구현은 adapter 모듈에 있고 컴파일 타임엔 보이지 않는다. 아래 결정
대부분이 이 원칙에서 파생된다.
---
## 유스케이스 계약 (usecase / command / query / capability)
### UseCase / CommandUseCase / QueryUseCase
- inbound port(헥사고날의 primary port)다. 모든 구체 유스케이스는 쓰기면 `CommandUseCase`,
읽기면 `QueryUseCase` 를 extends 해서 **연산의 종류를 타입에 박아 둔다.** 이렇게 해야
capability·트랜잭션 모드 같은 계약을 컴파일/ArchUnit 단계에서 강제할 수 있다. 클래스 이름은
반드시 `UseCase` 로 끝난다(ArchUnit 강제).
- 제네릭 입출력 타입(`<I, O>`)에는 web 요청 DTO·JPA 엔티티·외부 클라이언트 응답 타입이 올 수
없다. 입력은 `Command`/`Query`, 출력은 도메인 객체·도메인 프로젝션·`Void` 만 허용. 이 자리에
transport 타입이 들어오면 application 계층 경계가 깨진다.
### Command / Query (마커)
- 둘 다 마커 인터페이스. `Command` = 쓰기 의도, `Query` = 읽기 의도.
- 반드시 불변 타입(가능하면 `record`)이고 도메인 타입·원시 값 객체로만 구성한다. web DTO,
JPA 엔티티, 외부 응답 타입은 필드에 넣을 수 없다 — 이게 들어오면 transport 관심사가
application 계층으로 새는 것.
### UseCaseCapability (모든 유스케이스 필수 애너테이션)
- 모든 구체 `*UseCase` 클래스에 필수. 유스케이스의 **트랜잭션 모양·멱등성·리포지토리 접근·외부
호출 여부를 본문을 읽지 않고도** 알 수 있게 만든다. ArchUnit 이 모든 구체 inbound port 에 이
애너테이션이 일관되게 붙어 있는지 검사한다.
- 일관성 규칙(ArchUnit 으로 강제):
- `QueryUseCase` 구현은 `transactionMode = READ_ONLY` + `repositoryAccess = READ_REPOSITORY` 여야 한다.
- `transactionMode = REQUIRES_NEW` 는 outbox / audit / compensation 흐름 전용이다.
- 외부 `*Port`(outbound adapter 에 바인딩된 포트)를 호출하려면 `externalOutboundAllowed = true`
가 필요하다. 없으면 outbound 어댑터 호출 금지.
- `repositoryAccess``WRITE_REPOSITORY` 가 아닌 유스케이스는 리포지토리 포트의 쓰기
메서드(save/delete/update/insert)를 호출할 수 없다 — `read_only_use_cases_do_not_call_repository_write_methods`
규칙이 막는다. 단 정적 분석은 **직접 호출만** 잡으므로, helper/mapper 를 거친 쓰기는 리뷰가 본다.
- `bulkWrite = true``WRITE_REPOSITORY` 를 함께 요구한다(대량 쓰기도 결국 쓰기).
`bulk_write_capability_requires_write_repository_access` 가 강제.
- 이 애너테이션은 `docs/registries/capabilities.yaml` 의 7개 capability 를 코드로 구현한 것이며,
**코드가 SSOT** 다. 추가 플래그의 의미:
- `sensitiveRead` — PII/자격증명/비밀을 읽는 유스케이스 선언. 필드 단위 마커(엔티티 FQN +
필드명 테이블)와 자동 강제는 이 영역의 책임이고 아직 미구현이라,
지금은 리뷰 기반의 선언적 계약이다. 이 마커를 **도메인/JPA 엔티티 애너테이션으로 표현하면 안
된다**(프레임워크 의존이 도메인에 새는 것을 막기 위함).
- `bulkWrite` — 단일 트랜잭션에서 100행/배치를 초과하는 쓰기(registry 임계치 = 100). 임계치
미만이면 평범한 `WRITE_REPOSITORY` 선언으로 충분하다.
- `crossTenantAdmin` — 테넌트 경계를 넘는 admin 연산. cross-tenant 접근 정책 자체는
이 영역의 책임(여기선 어휘만 제공). single-tenant 유스케이스에 붙이면
리뷰 reject.
---
## 트랜잭션 경계 (transaction)
### TransactionPort
- **존재 이유**: application 유스케이스가 `org.springframework.transaction.annotation.Transactional`
을 import 하지 않고도 트랜잭션 의도를 선언하게 하기 위한 추상화다. 구현(보통
`SpringTransactionPort`)은 persistence adapter 가 Spring `PlatformTransactionManager` 로 제공한다.
application/domain 을 프레임워크-free 로 유지하는 핵심 장치.
- 세 가지 경계:
- `inWrite` — REQUIRED + read-write, `READ_COMMITTED`. command 유스케이스 기본.
- `inRead` — REQUIRED + read-only, `READ_COMMITTED`. query 유스케이스 기본.
- `inNew` — REQUIRES_NEW + read-write. UseCaseCapability 에 `REQUIRES_NEW` 를 명시한
유스케이스(outbox/audit/compensation)에서만 허용.
- **콜백 시그니처(D11)**: 세 메서드 모두 `Supplier`/`Runnable` 을 받아 checked exception 을 던질
수 없다. Spring `TransactionCallback<T>` 제약과 동일하다. 그래서 호출자는 도메인 checked
exception 을 `RuntimeException` 하위로 감싸야 한다(`DomainException extends RuntimeException`).
`IOException``UncheckedIOException`, `SQLException` 은 Spring `DataAccessException` 계층이
자동 변환한다. 콜백 안에서 던진 `RuntimeException` 은 롤백 + 호출자 전파.
- **`inNew` 풀 사이징 비용(D12)**: REQUIRES_NEW 는 바깥 트랜잭션의 커넥션을 잡아둔 채 **새 물리
JDBC 커넥션을 추가로** 잡는다. 즉 미완료 `inNew` 호출 하나당 풀에서 커넥션 하나를 더 쓴다.
```
hikari.maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1
```
여기서 `max_inNew_depth` 는 스레드당 미완료 `inNew` 중첩의 최대 깊이다.
**금지**: 많은 레코드를 도는 루프 안에서 `inNew` 호출(예: per-row outbox dispatch). 풀 고갈 +
데드락 위험. 레코드를 한 번의 `inNew` 안에서 배치 처리하거나, 루프를 트랜잭션 경계 밖으로 빼라.
- **금지 목록**: `NESTED`/`NEVER` propagation, `READ_UNCOMMITTED` isolation, application 패키지에서
`@Transactional` 직접 사용, `inNew` 의 per-record 루프 호출.
### TransactionMode
- `WRITE` / `READ_ONLY` / `REQUIRES_NEW`. TransactionPort API 와 UseCaseCapability 양쪽에서 쓰여,
유스케이스의 트랜잭션 계약을 본문 없이 드러낸다.
- `READ_ONLY` 일 때 persistence adapter 는 `readOnly = true` 를 켜서 드라이버 읽기 최적화
(Hibernate flush-mode MANUAL 등)를 적용하는 것이 좋다.
### Isolation
- `READ_COMMITTED` 가 고정 기본값이고, **모든 트랜잭션 템플릿에 명시적으로** 설정한다. vendor
기본값에 위임하지 않는 이유: 엔진마다 기본 isolation 이 다르다(PostgreSQL = READ COMMITTED,
MySQL InnoDB = REPEATABLE READ). 위임하면 같은 코드가 DB 에 따라 다른 isolation 으로 도는
silent 위험이 생긴다.
- `REPEATABLE_READ` / `SERIALIZABLE` 은 쓰기 무거운/읽기 일관성 유스케이스를 위한 명시적
opt-in. `READ_UNCOMMITTED` 는 금지라 enum 에 아예 선언하지 않았다.
- 단, 더 엄격한 레벨을 TransactionPort 호출 경로로 라우팅하는 per-use-case 선택 메커니즘은
관련 추상화 설계와의 공동 변경이라 아직 `planned` 다.
그전까지 이 상수들은 **어휘만** 선언하고, 실제 호출 경로는 `READ_COMMITTED` 로 고정된다.
---
## 인가 (security)
인증("누구냐")은 web/security adapter 책임이고, 이 패키지는 인가("이걸 해도 되냐")만 소유한다.
### AuthorizationPort
- 제품 인가 enforcement point(PEP). 인증된 호출자가 권한이 필요한 연산을 수행해도 되는지
결정한다. 인증된 principal 의 raw role 을 입력으로 받는다(인증 자체는 베이스라인 소유).
- **왜 포트인가, `@PreAuthorize` 가 아니라**: Spring method-security 애너테이션은 빈을
`org.springframework.security` 타입에 묶는다. application/domain 은 프레임워크-free 여야 하므로
(TransactionPort 선례), enforcement *결정* 은 평범한 Java 포트로 표현하고 Spring 의존
enforcement *메커니즘*(커스텀 `AuthorizationManager`)은 web adapter 에 둔다.
- **fail-closed**: 호출자가 필요한 권한을 가진 경우에만 정상 반환한다. 그 외(미지 role, role 없음,
매핑 누락)는 전부 `AuthorizationDeniedException`.
### RequiresPermission (애너테이션)
- 인증된 호출자가 가드된 유스케이스를 호출하려면 가져야 하는 `Permission` 을 선언한다. 값은
`resource:action` 토큰(예: `"worklog:close"`). `@UseCaseCapability` 패턴을 그대로 따라, 유스케이스가
요구하는 권한을 본문 없이 보이게 한다.
- **순수 선언 — Spring-free**: Spring Security 타입을 일절 담지 않는다. 실제 enforcement 는 web
adapter 의 `RequiresPermissionAuthorizationManager` 가 RUNTIME retention 으로 이 애너테이션을
읽어 `AuthorizationPort` 에 위임한다. enforcement 가 Spring AOP 프록시를 거치므로
self-invocation 이나 비-Spring 빈 호출은 우회된다 — 모든 mutating 진입점을 가드하는 책임은
아키텍처 강제 규칙 몫이다.
- **적용 대상**: skeleton 기본은 mutating/민감 유스케이스(`repositoryAccess = WRITE_REPOSITORY`)에
필수, public read 는 항상 면제(D4). 타입/메서드 어디에도 붙일 수 있다.
### AuthorizationPrincipal
- `AuthorizationPort` 가 소비하는 프레임워크-free 호출자 뷰 — IdP subject + 호출자의 raw role
이름. web adapter 는 Spring 인지 principal(`AuthenticatedPrincipal`, Keycloak 스타일 raw role)을
노출하지만 application 계층은 그 타입을 import 할 수 없으므로, enforcement point 가 이 추상으로
내려 매핑한다.
- **role 은 raw, Spring authority 가 아니다**: role set 은 IdP 가 발급한 그대로의 문자열
(`"admin"`, `"user"`)이며 `ROLE_*` GrantedAuthority 형태가 아니다. role→permission 해석이 raw
이름을 키로 쓰므로(D3), 둘을 섞으면 권한 0개로 해석돼 fail-closed denial 이 된다.
### AuthorizationDeniedException
- 인증된 호출자가 필요한 `Permission` 을 못 가졌을 때 `AuthorizationPort` 가 던진다.
- **프레임워크-free, application 소유**: application 계층은 Spring Security 에 의존할 수 없어
Spring `AccessDeniedException` 을 던질 수 없다. web adapter 의 method-security enforcement
point 가 경계에서 이 예외를 잡아 Spring authorization 실패로 번역하고, error pipeline 이
`AUTHZ_INSUFFICIENT_PERMISSION` 403 으로 매핑한다(코드 SSOT 는 베이스라인 소유).
- **fail-closed**: denial 은 필요한 권한과 호출자 subject 만 담고, 호출자의 *유효 권한 집합* 은
절대 담지 않는다(인가 표면을 클라이언트에 노출하지 않기 위함).
---
## 멱등성 (idempotency)
application 계층이 in-flight 대기·replay **정책** 을 소유하고, 저장은
`IdempotencyStorePort` 로 위임한다.
### IdempotencyExecutor
- application 유스케이스 경계에서 멱등 실행을 조율한다. 하나의 `IdempotencyScope` 에 대해 순서대로:
1. **claim** — store 유니크 제약 위의 atomic insert-or-read(D7). 이긴 caller 가 action 을
정확히 한 번 실행한다.
2. **fingerprint mismatch** — 같은 key, 다른 body(다른 fingerprint)인 live record 는
`IdempotencyRequestMismatchException` → 422(D8).
3. **replay** — `COMPLETED` record 는 저장된 응답을 codec 으로 재생(§B).
4. **in-flight wait** — 동시 `IN_FLIGHT` record 는 최대 `IN_FLIGHT_WAIT`(200ms) 동안 폴링하고,
여전히 미해결이면 `IdempotencyInFlightException` → 409(D7).
- claim 한 action 이 예외를 던지면 in-flight record 를 `discard` 한다 — 그래야 재시도가 TTL
만료까지 409 에 갇히지 않는다.
- **200ms 대기 창의 출처(표준이 강제한 값 아님 — 스켈레톤 기본 선택)**: 이 200ms 대기 창은
인용 가능한 표준이 강제하는 값이 아니다(IETF/Toss 는 즉시 409 를 권장). 표준의 운영 친화적
변형이다 — "표준 기반 + 운영 변형" 으로 설명해야지 "표준을 따른다" 고 말하면 안 된다. 블로킹
폴링이 요청 스레드를 점유하므로, 중복 도착이 몰리면 스레드 점유 vs 클라이언트 재시도 친화성의
트레이드오프가 있다(임계치는 부하 테스트로 검증 필요).
- 상수: `IN_FLIGHT_WAIT = 200ms`(D7), `MAX_TTL = 72h`(D6 하드 캡), `POLL_INTERVAL = 20ms`.
- package-private 한 `Sleeper` 주입 생성자가 따로 있는 이유: 테스트 `Sleeper` 가 mutable clock 을
전진시켜 실제 시간 블로킹 없이 in-flight wait 를 결정적으로 검증하기 위함.
### IdempotencyStorePort
- 멱등 record 저장 outbound 포트(D3). `adapter-persistence` 가 유니크 제약
`(tenant, principal, idempotency_key, use_case_name)` 위에 구현한다. 포트는 저장 primitive
(tryBegin/find/complete/discard)만 노출하고, 정책은 executor 가 가진다.
- **in-memory 프로덕션 구현 금지**: 계약상 내구성 있고 유일성을 강제하는 백킹 스토어가 필요하다.
Redis 는 내구 스토어 앞단의 선택적 캐시로만 허용된다.
- **만료 record 는 reclaimable**: `expiresAt <= now` 인 record 는 없는 것처럼 취급해야 한다.
`tryBegin` 은 만료된 죽은 행을 purge/replace 해서 새 claim 이 이기게 하고, `find` 는 만료된
record 를 absent 로 보아 만료 replay 를 거부한다(§E / TTL 경계).
### IdempotencyScope
- 멱등 요청의 정체성이자 계약의 SSOT scope 모양(D2). 기본은 triple
`(authenticatedPrincipal, idempotencyKey, useCaseName)`. 테넌트 격리가 활성이면 `tenant` 차원을
앞에 붙인 4-tuple 이 되고, single-tenant 모드에선 `tenant = null`.
- **여기서 막는 실패 모드**: `principal`/`useCaseName` 이 빠진 key 는 전역적으로 충돌해 다른
caller 의 응답을 재생할 수 있다. `of` 팩토리가 blank 필수 차원을 `IdempotencyScopeMissingException`
으로 거부해, scope 없는 key 가 store 에 닿지 못하게 막는다. 막으려는 실패 모드는 "scope 누락으로
인한 silent 전역 충돌"이다.
- `storageKey()` 는 진단/단일 컬럼 조회용으로 사람이 읽는 join 문자열일 뿐이다. 유일성은 차원
컬럼에 강제되지 이 문자열에 강제되지 않는다.
### RequestFingerprint
- 요청 body 의 SHA-256 지문(D8). 같은 scope 인데 body 가 다르면(지문이 다르면) 클라이언트
버그로 보고 executor 가 422 로 거부한다. null/빈 body 는 zero-length payload 지문으로 취급해
빈 replay 의 일관성을 지킨다.
- **SHA-256 선택(표준이 강제한 값 아님 — 스켈레톤 기본 선택)**: MD5/SHA-1 대비 충돌 저항성 때문에
SHA-256 을 골랐다 — 인용 가능한 표준이 강제한 것도, 측정된 성능 근거가 있는 것도 아니다. body
**정규화**(JSON 키 순서·공백·인코딩)는 의도적으로 적용하지 않는다 — 전송된 raw 바이트를 그대로
해시한다. 정규 동등성이 필요한 caller 는 `ofSha256` 호출 전에 직접 정규화해야 한다. 안 하면 키
순서만 바뀐 의미상 동일 replay 가 false mismatch 가 된다(부하와 실제 요청 형태로 검증 필요).
- `NoSuchAlgorithmException` catch 는 도달 불가다(SHA-256 은 모든 JDK 에 필수). 그래서
`IllegalStateException` 으로 감싼다.
### 멱등성 값/계약 타입 (Context · Record · StoredResponse · Codec · Status · Sleeper)
- `IdempotencyContext` — executor 입력. scope + fingerprint + 선택적 per-use-case TTL
override(D6). 결제/송금 같은 장기 유스케이스가 72h 캡까지 override 가능하고, 캡 초과는 executor 가
거부한다.
- `IdempotencyRecord` — application 이 보는 영속 record. `COMPLETED` 면 반드시 response 를
동반해야 한다(생성자가 강제). adapter 가 테이블 행과 매핑한다.
- `StoredResponse` — 완료된 응답의 불투명 직렬화 표현으로, 중복 caller 에게 그대로 재생된다(§B).
**어디에** 물리 저장되는지(작으면 행 인라인 ≤8KB, 크면 object store + 행엔 ref 만 — §F/D9)는
전적으로 persistence adapter 관심사라 여기선 보이지 않는다 → application 계층을 transport·저장
중립으로 유지.
- `IdempotentResponseCodec` — 유스케이스 결과를 `StoredResponse` payload 로 직렬화/역직렬화.
application 은 wire-format 중립이다 — 구체 JSON 인코딩은 `R` 의 응답 모양을 아는 `adapter-web`
caller 가 소유하고, executor 는 그 문자열을 store 로 왕복시키기만 한다.
- `IdempotencyStatus` — `IN_FLIGHT` / `COMPLETED` 두 값.
- `Sleeper` — `Thread.sleep` 우회 인터페이스. executor 의 in-flight 폴링 대기를 결정적으로
테스트하기 위함(테스트 Sleeper 가 실제 시간 블로킹 대신 mutable clock 전진).
### 멱등성 예외 3종 (공통 패턴)
- `IdempotencyRequestMismatchException`(422), `IdempotencyInFlightException`(409),
`IdempotencyScopeMissingException`(400, `VALIDATION_FAILED`).
- 셋 다 **프레임워크-free, application 소유**(`AuthorizationDeniedException` 선례). web adapter 가
경계에서 각각의 HTTP 코드로 매핑한다. 진단 메시지는 scope storage key / 누락 차원을 **로그용으로만**
담고, 클라이언트에는 핸들러가 고정 client-safe 메시지로 대체한다(fingerprint 자체는 노출 안 함).
- retryable: mismatch / in-flight 모두 `false`. 클라이언트는 body 를 고치거나 결과를 polling 해야지
단순 재시도를 하면 안 된다.
---
## 트랜잭셔널 아웃박스 릴레이 (outbox)
핵심 패턴: 비즈니스 쓰기와 같은 트랜잭션에 이벤트를 append → 별도 relay 가 짧은 트랜잭션으로
claim → 트랜잭션 밖에서 발행 → at-least-once 보장.
### OutboxAppendPort
- 트랜잭셔널 아웃박스에 새 이벤트를 append 하는 outbound 포트.
- **dual-write 금지(D2)**: 반드시 이벤트를 만든 비즈니스 연산과 **같은 DB 트랜잭션 안에서**
호출해야 한다(그 트랜잭션은 caller 가 `TransactionPort.inWrite` 로 연다). 트랜잭션 밖에서, 또는
비즈니스 연산과 다른 트랜잭션에서 호출하면 dual-write 금지 위반이다 — 비즈니스는 커밋됐는데
outbox append 가 안 되거나(또는 그 반대) 이벤트가 silent 손실/허위 방출된다. 따라서 구현은
내부에서 새 트랜잭션을 열면 안 되고, 바깥 트랜잭션에 참여해야 한다.
### OutboxStorePort
- relay 측에서 outbox store 에 접근하는 포트. 모든 mutating 연산
(`claimBatch`/`markPublished`/`markFailed`/`markDead`)은 relay 유스케이스가 소유한
`TransactionPort.inWrite` 경계 안에서 호출하며, 구현은 자체 트랜잭션을 열지 않는다.
- **claim 의미(I4 / I6)** — `claimBatch` 가 적격 행을 atomic 하게 `IN_FLIGHT` 로 전이시킨다:
- 적격 행: `PENDING`, `next_attempt_at <= now` 인 `FAILED`, `next_attempt_at <= now` 인
`IN_FLIGHT`(고아 in-flight — I6 timeout 재사용).
- **FIFO 게이트(I4)**: 같은 `aggregate_id` 의 더 이른 `occurred_at` 행이 아직 `PUBLISHED` 가
아니면 그 aggregate 의 뒤 행들은 skip 한다. `DEAD` 행도 그 aggregate 큐를 막는다(strict
FIFO — 해제하려면 runbook 개입 필요).
- claim 후: `status = IN_FLIGHT`, `attempt_count += 1`,
`next_attempt_at = now + inFlightTimeout`.
- 순서: adapter 는 claim 쿼리에 `ORDER BY occurred_at ASC` 를 쓰는 게 좋고, relay 도 받은
배치를 방어적으로 재정렬한다.
- **in-flight timeout 재사용(I6)**: `next_attempt_at` 컬럼을 in-flight 고아 timeout 으로
재사용한다. relay 인스턴스가 죽어 `IN_FLIGHT` 로 남은 행이 `next_attempt_at` 을 지나면 다음
폴링에서 다시 claim 가능해진다. 별도 `claimed_at` 컬럼 없이 at-least-once 전달을 보장하는 트릭.
- `countByStatus` / `oldestUnpublishedAgeSecondsByEventType` 는 메트릭 게이지
(`outbox.pending.size`, `outbox.publisher.lag`)의 데이터 소스다. 트랜잭션 밖 read-only
(metric-scrape 경로)로 호출된다.
### OutboxMessagePublishPort
- claim 된 outbox 이벤트를 메시지 브로커에 발행하는 outbound 포트.
- **fail-closed 계약(I8)**: 발행 실패는 반드시 `RuntimeException` 으로 표면화해야 한다. 구현은
예외를 삼키거나 실패 시 log-and-return 하면 안 된다. 일반적인 fail-open 메시징 publisher(잡고
로그 후 정상 반환)와의 **의도적·문서화된 차이** 다 — relay 의 Failure condition 이 발행 실패를
예외로 관측해야 FAILED/DEAD 전이 + 에러 코드 로그(`OUTBOX_PUBLISH_FAILED` /
`OUTBOX_DEAD_LETTER`)를 구동할 수 있기 때문. 삼킨 실패(예외 없음·전이 없음·ERROR 로그 없음)가
금지 조건이다 — 행이 영원히 `IN_FLIGHT` 로 남고, aggregate FIFO 큐가 조용히 막히며, 메트릭엔
이상이 안 보인다.
- **호출 위치**: relay 유스케이스가 **트랜잭션 밖에서** 호출한다. 짧은 `inWrite` 로 배치 claim →
트랜잭션 해제 → 발행 → 결과별로 다시 짧은 `inWrite` 로 상태 갱신. 브로커 호출이 진행되는 동안
트랜잭션 보유 시간을 최소화한다.
### PublishPendingOutboxEventsUseCase (릴레이 본체)
- pending outbox 이벤트를 claim 해 브로커에 발행하는 relay 유스케이스.
- **알고리즘(claim short, publish outside tx)**:
1. 짧은 write 트랜잭션 안에서 배치를 claim 한다.
2. `occurred_at` 오름차순으로 방어적 정렬(claim 쿼리도 정렬하지만, adapter 가 안 해도 relay 가
FIFO 를 강제한다).
3. 각 이벤트를 **트랜잭션 밖에서** 발행하고 결과로 상태 머신을 구동한다:
- 발행 성공 → `inWrite { markPublished }` → `PUBLISHED`.
- 발행 실패(`RuntimeException`): `attemptCount >= maxAttempts` 면 `markDead` +
`OUTBOX_DEAD_LETTER` ERROR 로그; 아니면 `markFailed(nextAttemptAt)` +
`OUTBOX_PUBLISH_FAILED` ERROR 로그.
- **발행 실패는 절대 삼키지 않는다**: relay 는 각 발행 예외를 잡아 FAILED/DEAD 상태 머신을
구동하고 ERROR 로그를 낸 뒤 rethrow 하지 않는다(스케줄러 루프가 다음 이벤트로 계속 가야
하므로). 모든 발행 실패는 반드시 (a) 상태 전이와 (b) error code·correlationId·eventId·eventType·
attemptCount 를 담은 ERROR 로그를 **둘 다** 남긴다. 둘 중 하나라도 빠지면 금지된 silent-swallow.
- **상태 갱신 실패는 시끄럽게 전파한다**: 발행 성공 후의 `markPublished` 실패는 store/인프라
에러지 발행 실패가 아니다. 따라서 FAILED/DEAD 머신을 구동하면 안 된다(이미 전달된 이벤트를
dead-letter 하는 꼴). 대신 스케줄러 catch 블록으로 전파되고, 행은 `IN_FLIGHT` 로 남아 고아
visibility-timeout 재claim 경로로 복구된다 → 재발행 → consumer dedupe 가 중복을
흡수(at-least-once). 배치 중간의 상태 갱신 실패가 그 tick 의 남은 배치를 중단시키는 것은
허용된다 — DB 가 실패 중이면 뒤따르는 갱신도 실패할 테고, 다음 tick 이 모든 `IN_FLIGHT` 고아를
재시도하기 때문.
- **수동 와이어링(@Service 아님, context 빈 아님)**: `app-bootstrap` 이 수동으로 생성한다
(`batchSize`·`inFlightTimeout` 같은 설정값이 필요해서 자동 등록이 안 된다). `UseCaseCapability`
는 빈 등록 방식과 무관하게 필수다(ArchUnit 강제). 그리고 **Spring 빈으로 등록하면 안 된다**:
클래스 레벨 `@RequiresPermission` pointcut(web `MethodSecurityConfig`)이 이 타입 빈을 CGLIB
프록시하려 드는데, `final` 클래스라 불가능하고, 스케줄러 스레드엔 `Authentication` 이 없어 매
relay tick 이 fail-closed 거부될 것이다.
- **권한**: `"outbox:relay"` 는 시스템 내부 권한이다. relay 는 엔드유저가 아니라 스케줄러 빈이
호출한다. `@RequiresPermission` 선언은 ArchUnit `mutating_use_cases_declare_required_permission`
(D4) 을 충족시키기 위함이고, 스케줄러 컨텍스트에서의 실제 강제는 관례에 맡긴다(스케줄러는
app-bootstrap 내부).
### OutboxBackoffPolicy
- relay 재시도 스케줄링용 exponential-backoff-with-full-jitter 정책(I10).
- 공식: `base = 30s`, `maxAttempts = 3`, `delay = base × 2^(attemptCount-1) + jitter(0..base)`,
`nextAttemptAt = now + delay`. 여기서 `attemptCount` 는 방금 실패한 시도의 1-based 번호다.
- full jitter 는 주입된 `RandomGenerator` 로 `[0, base]` 균등 분포에서 뽑는다. 고정 시드/0 을
반환하는 generator 를 주면 테스트가 결정적이 된다(I10).
- **상수는 고정이다(I10)**: `BASE_DELAY`·`MAX_ATTEMPTS`·jitter 범위는 설정 프로퍼티가 아니다.
외부화하면 registry alert 임계치(`error-codes.yaml` 의 `retry_after_seconds=30`)와
`outbox-publish-failed` runbook 이 실제 런타임 값과 어긋난다. 이 상수는 registry·runbook
업데이트와 **함께만** 바꾼다.
- 지수 계산은 overflow 방지를 위해 `2^30` 에서 cap 한다(`2^30 × 30s > 30년` 이라 안전).
### outbox 값/상태 타입 (Status · Event · NewOutboxEvent · RelayResult · Command)
- `OutboxEventStatus` — 행 상태 머신(D5). `PENDING → IN_FLIGHT → {PUBLISHED | FAILED | DEAD}`,
`FAILED → IN_FLIGHT`(재claim), `IN_FLIGHT 고아 → IN_FLIGHT`(I6). `DEAD` 는 FIFO 형제를 막는다 —
같은 aggregate 의 뒤 행 claim 을 차단하므로 runbook 개입이 필요하다.
- `OutboxEvent` — `claimBatch` 가 반환하는 claim-result 읽기 모델. `NewOutboxEvent` 필드 + 현재
`status` + `attemptCount`. relay 가 `attemptCount` 로 다음 실패 시 dead-letter 여부를 결정한다.
- `NewOutboxEvent` — caller 의 write 트랜잭션 안에서 append 할 새 이벤트 값 객체. 모든 필드가
필수이고 null/blank 면 생성 시점에 거부한다(D12). caller 가 `eventId`·`idempotencyKey` 를
공급한다(I12 — outbox core 는 ID 생성에 비의존). 권장 기본값은 `idempotencyKey = eventId`
(per-event dedup, CloudEvents-C2).
- `OutboxRelayResult` — relay 한 사이클의 결과. `claimedCount` + per-event outcome 목록. 내부
`Outcome` enum 이 `OutboxEventStatus` 의 5값을 재사용하지 않고 3값(PUBLISHED/FAILED/DEAD)을
따로 두는 이유: relay 관점은 "이번 실행에서 무슨 일이 있었나" 이지 "행의 현재 영속 상태" 가
아니다(후자는 store 가 5값으로 추적). 별도 enum 이 메트릭·스케줄러 로깅 consumer 에게 relay API
를 깔끔하게 유지해 준다.
- `PublishPendingOutboxEventsCommand` — relay 커맨드 마커. 스케줄러 구동이라 caller 파라미터가
없고, 모든 운영 파라미터는 생성 시점에 주입된다(IdempotencyExecutor 선례). 호출마다 새 인스턴스를
만들 필요가 없게 `INSTANCE` 싱글톤을 제공한다.
---
## 분산 락 (lock)
application 계층은 락 획득/해제 계약만 알고, 실제 구현은 adapter 가 소유한다.
### DistributedLockPort
- 분산 락 획득 outbound 포트.
- **왜 이 포트가 있나(D2)**: 유스케이스는 락 클라이언트·Spring `LockRegistry`·advisory-lock SQL
을 직접 import 하면 안 된다. 이 포트가 application-core 안에서 협조적 상호배제를 위해 허용된
유일한 의존이다. adapter 구현(`JdbcLockRegistry` 멀티 인스턴스, `DefaultLockRegistry` 단일
인스턴스)은 `adapter-persistence` 에 있고 컴파일 타임엔 application 계층에 안 보인다.
- **try-lock + 유한 waitTime + 필수 leaseTtl(D5)**: `tryAcquire` 는 try-lock 이다 — 최대
`waitTime` 만 블로킹하고 held 핸들을 반환하거나 `LockAcquisitionTimeoutException` 을 던진다.
무한 블로킹은 금지(항상 유한 `waitTime` 공급). `leaseTtl` 은 JVM 이 `close()` 전에 죽어도
adapter 가 락을 유지하는 최대 시간으로, 죽은 보유자가 시스템을 무한 데드락 시키는 것을
막는다. `JdbcLockRegistry` 는 registry 기본 TTL 과 호출별 lock TTL 을 강제하고, in-process
`DefaultLockRegistry` 는 advisory(강제 없음)다.
- **트랜잭션 커밋 순서 불변식(D4)**: 반환된 핸들은 보호 작업의 DB 트랜잭션이 **커밋된 후에만**
해제해야 한다. 트랜잭션 안(커밋 전)에서 해제하면 lost-update 경합이 생긴다 — 두 번째 스레드가
락을 얻어, 첫 트랜잭션의 쓰기가 DB 에 보이기 전에 자신의 read-modify-write 를 시작한다.
- 올바른 패턴: `tryAcquire` → `try { txPort.inWrite(...) } finally { lock.close() }`(커밋 후 해제).
- 금지된 역순: `inWrite` 콜백 안에서 `lock.close()`(커밋 전 해제 → 다른 스레드가 stale 상태를 본다).
- **효율 락이지 정합성 락이 아니다(D6)**: 이 락은 경합·불필요한 재시도를 줄이는 *효율* 장치다.
정합성(중복·충돌 쓰기 방지)은 여전히 DB 제약(유니크 인덱스·낙관적 동시성)이 강제한다. 이 락
하나만 정합성 가드로 의존하면 안 된다.
- **lease 만료 시 해제(SI-LOCK-C5)**: TTL 기반 provider(`JdbcLockRegistry`)에서 보유자가
`close()` 하기 전에 lease 가 만료되면, 락 행은 이미 다른 인스턴스가 회수했을 수 있다. metered
`distributedLockProvider` 가 이를 로그 + 카운트 이벤트(`lock.lease.expired`)로 표면화하고
`close()` 는 정상 반환한다 — 만료가 caller 의 `finally` 를 터뜨리거나 보호 작업 자신의 예외를
가리지 않게 하기 위함.
- `leaseTtl` 이 provider 설정 TTL 을 초과하면 `IllegalArgumentException`. shipped 와이어링은 provider
설정과 같은 `LockSettings.leaseTtl()` 로 바인딩하므로 런타임엔 안 터지고, mis-wired caller/test 를
잡는 가드다.
### DistributedLock (핸들)
- 획득한 분산 락 핸들(`AutoCloseable`). `close()` 가 해제이고 `finally` 에서 한 번 호출하기에
안전하며, 구현은 idempotent 해야 한다(여러 번 close 해도 throw 금지).
- D4 커밋 순서 불변식은 `DistributedLockPort` 와 동일하다 — 커밋 후에만 `close`.
- `close()` 가 `AutoCloseable` 의 `throws Exception` 을 제거하도록 override 한 이유: 구현이 해제
시 checked exception 을 던지지 못하게 해서, caller 가 checked-exception 의식 없이 `finally` 에
둘 수 있게 하기 위함.
### LockAcquisitionTimeoutException
- `waitTime` 안에 락을 얻지 못하면 `tryAcquire` 가 던진다(D5).
- `OperationalError.LOCK_ACQUISITION_TIMEOUT`(CONFLICT, 409, retryable=true)을 운반한다. 락
경합은 일시적이다 — 현재 보유자가 임계 구역을 떠나거나 lease TTL 이 만료되면 재시도가
획득한다(D6 효율 락).
- web adapter 가 `errorCode()` + 고정 client-safe 메시지로 409 를 만든다. 진단용 `getMessage()`
(key·waitTime 포함)는 서버 로그 전용이고 API 클라이언트에 전달하면 안 된다.
---
## 알림 포트 (notification)
### NotificationPort
- named channel + 논리 route 로 알림을 전달하는 outbound 포트.
- 2-인자 overload 는 관례적 `"default"` route 를 쓰는 편의 메서드다. adapter 가
`app.notification.routes.<channel>.<route>` 에서 구체 provider 목록을 해석하므로, application
계층은 provider 선택과 분리된다(호출부에 provider id 가 없다).
- fan-out(route 당 여러 provider)은 adapter 관심사다. 어떤 provider 가 실패하면 adapter 가
fail-open 정책(관측만, 전파 안 함)을 적용하고, route 자체가 unbound 면
`AdapterDisabledException("notification", ...)` 을 던진다.
- outbound 포트의 `*Port` 접미사 명명 규칙을 따른다(CLAUDE.md).
### Notification (값 객체)
- transport-중립 알림 값으로, routing target 과 내용만 운반한다.
- **절대 logger(또는 dependency logger)에 넘기면 안 된다** — recipient 주소·body 가 로그 라인에
닿을 수 없게 하기 위함(PII 계약).
- `adapter-outbound` 에서 `application-core` 로 옮긴 이유: 유스케이스가 adapter 타입을 import 하지
않고도 알림을 만들어 `NotificationPort` 로 전달할 수 있게 하기 위함(clean-architecture
HARD-STOP #3).
### Channel
- 알림 채널 판별자(`EMAIL`/`SLACK`). provider 와 독립적으로 전달 매체를 식별한다.
- caller 가 `Channel` 값(compile-safe) + 논리 route 이름을 `NotificationPort` 에 넘기면 adapter 가
`app.notification.routes.<channel>.<route>` 로 provider 목록을 해석한다 — 도메인은 provider
세부를 들여다보지 않는다(HARD-STOP #4).
---
## 로그 가명화 포트 (observability)
### UserPrincipalPseudonymizerPort
- raw 보안 principal id 를, 값이 로그/MDC 에 쓰이기 전에 안정적 가명 토큰으로 바꾸는 outbound
포트.
- **null/blank 의미**: `rawPrincipal` 이 null/blank 면 null 을 반환한다. 가명화할 게 없으니, 이
경우 caller 는 `user_principal` MDC 키에 아무 값도 넣으면 안 된다.
- **안정 토큰 계약**: non-blank 입력에 대해 (1) 같은 입력 + 같은 salt 는 같은 salt epoch 안에서
항상 같은 출력을 낸다(안정), (2) raw principal 의 trivial 복원이 불가능하다(일방향 derivation
이지 인코딩·가역 변환이 아니다), (3) `user_principal` MDC 와 로그 라인에 안전하다.
- **알고리즘 SSOT**: 구체 알고리즘(90일 회전 salt 로 keyed 한 HMAC-SHA-256)은
이 영역의 책임다. 이 포트는
"보안 principal 을 가명 형태로 기록한다" 는 계약만 소유한다. 구현은 `adapter-identifier` 에
있고 `app-bootstrap` 이 와이어링한다.
- **계층**: 순수 Java 인터페이스다. 구현은 application-core/domain-core 에서 참조하면 안 된다.
consumer(`adapter-web` 의 `RequestLoggingFilter`)가 이 포트를 주입받아
`AuthenticatedPrincipal.idpUserId()` 를 가명화한 뒤 `MDC.put("user_principal", ...)` 한다.
+15
View File
@@ -0,0 +1,15 @@
// Application use case contract.
//
// Depends only on the domain and operational contracts. spring-boot-starter is kept on
// the compile classpath so application use cases can opt into @Service registration
// without depending on transport / persistence frameworks.
//
// spring-tx is intentionally NOT declared: application code MUST NOT import
// `org.springframework.transaction.annotation.Transactional`. Use the
// `TransactionPort` abstraction. The CleanArchitectureTest ArchUnit suite enforces
// this for any module that resides under `..application..`.
dependencies {
implementation project(':domain-core')
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter'
}
+151
View File
@@ -0,0 +1,151 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,17 @@
package dev.caskeleton.application.capability;
/**
* Idempotency contract a write use case declares — the basis downstream contracts (rate-limit,
* idempotency-key, retry) build on. See README.
*/
public enum Idempotency {
/** Safe to retry with the same input — same effect, same result. */
IDEMPOTENT,
/** Safe to retry only after applying an idempotency key (e.g. de-dup table). */
KEYED,
/** Repeating the operation produces a new effect (create / append / charge). */
NOT_IDEMPOTENT
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.capability;
/**
* Declares the repository access a use case is allowed to perform. Paired with {@link
* dev.caskeleton.application.transaction.TransactionMode} so an undeclared read→write upgrade is
* caught by review and fitness functions.
*/
public enum RepositoryAccess {
/** Use case does not touch any repository port. */
NONE,
/** Use case may only call read methods of repository ports. */
READ_REPOSITORY,
/** Use case may call read and write methods of repository ports. */
WRITE_REPOSITORY
}
@@ -0,0 +1,35 @@
package dev.caskeleton.application.capability;
import dev.caskeleton.application.transaction.TransactionMode;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Mandatory capability contract attached to every concrete {@code *UseCase} class: it makes the
* transactional shape, idempotency, repository access, and outbound-side-effect surface readable
* without inspecting the body. ArchUnit enforces its presence and the coherence rules. See README
* for the coherence rules and the {@code capabilities.yaml} registry mapping.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface UseCaseCapability {
TransactionMode transactionMode();
Idempotency idempotency();
RepositoryAccess repositoryAccess();
boolean externalOutboundAllowed() default false;
/** Declares the use case reads sensitive fields (PII / credentials / secrets). See README. */
boolean sensitiveRead() default false;
/** Declares a bulk write (>100 rows/batch); requires {@code WRITE_REPOSITORY}. See README. */
boolean bulkWrite() default false;
/** Declares the use case crosses tenant boundaries (an admin operation). See README. */
boolean crossTenantAdmin() default false;
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.command;
/**
* Marker for application command contracts (write intents). A {@code Command} is a plain immutable
* type (preferably a {@code record}) of domain/primitive values. See README for the forbidden field
* types.
*/
public interface Command {}
@@ -0,0 +1,30 @@
package dev.caskeleton.application.fileexport;
/**
* Immutable receipt for a file written through {@link FileExportPort}. Framework-neutral value
* object (no Spring / IO types) so the application layer stays decoupled from the export backend.
*
* @param fileName the name the file was written under (never null/blank)
* @param path the absolute path the file was written to (never null/blank)
* @param byteSize the written file size in bytes (never negative)
* @param rowCount the number of data rows written, excluding the header (never negative)
*/
public record ExportedFile(String fileName, String path, long byteSize, long rowCount) {
public ExportedFile {
if (fileName == null || fileName.isBlank()) {
throw new IllegalArgumentException("ExportedFile.fileName must be non-null and non-blank");
}
if (path == null || path.isBlank()) {
throw new IllegalArgumentException("ExportedFile.path must be non-null and non-blank");
}
if (byteSize < 0) {
throw new IllegalArgumentException(
"ExportedFile.byteSize must be non-negative, was " + byteSize);
}
if (rowCount < 0) {
throw new IllegalArgumentException(
"ExportedFile.rowCount must be non-negative, was " + rowCount);
}
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.application.fileexport;
import java.util.List;
/**
* Outbound port for exporting tabular data as a delimited file — the "file server" boundary (a
* stand-in for an NFS mount, shared file server, or SFTP drop). The application layer hands over
* plain strings, so use cases stay decoupled from the export format and the destination filesystem.
* The adapter is selected by configuration ({@code ca-skeleton.fileserver}); see the {@code
* adapter:outbound:fileserver} README for the on-disk layout and the CSV-escaping contract.
*
* <p>The contract is deliberately domain-neutral: no framework or domain type crosses it. A caller
* supplies a bare {@code fileName}, an optional {@code header} row, and the data {@code rows} as
* lists of already-stringified field values; the adapter owns file placement, RFC-4180 escaping,
* and byte encoding, and returns an {@link ExportedFile} receipt.
*/
public interface FileExportPort {
/**
* Writes {@code header} + {@code rows} as a CSV file named {@code fileName} under the adapter's
* configured base directory, overwriting any existing file at that name.
*
* <p>Every field is escaped per RFC-4180: a field containing a comma, double-quote, carriage
* return, or line feed is wrapped in double-quotes with embedded quotes doubled. A {@code null}
* field is written as an empty field. The file is UTF-8 encoded.
*
* @param fileName the target file name (not a path); must be non-null and non-blank and must not
* escape the base directory (no path separators that resolve outside it)
* @param header the column names written as the first line; must be non-null (may be empty, in
* which case no header line is written)
* @param rows the data rows, each a list of field values in column order; must be non-null (may
* be empty); individual field values may be {@code null}
* @return an {@link ExportedFile} receipt (file name, absolute path, byte size, row count)
* @throws IllegalArgumentException if {@code fileName} is blank or escapes the base directory
*/
ExportedFile exportCsv(String fileName, List<String> header, List<List<String>> rows);
}
@@ -0,0 +1,33 @@
package dev.caskeleton.application.idempotency;
import java.time.Duration;
import java.util.Objects;
/**
* The per-request inputs to {@link IdempotencyExecutor#execute}: the request {@link
* IdempotencyScope identity}, its body {@link RequestFingerprint}, and an optional per-use-case TTL
* override (≤ 72h, enforced by the executor — see {@link #withTtl}).
*
* @param scope request identity (triple or tenant 4-tuple)
* @param fingerprint SHA-256 of the request body
* @param ttlOverride per-use-case TTL, or {@code null} to use the configured default
*/
public record IdempotencyContext(
IdempotencyScope scope, RequestFingerprint fingerprint, Duration ttlOverride) {
public IdempotencyContext {
Objects.requireNonNull(scope, "scope");
Objects.requireNonNull(fingerprint, "fingerprint");
}
/** Context using the executor's configured default TTL. */
public static IdempotencyContext of(IdempotencyScope scope, RequestFingerprint fingerprint) {
return new IdempotencyContext(scope, fingerprint, null);
}
/** Context with a per-use-case TTL override (must be ≤ 72h, enforced by the executor). */
public static IdempotencyContext withTtl(
IdempotencyScope scope, RequestFingerprint fingerprint, Duration ttl) {
return new IdempotencyContext(scope, fingerprint, Objects.requireNonNull(ttl, "ttl"));
}
}
@@ -0,0 +1,123 @@
package dev.caskeleton.application.idempotency;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Supplier;
/**
* Orchestrates idempotent execution at the application use case boundary. For a single {@link
* IdempotencyScope} it runs, in order: claim (atomic {@link IdempotencyStorePort#tryBegin
* insert-or-read}, winner runs the action once) → fingerprint mismatch ({@link
* IdempotencyRequestMismatchException}, 422) → replay of a {@link IdempotencyStatus#COMPLETED}
* record → in-flight wait up to {@link #IN_FLIGHT_WAIT} then {@link IdempotencyInFlightException}
* (409). A throwing action is {@link IdempotencyStorePort#discard discarded} so the request can be
* retried. See README for the policy details and the rationale behind the 200ms wait window.
*/
public final class IdempotencyExecutor {
/** In-flight wait window before a concurrent duplicate gets 409. */
public static final Duration IN_FLIGHT_WAIT = Duration.ofMillis(200);
/** Hard cap: a per-use-case TTL override may not exceed 72h. */
public static final Duration MAX_TTL = Duration.ofHours(72);
private static final Duration POLL_INTERVAL = Duration.ofMillis(20);
private final IdempotencyStorePort store;
private final Clock clock;
private final Duration defaultTtl;
private final Sleeper sleeper;
public IdempotencyExecutor(IdempotencyStorePort store, Clock clock, Duration defaultTtl) {
this(store, clock, defaultTtl, Sleeper.realtime());
}
// Visible for testing: a test Sleeper advances a mutable clock so the in-flight
// wait is deterministic without blocking real time.
IdempotencyExecutor(
IdempotencyStorePort store, Clock clock, Duration defaultTtl, Sleeper sleeper) {
this.store = Objects.requireNonNull(store, "store");
this.clock = Objects.requireNonNull(clock, "clock");
this.defaultTtl = requireSaneTtl(defaultTtl);
this.sleeper = Objects.requireNonNull(sleeper, "sleeper");
}
/**
* Execute {@code action} idempotently under {@code context}, replaying a prior response when the
* scope has already been seen.
*
* @throws IdempotencyRequestMismatchException same key, different body (422)
* @throws IdempotencyInFlightException a duplicate is still in flight (409)
*/
public <R> R execute(
IdempotencyContext context, Supplier<R> action, IdempotentResponseCodec<R> codec) {
Objects.requireNonNull(context, "context");
Objects.requireNonNull(action, "action");
Objects.requireNonNull(codec, "codec");
Duration ttl =
requireSaneTtl(context.ttlOverride() != null ? context.ttlOverride() : defaultTtl);
IdempotencyScope scope = context.scope();
RequestFingerprint fingerprint = context.fingerprint();
Instant deadline = clock.instant().plus(IN_FLIGHT_WAIT);
while (true) {
Instant now = clock.instant();
Optional<IdempotencyRecord> existing = store.find(scope, now);
if (existing.isEmpty()) {
// No live record — try to claim the scope and own the execution.
if (store.tryBegin(scope, fingerprint, now.plus(ttl))) {
return runAndComplete(scope, action, codec);
}
// Lost the claim race (another caller inserted concurrently); loop to
// read their record. Bounded by the in-flight deadline below.
if (!now.isBefore(deadline)) {
throw new IdempotencyInFlightException(scope);
}
sleeper.sleep(POLL_INTERVAL);
continue;
}
IdempotencyRecord record = existing.get();
// Mismatch is terminal regardless of status: same key, different body.
if (!record.fingerprint().equals(fingerprint)) {
throw new IdempotencyRequestMismatchException(scope);
}
if (record.status() == IdempotencyStatus.COMPLETED) {
return codec.deserialize(record.response().payload());
}
// IN_FLIGHT by another caller — wait out the window then surface 409.
if (!now.isBefore(deadline)) {
throw new IdempotencyInFlightException(scope);
}
sleeper.sleep(POLL_INTERVAL);
}
}
private <R> R runAndComplete(
IdempotencyScope scope, Supplier<R> action, IdempotentResponseCodec<R> codec) {
try {
R result = action.get();
store.complete(scope, new StoredResponse(codec.serialize(result)));
return result;
} catch (RuntimeException e) {
store.discard(scope);
throw e;
}
}
private static Duration requireSaneTtl(Duration ttl) {
Objects.requireNonNull(ttl, "ttl");
if (ttl.isZero() || ttl.isNegative()) {
throw new IllegalArgumentException("idempotency TTL must be positive, was " + ttl);
}
if (ttl.compareTo(MAX_TTL) > 0) {
throw new IllegalArgumentException("idempotency TTL exceeds the 72h cap (D6), was " + ttl);
}
return ttl;
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.idempotency;
/**
* Raised when a concurrent request for the same {@link IdempotencyScope} is still processing after
* the in-flight wait window elapses. Framework-free, application-owned; the web adapter maps it to
* a {@code IDEMPOTENT_IN_FLIGHT} 409 (retryable=false — the client polls). The diagnostic carries
* the scope storage key for logs only. See README.
*/
public class IdempotencyInFlightException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient IdempotencyScope scope;
public IdempotencyInFlightException(IdempotencyScope scope) {
super("idempotent request still in flight: " + (scope == null ? "<null>" : scope.storageKey()));
this.scope = scope;
}
public IdempotencyScope scope() {
return scope;
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.application.idempotency;
import java.time.Instant;
import java.util.Objects;
/**
* A persisted idempotency record as seen by the application layer; the persistence adapter maps it
* to and from table rows.
*
* @param scope the request identity (triple or tenant 4-tuple)
* @param fingerprint SHA-256 of the original request body
* @param status lifecycle state
* @param response the stored response — present only when {@code status == COMPLETED}
* @param createdAt when the record was first claimed
* @param expiresAt TTL boundary; a record at/after this instant is treated as absent
*/
public record IdempotencyRecord(
IdempotencyScope scope,
RequestFingerprint fingerprint,
IdempotencyStatus status,
StoredResponse response,
Instant createdAt,
Instant expiresAt) {
public IdempotencyRecord {
Objects.requireNonNull(scope, "scope");
Objects.requireNonNull(fingerprint, "fingerprint");
Objects.requireNonNull(status, "status");
Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(expiresAt, "expiresAt");
if (status == IdempotencyStatus.COMPLETED && response == null) {
throw new IllegalArgumentException("a COMPLETED record must carry a stored response");
}
}
public boolean isExpiredAt(Instant now) {
return !now.isBefore(expiresAt);
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.idempotency;
/**
* Raised when an {@code Idempotency-Key} is reused with a different request body (same {@link
* IdempotencyScope}, different {@link RequestFingerprint}). Framework-free, application-owned; the
* web adapter maps it to a {@code IDEMPOTENT_REQUEST_MISMATCH} 422 (retryable=false). The
* diagnostic carries the scope storage key for logs only. See README.
*/
public class IdempotencyRequestMismatchException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient IdempotencyScope scope;
public IdempotencyRequestMismatchException(IdempotencyScope scope) {
super(
"idempotency key reused with a different request body: "
+ (scope == null ? "<null>" : scope.storageKey()));
this.scope = scope;
}
public IdempotencyScope scope() {
return scope;
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.application.idempotency;
/**
* Identity of an idempotent request: the triple {@code (principal, idempotencyKey, useCaseName)},
* or a 4-tuple with a leading {@code tenant} when tenant isolation is active ({@code null}
* otherwise). The {@link #of} factory rejects blank required dimensions with {@link
* IdempotencyScopeMissingException}. See README for the global-collision failure mode.
*
* @param tenant tenant id, or {@code null} in single-tenant mode
* @param principal authenticated principal (pseudonymized)
* @param idempotencyKey the client-supplied {@code Idempotency-Key} value
* @param useCaseName the {@code application-core} use case identifier
*/
public record IdempotencyScope(
String tenant, String principal, String idempotencyKey, String useCaseName) {
/** Single-tenant triple scope. */
public static IdempotencyScope of(String principal, String idempotencyKey, String useCaseName) {
return of(null, principal, idempotencyKey, useCaseName);
}
/**
* Tenant-aware scope. {@code tenant} may be {@code null} (single-tenant); the other three
* dimensions are mandatory and rejected when blank.
*/
public static IdempotencyScope of(
String tenant, String principal, String idempotencyKey, String useCaseName) {
requirePresent("principal", principal);
requirePresent("idempotencyKey", idempotencyKey);
requirePresent("useCaseName", useCaseName);
String normalizedTenant = (tenant == null || tenant.isBlank()) ? null : tenant;
return new IdempotencyScope(normalizedTenant, principal, idempotencyKey, useCaseName);
}
private static void requirePresent(String dimension, String value) {
if (value == null || value.isBlank()) {
throw new IdempotencyScopeMissingException(dimension);
}
}
/** True when this scope carries a tenant dimension (4-tuple form). */
public boolean isTenantScoped() {
return tenant != null;
}
/**
* Stable storage key for diagnostics / single-column lookups. The persistence adapter enforces
* uniqueness on the dimension columns, not on this string; this is a human-readable join only.
*/
public String storageKey() {
String prefix = tenant == null ? "" : tenant + "::";
return prefix + principal + "::" + idempotencyKey + "::" + useCaseName;
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.idempotency;
/**
* Raised when an idempotency request omits a required scope dimension. Framework-free,
* application-owned; the web adapter maps it to a {@code VALIDATION_FAILED} 400. The diagnostic
* names the missing dimension for logs only. See README.
*/
public class IdempotencyScopeMissingException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final String missingDimension;
public IdempotencyScopeMissingException(String missingDimension) {
super("idempotency scope is incomplete: '" + missingDimension + "' is blank");
this.missingDimension = missingDimension;
}
public String missingDimension() {
return missingDimension;
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.idempotency;
/**
* Lifecycle of an idempotency record.
*
* <ul>
* <li>{@link #IN_FLIGHT} — a caller has claimed the scope and is executing the action; concurrent
* arrivals wait then receive 409.
* <li>{@link #COMPLETED} — the action finished and the response is stored for replay.
* </ul>
*/
public enum IdempotencyStatus {
IN_FLIGHT,
COMPLETED
}
@@ -0,0 +1,43 @@
package dev.caskeleton.application.idempotency;
import java.time.Instant;
import java.util.Optional;
/**
* Outbound port for idempotency record storage, implemented by {@code adapter-persistence} over a
* DB table unique on {@code (tenant, principal, idempotency_key, use_case_name)}. The {@link
* IdempotencyExecutor} owns the wait/replay policy; this port exposes only storage primitives. See
* README (in-memory production impls forbidden; Redis cache-only).
*/
public interface IdempotencyStorePort {
/**
* Atomically claim the scope by inserting an {@link IdempotencyStatus#IN_FLIGHT} record. Returns
* {@code true} when this caller won the claim, {@code false} when a live record already exists
* (the unique constraint is the arbiter — race-safe). An expired record must be treated as
* reclaimable. See README.
*
* @param scope the request identity
* @param fingerprint SHA-256 of the request body
* @param expiresAt TTL boundary for the new record
*/
boolean tryBegin(IdempotencyScope scope, RequestFingerprint fingerprint, Instant expiresAt);
/**
* Read the current live record for a scope. A record at/after its {@code expiresAt} must be
* treated as absent (expired replay is refused).
*/
Optional<IdempotencyRecord> find(IdempotencyScope scope, Instant now);
/**
* Transition the in-flight record for {@code scope} to {@link IdempotencyStatus#COMPLETED},
* persisting the response for replay.
*/
void complete(IdempotencyScope scope, StoredResponse response);
/**
* Remove the in-flight record for {@code scope} so the original request can be retried. Called
* when the claimed action throws (a stuck IN_FLIGHT row would 409 every retry).
*/
void discard(IdempotencyScope scope);
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.idempotency;
/**
* Serializes a use case result to/from the opaque {@link StoredResponse} payload so the {@link
* IdempotencyExecutor} can replay a completed response. The application layer is
* wire-format-neutral — the concrete encoding is owned by the {@code adapter-web} caller (see
* README).
*
* @param <R> the use case result type being made idempotent
*/
public interface IdempotentResponseCodec<R> {
/** Serialize a freshly-produced result for storage. */
String serialize(R result);
/** Reconstruct a result from a stored payload during replay. */
R deserialize(String payload);
}
@@ -0,0 +1,38 @@
package dev.caskeleton.application.idempotency;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
/**
* SHA-256 fingerprint of a request body. Same {@link IdempotencyScope} but a different body
* (different fingerprint) is a client bug, rejected by the executor with {@link
* IdempotencyRequestMismatchException} (422); a null/empty body hashes as a zero-length payload.
* The raw transmitted bytes are hashed — no canonicalization. See README for the SHA-256 choice and
* the canonicalization caveat.
*
* @param hex lowercase hex SHA-256 digest
*/
public record RequestFingerprint(String hex) {
public RequestFingerprint {
Objects.requireNonNull(hex, "hex");
if (hex.length() != 64) {
throw new IllegalArgumentException(
"SHA-256 fingerprint must be 64 hex chars, was " + hex.length());
}
}
/** Compute the SHA-256 fingerprint of the raw request body bytes. */
public static RequestFingerprint ofSha256(byte[] body) {
byte[] payload = body == null ? new byte[0] : body;
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(payload);
return new RequestFingerprint(HexFormat.of().formatHex(digest));
} catch (NoSuchAlgorithmException e) {
// SHA-256 is mandated by every JDK — unreachable.
throw new IllegalStateException("SHA-256 algorithm unavailable", e);
}
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.application.idempotency;
import java.time.Duration;
/**
* Indirection over {@code Thread.sleep} so the {@link IdempotencyExecutor}'s in-flight poll wait is
* deterministically testable (a test {@code Sleeper} can advance a mutable clock instead of
* blocking real time).
*/
@FunctionalInterface
public interface Sleeper {
void sleep(Duration duration);
/** Real-time sleeper that restores the interrupt flag and stops waiting on interruption. */
static Sleeper realtime() {
return duration -> {
try {
Thread.sleep(Math.max(0L, duration.toMillis()));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.application.idempotency;
import java.util.Objects;
/**
* Opaque serialized representation of a completed idempotent response, replayed verbatim to
* duplicate callers. Holds only the {@code payload} string from an {@link IdempotentResponseCodec};
* where it is physically stored is a persistence-adapter concern (see README).
*
* @param payload codec-serialized response body
*/
public record StoredResponse(String payload) {
public StoredResponse {
Objects.requireNonNull(payload, "payload");
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.lock;
/**
* Handle to an acquired distributed lock, returned by {@link DistributedLockPort#tryAcquire(String,
* java.time.Duration, java.time.Duration)}. {@code close()} releases the lock, is safe in a {@code
* finally} block, and should be idempotent. It must be called only after the protected transaction
* commits. See README.
*/
public interface DistributedLock extends AutoCloseable {
/**
* Releases this distributed lock. Overrides {@link AutoCloseable#close()} to drop the {@code
* throws Exception} so callers need no checked-exception ceremony in {@code finally}. Call only
* after the protected transaction has committed.
*/
@Override
void close();
}
@@ -0,0 +1,30 @@
package dev.caskeleton.application.lock;
import java.time.Duration;
/**
* Outbound port for acquiring a distributed lock — the only dependency the application layer may
* use for coordinated mutual exclusion (no lock client / SQL imports in use cases). {@code
* tryAcquire} is a try-lock with a finite {@code waitTime} and a crash-safety {@code leaseTtl}; the
* handle must be released only after the protected transaction commits. This is an efficiency lock,
* not a correctness lock (DB constraints still guard correctness). See README for the full
* rationale.
*/
public interface DistributedLockPort {
/**
* Attempts to acquire the distributed lock identified by {@code key}, blocking for at most {@code
* waitTime}. Returns a {@link DistributedLock} whose {@code close()} releases the lock — call it
* only after the protected transaction commits. See README for lease-expiry-on-release behaviour.
*
* @param key the lock identifier; must be non-null and non-blank
* @param waitTime the maximum time to wait for the lock; must be finite and positive
* @param leaseTtl the maximum duration the lock may be held before the adapter auto-expires it
* (crash-safety; must be positive and not exceed the provider's configured TTL)
* @return the acquired lock handle — caller is responsible for releasing it
* @throws LockAcquisitionTimeoutException if the lock could not be acquired within {@code
* waitTime}
* @throws IllegalArgumentException if {@code leaseTtl} exceeds the provider's configured TTL
*/
DistributedLock tryAcquire(String key, Duration waitTime, Duration leaseTtl);
}
@@ -0,0 +1,47 @@
package dev.caskeleton.application.lock;
import dev.caskeleton.shared.error.OperationalError;
import java.time.Duration;
/**
* Thrown by {@link DistributedLockPort#tryAcquire(String, Duration, Duration)} when the lock could
* not be acquired within the bounded {@code waitTime}. Carries {@link
* OperationalError#LOCK_ACQUISITION_TIMEOUT} (CONFLICT, 409, retryable — contention is transient).
* The web adapter maps it to 409 with a client-safe message; {@link #getMessage()} is
* server-log-only. See README.
*/
public final class LockAcquisitionTimeoutException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final String key;
private final transient Duration waitTime;
/**
* @param key the lock key that could not be acquired
* @param waitTime the time that elapsed before the attempt was abandoned
*/
public LockAcquisitionTimeoutException(String key, Duration waitTime) {
super("failed to acquire distributed lock '" + key + "' within " + waitTime);
this.key = key;
this.waitTime = waitTime;
}
/** The lock key that could not be acquired within the allotted wait time. */
public String key() {
return key;
}
/** The wait duration that elapsed before the acquisition attempt was abandoned. */
public Duration waitTime() {
return waitTime;
}
/**
* Returns {@link OperationalError#LOCK_ACQUISITION_TIMEOUT}, the stable client-facing error code
* for a distributed-lock timeout.
*/
public OperationalError errorCode() {
return OperationalError.LOCK_ACQUISITION_TIMEOUT;
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.notification;
/**
* Notification channel discriminator — identifies the delivery medium (e.g. email, Slack)
* independently of the provider. The adapter resolves the provider list via {@code
* app.notification.routes.<channel>.<route>}. See README.
*/
public enum Channel {
/** Electronic mail channel. */
EMAIL,
/** Slack messaging channel. */
SLACK
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.notification;
import java.util.Objects;
/**
* Transport-neutral notification value delivered via {@link NotificationPort}, carrying routing
* target and content only. It MUST NEVER be passed to any logger — recipient and body are PII.
* Lives in {@code application-core} (not the adapter) so use cases need no adapter imports. See
* README.
*
* @param recipient channel / address (e.g. Slack channel id or email address — PII)
* @param subject short subject / title
* @param body message body (may contain PII)
*/
public record Notification(String recipient, String subject, String body) {
public Notification {
Objects.requireNonNull(recipient, "recipient");
Objects.requireNonNull(subject, "subject");
Objects.requireNonNull(body, "body");
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.application.notification;
/**
* Outbound port for delivering notifications via a named channel and logical route. The adapter
* resolves the provider list from {@code app.notification.routes.<channel>.<route>}, so the
* application layer is decoupled from provider selection. See README for fan-out / fail-open
* behaviour.
*/
public interface NotificationPort {
/**
* Delivers a notification on {@code channel} via the {@code "default"} route.
*
* @param channel delivery channel (compile-safe)
* @param notification content to deliver (contains PII — never log this value)
*/
default void notify(Channel channel, Notification notification) {
notify(channel, "default", notification);
}
/**
* Delivers a notification on {@code channel} via a named logical {@code route}.
*
* @param channel delivery channel (compile-safe)
* @param route logical route name bound in {@code app.notification.routes}
* @param notification content to deliver (contains PII — never log this value)
*/
void notify(Channel channel, String route, Notification notification);
}
@@ -0,0 +1,21 @@
package dev.caskeleton.application.observability;
/**
* Outbound port that converts a raw security principal id into a stable pseudonymous token before
* it is written to logs or MDC. Returns {@code null} for a null/blank input. The token is stable
* and one-way (no trivial recovery). The concrete algorithm lives in {@code
* adapter:outbound:identifier} ({@code dev.caskeleton.adapter.outbound.identifier});
* implementations must not be referenced from application-core/domain-core. See README.
*/
public interface UserPrincipalPseudonymizerPort {
/**
* Returns a stable pseudonymous token derived from {@code rawPrincipal}, or {@code null} when it
* is {@code null} or blank.
*
* @param rawPrincipal the raw security principal id (may be {@code null}/blank for
* unauthenticated requests)
* @return a stable pseudonymous token for logging/MDC, or {@code null} for null/blank input
*/
String pseudonymize(String rawPrincipal);
}
@@ -0,0 +1,45 @@
package dev.caskeleton.application.outbox;
import java.time.Instant;
import java.util.Objects;
/**
* Value object for a new outbox event, appended within the caller's write transaction. All fields
* are required (null/blank is rejected at construction). Callers supply {@code eventId} and {@code
* idempotencyKey} (the outbox core is ID-generation-agnostic); the recommended default is {@code
* idempotencyKey = eventId}. See README.
*
* @param eventId unique identifier for this event (e.g. a UUIDv7 string)
* @param eventType logical event type name (e.g. {@code "UserCreated"})
* @param aggregateId the aggregate that emitted this event; used as the message routing key and
* FIFO-gate anchor
* @param payload serialised event payload (pre-serialised JSON string)
* @param occurredAt wall-clock time at which the domain event occurred
* @param correlationId trace / correlation identifier for log correlation
* @param idempotencyKey consumer-side deduplication key (D12 / I12)
*/
public record NewOutboxEvent(
String eventId,
String eventType,
String aggregateId,
String payload,
Instant occurredAt,
String correlationId,
String idempotencyKey) {
public NewOutboxEvent {
requireNonBlank(eventId, "eventId");
requireNonBlank(eventType, "eventType");
requireNonBlank(aggregateId, "aggregateId");
requireNonBlank(payload, "payload");
Objects.requireNonNull(occurredAt, "occurredAt must not be null");
requireNonBlank(correlationId, "correlationId");
requireNonBlank(idempotencyKey, "idempotencyKey");
}
private static void requireNonBlank(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be null or blank");
}
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.outbox;
/**
* Outbound port for appending a new event to the transactional outbox. <strong>Dual-write
* prohibition</strong>: must be called inside the same DB transaction as the business operation
* that generates the event ({@code TransactionPort.inWrite(...)}, opened by the caller);
* implementations must not open their own transaction. See README.
*/
public interface OutboxAppendPort {
/**
* Appends {@code event} to the outbox table, participating in the caller's existing write
* transaction. Calling outside {@code TransactionPort.inWrite(...)} is a contract violation
* (silent event loss).
*
* @param event the new event to persist; must not be {@code null}
*/
void append(NewOutboxEvent event);
}
@@ -0,0 +1,71 @@
package dev.caskeleton.application.outbox;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.random.RandomGenerator;
/**
* Exponential-backoff-with-full-jitter policy for outbox relay retry scheduling: {@code delay =
* base × 2^(attemptCount-1) + jitter(0..base)} with {@code base = 30s}, {@code maxAttempts = 3};
* {@code attemptCount} is the 1-based just-failed attempt. Full jitter uses the injected {@link
* RandomGenerator} (deterministic in tests with a fixed-seed generator). The constants are fixed,
* not configurable — see README for why.
*/
public final class OutboxBackoffPolicy {
/** Base retry delay (registry SSOT: retry_after_seconds=30). */
public static final Duration BASE_DELAY = Duration.ofSeconds(30);
/** Maximum delivery attempts before a row is DEAD-lettered. */
public static final int MAX_ATTEMPTS = 3;
private final RandomGenerator random;
/**
* Creates a policy using the supplied {@link RandomGenerator} for jitter (pass a fixed-value
* generator for deterministic tests).
*
* @param random source of randomness for full-jitter computation; must not be null
*/
public OutboxBackoffPolicy(RandomGenerator random) {
this.random = Objects.requireNonNull(random, "random must not be null");
}
/**
* Returns the maximum number of delivery attempts before an event is dead-lettered.
*
* @return {@value #MAX_ATTEMPTS}
*/
public int maxAttempts() {
return MAX_ATTEMPTS;
}
/**
* Computes the next attempt instant for a failed event.
*
* @param attemptCount the 1-based attempt number that just failed (e.g. {@code 1} for the first
* failure)
* @param now the current wall-clock instant
* @return the earliest instant at which the event may be re-claimed
* @throws IllegalArgumentException if {@code attemptCount < 1}
*/
public Instant nextAttemptAt(int attemptCount, Instant now) {
if (attemptCount < 1) {
throw new IllegalArgumentException(
"attemptCount must be >= 1 (1-based), was " + attemptCount);
}
Objects.requireNonNull(now, "now must not be null");
// Exponential base: base * 2^(attemptCount-1)
// Capped to avoid overflow for very large attemptCount values.
long exponent = Math.min(attemptCount - 1, 30); // 2^30 * 30s > 30 years — safe cap
long baseSeconds = BASE_DELAY.toSeconds() * (1L << exponent);
// Full jitter: uniform in [0, base]
double jitterSeconds = random.nextDouble() * BASE_DELAY.toSeconds();
long totalSeconds = baseSeconds + (long) jitterSeconds;
return now.plusSeconds(totalSeconds);
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.application.outbox;
import java.time.Instant;
import java.util.Objects;
/**
* Immutable view of an outbox event row returned by {@link OutboxStorePort#claimBatch}.
*
* <p>This is a claim-result read model: it carries the full {@link NewOutboxEvent} fields plus the
* current {@link OutboxEventStatus} and the number of delivery attempts already made. The relay
* uses {@code attemptCount} to decide whether to DEAD-letter the event on the next failure ({@code
* attemptCount >= maxAttempts}).
*
* @param eventId unique identifier for this event
* @param eventType logical event type name
* @param aggregateId the aggregate that emitted this event
* @param payload serialised event payload (pre-serialised JSON string)
* @param occurredAt wall-clock time at which the domain event occurred
* @param correlationId trace / correlation identifier
* @param idempotencyKey consumer-side deduplication key
* @param status current lifecycle status (will be {@link OutboxEventStatus#IN_FLIGHT} immediately
* after a successful claim)
* @param attemptCount number of delivery attempts already made (1-based after the first claim)
*/
public record OutboxEvent(
String eventId,
String eventType,
String aggregateId,
String payload,
Instant occurredAt,
String correlationId,
String idempotencyKey,
OutboxEventStatus status,
int attemptCount) {
public OutboxEvent {
Objects.requireNonNull(eventId, "eventId");
Objects.requireNonNull(eventType, "eventType");
Objects.requireNonNull(aggregateId, "aggregateId");
Objects.requireNonNull(payload, "payload");
Objects.requireNonNull(occurredAt, "occurredAt");
Objects.requireNonNull(correlationId, "correlationId");
Objects.requireNonNull(idempotencyKey, "idempotencyKey");
Objects.requireNonNull(status, "status");
if (attemptCount < 0) {
throw new IllegalArgumentException("attemptCount must be >= 0, was " + attemptCount);
}
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.application.outbox;
/**
* Status state machine for a transactional outbox event row. {@code DEAD} blocks its aggregate's
* FIFO siblings until manually resolved. See README for the full transition diagram.
*/
public enum OutboxEventStatus {
/** Row inserted by the domain operation; not yet picked up by the relay. */
PENDING,
/**
* Relay has claimed this row and is attempting to publish. {@code next_attempt_at} is set to
* {@code claim_time + in_flight_timeout} so an orphaned IN_FLIGHT row is re-claimable after that
* deadline.
*/
IN_FLIGHT,
/**
* Broker acknowledged the publish. Terminal success state. The reaper will purge rows in this
* state after the configured retention period.
*/
PUBLISHED,
/**
* Publish failed transiently; {@code next_attempt_at} carries the backoff deadline. The row will
* be re-claimed once {@code now >= next_attempt_at}.
*/
FAILED,
/**
* All retry attempts exhausted. Terminal failure state. Manual operator intervention is required;
* see {@code OperationalError.OUTBOX_DEAD_LETTER}. While a row is DEAD its aggregate's FIFO queue
* is blocked.
*/
DEAD
}
@@ -0,0 +1,20 @@
package dev.caskeleton.application.outbox;
/**
* Outbound port for publishing a claimed outbox event to the message broker.
* <strong>Fail-closed</strong>: any publish failure MUST be surfaced by throwing a {@link
* RuntimeException} — implementations must not swallow or log-and-return — so the relay can drive
* the FAILED/DEAD transition. Called by the relay <em>outside</em> any write transaction. See
* README for the fail-closed rationale and the call-site sequencing.
*/
public interface OutboxMessagePublishPort {
/**
* Publishes {@code event} to the configured message broker. Must throw on any failure
* (fail-closed); must not catch-and-swallow the broker client's exceptions.
*
* @param event the claimed outbox event to publish; must not be {@code null}
* @throws RuntimeException if the publish fails for any reason
*/
void publish(OutboxEvent event);
}
@@ -0,0 +1,57 @@
package dev.caskeleton.application.outbox;
import java.util.List;
import java.util.Objects;
/**
* Result returned by {@link PublishPendingOutboxEventsUseCase} after one relay cycle: the number of
* events claimed and a per-event {@link Outcome} list. See README for why {@code Outcome} is a
* separate 3-value enum rather than reusing {@link OutboxEventStatus}.
*
* @param claimedCount total number of events claimed from the store in this cycle
* @param outcomes per-event outcomes in the order they were processed
*/
public record OutboxRelayResult(int claimedCount, List<EventOutcome> outcomes) {
public OutboxRelayResult {
if (claimedCount < 0) {
throw new IllegalArgumentException("claimedCount must be >= 0, was " + claimedCount);
}
outcomes = List.copyOf(Objects.requireNonNull(outcomes, "outcomes must not be null"));
}
/**
* Outcome of a single event during one relay cycle.
*
* @param eventId the event identifier
* @param eventType the logical event type name
* @param outcome the result of the publish attempt
*/
public record EventOutcome(String eventId, String eventType, Outcome outcome) {
public EventOutcome {
Objects.requireNonNull(eventId, "eventId must not be null");
Objects.requireNonNull(eventType, "eventType must not be null");
Objects.requireNonNull(outcome, "outcome must not be null");
}
}
/**
* Per-event relay cycle outcome.
*
* <p>Three values mirror the three terminal states reachable in a single relay cycle: broker ack
* ({@link #PUBLISHED}), transient failure below the retry cap ({@link #FAILED}), or retry cap
* exhausted ({@link #DEAD}).
*/
public enum Outcome {
/** Broker acknowledged the publish; row transitions to PUBLISHED. */
PUBLISHED,
/** Publish failed transiently; row transitions to FAILED with a backoff window. */
FAILED,
/** All retry attempts exhausted; row transitions to DEAD. */
DEAD
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.application.outbox;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* Outbound port for relay-side access to the outbox store. All mutating operations ({@link
* #claimBatch}, {@link #markPublished}, {@link #markFailed}, {@link #markDead}) must be called
* inside a {@code TransactionPort.inWrite(...)} boundary owned by the relay; implementations must
* not open their own transaction. See README for the claim/FIFO/in-flight-timeout semantics.
*/
public interface OutboxStorePort {
/**
* Atomically claims up to {@code batchSize} eligible rows and transitions them to {@link
* OutboxEventStatus#IN_FLIGHT}. Must be called inside {@code TransactionPort.inWrite(...)}. See
* README for eligibility and the FIFO gate.
*
* @param batchSize maximum number of events to claim
* @param now current wall-clock time used for eligibility checks
* @param inFlightTimeout duration after which an IN_FLIGHT orphan becomes re-claimable
* @return claimed events ({@code status = IN_FLIGHT}, incremented {@code attemptCount}); empty if
* none
*/
List<OutboxEvent> claimBatch(int batchSize, Instant now, Duration inFlightTimeout);
/**
* Marks the event as successfully published. Must be called inside {@code
* TransactionPort.inWrite(...)} after a successful publish.
*
* @param eventId the identifier of the event to mark published
*/
void markPublished(String eventId);
/**
* Marks the event as failed and schedules the next retry attempt. Must be called inside {@code
* TransactionPort.inWrite(...)} when a publish fails transiently and {@code attemptCount <
* maxAttempts}.
*
* @param eventId the identifier of the event to mark failed
* @param nextAttemptAt the earliest instant at which the event may be re-claimed
*/
void markFailed(String eventId, Instant nextAttemptAt);
/**
* Marks the event as dead-lettered after all retry attempts are exhausted. Must be called inside
* {@code TransactionPort.inWrite(...)}. A DEAD row blocks its aggregate's FIFO queue until
* manually resolved (see README).
*
* @param eventId the identifier of the event to dead-letter
*/
void markDead(String eventId);
/**
* Returns a count of rows grouped by {@link OutboxEventStatus} — gauge source for the {@code
* outbox.pending.size} metric. Called outside a transaction (read-only).
*
* @return map from status to row count; statuses with zero rows may be absent
*/
Map<OutboxEventStatus, Long> countByStatus();
/**
* Returns the age in seconds of the oldest unpublished row, grouped by event type — gauge source
* for the {@code outbox.publisher.lag} metric. Called outside a transaction (read-only).
*
* @param now current wall-clock time used to compute age
* @return map from event type to oldest-unpublished-row age in seconds; absent when none
*/
Map<String, Long> oldestUnpublishedAgeSecondsByEventType(Instant now);
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.outbox;
import dev.caskeleton.application.command.Command;
/**
* Command marker for the outbox relay use case. The relay is scheduler-driven and carries no caller
* parameters (operational params are injected at construction); the {@link #INSTANCE} singleton
* conveys the intent. See README.
*/
public record PublishPendingOutboxEventsCommand() implements Command {
/** Canonical no-parameter instance — the relay command carries no parameters. */
public static final PublishPendingOutboxEventsCommand INSTANCE =
new PublishPendingOutboxEventsCommand();
}
@@ -0,0 +1,169 @@
package dev.caskeleton.application.outbox;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import dev.caskeleton.application.usecase.CommandUseCase;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Relay use case that claims pending outbox events and publishes them to the broker: claim a batch
* in a short write transaction, sort by {@code occurredAt}, then publish each event
* <em>outside</em> any transaction and drive the PUBLISHED / FAILED / DEAD state machine per
* result. Publish failures are logged and never rethrown; a status-update failure after a
* successful publish propagates and the row is recovered via the in-flight timeout. Wired manually
* by {@code app-bootstrap} (not a Spring bean). See README for the full algorithm, failure
* semantics, manual-wiring rationale, and the {@code "outbox:relay"} permission.
*/
@RequiresPermission("outbox:relay")
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
externalOutboundAllowed = true)
public final class PublishPendingOutboxEventsUseCase
implements CommandUseCase<PublishPendingOutboxEventsCommand, OutboxRelayResult> {
private static final Logger log =
LoggerFactory.getLogger(PublishPendingOutboxEventsUseCase.class);
private final OutboxStorePort store;
private final OutboxMessagePublishPort publishPort;
private final TransactionPort tx;
private final OutboxBackoffPolicy backoffPolicy;
private final Clock clock;
private final int batchSize;
private final Duration inFlightTimeout;
/**
* Constructs the relay use case with all required collaborators (wired manually by {@code
* app-bootstrap}; see README).
*
* @param store outbox store port (claim + status update)
* @param publishPort fail-closed broker publish port
* @param tx transaction port for short write boundaries
* @param backoffPolicy retry backoff policy
* @param clock wall-clock source (injected for testability)
* @param batchSize maximum events to claim per relay cycle
* @param inFlightTimeout how long a claimed row stays IN_FLIGHT before re-claim
*/
public PublishPendingOutboxEventsUseCase(
OutboxStorePort store,
OutboxMessagePublishPort publishPort,
TransactionPort tx,
OutboxBackoffPolicy backoffPolicy,
Clock clock,
int batchSize,
Duration inFlightTimeout) {
this.store = Objects.requireNonNull(store, "store must not be null");
this.publishPort = Objects.requireNonNull(publishPort, "publishPort must not be null");
this.tx = Objects.requireNonNull(tx, "tx must not be null");
this.backoffPolicy = Objects.requireNonNull(backoffPolicy, "backoffPolicy must not be null");
this.clock = Objects.requireNonNull(clock, "clock must not be null");
if (batchSize <= 0) {
throw new IllegalArgumentException("batchSize must be > 0, was " + batchSize);
}
this.batchSize = batchSize;
this.inFlightTimeout =
Objects.requireNonNull(inFlightTimeout, "inFlightTimeout must not be null");
}
@Override
public OutboxRelayResult handle(PublishPendingOutboxEventsCommand command) {
Objects.requireNonNull(command, "command must not be null");
Instant now = clock.instant();
// Step 1: Claim a batch inside a short write transaction.
List<OutboxEvent> claimed = tx.inWrite(() -> store.claimBatch(batchSize, now, inFlightTimeout));
if (claimed.isEmpty()) {
return new OutboxRelayResult(0, List.of());
}
// Step 2: Defensive sort by occurredAt ascending (relay enforces FIFO even if the adapter does
// not).
List<OutboxEvent> sorted = new ArrayList<>(claimed);
sorted.sort(Comparator.comparing(OutboxEvent::occurredAt));
// Step 3: Publish each event outside any transaction; drive status machine per result.
List<OutboxRelayResult.EventOutcome> outcomes = new ArrayList<>(sorted.size());
for (OutboxEvent event : sorted) {
OutboxRelayResult.Outcome outcome = publishOne(event, now);
outcomes.add(new OutboxRelayResult.EventOutcome(event.eventId(), event.eventType(), outcome));
}
return new OutboxRelayResult(claimed.size(), outcomes);
}
/**
* Attempts to publish one event and drives the FAILED/DEAD state machine on publish failure. A
* publish failure is caught here and never rethrown; a {@code markPublished} failure after a
* successful publish is NOT caught — it propagates so the row is recovered via the in-flight
* timeout. See README for both failure modes.
*/
private OutboxRelayResult.Outcome publishOne(OutboxEvent event, Instant now) {
try {
publishPort.publish(event);
} catch (RuntimeException publishEx) {
// Publish failure: drive FAILED/DEAD state machine + ERROR log; do NOT rethrow.
return handlePublishFailure(event, now, publishEx);
}
// markPublished failure (if any) propagates: the row stays IN_FLIGHT and is
// recovered via the orphan visibility-timeout reclaim path.
tx.inWrite(() -> store.markPublished(event.eventId()));
return OutboxRelayResult.Outcome.PUBLISHED;
}
/**
* Drives the FAILED/DEAD state transition and produces a mandatory ERROR log — always both a
* status transition and an ERROR log (omitting either is the forbidden silent-swallow). See
* README.
*/
private OutboxRelayResult.Outcome handlePublishFailure(
OutboxEvent event, Instant now, RuntimeException cause) {
if (event.attemptCount() >= backoffPolicy.maxAttempts()) {
// All attempts exhausted — DEAD-letter the event.
tx.inWrite(() -> store.markDead(event.eventId()));
log.error(
"error_code={} eventId={} eventType={} aggregateId={} correlationId={} attemptCount={} "
+ "— outbox event dead-lettered after {} attempts; manual intervention required",
"OUTBOX_DEAD_LETTER",
event.eventId(),
event.eventType(),
event.aggregateId(),
event.correlationId(),
event.attemptCount(),
backoffPolicy.maxAttempts(),
cause);
return OutboxRelayResult.Outcome.DEAD;
} else {
// Transient failure — schedule retry with exponential backoff.
Instant nextAttemptAt = backoffPolicy.nextAttemptAt(event.attemptCount(), now);
tx.inWrite(() -> store.markFailed(event.eventId(), nextAttemptAt));
log.error(
"error_code={} eventId={} eventType={} aggregateId={} correlationId={} attemptCount={} "
+ "nextAttemptAt={} — outbox publish failed transiently; will retry",
"OUTBOX_PUBLISH_FAILED",
event.eventId(),
event.eventType(),
event.aggregateId(),
event.correlationId(),
event.attemptCount(),
nextAttemptAt,
cause);
return OutboxRelayResult.Outcome.FAILED;
}
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.query;
/**
* Marker for application query contracts (read intents). A {@code Query} is a plain immutable type
* (preferably a {@code record}) of domain/primitive values. See README for the forbidden field
* types.
*/
public interface Query {}
@@ -0,0 +1,37 @@
package dev.caskeleton.application.security;
import dev.caskeleton.shared.security.Permission;
/**
* Raised by {@link AuthorizationPort} when an authenticated caller lacks a required {@link
* Permission}. Framework-free and application-owned (the layer cannot throw Spring's {@code
* AccessDeniedException}); the web adapter translates it to a {@code AUTHZ_INSUFFICIENT_PERMISSION}
* 403. Fail-closed: it carries the required permission and subject for diagnostics, never the
* caller's effective permission set. See README.
*/
public class AuthorizationDeniedException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient Permission requiredPermission;
private final String subject;
public AuthorizationDeniedException(String subject, Permission requiredPermission) {
super(
"authorization denied: subject '"
+ subject
+ "' lacks permission '"
+ (requiredPermission == null ? "<null>" : requiredPermission.value())
+ "'");
this.subject = subject;
this.requiredPermission = requiredPermission;
}
public Permission requiredPermission() {
return requiredPermission;
}
public String subject() {
return subject;
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.application.security;
import dev.caskeleton.shared.security.Permission;
/**
* Product-authorization enforcement point (PEP): decides whether an authenticated caller may
* perform a permission-guarded operation. Authentication itself is owned by
* feature-security-operational-baseline; this port consumes the principal's raw roles. Fail-closed
* — it returns only when the caller holds the required permission, else throws {@link
* AuthorizationDeniedException}. See README for why this is a plain port (not
* {@code @PreAuthorize}) and how it maps raw roles to permissions.
*/
public interface AuthorizationPort {
/**
* Requires that {@code principal} holds {@code required}; otherwise denies access.
*
* @param principal the framework-free view of the authenticated caller
* @param required the permission the guarded operation demands
* @throws AuthorizationDeniedException if the caller's effective permissions do not include
* {@code required} (fail-closed)
*/
void requirePermission(AuthorizationPrincipal principal, Permission required);
}
@@ -0,0 +1,18 @@
package dev.caskeleton.application.security;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
/**
* Framework-free view of the authenticated caller consumed by {@link AuthorizationPort}: the IdP
* subject plus the caller's <em>raw</em> role names (e.g. {@code "admin"}, not the {@code ROLE_*}
* authority form). See README for why the application layer maps down to this abstraction and why
* raw role names matter for role→permission resolution.
*/
public record AuthorizationPrincipal(String subject, Set<String> roles) {
public AuthorizationPrincipal {
roles = roles == null ? Set.of() : Collections.unmodifiableSet(new HashSet<>(roles));
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.application.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Declares the {@link dev.caskeleton.shared.security.Permission} an authenticated caller must hold
* to invoke a guarded use case. The value is a {@code resource:action} token (e.g. {@code
* "worklog:close"}), making the requirement visible without reading the body — mirrors the
* {@code @UseCaseCapability} pattern. Pure declaration, Spring-free: the web adapter's {@code
* RequiresPermissionAuthorizationManager} reads it (retention {@code RUNTIME}) and delegates to
* {@link AuthorizationPort}. See README for the AOP-proxy bypass caveat and where it is required.
*/
@Documented
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RequiresPermission {
/** The required permission as a {@code resource:action} token. */
String value();
}
@@ -0,0 +1,57 @@
package dev.caskeleton.application.storage;
import java.util.Optional;
/**
* Outbound port for blob (object) storage — the only dependency the application layer may use to
* persist and retrieve binary content, so use cases stay decoupled from the storage backend (local
* filesystem, S3, or MinIO). The adapter is selected by configuration ({@code
* ca-skeleton.objectstorage.backend}); see the {@code adapter:outbound:objectstorage} README for
* the backend matrix and the key-mapping contract.
*
* <p>Keys are backend-relative, caller-supplied, opaque strings (e.g. {@code
* "posters/2026/cover.png"}). Implementations MUST reject a key that escapes the backend's
* namespace (path traversal) with {@link IllegalArgumentException}. Content is passed and returned
* as raw bytes; this port intentionally exposes no streaming/presigned-URL surface — a fork adds
* those when a concrete feature needs them.
*/
public interface ObjectStoragePort {
/**
* Stores {@code content} under {@code key}, overwriting any existing object at that key.
*
* @param key the backend-relative object key; must be non-null and non-blank
* @param content the raw bytes to store; must be non-null (may be empty)
* @param contentType the MIME type to record for the object; must be non-null and non-blank
* @return a {@link StoredObject} receipt (key, size, content type, backend locator)
* @throws IllegalArgumentException if {@code key} is blank or escapes the backend namespace
*/
StoredObject put(String key, byte[] content, String contentType);
/**
* Reads the object stored under {@code key}.
*
* @param key the backend-relative object key; must be non-null and non-blank
* @return the object bytes, or {@link Optional#empty()} if no object exists at {@code key}
* @throws IllegalArgumentException if {@code key} is blank or escapes the backend namespace
*/
Optional<byte[]> get(String key);
/**
* Deletes the object stored under {@code key}. A no-op when no object exists at {@code key}
* (idempotent).
*
* @param key the backend-relative object key; must be non-null and non-blank
* @throws IllegalArgumentException if {@code key} is blank or escapes the backend namespace
*/
void delete(String key);
/**
* Reports whether an object exists at {@code key}.
*
* @param key the backend-relative object key; must be non-null and non-blank
* @return {@code true} if an object exists at {@code key}, {@code false} otherwise
* @throws IllegalArgumentException if {@code key} is blank or escapes the backend namespace
*/
boolean exists(String key);
}
@@ -0,0 +1,30 @@
package dev.caskeleton.application.storage;
import java.net.URI;
import java.util.Objects;
/**
* Immutable receipt for a blob stored through {@link ObjectStoragePort}. Framework-neutral value
* object (no Spring / AWS types) so the application layer stays decoupled from the storage backend.
*
* @param key the object key the blob was stored under (backend-relative, never null/blank)
* @param size the stored content length in bytes (never negative)
* @param contentType the MIME type the blob was stored with (never null/blank)
* @param location a backend-specific locator — a {@code file://} URI for the filesystem backend, an
* {@code s3://bucket/key} URI for the S3/MinIO backend (never null)
*/
public record StoredObject(String key, long size, String contentType, URI location) {
public StoredObject {
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("StoredObject.key must be non-null and non-blank");
}
if (size < 0) {
throw new IllegalArgumentException("StoredObject.size must be non-negative, was " + size);
}
if (contentType == null || contentType.isBlank()) {
throw new IllegalArgumentException("StoredObject.contentType must be non-null and non-blank");
}
Objects.requireNonNull(location, "StoredObject.location must be non-null");
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.application.transaction;
/**
* Transaction isolation values exposed by {@link TransactionPort}. {@link #READ_COMMITTED} is the
* pinned default, set explicitly on every transaction template. See README for why the vendor
* default is never used, why {@code READ_UNCOMMITTED} is not declared, and the still-planned
* per-use-case routing of the stricter levels.
*/
public enum Isolation {
/** Pinned default for every use case (statement-level snapshot semantics). */
READ_COMMITTED,
/** Explicit opt-in for write-heavy / read-consistency use cases (transaction-level snapshot). */
REPEATABLE_READ,
/** Explicit opt-in for the strongest guarantee. Serialization anomalies fail and are retried. */
SERIALIZABLE
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.transaction;
/**
* Declares the transactional intent of a use case.
*
* <p>Used both at the API surface of {@link TransactionPort} and in the {@link
* dev.caskeleton.application.capability.UseCaseCapability} annotation so a use case's transactional
* contract is visible without reading the body.
*/
public enum TransactionMode {
/** REQUIRED read-write at {@link Isolation#READ_COMMITTED}; default for command use cases. */
WRITE,
/** REQUIRED read-only at {@link Isolation#READ_COMMITTED}; default for query use cases. */
READ_ONLY,
/**
* REQUIRES_NEW read-write; outbox / audit / compensation only. Must be declared on the {@link
* dev.caskeleton.application.capability.UseCaseCapability} annotation.
*/
REQUIRES_NEW
}
@@ -0,0 +1,57 @@
package dev.caskeleton.application.transaction;
import java.util.function.Supplier;
/**
* Outbound port for application-managed transaction boundaries: use cases declare transactional
* intent without importing Spring's {@code @Transactional}. The persistence adapter (typically
* {@code SpringTransactionPort}) implements it over Spring's {@code PlatformTransactionManager}.
*
* <ul>
* <li>{@link #inWrite(Supplier)} — REQUIRED + read-write, {@code READ_COMMITTED}. Command
* default.
* <li>{@link #inRead(Supplier)} — REQUIRED + read-only, {@code READ_COMMITTED}. Query default.
* <li>{@link #inNew(Supplier)} — REQUIRES_NEW; outbox / audit / compensation only.
* </ul>
*
* <p>Callbacks are {@link Supplier} / {@link Runnable} (no checked exceptions); a thrown {@link
* RuntimeException} rolls back and propagates. See README for the checked-exception wrapping rules,
* the {@code inNew} pool-sizing formula, and the forbidden propagation/isolation list.
*/
public interface TransactionPort {
<T> T inWrite(Supplier<T> action);
<T> T inRead(Supplier<T> action);
/**
* Run {@code action} in a NEW physical transaction (PROPAGATION_REQUIRES_NEW), reserved for
* outbox / audit / compensation flows that must commit independently of the caller. See README
* for the pool-sizing cost and the per-record loop anti-pattern.
*/
<T> T inNew(Supplier<T> action);
default void inWrite(Runnable action) {
inWrite(
() -> {
action.run();
return null;
});
}
default void inRead(Runnable action) {
inRead(
() -> {
action.run();
return null;
});
}
default void inNew(Runnable action) {
inNew(
() -> {
action.run();
return null;
});
}
}
@@ -0,0 +1,12 @@
package dev.caskeleton.application.usecase;
import dev.caskeleton.application.command.Command;
/**
* Inbound port for write use cases. Implementations must carry {@link
* dev.caskeleton.application.capability.UseCaseCapability}.
*
* @param <C> the command contract describing the write intent
* @param <R> the result returned to the caller
*/
public interface CommandUseCase<C extends Command, R> extends UseCase<C, R> {}
@@ -0,0 +1,12 @@
package dev.caskeleton.application.usecase;
import dev.caskeleton.application.query.Query;
/**
* Inbound port for read-only use cases. Implementations declare {@code READ_ONLY} + {@code
* READ_REPOSITORY} on {@link dev.caskeleton.application.capability.UseCaseCapability} (see README).
*
* @param <Q> the query contract describing the read intent
* @param <R> the projection or domain object returned to the caller
*/
public interface QueryUseCase<Q extends Query, R> extends UseCase<Q, R> {}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.usecase;
/**
* Marker interface for inbound application use cases (primary ports in hexagonal terms). Concrete
* ports extend {@link CommandUseCase} or {@link QueryUseCase}. See README for the input/output type
* rules and the {@code UseCase} naming contract.
*
* @param <I> the input contract (a {@code Command} or {@code Query})
* @param <O> the output contract (a domain object, domain projection, or {@code Void})
*/
public interface UseCase<I, O> {
O handle(I input);
}
@@ -0,0 +1,94 @@
package dev.caskeleton.application.capability;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.usecase.CommandUseCase;
import org.junit.jupiter.api.Test;
class UseCaseCapabilityTest {
@Test
void exposesDeclaredTransactionModeIdempotencyAndRepositoryAccess() {
UseCaseCapability capability = ExampleWriteUseCase.class.getAnnotation(UseCaseCapability.class);
assertThat(capability).isNotNull();
assertThat(capability.transactionMode()).isEqualTo(TransactionMode.WRITE);
assertThat(capability.idempotency()).isEqualTo(Idempotency.KEYED);
assertThat(capability.repositoryAccess()).isEqualTo(RepositoryAccess.WRITE_REPOSITORY);
}
@Test
void externalOutboundDefaultsToFalseWhenUnspecified() {
UseCaseCapability capability = ExampleWriteUseCase.class.getAnnotation(UseCaseCapability.class);
assertThat(capability.externalOutboundAllowed()).isFalse();
}
@Test
void externalOutboundIsReadableWhenExplicitlyEnabled() {
UseCaseCapability capability =
ExampleOutboundUseCase.class.getAnnotation(UseCaseCapability.class);
assertThat(capability.externalOutboundAllowed()).isTrue();
}
@Test
void sensitiveBulkAndCrossTenantFlagsDefaultToFalseWhenUnspecified() {
UseCaseCapability capability = ExampleWriteUseCase.class.getAnnotation(UseCaseCapability.class);
assertThat(capability.sensitiveRead()).isFalse();
assertThat(capability.bulkWrite()).isFalse();
assertThat(capability.crossTenantAdmin()).isFalse();
}
@Test
void sensitiveBulkAndCrossTenantFlagsAreReadableWhenExplicitlyEnabled() {
UseCaseCapability capability =
ExampleAdminBulkUseCase.class.getAnnotation(UseCaseCapability.class);
assertThat(capability.sensitiveRead()).isTrue();
assertThat(capability.bulkWrite()).isTrue();
assertThat(capability.crossTenantAdmin()).isTrue();
}
record ExampleCommand() implements Command {}
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
static final class ExampleWriteUseCase implements CommandUseCase<ExampleCommand, Void> {
@Override
public Void handle(ExampleCommand input) {
return null;
}
}
@UseCaseCapability(
transactionMode = TransactionMode.REQUIRES_NEW,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
externalOutboundAllowed = true)
static final class ExampleOutboundUseCase implements CommandUseCase<ExampleCommand, Void> {
@Override
public Void handle(ExampleCommand input) {
return null;
}
}
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY,
sensitiveRead = true,
bulkWrite = true,
crossTenantAdmin = true)
static final class ExampleAdminBulkUseCase implements CommandUseCase<ExampleCommand, Void> {
@Override
public Void handle(ExampleCommand input) {
return null;
}
}
}
@@ -0,0 +1,291 @@
package dev.caskeleton.application.idempotency;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class IdempotencyExecutorTest {
private static final Instant T0 = Instant.parse("2026-06-09T00:00:00Z");
private static final Duration DEFAULT_TTL = Duration.ofHours(24);
private static final IdempotencyScope SCOPE =
IdempotencyScope.of("user-42", "key-abc", "CreateWorkLogUseCase");
private static final RequestFingerprint FP_A = RequestFingerprint.ofSha256("body-A".getBytes());
private static final RequestFingerprint FP_B = RequestFingerprint.ofSha256("body-B".getBytes());
private static final IdempotentResponseCodec<String> STRING_CODEC =
new IdempotentResponseCodec<>() {
@Override
public String serialize(String result) {
return result;
}
@Override
public String deserialize(String payload) {
return payload;
}
};
@Test
void firstCallerRunsActionOnceAndStoresResponse() {
FakeStore store = new FakeStore();
MutableClock clock = new MutableClock(T0);
AtomicInteger runs = new AtomicInteger();
IdempotencyExecutor executor =
new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper());
String result =
executor.execute(
IdempotencyContext.of(SCOPE, FP_A),
() -> {
runs.incrementAndGet();
return "created";
},
STRING_CODEC);
assertThat(result).isEqualTo("created");
assertThat(runs.get()).isEqualTo(1);
assertThat(store.find(SCOPE, T0))
.hasValueSatisfying(r -> assertThat(r.status()).isEqualTo(IdempotencyStatus.COMPLETED));
}
@Test
void duplicateWithSameBodyReplaysWithoutRerunningAction() {
FakeStore store = new FakeStore();
MutableClock clock = new MutableClock(T0);
AtomicInteger runs = new AtomicInteger();
IdempotencyExecutor executor =
new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper());
executor.execute(
IdempotencyContext.of(SCOPE, FP_A),
() -> {
runs.incrementAndGet();
return "created";
},
STRING_CODEC);
String replay =
executor.execute(
IdempotencyContext.of(SCOPE, FP_A),
() -> {
runs.incrementAndGet();
return "SHOULD-NOT-RUN";
},
STRING_CODEC);
assertThat(replay).isEqualTo("created");
assertThat(runs.get()).as("action runs exactly once across the duplicate").isEqualTo(1);
}
@Test
void sameKeyDifferentBodyIsRejectedAsMismatch() {
FakeStore store = new FakeStore();
MutableClock clock = new MutableClock(T0);
IdempotencyExecutor executor =
new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper());
executor.execute(IdempotencyContext.of(SCOPE, FP_A), () -> "created", STRING_CODEC);
assertThatThrownBy(
() -> executor.execute(IdempotencyContext.of(SCOPE, FP_B), () -> "other", STRING_CODEC))
.isInstanceOf(IdempotencyRequestMismatchException.class);
}
@Test
void concurrentInFlightArrivalWaitsThenSurfaces409() {
FakeStore store = new FakeStore();
MutableClock clock = new MutableClock(T0);
// Another caller has already claimed the scope and is still running.
store.tryBegin(SCOPE, FP_A, T0.plus(Duration.ofHours(1)));
IdempotencyExecutor executor =
new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper());
assertThatThrownBy(
() -> executor.execute(IdempotencyContext.of(SCOPE, FP_A), () -> "x", STRING_CODEC))
.isInstanceOf(IdempotencyInFlightException.class);
// The poll loop advanced the clock by at least the 200ms in-flight window.
assertThat(Duration.between(T0, clock.instant()))
.isGreaterThanOrEqualTo(IdempotencyExecutor.IN_FLIGHT_WAIT);
}
@Test
void inFlightThatCompletesDuringWaitReplaysTheResult() {
// The in-flight record flips to COMPLETED after the first poll sleep.
MutableClock clock = new MutableClock(T0);
FakeStore store = new FakeStore();
store.tryBegin(SCOPE, FP_A, T0.plus(Duration.ofHours(1)));
store.completeAfterFirstFind = "winner-result";
IdempotencyExecutor executor =
new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper());
String result = executor.execute(IdempotencyContext.of(SCOPE, FP_A), () -> "x", STRING_CODEC);
assertThat(result).isEqualTo("winner-result");
}
@Test
void actionFailureDiscardsTheRecordSoARetryCanWin() {
FakeStore store = new FakeStore();
MutableClock clock = new MutableClock(T0);
IdempotencyExecutor executor =
new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper());
assertThatThrownBy(
() ->
executor.execute(
IdempotencyContext.of(SCOPE, FP_A),
() -> {
throw new IllegalStateException("boom");
},
STRING_CODEC))
.isInstanceOf(IllegalStateException.class);
assertThat(store.find(SCOPE, T0)).as("failed claim is discarded").isEmpty();
String retry =
executor.execute(IdempotencyContext.of(SCOPE, FP_A), () -> "recovered", STRING_CODEC);
assertThat(retry).isEqualTo("recovered");
}
@Test
void ttlOverrideAbove72hCapIsRejected() {
FakeStore store = new FakeStore();
MutableClock clock = new MutableClock(T0);
IdempotencyExecutor executor =
new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper());
assertThatThrownBy(
() ->
executor.execute(
IdempotencyContext.withTtl(SCOPE, FP_A, Duration.ofHours(73)),
() -> "x",
STRING_CODEC))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("72h");
}
@Test
void defaultTtlAbove72hCapIsRejectedAtConstruction() {
assertThatThrownBy(
() ->
new IdempotencyExecutor(
new FakeStore(), new MutableClock(T0), Duration.ofHours(73)))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void expiredRecordIsTreatedAsAbsentAndReclaimed() {
FakeStore store = new FakeStore();
store.tryBegin(SCOPE, FP_A, T0.plus(Duration.ofHours(1)));
store.complete(SCOPE, new StoredResponse("stale"));
// Clock is now past the record's expiry → replay must be refused, new run wins.
MutableClock clock = new MutableClock(T0.plus(Duration.ofHours(2)));
IdempotencyExecutor executor =
new IdempotencyExecutor(store, clock, DEFAULT_TTL, clock.sleeper());
String result =
executor.execute(IdempotencyContext.of(SCOPE, FP_A), () -> "fresh", STRING_CODEC);
assertThat(result).isEqualTo("fresh");
}
// ---- test doubles ----
/** In-memory store keyed by storage key; honours TTL expiry in {@link #find}. */
static final class FakeStore implements IdempotencyStorePort {
final Map<String, IdempotencyRecord> rows = new ConcurrentHashMap<>();
String completeAfterFirstFind;
private boolean firstFindSeen;
@Override
public boolean tryBegin(
IdempotencyScope scope, RequestFingerprint fingerprint, Instant expiresAt) {
IdempotencyRecord claim =
new IdempotencyRecord(
scope,
fingerprint,
IdempotencyStatus.IN_FLIGHT,
null,
expiresAt.minusSeconds(1),
expiresAt);
return rows.putIfAbsent(scope.storageKey(), claim) == null;
}
@Override
public Optional<IdempotencyRecord> find(IdempotencyScope scope, Instant now) {
IdempotencyRecord record = rows.get(scope.storageKey());
if (record == null) {
return Optional.empty();
}
if (record.isExpiredAt(now)) {
// Lazy expiry: an expired row is reclaimable, so purge it on read.
rows.remove(scope.storageKey(), record);
return Optional.empty();
}
// Simulate the concurrent winner completing mid-wait.
if (completeAfterFirstFind != null
&& firstFindSeen
&& record.status() == IdempotencyStatus.IN_FLIGHT) {
complete(scope, new StoredResponse(completeAfterFirstFind));
record = rows.get(scope.storageKey());
}
firstFindSeen = true;
return Optional.of(record);
}
@Override
public void complete(IdempotencyScope scope, StoredResponse response) {
rows.compute(
scope.storageKey(),
(k, cur) ->
new IdempotencyRecord(
cur.scope(),
cur.fingerprint(),
IdempotencyStatus.COMPLETED,
response,
cur.createdAt(),
cur.expiresAt()));
}
@Override
public void discard(IdempotencyScope scope) {
rows.remove(scope.storageKey());
}
}
/** A clock whose {@code instant()} is advanced explicitly by the test sleeper. */
static final class MutableClock extends Clock {
private Instant instant;
MutableClock(Instant start) {
this.instant = start;
}
Sleeper sleeper() {
return duration -> instant = instant.plus(duration);
}
@Override
public Instant instant() {
return instant;
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.application.idempotency;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
class IdempotencyScopeTest {
@Test
void tripleScopeHasNoTenantDimension() {
IdempotencyScope scope = IdempotencyScope.of("user-1", "key-1", "RegisterUserUseCase");
assertThat(scope.isTenantScoped()).isFalse();
assertThat(scope.tenant()).isNull();
assertThat(scope.storageKey()).isEqualTo("user-1::key-1::RegisterUserUseCase");
}
@Test
void tenantScopePrependsTheTenantDimension() {
IdempotencyScope scope = IdempotencyScope.of("acme", "user-1", "key-1", "RegisterUserUseCase");
assertThat(scope.isTenantScoped()).isTrue();
assertThat(scope.storageKey()).isEqualTo("acme::user-1::key-1::RegisterUserUseCase");
}
@Test
void blankTenantCollapsesToSingleTenantTriple() {
IdempotencyScope scope = IdempotencyScope.of(" ", "user-1", "key-1", "RegisterUserUseCase");
assertThat(scope.isTenantScoped()).isFalse();
}
@Test
void missingPrincipalIsRejectedToPreventGlobalCollision() {
// branch-note §실패 모드: a scope-less key would replay another caller's response.
assertThatThrownBy(() -> IdempotencyScope.of(" ", "key-1", "UseCase"))
.isInstanceOf(IdempotencyScopeMissingException.class)
.satisfies(
e ->
assertThat(((IdempotencyScopeMissingException) e).missingDimension())
.isEqualTo("principal"));
}
@Test
void missingIdempotencyKeyIsRejected() {
assertThatThrownBy(() -> IdempotencyScope.of("user-1", null, "UseCase"))
.isInstanceOf(IdempotencyScopeMissingException.class);
}
@Test
void missingUseCaseNameIsRejected() {
assertThatThrownBy(() -> IdempotencyScope.of("user-1", "key-1", ""))
.isInstanceOf(IdempotencyScopeMissingException.class);
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.application.idempotency;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class RequestFingerprintTest {
@Test
void sameBytesProduceTheSameFingerprint() {
RequestFingerprint a =
RequestFingerprint.ofSha256("{\"x\":1}".getBytes(StandardCharsets.UTF_8));
RequestFingerprint b =
RequestFingerprint.ofSha256("{\"x\":1}".getBytes(StandardCharsets.UTF_8));
assertThat(a).isEqualTo(b);
}
@Test
void differentBytesProduceDifferentFingerprints() {
RequestFingerprint a =
RequestFingerprint.ofSha256("{\"x\":1}".getBytes(StandardCharsets.UTF_8));
RequestFingerprint b =
RequestFingerprint.ofSha256("{\"x\":2}".getBytes(StandardCharsets.UTF_8));
assertThat(a).isNotEqualTo(b);
}
@Test
void digestIsAKnownSha256Vector() {
// SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
assertThat(RequestFingerprint.ofSha256(new byte[0]).hex())
.isEqualTo("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
}
@Test
void nullBodyIsTreatedAsEmptyBody() {
assertThat(RequestFingerprint.ofSha256(null))
.isEqualTo(RequestFingerprint.ofSha256(new byte[0]));
}
@Test
void hexMustBe64Chars() {
assertThatThrownBy(() -> new RequestFingerprint("deadbeef"))
.isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,196 @@
package dev.caskeleton.application.lock;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import org.junit.jupiter.api.Test;
/**
* Contract-shape test for {@link DistributedLockPort} and {@link DistributedLock}.
*
* <p>Uses an in-test fake backed by {@link ReentrantLock} to prove:
*
* <ol>
* <li>{@code tryAcquire} returns a non-null handle.
* <li>{@code close()} releases the lock so a subsequent acquire on the same key succeeds.
* <li>A try-finally release pattern demonstrates the D4 ordering (work inside try, release in
* finally after work completes).
* <li>A second attempt while the key is held times out and throws {@link
* LockAcquisitionTimeoutException} carrying the right key.
* </ol>
*/
class DistributedLockPortContractTest {
private static final Duration SHORT_WAIT = Duration.ofMillis(50);
private static final Duration LEASE_TTL = Duration.ofSeconds(30);
// -----------------------------------------------------------------------
// Fake implementation — kept in-test to avoid polluting production sources
// -----------------------------------------------------------------------
/**
* In-test fake backed by {@link ReentrantLock} (non-reentrant mode enforced via {@link
* ReentrantLock#tryLock(long, TimeUnit)} with the supplied waitTime).
*
* <p>TTL is not enforced by the in-process fake (TTL enforcement is the JDBC registry adapter's
* responsibility). The leaseTtl parameter is accepted but ignored here, mirroring the fact that
* the port surface carries it as a contract hint to the adapter.
*/
private static final class FakeDistributedLockPort implements DistributedLockPort {
private final ConcurrentHashMap<String, ReentrantLock> locks = new ConcurrentHashMap<>();
@Override
public DistributedLock tryAcquire(String key, Duration waitTime, Duration leaseTtl) {
ReentrantLock lock = locks.computeIfAbsent(key, k -> new ReentrantLock());
boolean acquired;
try {
acquired = lock.tryLock(waitTime.toMillis(), TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockAcquisitionTimeoutException(key, waitTime);
}
if (!acquired) {
throw new LockAcquisitionTimeoutException(key, waitTime);
}
return () -> lock.unlock();
}
}
// -----------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------
@Test
void acquireReturnsNonNullHandle() {
DistributedLockPort port = new FakeDistributedLockPort();
DistributedLock lock = port.tryAcquire("test-key", SHORT_WAIT, LEASE_TTL);
assertThat(lock).isNotNull();
lock.close(); // release to avoid resource leak
}
@Test
void closeReleasesLockSoSubsequentAcquireSucceeds() {
DistributedLockPort port = new FakeDistributedLockPort();
DistributedLock first = port.tryAcquire("orders/1", SHORT_WAIT, LEASE_TTL);
first.close(); // release
// second acquire on the same key must succeed after release
DistributedLock second = port.tryAcquire("orders/1", SHORT_WAIT, LEASE_TTL);
assertThat(second).isNotNull();
second.close();
}
/**
* Demonstrates the D4 ordering invariant: the lock handle is released AFTER the protected work
* completes (represented here by a side-effect list), not before.
*
* <p>Canonical pattern (from port Javadoc):
*
* <pre>
* DistributedLock lock = port.tryAcquire(key, waitTime, leaseTtl);
* try {
* txPort.inWrite(() -> { ... protected work ... }); // commit returns here
* } finally {
* lock.close(); // release AFTER commit
* }
* </pre>
*/
@Test
void tryFinallyReleaseAfterWorkDemonstratesD4Ordering() {
DistributedLockPort port = new FakeDistributedLockPort();
List<String> events = new ArrayList<>();
DistributedLock lock = port.tryAcquire("payment/99", SHORT_WAIT, LEASE_TTL);
try {
events.add("work-done"); // represents: tx.inWrite(() -> { ... })
} finally {
lock.close(); // release AFTER work (D4 ordering)
events.add("lock-released");
}
// work must complete before release
assertThat(events).containsExactly("work-done", "lock-released");
}
/**
* While the main thread holds the lock, a second attempt on the same key (same thread —
* ReentrantLock is non-reentrant when used via tryLock with a zero-ish wait) must time out and
* throw {@link LockAcquisitionTimeoutException} carrying the correct key.
*
* <p>We hold the lock on the main thread and call tryAcquire again immediately with a very short
* waitTime. Because the fake uses a {@link ReentrantLock} and the second attempt uses a
* <em>different</em> {@link ReentrantLock#tryLock(long, TimeUnit)} call (not the re-entrant
* path), this is deterministic without needing a second thread.
*
* <p>Note: {@link ReentrantLock} IS re-entrant by design, so for true non-reentrancy the fake
* uses a second thread here to keep the test deterministic.
*/
@Test
void timeoutWhileKeyIsHeldThrowsLockAcquisitionTimeoutExceptionWithCorrectKey()
throws InterruptedException {
FakeDistributedLockPort port = new FakeDistributedLockPort();
String key = "inventory/sku-99";
// Hold the lock in a background thread until the main thread's test completes.
CountDownLatch held = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
Thread holder =
new Thread(
() -> {
DistributedLock lock = port.tryAcquire(key, SHORT_WAIT, LEASE_TTL);
held.countDown(); // signal: lock is held
try {
release.await(); // wait until main thread is done asserting
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.close();
}
},
"lock-holder");
holder.start();
held.await(); // wait for the holder thread to acquire the lock
// Now try to acquire from the main thread — must time out
assertThatThrownBy(() -> port.tryAcquire(key, SHORT_WAIT, LEASE_TTL))
.isInstanceOf(LockAcquisitionTimeoutException.class)
.satisfies(
ex -> {
LockAcquisitionTimeoutException timeout = (LockAcquisitionTimeoutException) ex;
assertThat(timeout.key()).isEqualTo(key);
assertThat(timeout.waitTime()).isEqualTo(SHORT_WAIT);
});
release.countDown(); // unblock the holder thread
holder.join(1_000); // wait for holder to finish cleanly
}
@Test
void tryWithResourcesCompilesAndReleases() {
DistributedLockPort port = new FakeDistributedLockPort();
// DistributedLock extends AutoCloseable — must compile with try-with-resources
try (DistributedLock ignored = port.tryAcquire("key", SHORT_WAIT, LEASE_TTL)) {
// work
}
// after the try-with-resources block, the lock must be released
// so a new acquire succeeds
DistributedLock second = port.tryAcquire("key", SHORT_WAIT, LEASE_TTL);
assertThat(second).isNotNull();
second.close();
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.application.lock;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.shared.error.OperationalError;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class LockAcquisitionTimeoutExceptionTest {
@Test
void carriesKeyAndWaitTime() {
Duration waitTime = Duration.ofMillis(50);
LockAcquisitionTimeoutException ex = new LockAcquisitionTimeoutException("orders/42", waitTime);
assertThat(ex.key()).isEqualTo("orders/42");
assertThat(ex.waitTime()).isEqualTo(waitTime);
}
@Test
void errorCodeIsLOCKACQUISITIONTIMEOUT() {
LockAcquisitionTimeoutException ex =
new LockAcquisitionTimeoutException("orders/42", Duration.ofMillis(50));
assertThat(ex.errorCode()).isEqualTo(OperationalError.LOCK_ACQUISITION_TIMEOUT);
}
@Test
void messageContainsKey() {
LockAcquisitionTimeoutException ex =
new LockAcquisitionTimeoutException("orders/42", Duration.ofSeconds(3));
assertThat(ex.getMessage()).contains("orders/42");
}
@Test
void messageContainsWaitTime() {
Duration waitTime = Duration.ofMillis(500);
LockAcquisitionTimeoutException ex = new LockAcquisitionTimeoutException("some-key", waitTime);
// Duration.toString() produces ISO-8601 form e.g. "PT0.5S"
assertThat(ex.getMessage()).contains(waitTime.toString());
}
@Test
void isARuntimeException() {
LockAcquisitionTimeoutException ex =
new LockAcquisitionTimeoutException("k", Duration.ofSeconds(1));
assertThat(ex).isInstanceOf(RuntimeException.class);
}
}
@@ -0,0 +1,96 @@
package dev.caskeleton.application.notification;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
/**
* Contract test for the application-core notification types: {@link Channel}, {@link Notification},
* and {@link NotificationPort}.
*
* <p>Verifies:
*
* <ul>
* <li>{@link Notification} compact constructor rejects nulls (invariant).
* <li>{@link NotificationPort#notify(Channel, Notification)} delegates to the two-arg overload
* with route="default".
* <li>{@link Channel} enum values are stable (EMAIL, SLACK).
* <li>No Spring/JPA/HTTP import is needed — pure unit test.
* </ul>
*/
class NotificationPortContractTest {
/** Fake port that records calls for assertion. */
private static final class RecordingPort implements NotificationPort {
record Call(Channel channel, String route, Notification notification) {}
final List<Call> calls = new ArrayList<>();
@Override
public void notify(Channel channel, String route, Notification notification) {
calls.add(new Call(channel, route, notification));
}
}
@Test
void notificationRejectsNullRecipient() {
assertThatNullPointerException()
.isThrownBy(() -> new Notification(null, "subject", "body"))
.withMessageContaining("recipient");
}
@Test
void notificationRejectsNullSubject() {
assertThatNullPointerException()
.isThrownBy(() -> new Notification("me@example.com", null, "body"))
.withMessageContaining("subject");
}
@Test
void notificationRejectsNullBody() {
assertThatNullPointerException()
.isThrownBy(() -> new Notification("me@example.com", "subject", null))
.withMessageContaining("body");
}
@Test
void notificationConstructsCorrectlyWithValidFields() {
Notification n = new Notification("me@example.com", "Hello", "World");
assertThat(n.recipient()).isEqualTo("me@example.com");
assertThat(n.subject()).isEqualTo("Hello");
assertThat(n.body()).isEqualTo("World");
}
@Test
void channelEnumHasEmailAndSlack() {
assertThat(Channel.values()).containsExactlyInAnyOrder(Channel.EMAIL, Channel.SLACK);
}
@Test
void defaultNotifyDelegatesToRouteDefault() {
RecordingPort port = new RecordingPort();
Notification n = new Notification("me@example.com", "s", "b");
port.notify(Channel.EMAIL, n);
assertThat(port.calls).hasSize(1);
assertThat(port.calls.get(0).channel()).isEqualTo(Channel.EMAIL);
assertThat(port.calls.get(0).route()).isEqualTo("default");
assertThat(port.calls.get(0).notification()).isSameAs(n);
}
@Test
void explicitRouteOverloadPassesThroughTheRouteName() {
RecordingPort port = new RecordingPort();
Notification n = new Notification("me@example.com", "s", "b");
port.notify(Channel.SLACK, "alerts", n);
assertThat(port.calls).hasSize(1);
assertThat(port.calls.get(0).route()).isEqualTo("alerts");
}
}
@@ -0,0 +1,108 @@
package dev.caskeleton.application.outbox;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
/** Validates {@link NewOutboxEvent} required-field invariants (D12). */
class NewOutboxEventTest {
private static final Instant OCCURRED_AT = Instant.parse("2026-06-11T10:00:00Z");
@Test
void validEventConstructsSuccessfully() {
NewOutboxEvent event =
new NewOutboxEvent("evt-1", "UserCreated", "agg-1", "{}", OCCURRED_AT, "corr-1", "ikey-1");
assertThat(event.eventId()).isEqualTo("evt-1");
assertThat(event.eventType()).isEqualTo("UserCreated");
assertThat(event.aggregateId()).isEqualTo("agg-1");
assertThat(event.payload()).isEqualTo("{}");
assertThat(event.occurredAt()).isEqualTo(OCCURRED_AT);
assertThat(event.correlationId()).isEqualTo("corr-1");
assertThat(event.idempotencyKey()).isEqualTo("ikey-1");
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n"})
void nullOrBlankEventIdIsRejected(String bad) {
assertThatThrownBy(
() ->
new NewOutboxEvent(
bad, "UserCreated", "agg-1", "{}", OCCURRED_AT, "corr-1", "ikey-1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("eventId");
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t"})
void nullOrBlankEventTypeIsRejected(String bad) {
assertThatThrownBy(
() -> new NewOutboxEvent("evt-1", bad, "agg-1", "{}", OCCURRED_AT, "corr-1", "ikey-1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("eventType");
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" "})
void nullOrBlankAggregateIdIsRejected(String bad) {
assertThatThrownBy(
() ->
new NewOutboxEvent(
"evt-1", "UserCreated", bad, "{}", OCCURRED_AT, "corr-1", "ikey-1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("aggregateId");
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" "})
void nullOrBlankPayloadIsRejected(String bad) {
assertThatThrownBy(
() ->
new NewOutboxEvent(
"evt-1", "UserCreated", "agg-1", bad, OCCURRED_AT, "corr-1", "ikey-1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("payload");
}
@Test
void nullOccurredAtIsRejected() {
assertThatThrownBy(
() ->
new NewOutboxEvent("evt-1", "UserCreated", "agg-1", "{}", null, "corr-1", "ikey-1"))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("occurredAt");
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" "})
void nullOrBlankCorrelationIdIsRejected(String bad) {
assertThatThrownBy(
() ->
new NewOutboxEvent(
"evt-1", "UserCreated", "agg-1", "{}", OCCURRED_AT, bad, "ikey-1"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("correlationId");
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" "})
void nullOrBlankIdempotencyKeyIsRejected(String bad) {
assertThatThrownBy(
() ->
new NewOutboxEvent(
"evt-1", "UserCreated", "agg-1", "{}", OCCURRED_AT, "corr-1", bad))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("idempotencyKey");
}
}
@@ -0,0 +1,126 @@
package dev.caskeleton.application.outbox;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Duration;
import java.time.Instant;
import java.util.random.RandomGenerator;
import org.junit.jupiter.api.Test;
/**
* Tests for {@link OutboxBackoffPolicy}: exponential base, jitter boundaries, and the maxAttempts
* accessor.
*/
class OutboxBackoffPolicyTest {
private static final Instant NOW = Instant.parse("2026-06-11T10:00:00Z");
/** Deterministic random that always returns 0.0 — produces zero jitter. */
private static final RandomGenerator ZERO_RANDOM =
new RandomGenerator() {
@Override
public long nextLong() {
return 0L;
}
@Override
public double nextDouble() {
return 0.0;
}
};
/**
* Deterministic random that returns the largest double strictly less than 1.0 — produces
* near-maximum jitter without touching the boundary.
*
* <p>{@code 1.0 - Double.MIN_VALUE} underflows to {@code 1.0} in double arithmetic (the ULP at
* 1.0 is {@code ~2.2e-16}, far larger than {@code Double.MIN_VALUE ~4.9e-324}). {@link
* Math#nextDown(double)} returns the correct predecessor representable double.
*/
private static final RandomGenerator MAX_RANDOM =
new RandomGenerator() {
@Override
public long nextLong() {
return Long.MAX_VALUE;
}
@Override
public double nextDouble() {
return Math.nextDown(1.0);
}
};
@Test
void maxAttemptsIsThree() {
OutboxBackoffPolicy policy = new OutboxBackoffPolicy(ZERO_RANDOM);
assertThat(policy.maxAttempts()).isEqualTo(3);
}
@Test
void firstAttemptBaseDelayIs30sWithZeroJitter() {
OutboxBackoffPolicy policy = new OutboxBackoffPolicy(ZERO_RANDOM);
Instant next = policy.nextAttemptAt(1, NOW);
// base * 2^(1-1) = 30s * 1 = 30s; jitter = 0
assertThat(next).isEqualTo(NOW.plusSeconds(30));
}
@Test
void secondAttemptBaseDelayIs60sWithZeroJitter() {
OutboxBackoffPolicy policy = new OutboxBackoffPolicy(ZERO_RANDOM);
Instant next = policy.nextAttemptAt(2, NOW);
// base * 2^(2-1) = 30s * 2 = 60s; jitter = 0
assertThat(next).isEqualTo(NOW.plusSeconds(60));
}
@Test
void thirdAttemptBaseDelayIs120sWithZeroJitter() {
OutboxBackoffPolicy policy = new OutboxBackoffPolicy(ZERO_RANDOM);
Instant next = policy.nextAttemptAt(3, NOW);
// base * 2^(3-1) = 30s * 4 = 120s; jitter = 0
assertThat(next).isEqualTo(NOW.plusSeconds(120));
}
@Test
void jitterAddsUpToBaseSeconds() {
OutboxBackoffPolicy zeroPolicy = new OutboxBackoffPolicy(ZERO_RANDOM);
OutboxBackoffPolicy maxPolicy = new OutboxBackoffPolicy(MAX_RANDOM);
Instant zeroNext = zeroPolicy.nextAttemptAt(1, NOW);
Instant maxNext = maxPolicy.nextAttemptAt(1, NOW);
Duration delta = Duration.between(zeroNext, maxNext);
// Full jitter range is [0, 30s), so delta must be in [0, 30s)
assertThat(delta.toSeconds()).isGreaterThanOrEqualTo(0);
assertThat(delta.toSeconds()).isLessThan(OutboxBackoffPolicy.BASE_DELAY.toSeconds());
}
@Test
void nextAttemptAtIsAlwaysAfterNow() {
OutboxBackoffPolicy policy = new OutboxBackoffPolicy(ZERO_RANDOM);
for (int attempt = 1; attempt <= 5; attempt++) {
assertThat(policy.nextAttemptAt(attempt, NOW)).isAfter(NOW);
}
}
@Test
void attemptCountZeroIsRejected() {
OutboxBackoffPolicy policy = new OutboxBackoffPolicy(ZERO_RANDOM);
assertThatThrownBy(() -> policy.nextAttemptAt(0, NOW))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("1-based");
}
@Test
void nullNowIsRejected() {
OutboxBackoffPolicy policy = new OutboxBackoffPolicy(ZERO_RANDOM);
assertThatThrownBy(() -> policy.nextAttemptAt(1, null))
.isInstanceOf(NullPointerException.class);
}
@Test
void nullRandomIsRejectedAtConstruction() {
assertThatThrownBy(() -> new OutboxBackoffPolicy(null))
.isInstanceOf(NullPointerException.class);
}
}
@@ -0,0 +1,395 @@
package dev.caskeleton.application.outbox;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.transaction.TransactionPort;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import java.util.random.RandomGenerator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link PublishPendingOutboxEventsUseCase} using fake ports.
*
* <p>Covers:
*
* <ul>
* <li>Successful transition: IN_FLIGHT → PUBLISHED
* <li>Transient failure → FAILED + backoff window
* <li>3 attempt exhaustion → DEAD
* <li>Failure is NOT swallowed (status transition + ERROR log required)
* <li>Events processed in {@code occurredAt} ascending order
* </ul>
*/
class PublishPendingOutboxEventsUseCaseTest {
private static final Instant NOW = Instant.parse("2026-06-11T10:00:00Z");
private static final Duration IN_FLIGHT_TIMEOUT = Duration.ofMinutes(5);
private static final int BATCH_SIZE = 10;
private FakeOutboxStorePort store;
private FakeOutboxMessagePublishPort publishPort;
private FakeTransactionPort tx;
private OutboxBackoffPolicy backoffPolicy;
private Clock clock;
private PublishPendingOutboxEventsUseCase useCase;
@BeforeEach
void setUp() {
store = new FakeOutboxStorePort();
publishPort = new FakeOutboxMessagePublishPort();
tx = new FakeTransactionPort();
clock = Clock.fixed(NOW, ZoneOffset.UTC);
// Use fixed random for determinism: always returns 0.0 jitter (nextDouble() = 0.0)
backoffPolicy = new OutboxBackoffPolicy(new ZeroRandom());
useCase =
new PublishPendingOutboxEventsUseCase(
store, publishPort, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT);
}
// ---- success path ----
@Test
void successfulPublishTransitionsEventToPublished() {
OutboxEvent event = makeEvent("evt-1", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
store.addClaimable(event);
OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
assertThat(result.claimedCount()).isEqualTo(1);
assertThat(result.outcomes()).hasSize(1);
assertThat(result.outcomes().getFirst().outcome())
.isEqualTo(OutboxRelayResult.Outcome.PUBLISHED);
assertThat(store.publishedEvents).containsExactly("evt-1");
assertThat(publishPort.publishedEvents).containsExactly("evt-1");
}
@Test
void multipleEventsPublishedInOccurredAtAscendingOrder() {
// Add events out of order to ensure relay sorts defensively
OutboxEvent late = makeEvent("evt-late", "UserUpdated", "agg-1", NOW.minusSeconds(10), 1);
OutboxEvent early = makeEvent("evt-early", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
store.addClaimable(late);
store.addClaimable(early);
useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
// The publish port must receive events in occurredAt order (early before late)
assertThat(publishPort.publishedEvents).containsExactly("evt-early", "evt-late");
}
// ---- transient failure path ----
@Test
void transientFailureTransitionsEventToFailedWithBackoff() {
OutboxEvent event = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
store.addClaimable(event);
publishPort.failOn("evt-fail", new RuntimeException("broker down"));
OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
assertThat(result.outcomes()).hasSize(1);
assertThat(result.outcomes().getFirst().outcome()).isEqualTo(OutboxRelayResult.Outcome.FAILED);
// The store must record the FAILED transition with a future nextAttemptAt
assertThat(store.failedEvents).containsKey("evt-fail");
Instant nextAttemptAt = store.failedEvents.get("evt-fail");
// backoff = now + 30s * 2^(1-1) = now + 30s (jitter=0 with fixed rng)
assertThat(nextAttemptAt).isAfter(NOW);
// Not yet DEAD (attempt 1 < maxAttempts 3)
assertThat(store.deadEvents).doesNotContain("evt-fail");
}
@Test
void failureOnThirdAttemptTransitionsEventToDead() {
// attemptCount=3 means this is the 3rd attempt — next failure should DEAD
OutboxEvent event = makeEvent("evt-dead", "UserCreated", "agg-1", NOW.minusSeconds(60), 3);
store.addClaimable(event);
publishPort.failOn("evt-dead", new RuntimeException("persistent broker down"));
OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
assertThat(result.outcomes().getFirst().outcome()).isEqualTo(OutboxRelayResult.Outcome.DEAD);
assertThat(store.deadEvents).contains("evt-dead");
assertThat(store.failedEvents).doesNotContainKey("evt-dead");
}
// ---- failure NOT swallowed ----
@Test
void failureIsNotSwallowedRelayContinuesWithNextEvent() {
// Two events: first fails, second succeeds — both must produce status transitions
OutboxEvent fail = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(120), 1);
OutboxEvent ok = makeEvent("evt-ok", "UserUpdated", "agg-1", NOW.minusSeconds(60), 1);
store.addClaimable(fail);
store.addClaimable(ok);
publishPort.failOn("evt-fail", new RuntimeException("transient"));
OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
// Both events must have outcomes — failure did not cause the relay loop to abort
assertThat(result.claimedCount()).isEqualTo(2);
assertThat(result.outcomes()).hasSize(2);
// First event failed (earlier occurredAt)
OutboxRelayResult.EventOutcome failOutcome =
result.outcomes().stream()
.filter(o -> o.eventId().equals("evt-fail"))
.findFirst()
.orElseThrow();
assertThat(failOutcome.outcome()).isEqualTo(OutboxRelayResult.Outcome.FAILED);
// Second event succeeded
OutboxRelayResult.EventOutcome okOutcome =
result.outcomes().stream()
.filter(o -> o.eventId().equals("evt-ok"))
.findFirst()
.orElseThrow();
assertThat(okOutcome.outcome()).isEqualTo(OutboxRelayResult.Outcome.PUBLISHED);
// FAILED event must have a status transition (not silently swallowed)
assertThat(store.failedEvents).containsKey("evt-fail");
}
@Test
void failureOnSecondAttemptTransitionsToFailedNotDead() {
OutboxEvent event = makeEvent("evt-2nd", "UserCreated", "agg-1", NOW.minusSeconds(60), 2);
store.addClaimable(event);
publishPort.failOn("evt-2nd", new RuntimeException("2nd attempt failure"));
OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
assertThat(result.outcomes().getFirst().outcome()).isEqualTo(OutboxRelayResult.Outcome.FAILED);
assertThat(store.failedEvents).containsKey("evt-2nd");
assertThat(store.deadEvents).doesNotContain("evt-2nd");
}
@Test
void emptyBatchReturnsZeroClaimed() {
OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
assertThat(result.claimedCount()).isEqualTo(0);
assertThat(result.outcomes()).isEmpty();
}
// ---- markPublished failure — spec §엣지·실패·의존 semantics ----
/**
* When {@code store.markPublished} throws after a SUCCESSFUL publish, the exception must
* propagate out of {@code handle()} rather than being caught and misclassified as a publish
* failure (which would trigger FAILED/DEAD state machine and potentially dead-letter a
* successfully-delivered event).
*
* <p>Contract (spec §엣지·실패·의존):
*
* <ul>
* <li>The exception propagates — it is NOT swallowed inside {@code publishOne}.
* <li>{@code markFailed} is NOT called for the event (no misclassification).
* <li>{@code markDead} is NOT called for the event (no misclassification).
* <li>{@code publishPort.publish} was called exactly once.
* <li>The row remains IN_FLIGHT and is recovered via the orphan visibility-timeout reclaim path
* on the next tick — re-published → duplicate absorbed by consumer dedupe (at-least-once).
* </ul>
*/
@Test
void markPublishedFailurePropagatesAndDoesNotMisclassifyAsPublishFailure() {
OutboxEvent event =
makeEvent("evt-store-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
ThrowingOnMarkPublishedStorePort throwingStore =
new ThrowingOnMarkPublishedStorePort(new RuntimeException("DB down on markPublished"));
throwingStore.addClaimable(event);
PublishPendingOutboxEventsUseCase useCaseWithThrowingStore =
new PublishPendingOutboxEventsUseCase(
throwingStore, publishPort, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT);
// The exception must propagate — handle() must throw.
assertThatThrownBy(
() -> useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE))
.isInstanceOf(RuntimeException.class)
.hasMessage("DB down on markPublished");
// publish was called exactly once — the broker call succeeded.
assertThat(publishPort.publishedEvents).containsExactly("evt-store-fail");
// No misclassification: the event must NOT be marked FAILED or DEAD.
assertThat(throwingStore.failedEvents)
.as("markFailed must NOT be called when only markPublished fails")
.doesNotContainKey("evt-store-fail");
assertThat(throwingStore.deadEvents)
.as("markDead must NOT be called when only markPublished fails")
.doesNotContain("evt-store-fail");
}
/**
* In a multi-event batch where the first event's markPublished fails, the exception propagates
* out of handle(), aborting the remaining batch for that tick. The second event must NOT have
* been published (no partial-batch silent swallow).
*
* <p>Trade-off (spec §엣지·실패·의존 note): if the DB is failing during status updates, subsequent
* markPublished calls would fail too — aborting the batch is acceptable. The next tick retries
* all IN_FLIGHT orphans after the visibility timeout.
*/
@Test
void markPublishedFailureAbortsRemainingBatchForCurrentTick() {
OutboxEvent first = makeEvent("evt-first", "UserCreated", "agg-1", NOW.minusSeconds(120), 1);
OutboxEvent second = makeEvent("evt-second", "UserUpdated", "agg-1", NOW.minusSeconds(60), 1);
ThrowingOnMarkPublishedStorePort throwingStore =
new ThrowingOnMarkPublishedStorePort(new RuntimeException("DB down on markPublished"));
throwingStore.addClaimable(first);
throwingStore.addClaimable(second);
PublishPendingOutboxEventsUseCase useCaseWithThrowingStore =
new PublishPendingOutboxEventsUseCase(
throwingStore, publishPort, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT);
assertThatThrownBy(
() -> useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE))
.isInstanceOf(RuntimeException.class)
.hasMessage("DB down on markPublished");
// First event (earlier occurredAt) must have been published to the broker,
// and the second event must NOT have been published (no partial-batch silent swallow).
assertThat(publishPort.publishedEvents).containsExactly("evt-first");
// No FAILED or DEAD misclassification for the first event.
assertThat(throwingStore.failedEvents).doesNotContainKey("evt-first");
assertThat(throwingStore.deadEvents).doesNotContain("evt-first");
// No FAILED or DEAD misclassification for the second event either.
assertThat(throwingStore.failedEvents).doesNotContainKey("evt-second");
assertThat(throwingStore.deadEvents).doesNotContain("evt-second");
}
// ---- helper ----
private static OutboxEvent makeEvent(
String eventId, String eventType, String aggregateId, Instant occurredAt, int attemptCount) {
return new OutboxEvent(
eventId,
eventType,
aggregateId,
"{\"data\": \"test\"}",
occurredAt,
"corr-" + eventId,
"ikey-" + eventId,
OutboxEventStatus.IN_FLIGHT,
attemptCount);
}
// ---- test doubles ----
static class FakeOutboxStorePort implements OutboxStorePort {
final List<OutboxEvent> claimable = new ArrayList<>();
final List<String> publishedEvents = new ArrayList<>();
final Map<String, Instant> failedEvents = new LinkedHashMap<>();
final List<String> deadEvents = new ArrayList<>();
void addClaimable(OutboxEvent event) {
claimable.add(event);
}
@Override
public List<OutboxEvent> claimBatch(int batchSize, Instant now, Duration inFlightTimeout) {
return List.copyOf(claimable);
}
@Override
public void markPublished(String eventId) {
publishedEvents.add(eventId);
}
@Override
public void markFailed(String eventId, Instant nextAttemptAt) {
failedEvents.put(eventId, nextAttemptAt);
}
@Override
public void markDead(String eventId) {
deadEvents.add(eventId);
}
@Override
public Map<OutboxEventStatus, Long> countByStatus() {
return Map.of();
}
@Override
public Map<String, Long> oldestUnpublishedAgeSecondsByEventType(Instant now) {
return Map.of();
}
}
/**
* Extends {@link FakeOutboxStorePort}, overriding only {@link #markPublished} to throw,
* simulating a DB-down scenario after a successful broker publish. All other operations —
* claimBatch, markFailed, markDead, countByStatus, oldestUnpublishedAgeSecondsByEventType — are
* inherited unchanged.
*/
static final class ThrowingOnMarkPublishedStorePort extends FakeOutboxStorePort {
private final RuntimeException markPublishedEx;
ThrowingOnMarkPublishedStorePort(RuntimeException markPublishedEx) {
this.markPublishedEx = markPublishedEx;
}
@Override
public void markPublished(String eventId) {
throw markPublishedEx;
}
}
static final class FakeOutboxMessagePublishPort implements OutboxMessagePublishPort {
final List<String> publishedEvents = new ArrayList<>();
private final Map<String, RuntimeException> failureMap = new LinkedHashMap<>();
void failOn(String eventId, RuntimeException ex) {
failureMap.put(eventId, ex);
}
@Override
public void publish(OutboxEvent event) {
if (failureMap.containsKey(event.eventId())) {
throw failureMap.get(event.eventId());
}
publishedEvents.add(event.eventId());
}
}
static final class FakeTransactionPort implements TransactionPort {
@Override
public <T> T inWrite(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inRead(Supplier<T> action) {
return action.get();
}
@Override
public <T> T inNew(Supplier<T> action) {
return action.get();
}
}
/** Deterministic RandomGenerator that always returns 0 — produces zero jitter. */
static final class ZeroRandom implements RandomGenerator {
@Override
public long nextLong() {
return 0L;
}
@Override
public double nextDouble() {
return 0.0;
}
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.application.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.shared.security.Permission;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
class AuthorizationContractTest {
@Test
void principalDefendsAgainstNullRoles() {
AuthorizationPrincipal p = new AuthorizationPrincipal("sub-1", null);
assertThat(p.subject()).isEqualTo("sub-1");
assertThat(p.roles()).isEmpty();
}
@Test
void principalRolesAreAnUnmodifiableCopy() {
Set<String> mutable = new HashSet<>(Set.of("admin"));
AuthorizationPrincipal p = new AuthorizationPrincipal("sub-1", mutable);
mutable.add("user"); // must not bleed into the principal
assertThat(p.roles()).containsExactly("admin");
assertThatThrownBy(() -> p.roles().add("hacker"))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void deniedExceptionCarriesRequiredPermissionAndSubject() {
Permission required = Permission.parse("worklog:close");
AuthorizationDeniedException ex = new AuthorizationDeniedException("sub-1", required);
assertThat(ex.requiredPermission()).isEqualTo(required);
assertThat(ex.subject()).isEqualTo("sub-1");
assertThat(ex.getMessage()).contains("worklog:close").contains("sub-1");
}
@Test
void requiresPermissionAnnotationIsRuntimeAndTargetsTypeOrMethod() {
Retention retention = RequiresPermission.class.getAnnotation(Retention.class);
Target target = RequiresPermission.class.getAnnotation(Target.class);
assertThat(retention.value()).isEqualTo(RetentionPolicy.RUNTIME);
assertThat(target.value()).containsExactlyInAnyOrder(ElementType.TYPE, ElementType.METHOD);
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.application.transaction;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
/**
* feature-transaction-concurrency-contract D3 / §구현 가이드 1 — isolation vocabulary policy.
*
* <p>This branch owns the {@link Isolation} vocabulary. {@code READ_COMMITTED} is the pinned
* default (every {@code SpringTransactionPort} template pins it explicitly — see {@code
* SpringTransactionPortTest}); {@code REPEATABLE_READ}/{@code SERIALIZABLE} are the explicit opt-in
* levels for write-heavy / read-consistency use cases; {@code READ_UNCOMMITTED} is forbidden and
* therefore MUST NOT be a DECLARED constant.
*
* <p>Wiring these stricter levels through the {@code TransactionPort} call path is a joint change
* with {@code feature-application-port-usecase-contract} (the abstraction owner) and stays {@code
* planned}; this test pins only the vocabulary surface this branch ships.
*/
class IsolationTest {
private static final List<String> DECLARED =
Arrays.stream(Isolation.values()).map(Enum::name).toList();
@Test
void readCommittedIsThePinnedDefaultLevel() {
assertThat(DECLARED).contains("READ_COMMITTED");
}
@Test
void stricterLevelsAreAvailableForExplicitOptIn() {
assertThat(DECLARED)
.as("write-heavy / read-consistency use cases pin REPEATABLE_READ or SERIALIZABLE")
.contains("REPEATABLE_READ", "SERIALIZABLE");
}
@Test
void readUncommittedIsForbiddenAndNeverDeclared() {
assertThat(DECLARED)
.as("READ_UNCOMMITTED is forbidden — it must not be a DECLARED constant")
.doesNotContain("READ_UNCOMMITTED");
}
@Test
void onlyTheThreeContractedLevelsExist() {
assertThat(Stream.of(Isolation.values()))
.as("the exposed isolation surface is exactly the contracted three levels")
.hasSize(3);
}
}
@@ -0,0 +1,77 @@
package dev.caskeleton.application.transaction;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
import org.junit.jupiter.api.Test;
class TransactionPortTest {
@Test
void runnableInWriteDelegatesToSupplierInWrite() {
RecordingTransactionPort port = new RecordingTransactionPort();
List<String> sideEffect = new ArrayList<>();
port.inWrite(() -> sideEffect.add("ran"));
assertThat(port.invocations).containsExactly(TransactionMode.WRITE);
assertThat(sideEffect).containsExactly("ran");
}
@Test
void runnableInReadDelegatesToSupplierInRead() {
RecordingTransactionPort port = new RecordingTransactionPort();
List<String> sideEffect = new ArrayList<>();
port.inRead(() -> sideEffect.add("ran"));
assertThat(port.invocations).containsExactly(TransactionMode.READ_ONLY);
assertThat(sideEffect).containsExactly("ran");
}
@Test
void runnableInNewDelegatesToSupplierInNew() {
RecordingTransactionPort port = new RecordingTransactionPort();
List<String> sideEffect = new ArrayList<>();
port.inNew(() -> sideEffect.add("ran"));
assertThat(port.invocations).containsExactly(TransactionMode.REQUIRES_NEW);
assertThat(sideEffect).containsExactly("ran");
}
@Test
void supplierInWriteReturnsActionValue() {
RecordingTransactionPort port = new RecordingTransactionPort();
String result = port.inWrite(() -> "v");
assertThat(result).isEqualTo("v");
assertThat(port.invocations).containsExactly(TransactionMode.WRITE);
}
private static final class RecordingTransactionPort implements TransactionPort {
private final List<TransactionMode> invocations = new ArrayList<>();
@Override
public <T> T inWrite(Supplier<T> action) {
invocations.add(TransactionMode.WRITE);
return action.get();
}
@Override
public <T> T inRead(Supplier<T> action) {
invocations.add(TransactionMode.READ_ONLY);
return action.get();
}
@Override
public <T> T inNew(Supplier<T> action) {
invocations.add(TransactionMode.REQUIRES_NEW);
return action.get();
}
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.application.usecase;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.command.Command;
import dev.caskeleton.application.query.Query;
import dev.caskeleton.application.transaction.TransactionMode;
import org.junit.jupiter.api.Test;
class UseCaseContractTest {
@Test
void commandUseCaseImplementsUseCaseWithCommandInput() {
assertThat(UseCase.class.isAssignableFrom(CommandUseCase.class)).isTrue();
assertThat(new RegisterUseCase().handle(new RegisterCommand("alice")))
.isEqualTo("registered:alice");
}
@Test
void queryUseCaseImplementsUseCaseWithQueryInput() {
assertThat(UseCase.class.isAssignableFrom(QueryUseCase.class)).isTrue();
assertThat(new FindUseCase().handle(new FindQuery("alice"))).isEqualTo("found:alice");
}
record RegisterCommand(String name) implements Command {}
record FindQuery(String name) implements Query {}
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
static final class RegisterUseCase implements CommandUseCase<RegisterCommand, String> {
@Override
public String handle(RegisterCommand input) {
return "registered:" + input.name();
}
}
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
static final class FindUseCase implements QueryUseCase<FindQuery, String> {
@Override
public String handle(FindQuery input) {
return "found:" + input.name();
}
}
}