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
@@ -0,0 +1,28 @@
# adapter:outbound:cache-redis — cache and Redis adapter
## Registered identity
- Module ID: `adapter-outbound-cache-redis`
- Gradle path: `:adapter:outbound:cache-redis`
- Focused test: `./gradlew :adapter:outbound:cache-redis: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.adapter.outbound.cache`.
## Responsibility
- Implement cache stores, routing, Redis capability, and fail-open technical behavior behind ports.
- Own cache binding settings and Redis client adaptation.
- Reuse `adapter:outbound:support` for shared outbound concerns.
## Boundaries
- Allowed dependency edges come only from `.harness/project/modules.yaml`.
- No inbound transport, persistence entity/repository, bootstrap, or sample dependency.
- Cache adapters do not decide business freshness, entitlement, or domain fallback rules.
## Tests
Use fake Redis clients and contract tests for routing/fail-open behavior. Do not use a real network in
focused tests; configuration changes include binding/validation coverage.
@@ -0,0 +1,37 @@
# adapter:outbound:cache-redis — 설계 결정 참조
캐시 아웃바운드 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.outbound.cache`(`core` 서브
패키지에 라우팅/SPI 추상화, `redis` 서브패키지에 Redis 바인딩). `:adapter:outbound:support`
의존해 공유 correlation / fail-open 의존성 로깅을 재사용한다.
허용/금지 의존 정책은 `src/build.gradle`
`allowedProjectDependencies['adapter:outbound:cache-redis']` 항목이 SSOT 다(이 모듈은 아직 별도
CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용
기록이다.
## 모듈 개요
application-core 포트 뒤에 두는 **선택형** 캐시 어댑터다. `@ConditionalOnProperty`
(`APP_CACHE_REDIS_ENABLED`)로 게이팅되고 기본 비활성이다. `core` 서브패키지는 라우팅/SPI 추상화만
갖고, `redis` 서브패키지가 이 모듈이 기본 제공하는 유일한 구체 백엔드(`RedisCacheAdapterConfig`
/ `RedisCacheStore`)다. 다른 벤더 백엔드가 필요하면 `CacheBackend` SPI 를 구현해 빈으로 추가한다.
## 중앙 fail-open 합성
`FailOpenCacheStore` 데코레이터는 `CacheRouterConfig` 가 모든 `CacheBackend`**중앙에서**
적용한다 — 백엔드 설정이 실수로 fail-open 정책을 빠뜨릴 수 없다. 백엔드 실패는 cache-miss 로
다운그레이드돼 외부 장애가 5xx 로 번지지 않는다. 바인딩되지 않은 논리 이름은 설정 오류이며
라우터에서 fail-fast 한다(Layer 3).
## 기여 계약은 `CacheBackend`, SPI 는 `CacheStore`
기여(contribution) 타입을 `CacheStore` 가 아닌 `CacheBackend` 로 둔 건 의도적이다 — 임의의
`CacheStore` 빈이 실수로 라우팅되지 않게 하고, 타입이 IDE 탐색 가능하며 중복 id 는 startup 을
실패시킨다. `CacheStore.get()``Optional.empty()` 는 miss 를 뜻한다(SDK 타입이 어댑터 밖으로
새지 않게 — B7).
## 라우팅 바인딩
논리 캐시 이름 → 백엔드는 `app.cache.bindings.<name>=<backendId>` 로 선택하며, `backendId`
`CacheBackend#backendId()` 에서 온다. 백엔드는 `@ConditionalOnProperty` 게이팅 config(예:
`RedisCacheAdapterConfig`)가 `CacheBackend` 빈으로 기여한다.
@@ -0,0 +1,14 @@
plugins { id 'groovy' }
dependencies {
implementation project(':domain-core')
implementation project(':application-core')
implementation project(':shared-contract')
implementation project(':adapter:outbound:support')
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.slf4j:slf4j-api'
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
}
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
@@ -0,0 +1,156 @@
# 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=testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=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=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,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=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.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
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=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.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
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=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=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=testCompileClasspath,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=testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=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=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.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
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=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=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=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,24 @@
package dev.caskeleton.adapter.outbound.cache;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Logical-cache-name → backendId bindings bound from {@code app.cache.bindings.*} (relaxed binding
* also accepts env keys, e.g. {@code APP_CACHE_BINDINGS_WORKLOG=redis}).
*
* <p>Example: {@code app.cache.bindings.worklog=redis} routes {@code
* CacheStoreRouter.get("worklog", key)} to the backend whose {@link CacheBackend#backendId()} is
* {@code redis}. Absent keys default to an empty map so the cache template stays a non-required
* optional module. Binding consistency (every referenced backendId has an enabled backend) is
* validated fail-fast by {@code CacheStoreRouter} at startup.
*
* @param bindings logical cache name → backendId (default empty)
*/
@ConfigurationProperties(prefix = "app.cache")
public record CacheBindingSettings(Map<String, String> bindings) {
public CacheBindingSettings {
bindings = (bindings == null) ? Map.of() : Map.copyOf(bindings);
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.outbound.cache;
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
import dev.caskeleton.adapter.outbound.cache.core.CacheStoreRouter;
import dev.caskeleton.adapter.outbound.cache.core.FailOpenCacheStore;
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Assembles the {@link CacheStoreRouter} from every contributed {@link CacheBackend} bean. Adding a
* backend is new files only — this config and the router never change. The fail-open policy is
* applied here, centrally, by wrapping every backend in {@link FailOpenCacheStore}, so a backend
* config cannot forget it.
*/
@Configuration
@EnableConfigurationProperties(CacheBindingSettings.class)
public class CacheRouterConfig {
@Bean
public CacheStoreRouter cacheStoreRouter(
ObjectProvider<List<CacheBackend>> backends,
CacheBindingSettings settings,
FailOpenDependencyLogger failOpenDependencyLogger) {
List<FailOpenCacheStore> failOpenBackends =
backends.getIfAvailable(List::of).stream()
.map(backend -> new FailOpenCacheStore(backend, failOpenDependencyLogger))
.toList();
return new CacheStoreRouter(failOpenBackends, settings.bindings());
}
}
@@ -0,0 +1,12 @@
package dev.caskeleton.adapter.outbound.cache.core;
/**
* Cache backend contribution contract. A backend opts into routing by registering a bean of this
* interface; {@link #backendId()} is the identifier referenced by {@code
* app.cache.bindings.<logicalName>} values.
*/
public interface CacheBackend extends CacheStore {
/** Stable backend identifier referenced by {@code app.cache.bindings.*} values. */
String backendId();
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.outbound.cache.core;
/**
* Unchecked wrapper a cache backend binding throws when its integration client fails (the seam
* interfaces declare {@code throws Exception}, but {@link CacheStore} does not). The {@link
* FailOpenCacheStore} decorator catches it and applies the fail-open cache-miss contract — backend
* bindings must propagate failures, never swallow them, so a backend outage is observable and
* cannot be mistaken for a miss.
*/
public class CacheBackendException extends RuntimeException {
private static final long serialVersionUID = 1L;
public CacheBackendException(String backendId, Throwable cause) {
super("cache backend '" + backendId + "' access failed", cause);
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.outbound.cache.core;
import java.util.Optional;
/**
* Per-backend cache SPI for the optional adapter template. Consumers do not inject this type
* directly — they call {@link CacheStoreRouter} with a logical cache name. {@link #get(String)}
* returns {@link Optional#empty()} on a miss (so no cache SDK type escapes the adapter — B7).
* Routing and fail-open composition rationale is in the module README.
*/
public interface CacheStore {
/**
* Reads a cached value. {@link Optional#empty()} == miss (or a degraded backend's fail-open
* downgrade).
*/
Optional<String> get(String key);
/**
* Writes a value. Backend failures are handled fail-open by the central {@link
* FailOpenCacheStore}.
*/
void put(String key, String value);
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.cache.core;
import dev.caskeleton.shared.error.AdapterDisabledException;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* Routes logical cache names to contributed {@link CacheBackend}s ({@code
* app.cache.bindings.<logicalName>=<backendId>}). The Layer 3 fail-fast contract lives here: a
* duplicate backendId or a binding to a backendId with no enabled backend fails construction, and
* {@code get}/{@code put} on an unbound logical name throws {@link AdapterDisabledException} (never
* a silent no-op). With no backends and no bindings it constructs cleanly, so the cache template
* never becomes a required dependency. It does not expose the resolved {@link CacheStore}, so no
* adapter type escapes via a public return (B7).
*/
public final class CacheStoreRouter {
private static final String ADAPTER_NAME = "cache";
private final Map<String, CacheStore> backends;
private final Map<String, String> bindings;
public CacheStoreRouter(
Collection<? extends CacheBackend> backends, Map<String, String> bindings) {
Map<String, CacheStore> byId = new HashMap<>();
for (CacheBackend backend : backends) {
CacheStore previous = byId.putIfAbsent(backend.backendId(), backend);
if (previous != null) {
throw new IllegalStateException(
"duplicate cache backendId '"
+ backend.backendId()
+ "' — every contributed CacheBackend bean must have a unique backendId");
}
}
this.backends = Map.copyOf(byId);
this.bindings = Map.copyOf(bindings);
for (Map.Entry<String, String> binding : this.bindings.entrySet()) {
if (!this.backends.containsKey(binding.getValue())) {
throw new IllegalStateException(
"app.cache.bindings."
+ binding.getKey()
+ "="
+ binding.getValue()
+ " references cache backend '"
+ binding.getValue()
+ "' but no enabled backend contributes that id — enable the backend"
+ " (e.g. app.cache."
+ binding.getValue()
+ ".enabled=true)"
+ " or fix the binding");
}
}
}
/** Reads from the backend bound to {@code logicalName} (empty == miss). */
public Optional<String> get(String logicalName, String key) {
return resolve(logicalName).get(key);
}
/** Writes to the backend bound to {@code logicalName}. */
public void put(String logicalName, String key, String value) {
resolve(logicalName).put(key, value);
}
private CacheStore resolve(String logicalName) {
String backendId = bindings.get(logicalName);
if (backendId == null) {
throw new AdapterDisabledException(
ADAPTER_NAME,
"no cache backend bound for logical cache '"
+ logicalName
+ "' — set app.cache.bindings."
+ logicalName
+ "=<backendId> and enable that backend"
+ " (integration-adapter-templates Layer 3)");
}
return backends.get(backendId);
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.adapter.outbound.cache.core;
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
import java.util.Optional;
/**
* Fail-open decorator: a cache backend outage degrades to a miss ({@code get} → empty, {@code put}
* swallowed), never a 5xx. Applied centrally by {@code CacheRouterConfig}.
*/
public final class FailOpenCacheStore implements CacheBackend {
private static final String DEPENDENCY_TYPE = "cache";
private final CacheBackend delegate;
private final FailOpenDependencyLogger dependencyLogger;
public FailOpenCacheStore(CacheBackend delegate, FailOpenDependencyLogger dependencyLogger) {
this.delegate = delegate;
this.dependencyLogger = dependencyLogger;
}
@Override
public String backendId() {
return delegate.backendId();
}
@Override
public Optional<String> get(String key) {
try {
Optional<String> value = delegate.get(key);
dependencyLogger.logSuccess(delegate.backendId(), DEPENDENCY_TYPE, "get");
return value;
} catch (Exception ex) {
// fail-open: an unavailable backend degrades to a cache-miss, not a 5xx.
dependencyLogger.logFailure(delegate.backendId(), DEPENDENCY_TYPE, "get", ex);
return Optional.empty();
}
}
@Override
public void put(String key, String value) {
try {
delegate.put(key, value);
dependencyLogger.logSuccess(delegate.backendId(), DEPENDENCY_TYPE, "put");
} catch (Exception ex) {
// fail-open: a failed cache write is observed, not propagated.
dependencyLogger.logFailure(delegate.backendId(), DEPENDENCY_TYPE, "put", ex);
}
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Layer 1 bean-gating for the Redis cache backend. The {@code app.cache.redis.enabled} flag (env
* {@code APP_CACHE_REDIS_ENABLED}, default false) decides whether this backend <em>contributes</em>
* a {@link CacheBackend} bean whose {@link CacheBackend#backendId()} is {@link
* RedisCacheStore#BACKEND_ID} — the id that {@code app.cache.bindings.<logicalName>=redis} routes
* to. Fail-open wrapping and dependency logging are applied centrally by {@code CacheRouterConfig};
* this config stays a thin contribution.
*
* <p>No disabled-sentinel bean: when disabled this config contributes nothing, and the Layer 3
* fail-fast contract is enforced by {@code CacheStoreRouter} (unbound logical name → {@code
* AdapterDisabledException}; binding to a disabled backend → startup failure). Backend configs
* therefore never need to know about each other — a new backend is new files only.
*/
@Configuration
public class RedisCacheAdapterConfig {
@Bean
@ConditionalOnProperty(
name = "app.cache.redis.enabled",
havingValue = "true",
matchIfMissing = false)
public CacheBackend redisCacheBackend(RedisClient redisClient) {
return new RedisCacheStore(redisClient);
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
import dev.caskeleton.adapter.outbound.cache.core.CacheBackendException;
import java.util.Optional;
/**
* Thin Redis binding of {@link CacheBackend} (active only when {@code
* app.cache.redis.enabled=true}). Delegates to the project-supplied {@link RedisClient} seam and
* wraps its checked failures into {@link CacheBackendException}. The fail-open contract (outage ==
* cache-miss, never a 5xx) lives in {@code FailOpenCacheStore}, which {@code CacheRouterConfig}
* composes around every contributed backend centrally — keeping the policy identical across all
* backends.
*/
public class RedisCacheStore implements CacheBackend {
/** Routing id referenced by {@code app.cache.bindings.*} values. */
public static final String BACKEND_ID = "redis";
private final RedisClient client;
public RedisCacheStore(RedisClient client) {
this.client = client;
}
@Override
public String backendId() {
return BACKEND_ID;
}
@Override
public Optional<String> get(String key) {
try {
return client.read(key);
} catch (Exception ex) {
throw new CacheBackendException(BACKEND_ID, ex);
}
}
@Override
public void put(String key, String value) {
try {
client.write(key, value);
} catch (Exception ex) {
throw new CacheBackendException(BACKEND_ID, ex);
}
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import java.util.Optional;
/**
* Integration seam the forking project implements to bind the Redis cache template to a real
* client. The skeleton carries no Redis SDK dependency — the implementation is supplied by the
* project that enables Redis. Implementations may throw on a backend outage; the fail-open handling
* is done by {@code FailOpenCacheStore} (module README).
*/
public interface RedisClient {
/**
* Reads a value from Redis.
*
* @return the value, or empty if absent
* @throws Exception on a backend/connection failure (handled fail-open by the store)
*/
Optional<String> read(String key) throws Exception;
/**
* Writes a value to Redis.
*
* @throws Exception on a backend/connection failure (handled fail-open by the store)
*/
void write(String key, String value) throws Exception;
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.cache;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Map;
import org.junit.jupiter.api.Test;
class CacheBindingSettingsTest {
@Test
void nullBindingsDefaultToAnEmptyMap() {
// absent app.cache.bindings.* keys must not be a startup dependency (L262).
CacheBindingSettings settings = new CacheBindingSettings(null);
assertThat(settings.bindings()).isEmpty();
}
@Test
void bindingsAreDefensivelyCopied() {
CacheBindingSettings settings = new CacheBindingSettings(Map.of("worklog", "redis"));
assertThat(settings.bindings()).containsEntry("worklog", "redis");
assertThatThrownBy(() -> settings.bindings().put("x", "y"))
.isInstanceOf(UnsupportedOperationException.class);
}
}
@@ -0,0 +1,113 @@
package dev.caskeleton.adapter.outbound.cache.core;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.shared.error.AdapterDisabledException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class CacheStoreRouterTest {
@Test
void routesEachLogicalCacheToItsBoundBackend() {
CacheStoreRouter router =
new CacheStoreRouter(
List.of(fixedStore("redis", "from-redis"), fixedStore("local", "from-local")),
Map.of("worklog", "redis", "codes", "local"));
assertThat(router.get("worklog", "k")).contains("from-redis");
assertThat(router.get("codes", "k")).contains("from-local");
}
@Test
void putRoutesToTheBoundBackend() {
AtomicReference<String> written = new AtomicReference<>();
CacheBackend recording =
new CacheBackend() {
@Override
public String backendId() {
return "redis";
}
@Override
public Optional<String> get(String key) {
return Optional.empty();
}
@Override
public void put(String key, String value) {
written.set(key + "=" + value);
}
};
CacheStoreRouter router = new CacheStoreRouter(List.of(recording), Map.of("worklog", "redis"));
router.put("worklog", "k", "v");
assertThat(written.get()).isEqualTo("k=v");
}
@Test
void unboundLogicalCacheFailsFastWithRemediationMessage() {
// D4: a cache path that is not wired is a configuration error — fail fast,
// never a silent no-op (DisabledAdapterSentinelTest contract, router edition).
CacheStoreRouter router = new CacheStoreRouter(List.of(), Map.of());
assertThatThrownBy(() -> router.get("worklog", "k"))
.isInstanceOf(AdapterDisabledException.class)
.hasMessageContaining("app.cache.bindings.worklog")
.extracting("adapterName")
.isEqualTo("cache");
assertThatThrownBy(() -> router.put("worklog", "k", "v"))
.isInstanceOf(AdapterDisabledException.class);
}
@Test
void bindingToAnUnknownBackendFailsConstruction() {
// startup validation: a binding that names a backend with no enabled bean is a
// configuration contradiction — surface it at boot, not on first cache access.
assertThatThrownBy(() -> new CacheStoreRouter(List.of(), Map.of("worklog", "redis")))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("app.cache.bindings.worklog")
.hasMessageContaining("redis");
}
@Test
void duplicateBackendIdsFailConstruction() {
// two contributions claiming the same backendId is a wiring bug — routing would
// silently pick one of them; surface it at boot with the offending id.
assertThatThrownBy(
() ->
new CacheStoreRouter(
List.of(fixedStore("redis", "a"), fixedStore("redis", "b")), Map.of()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("duplicate")
.hasMessageContaining("redis");
}
@Test
void emptyRouterConstructsCleanly() {
// L262: zero backends + zero bindings must not block startup.
assertThat(new CacheStoreRouter(List.of(), Map.of())).isNotNull();
}
private static CacheBackend fixedStore(String backendId, String cachedValue) {
return new CacheBackend() {
@Override
public String backendId() {
return backendId;
}
@Override
public Optional<String> get(String key) {
return Optional.of(cachedValue);
}
@Override
public void put(String key, String value) {}
};
}
}
@@ -0,0 +1,120 @@
package dev.caskeleton.adapter.outbound.cache.core;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
class FailOpenCacheStoreTest {
private ch.qos.logback.classic.Logger logbackLogger;
private ListAppender<ILoggingEvent> appender;
private FailOpenDependencyLogger dependencyLogger;
@BeforeEach
void setUp() {
logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.failopen");
appender = new ListAppender<>();
appender.start();
logbackLogger.addAppender(appender);
logbackLogger.setLevel(Level.DEBUG);
dependencyLogger = new FailOpenDependencyLogger(logbackLogger);
}
@AfterEach
void tearDown() {
logbackLogger.detachAppender(appender);
MDC.clear();
}
@Test
void getReturnsTheDelegateValueOnAHit() {
FailOpenCacheStore store = new FailOpenCacheStore(fixedDelegate("cached"), dependencyLogger);
assertThat(store.get("k")).contains("cached");
}
@Test
void backendIdDelegatesToTheWrappedBackend() {
FailOpenCacheStore store = new FailOpenCacheStore(fixedDelegate("cached"), dependencyLogger);
// the decorator must not change routing identity — the router maps by this id.
assertThat(store.backendId()).isEqualTo("redis");
}
@Test
void getDegradesToACacheMissWhenTheDelegateThrows() {
FailOpenCacheStore store = new FailOpenCacheStore(failingDelegate(), dependencyLogger);
// fail-open: unavailable backend == cache-miss (Optional.empty), never an exception.
assertThat(store.get("k")).isEmpty();
}
@Test
void getOnFailingDelegateNeverThrowsAndLogsCorrelationId() {
MDC.put(OutboundCorrelation.MDC_KEY, "corr-cache-1");
FailOpenCacheStore store = new FailOpenCacheStore(failingDelegate(), dependencyLogger);
assertThatCode(() -> store.get("k")).doesNotThrowAnyException();
ILoggingEvent warn =
appender.list.stream().filter(e -> e.getLevel() == Level.WARN).findFirst().orElseThrow();
assertThat(warn.getFormattedMessage())
.contains("dependency_name=\"redis\"")
.contains("dependency_type=\"cache\"")
.contains("correlation_id=\"corr-cache-1\"");
}
@Test
void putIsFailOpenWhenTheDelegateThrows() {
FailOpenCacheStore store = new FailOpenCacheStore(failingDelegate(), dependencyLogger);
assertThatCode(() -> store.put("k", "v")).doesNotThrowAnyException();
}
private static CacheBackend fixedDelegate(String cachedValue) {
return new CacheBackend() {
@Override
public String backendId() {
return "redis";
}
@Override
public Optional<String> get(String key) {
return Optional.of(cachedValue);
}
@Override
public void put(String key, String value) {}
};
}
private static CacheBackend failingDelegate() {
return new CacheBackend() {
@Override
public String backendId() {
return "redis";
}
@Override
public Optional<String> get(String key) {
throw new RuntimeException("connection refused");
}
@Override
public void put(String key, String value) {
throw new RuntimeException("connection refused");
}
};
}
}
@@ -0,0 +1,108 @@
package dev.caskeleton.adapter.outbound.cache.redis;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.cache.core.CacheBackendException;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class RedisCacheStoreTest {
@Test
void backendIdIsRedis() {
// routing identity the router maps app.cache.bindings.* values against.
assertThat(new RedisCacheStore(failingClient()).backendId()).isEqualTo("redis");
}
@Test
void getReturnsTheClientValueOnAHit() {
RedisCacheStore store =
new RedisCacheStore(
new RedisClient() {
@Override
public Optional<String> read(String key) {
return Optional.of("cached");
}
@Override
public void write(String key, String value) {}
});
assertThat(store.get("k")).contains("cached");
}
@Test
void putDelegatesToTheClient() {
AtomicReference<String> written = new AtomicReference<>();
RedisCacheStore store =
new RedisCacheStore(
new RedisClient() {
@Override
public Optional<String> read(String key) {
return Optional.empty();
}
@Override
public void write(String key, String value) {
written.set(key + "=" + value);
}
});
store.put("k", "v");
assertThat(written.get()).isEqualTo("k=v");
}
@Test
void getWrapsACheckedClientFailureIntoCacheBackendException() {
RedisCacheStore store = new RedisCacheStore(failingClient());
// fail-open is the decorator's job (FailOpenCacheStore) — the binding itself
// must propagate, otherwise a failure could be silently mistaken for a miss.
assertThatThrownBy(() -> store.get("k"))
.isInstanceOf(CacheBackendException.class)
.hasMessageContaining("redis");
}
@Test
void putWrapsACheckedClientFailureIntoCacheBackendException() {
RedisCacheStore store = new RedisCacheStore(failingClient());
assertThatThrownBy(() -> store.put("k", "v"))
.isInstanceOf(CacheBackendException.class)
.hasMessageContaining("redis");
}
@Test
void getPropagatesEmptyOnAMiss() {
RedisCacheStore store =
new RedisCacheStore(
new RedisClient() {
@Override
public Optional<String> read(String key) {
return Optional.empty();
}
@Override
public void write(String key, String value) {}
});
assertThat(store.get("k")).isEmpty();
}
private static RedisClient failingClient() {
return new RedisClient() {
@Override
public Optional<String> read(String key) throws Exception {
throw new Exception("connection refused");
}
@Override
public void write(String key, String value) throws Exception {
throw new Exception("connection refused");
}
};
}
}
+49
View File
@@ -0,0 +1,49 @@
# adapter:outbound:fileserver — module rules
## Registered identity
- Module ID: `adapter-outbound-fileserver`
- Gradle path: `:adapter:outbound:fileserver`
- Focused test: `./gradlew :adapter:outbound:fileserver: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.adapter.outbound.fileserver`. Driven (outbound) adapter implementing
`dev.caskeleton.application.fileexport.FileExportPort` (application-core). Design rationale lives in
[README.md](README.md).
## Responsibility
- Export tabular data as CSV files behind `FileExportPort`, written under
`ca-skeleton.fileserver.base-directory` (stand-in for NFS/SFTP). Single implementation
(`FilesystemCsvExportAdapter`); pure JDK filesystem IO, no external service.
- Opt-in: `FileExportConfig` gates the single `FileExportPort` bean with
`@ConditionalOnProperty(ca-skeleton.fileserver.enabled=true)`, default off. The adapter is a plain
class; the config assembles it as a bean.
## Allowed
- Project deps: `:application-core`, `:shared-contract` — SSOT is the
`adapter-outbound-fileserver` entry in `.harness/project/modules.yaml`; `src/build.gradle`
enforces it. No
`:domain-core`, no sibling adapters.
- External: NONE (pure filesystem). `spring-boot-starter`, `spring-boot-configuration-processor`
(annotation processor) only.
## Forbidden
- Inbound adapters, sibling outbound adapters, persistence, `app-bootstrap`, `sample-portfolio`
(ArchUnit `OUTBOUND_ADAPTERS_*` family rules).
- Leaking a framework/domain type across `FileExportPort` — the port takes/returns only `String` /
`List<String>` / `List<List<String>>` / `ExportedFile`.
- Fully-qualified inline type references; more than one public top-level type per file.
## Tests
`FilesystemCsvExportAdapterTest` (temp-dir CSV write/verify: header + rows, RFC-4180 escaping,
null-field, overwrite, path-traversal + blank-name rejection).
```bash
cd src
./gradlew :adapter:outbound:fileserver:check
```
+64
View File
@@ -0,0 +1,64 @@
# adapter:outbound:fileserver — design-decision reference
File-server export outbound (driven) adapter. Package root:
`dev.caskeleton.adapter.outbound.fileserver`. Implements the `application-core` port
`dev.caskeleton.application.fileexport.FileExportPort` behind an opt-in `@ConditionalOnProperty`
selector, mirroring the existing outbound adapters (notification / cache-redis / httpclient /
objectstorage).
The allowed/forbidden dependency policy is owned by `src/build.gradle`'s
`allowedProjectDependencies['adapter:outbound:fileserver']` (SSOT). Module rules live in
[CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code
comments.
## Module overview
An **opt-in** file-export adapter placed behind an application-core port. A single
`FilesystemCsvExportAdapter` writes CSV files under `ca-skeleton.fileserver.base-directory` — a
stand-in for an NFS mount, shared file server, or SFTP drop. There is no external service and no
external dependency (pure JDK filesystem IO), so the local profile just works and the lockfile only
pins the shared Spring Boot / tooling graph.
Selector: `ca-skeleton.fileserver.enabled=true` (default `false`). Unlike objectstorage there is a
single implementation, so no backend switch is needed; the `enabled` flag keeps the module from
activating unexpectedly when merely present on the classpath. `FileExportConfig` gates the single
`FileExportPort` bean on that flag.
## The port contract (framework/domain-neutral)
`FileExportPort` is a minimal, domain-neutral surface:
- `ExportedFile exportCsv(String fileName, List<String> header, List<List<String>> rows)`.
The caller supplies a bare file name, an optional header row, and the data rows as lists of
already-stringified field values. The adapter owns file placement, RFC-4180 escaping, and byte
encoding, and returns an `ExportedFile` receipt (`fileName`, absolute `path`, `byteSize`,
`rowCount`). No framework or domain type crosses the port — the application layer stays decoupled
from the CSV format and the destination filesystem. A fork that needs a real domain export maps its
rows to `List<List<String>>` at the call site (or adds a typed convenience method in its own layer).
## CSV escaping
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 `null` field is written as an
empty field. Rows are separated by `\n` and the file is UTF-8 encoded. Overwriting an existing file
at the same name replaces it.
## IO-failure handling
Filesystem IO failures are wrapped in the shared-contract `DependencyFailureException`
(`dependencyName="fileserver"`) so a fork's web error handler classifies them uniformly with the
other outbound dependencies. A blank file name or one that escapes the base directory (path
traversal) is `IllegalArgumentException` (a caller bug, not a dependency failure) — the adapter
normalises the resolved path and checks it still starts with the base directory.
## Tests
- `FilesystemCsvExportAdapterTest``@TempDir` write/verify: header + rows, CSV escaping of a field
containing a comma / quote / newline, null-field handling, header-only and headerless exports,
overwrite, and path-traversal + blank-name rejection.
```bash
cd src
./gradlew :adapter:outbound:fileserver:check
```
@@ -0,0 +1,15 @@
// Driven adapter: file server / filesystem exports behind application-core's FileExportPort. Writes
// delimited (CSV) files to a configured base directory — a stand-in for an NFS mount, shared file
// server, or SFTP drop. Pure JDK filesystem IO, so there are NO external dependencies: the lockfile
// only pins the shared Spring Boot / tooling graph. Opt-in via @ConditionalOnProperty
// (ca-skeleton.fileserver.enabled), off by default so the module never activates unexpectedly.
description = 'Outbound adapter: file server exports (filesystem/CSV)'
dependencies {
implementation project(':application-core')
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
@@ -0,0 +1,152 @@
# 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-configuration-processor:4.0.0=annotationProcessor
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,25 @@
package dev.caskeleton.adapter.outbound.fileserver;
import dev.caskeleton.application.fileexport.FileExportPort;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Opt-in wiring for the file-server export adapter. The single {@link FileExportPort} bean is
* contributed only when {@code ca-skeleton.fileserver.enabled=true}, so the module never activates
* unexpectedly when merely present on the classpath (there is a single implementation, so no
* backend selector is needed). The adapter is a plain class; this config assembles it as a bean,
* mirroring the object-storage module.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(FileExportProperties.class)
public class FileExportConfig {
@Bean
@ConditionalOnProperty(prefix = "ca-skeleton.fileserver", name = "enabled", havingValue = "true")
public FileExportPort filesystemCsvExportPort(FileExportProperties properties) {
return new FilesystemCsvExportAdapter(properties.getBaseDirectory());
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.outbound.fileserver;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Typed settings for the file-server export adapter, bound from {@code ca-skeleton.fileserver.*}.
* Bound as a mutable JavaBean (not a record) so a fork can leave any subset of fields unset and
* inherit the defaults below.
*/
@ConfigurationProperties(prefix = "ca-skeleton.fileserver")
public class FileExportProperties {
/**
* Whether to contribute the export adapter. Defaults to {@code false} so the module never
* activates unexpectedly when merely present on the classpath; a fork opts in explicitly.
*/
private boolean enabled = false;
/** Base directory that export files are written under (stand-in for an NFS/SFTP drop). */
private String baseDirectory = "./.data/fileserver";
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getBaseDirectory() {
return baseDirectory;
}
public void setBaseDirectory(String baseDirectory) {
this.baseDirectory = baseDirectory;
}
}
@@ -0,0 +1,106 @@
package dev.caskeleton.adapter.outbound.fileserver;
import dev.caskeleton.application.fileexport.ExportedFile;
import dev.caskeleton.application.fileexport.FileExportPort;
import dev.caskeleton.shared.error.DependencyFailureException;
import dev.caskeleton.shared.error.OperationalError;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Filesystem-backed {@link FileExportPort} — writes a CSV export under a configured base directory
* (the "file server" boundary, a stand-in for NFS/SFTP). Fields are escaped per RFC-4180 and the
* file is UTF-8 encoded; the returned {@link ExportedFile} carries the absolute path, byte size,
* and row count.
*/
public class FilesystemCsvExportAdapter implements FileExportPort {
private static final String DEPENDENCY_NAME = "fileserver";
private static final Logger log = LoggerFactory.getLogger(FilesystemCsvExportAdapter.class);
private final Path baseDir;
public FilesystemCsvExportAdapter(String baseDirectory) {
this.baseDir = Path.of(baseDirectory).toAbsolutePath().normalize();
try {
Files.createDirectories(baseDir);
log.info("filesystem file-export base dir: {}", baseDir);
} catch (IOException e) {
throw new DependencyFailureException(
OperationalError.INTERNAL_ERROR,
DEPENDENCY_NAME,
"cannot create file-export base dir",
e);
}
}
@Override
public ExportedFile exportCsv(String fileName, List<String> header, List<List<String>> rows) {
Objects.requireNonNull(header, "header must be non-null");
Objects.requireNonNull(rows, "rows must be non-null");
Path target = resolve(fileName);
StringBuilder csv = new StringBuilder();
if (!header.isEmpty()) {
appendRow(csv, header);
}
for (List<String> row : rows) {
Objects.requireNonNull(row, "row must be non-null");
appendRow(csv, row);
}
byte[] bytes = csv.toString().getBytes(StandardCharsets.UTF_8);
try {
Files.write(target, bytes);
} catch (IOException e) {
throw new DependencyFailureException(
OperationalError.INTERNAL_ERROR, DEPENDENCY_NAME, "failed to write CSV export", e);
}
log.info("exported {} rows -> {} ({} bytes)", rows.size(), target, bytes.length);
return new ExportedFile(fileName, target.toString(), bytes.length, rows.size());
}
/** Appends one CSV record (comma-separated, escaped fields, trailing line feed). */
private static void appendRow(StringBuilder csv, List<String> fields) {
for (int i = 0; i < fields.size(); i++) {
if (i > 0) {
csv.append(',');
}
csv.append(escape(fields.get(i)));
}
csv.append('\n');
}
/** Minimal RFC-4180 escaping: quote fields containing a comma, quote, CR, or LF. */
private static String escape(String value) {
if (value == null) {
return "";
}
if (value.contains(",")
|| value.contains("\"")
|| value.contains("\n")
|| value.contains("\r")) {
return '"' + value.replace("\"", "\"\"") + '"';
}
return value;
}
/** Resolves a bare file name under {@code baseDir}, rejecting blank names and path traversal. */
private Path resolve(String fileName) {
if (fileName == null || fileName.isBlank()) {
throw new IllegalArgumentException("fileName must be non-null and non-blank");
}
Path resolved = baseDir.resolve(fileName).normalize();
if (!resolved.startsWith(baseDir) || resolved.equals(baseDir)) {
throw new IllegalArgumentException("illegal export file name (path traversal): " + fileName);
}
return resolved;
}
}
@@ -0,0 +1,102 @@
package dev.caskeleton.adapter.outbound.fileserver;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.application.fileexport.ExportedFile;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Temp-dir CSV write/verify contract for {@link FilesystemCsvExportAdapter}. */
class FilesystemCsvExportAdapterTest {
private Path baseDir;
private FilesystemCsvExportAdapter adapter;
@BeforeEach
void setUp(@TempDir Path tempDir) {
baseDir = tempDir;
adapter = new FilesystemCsvExportAdapter(tempDir.toString());
}
@Test
void exportsHeaderAndRowsWithCsvEscaping() throws IOException {
List<String> header = List.of("id", "name", "note");
List<List<String>> rows =
List.of(
List.of("1", "plain", "ok"),
List.of("2", "has,comma", "quote\"inside"),
List.of("3", "line\nbreak", "trailing"));
ExportedFile result = adapter.exportCsv("export.csv", header, rows);
Path written = baseDir.resolve("export.csv");
assertThat(written).exists();
assertThat(result.fileName()).isEqualTo("export.csv");
assertThat(result.path()).isEqualTo(written.toAbsolutePath().normalize().toString());
assertThat(result.rowCount()).isEqualTo(3);
assertThat(result.byteSize()).isEqualTo(Files.size(written));
String content = Files.readString(written, StandardCharsets.UTF_8);
assertThat(content)
.isEqualTo(
"id,name,note\n"
+ "1,plain,ok\n"
+ "2,\"has,comma\",\"quote\"\"inside\"\n"
+ "3,\"line\nbreak\",trailing\n");
}
@Test
void writesHeaderOnlyWhenRowsEmpty() throws IOException {
ExportedFile result = adapter.exportCsv("empty.csv", List.of("a", "b"), List.of());
assertThat(result.rowCount()).isZero();
assertThat(Files.readString(baseDir.resolve("empty.csv"), StandardCharsets.UTF_8))
.isEqualTo("a,b\n");
}
@Test
void writesNoHeaderLineWhenHeaderEmpty() throws IOException {
adapter.exportCsv("headerless.csv", List.of(), List.of(List.of("only", "data")));
assertThat(Files.readString(baseDir.resolve("headerless.csv"), StandardCharsets.UTF_8))
.isEqualTo("only,data\n");
}
@Test
void nullFieldIsWrittenAsEmpty() throws IOException {
List<String> rowWithNull = Arrays.asList("x", null, "z");
adapter.exportCsv("nulls.csv", List.of("a", "b", "c"), List.of(rowWithNull));
assertThat(Files.readString(baseDir.resolve("nulls.csv"), StandardCharsets.UTF_8))
.isEqualTo("a,b,c\nx,,z\n");
}
@Test
void overwritesExistingFile() throws IOException {
adapter.exportCsv("dup.csv", List.of("a"), List.of(List.of("first")));
adapter.exportCsv("dup.csv", List.of("a"), List.of(List.of("second")));
assertThat(Files.readString(baseDir.resolve("dup.csv"), StandardCharsets.UTF_8))
.isEqualTo("a\nsecond\n");
}
@Test
void pathTraversalFileNameIsRejected() {
assertThatThrownBy(() -> adapter.exportCsv("../escape.csv", List.of("a"), List.of()))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void blankFileNameIsRejected() {
assertThatThrownBy(() -> adapter.exportCsv(" ", List.of("a"), List.of()))
.isInstanceOf(IllegalArgumentException.class);
}
}
+30
View File
@@ -0,0 +1,30 @@
# adapter:outbound:httpclient — resilient HTTP client adapter
## Registered identity
- Module ID: `adapter-outbound-httpclient`
- Gradle path: `:adapter:outbound:httpclient`
- Focused test: `./gradlew :adapter:outbound:httpclient: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.adapter.outbound.httpclient`.
## Responsibility
- Own outbound REST client construction, timeouts, retries, circuit breakers, response-size bounds,
trace propagation, diagnostics, and shutdown safety.
- Adapt external HTTP calls behind application/domain ports.
- Reuse `adapter:outbound:support` for shared outbound concerns.
## Boundaries
- Allowed dependency edges come only from `.harness/project/modules.yaml`.
- No inbound controller/DTO, persistence, bootstrap, or sample dependency.
- Retry and circuit-breaker code is technical resilience; business compensation and use-case
sequencing stay in application/domain layers.
## Tests
Use fake clients/servers or direct collaborator fakes with no real network. Settings receive
binding/validation tests; retry/error mapping and resource bounds receive focused unit tests.
+148
View File
@@ -0,0 +1,148 @@
# adapter:outbound:httpclient — 설계 결정 참조
아웃바운드 HTTP client 베이스라인 모듈. 패키지 루트:
`dev.caskeleton.adapter.outbound.httpclient`(`resilience`, `diagnostics` 서브패키지 포함).
`:adapter:outbound:support` 에 의존해 공유 correlation / fail-open 의존성 로깅을 재사용한다.
허용/금지 의존 정책은 `src/build.gradle`
`allowedProjectDependencies['adapter:outbound:httpclient']` 항목이 SSOT 다(이 모듈은 아직 별도
CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용
기록이다.
## OutboundHttpClient
단일 명명 의존성(named upstream dependency)용 베이스라인 HTTP 클라이언트.
### static `baseline(...)` 팩토리인 이유
`public final class` + `private` 생성자 + `public static baseline(...)` 형태다. ArchUnit B7 은
non-`@Configuration` 아웃바운드 클래스의 public **non-static** 메서드가 `..adapter.outbound..`
타입을 반환하는 것을 금지한다. static 메서드는 B7 대상에서 제외되므로, 인스턴스 팩토리 대신
static 팩토리를 쓴다.
### 템플릿 seam — 기본 client 빈 없음
일반(generic) `OutboundHttpClient` 빈은 두지 않는다. 의존성마다 고유한 이름·base URL 이
필요하므로, 포킹 프로젝트가 자신의 `@Configuration` 에서 `baseline(...)` 을 직접 호출해
의존성별 인스턴스를 만든다 — `OutboundHttpClientConfig` 가 만들어 주지 않는다.
### 내부 RestClient 두 개
`buffered``streaming` 두 인스턴스를 둔다. 둘은 하나의 공유 `JdkClientHttpRequestFactory`
위에 만들어져 TCP 연결 풀/타임아웃 설정이 동일하다. `buffered`
`ResponseSizeBoundingInterceptor`(D7)를 포함하고, `streaming` 은 크기 인터셉터 없이 raw
`InputStream` 을 그대로 전달한다.
### retry 를 CB **바깥**에 두는 이유
`exchange()` 의 decoration 순서는 CB(outer) → retry(inner)다. retry 를 CB 바깥에 둬야 각 retry
시도가 CB 슬라이딩 윈도에 **독립적으로** 카운트된다. retry 를 CB 안에 두면 모든 재시도가 CB 호출
1건으로 합산돼 실제 실패 빈도가 CB 에 가려진다.
### size 위반은 분류하지 않고 전파
`OutboundResponseSizeExceededException` 은 의도적으로 `DependencyFailureException`
**아니다**. 이는 업스트림 실패가 아니라 호출자가 잘못된 API 경로를 골랐다는 사용 계약
(usage-contract) 위반이며, 분류 없이 그대로 전파한다. 큰 응답이 예상되면 호출자는 `stream()`
써야 한다.
### streaming 경로에 retry 없음
이미 소비된 스트림은 안전하게 재발행할 수 없다 — reader 에 이미 전달된 바이트는 잃고, 서버가
처음부터 재전송을 보장하지 않는다. 그래서 `stream()` 은 retry 없이 shutdown 게이팅·분류·로깅만
적용한다.
### OutboundHttpShutdownGuard — SmartLifecycle 인 이유
`SmartLifecycle` + `getPhase() = Integer.MAX_VALUE`(가장 먼저 stop)로 종료 시 아웃바운드
호출자보다 먼저 멈춘다. `ContextClosedEvent` 를 쓰지 않는 이유: SmartLifecycle phase 순서는
결정적이고 close 시퀀스가 빈을 파괴하기 전에 동작하지만, `ContextClosedEvent` 는 컨텍스트 종료가
시작된 뒤 발생하고 다른 lifecycle 빈과의 순서가 정의되지 않는다. 종료 중에는
`DEPENDENCY_CIRCUIT_OPEN`/REJECTED 로 fail-fast 한다(전용 shutdown 코드를 새로 만들지 않고
가장 가까운 버킷을 재사용).
### OutboundHttpTimeoutEnforcer — `static @Bean` BeanPostProcessor
raw `RestClient`/`RestClient.Builder` 빈이 등록되면 startup 을 실패시키는 BeanPostProcessor 다.
`static @Bean` 으로 선언해야 다른 빈보다 먼저 생성된다 — non-static BeanPostProcessor 는 일찍
생성되는 빈을 놓칠 수 있다. 인라인으로 직접 만든 `RestClient`(빈이 아닌)는 잡지 못하는 잔여
리스크가 있다.
## resilience — `OutboundHttpResilienceConfig` / `OutboundHttpResilience` / `OutboundRetryPolicy`
### 메트릭 없는 resilience 금지 (D3)
retry/CB 중 하나라도 켜지면 `MeterRegistry` 빈이 **반드시** 있어야 한다. low-cardinality 메트릭
없이 retry/CB 를 돌리는 것은 D3 가 금지한다. enable 상태인데 registry 가 없으면 빈 생성 시점에
`IllegalStateException` 으로 startup 을 실패시킨다.
### D4 메트릭 정규화 — `@Bean` 이 아니라 직접 주입
`MeterFilter``MeterRegistry.Config#meterFilter`**직접** 등록한다. Spring Boot Actuator 의
`MeterRegistryCustomizer`(filter 빈을 주워가는 자동설정)는 이 모듈 classpath 에 없으므로,
`@Bean MeterFilter` 로 등록하면 아무 효과가 없다.
### Micrometer 1.15.x 비호환 — 커스텀 `map(Meter.Id)` 필요
`MeterFilter.replaceTagValues` / `MeterFilter.renameTag``TaggedRetryMetrics` /
`TaggedCircuitBreakerMetrics` 가 등록한 `FunctionCounter`·`DefaultGauge` 인스턴스의 태그
키/값을 Micrometer 1.15.x 에서 안정적으로 변환하지 못한다(이들 ID 가 편의 팩토리의 `map()`
체인을 타지 않음). 명시적 `map(Meter.Id)` 구현을 가진 커스텀 `MeterFilter` 가 필요하다
(Micrometer 1.15.11 / Resilience4j 2.2.0 에서 확인).
### MeterFilter 설치 순서 불변식
필터는 설치 **이후** 등록되는 meter 에만 영향을 준다. `TaggedCircuitBreakerMetrics` 는 state
gauge 를 `bindTo()` 시점에 즉시(eager) 등록하므로, state 태그 대문자화 필터는 `bindTo()`
**전에** 설치해야 한다 — 아니면 그 gauge 들에는 변환이 조용히 누락된다. 정규화 필터(1–3)는
DENY 필터(4)보다 먼저 설치해 rename 된 태그 키가 필터 4 의 NEUTRAL 판정에 보인다.
### 승인된 3개 meter 외 전부 DENY
`resilience4j.retry.calls`, `resilience4j.circuitbreaker.calls`,
`resilience4j.circuitbreaker.state` 만 통과시키고, vendor 가 추가로 내보내는 `failure.rate`,
`buffered.calls`, `not.permitted.calls`, `slow.call.rate` 등은 D4 low-cardinality 를 위해
필터 4 가 DENY 한다.
### OutboundHttpResilience — `Optional.empty()` 계약
retry/CB 머신을 담는 홀더. `retryFor()` / `circuitBreakerFor()` 는 해당 기능이 비활성이면
`Optional.empty()`(= decoration 없음)를 반환한다. 생성자의 registry 인자는 nullable — 기능이
비활성일 때 `null` 을 넘긴다.
### OutboundRetryPolicy — POST/PATCH 는 항상 non-retryable
재시도 조건은 ThreadLocal 호출 컨텍스트 기반의 4가지로, 멱등(idempotent) 메서드만 재시도한다.
POST/PATCH 는 Idempotency-Key 계약이 정의되지 않았으므로 보수적으로 항상 재시도하지 않는다.
exponential random backoff(jitter)는 settings 로 구동된다.
## diagnostics — `OutboundHttpErrorMapper` / `OutboundHttpDependencyLogger`
### 분류 순서가 타입 계층 때문에 중요
`HttpConnectTimeoutException extends HttpTimeoutException` 이므로 connect-timeout 을
read-timeout **보다 먼저** 검사해야 한다 — 순서가 바뀌면 connect 타임아웃이 read 타임아웃으로
오분류된다. `ConnectException``UnresolvedAddressException`(JDK HttpClient 래핑)을 감쌀 수
있어, DNS 검사는 `CONNECT_FAILED` 반환 전에 sub-cause 체인을 훑는다.
업스트림 4xx 는 408/429 포함 전부 `DEPENDENCY_4XX_CLIENT`(non-retryable, PERMANENT)로
분류한다. 의미상 408/429 는 재시도 가능하지만 Idempotency-Key 계약이 없는 상태에서의 보수적·
안전한 결정이며, 열린(open) 리스크로 남겨 둔다.
### 진단 메시지 본문 누출 금지 (D12)
진단 메시지에 `getResponseBodyAsString()`, 응답 헤더, 업스트림 페이로드를 **절대** 포함하지
않는다 — HTTP status code 와 예외 클래스명만 쓴다.
### 로그 레벨 규칙
로그 필드는 MDC SSOT 를 따른다. SUCCESS=DEBUG, CIRCUIT_OPEN/REJECTED=WARN(예상되는 일시적
상태 — use case 는 성공), 그 외 hard failure=ERROR. 이는 `:adapter:outbound:support`
`FailOpenDependencyLogger`(선택형 어댑터 fail-open, 전부 WARN)와 명확히 구분된다 — 이 client
는 호출자에게 직접 노출되는 hard failure 를 다루므로 ERROR 까지 올린다. 본문/수신자/페이로드를
받지 않아 PII 가 로그에 닿지 않는다.
## TraceContextPropagationInterceptor
> 코드에는 압축된 경고만 남기고, 전체 메커니즘은 여기 둔다.
sampled 플래그가 `00`(not-sampled)로 하드코딩돼 있다 — 스켈레톤은 exporter/sampler 를
와이어링하지 않고 foundation `mdc-keys.yaml``trace_flags`/`sampled` 키가 없어 inbound
sampled 비트를 전파할 수 없기 때문이다. `traceparent``trace_id`+`span_id` 로만 재구성되고
sampled 비트는 `00` 으로 강제된다.
포크가 실제 트레이서(Micrometer Tracing + OTel)를 붙이면 두 가지가 터진다:
1. **downstream suppression** — downstream `ParentBased` sampler 가 `00` 을 "parent not
sampled" 로 읽고 child span 을 버린다. upstream 이 샘플링한 트레이스라도 이 경계에서 분산
트레이스가 끊긴다.
2. **이 인터셉터가 경쟁에서 이긴다** — RestClient 에 **가장 먼저** 등록돼(`OutboundHttpClient`
참조) 나중 OTel instrumentation 인터셉터보다 앞서 `traceparent` 를 찍고, 멱등 가드가 실제
instrumentation 을 스킵시킨다. 즉 `00` 은 fallback 이 아니라 실제 결정을 덮어쓴다.
**포크 체크리스트**: (a) 이 인터셉터를 비활성화/제거하고 `traceparent` 소유를 OTel 에 넘기거나,
(b) `TraceParent.of(.., false)``false` 를 실제 `Span.getSpanContext().isSampled()`
교체하고 foundation MDC `trace_flags` carrier 를 마련한다. 스켈레톤 테스트는 no-tracer
메커니즘만 검증하며 live SDK 와의 합성은 검증하지 않는다.
@@ -0,0 +1,19 @@
plugins { id 'groovy' }
dependencies {
implementation project(':domain-core')
implementation project(':application-core')
implementation project(':shared-contract')
implementation project(':adapter:outbound:support')
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.springframework:spring-web'
implementation 'io.micrometer:micrometer-core'
implementation 'io.github.resilience4j:resilience4j-retry:2.2.0'
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
implementation 'io.github.resilience4j:resilience4j-micrometer:2.2.0'
implementation 'org.slf4j:slf4j-api'
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
}
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
@@ -0,0 +1,166 @@
# 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=testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=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=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,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=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.github.resilience4j:resilience4j-bulkhead:2.2.0=runtimeClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-core:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-micrometer:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-ratelimiter:2.2.0=runtimeClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-retry:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-timelimiter:2.2.0=runtimeClasspath,testRuntimeClasspath
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-core: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=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.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
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=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=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.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,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=testCompileClasspath,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.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath
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=testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=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=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.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
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=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=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=compileClasspath,runtimeClasspath,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=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,63 @@
package dev.caskeleton.adapter.outbound.httpclient;
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger;
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
import dev.caskeleton.shared.error.DependencyFailureException;
import dev.caskeleton.shared.error.OperationalError;
/**
* Package-private observer that centralises duration calculation, success/failure logging, error
* classification, and shutdown rejection for outbound HTTP calls.
*/
final class OutboundHttpCallObserver {
private final String dependencyName;
private final OutboundHttpErrorMapper errorMapper;
private final OutboundHttpDependencyLogger logger;
OutboundHttpCallObserver(
String dependencyName,
OutboundHttpErrorMapper errorMapper,
OutboundHttpDependencyLogger logger) {
this.dependencyName = dependencyName;
this.errorMapper = errorMapper;
this.logger = logger;
}
void recordSuccess(long startNs, int retryAttempt) {
long durationMs = (System.nanoTime() - startNs) / 1_000_000;
logger.logSuccess(dependencyName, durationMs, retryAttempt);
}
/**
* <em>Returns</em> the classified exception so the caller throws via {@code throw
* observer.recordFailure(...)}.
*/
DependencyFailureException recordFailure(Throwable t, long startNs, int retryAttempt) {
long durationMs = (System.nanoTime() - startNs) / 1_000_000;
DependencyFailureException dfe = errorMapper.classify(dependencyName, t);
String outcome = outcomeFor(dfe);
logger.logFailure(dependencyName, outcome, durationMs, retryAttempt, dfe);
return dfe;
}
/**
* Reuses {@code DEPENDENCY_CIRCUIT_OPEN}/REJECTED semantics since no dedicated shutdown code
* exists.
*/
DependencyFailureException rejectShutdown(String message) {
DependencyFailureException rejected =
new DependencyFailureException(
OperationalError.DEPENDENCY_CIRCUIT_OPEN, dependencyName, message, null);
logger.logFailure(dependencyName, "REJECTED", 0L, 0, rejected);
return rejected;
}
private static String outcomeFor(DependencyFailureException dfe) {
return switch ((OperationalError) dfe.errorCode()) {
case DEPENDENCY_TIMEOUT -> "TIMEOUT";
case DEPENDENCY_CIRCUIT_OPEN -> "CIRCUIT_OPEN";
default -> "FAILURE";
};
}
}
@@ -0,0 +1,177 @@
package dev.caskeleton.adapter.outbound.httpclient;
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger;
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.retry.Retry;
import java.io.InputStream;
import java.time.Instant;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;
import org.springframework.http.HttpMethod;
import org.springframework.web.client.RestClient;
/**
* Baseline outbound HTTP client for a single named upstream dependency. Created via the {@code
* static baseline(...)} factory; holds two internal RestClients (buffered + streaming). Rationale
* for the static factory, the two-client split, and retry/CB decoration order is in the module
* README.
*/
public final class OutboundHttpClient {
private final String dependencyName;
private final OutboundHttpSettings settings;
private final OutboundHttpShutdownGuard shutdownGuard;
private final OutboundHttpResilience resilience;
private final OutboundRetryPolicy retryPolicy;
private final OutboundHttpCallObserver observer;
private final RestClient bufferedClient;
private final RestClient streamingClient;
private OutboundHttpClient(
String dependencyName,
String baseUrl,
OutboundHttpSettings settings,
OutboundHttpShutdownGuard shutdownGuard,
OutboundHttpResilience resilience,
OutboundRetryPolicy retryPolicy,
OutboundHttpErrorMapper errorMapper,
OutboundHttpDependencyLogger logger) {
this.dependencyName = dependencyName;
this.settings = settings;
this.shutdownGuard = shutdownGuard;
this.resilience = resilience;
this.retryPolicy = retryPolicy;
this.observer = new OutboundHttpCallObserver(dependencyName, errorMapper, logger);
var clients = OutboundHttpRestClientFactory.create(dependencyName, baseUrl, settings);
this.bufferedClient = clients.buffered();
this.streamingClient = clients.streaming();
}
/**
* Baseline client factory. Why it is static (B7) and the template-seam usage are in the module
* README — forking projects call this per dependency from their own {@code @Configuration}.
*/
public static OutboundHttpClient baseline(
String dependencyName,
String baseUrl,
OutboundHttpSettings settings,
OutboundHttpShutdownGuard shutdownGuard,
OutboundHttpResilience resilience,
OutboundRetryPolicy retryPolicy,
OutboundHttpErrorMapper errorMapper,
OutboundHttpDependencyLogger logger) {
return new OutboundHttpClient(
dependencyName,
baseUrl,
settings,
shutdownGuard,
resilience,
retryPolicy,
errorMapper,
logger);
}
/** Shortcut: GET with buffered deserialization. */
public <T> T get(String uri, Class<T> responseType) {
return exchange(HttpMethod.GET, uri, null, responseType);
}
/**
* If the response exceeds the size limit, {@link OutboundResponseSizeExceededException}
* propagates unclassified (usage-contract violation — large responses must use {@link #stream}).
*/
public <T> T exchange(HttpMethod method, String uri, Object requestBody, Class<T> responseType) {
if (shutdownGuard.isShuttingDown()) {
throw observer.rejectShutdown("shutdown in progress — outbound call rejected fail-fast (D8)");
}
Instant deadline = Instant.now().plus(settings.globalCallTimeout());
retryPolicy.beginCall(method, deadline);
// Track attempt count for logging — declared outside try so catch can read it.
int[] attemptCount = {0};
long startNs = System.nanoTime();
try {
Supplier<T> supplier = buildSupplier(method, uri, requestBody, responseType);
// Retry OUTSIDE the CB so each attempt is independently CB-counted
// (inside the CB, all retries would count as a single CB call).
Optional<CircuitBreaker> cb = resilience.circuitBreakerFor(dependencyName);
Optional<Retry> retry = resilience.retryFor(dependencyName);
Supplier<T> countingSupplier =
() -> {
attemptCount[0]++;
return supplier.get();
};
Supplier<T> decorated = countingSupplier;
if (retry.isPresent()) {
decorated = Retry.decorateSupplier(retry.get(), decorated);
}
if (cb.isPresent()) {
decorated = CircuitBreaker.decorateSupplier(cb.get(), decorated);
}
T result = decorated.get();
// retryAttempt = attemptCount - 1 (0 means the first attempt succeeded).
observer.recordSuccess(startNs, Math.max(0, attemptCount[0] - 1));
return result;
} catch (OutboundResponseSizeExceededException sizeEx) {
// Size violation — propagate unclassified (usage-contract violation, not an upstream
// failure).
throw sizeEx;
} catch (Throwable t) {
throw observer.recordFailure(t, startNs, Math.max(0, attemptCount[0] - 1));
} finally {
retryPolicy.endCall();
}
}
/**
* Streaming API for large responses. No retry — a consumed stream cannot be safely re-issued
* (delivered bytes are lost and the server may not support resending).
*/
public <T> T stream(HttpMethod method, String uri, Function<InputStream, T> reader) {
if (shutdownGuard.isShuttingDown()) {
throw observer.rejectShutdown(
"shutdown in progress — outbound stream call rejected fail-fast (D8)");
}
long startNs = System.nanoTime();
try {
T result =
streamingClient
.method(method)
.uri(uri)
.exchange((req, res) -> reader.apply(res.getBody()));
observer.recordSuccess(startNs, 0);
return result;
} catch (Throwable t) {
throw observer.recordFailure(t, startNs, 0);
}
}
private <T> Supplier<T> buildSupplier(
HttpMethod method, String uri, Object requestBody, Class<T> responseType) {
return () -> {
var spec = bufferedClient.method(method).uri(uri);
if (requestBody != null) {
spec = spec.body(requestBody);
}
// Default RestClient status handling throws on 4xx/5xx; the error mapper classifies it.
return spec.retrieve().body(responseType);
};
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.httpclient;
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger;
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Registers the common outbound HTTP infrastructure beans. No default {@link OutboundHttpClient}
* bean — forking projects call {@link OutboundHttpClient#baseline} per dependency (module README).
* {@code @ConditionalOnMissingBean} on each bean lets a fork substitute its own implementation.
*/
@Configuration
@EnableConfigurationProperties(OutboundHttpSettings.class)
public class OutboundHttpClientConfig {
@Bean
@ConditionalOnMissingBean
public OutboundHttpShutdownGuard outboundHttpShutdownGuard() {
return new OutboundHttpShutdownGuard();
}
/**
* Must be {@code static}: a BeanPostProcessor must be created before other beans to intercept
* their post-init callbacks. A {@code static @Bean} is built directly by the BeanFactory
* infrastructure, bypassing the {@code @Configuration} instance lifecycle, so it is ready early
* enough.
*/
@Bean
@ConditionalOnMissingBean
public static OutboundHttpTimeoutEnforcer outboundHttpTimeoutEnforcer() {
return new OutboundHttpTimeoutEnforcer();
}
@Bean
@ConditionalOnMissingBean
public OutboundHttpErrorMapper outboundHttpErrorMapper() {
return new OutboundHttpErrorMapper();
}
@Bean
@ConditionalOnMissingBean
public OutboundHttpDependencyLogger outboundHttpDependencyLogger() {
return new OutboundHttpDependencyLogger();
}
@Bean
@ConditionalOnMissingBean
public OutboundRetryPolicy outboundRetryPolicy(
OutboundHttpSettings settings,
OutboundHttpShutdownGuard guard,
OutboundHttpErrorMapper mapper) {
return new OutboundRetryPolicy(settings, guard, mapper);
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.outbound.httpclient;
import java.net.http.HttpClient;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
/**
* Package-private factory that builds the shared {@link JdkClientHttpRequestFactory} and the
* buffered/streaming {@link RestClient} pair for a single named dependency. Both share one request
* factory so TCP pooling/settings are identical — only buffered carries the {@link
* ResponseSizeBoundingInterceptor}.
*/
final class OutboundHttpRestClientFactory {
private OutboundHttpRestClientFactory() {}
record Clients(RestClient buffered, RestClient streaming) {}
static Clients create(String dependencyName, String baseUrl, OutboundHttpSettings settings) {
HttpClient httpClient =
HttpClient.newBuilder().connectTimeout(settings.connectTimeout()).build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(settings.readTimeout());
// Register TraceContextPropagationInterceptor first so trace headers exist before
// the size interceptor inspects the response.
RestClient buffered =
RestClient.builder()
.requestFactory(requestFactory)
.baseUrl(baseUrl)
.requestInterceptor(new TraceContextPropagationInterceptor())
.requestInterceptor(
new ResponseSizeBoundingInterceptor(
dependencyName, settings.responseSizeLimit().toBytes()))
.build();
RestClient streaming =
RestClient.builder()
.requestFactory(requestFactory)
.baseUrl(baseUrl)
.requestInterceptor(new TraceContextPropagationInterceptor())
.build();
return new Clients(buffered, streaming);
}
}
@@ -0,0 +1,182 @@
package dev.caskeleton.adapter.outbound.httpclient;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
import org.springframework.util.unit.DataSize;
/**
* Typed settings for the outbound HTTP client baseline, bound from {@code app.outbound.http.*}. The
* compact constructor rejects missing/zero/negative timeouts at binding time (startup failure); the
* rationale is in the module README.
*
* @param connectTimeout TCP connect timeout; must be positive
* @param readTimeout socket read timeout; must be positive
* @param globalCallTimeout end-to-end deadline budget per call including retries; must be positive
* @param retryEnabled whether the Resilience4j retry decorator is active
* @param circuitBreakerEnabled whether the Resilience4j circuit-breaker decorator is active
* @param responseSizeLimit max in-memory response body size; null defaults to 10 MB; zero/negative
* forbidden
* @param retry retry tuning; null applies defaults
* @param circuitBreaker circuit-breaker tuning; null applies defaults
*/
@ConfigurationProperties(prefix = "app.outbound.http")
public record OutboundHttpSettings(
Duration connectTimeout,
Duration readTimeout,
Duration globalCallTimeout,
boolean retryEnabled,
boolean circuitBreakerEnabled,
DataSize responseSizeLimit,
Retry retry,
CircuitBreaker circuitBreaker) {
/** Registry default for {@code APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT}. */
private static final DataSize DEFAULT_RESPONSE_SIZE_LIMIT = DataSize.ofMegabytes(10);
private static final int DEFAULT_RETRY_MAX_ATTEMPTS = 3;
private static final Duration DEFAULT_RETRY_INITIAL_BACKOFF = Duration.ofMillis(100);
private static final double DEFAULT_RETRY_BACKOFF_MULTIPLIER = 2.0;
private static final float DEFAULT_CB_FAILURE_RATE_THRESHOLD = 50f;
private static final int DEFAULT_CB_SLIDING_WINDOW_SIZE = 100;
private static final int DEFAULT_CB_MINIMUM_NUMBER_OF_CALLS = 100;
private static final Duration DEFAULT_CB_WAIT_DURATION_IN_OPEN_STATE = Duration.ofSeconds(60);
private static final int DEFAULT_CB_PERMITTED_CALLS_IN_HALF_OPEN = 10;
@ConstructorBinding
public OutboundHttpSettings {
if (connectTimeout == null || connectTimeout.isZero() || connectTimeout.isNegative()) {
throw new IllegalArgumentException(
"APP_OUTBOUND_HTTP_CONNECT_TIMEOUT (app.outbound.http.connect-timeout) must be a "
+ "positive duration (spring_duration_shorthand_non_zero, D5)");
}
if (readTimeout == null || readTimeout.isZero() || readTimeout.isNegative()) {
throw new IllegalArgumentException(
"APP_OUTBOUND_HTTP_READ_TIMEOUT (app.outbound.http.read-timeout) must be a "
+ "positive duration (spring_duration_shorthand_non_zero, D5)");
}
if (globalCallTimeout == null || globalCallTimeout.isZero() || globalCallTimeout.isNegative()) {
throw new IllegalArgumentException(
"APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT (app.outbound.http.global-call-timeout) must be a "
+ "positive duration (spring_duration_shorthand_non_zero, D5)");
}
if (responseSizeLimit == null) {
responseSizeLimit = DEFAULT_RESPONSE_SIZE_LIMIT;
} else if (responseSizeLimit.toBytes() <= 0) {
throw new IllegalArgumentException(
"APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT (app.outbound.http.response-size-limit) "
+ "must be a positive DataSize (registry default 10MB)");
}
// Unset nested sections (absent yml / secondary ctor) → substitute defaults-filled records.
if (retry == null) {
retry = new Retry(null, null, null);
}
if (circuitBreaker == null) {
circuitBreaker = new CircuitBreaker(null, null, null, null, null);
}
}
/**
* Secondary constructor: defaults for resilience tuning; preserves the original 6-arg call sites.
*/
public OutboundHttpSettings(
Duration connectTimeout,
Duration readTimeout,
Duration globalCallTimeout,
boolean retryEnabled,
boolean circuitBreakerEnabled,
DataSize responseSizeLimit) {
this(
connectTimeout,
readTimeout,
globalCallTimeout,
retryEnabled,
circuitBreakerEnabled,
responseSizeLimit,
null,
null);
}
/**
* Resilience4j retry tuning. Null fields fall back to defaults (maxAttempts=3 /
* initialBackoff=100ms / backoffMultiplier=2.0), preserving the prior hardcoded behavior.
*/
public record Retry(Integer maxAttempts, Duration initialBackoff, Double backoffMultiplier) {
public Retry {
if (maxAttempts == null) {
maxAttempts = DEFAULT_RETRY_MAX_ATTEMPTS;
} else if (maxAttempts < 1) {
throw new IllegalArgumentException(
"APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS (app.outbound.http.retry.max-attempts) "
+ "must be >= 1 (positive_int)");
}
if (initialBackoff == null) {
initialBackoff = DEFAULT_RETRY_INITIAL_BACKOFF;
} else if (initialBackoff.isZero() || initialBackoff.isNegative()) {
throw new IllegalArgumentException(
"APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF (app.outbound.http.retry.initial-backoff) "
+ "must be a positive duration (spring_duration_shorthand_non_zero)");
}
if (backoffMultiplier == null) {
backoffMultiplier = DEFAULT_RETRY_BACKOFF_MULTIPLIER;
} else if (backoffMultiplier < 1.0) {
throw new IllegalArgumentException(
"APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER (app.outbound.http.retry.backoff-multiplier) "
+ "must be >= 1.0 (double_ge_1)");
}
}
}
/**
* Resilience4j circuit-breaker tuning. Null fields fall back to Resilience4j {@code ofDefaults()}
* (failureRate=50 / slidingWindow=100 / minCalls=100 / waitOpen=60s / permittedHalfOpen=10).
* slidingWindowType is not exposed and stays the library default (COUNT_BASED).
*/
public record CircuitBreaker(
Float failureRateThreshold,
Integer slidingWindowSize,
Integer minimumNumberOfCalls,
Duration waitDurationInOpenState,
Integer permittedCallsInHalfOpen) {
public CircuitBreaker {
if (failureRateThreshold == null) {
failureRateThreshold = DEFAULT_CB_FAILURE_RATE_THRESHOLD;
} else if (failureRateThreshold <= 0f || failureRateThreshold > 100f) {
throw new IllegalArgumentException(
"APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD "
+ "(app.outbound.http.circuit-breaker.failure-rate-threshold) "
+ "must be in (0, 100] (float_in_0_exclusive_to_100)");
}
if (slidingWindowSize == null) {
slidingWindowSize = DEFAULT_CB_SLIDING_WINDOW_SIZE;
} else if (slidingWindowSize < 1) {
throw new IllegalArgumentException(
"APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE "
+ "(app.outbound.http.circuit-breaker.sliding-window-size) must be >= 1 (positive_int)");
}
if (minimumNumberOfCalls == null) {
minimumNumberOfCalls = DEFAULT_CB_MINIMUM_NUMBER_OF_CALLS;
} else if (minimumNumberOfCalls < 1) {
throw new IllegalArgumentException(
"APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS "
+ "(app.outbound.http.circuit-breaker.minimum-number-of-calls) must be >= 1 (positive_int)");
}
if (waitDurationInOpenState == null) {
waitDurationInOpenState = DEFAULT_CB_WAIT_DURATION_IN_OPEN_STATE;
} else if (waitDurationInOpenState.isZero() || waitDurationInOpenState.isNegative()) {
throw new IllegalArgumentException(
"APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE "
+ "(app.outbound.http.circuit-breaker.wait-duration-in-open-state) "
+ "must be a positive duration (spring_duration_shorthand_non_zero)");
}
if (permittedCallsInHalfOpen == null) {
permittedCallsInHalfOpen = DEFAULT_CB_PERMITTED_CALLS_IN_HALF_OPEN;
} else if (permittedCallsInHalfOpen < 1) {
throw new IllegalArgumentException(
"APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN "
+ "(app.outbound.http.circuit-breaker.permitted-calls-in-half-open) must be >= 1 (positive_int)");
}
}
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.adapter.outbound.httpclient;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.context.SmartLifecycle;
/**
* Outbound HTTP client shutdown guard. With {@code getPhase() = }{@link Integer#MAX_VALUE} it is
* stopped first during shutdown, setting the {@link #isShuttingDown()} flag ahead of any outbound
* caller. Why SmartLifecycle instead of {@code ContextClosedEvent}: see the module README.
*/
public final class OutboundHttpShutdownGuard implements SmartLifecycle {
private final AtomicBoolean running = new AtomicBoolean(false);
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
@Override
public void start() {
running.set(true);
}
@Override
public void stop() {
shuttingDown.set(true);
running.set(false);
}
@Override
public boolean isRunning() {
return running.get();
}
@Override
public boolean isAutoStartup() {
return true;
}
@Override
public int getPhase() {
return Integer.MAX_VALUE;
}
/**
* True after {@link #stop()} — callers short-circuit on this flag instead of waiting for
* timeouts.
*/
public boolean isShuttingDown() {
return shuttingDown.get();
}
}
@@ -0,0 +1,33 @@
package dev.caskeleton.adapter.outbound.httpclient;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.web.client.RestClient;
/**
* {@link BeanPostProcessor} that blocks raw, timeout-less {@link RestClient} / {@link
* RestClient.Builder} beans at startup. On detection it fails context startup, pointing to {@code
* OutboundHttpClient.baseline(...)} and the required env keys. It cannot catch a non-bean inline
* {@code RestClient.create()} — code review and the import-gate (G4) defend that case.
*/
public final class OutboundHttpTimeoutEnforcer implements BeanPostProcessor {
private static final String ERROR_MESSAGE =
"A raw RestClient or RestClient.Builder bean was detected. "
+ "All outbound HTTP clients must be built via OutboundHttpClient.baseline(...) "
+ "so that connect, read, and global-call timeouts are applied. "
+ "Set the required env keys: "
+ "APP_OUTBOUND_HTTP_CONNECT_TIMEOUT, "
+ "APP_OUTBOUND_HTTP_READ_TIMEOUT, "
+ "APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT "
+ "(feature-outbound-http-client-baseline I2 — timeout | Forbidden row).";
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (bean instanceof RestClient || bean instanceof RestClient.Builder) {
throw new BeanCreationException(beanName, ERROR_MESSAGE);
}
return bean;
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.httpclient;
/**
* Thrown on the BUFFERED client path when a response body exceeds {@link
* OutboundHttpSettings#responseSizeLimit()}. Callers expecting large responses must use {@link
* OutboundHttpClient#stream}.
*
* <p>Intentionally NOT a {@link dev.caskeleton.shared.error.DependencyFailureException} — it is a
* usage-contract violation (wrong API path), not an upstream failure, so it propagates
* unclassified.
*/
public final class OutboundResponseSizeExceededException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final String dependencyName;
private final long limitBytes;
public OutboundResponseSizeExceededException(String dependencyName, long limitBytes) {
super(
"Response from dependency '"
+ dependencyName
+ "' exceeds the configured limit of "
+ limitBytes
+ " bytes. Use the streaming API (OutboundHttpClient#stream) "
+ "for responses larger than the limit (D7: responses above the limit must be "
+ "streamed; buffered in-memory load above the limit is forbidden).");
this.dependencyName = dependencyName;
this.limitBytes = limitBytes;
}
/** The upstream dependency name that triggered the size violation. */
public String dependencyName() {
return dependencyName;
}
/** The configured limit in bytes that was exceeded. */
public long limitBytes() {
return limitBytes;
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.adapter.outbound.httpclient;
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
import dev.caskeleton.shared.error.DependencyFailureException;
import java.time.Instant;
import java.util.Set;
import org.springframework.http.HttpMethod;
/**
* Per-call retry gate for the outbound HTTP client. {@link #shouldRetry(Throwable)} retries only
* when all four conditions hold (not shutting down, idempotent method, retryable classification,
* deadline budget remaining). The call context lives in a ThreadLocal; pair {@link
* #beginCall}/{@link #endCall} in try/finally.
*/
public final class OutboundRetryPolicy {
/**
* Only RFC 9110 idempotent methods are retried. POST/PATCH are always excluded (no
* Idempotency-Key contract).
*/
private static final Set<HttpMethod> IDEMPOTENT_METHODS =
Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.PUT, HttpMethod.DELETE);
private final OutboundHttpShutdownGuard guard;
private final OutboundHttpErrorMapper mapper;
private final ThreadLocal<CallContext> callContextHolder = new ThreadLocal<>();
public OutboundRetryPolicy(
OutboundHttpSettings settings,
OutboundHttpShutdownGuard guard,
OutboundHttpErrorMapper mapper) {
// settings kept in the signature for future policy extension — the gate uses guard/mapper only.
this.guard = guard;
this.mapper = mapper;
}
/** Must be paired with {@link #endCall()} in try/finally. */
public void beginCall(HttpMethod method, Instant deadline) {
callContextHolder.set(new CallContext(method, deadline));
}
public void endCall() {
callContextHolder.remove();
}
public boolean shouldRetry(Throwable failure) {
if (guard.isShuttingDown()) {
return false;
}
CallContext ctx = callContextHolder.get();
if (ctx == null) {
return false;
}
if (!IDEMPOTENT_METHODS.contains(ctx.method())) {
// POST/PATCH are always false — Idempotency-Key contract undefined.
return false;
}
boolean retryable;
if (failure instanceof DependencyFailureException dfe) {
// Already classified — use the embedded code to avoid double-classification.
retryable = dfe.errorCode().retryable();
} else {
retryable = mapper.classify("_retry-check_", failure).errorCode().retryable();
}
if (!retryable) {
return false;
}
return Instant.now().isBefore(ctx.deadline());
}
private record CallContext(HttpMethod method, Instant deadline) {}
}
@@ -0,0 +1,130 @@
package dev.caskeleton.adapter.outbound.httpclient;
import java.io.IOException;
import java.io.InputStream;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRequest;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
/**
* Interceptor for the BUFFERED RestClient path that enforces {@link
* OutboundHttpSettings#responseSizeLimit()}. Rejects immediately when Content-Length exceeds the
* limit; since that header may be absent or wrong, it also wraps the body in a counting {@link
* BoundedInputStream} that throws once the limit is crossed.
*/
final class ResponseSizeBoundingInterceptor implements ClientHttpRequestInterceptor {
private final String dependencyName;
private final long limitBytes;
ResponseSizeBoundingInterceptor(String dependencyName, long limitBytes) {
this.dependencyName = dependencyName;
this.limitBytes = limitBytes;
}
@Override
public ClientHttpResponse intercept(
HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
ClientHttpResponse response = execution.execute(request, body);
// Level 1: Content-Length fast-path — refuse immediately, no body bytes consumed.
long contentLength = response.getHeaders().getContentLength();
if (contentLength > limitBytes) {
response.close();
throw new OutboundResponseSizeExceededException(dependencyName, limitBytes);
}
// Level 2: wrap the body stream with a counting InputStream that throws once
// the limit is crossed (handles missing or lying Content-Length).
return new SizeCapClientHttpResponse(response, dependencyName, limitBytes);
}
private static final class SizeCapClientHttpResponse implements ClientHttpResponse {
private final ClientHttpResponse delegate;
private final String dependencyName;
private final long limitBytes;
private InputStream boundedBody;
SizeCapClientHttpResponse(ClientHttpResponse delegate, String dependencyName, long limitBytes) {
this.delegate = delegate;
this.dependencyName = dependencyName;
this.limitBytes = limitBytes;
}
@Override
public InputStream getBody() throws IOException {
if (boundedBody == null) {
boundedBody = new BoundedInputStream(delegate.getBody(), dependencyName, limitBytes);
}
return boundedBody;
}
@Override
public HttpStatusCode getStatusCode() throws IOException {
return delegate.getStatusCode();
}
@Override
public String getStatusText() throws IOException {
return delegate.getStatusText();
}
@Override
public HttpHeaders getHeaders() {
return delegate.getHeaders();
}
@Override
public void close() {
delegate.close();
}
}
static final class BoundedInputStream extends InputStream {
private final InputStream delegate;
private final String dependencyName;
private final long limitBytes;
private long bytesRead = 0;
BoundedInputStream(InputStream delegate, String dependencyName, long limitBytes) {
this.delegate = delegate;
this.dependencyName = dependencyName;
this.limitBytes = limitBytes;
}
@Override
public int read() throws IOException {
int b = delegate.read();
if (b != -1) {
checkLimit(1);
}
return b;
}
@Override
public int read(byte[] buf, int off, int len) throws IOException {
int n = delegate.read(buf, off, len);
if (n > 0) {
checkLimit(n);
}
return n;
}
@Override
public void close() throws IOException {
delegate.close();
}
private void checkLimit(int n) {
bytesRead += n;
if (bytesRead > limitBytes) {
throw new OutboundResponseSizeExceededException(dependencyName, limitBytes);
}
}
}
}
@@ -0,0 +1,108 @@
package dev.caskeleton.adapter.outbound.httpclient;
import dev.caskeleton.shared.tracing.BaggageAllowlist;
import dev.caskeleton.shared.tracing.TraceParent;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.MDC;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
/**
* {@link ClientHttpRequestInterceptor} that propagates the distributed trace context from the
* current SLF4J {@link MDC} into outbound HTTP headers ({@code traceparent}, {@code X-Request-Id},
* {@code X-Correlation-Id}, {@code baggage}). Baggage is filtered by {@link BaggageAllowlist} so
* only {@code request_id}/{@code tenant_id} leave and credentials/PII are stripped (D8).
* Already-set headers are not overwritten, and the downstream call always runs.
*
* <p><strong>⚠ FORK LANDMINE:</strong> the sampled flag is hardcoded {@code 00} (not-sampled) and
* this interceptor is registered FIRST on the RestClient — a fork that wires a real tracer
* (Micrometer Tracing + OTel) will have its {@code traceparent} overwritten and its sampling
* decision dropped. A fork MUST disable this interceptor OR replace the hardcoded flag. See the
* module README (FORK LANDMINE section) for the full mechanism and checklist.
*/
public final class TraceContextPropagationInterceptor implements ClientHttpRequestInterceptor {
// snake_case MDC keys — SSOT: docs/registries/mdc-keys.yaml (foundation D11)
private static final String MDC_TRACE_ID = "trace_id";
private static final String MDC_SPAN_ID = "span_id";
private static final String MDC_REQUEST_ID = "request_id";
private static final String MDC_CORRELATION_ID = "correlation_id";
private static final String MDC_TENANT_ID = "tenant_id";
// Outbound header names (W3C / docs/registries/headers.yaml)
private static final String HEADER_TRACEPARENT = "traceparent";
private static final String HEADER_REQUEST_ID = "X-Request-Id";
private static final String HEADER_CORRELATION_ID = "X-Correlation-Id";
private static final String HEADER_BAGGAGE = "baggage";
// Baggage MDC keys to collect (must all be allowlisted — D8 defense in depth)
private static final List<String> BAGGAGE_MDC_KEYS = List.of(MDC_REQUEST_ID, MDC_TENANT_ID);
@Override
public ClientHttpResponse intercept(
HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
injectHeaders(request);
return execution.execute(request, body);
}
private void injectHeaders(HttpRequest request) {
injectTraceparent(request);
injectSingleHeader(request, HEADER_REQUEST_ID, MDC.get(MDC_REQUEST_ID));
injectSingleHeader(request, HEADER_CORRELATION_ID, MDC.get(MDC_CORRELATION_ID));
injectBaggage(request);
}
private void injectTraceparent(HttpRequest request) {
if (request.getHeaders().containsHeader(HEADER_TRACEPARENT)) {
return; // already set — do not overwrite
}
String traceId = MDC.get(MDC_TRACE_ID);
String spanId = MDC.get(MDC_SPAN_ID);
if (!TraceParent.isValidTraceId(traceId) || !TraceParent.isValidSpanId(spanId)) {
return; // malformed or absent — skip silently
}
try {
// sampled=false: the skeleton has no exporter/sampler and mdc-keys.yaml has no
// trace-flags key, so the inbound sampled bit cannot be propagated (see FORK LANDMINE).
TraceParent tp = TraceParent.of(traceId, spanId, false);
request.getHeaders().set(HEADER_TRACEPARENT, tp.toHeader());
} catch (IllegalArgumentException ignored) {
// The guards above should prevent this; if reached (e.g. concurrent MDC mutation), skip
// instead of throwing.
}
}
private void injectBaggage(HttpRequest request) {
if (request.getHeaders().containsHeader(HEADER_BAGGAGE)) {
return; // already set — do not overwrite
}
Map<String, String> raw = new LinkedHashMap<>();
for (String key : BAGGAGE_MDC_KEYS) {
String value = MDC.get(key);
if (value != null && !value.isBlank()) {
raw.put(key, value);
}
}
// Defense in depth: even if BAGGAGE_MDC_KEYS drifts, the filter removes forbidden keys.
Map<String, String> safe = BaggageAllowlist.filter(raw);
String rendered = BaggageAllowlist.renderHeader(safe);
if (!rendered.isEmpty()) {
request.getHeaders().set(HEADER_BAGGAGE, rendered);
}
}
private static void injectSingleHeader(HttpRequest request, String headerName, String mdcValue) {
if (mdcValue == null || mdcValue.isBlank()) {
return;
}
if (request.getHeaders().containsHeader(headerName)) {
return; // do not overwrite existing header
}
request.getHeaders().set(headerName, mdcValue);
}
}
@@ -0,0 +1,67 @@
package dev.caskeleton.adapter.outbound.httpclient.diagnostics;
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Structured log emitter for the outbound HTTP path. Field names follow the registry
* log_field_mapping SSOT. It accepts no body/URI/payload, so PII cannot reach the log (D13). Log
* levels: success=DEBUG, outcome CIRCUIT_OPEN/REJECTED=WARN (expected transient state), other
* failures=ERROR.
*/
public final class OutboundHttpDependencyLogger {
private final Logger log;
public OutboundHttpDependencyLogger() {
this(LoggerFactory.getLogger(OutboundHttpDependencyLogger.class));
}
/** Test seam — inject a logger bound to a captured appender. */
public OutboundHttpDependencyLogger(Logger log) {
this.log = log;
}
public void logSuccess(String dependencyName, long durationMs, int retryAttempt) {
log.debug(
"dependency_name=\"{}\" dependency_type=\"http\" outcome=\"SUCCESS\" "
+ "duration_ms={} retry_attempt={} correlation_id=\"{}\"",
dependencyName,
durationMs,
retryAttempt,
OutboundCorrelation.current());
}
public void logFailure(
String dependencyName, String outcome, long durationMs, int retryAttempt, Throwable cause) {
String errorField = cause.getClass().getSimpleName() + ": " + cause.getMessage();
String format =
"dependency_name=\"{}\" dependency_type=\"http\" "
+ "outcome=\"{}\" "
+ "duration_ms={} "
+ "retry_attempt={} "
+ "correlation_id=\"{}\" "
+ "error=\"{}\"";
if ("CIRCUIT_OPEN".equals(outcome) || "REJECTED".equals(outcome)) {
log.warn(
format,
dependencyName,
outcome,
durationMs,
retryAttempt,
OutboundCorrelation.current(),
errorField);
} else {
log.error(
format,
dependencyName,
outcome,
durationMs,
retryAttempt,
OutboundCorrelation.current(),
errorField);
}
}
}
@@ -0,0 +1,164 @@
package dev.caskeleton.adapter.outbound.httpclient.diagnostics;
import dev.caskeleton.shared.error.DependencyFailureException;
import dev.caskeleton.shared.error.OperationalError;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import java.net.ConnectException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.net.http.HttpConnectTimeoutException;
import java.net.http.HttpTimeoutException;
import java.nio.channels.UnresolvedAddressException;
import java.util.concurrent.TimeoutException;
import org.springframework.web.client.RestClientResponseException;
/**
* Classifies raw network/HTTP exceptions into stable {@link OperationalError}{@code .DEPENDENCY_*}
* codes. Walks the full cause chain once and takes the first match in priority order. Why the
* ordering matters (type hierarchy) and the 4xx/408/429 policy are in the module README.
*
* <p>Body-leakage safety (D12): the returned diagnostic message never includes the response body,
* headers, or payload — only the HTTP status and exception class names. The original throwable is
* attached as the cause only.
*/
public final class OutboundHttpErrorMapper {
/** Returns the classified exception — never {@code null}; always rethrow or propagate. */
public DependencyFailureException classify(String dependencyName, Throwable failure) {
Throwable current = failure;
while (current != null) {
if (current instanceof CallNotPermittedException) {
return new DependencyFailureException(
OperationalError.DEPENDENCY_CIRCUIT_OPEN,
dependencyName,
"Circuit breaker open for dependency: "
+ dependencyName
+ " ("
+ current.getClass().getSimpleName()
+ ")",
failure);
}
if (current instanceof UnknownHostException
|| current instanceof UnresolvedAddressException) {
return new DependencyFailureException(
OperationalError.DEPENDENCY_DNS_FAILED,
dependencyName,
"DNS resolution failed for dependency: "
+ dependencyName
+ " ("
+ current.getClass().getSimpleName()
+ ")",
failure);
}
// HttpConnectTimeoutException extends HttpTimeoutException — check before the read-timeout
// rule.
if (current instanceof HttpConnectTimeoutException) {
return new DependencyFailureException(
OperationalError.DEPENDENCY_CONNECT_FAILED,
dependencyName,
"Connect failed for dependency: "
+ dependencyName
+ " ("
+ current.getClass().getSimpleName()
+ ")",
failure);
}
if (current instanceof ConnectException) {
// A ConnectException may wrap an UnresolvedAddressException, so DNS takes priority.
if (hasDnsCauseInChain(current.getCause())) {
return new DependencyFailureException(
OperationalError.DEPENDENCY_DNS_FAILED,
dependencyName,
"DNS resolution failed for dependency: "
+ dependencyName
+ " (wrapped in ConnectException; root cause "
+ rootCauseClassName(current)
+ ")",
failure);
}
return new DependencyFailureException(
OperationalError.DEPENDENCY_CONNECT_FAILED,
dependencyName,
"Connect failed for dependency: "
+ dependencyName
+ " ("
+ current.getClass().getSimpleName()
+ ")",
failure);
}
if (current instanceof HttpTimeoutException
|| current instanceof SocketTimeoutException
|| current instanceof TimeoutException) {
return new DependencyFailureException(
OperationalError.DEPENDENCY_TIMEOUT,
dependencyName,
"Timeout for dependency: "
+ dependencyName
+ " ("
+ current.getClass().getSimpleName()
+ ")",
failure);
}
if (current instanceof RestClientResponseException responseEx) {
int status = responseEx.getStatusCode().value();
if (status >= 400 && status < 500) {
String diagnostic = build4xxDiagnostic(dependencyName, status);
return new DependencyFailureException(
OperationalError.DEPENDENCY_4XX_CLIENT, dependencyName, diagnostic, failure);
}
if (status >= 500) {
// Diagnostic message must NOT include getResponseBodyAsString() (D12).
return new DependencyFailureException(
OperationalError.DEPENDENCY_5XX_SERVER,
dependencyName,
"Upstream 5xx from dependency: "
+ dependencyName
+ " status="
+ status
+ " ("
+ current.getClass().getSimpleName()
+ ")",
failure);
}
}
current = current.getCause();
}
// No recognised cause in the chain — conservative fallback.
String rootCauseClass = rootCauseClassName(failure);
return new DependencyFailureException(
OperationalError.DEPENDENCY_CONNECT_FAILED,
dependencyName,
"Unclassified failure for dependency: " + dependencyName + " root-cause=" + rootCauseClass,
failure);
}
private static String build4xxDiagnostic(String dependencyName, int status) {
String base = "Upstream 4xx from dependency: " + dependencyName + " status=" + status;
return switch (status) {
case 401 -> base + " — check credential / auth-token configuration";
case 403 -> base + " — check scope/config for dependency access";
default -> base;
};
}
private static boolean hasDnsCauseInChain(Throwable t) {
Throwable current = t;
while (current != null) {
if (current instanceof UnknownHostException
|| current instanceof UnresolvedAddressException) {
return true;
}
current = current.getCause();
}
return false;
}
private static String rootCauseClassName(Throwable t) {
Throwable root = t;
while (root.getCause() != null) {
root = root.getCause();
}
return root.getClass().getSimpleName();
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings;
import dev.caskeleton.adapter.outbound.httpclient.OutboundRetryPolicy;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import io.github.resilience4j.core.IntervalFunction;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import io.github.resilience4j.retry.RetryRegistry;
import java.util.Optional;
/**
* Holder for the optional Resilience4j retry / circuit-breaker machinery. Returns {@link
* Optional#empty()} (no decoration) when the corresponding feature is disabled.
*/
public final class OutboundHttpResilience {
private final OutboundHttpSettings settings;
private final OutboundRetryPolicy retryPolicy;
private final RetryRegistry retryRegistry;
private final CircuitBreakerRegistry circuitBreakerRegistry;
/** Registry args are nullable — pass {@code null} when the feature is disabled. */
public OutboundHttpResilience(
OutboundHttpSettings settings,
OutboundRetryPolicy retryPolicy,
RetryRegistry retryRegistry,
CircuitBreakerRegistry circuitBreakerRegistry) {
this.settings = settings;
this.retryPolicy = retryPolicy;
this.retryRegistry = retryRegistry;
this.circuitBreakerRegistry = circuitBreakerRegistry;
}
/** Returns {@link Optional#empty()} when retry is disabled. */
public Optional<Retry> retryFor(String dependencyName) {
if (!settings.retryEnabled() || retryRegistry == null) {
return Optional.empty();
}
OutboundHttpSettings.Retry r = settings.retry();
RetryConfig config =
RetryConfig.custom()
.maxAttempts(r.maxAttempts())
.intervalFunction(
IntervalFunction.ofExponentialRandomBackoff(
r.initialBackoff(), r.backoffMultiplier()))
.retryOnException(retryPolicy::shouldRetry)
.build();
return Optional.of(retryRegistry.retry(dependencyName, config));
}
/** Returns {@link Optional#empty()} when the circuit breaker is disabled. */
public Optional<CircuitBreaker> circuitBreakerFor(String dependencyName) {
if (!settings.circuitBreakerEnabled() || circuitBreakerRegistry == null) {
return Optional.empty();
}
OutboundHttpSettings.CircuitBreaker c = settings.circuitBreaker();
CircuitBreakerConfig config =
CircuitBreakerConfig.custom()
.failureRateThreshold(c.failureRateThreshold())
.slidingWindowSize(c.slidingWindowSize())
.minimumNumberOfCalls(c.minimumNumberOfCalls())
.waitDurationInOpenState(c.waitDurationInOpenState())
.permittedNumberOfCallsInHalfOpenState(c.permittedCallsInHalfOpen())
.build();
return Optional.of(circuitBreakerRegistry.circuitBreaker(dependencyName, config));
}
}
@@ -0,0 +1,174 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings;
import dev.caskeleton.adapter.outbound.httpclient.OutboundRetryPolicy;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import io.github.resilience4j.micrometer.tagged.TaggedCircuitBreakerMetrics;
import io.github.resilience4j.micrometer.tagged.TaggedRetryMetrics;
import io.github.resilience4j.retry.RetryRegistry;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.config.MeterFilter;
import io.micrometer.core.instrument.config.MeterFilterReply;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Builds the {@link OutboundHttpResilience} bean and normalises the resilience4j metrics.
*
* <p>Micrometer 1.15.x incompatibility: {@code MeterFilter.replaceTagValues}/{@code renameTag} do
* not transform the tags of the {@code FunctionCounter}/{@code DefaultGauge} instances registered
* by {@code TaggedRetryMetrics}/{@code TaggedCircuitBreakerMetrics} — an explicit {@code
* map(Meter.Id)} implementation is required (verified on Micrometer 1.15.11 / Resilience4j 2.2.0).
* Full normalisation / DENY-policy rationale is in the module README.
*/
@Configuration
public class OutboundHttpResilienceConfig {
private static final String RETRY_CALLS_METER = "resilience4j.retry.calls";
private static final String CB_CALLS_METER = "resilience4j.circuitbreaker.calls";
private static final String CB_STATE_METER = "resilience4j.circuitbreaker.state";
private static final String RESILIENCE4J_PREFIX = "resilience4j.";
@Bean
public OutboundHttpResilience outboundHttpResilience(
OutboundHttpSettings settings,
OutboundRetryPolicy retryPolicy,
ObjectProvider<MeterRegistry> meterRegistryProvider) {
boolean resilienceEnabled = settings.retryEnabled() || settings.circuitBreakerEnabled();
MeterRegistry meterRegistry = meterRegistryProvider.getIfAvailable();
// D3: a MeterRegistry is required when retry/CB is enabled — resilience without metrics is
// forbidden.
if (resilienceEnabled && meterRegistry == null) {
throw new IllegalStateException(
"APP_OUTBOUND_HTTP_RETRY_ENABLED/CIRCUIT_BREAKER_ENABLED=true requires a "
+ "MeterRegistry — retry/circuit breaker without low-cardinality metrics is "
+ "forbidden (feature-outbound-http-client-baseline D3)");
}
RetryRegistry retryRegistry = null;
CircuitBreakerRegistry cbRegistry = null;
if (resilienceEnabled) {
// Filters affect only meters registered AFTER install. CB state gauges are
// registered eagerly at bindTo(), so filters must be installed before bindTo()
// or the transform is silently skipped for them.
applyMeterFilters(meterRegistry);
if (settings.retryEnabled()) {
retryRegistry = RetryRegistry.ofDefaults();
TaggedRetryMetrics.ofRetryRegistry(retryRegistry).bindTo(meterRegistry);
}
if (settings.circuitBreakerEnabled()) {
cbRegistry = CircuitBreakerRegistry.ofDefaults();
TaggedCircuitBreakerMetrics.ofCircuitBreakerRegistry(cbRegistry).bindTo(meterRegistry);
}
}
return new OutboundHttpResilience(settings, retryPolicy, retryRegistry, cbRegistry);
}
private static void applyMeterFilters(MeterRegistry meterRegistry) {
// Rename "kind" → "outcome" and remap vendor values (retry.calls).
meterRegistry
.config()
.meterFilter(
remapKindToOutcome(
RETRY_CALLS_METER,
kind ->
switch (kind) {
case "successful_without_retry", "successful_with_retry" -> "SUCCESS";
case "failed_without_retry", "failed_with_retry" -> "FAILURE";
default -> kind;
}));
// Rename "kind" → "outcome" (circuitbreaker.calls).
meterRegistry
.config()
.meterFilter(
remapKindToOutcome(
CB_CALLS_METER,
kind ->
switch (kind) {
case "successful" -> "SUCCESS";
case "failed", "ignored" -> "FAILURE";
default -> kind;
}));
// Uppercase the "state" tag values (circuitbreaker.state).
meterRegistry.config().meterFilter(uppercaseStateTag(CB_STATE_METER));
// DENY vendor resilience4j.* extras outside the approved three meters (D4 low-cardinality).
meterRegistry
.config()
.meterFilter(
new MeterFilter() {
@Override
public MeterFilterReply accept(Meter.Id id) {
String name = id.getName();
if (!name.startsWith(RESILIENCE4J_PREFIX)) {
return MeterFilterReply.NEUTRAL;
}
if (name.equals(RETRY_CALLS_METER)
|| name.equals(CB_CALLS_METER)
|| name.equals(CB_STATE_METER)) {
return MeterFilterReply.NEUTRAL;
}
return MeterFilterReply.DENY;
}
@Override
public Meter.Id map(Meter.Id id) {
return id;
}
});
}
private static MeterFilter remapKindToOutcome(
String meterName, Function<String, String> kindMapper) {
return new MeterFilter() {
@Override
public Meter.Id map(Meter.Id id) {
if (!id.getName().equals(meterName)) {
return id;
}
List<Tag> newTags = new ArrayList<>();
for (Tag t : id.getTags()) {
if ("kind".equals(t.getKey())) {
newTags.add(Tag.of("outcome", kindMapper.apply(t.getValue())));
} else {
newTags.add(t);
}
}
return id.replaceTags(newTags);
}
};
}
private static MeterFilter uppercaseStateTag(String meterName) {
return new MeterFilter() {
@Override
public Meter.Id map(Meter.Id id) {
if (!id.getName().equals(meterName)) {
return id;
}
List<Tag> newTags = new ArrayList<>();
for (Tag t : id.getTags()) {
if ("state".equals(t.getKey())) {
newTags.add(Tag.of("state", t.getValue().toUpperCase()));
} else {
newTags.add(t);
}
}
return id.replaceTags(newTags);
}
};
}
}
@@ -0,0 +1,129 @@
package dev.caskeleton.adapter.outbound.httpclient
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper
import dev.caskeleton.shared.error.DependencyFailureException
import dev.caskeleton.shared.error.OperationalError
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.util.unit.DataSize
import org.springframework.web.client.HttpClientErrorException
import spock.lang.Specification
import java.net.http.HttpTimeoutException
import java.nio.charset.StandardCharsets
import java.time.Duration
import java.time.Instant
/**
* {@link OutboundRetryPolicy} 도메인 스펙 — C2 테스트 형태(순수 재시도 결정 로직 = Spock).
*
* <p>feature-outbound-http-client-baseline D6/D8, plan I3/I4: shouldRetry 는 네 조건이 모두 참일
* 때만 true — (1) 셧다운 아님, (2) 컨텍스트 존재 + 멱등 메서드, (3) 분류가 재시도 가능, (4) 마감 이내.</p>
*/
class OutboundRetryPolicySpec extends Specification {
OutboundHttpShutdownGuard guard
OutboundRetryPolicy policy
def setup() {
def settings = new OutboundHttpSettings(
Duration.ofSeconds(2),
Duration.ofSeconds(5),
Duration.ofSeconds(10),
true,
false,
DataSize.ofMegabytes(10))
guard = new OutboundHttpShutdownGuard()
guard.start()
policy = new OutboundRetryPolicy(settings, guard, new OutboundHttpErrorMapper())
}
def "멱등 메서드 #method 는 재시도 가능 오류 + 마감 이내면 재시도 여부가 #expected 다"() {
given:
policy.beginCall(method, Instant.now().plusSeconds(30))
expect: "POST/PATCH 는 멱등키 계약 미정의(I4)라 항상 false"
policy.shouldRetry(new HttpTimeoutException("read timed out")) == expected
cleanup:
policy.endCall()
where:
method || expected
HttpMethod.GET || true
HttpMethod.HEAD || true
HttpMethod.PUT || true
HttpMethod.DELETE || true
HttpMethod.POST || false
HttpMethod.PATCH || false
}
def "셧다운 중이면 GET 도 재시도를 억제한다 (D8)"() {
given:
guard.stop()
policy.beginCall(HttpMethod.GET, Instant.now().plusSeconds(30))
expect:
!policy.shouldRetry(new HttpTimeoutException("timeout"))
cleanup:
policy.endCall()
}
def "비재시도 분류(4xx)는 GET 이라도 재시도하지 않는다"() {
given:
def fourXx = HttpClientErrorException.create(HttpStatus.NOT_FOUND, "Not Found",
HttpHeaders.EMPTY, new byte[0], StandardCharsets.UTF_8)
policy.beginCall(HttpMethod.GET, Instant.now().plusSeconds(30))
expect:
!policy.shouldRetry(fourXx)
cleanup:
policy.endCall()
}
def "마감을 지난 호출은 재시도 가능 오류라도 재시도하지 않는다 (I3)"() {
given:
policy.beginCall(HttpMethod.GET, Instant.now().minusSeconds(1))
expect:
!policy.shouldRetry(new HttpTimeoutException("timeout"))
cleanup:
policy.endCall()
}
def "호출 컨텍스트가 없으면 재시도하지 않는다"() {
expect:
!policy.shouldRetry(new HttpTimeoutException("timeout"))
}
def "endCall 이후에는 컨텍스트가 비어 재시도하지 않는다"() {
given:
policy.beginCall(HttpMethod.GET, Instant.now().plusSeconds(30))
policy.endCall()
expect:
!policy.shouldRetry(new HttpTimeoutException("timeout"))
}
def "이미 분류된 DependencyFailureException 은 내장 코드(#code)로 재시도 여부를 #expected 로 판단한다"() {
given:
policy.beginCall(HttpMethod.GET, Instant.now().plusSeconds(30))
expect:
policy.shouldRetry(new DependencyFailureException(code, "test-api", "x", null)) == expected
cleanup:
policy.endCall()
where:
code || expected
OperationalError.DEPENDENCY_5XX_SERVER || true
OperationalError.DEPENDENCY_4XX_CLIENT || false
}
}
@@ -0,0 +1,106 @@
package dev.caskeleton.adapter.outbound.httpclient.diagnostics
import dev.caskeleton.shared.error.OperationalError
import io.github.resilience4j.circuitbreaker.CallNotPermittedException
import io.github.resilience4j.circuitbreaker.CircuitBreaker
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.web.client.HttpClientErrorException
import org.springframework.web.client.HttpServerErrorException
import org.springframework.web.client.ResourceAccessException
import spock.lang.Specification
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.net.http.HttpConnectTimeoutException
import java.net.http.HttpTimeoutException
import java.nio.channels.UnresolvedAddressException
import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeoutException
/**
* {@link OutboundHttpErrorMapper} 도메인 스펙 — C2 테스트 형태(순수 예외→코드 분류 매트릭스 = Spock).
*
* <p>feature-outbound-http-client-baseline D12, plan I5/I10: 원인 체인을 따라 우선순위 표의 첫 매칭이
* 이긴다. 상류 응답 바디는 분류 예외 메시지에 노출되지 않는다(D12 누출 방지).</p>
*/
class OutboundHttpErrorMapperSpec extends Specification {
def mapper = new OutboundHttpErrorMapper()
def "#desc 는 #expectedCode 로 분류된다"() {
expect:
mapper.classify("dep-api", failure).errorCode() == expectedCode
where:
desc | failure || expectedCode
"서킷 오픈" | CallNotPermittedException.createCallNotPermittedException(CircuitBreaker.ofDefaults("cb")) || OperationalError.DEPENDENCY_CIRCUIT_OPEN
"알 수 없는 호스트" | new UnknownHostException("h") || OperationalError.DEPENDENCY_DNS_FAILED
"미해결 주소" | new UnresolvedAddressException() || OperationalError.DEPENDENCY_DNS_FAILED
"ResourceAccessException 으로 감싼 호스트 실패" | new ResourceAccessException("I/O error", new UnknownHostException("h")) || OperationalError.DEPENDENCY_DNS_FAILED
"커넥트 타임아웃(read 타임아웃보다 우선)" | new HttpConnectTimeoutException("connect timed out") || OperationalError.DEPENDENCY_CONNECT_FAILED
"커넥트 거부" | new ConnectException("Connection refused") || OperationalError.DEPENDENCY_CONNECT_FAILED
"HTTP read 타임아웃" | new HttpTimeoutException("read timed out") || OperationalError.DEPENDENCY_TIMEOUT
"소켓 타임아웃" | new SocketTimeoutException("Read timed out") || OperationalError.DEPENDENCY_TIMEOUT
"concurrent 타임아웃" | new TimeoutException("deadline exceeded") || OperationalError.DEPENDENCY_TIMEOUT
"HTTP 404" | clientError(HttpStatus.NOT_FOUND) || OperationalError.DEPENDENCY_4XX_CLIENT
"HTTP 408 (registry SSOT: 4xx)" | clientError(HttpStatus.REQUEST_TIMEOUT) || OperationalError.DEPENDENCY_4XX_CLIENT
"HTTP 429 (registry SSOT: 4xx)" | clientError(HttpStatus.TOO_MANY_REQUESTS) || OperationalError.DEPENDENCY_4XX_CLIENT
"HTTP 500" | serverError(HttpStatus.INTERNAL_SERVER_ERROR) || OperationalError.DEPENDENCY_5XX_SERVER
"HTTP 503" | serverError(HttpStatus.SERVICE_UNAVAILABLE) || OperationalError.DEPENDENCY_5XX_SERVER
"미인식 예외(보수적 폴백)" | new RuntimeException("something weird") || OperationalError.DEPENDENCY_CONNECT_FAILED
}
def "401 은 4xx 로 분류되고 credential 진단을 포함한다"() {
when:
def result = mapper.classify("secure-api", clientError(HttpStatus.UNAUTHORIZED))
then:
result.errorCode() == OperationalError.DEPENDENCY_4XX_CLIENT
result.message.contains("credential")
}
def "403 은 4xx 로 분류되고 scope 진단을 포함한다"() {
when:
def result = mapper.classify("secure-api", clientError(HttpStatus.FORBIDDEN))
then:
result.errorCode() == OperationalError.DEPENDENCY_4XX_CLIENT
result.message.contains("scope")
}
def "폴백 진단은 root cause 클래스명을 포함한다"() {
when:
def result = mapper.classify("unknown-api", new RuntimeException("something weird"))
then:
result.message.contains("RuntimeException")
}
def "상류 응답 바디는 분류된 예외 메시지에 노출되지 않는다 (D12)"() {
given:
byte[] secretBody = "UPSTREAM_SECRET".getBytes(StandardCharsets.UTF_8)
def ex = HttpServerErrorException.create(HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error",
HttpHeaders.EMPTY, secretBody, StandardCharsets.UTF_8)
when:
def result = mapper.classify("leaky-api", ex)
then:
!result.message.contains("UPSTREAM_SECRET")
}
private static HttpClientErrorException clientError(HttpStatus status) {
HttpClientErrorException.create(status, status.reasonPhrase,
HttpHeaders.EMPTY, new byte[0], StandardCharsets.UTF_8)
}
private static HttpServerErrorException serverError(HttpStatus status) {
HttpServerErrorException.create(status, status.reasonPhrase,
HttpHeaders.EMPTY, new byte[0], StandardCharsets.UTF_8)
}
}
@@ -0,0 +1,182 @@
package dev.caskeleton.adapter.outbound.httpclient;
import static org.assertj.core.api.Assertions.assertThat;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger;
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
import dev.caskeleton.shared.error.DependencyFailureException;
import dev.caskeleton.shared.error.OperationalError;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import java.net.SocketTimeoutException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
/**
* Unit tests for {@link OutboundHttpCallObserver}.
*
* <p>Verifies:
*
* <ul>
* <li>{@code recordSuccess} emits a DEBUG log line with the correct fields.
* <li>{@code recordFailure} classifies, logs at ERROR/WARN, and returns a {@link
* DependencyFailureException} (so the caller can {@code throw observer.recordFailure(...)}).
* <li>{@code rejectShutdown} builds DEPENDENCY_CIRCUIT_OPEN / REJECTED and returns the dfe.
* <li>{@code outcomeFor} mapping: TIMEOUT → "TIMEOUT", CIRCUIT_OPEN → "CIRCUIT_OPEN", other →
* "FAILURE".
* </ul>
*/
class OutboundHttpCallObserverTest {
private static final String DEP = "test-dep";
private ch.qos.logback.classic.Logger logbackLogger;
private ListAppender<ILoggingEvent> logAppender;
private OutboundHttpCallObserver observer;
@BeforeEach
void setUp() {
logbackLogger =
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbound.observer");
logAppender = new ListAppender<>();
logAppender.start();
logbackLogger.addAppender(logAppender);
logbackLogger.setLevel(Level.DEBUG);
OutboundHttpDependencyLogger logger = new OutboundHttpDependencyLogger(logbackLogger);
observer = new OutboundHttpCallObserver(DEP, new OutboundHttpErrorMapper(), logger);
}
// =========================================================================
// recordSuccess
// =========================================================================
@Test
void recordSuccessEmitsDebugLogWithOutcomeSUCCESS() {
long startNs = System.nanoTime();
observer.recordSuccess(startNs, 0);
assertThat(logAppender.list).hasSize(1);
ILoggingEvent event = logAppender.list.get(0);
assertThat(event.getLevel()).isEqualTo(Level.DEBUG);
assertThat(event.getFormattedMessage()).contains("outcome=\"SUCCESS\"");
assertThat(event.getFormattedMessage()).contains("dependency_name=\"" + DEP + "\"");
assertThat(event.getFormattedMessage()).contains("retry_attempt=0");
}
@Test
void recordSuccessIncludesNonZeroRetryAttempt() {
long startNs = System.nanoTime();
observer.recordSuccess(startNs, 2);
assertThat(logAppender.list).hasSize(1);
assertThat(logAppender.list.get(0).getFormattedMessage()).contains("retry_attempt=2");
}
// =========================================================================
// recordFailure — outcome mapping
// =========================================================================
@Test
void recordFailureTimeoutMapsToTIMEOUTOutcomeAndReturnsDfe() {
long startNs = System.nanoTime();
SocketTimeoutException cause = new SocketTimeoutException("read timed out");
DependencyFailureException dfe = observer.recordFailure(cause, startNs, 0);
assertThat(dfe).isNotNull();
assertThat(dfe.errorCode()).isEqualTo(OperationalError.DEPENDENCY_TIMEOUT);
assertThat(logAppender.list).hasSize(1);
assertThat(logAppender.list.get(0).getFormattedMessage()).contains("outcome=\"TIMEOUT\"");
}
@Test
void recordFailureCircuitOpenMapsToCIRCUITOPENOutcomeAndLogsWARN() {
long startNs = System.nanoTime();
// Build a DFE that classify() will return for a CallNotPermittedException
CircuitBreaker cb = CircuitBreaker.ofDefaults("test");
cb.transitionToOpenState();
CallNotPermittedException cnp = CallNotPermittedException.createCallNotPermittedException(cb);
DependencyFailureException dfe = observer.recordFailure(cnp, startNs, 0);
assertThat(dfe).isNotNull();
assertThat(dfe.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CIRCUIT_OPEN);
assertThat(logAppender.list).hasSize(1);
ILoggingEvent event = logAppender.list.get(0);
assertThat(event.getLevel()).isEqualTo(Level.WARN);
assertThat(event.getFormattedMessage()).contains("outcome=\"CIRCUIT_OPEN\"");
}
@Test
void recordFailureUnclassifiedMapsToFAILUREOutcomeAndLogsERROR() {
long startNs = System.nanoTime();
RuntimeException cause = new RuntimeException("unexpected");
DependencyFailureException dfe = observer.recordFailure(cause, startNs, 1);
assertThat(dfe).isNotNull();
// fallback → DEPENDENCY_CONNECT_FAILED in classifier, outcome → "FAILURE"
assertThat(logAppender.list).hasSize(1);
ILoggingEvent event = logAppender.list.get(0);
assertThat(event.getLevel()).isEqualTo(Level.ERROR);
assertThat(event.getFormattedMessage()).contains("outcome=\"FAILURE\"");
assertThat(event.getFormattedMessage()).contains("retry_attempt=1");
}
@Test
void recordFailureReturnsDfeSoCallerCanThrow() {
long startNs = System.nanoTime();
DependencyFailureException dfe =
observer.recordFailure(new RuntimeException("boom"), startNs, 0);
// Must be throwable
assertThat(dfe).isInstanceOf(DependencyFailureException.class);
}
// =========================================================================
// rejectShutdown
// =========================================================================
@Test
void rejectShutdownReturnsDEPENDENCYCIRCUITOPENWithProvidedMessage() {
String msg = "shutdown in progress — outbound call rejected fail-fast (D8)";
DependencyFailureException dfe = observer.rejectShutdown(msg);
assertThat(dfe).isNotNull();
assertThat(dfe.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CIRCUIT_OPEN);
assertThat(dfe.getMessage()).contains(msg);
}
@Test
void rejectShutdownLogsREJECTEDOutcomeAtWARN() {
observer.rejectShutdown("shutdown test");
assertThat(logAppender.list).hasSize(1);
ILoggingEvent event = logAppender.list.get(0);
assertThat(event.getLevel()).isEqualTo(Level.WARN);
assertThat(event.getFormattedMessage()).contains("outcome=\"REJECTED\"");
}
@Test
void rejectShutdownLogsDuration0AndRetryAttempt0() {
observer.rejectShutdown("shutdown test");
assertThat(logAppender.list).hasSize(1);
String msg = logAppender.list.get(0).getFormattedMessage();
assertThat(msg).contains("duration_ms=0");
assertThat(msg).contains("retry_attempt=0");
}
@Test
void rejectShutdownDfeCauseIsNull() {
DependencyFailureException dfe = observer.rejectShutdown("shutdown test");
assertThat(dfe.getCause()).isNull();
}
}
@@ -0,0 +1,777 @@
package dev.caskeleton.adapter.outbound.httpclient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import com.sun.net.httpserver.HttpServer;
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpDependencyLogger;
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilience;
import dev.caskeleton.adapter.outbound.httpclient.resilience.OutboundHttpResilienceConfig;
import dev.caskeleton.shared.error.DependencyFailureException;
import dev.caskeleton.shared.error.OperationalError;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.time.Duration;
import java.util.Arrays;
import java.util.Iterator;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpMethod;
import org.springframework.util.unit.DataSize;
/**
* Integration tests for {@link OutboundHttpClient} against a local JDK {@link HttpServer} (no
* network, no Testcontainers, no Spring context).
*
* <p>Covers the spec 테스트 계약 from the plan §검증 매트릭스:
*
* <ol>
* <li>200 OK → body decoded + structured log fields verified
* <li>Read timeout → {@code DEPENDENCY_TIMEOUT} / retryable
* <li>Connect refused → {@code DEPENDENCY_CONNECT_FAILED}
* <li>Unknown host → {@code DEPENDENCY_DNS_FAILED}
* <li>500 with secret body → {@code DEPENDENCY_5XX_SERVER} + secret NOT in msg/log
* <li>401 / 404 → {@code DEPENDENCY_4XX_CLIENT} non-retryable
* <li>Retry disabled default → exactly 1 hit on always-500
* <li>Retry enabled → GET 3 hits; no {@code kind} tag; POST 1 hit (I4)
* <li>Circuit breaker open → {@code DEPENDENCY_CIRCUIT_OPEN} + 0 hits + meters present
* <li>Shutdown → {@code DEPENDENCY_CIRCUIT_OPEN} + outcome REJECTED in log
* <li>Response size limit → buffered throws; streaming path succeeds
* </ol>
*/
class OutboundHttpClientTest {
// -------------------------------------------------------------------------
// Logger capture
// -------------------------------------------------------------------------
private ch.qos.logback.classic.Logger logbackLogger;
private ListAppender<ILoggingEvent> logAppender;
private OutboundHttpDependencyLogger testLogger;
// -------------------------------------------------------------------------
// Local JDK HttpServer lifecycle
// -------------------------------------------------------------------------
private HttpServer server;
private String baseUrl;
@BeforeEach
void setUpLoggerAndServer() throws IOException {
// Set up log capture
logbackLogger =
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbound.http.client");
logAppender = new ListAppender<>();
logAppender.start();
logbackLogger.addAppender(logAppender);
logbackLogger.setLevel(Level.DEBUG);
testLogger = new OutboundHttpDependencyLogger(logbackLogger);
// Bind to localhost:0 (ephemeral port)
server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.start();
int port = server.getAddress().getPort();
baseUrl = "http://localhost:" + port;
}
@AfterEach
void tearDown() {
logbackLogger.detachAppender(logAppender);
server.stop(0);
}
// =========================================================================
// Test 1: 200 OK → body decoded + structured log fields
// Spec: "outbound log에 dependency.name/type/duration_ms가 없으면 실패"
// =========================================================================
@Test
void t1200okBodyDecodedAndSuccessLogContainsRequiredFields() {
server.createContext(
"/hello",
exchange -> {
byte[] body = "world".getBytes();
exchange.sendResponseHeaders(200, body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
});
OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings()));
String result = client.get("/hello", String.class);
assertThat(result).isEqualTo("world");
assertThat(logAppender.list).hasSize(1);
String logMsg = logAppender.list.get(0).getFormattedMessage();
assertThat(logMsg).contains("dependency_name=\"test-dep\"");
assertThat(logMsg).contains("dependency_type=\"http\"");
assertThat(logMsg).contains("duration_ms=");
}
// =========================================================================
// Test 2: Read timeout → DEPENDENCY_TIMEOUT + retryable
// Spec: "upstream timeout은 retryable dependency failure로 분류"
// =========================================================================
@Test
void t2ReadTimeoutThrowsDEPENDENCYTIMEOUTAndIsRetryable() {
server.createContext(
"/slow",
exchange -> {
// Sleep longer than the read timeout (read=300ms)
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {
}
exchange.sendResponseHeaders(200, 0);
exchange.getResponseBody().close();
});
OutboundHttpSettings settings =
new OutboundHttpSettings(
Duration.ofMillis(500), // connectTimeout
Duration.ofMillis(300), // readTimeout
Duration.ofSeconds(2), // globalCallTimeout
false,
false,
DataSize.ofMegabytes(10));
OutboundHttpClient client = client(settings, defaultResilience(settings));
DependencyFailureException ex =
catchThrowableOfType(
DependencyFailureException.class, () -> client.get("/slow", String.class));
assertThat(ex).isNotNull();
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_TIMEOUT);
assertThat(ex.errorCode().retryable()).isTrue();
}
// =========================================================================
// Test 3: Connect refused → DEPENDENCY_CONNECT_FAILED
// =========================================================================
@Test
void t3ConnectRefusedThrowsDEPENDENCYCONNECTFAILED() throws IOException {
// Bind a ServerSocket to get a port, then close it so the OS knows nothing is
// listening — subsequent connect attempts get immediate "Connection refused" rather
// than a timeout (unlike HttpServer.stop() which may leave the port in TIME_WAIT).
int refusedPort;
try (ServerSocket ss = new ServerSocket(0)) {
refusedPort = ss.getLocalPort();
}
OutboundHttpClient client =
OutboundHttpClient.baseline(
"test-dep",
"http://localhost:" + refusedPort,
defaultSettings(),
activeGuard(),
defaultResilience(defaultSettings()),
retryPolicy(defaultSettings()),
new OutboundHttpErrorMapper(),
testLogger);
DependencyFailureException ex =
catchThrowableOfType(
DependencyFailureException.class, () -> client.get("/any", String.class));
assertThat(ex).isNotNull();
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CONNECT_FAILED);
}
// =========================================================================
// Test 4: Unknown host → DEPENDENCY_DNS_FAILED
// =========================================================================
@Test
void t4UnknownHostThrowsDEPENDENCYDNSFAILED() {
OutboundHttpClient client =
OutboundHttpClient.baseline(
"test-dep",
"http://nonexistent-host-zzz.invalid",
defaultSettings(),
activeGuard(),
defaultResilience(defaultSettings()),
retryPolicy(defaultSettings()),
new OutboundHttpErrorMapper(),
testLogger);
DependencyFailureException ex =
catchThrowableOfType(
DependencyFailureException.class, () -> client.get("/path", String.class));
assertThat(ex).isNotNull();
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_DNS_FAILED);
}
// =========================================================================
// Test 5: 500 with secret body → DEPENDENCY_5XX_SERVER
// AND "UPSTREAM_SECRET" NOT in ex.getMessage() NOR in any log line
// Spec: "upstream raw error body가 response/log에 노출되면 실패"
// =========================================================================
@Test
void t5500BodySecretNotLeakedInMessageOrLog() {
final String secret = "UPSTREAM_SECRET";
server.createContext(
"/fail500",
exchange -> {
byte[] body = secret.getBytes();
exchange.sendResponseHeaders(500, body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
});
OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings()));
DependencyFailureException ex =
catchThrowableOfType(
DependencyFailureException.class, () -> client.get("/fail500", String.class));
assertThat(ex).isNotNull();
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_5XX_SERVER);
// The secret must NOT appear in the exception message
assertThat(ex.getMessage()).doesNotContain(secret);
// The secret must NOT appear in any captured log line
for (ILoggingEvent event : logAppender.list) {
assertThat(event.getFormattedMessage()).doesNotContain(secret);
}
}
// =========================================================================
// Test 6: 401 → DEPENDENCY_4XX_CLIENT non-retryable; 404 → same code
// =========================================================================
@Test
void t6401ThrowsDEPENDENCY4XXCLIENTNonRetryable() {
server.createContext(
"/auth",
exchange -> {
exchange.sendResponseHeaders(401, -1);
exchange.getResponseBody().close();
});
OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings()));
DependencyFailureException ex =
catchThrowableOfType(
DependencyFailureException.class, () -> client.get("/auth", String.class));
assertThat(ex).isNotNull();
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_4XX_CLIENT);
assertThat(ex.errorCode().retryable()).isFalse();
}
@Test
void t6404ThrowsDEPENDENCY4XXCLIENTNonRetryable() {
server.createContext(
"/notfound",
exchange -> {
exchange.sendResponseHeaders(404, -1);
exchange.getResponseBody().close();
});
OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings()));
DependencyFailureException ex =
catchThrowableOfType(
DependencyFailureException.class, () -> client.get("/notfound", String.class));
assertThat(ex).isNotNull();
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_4XX_CLIENT);
assertThat(ex.errorCode().retryable()).isFalse();
}
// =========================================================================
// Test 7: Retry disabled (default) → exactly 1 hit on always-500
// =========================================================================
@Test
void t7RetryDisabledSingleHitOnAlways500() {
AtomicInteger hitCount = new AtomicInteger(0);
server.createContext(
"/fail",
exchange -> {
hitCount.incrementAndGet();
exchange.sendResponseHeaders(500, -1);
exchange.getResponseBody().close();
});
// Default settings: retryEnabled=false
OutboundHttpClient client = client(defaultSettings(), defaultResilience(defaultSettings()));
assertThatThrownBy(() -> client.get("/fail", String.class))
.isInstanceOf(DependencyFailureException.class);
assertThat(hitCount.get()).isEqualTo(1);
}
// =========================================================================
// Test 8: Retry enabled → GET 3 hits; no `kind` tag; POST 1 hit (plan I4)
// Spec: "POST retry fully forbidden"; meter outcome tag, no kind tag
// =========================================================================
@Test
void t8RetryEnabledGETAlways500Hits3Times() {
AtomicInteger hitCount = new AtomicInteger(0);
server.createContext(
"/retry",
exchange -> {
hitCount.incrementAndGet();
exchange.sendResponseHeaders(500, -1);
exchange.getResponseBody().close();
});
SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
OutboundHttpSettings retrySettings =
new OutboundHttpSettings(
Duration.ofMillis(500),
Duration.ofMillis(500),
Duration.ofSeconds(10), // generous global timeout for 3 retries
true,
false,
DataSize.ofMegabytes(10));
// CRITICAL: the retryPolicy instance MUST be shared between the resilience config
// and the client. OutboundHttpClient.exchange() calls retryPolicy.beginCall() to
// set the thread-local call context that retryPolicy.shouldRetry() checks.
// Using two separate instances means shouldRetry() sees no context (ctx == null)
// and always returns false — producing exactly 1 hit instead of 3.
OutboundRetryPolicy sharedPolicy = retryPolicy(retrySettings);
OutboundHttpResilience resilience =
new OutboundHttpResilienceConfig()
.outboundHttpResilience(retrySettings, sharedPolicy, singletonProvider(meterRegistry));
OutboundHttpClient client =
OutboundHttpClient.baseline(
"test-dep",
baseUrl,
retrySettings,
activeGuard(),
resilience,
sharedPolicy,
new OutboundHttpErrorMapper(),
testLogger);
assertThatThrownBy(() -> client.get("/retry", String.class))
.isInstanceOf(DependencyFailureException.class);
assertThat(hitCount.get()).isEqualTo(3);
// Failure log must record retry_attempt=2 (3 attempts → attemptCount=3 → 3-1=2).
// This assertion catches the previously hardcoded retry_attempt=0 bug on the failure path.
assertThat(logAppender.list).isNotEmpty();
boolean foundRetryAttempt2 =
logAppender.list.stream()
.anyMatch(e -> e.getFormattedMessage().contains("retry_attempt=2"));
assertThat(foundRetryAttempt2)
.as("Failure log must contain retry_attempt=2 for always-500 GET with 3 hits")
.isTrue();
// Spec: resilience4j.retry.calls meters must exist with `outcome` tag and NO `kind` tag.
// TaggedRetryMetrics registers FunctionCounters (not Counter), so use find().meters()
// rather than find().counters() — FunctionCounter does not implement Counter.
var retryMeters = meterRegistry.find("resilience4j.retry.calls").meters();
assertThat(retryMeters).isNotEmpty();
retryMeters.forEach(
meter -> {
assertThat(meter.getId().getTag("outcome"))
.as("outcome tag must be present on resilience4j.retry.calls")
.isNotNull();
assertThat(meter.getId().getTag("kind"))
.as("kind tag must NOT be present (D4 vendor tag remapped to outcome)")
.isNull();
});
}
@Test
void t8RetryEnabledPOSTAlways500HitsExactly1TimePlanI4() {
AtomicInteger hitCount = new AtomicInteger(0);
server.createContext(
"/post-retry",
exchange -> {
hitCount.incrementAndGet();
exchange.sendResponseHeaders(500, -1);
exchange.getResponseBody().close();
});
SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
OutboundHttpSettings retrySettings =
new OutboundHttpSettings(
Duration.ofMillis(500),
Duration.ofMillis(500),
Duration.ofSeconds(10),
true,
false,
DataSize.ofMegabytes(10));
// Same shared-policy pattern as GET test above (plan I4 verification).
OutboundRetryPolicy sharedPolicy = retryPolicy(retrySettings);
OutboundHttpResilience resilience =
new OutboundHttpResilienceConfig()
.outboundHttpResilience(retrySettings, sharedPolicy, singletonProvider(meterRegistry));
OutboundHttpClient client =
OutboundHttpClient.baseline(
"test-dep",
baseUrl,
retrySettings,
activeGuard(),
resilience,
sharedPolicy,
new OutboundHttpErrorMapper(),
testLogger);
assertThatThrownBy(() -> client.exchange(HttpMethod.POST, "/post-retry", null, String.class))
.isInstanceOf(DependencyFailureException.class);
// Plan I4: POST retry is fully forbidden — exactly 1 hit regardless of retry enabled
assertThat(hitCount.get()).isEqualTo(1);
}
// =========================================================================
// Test 9: Circuit breaker open → DEPENDENCY_CIRCUIT_OPEN + 0 hits
// + resilience4j.circuitbreaker.state gauge with UPPERCASE state
// + denied vendor meters ABSENT
// =========================================================================
@Test
void t9CircuitBreakerOpenShortCircuitsCallAndMetersCorrect() {
AtomicInteger hitCount = new AtomicInteger(0);
server.createContext(
"/cb",
exchange -> {
hitCount.incrementAndGet();
exchange.sendResponseHeaders(200, -1);
exchange.getResponseBody().close();
});
SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
OutboundHttpSettings cbSettings =
new OutboundHttpSettings(
Duration.ofMillis(500),
Duration.ofMillis(500),
Duration.ofSeconds(10),
false,
true,
DataSize.ofMegabytes(10));
OutboundRetryPolicy sharedPolicy = retryPolicy(cbSettings);
OutboundHttpResilience resilience =
new OutboundHttpResilienceConfig()
.outboundHttpResilience(cbSettings, sharedPolicy, singletonProvider(meterRegistry));
// Force the CB into OPEN state before any call
resilience.circuitBreakerFor("test-dep").get().transitionToOpenState();
OutboundHttpClient client =
OutboundHttpClient.baseline(
"test-dep",
baseUrl,
cbSettings,
activeGuard(),
resilience,
sharedPolicy,
new OutboundHttpErrorMapper(),
testLogger);
DependencyFailureException ex =
catchThrowableOfType(
DependencyFailureException.class, () -> client.get("/cb", String.class));
assertThat(ex).isNotNull();
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CIRCUIT_OPEN);
// Hit count must be 0 — CB blocked the call entirely
assertThat(hitCount.get()).isEqualTo(0);
// resilience4j.circuitbreaker.state gauge must be present with UPPERCASE state tag value
var stateMeter = meterRegistry.find("resilience4j.circuitbreaker.state").gauges();
assertThat(stateMeter).isNotEmpty();
stateMeter.forEach(
gauge -> {
String stateTag = gauge.getId().getTag("state");
assertThat(stateTag).isNotNull();
// UPPERCASE: CLOSED, OPEN, HALF_OPEN — not lowercase
assertThat(stateTag).isEqualTo(stateTag.toUpperCase());
});
// Denied vendor meter must be ABSENT (D4 low-cardinality filter)
assertThat(meterRegistry.find("resilience4j.circuitbreaker.failure.rate").gauges()).isEmpty();
}
// =========================================================================
// Test 10: Shutdown → DEPENDENCY_CIRCUIT_OPEN + outcome REJECTED in log
// + server hit count unchanged (fail-fast, no network)
// Spec: "shutdown phase에서 outbound HTTP 호출이 retry를 시도하면 실패"
// =========================================================================
@Test
void t10ShutdownGuardStoppedThrowsDEPENDENCYCIRCUITOPENAndLogsREJECTED() {
AtomicInteger hitCount = new AtomicInteger(0);
server.createContext(
"/shutdown-test",
exchange -> {
hitCount.incrementAndGet();
exchange.sendResponseHeaders(200, -1);
exchange.getResponseBody().close();
});
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
guard.start();
// Trigger shutdown
guard.stop();
OutboundHttpClient client =
OutboundHttpClient.baseline(
"test-dep",
baseUrl,
defaultSettings(),
guard,
defaultResilience(defaultSettings()),
retryPolicy(defaultSettings()),
new OutboundHttpErrorMapper(),
testLogger);
DependencyFailureException ex =
catchThrowableOfType(
DependencyFailureException.class, () -> client.get("/shutdown-test", String.class));
assertThat(ex).isNotNull();
assertThat(ex.errorCode()).isEqualTo(OperationalError.DEPENDENCY_CIRCUIT_OPEN);
// Server hit count unchanged — no network call was made
assertThat(hitCount.get()).isEqualTo(0);
// Failure log must contain outcome="REJECTED"
assertThat(logAppender.list).isNotEmpty();
boolean foundRejected =
logAppender.list.stream()
.anyMatch(e -> e.getFormattedMessage().contains("outcome=\"REJECTED\""));
assertThat(foundRejected)
.as("Log must contain outcome=\"REJECTED\" for shutdown path")
.isTrue();
}
// =========================================================================
// Test 11a: Response size limit — Content-Length path throws
// OutboundResponseSizeExceededException
// =========================================================================
@Test
void t11aBufferedGetThrowsSizeExceededWhenContentLengthExceedsLimit() {
byte[] bigBody = new byte[1024]; // 1KB
Arrays.fill(bigBody, (byte) 'X');
server.createContext(
"/big",
exchange -> {
exchange.sendResponseHeaders(200, bigBody.length); // known Content-Length
try (OutputStream os = exchange.getResponseBody()) {
os.write(bigBody);
}
});
// 64-byte limit
OutboundHttpSettings tinyLimitSettings =
new OutboundHttpSettings(
Duration.ofMillis(500),
Duration.ofMillis(500),
Duration.ofSeconds(10),
false,
false,
DataSize.ofBytes(64));
OutboundHttpClient client = client(tinyLimitSettings, defaultResilience(tinyLimitSettings));
assertThatThrownBy(() -> client.get("/big", byte[].class))
.isInstanceOf(OutboundResponseSizeExceededException.class);
}
// =========================================================================
// Test 11b: Response size limit — chunked/streaming path without Content-Length
// Counting-stream also throws for buffered path
// =========================================================================
@Test
void t11bBufferedGetThrowsSizeExceededOnChunkedResponseNoContentLength() {
byte[] bigBody = new byte[1024]; // 1KB
Arrays.fill(bigBody, (byte) 'Y');
server.createContext(
"/chunked",
exchange -> {
// sendResponseHeaders(200, 0) = chunked (unknown Content-Length)
exchange.sendResponseHeaders(200, 0);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bigBody);
}
});
OutboundHttpSettings tinyLimitSettings =
new OutboundHttpSettings(
Duration.ofMillis(500),
Duration.ofMillis(500),
Duration.ofSeconds(10),
false,
false,
DataSize.ofBytes(64));
OutboundHttpClient client = client(tinyLimitSettings, defaultResilience(tinyLimitSettings));
// Counting-stream path in BoundedInputStream should throw too
assertThatThrownBy(() -> client.get("/chunked", byte[].class))
.isInstanceOf(OutboundResponseSizeExceededException.class);
}
// =========================================================================
// Test 11c: stream() API on the same oversized response succeeds (D7)
// Spec: D7 "초과 시 streaming 처리 의무" — stream() path has no size limit
// =========================================================================
@Test
void t11cStreamApiSucceedsOnOversizedResponse() throws Exception {
byte[] bigBody = new byte[1024]; // 1KB
Arrays.fill(bigBody, (byte) 'Z');
server.createContext(
"/stream-ok",
exchange -> {
exchange.sendResponseHeaders(200, bigBody.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bigBody);
}
});
OutboundHttpSettings tinyLimitSettings =
new OutboundHttpSettings(
Duration.ofMillis(500),
Duration.ofMillis(500),
Duration.ofSeconds(10),
false,
false,
DataSize.ofBytes(64));
OutboundHttpClient client = client(tinyLimitSettings, defaultResilience(tinyLimitSettings));
// stream() bypasses the ResponseSizeBoundingInterceptor — should succeed and return all bytes
byte[] result =
client.stream(
HttpMethod.GET,
"/stream-ok",
in -> {
try {
return in.readAllBytes();
} catch (IOException e) {
throw new RuntimeException(e);
}
});
assertThat(result).hasSize(1024);
assertThat(result[0]).isEqualTo((byte) 'Z');
}
// =========================================================================
// Private factory / helper methods
// =========================================================================
/** Standard settings with generous timeouts for most tests. */
private static OutboundHttpSettings defaultSettings() {
return new OutboundHttpSettings(
Duration.ofMillis(500),
Duration.ofMillis(500),
Duration.ofSeconds(10),
false,
false,
DataSize.ofMegabytes(10));
}
/** Creates an already-started (not-shutting-down) shutdown guard. */
private static OutboundHttpShutdownGuard activeGuard() {
OutboundHttpShutdownGuard g = new OutboundHttpShutdownGuard();
g.start();
return g;
}
/** Creates a retry policy wired to a fresh guard and error mapper. */
private static OutboundRetryPolicy retryPolicy(OutboundHttpSettings settings) {
return new OutboundRetryPolicy(settings, activeGuard(), new OutboundHttpErrorMapper());
}
/**
* Creates a {@link OutboundHttpResilience} with both retry and CB disabled — no MeterRegistry
* needed.
*/
private static OutboundHttpResilience defaultResilience(OutboundHttpSettings settings) {
return new OutboundHttpResilience(settings, retryPolicy(settings), null, null);
}
/** Builds a client against the local {@link #server} with the given settings/resilience. */
private OutboundHttpClient client(
OutboundHttpSettings settings, OutboundHttpResilience resilience) {
return OutboundHttpClient.baseline(
"test-dep",
baseUrl,
settings,
activeGuard(),
resilience,
retryPolicy(settings),
new OutboundHttpErrorMapper(),
testLogger);
}
/**
* ObjectProvider returning the given singleton (mirrors OutboundHttpResilienceConfigTest
* pattern).
*/
private static ObjectProvider<MeterRegistry> singletonProvider(MeterRegistry instance) {
return new ObjectProvider<>() {
@Override
public MeterRegistry getObject() {
return instance;
}
@Override
public MeterRegistry getObject(Object... args) {
return instance;
}
@Override
public MeterRegistry getIfAvailable() {
return instance;
}
@Override
public MeterRegistry getIfUnique() {
return instance;
}
@Override
public Iterator<MeterRegistry> iterator() {
return Stream.of(instance).iterator();
}
};
}
}
@@ -0,0 +1,82 @@
package dev.caskeleton.adapter.outbound.httpclient;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.util.unit.DataSize;
import org.springframework.web.client.RestClient;
/**
* Unit tests for {@link OutboundHttpRestClientFactory}.
*
* <p>Verifies that {@code create()} returns a non-null {@code Clients} record with distinct
* (different object identity) buffered and streaming {@link RestClient} instances, both backed by
* the SAME shared request factory (behavior-preserving refactor: the original constructor built one
* {@link org.springframework.http.client.JdkClientHttpRequestFactory} and shared it).
*
* <p>We cannot directly assert that the two RestClients share the same factory instance via public
* API, but we can verify:
*
* <ol>
* <li>Both clients are non-null and distinct objects.
* <li>The factory method completes without throwing given valid settings (proxy for "timeout
* wiring did not blow up").
* <li>The nested {@code Clients} record accessors work correctly.
* </ol>
*/
class OutboundHttpRestClientFactoryTest {
private static OutboundHttpSettings validSettings() {
return new OutboundHttpSettings(
Duration.ofMillis(500),
Duration.ofMillis(500),
Duration.ofSeconds(10),
false,
false,
DataSize.ofMegabytes(10));
}
@Test
void createReturnsNonNullClientsRecord() {
var clients =
OutboundHttpRestClientFactory.create("test-dep", "http://localhost:9999", validSettings());
assertThat(clients).isNotNull();
}
@Test
void createBufferedClientIsNonNull() {
var clients =
OutboundHttpRestClientFactory.create("test-dep", "http://localhost:9999", validSettings());
assertThat(clients.buffered()).isNotNull();
}
@Test
void createStreamingClientIsNonNull() {
var clients =
OutboundHttpRestClientFactory.create("test-dep", "http://localhost:9999", validSettings());
assertThat(clients.streaming()).isNotNull();
}
@Test
void createBufferedAndStreamingAreDistinctObjects() {
var clients =
OutboundHttpRestClientFactory.create("test-dep", "http://localhost:9999", validSettings());
// They must be distinct RestClient instances (buffered has the size interceptor; streaming does
// not)
assertThat(clients.buffered()).isNotSameAs(clients.streaming());
}
@Test
void createIsStableForMultipleCallsWithSameArgs() {
// Each call creates a fresh set of clients — factory is stateless/repeatable
var c1 =
OutboundHttpRestClientFactory.create("dep-a", "http://localhost:8080", validSettings());
var c2 =
OutboundHttpRestClientFactory.create("dep-a", "http://localhost:8080", validSettings());
assertThat(c1).isNotNull();
assertThat(c2).isNotNull();
// Different invocations produce independent client instances
assertThat(c1.buffered()).isNotSameAs(c2.buffered());
}
}
@@ -0,0 +1,369 @@
package dev.caskeleton.adapter.outbound.httpclient;
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;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.unit.DataSize;
/**
* Binding and compact-constructor validation tests for {@link OutboundHttpSettings}
* (feature-outbound-http-client-baseline D5 — "timeout 미설정 또는 무한 timeout forbidden"; registry
* validation {@code spring_duration_shorthand_non_zero}).
*/
class OutboundHttpSettingsTest {
// --- direct construction ---
@Test
void validSettingsConstructedDirectly() {
OutboundHttpSettings settings =
new OutboundHttpSettings(
Duration.ofSeconds(2),
Duration.ofSeconds(5),
Duration.ofSeconds(10),
false,
false,
DataSize.ofMegabytes(10));
assertThat(settings.connectTimeout()).isEqualTo(Duration.ofSeconds(2));
assertThat(settings.readTimeout()).isEqualTo(Duration.ofSeconds(5));
assertThat(settings.globalCallTimeout()).isEqualTo(Duration.ofSeconds(10));
assertThat(settings.retryEnabled()).isFalse();
assertThat(settings.circuitBreakerEnabled()).isFalse();
assertThat(settings.responseSizeLimit()).isEqualTo(DataSize.ofMegabytes(10));
}
@Test
void nullConnectTimeoutThrowsNamingEnvKey() {
assertThatThrownBy(
() ->
new OutboundHttpSettings(
null,
Duration.ofSeconds(5),
Duration.ofSeconds(10),
false,
false,
DataSize.ofMegabytes(10)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT")
.hasMessageContaining("app.outbound.http.connect-timeout");
}
@Test
void zeroConnectTimeoutThrowsNamingEnvKey() {
assertThatThrownBy(
() ->
new OutboundHttpSettings(
Duration.ZERO,
Duration.ofSeconds(5),
Duration.ofSeconds(10),
false,
false,
DataSize.ofMegabytes(10)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT");
}
@Test
void negativeConnectTimeoutThrows() {
assertThatThrownBy(
() ->
new OutboundHttpSettings(
Duration.ofSeconds(-1),
Duration.ofSeconds(5),
Duration.ofSeconds(10),
false,
false,
DataSize.ofMegabytes(10)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT");
}
@Test
void nullReadTimeoutThrowsNamingEnvKey() {
assertThatThrownBy(
() ->
new OutboundHttpSettings(
Duration.ofSeconds(2),
null,
Duration.ofSeconds(10),
false,
false,
DataSize.ofMegabytes(10)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_READ_TIMEOUT")
.hasMessageContaining("app.outbound.http.read-timeout");
}
@Test
void zeroReadTimeoutThrowsNamingEnvKey() {
assertThatThrownBy(
() ->
new OutboundHttpSettings(
Duration.ofSeconds(2),
Duration.ZERO,
Duration.ofSeconds(10),
false,
false,
DataSize.ofMegabytes(10)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_READ_TIMEOUT");
}
@Test
void nullGlobalCallTimeoutThrowsNamingEnvKey() {
assertThatThrownBy(
() ->
new OutboundHttpSettings(
Duration.ofSeconds(2),
Duration.ofSeconds(5),
null,
false,
false,
DataSize.ofMegabytes(10)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT")
.hasMessageContaining("app.outbound.http.global-call-timeout");
}
@Test
void negativeGlobalCallTimeoutThrows() {
assertThatThrownBy(
() ->
new OutboundHttpSettings(
Duration.ofSeconds(2),
Duration.ofSeconds(5),
Duration.ofMillis(-1),
false,
false,
DataSize.ofMegabytes(10)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT");
}
@Test
void nullResponseSizeLimitDefaultsTo10MB() {
OutboundHttpSettings settings =
new OutboundHttpSettings(
Duration.ofSeconds(2),
Duration.ofSeconds(5),
Duration.ofSeconds(10),
false,
false,
null);
assertThat(settings.responseSizeLimit()).isEqualTo(DataSize.ofMegabytes(10));
}
@Test
void negativeResponseSizeLimitThrowsNamingEnvKey() {
assertThatThrownBy(
() ->
new OutboundHttpSettings(
Duration.ofSeconds(2),
Duration.ofSeconds(5),
Duration.ofSeconds(10),
false,
false,
DataSize.ofBytes(-1)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT");
}
@Test
void zeroResponseSizeLimitThrowsNamingEnvKey() {
assertThatThrownBy(
() ->
new OutboundHttpSettings(
Duration.ofSeconds(2),
Duration.ofSeconds(5),
Duration.ofSeconds(10),
false,
false,
DataSize.ofBytes(0)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT");
}
// --- ApplicationContextRunner binding ---
@Configuration
@EnableConfigurationProperties(OutboundHttpSettings.class)
@EnableAutoConfiguration
static class BindingConfig {}
@Test
void settingsBindFromApplicationContextRunner() {
new ApplicationContextRunner()
.withUserConfiguration(BindingConfig.class)
.withPropertyValues(
"app.outbound.http.connect-timeout=2s",
"app.outbound.http.read-timeout=5s",
"app.outbound.http.global-call-timeout=10s",
"app.outbound.http.retry-enabled=true",
"app.outbound.http.circuit-breaker-enabled=false",
"app.outbound.http.response-size-limit=10MB")
.run(
ctx -> {
assertThat(ctx).hasNotFailed();
OutboundHttpSettings s = ctx.getBean(OutboundHttpSettings.class);
assertThat(s.connectTimeout()).isEqualTo(Duration.ofSeconds(2));
assertThat(s.readTimeout()).isEqualTo(Duration.ofSeconds(5));
assertThat(s.globalCallTimeout()).isEqualTo(Duration.ofSeconds(10));
assertThat(s.retryEnabled()).isTrue();
assertThat(s.responseSizeLimit()).isEqualTo(DataSize.ofMegabytes(10));
});
}
@Test
void contextFailsWhenConnectTimeoutIsMissingFromBinding() {
new ApplicationContextRunner()
.withUserConfiguration(BindingConfig.class)
.withPropertyValues(
"app.outbound.http.read-timeout=5s", "app.outbound.http.global-call-timeout=10s")
.run(ctx -> assertThat(ctx).hasFailed());
}
// --- nested record 기본값 (직접 생성) ---
@Test
void nestedRecordsDefaultWhenNullViaAuxConstructor() {
// 보조 6-arg 생성자: retry/circuitBreaker 미지정 → 기본값 채워진 record
OutboundHttpSettings s =
new OutboundHttpSettings(
Duration.ofSeconds(2),
Duration.ofSeconds(5),
Duration.ofSeconds(10),
false,
false,
DataSize.ofMegabytes(10));
assertThat(s.retry().maxAttempts()).isEqualTo(3);
assertThat(s.retry().initialBackoff()).isEqualTo(Duration.ofMillis(100));
assertThat(s.retry().backoffMultiplier()).isEqualTo(2.0);
assertThat(s.circuitBreaker().failureRateThreshold()).isEqualTo(50f);
assertThat(s.circuitBreaker().slidingWindowSize()).isEqualTo(100);
assertThat(s.circuitBreaker().minimumNumberOfCalls()).isEqualTo(100);
assertThat(s.circuitBreaker().waitDurationInOpenState()).isEqualTo(Duration.ofSeconds(60));
assertThat(s.circuitBreaker().permittedCallsInHalfOpen()).isEqualTo(10);
}
@Test
void retryRecordNullFieldsDefault() {
OutboundHttpSettings.Retry r = new OutboundHttpSettings.Retry(null, null, null);
assertThat(r.maxAttempts()).isEqualTo(3);
assertThat(r.initialBackoff()).isEqualTo(Duration.ofMillis(100));
assertThat(r.backoffMultiplier()).isEqualTo(2.0);
}
@Test
void circuitBreakerRecordNullFieldsDefault() {
OutboundHttpSettings.CircuitBreaker c =
new OutboundHttpSettings.CircuitBreaker(null, null, null, null, null);
assertThat(c.failureRateThreshold()).isEqualTo(50f);
assertThat(c.slidingWindowSize()).isEqualTo(100);
assertThat(c.minimumNumberOfCalls()).isEqualTo(100);
assertThat(c.waitDurationInOpenState()).isEqualTo(Duration.ofSeconds(60));
assertThat(c.permittedCallsInHalfOpen()).isEqualTo(10);
}
// --- 경계 검증 ---
@Test
void retryMaxAttemptsBelowOneThrows() {
assertThatThrownBy(() -> new OutboundHttpSettings.Retry(0, null, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS");
}
@Test
void retryInitialBackoffZeroThrows() {
assertThatThrownBy(() -> new OutboundHttpSettings.Retry(3, Duration.ZERO, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF");
}
@Test
void retryBackoffMultiplierBelowOneThrows() {
assertThatThrownBy(() -> new OutboundHttpSettings.Retry(3, null, 0.5))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER");
}
@Test
void cbFailureRateOutOfRangeThrows() {
assertThatThrownBy(() -> new OutboundHttpSettings.CircuitBreaker(0f, null, null, null, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD");
assertThatThrownBy(() -> new OutboundHttpSettings.CircuitBreaker(150f, null, null, null, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD");
}
@Test
void cbSlidingWindowBelowOneThrows() {
assertThatThrownBy(() -> new OutboundHttpSettings.CircuitBreaker(50f, 0, null, null, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE");
}
@Test
void cbMinimumCallsBelowOneThrows() {
assertThatThrownBy(() -> new OutboundHttpSettings.CircuitBreaker(50f, 100, 0, null, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS");
}
@Test
void cbWaitDurationZeroThrows() {
assertThatThrownBy(
() -> new OutboundHttpSettings.CircuitBreaker(50f, 100, 100, Duration.ZERO, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE");
}
@Test
void cbPermittedHalfOpenBelowOneThrows() {
assertThatThrownBy(
() -> new OutboundHttpSettings.CircuitBreaker(50f, 100, 100, Duration.ofSeconds(60), 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN");
}
// --- ApplicationContextRunner 바인딩 ---
@Test
void nestedSettingsBindFromApplicationContextRunner() {
new ApplicationContextRunner()
.withUserConfiguration(BindingConfig.class)
.withPropertyValues(
"app.outbound.http.connect-timeout=2s",
"app.outbound.http.read-timeout=5s",
"app.outbound.http.global-call-timeout=10s",
"app.outbound.http.retry.max-attempts=5",
"app.outbound.http.retry.initial-backoff=250ms",
"app.outbound.http.retry.backoff-multiplier=3.0",
"app.outbound.http.circuit-breaker.failure-rate-threshold=25",
"app.outbound.http.circuit-breaker.sliding-window-size=20",
"app.outbound.http.circuit-breaker.minimum-number-of-calls=7",
"app.outbound.http.circuit-breaker.wait-duration-in-open-state=30s",
"app.outbound.http.circuit-breaker.permitted-calls-in-half-open=4")
.run(
ctx -> {
assertThat(ctx).hasNotFailed();
OutboundHttpSettings s = ctx.getBean(OutboundHttpSettings.class);
assertThat(s.retry().maxAttempts()).isEqualTo(5);
assertThat(s.retry().initialBackoff()).isEqualTo(Duration.ofMillis(250));
assertThat(s.retry().backoffMultiplier()).isEqualTo(3.0);
assertThat(s.circuitBreaker().failureRateThreshold()).isEqualTo(25f);
assertThat(s.circuitBreaker().slidingWindowSize()).isEqualTo(20);
assertThat(s.circuitBreaker().minimumNumberOfCalls()).isEqualTo(7);
assertThat(s.circuitBreaker().waitDurationInOpenState())
.isEqualTo(Duration.ofSeconds(30));
assertThat(s.circuitBreaker().permittedCallsInHalfOpen()).isEqualTo(4);
});
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.adapter.outbound.httpclient;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/**
* Contract tests for {@link OutboundHttpShutdownGuard} (feature-outbound-http-client-baseline D8 /
* plan decision I6 — SmartLifecycle phase ordering ensures shutdown guard is stopped first).
*/
class OutboundHttpShutdownGuardTest {
@Test
void autoStartupIsTrue() {
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
assertThat(guard.isAutoStartup()).isTrue();
}
@Test
void startsAsRunningAndNotShuttingDown() {
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
guard.start();
assertThat(guard.isRunning()).isTrue();
assertThat(guard.isShuttingDown()).isFalse();
}
@Test
void stopFlipsShuttingDownToTrueAndStopsRunning() {
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
guard.start();
guard.stop();
assertThat(guard.isShuttingDown()).isTrue();
assertThat(guard.isRunning()).isFalse();
}
@Test
void phaseIsIntegerMaxValue() {
// Spring stops phases in DESCENDING order — Integer.MAX_VALUE means this
// lifecycle bean is stopped FIRST during shutdown (D8 / plan I6).
assertThat(new OutboundHttpShutdownGuard().getPhase()).isEqualTo(Integer.MAX_VALUE);
}
@Test
void isNotShuttingDownBeforeStopIsCalled() {
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
// guard has never been started or stopped
assertThat(guard.isShuttingDown()).isFalse();
}
}
@@ -0,0 +1,88 @@
package dev.caskeleton.adapter.outbound.httpclient;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
/**
* Contract tests for {@link OutboundHttpTimeoutEnforcer} (plan decision I2 — BeanPostProcessor that
* detects raw {@link RestClient} / {@link RestClient.Builder} beans and fails the
* ApplicationContext startup with a descriptive error message citing the registry env-key names).
*/
class OutboundHttpTimeoutEnforcerTest {
private final ApplicationContextRunner runner =
new ApplicationContextRunner().withBean(OutboundHttpTimeoutEnforcer.class);
@Configuration
static class RawRestClientConfig {
@Bean
RestClient rawClient() {
return RestClient.create();
}
}
@Configuration
static class RawRestClientBuilderConfig {
@Bean
RestClient.Builder rawBuilder() {
return RestClient.builder();
}
}
@Configuration
static class SafeConfig {
@Bean
String harmlessBean() {
return "hello";
}
}
@Test
void contextFailsWhenARawRestClientBeanIsRegistered() {
runner
.withUserConfiguration(RawRestClientConfig.class)
.run(
ctx -> {
assertThat(ctx).hasFailed();
assertThat(ctx.getStartupFailure().getMessage())
.contains("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT");
});
}
@Test
void failureMessageMentionsAllThreeTimeoutEnvKeys() {
runner
.withUserConfiguration(RawRestClientConfig.class)
.run(
ctx -> {
assertThat(ctx).hasFailed();
String msg = ctx.getStartupFailure().getMessage();
assertThat(msg)
.contains("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT")
.contains("APP_OUTBOUND_HTTP_READ_TIMEOUT")
.contains("APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT");
});
}
@Test
void contextFailsWhenARawRestClientBuilderBeanIsRegistered() {
runner
.withUserConfiguration(RawRestClientBuilderConfig.class)
.run(
ctx -> {
assertThat(ctx).hasFailed();
assertThat(ctx.getStartupFailure().getMessage())
.contains("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT");
});
}
@Test
void contextStartsFineWhenNoRawRestClientBeanIsPresent() {
runner.withUserConfiguration(SafeConfig.class).run(ctx -> assertThat(ctx).hasNotFailed());
}
}
@@ -0,0 +1,235 @@
package dev.caskeleton.adapter.outbound.httpclient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import dev.caskeleton.shared.tracing.BaggageAllowlist;
import java.io.IOException;
import java.net.URI;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.mock.http.client.MockClientHttpRequest;
/**
* Unit tests for {@link TraceContextPropagationInterceptor} (Slice 3a,
* feature-distributed-tracing-contract §1 / D2 / D8).
*
* <p>Covers:
*
* <ol>
* <li>Full MDC → all four headers set; baggage contains ONLY allowlisted keys.
* <li>Forbidden MDC key does NOT appear in baggage (D2/D8 trust-boundary).
* <li>Empty/invalid MDC → no headers set; call still executed.
* <li>Header already set → not overwritten.
* <li>Only partial MDC (trace_id/span_id present, no request_id/correlation_id/tenant_id) → only
* traceparent set.
* </ol>
*/
class TraceContextPropagationInterceptorTest {
// Valid W3C values (32-hex trace-id, 16-hex span-id)
private static final String VALID_TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736";
private static final String VALID_SPAN_ID = "00f067aa0ba902b7";
private static final String VALID_REQUEST_ID = "req-abc-123";
private static final String VALID_CORR_ID = "corr-xyz-456";
private static final String VALID_TENANT_ID = "tenant-42";
private TraceContextPropagationInterceptor interceptor;
@BeforeEach
void setUp() {
interceptor = new TraceContextPropagationInterceptor();
MDC.clear();
}
@AfterEach
void clearMdc() {
MDC.clear();
}
// =========================================================================
// Test 1: Full MDC → all four outbound headers; baggage allowlisted only
// Spec: "outbound HTTP → propagate traceparent, requestId, correlationId"
// "baggage에 금지 정보가 기록되면 실패"
// =========================================================================
@Test
void t1FullMdcAllHeadersSetBaggageContainsOnlyAllowlistedKeys() throws IOException {
MDC.put("trace_id", VALID_TRACE_ID);
MDC.put("span_id", VALID_SPAN_ID);
MDC.put("request_id", VALID_REQUEST_ID);
MDC.put("correlation_id", VALID_CORR_ID);
MDC.put("tenant_id", VALID_TENANT_ID);
// Forbidden key that must never appear in baggage
MDC.put("user_principal", "evil-secret");
MDC.put("jwt_token", "Bearer eyJhbGci...");
MockClientHttpRequest request =
new MockClientHttpRequest(HttpMethod.GET, URI.create("/api/resource"));
ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class);
ClientHttpRequestExecution execution = stubExecution(fakeResponse);
ClientHttpResponse result = interceptor.intercept(request, new byte[0], execution);
assertThat(result).isSameAs(fakeResponse);
verify(execution, times(1)).execute(any(), any());
HttpHeaders headers = request.getHeaders();
// traceparent — W3C format 00-<traceId>-<spanId>-00
assertThat(headers.getFirst("traceparent"))
.isEqualTo("00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-00");
// requestId and correlationId
assertThat(headers.getFirst("X-Request-Id")).isEqualTo(VALID_REQUEST_ID);
assertThat(headers.getFirst("X-Correlation-Id")).isEqualTo(VALID_CORR_ID);
// baggage: ONLY tenant_id and request_id may appear
String baggage = headers.getFirst("baggage");
assertThat(baggage).isNotNull().isNotBlank();
assertThat(baggage).contains("tenant_id=" + VALID_TENANT_ID);
assertThat(baggage).contains("request_id=" + VALID_REQUEST_ID);
// Forbidden keys must NOT appear in baggage
assertThat(baggage).doesNotContain("user_principal");
assertThat(baggage).doesNotContain("jwt_token");
assertThat(baggage).doesNotContain("evil-secret");
assertThat(baggage).doesNotContain("Bearer");
// Cross-check: all baggage keys must be in the ALLOWED set
BaggageAllowlist.parseHeader(baggage)
.forEach(
(key, value) ->
assertThat(BaggageAllowlist.isAllowed(key))
.as("Baggage key '%s' must be allowlisted", key)
.isTrue());
}
// =========================================================================
// Test 2: Empty/invalid MDC → no headers added; call still executes
// Spec: "invalid MDC value never throws — skip the header instead"
// =========================================================================
@Test
void t2EmptyMdcNoHeadersAddedCallStillExecutes() throws IOException {
// MDC is empty (no keys set)
MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/ping"));
ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class);
ClientHttpRequestExecution execution = stubExecution(fakeResponse);
ClientHttpResponse result = interceptor.intercept(request, new byte[0], execution);
assertThat(result).isSameAs(fakeResponse);
verify(execution, times(1)).execute(any(), any());
HttpHeaders headers = request.getHeaders();
assertThat(headers.containsHeader("traceparent")).isFalse();
assertThat(headers.containsHeader("X-Request-Id")).isFalse();
assertThat(headers.containsHeader("X-Correlation-Id")).isFalse();
assertThat(headers.containsHeader("baggage")).isFalse();
}
// =========================================================================
// Test 3: Invalid trace_id/span_id (wrong format) → no traceparent header
// but call still executes and other valid headers ARE set
// =========================================================================
@Test
void t3InvalidTraceIdAndSpanIdTraceparentSkippedOtherHeadersSet() throws IOException {
MDC.put("trace_id", "not-a-valid-trace-id"); // not 32 hex
MDC.put("span_id", "BAD"); // uppercase, not 16 hex
MDC.put("request_id", VALID_REQUEST_ID);
MDC.put("correlation_id", VALID_CORR_ID);
MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/data"));
ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class);
ClientHttpRequestExecution execution = stubExecution(fakeResponse);
ClientHttpResponse result = interceptor.intercept(request, new byte[0], execution);
assertThat(result).isSameAs(fakeResponse);
verify(execution, times(1)).execute(any(), any());
HttpHeaders headers = request.getHeaders();
// No traceparent — both IDs are invalid
assertThat(headers.containsHeader("traceparent")).isFalse();
// Other headers still set
assertThat(headers.getFirst("X-Request-Id")).isEqualTo(VALID_REQUEST_ID);
assertThat(headers.getFirst("X-Correlation-Id")).isEqualTo(VALID_CORR_ID);
}
// =========================================================================
// Test 4: Header already set on the request → not overwritten (idempotent)
// =========================================================================
@Test
void t4HeaderAlreadySetNotOverwritten() throws IOException {
MDC.put("trace_id", VALID_TRACE_ID);
MDC.put("span_id", VALID_SPAN_ID);
MDC.put("request_id", VALID_REQUEST_ID);
MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/item"));
// Pre-set an existing traceparent
String existingTraceparent = "00-aaaabbbbccccdddd1111222233334444-5555666677778888-01";
request.getHeaders().set("traceparent", existingTraceparent);
String existingRequestId = "already-set-req-id";
request.getHeaders().set("X-Request-Id", existingRequestId);
ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class);
ClientHttpRequestExecution execution = stubExecution(fakeResponse);
interceptor.intercept(request, new byte[0], execution);
HttpHeaders headers = request.getHeaders();
// Must preserve the original values
assertThat(headers.getFirst("traceparent")).isEqualTo(existingTraceparent);
assertThat(headers.getFirst("X-Request-Id")).isEqualTo(existingRequestId);
}
// =========================================================================
// Test 5: Only trace_id + span_id in MDC (no request_id/corr/tenant) →
// only traceparent set; no baggage header
// =========================================================================
@Test
void t5OnlyTraceContextTraceparentSetNoBaggage() throws IOException {
MDC.put("trace_id", VALID_TRACE_ID);
MDC.put("span_id", VALID_SPAN_ID);
MockClientHttpRequest request =
new MockClientHttpRequest(HttpMethod.GET, URI.create("/status"));
ClientHttpResponse fakeResponse = mock(ClientHttpResponse.class);
ClientHttpRequestExecution execution = stubExecution(fakeResponse);
interceptor.intercept(request, new byte[0], execution);
HttpHeaders headers = request.getHeaders();
assertThat(headers.getFirst("traceparent"))
.isEqualTo("00-" + VALID_TRACE_ID + "-" + VALID_SPAN_ID + "-00");
assertThat(headers.containsHeader("X-Request-Id")).isFalse();
assertThat(headers.containsHeader("X-Correlation-Id")).isFalse();
// request_id absent → baggage only has tenant_id if present; here neither → no baggage
assertThat(headers.containsHeader("baggage")).isFalse();
}
// =========================================================================
// Private helpers
// =========================================================================
private static ClientHttpRequestExecution stubExecution(ClientHttpResponse response)
throws IOException {
ClientHttpRequestExecution execution = mock(ClientHttpRequestExecution.class);
when(execution.execute(any(), any())).thenReturn(response);
return execution;
}
}
@@ -0,0 +1,139 @@
package dev.caskeleton.adapter.outbound.httpclient.diagnostics;
import static org.assertj.core.api.Assertions.assertThat;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
/**
* Contract tests for {@link OutboundHttpDependencyLogger} (feature-outbound-http-client-baseline
* §Audit F1 / plan decision I12 — registry log_field_mapping field names: dependency_name,
* dependency_type, outcome, duration_ms, retry_attempt, correlation_id).
*
* <p>Uses the ListAppender pattern from {@code support/FailOpenDependencyLoggerTest} (test seam via
* logger injection).
*/
class OutboundHttpDependencyLoggerTest {
private ch.qos.logback.classic.Logger logbackLogger;
private ListAppender<ILoggingEvent> appender;
private OutboundHttpDependencyLogger logger;
@BeforeEach
void setUp() {
logbackLogger =
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbound.http.dependency");
appender = new ListAppender<>();
appender.start();
logbackLogger.addAppender(appender);
logbackLogger.setLevel(Level.DEBUG);
logger = new OutboundHttpDependencyLogger(logbackLogger);
}
@AfterEach
void tearDown() {
logbackLogger.detachAppender(appender);
MDC.clear();
}
// --- success ---
@Test
void successLogIsAtDEBUGLevel() {
logger.logSuccess("github", 123L, 0);
assertThat(appender.list).hasSize(1);
assertThat(appender.list.get(0).getLevel()).isEqualTo(Level.DEBUG);
}
@Test
void successLogContainsAllRequiredFields() {
MDC.put(OutboundCorrelation.MDC_KEY, "corr-42");
logger.logSuccess("github", 250L, 1);
String msg = appender.list.get(0).getFormattedMessage();
assertThat(msg)
.contains("dependency_name=\"github\"")
.contains("dependency_type=\"http\"")
.contains("outcome=\"SUCCESS\"")
.contains("duration_ms=250")
.contains("retry_attempt=1")
.contains("correlation_id=\"corr-42\"");
}
@Test
void successLogUsesUnknownCorrelationIdWhenMdcIsEmpty() {
logger.logSuccess("payment-api", 10L, 0);
assertThat(appender.list.get(0).getFormattedMessage())
.contains("correlation_id=\"" + OutboundCorrelation.UNKNOWN + "\"");
}
// --- failure ---
@Test
void failureLogIsAtERRORLevelForFAILUREOutcome() {
logger.logFailure(
"inventory-api", "FAILURE", 500L, 2, new RuntimeException("connection refused"));
assertThat(appender.list).hasSize(1);
assertThat(appender.list.get(0).getLevel()).isEqualTo(Level.ERROR);
}
@Test
void failureLogContainsAllRequiredFields() {
MDC.put(OutboundCorrelation.MDC_KEY, "corr-99");
logger.logFailure("payment-api", "TIMEOUT", 3000L, 3, new RuntimeException("read timed out"));
String msg = appender.list.get(0).getFormattedMessage();
assertThat(msg)
.contains("dependency_name=\"payment-api\"")
.contains("dependency_type=\"http\"")
.contains("outcome=\"TIMEOUT\"")
.contains("duration_ms=3000")
.contains("retry_attempt=3")
.contains("correlation_id=\"corr-99\"")
.contains("error=\"RuntimeException: read timed out\"");
}
@Test
void circuitOpenOutcomeLogsAtWARNLevel() {
logger.logFailure("catalog-api", "CIRCUIT_OPEN", 0L, 0, new RuntimeException("circuit open"));
assertThat(appender.list.get(0).getLevel()).isEqualTo(Level.WARN);
}
@Test
void rejectedOutcomeLogsAtWARNLevel() {
logger.logFailure("catalog-api", "REJECTED", 0L, 0, new RuntimeException("shutting down"));
assertThat(appender.list.get(0).getLevel()).isEqualTo(Level.WARN);
}
@Test
void failureLogContainsOnlyExceptionClassAndMessageNotBody() {
// By construction: logFailure signature does not accept a request/response body.
// A throwable whose message contains a "BODY_SECRET" string is acceptable to pass
// as cause (the exception message appears in the error= field for server logs),
// but we assert the field format stays within class+message and never exposes
// a body string that was not part of the throwable message.
RuntimeException cause = new RuntimeException("upstream error");
logger.logFailure("external-api", "FAILURE", 100L, 0, cause);
String msg = appender.list.get(0).getFormattedMessage();
// The log must contain the error= field
assertThat(msg).contains("error=\"RuntimeException: upstream error\"");
// The signature has no body parameter — a body string passed ONLY as a separate
// argument cannot appear in the log (by-construction contract).
assertThat(msg).doesNotContain("RESPONSE_BODY_CONTENT");
}
}
@@ -0,0 +1,241 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings;
import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpShutdownGuard;
import dev.caskeleton.adapter.outbound.httpclient.OutboundRetryPolicy;
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import java.time.Duration;
import java.util.Iterator;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.unit.DataSize;
/**
* TDD tests for {@link OutboundHttpResilienceConfig} (feature-outbound-http-client-baseline plan
* decisions I7 — D3 activation guard).
*
* <p>Test contract: "retry/CB enabled 인데 metric/retryable classification 부재 시 실패".
*/
class OutboundHttpResilienceConfigTest {
// --- direct construction tests (no Spring context needed) ---
@Test
void retryEnabledWithoutMeterRegistryThrowsOnBeanCreation() {
OutboundHttpSettings settings = retrySettings();
OutboundRetryPolicy policy = policy(settings);
OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig();
// No MeterRegistry available → must throw with D3 message
assertThatThrownBy(() -> config.outboundHttpResilience(settings, policy, emptyProvider()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("MeterRegistry")
.hasMessageContaining("D3");
}
@Test
void circuitBreakerEnabledWithoutMeterRegistryThrowsOnBeanCreation() {
OutboundHttpSettings settings = cbSettings();
OutboundRetryPolicy policy = policy(settings);
OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig();
assertThatThrownBy(() -> config.outboundHttpResilience(settings, policy, emptyProvider()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("MeterRegistry");
}
@Test
void retryEnabledWithSimpleMeterRegistryProducesResilienceWithRetry() {
OutboundHttpSettings settings = retrySettings();
OutboundRetryPolicy policy = policy(settings);
SimpleMeterRegistry meter = new SimpleMeterRegistry();
OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig();
OutboundHttpResilience resilience =
config.outboundHttpResilience(settings, policy, singletonProvider(meter));
assertThat(resilience.retryFor("test-dep")).isPresent();
assertThat(resilience.circuitBreakerFor("test-dep")).isEmpty();
}
@Test
void bothDisabledProducesEmptyOptionalsAndNoExceptionWithoutMeterRegistry() {
OutboundHttpSettings settings = disabledSettings();
OutboundRetryPolicy policy = policy(settings);
OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig();
// Must NOT throw even without a MeterRegistry when both features are disabled
OutboundHttpResilience resilience =
config.outboundHttpResilience(settings, policy, emptyProvider());
assertThat(resilience.retryFor("dep")).isEmpty();
assertThat(resilience.circuitBreakerFor("dep")).isEmpty();
}
@Test
void circuitBreakerEnabledWithSimpleMeterRegistryProducesCircuitBreaker() {
OutboundHttpSettings settings = cbSettings();
OutboundRetryPolicy policy = policy(settings);
SimpleMeterRegistry meter = new SimpleMeterRegistry();
OutboundHttpResilienceConfig config = new OutboundHttpResilienceConfig();
OutboundHttpResilience resilience =
config.outboundHttpResilience(settings, policy, singletonProvider(meter));
assertThat(resilience.circuitBreakerFor("test-dep")).isPresent();
assertThat(resilience.retryFor("test-dep")).isEmpty();
}
// --- ApplicationContextRunner test for startup failure path ---
@Configuration
@EnableConfigurationProperties(OutboundHttpSettings.class)
static class RetryEnabledNoMeterConfig {
@Bean
OutboundHttpShutdownGuard guard() {
return new OutboundHttpShutdownGuard();
}
@Bean
OutboundHttpErrorMapper mapper() {
return new OutboundHttpErrorMapper();
}
@Bean
OutboundRetryPolicy retryPolicy(
OutboundHttpSettings s, OutboundHttpShutdownGuard g, OutboundHttpErrorMapper m) {
return new OutboundRetryPolicy(s, g, m);
}
}
@Test
void applicationContextFailsToStartWhenRetryEnabledAndNoMeterRegistryBean() {
// OutboundHttpResilienceConfig must be registered as a configuration class (NOT as a
// @Bean factory product — Spring does not process @Bean methods on factory-method
// beans), so its outboundHttpResilience(...) bean definition participates in startup.
new ApplicationContextRunner()
.withUserConfiguration(RetryEnabledNoMeterConfig.class, OutboundHttpResilienceConfig.class)
.withPropertyValues(
"app.outbound.http.connect-timeout=2s",
"app.outbound.http.read-timeout=5s",
"app.outbound.http.global-call-timeout=10s",
"app.outbound.http.retry-enabled=true" // enabled but no MeterRegistry bean
)
.run(
ctx -> {
assertThat(ctx).hasFailed();
assertThat(ctx.getStartupFailure().getMessage()).contains("MeterRegistry");
});
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private static OutboundHttpSettings retrySettings() {
return new OutboundHttpSettings(
Duration.ofSeconds(2),
Duration.ofSeconds(5),
Duration.ofSeconds(10),
true,
false,
DataSize.ofMegabytes(10));
}
private static OutboundHttpSettings cbSettings() {
return new OutboundHttpSettings(
Duration.ofSeconds(2),
Duration.ofSeconds(5),
Duration.ofSeconds(10),
false,
true,
DataSize.ofMegabytes(10));
}
private static OutboundHttpSettings disabledSettings() {
return new OutboundHttpSettings(
Duration.ofSeconds(2),
Duration.ofSeconds(5),
Duration.ofSeconds(10),
false,
false,
DataSize.ofMegabytes(10));
}
private static OutboundRetryPolicy policy(OutboundHttpSettings settings) {
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
guard.start();
return new OutboundRetryPolicy(settings, guard, new OutboundHttpErrorMapper());
}
/** ObjectProvider that always returns null (simulates absent bean). */
private static ObjectProvider<MeterRegistry> emptyProvider() {
return new ObjectProvider<>() {
@Override
public MeterRegistry getObject() {
return null;
}
@Override
public MeterRegistry getObject(Object... args) {
return null;
}
@Override
public MeterRegistry getIfAvailable() {
return null;
}
@Override
public MeterRegistry getIfUnique() {
return null;
}
@Override
public Iterator<MeterRegistry> iterator() {
return Stream.<MeterRegistry>empty().iterator();
}
};
}
/** ObjectProvider that returns the supplied singleton instance. */
private static ObjectProvider<MeterRegistry> singletonProvider(MeterRegistry instance) {
return new ObjectProvider<>() {
@Override
public MeterRegistry getObject() {
return instance;
}
@Override
public MeterRegistry getObject(Object... args) {
return instance;
}
@Override
public MeterRegistry getIfAvailable() {
return instance;
}
@Override
public MeterRegistry getIfUnique() {
return instance;
}
@Override
public Iterator<MeterRegistry> iterator() {
return Stream.of(instance).iterator();
}
};
}
}
@@ -0,0 +1,106 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpSettings;
import dev.caskeleton.adapter.outbound.httpclient.OutboundHttpShutdownGuard;
import dev.caskeleton.adapter.outbound.httpclient.OutboundRetryPolicy;
import dev.caskeleton.adapter.outbound.httpclient.diagnostics.OutboundHttpErrorMapper;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import io.github.resilience4j.core.IntervalBiFunction;
import io.github.resilience4j.core.functions.Either;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryRegistry;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.util.unit.DataSize;
/**
* {@link OutboundHttpResilience}가 {@link OutboundHttpSettings}의 retry/circuit-breaker 튜닝 값을 실제
* Resilience4j config로 흘려보내는지 검증한다(설정 외부화 계약).
*/
class OutboundHttpResilienceTest {
private static OutboundHttpSettings settings(
boolean retry,
boolean cb,
OutboundHttpSettings.Retry r,
OutboundHttpSettings.CircuitBreaker c) {
return new OutboundHttpSettings(
Duration.ofSeconds(2),
Duration.ofSeconds(5),
Duration.ofSeconds(10),
retry,
cb,
DataSize.ofMegabytes(10),
r,
c);
}
private static OutboundRetryPolicy policy(OutboundHttpSettings s) {
OutboundHttpShutdownGuard guard = new OutboundHttpShutdownGuard();
guard.start();
return new OutboundRetryPolicy(s, guard, new OutboundHttpErrorMapper());
}
@Test
void retryConfigCarriesConfiguredMaxAttemptsAndBackoff() {
OutboundHttpSettings.Retry r = new OutboundHttpSettings.Retry(5, Duration.ofMillis(200), 3.0);
OutboundHttpSettings s = settings(true, false, r, null);
OutboundHttpResilience resilience =
new OutboundHttpResilience(s, policy(s), RetryRegistry.ofDefaults(), null);
Retry retry = resilience.retryFor("dep").orElseThrow();
assertThat(retry.getRetryConfig().getMaxAttempts()).isEqualTo(5);
// exponential random backoff: attempt 1 interval ∈ [initial*0.5, initial*1.5] (jitter 0.5).
// RetryConfig.getIntervalFunction() is deprecated in Resilience4j 2.x → read the backoff via
// the non-deprecated getIntervalBiFunction(); the Either result is unused by a plain
// interval-based backoff (IntervalBiFunction wraps the IntervalFunction, ignoring the result).
IntervalBiFunction<Object> backoff = retry.getRetryConfig().getIntervalBiFunction();
long firstInterval = backoff.apply(1, Either.right(null));
assertThat(firstInterval).isBetween(100L, 300L);
}
@Test
void circuitBreakerConfigCarriesConfiguredValues() {
OutboundHttpSettings.CircuitBreaker c =
new OutboundHttpSettings.CircuitBreaker(25f, 20, 7, Duration.ofSeconds(30), 4);
OutboundHttpSettings s = settings(false, true, null, c);
OutboundHttpResilience resilience =
new OutboundHttpResilience(s, policy(s), null, CircuitBreakerRegistry.ofDefaults());
CircuitBreaker cb = resilience.circuitBreakerFor("dep").orElseThrow();
var cfg = cb.getCircuitBreakerConfig();
assertThat(cfg.getFailureRateThreshold()).isEqualTo(25f);
assertThat(cfg.getSlidingWindowSize()).isEqualTo(20);
assertThat(cfg.getMinimumNumberOfCalls()).isEqualTo(7);
assertThat(cfg.getPermittedNumberOfCallsInHalfOpenState()).isEqualTo(4);
// CB는 plain Duration getter가 없음 → interval function으로 검증(javap 확인)
assertThat(cfg.getWaitIntervalFunctionInOpenState().apply(1)).isEqualTo(30_000L);
}
@Test
void disabledSettingsReturnEmptyOptionals() {
OutboundHttpSettings s = settings(false, false, null, null);
OutboundHttpResilience resilience = new OutboundHttpResilience(s, policy(s), null, null);
assertThat(resilience.retryFor("dep")).isEmpty();
assertThat(resilience.circuitBreakerFor("dep")).isEmpty();
}
@Test
void defaultTuningPreservesResilience4jDefaults() {
OutboundHttpSettings s = settings(true, true, null, null); // null nested → 기본값
OutboundHttpResilience resilience =
new OutboundHttpResilience(
s, policy(s), RetryRegistry.ofDefaults(), CircuitBreakerRegistry.ofDefaults());
assertThat(resilience.retryFor("dep").orElseThrow().getRetryConfig().getMaxAttempts())
.isEqualTo(3);
var cfg = resilience.circuitBreakerFor("dep").orElseThrow().getCircuitBreakerConfig();
assertThat(cfg.getFailureRateThreshold()).isEqualTo(50f);
assertThat(cfg.getSlidingWindowSize()).isEqualTo(100);
assertThat(cfg.getMinimumNumberOfCalls()).isEqualTo(100);
assertThat(cfg.getPermittedNumberOfCallsInHalfOpenState()).isEqualTo(10);
assertThat(cfg.getWaitIntervalFunctionInOpenState().apply(1)).isEqualTo(60_000L);
}
}
+51
View File
@@ -0,0 +1,51 @@
# adapter:outbound:identifier — non-IO infrastructure-capability adapter
## Registered identity
- Module ID: `adapter-outbound-identifier`
- Gradle path: `:adapter:outbound:identifier`
- Focused test: `./gradlew :adapter:outbound:identifier: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.adapter.outbound.identifier`.
Design decisions previously kept as code comments (algorithm SSOT, salt origin,
build choices) live in [README.md](README.md).
## Responsibility
- Driven adapters for capabilities with **no external-system integration**:
identifier generation/encoding today, and clock / crypto/random sources by the
same rationale (feature-resource-identifier-contract §4 taxonomy).
- `UuidCodec` — UUID handling on top of the JDK `java.util.UUID` (RFC 9562 UUIDv7):
`normalize(String)` accepts a case-insensitive canonical UUID and returns the
canonical 36-character lowercase form (D3); `toUuid` / `fromUuid` convert between
the UUID string and the 128-bit `UUID` stored in the PostgreSQL `uuid` column (D10).
- Kept out of `adapter-outbound` on purpose: a UUID id/codec capability is
infrastructure, not an outbound integration point, so `adapter-outbound` keeps its
documented meaning (external HTTP / messaging / cache / notifications).
## Allowed
- `:application-core`, `:domain-core`, `:shared-contract` (Gradle matrix). Currently
only `:domain-core` + `com.github.f4b6a3:uuid-creator` are declared in
[build.gradle](build.gradle).
## Forbidden
- Persistence or web technology (JPA/Hibernate/Spring Data/Spring Web) — ArchUnit
`identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap`;
`.claude/hooks/ca_import_gate.py` G4 가 쓰기 시점에 차단.
- inbound adapters, persistence adapters, other outbound leaves, `app-bootstrap`,
`sample-portfolio`.
- External IO (HTTP / messaging / cache / DB) — that belongs in `adapter-outbound`.
## Test
Pure unit tests, no Spring context (`UuidCodecSpec`).
```bash
cd src
./gradlew :adapter:outbound:identifier:test --console=plain
```
+72
View File
@@ -0,0 +1,72 @@
# adapter-identifier — 설계 결정 참조
비-IO 인프라 능력(capability) 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.identifier`.
허용/금지 의존과 테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다.
이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다
"왜 이렇게 했나"가 궁금할 때 본다.
## 왜 별도 모듈인가 (adapter-outbound 와의 구분)
`adapter-outbound` 처럼 도메인 포트를 구현하는 driven/secondary 어댑터지만, **외부 시스템 연동이
없는**(no external-system integration) 능력만 담는다: 식별자 생성/인코딩(UUIDv7), 같은 근거로 clock·
crypto/random 소스. UUID id/코덱 능력은 인프라이지 아웃바운드 연동 지점이 아니므로, 이것을
`adapter-outbound` 밖에 둬야 그 모듈의 문서화된 의미("외부 HTTP / messaging / cache / notifications")가
유지된다.
## UuidCodec
도메인 무관 UUID 변환 유틸. JDK `java.util.UUID`(RFC 9562 UUIDv7) 위에서 동작한다.
- `normalize(String)`**D3**: 대소문자 무관 canonical UUID 입력을 받아 canonical 36자 소문자
형태로 반환. 형식 오류 UUID 에는 `IllegalArgumentException`. `null` 입력은 `null` 반환.
- `toUuid(String)`**D10**: UUID 문자열 → 128-bit `UUID` (PostgreSQL `uuid` 컬럼용).
- `fromUuid(UUID)`**D10**: 저장된 `UUID` → canonical 36자 소문자 UUID 문자열.
## HmacUserPrincipalPseudonymizer
`UserPrincipalPseudonymizerPort`(application-core) 의 HMAC-SHA-256 구현.
### 알고리즘 SSOT
구체 알고리즘은 90일 회전 salt 로 키잉한 HMAC-SHA-256 이다. 이 클래스가 유일한 구현이며,
비-IO crypto 능력 어댑터로 이 모듈에 있고 `app-bootstrap`
이 싱글톤 빈으로 와이어링한다.
### 출력
비어있지 않은 `rawPrincipal` 에 대해 단방향·안정적인 256-bit HMAC 토큰을 64자 소문자 hex 로 반환.
`rawPrincipal``null` 이거나 blank 면 `null` 반환.
### Salt 출처
salt 는 `app-bootstrap``APP_PRIVACY_PSEUDONYMIZATION_SALT` 환경변수에서 공급한다(분류: secret,
회전 주기: 90일). 이 클래스는 salt 를 스스로 조달하지 않는다.
### Thread safety
`Mac` 인스턴스는 thread-safe 하지 않다. 매 `pseudonymize(String)` 호출마다 새 `Mac` 을 생성하므로
공유 싱글톤 빈으로 안전하다. HmacSHA256 은 JDK 필수 알고리즘(JCA spec)이라 `NoSuchAlgorithmException`·
`InvalidKeyException` 은 사실상 도달 불가능하며, 호출부에 checked exception 잡음을 남기지 않으려고
`IllegalStateException` 으로 감싼다.
### Spring-free
이 모듈(`adapter-identifier`)은 설계상 Spring-free 다. 어노테이션이 없고, 빈 생성은
`app-bootstrap` 의 책임이다.
## 빌드 결정 (build.gradle)
### Groovy / Spock (C2 테스트 형태)
순수 값-코덱 동작(`UuidCodec`)은 Groovy/Spock 스펙(`src/test/groovy`)으로 명세한다. core `groovy`
플러그인이 컴파일하고, 모든 서브프로젝트에 이미 켜진 JUnit Platform(`useJUnitPlatform()`)에서 실행된다.
가드/계약 테스트(`HmacUserPrincipalPseudonymizerTest` — 생성자 가드, 정확한 예외/포맷 계약)는 설계상
Java(`src/test/java`)로 둔다. Spock 2.4 / Groovy 4.0 variant 를 쓰며, spock-core 가 groovy.jar 를
transitive 로 끌어오므로 data-driven `where:` 스펙에 다른 Groovy 모듈이 필요 없다.
### implementation vs api
`:application-core``implementation` 으로 선언한다(`api` 아님). adapter-identifier 가 자신의 public
ABI 에 application-core 타입을 노출하지 않기 때문이다. 유일한 와이어링 소비자인 `app-bootstrap`
이미 자기 classpath 에 application-core 를 갖고 있다. 이 의존 edge 는 `src/build.gradle`
`allowedProjectDependencies['adapter-identifier']` 로 허용된다.
### UTF-8 인코딩 고정
한국어(비-ASCII) Spock 스펙 메서드명은 소스를 UTF-8 로 읽어야만 컴파일·리포팅이 정상이다. 이 모듈이
비-ASCII 소스를 처음 갖는 모듈이라 컴파일 인코딩을 명시적으로 고정한다 — UTF-8 호스트에선 no-op 지만,
플랫폼 기본이 다른 fork(예: 한국어 Windows / MS949)에서 mojibake 빌드를 막는다. C2 가 더 많은 모듈로
퍼지면 root subprojects 블록(`-parameters` 옆)으로 승격한다.
@@ -0,0 +1,22 @@
// groovy: compiles the UuidCodec Spock specs under src/test/groovy. See README.
plugins {
id 'groovy'
}
dependencies {
implementation project(':domain-core')
implementation project(':application-core')
implementation 'com.github.f4b6a3:uuid-creator:6.1.1'
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
}
// Pin UTF-8 so non-ASCII (Korean) Spock spec names build on any host. See README.
tasks.withType(GroovyCompile).configureEach {
groovyOptions.encoding = 'UTF-8'
options.encoding = 'UTF-8'
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
@@ -0,0 +1,157 @@
# 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=testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=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.f4b6a3:uuid-creator:6.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
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=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,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=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=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.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=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.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
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=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=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,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=testCompileClasspath,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=testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=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=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=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=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=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=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=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=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,48 @@
package dev.caskeleton.adapter.outbound.identifier;
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* HMAC-SHA-256 implementation of {@link UserPrincipalPseudonymizerPort}: returns a stable
* 64-character lowercase hex token, or {@code null} for null/blank input. Thread-safe as a shared
* singleton. See README for the algorithm SSOT, salt origin, and design rationale.
*/
public final class HmacUserPrincipalPseudonymizer implements UserPrincipalPseudonymizerPort {
private static final String ALGORITHM = "HmacSHA256";
private final SecretKeySpec key;
public HmacUserPrincipalPseudonymizer(byte[] salt) {
if (salt == null || salt.length == 0) {
throw new IllegalArgumentException(
"HMAC salt must not be null or empty — supplied by APP_PRIVACY_PSEUDONYMIZATION_SALT");
}
byte[] saltCopy = salt.clone(); // defensive copy; caller's array is not retained
this.key = new SecretKeySpec(saltCopy, ALGORITHM);
}
@Override
public String pseudonymize(String rawPrincipal) {
if (rawPrincipal == null || rawPrincipal.isBlank()) {
return null;
}
try {
Mac mac = Mac.getInstance(ALGORITHM); // Mac is not thread-safe — fresh instance per call
mac.init(key);
byte[] digest = mac.doFinal(rawPrincipal.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
// Unreachable: HmacSHA256 is a mandatory JDK algorithm and the key spec is valid.
throw new IllegalStateException(
"HmacSHA256 unavailable or key invalid — this should never happen on a compliant JDK", e);
}
}
}
@@ -0,0 +1,30 @@
package dev.caskeleton.adapter.outbound.identifier;
import java.util.UUID;
/** Domain-agnostic UUID conversion utility. See README for the design rationale. */
public final class UuidCodec {
private UuidCodec() {}
/**
* Accepts a case-insensitive canonical UUID string and returns the canonical 36-character
* lowercase form; {@code null} input returns {@code null}.
*
* @throws IllegalArgumentException on a malformed UUID
*/
public static String normalize(String input) {
if (input == null) {
return null;
}
return UUID.fromString(input).toString();
}
public static UUID toUuid(String uuidString) {
return UUID.fromString(uuidString);
}
public static String fromUuid(UUID uuid) {
return uuid.toString();
}
}
@@ -0,0 +1,5 @@
/**
* Non-IO infrastructure-capability adapters (identifier generation/codec, clock, crypto/random).
* See README for why these are separate from {@code adapter-outbound}.
*/
package dev.caskeleton.adapter.outbound.identifier;
@@ -0,0 +1,39 @@
package dev.caskeleton.adapter.outbound.identifier
import spock.lang.Specification
class UuidCodecSpec extends Specification {
static final String CANONICAL = "0190bd6e-7c3e-7abc-8def-0123456789ab"
def "normalize 는 #label 을 36자 소문자 canonical 형태로 변환한다"() {
expect:
UuidCodec.normalize(input) == CANONICAL
where:
label | input
"이미 canonical 인 입력" | CANONICAL
"대문자 입력" | CANONICAL.toUpperCase()
}
def "normalize 는 null 입력에 대해 null 을 반환한다"() {
expect:
UuidCodec.normalize(null) == null
}
def "normalize 는 형식이 잘못된 UUID 를 거부한다"() {
when:
UuidCodec.normalize("not-a-uuid")
then:
thrown(IllegalArgumentException)
}
def "UUID -> UUID -> UUID 왕복 변환은 무손실이다"() {
given:
def uuid = UuidCodec.toUuid(CANONICAL)
expect:
UuidCodec.fromUuid(uuid) == CANONICAL
}
}
@@ -0,0 +1,109 @@
package dev.caskeleton.adapter.outbound.identifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
class HmacUserPrincipalPseudonymizerTest {
private static final byte[] SALT_A = "test-salt-A-32-bytes-padding-xxx".getBytes();
private static final byte[] SALT_B = "test-salt-B-32-bytes-padding-yyy".getBytes();
// -----------------------------------------------------------------------
// Constructor guard tests
// -----------------------------------------------------------------------
@Test
void nullSaltThrowsIllegalArgumentException() {
assertThatThrownBy(() -> new HmacUserPrincipalPseudonymizer(null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void emptySaltThrowsIllegalArgumentException() {
assertThatThrownBy(() -> new HmacUserPrincipalPseudonymizer(new byte[0]))
.isInstanceOf(IllegalArgumentException.class);
}
// -----------------------------------------------------------------------
// Null / blank input → null output
// -----------------------------------------------------------------------
@Test
void nullInputReturnsNull() {
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
assertThat(pseudonymizer.pseudonymize(null)).isNull();
}
@Test
void blankInputReturnsNull() {
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
assertThat(pseudonymizer.pseudonymize(" ")).isNull();
}
@Test
void emptyStringInputReturnsNull() {
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
assertThat(pseudonymizer.pseudonymize("")).isNull();
}
// -----------------------------------------------------------------------
// Determinism
// -----------------------------------------------------------------------
@Test
void sameInputSameSaltProducesSameOutputOnSameInstance() {
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
String first = pseudonymizer.pseudonymize("user-123");
String second = pseudonymizer.pseudonymize("user-123");
assertThat(first).isEqualTo(second);
}
@Test
void sameInputSameSaltProducesSameOutputAcrossTwoInstances() {
var p1 = new HmacUserPrincipalPseudonymizer(SALT_A);
var p2 = new HmacUserPrincipalPseudonymizer(SALT_A.clone());
assertThat(p1.pseudonymize("user-abc")).isEqualTo(p2.pseudonymize("user-abc"));
}
// -----------------------------------------------------------------------
// Salt-sensitivity
// -----------------------------------------------------------------------
@Test
void differentSaltProducesDifferentOutput() {
var pA = new HmacUserPrincipalPseudonymizer(SALT_A);
var pB = new HmacUserPrincipalPseudonymizer(SALT_B);
assertThat(pA.pseudonymize("user-xyz")).isNotEqualTo(pB.pseudonymize("user-xyz"));
}
// -----------------------------------------------------------------------
// One-way property (output != input, output does not contain input)
// -----------------------------------------------------------------------
@Test
void outputDoesNotEqualRawInput() {
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
String raw = "alice@example.com";
assertThat(pseudonymizer.pseudonymize(raw)).isNotEqualTo(raw);
}
@Test
void outputDoesNotContainRawInputAsSubstring() {
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
String raw = "alice";
assertThat(pseudonymizer.pseudonymize(raw)).doesNotContain(raw);
}
// -----------------------------------------------------------------------
// Output format: lowercase hex, exactly 64 characters (256-bit HMAC)
// -----------------------------------------------------------------------
@Test
void outputMatchesLowercaseHex64CharPattern() {
var pseudonymizer = new HmacUserPrincipalPseudonymizer(SALT_A);
String token = pseudonymizer.pseudonymize("some-user");
assertThat(token).matches("^[0-9a-f]{64}$");
}
}
+29
View File
@@ -0,0 +1,29 @@
# adapter:outbound:messaging — messaging adapter
## Registered identity
- Module ID: `adapter-outbound-messaging`
- Gradle path: `:adapter:outbound:messaging`
- Focused test: `./gradlew :adapter:outbound:messaging: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.adapter.outbound.messaging`.
## Responsibility
- Implement outbound message publication and broker integration behind application/domain ports.
- Own broker settings, serialization envelope, disabled/fail-safe technical modes, and outbox
publication adaptation.
- Reuse `adapter:outbound:support` for shared technical concerns.
## Boundaries
- Allowed dependency edges come only from `.harness/project/modules.yaml`.
- No inbound DTO/controller, persistence repository/entity, bootstrap, or sample dependency.
- Do not hide use-case sequencing or business routing policy in broker adapters.
## Tests
Use unit/contract tests with fake broker senders. No real network or broker is used in focused tests;
settings records receive binding/validation tests when configuration changes.
+44
View File
@@ -0,0 +1,44 @@
# adapter:outbound:messaging — 설계 결정 참조
메시징(broker publish + outbox) 아웃바운드 어댑터 모듈. 패키지 루트:
`dev.caskeleton.adapter.outbound.messaging`. `:adapter:outbound:support` 에 의존해 공유
correlation / fail-open 의존성 로깅을 재사용한다.
허용/금지 의존 정책은 `src/build.gradle`
`allowedProjectDependencies['adapter:outbound:messaging']` 항목이 SSOT 다(이 모듈은 아직 별도
CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용
기록이다.
## 모듈 개요
application-core 포트(`MessagePublisher` / `OutboxMessagePublishPort`) 뒤에 두는 **선택형**
연동 어댑터다. `@ConditionalOnProperty` 로 게이팅되고 기본 비활성이며, 비활성 바인딩은
`Disabled*` 구현으로 fail-fast 한다(Layer 3). 무거운 broker SDK 는 의도적으로 classpath 에
최소화하고, 실제 broker client(`KafkaSender`)는 포킹 프로젝트가 채우는 seam 이다.
## 두 포트를 하나의 활성 broker 에 조립
`MessagingConfig` 는 두 messaging 포트를 단일 활성 `MessageBroker` 위에 조립한다 — broker
추가는 새 broker 구현 파일 추가만으로 끝나고 이 config 는 바뀌지 않는다.
## broker 선택 검증
`app.messaging.broker` 가 설정됐는데 `MessageBroker` 빈이 없으면 startup 을 명시적 메시지로
실패시킨다(조용한 no-op 아님). settings 와 활성 빈의 `brokerId()` 불일치도 startup 실패다.
## 비활성 sentinel 두 개를 분리한 이유
`DisabledMessagePublisher``DisabledOutboxMessagePublisher` 는 별도 클래스다 — 한 클래스가
두 포트를 모두 구현하면 `getBean(MessagePublisher.class)` 가 모호해진다.
## OutboxEnvelopeJson — 손수 짠 JSON
이 모듈은 `jackson-databind` 를 classpath 에 두지 않아(스켈레톤을 가볍게 유지) outbox envelope
직렬화는 의존성 없는 손수 짠 JSON 이다.
## MessagePublisher vs OutboxMessagePublishPort
`MessagePublisher` 는 fail-open 어댑터-로컬 발행기로, 발행 실패를 correlationId 와 함께
로깅하고 삼켜(→ `:adapter:outbound:support``FailOpenDependencyLogger`) outbox/retry 로
위임하므로 core 5xx 가 되지 않는다. 내구성 있는 전달이 필요하면 `OutboxMessagePublishPort`
쓴다. 반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7).
@@ -0,0 +1,14 @@
plugins { id 'groovy' }
dependencies {
implementation project(':domain-core')
implementation project(':application-core')
implementation project(':shared-contract')
implementation project(':adapter:outbound:support')
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.slf4j:slf4j-api'
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
}
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
@@ -0,0 +1,156 @@
# 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=testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=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=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,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=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.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
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=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.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
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=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=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=testCompileClasspath,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=testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=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=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.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
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=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=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=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,72 @@
package dev.caskeleton.adapter.outbound.messaging;
import dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher;
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
import dev.caskeleton.adapter.outbound.messaging.core.MessagePublisher;
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessagePublisher;
import dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher;
import dev.caskeleton.adapter.outbound.messaging.outbox.OutboxMessagePublishAdapter;
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Assembles both messaging ports onto the single active {@link MessageBroker} (the cache
* central-assembly pattern). A broker is contributed as a
* {@code @ConditionalOnProperty(app.messaging.broker=<id>)}-gated bean, at most one active — adding
* a broker is new files only and this config never changes. With no active broker it binds the
* disabled sentinels (fail-fast); if set but no contributing bean exists, startup fails.
*/
@Configuration
@EnableConfigurationProperties(MessagingSettings.class)
public class MessagingConfig {
@Bean
public MessagePublisher messagePublisher(
ObjectProvider<MessageBroker> brokerProvider,
MessagingSettings settings,
FailOpenDependencyLogger dependencyLogger) {
MessageBroker active = resolveBroker(brokerProvider, settings);
return (active == null)
? new DisabledMessagePublisher()
: new OutboundMessagePublisher(active, dependencyLogger);
}
@Bean
public OutboxMessagePublishPort outboxMessagePublishPort(
ObjectProvider<MessageBroker> brokerProvider,
MessagingSettings settings,
FailOpenDependencyLogger dependencyLogger) {
MessageBroker active = resolveBroker(brokerProvider, settings);
return (active == null)
? new DisabledOutboxMessagePublisher()
: new OutboxMessagePublishAdapter(active, dependencyLogger);
}
private static MessageBroker resolveBroker(
ObjectProvider<MessageBroker> brokerProvider, MessagingSettings settings) {
if (settings.broker().isBlank()) {
return null;
}
MessageBroker active = brokerProvider.getIfAvailable();
if (active == null) {
throw new IllegalStateException(
"app.messaging.broker="
+ settings.broker()
+ " but no MessageBroker bean contributes that id — enable the broker template"
+ " (supply its client seam) or unset app.messaging.broker");
}
if (!settings.broker().equals(active.brokerId())) {
throw new IllegalStateException(
"app.messaging.broker="
+ settings.broker()
+ " but the active MessageBroker reports brokerId '"
+ active.brokerId()
+ "'");
}
return active;
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.messaging;
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Messaging template selection. {@code app.messaging.broker=<brokerId>} chooses the single active
* {@link MessageBroker} (e.g. {@code kafka}); unset/blank = no broker = fail-fast on use (the
* disabled sentinels {@code DisabledMessagePublisher} / {@code DisabledOutboxMessagePublisher}).
*
* @param broker the active broker id, matched against {@link MessageBroker#brokerId()}; blank means
* the messaging template is disabled (the default)
*/
@ConfigurationProperties(prefix = "app.messaging")
public record MessagingSettings(String broker) {
public MessagingSettings {
broker = (broker == null) ? "" : broker.trim();
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.outbound.messaging.core;
import dev.caskeleton.shared.error.AdapterDisabledException;
/**
* Fail-fast {@link MessagePublisher} binding when no broker is active ({@code app.messaging.broker}
* unset — the default). Any publish throws {@link AdapterDisabledException} — never a silent no-op
* (mirrors the cache router's unbound fail-fast). Broker-agnostic; the outbox counterpart is {@code
* DisabledOutboxMessagePublisher}.
*/
public class DisabledMessagePublisher implements MessagePublisher {
@Override
public void publish(OutboundMessage message) {
throw new AdapterDisabledException("messaging");
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.messaging.core;
/**
* SPI a forking project contributes to bind the messaging template to a real broker. The active
* broker is selected by {@code app.messaging.broker=<brokerId>}. The skeleton carries no broker SDK
* — it is supplied by the project that selects the broker.
*/
public interface MessageBroker {
/** Stable broker identifier matched against {@code app.messaging.broker}. */
String brokerId();
/**
* Sends a message to the broker. May throw on any transport/broker failure; the binding decorator
* applies the fail-open (general) or fail-closed (outbox) policy.
*
* @throws Exception on any send failure (handled by the binding decorator)
*/
void send(OutboundMessage message) throws Exception;
}
@@ -0,0 +1,13 @@
package dev.caskeleton.adapter.outbound.messaging.core;
/**
* Adapter-local fire-and-forget (fail-open) publishing port for the optional adapter template. Not
* use-case-facing ({@code adapter-outbound} type, so application-core cannot hold it) — a use case
* that needs durable delivery uses the application-core {@code OutboxMessagePublishPort}. The
* active binding is selected by {@code app.messaging.broker}. The {@code void} return keeps broker
* SDK types from escaping the adapter (B7).
*/
public interface MessagePublisher {
void publish(OutboundMessage message);
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.messaging.core;
import java.util.Objects;
/**
* Transport-neutral message a {@link MessagePublisher} emits. Carries only the routing key family
* ({@code topic}, partition {@code key}) and an already-serialized {@code payload} string — it
* deliberately does NOT depend on any broker SDK type, so the messaging port stays
* template-portable across Kafka and any future broker.
*
* @param topic logical destination / topic
* @param key partition / ordering key (may be empty, never null)
* @param payload serialized message body (the producer is responsible for serialization)
*/
public record OutboundMessage(String topic, String key, String payload) {
public OutboundMessage {
Objects.requireNonNull(topic, "topic");
Objects.requireNonNull(key, "key");
Objects.requireNonNull(payload, "payload");
if (topic.isBlank()) {
throw new IllegalArgumentException("topic must not be blank");
}
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.outbound.messaging.core;
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
/**
* General {@link MessagePublisher} binding (fail-open). Delegates to the active {@link
* MessageBroker}; on failure it logs with the correlation id and swallows — a broker outage must
* never turn a core use case into a 5xx (durable delivery is delegated to the outbox/retry path).
* The fail-closed counterpart is {@code OutboxMessagePublishAdapter}.
*
* <p>Broker-agnostic: the same decorator serves any {@link MessageBroker}, so adding a broker never
* touches this class.
*/
public class OutboundMessagePublisher implements MessagePublisher {
private static final String DEPENDENCY_TYPE = "messaging";
private final MessageBroker broker;
private final FailOpenDependencyLogger dependencyLogger;
public OutboundMessagePublisher(MessageBroker broker, FailOpenDependencyLogger dependencyLogger) {
this.broker = broker;
this.dependencyLogger = dependencyLogger;
}
@Override
public void publish(OutboundMessage message) {
try {
broker.send(message);
dependencyLogger.logSuccess(broker.brokerId(), DEPENDENCY_TYPE, "publish");
} catch (Exception ex) {
// fail-open: observe with correlationId, delegate durability to outbox/retry,
// do NOT propagate — the core use case must still succeed.
dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex);
}
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.messaging.kafka;
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Layer 1 gating for the Kafka broker template: registers the Kafka {@link MessageBroker} only when
* {@code app.messaging.broker=kafka}. The central {@code MessagingConfig} binds both messaging
* ports onto it.
*
* <p>Needs a project-supplied {@link KafkaSender} bean (the integration seam) and a non-empty
* {@code app.messaging.kafka.brokers}. Adding another broker is a new config like this one — this
* file never changes.
*/
@Configuration
@EnableConfigurationProperties(KafkaAdapterSettings.class)
public class KafkaAdapterConfig {
@Bean
@ConditionalOnProperty(name = "app.messaging.broker", havingValue = "kafka")
public MessageBroker kafkaMessageBroker(KafkaSender sender, KafkaAdapterSettings settings) {
if (settings.brokers().isEmpty()) {
throw new IllegalStateException(
"app.messaging.broker=kafka requires a non-empty app.messaging.kafka.brokers "
+ "(CSV of host:port)");
}
return new KafkaMessageBroker(sender);
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.messaging.kafka;
import java.util.List;
import java.util.regex.Pattern;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Kafka broker tuning bound from {@code app.messaging.kafka.*}. Validation is format-only ({@code
* host:port} per entry); the "Kafka selected ⇒ brokers required" cross-field rule is enforced in
* {@code KafkaAdapterConfig}, so an empty list is valid at bind time.
*
* @param brokers CSV of {@code host:port} broker endpoints (each entry format-validated)
*/
@ConfigurationProperties(prefix = "app.messaging.kafka")
public record KafkaAdapterSettings(List<String> brokers) {
private static final Pattern HOST_PORT = Pattern.compile("^[^:\\s]+:\\d{1,5}$");
public KafkaAdapterSettings {
brokers = (brokers == null) ? List.of() : List.copyOf(brokers);
for (String broker : brokers) {
if (!HOST_PORT.matcher(broker.trim()).matches()) {
throw new IllegalArgumentException(
"APP_MESSAGING_KAFKA_BROKERS entry '" + broker + "' is not host:port");
}
}
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.messaging.kafka;
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage;
/**
* Kafka contribution of the {@link MessageBroker} SPI (brokerId {@code "kafka"}). Delegates the raw
* send to the project-supplied {@link KafkaSender} seam; the fail-open (general) and fail-closed
* (outbox) policies are applied by the binding decorators in the messaging package, not here — so
* this class carries no policy and no Kafka SDK.
*/
public class KafkaMessageBroker implements MessageBroker {
private static final String BROKER_ID = "kafka";
private final KafkaSender sender;
public KafkaMessageBroker(KafkaSender sender) {
this.sender = sender;
}
@Override
public String brokerId() {
return BROKER_ID;
}
@Override
public void send(OutboundMessage message) throws Exception {
sender.send(message);
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.messaging.kafka;
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage;
/**
* Integration seam the forking project implements to bind the Kafka template to a real producer.
* The skeleton carries no Kafka SDK dependency — it is added by the project that enables Kafka. A
* send failure may throw; the binding decorator applies the failure policy.
*/
@FunctionalInterface
public interface KafkaSender {
/**
* Sends a message to the broker. May throw on any transport/broker failure.
*
* @throws Exception on any send failure (caught and handled fail-open by the publisher)
*/
void send(OutboundMessage message) throws Exception;
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.messaging.outbox;
import dev.caskeleton.application.outbox.OutboxEvent;
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
import dev.caskeleton.shared.error.AdapterDisabledException;
/**
* Fail-fast {@link OutboxMessagePublishPort} binding when no broker is active ({@code
* app.messaging.broker} unset — the default). Any publish throws {@link AdapterDisabledException} —
* never a silent no-op. Kept separate from {@code DisabledMessagePublisher} so each disabled bean
* implements exactly one port (a single class implementing both makes {@code
* getBean(MessagePublisher.class)} ambiguous).
*/
public class DisabledOutboxMessagePublisher implements OutboxMessagePublishPort {
@Override
public void publish(OutboxEvent event) {
throw new AdapterDisabledException("messaging");
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.adapter.outbound.messaging.outbox;
import dev.caskeleton.application.outbox.OutboxEvent;
/**
* Hand-rolled, dependency-free JSON serialiser for the outbox envelope (no Jackson — the module
* deliberately keeps {@code jackson-databind} off its classpath).
*
* <p>{@link OutboxEvent#payload()} MUST already be a valid serialised JSON value; it is inserted
* verbatim (no escaping) — serialisation policy is owned by the {@code schema-serialization}
* branch, this class only assembles the envelope. All other string fields go through {@link
* #escape(String)}.
*/
public final class OutboxEnvelopeJson {
private OutboxEnvelopeJson() {}
public static String toJson(OutboxEvent event) {
return "{"
+ "\"eventId\":\""
+ escape(event.eventId())
+ "\","
+ "\"eventType\":\""
+ escape(event.eventType())
+ "\","
+ "\"aggregateId\":\""
+ escape(event.aggregateId())
+ "\","
+ "\"occurredAt\":\""
+ escape(event.occurredAt().toString())
+ "\","
+ "\"correlationId\":\""
+ escape(event.correlationId())
+ "\","
+ "\"idempotencyKey\":\""
+ escape(event.idempotencyKey())
+ "\","
+ "\"payload\":"
+ event.payload()
+ "}";
}
/** Escapes a string for a JSON string literal (RFC 8259 §7). */
static String escape(String value) {
StringBuilder sb = new StringBuilder(value.length() + 4);
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '\\') {
sb.append("\\\\");
} else if (c == '"') {
sb.append("\\\"");
} else if (c == '\b') {
sb.append("\\b");
} else if (c == '\t') {
sb.append("\\t");
} else if (c == '\n') {
sb.append("\\n");
} else if (c == '\f') {
sb.append("\\f");
} else if (c == '\r') {
sb.append("\\r");
} else if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
return sb.toString();
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.adapter.outbound.messaging.outbox;
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage;
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
import dev.caskeleton.application.outbox.OutboxEvent;
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
/**
* Outbox {@link OutboxMessagePublishPort} binding (fail-closed). Maps the claimed {@link
* OutboxEvent} to an {@link OutboundMessage} and delegates to the active {@link MessageBroker}; on
* failure it logs and re-throws so the relay can drive the FAILED/DEAD transition (the documented
* fail-closed contract — contrast the fail-open general {@code OutboundMessagePublisher}).
*
* <p>Broker-agnostic: the same decorator serves any {@link MessageBroker}, so adding a broker never
* touches this class.
*/
public class OutboxMessagePublishAdapter implements OutboxMessagePublishPort {
private static final String DEPENDENCY_TYPE = "messaging";
private final MessageBroker broker;
private final FailOpenDependencyLogger dependencyLogger;
public OutboxMessagePublishAdapter(
MessageBroker broker, FailOpenDependencyLogger dependencyLogger) {
this.broker = broker;
this.dependencyLogger = dependencyLogger;
}
@Override
public void publish(OutboxEvent event) {
String envelope = OutboxEnvelopeJson.toJson(event);
OutboundMessage message = new OutboundMessage(event.eventType(), event.aggregateId(), envelope);
try {
broker.send(message);
dependencyLogger.logSuccess(broker.brokerId(), DEPENDENCY_TYPE, "publish");
} catch (RuntimeException ex) {
// fail-closed: log then propagate — the relay must observe this to drive FAILED/DEAD.
dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex);
throw ex;
} catch (Exception ex) {
// Wrap checked exceptions; preserve cause so the relay can inspect it.
dependencyLogger.logFailure(broker.brokerId(), DEPENDENCY_TYPE, "publish", ex);
throw new RuntimeException(
"outbox publish failed for broker '" + broker.brokerId() + "'", ex);
}
}
}
@@ -0,0 +1,110 @@
package dev.caskeleton.adapter.outbound.messaging.core;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
/**
* General {@link OutboundMessagePublisher} = fail-open: delegates to the active {@link
* MessageBroker}; a broker failure is logged with the correlation id and swallowed (never
* propagated). Broker-agnostic — a fake broker stands in for any real broker.
*/
class OutboundMessagePublisherTest {
private ch.qos.logback.classic.Logger logbackLogger;
private ListAppender<ILoggingEvent> appender;
private FailOpenDependencyLogger dependencyLogger;
@BeforeEach
void setUp() {
logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.messaging");
appender = new ListAppender<>();
appender.start();
logbackLogger.addAppender(appender);
logbackLogger.setLevel(Level.DEBUG);
dependencyLogger = new FailOpenDependencyLogger(logbackLogger);
}
@AfterEach
void tearDown() {
logbackLogger.detachAppender(appender);
MDC.clear();
}
/** Fake broker (brokerId "kafka") capturing sends, optionally failing. */
private static final class FakeBroker implements MessageBroker {
final List<OutboundMessage> sent = new ArrayList<>();
private final RuntimeException failure;
FakeBroker() {
this.failure = null;
}
FakeBroker(RuntimeException failure) {
this.failure = failure;
}
@Override
public String brokerId() {
return "kafka";
}
@Override
public void send(OutboundMessage message) {
if (failure != null) {
throw failure;
}
sent.add(message);
}
}
@Test
void publishDelegatesToTheActiveBrokerOnSuccess() {
FakeBroker broker = new FakeBroker();
OutboundMessagePublisher publisher = new OutboundMessagePublisher(broker, dependencyLogger);
OutboundMessage message = new OutboundMessage("worklog-events", "wl-1", "{}");
publisher.publish(message);
assertThat(broker.sent).containsExactly(message);
}
@Test
void publishFailureIsFailOpenAndNeverThrows() {
FakeBroker broker = new FakeBroker(new IllegalStateException("broker unavailable"));
OutboundMessagePublisher publisher = new OutboundMessagePublisher(broker, dependencyLogger);
// fail-open: a broker outage must not propagate to the core use case.
assertThatCode(() -> publisher.publish(new OutboundMessage("t", "k", "p")))
.doesNotThrowAnyException();
}
@Test
void publishFailureLogCarriesCorrelationIdAndBrokerId() {
MDC.put(OutboundCorrelation.MDC_KEY, "corr-msg-1");
FakeBroker broker = new FakeBroker(new IllegalStateException("broker unavailable"));
OutboundMessagePublisher publisher = new OutboundMessagePublisher(broker, dependencyLogger);
publisher.publish(new OutboundMessage("t", "k", "p"));
ILoggingEvent event =
appender.list.stream().filter(e -> e.getLevel() == Level.WARN).findFirst().orElseThrow();
assertThat(event.getFormattedMessage())
.contains("correlation_id=\"corr-msg-1\"")
.contains("dependency_name=\"kafka\"")
.contains("dependency_type=\"messaging\"")
.contains("operation=\"publish\"");
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.messaging.kafka;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class KafkaAdapterSettingsTest {
@Test
void emptyOrNullBrokersIsTolerated() {
// bound globally via @ConfigurationPropertiesScan even when Kafka is not the active
// broker — an empty list must bind cleanly (the "required" guard lives in the config).
assertThat(new KafkaAdapterSettings((List<String>) null).brokers()).isEmpty();
assertThat(new KafkaAdapterSettings(List.of()).brokers()).isEmpty();
}
@Test
void rejectsABrokerThatIsNotHostPort() {
assertThatThrownBy(() -> new KafkaAdapterSettings(List.of("not-a-broker")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("host:port");
}
@Test
void acceptsCsvOfHostPortBrokers() {
KafkaAdapterSettings settings =
new KafkaAdapterSettings(List.of("broker-1:9092", "broker-2:9092"));
assertThat(settings.brokers()).containsExactly("broker-1:9092", "broker-2:9092");
}
@Test
void brokersListIsDefensivelyCopied() {
List<String> mutable = new ArrayList<>(List.of("broker-1:9092"));
KafkaAdapterSettings settings = new KafkaAdapterSettings(mutable);
mutable.add("broker-2:9092");
assertThat(settings.brokers()).containsExactly("broker-1:9092");
}
}
@@ -0,0 +1,266 @@
package dev.caskeleton.adapter.outbound.messaging.outbox;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage;
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
import dev.caskeleton.application.outbox.OutboxEvent;
import dev.caskeleton.application.outbox.OutboxEventStatus;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
/**
* Broker-agnostic outbox publish adapter (fail-closed). Covers:
*
* <ul>
* <li>Success — correct OutboundMessage (topic=eventType, key=aggregateId, payload=envelope JSON)
* sent to the active broker.
* <li>Fail-closed — broker failure is logged then propagated (never swallowed); checked
* exceptions are wrapped.
* <li>Envelope JSON fields and escaping.
* </ul>
*/
class OutboxMessagePublishAdapterTest {
private ch.qos.logback.classic.Logger logbackLogger;
private ListAppender<ILoggingEvent> appender;
private FailOpenDependencyLogger dependencyLogger;
@BeforeEach
void setUp() {
logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbox");
appender = new ListAppender<>();
appender.start();
logbackLogger.addAppender(appender);
logbackLogger.setLevel(Level.DEBUG);
dependencyLogger = new FailOpenDependencyLogger(logbackLogger);
}
@AfterEach
void tearDown() {
logbackLogger.detachAppender(appender);
MDC.clear();
}
/** Fake broker (brokerId "kafka") capturing sends, optionally failing with a given throwable. */
private static final class FakeBroker implements MessageBroker {
final List<OutboundMessage> sent = new ArrayList<>();
private final Exception failure;
FakeBroker() {
this.failure = null;
}
FakeBroker(Exception failure) {
this.failure = failure;
}
@Override
public String brokerId() {
return "kafka";
}
@Override
public void send(OutboundMessage message) throws Exception {
if (failure != null) {
throw failure;
}
sent.add(message);
}
}
private OutboxEvent sampleEvent() {
return new OutboxEvent(
"evt-01",
"WorkLogReserved",
"wl-agg-1",
"{\"workLogId\":\"wl-01\"}",
Instant.parse("2024-01-02T03:04:05Z"),
"corr-abc",
"idem-xyz",
OutboxEventStatus.IN_FLIGHT,
1);
}
@Nested
class SuccessPath {
@Test
void publishSendsMessageWithCorrectTopicKeyAndEnvelopePayload() {
FakeBroker broker = new FakeBroker();
OutboxMessagePublishAdapter adapter =
new OutboxMessagePublishAdapter(broker, dependencyLogger);
adapter.publish(sampleEvent());
assertThat(broker.sent).hasSize(1);
OutboundMessage sent = broker.sent.get(0);
assertThat(sent.topic()).isEqualTo("WorkLogReserved");
assertThat(sent.key()).isEqualTo("wl-agg-1");
}
@Test
void publishEnvelopeContainsAllFields() {
FakeBroker broker = new FakeBroker();
OutboxMessagePublishAdapter adapter =
new OutboxMessagePublishAdapter(broker, dependencyLogger);
adapter.publish(sampleEvent());
String payload = broker.sent.get(0).payload();
assertThat(payload).contains("\"eventId\"");
assertThat(payload).contains("\"eventType\"");
assertThat(payload).contains("\"aggregateId\"");
assertThat(payload).contains("\"occurredAt\"");
assertThat(payload).contains("\"correlationId\"");
assertThat(payload).contains("\"idempotencyKey\"");
assertThat(payload).contains("\"payload\"");
}
@Test
void publishEnvelopeFieldValuesMatchEvent() {
FakeBroker broker = new FakeBroker();
OutboxMessagePublishAdapter adapter =
new OutboxMessagePublishAdapter(broker, dependencyLogger);
adapter.publish(sampleEvent());
String payload = broker.sent.get(0).payload();
assertThat(payload).contains("\"evt-01\"");
assertThat(payload).contains("\"WorkLogReserved\"");
assertThat(payload).contains("\"wl-agg-1\"");
assertThat(payload).contains("2024-01-02T03:04:05Z");
assertThat(payload).contains("\"corr-abc\"");
assertThat(payload).contains("\"idem-xyz\"");
assertThat(payload).contains("{\"workLogId\":\"wl-01\"}");
}
}
@Nested
class FailClosedPath {
@Test
void publishFailurePropagatesAsRuntimeException() {
FakeBroker broker = new FakeBroker(new IllegalStateException("broker down"));
OutboxMessagePublishAdapter adapter =
new OutboxMessagePublishAdapter(broker, dependencyLogger);
assertThatThrownBy(() -> adapter.publish(sampleEvent())).isInstanceOf(RuntimeException.class);
}
@Test
void publishFailureIsLoggedBeforePropagation() {
MDC.put(OutboundCorrelation.MDC_KEY, "corr-fail-1");
FakeBroker broker = new FakeBroker(new IllegalStateException("broker unavailable"));
OutboxMessagePublishAdapter adapter =
new OutboxMessagePublishAdapter(broker, dependencyLogger);
try {
adapter.publish(sampleEvent());
} catch (RuntimeException ignored) {
// expected
}
boolean warnLogged =
appender.list.stream()
.anyMatch(
e ->
e.getLevel() == Level.WARN
&& e.getFormattedMessage().contains("corr-fail-1"));
assertThat(warnLogged).as("Expected a WARN log with the correlationId on failure").isTrue();
}
@Test
void publishFailureLogCarriesDependencyAndOperation() {
FakeBroker broker = new FakeBroker(new RuntimeException("connection refused"));
OutboxMessagePublishAdapter adapter =
new OutboxMessagePublishAdapter(broker, dependencyLogger);
try {
adapter.publish(sampleEvent());
} catch (RuntimeException ignored) {
// expected
}
String msg =
appender.list.stream()
.filter(e -> e.getLevel() == Level.WARN)
.findFirst()
.map(ILoggingEvent::getFormattedMessage)
.orElse("");
assertThat(msg)
.contains("dependency_name=\"kafka\"")
.contains("dependency_type=\"messaging\"")
.contains("operation=\"publish\"");
}
@Test
void publishWrapsCheckedExceptionInRuntimeException() {
FakeBroker broker = new FakeBroker(new IOException("network error"));
OutboxMessagePublishAdapter adapter =
new OutboxMessagePublishAdapter(broker, dependencyLogger);
assertThatThrownBy(() -> adapter.publish(sampleEvent()))
.isInstanceOf(RuntimeException.class)
.hasCauseInstanceOf(IOException.class);
}
}
@Nested
class JsonEscape {
@Test
void envelopeEscapesDoubleQuotesInStringFields() {
OutboxEvent eventWithQuote =
new OutboxEvent(
"evt-02",
"Has\"Quote",
"agg-1",
"{}",
Instant.parse("2024-01-01T00:00:00Z"),
"corr-1",
"idem-1",
OutboxEventStatus.IN_FLIGHT,
1);
FakeBroker broker = new FakeBroker();
new OutboxMessagePublishAdapter(broker, dependencyLogger).publish(eventWithQuote);
assertThat(broker.sent.get(0).payload()).contains("Has\\\"Quote");
}
@Test
void envelopeInsertsPayloadRawWithoutDoubleEncoding() {
OutboxEvent event =
new OutboxEvent(
"evt-05",
"SomeEvent",
"agg-2",
"{\"nested\":{\"a\":1}}",
Instant.parse("2024-01-01T00:00:00Z"),
"corr-2",
"idem-2",
OutboxEventStatus.IN_FLIGHT,
1);
FakeBroker broker = new FakeBroker();
new OutboxMessagePublishAdapter(broker, dependencyLogger).publish(event);
String payload = broker.sent.get(0).payload();
assertThat(payload).contains("{\"nested\":{\"a\":1}}");
assertThat(payload).doesNotContain("\"{\\\"nested\\\"");
}
}
}
@@ -0,0 +1,28 @@
# adapter:outbound:notification — notification adapter
## Registered identity
- Module ID: `adapter-outbound-notification`
- Gradle path: `:adapter:outbound:notification`
- Focused test: `./gradlew :adapter:outbound:notification: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.adapter.outbound.notification`.
## Responsibility
- Implement notification provider routing and provider-specific Slack/email clients behind ports.
- Own provider settings, technical fallback, and provider adaptation.
- Reuse `adapter:outbound:support` for shared outbound concerns.
## Boundaries
- Allowed dependency edges come only from `.harness/project/modules.yaml`.
- No inbound DTO/controller, persistence, bootstrap, or sample dependency.
- Provider selection may route configured channels but must not encode business eligibility rules.
## Tests
Use provider/client fakes and contract tests; no real webhook or email network calls. Settings changes
include binding/validation tests.
@@ -0,0 +1,34 @@
# adapter:outbound:notification — 설계 결정 참조
알림(email/Slack 등) 아웃바운드 어댑터 모듈. 패키지 루트:
`dev.caskeleton.adapter.outbound.notification`. `:adapter:outbound:support` 에 의존해 공유
correlation / fail-open 의존성 로깅을 재사용한다.
허용/금지 의존 정책은 `src/build.gradle`
`allowedProjectDependencies['adapter:outbound:notification']` 항목이 SSOT 다(이 모듈은 아직
별도 CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔
참조용 기록이다.
## 모듈 개요
application-core 포트 뒤에 두는 **선택형** 알림 어댑터다. `@ConditionalOnProperty` 로 게이팅되고
기본 비활성이다. 이 모듈이 기본 제공하는 프로바이더는 `email/google`(`GoogleEmailProvider` /
`GoogleEmailClient`)과 `slack/webhook`(`SlackWebhookProvider` / `SlackClient`)이며, 실제 연동
client 는 포킹 프로젝트가 채우는 seam 이다.
## (channel, providerId) 복합 키 + fan-out
`RoutingNotifier``(channel, providerId)` 복합 키로 프로바이더를 등록한다 — 채널 내 중복
`providerId` 는 생성 시점에 실패한다. 라우트당 providerId 목록을 주면 fan-out(모든 프로바이더
호출)이 된다. 각 프로바이더는 이미 `FailOpenNotificationProvider` 로 감싸져 있어 한 곳의 실패가
다른 곳을 막지 않고, 그래서 fan-out 루프에 try/catch 가 필요 없다. `FailOpenNotificationProvider
.send``throws` 를 선언하지 않는 건 이 루프를 try/catch 없이 예외-free 로 증명하기 위함이다.
## 중앙 fail-open 합성 + 라우팅 바인딩
`NotificationConfig` 가 모든 프로바이더를 `FailOpenNotificationProvider` 로 중앙에서 감싼다(→
`:adapter:outbound:support``FailOpenDependencyLogger` 로 WARN 로깅). 라우팅은
`app.notification.routes.<channel>.<route>=<providerId>[,<providerId>]`. 프로바이더는
`channel()`+`providerId()` 로 키잉된 `NotificationProvider` 빈으로 기여한다(예:
`GoogleEmailProvider`, `SlackWebhookProvider`). `GoogleEmailClient`/`SlackClient` 는 포크가
구현하는 seam 이며 실패는 데코레이터가 fail-open 처리한다.
@@ -0,0 +1,15 @@
plugins { id 'groovy' }
dependencies {
implementation project(':domain-core')
implementation project(':application-core')
implementation project(':shared-contract')
implementation project(':adapter:outbound:support')
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.springframework:spring-web' // Slack webhook client (RestClient)
implementation 'org.slf4j:slf4j-api'
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
}
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
@@ -0,0 +1,156 @@
# 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=testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=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=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,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=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.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
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=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.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
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=runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=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=testCompileClasspath,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=testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=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=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.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
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=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=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=compileClasspath,runtimeClasspath,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=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,40 @@
package dev.caskeleton.adapter.outbound.notification;
import dev.caskeleton.adapter.outbound.notification.core.FailOpenNotificationProvider;
import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider;
import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier;
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
import dev.caskeleton.application.notification.NotificationPort;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Assembles the {@link RoutingNotifier} from every contributed {@link NotificationProvider} bean,
* mirroring {@code CacheRouterConfig}.
*
* <p>Provider discovery is type-explicit: a provider opts in by registering a {@link
* NotificationProvider} bean (its {@link NotificationProvider#providerId()} + {@link
* NotificationProvider#channel()} are the routing keys). Adding a provider is therefore new files
* only — this config and {@link RoutingNotifier} never change. The fail-open policy is applied
* here, centrally, by wrapping every provider in {@link FailOpenNotificationProvider} — a provider
* config cannot forget it.
*/
@Configuration
@EnableConfigurationProperties(NotificationRoutesSettings.class)
public class NotificationConfig {
@Bean
public NotificationPort notificationPort(
ObjectProvider<List<NotificationProvider>> providers,
NotificationRoutesSettings settings,
FailOpenDependencyLogger failOpenDependencyLogger) {
List<FailOpenNotificationProvider> failOpenProviders =
providers.getIfAvailable(List::of).stream()
.map(p -> new FailOpenNotificationProvider(p, failOpenDependencyLogger))
.toList();
return new RoutingNotifier(failOpenProviders, settings.routes());
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.notification;
import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier;
import dev.caskeleton.application.notification.Channel;
import java.util.List;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Routes binding for the multi-provider notification template: {@code
* app.notification.routes.<channel>.<route>=<providerId>[,<providerId>]}.
*
* <p>Mirrors {@code CacheBindingSettings}: relaxed binding maps the channel segment (e.g. {@code
* email} → {@link Channel#EMAIL}) automatically via Spring's {@code ApplicationConversionService}.
* Route values are coerced to {@code List<String>} by the binder (comma-separated or YAML list).
* Default is an empty map so the notification template stays a non-required optional module:
* startup never fails when no routes are configured. Consistency (every referenced providerId has
* an enabled provider) is validated fail-fast by {@link RoutingNotifier} at construction time.
*
* @param routes channel → (route → providerId list), default empty
*/
@ConfigurationProperties(prefix = "app.notification")
public record NotificationRoutesSettings(Map<Channel, Map<String, List<String>>> routes) {
public NotificationRoutesSettings {
routes = (routes == null) ? Map.of() : Map.copyOf(routes);
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.outbound.notification.core;
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
import dev.caskeleton.application.notification.Channel;
import dev.caskeleton.application.notification.Notification;
/**
* Fail-open decorator: a provider failure is logged (no payload/PII) and swallowed so a
* notification — a side-effect — never fails the core use case. Applied centrally by
* NotificationConfig.
*/
public final class FailOpenNotificationProvider implements NotificationProvider {
private static final String DEPENDENCY_TYPE = "notification";
private final NotificationProvider delegate;
private final FailOpenDependencyLogger dependencyLogger;
public FailOpenNotificationProvider(
NotificationProvider delegate, FailOpenDependencyLogger dependencyLogger) {
this.delegate = delegate;
this.dependencyLogger = dependencyLogger;
}
@Override
public Channel channel() {
return delegate.channel();
}
@Override
public String providerId() {
return delegate.providerId();
}
@Override
public void send(Notification notification) {
try {
delegate.send(notification);
dependencyLogger.logSuccess(delegate.providerId(), DEPENDENCY_TYPE, "send");
} catch (Exception ex) {
// fail-open: observe (no payload/PII), do not fail the core use case.
dependencyLogger.logFailure(delegate.providerId(), DEPENDENCY_TYPE, "send", ex);
}
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.notification.core;
import dev.caskeleton.application.notification.Channel;
import dev.caskeleton.application.notification.Notification;
/**
* SPI a forking project contributes to bind the notification template to a real provider for a
* given {@link Channel}. Each provider registers a bean of this type. {@link #providerId()} is the
* identifier referenced by {@code app.notification.routes.<channel>.<route>=<id>[,<id>]} values and
* must be unique within a channel.
*/
public interface NotificationProvider {
/** The channel this provider delivers to (e.g. {@link Channel#EMAIL}). */
Channel channel();
/**
* Stable provider id referenced by {@code app.notification.routes.*}; unique within a channel.
*/
String providerId();
/**
* Sends the notification via the provider. May throw on any transport/provider failure; the
* {@link FailOpenNotificationProvider} decorator applies the fail-open policy centrally — this
* method must not swallow its own exceptions.
*
* @param notification the notification to send (contains PII — never log this value)
* @throws Exception on a send failure (caught and handled fail-open by the decorator)
*/
void send(Notification notification) throws Exception;
}
@@ -0,0 +1,142 @@
package dev.caskeleton.adapter.outbound.notification.core;
import dev.caskeleton.application.notification.Channel;
import dev.caskeleton.application.notification.Notification;
import dev.caskeleton.application.notification.NotificationPort;
import dev.caskeleton.shared.error.AdapterDisabledException;
import java.util.Collection;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Routes notification calls to contributed {@link NotificationProvider}s ({@code
* app.notification.routes.<channel>.<route>=<providerId>[,<providerId>]}). A duplicate {@code
* providerId} within a channel, or a route to a providerId with no enabled provider, fails
* construction; {@code notify} on an unbound channel/route throws {@link AdapterDisabledException}
* (Layer 3, never a silent no-op). A route's providerId list is fanned out to all providers, each
* already wrapped in {@link FailOpenNotificationProvider}, so one failure does not block the others
* — hence no try/catch in the fan-out loop. It does not expose the resolved provider, so no adapter
* type escapes via a public return (B7).
*/
public final class RoutingNotifier implements NotificationPort {
private static final String ADAPTER_NAME = "notification";
private final Map<Channel, Map<String, FailOpenNotificationProvider>> registry;
private final Map<Channel, Map<String, List<String>>> routes;
public RoutingNotifier(
Collection<? extends FailOpenNotificationProvider> providers,
Map<Channel, Map<String, List<String>>> routes) {
this.registry = buildRegistry(providers);
validateRoutes(routes, this.registry);
this.routes = immutableRoutesCopy(routes);
}
/**
* Indexes contributed providers by {@code (channel, providerId)}. A duplicate providerId within a
* channel fails at construction (boot).
*/
private static Map<Channel, Map<String, FailOpenNotificationProvider>> buildRegistry(
Collection<? extends FailOpenNotificationProvider> providers) {
Map<Channel, Map<String, FailOpenNotificationProvider>> registry = new EnumMap<>(Channel.class);
for (FailOpenNotificationProvider provider : providers) {
Map<String, FailOpenNotificationProvider> byId =
registry.computeIfAbsent(provider.channel(), ch -> new HashMap<>());
FailOpenNotificationProvider previous = byId.putIfAbsent(provider.providerId(), provider);
if (previous != null) {
throw new IllegalStateException(
"duplicate notification providerId '"
+ provider.providerId()
+ "' for channel "
+ provider.channel()
+ " — every contributed NotificationProvider bean must have a"
+ " unique providerId within its channel");
}
}
Map<Channel, Map<String, FailOpenNotificationProvider>> immutable =
new EnumMap<>(Channel.class);
registry.forEach((channel, byId) -> immutable.put(channel, Map.copyOf(byId)));
return Map.copyOf(immutable);
}
/**
* Fails at construction (boot) if any route references a providerId with no enabled provider on
* that channel. The per-channel registry lookup is hoisted out of the inner loops — it depends
* only on the channel.
*/
private static void validateRoutes(
Map<Channel, Map<String, List<String>>> routes,
Map<Channel, Map<String, FailOpenNotificationProvider>> registry) {
for (Map.Entry<Channel, Map<String, List<String>>> channelEntry : routes.entrySet()) {
Channel channel = channelEntry.getKey();
Map<String, FailOpenNotificationProvider> channelRegistry =
registry.getOrDefault(channel, Map.of());
for (Map.Entry<String, List<String>> routeEntry : channelEntry.getValue().entrySet()) {
String route = routeEntry.getKey();
for (String providerId : routeEntry.getValue()) {
if (!channelRegistry.containsKey(providerId)) {
throw new IllegalStateException(
"app.notification.routes."
+ channel.name().toLowerCase()
+ "."
+ route
+ " references providerId '"
+ providerId
+ "' but no enabled provider contributes that id for channel "
+ channel
+ " — enable the provider or fix the route binding");
}
}
}
}
}
/** Deep, immutable copy of the routes map (channel → route → providerId list). */
private static Map<Channel, Map<String, List<String>>> immutableRoutesCopy(
Map<Channel, Map<String, List<String>>> routes) {
Map<Channel, Map<String, List<String>>> immutable = new EnumMap<>(Channel.class);
routes.forEach(
(channel, routeMap) -> {
Map<String, List<String>> copy = new HashMap<>();
routeMap.forEach((route, ids) -> copy.put(route, List.copyOf(ids)));
immutable.put(channel, Map.copyOf(copy));
});
return Map.copyOf(immutable);
}
@Override
public void notify(Channel channel, String route, Notification notification) {
List<String> providerIds = resolveRoute(channel, route);
Map<String, FailOpenNotificationProvider> channelRegistry =
registry.getOrDefault(channel, Map.of());
// FailOpenNotificationProvider.send declares no throws — no try/catch needed.
// Individual provider failures are observed (logged) inside the decorator
// and never propagated, so one failure does not block remaining fan-out sends.
for (String providerId : providerIds) {
channelRegistry.get(providerId).send(notification);
}
}
private List<String> resolveRoute(Channel channel, String route) {
Map<String, List<String>> channelRoutes = routes.get(channel);
if (channelRoutes == null || !channelRoutes.containsKey(route)) {
throw new AdapterDisabledException(
ADAPTER_NAME,
"no notification route bound for channel="
+ channel
+ " route='"
+ route
+ "' — set app.notification.routes."
+ channel.name().toLowerCase()
+ "."
+ route
+ "=<providerId>[,<providerId>] and enable that provider"
+ " (integration-adapter-templates Layer 3)");
}
return channelRoutes.get(route);
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.notification.email.google;
import dev.caskeleton.application.notification.Notification;
/**
* Integration seam the forking project implements to bind the Google Email template to a real
* client (Gmail API / SMTP). The skeleton carries no mail SDK dependency — it is supplied by the
* project that enables Google Email.
*/
@FunctionalInterface
public interface GoogleEmailClient {
/**
* Sends an email notification. May throw on any transport/provider failure.
*
* @throws Exception on a send failure (caught and handled fail-open by the adapter)
*/
void send(Notification notification) throws Exception;
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.notification.email.google;
import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Layer 1 gating for the Google Email provider template: registers the Google Email {@link
* NotificationProvider} only when {@code app.notification.google-email.enabled=true}. Needs a
* project-supplied {@link GoogleEmailClient} bean (the integration seam). Adding another email
* provider is a new config like this one — this file never changes.
*/
@Configuration
public class GoogleEmailNotificationAdapterConfig {
@Bean
@ConditionalOnProperty(
name = "app.notification.google-email.enabled",
havingValue = "true",
matchIfMissing = false)
public NotificationProvider googleEmailProvider(GoogleEmailClient googleEmailClient) {
return new GoogleEmailProvider(googleEmailClient);
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.notification.email.google;
import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider;
import dev.caskeleton.application.notification.Channel;
import dev.caskeleton.application.notification.Notification;
/**
* Google Email contribution of the {@link NotificationProvider} SPI (channel {@link Channel#EMAIL},
* providerId {@code "google-email"}). Delegates the raw send to the project-supplied {@link
* GoogleEmailClient} seam; the fail-open policy and PII-safe logging are applied centrally by
* {@link dev.caskeleton.adapter.outbound.notification.core.FailOpenNotificationProvider}, not here
* — so this class carries no policy and no mail SDK.
*
* <p>Adding another email provider (e.g. AWS SES) is a new sibling file with a different {@code
* providerId} — this file never changes.
*/
public class GoogleEmailProvider implements NotificationProvider {
private static final String PROVIDER_ID = "google-email";
private final GoogleEmailClient client;
public GoogleEmailProvider(GoogleEmailClient client) {
this.client = client;
}
@Override
public Channel channel() {
return Channel.EMAIL;
}
@Override
public String providerId() {
return PROVIDER_ID;
}
@Override
public void send(Notification notification) throws Exception {
client.send(notification);
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.notification.slack.webhook;
import dev.caskeleton.application.notification.Notification;
/**
* Integration seam the forking project implements to bind the Slack template to a real Slack client
* (incoming-webhook / Web API). The skeleton carries no Slack SDK dependency — it is supplied by
* the project that enables Slack.
*/
@FunctionalInterface
public interface SlackClient {
/**
* Sends a notification to Slack. May throw on any transport/provider failure.
*
* @throws Exception on a send failure (caught and handled fail-open by the adapter)
*/
void send(Notification notification) throws Exception;
}

Some files were not shown because too many files have changed in this diff Show More