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.
- Welche Richtung behalten?
- “Unerwünschte” Service-Aufrufe identifizieren und daraus ein Interface machen
- DIP anwenden: Interface statt Service nutzen
- Implementierung des Interfaces auf der “anderen Seite” des Zykels
Im Source Code ist das anhand von zwei Packages “vorher” und “nachher” nachzuvollziehen.
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.