Zur Strukturierung von Software existiert eine Vielzahl von Architekturmustern. Unter dem Begriff Clean Architecture lassen sich mehrere davon zusammenfassen, welche sich zwar im Detail unterscheiden, beim Ziel allerdings auf das gleiche setzen: Trennung von Verantwortlichkeiten. Im Ideal wird dadurch eine Software erreicht, die sich langfristig einfach warten und erweitern lässt. In diesem Artikel möchte ich neben den allgemeinen Prinzipien der Clean Architecture ebenso anhand des Architekturmusters “Hexagonale Architektur“ ins Detail gehen. Über ein Praxisbeispiel werden wir einige Vor- und Nachteile dieser Vorgehensweise sehen. Das Beispiel verwendet C# / ASP.NET Core im Backend und APS.NET Core Blazor Webassembly im Frontend.
Inhaltsverzeichnis
Bevor wir starten
Wenn es um Architekturthemen geht, ist mir zunächst eines wichtig: Es gibt nicht die eine Musterlösung. Architekturstile, -Muster und -Patterns können für einen Einsatzzweck passen oder auch nicht. Aus diesem Grund möchte ich das hier beschriebene Beispiel auch nicht als Musterlösung für alle Applikationen verstanden wissen. Ich sehe es viel zu häufig, dass einige Patterns verwendet werden, mehr damit man sie hat oder man sauber ist und weniger, weil sie tatsächlich echte Vorteile für das Projekt bringen. Anhand der Vor- und Nachteile der Hexagonalen Architektur möchte ich in diesem Artikel auf einige Szenarien eingehen, in denen man Hexagonale Architektur gut verwenden kann.
Klassische Herangehensweise in Schichten
Wir kennen es alle: Klassischerweise teilt man Software gerne in mehrere Schichten auf. Sehr bekannt ist dabei das 3-Schichten Modell mit Präsentation-, Business-Logik- und Persistenz-Schicht. Verweise gehen dabei immer von oben nach unten und nicht anders herum. Die Präsentations-Schicht verwendet somit die Business-Logik-Schicht, die Business-Logik-Schicht wiederum verwendet die Persistenz-Schicht. Grundsätzlich ist das auch ein guter Weg, es ergeben sich aber auch einige Nachteile:
- Business-Logik hat direkte Abhängigkeit zu darunter liegenden Schichten
- Persistenz-Schicht bildet primär die Datenbank ab, es gibt aber noch andere Aufgaben, die unterhalb der Business-Logik sind. Beispielsweise sind das Logging, Anbindung fremder Webservices oder das Erzeugen von Notifications
- Die Tatsache, dass die Business-Logik-Schicht von der Persistenz-Schicht abhängig ist, erschwert die Erstellung von automatisierten Tests
Das Dependency Inversion Principle
Mit dem Dependency Inversion Principle (DIP) kann man einen etwas anderen Weg gehen. Hierbei ist es möglich, die Richtung der Abhängigkeit zwischen Business-Logik und Persistenz umzudrehen. Plötzlich hat die Business-Logik keine Abhängigkeiten mehr und ist damit völlig unabhängig vom Rest. Damit das funktioniert, muss die Business-Logik-Schicht über Schnittstellen definieren, welche Funktionalitäten von der Persistenz-Schicht benötigt werden. Die Persistenz-Schicht implementiert diese Schnittstellen und wird im Prinzip von außen “eingehängt”.
Zur Verdeutlichung nachfolgend noch ein Beispiel. Auf der Business-Logik-Schicht haben wir die Klasse EinkaufUseCase. Diese kümmert sich um die Abwicklung eines Einkaufs. Zum Speichern der notwendigen Daten wird nach dem 3-Schichten-Modell die Klasse EinkaufSqlRepository aus der Persistenz-Schicht verwendet. Unter Verwendung des DIP definiert die Business-Logik-Schicht das Interface IEinkaufRepository und gibt damit lediglich an, welche Methoden benötigt werden. Die Persistenz-Schicht implementiert dieses Interface in der Klasse EinkaufSqlRepository und stellt dadurch die notwendigen Methoden zur Verfügung.
Design als Zwiebel
Den Gedanken des DIP folgend kommt man zum Ansatz der Clean Architecture wie unter [1] beschrieben. Das, was wir oben als Business-Logik-Schicht beschrieben haben wären hier die Entities und Use Cases (bzw. Domänenmodell und Business-Logik). Die Präsentations-Schicht ist außen, genauso, wie die Persistenz-Schicht. Alle Abhängigkeiten gehen ebenso von außen nach innen. Gründe für diese Herangehensweise sind beispielsweise folgende:
- Bessere Testbarkeit
- Business-Logik frei von externen Einflüssen
- Bessere Wartbarkeit
Clean Architecture ist ein Überbegriff über mehrere Architekturmuster. Ein Beispiel davon ist die Hexagonale Architektur, mit der wir nun weiter im Detail beschäftigen wollen.
Hexagonale Architektur
Hexagonale Architektur (oder Ports and Adapters) stammt ursprünglich von Alistair Cockburn. Unter [2] entsprechend ein Beitrag über die Hexagonale Architektur aus 2005. Dieser Artikel ist bereits sehr alt, hat allerdings nach wie vor Gültigkeit. Insbesondere wenn es um die Entwicklung von Microservices geht, denn diese lassen sich i. d. R. gut mit Hexagonaler Architektur abbilden. Doch was genau ist Hexagonale Architektur?
Im Wesentlichen geht man davon aus, dass sich die Applikations-Logik und das Domänenmodell in der Mitte befinden. Sie haben keine Abhängigkeiten nach außen. Stattdessen werden Ports definiert, über die Requests von außen in die Applikations-Logik kommen (incoming) und es werden Ports definiert, über die von außen bereitgestellte Funktionen aufgerufen werden (outgoing). Zu jedem Port gibt es mindestens einen Adapter, welcher sich um die Anbindung an die Außenwelt kümmert. Grafisch werden diese Elemente in einem Hexagon dargestellt, bei denen sich die eingehenden Ports / Adapter auf der linken Seite befinden und die ausgehenden entsprechend auf der rechten Seite. Das Hexagon ist ein grafisches Hilfsmittel, welches verdeutlichen soll, dass es auf jeder Seite beliebig viele Ports geben kann. Bei Ports auf der linken Seite wird meist von incoming oder driving gesprochen. Bei denen auf der rechten Seite von outgoing oder driven.
In nachfolgender Abbildung ist das Hexagon beispielhaft mit den drei möglichen incoming Adaptern “Web-UI”, “Mobile App” und “Job” dargestellt. Es könnte sich dabei etwa um ein Backend handeln, welches eine Html-basierte Weboberfläche bereitstellt, eine Mobile-App für Android / iOS und zusätzlich im Backend einige Jobs laufen. Auf der Seite der outgoing Adapter werden je Anwendungsfall fremde Webservices aufgerufen, in die eigene Datenbank geschrieben / davon gelesen und Benachrichtigungen ausgelöst.
Die Rolle von Data Transfer Objects (DTOs)
Ein wichtiger Baustein fehlt uns noch, und zwar die Data Transfer Objects (DTOs). In den meisten Applikationen hat man das Problem, dass Daten von außen oder nach außen ein unterschiedliches Format aufweisen. So hat ein fremder Webservice etwa ein völlig anderes Datenmodell als das, was man in der eigenen Domäne vorgesehen hat. Ähnlich kann es bei Schnittstellen in Richtung der eigenen Website sein. Da i. d. R. Json als Datenübertragungsformat eingesetzt wird, können damit viele Datentypen aus .NET nicht direkt abgebildet werden (z. B. DateTimeOffset). Data Transfer Objects können verwendet werden, um hier das eigene Modell vor Einflüssen von außen zu schützen. Die Aufgabe der Adapter ist dabei, zwischen dem eigenen Domänenmodell und den DTOs zu übersetzen.
Beispiel für mit C#, ASP.NET Core 6 und Blazor
Das Beispiel ist unter [3] zu finden. Es handelt sich um eine einfache Applikation zur Pflege von Workshop-Protokollen. Es können neue Workshops angelegt und innerhalb der Workshops Protokolleinträge gepflegt werden. Das Frontend ist mittels ASP.NET Core Blazor Webassembly umgesetzt. Die Kommunikation mit dem Backend erfolgt über eine REST-API. Als Datenspeicher wird eine lokale SQLite Datenbank mittels Entity Framework Core angebunden. Mit Blick auf das Hexagon ergibt sich ein einfaches Bild. Die Blazor-Applikation inklusive der REST-API ist der einzige incoming Adapter. Die SQLite Datenbank auf der anderen Seite ist der einzige outgoing Adapter. Nachfolgend ein Screenshot der Beispiel-Applikation.
Die verschiedenen Elemente der Hexagonalen Architektur spiegeln sich in den Projekten der Projektmappe wieder:
- Incoming
- HappyCoding.HexagonalArchitecture.WebUI.Client
- HappyCoding.HexagonalArchitecture.WebUI.Server
- Outgoing
- HappyCoding.HexagonalArchitecture.SQLiteAdapter
- HappyCoding.HexagonalArchitecture.Application
- HappyCoding.HexagonalArchitecture.Application.Dtos
- HappyCoding.HexagonalArchitecture.Domain
Alle Anwendungsfälle befinden sich innerhalb des Projekts HappyCoding.HexagonalArchitecture.Application. Diese bekommen von außen das jeweilige DTO zu einem Anwendungsfall in Form eines Requests rein. Anschließend wird das Domänenmodell je nach Art des Requests aktualisiert und gespeichert. Ggf. wird eine Antwort nach Außen in Form eines DTO generiert. Nachfolgender Codeausschnitt zeigt das für den Anwendungsfall “Workshop erzeugen”.
namespace HappyCoding.HexagonalArchitecture.Application; public class CreateWorkshopRequestHandler : IRequestHandler<CreateWorkshopRequest, WorkshopDto> { private readonly IUnitOfWork _unitOfWork; public CreateWorkshopRequestHandler( IUnitOfWork unitOfWork) { _unitOfWork = unitOfWork; } public async Task<WorkshopDto> Handle(CreateWorkshopRequest request, CancellationToken cancellationToken) { var workshopDto = request.Workshop; var newWorkshop = Workshop.CreateNew( workshopDto.Project, workshopDto.Title, workshopDto.StartTimestamp, workshopDto.Protocol.Select(x => ProtocolEntry.CreateNew( x.Text, (ProtocolEntryType)x.EntryType, new ProtocolEntryPriority(x.Priority)))); await _unitOfWork.Workshops.AddWorkshopAsync(newWorkshop, cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken); return WorkshopMapper.WorkshopToDto(newWorkshop); } }
Der Port in Richtung Datenbank ist hierbei wie im nachfolgenden Codeausschnitt zu sehen definiert. Hierbei werden die Patterns UnitOfWork und Repository verwendet. Die Verwendung dieser Patterns ist kein Muss, kann aber für vergleichbare Pflege-Applikationen eine gute Praxis sein.
namespace HappyCoding.HexagonalArchitecture.Domain.Ports; public interface IUnitOfWork { IWorkshopRepository Workshops { get; } Task SaveChangesAsync(CancellationToken cancellationToken); } public interface IWorkshopRepository { Task AddWorkshopAsync(Workshop workshop, CancellationToken cancellationToken); Task<Workshop> GetWorkshopAsync(Guid workshopID, CancellationToken cancellationToken); Task<ImmutableArray<WorkshopShortInfo>> SearchWorkshopsAsync(string queryString, CancellationToken cancellationToken); Task DeleteWorkshopAsync(Guid workshopID, CancellationToken cancellationToken); }
Das Projekt HappyCoding.HexagonalArchitecture.SQLiteAdapter implementiert diesen Port. Wichtig hierbei ist, dass die Applikationslogik und das Domänenmodell zunächst keine Kenntnis darüber haben, dass EntityFrameworkCore oder SQLite verwendet werden. Genauso gut könnte die notwendige Persistenz-Logik über einen InMemory-Provider (z. B. für Unittests) oder über eine schlichte Json-Datei implementiert werden.
In Richtung Web-UI wird der Port nicht durch ein Interface, sondern durch eine Request-Struktur abgebildet. Nachfolgender Codeausschnitt zeigt den CreateWorkshopRequest aus obigen Beispiel.
namespace HappyCoding.HexagonalArchitecture.Application; public record CreateWorkshopRequest(WorkshopWithoutIDDto Workshop) : IRequest<WorkshopDto>;
Der zugehörige Adapter in Richtung Web-UI ist der ASP.NET Core ApiController. Nachfolgender Codeausschnitt zeigt die Implementierung. Das Interface IMediator kommt aus der Bibliothek MediatR, welche das Mediator-Pattern umsetzt. In diesem Artikel möchte ich hier nicht zu weit ins Detail gehen. Nur so viel: Der Mediator sorgt dafür, dass beim Senden des Requests der zugehörige Handler in der Applikations-Logik aufgerufen wird.
namespace HappyCoding.HexagonalArchitecture.WebUI.Server.Controllers; [ApiController] [Route("[controller]")] public class WorkshopsController : ControllerBase { private readonly IMediator _mediator; public WorkshopsController(IMediator mediator) { _mediator = mediator; } [HttpPost] [ProducesResponseType(typeof(WorkshopDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task<IActionResult> CreateWorkshop(WorkshopWithoutIDDto workshop) { if (!ModelState.IsValid) { return BadRequest(); } return Ok(await _mediator.Send( new CreateWorkshopRequest(workshop))); } // ... }
ASP.NET Core Blazor wiederum bekommt von all dem nicht viel mit. Es wird lediglich gegen die bereitgestellte REST-API entwickelt. Da ASP.NET Core Blazor auch C# für die Logik am Web-Frontend verwendet, können hier aber direkt die in der Projektmappe definierten DTOs verwenden. Das ist auch der Grund, warum die DTOs in einem eigenen Projekt namens HappyCoding.HexagonalArchitecture.Dtos liegen.
Vorteile
Ein Blick in den Quellcode offenbart den aus meiner Sicht größten Vorteil dieser Architektur: Es entsteht eine klare Trennung zwischen verschiedenen Adaptern in die Außenwelt und die Anwendungsfälle + dem Domänenmodell in der Mitte. Achtet man bei der Entwicklung einer Applikation auf die Einhaltung der Hexagonalen Architektur, so sind die verschiedenen Bestandteile im Code schnell zu finden. Daneben sind alle Bestandteile mit geringem Aufwand testbar. Das gilt für Adapter ebenso, wie für die Applikationslogik und dem Domänenmodell. Der Grund dafür ist, dass sich die Ports i. d. R. einfach mit einem Mocking-Framework mocken lassen.
Ein weiterer Vorteil ist, dass die gleiche Applikationslogik auf unterschiedlichen Wegen bereitgestellt werden kann. Hier im Beispiel haben wir eine ASP.NET Core Blazor Applikation gesehen. Es ist aber ohne weiteres möglich, die gleiche Logik über eine Desktop-GUI zur Verfügung zu stellen. Auch könnten bestimmte Anwendungsfälle von einem Job getriggert werden (man denke an Benachrichtigungen oder Archivierung).
Nachteile
Bei einigen Adaptern ist es so, dass sich eine vollständige Unabhängigkeit des Domänenmodells gegenüber der verwendeten Technologie in dem Adapter nur schwierig vermeiden lässt. Im gezeigten Beispiel ist das gut am Einsatz von Entity Framework Core zu erkennen. Als Modell für die Datenbank werden direkt die Klassen aus dem Domänenmodell im Zentrum verwendet – das ist zwar strenggenommen eine Verletzung gegenüber der Hexagonalen Architektur, hat aber durchaus praktische Gründe. Nur so funktioniert das Change-Tracking von Entity Framework Core während der Bearbeitung eines Anwendungsfalls. Der Nachteil ist aber, dass die Klassen im Domänenmodell so entwickelt werden müssen, dass sie kompatibel zum Entity Framework Core sind.
Fazit
Seit dem letzten Jahr beschäftige ich mich wieder tiefer mit den Prinzip hinter Clean Architecture und insbesondere der Hexagonalen Architektur. Der Trigger dazu kam aus einem Kundenprojekt, bei dem gerade auf diese Architektur umgestellt wurde. Allgemein hat sich dieser Ansatz für mich zu einer guten, breit verwendbaren Lösung entwickelt. Insbesondere bei Applikationen, welche in irgendeiner Form auf Requests von außen reagieren, lässt sich Hexagonale Architektur gut anwenden. Ich möchte aber zu bedenken geben, dass es auch Applikationen gibt, wo dieser Ansatz nicht so gut passt. In meiner Vergangenheit habe ich mich etwa häufig mit der Entwicklung von Simulationen und Visualisierungen beschäftigt – von der technischen Perspektive vergleichbar mit der Entwicklung von Games. Hier wäre eine Hexagonale Architektur nur in Teilen anwendbar.
Downloads
- Quellcode des Beispiels aus diesem Artikel
https://www.rolandk.de/files/2022/HappyCoding.HexagonalArchitecture.zip
Verweise
- Allgemeine Infos zu Clean Architecture von Robert C. Martin (Uncle Bob)
https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html - Artikel von Alistair Cockburn zu Hexagonaler Architektur (bzw. Ports & Adapters)
https://alistair.cockburn.us/hexagonal-architecture/ - Repository des Beispiels aus diesem Artikel
https://github.com/RolandKoenig/HappyCoding/tree/main/2022/HappyCoding.HexagonalArchitecture
Ebenfalls interessant
- Überwachen von Coding Conventions per Roslyn Analyzer
https://www.rolandk.de/wp-posts/2021/10/ueberwachen-von-coding-conventions-per-roslyn-analyzer/ - Prism als Basis von modern strukturierten Applikationen
https://www.rolandk.de/wp-posts/2021/03/prism-als-basis-von-modern-strukturierten-applikationen/ - Typisierten HttpClient mit NSwag generieren
https://www.rolandk.de/wp-posts/2022/11/typisierten-httpclient-mit-nswag-generieren