feat: redis, fileserver, httpclient 런타임 시점 구현 추가

This commit is contained in:
donghyeon-ka
2026-07-28 14:26:54 +09:00
parent 7363b2aa1e
commit b3add0162d
257 changed files with 30430 additions and 1357 deletions
+11 -7
View File
@@ -4,9 +4,9 @@
- Module ID: `application-core`
- Gradle path: `:application-core`
- Focused test: `./gradlew :application-core:test --console=plain`
- Focused test (derived from Gradle path): `./gradlew :application-core:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `.harness/project/modules.yaml`.
- Registry SSOT: `src/config/architecture/modules.json`.
Package root: `dev.caskeleton.application`.
@@ -19,15 +19,14 @@ Package root: `dev.caskeleton.application`.
- Application exceptions and policy types.
- Coordinate domain models through ports.
- Own application transaction boundaries through the `TransactionPort` abstraction.
- Expose framework-free invocation context through ports such as `CorrelationIdPort`; adapters own
MDC or other concrete storage.
## 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.
- Java standard library types.
## Forbidden
@@ -45,6 +44,8 @@ Package root: `dev.caskeleton.application`.
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.
- Diagnostic frameworks (`org.slf4j`, `java.util.logging`, Logback, Log4j, Micrometer). Express
diagnostic intent through a specific outbound `*Port`; adapters own rendering.
## Contract types
@@ -71,7 +72,6 @@ Package root: `dev.caskeleton.application`.
## Canonical use case shape
```java
@Service
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.KEYED,
@@ -95,6 +95,9 @@ public final class RegisterUserUseCase implements CommandUseCase<RegisterUserCom
}
```
The class is plain Java. A composition root constructs it with its ports and configuration values;
application-core never self-registers with a DI framework.
## Allowed transactional shapes
| Use case shape | `transactionMode` | TransactionPort call | When |
@@ -174,6 +177,7 @@ The read side has two equally-valid shapes; pick per read, do not force one:
- `application_does_not_depend_on_adapters_or_transport`
- `application_does_not_use_spring_transactional_annotation`
- `application_does_not_depend_on_application_context` (D11)
- `APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK`
- `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)
+29 -11
View File
@@ -8,9 +8,11 @@
추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로 풀어 둔다.
이 계층을 관통하는 큰 원칙 하나: **application-core 는 프레임워크-free 다.** Spring/JPA/HTTP
타입을 직접 들이지 않고, 필요한 인프라 능력(트랜잭션·락·인가·알림 등)은 전부 `*Port`
인터페이스로 추상화한다. 구현은 adapter 모듈에 있고 컴파일 타임엔 보이지 않는다. 아래 결정
대부분이 이 원칙에서 파생된다.
타입뿐 아니라 SLF4J/JUL/Logback/Log4j/Micrometer도 직접 들이지 않고, 필요한 인프라 능력
(트랜잭션·락·인가·알림·운영 진단 등)은 전부 구체적인 목적의 `*Port` 인터페이스로 추상화한다.
구현은 adapter 모듈에 있고 컴파일 타임엔 보이지 않는다. Gradle
`verifyApplicationCoreDependencyPurity`와 ArchUnit
`APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK`가 이 계약을 자동 검증한다.
---
@@ -298,8 +300,8 @@ claim → 트랜잭션 밖에서 발행 → at-least-once 보장.
- **fail-closed 계약(I8)**: 발행 실패는 반드시 `RuntimeException` 으로 표면화해야 한다. 구현은
예외를 삼키거나 실패 시 log-and-return 하면 안 된다. 일반적인 fail-open 메시징 publisher(잡고
로그 후 정상 반환)와의 **의도적·문서화된 차이** 다 — relay 의 Failure condition 이 발행 실패를
예외로 관측해야 FAILED/DEAD 전이 + 에러 코드 로그(`OUTBOX_PUBLISH_FAILED` /
`OUTBOX_DEAD_LETTER`)를 구동할 수 있기 때문. 삼킨 실패(예외 없음·전이 없음·ERROR 로그 없음)가
예외로 관측해야 FAILED/DEAD 전이 + typed failure report를 구동할 수 있기 때문. 삼킨 실패
(예외 없음·전이 없음·report 없음)가
금지 조건이다 — 행이 영원히 `IN_FLIGHT` 로 남고, aggregate FIFO 큐가 조용히 막히며, 메트릭엔
이상이 안 보인다.
- **호출 위치**: relay 유스케이스가 **트랜잭션 밖에서** 호출한다. 짧은 `inWrite` 로 배치 claim →
@@ -315,13 +317,18 @@ claim → 트랜잭션 밖에서 발행 → at-least-once 보장.
FIFO 를 강제한다).
3. 각 이벤트를 **트랜잭션 밖에서** 발행하고 결과로 상태 머신을 구동한다:
- 발행 성공 → `inWrite { markPublished }` → `PUBLISHED`.
- 발행 실패(`RuntimeException`): `attemptCount >= maxAttempts` 면 `markDead` +
`OUTBOX_DEAD_LETTER` ERROR 로그; 아니면 `markFailed(nextAttemptAt)` +
`OUTBOX_PUBLISH_FAILED` ERROR 로그.
- 발행 실패(`RuntimeException`): `attemptCount >= maxAttempts` 면 `markDead`, 아니면
`markFailed(nextAttemptAt)`를 먼저 성공시킨 뒤 해당 typed failure report를 보낸다.
- **발행 실패는 절대 삼키지 않는다**: relay 는 각 발행 예외를 잡아 FAILED/DEAD 상태 머신을
구동하고 ERROR 로그를 낸 뒤 rethrow 하지 않는다(스케줄러 루프가 다음 이벤트로 계속 가야
하므로). 모든 발행 실패는 반드시 (a) 상태 전이와 (b) error code·correlationId·eventId·eventType·
attemptCount 를 담은 ERROR 로그를 **둘 다** 남긴다. 둘 중 하나라도 빠지면 금지된 silent-swallow.
구동하고, 성공한 전이만 `OutboxRelayFailureReportPort`로 보고한 뒤 rethrow 하지 않는다
(스케줄러 루프가 다음 이벤트로 계속 가야 하므로). 상태 전이가 실패하면 예외가 전파되고 report는
없다. reporter가 `RuntimeException`을 던져도 persisted outcome을 바꾸거나 다음 이벤트를 막지
못한다.
- **안전한 allowlist report**: `OutboxRelayFailureReport`는
`code/eventId/eventType/aggregateId/correlationId/attemptCount/nextAttemptAt/cause`만 가진다.
payload, idempotency key, whole `OutboxEvent`, severity/template, arbitrary map은 타입 수준에서
전달할 수 없다. retry factory는 `OUTBOX_PUBLISH_FAILED`와 필수 `nextAttemptAt`, dead factory는
`OUTBOX_DEAD_LETTER`와 null retry time을 고정한다.
- **상태 갱신 실패는 시끄럽게 전파한다**: 발행 성공 후의 `markPublished` 실패는 store/인프라
에러지 발행 실패가 아니다. 따라서 FAILED/DEAD 머신을 구동하면 안 된다(이미 전달된 이벤트를
dead-letter 하는 꼴). 대신 스케줄러 catch 블록으로 전파되고, 행은 `IN_FLIGHT` 로 남아 고아
@@ -372,6 +379,9 @@ claim → 트랜잭션 밖에서 발행 → at-least-once 보장.
- `PublishPendingOutboxEventsCommand` — relay 커맨드 마커. 스케줄러 구동이라 caller 파라미터가
없고, 모든 운영 파라미터는 생성 시점에 주입된다(IdempotencyExecutor 선례). 호출마다 새 인스턴스를
만들 필요가 없게 `INSTANCE` 싱글톤을 제공한다.
- `OutboxRelayFailureReportPort` / `OutboxRelayFailureReport` — confirmed FAILED/DEAD 상태를
adapter에 전달하는 framework-free outbound contract. 구조화 ERROR 필드와 runbook 렌더링은
messaging adapter가 소유한다.
---
@@ -462,6 +472,14 @@ application 계층은 락 획득/해제 계약만 알고, 실제 구현은 adapt
## 로그 가명화 포트 (observability)
### CorrelationIdPort
- 현재 application invocation의 correlation id를 `Optional<String>`으로 읽는 framework-free
경계다. application/sample use case는 MDC나 SLF4J를 직접 알지 않는다.
- inbound web adapter가 sanitized `correlation_id` MDC 슬롯을 구현 세부로 읽는다.
- 값이 없거나 blank이면 event publisher는 생성한 event id를 correlation id로 재사용해 기존
self-correlation 동작을 유지한다.
### UserPrincipalPseudonymizerPort
- raw 보안 principal id 를, 값이 로그/MDC 에 쓰이기 전에 안정적 가명 토큰으로 바꾸는 outbound
+2 -12
View File
@@ -1,15 +1,5 @@
// 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..`.
// Framework-free application use-case contract. Runtime dependencies are project-only;
// composition and diagnostic rendering belong to adapters/bootstrap.
dependencies {
implementation project(':domain-core')
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter'
}
+5 -74
View File
@@ -1,23 +1,17 @@
# 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.findbugs:jsr305:3.0.2=checkstyle,spotbugs
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
@@ -31,27 +25,16 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=anno
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
@@ -60,28 +43,22 @@ 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-api:2.25.2=spotbugs
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.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath
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
@@ -91,61 +68,15 @@ 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-api:2.0.17=spotbugs,spotbugsSlf4j
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=
empty=compileClasspath,runtimeClasspath
@@ -0,0 +1,8 @@
package dev.caskeleton.application.cache;
/** Business-classified source absence that is safe to negative-cache. */
public enum AuthoritativeAbsence {
NOT_FOUND,
DELETED,
NOT_APPLICABLE
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.cache;
/** Provider-neutral invalidation result. */
public enum CacheInvalidationOutcome {
INVALIDATED,
ALREADY_ABSENT,
DEGRADED_UNAVAILABLE,
INDETERMINATE
}
@@ -0,0 +1,88 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** Lookup result that never collapses provider failure, negative entries, and normal misses. */
public sealed interface CacheLookup<V>
permits CacheLookup.Hit,
CacheLookup.NegativeHit,
CacheLookup.Miss,
CacheLookup.IncompatibleSchema,
CacheLookup.Unavailable {
record Hit<V>(V value, Freshness freshness, String sourceRevision) implements CacheLookup<V> {
public Hit {
Objects.requireNonNull(value, "value must be non-null");
Objects.requireNonNull(freshness, "freshness must be non-null");
if (sourceRevision == null || sourceRevision.isBlank() || sourceRevision.length() > 128) {
throw new IllegalArgumentException("sourceRevision must contain 1..128 characters");
}
}
}
record NegativeHit<V>(AuthoritativeAbsence reason) implements CacheLookup<V> {
public NegativeHit {
Objects.requireNonNull(reason, "reason must be non-null");
}
}
record Miss<V>(MissReason reason) implements CacheLookup<V> {
public Miss {
Objects.requireNonNull(reason, "reason must be non-null");
}
}
record IncompatibleSchema<V>(SchemaCategory category, SchemaPolicy policy)
implements CacheLookup<V> {
public IncompatibleSchema {
Objects.requireNonNull(category, "category must be non-null");
Objects.requireNonNull(policy, "policy must be non-null");
}
}
record Unavailable<V>(UnavailabilityReason reason, OperationCertainty certainty)
implements CacheLookup<V> {
public Unavailable {
Objects.requireNonNull(reason, "reason must be non-null");
Objects.requireNonNull(certainty, "certainty must be non-null");
}
}
enum Freshness {
FRESH,
STALE
}
enum MissReason {
ABSENT,
EXPIRED,
INVALIDATED
}
enum SchemaCategory {
FUTURE_VERSION,
RETIRED_VERSION,
UNKNOWN_ENVELOPE,
CORRUPT_ENVELOPE
}
enum SchemaPolicy {
FAIL_FAST,
QUARANTINE_AND_RELOAD
}
enum UnavailabilityReason {
UNAVAILABLE,
OVERLOADED
}
enum OperationCertainty {
NOT_APPLIED,
INDETERMINATE
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.application.cache;
/** Application-visible consistency intent; technical TTL and codec remain provider policy. */
public enum CacheRecordIntent {
UPSERT,
ONLY_IF_SOURCE_REVISION_NEWER
}
@@ -0,0 +1,14 @@
package dev.caskeleton.application.cache;
import java.util.Objects;
/** Metadata derived from the authoritative source, never from a cache provider. */
public record CacheRecordMetadata(String sourceRevision, CacheRecordIntent intent) {
public CacheRecordMetadata {
if (sourceRevision == null || sourceRevision.isBlank() || sourceRevision.length() > 128) {
throw new IllegalArgumentException("sourceRevision must contain 1..128 characters");
}
Objects.requireNonNull(intent, "intent must be non-null");
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.cache;
/** Provider-neutral result of recording a positive or authoritative-negative entry. */
public enum CacheRecordOutcome {
RECORDED,
NOT_RECORDED_CONDITION,
NOT_RECORDED_PROVIDER_POLICY,
DEGRADED_UNAVAILABLE,
INDETERMINATE
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.cache;
/**
* Provider-neutral cache-region contract. Concrete use cases should extend this interface with a
* semantic port name and domain-specific key/value types.
*/
public interface CacheRegionPort<K, V> {
CacheLookup<V> lookup(K key);
CacheRecordOutcome record(K key, V value, CacheRecordMetadata metadata);
CacheRecordOutcome recordAbsent(K key, AuthoritativeAbsence reason, CacheRecordMetadata metadata);
CacheInvalidationOutcome invalidate(K key);
}
@@ -0,0 +1,60 @@
package dev.caskeleton.application.filepublication;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/** Ordered, versioned schema for tabular publication. */
public record ExportSchema(String schemaId, int version, List<Column> columns) {
public ExportSchema {
schemaId = FilePublicationValues.requireOpaque("schemaId", schemaId, 128);
if (version < 1) {
throw new IllegalArgumentException("schema version must be >= 1");
}
if (columns == null || columns.isEmpty()) {
throw new IllegalArgumentException("schema columns must be non-empty");
}
columns = List.copyOf(columns);
Set<String> names = new HashSet<>();
for (Column column : columns) {
Objects.requireNonNull(column, "schema column must be non-null");
if (!names.add(column.name())) {
throw new IllegalArgumentException("duplicate schema column: " + column.name());
}
}
}
public record Column(
String name,
CellType cellType,
boolean nullable,
FormulaPolicy formulaPolicy,
int maximumUtf8Bytes) {
public Column {
name = FilePublicationValues.requireOpaque("column name", name, 128);
Objects.requireNonNull(cellType, "cellType must be non-null");
Objects.requireNonNull(formulaPolicy, "formulaPolicy must be non-null");
if (maximumUtf8Bytes < 1) {
throw new IllegalArgumentException("maximumUtf8Bytes must be >= 1");
}
}
}
public enum CellType {
TEXT,
INTEGER,
DECIMAL,
BOOLEAN,
DATE,
INSTANT
}
public enum FormulaPolicy {
ALLOW,
MITIGATE,
REJECT
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.application.filepublication;
/** Registered logical file destination; never a path, URI, host, or provider identifier. */
public record FileDestinationId(String value) {
public FileDestinationId {
if (value == null || !value.matches("[a-z][a-z0-9-]{0,62}")) {
throw new IllegalArgumentException("destinationId must match [a-z][a-z0-9-]{0,62}");
}
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.application.filepublication;
/**
* Provider-neutral publication failure. The reason is stable application-facing vocabulary; paths,
* credentials, and provider exception messages must not be embedded in it.
*/
public final class FilePublicationException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final Reason reason;
public FilePublicationException(Reason reason, String message) {
super(message);
this.reason = reason;
}
public FilePublicationException(Reason reason, String message, Throwable cause) {
super(message, cause);
this.reason = reason;
}
public Reason reason() {
return reason;
}
public enum Reason {
INVALID_REQUEST,
CONFLICT,
CAPACITY_EXCEEDED,
UNAVAILABLE,
CANCELLED,
PUBLISH_INDETERMINATE
}
}
@@ -0,0 +1,10 @@
package dev.caskeleton.application.filepublication;
/**
* Outbound application port for publishing a bounded tabular artifact to a registered logical
* destination.
*/
public interface FilePublicationPort {
FilePublishReceipt publish(FilePublishRequest request, TabularRowProducer producer);
}
@@ -0,0 +1,20 @@
package dev.caskeleton.application.filepublication;
final class FilePublicationValues {
private FilePublicationValues() {}
static String requireOpaque(String field, String value, int maximumLength) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must be non-null and non-blank");
}
String normalized = value.trim();
if (normalized.length() > maximumLength) {
throw new IllegalArgumentException(field + " exceeds " + maximumLength + " characters");
}
if (normalized.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(field + " must not contain control characters");
}
return normalized;
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.filepublication;
/** Stable opaque operation identity retained across resolution and retry. */
public record FilePublishOperationId(String value) {
public FilePublishOperationId {
value = FilePublicationValues.requireOpaque("operationId", value, 128);
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.application.filepublication;
import java.time.Instant;
import java.util.Objects;
/** Sanitized publication receipt. It intentionally contains no local/remote path or credential. */
public record FilePublishReceipt(
FilePublishOperationId operationId,
PublishedFileReference reference,
FileDestinationId destinationId,
String publishedFileName,
FileVersion version,
String formatProfileId,
String mediaType,
String charset,
long byteSize,
long dataRowCount,
int columnCount,
String sha256,
Instant publishedAt,
PublicationGuarantee publicationGuarantee,
DurabilityGuarantee durabilityGuarantee,
long formulaMitigatedCount) {
public FilePublishReceipt {
Objects.requireNonNull(operationId, "operationId must be non-null");
Objects.requireNonNull(reference, "reference must be non-null");
Objects.requireNonNull(destinationId, "destinationId must be non-null");
publishedFileName =
FilePublicationValues.requireOpaque("publishedFileName", publishedFileName, 256);
Objects.requireNonNull(version, "version must be non-null");
formatProfileId = FilePublicationValues.requireOpaque("formatProfileId", formatProfileId, 128);
mediaType = FilePublicationValues.requireOpaque("mediaType", mediaType, 128);
charset = FilePublicationValues.requireOpaque("charset", charset, 64);
sha256 = FilePublicationValues.requireOpaque("sha256", sha256, 64);
Objects.requireNonNull(publishedAt, "publishedAt must be non-null");
Objects.requireNonNull(publicationGuarantee, "publicationGuarantee must be non-null");
Objects.requireNonNull(durabilityGuarantee, "durabilityGuarantee must be non-null");
if (byteSize < 0 || dataRowCount < 0 || columnCount < 1 || formulaMitigatedCount < 0) {
throw new IllegalArgumentException("receipt counts and sizes are out of range");
}
}
public enum PublicationGuarantee {
UNIQUE_ATOMIC_CREATE
}
public enum DurabilityGuarantee {
PROCESS_LOCAL_SYNC,
PROVIDER_ACK_ONLY
}
}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.filepublication;
import java.util.Objects;
/** Provider-neutral publication intent. */
public record FilePublishRequest(
FilePublishOperationId operationId,
FileDestinationId destinationId,
LogicalFileName logicalFileName,
SourceRevision sourceRevision,
ExportSchema schema,
String formatProfileId) {
public FilePublishRequest {
Objects.requireNonNull(operationId, "operationId must be non-null");
Objects.requireNonNull(destinationId, "destinationId must be non-null");
Objects.requireNonNull(logicalFileName, "logicalFileName must be non-null");
Objects.requireNonNull(sourceRevision, "sourceRevision must be non-null");
Objects.requireNonNull(schema, "schema must be non-null");
formatProfileId = FilePublicationValues.requireOpaque("formatProfileId", formatProfileId, 128);
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.filepublication;
/** Opaque immutable version of a published artifact. */
public record FileVersion(String value) {
public FileVersion {
value = FilePublicationValues.requireOpaque("fileVersion", value, 128);
}
}
@@ -0,0 +1,12 @@
package dev.caskeleton.application.filepublication;
/** Display/naming input that cannot carry filesystem path syntax. */
public record LogicalFileName(String value) {
public LogicalFileName {
value = FilePublicationValues.requireOpaque("logicalFileName", value, 128);
if (value.contains("/") || value.contains("\\") || value.equals(".") || value.equals("..")) {
throw new IllegalArgumentException("logicalFileName must not contain path syntax");
}
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.filepublication;
/** Opaque reference that never exposes a filesystem path, host, or provider location. */
public record PublishedFileReference(String value) {
public PublishedFileReference {
value = FilePublicationValues.requireOpaque("publishedFileReference", value, 256);
}
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.filepublication;
/** Stable source snapshot or source fingerprint selected by the application. */
public record SourceRevision(String value) {
public SourceRevision {
value = FilePublicationValues.requireOpaque("sourceRevision", value, 256);
}
}
@@ -0,0 +1,77 @@
package dev.caskeleton.application.filepublication;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;
/** Closed framework-free set of cells supported by the baseline tabular publisher. */
public sealed interface TabularCell {
ExportSchema.CellType cellType();
record TextCell(String value) implements TabularCell {
public TextCell {
Objects.requireNonNull(value, "text value must be non-null");
}
@Override
public ExportSchema.CellType cellType() {
return ExportSchema.CellType.TEXT;
}
}
record IntegerCell(long value) implements TabularCell {
@Override
public ExportSchema.CellType cellType() {
return ExportSchema.CellType.INTEGER;
}
}
record DecimalCell(BigDecimal value) implements TabularCell {
public DecimalCell {
Objects.requireNonNull(value, "decimal value must be non-null");
}
@Override
public ExportSchema.CellType cellType() {
return ExportSchema.CellType.DECIMAL;
}
}
record BooleanCell(boolean value) implements TabularCell {
@Override
public ExportSchema.CellType cellType() {
return ExportSchema.CellType.BOOLEAN;
}
}
record DateCell(LocalDate value) implements TabularCell {
public DateCell {
Objects.requireNonNull(value, "date value must be non-null");
}
@Override
public ExportSchema.CellType cellType() {
return ExportSchema.CellType.DATE;
}
}
record InstantCell(Instant value) implements TabularCell {
public InstantCell {
Objects.requireNonNull(value, "instant value must be non-null");
}
@Override
public ExportSchema.CellType cellType() {
return ExportSchema.CellType.INSTANT;
}
}
record NullCell() implements TabularCell {
@Override
public ExportSchema.CellType cellType() {
throw new IllegalStateException("null cells do not have a concrete cell type");
}
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.filepublication;
import java.util.List;
import java.util.Objects;
/** Immutable ordered row. */
public record TabularRow(List<TabularCell> cells) {
public TabularRow {
Objects.requireNonNull(cells, "cells must be non-null");
cells = List.copyOf(cells);
if (cells.stream().anyMatch(Objects::isNull)) {
throw new IllegalArgumentException("cells must not contain null; use NullCell");
}
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.application.filepublication;
/** Synchronous single-attempt producer for bounded row-by-row publication. */
@FunctionalInterface
public interface TabularRowProducer {
void produce(TabularRowSink sink);
}
@@ -0,0 +1,9 @@
package dev.caskeleton.application.filepublication;
/** Attempt-scoped non-thread-safe sink owned by the file publication adapter. */
public interface TabularRowSink {
void write(TabularRow row);
void checkpoint();
}
@@ -0,0 +1,15 @@
package dev.caskeleton.application.observability;
import java.util.Optional;
/**
* Reads the correlation identifier associated with the current application invocation.
*
* <p>Implementations own transport or diagnostic storage. They return {@link Optional#empty()} when
* no non-blank correlation identifier is available.
*/
@FunctionalInterface
public interface CorrelationIdPort {
Optional<String> currentCorrelationId();
}
@@ -0,0 +1,46 @@
package dev.caskeleton.application.outbound;
import java.time.Duration;
import java.util.Objects;
/**
* Absolute monotonic deadline carrier for nested application calls. It is process-local and must
* never be serialized as a wall-clock timestamp.
*/
public record CallBudget(long monotonicDeadlineNanos) {
private static final Duration MAXIMUM_BUDGET = Duration.ofDays(365);
public static CallBudget fromNow(Duration duration) {
return after(System.nanoTime(), duration);
}
public static CallBudget after(long monotonicNowNanos, Duration duration) {
Objects.requireNonNull(duration, "duration must be non-null");
if (duration.isZero() || duration.isNegative() || duration.compareTo(MAXIMUM_BUDGET) > 0) {
throw new IllegalArgumentException("call budget duration must be in (0, 365 days]");
}
long durationNanos;
try {
durationNanos = duration.toNanos();
} catch (ArithmeticException exception) {
throw new IllegalArgumentException(
"call budget duration exceeds the supported range", exception);
}
return new CallBudget(monotonicNowNanos + durationNanos);
}
public long remainingNanosAt(long monotonicNowNanos) {
long remaining = monotonicDeadlineNanos - monotonicNowNanos;
return remaining > 0 ? remaining : 0;
}
public boolean isExpiredAt(long monotonicNowNanos) {
return monotonicDeadlineNanos - monotonicNowNanos <= 0;
}
public CallBudget intersect(CallBudget other) {
Objects.requireNonNull(other, "other must be non-null");
return monotonicDeadlineNanos - other.monotonicDeadlineNanos <= 0 ? this : other;
}
}
@@ -0,0 +1,88 @@
package dev.caskeleton.application.outbox;
import dev.caskeleton.shared.error.OperationalError;
import java.time.Instant;
import java.util.Objects;
/**
* Safe immutable allowlist for reporting a confirmed FAILED or DEAD outbox relay transition.
*
* <p>The report deliberately cannot carry an event payload, idempotency key, rendered message,
* severity, arbitrary fields, or the whole {@link OutboxEvent}.
*/
public record OutboxRelayFailureReport(
OperationalError code,
String eventId,
String eventType,
String aggregateId,
String correlationId,
int attemptCount,
Instant nextAttemptAt,
RuntimeException cause) {
public OutboxRelayFailureReport {
Objects.requireNonNull(code, "code must not be null");
requireNonBlank(eventId, "eventId");
requireNonBlank(eventType, "eventType");
requireNonBlank(aggregateId, "aggregateId");
requireNonBlank(correlationId, "correlationId");
Objects.requireNonNull(cause, "cause must not be null");
if (attemptCount < 1) {
throw new IllegalArgumentException("attemptCount must be >= 1, was " + attemptCount);
}
if (code == OperationalError.OUTBOX_PUBLISH_FAILED) {
if (nextAttemptAt == null) {
throw new IllegalArgumentException("nextAttemptAt is required for OUTBOX_PUBLISH_FAILED");
}
} else if (code == OperationalError.OUTBOX_DEAD_LETTER) {
if (nextAttemptAt != null) {
throw new IllegalArgumentException("nextAttemptAt is forbidden for OUTBOX_DEAD_LETTER");
}
} else {
throw new IllegalArgumentException("unsupported outbox relay failure code: " + code);
}
}
public static OutboxRelayFailureReport retryableFailure(
String eventId,
String eventType,
String aggregateId,
String correlationId,
int attemptCount,
Instant nextAttemptAt,
RuntimeException cause) {
return new OutboxRelayFailureReport(
OperationalError.OUTBOX_PUBLISH_FAILED,
eventId,
eventType,
aggregateId,
correlationId,
attemptCount,
nextAttemptAt,
cause);
}
public static OutboxRelayFailureReport deadLetter(
String eventId,
String eventType,
String aggregateId,
String correlationId,
int attemptCount,
RuntimeException cause) {
return new OutboxRelayFailureReport(
OperationalError.OUTBOX_DEAD_LETTER,
eventId,
eventType,
aggregateId,
correlationId,
attemptCount,
null,
cause);
}
private static void requireNonBlank(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
}
}
@@ -0,0 +1,13 @@
package dev.caskeleton.application.outbox;
/**
* Reports a confirmed outbox relay failure transition to an operational diagnostics adapter.
*
* <p>Implementations must not throw. Callers still defend against {@link RuntimeException} so a
* diagnostic failure can never change the authoritative persisted relay outcome.
*/
@FunctionalInterface
public interface OutboxRelayFailureReportPort {
void report(OutboxRelayFailureReport report);
}
@@ -14,17 +14,17 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.function.Supplier;
/**
* 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.
* result. Confirmed failure transitions are reported through a typed outbound port and publish
* failures are 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(
@@ -35,11 +35,9 @@ import org.slf4j.LoggerFactory;
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 OutboxRelayFailureReportPort failureReporter;
private final TransactionPort tx;
private final OutboxBackoffPolicy backoffPolicy;
private final Clock clock;
@@ -52,6 +50,7 @@ public final class PublishPendingOutboxEventsUseCase
*
* @param store outbox store port (claim + status update)
* @param publishPort fail-closed broker publish port
* @param failureReporter diagnostics port for confirmed FAILED/DEAD transitions
* @param tx transaction port for short write boundaries
* @param backoffPolicy retry backoff policy
* @param clock wall-clock source (injected for testability)
@@ -61,6 +60,7 @@ public final class PublishPendingOutboxEventsUseCase
public PublishPendingOutboxEventsUseCase(
OutboxStorePort store,
OutboxMessagePublishPort publishPort,
OutboxRelayFailureReportPort failureReporter,
TransactionPort tx,
OutboxBackoffPolicy backoffPolicy,
Clock clock,
@@ -68,6 +68,8 @@ public final class PublishPendingOutboxEventsUseCase
Duration inFlightTimeout) {
this.store = Objects.requireNonNull(store, "store must not be null");
this.publishPort = Objects.requireNonNull(publishPort, "publishPort must not be null");
this.failureReporter =
Objects.requireNonNull(failureReporter, "failureReporter 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");
@@ -116,7 +118,7 @@ public final class PublishPendingOutboxEventsUseCase
try {
publishPort.publish(event);
} catch (RuntimeException publishEx) {
// Publish failure: drive FAILED/DEAD state machine + ERROR log; do NOT rethrow.
// Publish failure: drive FAILED/DEAD state machine + typed report; do NOT rethrow.
return handlePublishFailure(event, now, publishEx);
}
// markPublished failure (if any) propagates: the row stays IN_FLIGHT and is
@@ -126,9 +128,9 @@ public final class PublishPendingOutboxEventsUseCase
}
/**
* 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.
* Drives the FAILED/DEAD state transition and reports it only after persistence succeeds. A
* transition failure remains authoritative and propagates without a report. A diagnostic adapter
* failure is contained and cannot change the persisted outcome. See README.
*/
private OutboxRelayResult.Outcome handlePublishFailure(
OutboxEvent event, Instant now, RuntimeException cause) {
@@ -136,34 +138,39 @@ public final class PublishPendingOutboxEventsUseCase
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);
reportFailure(
() ->
OutboxRelayFailureReport.deadLetter(
event.eventId(),
event.eventType(),
event.aggregateId(),
event.correlationId(),
event.attemptCount(),
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);
reportFailure(
() ->
OutboxRelayFailureReport.retryableFailure(
event.eventId(),
event.eventType(),
event.aggregateId(),
event.correlationId(),
event.attemptCount(),
nextAttemptAt,
cause));
return OutboxRelayResult.Outcome.FAILED;
}
}
private void reportFailure(Supplier<OutboxRelayFailureReport> reportFactory) {
try {
failureReporter.report(reportFactory.get());
} catch (RuntimeException ignored) {
// Report construction and delivery are non-authoritative. The persisted transition remains.
}
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.application.cache;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
class CacheRegionContractTest {
@Test
void keepsMissNegativeHitAndUnavailableDistinct() {
CacheLookup<String> miss = new CacheLookup.Miss<>(CacheLookup.MissReason.ABSENT);
CacheLookup<String> negative = new CacheLookup.NegativeHit<>(AuthoritativeAbsence.NOT_FOUND);
CacheLookup<String> unavailable =
new CacheLookup.Unavailable<>(
CacheLookup.UnavailabilityReason.OVERLOADED,
CacheLookup.OperationCertainty.NOT_APPLIED);
assertThat(miss).isInstanceOf(CacheLookup.Miss.class);
assertThat(negative).isInstanceOf(CacheLookup.NegativeHit.class);
assertThat(unavailable).isInstanceOf(CacheLookup.Unavailable.class);
}
@Test
void hitCarriesFreshnessAndSourceRevisionWithoutProviderTypes() {
CacheLookup.Hit<String> hit =
new CacheLookup.Hit<>("snapshot", CacheLookup.Freshness.STALE, "source-42");
assertThat(hit.value()).isEqualTo("snapshot");
assertThat(hit.freshness()).isEqualTo(CacheLookup.Freshness.STALE);
assertThat(hit.sourceRevision()).isEqualTo("source-42");
}
@Test
void metadataRejectsBlankRevision() {
assertThatThrownBy(
() -> new CacheRecordMetadata(" ", CacheRecordIntent.ONLY_IF_SOURCE_REVISION_NEWER))
.isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,99 @@
package dev.caskeleton.application.filepublication;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import org.junit.jupiter.api.Test;
class FilePublicationContractTest {
@Test
void logicalFileNameRejectsPathSyntax() {
assertThatThrownBy(() -> new LogicalFileName("../report.csv"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LogicalFileName("nested/report.csv"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LogicalFileName("nested\\report.csv"))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void schemaRejectsDuplicateColumnNames() {
assertThatThrownBy(
() ->
new ExportSchema(
"worklog-v1",
1,
List.of(
new ExportSchema.Column(
"id",
ExportSchema.CellType.INTEGER,
false,
ExportSchema.FormulaPolicy.REJECT,
64),
new ExportSchema.Column(
"id",
ExportSchema.CellType.TEXT,
false,
ExportSchema.FormulaPolicy.MITIGATE,
128))))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("duplicate");
}
@Test
void schemaDefensivelyCopiesColumns() {
ExportSchema schema =
new ExportSchema(
"worklog-v1",
1,
List.of(
new ExportSchema.Column(
"id",
ExportSchema.CellType.INTEGER,
false,
ExportSchema.FormulaPolicy.REJECT,
64)));
assertThat(schema.columns()).hasSize(1);
assertThatThrownBy(
() ->
schema
.columns()
.add(
new ExportSchema.Column(
"other",
ExportSchema.CellType.TEXT,
true,
ExportSchema.FormulaPolicy.MITIGATE,
128)))
.isInstanceOf(UnsupportedOperationException.class);
}
@Test
void requestRequiresRegisteredIdentifiersAndFormatProfile() {
ExportSchema schema =
new ExportSchema(
"worklog-v1",
1,
List.of(
new ExportSchema.Column(
"id",
ExportSchema.CellType.INTEGER,
false,
ExportSchema.FormulaPolicy.REJECT,
64)));
FilePublishRequest request =
new FilePublishRequest(
new FilePublishOperationId("01J1234567890ABCDEFGHJKMNP"),
new FileDestinationId("local-export"),
new LogicalFileName("worklogs"),
new SourceRevision("snapshot-42"),
schema,
"csv-rfc4180-v1");
assertThat(request.formatProfileId()).isEqualTo("csv-rfc4180-v1");
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.application.observability;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class CorrelationIdPortTest {
@Test
void exposesPresentCorrelationIdWithoutAFrameworkType() {
CorrelationIdPort port = () -> Optional.of("corr-123");
assertThat(port.currentCorrelationId()).contains("corr-123");
}
@Test
void exposesAbsenceExplicitly() {
CorrelationIdPort port = Optional::empty;
assertThat(port.currentCorrelationId()).isEmpty();
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.application.outbound;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class CallBudgetTest {
@Test
void measuresRemainingTimeInTheMonotonicDomain() {
CallBudget budget = CallBudget.after(1_000, Duration.ofNanos(250));
assertThat(budget.remainingNanosAt(1_100)).isEqualTo(150);
assertThat(budget.isExpiredAt(1_249)).isFalse();
assertThat(budget.isExpiredAt(1_250)).isTrue();
assertThat(budget.remainingNanosAt(1_300)).isZero();
}
@Test
void childBudgetCannotOutliveItsParent() {
CallBudget parent = CallBudget.after(1_000, Duration.ofNanos(200));
CallBudget longerChild = CallBudget.after(1_050, Duration.ofNanos(500));
assertThat(parent.intersect(longerChild)).isEqualTo(parent);
}
@Test
void rejectsNonPositiveAndUnreasonablyLargeDurations() {
assertThatThrownBy(() -> CallBudget.after(1_000, Duration.ZERO))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> CallBudget.after(1_000, Duration.ofDays(366)))
.isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,147 @@
package dev.caskeleton.application.outbox;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.shared.error.OperationalError;
import java.lang.reflect.RecordComponent;
import java.time.Instant;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
class OutboxRelayFailureReportTest {
private static final Instant NEXT_ATTEMPT_AT = Instant.parse("2026-07-25T01:02:03Z");
private static final RuntimeException CAUSE = new RuntimeException("broker unavailable");
@Test
void recordComponentsAreAnExactSafeAllowlist() {
assertThat(
Arrays.stream(OutboxRelayFailureReport.class.getRecordComponents())
.map(RecordComponent::getName))
.containsExactly(
"code",
"eventId",
"eventType",
"aggregateId",
"correlationId",
"attemptCount",
"nextAttemptAt",
"cause");
}
@Test
void retryableFailureFactoryCreatesPublishFailedReport() {
OutboxRelayFailureReport report =
OutboxRelayFailureReport.retryableFailure(
"evt-1", "WorkLogReserved", "agg-1", "corr-1", 2, NEXT_ATTEMPT_AT, CAUSE);
assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_PUBLISH_FAILED);
assertThat(report.eventId()).isEqualTo("evt-1");
assertThat(report.eventType()).isEqualTo("WorkLogReserved");
assertThat(report.aggregateId()).isEqualTo("agg-1");
assertThat(report.correlationId()).isEqualTo("corr-1");
assertThat(report.attemptCount()).isEqualTo(2);
assertThat(report.nextAttemptAt()).isEqualTo(NEXT_ATTEMPT_AT);
assertThat(report.cause()).isSameAs(CAUSE);
}
@Test
void deadLetterFactoryCreatesTerminalReportWithoutRetryTime() {
OutboxRelayFailureReport report =
OutboxRelayFailureReport.deadLetter(
"evt-1", "WorkLogReserved", "agg-1", "corr-1", 3, CAUSE);
assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_DEAD_LETTER);
assertThat(report.nextAttemptAt()).isNull();
assertThat(report.cause()).isSameAs(CAUSE);
}
@Test
void rejectsUnsupportedCode() {
assertThatThrownBy(
() ->
new OutboxRelayFailureReport(
OperationalError.INTERNAL_ERROR,
"evt-1",
"Event",
"agg-1",
"corr-1",
1,
null,
CAUSE))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("code");
}
@Test
void rejectsBlankIdentifiersAndEventType() {
assertThatThrownBy(
() -> OutboxRelayFailureReport.deadLetter(" ", "Event", "agg-1", "corr-1", 1, CAUSE))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("eventId");
assertThatThrownBy(
() -> OutboxRelayFailureReport.deadLetter("evt-1", "", "agg-1", "corr-1", 1, CAUSE))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("eventType");
assertThatThrownBy(
() -> OutboxRelayFailureReport.deadLetter("evt-1", "Event", "\t", "corr-1", 1, CAUSE))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("aggregateId");
assertThatThrownBy(
() -> OutboxRelayFailureReport.deadLetter("evt-1", "Event", "agg-1", "\n", 1, CAUSE))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("correlationId");
}
@Test
void rejectsAttemptCountBelowOne() {
assertThatThrownBy(
() ->
OutboxRelayFailureReport.deadLetter("evt-1", "Event", "agg-1", "corr-1", 0, CAUSE))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("attemptCount");
}
@Test
void retryableFailureRequiresNextAttemptAt() {
assertThatThrownBy(
() ->
new OutboxRelayFailureReport(
OperationalError.OUTBOX_PUBLISH_FAILED,
"evt-1",
"Event",
"agg-1",
"corr-1",
1,
null,
CAUSE))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("nextAttemptAt");
}
@Test
void deadLetterForbidsNextAttemptAt() {
assertThatThrownBy(
() ->
new OutboxRelayFailureReport(
OperationalError.OUTBOX_DEAD_LETTER,
"evt-1",
"Event",
"agg-1",
"corr-1",
1,
NEXT_ATTEMPT_AT,
CAUSE))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("nextAttemptAt");
}
@Test
void causeIsRequired() {
assertThatThrownBy(
() -> OutboxRelayFailureReport.deadLetter("evt-1", "Event", "agg-1", "corr-1", 1, null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("cause");
}
}
@@ -26,7 +26,7 @@ import org.junit.jupiter.api.Test;
* <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>Failure is NOT swallowed (status transition + typed report attempt required)
* <li>Events processed in {@code occurredAt} ascending order
* </ul>
*/
@@ -38,6 +38,7 @@ class PublishPendingOutboxEventsUseCaseTest {
private FakeOutboxStorePort store;
private FakeOutboxMessagePublishPort publishPort;
private RecordingFailureReporter reporter;
private FakeTransactionPort tx;
private OutboxBackoffPolicy backoffPolicy;
private Clock clock;
@@ -47,13 +48,14 @@ class PublishPendingOutboxEventsUseCaseTest {
void setUp() {
store = new FakeOutboxStorePort();
publishPort = new FakeOutboxMessagePublishPort();
reporter = new RecordingFailureReporter();
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);
store, publishPort, reporter, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT);
}
// ---- success path ----
@@ -71,6 +73,7 @@ class PublishPendingOutboxEventsUseCaseTest {
.isEqualTo(OutboxRelayResult.Outcome.PUBLISHED);
assertThat(store.publishedEvents).containsExactly("evt-1");
assertThat(publishPort.publishedEvents).containsExactly("evt-1");
assertThat(reporter.reports).isEmpty();
}
@Test
@@ -106,6 +109,16 @@ class PublishPendingOutboxEventsUseCaseTest {
assertThat(nextAttemptAt).isAfter(NOW);
// Not yet DEAD (attempt 1 < maxAttempts 3)
assertThat(store.deadEvents).doesNotContain("evt-fail");
assertThat(reporter.reports)
.containsExactly(
OutboxRelayFailureReport.retryableFailure(
"evt-fail",
"UserCreated",
"agg-1",
"corr-evt-fail",
1,
nextAttemptAt,
publishPort.failureOn("evt-fail")));
}
@Test
@@ -120,6 +133,15 @@ class PublishPendingOutboxEventsUseCaseTest {
assertThat(result.outcomes().getFirst().outcome()).isEqualTo(OutboxRelayResult.Outcome.DEAD);
assertThat(store.deadEvents).contains("evt-dead");
assertThat(store.failedEvents).doesNotContainKey("evt-dead");
assertThat(reporter.reports)
.containsExactly(
OutboxRelayFailureReport.deadLetter(
"evt-dead",
"UserCreated",
"agg-1",
"corr-evt-dead",
3,
publishPort.failureOn("evt-dead")));
}
// ---- failure NOT swallowed ----
@@ -207,7 +229,14 @@ class PublishPendingOutboxEventsUseCaseTest {
PublishPendingOutboxEventsUseCase useCaseWithThrowingStore =
new PublishPendingOutboxEventsUseCase(
throwingStore, publishPort, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT);
throwingStore,
publishPort,
reporter,
tx,
backoffPolicy,
clock,
BATCH_SIZE,
IN_FLIGHT_TIMEOUT);
// The exception must propagate — handle() must throw.
assertThatThrownBy(
@@ -225,6 +254,7 @@ class PublishPendingOutboxEventsUseCaseTest {
assertThat(throwingStore.deadEvents)
.as("markDead must NOT be called when only markPublished fails")
.doesNotContain("evt-store-fail");
assertThat(reporter.reports).isEmpty();
}
/**
@@ -248,7 +278,14 @@ class PublishPendingOutboxEventsUseCaseTest {
PublishPendingOutboxEventsUseCase useCaseWithThrowingStore =
new PublishPendingOutboxEventsUseCase(
throwingStore, publishPort, tx, backoffPolicy, clock, BATCH_SIZE, IN_FLIGHT_TIMEOUT);
throwingStore,
publishPort,
reporter,
tx,
backoffPolicy,
clock,
BATCH_SIZE,
IN_FLIGHT_TIMEOUT);
assertThatThrownBy(
() -> useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE))
@@ -266,6 +303,110 @@ class PublishPendingOutboxEventsUseCaseTest {
// No FAILED or DEAD misclassification for the second event either.
assertThat(throwingStore.failedEvents).doesNotContainKey("evt-second");
assertThat(throwingStore.deadEvents).doesNotContain("evt-second");
assertThat(reporter.reports).isEmpty();
}
@Test
void markFailedFailurePropagatesAndEmitsNoReport() {
OutboxEvent event = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
ThrowingTransitionStorePort throwingStore =
new ThrowingTransitionStorePort(new RuntimeException("markFailed failed"), null);
throwingStore.addClaimable(event);
publishPort.failOn("evt-fail", new RuntimeException("broker down"));
PublishPendingOutboxEventsUseCase throwingUseCase =
new PublishPendingOutboxEventsUseCase(
throwingStore,
publishPort,
reporter,
tx,
backoffPolicy,
clock,
BATCH_SIZE,
IN_FLIGHT_TIMEOUT);
assertThatThrownBy(() -> throwingUseCase.handle(PublishPendingOutboxEventsCommand.INSTANCE))
.hasMessage("markFailed failed");
assertThat(reporter.reports).isEmpty();
}
@Test
void markDeadFailurePropagatesAndEmitsNoReport() {
OutboxEvent event = makeEvent("evt-dead", "UserCreated", "agg-1", NOW.minusSeconds(60), 3);
ThrowingTransitionStorePort throwingStore =
new ThrowingTransitionStorePort(null, new RuntimeException("markDead failed"));
throwingStore.addClaimable(event);
publishPort.failOn("evt-dead", new RuntimeException("broker down"));
PublishPendingOutboxEventsUseCase throwingUseCase =
new PublishPendingOutboxEventsUseCase(
throwingStore,
publishPort,
reporter,
tx,
backoffPolicy,
clock,
BATCH_SIZE,
IN_FLIGHT_TIMEOUT);
assertThatThrownBy(() -> throwingUseCase.handle(PublishPendingOutboxEventsCommand.INSTANCE))
.hasMessage("markDead failed");
assertThat(reporter.reports).isEmpty();
}
@Test
void throwingReporterPreservesFailedOutcomeAndRelayContinues() {
OutboxEvent failed = makeEvent("evt-fail", "UserCreated", "agg-1", NOW.minusSeconds(120), 1);
OutboxEvent succeeded = makeEvent("evt-ok", "UserUpdated", "agg-1", NOW.minusSeconds(60), 1);
store.addClaimable(failed);
store.addClaimable(succeeded);
publishPort.failOn("evt-fail", new RuntimeException("broker down"));
OutboxRelayFailureReportPort throwingReporter =
ignored -> {
throw new RuntimeException("reporter failed");
};
PublishPendingOutboxEventsUseCase useCaseWithThrowingReporter =
new PublishPendingOutboxEventsUseCase(
store,
publishPort,
throwingReporter,
tx,
backoffPolicy,
clock,
BATCH_SIZE,
IN_FLIGHT_TIMEOUT);
OutboxRelayResult result =
useCaseWithThrowingReporter.handle(PublishPendingOutboxEventsCommand.INSTANCE);
assertThat(result.outcomes())
.extracting(OutboxRelayResult.EventOutcome::outcome)
.containsExactly(OutboxRelayResult.Outcome.FAILED, OutboxRelayResult.Outcome.PUBLISHED);
assertThat(store.failedEvents).containsKey("evt-fail");
assertThat(store.publishedEvents).containsExactly("evt-ok");
}
@Test
void malformedReportDataPreservesFailedAndDeadOutcomesAndRelayContinues() {
OutboxEvent failed = makeEvent("evt-fail", "UserCreated", " ", NOW.minusSeconds(180), 1);
OutboxEvent dead = makeEvent("evt-dead", "UserDeleted", " ", NOW.minusSeconds(120), 3);
OutboxEvent succeeded = makeEvent("evt-ok", "UserUpdated", "agg-1", NOW.minusSeconds(60), 1);
store.addClaimable(failed);
store.addClaimable(dead);
store.addClaimable(succeeded);
publishPort.failOn("evt-fail", new RuntimeException("transient broker failure"));
publishPort.failOn("evt-dead", new RuntimeException("persistent broker failure"));
OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
assertThat(result.outcomes())
.extracting(OutboxRelayResult.EventOutcome::outcome)
.containsExactly(
OutboxRelayResult.Outcome.FAILED,
OutboxRelayResult.Outcome.DEAD,
OutboxRelayResult.Outcome.PUBLISHED);
assertThat(store.failedEvents).containsKey("evt-fail");
assertThat(store.deadEvents).containsExactly("evt-dead");
assertThat(store.publishedEvents).containsExactly("evt-ok");
assertThat(reporter.reports).isEmpty();
}
// ---- helper ----
@@ -346,6 +487,33 @@ class PublishPendingOutboxEventsUseCaseTest {
}
}
static final class ThrowingTransitionStorePort extends FakeOutboxStorePort {
private final RuntimeException markFailedException;
private final RuntimeException markDeadException;
ThrowingTransitionStorePort(
RuntimeException markFailedException, RuntimeException markDeadException) {
this.markFailedException = markFailedException;
this.markDeadException = markDeadException;
}
@Override
public void markFailed(String eventId, Instant nextAttemptAt) {
if (markFailedException != null) {
throw markFailedException;
}
super.markFailed(eventId, nextAttemptAt);
}
@Override
public void markDead(String eventId) {
if (markDeadException != null) {
throw markDeadException;
}
super.markDead(eventId);
}
}
static final class FakeOutboxMessagePublishPort implements OutboxMessagePublishPort {
final List<String> publishedEvents = new ArrayList<>();
private final Map<String, RuntimeException> failureMap = new LinkedHashMap<>();
@@ -354,6 +522,10 @@ class PublishPendingOutboxEventsUseCaseTest {
failureMap.put(eventId, ex);
}
RuntimeException failureOn(String eventId) {
return failureMap.get(eventId);
}
@Override
public void publish(OutboxEvent event) {
if (failureMap.containsKey(event.eventId())) {
@@ -363,6 +535,15 @@ class PublishPendingOutboxEventsUseCaseTest {
}
}
static final class RecordingFailureReporter implements OutboxRelayFailureReportPort {
final List<OutboxRelayFailureReport> reports = new ArrayList<>();
@Override
public void report(OutboxRelayFailureReport report) {
reports.add(report);
}
}
static final class FakeTransactionPort implements TransactionPort {
@Override
public <T> T inWrite(Supplier<T> action) {