-
Notifications
You must be signed in to change notification settings - Fork 0
Implement shared email notification orchestration (Thymeleaf renderer, per-type builders) #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| <project xmlns="http://maven.apache.org/POM/4.0.0" | ||
| xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
| xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | ||
| <modelVersion>4.0.0</modelVersion> | ||
|
|
||
| <parent> | ||
| <groupId>org.springframework.boot</groupId> | ||
| <artifactId>spring-boot-starter-parent</artifactId> | ||
| <version>3.3.2</version> | ||
| <relativePath/> | ||
| </parent> | ||
|
|
||
| <groupId>com.example</groupId> | ||
| <artifactId>email-notification-service</artifactId> | ||
| <version>0.0.1-SNAPSHOT</version> | ||
| <name>email-notification-service</name> | ||
| <description>Pattern example for email notification orchestration</description> | ||
|
|
||
| <properties> | ||
| <java.version>21</java.version> | ||
| </properties> | ||
|
|
||
| <dependencies> | ||
| <dependency> | ||
| <groupId>org.springframework.boot</groupId> | ||
| <artifactId>spring-boot-starter</artifactId> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>org.springframework.boot</groupId> | ||
| <artifactId>spring-boot-starter-thymeleaf</artifactId> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>org.springframework.boot</groupId> | ||
| <artifactId>spring-boot-configuration-processor</artifactId> | ||
| <optional>true</optional> | ||
| </dependency> | ||
|
|
||
| <dependency> | ||
| <groupId>org.springframework.boot</groupId> | ||
| <artifactId>spring-boot-starter-test</artifactId> | ||
| <scope>test</scope> | ||
| </dependency> | ||
| </dependencies> | ||
|
|
||
| <build> | ||
| <plugins> | ||
| <plugin> | ||
| <groupId>org.springframework.boot</groupId> | ||
| <artifactId>spring-boot-maven-plugin</artifactId> | ||
| </plugin> | ||
| </plugins> | ||
| </build> | ||
| </project> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HoldListCreatedPayload> { | ||
|
|
||
| @Override | ||
| public NotificationType type() { | ||
| return NotificationType.HOLD_LIST_CREATED; | ||
| } | ||
|
|
||
| @Override | ||
| public Class<HoldListCreatedPayload> payloadClass() { | ||
| return HoldListCreatedPayload.class; | ||
| } | ||
|
|
||
| @Override | ||
| public Map<String, Object> buildModel(HoldListCreatedPayload payload) { | ||
| return Map.of( | ||
| "holdListName", payload.holdListName(), | ||
| "createdBy", payload.createdBy(), | ||
| "itemCount", payload.itemCount() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HoldListRemovedPayload> { | ||
|
|
||
| @Override | ||
| public NotificationType type() { | ||
| return NotificationType.HOLD_LIST_REMOVED; | ||
| } | ||
|
|
||
| @Override | ||
| public Class<HoldListRemovedPayload> payloadClass() { | ||
| return HoldListRemovedPayload.class; | ||
| } | ||
|
|
||
| @Override | ||
| public Map<String, Object> buildModel(HoldListRemovedPayload payload) { | ||
| return Map.of( | ||
| "holdListName", payload.holdListName(), | ||
| "removedBy", payload.removedBy(), | ||
| "reason", payload.reason() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.example.notification.config; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record NotificationDefinition( | ||
| List<String> recipients, | ||
| String template, | ||
| String subject, | ||
| Boolean enabled | ||
| ) { | ||
| public boolean isEnabled() { | ||
| return enabled == null || enabled; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<NotificationType, NotificationDefinition> email = new EnumMap<>(NotificationType.class); | ||
|
|
||
| public Map<NotificationType, NotificationDefinition> getEmail() { | ||
| return email; | ||
| } | ||
|
|
||
| public void setEmail(Map<NotificationType, NotificationDefinition> email) { | ||
| this.email = email; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package com.example.notification.domain; | ||
|
|
||
| public record HoldListCreatedPayload( | ||
| String holdListName, | ||
| String createdBy, | ||
| int itemCount | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package com.example.notification.domain; | ||
|
|
||
| public record HoldListRemovedPayload( | ||
| String holdListName, | ||
| String removedBy, | ||
| String reason | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package com.example.notification.model; | ||
|
|
||
| public enum NotificationType { | ||
| HOLD_LIST_CREATED, | ||
| HOLD_LIST_REMOVED | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<NotificationType, NotificationModelBuilder<?>> buildersByType; | ||
|
|
||
| public EmailNotificationOrchestrator( | ||
| NotificationCatalog catalog, | ||
| TemplateRenderer renderer, | ||
| EmailPublicationPublisher publisher, | ||
| List<NotificationModelBuilder<?>> 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); | ||
|
Comment on lines
+34
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The constructor populates Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
|
|
||
| public <T> void notify(NotificationType type, T payload) { | ||
| NotificationDefinition definition = catalog.getRequired(type); | ||
| if (!definition.isEnabled()) { | ||
| return; | ||
| } | ||
|
|
||
| NotificationModelBuilder<T> builder = resolveBuilder(type, payload); | ||
| Map<String, Object> 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 <T> NotificationModelBuilder<T> 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<T>) rawBuilder; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.example.notification.orchestration; | ||
|
|
||
| import com.example.notification.model.NotificationType; | ||
|
|
||
| import java.util.Map; | ||
|
|
||
| public interface NotificationModelBuilder<T> { | ||
|
|
||
| NotificationType type(); | ||
|
|
||
| Class<T> payloadClass(); | ||
|
|
||
| Map<String, Object> buildModel(T payload); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package com.example.notification.publish; | ||
|
|
||
| public interface EmailPublicationPublisher { | ||
| void publish(EmailPublicationRequest request); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String> recipients, | ||
| String subject, | ||
| String body | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.example.notification.rendering; | ||
|
|
||
| import java.util.Map; | ||
|
|
||
| public interface TemplateRenderer { | ||
| String render(String templatePath, Map<String, Object> model); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String, Object> model) { | ||
| Context context = new Context(Locale.getDefault(), model); | ||
| return templateEngine.process(templatePath, context); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
EmailNotificationOrchestratoris a@Servicewith a required constructor dependency onEmailPublicationPublisher, but a repo-wide search (rginsrc/main/java) shows only the interface and no@Component/@Beanimplementation. In this commit, that leaves the Spring context unable to create the orchestrator (and thereforeHoldListDomainService), so the application will fail to start with an unsatisfied dependency.Useful? React with 👍 / 👎.