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: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;
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.notification.slack.webhook;
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 Slack webhook provider template: registers the Slack webhook {@link
* NotificationProvider} only when {@code app.notification.slack-webhook.enabled=true}. Needs a
* project-supplied {@link SlackClient} bean (the integration seam). Adding another Slack provider
* is a new config like this one — this file never changes.
*/
@Configuration
public class SlackNotificationAdapterConfig {
@Bean
@ConditionalOnProperty(
name = "app.notification.slack-webhook.enabled",
havingValue = "true",
matchIfMissing = false)
public NotificationProvider slackWebhookProvider(SlackClient slackClient) {
return new SlackWebhookProvider(slackClient);
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.notification.slack.webhook;
import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider;
import dev.caskeleton.application.notification.Channel;
import dev.caskeleton.application.notification.Notification;
/**
* Slack incoming-webhook contribution of the {@link NotificationProvider} SPI (channel {@link
* Channel#SLACK}, providerId {@code "slack-webhook"}). Delegates the raw send to the
* project-supplied {@link SlackClient} 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 Slack SDK.
*
* <p>A second Slack provider (e.g. a Web-API client) is added as a sibling {@link
* NotificationProvider} with a different {@code providerId}.
*/
public class SlackWebhookProvider implements NotificationProvider {
private static final String PROVIDER_ID = "slack-webhook";
private final SlackClient client;
public SlackWebhookProvider(SlackClient client) {
this.client = client;
}
@Override
public Channel channel() {
return Channel.SLACK;
}
@Override
public String providerId() {
return PROVIDER_ID;
}
@Override
public void send(Notification notification) throws Exception {
client.send(notification);
}
}
@@ -0,0 +1,174 @@
package dev.caskeleton.adapter.outbound.notification.core;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
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.support.FailOpenDependencyLogger;
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
import dev.caskeleton.application.notification.Channel;
import dev.caskeleton.application.notification.Notification;
import dev.caskeleton.shared.error.AdapterDisabledException;
import java.util.List;
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.LoggerFactory;
import org.slf4j.MDC;
/**
* Adapter-level behavior contract for the router-fail-fast notification design: {@link
* RoutingNotifier} + {@link FailOpenNotificationProvider}.
*
* <p>Contracts verified here (plan §4 / router-fail-fast shape, aligned with the cache template):
*
* <ul>
* <li>Fail-open: a provider send failure is observed (no PII) and never rethrown.
* <li>Fail-fast: an unbound route throws {@link AdapterDisabledException}.
* <li>PII contract: {@link Notification} (recipient/body) NEVER appears in a log line.
* </ul>
*
* <p>The old per-channel {@code Disabled*Notifier} sentinel tests are removed — the
* router-fail-fast design has no per-channel sentinel; Layer 3 is now enforced by {@link
* RoutingNotifier#notify} for unbound routes (mirror of cache D4).
*/
class NotificationAdapterTest {
// PII markers placed in the notification to assert they never reach a log line.
private static final Notification PII_NOTIFICATION =
new Notification("secret@gmail.com", "subject", "secret-body-content");
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.notification");
appender = new ListAppender<>();
appender.start();
logbackLogger.addAppender(appender);
logbackLogger.setLevel(Level.DEBUG);
dependencyLogger = new FailOpenDependencyLogger(logbackLogger);
}
@AfterEach
void tearDown() {
logbackLogger.detachAppender(appender);
MDC.clear();
}
private static NotificationProvider failingProvider(Channel channel, String id) {
return new NotificationProvider() {
@Override
public Channel channel() {
return channel;
}
@Override
public String providerId() {
return id;
}
@Override
public void send(Notification n) throws Exception {
throw new RuntimeException("provider-failure");
}
};
}
// -------------------------------------------------------------------------
// Fail-open (FailOpenNotificationProvider)
// -------------------------------------------------------------------------
@Test
void emailSendFailureIsFailOpenAndNeverRethrows() {
FailOpenNotificationProvider failOpen =
new FailOpenNotificationProvider(
failingProvider(Channel.EMAIL, "google-email"), dependencyLogger);
assertThatCode(() -> failOpen.send(PII_NOTIFICATION)).doesNotThrowAnyException();
}
@Test
void emailSendFailureLogContainsProviderIdAndNoPii() {
MDC.put(OutboundCorrelation.MDC_KEY, "corr-email-1");
FailOpenNotificationProvider failOpen =
new FailOpenNotificationProvider(
failingProvider(Channel.EMAIL, "google-email"), dependencyLogger);
failOpen.send(PII_NOTIFICATION);
String rendered =
appender.list.stream()
.filter(e -> e.getLevel() == Level.WARN)
.findFirst()
.orElseThrow()
.getFormattedMessage();
assertThat(rendered)
.contains("dependency_name=\"google-email\"")
.contains("dependency_type=\"notification\"")
.contains("outcome=\"FAILURE\"")
.contains("correlation_id=\"corr-email-1\"");
assertThat(rendered).doesNotContain("secret@gmail.com").doesNotContain("secret-body-content");
}
@Test
void slackSendFailureIsFailOpenAndNeverRethrows() {
FailOpenNotificationProvider failOpen =
new FailOpenNotificationProvider(
failingProvider(Channel.SLACK, "slack-webhook"), dependencyLogger);
assertThatCode(() -> failOpen.send(PII_NOTIFICATION)).doesNotThrowAnyException();
}
@Test
void slackSendFailureLogContainsProviderIdAndNoPii() {
MDC.put(OutboundCorrelation.MDC_KEY, "corr-slack-1");
FailOpenNotificationProvider failOpen =
new FailOpenNotificationProvider(
failingProvider(Channel.SLACK, "slack-webhook"), dependencyLogger);
failOpen.send(PII_NOTIFICATION);
String rendered =
appender.list.stream()
.filter(e -> e.getLevel() == Level.WARN)
.findFirst()
.orElseThrow()
.getFormattedMessage();
assertThat(rendered)
.contains("dependency_name=\"slack-webhook\"")
.contains("dependency_type=\"notification\"")
.contains("correlation_id=\"corr-slack-1\"");
assertThat(rendered).doesNotContain("secret@gmail.com").doesNotContain("secret-body-content");
}
// -------------------------------------------------------------------------
// Fail-fast (RoutingNotifier — router-fail-fast, no Disabled* sentinel)
// -------------------------------------------------------------------------
@Test
void unboundRouteThrowsAdapterDisabledExceptionForEmail() {
RoutingNotifier notifier = new RoutingNotifier(List.of(), Map.of());
assertThatThrownBy(() -> notifier.notify(Channel.EMAIL, PII_NOTIFICATION))
.isInstanceOf(AdapterDisabledException.class)
.extracting("adapterName")
.isEqualTo("notification");
}
@Test
void unboundRouteThrowsAdapterDisabledExceptionForSlack() {
RoutingNotifier notifier = new RoutingNotifier(List.of(), Map.of());
assertThatThrownBy(() -> notifier.notify(Channel.SLACK, "alerts", PII_NOTIFICATION))
.isInstanceOf(AdapterDisabledException.class)
.extracting("adapterName")
.isEqualTo("notification");
}
}
@@ -0,0 +1,343 @@
package dev.caskeleton.adapter.outbound.notification.core;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
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.support.FailOpenDependencyLogger;
import dev.caskeleton.adapter.outbound.support.OutboundCorrelation;
import dev.caskeleton.application.notification.Channel;
import dev.caskeleton.application.notification.Notification;
import dev.caskeleton.shared.error.AdapterDisabledException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
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;
/**
* TDD tests for the multi-provider notification redesign: {@link RoutingNotifier} + {@link
* FailOpenNotificationProvider}.
*
* <p>Behavior contracts (plan §4):
*
* <ul>
* <li>default route → single provider send once.
* <li>route=N providerIds → N sends (fan-out).
* <li>one provider throws → others still send + failure logged (fail-open, no propagation).
* <li>unbound route → {@link AdapterDisabledException}.
* <li>duplicate providerId in channel → startup {@link IllegalStateException}.
* <li>route references non-existent providerId → startup {@link IllegalStateException}.
* <li>zero providers + zero routes → constructs cleanly.
* <li>PII: {@link Notification} is NEVER passed to the dependency logger.
* </ul>
*
* <p>{@link RoutingNotifier} accepts {@link FailOpenNotificationProvider} (typed registry — no
* try/catch in the fan-out loop). Tests that need routing wrap raw stubs in {@link
* FailOpenNotificationProvider} before passing to the constructor.
*/
class RoutingNotifierTest {
// PII markers placed in the notification to assert they never reach a log line.
private static final Notification PII_NOTIFICATION =
new Notification("secret@gmail.com", "subject", "secret-body-content");
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.notification");
appender = new ListAppender<>();
appender.start();
logbackLogger.addAppender(appender);
logbackLogger.setLevel(Level.DEBUG);
dependencyLogger = new FailOpenDependencyLogger(logbackLogger);
}
@AfterEach
void tearDown() {
logbackLogger.detachAppender(appender);
MDC.clear();
}
// -------------------------------------------------------------------------
// Stub helpers
// -------------------------------------------------------------------------
private static NotificationProvider stubProvider(Channel channel, String providerId) {
return new NotificationProvider() {
@Override
public Channel channel() {
return channel;
}
@Override
public String providerId() {
return providerId;
}
@Override
public void send(Notification n) {
/* no-op success */
}
};
}
private static NotificationProvider countingProvider(
Channel channel, String providerId, AtomicInteger counter) {
return new NotificationProvider() {
@Override
public Channel channel() {
return channel;
}
@Override
public String providerId() {
return providerId;
}
@Override
public void send(Notification n) {
counter.incrementAndGet();
}
};
}
private static NotificationProvider failingProvider(Channel channel, String providerId) {
return new NotificationProvider() {
@Override
public Channel channel() {
return channel;
}
@Override
public String providerId() {
return providerId;
}
@Override
public void send(Notification n) throws Exception {
throw new RuntimeException("provider-failure");
}
};
}
/** Wraps a raw provider in FailOpenNotificationProvider (mirrors NotificationConfig). */
private FailOpenNotificationProvider failOpen(NotificationProvider provider) {
return new FailOpenNotificationProvider(provider, dependencyLogger);
}
// -------------------------------------------------------------------------
// FailOpenNotificationProvider tests
// -------------------------------------------------------------------------
@Test
void failOpenProviderSwallowsExceptionAndNeverRethrows() {
FailOpenNotificationProvider failOpenProv =
new FailOpenNotificationProvider(
failingProvider(Channel.EMAIL, "google-email"), dependencyLogger);
assertThatCode(() -> failOpenProv.send(PII_NOTIFICATION)).doesNotThrowAnyException();
}
@Test
void failOpenProviderLogsFailureWithProviderIdAndNoPii() {
MDC.put(OutboundCorrelation.MDC_KEY, "corr-1");
FailOpenNotificationProvider failOpenProv =
new FailOpenNotificationProvider(
failingProvider(Channel.EMAIL, "google-email"), dependencyLogger);
failOpenProv.send(PII_NOTIFICATION);
String rendered =
appender.list.stream()
.filter(e -> e.getLevel() == Level.WARN)
.findFirst()
.orElseThrow()
.getFormattedMessage();
assertThat(rendered)
.contains("dependency_name=\"google-email\"")
.contains("dependency_type=\"notification\"")
.contains("correlation_id=\"corr-1\"");
// PII contract: notification body/recipient must never appear in logs.
assertThat(rendered).doesNotContain("secret@gmail.com").doesNotContain("secret-body-content");
}
@Test
void failOpenProviderDelegatesChannelAndProviderId() {
FailOpenNotificationProvider failOpenProv =
new FailOpenNotificationProvider(
stubProvider(Channel.SLACK, "slack-webhook"), dependencyLogger);
assertThat(failOpenProv.channel()).isEqualTo(Channel.SLACK);
assertThat(failOpenProv.providerId()).isEqualTo("slack-webhook");
}
@Test
void failOpenProviderLogsSuccessOnSendWithNoPii() {
FailOpenNotificationProvider failOpenProv =
new FailOpenNotificationProvider(
stubProvider(Channel.EMAIL, "google-email"), dependencyLogger);
failOpenProv.send(PII_NOTIFICATION);
boolean anyPii =
appender.list.stream()
.map(ILoggingEvent::getFormattedMessage)
.anyMatch(m -> m.contains("secret@gmail.com") || m.contains("secret-body-content"));
assertThat(anyPii).isFalse();
}
// -------------------------------------------------------------------------
// RoutingNotifier tests
// -------------------------------------------------------------------------
@Test
void defaultRouteSendsToSingleProviderOnce() {
AtomicInteger sendCount = new AtomicInteger();
List<FailOpenNotificationProvider> providers =
List.of(failOpen(countingProvider(Channel.EMAIL, "google-email", sendCount)));
Map<Channel, Map<String, List<String>>> routes =
Map.of(Channel.EMAIL, Map.of("default", List.of("google-email")));
RoutingNotifier notifier = new RoutingNotifier(providers, routes);
notifier.notify(Channel.EMAIL, PII_NOTIFICATION);
assertThat(sendCount.get()).isEqualTo(1);
}
@Test
void fanOutRouteSendsToAllProviders() {
AtomicInteger sendA = new AtomicInteger();
AtomicInteger sendB = new AtomicInteger();
List<FailOpenNotificationProvider> providers =
List.of(
failOpen(countingProvider(Channel.EMAIL, "provider-a", sendA)),
failOpen(countingProvider(Channel.EMAIL, "provider-b", sendB)));
Map<Channel, Map<String, List<String>>> routes =
Map.of(Channel.EMAIL, Map.of("alerts", List.of("provider-a", "provider-b")));
RoutingNotifier notifier = new RoutingNotifier(providers, routes);
notifier.notify(Channel.EMAIL, "alerts", PII_NOTIFICATION);
assertThat(sendA.get()).isEqualTo(1);
assertThat(sendB.get()).isEqualTo(1);
}
@Test
void failingProviderDoesNotBlockRemainingFanOutSends() {
AtomicInteger sendB = new AtomicInteger();
// provider-a fails (fail-open wraps it), provider-b must still get called
List<FailOpenNotificationProvider> wrapped =
List.of(
new FailOpenNotificationProvider(
failingProvider(Channel.EMAIL, "provider-a"), dependencyLogger),
new FailOpenNotificationProvider(
countingProvider(Channel.EMAIL, "provider-b", sendB), dependencyLogger));
Map<Channel, Map<String, List<String>>> routes =
Map.of(Channel.EMAIL, Map.of("alerts", List.of("provider-a", "provider-b")));
RoutingNotifier notifier = new RoutingNotifier(wrapped, routes);
assertThatCode(() -> notifier.notify(Channel.EMAIL, "alerts", PII_NOTIFICATION))
.doesNotThrowAnyException();
assertThat(sendB.get()).isEqualTo(1);
}
@Test
void unboundRouteThrowsAdapterDisabledException() {
RoutingNotifier notifier = new RoutingNotifier(List.of(), Map.of());
assertThatThrownBy(() -> notifier.notify(Channel.EMAIL, PII_NOTIFICATION))
.isInstanceOf(AdapterDisabledException.class)
.extracting("adapterName")
.isEqualTo("notification");
}
@Test
void duplicateProviderIdWithinChannelFailsConstruction() {
List<FailOpenNotificationProvider> providers =
List.of(
failOpen(stubProvider(Channel.EMAIL, "google-email")),
failOpen(stubProvider(Channel.EMAIL, "google-email"))); // duplicate
assertThatThrownBy(() -> new RoutingNotifier(providers, Map.of()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("duplicate")
.hasMessageContaining("google-email");
}
@Test
void routeReferencingUnknownProviderFailsConstruction() {
List<FailOpenNotificationProvider> providers = List.of(); // no providers
Map<Channel, Map<String, List<String>>> routes =
Map.of(Channel.EMAIL, Map.of("default", List.of("google-email")));
assertThatThrownBy(() -> new RoutingNotifier(providers, routes))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("google-email");
}
@Test
void zeroProvidersAndRoutesConstructsCleanly() {
// L262: optional module must not block startup when unconfigured.
assertThatCode(() -> new RoutingNotifier(List.of(), Map.of())).doesNotThrowAnyException();
}
@Test
void piiNeverReachesLogOnSendFailure() {
List<FailOpenNotificationProvider> wrapped =
List.of(
new FailOpenNotificationProvider(
failingProvider(Channel.SLACK, "slack-webhook"), dependencyLogger));
Map<Channel, Map<String, List<String>>> routes =
Map.of(Channel.SLACK, Map.of("default", List.of("slack-webhook")));
RoutingNotifier notifier = new RoutingNotifier(wrapped, routes);
notifier.notify(Channel.SLACK, PII_NOTIFICATION);
boolean anyPii =
appender.list.stream()
.map(ILoggingEvent::getFormattedMessage)
.anyMatch(m -> m.contains("secret@gmail.com") || m.contains("secret-body-content"));
assertThat(anyPii).isFalse();
}
@Test
void notifyWithExplicitRouteUsesNamedRoute() {
List<String> sent = new ArrayList<>();
NotificationProvider provider =
new NotificationProvider() {
@Override
public Channel channel() {
return Channel.SLACK;
}
@Override
public String providerId() {
return "slack-webhook";
}
@Override
public void send(Notification n) {
sent.add(providerId());
}
};
Map<Channel, Map<String, List<String>>> routes =
Map.of(Channel.SLACK, Map.of("team-alerts", List.of("slack-webhook")));
RoutingNotifier notifier = new RoutingNotifier(List.of(failOpen(provider)), routes);
notifier.notify(Channel.SLACK, "team-alerts", PII_NOTIFICATION);
assertThat(sent).containsExactly("slack-webhook");
}
}