ADR: Architekturentscheidungen festhalten
Kurz gesagt: Ein ADR (architecture decision record) ist ein kurzes Dokument mit Kontext, Optionen, Entscheidung und Konsequenzen; eine alte Entscheidung wird nicht umgeschrieben, sondern durch eine neue ersetzt.
Problem
Warum haben wir eine Warteschlange?
Eine neue Kollegin öffnet den Zahlungscode. Benachrichtigungen laufen über eine Warteschlange, die E-Mail kommt manchmal eine Minute später, und ihr Konsument muss Duplikate wegwerfen. „Das ist kompliziert. Warum rufen wir die E-Mail nicht einfach direkt auf?“
Niemand weiß es mehr. Wer es entschieden hat, ist nicht mehr in der Firma. Die Diskussion fand in einem Chat statt, der längst gelöscht ist. Es bleiben zwei schlechte Möglichkeiten: etwas behalten, das du nicht verstehst, oder es entfernen und am nächsten Black Friday auf genau den Rechen treten, wegen dem es eingebaut wurde.
Die Idee
Ein ADR: die Entscheidung und das Warum
Ein ADR (architecture decision record) ist ein kurzes Dokument über eine Entscheidung. Es wird geschrieben, wenn die Entscheidung fällt, und liegt im Repository neben dem Code (docs/adr/0007-notifications-queue.md). Es hat sechs Teile:
- Titel und Nummer: was entschieden wurde, mit einer laufenden Nummer;
- Status: vorgeschlagen, angenommen, ersetzt;
- Kontext: was jetzt gilt, was weh tut und was einschränkt;
- Optionen: mindestens zwei, die wirklich erwogen wurden;
- Entscheidung: welche Option und warum;
- Konsequenzen: was leichter wird und was schwerer wird.
Hier ist das ADR, das die Kollegin gesucht hat:
# ADR-0007: Benachrichtigungen laufen über eine Warteschlange
## StatusAngenommen. Ersetzt ADR-0004.
## KontextADR-0004: Die Zahlung ruft die E-Mail direkt auf. Am Black Fridayantwortete der E-Mail-Anbieter in 8 Sekunden, und Zahlungen liefen in den Timeout.
## Optionen1. Ein direkter Aufruf mit kurzem Timeout: Die Zahlung geht durch, die E-Mail geht verloren.2. Eine Warteschlange: Die Zahlung veröffentlicht ein Ereignis, die Benachrichtigungen lesen es später.
## EntscheidungEine Warteschlange. Eine verlorene E-Mail für eine bezahlte Bestellung ist schlimmer als eine späte.
## Konsequenzen- Die Zahlung wartet nicht auf die E-Mail.- Die E-Mail kann eine Minute später ankommen.- Die Zustellung ist „mindestens einmal“, also ist der Konsument idempotent.- Ein Teil mehr, der überwacht werden muss: die Warteschlange.Die zweite Hälfte ist die wertvollste: Sie sagt der Kollegin, warum es Duplikate gibt und was kaputtgeht, wenn die Warteschlange ausfällt. Die Warteschlange und der idempotente Konsument stammen aus der Lektion über Ereignisse.
Die Idee
Die Status und das Ändern einer Entscheidung
Ein ADR durchläuft einige Status:
- vorgeschlagen (proposed): Es ist geschrieben und wird diskutiert, meist in einem Pull Request;
- angenommen (accepted): Ab heute ist das die Regel;
- ersetzt (superseded): Ein neueres ADR hat es geändert, und das alte verweist darauf;
- veraltet (deprecated): Es gilt nicht mehr, und nichts ersetzt es.
Ein angenommenes ADR wird nicht umgeschrieben. Wenn die Entscheidung nicht mehr stimmt, schreibst du ein neues ADR, das das alte ersetzt, und gibst dem alten den Status „ersetzt durch ADR-0007“. Das alte war in seinem Kontext richtig: Löschst du es, verlierst du, warum es gewählt wurde und was danach geschah.
Sieh es dir an
Eine Entscheidung, ihre Konsequenzen, eine neue Entscheidung
static readonly HashSet<string> Statuses = ["proposed", "accepted", "superseded", "deprecated"];
static List<string> Check(List<Adr> adrs){ var all = ByNumber(adrs); var bad = new List<string>(); foreach (var a in adrs) { var n = a.Name; if (!Statuses.Contains(a.Status)) bad.Add($"{n}: unbekannter Status {a.Status}"); if (a.Context == "") bad.Add($"{n}: kein Kontext"); if (a.Options.Count < 2) bad.Add($"{n}: weniger als zwei Optionen"); if (a.Decision == "") bad.Add($"{n}: keine Entscheidung"); if (a.Consequences == "") bad.Add($"{n}: keine Konsequenzen"); if (a.Status == "superseded") { var next = a.SupersededBy is int k ? all.GetValueOrDefault(k) : null; if (next is null) bad.Add($"{n}: ersetzt, sagt aber nicht, wodurch"); else if (next.Status != "accepted") bad.Add($"{n} → {next.Name}: der Ersatz ist nicht angenommen"); } } return bad;}var adr4 = new Adr(4, "Benachrichtigungen werden synchron verschickt", "proposed", "Erste Version: E-Mail ist schnell", ["direkter Aufruf", "Warteschlange"], "Die Zahlung ruft die E-Mail direkt auf", "Einfach, aber eine langsame E-Mail bremst die Zahlung", null);var adr4 = new Adr(4, "Benachrichtigungen werden synchron verschickt", "accepted", "Erste Version: E-Mail ist schnell", ["direkter Aufruf", "Warteschlange"], "Die Zahlung ruft die E-Mail direkt auf", "Einfach, aber eine langsame E-Mail bremst die Zahlung", null);var adr4 = new Adr(4, "Benachrichtigungen werden synchron verschickt", "accepted", "Erste Version: E-Mail ist schnell", ["direkter Aufruf", "Warteschlange"], "Die Zahlung ruft die E-Mail direkt auf", "Einfach, aber eine langsame E-Mail bremst die Zahlung", null);
var adr7 = new Adr(7, "Benachrichtigungen laufen über eine Warteschlange", "proposed", "ADR-0004: E-Mail bremst die Zahlungen", ["direkt mit Timeout", "Warteschlange"], "Die Zahlung legt ein Ereignis in die Warteschlange", "Kein Warten; eine E-Mail kann doppelt kommen", null);var adr4 = new Adr(4, "Benachrichtigungen werden synchron verschickt", "superseded", "Erste Version: E-Mail ist schnell", ["direkter Aufruf", "Warteschlange"], "Die Zahlung ruft die E-Mail direkt auf", "Einfach, aber eine langsame E-Mail bremst die Zahlung", 7);
var adr7 = new Adr(7, "Benachrichtigungen laufen über eine Warteschlange", "accepted", "ADR-0004: E-Mail bremst die Zahlungen", ["direkt mit Timeout", "Warteschlange"], "Die Zahlung legt ein Ereignis in die Warteschlange", "Kein Warten; eine E-Mail kann doppelt kommen", null);static readonly HashSet<string> Statuses = ["proposed", "accepted", "superseded", "deprecated"];
static List<string> Check(List<Adr> adrs){ var all = ByNumber(adrs); var bad = new List<string>(); foreach (var a in adrs) { var n = a.Name; if (!Statuses.Contains(a.Status)) bad.Add($"{n}: unbekannter Status {a.Status}"); if (a.Context == "") bad.Add($"{n}: kein Kontext"); if (a.Options.Count < 2) bad.Add($"{n}: weniger als zwei Optionen"); if (a.Decision == "") bad.Add($"{n}: keine Entscheidung"); if (a.Consequences == "") bad.Add($"{n}: keine Konsequenzen"); if (a.Status == "superseded") { var next = a.SupersededBy is int k ? all.GetValueOrDefault(k) : null; if (next is null) bad.Add($"{n}: ersetzt, sagt aber nicht, wodurch"); else if (next.Status != "accepted") bad.Add($"{n} → {next.Name}: der Ersatz ist nicht angenommen"); } } return bad;}Schritt für Schritt, in Worten
- ADR-0004 wird vorgeschlagen: der Kontext, zwei Optionen und die Entscheidung. Die Zahlung ruft die E-Mail direkt auf.
- Das Team nimmt es an. Auch der Preis ist festgehalten: Eine langsame E-Mail bremst die Zahlung.
- Black Friday: Der E-Mail-Anbieter antwortet in 8 Sekunden, und die Zahlungen laufen in den Timeout. Die festgehaltene Konsequenz tritt ein.
- Eine neue Entscheidung, ADR-0007: Benachrichtigungen laufen über eine Warteschlange. Ihr Kontext verweist auf ADR-0004.
- Die Optionen und der Preis: Die Zahlung wartet nicht mehr, aber eine E-Mail kann später kommen, und zweimal.
- ADR-0007 ist angenommen. Das alte wird weder gelöscht noch umgeschrieben: Sein Status wird „ersetzt“, und es verweist auf das neue.
- Die Prüfung aus der Lektion: Ein ersetztes ADR muss auf ein angenommenes verweisen. ADR-0004 → ADR-0007 besteht.
- Wer fragt „Warum eine Warteschlange?“, liest ADR-0007 und von dort ADR-0004. Die Geschichte bleibt, mit den Optionen und dem Preis.
Sieh es dir an
Die ADRs prüfen
In einem echten Projekt sind ADRs Markdown-Dateien, und die Prüfung liest ihre Überschriften. Hier ist jedes ADR ein Record mit denselben Feldern, damit das Programm kurz bleibt:
record Adr(int Number, string Title, string Status, string Context, List<string> Options, string Decision, string Consequences, int? SupersededBy){ public string Name => $"ADR-{Number:D4}";}var adr4 = new Adr(4, "Benachrichtigungen werden synchron verschickt", "superseded", "Erste Version: E-Mail ist schnell", ["direkter Aufruf", "Warteschlange"], "Die Zahlung ruft die E-Mail direkt auf", "Einfach, aber eine langsame E-Mail bremst die Zahlung", 7);
var adr7 = new Adr(7, "Benachrichtigungen laufen über eine Warteschlange", "accepted", "ADR-0004: E-Mail bremst die Zahlungen", ["direkt mit Timeout", "Warteschlange"], "Die Zahlung legt ein Ereignis in die Warteschlange", "Kein Warten; eine E-Mail kann doppelt kommen", null);Die Prüfung bewacht die zwei Dinge, die man vergisst: die Teile eines ADR und die Kette „ersetzt durch“.
static Dictionary<int, Adr> ByNumber(List<Adr> adrs) => adrs.ToDictionary(a => a.Number);static readonly HashSet<string> Statuses = ["proposed", "accepted", "superseded", "deprecated"];
static List<string> Check(List<Adr> adrs){ var all = ByNumber(adrs); var bad = new List<string>(); foreach (var a in adrs) { var n = a.Name; if (!Statuses.Contains(a.Status)) bad.Add($"{n}: unbekannter Status {a.Status}"); if (a.Context == "") bad.Add($"{n}: kein Kontext"); if (a.Options.Count < 2) bad.Add($"{n}: weniger als zwei Optionen"); if (a.Decision == "") bad.Add($"{n}: keine Entscheidung"); if (a.Consequences == "") bad.Add($"{n}: keine Konsequenzen"); if (a.Status == "superseded") { var next = a.SupersededBy is int k ? all.GetValueOrDefault(k) : null; if (next is null) bad.Add($"{n}: ersetzt, sagt aber nicht, wodurch"); else if (next.Status != "accepted") bad.Add($"{n} → {next.Name}: der Ersatz ist nicht angenommen"); } } return bad;}ADR-0005 ist als ersetzt markiert, sagt aber nicht, wodurch. ADR-0009 hat eine Option und keine Konsequenzen:
var adr5 = new Adr(5, "Der Rabatt wird im Controller berechnet", "superseded", "Ein Bildschirm, eine Regel", ["im Controller", "in der Domäne"], "Die Regel steckt im Controller", "Schnell, aber die Regel wird kopiert", null);var adr9 = new Adr(9, "Cache für die Produktseiten", "proposed", "Die Datenbank ist in Kampagnen bei 100 %", ["Redis"], "Cache-aside mit TTL von 5 Minuten", "", null);var problems = Check([adr4, adr5, adr7, adr9]);problems.ForEach(Console.WriteLine);Console.WriteLine($"{problems.Count} Probleme");ADR-0005: ersetzt, sagt aber nicht, wodurchADR-0009: weniger als zwei OptionenADR-0009: keine Konsequenzen3 ProblemeEine solche Prüfung läuft bei jedem Pull Request mit den Tests, und ein ADR ohne Konsequenzen kommt nie in den Hauptzweig.
Probier es aus
Sag die Ausgabe voraus
Die Prüfung oben bekommt zwei ADRs über die Berichte. Was gibt sie aus?
var adr11 = new Adr(11, "Berichte lesen die Hauptdatenbank", "superseded", "Es gibt wenige Berichte", ["die Hauptdatenbank", "ein Replikat"], "Berichte lesen die Hauptdatenbank", "Einfach, aber belastet die Datenbank", 12);var adr12 = new Adr(12, "Berichte lesen ein Replikat", "proposed", "Berichte bremsen die Zahlungen", ["ein Replikat"], "Berichte lesen ein Replikat", "Die Daten sind eine Minute älter", null);Check([adr11, adr12]).ForEach(Console.WriteLine);ADR-0011 ist durch ADR-0012 ersetzt, und ADR-0012 ist noch vorgeschlagen: Die Regel schlägt an, weil der Ersatz angenommen sein muss. ADR-0012 hat außerdem nur eine Option.
Sieh es dir an
Warum eine Warteschlange: entlang der Kette
Die Kollegin beginnt bei ADR-0004, das in alten Diskussionen auftaucht, und kommt bei der geltenden Entscheidung an:
static void History(Dictionary<int, Adr> all, int number){ var a = all[number]; Console.WriteLine($"{a.Name} {a.Title} [{a.Status}]"); while (a.SupersededBy is int next) { a = all[next]; Console.WriteLine($"→ {a.Name} {a.Title} [{a.Status}]"); }}// Warum laufen Benachrichtigungen über eine Warteschlange?History(ByNumber([adr4, adr7]), 4);ADR-0004 Benachrichtigungen werden synchron verschickt [superseded]→ ADR-0007 Benachrichtigungen laufen über eine Warteschlange [accepted]Zwei Minuten Lesen statt einer Woche Raten. Wenn der Kontext von ADR-0007 nicht mehr stimmt (E-Mails gehen an ein anderes System, das schnell antwortet), schreibt die Kollegin ADR-0015, das es ersetzt, und die Kette wächst.
Probier es aus
Finde den Fehler
Ein Kollege schlägt ein ADR für den Cache aus der Lektion über Caching vor. Welche Zeile macht das ADR für den Nächsten nutzlos?
Wähle die Zeile mit dem Fehler.
Die Konsequenzen sind durchweg gut. Eine Entscheidung ohne Preis ist keine Entscheidung, sondern Werbung. Es fehlt, was in einem Jahr gebraucht wird: Ein Preis kann bis zu 5 Minuten alt sein; jeder, der Preise schreibt, muss die Kopien löschen; Redis ist ein System mehr, das überwacht werden muss; fällt Redis aus, kommt die ganze Last zurück auf die Datenbank.
Die Idee
Wann ein ADR hilft und wann es Bürokratie ist
- Es hilft bei Entscheidungen, die teuer zu ändern sind (eine Warteschlange, eine Datenbank, Modulgrenzen, ein API-Format) und mehr als eine vernünftige Antwort haben.
- Es hilft, wenn es vor der Entscheidung geschrieben wird: Die Optionen und der Preis kommen heraus, solange du noch etwas anderes wählen kannst.
- Es schadet, wenn jede Kleinigkeit ein ADR bekommt. Der Name einer Klasse oder eine Bibliothek, die du in einer Stunde austauschst, ist keine Architekturentscheidung.
- Es schadet, wenn es hinterher geschrieben wird, um zu rechtfertigen, was schon getan ist: Dann sind die Optionen erfunden und die Konsequenzen alle gut.
- Es ist nutzlos, wenn es niemand findet. Setze die ADR-Nummer als Kommentar an die Stelle im Code und in den Pull Request.
Genauer, für Neugierige
Woher es kommt. Das Format stammt von Michael Nygard (2011). Beliebte Vorlagen sind seine und MADR (Markdown Any Decision Records), das „Vor- und Nachteile jeder Option“ ergänzt.
Werkzeuge. adr-tools legt eine neue Datei mit der nächsten Nummer an und ändert Status; log4brains baut aus den ADRs eine Website. Eine Prüfung wie die in dieser Lektion ist an einem Nachmittag geschrieben und läuft mit den Tests.
ADR, RFC und Design-Doc. Ein Design-Doc beschreibt ein ganzes System oder eine große Änderung, bevor sie gebaut wird. Ein RFC ist ein Vorschlag, den viele Leute diskutieren. Ein ADR ist eine Entscheidung, kurz, und bleibt als Geschichte. Ein Design-Doc endet oft in einigen ADRs.
Wer sie schreibt. Nicht nur der Architekt. Wer die Änderung vorschlägt, schreibt das ADR im Pull Request, und das Code-Review wird auch zum Review der Entscheidung.
Die Idee
Welche Form wann
Das Modul hat neun Wege gezeigt, ein System anzuordnen. Keiner ist für sich allein gut: Jeder löst einen Schmerz und hat einen Preis.
| Form | Hilft, wenn | Schadet, wenn |
|---|---|---|
| Schichten | die Regeln ohne Datenbank und ohne Bildschirm getestet werden müssen | die Anwendung nur aus Formularen über Tabellen besteht und die Schichten Aufrufe nur durchreichen |
| Hexagonal | die Domäne lange lebt, während Zahlung und Datenbank wechseln | jede Klasse ein Interface ohne zweite Implementierung hat |
| Modularer Monolith | ein oder wenige Teams, ein Deploy | die Grenzen nicht eingehalten werden und Module in die Tabellen anderer Module greifen |
| Services | unabhängige Teams, unterschiedliche Skalierung, getrennte Deploys | ein Team: Netzwerk, Daten und Monitoring kosten mehr, als sie bringen |
| Ereignisse und Warteschlangen | eine langsame Folge die Hauptaktion nicht bremsen darf | die Antwort sofort gebraucht wird; Duplikate und Reihenfolge werden von Hand gelöst |
| Outbox | ein Ereignis bei einem Absturz nicht verloren gehen darf | es keine Warteschlange gibt: Ein Aufruf im selben Prozess genügt |
| API-Vertrag | Clients, die du nicht aktualisieren kannst (die Mobile-App) | Code in einem Prozess, der sich gemeinsam ändert |
| Cache | dasselbe oft gelesen wird und ein bisschen alt sein darf | der Wert exakt sein muss oder sich nicht ungültig machen lässt |
| Skalierung | ein Server nicht reicht oder nicht der einzige sein darf | ein Server reicht und der Engpass die Datenbank ist |
Erst der Monolith. Ein neues System beginnt als modularer Monolith mit klaren Grenzen. Ein Service wird herausgelöst, wenn es einen konkreten Grund gibt: ein eigenes Team, eine andere Skalierung, unabhängige Deploys. Der Grund kommt zusammen mit dem Preis in ein ADR. Das Gegenteil (Services von Anfang an) zahlt den Preis eines verteilten Systems, bevor jemand weiß, wo die echten Grenzen liegen.
Übung
Der Inhalt des Ordners
Löse es in deinem Editor.
Der Ordner docs/adr/ wächst, und niemand weiß mehr, welche ADRs gelten. Schreibe Toc: Es gibt alle ADRs nach Nummer sortiert mit ihrem Status aus, und bei einem ersetzten auch die Nummer seines Ersatzes. Gib ihr ADR-0007, ADR-0001, ADR-0009 und ADR-0004 in dieser Reihenfolge.
ADR-0001 Modularer Monolith statt Services [accepted]ADR-0004 Benachrichtigungen werden synchron verschickt [superseded → ADR-0007]ADR-0007 Benachrichtigungen laufen über eine Warteschlange [accepted]ADR-0009 Cache für die Produktseiten [proposed]Weitere Aufgaben
- Die geltenden. Gib nur die angenommenen ADRs aus: Das ist „die Architektur heute“.
- Ein Zyklus. Was passiert mit
history, wenn ADR-0004 durch 0007 und 0007 durch 0004 ersetzt wird? Ergänze eine Prüfung, die so einen Zyklus findet.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Transactional Outbox · API-Design: Vertrag, Versionen, wiederholte Anfragen · Caching · Skalierung
- Geht weiter in: Nichtfunktionale Anforderungen, die du messen kannst · Trade-offs · Ein laufendes System verändern (Strangler Fig, Expand–Contract) · Architektur-Review
- Aufgaben zum Thema: Softwarearchitektur