To-Do-Listen-App auf einem Smartphone neben einer schematischen Darstellung der Domainenänderung

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 records OpenTask und CompletedTask hinzu. Außerdem nehmen wir sie in das Interface Task mit auf:

Java
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

Java
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:

Java
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:

Java
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

Java
Validation<String, String> validateDescription(String description)

ablösen durch:

Java
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:

Java
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:

Java
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

Java
return restApiValidator
    .validateDescription(description)
    .map(taskService::createTask)
    .map(restApiMapper::toDto)
    .fold(RestApiController::createBadRequestResponse, RestApiController::createOkResponse);

ersetzen durch:

Java
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

Java
public record TaskDto(String id, String description, String dueDate, String state, String completionTime) {}

und erweitern die Methode RestApiMapper::toDto um eine entsprechende Zeile:

Java
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

Java
package de.colenet.hexagonal.todo.list.application.scheduler;

@Component
class DueNotificationScheduler {}

in der wir eine Methode

Java
@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:

Java
@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

Java
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

Java
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:

Java
@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:

Java
.adapter("console", getAdapterIdentifier("console"))

Außerdem vervollständigen wir an dieser Stelle noch ein offenes To-do und entfernen folgende Zeile

Java
.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:

Java
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):

Plaintext
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:

Java
@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.

Bisherige 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

Ähnliche Beiträge

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert