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"));
+ }
+}