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

## 1) Pattern summary
This code uses one shared orchestration flow and tiny per-notification model builders.

Why this keeps cognitive load low:
- One entry point: `EmailNotificationOrchestrator.notify(type, payload)`.
- One place for config lookup: `NotificationCatalog`.
- One place for rendering: `TemplateRenderer`.
- One place for publishing: `EmailPublicationPublisher`.
- Notification-specific logic is isolated to small builders (`buildModel`).

## 2) Package / file structure

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

## 3) Main runtime flow
1. Domain service calls orchestrator once.
2. Orchestrator loads config by `NotificationType`.
3. Registry picks the matching model builder.
4. Builder converts payload -> template model.
5. Shared renderer renders template.
6. Orchestrator builds `EmailPublicationRequest`.
7. Shared publisher interface publishes message.

## 4) application.yml example
See `src/main/resources/application.yml` for two notifications (`HOLD_LIST_CREATED`, `HOLD_LIST_REMOVED`).

## 5) Template examples
See:
- `src/main/resources/templates/notifications/hold-list-created.html`
- `src/main/resources/templates/notifications/hold-list-removed.html`

## 6) Example usage from a domain service
`HoldListDomainService` shows both create/remove use cases and calls one orchestrator method.

## 7) How to add a new notification
1. Add enum value to `NotificationType`.
2. Add YAML entry under `notifications.definitions`.
3. Add Thymeleaf template under `resources/templates/...`.
4. Add payload record (optional if existing payload works).
5. Add one `NotificationModelBuilder` implementation for that type.

No change is required in `EmailNotificationOrchestrator` or shared renderer/publisher logic.

## 8) Focused unit tests
- `EmailNotificationOrchestratorTest`
- `NotificationModelBuilderRegistryTest`

## 9) Tradeoffs
This is intentionally boring and explicit:
- Better than ad hoc per-notification services because render/publish/config logic is centralized.
- Better than huge switches because type-specific logic stays in focused builders.
- Slightly less compile-time type safety in orchestrator payload parameter, but much easier day-2 maintenance and extension.
48 changes: 48 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<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>java-test-zone</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>java-test-zone</name>
<description>Notification orchestration pattern example</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-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 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,54 @@
package com.example.notifications.application;

import com.example.notifications.config.NotificationCatalog;
import com.example.notifications.config.NotificationDefinition;
import com.example.notifications.domain.NotificationPayload;
import com.example.notifications.domain.NotificationType;
import com.example.notifications.model.NotificationModelBuilderRegistry;
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 catalog;
private final NotificationModelBuilderRegistry builderRegistry;
private final TemplateRenderer templateRenderer;
private final EmailPublicationPublisher publisher;

public EmailNotificationOrchestrator(
NotificationCatalog catalog,
NotificationModelBuilderRegistry builderRegistry,
TemplateRenderer templateRenderer,
EmailPublicationPublisher publisher

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 this commit only adds the interface and no @Component/@Bean implementation in src/main. As a result, booting NotificationApplication in this repo will fail during context initialization with an unsatisfied dependency before any notification flow can run.

Useful? React with 👍 / 👎.

) {
this.catalog = catalog;
this.builderRegistry = builderRegistry;
this.templateRenderer = templateRenderer;
this.publisher = publisher;
}

public void notify(NotificationType type, NotificationPayload payload) {
NotificationDefinition definition = catalog.getRequired(type);

if (!definition.isEnabled()) {
return;
}

Map<String, Object> model = builderRegistry.buildModel(type, payload);
String body = templateRenderer.render(definition.templatePath(), model);

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

publisher.publish(request);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.example.notifications.application;

import com.example.notifications.domain.HoldListCreatedPayload;
import com.example.notifications.domain.HoldListRemovedPayload;
import com.example.notifications.domain.NotificationType;
import org.springframework.stereotype.Service;

import java.time.LocalDate;

@Service
public class HoldListDomainService {

private final EmailNotificationOrchestrator orchestrator;

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

public void createHoldList(String holdListName, String createdBy) {
// domain behavior omitted
orchestrator.notify(
NotificationType.HOLD_LIST_CREATED,
new HoldListCreatedPayload(holdListName, createdBy, LocalDate.now())
);
}

public void removeHoldList(String holdListName, String removedBy, String reason) {
// domain behavior omitted
orchestrator.notify(
NotificationType.HOLD_LIST_REMOVED,
new HoldListRemovedPayload(holdListName, removedBy, reason)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.example.notifications.builder;

import com.example.notifications.domain.HoldListCreatedPayload;
import com.example.notifications.domain.NotificationType;
import com.example.notifications.model.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(),
"effectiveDate", payload.effectiveDate()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.example.notifications.builder;

import com.example.notifications.domain.HoldListRemovedPayload;
import com.example.notifications.domain.NotificationType;
import com.example.notifications.model.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.notifications.config;

import com.example.notifications.domain.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.getDefinitions().get(type);
if (definition == null) {
throw new IllegalArgumentException("No notification definition configured 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,21 @@
package com.example.notifications.config;

import com.example.notifications.domain.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> definitions = new EnumMap<>(NotificationType.class);

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

public void setDefinitions(Map<NotificationType, NotificationDefinition> definitions) {
this.definitions = definitions == null ? new EnumMap<>(NotificationType.class) : new EnumMap<>(definitions);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.notifications.domain;

import java.time.LocalDate;

public record HoldListCreatedPayload(
String holdListName,
String createdBy,
LocalDate effectiveDate
) implements NotificationPayload {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.notifications.domain;

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

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

public enum NotificationType {
HOLD_LIST_CREATED,
HOLD_LIST_REMOVED
}
Loading