Ports und Adapters in fünf Minuten

In modernen Softwareentwicklungsprojekten wird mit Prinzipien und Architekturmodellen gearbeitet, um Software wartbarer, nachvollziehbarer und erweiterbar zu gestalten. Ein mögliches – und durchaus sinnvolles – Architekturmodell sind die Ports und Adapters, auch als Hexagonale Architektur oder Onion-Architektur bekannt (auf die Unterschiede gehen wir später kurz ein).

Das Konzept

Mithilfe von Adaptern werden die entstehende Anwendung und Komponenten, mit denen kommuniziert werden, logisch abgetrennt. Der Anwendungskern wird von den Schnittstellen entfernt und ist von diesen nicht abhängig – dadurch werden sie einfach austauschbar. Fangen wir mit der einfacheren Seite an – eine Schnittstelle ruft die Anwendung auf. Dies kann zum Beispiel über eine Oberfläche passieren:

Ports and Adapters - Kommunikation der Anwendung mit einem User-Interface
In dieser Grafik wird die Anwendung von einer Webanwendung aus gesteuert. Die Kommunikation zwischen Frontend und Anwendung kann zum Beispiel mittels REST-API erfolgen – dann empfängt der Adapter die Aufrufe und kommuniziert mittels UI-Port die gewünschte Interaktion mit der Anwendung. In der Regel folgt darauf eine Antwort in Richtung der Webanwendung.

In der Umsetzung kann man sich dies einfach vorstellen. Nehmen wir hierzu ein Java-Projekt mit Maven, welches eine Chatanwendung umsetzten soll. Angenommen, es gibt ein chat-core Modul. In diesem liegt unser Anwendungskern. Ein weiteres chat-ui Modul enthält die Webanwendung. Im Kern gibt es die Klasse UiPort, welche eine Methode sendMessage(String message) implementiert. Diese Methode kann nun vom Web-Adapter, welcher in dem UI-Modul liegt, aufgerufen werden. Damit ist der Adapter von dem Anwendungskern abhängig. Bei dieser Art von Schnittstelle spricht man von einem Primary Component (dt. primäre Komponente).

Sehen wir uns das ganze mal anhand einem Java EE-Beispiel an:

/* Diese Klasse ist im chat-core Modul */ 
@ApplicationScoped 
public class UiPort { 
  public void sendMessage(String message) { 
    // Aufruf der Kernlogik zum Nachrichtenversand ... 
  } 
}

/* Diese Klasse ist im chat-ui Modul */ 
@ApplicationScoped 
public class WebAdapter { 
  @Inject UiPort uiPort; // zur Laufzeit wird hier eine Instanz des UiPort eingefügt 
  
  public void enterPressed(String textField) { 
    uiPort.sendmessage(textField); 
  } 
}
Java

In die andere Richtung wird es etwas spannender. Wir führen ein weiteres Modul chat-db ein, welches unsere Chatnachrichten verwalten soll. Würden wir hier wie eben vorgehen, hätte das zur Folge, dass unsere Anwendung von der Datenbank bzw. dessen Adapter abhängig wäre. Gemäß dem SOLID Prinzip Dependency-Inversion darf die Anwendung aber nicht von einer Komponente abhängig sein, selbst wenn diese die Komponente steuert (Inversion of Control).

Die Lösung: die Anwendung enthält den PersistencePort, ein Interface, welches von dem Anwendungskern aufgerufen wird. Dieser enthält die Methode storeMessage(String message). Die Implementierung dieses Interfaces legen wir nun in den Adapter. Diese wird zur Laufzeit bereitgestellt – die Abhängigkeit besteht nun hier auch vom DB-Modul auf den Anwendungskern, da dort das zu implementierende Interface liegt. Der Adapter kommuniziert direkt mit der externen Schnittstelle, z.B. Hibernate mit MariaDB. In diesem Falle würde der Adapter auch für die Umwandlung der Anwendungsobjekten in Entitäten verantwortlich sein.

Ports and Adapters - Kommunikation der Anwendung mit einer Datenbank
In dieser Grafik kommuniziert die Anwendung mit einer Datenbank. Die Anwendung reicht dem Adapter über den Port die zu persistierenden Daten. Dieser ist für die Umwandlung in Datenbank-Entitäten und die Persistierung zuständig.

Um die Implementierung des Adapters ohne eine Abhängigkeit ausgehend vom Anwendungskern verwenden zu können, muss man auf Dependency-Injection oder eine Factory zurückgreifen. Im Java EE-Kontext kann man diese Injection wie folgt umsetzen:

/* Diese Klasse ist im chat-db Modul */ 
@ApplicationScoped 
public class PersistenceAdapter implements PersistencePort { 
  @Override 
  public void storeMessage(String message) { 
    // Logik für die Persistierung ... 
  } 
} 

/* Dieses Interface ist im chat-core Modul */ 
public interface IPersistencePort { 
  public void storeMessage(String message); 
} 

/* Diese Klasse ist im chat-core Modul */ 
@ApplicationScoped 
public class ChatController { 
  @Inject IPersistencePort persistencePort; // zur Laufzeit wird hier eine Instanz des PersistenceAdapter eingefügt 
  
  // hier kann der Port aufgerufen werden, um Nachrichten zu persistieren 
}
Java

Wieso das Ganze?

Die beschriebene Trennung zwischen Anwendung und Komponenten bringt einige Vorteile mit sich:

  • Komponenten außerhalb der Anwendung sind vollständig austauschbar. Welche Datenbank verwendet wird, und wie mit dieser kommuniziert wird, ist für die Anwendung nicht relevant.
  • Im Falle von Komponenten, die die Anwendung aufrufen (sog. Driving oder Primary Components), können an einem Port mehrere Adapter (und Komponenten) existieren – so kann der UI-Port sowohl über einen Adapter für eine Webanwendung als auch einen Adapter für eine Mobilanwendung angesprochen werden.
  • Änderungen an Adaptern beeinflussen die Anwendung nicht, und Änderungen an der Anwendung die Adapter nicht. Nur, wenn die Ports angepasst werden, sind beidseitig Änderungen notwendig.
  • Die Anwendung sowie die einzelnen Adapter sind leichter testbar – Testframeworks können einfach einen Adapter oder die Anwendung simulieren, um jeweils das Gegenstück in einer Testumgebung zu testen

Das ist auch schon alles – zumindest in sehr grundlegender Ausführung. Der wahre Umsetzungsaufwand der hexagonalen Architektur entsteht durch das Mapping und dem Erstellen von guten(!) Ports.

Die Adapter benötigen die Daten oft in einem anderen Format, als es von der Anwendung bereitgestellt wird – Objekte müssen für die Datenbank in Entitäten umgewandelt werden, welche in Tabellen persistiert werden können. Eine REST-API arbeitet mit JSON-Objekten, welche zunächst in für die Anwendung verwendbare Objekte umgewandelt werden müssen, und in der Regel ebenfalls eine Umwandlung in die andere Richtung für Antworten der Anwendung.

Geben die Ports den Adaptern zu sehr die Umsetzung vor, kann es wesentlich schneller notwendig sein, diese anzupassen, sobald sich ein Adapter oder die Anwendung verändert. Die Kunst liegt darin, enge Kopplung zu vermeiden, und zukunftsfähige Ports zu entwerfen.

Die Variante der Onion-Architektur

Nun sind Dir die Grundlagen der Ports und Adapters bzw. Hexagonalen Architektur bekannt. Zugleich hast Du nun aber auch fast alle Bausteine der Onion-Architektur gelernt – diese baut nämlich effektiv auf die hier beschriebene Architektur auf, nur dass sie außerdem noch in den Anwendungskern selbst blickt:

Auch hier wird nur die Regel der nach innen zeigenden Dependencies angewandt: Kern-Komponenten (die Domain – grundlegende Elemente wie ein Kunde oder Artikel) der Anwendung haben keine Abhängigkeiten auf die Domain-Services (Logik mit mehreren Objekten aus der Domain – beispielsweise Bestellungen). Diese wiederum haben keine Abhängigkeiten auf die Services der Anwendung, welche Use Cases umsetzen und mit unseren Ports sprechen können.

Die Verknüpfung von Architekturkonzepten kann weitergesponnen werden, indem weitere Konzepte zugezogen werden. Hierzu eine Leseempfehlung: DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together von Herberto Graça – praktisch ein All-Inclusive Modell.

Schlusswort

Ports und Adapters und ihre Verwandten präsentieren einen seriösen Ansatz, wie eine Anwendung sauber aufgebaut werden kann. Das Ganze ist jedoch mit Vorsicht zu genießen: mit der Architektur entsteht signifikanter Umsetzungsaufwand, und kommt nur in mittelgroßen bis großen Anwendungen infrage – wobei letztere möglicherweise mehr von einer dem Anwendungsfall angepassten Variante profitieren. Und wenn die Anwendung mit diesem Konzept umgesetzt werden soll, dann bitte richtig. Nehmt Euch die Zeit, sinnvolle Schnittstellen zu entwerfen, denn das ist eine ganz eigene Philosophie.