Eine kulinarische Warnung und die dunkle Seite des Codes
Willkommen, Mitarchitekt und geschätzter Entwickler, in unserem hexagonalen Stadtstaat – einem Reich, in dem Pragmatismus suprema regiert und Flexibilität unsere größte Stärke ist. Dieser Leitfaden ist keine Elfenbeinturm-Doktrin voller theoretischer Perfektion, sondern vielmehr ein vielseitiges Werkzeugarsenal, das in der Schmiede realer Herausforderungen und unzähliger Produktionsschlachten geschmiedet wurde.
Table of Contents
Bevor wir die Zitadelle der sauberen Architektur errichten können, müssen wir zunächst die Realität unserer Branche anerkennen. Wir müssen die „Dunkle Seite des Codes“ navigieren. Die Softwarewelt ist voll von kulinarischen Katastrophen, die einen Koch zum Weinen bringen würden. Wir alle kennen Spaghetti-Code1, ein verworrenes Durcheinander, in dem der Programmfluss sich wie Pasta windet und dreht, sodass die Wartung zu einem Albtraum wird, bei dem Referenzen und Abhängigkeiten überall hin springen. Doch das Menü der Katastrophen ist umfangreich.
Wir begegnen Spaghetti mit Fleischbällchen2, einer chaotischen Verschmelzung, bei der objektorientierte „Fleischbällchen“ ziellos in einem Meer aus prozeduralem Spaghetti schwimmen, oft das Ergebnis fehlender Coding-Standards oder Sprachbeschränkungen. Wir haben mit Ravioli-Code3 zu kämpfen, bei dem der Drang nach Modularität Tausende winziger, lose gekoppelter Komponenten erzeugt, die unmöglich zu verfolgen und nachzuvollziehen sind. Und dann gibt es Lasagne-Code4, die fehlgeschlagene Schichtarchitektur, bei der starre Ebenen eine monolithische Struktur schaffen, die so zerbrechlich ist, dass eine Änderung in der Datenbankgrundlage bis zum UI-Dach emporwabert.
Jenseits der Pasta kämpfen wir mit verhaltensbezogenen Anti-Patterns. „Hände in der Hose“5 stellt eine Verletzung der Kapselung dar, bei der Entwickler Interfaces umgehen, um die internen Mechanismen einer Komponente zu betatschen. Dies äußert sich oft als anämische Domänenmodelle6 – Objekte, die lediglich Säcke aus Gettern und Settern ohne Verhalten sind.
Betrachten Sie dieses anämische Modell, ein klassisches Beispiel für das „Hände in der Hose“-Anti-Pattern:
// Anämisches Domänenmodell
public class Order {
private List<Item> items;
private double totalPrice;
public List<Item> getItems() { return items; }
public void setItems(final List<Item> items) { this.items = items; }
// ... Getter und Setter für totalPrice
}
// Verwendung: Externe Logik manipuliert internen Zustand
Order order = new Order();
order.setItems(itemList);
double total = 0;
for (Item item : order.getItems()) {
total += item.getPrice();
}
order.setTotalPrice(total);
In diesem Szenario greift externer Code in das Order-Objekt hinein, um seinen Zustand zu manipulieren. Um dies zu beheben, müssen wir zu einem reichen, hexagonalen Domänenmodell übergehen, bei dem wir das Verhalten kapseln:
public class Order {
private List<Item> items;
public void addItem(final Item item) {
items.add(item);
}
public double calculateTotalPrice() {
return items.stream().mapToDouble(Item::getPrice).sum();
}
}
Wir haben auch mit „Der Einzeiler“7 zu kämpfen, bei dem Code zu unlesbaren Rätseln komprimiert wird, und „Happy-Path-getriebene Entwicklung“8, bei der wir Randfälle optimistisch ignorieren, bis der Produktionscrash eintritt. Um diesem kulinarischen Inferno und diesen allgegenwärtigen Code-Gerüchen zu entkommen, wenden wir uns dem Hexagon zu.
Das Hexagon enthüllt
Die Hexagonale Architektur, auch bekannt als „Ports und Adapter“, ist nicht nur ein Muster; sie ist ein strukturelles Framework zur Erreichung der Abhängigkeitsinversion9. Sie teilt Prinzipien mit Clean Architecture und Onion Architecture und betont die Unabhängigkeit von Frameworks, UIs und Datenbanken. Das Kernprinzip ist einfach und doch tiefgründig: Abhängigkeiten zeigen immer nach innen10.
Die räumliche Perspektive: Kartierung des Stadtstaats
Um diese Architektur wirklich zu verstehen, müssen wir sie räumlich visualisieren und vom Zentrum nach außen reisen:
- Das Zentrum (Die Domäne): Im allerherzen liegt die Domäne, die unsere Entities und Geschäftsregeln enthält. Dies ist das „Was“ und „Warum“ unserer Anwendung. Sie kennt nichts von der Außenwelt – keine Datenbanken, keine Web-Anfragen, keine Frameworks.
- Der innere Ring (Anwendungsdienste): Um die Domäne herum liegen die Anwendungsdienste. Diese stellen (implementieren) die Use Cases dar. Sie fungieren als Orchestratoren und koordinieren den Datenfluss und Operationen zwischen der Außenwelt und dem Domänenkern.
- Die Ränder (Ports): Die Grenzen unseres Hexagons werden durch die Ports definiert.
- Eingabe-Ports (linke Seite) definieren die Verträge dafür, wie die Welt mit uns kommuniziert (Use Cases).
- Ausgabe-Ports (rechte Seite) definieren die Verträge dafür, was die Anwendung von der Welt braucht (z. B. APIs, Persistenz, Benachrichtigungen).
- Jenseits des Hexagons (Adapter): Außerhalb der Mauern liegen die Adapter, die Implementierungsdetails, die die Lücke zur Realität überbrücken.
- Primäre (treibende) Adapter (z. B. REST-Controller) rufen Eingabe-Ports auf, um die Anwendung anzutreiben.
- Sekundäre (angetriebene) Adapter (z. B. JPA-Repositories, SMTP-Clients) implementieren Ausgabe-Ports, um von der Anwendung angetrieben zu werden.
Diese Struktur stellt sicher, dass der Kern isoliert bleibt. Wir können eine Datenbank oder ein UI-Framework austauschen, ohne die Geschäftslogik anzurühren, und erreichen echte Modularität und Testbarkeit.
Die Bürger kennenlernen (Vom Konzept zum Code)
Jetzt, da wir den Bauplan haben, lernen wir die Bürger kennen, die unseren hexagonalen Stadtstaat bewohnen.
Eingabe-Ports: Tore zum Kern
Eingabe-Ports dienen als Vertrag für unsere Use Cases. Sie definieren die spezifischen Interaktionen, die der Außenwelt zur Verfügung stehen. Ein Use Case akzeptiert typischerweise ein Command11 (für zustandsändernde Operationen) oder eine Query (für Datenabrufe).
Entscheidend ist, dass diese Commands und Queries selbstvalidierende POJOs sind, die Daten tragen, aber kein Verhalten. Sie repräsentieren die Absicht des Benutzers.
Hier ein Beispiel für ein PlaceOrderCommand, das bei der Erstellung seine eigene Integrität durchsetzt:
public class PlaceOrderCommand {
private final String customerId;
private final List<OrderItemDTO> items;
public PlaceOrderCommand(final String customerId, final List<OrderItemDTO> items) {
this.customerId = Objects.requireNonNull(customerId, "Customer ID must not be null");
this.items = Objects.requireNonNull(items, "Order items must not be null");
if (items.isEmpty()) {
throw new IllegalArgumentException("Order must contain at least one item");
}
}
// Getters...
}
Um die Ausführung dieser Commands weiter zu entkoppeln, können wir einen Command Bus12 einsetzen. Statt einen spezifischen Service in einen Controller zu injizieren, sendet der Controller ein Command an einen Bus, der es an den entsprechenden Handler weiterleitet. Dieses Muster trennt die Absicht (das Command) von der Ausführung (dem Handler).
Die Anwendungsschicht: Orchestrierung von Geschäftsprozessen
Anwendungsdienste sind die Dirigenten unseres architektonischen Orchesters. Sie sitzen in der Anwendungsschicht und bilden die Brücke zwischen externen Akteuren und der internen Domäne. Ihre primäre Verantwortung ist die Orchestrierung: Sie koordinieren den Datenfluss und verwalten Interaktionen zwischen Domänenobjekten und Ausgabe-Ports. Sie enthalten keine Geschäftsregeln; das ist Aufgabe der Domäne.
Allerdings beinhalten reale Use Cases oft „sekundäre Aufgaben”, die eine primäre Operation begleiten. Nach der Registrierung eines Benutzers (primäre Aufgabe) müssen wir möglicherweise eine Bestätigungs-E-Mail senden oder Statistiken aktualisieren. Eine direkte Implementierung dieser Aufgaben im Service verletzt das Single Responsibility Principle13.
Die Lösung sind Domänenereignisse. Durch das Versenden eines Ereignisses nach der primären Aufgabe können wir sekundäre Nebenwirkungen an separate Handler delegieren.
Hier ist ein refaktorisiertes SignupApplicationService mit Domänenereignissen:
public class SignupApplicationService implements SignupUseCase {
private final UserRepository userRepository;
private final DomainEventDispatcher domainEventDispatcher;
public SignupApplicationService(final UserRepository userRepository, final DomainEventDispatcher domainEventDispatcher) {
this.userRepository = userRepository;
this.domainEventDispatcher = domainEventDispatcher;
}
@Override
public void signup(final SignupCommand command) {
// 1. Prüfe Geschäftsregeln (z.B. Eindeutigkeit der E-Mail)
if (this.userRepository.existsByEmail(command.getEmail())) {
throw new IllegalArgumentException("E-Mail bereits in Verwendung");
}
// 2. Erstelle den Benutzer (Primäre Aufgabe)
final User user = new User(command.getName(), command.getEmail());
this.userRepository.save(user);
// 3. Veröffentliche Domänenereignis zur Delegation der 'sekundären Aufgaben'
this.domainEventDispatcher.dispatch(new UserSignedUpEvent(user));
}
}
Separate Handler können nun auf UserSignedUpEvent lauschen, um E-Mails zu senden oder Profile zu aktualisieren, wodurch der Service rein bleibt und das Offen-Geschlossen-Prinzip14 eingehalten wird.
Die Domänenschicht: Das Herz des Hexagons
Wir gelangen zum Kern: die Domänenschicht. Hier müssen wir von einem anämischen Modell zu einem reichen Domänenmodell übergehen. Unsere Domäne muss drei funktionale Prinzipien einhalten: Reinheit (keine Nebenwirkungen), Kapselung (Verbergen des internen Zustands) und Vollständigkeit (Behandlung aller Szenarien).
Die Domäne wird durch spezifische Bausteine bevölkert:
- Entities & Value Objects: Wir sollten Value Objects15 statt Primitiven verwenden, um „Primitive Obsession” zu verhindern.
- Domain Services: Wenn Logik mehrere Aggregates umspannt (z.B. Berechnung einer Schach-Elo-Zahl basierend auf zwei Spielern), verwenden Sie einen Domain Service16 statt die Logik in eine Entity zu zwingen.
- Factories: Verwenden Sie Factories für komplexe Objekterstellung, um sicherzustellen, dass Objekte nie in einem ungültigen Zustand instanziiert werden. Sie sind die Wächter der Domänenintegrität bei der Erstellung.
Ausgabe-Ports: Das diplomatische Korps
Ausgabe-Ports sind die Kanäle, durch die unser Stadtstaat mit der Außenwelt kommuniziert. Sie sind Rollenschnittstellen17, definiert durch die spezifische Interaktion, die die Domäne benötigt, nicht durch Implementierungsdetails des externen Tools.
Ein häufiger Fehler ist die Definition von „Kopfzeilen-Schnittstellen”18, die einfach die public-Methoden einer Implementierung nachahmen. Stattdessen sollten wir Schnittstellen basierend auf der Rolle definieren, die sie für die Anwendung spielen.
Repositories: Die Gedächtniswächter
Repositories sind eine spezielle Art von Ausgabe-Port. Während sie Persistenz verwalten, gehören sie zur konzeptionellen Welt der Domäne. Eine moderne Best Practice in dieser Architektur ist die Schnittstellen-Segmentierung. Statt eines gigantischen, generischen Repositories, das alle CRUD-Operationen freigibt, sollten wir feingranulare Schnittstellen für spezifische Bedürfnisse erstellen.
Betrachten Sie dieses segmentierte Repository-Beispiel:
// Feingranulare Schnittstellen in der Domänen-/Anwendungsschicht
public interface CreateCustomerOutputPort {
void save(final Customer customer);
}
public interface FindCustomersOutputPort {
Optional<Customer> findById(final CustomerId id);
List<Customer> findAll();
}
// Implementierung in der Adapter-Schicht
@Repository
@RequiredArgsConstructor
public class JPACustomerRepository implements CreateCustomerOutputPort, FindCustomersOutputPort {
private final SpringCustomerRepository springCustomerRepository; // Internes Spring Data Repo
private final CustomerMapper customerMapper;
@Override
public void save(final Customer customer) {
final JpaCustomer jpaCustomer = this.customerMapper.convertToJpaEntity(customer);
this.springCustomerRepository.save(jpaCustomer);
}
// ... Implementierung von findAll
}
Dieser Ansatz verhindert „leckende Abstraktionen”, bei denen datenbankspezifische Methoden die Domäne verschmutzen, und hält sich ans Prinzip der Schnittstellentrennung19.
Die unsichtbaren Säulen (Technische Aspekte)
Da wir die sichtbare Struktur errichtet haben, müssen wir uns nun den unsichtbaren Säulen widmen, die unsere Architektur stützen: Datenfluss, Steuerungsfluss, Fehlerbehandlung und Validierung.
Datenfluss und Mapping-Strategien
Wie kreuzen Daten die Grenzen unseres Hexagons? Wir identifizieren drei Strategien zum Mapping von Daten zwischen den Schichten:
- Bypass-Mapping: Das Domänenobjekt wird überall verwendet (Controller → Service → Repository). Dies ist einfach, schafft aber enge Kopplung und leckende Abstraktionen. Es ist der Weg des geringsten Widerstands, der oft zu technischem Schuldenberg führt.
- Bridge-Mapping: Adapter haben ihre eigenen Modelle, mappen sie aber vor der Übergabe an den Kern auf das Domänenobjekt. Dies ist besser, aber die Domäne ist immer noch den äußeren Schichten ausgesetzt.
- Barrier-Mapping (empfohlen): Volle Trennung. Eingabe-Adapter mappen Requests zu Commands. Services mappen Commands zu Domänenobjekten. Ausgabe-Ports akzeptieren DTOs20 oder Persistenz-Entities, die aus Domänenobjekten gemappt wurden. Dies schafft eine „Firewall” um den Kern und gewährleistet echte Trennung der Verantwortlichkeiten.
Die goldene Regel der Datentypen lautet: Grenztypen (Requests, DTOs) und Adapter-Typen (JPA-Entities) dürfen niemals in die Domänentypen eindringen.
Fehlerbehandlung: Die Either-Revolution
In der Java-Welt haben wir eine Sucht nach Exceptions. Aber Exceptions wirken wie GOTO-Anweisungen, unterbrechen den Steuerungsfluss und verletzen das Prinzip der geringsten Verblüffung21. Warum sollte ein „Benutzer nicht gefunden”-Szenario – ein völlig gültiges Geschäftsresultat – die Programmausführung unerwartet springen lassen?
Anstatt Exceptions für Geschäftsfehler zu werfen, sollten wir Fehler als Werte behandeln. Wir verwenden die Either<L, R>-Monade (oft aus Bibliotheken wie Vavr22). Die Linke Seite enthält den Fehler (geschäftlich oder technisch), die Rechte Seite den Erfolgswert.
// Explizite Signatur, die die Fehler-Möglichkeit deklariert
public Either<ApplicationError, List<DossierDTO>> fetchPortfolio(final String user) {
try {
List<DossierDTO> portfolio = externalService.get(user);
if (portfolio == null) {
return Either.left(new PortfolioNotFoundError(user));
}
return Either.right(portfolio);
} catch (RestClientException e) {
return Either.left(new PortfolioServiceNotAvailableError(e));
}
}
Dies zwingt den Verbraucher, den Fehler explizit zu behandeln, was zu sichererem und vorhersehbarerem Code führt.
Validierungen: Das immer-gültige Domänenmodell
Wir müssen zwischen syntaktischer Validierung (Prüfung von Eingabeformaten wie E-Mail-Regex) und semantischer Validierung (Prüfung von Geschäfts-Invarianz wie „E-Mail muss eindeutig sein”) unterscheiden.
Um das immer-gültige Domänenmodell23 durchzusetzen, sollte ein Objekt nie in einem ungültigen Zustand existieren. Der „isValid()”-Ansatz ist fehlerhaft, da er impliziert, dass das Objekt zuerst in einem ungültigen Zustand erstellt wurde. Stattdessen verwenden wir statische Factory-Methoden, um vor der Instanziierung zu validieren.
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class User {
private final String email;
// Factory-Methode gibt ein Validation-Objekt (Vavr) zurück
public static Validation<List<DomainError>, User> validateThenCreate(final String email) {
return isValidDomainEmail(email)
? Validation.valid(new User(email))
: Validation.invalid(List.of(new DomainError("Ungültige E-Mail-Domäne")));
}
}
Dies stellt sicher, dass, wenn Sie eine Referenz auf ein User-Objekt halten, dieses garantiert gültig ist.
Transaktionalität: Framework-agnostischer Ausgabe-Port
Transaktionalität ist entscheidend für die Datenintegrität, aber wir sollten unsere Domäne nicht mit frameworkspezifischen Annotationen wie Springs @Transactional verschmutzen. Stattdessen definieren wir Transaktionalität als Ausgabe-Port mit einer eigenen @Transactional-Annotation (im Shared Kernel24) und verwenden einen Adapter (über Spring AOP), um sie zur Laufzeit auf den tatsächlichen Transaktionsmanager zu mappen.
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Transactional {
// Eigenschaften für Rollback-Regeln mit Either
Class<?>[] rollbackForWithEither() default {};
}
Dies hält unseren Application Core vollständig framework-agnostisch.
Compliance und Erfüllung
Wie stellen wir sicher, dass unser Code diese Prinzipien tatsächlich widerspiegelt? Wir benötigen eine Strategie für Codeorganisation und automatisierte Durchsetzung.
Architektonische Kartografie: Den Code ausmessen
Die Struktur Ihres Codes sollte seine Architektur „herausschreien”. Wir sollten nicht nach „Art” organisieren (alle Services in ein services-Package, alle Controller in controllers). Stattdessen verwenden wir eine architektonisch expressive Strategie, die die hexagonale Struktur widerspiegelt.
Eine empfohlene Multi-Modul-Maven-Struktur sieht so aus:
hexagonal-ref-app(Root)shared-kernel: Gemeinsame DTOs, Errors, Domänenereignisse, Validierungen.application-core: Das Hexagon selbst.domain: Entities, Wertobjekte.input-ports: Use-Case-Schnittstellen, Commands/Queries.application: Handler/Services-Implementierungen.output-ports: Schnittstellen für externe Bedürfnisse.
adapters:api-adapter: REST-Controller (Primärer Adapter).persistence-adapter: JPA-Repositories (Sekundärer Adapter).
spring-boot-assembly: Einstiegspunkt für die Ausführung.
Entscheidend ist die Verwendung von Javas package-private-Sichtbarkeit zur Grenzdurchsetzung. Nur Ports (Schnittstellen) und DTOs sollten public sein. Implementierungen sollten vor der Welt verborgen bleiben und nur über Dependency Injection zugänglich sein.
ArchUnit: Der Wächter der Integrität
Wir können nicht allein auf Entwicklerdisziplin vertrauen. Wir verwenden ArchUnit25, um unsere architektonischen Regeln zu automatisieren. Es agiert als unermüdlicher Wächter.
final ArchRule commandDependencyRule = classes()
.that().resideInAnyPackage("..application.command..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage("java..", "..shared..", "..application.command..");
Dies stellt sicher, dass Commands rein bleiben und nicht von Web- oder Persistenzschichten abhängen. Durch FreezingArchRule können Legacy-Projekte schrittweise verbessert werden.
Teststrategie
Testing in der Hexagonalen Architektur wird durch die strikte Trennung der Verantwortlichkeiten vereinfacht. Wir setzen spezifische Typologien ein, um jede Schicht effektiv anzugehen.
Unit-Testing statischer Factory-Methoden
Wir testen die Domänenlogik in vollständiger Isolation. Da unsere Objekte „immer gültig” sind, müssen wir verifizieren, dass die Factories ungültige Eingaben ablehnen.
@ParameterizedTest
@MethodSource("invalidArguments")
void validateThenCreateShouldReturnError(String email) {
Validation<Error, User> result = User.validateThenCreate(email);
assertThat(result.isInvalid()).isTrue();
}
Unit-Testing der Handler
Handler sind Orchestratoren. Um sie zu testen, verwenden wir Mockito26, um die Ausgabe-Ports zu mocken. Wir testen nicht die Datenbank; wir testen, dass der Handler den Datenbank-Port korrekt aufruft.
@ExtendWith(MockitoExtension.class)
class DownloadArticleHandlerTest {
@Mock DownloadArticleOutputPort outputPort;
@InjectMocks DownloadArticleHandler handler;
@Test
void handleShouldReturnContent() {
// given
given(outputPort.download(any(), any())).willReturn(Either.right(content));
// when
var result = handler.handle(query);
// then
assertThat(result.isRight()).isTrue();
verify(outputPort).download(any(), any());
}
}
Dies stellt sicher, dass die Orchestrierungslogik korrekt ist, ohne echte Datenbankverbindung zu benötigen.
Unit-Testing der Adapter
Adapter sind heterogen und erfordern spezifische Strategien.
- REST-Controller: Wir testen, dass sie HTTP-Requests korrekt zu Commands und DTOs zu Responses mappen.
- Externe APIs: Wir vermeiden echte Netzwerkaufrufe. Wir können
MockRestServiceServer(bei Verwendung von Springs RestClient) oderMockedConstruction(bei generierten OpenAPI27-Clients) verwenden, um API-Verhalten zu simulieren.
Testing eines Controllers mit Mockito:
@Test
void profileShouldReturnResponse() {
// given
given(queryBus.query(any())).willReturn(Either.right(userProfile));
// when
ResponseEntity<?> response = controller.profile(auth, request);
// then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
Vom Bauplan zur Realität (Referenzanwendung)
Um diese Konzepte zu festigen, zerlegen wir den „Create Article”-Flow in unserer Referenzanwendung28, einem vollständigen Hexagonalen Meisterwerk.
Der Eingabe-Port (Use Case)
Befindet sich in application-core/input-ports. Erweitert CommandHandler und verwendet ein selbstvalidierendes Command. Dies definiert den Vertrag.
@UseCase
public interface CreateArticleUseCase extends CommandHandler<CreateArticleCommand> {}
@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class CreateArticleCommand implements Command {
// Felder...
public static Validation<Error, CreateArticleCommand> validateThenCreate(...) { ... }
}
Der Anwendungsdienst (Handler)
Befindet sich in application-core/application. Orchestriert den Flow.
@ApplicationService
@RequiredArgsConstructor
final class CreateArticleHandler implements CreateArticleUseCase {
private final AuthorOutputPort authorOutputPort;
private final ArticleRepository articleRepository;
@Override
@Transactional
public Either<Error, Void> handle(final CreateArticleCommand command) {
return this.authorOutputPort.lookupAuthor(command.authorId())
.flatMap(author -> ArticleMapper.INSTANCE.toArticle(command, author).toEither())
.flatMap(this.articleRepository::save);
}
}
Beachten Sie die Verwendung von flatMap, um Operationen zu verketten. Wenn ein Schritt fehlschlägt (Either.Left), stoppt die Kette, und der Fehler propagiert. Bei Erfolg fließt er zum nächsten Schritt.
Die Domain-Entity
Befindet sich in application-core/domain. Verwendet das Factory-Method-Muster, um Gültigkeit bei Erstellung zu gewährleisten.
public class Article {
public static Validation<Error, Article> validateThenCreate(...) {
// Validierungen...
return Validation.valid(new Article(...));
}
}
Der Ausgabe-Port
Befindet sich in application-core/output-ports.
@OutputPort
public interface AuthorOutputPort {
Either<Error, AuthorDTO> lookupAuthor(final String id);
}
Der Adapter (Externe API)
Befindet sich in author-external-adapter. Implementiert den Port und behandelt die schmutzigen Details des REST-Aufrufs.
@Adapter
@RequiredArgsConstructor
final class AuthorExternalAPIAdapter implements AuthorOutputPort {
private final RestClient restClient;
@Override
public Either<Error, AuthorDTO> lookupAuthor(String id) {
// Verwendet Try, um Exceptions vom REST-Aufruf einzufangen und zu Error-Werten umzuwandeln
// Gibt Either.right(dto) oder Either.left(error) zurück
}
}
Die Assembly
Zuletzt verwenden wir in spring-boot-assembly @ComponentScan mit unseren benutzerdefinierten Filtern, um alles zusammenzuführen. Hier passiert die Magie, und unsere isolierten Komponenten werden zu einer laufenden Anwendung.
Wir nutzen Dependency Injection (DI), um unsere Komponenten zusammenzuführen. Um unseren Code noch ausdrucksstärker zu machen, definieren wir eigene Annotationen als architektonische Wegweiser: @Adapter, @ApplicationService und @DomainService.
@ComponentScan(basePackages = "com.emedina", includeFilters = @ComponentScan.Filter(
type = FilterType.ANNOTATION,
classes = {Adapter.class, ApplicationService.class, DomainService.class}
))
Dies weist Spring genau an, wonach es suchen soll, und verwandelt unsere DI-Konfiguration in eine semantische Karte unserer Architektur.
Schlussfolgerung: Hexagonale Horizonte
Wir haben eine weite Landschaft durchquert, vom chaotischen Realitätsdschungel des „Spaghetti-Codes” und „Lasagne-Architektur” zur strukturierten Eleganz der Hexagonalen Zitadelle. Wir sind auf einer Quest gewesen, die Versprechen unserer Hexagonalen Architektur zu erfüllen, bewusst der Sirenenstimme der Implementierungsdetails ausweichend.
Diese Reise war mehr als eine technische Übung; sie war ein Manifest für sauberen, wartbaren und ausdrucksstarken Code. Wir haben gesehen, wie ein Codebase seinen Zweck und seine Semantik herausschreien kann. Stellen Sie sich vor, ein Bug tritt bei Artikel-Updates auf. In einer traditionellen Anwendung könnte dies eine wilde Jagd durch ausufernde Service-Klassen auslösen. Aber in unserem hexagonalen Stadtstaat ist der Pfad klar. Wir würden schnell zum input-ports-Modul navigieren, dann links zum api-adapter und rechts durch application-core zu output-ports – ohne Zögern, ohne falsche Fährten.
Wir haben Error-Werte statt Exceptions umarmt, das Implizite explizit gemacht und Verantwortlichkeiten in Module zerlegt, die eine Geschichte erzählen. Am wichtigsten haben wir unsere Domäne, diesen kostbaren Kern der Geschäftslogik, gegen die wechselnden Dünen externer Frameworks und Technologien befestigt.
Diese Klarheit geht darum, Entwickler zu befähigen. Es geht darum, Systeme zu schaffen, die zum Evolieren gebaut sind. Ob Sie einen bestehenden Goliath refactoren oder einen neuen David erschaffen, die Prinzipien, die wir erkundet haben, bieten eine robuste Grundlage. Unsere Hexagonale Architektur zeichnet nicht nur Grenzen; sie schafft ein lebendiges, atmendes Ökosystem, in dem Geschäftslogik gedeiht.
Also, lieber Architekt, während Sie am Schwellen Ihrer nächsten Projekts stehen, bewaffnet mit den Erkenntnissen dieser Reise, umarmen Sie den hexagonalen Weg. Lassen Sie Ihren Code seinen Zweck singen, lassen Sie Ihre Architektur Flexibilität atmen und lassen Sie Ihren kreativen Geist in dieser neu entdeckten architektonischen Freiheit emporsteigen. Die hexagonale Revolution wartet – sind Sie bereit, den Wandel anzuführen?
Dieser Artikel versucht, die viel umfassendere und detailliertere Erkundung der Hexagonalen Architektur zusammenzufassen, die im Buch „Decoupling By Design: A Pragmatic Approach to Hexagonal Architecture” behandelt wird.