Hexagonale Architektur in der Praxis: Umsetzung in einem Java/Spring Boot Projekt – Teil 4: Folgen einer Änderung am Domänenmodell und die Applikationsschicht
Diese Artikel-Reihe ist der hexagonalen Architektur gewidmet: Wir möchten ihre Grundprinzipien verstehen und diese in der Praxis erleben. Wir werden uns anschauen, wie sich eine hexagonale Architektur in einer typischen Webanwendung praktisch realisieren lässt. Dazu werden wir Schritt für Schritt eine kleine To-do-Listen-Anwendung entwickeln, die es ermöglichen soll, Tasks anzulegen, angelegte Tasks anzuzeigen und Tasks als erledigt zu markieren. Die Anwendung wird über eine REST-Schnittstelle bedienbar sein und die Tasks werden konfigurierbar entweder In-Memory oder persistent in einer echten MongoDB verwaltet.
Im ersten Teil dieser Serie haben wir ein frisches Java/Spring-Boot-Projekt aufgesetzt und konfiguriert. Wir haben uns überlegt, wie wir uns die Architektur der Anwendung und ihre Umsetzung vorstellen und diese Vorstellung mittels ArchUnit in einem automatisierten Architekturtest festgehalten.
Im zweiten Teil sind wir mit der Modellierung der Domäne, der Implementierung der Kernlogik und der Datenhaltung der Anwendung in die eigentliche Entwicklung gestartet.
Im dritten Teil haben wir alle grundlegenden Funktionalitäten unserer Anwendung implementiert, sodass diese bereits nutzbar ist.
Nun möchten wir uns einerseits anschauen, welche Auswirkungen eine Anpassung am Domänenmodell auf die bestehende Anwendung hat und außerdem die bis dato noch unbeachtete Applikationsschicht mit einer geplanten Aufgabe zum regelmäßigen Senden von Benachrichtigungen füllen sowie der Möglichkeit, beim Anwendungsstart automatisch einige Beispieltasks zu erzeugen.
Ihr findet den aktuellen Stand des Projekts sowie die Änderungen aus diesem Teil auf GitHub.
Erweiterung des Domänenmodells
Unsere Anforderung soll recht simpel gehalten werden. Wir wollen unserem Task eine neue Eigenschaft Fälligkeitsdatum hinzufügen. Hierbei soll es sich um eine optionale Eigenschaft handeln und sie soll sowohl bei offenen als auch (zur Nachvollziehbarkeit) bei geschlossenen Tasks vorhanden sein.
In einem ersten Schritt wollen wir nur diese neue Eigenschaft hinzufügen und uns anschauen, an welchen Stellen unserer Anwendung wir dazu alles Anpassungen vornehmen müssen. Im Anschluss daran werden wir unsere Anwendung dann um automatische Fälligkeitsbenachrichtigungen erweitern.
Die Erweiterung unserer Modells gestaltet sich denkbar einfach – wir fügen lediglich die neue Eigenschaft in den beiden record
s OpenTask
und CompletedTask
hinzu. Außerdem nehmen wir sie in das Interface Task
mit auf:
public sealed interface Task {
UUID id();
String description();
Optional<LocalDate> dueDate();
record OpenTask(UUID id, String description, Optional<LocalDate> dueDate) implements Task {}
record CompletedTask(UUID id, String description, Optional<LocalDate> dueDate, LocalDateTime completionTime)
implements Task {}
}
Jetzt können wir uns vom Typsystem und dem Compiler leiten lassen. Als Erstes fällt uns direkt auf, dass den Konstruktoraufrufen in TaskService
nun einen Parameter fehlt. Dies beheben wir, indem wir einerseits in withToggledCompletionState
den jeweils neuen Parameter mit übergeben
private Task withToggledCompletionState(Task task) {
return switch (task) {
case OpenTask t -> new CompletedTask(t.id(), t.description(), t.dueDate(), LocalDateTime.now(clock));
case CompletedTask t -> new OpenTask(t.id(), t.description(), t.dueDate());
};
}
und andererseits in createTask
die Methodensignatur um einen entsprechenden Parameter erweitern und diesen weiterreichen:
public Task createTask(String description, Optional<LocalDate> dueDate) {
return taskRepository.save(new OpenTask(UUID.randomUUID(), description, dueDate));
}
Durch diese Signaturänderung wird nun natürlich der Aufruf dieser
Methode aus RestApiController
heraus ungültig.
Um das zu beheben, müssen wir uns ein wenig mehr anstrengen. Wir erweitern auch hier wieder die Signatur, um den neuen Parameter auch im REST-Aufruf mitgeben zu können:
ResponseEntity<?> createTask(@RequestParam String description, @RequestParam @Nullable String dueDate)
Auffällig ist hier der Typ @Nullable String
. Diesen wählen wir, damit unser Endpunkt auch weiterhin ohne diesen Parameter aufrufbar ist und wir Serialisierbarkeit gewährleisten. Im Gegensatz dazu haben wir uns im Domänenmodell für den Typ Optional<LocalDate>
entschieden, der einerseits mit LocalDate
spezifischer als String
ist und andererseits die Optionalität der Eigenschaft durch Optional
explizit macht und dadurch Benutzer dazu zwingt, damit umzugehen. Als Nächstes erweitern wir unseren RestApiValidator
. Hier wollen wir die bestehende Methode
Validation<String, String> validateDescription(String description)
ablösen durch:
Validation<Seq<String>, Tuple2<String, Optional<LocalDate>>> validateCreateTaskParameters(
String description,
String dueDate
)
Diese soll uns also im Erfolgsfall ein Tupel bestehend aus der, wie
bisher validierten description
, aber erweitert um ein
validiertes und in den erwarteten Typ umgewandeltes dueDate
zurückgeben. Im Fehlerfall wollen wir außerdem
Seq<String>
statt nur String
zurückgeben, um gegebenenfalls für beide Validierungen eine
Fehlernachricht bereitstellen zu können. Bei Seq
(für
Sequence) handelt es sich hierbei um einen von Vavr
bereitgestellten Typ.
Hierzu bauen wir uns erst eine dedizierte Validierungsfunktion für das dueDate
, die uns entweder ein leeres Optional
oder aber das geparste Datum liefert:
private static Validation<String, Optional<LocalDate>> validateDueDate(String dueDate) {
if (!StringUtils.hasText(dueDate)) {
return Validation.valid(Optional.empty());
}
return Try
.success(dueDate)
.mapTry(LocalDate::parse)
.map(Optional::of)
.toValidation(__ -> "Due date has to be in format yyyy-MM-dd: " + dueDate);
}
Diese Methode kombinieren wir dann mit validateDescription
zu:
public Validation<Seq<String>, Tuple2<String, Optional<LocalDate>>> validateCreateTaskParameters(
String description,
String dueDate
) {
return Validation.combine(validateDescription(description), validateDueDate(dueDate)).ap(Tuple::of);
}
Schließlich passen wir noch den Aufruf in RestApiController::createTask
an, indem wir die bestehende Implementierung
return restApiValidator
.validateDescription(description)
.map(taskService::createTask)
.map(restApiMapper::toDto)
.fold(RestApiController::createBadRequestResponse, RestApiController::createOkResponse);
ersetzen durch:
return restApiValidator
.validateCreateTaskParameters(description, dueDate)
.map(Function2.of(taskService::createTask).tupled())
.map(restApiMapper::toDto)
.mapError(messages -> String.join("; ", messages))
.fold(RestApiController::createBadRequestResponse, RestApiController::createOkResponse);
Wir validieren also nun beide Parameter und reichen diese dann an den Service weiter. Im Fehlerfall konkatenieren wir außerdem alle Nachrichten zu einer einzigen Nachricht, die wir dann in die Response aufnehmen.
Sofern wir auch unsere Testklassen entsprechend angepasst haben, ist unsere Anwendung nun wieder kompilierbar und nutzbar. Auch ist es uns jetzt möglich, Tasks mit einem Fälligkeitsdatum via REST-Aufruf anzulegen. Auffällig ist aber, dass alle Antworten unserer Endpunkte kein Fälligkeitsdatum mitliefern.
In erster Linie gilt hier aber völlig unironisch das Motto It’s not a bug, it’s a feature. Dieser Umstand zeigt nämlich, dass wir nicht aus Versehen implizit unsere Schnittstelle verändert haben, sondern dies durch die Entkopplung von Task
zu TaskDto
eine bewusste Entscheidung sein muss. Da wir besagte Änderung an dieser Stelle aber natürlich gutheißen, fügen wir die neue Eigenschaft in einem letzten Schritt noch zu unserem Austauschmodell hinzu
public record TaskDto(String id, String description, String dueDate, String state, String completionTime) {}
und erweitern die Methode RestApiMapper::toDto
um eine entsprechende Zeile:
model.dueDate().map(LocalDate::toString).orElse(null)
Damit enthalten schließlich auch alle Antworten unserer Endpunkte die neue Eigenschaft. Im folgenden Abschnitt wollen wir das neu hinzugefügte Fälligkeitsdatum nutzen, um automatisiert Fälligkeitsbenachrichtigungen zu versenden.
Die Applikationsschicht: Automatische Fälligkeitsbenachrichtigungen
Auch hier wollen wir die Anforderungen zu Demonstrationszwecken
möglichst simpel halten: Wir wollen in regelmäßigen Abständen all unsere
offenen Tasks anschauen, prüfen, ob das Fälligkeitsdatum überschritten
ist, und in diesem Fall eine Benachrichtigung senden. Dazu
implementieren wir eine geplante Aufgabe mittels der von Spring
bereitgestellten @Scheduled
Annotation und lassen diese in
einem fixen Intervall laufen.
Solche geplanten Aufgaben sind ein typisches Beispiel für Code, den wir in der Applikationsschicht unterbringen. Denn weder handelt es sich hierbei um Kernlogik, sodass die Domäne der falsche Ort dafür ist, noch haben wir hier eine Anbindung an die Außenwelt, was für einen Adapter sprechen würde. Andere typische Themen, die in der Applikationsschicht ihren Platz finden, sind etwa Transaktionsmanagement, die Zusammenführung mehrerer Teile der Kernlogik zu einem Workflow oder einmalige Aufgaben, die beim Anwendungsstart erledigt werden müssen (hierfür sehen wir später noch ein Beispiel).
Wir starten also mit einer neuen Klasse
package de.colenet.hexagonal.todo.list.application.scheduler;
@Component
class DueNotificationScheduler {}
in der wir eine Methode
@Scheduled(fixedRateString = "${notification.interval}", timeUnit = TimeUnit.SECONDS)
void sendDueNotifications()
implementieren möchten. Das Benachrichtigungsintervall wollen wir
hierbei konfigurierbar halten und legen dafür den Parameter
notification.interval
in den
application.properties
an und belegen diesen dort mit einem
geeigneten Wert, etwa notification.interval=60
für eine
minütliche Benachrichtigung.
Außerdem müssen wir den Scheduling-Mechanismus von Spring überhaupt erst aktivieren, indem wir unsere Einstiegsklasse mit @EnableScheduling
annotieren:
@SpringBootApplication
@EnableScheduling
class HexagonalToDoListApplication
Damit haben wir die Grundlagen geschaffen und können uns jetzt dem
Inhalt der Methode sendDueNotifications
widmen. Diese soll
erst alle fälligen, offenen Tasks laden und dann für jede davon eine
Benachrichtigung senden.
Zum Laden der Tasks erweitern wir unseren TaskService
um eine Methode
List<OpenTask> getAllOpenTasksWithDueDateBeforeOrEqual(LocalDate date)
Diese soll, wie der Name sagt, alle offenen Tasks zurückgeben, die ein Fälligkeitsdatum vor oder gleich dem angegebenen Datum haben. Auf die genaue Umsetzung wollen wir an dieser Stelle nicht genau eingehen (wir würden dabei nichts Neues sehen). Die Grundidee ist aber natürlich, auch eine entsprechende Methode im Repository anzubieten und diese basierend auf unserem Cache umzusetzen. Die Details dazu könnt ihr euch gerne im GitHub Repository anschauen.
Das Senden der Benachrichtigungen wollen wir dann wiederrum in entsprechende Adapter auslagern. Dazu definieren wir uns wie gehabt einen Port
package de.colenet.hexagonal.todo.list.application.scheduler;
import de.colenet.hexagonal.todo.list.domain.model.task.Task.OpenTask;
public interface DueNotificationSender {
void sendDueNotification(OpenTask task);
}
den wir dann später durch einen konkreten Adapter realisieren.
Erst einmal sind wir jetzt aber in der Lage, den Scheduler zu vervollständigen:
@Component
class DueNotificationScheduler {
private static final Logger LOGGER = LoggerFactory.getLogger(DueNotificationScheduler.class);
private final Clock clock;
private final DueNotificationSender dueNotificationSender;
private final TaskService taskService;
DueNotificationScheduler(Clock clock, DueNotificationSender dueNotificationSender, TaskService taskService) {
this.clock = clock;
this.dueNotificationSender = dueNotificationSender;
this.taskService = taskService;
}
@Scheduled(fixedRateString = "${notification.interval}", timeUnit = TimeUnit.SECONDS)
void sendDueNotifications() {
LOGGER.info("Sending due notifications");
taskService
.getAllOpenTasksWithDueDateBeforeOrEqual(LocalDate.now(clock))
.forEach(dueNotificationSender::sendDueNotification);
LOGGER.info("Finished sending due notifications");
}
}
Um auf den ersten Blick sehen zu können, wann unsere Aufgabe ausgeführt wird, haben wir hier außerdem noch ein paar Logeinträge hinzugefügt.
Es fehlt uns jetzt also nur noch ein konkreter Adapter, der den eben definierten Port realisiert. Hierfür haben wir uns alle Möglichkeiten offen gelassen: Wir könnten eine E-Mail versenden, unseren favorisierten Chat-Client wie etwa Slack verwenden, Push-Benachrichtigungen auf Mobilgeräten erzeugen oder was auch immer wir sonst möchten.
Hier wählen wir aber die denkbar einfachste Variante und wollen als „Benachrichtigung” einfach den fälligen Task loggen. Dazu registrieren wir zuerst in unserem Architekturtest einen neuen Adapter:
.adapter("console", getAdapterIdentifier("console"))
Außerdem vervollständigen wir an dieser Stelle noch ein offenes To-do und entfernen folgende Zeile
.withOptionalLayers(true) // TODO Remove this as soon as our layers are filled
denn all unsere Schichten sind inzwischen mit Inhalt gefüllt (mal
abgesehen von dem gerade registrierten console
-Adapter,
aber dazu kommen wir nun).
Wie bereits erwähnt, wollen wir in diesem neuen Adapter einfach einen Logeintrag als Benachrichtigung erzeugen. Dazu ist folgende Implementierung völlig ausreichend:
package de.colenet.hexagonal.todo.list.adapter.console;
import ...; // Omitted for clarity
@Component
class DueNotificationLogger implements DueNotificationSender {
private static final Logger LOGGER = LoggerFactory.getLogger(DueNotificationLogger.class);
public void sendDueNotification(OpenTask task) {
LOGGER.warn("Task is due: {}", task);
}
}
Starten wir nun unsere Anwendung und legen uns einen offenen Task mit Fälligkeitsdatum in der Vergangenheit an (etwa via POST http://localhost:8080/tasks?description=Due&dueDate=2023-07-01
), so sehen wir innerhalb kürzester Zeit (natürlich abhängig vom Parameter notification.interval
) folgende Logeinträge (zur Übersichtlichkeit aufs Wesentliche reduziert):
INFO: Sending due notifications
WARN: Task is due: OpenTask[id=5d3fcb2d-b8c9-47d2-9ba9-c95e7be05b65, description=Due, dueDate=Optional[2023-07-01]]
INFO: Finished sending due notifications
Abschließend wollen wir noch aus Bequemlichkeit zum Anwendungsstart automatisch einige Beispieltasks anlegen, um unsere neuen Benachrichtigungen sofort beobachten zu können, ohne manuell fällige Tasks anlegen zu müssen.
Erzeugung von Beispieltasks
Wie erwähnt handelt es sich auch hierbei um eine Aufgabe, die sich typischerweise in der Applikationsschicht ansiedelt. Hierzu bauen wir uns lediglich eine neue Klasse ExampleTaskCreator
in de.colenet.hexagonal.todo.list.application.startup
, die eine Handvoll Task
-Objekte erzeugt und diese dann mittels
TaskRepository::save
abspeichert. Außerdem wollen wir dieses Verhalten dynamisch ein- und ausschaltbar machen und legen uns dazu eine neue Eigenschaft startup.exampletasks.create
in den application.properties
an, die wir mit true
vorkonfigurieren. Da wir diesen Prozess einmalig nach dem Anwendungsstart ausführen wollen, kennzeichnen wir die entsprechende Methode außerdem mit @EventListener(ApplicationReadyEvent.class)
. Alles in allem landen wir bei folgender Implementierung:
@Component
@ConditionalOnProperty(prefix = "startup", name = "exampletasks.create", havingValue = "true")
class ExampleTaskCreator {
private static final Logger LOGGER = LoggerFactory.getLogger(ExampleTaskCreator.class);
private final TaskRepository taskRepository;
ExampleTaskCreator(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@EventListener(ApplicationReadyEvent.class)
public void createExampleTasks() {
LOGGER.info("Creating some example tasks");
Stream
.of(
new CompletedTask(
UUID.randomUUID(),
"[EXAMPLE] This task was completed yesterday",
Optional.empty(),
LocalDateTime.now().minusDays(1L)
),
// ... more example tasks
)
.forEach(taskRepository::save);
LOGGER.info("Example tasks successfully created");
}
}
Ausblick
Damit sind wir fast am Ende der Reihe angelangt. Unsere Applikation beinhaltet nun jegliche beabsichtigte Funktionalität und wir haben alle Schichten der hexagonalen Architektur in Verwendung gesehen.
In nächsten und gleichzeitig letzten Teil der Reihe wollen wir uns schließlich noch anschauen, wie wir unseren In-Memory Cache durch eine echte, persistente Datenbank ersetzen können. Dazu werden wir exemplarisch eine MongoDB verwenden. Konzeptuell lässt sich die Vorgehensweise aber natürlich auch auf andere Datenbanken übertragen.
Alle Folgen der Reihe
„Hexagonale Architektur in der Praxis: Umsetzung in einem Java/Spring-Boot-Projekt“
Teil 1: Projektbeschreibung, Setup und automatische Architekturtests
Teil 2: Modellierung, Kernlogik und In-Memory Cache
Teil 3: REST-Schnittstelle mit Antikorruptionsschicht
Teil 4: Folgen einer Änderung am Domänenmodell und die Applikationsschicht
Teil 5: Anbindung der Datenbank (am Beispiel einer MongoDB)
Fragen, Anmerkungen oder Austausch zum Thema gewünscht?
Nutzt gerne die Kommentarfunktion unter dem Beitrag und Ricardo meldet sich bei euch zurück.