Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions pom.xml
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
Comment on lines +23 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Provide a concrete EmailPublicationPublisher bean

EmailNotificationOrchestrator is a @Service with a required constructor dependency on EmailPublicationPublisher, but a repo-wide search (rg in src/main/java) shows only the interface and no @Component/@Bean implementation. In this commit, that leaves the Spring context unable to create the orchestrator (and therefore HoldListDomainService), so the application will fail to start with an unsatisfied dependency.

Useful? React with 👍 / 👎.

) {
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject duplicate model builders per notification type

The constructor populates buildersByType with put but never checks for an existing entry, so if two NotificationModelBuilder beans return the same NotificationType, one silently overwrites the other. That can route notifications through the wrong builder based on bean ordering, producing incorrect models or runtime payload-type errors; this should fail fast on duplicate registrations.

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);
}
}
Loading