diff --git a/README.md b/README.md index a0054b0..d236736 100644 --- a/README.md +++ b/README.md @@ -1 +1,81 @@ -# nginx-remote-app-poc \ No newline at end of file +# Spring Boot Email Notification Pattern (Publisher Only) + +## 1) Pattern summary +This code uses one shared orchestration flow and tiny per-notification model builders. + +Why this keeps cognitive load low: +- One entry point: `EmailNotificationOrchestrator.notify(type, payload)`. +- One place for config lookup: `NotificationCatalog`. +- One place for rendering: `TemplateRenderer`. +- One place for publishing: `EmailPublicationPublisher`. +- Notification-specific logic is isolated to small builders (`buildModel`). + +## 2) Package / file structure + +```text +com.example.notifications +├── NotificationApplication +├── application +│ ├── EmailNotificationOrchestrator +│ └── HoldListDomainService +├── builder +│ ├── HoldListCreatedModelBuilder +│ └── HoldListRemovedModelBuilder +├── config +│ ├── NotificationCatalog +│ ├── NotificationDefinition +│ └── NotificationProperties +├── domain +│ ├── HoldListCreatedPayload +│ ├── HoldListRemovedPayload +│ ├── NotificationPayload +│ └── NotificationType +├── model +│ ├── NotificationModelBuilder +│ └── NotificationModelBuilderRegistry +├── publish +│ ├── EmailPublicationPublisher +│ └── EmailPublicationRequest +└── render + ├── TemplateRenderer + └── ThymeleafTemplateRenderer +``` + +## 3) Main runtime flow +1. Domain service calls orchestrator once. +2. Orchestrator loads config by `NotificationType`. +3. Registry picks the matching model builder. +4. Builder converts payload -> template model. +5. Shared renderer renders template. +6. Orchestrator builds `EmailPublicationRequest`. +7. Shared publisher interface publishes message. + +## 4) application.yml example +See `src/main/resources/application.yml` for two notifications (`HOLD_LIST_CREATED`, `HOLD_LIST_REMOVED`). + +## 5) Template examples +See: +- `src/main/resources/templates/notifications/hold-list-created.html` +- `src/main/resources/templates/notifications/hold-list-removed.html` + +## 6) Example usage from a domain service +`HoldListDomainService` shows both create/remove use cases and calls one orchestrator method. + +## 7) How to add a new notification +1. Add enum value to `NotificationType`. +2. Add YAML entry under `notifications.definitions`. +3. Add Thymeleaf template under `resources/templates/...`. +4. Add payload record (optional if existing payload works). +5. Add one `NotificationModelBuilder` implementation for that type. + +No change is required in `EmailNotificationOrchestrator` or shared renderer/publisher logic. + +## 8) Focused unit tests +- `EmailNotificationOrchestratorTest` +- `NotificationModelBuilderRegistryTest` + +## 9) Tradeoffs +This is intentionally boring and explicit: +- Better than ad hoc per-notification services because render/publish/config logic is centralized. +- Better than huge switches because type-specific logic stays in focused builders. +- Slightly less compile-time type safety in orchestrator payload parameter, but much easier day-2 maintenance and extension. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..766be2d --- /dev/null +++ b/pom.xml @@ -0,0 +1,48 @@ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.2 + + + + com.example + java-test-zone + 0.0.1-SNAPSHOT + java-test-zone + Notification orchestration pattern example + + + 21 + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/src/main/java/com/example/notifications/NotificationApplication.java b/src/main/java/com/example/notifications/NotificationApplication.java new file mode 100644 index 0000000..b5d1567 --- /dev/null +++ b/src/main/java/com/example/notifications/NotificationApplication.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 NotificationApplication { + + public static void main(String[] args) { + SpringApplication.run(NotificationApplication.class, args); + } +} diff --git a/src/main/java/com/example/notifications/application/EmailNotificationOrchestrator.java b/src/main/java/com/example/notifications/application/EmailNotificationOrchestrator.java new file mode 100644 index 0000000..1a26554 --- /dev/null +++ b/src/main/java/com/example/notifications/application/EmailNotificationOrchestrator.java @@ -0,0 +1,54 @@ +package com.example.notifications.application; + +import com.example.notifications.config.NotificationCatalog; +import com.example.notifications.config.NotificationDefinition; +import com.example.notifications.domain.NotificationPayload; +import com.example.notifications.domain.NotificationType; +import com.example.notifications.model.NotificationModelBuilderRegistry; +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 catalog; + private final NotificationModelBuilderRegistry builderRegistry; + private final TemplateRenderer templateRenderer; + private final EmailPublicationPublisher publisher; + + public EmailNotificationOrchestrator( + NotificationCatalog catalog, + NotificationModelBuilderRegistry builderRegistry, + TemplateRenderer templateRenderer, + EmailPublicationPublisher publisher + ) { + this.catalog = catalog; + this.builderRegistry = builderRegistry; + this.templateRenderer = templateRenderer; + this.publisher = publisher; + } + + public void notify(NotificationType type, NotificationPayload payload) { + NotificationDefinition definition = catalog.getRequired(type); + + if (!definition.isEnabled()) { + return; + } + + Map model = builderRegistry.buildModel(type, payload); + String body = templateRenderer.render(definition.templatePath(), model); + + EmailPublicationRequest request = new EmailPublicationRequest( + type, + definition.subject(), + definition.recipients(), + body + ); + + publisher.publish(request); + } +} diff --git a/src/main/java/com/example/notifications/application/HoldListDomainService.java b/src/main/java/com/example/notifications/application/HoldListDomainService.java new file mode 100644 index 0000000..bf5a203 --- /dev/null +++ b/src/main/java/com/example/notifications/application/HoldListDomainService.java @@ -0,0 +1,34 @@ +package com.example.notifications.application; + +import com.example.notifications.domain.HoldListCreatedPayload; +import com.example.notifications.domain.HoldListRemovedPayload; +import com.example.notifications.domain.NotificationType; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; + +@Service +public class HoldListDomainService { + + private final EmailNotificationOrchestrator orchestrator; + + public HoldListDomainService(EmailNotificationOrchestrator orchestrator) { + this.orchestrator = orchestrator; + } + + public void createHoldList(String holdListName, String createdBy) { + // domain behavior omitted + orchestrator.notify( + NotificationType.HOLD_LIST_CREATED, + new HoldListCreatedPayload(holdListName, createdBy, LocalDate.now()) + ); + } + + public void removeHoldList(String holdListName, String removedBy, String reason) { + // domain behavior omitted + orchestrator.notify( + NotificationType.HOLD_LIST_REMOVED, + new HoldListRemovedPayload(holdListName, removedBy, reason) + ); + } +} 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..d842e1d --- /dev/null +++ b/src/main/java/com/example/notifications/builder/HoldListCreatedModelBuilder.java @@ -0,0 +1,31 @@ +package com.example.notifications.builder; + +import com.example.notifications.domain.HoldListCreatedPayload; +import com.example.notifications.domain.NotificationType; +import com.example.notifications.model.NotificationModelBuilder; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class HoldListCreatedModelBuilder implements NotificationModelBuilder { + + @Override + public NotificationType type() { + return NotificationType.HOLD_LIST_CREATED; + } + + @Override + public Class payloadClass() { + return HoldListCreatedPayload.class; + } + + @Override + public Map buildModel(HoldListCreatedPayload payload) { + return Map.of( + "holdListName", payload.holdListName(), + "createdBy", payload.createdBy(), + "effectiveDate", payload.effectiveDate() + ); + } +} 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..cd07230 --- /dev/null +++ b/src/main/java/com/example/notifications/builder/HoldListRemovedModelBuilder.java @@ -0,0 +1,31 @@ +package com.example.notifications.builder; + +import com.example.notifications.domain.HoldListRemovedPayload; +import com.example.notifications.domain.NotificationType; +import com.example.notifications.model.NotificationModelBuilder; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class HoldListRemovedModelBuilder implements NotificationModelBuilder { + + @Override + public NotificationType type() { + return NotificationType.HOLD_LIST_REMOVED; + } + + @Override + public Class payloadClass() { + return HoldListRemovedPayload.class; + } + + @Override + public Map buildModel(HoldListRemovedPayload payload) { + return Map.of( + "holdListName", payload.holdListName(), + "removedBy", payload.removedBy(), + "reason", payload.reason() + ); + } +} 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..1363461 --- /dev/null +++ b/src/main/java/com/example/notifications/config/NotificationCatalog.java @@ -0,0 +1,22 @@ +package com.example.notifications.config; + +import com.example.notifications.domain.NotificationType; +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("No notification definition configured 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..4c8419e --- /dev/null +++ b/src/main/java/com/example/notifications/config/NotificationProperties.java @@ -0,0 +1,21 @@ +package com.example.notifications.config; + +import com.example.notifications.domain.NotificationType; +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 == null ? new EnumMap<>(NotificationType.class) : new EnumMap<>(definitions); + } +} diff --git a/src/main/java/com/example/notifications/domain/HoldListCreatedPayload.java b/src/main/java/com/example/notifications/domain/HoldListCreatedPayload.java new file mode 100644 index 0000000..d8a2f2e --- /dev/null +++ b/src/main/java/com/example/notifications/domain/HoldListCreatedPayload.java @@ -0,0 +1,10 @@ +package com.example.notifications.domain; + +import java.time.LocalDate; + +public record HoldListCreatedPayload( + String holdListName, + String createdBy, + LocalDate effectiveDate +) implements NotificationPayload { +} diff --git a/src/main/java/com/example/notifications/domain/HoldListRemovedPayload.java b/src/main/java/com/example/notifications/domain/HoldListRemovedPayload.java new file mode 100644 index 0000000..8aec46f --- /dev/null +++ b/src/main/java/com/example/notifications/domain/HoldListRemovedPayload.java @@ -0,0 +1,8 @@ +package com.example.notifications.domain; + +public record HoldListRemovedPayload( + String holdListName, + String removedBy, + String reason +) implements NotificationPayload { +} diff --git a/src/main/java/com/example/notifications/domain/NotificationPayload.java b/src/main/java/com/example/notifications/domain/NotificationPayload.java new file mode 100644 index 0000000..2059266 --- /dev/null +++ b/src/main/java/com/example/notifications/domain/NotificationPayload.java @@ -0,0 +1,4 @@ +package com.example.notifications.domain; + +public interface NotificationPayload { +} diff --git a/src/main/java/com/example/notifications/domain/NotificationType.java b/src/main/java/com/example/notifications/domain/NotificationType.java new file mode 100644 index 0000000..a4b2600 --- /dev/null +++ b/src/main/java/com/example/notifications/domain/NotificationType.java @@ -0,0 +1,6 @@ +package com.example.notifications.domain; + +public enum NotificationType { + HOLD_LIST_CREATED, + HOLD_LIST_REMOVED +} diff --git a/src/main/java/com/example/notifications/model/NotificationModelBuilder.java b/src/main/java/com/example/notifications/model/NotificationModelBuilder.java new file mode 100644 index 0000000..115b7c6 --- /dev/null +++ b/src/main/java/com/example/notifications/model/NotificationModelBuilder.java @@ -0,0 +1,15 @@ +package com.example.notifications.model; + +import com.example.notifications.domain.NotificationPayload; +import com.example.notifications.domain.NotificationType; + +import java.util.Map; + +public interface NotificationModelBuilder { + + NotificationType type(); + + Class payloadClass(); + + Map buildModel(T payload); +} diff --git a/src/main/java/com/example/notifications/model/NotificationModelBuilderRegistry.java b/src/main/java/com/example/notifications/model/NotificationModelBuilderRegistry.java new file mode 100644 index 0000000..5e62f33 --- /dev/null +++ b/src/main/java/com/example/notifications/model/NotificationModelBuilderRegistry.java @@ -0,0 +1,49 @@ +package com.example.notifications.model; + +import com.example.notifications.domain.NotificationPayload; +import com.example.notifications.domain.NotificationType; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@Component +public class NotificationModelBuilderRegistry { + + private final Map> buildersByType; + + public NotificationModelBuilderRegistry(List> builders) { + this.buildersByType = new EnumMap<>(NotificationType.class); + for (NotificationModelBuilder builder : builders) { + buildersByType.put(builder.type(), builder); + } + } + + public NotificationModelBuilder getRequired(NotificationType type) { + NotificationModelBuilder builder = buildersByType.get(type); + if (builder == null) { + throw new IllegalArgumentException("No notification model builder registered for type: " + type); + } + return builder; + } + + public Map buildModel(NotificationType type, NotificationPayload payload) { + NotificationModelBuilder builder = getRequired(type); + return buildModelWithCheckedPayload(builder, payload); + } + + private Map buildModelWithCheckedPayload( + NotificationModelBuilder builder, + NotificationPayload payload + ) { + if (!builder.payloadClass().isInstance(payload)) { + throw new IllegalArgumentException( + "Payload type mismatch for " + builder.type() + ". Expected " + + builder.payloadClass().getSimpleName() + + " but got " + payload.getClass().getSimpleName()); + } + T castedPayload = builder.payloadClass().cast(payload); + return builder.buildModel(castedPayload); + } +} 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..5b6ccc3 --- /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.domain.NotificationType; + +import java.util.List; + +public record EmailPublicationRequest( + NotificationType notificationType, + String subject, + List recipients, + String body +) { +} 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..8ed79fe --- /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.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(); + context.setVariables(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..70a0d7d --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,15 @@ +notifications: + definitions: + HOLD_LIST_CREATED: + recipients: + - ops@example.com + - support@example.com + templatePath: templates/notifications/hold-list-created + subject: "Hold list created" + enabled: true + HOLD_LIST_REMOVED: + recipients: + - ops@example.com + templatePath: templates/notifications/hold-list-removed + subject: "Hold list removed" + # enabled omitted intentionally (defaults to true) diff --git a/src/main/resources/templates/notifications/hold-list-created.html b/src/main/resources/templates/notifications/hold-list-created.html new file mode 100644 index 0000000..24537d5 --- /dev/null +++ b/src/main/resources/templates/notifications/hold-list-created.html @@ -0,0 +1,11 @@ + + + +

A hold list was created.

+
    +
  • Name: Sample Hold List
  • +
  • Created by: jdoe
  • +
  • Effective date: 2026-03-24
  • +
+ + diff --git a/src/main/resources/templates/notifications/hold-list-removed.html b/src/main/resources/templates/notifications/hold-list-removed.html new file mode 100644 index 0000000..e36df4d --- /dev/null +++ b/src/main/resources/templates/notifications/hold-list-removed.html @@ -0,0 +1,11 @@ + + + +

A hold list was removed.

+
    +
  • Name: Sample Hold List
  • +
  • Removed by: jdoe
  • +
  • Reason: No longer needed
  • +
+ + diff --git a/src/test/java/com/example/notifications/application/EmailNotificationOrchestratorTest.java b/src/test/java/com/example/notifications/application/EmailNotificationOrchestratorTest.java new file mode 100644 index 0000000..1c11928 --- /dev/null +++ b/src/test/java/com/example/notifications/application/EmailNotificationOrchestratorTest.java @@ -0,0 +1,73 @@ +package com.example.notifications.application; + +import com.example.notifications.config.NotificationCatalog; +import com.example.notifications.config.NotificationDefinition; +import com.example.notifications.domain.HoldListCreatedPayload; +import com.example.notifications.domain.HoldListRemovedPayload; +import com.example.notifications.domain.NotificationType; +import com.example.notifications.model.NotificationModelBuilderRegistry; +import com.example.notifications.publish.EmailPublicationPublisher; +import com.example.notifications.publish.EmailPublicationRequest; +import com.example.notifications.render.TemplateRenderer; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class EmailNotificationOrchestratorTest { + + private final NotificationCatalog catalog = mock(NotificationCatalog.class); + private final NotificationModelBuilderRegistry builderRegistry = mock(NotificationModelBuilderRegistry.class); + private final TemplateRenderer templateRenderer = mock(TemplateRenderer.class); + private final EmailPublicationPublisher publisher = mock(EmailPublicationPublisher.class); + + private final EmailNotificationOrchestrator orchestrator = new EmailNotificationOrchestrator( + catalog, builderRegistry, templateRenderer, publisher + ); + + @Test + void publishes_email_when_notification_is_enabled() { + HoldListCreatedPayload payload = new HoldListCreatedPayload("Watchlist-A", "alice", LocalDate.of(2026, 3, 24)); + + NotificationDefinition definition = new NotificationDefinition( + List.of("ops@example.com"), + "templates/notifications/hold-list-created", + "Hold list created", + true + ); + + when(catalog.getRequired(NotificationType.HOLD_LIST_CREATED)).thenReturn(definition); + when(builderRegistry.buildModel(NotificationType.HOLD_LIST_CREATED, payload)).thenReturn(Map.of("holdListName", "Watchlist-A")); + when(templateRenderer.render(eq("templates/notifications/hold-list-created"), any())).thenReturn("rendered-body"); + + orchestrator.notify(NotificationType.HOLD_LIST_CREATED, payload); + + verify(publisher).publish(new EmailPublicationRequest( + NotificationType.HOLD_LIST_CREATED, + "Hold list created", + List.of("ops@example.com"), + "rendered-body" + )); + } + + @Test + void skips_publish_when_notification_is_disabled() { + HoldListRemovedPayload payload = new HoldListRemovedPayload("Watchlist-A", "bob", "cleanup"); + NotificationDefinition definition = new NotificationDefinition( + List.of("ops@example.com"), + "templates/notifications/hold-list-removed", + "Hold list removed", + false + ); + + when(catalog.getRequired(NotificationType.HOLD_LIST_REMOVED)).thenReturn(definition); + + orchestrator.notify(NotificationType.HOLD_LIST_REMOVED, payload); + + verifyNoInteractions(builderRegistry, templateRenderer, publisher); + } +} diff --git a/src/test/java/com/example/notifications/application/NotificationModelBuilderRegistryTest.java b/src/test/java/com/example/notifications/application/NotificationModelBuilderRegistryTest.java new file mode 100644 index 0000000..bacd6c8 --- /dev/null +++ b/src/test/java/com/example/notifications/application/NotificationModelBuilderRegistryTest.java @@ -0,0 +1,46 @@ +package com.example.notifications.application; + +import com.example.notifications.builder.HoldListCreatedModelBuilder; +import com.example.notifications.builder.HoldListRemovedModelBuilder; +import com.example.notifications.domain.HoldListCreatedPayload; +import com.example.notifications.domain.NotificationType; +import com.example.notifications.model.NotificationModelBuilderRegistry; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class NotificationModelBuilderRegistryTest { + + @Test + void delegates_to_correct_builder_for_each_type() { + NotificationModelBuilderRegistry registry = new NotificationModelBuilderRegistry( + java.util.List.of(new HoldListCreatedModelBuilder(), new HoldListRemovedModelBuilder()) + ); + + Map model = registry.buildModel( + NotificationType.HOLD_LIST_CREATED, + new HoldListCreatedPayload("Watchlist-B", "carol", LocalDate.of(2026, 3, 1)) + ); + + assertEquals("Watchlist-B", model.get("holdListName")); + assertEquals("carol", model.get("createdBy")); + } + + @Test + void throws_clear_error_for_payload_mismatch() { + NotificationModelBuilderRegistry registry = new NotificationModelBuilderRegistry( + java.util.List.of(new HoldListCreatedModelBuilder()) + ); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> registry.buildModel( + NotificationType.HOLD_LIST_CREATED, + new com.example.notifications.domain.HoldListRemovedPayload("x", "y", "z") + )); + + assertTrue(ex.getMessage().contains("Payload type mismatch")); + } +}