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

This commit is contained in:
donghyeon-ka
2026-07-28 14:26:54 +09:00
parent 7363b2aa1e
commit b3add0162d
257 changed files with 30430 additions and 1357 deletions
+9 -3
View File
@@ -4,9 +4,9 @@
- Module ID: `adapter-outbound-messaging`
- Gradle path: `:adapter:outbound:messaging`
- Focused test: `./gradlew :adapter:outbound:messaging:test --console=plain`
- Focused test (derived from Gradle path): `./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`.
- Registry SSOT: `src/config/architecture/modules.json`.
Package root: `dev.caskeleton.adapter.outbound.messaging`.
@@ -15,13 +15,19 @@ Package root: `dev.caskeleton.adapter.outbound.messaging`.
- 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.
- Own structured rendering of `OutboxRelayFailureReport` through the single unconditional
`Slf4jOutboxRelayFailureReportAdapter` bean.
- Reuse `adapter:outbound:support` for shared technical concerns.
## Boundaries
- Allowed dependency edges come only from `.harness/project/modules.yaml`.
- Allowed dependency edges come only from the module's
`src/config/architecture/modules.json` entry.
- No inbound DTO/controller, persistence repository/entity, bootstrap, or sample dependency.
- Do not hide use-case sequencing or business routing policy in broker adapters.
- `OutboxMessagePublishAdapter` is mapping/send-only and emits no dependency log. The confirmed
FAILED/DEAD transition owns the one canonical ERROR; only the general fail-open publisher keeps
`FailOpenDependencyLogger`.
## Tests
+22 -6
View File
@@ -2,12 +2,12 @@
메시징(broker publish + outbox) 아웃바운드 어댑터 모듈. 패키지 루트:
`dev.caskeleton.adapter.outbound.messaging`. `:adapter:outbound:support` 에 의존해 공유
correlation / fail-open 의존성 로깅을 재사용한다.
correlation / fail-open 의존성 로깅을 일반 publisher에서 재사용한다. outbox relay 실패는 이
모듈이 별도의 typed report adapter로 구조화한다.
허용/금지 의존 정책은 `src/build.gradle`
`allowedProjectDependencies['adapter:outbound:messaging']` 항목이 SSOT 다(이 모듈은 아직 별도
CLAUDE.md 를 두지 않았다). 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용
기록이다.
허용/금지 의존 정책은 `src/config/architecture/modules.json`과 이 모듈
[CLAUDE.md](CLAUDE.md)가 소유한다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔
참조용 기록이다.
## 모듈 개요
@@ -41,4 +41,20 @@ application-core 포트(`MessagePublisher` / `OutboxMessagePublishPort`) 뒤에
`MessagePublisher` 는 fail-open 어댑터-로컬 발행기로, 발행 실패를 correlationId 와 함께
로깅하고 삼켜(→ `:adapter:outbound:support``FailOpenDependencyLogger`) outbox/retry 로
위임하므로 core 5xx 가 되지 않는다. 내구성 있는 전달이 필요하면 `OutboxMessagePublishPort`
쓴다. 반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7).
쓴다. `OutboxMessagePublishAdapter`는 envelope mapping + broker send만 수행하며 runtime 예외를
그대로 전파하고 checked 예외는 cause를 보존해 감싼다. 성공 DEBUG나 실패 WARN을 남기지 않는다.
반환 타입을 void 로 둬 broker SDK 타입이 어댑터 밖으로 새지 않는다(B7).
## OutboxRelayFailureReport 구조화 ERROR
`MessagingConfig`는 broker 활성 여부와 무관하게 정확히 하나의
`Slf4jOutboxRelayFailureReportAdapter`를 등록한다. broker 설정이 blank면 안전한
`dependency_name=disabled`를 쓴다. 이 adapter는 confirmed FAILED/DEAD report 하나를 SLF4J 2 fluent
ERROR 하나로 렌더링한다.
공통 field는 `error.code`, `error.category`, `dependency_name`,
`dependency_type=messaging`, `outcome`, `event_id`, `event_type`, `aggregate_id`,
`correlation_id`, `attempt_count`, `runbook_link`다. retry report만 `next_attempt_at`을 추가한다.
payload/idempotency key/envelope/exception-derived field는 받거나 렌더링하지 않고 cause만 throwable로
붙인다. logging 내부 `RuntimeException`은 adapter와 use case 양쪽에서 방어하므로 persisted
FAILED/DEAD outcome을 바꾸지 않는다.
+1 -5
View File
@@ -1,14 +1,10 @@
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'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
+11 -15
View File
@@ -2,8 +2,8 @@
# 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
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=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
@@ -41,11 +41,10 @@ commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testComp
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.annotation:jakarta.annotation-api:3.0.0=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
@@ -59,13 +58,11 @@ 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-api:2.25.2=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.logging.log4j:log4j-to-slf4j:2.25.2=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
@@ -90,7 +87,7 @@ 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-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
@@ -111,12 +108,11 @@ 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:jul-to-slf4j:2.0.17=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-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
@@ -125,13 +121,13 @@ org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,t
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-logging:4.0.0=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-starter:4.0.0=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
@@ -149,7 +145,7 @@ 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
org.yaml:snakeyaml:2.5=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
@@ -6,8 +6,10 @@ 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.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter;
import dev.caskeleton.adapter.outbound.support.FailOpenDependencyLogger;
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@@ -37,13 +39,18 @@ public class MessagingConfig {
@Bean
public OutboxMessagePublishPort outboxMessagePublishPort(
ObjectProvider<MessageBroker> brokerProvider,
MessagingSettings settings,
FailOpenDependencyLogger dependencyLogger) {
ObjectProvider<MessageBroker> brokerProvider, MessagingSettings settings) {
MessageBroker active = resolveBroker(brokerProvider, settings);
return (active == null)
? new DisabledOutboxMessagePublisher()
: new OutboxMessagePublishAdapter(active, dependencyLogger);
: new OutboxMessagePublishAdapter(active);
}
/** Always available, including when broker publication is disabled. */
@Bean
public OutboxRelayFailureReportPort outboxRelayFailureReportPort(MessagingSettings settings) {
String dependencyName = settings.broker().isBlank() ? "disabled" : settings.broker();
return new Slf4jOutboxRelayFailureReportAdapter(dependencyName);
}
private static MessageBroker resolveBroker(
@@ -2,30 +2,26 @@ 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;
import java.util.Objects;
/**
* 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}).
* OutboxEvent} to an {@link OutboundMessage} and delegates to the active {@link MessageBroker}.
* Runtime failures propagate unchanged and checked failures are wrapped with their cause so the
* relay can drive the FAILED/DEAD transition. This adapter emits no dependency log; the confirmed
* transition has one canonical ERROR reporter.
*
* <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;
public OutboxMessagePublishAdapter(MessageBroker broker) {
this.broker = Objects.requireNonNull(broker, "broker must not be null");
}
@Override
@@ -34,14 +30,10 @@ public class OutboxMessagePublishAdapter implements OutboxMessagePublishPort {
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,62 @@
package dev.caskeleton.adapter.outbound.messaging.outbox;
import dev.caskeleton.application.outbox.OutboxRelayFailureReport;
import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;
import dev.caskeleton.shared.error.OperationalError;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.spi.LoggingEventBuilder;
/** Renders confirmed outbox relay failure transitions as one safe structured SLF4J ERROR. */
public final class Slf4jOutboxRelayFailureReportAdapter implements OutboxRelayFailureReportPort {
private static final String DEPENDENCY_TYPE = "messaging";
private static final String LOG_MESSAGE = "confirmed outbox relay failure";
private final String dependencyName;
private final Logger logger;
public Slf4jOutboxRelayFailureReportAdapter(String dependencyName) {
this(dependencyName, LoggerFactory.getLogger(Slf4jOutboxRelayFailureReportAdapter.class));
}
Slf4jOutboxRelayFailureReportAdapter(String dependencyName, Logger logger) {
if (dependencyName == null || dependencyName.isBlank()) {
throw new IllegalArgumentException("dependencyName must not be blank");
}
this.dependencyName = dependencyName;
this.logger = Objects.requireNonNull(logger, "logger must not be null");
}
@Override
public void report(OutboxRelayFailureReport report) {
try {
Objects.requireNonNull(report, "report must not be null");
boolean retryable = report.code() == OperationalError.OUTBOX_PUBLISH_FAILED;
LoggingEventBuilder event =
logger
.atError()
.setCause(report.cause())
.addKeyValue("error.code", report.code().code())
.addKeyValue("error.category", report.code().category().name())
.addKeyValue("dependency_name", dependencyName)
.addKeyValue("dependency_type", DEPENDENCY_TYPE)
.addKeyValue("outcome", retryable ? "FAILED" : "DEAD")
.addKeyValue("event_id", report.eventId())
.addKeyValue("event_type", report.eventType())
.addKeyValue("aggregate_id", report.aggregateId())
.addKeyValue("correlation_id", report.correlationId())
.addKeyValue("attempt_count", report.attemptCount())
.addKeyValue(
"runbook_link",
retryable ? "runbook://outbox/publish-failed" : "runbook://outbox/dead-letter");
if (retryable) {
event = event.addKeyValue("next_attempt_at", report.nextAttemptAt().toString());
}
event.log(LOG_MESSAGE);
} catch (RuntimeException ignored) {
// Diagnostics are non-authoritative and must never escape into the relay.
}
}
}
@@ -9,19 +9,16 @@ 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.Arrays;
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:
@@ -29,33 +26,13 @@ import org.slf4j.MDC;
* <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
* <li>Fail-closed — broker failure is propagated without duplicate dependency logs; 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<>();
@@ -102,8 +79,7 @@ class OutboxMessagePublishAdapterTest {
@Test
void publishSendsMessageWithCorrectTopicKeyAndEnvelopePayload() {
FakeBroker broker = new FakeBroker();
OutboxMessagePublishAdapter adapter =
new OutboxMessagePublishAdapter(broker, dependencyLogger);
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
adapter.publish(sampleEvent());
@@ -116,8 +92,7 @@ class OutboxMessagePublishAdapterTest {
@Test
void publishEnvelopeContainsAllFields() {
FakeBroker broker = new FakeBroker();
OutboxMessagePublishAdapter adapter =
new OutboxMessagePublishAdapter(broker, dependencyLogger);
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
adapter.publish(sampleEvent());
@@ -134,8 +109,7 @@ class OutboxMessagePublishAdapterTest {
@Test
void publishEnvelopeFieldValuesMatchEvent() {
FakeBroker broker = new FakeBroker();
OutboxMessagePublishAdapter adapter =
new OutboxMessagePublishAdapter(broker, dependencyLogger);
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
adapter.publish(sampleEvent());
@@ -156,68 +130,53 @@ class OutboxMessagePublishAdapterTest {
@Test
void publishFailurePropagatesAsRuntimeException() {
FakeBroker broker = new FakeBroker(new IllegalStateException("broker down"));
OutboxMessagePublishAdapter adapter =
new OutboxMessagePublishAdapter(broker, dependencyLogger);
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
assertThatThrownBy(() -> adapter.publish(sampleEvent())).isInstanceOf(RuntimeException.class);
assertThatThrownBy(() -> adapter.publish(sampleEvent()))
.isInstanceOf(IllegalStateException.class)
.hasMessage("broker down");
}
@Test
void publishFailureIsLoggedBeforePropagation() {
MDC.put(OutboundCorrelation.MDC_KEY, "corr-fail-1");
void publishFailureEmitsNoDuplicateDependencyLog() {
ch.qos.logback.classic.Logger dependencyLogger =
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger(FailOpenDependencyLogger.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
dependencyLogger.addAppender(appender);
dependencyLogger.setLevel(Level.DEBUG);
FakeBroker broker = new FakeBroker(new IllegalStateException("broker unavailable"));
OutboxMessagePublishAdapter adapter =
new OutboxMessagePublishAdapter(broker, dependencyLogger);
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
try {
adapter.publish(sampleEvent());
} catch (RuntimeException ignored) {
// expected
} finally {
dependencyLogger.detachAppender(appender);
appender.stop();
}
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\"");
assertThat(appender.list).isEmpty();
}
@Test
void publishWrapsCheckedExceptionInRuntimeException() {
FakeBroker broker = new FakeBroker(new IOException("network error"));
OutboxMessagePublishAdapter adapter =
new OutboxMessagePublishAdapter(broker, dependencyLogger);
OutboxMessagePublishAdapter adapter = new OutboxMessagePublishAdapter(broker);
assertThatThrownBy(() -> adapter.publish(sampleEvent()))
.isInstanceOf(RuntimeException.class)
.hasCauseInstanceOf(IOException.class);
}
@Test
void adapterStateContainsOnlyTheBroker() {
assertThat(
Arrays.stream(OutboxMessagePublishAdapter.class.getDeclaredFields())
.map(field -> field.getName()))
.containsExactly("broker");
}
}
@Nested
@@ -237,7 +196,7 @@ class OutboxMessagePublishAdapterTest {
OutboxEventStatus.IN_FLIGHT,
1);
FakeBroker broker = new FakeBroker();
new OutboxMessagePublishAdapter(broker, dependencyLogger).publish(eventWithQuote);
new OutboxMessagePublishAdapter(broker).publish(eventWithQuote);
assertThat(broker.sent.get(0).payload()).contains("Has\\\"Quote");
}
@@ -256,7 +215,7 @@ class OutboxMessagePublishAdapterTest {
OutboxEventStatus.IN_FLIGHT,
1);
FakeBroker broker = new FakeBroker();
new OutboxMessagePublishAdapter(broker, dependencyLogger).publish(event);
new OutboxMessagePublishAdapter(broker).publish(event);
String payload = broker.sent.get(0).payload();
assertThat(payload).contains("{\"nested\":{\"a\":1}}");
@@ -0,0 +1,132 @@
package dev.caskeleton.adapter.outbound.messaging.outbox;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.spi.ThrowableProxy;
import ch.qos.logback.core.read.ListAppender;
import dev.caskeleton.application.outbox.OutboxRelayFailureReport;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.event.KeyValuePair;
class Slf4jOutboxRelayFailureReportAdapterTest {
private ch.qos.logback.classic.Logger logger;
private ListAppender<ILoggingEvent> appender;
private Slf4jOutboxRelayFailureReportAdapter adapter;
@BeforeEach
void setUp() {
logger =
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbox.relay.failure.report");
logger.setAdditive(false);
logger.setLevel(Level.ERROR);
appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
adapter = new Slf4jOutboxRelayFailureReportAdapter("kafka", logger);
}
@AfterEach
void tearDown() {
logger.detachAppender(appender);
appender.stop();
}
@Test
void retryableFailureEmitsOneSafeStructuredErrorWithCause() {
RuntimeException cause = new RuntimeException("unsafe-exception-derived-value");
Instant nextAttemptAt = Instant.parse("2026-07-25T01:02:03Z");
adapter.report(
OutboxRelayFailureReport.retryableFailure(
"evt-1", "WorkLogReserved", "agg-1", "corr-1", 2, nextAttemptAt, cause));
assertThat(appender.list).hasSize(1);
ILoggingEvent event = appender.list.getFirst();
assertThat(event.getLevel()).isEqualTo(Level.ERROR);
assertThat(event.getFormattedMessage()).isEqualTo("confirmed outbox relay failure");
assertThat(keyValues(event))
.containsExactlyInAnyOrderEntriesOf(
Map.ofEntries(
Map.entry("error.code", "OUTBOX_PUBLISH_FAILED"),
Map.entry("error.category", "TRANSIENT_DEPENDENCY"),
Map.entry("dependency_name", "kafka"),
Map.entry("dependency_type", "messaging"),
Map.entry("outcome", "FAILED"),
Map.entry("event_id", "evt-1"),
Map.entry("event_type", "WorkLogReserved"),
Map.entry("aggregate_id", "agg-1"),
Map.entry("correlation_id", "corr-1"),
Map.entry("attempt_count", 2),
Map.entry("runbook_link", "runbook://outbox/publish-failed"),
Map.entry("next_attempt_at", "2026-07-25T01:02:03Z")));
assertThat(((ThrowableProxy) event.getThrowableProxy()).getThrowable()).isSameAs(cause);
assertThat(event.getFormattedMessage()).doesNotContain("unsafe-exception-derived-value");
assertThat(keyValues(event).toString())
.doesNotContain("payload-secret", "idempotency-secret", "unsafe-exception-derived-value");
}
@Test
void deadLetterEmitsTerminalMappingWithoutNextAttemptAt() {
RuntimeException cause = new RuntimeException("broker down");
adapter.report(
OutboxRelayFailureReport.deadLetter(
"evt-2", "WorkLogReserved", "agg-2", "corr-2", 3, cause));
assertThat(appender.list).hasSize(1);
ILoggingEvent event = appender.list.getFirst();
assertThat(keyValues(event))
.containsEntry("error.code", "OUTBOX_DEAD_LETTER")
.containsEntry("error.category", "INTERNAL")
.containsEntry("outcome", "DEAD")
.containsEntry("runbook_link", "runbook://outbox/dead-letter")
.doesNotContainKey("next_attempt_at");
assertThat(((ThrowableProxy) event.getThrowableProxy()).getThrowable()).isSameAs(cause);
}
@Test
void loggerRuntimeExceptionIsContained() {
Logger throwingLogger = mock(Logger.class);
when(throwingLogger.atError()).thenThrow(new RuntimeException("logger failed"));
Slf4jOutboxRelayFailureReportAdapter throwingAdapter =
new Slf4jOutboxRelayFailureReportAdapter("kafka", throwingLogger);
assertThatCode(
() ->
throwingAdapter.report(
OutboxRelayFailureReport.deadLetter(
"evt-3",
"WorkLogReserved",
"agg-3",
"corr-3",
3,
new RuntimeException("broker down"))))
.doesNotThrowAnyException();
}
@Test
void nullReportIsContainedByTheNoThrowAdapterContract() {
assertThatCode(() -> adapter.report(null)).doesNotThrowAnyException();
}
private static Map<String, Object> keyValues(ILoggingEvent event) {
Map<String, Object> values = new LinkedHashMap<>();
for (KeyValuePair pair : event.getKeyValuePairs()) {
values.put(pair.key, pair.value);
}
return values;
}
}