Hexagonale Architektur mit Spring Boot umsetzen
Zurück zum Blog

Hexagonale Architektur mit Spring Boot umsetzen

7 Min. Lesezeit
Lesen auf English

Das Versprechen

Hexagonale Architektur -- auch bekannt als Ports & Adapters -- ist eines der meistdiskutierten Architekturmuster der letzten Jahre. Die Grundidee: Die Geschäftslogik bildet den Kern der Anwendung. Alles andere -- Datenbanken, HTTP-Schnittstellen, Messaging -- dockt als Adapter an definierten Ports an. Der Kern kennt die Außenwelt nicht. Die Außenwelt kennt den Kern nur über seine Ports.

Das Versprechen ist verlockend: austauschbare Infrastruktur, testbare Geschäftslogik, klare Abhängigkeitsrichtung. In der Theorie klingt das nach der perfekten Architektur. In der Praxis haben wir erlebt, dass Teams -- uns eingeschlossen -- bei der Umsetzung regelmäßig ins Over-Engineering abrutschen.

Was Hexagonale Architektur eigentlich bedeutet

Bevor wir zur pragmatischen Umsetzung kommen, kurz die Grundbegriffe.

Ports sind Schnittstellen, die der Anwendungskern definiert. Es gibt eingehende Ports (Use Cases, die von außen aufgerufen werden) und ausgehende Ports (Abhängigkeiten, die der Kern von der Außenwelt benötigt). Ein eingehender Port könnte ein Interface CreateOrderUseCase sein. Ein ausgehender Port ein Interface OrderRepository.

Adapter sind die konkreten Implementierungen dieser Ports. Ein REST-Controller ist ein eingehender Adapter -- er nimmt HTTP-Requests entgegen und ruft den entsprechenden Use Case auf. Eine JPA-Repository-Implementierung ist ein ausgehender Adapter -- sie implementiert den Port mit einer konkreten Datenbanktechnologie.

Der Kern enthält die Domänenlogik und die Anwendungslogik. Er definiert die Ports, kennt aber keine Adapter. Abhängigkeiten zeigen immer nach innen.

Soweit die reine Lehre. Das Problem beginnt, wenn man dieses Modell buchstabengetreu in ein Spring-Boot-Projekt überträgt.

Wo die reine Lehre scheitert

Wir haben in einem früheren Projekt die hexagonale Architektur streng umgesetzt. Jede Datenbankanbindung hinter einem Interface. Jede Domänenklasse frei von Framework-Annotationen. Mapping-Schichten zwischen Domänenmodell, Persistenzmodell und API-Modell. Das Ergebnis: Für eine einfache CRUD-Operation mussten wir sieben Klassen anfassen. Ein neues Feld im Domänenmodell zog Änderungen in fünf Dateien nach sich.

Die Probleme im Detail:

Zu viele Interfaces mit einer Implementierung. Wir hatten Dutzende von Interfaces, hinter denen jeweils genau eine Klasse stand. Das ist kein Abstraktionsgewinn -- das ist Rauschen. Ein Interface, das nie eine zweite Implementierung bekommt, erschwert die Navigation im Code und verdoppelt den Änderungsaufwand.

Mapping-Orgien zwischen Schichten. Domänenmodell nach Persistenzmodell. Persistenzmodell nach Domänenmodell. Domänenmodell nach DTO. DTO nach Response. Vier Mapping-Schritte für einen Datenbankzugriff. Die Mapper selbst wurden zur Fehlerquelle, weil vergessene Felder zu subtilen Bugs führten.

Framework-Feindlichkeit. Spring Boot ist kein Implementierungsdetail, das man verstecken muss. Die Wahrscheinlichkeit, dass wir in einem laufenden Projekt Spring Boot durch ein anderes Framework ersetzen, liegt bei null. Dennoch haben wir Aufwand betrieben, um jede Spring-Annotation aus dem Kern fernzuhalten. Diesen Aufwand hätten wir besser in Features investiert.

Die pragmatische Version

Nach diesen Erfahrungen haben wir unseren Ansatz überarbeitet. Wir übernehmen die wertvollen Ideen der hexagonalen Architektur -- Abhängigkeitsrichtung, Trennung von Fachlogik und Infrastruktur, Testbarkeit -- ohne die akademische Reinheit.

Package by Feature

Die wichtigste strukturelle Entscheidung: Wir organisieren unseren Code nach fachlichen Features, nicht nach technischen Schichten. Ein Feature-Paket enthält alles, was zu einem fachlichen Thema gehört.

com.encircle360.invoiceservice
  ├── invoice/
  │     ├── Invoice.java                  // Domänenmodell (JPA Entity)
  │     ├── InvoiceService.java           // Anwendungslogik
  │     ├── InvoiceRepository.java        // Spring Data Interface
  │     ├── InvoiceController.java        // REST Adapter
  │     ├── InvoiceCreatedEvent.java      // Domänen-Event
  │     └── InvoiceTestFactory.java       // Test-Hilfsobjekte
  ├── payment/
  │     ├── Payment.java
  │     ├── PaymentService.java
  │     ├── PaymentGateway.java           // Interface (mehrere Implementierungen)
  │     ├── StripePaymentGateway.java     // Adapter: Stripe
  │     ├── PayPalPaymentGateway.java     // Adapter: PayPal
  │     └── PaymentController.java
  └── config/
        └── SecurityConfig.java

Was hier auffällt: InvoiceRepository ist direkt ein Spring-Data-Interface. Kein zusätzliches Port-Interface davor. Invoice ist gleichzeitig Domänenmodell und JPA-Entity. Und PaymentGateway hat ein Interface -- weil es tatsächlich mehrere Implementierungen gibt.

Interfaces nur bei Bedarf

Die Regel ist simpel: Ein Interface entsteht, wenn es mehrere Implementierungen gibt oder wenn eine Abhängigkeit für Tests durch eine andere Implementierung ersetzt werden muss, die sich nicht mit den vorhandenen Mitteln (Testcontainers, @MockBean) abdecken lässt.

Konkret bei PaymentGateway: Stripe und PayPal sind zwei unterschiedliche Zahlungsanbieter. Hier macht ein Interface Sinn -- die Anwendungslogik soll nicht wissen, welcher Anbieter gerade aktiv ist.

InvoiceRepository hingegen braucht kein vorgelagertes Interface. Spring Data generiert die Implementierung, und mit Testcontainers testen wir gegen eine echte Datenbank. Ein zusätzliches Interface würde nur eine Indirektion hinzufügen, die niemand braucht.

Spring bewusst nutzen, nicht verstecken

In unserer pragmatischen Variante ist Spring kein Fremdkörper, der aus dem Kern ferngehalten werden muss. @Service, @Transactional, @Entity -- diese Annotationen gehören zum Alltag und machen den Code lesbarer, nicht schlechter.

Was wir dennoch beachten: Die Controller sind dünn. Sie nehmen Requests entgegen, validieren Eingaben und delegieren an den Service. Geschäftslogik hat in Controllern nichts verloren. Und die Geschäftslogik selbst lebt möglichst in den Domänenobjekten, nicht in anämischen Services.

@Entity
public class Invoice {

    // ... Felder und JPA-Mapping

    public void markAsPaid(LocalDate paymentDate) {
        if (this.status == InvoiceStatus.CANCELLED) {
            throw new InvoiceAlreadyCancelledException(this.id);
        }
        this.status = InvoiceStatus.PAID;
        this.paidAt = paymentDate;
    }

    public boolean isOverdue() {
        return this.status == InvoiceStatus.OPEN
            && this.dueDate.isBefore(LocalDate.now());
    }
}

Die Geschäftsregeln -- wann eine Rechnung als bezahlt markiert werden darf, wann sie überfällig ist -- stecken im Domänenobjekt. Der Service orchestriert, das Domänenobjekt entscheidet.

Integrationstests statt Mock-Akrobatik

Die pragmatische Architektur zahlt sich besonders beim Testen aus. Statt jede Schicht isoliert mit Mocks zu testen, setzen wir auf Integrationstests, die den gesamten Stack prüfen: vom HTTP-Request über die Geschäftslogik bis zur Datenbank.

@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class InvoiceControllerIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:12");

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldCreateInvoiceAndPersist() throws Exception {
        mockMvc.perform(post("/api/invoices")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"customer\": \"ACME\", \"amount\": 1500.00}"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").exists())
                .andExpect(jsonPath("$.status").value("OPEN"));
    }
}

Ein Test, der den gesamten Pfad abdeckt. Wenn dieser Test grün ist, funktioniert die Kette: Controller nimmt den Request an, Service verarbeitet ihn, Repository schreibt in die Datenbank, Response kommt korrekt zurück. Kein Mock, der eine falsche Sicherheit vermittelt. Testcontainers startet eine echte PostgreSQL-Instanz, und der Test läuft in Sekunden.

Unit-Tests haben weiterhin ihren Platz -- für komplexe Domänenlogik wie die markAsPaid-Methode oben. Aber an den Schichtgrenzen, wo die Integration der Komponenten getestet werden muss, sind Integrationstests mit Testcontainers überlegen.

Ehrliche Trade-offs

Dieser Ansatz ist kein Allheilmittel. Es gibt Trade-offs, die man kennen sollte.

Die Kopplung an Spring und JPA ist real. Wenn man tatsächlich das Framework wechseln muss, ist der Aufwand höher als bei der reinen hexagonalen Architektur. Unsere Erfahrung: In über fünf Jahren Spring-Boot-Projekten ist das kein einziges Mal passiert.

Die Grenze zwischen Pragmatismus und Schlampigkeit ist fließend. "Wir brauchen kein Interface" kann schnell zur Ausrede werden, um grundsätzlich auf Abstraktion zu verzichten. Die Faustregel hilft: Wenn es einen konkreten Grund für ein Interface gibt, erstelle es. Wenn der einzige Grund "es könnte ja irgendwann" ist, lass es.

Domänenlogik in JPA-Entities funktioniert gut, solange die Entities nicht zu komplex werden. Bei sehr reichhaltigen Domänenmodellen kann es sinnvoll sein, ein separates Domänenmodell einzuführen. Aber das ist eine Entscheidung, die man trifft, wenn die Komplexität es erfordert -- nicht präventiv.

Fazit

Die hexagonale Architektur enthält wertvolle Ideen: Abhängigkeiten zeigen nach innen, Geschäftslogik ist unabhängig von Infrastruktur, die Anwendung ist testbar. Diese Prinzipien übernehmen wir. Die akademische Umsetzung mit Interfaces für alles, Mapping zwischen fünf Schichten und Framework-Abstinenz im Kern -- die lassen wir weg.

Package by Feature, Interfaces nur bei Bedarf, Spring als Werkzeug statt als Feind, Integrationstests mit Testcontainers -- das ist unsere pragmatische Interpretation. Sie liefert die meisten Vorteile der hexagonalen Architektur bei einem Bruchteil der Komplexität. Nicht perfekt in der Theorie, aber wirksam in der Praxis.

Teilen

Patrick Hütter

Geschrieben von

Patrick Hütter

Gründer & Software-Architekt

Softwarearchitekt, Ingenieur und Unternehmer. Patrick baut seit über einem Jahrzehnt Produkte und Plattformen — von Enterprise-Backends über Cloud-Native-Infrastruktur bis hin zu KI-gestützten Anwendungen. Als Gründer von encircle360 verbindet er technische Tiefe mit unternehmerischem Denken und treibt Open-Source-Projekte voran, die echten Impact haben.