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
68 changes: 67 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,67 @@
# nginx-remote-app-poc
# Email Notification Orchestrator Pattern (Spring Boot)

A minimal, boring pattern for publishing email notification requests to another delivery service.

## Why this lowers cognitive load

- One entry point (`EmailNotificationOrchestrator`) for all notification publishing.
- One typed config map (`NotificationProperties`) for all notification definitions.
- One shared render path (`TemplateRenderer`).
- One shared publish path (`EmailPublicationPublisher`).
- Per-notification code is only a tiny `NotificationModelBuilder` that maps payload -> template model.

## Package structure

```text
com.example.notifications
├── builder
│ ├── HoldListCreatedModelBuilder
│ ├── HoldListRemovedModelBuilder
│ └── NotificationModelBuilder
├── config
│ ├── NotificationCatalog
│ ├── NotificationDefinition
│ ├── NotificationProperties
│ └── NotificationType
├── domain
│ └── HoldListDomainService
├── model
│ ├── HoldListCreatedPayload
│ ├── HoldListRemovedPayload
│ └── NotificationPayload
├── orchestration
│ ├── EmailNotificationOrchestrator
│ └── NotificationModelBuilderRegistry
├── publish
│ ├── EmailPublicationPublisher
│ ├── EmailPublicationRequest
│ └── LoggingEmailPublicationPublisher
└── render
├── TemplateRenderer
└── ThymeleafTemplateRenderer
```

## Runtime flow

1. Domain service calls orchestrator.
2. Orchestrator loads notification definition from catalog.
3. Orchestrator resolves model builder from registry.
4. Builder maps payload to model.
5. Shared renderer renders Thymeleaf template.
6. Orchestrator builds `EmailPublicationRequest`.
7. Shared publisher publishes request.

## How to add a notification

1. Add enum value to `NotificationType`.
2. Add YAML config entry under `notifications.definitions`.
3. Add Thymeleaf template file under `resources/templates`.
4. Add payload record (if needed).
5. Add one `NotificationModelBuilder` for model mapping.
6. Call orchestrator from domain code.

No orchestration flow changes required.

## Tradeoff vs ad-hoc per-notification services

This pattern avoids duplicated render/publish code and avoids giant switch statements in service methods. It also avoids creating one full service pipeline per notification. You get one stable flow with small, obvious extension points.
52 changes: 52 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<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 https://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.5</version>
<relativePath/>
</parent>

<groupId>com.example</groupId>
<artifactId>email-notifications</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>email-notifications</name>
<description>Email notification orchestrator pattern sample</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,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 EmailNotificationsApplication {

public static void main(String[] args) {
SpringApplication.run(EmailNotificationsApplication.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.example.notifications.builder;

import com.example.notifications.config.NotificationType;
import com.example.notifications.model.HoldListCreatedPayload;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
public class HoldListCreatedModelBuilder implements NotificationModelBuilder<HoldListCreatedPayload> {

@Override
public NotificationType supportsType() {
return NotificationType.HOLD_LIST_CREATED;
}

@Override
public Class<HoldListCreatedPayload> payloadType() {
return HoldListCreatedPayload.class;
}

@Override
public Map<String, Object> buildModel(HoldListCreatedPayload payload) {
return Map.of(
"holdListName", payload.holdListName(),
"createdBy", payload.createdBy(),
"impactedAccounts", payload.impactedAccounts()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.example.notifications.builder;

import com.example.notifications.config.NotificationType;
import com.example.notifications.model.HoldListRemovedPayload;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
public class HoldListRemovedModelBuilder implements NotificationModelBuilder<HoldListRemovedPayload> {

@Override
public NotificationType supportsType() {
return NotificationType.HOLD_LIST_REMOVED;
}

@Override
public Class<HoldListRemovedPayload> payloadType() {
return HoldListRemovedPayload.class;
}

@Override
public Map<String, Object> buildModel(HoldListRemovedPayload payload) {
return Map.of(
"holdListName", payload.holdListName(),
"removedBy", payload.removedBy(),
"removalReason", payload.removalReason()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.example.notifications.builder;

import com.example.notifications.config.NotificationType;
import com.example.notifications.model.NotificationPayload;

import java.util.Map;

public interface NotificationModelBuilder<T extends NotificationPayload> {

NotificationType supportsType();

Class<T> payloadType();

Map<String, Object> buildModel(T payload);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.notifications.config;

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("Missing notification configuration for type: " + type);
}
return definition;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.example.notifications.config;

import java.util.List;

public record NotificationDefinition(
List<String> recipients,
String templatePath,
String subject,
Boolean enabled
) {

public NotificationDefinition {
recipients = recipients == null ? List.of() : List.copyOf(recipients);
}

public boolean isEnabled() {
return enabled == null || enabled;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.example.notifications.config;

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> definitions = new EnumMap<>(NotificationType.class);

public Map<NotificationType, NotificationDefinition> getDefinitions() {
return definitions;
}

public void setDefinitions(Map<NotificationType, NotificationDefinition> definitions) {
this.definitions = definitions;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.example.notifications.config;

public enum NotificationType {
HOLD_LIST_CREATED,
HOLD_LIST_REMOVED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.example.notifications.domain;

import com.example.notifications.config.NotificationType;
import com.example.notifications.model.HoldListCreatedPayload;
import com.example.notifications.model.HoldListRemovedPayload;
import com.example.notifications.orchestration.EmailNotificationOrchestrator;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class HoldListDomainService {

private final EmailNotificationOrchestrator notificationOrchestrator;

public HoldListDomainService(EmailNotificationOrchestrator notificationOrchestrator) {
this.notificationOrchestrator = notificationOrchestrator;
}

public void createHoldList(String name, String actor, List<String> impactedAccounts) {
HoldListCreatedPayload payload = new HoldListCreatedPayload(name, actor, impactedAccounts);
notificationOrchestrator.publish(NotificationType.HOLD_LIST_CREATED, payload, HoldListCreatedPayload.class);
}

public void removeHoldList(String name, String actor, String reason) {
HoldListRemovedPayload payload = new HoldListRemovedPayload(name, actor, reason);
notificationOrchestrator.publish(NotificationType.HOLD_LIST_REMOVED, payload, HoldListRemovedPayload.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.notifications.model;

import java.util.List;

public record HoldListCreatedPayload(
String holdListName,
String createdBy,
List<String> impactedAccounts
) implements NotificationPayload {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.notifications.model;

public record HoldListRemovedPayload(
String holdListName,
String removedBy,
String removalReason
) implements NotificationPayload {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.example.notifications.model;

public interface NotificationPayload {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.example.notifications.orchestration;

import com.example.notifications.builder.NotificationModelBuilder;
import com.example.notifications.config.NotificationCatalog;
import com.example.notifications.config.NotificationDefinition;
import com.example.notifications.config.NotificationType;
import com.example.notifications.model.NotificationPayload;
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 notificationCatalog;
private final NotificationModelBuilderRegistry registry;
private final TemplateRenderer templateRenderer;
private final EmailPublicationPublisher publisher;

public EmailNotificationOrchestrator(NotificationCatalog notificationCatalog,
NotificationModelBuilderRegistry registry,
TemplateRenderer templateRenderer,
EmailPublicationPublisher publisher) {
this.notificationCatalog = notificationCatalog;
this.registry = registry;
this.templateRenderer = templateRenderer;
this.publisher = publisher;
}

public <T extends NotificationPayload> void publish(NotificationType type, T payload, Class<T> payloadType) {
NotificationDefinition definition = notificationCatalog.getRequired(type);
if (!definition.isEnabled()) {
return;
}

NotificationModelBuilder<T> builder = registry.getTyped(type, payloadType);
Map<String, Object> model = builder.buildModel(payload);
String body = templateRenderer.render(definition.templatePath(), model);

EmailPublicationRequest request = new EmailPublicationRequest(
type,
definition.recipients(),
definition.subject(),
body
);

publisher.publish(request);
}
}
Loading