Titelbild: Smartphoneansicht einer Todo-Liste neben Infografik zur Antikorruptionsebene

Hexagonale Architektur in der Praxis: Umsetzung in einem Java/Spring Boot Projekt – Teil 3: REST-Schnittstelle mit Antikorruptionsschicht

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.

Nun möchten wir unsere Anwendung auch interaktiv nutzbar machen und zu diesem Zweck eine REST-Schnittstelle bereitstellen.
Genauer möchten wir

  • einen GET-Endpunkt /task, der uns alle existierenden Tasks zurückliefert,
  • einen POST-Endpunkt /task, der es uns erlaubt, einen neuen Task anzulegen und den angelegten Task zurückliefert, sowie
  • einen POST-Endpunkt /task/toggle-completion/{id}, der es uns erlaubt, den Zustand eines existierenden Tasks umzuschalten und den umgeschalteten Task zurückliefert.

Hierbei soll der POST-Endpunkt /task mit einem Parameter description aufgerufen werden können. Mögliche Requests sehen dann etwa folgendermaßen aus:

HTTP
GET http://localhost:8080/tasks
POST http://localhost:8080/tasks?description=Some%20Task
POST http://localhost:8080/tasks/toggle-completion/69676cbb-430c-4be8-a3f2-f15cf9e976c3

Außerdem wollen wir natürlich auch mit fehlerhaften Benutzereingaben (wie etwa leerer description oder unbekannter id) umgehen können und werden dazu eine sogenannte Antikorruptionsschicht zwischen unsere Kernlogik und den REST-Adapter schalten. In einem ersten Schritt wollen wir uns aber erst einmal wieder mit Datenmodellen befassen.

Ihr findet den aktuellen Stand des Projekts sowie die Änderungen auch aus diesem Teil auf GitHub.

Tasks mittels eines dedizierten Modells serialisierbar machen

Als wir unser Domänenobjekt Task modelliert haben, haben wir uns bewusst nicht mit technischen Belangen auseinandergesetzt, sondern uns komplett auf die Fachlichkeit fokussiert. Entsprechend ungeeignet ist dieses Modell daher als Austauschformat. Das ist aber nicht weiter problematisch: Wir definieren uns nun ein dediziertes Transfermodell TaskDto, das möglichst schlank und einfach (de-)serialisierbar sein soll, und konvertieren dann nach Bedarf zwischen Task und TaskDto hin und her.

Dieses Transfermodell ist ein technisches Detail unserer REST-Schnittstelle und wird daher auch Teil unseres REST-Adapters, den wir in diesem Abschnitt anlegen. Da nach unseren Architekturregeln keine andere Schicht und auch kein anderer Adapter auf diesen REST-Adapter zugreifen darf, ist hierdurch sichergestellt, dass wir das Transfermodell TaskDto einzig lokal in diesem Adapter verwenden und es sich nicht durch den Rest unserer Codebasis verbreiten wird.

Die Implementierung ist denkbar einfach gehalten:

Java
package de.colenet.hexagonal.todo.list.adapter.rest.model;

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

Statt eines Summentyps verwenden wir jetzt einen Produkttyp mit dem zusätzlichen Feld state als Diskriminator. Außerdem verwenden wir simple, serialisierbare Typen für alle Eigenschaften. Hierdurch gehen uns natürlich einige Garantien verloren, dafür erhalten wir eine flache, serialisierbare Struktur.

Um mit dem neu erzeugten Modell TaskDto arbeiten zu können, brauchen wir noch eine Möglichkeit, um einen Task in ein TaskDto umzuwandeln. Dazu bauen wir uns einen Mapper mit einer entsprechenden Methode TaskDto toDto(Task model). In dieser werden wir wieder Pattern Matching verwenden mit der Konvention, unbenutzte Parameter mit einem Unterstrich zu kennzeichnen:

Java
package de.colenet.hexagonal.todo.list.adapter.rest.mapper;

import ...; // Omitted for clarity

@Component
public class RestApiMapper {

    public TaskDto toDto(Task model) {
        return new TaskDto(
            model.id().toString(),
            model.description(),
            switch (model) {
                case OpenTask __ -> "open";
                case CompletedTask __ -> "completed";
            },
            switch (model) {
                case OpenTask __ -> null;
                case CompletedTask t -> t.completionTime().toString();
            }
        );
    }
}

Anlegen eines REST-Controllers

Nachdem wir uns über die Datenstrukturen im Klaren sind, können wir jetzt zur Implementierung der Endpunkte übergehen. Dazu starten wir mit einem Controller, der Zugriff auf unseren Service sowie auf den neu angelegten Mapper haben wird:

Java
package de.colenet.hexagonal.todo.list.adapter.rest.controller;

import ...; // Omitted for clarity

@RestController
class RestApiController {

    private final RestApiMapper restApiMapper;
    private final TaskService taskService;

    RestApiController(RestApiMapper restApiMapper, TaskService taskService) {
        this.restApiMapper = restApiMapper;
        this.taskService = taskService;
    }
}

Wir wollen uns in einem ersten Schritt um den GET-Endpunkt kümmern, der alle existierenden Tasks zurückliefern soll. Dazu rufen wir die entsprechende Methode in unserem Service auf, wandeln die Resultate mittels unseres Mappers in Dtos um und verpacken das Ergebnis schließlich in einer ResponseEntity. Die Methode annotieren wir schließlich noch mit @GetMapping, alles Weitere erledigt Spring dann automatisch im Hintergrund.

Java
@GetMapping(value = "/tasks", produces = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<List<TaskDto>> getAllTasks() {
    return createOkResponse(taskService.getAllTasks().stream().map(restApiMapper::toDto).toList());
}
    
private static <T> ResponseEntity<T> createOkResponse(T value) {
    return new ResponseEntity<>(value, HttpStatus.OK);
}

Für den Endpunkt zum Anlegen eines neuen Tasks gehen wir völlig analog vor, nur dass wir hier noch einen Parameter zu berücksichtigen haben:

Java
@PostMapping(value = "/tasks", produces = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<?> createTask(@RequestParam String description) {
    return createOkResponse(restApiMapper.toDto(taskService.createTask(description)));
}

Schließlich fehlt uns noch der Endpunkt zum Umschalten des Zustands eines existierenden Tasks. Auch hier verfolgen wir wieder das Schema, die entsprechende Logik im Service aufzurufen und das Resultat umzuwandeln, müssen jedoch auch beachten, dass wir diesmal erst unseren id Parameter von String zu UUID konvertieren müssen und wir außerdem mit der Situation, dass es keinen Task mit der angegebenen ID gibt, umgehen können müssen. In diesem Fall werden wir schlicht mit dem Status 400 Bad Request antworten.

Damit gestaltet sich die Methode folgendermaßen:

Java
@PostMapping(value = "/tasks/toggle-completion/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<?> toggleCompletionState(@PathVariable String id) {
    return taskService
        .toggleCompletionState(UUID.fromString(id))
        .map(restApiMapper::toDto)
        .map(RestApiController::createOkResponse)
        .orElseGet(RestApiController::createBadRequestResponse);
}
    
private static <T> ResponseEntity<T> createBadRequestResponse() {
    return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}

Hiermit haben wir nun eine voll funktionsfähige Anwendung!

Im GitHub Repository findet ihr außerdem noch Unit- und Integrationstests für unseren neuen Controller (RestApiControllerTest und RestApiControllerIntegrationTest).

Zusätzlich ist es uns jetzt auch möglich, End-To-End-Tests, die einen vollständigen Anwendungskontext hochfahren, eine Folge von REST-Requests absetzen und von der Anwendung entsprechende Antworten erwarten, zu schreiben. Diese möchte ich an dieser Stelle kurz demonstrieren, da sie auch demonstrieren, wie unsere Anwendung zu verwenden ist:

Java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class HexagonalToDoListApplicationEndToEndTests {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void returnsEmptyListIfNoTasksHaveBeenCreated() {
        var result = getAllTasks();

        assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(result.getBody()).isEmpty();
    }

    @Test
    void creatingTasksIsPossibleAndCreatedTasksAreReturnedFromGetAll() {
        List<String> descriptions = List.of("Some description", "Some other description");

        descriptions.forEach(this::createTask);

        var result = getAllTasks();

        assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(result.getBody())
            .usingRecursiveFieldByFieldElementComparatorIgnoringFields("id")
            .containsExactlyInAnyOrder(
                new TaskDto(null, "Some description", "open", null),
                new TaskDto(null, "Some other description", "open", null)
            );
        assertThat(result.getBody()).extracting(TaskDto::id).extracting(UUID::fromString).doesNotContainNull();
    }

    @Test
    void completionStateOfTasksCanBeToggledAndTogglingOnlyChangesStateAndCompletionTime() {
        var createdTask = createTask("Some description").getBody();
        assertThat(createdTask.state()).isEqualTo("open");

        var id = createdTask.id();

        var toggledTask = toggleTask(id).getBody();
        assertThat(toggledTask)
            .usingRecursiveComparison()
            .ignoringFields("state", "completionTime")
            .isEqualTo(createdTask);
        assertThat(toggledTask.state()).isEqualTo("completed");
        assertThat(toggledTask.completionTime()).isNotNull();

        var toggledTwiceTask = toggleTask(id).getBody();
        assertThat(toggledTwiceTask).isEqualTo(createdTask);

        var toggledThriceTask = toggleTask(id).getBody();
        assertThat(toggledThriceTask)
            .usingRecursiveComparison()
            .ignoringFields("completionTime")
            .isEqualTo(toggledTask);
        assertThat(LocalDateTime.parse(toggledThriceTask.completionTime()))
            .isAfter(LocalDateTime.parse(toggledTask.completionTime()));
    }

    private ResponseEntity<List<TaskDto>> getAllTasks() {
        return restTemplate.exchange("/tasks", HttpMethod.GET, null, new ParameterizedTypeReference<>() {});
    }

    private ResponseEntity<TaskDto> createTask(String description) {
        return restTemplate.postForEntity(
            UriComponentsBuilder.fromPath("/tasks").queryParam("description", description).build().toUri(),
            null,
            TaskDto.class
        );
    }

    private ResponseEntity<TaskDto> toggleTask(String id) {
        return restTemplate.postForEntity("/tasks/toggle-completion/{id}", null, TaskDto.class, Map.of("id", id));
    }

Was uns jetzt noch fehlt, ist die eingangs erwähnte Antikorruptionsschicht. Diese soll verhindern, dass Tasks mit leerer Beschreibung angelegt werden (was derzeit möglich wäre, aber unserer Fachlichkeit widerspricht) oder aber, dass der Benutzer versucht, den Toggle-Endpunkt mit einem id-Wert aufzurufen, der keine gültige UUID darstellt (was derzeit zu einer Exception führen würde).

Datenintegrität durch eine Antikorruptionsschicht

Der sicherlich gängigste Weg im Spring-Umfeld zur Sicherstellung der Datenintegrität an Endpunkten ist die Verwendung von Bean Validation. Diese erfüllt den Zweck vollkommen und ist für Produktivprojekte auch klar zu empfehlen. Da dieser Mechanismus aber weitestgehend auf der Verwendung von Annotationen basiert, möchte ich hier zur besseren Nachvollziehbarkeit stattdessen eine manuelle Implementierung vornehmen.

In der Theorie siedelt sich eine solche Antikorruptionsschicht zwischen Adapter und Kernlogik an und soll dafür sorgen, dass die Kernlogik stets nur mit validen Daten aufgerufen wird. Das hat den immensen Vorteil, dass wir uns dann dort niemals Gedanken um invalide Zustände machen müssen und daher dort auch nicht defensiv programmieren müssen. Eine solche Schicht macht daher nicht nur an der Grenze zu einem REST-Adapter Sinn, sondern überall, wo potenziell invalide Datensätze von der Außenwelt an uns herangetragen werden (wie etwa eine Datenbank, über die wir nicht die alleinige Kontrolle haben, Message-Queues, Lesen vom Dateisystem).

Antikorruptionsschicht: Zwischen Adapter und Kernlogik.

Praktisch wollen wir diese Validierung der Daten daher im entsprechenden Adapter unterbringen, da diese nur dort Sinn macht. Denn in anderen Adaptern haben wir vermutlich andere Austauschformate und benötigen daher andere Validierungen.

Dazu werden wir uns nun einen RestApiValidator unterhalb des REST-Adapter-Pakets in de.colenet.hexagonal.todo.list.adapter.rest.validator anlegen. Dieser soll uns zwei Methoden – validateId und validateDescription – anbieten, die jeweils einen unvalidierten String entgegennehmen und uns im Erfolgsfall eine validierte UUID, respektive einen validierten String, zurückliefern.

Hierzu verwenden wir die Validation API von Vavr, die es uns auf elegante Art und Weise erlaubt, solche Validierungen inklusive der Berücksichtigung von Fehlern zu implementieren. Insbesondere erlaubt uns diese API, mehrere fehlgeschlagene Validierungen zu kombinieren und so dem Benutzer direkt Rückmeldung über jeglichen invaliden Input zurückzumelden (hierbei sprechen wir von einem Fail-Slow Ansatz). Hierzu entschließen wir uns, da wir im nächsten Teil der Serie unser Domänenmodell erweitern wollen und wir dann für den Endpunkt zum Anlegen eines neuen Tasks mehrere Parameter entgegennehmen werden. Im Gegensatz zum Fail-Slow-Ansatz würde die – im Java-Umfeld gängige – Methode, bei fehlgeschlagener Validierung eine Exception zu werfen, nur den ersten Validierungsfehler zurückmelden (ein sogenannter Fail-Fast-Ansatz).

Randbemerkung: Bei dem von uns gewählten Fail-Slow-Ansatz handelt es sich um eine in der funktionalen Programmierung gängigen Technik. Das Konzept ist in diesem Kontext als Validierung mittels applikativer Funktoren geläufig.

Kommen wir nun also zur Umsetzung des Validators. Zur Validierung der description wollen wir nur überprüfen, dass sie nicht leer ist, und ansonsten eine entsprechende Fehlermeldung generieren:

Java
public Validation<String, String> validateDescription(String description) {
    if (!StringUtils.hasText(description)) {
        return Validation.invalid("Description is mandatory");
    }

    return Validation.valid(description);
}

Der Rückgabetyp Validation ist hierbei ein von Vavr bereitgestelltes Interface, welches als ersten Typparameter den Typ des Fehlerobjekts und als zweiten Typparameter den Typ des Erfolgsobjekts erwartet. Für die Validierung unserer ID wird der zweite Typparameter daher UUID sein und im nächsten Post werden wir zudem eine Liste von Strings als Fehlertyp sehen.

Die Validierung der ID folgt demselben Schema, hier müssen wir jedoch zusätzlich prüfen, dass der Eingabewert eine valide UUID darstellt.

Java
public Validation<String, UUID> validateId(String id) {
    if (!StringUtils.hasText(id)) {
        return Validation.invalid("ID is mandatory");
    }

    return Try.of(() -> UUID.fromString(id)).toValidation(__ -> "Not a valid UUID: " + id);
}

Mittels Try fangen wir hier die von UUID::fromString im Fehlerfall geworfene Exception und wandeln sie in ein Validation-Objekt mit entsprechender Fehlermeldung um.

Nun müssen wir diese Validierungen nur noch in unserem RestApiController jeweils vor dem Aufruf der Kernlogik unterbringen. Dazu injizieren wir eine Instanz restApiValidator des gerade entworfenen Validators in den Controller und erweitern unsere Methoden dort folgendermaßen:

Java
@PostMapping(value = "/tasks", produces = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<?> createTask(@RequestParam String description) {
    return restApiValidator
        .validateDescription(description)
        .map(taskService::createTask)
        .map(restApiMapper::toDto)
        .fold(RestApiController::createBadRequestResponse, RestApiController::createOkResponse);
}

@PostMapping(value = "/tasks/toggle-completion/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<?> toggleCompletionState(@PathVariable String id) {
    return restApiValidator
        .validateId(id)
        .map(taskService::toggleCompletionState)
        .fold(
            RestApiController::createBadRequestResponse,
            toggledTask ->
                toggledTask
                    .map(restApiMapper::toDto)
                    .<ResponseEntity<?>>map(RestApiController::createOkResponse)
                    .orElseGet(() -> createBadRequestResponse("No task found for id: " + id))
        );
}

private static ResponseEntity<String> createBadRequestResponse(String message) {
    return new ResponseEntity<>(message, HttpStatus.BAD_REQUEST);
}

Natürlich haben wir im Repository auch Unittests für den Validator mit aufgenommen und unsere bestehenden Tests erweitert.

Damit ist der erste Wurf unserer Anwendung komplett.
Probiert sie doch gerne mal aus: Legt Tasks an, lasst euch diese anzeigen, vervollständigt sie und setzt auch mal absichtlich Requests mit invaliden Daten ab, wie etwa:

HTTP
POST http://localhost:8080/tasks?description=%20
POST http://localhost:8080/tasks/toggle-completion/not-a-valid-uuid

Ausblick

Im kommenden Teil wollen wir uns anschauen, wie sich eine Anpassung am Domänenmodell auf die Anwendung auswirkt. Dazu werden wir unseren Tasks ein Fälligkeitsdatum hinzufügen. Dieses werden wir dann auch nutzen, um die bisher noch unberührte Applikationsschicht mit Leben zu füllen, indem wir uns einen geplanten Auftrag anlegen, der uns regelmäßig über fällige Tasks benachrichtigt. Außerdem wollen wir die Möglichkeit bieten, beim Anwendungsstart automatisch einige Beispieltasks zu erzeugen.

Ähnliche Beiträge

Schreibe einen Kommentar

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