Deine erste Architekturaufgabe
Kurz gesagt: Ein Projekt zur Softwarearchitektur: Anforderungen mit Zahlen, drei Optionen mit ihren Kosten, eine Entscheidungsmatrix, ein ADR, ein Migrationsplan und ein Risikoregister für einen wachsenden Shop.
Problem
Die Aufgabe
Der Inhaber des Shops schreibt:
Ab dem Frühling verkaufen wir auch in Griechenland und Kroatien. Außerdem lassen wir externe Händler zu: Sie laden ihre Produkte selbst hoch, wir nehmen eine Provision. Der Black Friday darf nicht wieder so abstürzen wie letztes Jahr. Was kostet das, und wann ist es fertig?
Das ist die echte erste Aufgabe des Architekten. Niemand sagt dir „baue einen modularen Monolithen“. Man nennt dir einen Wunsch, und du musst Folgendes zurückbringen:
- Anforderungen mit Zahlen, die sich messen lassen;
- mindestens zwei Optionen mit ihren Kosten;
- eine Entscheidung, festgehalten in einem ADR;
- einen Migrationsplan für das laufende System, der bei jedem Schritt umkehrbar ist;
- ein Risikoregister;
- eine Prüfung, die die neuen Grenzen bewacht.
Die drei Länder liegen in der Eurozone, das Geld bleibt also in Euro. Alles andere unterscheidet sich: Sprache, MwSt., Kuriere, Rückgaberecht.
Die Idee
Der Plan
Jeder Schritt nutzt eine Lektion des Moduls:
- Die Anforderungen mit Perzentilen und einem Verfügbarkeitsbudget, wie in „Nichtfunktionale Anforderungen, die du messen kannst“.
- Die Optionen mit den Trade-offs aus „Trade-offs“ und den Formen aus „Monolith, modularer Monolith, Services“.
- Ein ADR wie in „ADR: Architekturentscheidungen festhalten“.
- Die Migration mit Strangler Fig und Expand–Contract.
- Die Risiken wie in „Architektur-Review“.
- Eine Fitness Function für die Grenzen, ebenfalls aus dem Review.
Jeder Schritt hat ein Ergebnis, mit dem du deines vergleichst. Die Lösungen stehen nach jedem Schritt unter „Lösung“, und am Ende gibt es eine Musterlösung der ganzen Aufgabe. Versuch es zuerst selbst: Architektur lernt man durch Entscheiden, nicht durch Lesen von Entscheidungen.
Übung
Schritt 1: Anforderungen mit Zahlen
„Mach es schnell und lass es nicht abstürzen“ ist keine Anforderung. Mach aus jedem dieser Wünsche ein Qualitätsszenario: Quelle, Auslöser, Reaktion und ein Maß mit einer Zahl.
- „Der Black Friday darf nicht abstürzen.“
- „Die Zahlung muss immer funktionieren.“
- „Händler sollen ihre Verkäufe sofort sehen.“
- „Ein neues Land soll sich leicht starten lassen.“
Beispielszenarien
| Qualität | Quelle und Auslöser | Reaktion | Maß |
|---|---|---|---|
| Skalierung | Black Friday, 5-facher üblicher Verkehr | die Zahlungsseite antwortet | p95 unter 800 ms, p99 unter 2 s |
| Verfügbarkeit | ein Käufer in einem der drei Länder zahlt | die Zahlung geht durch | 99,7 % der Minuten eines Monats, höchstens 130 Min. Ausfall |
| Konsistenz | ein Händler ändert den Bestand | Käufer sehen es | innerhalb von 2 Min.; alles, was über den Bestand hinaus verkauft wurde, wird innerhalb von 1 Stunde mit einer Entschuldigung storniert |
| Isolation | ein Händler lädt auf einmal 10.000 Produkte hoch | der Shop arbeitet wie zuvor | p95 des Katalogs wächst um höchstens 20 % |
| Erweiterbarkeit | ein viertes Land | startet mit Einstellungen | innerhalb von 2 Wochen, ohne Änderung am Bestellcode |
Die Zahlen hier sind Beispiele: In einer echten Aufgabe kommen sie vom Inhaber („Was kostet eine Stunde Ausfall am Black Friday?“) und aus der Messung des heutigen Systems.
Aus „Händler sollen ihre Verkäufe sofort sehen“ wurde „innerhalb von 2 Minuten“. Das ist eine bewusste Wahl für Eventual Consistency: „sofort“ würde die Systeme der Händler in den Zahlungspfad setzen.
Übung
Schritt 1b: das Budget der Kette
Die Zahlung läuft über die Shop-API, die Datenbank und den Zahlungsanbieter. Die erste Idee für die Händler ist, bei jeder Zahlung das System des Händlers zu fragen, ob der Artikel auf Lager ist. Die Verfügbarkeiten und p95-Werte unten sind Beispiele (für Anbieter aus dem Vertrag, für Händler aus einem Test):
chain = [ Hop("Shop-API", 99.95, 120), Hop("Datenbank", 99.95, 40), Hop("Zahlung", 99.9, 450), Hop("Bestand bei den Händlern", 99.0, 400),]budget("Der Bestand bei den Händlern liegt im Zahlungspfad:", chain, 99.7, 800)budget("Ohne ihn (eine Kopie, aus Ereignissen aktualisiert):", chain[:3], 99.7, 800)Schreibe budget, das die Verfügbarkeit entlang der Kette multipliziert, die p95-Werte addiert und mit dem Budget vergleicht: 99,7 % (Ausfall in Minuten für einen Monat mit 30 Tagen) und p95 unter 800 ms. Lass es mit den Händlern in der Kette laufen und ohne sie:
Der Bestand bei den Händlern liegt im Zahlungspfad: Verfügbarkeit 98.80% → 517 Min. Ausfall pro Monat (Budget 130) ✗ p95 1010 ms (Budget 800) ✗Ohne ihn (eine Kopie, aus Ereignissen aktualisiert): Verfügbarkeit 99.80% → 86 Min. Ausfall pro Monat (Budget 130) ✓ p95 610 ms (Budget 800) ✓Lösung
@dataclassclass Hop: name: str availability: float # % p95: int # msMINUTES_PER_MONTH = 30 * 24 * 60
def budget(title, chain, target, p95_budget): availability = 1.0 p95 = 0 for hop in chain: availability *= hop.availability / 100 p95 += hop.p95 # die Summe der p95-Werte ist eine Obergrenze, nicht das genaue p95 der Kette down = round((1 - availability) * MINUTES_PER_MONTH) allowed = round((1 - target / 100) * MINUTES_PER_MONTH) print(title) mark = "✓" if down <= allowed else "✗" print(f" Verfügbarkeit {availability * 100:.2f}% → {down} Min. Ausfall pro Monat (Budget {allowed}) {mark}") print(f" p95 {p95} ms (Budget {p95_budget}) {'✓' if p95 <= p95_budget else '✗'}")Ein System mit 99,0 % zieht die Zahlung auf 98,80 % herunter: 517 Minuten Ausfall im Monat bei einem Budget von 130. Auch die Latenz addiert sich. Der Bestand bei den Händlern kann also kein synchroner Schritt der Zahlung sein. Er wird zu einer Kopie auf unserer Seite, die aus Ereignissen aktualisiert wird (die Lektion „Ereignisse und Warteschlangen“), und der Preis dafür ist, dass die Kopie hinterherhinken kann. Diese Entscheidung kommt aus den Zahlen, nicht aus Geschmack.
Die Summe der p95-Werte ist eine Obergrenze: Das p95 der Kette ist meist niedriger, weil selten alle Schritte gleichzeitig langsam sind. Für ein Budget ist die Obergrenze die sichere Seite.
Übung
Schritt 2: Optionen mit ihren Kosten
Beschreibe mindestens zwei Optionen, die wirklich funktionieren würden, und die Kosten jeder: Geld, Zeit, Leute und was du verlierst. Drei naheliegende Optionen:
| A: drei Kopien des Shops | B: ein modularer Monolith | C: Services vom ersten Tag an | |
|---|---|---|---|
| Idee | ein Shop pro Land | das Land ist eine Einstellung, Händler sind ein Modul | Katalog, Bestellungen, Händler, Zahlung getrennt |
| Zeit bis zum Start | am schnellsten | mittel | am langsamsten |
| Kosten pro Monat | dreifache Infrastruktur und Pflege | ein System | viele Services, Monitoring, Netzwerk |
| was du verlierst | die Kopien laufen auseinander und lassen sich nie wieder zusammenführen | eine Änderung für ein Land geht an alle drei raus | das Team lernt verteilte Systeme, während es ausliefert |
Der Vergleich passiert in einer Entscheidungsmatrix: Kriterien mit Gewichten (wie wichtig jedes ist) und eine Punktzahl von 1 bis 5 für jede Option. Gewichte und Punkte gehören dem Team und dem Inhaber:
# Zeit bis zum Start 3, Verfügbarkeit 3, Kosten pro Monat 2, das Team kann es 2, Umkehrbarkeit 2weights = [3, 3, 2, 2, 2]options = { "A: drei Kopien des Shops": [5, 3, 2, 4, 2], "B: modularer Monolith, das Land ist eine Einstellung": [3, 4, 4, 4, 4], "C: Services vom ersten Tag an": [1, 4, 2, 2, 2],}for name, scores in options.items(): print(f"{name}: {total(scores, weights)}")print("Wahl:", best(options, weights))Schreibe total (die Summe aus Gewicht × Punkten) und best:
A: drei Kopien des Shops: 40B: modularer Monolith, das Land ist eine Einstellung: 45C: Services vom ersten Tag an: 27Wahl: B: modularer Monolith, das Land ist eine EinstellungLösung
def total(scores, weights): return sum(w * s for w, s in zip(weights, scores))
def best(options, weights): return max(options, key=lambda name: total(options[name], weights))Die Matrix trifft die Entscheidung nicht für dich. Sie macht die Gewichte sichtbar: Sagt der Inhaber „Zeit ist alles“, ist das eine Zahl, über die man streiten kann.
Übung
Schritt 2b: wie brüchig ist die Entscheidung
Die Punkte sind Einschätzung. Prüfe die Sensitivität: Erhöhe das Gewicht der Zeit bis zum Start ab 3 und finde das erste Gewicht, bei dem eine andere Option gewinnt:
Bei Gewicht 6 für die Zeit gewinnt A: drei Kopien des Shops (55 gegen 54)Die Wahl hält, bis die Zeit doppelt so wichtig wird wie die Verfügbarkeit. Das sagst du dem Inhaber: „Wenn der Start im Frühling wichtiger ist als alles andere, gewinnen die Kopien, aber hier sind ihre Kosten.“
Übung
Schritt 3: der ADR
Halte die Entscheidung in einem ADR fest. Die Prüfung aus der ADR-Lektion schaut nur, ob die Abschnitte da sind. Hier machst du sie strenger, denn ein ADR ohne Kosten ist Werbung:
- mindestens zwei Optionen unter „Optionen“;
- mindestens eine Konsequenz, die mit „- schlecht:“ beginnt;
- „Neu bewerten, wenn“ enthält eine Zahl, die sich messen lässt.
Hier ist der Entwurf, den dir ein Kollege schickt:
# ADR-014: Drei Länder und Händler im modularen Monolithen## Statusvorgeschlagen## KontextDer Shop startet in Griechenland und Kroatien und lässt externe Händler zu. Zahlung: 99.7% im Monat, p95 unter 800 ms.## Optionen- B: modularer Monolith, das Land ist eine Einstellung, Händler sind ein Modul## EntscheidungB. Der Bestand bei den Händlern kommt asynchron an und liegt nicht im Zahlungspfad.## Konsequenzen- gut: ein Code für die drei LänderSchreibe check_adr und lass es auf dem Entwurf und auf deinem endgültigen ADR laufen:
Entwurf: 3 ✗ Abschnitt „Neu bewerten, wenn“ fehlt ✗ Optionen: 1, es braucht mindestens 2 ✗ Konsequenzen: kein einziges „schlecht:“Endfassung: ✓ OKLösung
REQUIRED = ["Status", "Kontext", "Optionen", "Entscheidung", "Konsequenzen", "Neu bewerten, wenn"]
def sections(adr): result, current = {}, None for line in adr.strip().split("\n"): if line.startswith("## "): current = result[line[3:]] = [] elif current is not None and line.strip(): current.append(line) return result
def check_adr(adr): s = sections(adr) problems = [f"✗ Abschnitt „{name}“ fehlt" for name in REQUIRED if name not in s] count = sum(1 for line in s.get("Optionen", []) if line.startswith("- ")) if count < 2: problems.append(f"✗ Optionen: {count}, es braucht mindestens 2") if not any(line.startswith("- schlecht:") for line in s.get("Konsequenzen", [])): problems.append("✗ Konsequenzen: kein einziges „schlecht:“") if "Neu bewerten, wenn" in s and not any(ch.isdigit() for ch in " ".join(s["Neu bewerten, wenn"])): problems.append("✗ Neu bewerten, wenn: keine Zahl, die sich messen lässt") return problemsfor name, text in [("Entwurf", DRAFT), ("Endfassung", FINAL)]: problems = check_adr(text) print(f"{name}: {len(problems) if problems else '✓ OK'}") for p in problems: print(" " + p)Der endgültige ADR steht unten in der Musterlösung.
Übung
Schritt 4: der Migrationsplan
Der Shop läuft gerade. Bestellungen müssen das Land kennenlernen, ohne Stillstand und ohne große Umschaltung. Bring die Schritte in eine Reihenfolge, in der sich jeder bis zum letzten rückgängig machen lässt:
die Spalte country hinzufügen (leer, der alte Code liest sie nicht)10 %, 50 %, 100 % nur bei grünen Metriken, bei roten zurückder neue Code schreibt country, der alte läuft weiterBackfill: alte Bestellungen bekommen BG, danach die Anzahl vergleichen1 % des griechischen Verkehrs läuft über den neuen Weg (ein Flag)den alten Pfad abschalten und den alten Code entfernen
Die Anteile (1 %, 10 %, 50 %, 100 %) werden nicht nach dem Kalender erhöht, sondern nach Metriken. Schreibe rollout: Für jeden Anteil liest es die nächsten aufgezeichneten Metriken. Bei Grün (höchstens 5 Fehler pro 1000 und p95 unter 800 ms) geht es weiter, bei Rot schiebt es den Verkehr auf den vorherigen Anteil zurück und versucht es nach der Korrektur erneut. Die Metriken sind ein aufgezeichnetes Szenario, die Ausgabe ist also immer dieselbe:
observed = [ Metrics(2, 610), Metrics(9, 700), Metrics(3, 640), Metrics(4, 690), Metrics(3, 700),]rollout([1, 10, 50, 100], iter(observed))1%: Fehler 2/1000, p95 610 ms ✓10%: Fehler 9/1000, p95 700 ms ✗ zurück auf 1%10%: Fehler 3/1000, p95 640 ms ✓50%: Fehler 4/1000, p95 690 ms ✓100%: Fehler 3/1000, p95 700 ms ✓Der alte Pfad ist abgeschaltet: der Punkt ohne Rückweg.Lösung
@dataclassclass Metrics: errors_per_1000: int p95: int # ms
def healthy(self): return self.errors_per_1000 <= 5 and self.p95 <= 800def rollout(shares, observed): step = 0 while step < len(shares): m = next(observed) line = f"{shares[step]}%: Fehler {m.errors_per_1000}/1000, p95 {m.p95} ms" if m.healthy(): print(line + " ✓") step += 1 else: back = 0 if step == 0 else shares[step - 1] print(f"{line} ✗ zurück auf {back}%") print("Der alte Pfad ist abgeschaltet: der Punkt ohne Rückweg.")Es gibt nur einen Punkt ohne Rückweg, und er kommt zuletzt: das Entfernen des alten Pfads. Alles davor macht man mit einem Flag rückgängig. Die Spalte wird leer hinzugefügt (Expand) und erst entfernt, wenn sie niemand mehr liest (Contract).
Probier es aus
Finde den Fehler
Ein Kollege schreibt die Prüfung der Metriken. Der Rollout läuft glatt, aber 10 % starten mit 9 Fehlern pro 1000:
1%: Fehler 2/1000, p95 610 ms ✓10%: Fehler 9/1000, p95 700 ms ✓50%: Fehler 3/1000, p95 640 ms ✓100%: Fehler 4/1000, p95 690 ms ✓Der alte Pfad ist abgeschaltet: der Punkt ohne Rückweg.Wähle die Zeile mit dem Fehler.
Die Bedingung ist „oder“ statt „und“: Es reicht, dass eine der beiden Metriken grün ist. 9 Fehler pro 1000 kommen durch, weil p95 gut ist. Eine Sicherheitsbedingung ist „und“: Du gehst nur weiter, wenn alles grün ist.
Übung
Schritt 5: das Risikoregister
Hier sind die Risiken, die das Team gesammelt hat. Schreibe register, das sie sortiert (nach Punktzahl, bei Gleichstand nach Auswirkung) und die Maßnahme zeigt. Ein hohes Risiko (12 oder mehr) ohne Maßnahme ist ein offenes Loch und wird mit „✗“ angezeigt, ein kleineres ohne Maßnahme wird bewusst akzeptiert.
risks = [ Risk("MwSt. und Rechnungen werden zwischen den Ländern durcheinandergebracht", 3, 5, "eine Testrechnung für jedes Land vor 1%"), Risk("der Kurier in Kroatien hat keine Tracking-API", 2, 4, ""), Risk("der Black-Friday-Verkehr in Griechenland ist 5-mal so hoch", 3, 4, ""), Risk("die Bestandskopie hinkt hinterher und wir verkaufen Ausverkauftes", 4, 3, "eine Reservierung bei der Zahlung und eine Stornierung mit Entschuldigung"), Risk("das Team kennt das Händlermodul nicht", 3, 2, "Pair Programming in den ersten zwei Monaten"),]register(risks)15 MwSt. und Rechnungen werden zwischen den Ländern durcheinandergebracht → eine Testrechnung für jedes Land vor 1%12 der Black-Friday-Verkehr in Griechenland ist 5-mal so hoch → ✗ keine Maßnahme12 die Bestandskopie hinkt hinterher und wir verkaufen Ausverkauftes → eine Reservierung bei der Zahlung und eine Stornierung mit Entschuldigung 8 der Kurier in Kroatien hat keine Tracking-API → (akzeptiert) 6 das Team kennt das Händlermodul nicht → Pair Programming in den ersten zwei MonatenLösung
@dataclassclass Risk: name: str probability: int impact: int measure: str = ""
@property def score(self): return self.probability * self.impactdef register(risks): for r in sorted(risks, key=lambda r: (r.score, r.impact), reverse=True): measure = r.measure or ("✗ keine Maßnahme" if r.score >= 12 else "(akzeptiert)") print(f"{r.score:2} {r.name} → {measure}")Für den Black Friday ist die Maßnahme ein Lasttest mit dem 5-fachen Verkehr vor dem Start in Griechenland und ein Plan, was zuerst abgeschaltet wird (Empfehlungen, Suche nach Händler), damit die Zahlung überlebt. Skalierung steht in „Skalierung“, und das Abschalten ist wie der Circuit Breaker aus „Wenn der Nachbar langsam ist: Timeout, Retry, Circuit Breaker“.
Übung
Schritt 6: eine Fitness Function für die neuen Grenzen
Das Händlermodul ist neu. Fängt es in seinem ersten Monat an, das Innere der Bestellungen zu lesen, lässt es sich ein Jahr später nicht mehr herauslösen. Die Regel: Jedes Modul hat eine api (den öffentlichen Teil) und ein internal; ein anderes Modul wird nur über seine api aufgerufen. Die Imports der Module:
sellers.internal -> orders.apisellers.internal -> orders.internalorders.internal -> payments.apiorders.internal -> sellers.internalcatalog.internal -> sellers.apiorders.internal -> orders.db✗ sellers.internal -> orders.internal: ruft das Innere auf von orders✗ orders.internal -> sellers.internal: ruft das Innere auf von sellersVerstöße: 2if from_module != to_module and to_part != "":api: Ein anderes Modul erreichst du nur über seinen öffentlichen Teil. Innerhalb des eigenen Moduls (orders.internal -> orders.db) ist alles erlaubt.
Lösung
# ein fremdes Modul wird nur über seine api aufgerufendef boundary_violations(imports): found = [] for line in imports: source, target = line.split(" -> ") from_module, _ = source.split(".") to_module, to_part = target.split(".") if from_module != to_module and to_part != "api": found.append(f"{line}: ruft das Innere auf von {to_module}") return foundimports = [ "sellers.internal -> orders.api", "sellers.internal -> orders.internal", "orders.internal -> payments.api", "orders.internal -> sellers.internal", "catalog.internal -> sellers.api", "orders.internal -> orders.db",]found = boundary_violations(imports)for v in found: print("✗ " + v)print("Verstöße:", len(found))Lass sie bei jedem Commit mit einer Baseline laufen, wie im Review. Die heutigen zwei Verstöße werden behoben, bevor das Händlermodul ausgeliefert wird.
Sieh es dir an
Musterlösung
Vergleiche deine Lösung mit dieser. Sie ist nicht die einzig richtige: Richtig ist jede Lösung, deren Zahlen, Optionen und Risiken ehrlich und festgehalten sind.
Die Anforderungen. Die fünf Szenarien aus Schritt 1. Die wichtigsten: Zahlung mit 99,7 % und p95 unter 800 ms, und der Bestand bei den Händlern eventuell konsistent, innerhalb von 2 Minuten.
Die Optionen. A, B und C mit ihren Kosten; gewählt wird B mit 45 Punkten gegen 40 und 27. Die Entscheidung hält bis zu einem Gewicht von 6 für die Zeit.
Die Architektur von B.
- Das Land ist eine Einstellung: Sprache, MwSt., Kurier und Rückgaberegeln kommen aus einer Tabelle pro Land, nicht aus einem
ifim Code. - Händler sind ein Modul im Monolithen, mit eigenen Daten und einer öffentlichen
api(„Monolith, modularer Monolith, Services“). - Der Bestand bei den Händlern kommt als Ereignisse in eine Kopie auf unserer Seite (ein Outbox auf ihrer Seite oder eine Warteschlange auf unserer).
- Alles, was über den Bestand hinaus verkauft wurde, wird mit einer Saga storniert: Reservierung, Zahlung und bei einer Absage des Händlers eine Rückerstattung.
- Das Lager 2009 und die Buchhaltung bleiben wie bisher hinter einem Anti-Corruption-Layer.
Der ADR.
# ADR-014: Drei Länder und Händler im modularen Monolithen## Statusakzeptiert## KontextDer Shop startet in Griechenland und Kroatien und lässt externe Händler zu. Zahlung: 99.7% im Monat, p95 unter 800 ms.## Optionen- A: drei Kopien des Shops, eine pro Land- B: modularer Monolith, das Land ist eine Einstellung, Händler sind ein Modul- C: Services vom ersten Tag an## EntscheidungB. Der Bestand bei den Händlern kommt asynchron an und liegt nicht im Zahlungspfad.## Konsequenzen- gut: ein Code für die drei Länder- schlecht: eine Änderung für ein Land geht an alle drei raus- schlecht: die Bestandskopie kann Minuten hinterherhinken## Neu bewerten, wennWenn die Händler auf über 500 wachsen oder die Teams auf mehr als 3.Der Plan. Die Spalte country (Expand), der neue Code schreibt in sie, ein Backfill der alten Bestellungen als Bulgarien, dann 1 %, 10 %, 50 % und 100 % des griechischen Verkehrs bei grünen Metriken und zuletzt das Entfernen des alten Pfads (Contract). Kroatien folgt Griechenland mit demselben Plan.
Die Risiken. Das Register aus Schritt 5, mit einer Maßnahme für den Black Friday. Der Verantwortliche jedes Risikos steht dafür gerade, und das Register wird bis zum Start alle zwei Wochen durchgesehen.
Die Prüfungen. Die Grenzen aus Schritt 6 und das Budget aus Schritt 1 als Fitness Function: ein Lasttest vor jedem Start, der fehlschlägt, wenn das p95 der Zahlung über 800 ms liegt. Wie sie in der Pipeline laufen, ist ein Thema für ZeroToOps, den DevOps-Kurs.
Was wir nicht entschieden haben. Einen eigenen Service für Händler. Der ADR sagt, wann man darauf zurückkommt: bei mehr als 500 Händlern oder mehr als 3 Teams.
Test