Architektur-Review
Kurz gesagt: Ein Architektur-Review liest die echten Abhängigkeiten aus dem Code, findet Pfeile in die falsche Richtung und Zyklen und macht die Regeln zu einer Fitness Function, die sie bei jedem Commit bewacht.
Problem
Das Diagramm sagt „Schichten“
Du erbst den Online-Shop. In der README steht ein Diagramm: vier Schichten, Pfeile nur nach unten.
web web/shopservice service/orders service/paymentsdomain domain/pricingdata data/orders
web → service → domain → dataDeine erste Aufgabe ist klein: Der Preis auf der Seite soll zwei Nachkommastellen haben. Du änderst formatPrice in web/shop, und die Zahlungstests schlagen fehl. Es stellt sich heraus, dass domain/pricing formatPrice aus web/shop benutzt und dass Bestellungen und Zahlungen sich gegenseitig aufrufen.
Das Diagramm hat nicht absichtlich gelogen. Vor zwei Jahren stimmte es, und dann hat niemand mehr nachgeprüft.
Die Idee
Die Wahrheit steht in den Imports
Ein Architektur-Review beantwortet ein paar Fragen zu einem System, das schon läuft:
- Wie verlaufen die echten Grenzen? Welches Modul welches aufruft, laut Code.
- Werden die Regeln eingehalten? Die Schichten aus „Schichten“, die Richtung der Abhängigkeiten aus „Hexagonale und Clean Architecture“, die Modulgrenzen aus „Monolith, modularer Monolith, Services“.
- Halten die Anforderungen? Die Perzentile und die Verfügbarkeit aus „Nichtfunktionale Anforderungen, die du messen kannst“, am laufenden System gemessen, nicht versprochen.
- Was kann schiefgehen? Die Risiken, geordnet.
- Welche Entscheidungen hat niemand festgehalten? Dafür schreibst du nachträglich ADRs, damit klar ist, warum das System so ist, wie es ist.
Hier beginnen wir mit den ersten beiden. Jeder Import ist ein Pfeil von einem Modul zu einem anderen. Ein Werkzeug zieht sie als Liste aus dem Code, eine Abhängigkeit pro Zeile:
web/shop -> service/ordersservice/orders -> domain/pricingservice/orders -> service/paymentsservice/payments -> service/ordersservice/orders -> data/ordersdomain/pricing -> web/shopDas Programm macht aus der Liste einen Graphen und sucht zwei Dinge. Einen Pfeil nach oben: Eine tiefere Schicht ruft eine höhere auf. Einen Zyklus: ein Modul, das sich über andere Module wieder selbst erreicht. Ein Zyklus bedeutet, dass sich die beiden Module nicht getrennt ändern, testen oder ausliefern lassen. Zyklen findet man mit einer Tiefensuche mit Farben.
Sieh es dir an
Erklärt gegen echt
static int layer(String module) { return LAYERS.indexOf(module.split("/")[0]);}
// eine tiefere Schicht ruft eine höhere auf: ein Pfeil nach obenstatic List<Dep> violations(List<Dep> deps) { List<Dep> found = new ArrayList<>(); for (Dep d : deps) if (layer(d.from()) > layer(d.to())) found.add(d); return found;}
void visit(String at) { mark.put(at, Mark.GREY); // grau: auf dem aktuellen Pfad path.add(at); for (String to : next.get(at)) { if (mark.get(to) == null) visit(to); else if (mark.get(to) == Mark.GREY) cycle(to); } path.removeLast(); mark.put(at, Mark.BLACK); // schwarz: abgearbeitet}
void cycle(String to) { int start = path.indexOf(to); var loop = path.subList(start, path.size()); found.add(String.join(" -> ", loop) + " -> " + to);}Schritt für Schritt, in Worten
- Das Diagramm in der README: vier Schichten, Pfeile nur nach unten. Jedes Modul steht in seiner Schicht.
- Das Werkzeug liest die Imports: 6 echte Abhängigkeiten. Die meisten zeigen nach unten oder bleiben in einer Schicht.
- domain/pricing importiert web/shop: Schicht 2 ruft Schicht 0 auf. Der Pfeil zeigt nach oben.
- Die Tiefensuche startet bei web/shop. Jedes Modul auf dem aktuellen Pfad ist grau.
- domain/pricing zeigt zurück auf das graue web/shop: ein Zyklus, web/shop → service/orders → domain/pricing → web/shop.
- Die Suche geht weiter: service/payments zeigt zurück auf das graue service/orders. Ein zweiter Zyklus.
- Jedes Modul ist abgearbeitet (schwarz). Erklärt: Schichten, Pfeile nur nach unten. Echt: 1 Pfeil nach oben und 2 Zyklen.
Die Schichtregel ist ein einziger Vergleich: Der Index der Schicht wächst nach unten, ein Pfeil nach oben ist also layer(from) > layer(to).
static final List<String> LAYERS = List.of("web", "service", "domain", "data");
static int layer(String module) { return LAYERS.indexOf(module.split("/")[0]);}
// eine tiefere Schicht ruft eine höhere auf: ein Pfeil nach obenstatic List<Dep> violations(List<Dep> deps) { List<Dep> found = new ArrayList<>(); for (Dep d : deps) if (layer(d.from()) > layer(d.to())) found.add(d); return found;}Eine Tiefensuche findet die Zyklen. Ein graues Modul liegt auf dem aktuellen Pfad, ein schwarzes ist vollständig erkundet. Ein Pfeil auf ein graues Modul geht entlang des Pfads zurück: Das ist ein Zyklus, und der Zyklus ist der Teil des Pfads von diesem Modul bis jetzt.
record Dep(String from, String to) { @Override public String toString() { return from + " -> " + to; }}enum Mark { GREY, BLACK }
class Cycles { final Map<String, List<String>> next = new HashMap<>(); final Map<String, Mark> mark = new HashMap<>(); final List<String> path = new ArrayList<>(); final List<String> found = new ArrayList<>();
Cycles(List<Dep> deps) { for (Dep d : deps) { next.computeIfAbsent(d.from(), k -> new ArrayList<>()).add(d.to()); next.computeIfAbsent(d.to(), k -> new ArrayList<>()); } for (Dep d : deps) if (!mark.containsKey(d.from())) visit(d.from()); }
void visit(String at) { mark.put(at, Mark.GREY); // grau: auf dem aktuellen Pfad path.add(at); for (String to : next.get(at)) { if (mark.get(to) == null) visit(to); else if (mark.get(to) == Mark.GREY) cycle(to); } path.removeLast(); mark.put(at, Mark.BLACK); // schwarz: abgearbeitet }
void cycle(String to) { int start = path.indexOf(to); var loop = path.subList(start, path.size()); found.add(String.join(" -> ", loop) + " -> " + to); }}path.removeLast() gibt es ab Java 21. mark.get(to) == null heißt „noch nicht besucht“, weil die Map keinen Eintrag hat.Das ganze Review auf der Liste der Imports:
List<Dep> deps = parse(IMPORTS);System.out.println("Abhängigkeiten: " + deps.size());show("Verletzte Schichten", violations(deps));show("Zyklen", new Cycles(deps).found);Abhängigkeiten: 6Verletzte Schichten: domain/pricing -> web/shopZyklen: web/shop -> service/orders -> domain/pricing -> web/shop service/orders -> service/payments -> service/ordersDie eine Verletzung und die zwei Zyklen sind nicht drei getrennte Probleme. domain/pricing -> web/shop ist zugleich die Verletzung und der Pfeil, der den ersten Zyklus schließt: Behebst du ihn, verschwinden beide.
Probier es aus
Ergänze
Welcher Vergleich fängt den Pfeil nach oben? Die Schicht web hat den Index 0 und data den Index 3.
for (Dep d : deps)
if (layer(d.from()) layer(d.to())) found.add(d);>: Das Modul, von dem der Pfeil ausgeht, liegt in einer tieferen Schicht (größerer Index) als das Modul, auf das er zeigt. Ein Pfeil innerhalb derselben Schicht (service/orders -> service/payments) bricht die Schichten nicht, kann aber Teil eines Zyklus sein.
Probier es aus
Finde den Fehler
Ein Kollege hat visit „vereinfacht“. Jetzt gibt das Review aus:
Abhängigkeiten: 6Verletzte Schichten: domain/pricing -> web/shopZyklen: keineWähle die Zeile mit dem Fehler.
Das Modul wird beim Betreten schwarz statt grau. Dann ist nie ein Modul grau, die Prüfung auf einen Pfeil zurück ist nie wahr, und die Zyklen verschwinden. Das Schlimmste: Die Ausgabe sieht ruhig aus, „keine“. Eine Prüfung, die schweigt, musst du auch mit einer Eingabe testen, bei der du weißt, dass es einen Zyklus gibt.
Die Idee
Fitness Function: das Review bei jedem Commit
Ein Review ist eine Momentaufnahme. Morgen fügt jemand einen neuen Import hinzu, und die Momentaufnahme ist veraltet. Eine Fitness Function ist eine automatische Prüfung einer architektonischen Eigenschaft: Sie läuft bei jedem Commit wie ein Test und schlägt fehl, wenn die Regel verletzt wird. Der Name kommt aus dem Buch „Building Evolutionary Architectures“ (Ford, Parsons, Kua).
Das geerbte System hat schon eine Verletzung. Schlägt die Prüfung deswegen fehl, schlägt sie jeden Tag fehl, und das Team schaltet sie ab. Darum kommt die alte Schuld in eine Baseline: Die Prüfung schlägt nur bei einer neuen Verletzung fehl, und eine behobene muss die Baseline verlassen, damit sie nicht unbemerkt zurückkommt. So wird die Schuld nur kleiner.
// läuft bei jedem Commit: die alte Schuld steht in der Baselinestatic List<String> fitness(List<Dep> deps, List<String> baseline) { List<String> report = new ArrayList<>(); List<String> now = new ArrayList<>(); for (Dep d : violations(deps)) { now.add(d.toString()); if (!baseline.contains(d.toString())) report.add("✗ neue Abhängigkeit nach oben: " + d); } for (String old : baseline) if (!now.contains(old)) report.add("✓ behoben, nimm sie aus der Baseline: " + old); return report;}
static void check(String commit, List<Dep> deps, List<String> baseline) { List<String> report = fitness(deps, baseline); boolean fails = report.stream().anyMatch(r -> r.startsWith("✗")); System.out.println(commit + ": " + (fails ? "FEHLER" : "OK")); for (String r : report) System.out.println(" " + r);}Commit 1 fügt domain/pricing -> service/payments hinzu: Pricing will die Zahlungsgebühr. Commit 2 verschiebt formatPrice in die Domäne und entfernt die alte Verletzung:
List<Dep> deps = parse(IMPORTS);List<String> baseline = List.of("domain/pricing -> web/shop");
List<Dep> first = new ArrayList<>(deps);first.add(new Dep("domain/pricing", "service/payments"));check("Commit 1", first, baseline);
List<Dep> second = new ArrayList<>(deps);second.removeIf(d -> d.toString().equals("domain/pricing -> web/shop"));check("Commit 2", second, baseline);Commit 1: FEHLER ✗ neue Abhängigkeit nach oben: domain/pricing -> service/paymentsCommit 2: OK ✓ behoben, nimm sie aus der Baseline: domain/pricing -> web/shopEine Fitness Function ist nicht nur für Abhängigkeiten da. Dieselbe Idee bewacht andere Eigenschaften:
- das p95 der Zahlung im Lasttest bleibt unter dem Budget aus „Nichtfunktionale Anforderungen, die du messen kannst“;
- das Modul der Verkäufer ruft nur die öffentliche API der Bestellungen auf, nicht ihr Inneres;
- das JavaScript-Bundle der Seite ist kleiner als 200 KB (ein Beispiellimit);
- jedes ADR hat einen Abschnitt „Konsequenzen“.
Wie genau die Prüfung in der Pipeline (CI) läuft, ist ein Thema für ZeroToOps, den DevOps-Kurs. Hier ist der Teil, den der Programmierer schreibt: die Prüfung selbst.
Die Idee
Risikoregister
Das Review findet mehr, als du in der Zeit beheben kannst. Ein Risikoregister bringt es in Ordnung. Für jedes Risiko hältst du fest:
- was passieren kann;
- wie wahrscheinlich es ist, von 1 bis 5;
- wie sehr es wehtut, wenn es passiert (Auswirkung), von 1 bis 5;
- was du dagegen tust und wer dafür zuständig ist.
Die Punktzahl ist Wahrscheinlichkeit × Auswirkung. Du sortierst danach, und bei Gleichstand nach der Auswirkung: Das seltene, aber schwere Ereignis kommt vor dem häufigen, leichten.
record Risk(String name, int probability, int impact, String response) { int score() { return probability * impact; }
String level() { return score() >= 12 ? "hoch" : score() >= 6 ? "mittel" : "gering"; }}// nach Punktzahl; bei Gleichstand kommt die größere Auswirkung zuerststatic List<Risk> ranked(List<Risk> risks) { List<Risk> sorted = new ArrayList<>(risks); sorted.sort(Comparator.comparingInt(Risk::score) .thenComparingInt(Risk::impact) .reversed()); return sorted;}Die Risiken aus dem Review des Shops:
List<Risk> risks = List.of( new Risk("Zyklus orders ⇄ payments", 4, 3, "verringern: ein Ereignis statt eines Rückrufs"), new Risk("die Zahlung hat kein Timeout und bremst die Bestellungen", 3, 5, "verringern: ein Timeout und ein Circuit Breaker"), new Risk("die Domäne ruft web auf", 4, 2, "akzeptieren: wird mit der nächsten Preisänderung behoben"), new Risk("die Wiederherstellung aus dem Backup wurde nie probiert", 2, 5, "verringern: jeden Monat eine Probe-Wiederherstellung"), new Risk("nur eine Person kennt das Lager von 2009", 3, 4, "verringern: eine zweite Person und eine Beschreibung"), new Risk("die PDF-Bibliothek für Rechnungen wird nicht mehr gepflegt", 2, 2, "akzeptieren"));for (Risk r : ranked(risks)) System.out.printf("%2d %-6s %s → %s%n", r.score(), r.level(), r.name(), r.response());15 hoch die Zahlung hat kein Timeout und bremst die Bestellungen → verringern: ein Timeout und ein Circuit Breaker12 hoch nur eine Person kennt das Lager von 2009 → verringern: eine zweite Person und eine Beschreibung12 hoch Zyklus orders ⇄ payments → verringern: ein Ereignis statt eines Rückrufs10 mittel die Wiederherstellung aus dem Backup wurde nie probiert → verringern: jeden Monat eine Probe-Wiederherstellung 8 mittel die Domäne ruft web auf → akzeptieren: wird mit der nächsten Preisänderung behoben 4 gering die PDF-Bibliothek für Rechnungen wird nicht mehr gepflegt → akzeptierenEs gibt vier Antworten:
- vermeiden: die Ursache beseitigen, zum Beispiel das Feature nicht ausliefern;
- verringern: die Wahrscheinlichkeit oder die Auswirkung, zum Beispiel mit einem Timeout und einem Circuit Breaker aus „Wenn der Nachbar langsam ist: Timeout, Retry, Circuit Breaker“;
- übertragen: an jemand anderen, zum Beispiel durch eine Versicherung oder einen Vertrag mit dem Anbieter;
- akzeptieren: bewusst und schriftlich, mit einem Datum für einen neuen Blick.
Die Zahlen von 1 bis 5 sind ein Urteil, keine Messung. 12 ist nicht „doppelt so schlimm“ wie 6. Sie sind da, um zu sortieren und das Gespräch zu beginnen, nicht um damit zu rechnen.
thenComparingInt(Risk::impact).reversed() kehrt die ganze Ordnung um: Sowohl die Punktzahl als auch die Auswirkung gehen von groß nach klein.Probier es aus
Sage die Ausgabe voraus
Vier Risiken aus einem anderen Review, mit demselben ranked sortiert. Was gibt das Programm aus?
List<Risk> risks = List.of( new Risk("der E-Mail-Anbieter ändert seine API", 3, 2, ""), new Risk("das Zertifikat der Seite wird von Hand erneuert", 3, 3, ""), new Risk("das Lager fällt am Black Friday aus", 2, 5, ""), new Risk("die Monatsberichte kommen zu spät", 5, 1, ""));for (Risk r : ranked(risks)) System.out.println(r.score() + " " + r.name());Die Punktzahlen sind 6, 9, 10 und 5. Von oben nach unten: das Lager am Black Friday (2 × 5 = 10), das Zertifikat (3 × 3 = 9), die E-Mails (3 × 2 = 6), die Berichte (5 × 1 = 5). Die Berichte sind am wahrscheinlichsten, aber sie tun am wenigsten weh, wenn sie zu spät kommen.
Die Idee
Wenn das Review schadet
Ein Review ist ein Werkzeug, kein Ritual. Es schadet, wenn:
- es eine Prüfung für das Team ist. Ein Review, zu dem die Leute, die den Code geschrieben haben, nicht eingeladen sind, findet Schuldige statt Risiken. Sie wissen, warum die Dinge so sind.
- die Fitness Function wegen Rauschen fehlschlägt. Eine wackelige Prüfung (ein Lasttest auf einem geteilten Rechner) schlägt grundlos fehl und wird abgeschaltet. Eine stabile Regel bewacht mehr als zehn wackelige.
- die Baseline nur wächst. Dann ist sie eine Liste von Ausreden. Behalte ihre Größe über die Zeit im Blick.
- das Register in einem Ordner liegt. Ein Risiko ohne Zuständigen und ohne Datum für einen neuen Blick ist ein vergessenes Risiko.
- die Imports die ganze Wahrheit sind. Sind sie nicht. Eine gemeinsame Datenbanktabelle, ein Aufruf über HTTP und eine Warteschlange verbinden Module ohne Import. Module, die gegenseitig ihre Tabellen lesen („Monolith, modularer Monolith, Services“), tauchen im Import-Graphen nicht auf.
Genauer, für Neugierige
Die Werkzeuge. Niemand schreibt die Abhängigkeitsprüfung von Grund auf. Java hat ArchUnit (die Regeln sind JUnit-Tests) und jdeps im JDK; C# hat ArchUnitNET und NetArchTest; Python hat import-linter; JavaScript hat dependency-cruiser und madge (für Zyklen). Alle ziehen den Graphen aus dem Code und prüfen Regeln wie die in dieser Lektion, auch mit einer Baseline: In ArchUnit heißt sie FreezingArchRule.
Wie viele Zyklen gibt es? Die Suche in dieser Lektion findet für jeden Zyklus, auf den sie trifft, einen Pfeil zurück. Das ist nicht die Zahl aller Zyklen: Wenn du das Review nach domain/pricing -> service/payments laufen lässt, führt der Weg zu den Zahlungen über Pricing, und der kleine Zyklus „orders ⇄ payments“ wird nicht extra gezählt. Werkzeuge suchen starke Zusammenhangskomponenten (Tarjans Algorithmus): die Gruppen von Modulen, in denen jedes jedes andere erreicht. Die Frage „welche Module liegen in einem Zyklus“ ist nützlicher als „wie viele Zyklen gibt es“.
Review-Methoden. ATAM (Architecture Tradeoff Analysis Method, vom SEI) ist ein formales Review mit Qualitätsszenarien und den Beteiligten. Das C4-Modell ist eine Art, die Architektur auf vier Detailstufen zu zeichnen, damit das Diagramm die Frage des Lesers beantwortet.
Übung
Zyklen auch in der Prüfung
Löse diese Aufgabe in deinem Editor.
Füge der Fitness Function eine zweite Regel hinzu: Die Zahl der Zyklen darf nicht über die Zyklen-Baseline steigen (heute 2). Schreibe checkAll(deps, baseline, cycleBaseline), das alle Funde ausgibt und am Ende, ob es fehlschlägt. Lass es mit einem Commit laufen, der data/orders -> service/orders hinzufügt: Die Datenschicht ruft den Service auf, um nach dem Speichern eine E-Mail zu senden.
✗ neue Abhängigkeit nach oben: data/orders -> service/orders✗ Zyklen: 3, baseline: 2FEHLERWeitere Aufgaben
- Wer liegt in einem Zyklus. Behalte statt der Zahl der Zyklen die Menge der Module, die in irgendeinem Zyklus liegen. Welche Regel ist schwerer zu umgehen?
- Module in einer Schicht. Füge eine Regel hinzu:
service/paymentsdarfservice/ordersnicht direkt aufrufen, nur über ein Ereignis (die Lektion „Ereignisse und Warteschlangen“). Wie nimmst du sie in die Baseline auf, bis der Zyklus behoben ist? - Das Register. Gib jedem Risiko einen Zuständigen und ein Datum für einen neuen Blick. Gib die Risiken mit 12 Punkten oder mehr aus, die keinen Zuständigen haben.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Ereignisse und Warteschlangen · ADR: Architekturentscheidungen festhalten · Nichtfunktionale Anforderungen, die du messen kannst · Wenn der Nachbar langsam ist: Timeout, Retry, Circuit Breaker
- Geht weiter in: Die Unternehmenslandschaft und ihre Capabilities · Deine erste Architekturaufgabe
- Aufgaben zum Thema: Der Architekt bei der Arbeit