diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..df7891d
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,53 @@
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.2
+
+
+
+ com.example
+ email-notification-service
+ 0.0.1-SNAPSHOT
+ email-notification-service
+ Pattern example for email notification orchestration
+
+
+ 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/notification/NotificationApplication.java b/src/main/java/com/example/notification/NotificationApplication.java
new file mode 100644
index 0000000..767dca5
--- /dev/null
+++ b/src/main/java/com/example/notification/NotificationApplication.java
@@ -0,0 +1,15 @@
+package com.example.notification;
+
+import com.example.notification.config.NotificationProperties;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+
+@SpringBootApplication
+@EnableConfigurationProperties(NotificationProperties.class)
+public class NotificationApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(NotificationApplication.class, args);
+ }
+}
diff --git a/src/main/java/com/example/notification/builder/HoldListCreatedModelBuilder.java b/src/main/java/com/example/notification/builder/HoldListCreatedModelBuilder.java
new file mode 100644
index 0000000..4163d75
--- /dev/null
+++ b/src/main/java/com/example/notification/builder/HoldListCreatedModelBuilder.java
@@ -0,0 +1,31 @@
+package com.example.notification.builder;
+
+import com.example.notification.domain.HoldListCreatedPayload;
+import com.example.notification.model.NotificationType;
+import com.example.notification.orchestration.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(),
+ "itemCount", payload.itemCount()
+ );
+ }
+}
diff --git a/src/main/java/com/example/notification/builder/HoldListRemovedModelBuilder.java b/src/main/java/com/example/notification/builder/HoldListRemovedModelBuilder.java
new file mode 100644
index 0000000..7ac4f9c
--- /dev/null
+++ b/src/main/java/com/example/notification/builder/HoldListRemovedModelBuilder.java
@@ -0,0 +1,31 @@
+package com.example.notification.builder;
+
+import com.example.notification.domain.HoldListRemovedPayload;
+import com.example.notification.model.NotificationType;
+import com.example.notification.orchestration.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/notification/config/NotificationCatalog.java b/src/main/java/com/example/notification/config/NotificationCatalog.java
new file mode 100644
index 0000000..2edb387
--- /dev/null
+++ b/src/main/java/com/example/notification/config/NotificationCatalog.java
@@ -0,0 +1,22 @@
+package com.example.notification.config;
+
+import com.example.notification.model.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.getEmail().get(type);
+ if (definition == null) {
+ throw new IllegalArgumentException("No notification config found for type: " + type);
+ }
+ return definition;
+ }
+}
diff --git a/src/main/java/com/example/notification/config/NotificationDefinition.java b/src/main/java/com/example/notification/config/NotificationDefinition.java
new file mode 100644
index 0000000..06366d0
--- /dev/null
+++ b/src/main/java/com/example/notification/config/NotificationDefinition.java
@@ -0,0 +1,14 @@
+package com.example.notification.config;
+
+import java.util.List;
+
+public record NotificationDefinition(
+ List recipients,
+ String template,
+ String subject,
+ Boolean enabled
+) {
+ public boolean isEnabled() {
+ return enabled == null || enabled;
+ }
+}
diff --git a/src/main/java/com/example/notification/config/NotificationProperties.java b/src/main/java/com/example/notification/config/NotificationProperties.java
new file mode 100644
index 0000000..2903a1a
--- /dev/null
+++ b/src/main/java/com/example/notification/config/NotificationProperties.java
@@ -0,0 +1,21 @@
+package com.example.notification.config;
+
+import com.example.notification.model.NotificationType;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.EnumMap;
+import java.util.Map;
+
+@ConfigurationProperties(prefix = "notifications")
+public class NotificationProperties {
+
+ private Map email = new EnumMap<>(NotificationType.class);
+
+ public Map getEmail() {
+ return email;
+ }
+
+ public void setEmail(Map email) {
+ this.email = email;
+ }
+}
diff --git a/src/main/java/com/example/notification/domain/HoldListCreatedPayload.java b/src/main/java/com/example/notification/domain/HoldListCreatedPayload.java
new file mode 100644
index 0000000..f5ea424
--- /dev/null
+++ b/src/main/java/com/example/notification/domain/HoldListCreatedPayload.java
@@ -0,0 +1,8 @@
+package com.example.notification.domain;
+
+public record HoldListCreatedPayload(
+ String holdListName,
+ String createdBy,
+ int itemCount
+) {
+}
diff --git a/src/main/java/com/example/notification/domain/HoldListDomainService.java b/src/main/java/com/example/notification/domain/HoldListDomainService.java
new file mode 100644
index 0000000..419fb90
--- /dev/null
+++ b/src/main/java/com/example/notification/domain/HoldListDomainService.java
@@ -0,0 +1,25 @@
+package com.example.notification.domain;
+
+import com.example.notification.model.NotificationType;
+import com.example.notification.orchestration.EmailNotificationOrchestrator;
+import org.springframework.stereotype.Service;
+
+@Service
+public class HoldListDomainService {
+
+ private final EmailNotificationOrchestrator notificationOrchestrator;
+
+ public HoldListDomainService(EmailNotificationOrchestrator notificationOrchestrator) {
+ this.notificationOrchestrator = notificationOrchestrator;
+ }
+
+ public void createHoldList(String holdListName, String actor, int itemCount) {
+ HoldListCreatedPayload payload = new HoldListCreatedPayload(holdListName, actor, itemCount);
+ notificationOrchestrator.notify(NotificationType.HOLD_LIST_CREATED, payload);
+ }
+
+ public void removeHoldList(String holdListName, String actor, String reason) {
+ HoldListRemovedPayload payload = new HoldListRemovedPayload(holdListName, actor, reason);
+ notificationOrchestrator.notify(NotificationType.HOLD_LIST_REMOVED, payload);
+ }
+}
diff --git a/src/main/java/com/example/notification/domain/HoldListRemovedPayload.java b/src/main/java/com/example/notification/domain/HoldListRemovedPayload.java
new file mode 100644
index 0000000..a7e15b9
--- /dev/null
+++ b/src/main/java/com/example/notification/domain/HoldListRemovedPayload.java
@@ -0,0 +1,8 @@
+package com.example.notification.domain;
+
+public record HoldListRemovedPayload(
+ String holdListName,
+ String removedBy,
+ String reason
+) {
+}
diff --git a/src/main/java/com/example/notification/model/NotificationType.java b/src/main/java/com/example/notification/model/NotificationType.java
new file mode 100644
index 0000000..e272b43
--- /dev/null
+++ b/src/main/java/com/example/notification/model/NotificationType.java
@@ -0,0 +1,6 @@
+package com.example.notification.model;
+
+public enum NotificationType {
+ HOLD_LIST_CREATED,
+ HOLD_LIST_REMOVED
+}
diff --git a/src/main/java/com/example/notification/orchestration/EmailNotificationOrchestrator.java b/src/main/java/com/example/notification/orchestration/EmailNotificationOrchestrator.java
new file mode 100644
index 0000000..f443b1e
--- /dev/null
+++ b/src/main/java/com/example/notification/orchestration/EmailNotificationOrchestrator.java
@@ -0,0 +1,72 @@
+package com.example.notification.orchestration;
+
+import com.example.notification.config.NotificationCatalog;
+import com.example.notification.config.NotificationDefinition;
+import com.example.notification.model.NotificationType;
+import com.example.notification.publish.EmailPublicationPublisher;
+import com.example.notification.publish.EmailPublicationRequest;
+import com.example.notification.rendering.TemplateRenderer;
+import org.springframework.stereotype.Service;
+
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class EmailNotificationOrchestrator {
+
+ private final NotificationCatalog catalog;
+ private final TemplateRenderer renderer;
+ private final EmailPublicationPublisher publisher;
+ private final Map> buildersByType;
+
+ public EmailNotificationOrchestrator(
+ NotificationCatalog catalog,
+ TemplateRenderer renderer,
+ EmailPublicationPublisher publisher,
+ List> builders
+ ) {
+ this.catalog = catalog;
+ this.renderer = renderer;
+ this.publisher = publisher;
+ this.buildersByType = new EnumMap<>(NotificationType.class);
+
+ for (NotificationModelBuilder> builder : builders) {
+ this.buildersByType.put(builder.type(), builder);
+ }
+ }
+
+ public void notify(NotificationType type, T payload) {
+ NotificationDefinition definition = catalog.getRequired(type);
+ if (!definition.isEnabled()) {
+ return;
+ }
+
+ NotificationModelBuilder builder = resolveBuilder(type, payload);
+ Map model = builder.buildModel(payload);
+ String renderedBody = renderer.render(definition.template(), model);
+
+ EmailPublicationRequest request = new EmailPublicationRequest(
+ type,
+ definition.recipients(),
+ definition.subject(),
+ renderedBody
+ );
+ publisher.publish(request);
+ }
+
+ @SuppressWarnings("unchecked")
+ private NotificationModelBuilder resolveBuilder(NotificationType type, T payload) {
+ NotificationModelBuilder> rawBuilder = buildersByType.get(type);
+ if (rawBuilder == null) {
+ throw new IllegalArgumentException("No NotificationModelBuilder registered for type: " + type);
+ }
+
+ if (!rawBuilder.payloadClass().isInstance(payload)) {
+ throw new IllegalArgumentException("Payload type mismatch for %s. Expected %s but got %s"
+ .formatted(type, rawBuilder.payloadClass().getSimpleName(), payload.getClass().getSimpleName()));
+ }
+
+ return (NotificationModelBuilder) rawBuilder;
+ }
+}
diff --git a/src/main/java/com/example/notification/orchestration/NotificationModelBuilder.java b/src/main/java/com/example/notification/orchestration/NotificationModelBuilder.java
new file mode 100644
index 0000000..270f3c6
--- /dev/null
+++ b/src/main/java/com/example/notification/orchestration/NotificationModelBuilder.java
@@ -0,0 +1,14 @@
+package com.example.notification.orchestration;
+
+import com.example.notification.model.NotificationType;
+
+import java.util.Map;
+
+public interface NotificationModelBuilder {
+
+ NotificationType type();
+
+ Class payloadClass();
+
+ Map buildModel(T payload);
+}
diff --git a/src/main/java/com/example/notification/publish/EmailPublicationPublisher.java b/src/main/java/com/example/notification/publish/EmailPublicationPublisher.java
new file mode 100644
index 0000000..342cdaa
--- /dev/null
+++ b/src/main/java/com/example/notification/publish/EmailPublicationPublisher.java
@@ -0,0 +1,5 @@
+package com.example.notification.publish;
+
+public interface EmailPublicationPublisher {
+ void publish(EmailPublicationRequest request);
+}
diff --git a/src/main/java/com/example/notification/publish/EmailPublicationRequest.java b/src/main/java/com/example/notification/publish/EmailPublicationRequest.java
new file mode 100644
index 0000000..c40b6b2
--- /dev/null
+++ b/src/main/java/com/example/notification/publish/EmailPublicationRequest.java
@@ -0,0 +1,13 @@
+package com.example.notification.publish;
+
+import com.example.notification.model.NotificationType;
+
+import java.util.List;
+
+public record EmailPublicationRequest(
+ NotificationType notificationType,
+ List recipients,
+ String subject,
+ String body
+) {
+}
diff --git a/src/main/java/com/example/notification/rendering/TemplateRenderer.java b/src/main/java/com/example/notification/rendering/TemplateRenderer.java
new file mode 100644
index 0000000..6422585
--- /dev/null
+++ b/src/main/java/com/example/notification/rendering/TemplateRenderer.java
@@ -0,0 +1,7 @@
+package com.example.notification.rendering;
+
+import java.util.Map;
+
+public interface TemplateRenderer {
+ String render(String templatePath, Map model);
+}
diff --git a/src/main/java/com/example/notification/rendering/ThymeleafTemplateRenderer.java b/src/main/java/com/example/notification/rendering/ThymeleafTemplateRenderer.java
new file mode 100644
index 0000000..89f440e
--- /dev/null
+++ b/src/main/java/com/example/notification/rendering/ThymeleafTemplateRenderer.java
@@ -0,0 +1,24 @@
+package com.example.notification.rendering;
+
+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.getDefault(), 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..02361b0
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,15 @@
+notifications:
+ email:
+ HOLD_LIST_CREATED:
+ recipients:
+ - "ops@example.com"
+ - "audit@example.com"
+ template: "hold-list-created"
+ subject: "Hold list created"
+ enabled: true
+ HOLD_LIST_REMOVED:
+ recipients:
+ - "ops@example.com"
+ template: "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..62ee1e2
--- /dev/null
+++ b/src/main/resources/templates/hold-list-created.html
@@ -0,0 +1,11 @@
+
+
+
+A hold list was created.
+
+ - Name: name
+ - Created by: actor
+ - Item count: 0
+
+
+
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..f459ecc
--- /dev/null
+++ b/src/main/resources/templates/hold-list-removed.html
@@ -0,0 +1,11 @@
+
+
+
+A hold list was removed.
+
+ - Name: name
+ - Removed by: actor
+ - Reason: reason
+
+
+
diff --git a/src/test/java/com/example/notification/orchestration/EmailNotificationOrchestratorTest.java b/src/test/java/com/example/notification/orchestration/EmailNotificationOrchestratorTest.java
new file mode 100644
index 0000000..3fcc63c
--- /dev/null
+++ b/src/test/java/com/example/notification/orchestration/EmailNotificationOrchestratorTest.java
@@ -0,0 +1,150 @@
+package com.example.notification.orchestration;
+
+import com.example.notification.config.NotificationCatalog;
+import com.example.notification.config.NotificationDefinition;
+import com.example.notification.config.NotificationProperties;
+import com.example.notification.domain.HoldListCreatedPayload;
+import com.example.notification.domain.HoldListRemovedPayload;
+import com.example.notification.model.NotificationType;
+import com.example.notification.publish.EmailPublicationPublisher;
+import com.example.notification.publish.EmailPublicationRequest;
+import com.example.notification.rendering.TemplateRenderer;
+import org.junit.jupiter.api.Test;
+
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class EmailNotificationOrchestratorTest {
+
+ @Test
+ void publishes_using_shared_flow_for_created_notification() {
+ NotificationProperties properties = properties(Map.of(
+ NotificationType.HOLD_LIST_CREATED,
+ new NotificationDefinition(List.of("ops@example.com"), "hold-list-created", "Created", true)
+ ));
+
+ AtomicReference published = new AtomicReference<>();
+ EmailNotificationOrchestrator orchestrator = new EmailNotificationOrchestrator(
+ new NotificationCatalog(properties),
+ (template, model) -> "rendered:" + template + ":" + model.get("holdListName"),
+ published::set,
+ List.of(new CreatedBuilder())
+ );
+
+ orchestrator.notify(NotificationType.HOLD_LIST_CREATED, new HoldListCreatedPayload("VIP", "alice", 3));
+
+ assertNotNull(published.get());
+ assertEquals(NotificationType.HOLD_LIST_CREATED, published.get().notificationType());
+ assertEquals("Created", published.get().subject());
+ assertEquals("rendered:hold-list-created:VIP", published.get().body());
+ }
+
+ @Test
+ void skips_publish_when_notification_is_disabled() {
+ NotificationProperties properties = properties(Map.of(
+ NotificationType.HOLD_LIST_CREATED,
+ new NotificationDefinition(List.of("ops@example.com"), "hold-list-created", "Created", false)
+ ));
+
+ AtomicReference published = new AtomicReference<>();
+ EmailNotificationOrchestrator orchestrator = new EmailNotificationOrchestrator(
+ new NotificationCatalog(properties),
+ (template, model) -> "unused",
+ published::set,
+ List.of(new CreatedBuilder())
+ );
+
+ orchestrator.notify(NotificationType.HOLD_LIST_CREATED, new HoldListCreatedPayload("VIP", "alice", 3));
+
+ assertNull(published.get());
+ }
+
+ @Test
+ void supports_second_notification_type_without_changing_orchestrator() {
+ NotificationProperties properties = properties(Map.of(
+ NotificationType.HOLD_LIST_REMOVED,
+ new NotificationDefinition(List.of("ops@example.com"), "hold-list-removed", "Removed", true)
+ ));
+
+ AtomicReference published = new AtomicReference<>();
+ TemplateRenderer renderer = (template, model) -> "rendered:" + template + ":" + model.get("reason");
+ EmailPublicationPublisher publisher = published::set;
+
+ EmailNotificationOrchestrator orchestrator = new EmailNotificationOrchestrator(
+ new NotificationCatalog(properties),
+ renderer,
+ publisher,
+ List.of(new RemovedBuilder())
+ );
+
+ orchestrator.notify(NotificationType.HOLD_LIST_REMOVED, new HoldListRemovedPayload("VIP", "bob", "No longer needed"));
+
+ assertNotNull(published.get());
+ assertEquals("Removed", published.get().subject());
+ assertEquals("rendered:hold-list-removed:No longer needed", published.get().body());
+ }
+
+ @Test
+ void throws_when_payload_type_does_not_match_builder() {
+ NotificationProperties properties = properties(Map.of(
+ NotificationType.HOLD_LIST_CREATED,
+ new NotificationDefinition(List.of("ops@example.com"), "hold-list-created", "Created", true)
+ ));
+
+ EmailNotificationOrchestrator orchestrator = new EmailNotificationOrchestrator(
+ new NotificationCatalog(properties),
+ (template, model) -> "unused",
+ request -> { },
+ List.of(new CreatedBuilder())
+ );
+
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> orchestrator.notify(NotificationType.HOLD_LIST_CREATED, new HoldListRemovedPayload("VIP", "bob", "x")));
+
+ assertTrue(exception.getMessage().contains("Payload type mismatch"));
+ }
+
+ private static NotificationProperties properties(Map definitions) {
+ NotificationProperties properties = new NotificationProperties();
+ properties.setEmail(new EnumMap<>(definitions));
+ return properties;
+ }
+
+ private static class CreatedBuilder 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());
+ }
+ }
+
+ private static class RemovedBuilder 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("reason", payload.reason());
+ }
+ }
+}