diff --git a/README.md b/README.md index a0054b0..0e505e0 100644 --- a/README.md +++ b/README.md @@ -1 +1,67 @@ -# nginx-remote-app-poc \ No newline at end of file +# Email Notification Orchestrator Pattern (Spring Boot) + +A minimal, boring pattern for publishing email notification requests to another delivery service. + +## Why this lowers cognitive load + +- One entry point (`EmailNotificationOrchestrator`) for all notification publishing. +- One typed config map (`NotificationProperties`) for all notification definitions. +- One shared render path (`TemplateRenderer`). +- One shared publish path (`EmailPublicationPublisher`). +- Per-notification code is only a tiny `NotificationModelBuilder` that maps payload -> template model. + +## Package structure + +```text +com.example.notifications +├── builder +│ ├── HoldListCreatedModelBuilder +│ ├── HoldListRemovedModelBuilder +│ └── NotificationModelBuilder +├── config +│ ├── NotificationCatalog +│ ├── NotificationDefinition +│ ├── NotificationProperties +│ └── NotificationType +├── domain +│ └── HoldListDomainService +├── model +│ ├── HoldListCreatedPayload +│ ├── HoldListRemovedPayload +│ └── NotificationPayload +├── orchestration +│ ├── EmailNotificationOrchestrator +│ └── NotificationModelBuilderRegistry +├── publish +│ ├── EmailPublicationPublisher +│ ├── EmailPublicationRequest +│ └── LoggingEmailPublicationPublisher +└── render + ├── TemplateRenderer + └── ThymeleafTemplateRenderer +``` + +## Runtime flow + +1. Domain service calls orchestrator. +2. Orchestrator loads notification definition from catalog. +3. Orchestrator resolves model builder from registry. +4. Builder maps payload to model. +5. Shared renderer renders Thymeleaf template. +6. Orchestrator builds `EmailPublicationRequest`. +7. Shared publisher publishes request. + +## How to add a notification + +1. Add enum value to `NotificationType`. +2. Add YAML config entry under `notifications.definitions`. +3. Add Thymeleaf template file under `resources/templates`. +4. Add payload record (if needed). +5. Add one `NotificationModelBuilder` for model mapping. +6. Call orchestrator from domain code. + +No orchestration flow changes required. + +## Tradeoff vs ad-hoc per-notification services + +This pattern avoids duplicated render/publish code and avoids giant switch statements in service methods. It also avoids creating one full service pipeline per notification. You get one stable flow with small, obvious extension points. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..4c16c90 --- /dev/null +++ b/pom.xml @@ -0,0 +1,52 @@ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + com.example + email-notifications + 0.0.1-SNAPSHOT + email-notifications + Email notification orchestrator pattern sample + + + 21 + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/src/main/java/com/example/notifications/EmailNotificationsApplication.java b/src/main/java/com/example/notifications/EmailNotificationsApplication.java new file mode 100644 index 0000000..17ca3b9 --- /dev/null +++ b/src/main/java/com/example/notifications/EmailNotificationsApplication.java @@ -0,0 +1,14 @@ +package com.example.notifications; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class EmailNotificationsApplication { + + public static void main(String[] args) { + SpringApplication.run(EmailNotificationsApplication.class, args); + } +} diff --git a/src/main/java/com/example/notifications/builder/HoldListCreatedModelBuilder.java b/src/main/java/com/example/notifications/builder/HoldListCreatedModelBuilder.java new file mode 100644 index 0000000..2570335 --- /dev/null +++ b/src/main/java/com/example/notifications/builder/HoldListCreatedModelBuilder.java @@ -0,0 +1,30 @@ +package com.example.notifications.builder; + +import com.example.notifications.config.NotificationType; +import com.example.notifications.model.HoldListCreatedPayload; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class HoldListCreatedModelBuilder implements NotificationModelBuilder { + + @Override + public NotificationType supportsType() { + return NotificationType.HOLD_LIST_CREATED; + } + + @Override + public Class payloadType() { + return HoldListCreatedPayload.class; + } + + @Override + public Map buildModel(HoldListCreatedPayload payload) { + return Map.of( + "holdListName", payload.holdListName(), + "createdBy", payload.createdBy(), + "impactedAccounts", payload.impactedAccounts() + ); + } +} diff --git a/src/main/java/com/example/notifications/builder/HoldListRemovedModelBuilder.java b/src/main/java/com/example/notifications/builder/HoldListRemovedModelBuilder.java new file mode 100644 index 0000000..73562dc --- /dev/null +++ b/src/main/java/com/example/notifications/builder/HoldListRemovedModelBuilder.java @@ -0,0 +1,30 @@ +package com.example.notifications.builder; + +import com.example.notifications.config.NotificationType; +import com.example.notifications.model.HoldListRemovedPayload; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class HoldListRemovedModelBuilder implements NotificationModelBuilder { + + @Override + public NotificationType supportsType() { + return NotificationType.HOLD_LIST_REMOVED; + } + + @Override + public Class payloadType() { + return HoldListRemovedPayload.class; + } + + @Override + public Map buildModel(HoldListRemovedPayload payload) { + return Map.of( + "holdListName", payload.holdListName(), + "removedBy", payload.removedBy(), + "removalReason", payload.removalReason() + ); + } +} diff --git a/src/main/java/com/example/notifications/builder/NotificationModelBuilder.java b/src/main/java/com/example/notifications/builder/NotificationModelBuilder.java new file mode 100644 index 0000000..a51f854 --- /dev/null +++ b/src/main/java/com/example/notifications/builder/NotificationModelBuilder.java @@ -0,0 +1,15 @@ +package com.example.notifications.builder; + +import com.example.notifications.config.NotificationType; +import com.example.notifications.model.NotificationPayload; + +import java.util.Map; + +public interface NotificationModelBuilder { + + NotificationType supportsType(); + + Class payloadType(); + + Map buildModel(T payload); +} diff --git a/src/main/java/com/example/notifications/config/NotificationCatalog.java b/src/main/java/com/example/notifications/config/NotificationCatalog.java new file mode 100644 index 0000000..bc9d6d1 --- /dev/null +++ b/src/main/java/com/example/notifications/config/NotificationCatalog.java @@ -0,0 +1,21 @@ +package com.example.notifications.config; + +import org.springframework.stereotype.Component; + +@Component +public class NotificationCatalog { + + private final NotificationProperties properties; + + public NotificationCatalog(NotificationProperties properties) { + this.properties = properties; + } + + public NotificationDefinition getRequired(NotificationType type) { + NotificationDefinition definition = properties.getDefinitions().get(type); + if (definition == null) { + throw new IllegalArgumentException("Missing notification configuration for type: " + type); + } + return definition; + } +} diff --git a/src/main/java/com/example/notifications/config/NotificationDefinition.java b/src/main/java/com/example/notifications/config/NotificationDefinition.java new file mode 100644 index 0000000..de6a1b8 --- /dev/null +++ b/src/main/java/com/example/notifications/config/NotificationDefinition.java @@ -0,0 +1,19 @@ +package com.example.notifications.config; + +import java.util.List; + +public record NotificationDefinition( + List recipients, + String templatePath, + String subject, + Boolean enabled +) { + + public NotificationDefinition { + recipients = recipients == null ? List.of() : List.copyOf(recipients); + } + + public boolean isEnabled() { + return enabled == null || enabled; + } +} diff --git a/src/main/java/com/example/notifications/config/NotificationProperties.java b/src/main/java/com/example/notifications/config/NotificationProperties.java new file mode 100644 index 0000000..6212b0f --- /dev/null +++ b/src/main/java/com/example/notifications/config/NotificationProperties.java @@ -0,0 +1,20 @@ +package com.example.notifications.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.EnumMap; +import java.util.Map; + +@ConfigurationProperties(prefix = "notifications") +public class NotificationProperties { + + private Map definitions = new EnumMap<>(NotificationType.class); + + public Map getDefinitions() { + return definitions; + } + + public void setDefinitions(Map definitions) { + this.definitions = definitions; + } +} diff --git a/src/main/java/com/example/notifications/config/NotificationType.java b/src/main/java/com/example/notifications/config/NotificationType.java new file mode 100644 index 0000000..2c71b92 --- /dev/null +++ b/src/main/java/com/example/notifications/config/NotificationType.java @@ -0,0 +1,6 @@ +package com.example.notifications.config; + +public enum NotificationType { + HOLD_LIST_CREATED, + HOLD_LIST_REMOVED +} diff --git a/src/main/java/com/example/notifications/domain/HoldListDomainService.java b/src/main/java/com/example/notifications/domain/HoldListDomainService.java new file mode 100644 index 0000000..11243ca --- /dev/null +++ b/src/main/java/com/example/notifications/domain/HoldListDomainService.java @@ -0,0 +1,29 @@ +package com.example.notifications.domain; + +import com.example.notifications.config.NotificationType; +import com.example.notifications.model.HoldListCreatedPayload; +import com.example.notifications.model.HoldListRemovedPayload; +import com.example.notifications.orchestration.EmailNotificationOrchestrator; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class HoldListDomainService { + + private final EmailNotificationOrchestrator notificationOrchestrator; + + public HoldListDomainService(EmailNotificationOrchestrator notificationOrchestrator) { + this.notificationOrchestrator = notificationOrchestrator; + } + + public void createHoldList(String name, String actor, List impactedAccounts) { + HoldListCreatedPayload payload = new HoldListCreatedPayload(name, actor, impactedAccounts); + notificationOrchestrator.publish(NotificationType.HOLD_LIST_CREATED, payload, HoldListCreatedPayload.class); + } + + public void removeHoldList(String name, String actor, String reason) { + HoldListRemovedPayload payload = new HoldListRemovedPayload(name, actor, reason); + notificationOrchestrator.publish(NotificationType.HOLD_LIST_REMOVED, payload, HoldListRemovedPayload.class); + } +} diff --git a/src/main/java/com/example/notifications/model/HoldListCreatedPayload.java b/src/main/java/com/example/notifications/model/HoldListCreatedPayload.java new file mode 100644 index 0000000..fc7d573 --- /dev/null +++ b/src/main/java/com/example/notifications/model/HoldListCreatedPayload.java @@ -0,0 +1,10 @@ +package com.example.notifications.model; + +import java.util.List; + +public record HoldListCreatedPayload( + String holdListName, + String createdBy, + List impactedAccounts +) implements NotificationPayload { +} diff --git a/src/main/java/com/example/notifications/model/HoldListRemovedPayload.java b/src/main/java/com/example/notifications/model/HoldListRemovedPayload.java new file mode 100644 index 0000000..da0f7e5 --- /dev/null +++ b/src/main/java/com/example/notifications/model/HoldListRemovedPayload.java @@ -0,0 +1,8 @@ +package com.example.notifications.model; + +public record HoldListRemovedPayload( + String holdListName, + String removedBy, + String removalReason +) implements NotificationPayload { +} diff --git a/src/main/java/com/example/notifications/model/NotificationPayload.java b/src/main/java/com/example/notifications/model/NotificationPayload.java new file mode 100644 index 0000000..c73f2bb --- /dev/null +++ b/src/main/java/com/example/notifications/model/NotificationPayload.java @@ -0,0 +1,4 @@ +package com.example.notifications.model; + +public interface NotificationPayload { +} diff --git a/src/main/java/com/example/notifications/orchestration/EmailNotificationOrchestrator.java b/src/main/java/com/example/notifications/orchestration/EmailNotificationOrchestrator.java new file mode 100644 index 0000000..bd9d14d --- /dev/null +++ b/src/main/java/com/example/notifications/orchestration/EmailNotificationOrchestrator.java @@ -0,0 +1,52 @@ +package com.example.notifications.orchestration; + +import com.example.notifications.builder.NotificationModelBuilder; +import com.example.notifications.config.NotificationCatalog; +import com.example.notifications.config.NotificationDefinition; +import com.example.notifications.config.NotificationType; +import com.example.notifications.model.NotificationPayload; +import com.example.notifications.publish.EmailPublicationPublisher; +import com.example.notifications.publish.EmailPublicationRequest; +import com.example.notifications.render.TemplateRenderer; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +public class EmailNotificationOrchestrator { + + private final NotificationCatalog notificationCatalog; + private final NotificationModelBuilderRegistry registry; + private final TemplateRenderer templateRenderer; + private final EmailPublicationPublisher publisher; + + public EmailNotificationOrchestrator(NotificationCatalog notificationCatalog, + NotificationModelBuilderRegistry registry, + TemplateRenderer templateRenderer, + EmailPublicationPublisher publisher) { + this.notificationCatalog = notificationCatalog; + this.registry = registry; + this.templateRenderer = templateRenderer; + this.publisher = publisher; + } + + public void publish(NotificationType type, T payload, Class payloadType) { + NotificationDefinition definition = notificationCatalog.getRequired(type); + if (!definition.isEnabled()) { + return; + } + + NotificationModelBuilder builder = registry.getTyped(type, payloadType); + Map model = builder.buildModel(payload); + String body = templateRenderer.render(definition.templatePath(), model); + + EmailPublicationRequest request = new EmailPublicationRequest( + type, + definition.recipients(), + definition.subject(), + body + ); + + publisher.publish(request); + } +} diff --git a/src/main/java/com/example/notifications/orchestration/NotificationModelBuilderRegistry.java b/src/main/java/com/example/notifications/orchestration/NotificationModelBuilderRegistry.java new file mode 100644 index 0000000..a5aec1a --- /dev/null +++ b/src/main/java/com/example/notifications/orchestration/NotificationModelBuilderRegistry.java @@ -0,0 +1,44 @@ +package com.example.notifications.orchestration; + +import com.example.notifications.builder.NotificationModelBuilder; +import com.example.notifications.config.NotificationType; +import com.example.notifications.model.NotificationPayload; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@Component +public class NotificationModelBuilderRegistry { + + private final Map> builders; + + public NotificationModelBuilderRegistry(List> builders) { + this.builders = new EnumMap<>(NotificationType.class); + for (NotificationModelBuilder builder : builders) { + NotificationModelBuilder existing = this.builders.put(builder.supportsType(), builder); + if (existing != null) { + throw new IllegalStateException("Duplicate builder for type: " + builder.supportsType()); + } + } + } + + public NotificationModelBuilder getRequired(NotificationType type) { + NotificationModelBuilder builder = builders.get(type); + if (builder == null) { + throw new IllegalArgumentException("No model builder registered for type: " + type); + } + return builder; + } + + @SuppressWarnings("unchecked") + public NotificationModelBuilder getTyped(NotificationType type, Class payloadType) { + NotificationModelBuilder rawBuilder = getRequired(type); + if (!rawBuilder.payloadType().equals(payloadType)) { + throw new IllegalArgumentException("Payload type mismatch for " + type + ": expected " + + rawBuilder.payloadType().getSimpleName() + " but got " + payloadType.getSimpleName()); + } + return (NotificationModelBuilder) rawBuilder; + } +} diff --git a/src/main/java/com/example/notifications/publish/EmailPublicationPublisher.java b/src/main/java/com/example/notifications/publish/EmailPublicationPublisher.java new file mode 100644 index 0000000..fb78351 --- /dev/null +++ b/src/main/java/com/example/notifications/publish/EmailPublicationPublisher.java @@ -0,0 +1,6 @@ +package com.example.notifications.publish; + +public interface EmailPublicationPublisher { + + void publish(EmailPublicationRequest request); +} diff --git a/src/main/java/com/example/notifications/publish/EmailPublicationRequest.java b/src/main/java/com/example/notifications/publish/EmailPublicationRequest.java new file mode 100644 index 0000000..496ce67 --- /dev/null +++ b/src/main/java/com/example/notifications/publish/EmailPublicationRequest.java @@ -0,0 +1,13 @@ +package com.example.notifications.publish; + +import com.example.notifications.config.NotificationType; + +import java.util.List; + +public record EmailPublicationRequest( + NotificationType notificationType, + List recipients, + String subject, + String body +) { +} diff --git a/src/main/java/com/example/notifications/publish/LoggingEmailPublicationPublisher.java b/src/main/java/com/example/notifications/publish/LoggingEmailPublicationPublisher.java new file mode 100644 index 0000000..e923b37 --- /dev/null +++ b/src/main/java/com/example/notifications/publish/LoggingEmailPublicationPublisher.java @@ -0,0 +1,17 @@ +package com.example.notifications.publish; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +@Component +public class LoggingEmailPublicationPublisher implements EmailPublicationPublisher { + + private static final Logger log = LoggerFactory.getLogger(LoggingEmailPublicationPublisher.class); + + @Override + public void publish(EmailPublicationRequest request) { + log.info("Publishing email notification={}, recipients={}, subject={}", + request.notificationType(), request.recipients(), request.subject()); + } +} diff --git a/src/main/java/com/example/notifications/render/TemplateRenderer.java b/src/main/java/com/example/notifications/render/TemplateRenderer.java new file mode 100644 index 0000000..e488335 --- /dev/null +++ b/src/main/java/com/example/notifications/render/TemplateRenderer.java @@ -0,0 +1,8 @@ +package com.example.notifications.render; + +import java.util.Map; + +public interface TemplateRenderer { + + String render(String templatePath, Map model); +} diff --git a/src/main/java/com/example/notifications/render/ThymeleafTemplateRenderer.java b/src/main/java/com/example/notifications/render/ThymeleafTemplateRenderer.java new file mode 100644 index 0000000..f2a69b1 --- /dev/null +++ b/src/main/java/com/example/notifications/render/ThymeleafTemplateRenderer.java @@ -0,0 +1,24 @@ +package com.example.notifications.render; + +import org.springframework.stereotype.Component; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import java.util.Locale; +import java.util.Map; + +@Component +public class ThymeleafTemplateRenderer implements TemplateRenderer { + + private final TemplateEngine templateEngine; + + public ThymeleafTemplateRenderer(TemplateEngine templateEngine) { + this.templateEngine = templateEngine; + } + + @Override + public String render(String templatePath, Map model) { + Context context = new Context(Locale.US, model); + return templateEngine.process(templatePath, context); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..2e43e47 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,15 @@ +notifications: + definitions: + HOLD_LIST_CREATED: + recipients: + - ops@example.com + - risk@example.com + template-path: hold-list-created + subject: "Hold list created" + enabled: true + HOLD_LIST_REMOVED: + recipients: + - ops@example.com + template-path: hold-list-removed + subject: "Hold list removed" + enabled: true diff --git a/src/main/resources/templates/hold-list-created.html b/src/main/resources/templates/hold-list-created.html new file mode 100644 index 0000000..00c78ae --- /dev/null +++ b/src/main/resources/templates/hold-list-created.html @@ -0,0 +1,9 @@ + + + +

A hold list was created.

+

Name:

+

Created By:

+

Impacted Accounts:

+ + diff --git a/src/main/resources/templates/hold-list-removed.html b/src/main/resources/templates/hold-list-removed.html new file mode 100644 index 0000000..154c5fc --- /dev/null +++ b/src/main/resources/templates/hold-list-removed.html @@ -0,0 +1,9 @@ + + + +

A hold list was removed.

+

Name:

+

Removed By:

+

Reason:

+ + diff --git a/src/test/java/com/example/notifications/orchestration/EmailNotificationOrchestratorTest.java b/src/test/java/com/example/notifications/orchestration/EmailNotificationOrchestratorTest.java new file mode 100644 index 0000000..910a04d --- /dev/null +++ b/src/test/java/com/example/notifications/orchestration/EmailNotificationOrchestratorTest.java @@ -0,0 +1,71 @@ +package com.example.notifications.orchestration; + +import com.example.notifications.builder.NotificationModelBuilder; +import com.example.notifications.config.NotificationCatalog; +import com.example.notifications.config.NotificationDefinition; +import com.example.notifications.config.NotificationType; +import com.example.notifications.model.HoldListCreatedPayload; +import com.example.notifications.publish.EmailPublicationPublisher; +import com.example.notifications.publish.EmailPublicationRequest; +import com.example.notifications.render.TemplateRenderer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class EmailNotificationOrchestratorTest { + + private NotificationCatalog catalog; + private NotificationModelBuilderRegistry registry; + private TemplateRenderer renderer; + private EmailPublicationPublisher publisher; + private EmailNotificationOrchestrator orchestrator; + + @BeforeEach + void setUp() { + catalog = mock(NotificationCatalog.class); + registry = mock(NotificationModelBuilderRegistry.class); + renderer = mock(TemplateRenderer.class); + publisher = mock(EmailPublicationPublisher.class); + orchestrator = new EmailNotificationOrchestrator(catalog, registry, renderer, publisher); + } + + @Test + void publish_rendersAndPublishes() { + HoldListCreatedPayload payload = new HoldListCreatedPayload("AML-LOCK", "alice", List.of("A1", "A2")); + NotificationDefinition definition = new NotificationDefinition(List.of("ops@example.com"), "hold-list-created", "Hold list created", true); + + @SuppressWarnings("unchecked") + NotificationModelBuilder builder = mock(NotificationModelBuilder.class); + + when(catalog.getRequired(NotificationType.HOLD_LIST_CREATED)).thenReturn(definition); + when(registry.getTyped(NotificationType.HOLD_LIST_CREATED, HoldListCreatedPayload.class)).thenReturn(builder); + when(builder.buildModel(payload)).thenReturn(Map.of("holdListName", "AML-LOCK")); + when(renderer.render("hold-list-created", Map.of("holdListName", "AML-LOCK"))).thenReturn("rendered-body"); + + orchestrator.publish(NotificationType.HOLD_LIST_CREATED, payload, HoldListCreatedPayload.class); + + verify(publisher).publish(new EmailPublicationRequest( + NotificationType.HOLD_LIST_CREATED, + List.of("ops@example.com"), + "Hold list created", + "rendered-body" + )); + } + + @Test + void publish_skipsWhenDisabled() { + HoldListCreatedPayload payload = new HoldListCreatedPayload("AML-LOCK", "alice", List.of("A1")); + NotificationDefinition definition = new NotificationDefinition(List.of("ops@example.com"), "hold-list-created", "Hold list created", false); + + when(catalog.getRequired(NotificationType.HOLD_LIST_CREATED)).thenReturn(definition); + + orchestrator.publish(NotificationType.HOLD_LIST_CREATED, payload, HoldListCreatedPayload.class); + + verifyNoInteractions(registry, renderer, publisher); + } +} diff --git a/src/test/java/com/example/notifications/orchestration/NotificationModelBuilderRegistryTest.java b/src/test/java/com/example/notifications/orchestration/NotificationModelBuilderRegistryTest.java new file mode 100644 index 0000000..92a41b2 --- /dev/null +++ b/src/test/java/com/example/notifications/orchestration/NotificationModelBuilderRegistryTest.java @@ -0,0 +1,55 @@ +package com.example.notifications.orchestration; + +import com.example.notifications.builder.NotificationModelBuilder; +import com.example.notifications.config.NotificationType; +import com.example.notifications.model.HoldListCreatedPayload; +import com.example.notifications.model.HoldListRemovedPayload; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class NotificationModelBuilderRegistryTest { + + @Test + void getTyped_returnsBuilderWhenTypeMatches() { + NotificationModelBuilderRegistry registry = new NotificationModelBuilderRegistry(List.of(new CreatedBuilder())); + + NotificationModelBuilder builder = registry.getTyped( + NotificationType.HOLD_LIST_CREATED, + HoldListCreatedPayload.class + ); + + assertNotNull(builder); + } + + @Test + void getTyped_throwsWhenPayloadTypeMismatched() { + NotificationModelBuilderRegistry registry = new NotificationModelBuilderRegistry(List.of(new CreatedBuilder())); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> registry.getTyped(NotificationType.HOLD_LIST_CREATED, HoldListRemovedPayload.class)); + + assertTrue(error.getMessage().contains("Payload type mismatch")); + } + + private static class CreatedBuilder implements NotificationModelBuilder { + + @Override + public NotificationType supportsType() { + return NotificationType.HOLD_LIST_CREATED; + } + + @Override + public Class payloadType() { + return HoldListCreatedPayload.class; + } + + @Override + public Map buildModel(HoldListCreatedPayload payload) { + return Map.of(); + } + } +}