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