Kontakt
stefan.bente[at]th-koeln.de
+49 2261 8196 6367
Discord Server
Prof. Bente Personal Zoom
Adresse
Steinmüllerallee 4
51643 Gummersbach
Gebäude LC4
Raum 1708 (Wegbeschreibung)
Sprechstunde nach Vereinbarung
Terminanfrage: calendly.com Wenn Sie dieses Tool nicht nutzen wollen, schicken Sie eine Mail und ich weise Ihnen einen Termin zu.

Eine zyklische Abhängigkeit zwischen zwei Aggregates entsteht häufig dadurch, dass sich ihre Application Services gegenseitig aufrufen. Anhand eines kleinen, übersichtlichen Beispiels (Ticket und Traveller) wird Schritt für Schritt gezeigt, wie man einen solchen – fachlich gerechtfertigten – Zyklus mit dem Dependency Inversion Principle (DIP) auflöst: durch ein Interface auf der “allgemeinen” Seite und dessen Implementierung auf der “speziellen” Seite.

Beispielcode-Repository
https://gitlab.com/archi-lab/public/architecture-good-practices/-/tree/main/resolving-cycles-with-dip
Keywords
Dependency Inversion Principle, DIP, SOLID, Zyklus, Zykel, zyklische Abhängigkeit, Aggregate, Interface

Eine zyklische Abhängkeit mit dem Dependency Inversion Pattern (DIP) auflösen

Eine zyklische Abhängigkeit entsteht dadurch, dass zwei oder mehr Aggregates voneinander abhängig sind. Der häufigste Grund dafür ist, dass die Application Services sich gegenseitig aufrufen.

Nehmen wir als Beispiel eine Applikation zum Kaufen von Eisenbahntickets. Die beiden Aggregates Ticket und Traveller sind jeweils voneinander abhängig, weil ihre Services sich gegenseitig aufrufen.

flowchart LR
    TicketService[TicketService]
    TravellerService[TravellerService]

    TicketService -- finde den Käufer --> TravellerService
    TravellerService -- storniere Tickets \nbei Konto-Kündigung --> TicketService
    
    style TicketService fill:#d4f4dd,stroke:#2e7d32,stroke-width:2px
    style TravellerService fill:#fff3cd,stroke:#ff9800,stroke-width:2px

Beide Abhängigkeiten sind fachlich gerechtfertigt. Es ist also kein Versehen, das zum Beispiel aufgrund von vergessenen Importabhängigkeiten entstanden ist.

  • Um festzuhalten, wer ein Ticket gekauft hat, muss der Ticket-Service den Benutzernamen des Käufers in eine TravellerId übersetzen. Das Auflösen von Travellern ist Sache des Traveller-Aggregats, also ruft der Ticket-Service den Traveller-Service auf.
  • Wenn ein Traveller sein Konto schließt, müssen alle Tickets, die er noch hält, storniert werden. Der Traveller-Service steuert die Kontoschließung, also ruft er den Ticket-Service auf, um diese Tickets zu stornieren.

Im Folgenden wird erklärt, wie man diese zyklische Abhängigkeit in vier Schritten auflöst.

  1. Welche Richtung behalten?
  2. “Unerwünschte” Service-Aufrufe identifizieren und daraus ein Interface machen
  3. DIP anwenden: Interface statt Service nutzen
  4. Implementierung des Interfaces auf der “anderen Seite” des Zykels

Im Source Code ist das anhand von zwei Packages “vorher” und “nachher” nachzuvollziehen.

Variante Zustand Quelle
Vorher Services hängen voneinander ab before_with_cycle/
Nachher Zyklus mit DIP aufgelöst after_with_dip/

1. Welche Richtung behalten?

Hier sollte man das logische Datenmodell (also das Domänenmodell mit Beziehungsrichtungen) analysieren. Wir haben hier im Codebeispiel kein LDM als Diagramm, aber wenn man in die Entities schaut, dann sieht man folgende Modellierung:

classDiagram
direction LR
    class Traveller {
        TravellerId id
        String username
    }
    class Ticket {
        TicketId id
        TravellerId buyerId
        BigDecimal price
        LocalDate purchaseDate
        boolean cancelled
    }
    Ticket "*" --> "1" Traveller : Käufer des Tickets

    style Ticket fill:#d4f4dd,stroke:#2e7d32,stroke-width:2px
    style Traveller fill:#fff3cd,stroke:#ff9800,stroke-width:2px

Die Assoziation läuft vom Ticket zum Traveller: das Ticket trägt eine travellerId, um den Käufer nachzuhalten. Ticket kennt also Traveller, aber der Traveller weiß also nichts von seinen Tickets.

Grundsätzlich funktioniert das Auflösen von zyklischen Abhängigkeiten unabhängig davon, für welche der beiden Richtungen man sich als “Wunschrichtung” entscheidet. Es ist also für die Anwendung von DIP egal, welche der beiden man tatsächlich behält. In der Regel hat man sich im Code schon für eine Richtung entschieden. Es ist immer weniger Aufwand, wenn man die Richtung beibehaltet, die der Code schon abbildet. Wenn man aber ohnehin nicht zufrieden mit dem eigenen Code ist und noch mal refaktoren möchte, dann wäre das ein guter Moment. Hier gilt als Faustregel: Die Beziehungsrichtung sollte immer vom Speziellen zum Allgemeinen gehen.

Wenn hier unsere Wunschrichtung Ticket --> Traveller ist, dann passt das zu unserer Faustregel. Das Ticket ist sicher “spezieller” als der Traveller. “Spezieller” kann man so lesen, dass sich diese Seite wahrscheinlich häufiger verändern wird - sowohl beim Umgang mit den Daten wie auch bei der Wartung des Codes. Ticket ist ein Bewegungsdatum, während der Traveller ein Stammdatum ist. Stammdaten sind per se “stabiler”, werden also seltener verändert - sowohl als Datenobjekte wie auch, was die Features im Code angeht.

Randbemerkung: Laufzeitabhängigkeit zwischen den Services

Übrigens haben wir hier nicht nur eine Compilezeit-, sondern auch eine Laufzeitabhängigkeit zwischen den beiden Services. Momentan starten die Services nur wegen der @Lazy-Annotation bei der Constructor Injection im Ticket Service:

    public TravellerService( TravellerRepository travellerRepository,
                             @Lazy TicketService ticketService ) {
        this.travellerRepository = travellerRepository;
        this.ticketService = ticketService;
    }

Ohne @Lazy würde Spring beim Hochfahren den Traveller Service instanzieren wollen, der den Ticket Service braucht. Dann würde es den Ticket Service instanzieren, der wiederum den Traveller Service braucht. Mit @Lazy bekommen wir diese Laufzeitabhängigkeit “versteckt” - Spring startet erst mal mit einem Proxy und lädt dann den Ticketservice nach, wenn er angesprochen wird. Mittels DIP bekommen wir beides auf einmal gelöst - Compile- und Laufzeitabhängigkeit.

2. “Unerwünschte” Service-Aufrufe identifizieren und daraus ein Interface machen

Loswerden wollen wir also die Richtung Traveller -> Ticket. Das heißt, der Traveller-Service soll nicht mehr vom Ticket-Service abhängen. Die einzige Stelle, an der der Traveller Service den Ticket Service aufruft, ist hier:

@Service
public class TravellerService {
    // ...
    public void closeAccount( String username ) {
        Traveller traveller = travellerRepository.findByUsername( username )
                .orElseThrow( () -> new TravellerNotFoundException( username ) );
        ticketService.cancelAllTicketsOf( traveller.getId() );
        traveller.close();
        travellerRepository.save( traveller );
    }
    // ...
}

Für unser Interface brauchen wir also die Methode cancelAllTicketsOf( TravellerId travellerId ). Das Interface sollte in den Begriffen des Traveller-Pakets formuliert sein, also nicht von Tickets sprechen, sondern von der Fähigkeit, alle Tickets eines Travellers zu stornieren. Es könnte zum Beispiel TicketCancellation heißen und so aussehen:

public interface TicketCancellation {
    void cancelAllTicketsOf( TravellerId travellerId );
}

3. DIP anwenden: Interface statt Service nutzen

Jetzt können wir den unerwünschten Serviceaufruf ersetzen durch das Aufrufen des neuen Interfaces. Die Abhängigkeit nach außen ist damit verschwunden, wir rufen ja unser eigenes Interface auf.

@Service
public class TravellerService {
    private final TravellerRepository travellerRepository;
    private final TicketCancellation ticketCancellation;   

    public TravellerService( TravellerRepository travellerRepository,
                             TicketCancellation ticketCancellation ) {
        this.travellerRepository = travellerRepository;
        this.ticketCancellation = ticketCancellation;
    }
    //...
    public void closeAccount( String username ) {
        Traveller traveller = travellerRepository.findByUsername( username )
                .orElseThrow( () -> new TravellerNotFoundException( username ) );
        ticketCancellation.cancelAllTicketsOf( traveller.getId() );
        traveller.close();
        travellerRepository.save( traveller );
    }
}

4. Implementierung des Interfaces auf der “anderen Seite” des Zykels

Damit der Code aus Schritt 3 funktioniert, brauchen wir natürlich eine Implementierung des Interfaces TicketCancellation. Diese Implementierung muss auf der “anderen Seite” des Zykels liegen, also im Ticket-Paket. Das ist erlaubt - denn Ticket darf Traveller kennen. “Kennen” schließt “Implementieren eines Interfaces” mit ein. Die Implementierung könnte zum Beispiel TicketCancellationService heißen und so aussehen:

@Service
public class TicketCancellationService implements TicketCancellation {
    private final TicketRepository ticketRepository;
    public TicketCancellationService( TicketRepository ticketRepository ) {
        this.ticketRepository = ticketRepository;
    }

    @Override
    public void cancelAllTicketsOf( TravellerId travellerId ) {
        for ( Ticket ticket : ticketRepository.findByBuyerId( travellerId ) ) {
            ticket.cancel();
            ticketRepository.save( ticket );
        }
    }
}

Die Abhängigkeiten zwischen den beiden Aggregates sehen dann jetzt so aus:

flowchart LR
    TicketService[TicketService]
    TicketCancellation[TicketCancellation]
    TicketCancellationService[TicketCancellationService]
    TravellerService[TravellerService]

    TicketService -- finde den Käufer --> TravellerService
    TravellerService -- storniere Tickets \nbei Konto-Kündigung --> TicketCancellation
    TicketCancellationService -- implementiert \n(und wird von Spring anstelle des \nInterfaces injeziert)--> TicketCancellation
    
    style TicketService fill:#d4f4dd,stroke:#2e7d32,stroke-width:2px
    style TicketCancellationService fill:#d4f4dd,stroke:#2e7d32,stroke-width:2px
    style TravellerService fill:#fff3cd,stroke:#ff9800,stroke-width:2px
    style TicketCancellation fill:#fff3cd,stroke:#ff9800,stroke-width:2px

Warum ein eigener Service in Schritt 4?

Es ist verlockend, die neue Klasse wegzulassen und den bestehenden TicketService das Interface TicketCancellation implementieren zu lassen. Das behebt den Zykel zur Compile-Zeit, behält aber einen Zyklus zur Laufzeit. TicketService hängt vom TravellerService ab, um Tickets zu verkaufen; der Traveller-Service nutzt zwar das TicketCancellation interface, aber wenn das als TicketService injiziert wird, bräuchten wir weiterhin die @Lazy-Annotation (siehe oben, Laufzeitabhängigkeit zwischen den Services).

Die Stornierungs-Logik braucht nur das Ticket-Repository, nie den Traveller-Service. Sie in einen eigenen TicketCancellationService zu legen, hält diese Bean frei von jedem Pfad zurück zum Traveller-Service. Der Objektgraph zur Laufzeit bleibt damit so zyklenfrei wie der Paketgraph. Das ist der Grund, warum Schritt 4 einen (kleinen) neuen Service erstellt, und nicht einfach eine Methode zu TicketService hinzufügt.