{"generated_at":"2026-11-09T06:23:14.832389+00:00","site":"https://www.mikebild.dev","articles":[{"slug":"vier-releases-ohne-blindflug","title":"Releases mit getrennten Prüfgrenzen","summary":"Unabhängige Produkte lassen sich gemeinsam erweitern, wenn Verträge, lokale Prüfungen, echte Release-Artefakte, Produktionsprüfungen und eine zeitversetzte Beobachtung klar getrennte Beweise liefern.","tags":["Software","Architektur"],"published_at":"2026-08-19T10:00:00+00:00","updated_at":"2026-08-19T05:49:52.235446+00:00","url":"https://www.mikebild.dev/de/blog/vier-releases-ohne-blindflug/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/d2e4fcd4fd1ff957296f018b6c7340afbc81d4bd4f26b92804de76fda258a097.mp3","audio_duration_secs":781,"text":"Zwei Binärdateien können denselben Versionsnamen tragen und trotzdem unterschiedliche Hashes haben. Ein bestandener Quelltest zeigt nur, dass der geprüfte Code unter den geprüften Bedingungen das erwartete Ergebnis liefert. Er beantwortet nicht, ob das ausgelieferte Artefakt diesen Code enthält, ob der Prozess in Produktion dieses Artefakt ausführt und ob der öffentliche Vertrag des Produkts noch zu beidem passt. Sobald mehrere unabhängige Produkte gemeinsam erweitert werden, entsteht ein konkretes Zuordnungsproblem: Quellstand, veröffentlichtes Artefakt, laufende Version und öffentlicher Vertrag können auseinanderfallen.\n\nJede der fünf Grenzen – Repository-Vertrag, Release-Artefakt, Deployment-Smoke, Produktionsschnittstellen und zeitversetzte Beobachtung – beantwortet eine andere Frage und grenzt einen anderen Fehlerbereich ein. Fehlt diese Zuordnung, liefern mehrere bestandene Prüfungen noch keinen Nachweis für die laufende Version.\n\n## Fünf Prüfgrenzen, fünf Fragen\n\n\n```mermaid\nflowchart LR\n    V[\"Repository-Vertrag<br/>API und Doku im Produkt\"] --> A[\"Release-Artefakt<br/>benannt und identifizierbar\"]\n    A --> D[\"Deployment-Gate<br/>Health, Readiness und Runtime-Bindung\"]\n    D --> O[\"Produktionsschnittstellen<br/>reale Übergaben in PROD\"]\n    O --> S[\"Soak<br/>geplante Prüfungen über Zeit\"]\n```\n\nDer Repository-Vertrag beschreibt, was ein Produkt zusagt. Dazu sollten die Bedeutung von Zeiträumen, Aggregaten und Datenqualität, das Fehlerverhalten bei ungültigen Anfragen sowie die Grenzen der Autorisierung gehören. Die Release-Metadaten benennen das zur Auslieferung vorgesehene Artefakt; dessen Hash macht die Datei wiedererkennbar. Fehlt diese Artefaktkennung, kann ein grüner Deployment-Smoke nicht dem veröffentlichten Download zugeordnet werden. Der Health-Smoke prüft, ob der Dienst nach dem Wechsel antwortet. Ein getrenntes Readiness-Gate prüft, ob er Anfragen übernehmen kann. Über die Produktionsschnittstellen lässt sich anschließend prüfen, ob die Übergaben zwischen den getrennten Verantwortlichkeiten funktionieren. Eine zeitversetzte Beobachtung deckt schließlich Fehler ab, die erst in späteren Läufen oder an Zeitgrenzen sichtbar werden.\n\nEin Health-Endpoint kann bestätigen, dass ein Dienst antwortet. Ob ein ungültiges Zeitfenster korrekt abgewiesen wird, muss dagegen am Produktvertrag geprüft werden. Ein Unit-Test kann die Zeitlogik abdecken, erkennt aber keine veraltete API-Beschreibung im veröffentlichten Paket. Werden diese Beweise vermischt, bleiben mehrere bestandene Prüfungen, aber kein Nachweis für die tatsächlich laufende Version und ihren Vertrag.\n\n## Der Vertrag beginnt im Repository\n\nJedes beteiligte Produkt verantwortet die Bedeutung seiner eigenen API. Der dafür erforderliche Repository-Vertrag sollte die maschinenlesbare Beschreibung, relevante Schemas, Autorisierungsgrenzen und die zugehörigen Prüfungen gemeinsam versionieren. So lässt sich die öffentliche Zusage gegen den Quellstand prüfen, ohne dafür alle anderen Produkte starten zu müssen. Ein Release-Test sollte zusätzlich die mitgelieferte API-Beschreibung mit diesem Vertrag vergleichen und die relevanten Vertragsfälle gegen das gebaute Artefakt ausführen.\n\nVertragstests sollten mehr als den erfolgreichen Request abdecken. Ungültige Zeitgrenzen, zu große Fenster, fehlende Berechtigungen und begrenzte Ergebnisgrößen gehören ebenso in den Testumfang wie die Prüfung, dass Antworten keine internen Bezeichner oder Geheimnisse offenlegen. Der gemeinsame Nenner der Produkte bleibt der dokumentierte HTTP-Vertrag. Wie ein Produkt intern speichert, kann sich ändern, solange die Antwort die vereinbarte Bedeutung einhält.\n\nEin konsumierender Reporting-Workflow lässt sich zunächst gegen gespeicherte Antworten prüfen. Sinnvolle Fixtures bilden neben vollständigen Daten auch fehlende Quellen, Timeouts, leere Fenster, unbekannte zusätzliche Eigenschaften und eine vorherige kompatible Schemafassung ab. Der Workflow sollte daraus einen erklärten Datenqualitätszustand ableiten, statt mit einer unverständlichen Exception abzubrechen. Solche Prüfungen sind lokal und wiederholbar, benötigen aber weiterhin einen späteren Nachweis gegen die echten Produktionsschnittstellen.\n\n## Das Release-Artefakt ist der Prüfgegenstand\n\nEin grüner Quellstand garantiert noch kein korrektes Paket. Für die Auslieferung ist deshalb das benannte und gehashte Artefakt der unmittelbare Prüfgegenstand. Ein Produkt kann dafür ein selbstenthaltendes Binärartefakt mit Prüfsummen veröffentlichen. Deployment und Readiness-Gate prüfen dann zusätzlich die tatsächlich laufende Version.\n\nDer Vergleich zwischen dem Hash des veröffentlichten Downloads und dem Hash des installierten Binaries ist ein mit Standardwerkzeugen automatisierbarer Vergleich:\n\n```bash\nsha256sum <downloaded-release-binary>\nssh <production-host> 'sha256sum <installed-binary>'\n```\n\nDieser Vergleich weist nach, ob Download und installiertes Binary übereinstimmen. Er beweist für sich genommen nicht, dass das Artefakt reproduzierbar aus einem bestimmten Quellstand gebaut wurde. Verifizierbare Provenienz dokumentiert und bindet Quellreferenz und Build-Prozess an den veröffentlichten Hash. Reproduzierbarkeit weist erst ein unabhängiger Neubau mit demselben Ergebnis nach.\n\nLokale Änderungen dürfen einen Release nicht still ersetzen. Wird ein installiertes Binary überschrieben, stimmen Versionsname, veröffentlichter Download und laufender Prozess nicht mehr eindeutig überein. Auch ein dringender Fix braucht deshalb anschließend ein regulär benanntes und identifizierbares Artefakt, das veröffentlicht, installiert und erneut geprüft wird.\n\nZum Prüfgegenstand gehören außerdem die veröffentlichten Prüfsummenlisten. Eine formal ungültige Prüfsummendatei kann von üblichen Prüfwerkzeugen abgewiesen oder falsch eingelesen werden. Der passende Test besteht darin, die veröffentlichten Dateien herunterzuladen und mit denselben Werkzeugen zu prüfen, die auch ein Konsument verwendet.\n\nSemantic Versioning strukturiert die Benennung, ersetzt aber weder den Hashvergleich noch die Provenienz. Eine Versionsauskunft stammt aus dem laufenden Prozess, während ein Startprotokoll festhalten kann, welches Binary gestartet wurde. Eine falsch eingebettete Versionsnummer schließen beide Angaben allein nicht aus. Belastbar wird die Zuordnung erst, wenn Prüfsumme, Versionsmetadaten und Herkunft im Release-Prozess miteinander verbunden sind.\n\n## Deployment und Readiness sind eigene Beweise\n\nEin dokumentiertes Deployment kann veröffentlichte Binärartefakte beziehen, sie atomar wechseln und den laufenden Dienst anschließend über einen Health-Smoke prüfen. Der atomare Wechsel verhindert, dass während der Umschaltung ein teilweise ausgetauschter Stand aktiv wird.\n\nNach dem Wechsel bleiben zwei Fragen getrennt: Läuft der Prozess, und ist er bereit, Anfragen zu übernehmen? Ein gestarteter Prozess kann seine Voraussetzungen noch nicht erreicht haben und darf dann nicht als verfügbar gelten. Das Readiness-Gate kann zusätzlich die laufende Version nennen.\n\nDer Hashvergleich ordnet das installierte Binary dem Download zu. Die Versionsauskunft beschreibt, was der laufende Prozess meldet. Readiness bestätigt, dass dieser Prozess Anfragen übernehmen kann. Um zusätzlich eine falsche Prozessauswahl auszuschließen, muss ein Runtime-Nachweis den Prozess an das tatsächlich ausgeführte Binary oder dessen Herkunft binden, etwa über gebundene Startmetadaten oder einen unabhängig geprüften Executable-Hash. Ohne diese Bindung bleibt offen, ob der bereite Prozess tatsächlich die zuvor geprüfte Datei ausführt. Die Zuordnung zu Tag und Quellstand bleibt Aufgabe der Release-Provenienz.\n\n## Die Reihenfolge folgt den Abhängigkeiten\n\nProduktreleases werden entsprechend ihrer Abhängigkeiten nacheinander aktiviert und jeweils über ihre Produktionsoberfläche geprüft. Die konkrete Reihenfolge folgt nicht aus der Anzahl der Produkte, sondern aus der Richtung ihrer Verträge.\n\nEine mögliche Reihenfolge beginnt mit Providern und aktiviert den Konsumenten zuletzt. Das ist nur dann eine additive Auslieferung, wenn bestehende Routen, Daten und Konfigurationen unverändert bleiben und die neuen Vertragsteile noch von keinem laufenden Konsumenten benötigt werden. Unter dieser Bedingung kann jeder Provider einzeln deployt und geprüft werden.\n\nAnschließend folgt der Konsument, der die neuen Vertragsteile verwendet. Scheitert dieser Schritt, können die bereits veröffentlichten Provider-Versionen aktiv bleiben, sofern ihre Änderungen tatsächlich kompatibel sind. Bei brechenden Änderungen reicht diese Reihenfolge nicht aus. Dann braucht es beispielsweise einen Parallelbetrieb beider Vertragsfassungen oder eine andere geplante Migrationsfolge.\n\n## Produktion prüft reale Übergaben\n\nNach den Deployments beginnt die Prüfung der Gesamtstrecke über die tatsächlichen Produktionsschnittstellen. Eine Reporting-Architektur kann Produkt-APIs, immutable JSON-Artefakte, Veröffentlichung und Review als getrennte Verantwortlichkeiten halten.\n\nDiese Trennung bestimmt die Erfolgskriterien der Produktionsprüfung der Gesamtstrecke. Die Produkt-APIs müssen Antworten gemäß ihrem Vertrag liefern. Das daraus erzeugte JSON-Artefakt muss immutable identifizierbar sein. Veröffentlichung und Review müssen sich auf den vorgesehenen fachlichen Stand beziehen. Damit diese Übergaben prüfbar bleiben, müssen die jeweiligen Identifier von API-Antwort, Artefakt und nachfolgenden Oberflächen aneinander gebunden sein. Das kann über kryptografische Referenzen oder ein gegen Veränderung geschütztes Manifest erfolgen, das die relevanten Identifier und Hashes enthält. Identische Kennungen in allen Schichten sind dafür nicht erforderlich. Fehlt eine solche Bindung, lässt sich eine sichtbare Veröffentlichung nicht eindeutig dem geprüften Artefakt zuordnen.\n\nAutorisierte Übergaben brauchen außerdem einen negativen Zugriffstest, sofern der Vertrag zwischen öffentlichen und geschützten Daten unterscheidet. Ein erfolgreicher Zugriff bestätigt nur den erlaubten Pfad. Erst ein abgewiesener anonymer oder unberechtigter Request prüft die entsprechende Schutzgrenze.\n\nMenschliche Betriebsoberflächen sollten in dieser Strecke zeigen, welchen Release- oder Wissensstand die Prüfung betrifft und ob die zugehörige Freigabe noch offen oder abgeschlossen ist; den Maschinenvertrag ersetzen sie nicht.\n\n## Ein grüner Smoke deckt keinen späteren Lauf ab\n\nZeitabhängige Fehler können erst bei einem späteren geplanten Lauf sichtbar werden. Dazu gehören Übergänge zwischen Zeitfenstern, Zeitzonen, Sperren und das Verhalten bei Wiederholungen. Eine manuelle Produktionsprüfung prüft den unmittelbar ausgelösten Ablauf. Falls die Strecke zeitgesteuert betrieben wird, muss eine weitere Prüfung mindestens einen späteren geplanten Lauf und dessen Zeitgrenzen erfassen.\n\nEin Soak sollte deshalb mehrere geplante Läufe umfassen. Seine erforderliche Dauer muss aus Laufintervall und Fehlermodell folgen. Ein selten auftretender Grenzfall benötigt einen anderen Beobachtungszeitraum als ein häufig ausgeführter Job.\n\nVor dem Soak müssen die Erfolgskriterien feststehen: Welche Fenster sollen vollständig verarbeitet werden, welche Identifier müssen zusammenpassen und wie werden Lücken oder doppelte fachliche Veröffentlichungen erkannt? Der Beobachtungszeitraum beginnt erst nach dem letzten produktionsverändernden Release. Andernfalls umfasst die Messung mehrere Systemstände und kann keinem davon eindeutig zugeordnet werden.\n\n## Rollback ist pro Schicht verschieden\n\nGetrennte Prüfgrenzen benötigen getrennte Rückwege. Für ein Produktbinary muss ein Rollback das vorherige identifizierbare Release-Artefakt über denselben kontrollierten Wechsel installieren und anschließend erneut den Health- und Readiness-Smoke ausführen. Ein lokales Überschreiben ist auch in dieser Richtung kein belastbarer Rückweg.\n\nFür Reports oder Workflows lässt sich ohne dokumentiertes Zustands- und Persistenzmodell kein gleichartiger Rollback behaupten. Vor der Auslieferung muss deshalb pro Schicht geklärt werden, ob ein immutable Stand umgeschaltet, ein neuer korrigierter Stand erzeugt oder eine Datenänderung separat behandelt wird.\n\nEin bereits veröffentlichter Zustand sollte dabei nicht unter derselben Identität umgeschrieben werden. Stattdessen braucht jede Korrektur einen neuen nachvollziehbaren Stand. Vor dem Deployment sollten dafür konkrete Antworten vorliegen: Welches Artefakt ist der letzte bekannte gute Stand? Welche Datenänderungen überleben einen Binary-Rollback? Welcher Verweis muss umgeschaltet werden? Und welche Prüfung bestätigt anschließend, dass der gewünschte Stand wieder aktiv und bereit ist?\n\n## Getrennte Beweise bestimmen die Freigabe\n\nDie gemeinsame Auslieferung unabhängiger Produkte darf erst freigegeben werden, wenn jede Prüfgrenze ihren vorgesehenen Nachweis liefert. Vertragstests prüfen die fachliche Zusage. Der Hashvergleich bindet den veröffentlichten Download an die installierte Datei. Versions- und Runtime-Nachweise müssen den laufenden Prozess zusätzlich an diese Datei binden. Produktionsprüfungen prüfen die realen Übergaben, während zeitversetzte geplante Läufe die im Fehlermodell festgelegten zeitabhängigen Risiken prüfen.\n\nDer kleinste tragfähige Anfang besteht aus einem Produktvertrag, einem Consumer-Fixture, einem identifizierbaren Release-Artefakt und einer Produktionsprüfung über einen abgeschlossenen fachlichen Ausschnitt. Danach entscheidet das Fehlermodell, welche zeitversetzten Prüfungen und Rückwege zusätzlich erforderlich sind. Fehlt der Nachweis an einer Grenze, bleibt die Freigabe offen – unabhängig davon, wie viele andere Prüfungen bereits grün sind.\n\n## Weiterführende Quellen\n\n- [Contract Testing statt E2E-Marathon](https://www.mikebild.dev/de/blog/contract-testing-statt-e2e-marathon/)\n- [Deterministische CLIs](https://www.mikebild.dev/de/blog/deterministische-clis/)\n- [Semantic Versioning](https://semver.org/)\n- [SLSA v1.2: Spezifikation](https://slsa.dev/spec/v1.2/)"},{"slug":"entscheidungen-mit-geschichte","title":"Entscheidungen mit Geschichte","summary":"Entscheidungen lassen sich als eigene, reviewte Wissensobjekte mit Kontext, Entscheidung, Begründung und Alternativen führen. Claims sagen, was gilt; Entscheidungen erklären warum.","tags":["Software","Architektur"],"published_at":"2026-08-18T10:00:00+00:00","updated_at":"2026-08-19T03:21:10.221522+00:00","url":"https://www.mikebild.dev/de/blog/entscheidungen-mit-geschichte/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/653fcfb4bbe6b0a1b6463fd3470b1136ce911a95b3d6cd2e17f06b30e4e6ff0e.mp3","audio_duration_secs":588,"text":"Sechs Monate nach einem Architekturmeeting ist die Entscheidung noch im Code sichtbar, ihre Begründung nicht. Jemand schlägt erneut eine bereits verworfene Alternative vor. In diesem konstruierten Beispiel existiert das Meeting-Protokoll, aber die entscheidende Aussage liegt zwischen vielen anderen Gesprächsnotizen.\n\nEine Wissensbasis sollte Entscheidungen deshalb als eigene, geprüfte Wissensobjekte in einem Decision Log führen. Sie enthalten Kontext, getroffene Entscheidung, Begründung und verworfene Alternativen. Wissensänderungen sollten als Änderungsvorschlag eine menschliche Prüfung durchlaufen. Ein Claim kann den aktuell geltenden Sachverhalt festhalten; die Entscheidung dokumentiert zusätzlich, unter welchen Bedingungen und aus welchen Gründen eine Wahl getroffen wurde.\n\n## Eine Entscheidung beantwortet eine andere Frage\n\nIm folgenden fiktiven Beispiel hält der Claim `service-production / primary_database / PostgreSQL` den belegten aktuellen Stand fest. Die Entscheidung `use-postgresql-as-primary-store` erklärt zusätzlich, unter welchen Anforderungen diese Wahl getroffen wurde und welche Alternativen erwogen wurden.\n\n```json\n{\n  \"slug\": \"use-postgresql-as-primary-store\",\n  \"title\": \"Use PostgreSQL as the primary store\",\n  \"context\": \"Proposal review requires atomic visibility and searchable revisions.\",\n  \"decision\": \"Use PostgreSQL as the production source of truth.\",\n  \"rationale\": \"Transactions, full-text search, and advisory locks cover the required behavior.\",\n  \"alternatives\": [\n    \"Markdown files as the primary store\",\n    \"SQLite per process\"\n  ]\n}\n```\n\nIn diesem fiktiven Modellierungsbeispiel gehören Claim und Entscheidung fachlich zusammen, beantworten aber unterschiedliche Fragen. Wer nur den geltenden Konfigurationsstand benötigt, kommt möglicherweise mit dem Claim aus. Für ein Architekturreview ist zusätzlich die Entscheidung relevant, weil sonst offenbleibt, ob die damaligen Anforderungen noch gelten.\n\nEin separater Claim ist sinnvoll, wenn neben der Begründung auch ein aktueller, separat belegbarer Sachverhalt festgehalten werden soll.\n\n```mermaid\nflowchart LR\n    S[\"Quelle und Herkunft\"] --> P[\"Änderungsvorschlag\"]\n    P --> R{\"menschliche Prüfung\"}\n    R -->|freigegeben| D[\"Entscheidung<br/>Kontext · Wahl · Begründung · Alternativen\"]\n    D -.-> C[\"im Beispiel optionaler Claim<br/>aktueller Stand\"]\n    D --> H[\"Revision und Historie\"]\n```\n\n## Kontext begrenzt die Aussage\n\nEine Entscheidung ohne Kontext wirkt universeller, als sie gemeint ist. „Use PostgreSQL“ verrät nicht, für welches System, welche Umgebung oder welche Anforderungen die Wahl gilt.\n\nDer Kontext trennt die Situation und die Anforderungen von der anschließend dokumentierten Begründung:\n\n```text\nContext:\n  The service needs atomic proposal activation, revision history,\n  full-text search, and multiple concurrent reviewers.\n\nDecision:\n  Use PostgreSQL as the production source of truth.\n```\n\nDie Entscheidung gilt damit nicht automatisch für jedes lokale Werkzeug. Wenn eine Variante andere Anforderungen hat, braucht sie einen eigenen Geltungsbereich und gegebenenfalls eine eigene Entscheidung.\n\nDer dokumentierte Kontext schafft eine Grundlage für spätere Neubewertungen. Fallen Anforderungen weg oder kommen neue hinzu, lässt sich prüfen, ob die vorhandene Begründung noch trägt.\n\n## Alternativen dokumentieren die geprüften Optionen\n\nEine Liste verworfener Alternativen dokumentiert, welche Wege ernsthaft erwogen wurden und weshalb sie nicht gewählt wurden. Eine vollständige Marktanalyse ist dafür nicht erforderlich.\n\nEin Eintrag zu einer verworfenen Alternative könnte so aussehen:\n\n```json\n{\n  \"alternative\": \"Markdown files as the primary store\",\n  \"reason_rejected\": \"Concurrent atomic proposal activation would require additional coordination.\"\n}\n```\n\nGründe dürfen nur übernommen werden, wenn die Quelle sie trägt. Ein nachträglich aus allgemeinem Architekturwissen ergänzter Ablehnungsgrund würde die historische Entscheidung verfälschen.\n\nEine verworfene Alternative kann unter geänderten Bedingungen später sinnvoll werden. Die frühere Entscheidung bleibt dennoch relevant: Sie dokumentiert die damalige Wahl und ihre Voraussetzungen. Eine spätere Revision oder neue Entscheidung muss die geänderte Grundlage nachvollziehbar machen.\n\n## Freigegebene Entscheidungen behalten ihre Historie\n\nEine Architekturwahl darf nicht so überschrieben werden, als habe die frühere Fassung nie existiert. Ein Decision Log sollte bei freigegebenen Revisionen Herkunft und Historie bewahren. Dadurch bleibt nachvollziehbar, auf welcher Grundlage eine Wissensänderung geprüft wurde.\n\n```mermaid\nflowchart LR\n    S1[\"Quelle für frühere Fassung\"] --> R1[\"freigegebene Revision\"]\n    R1 --> H[\"Historie\"]\n    S2[\"neue Quelle\"] --> P[\"neuer Änderungsvorschlag\"]\n    R1 --> P\n    P --> R2[\"später freigegebene Revision\"]\n    R2 --> H\n```\n\nDie gespeicherte Herkunft und Revisionshistorie zeigen, welche freigegebene Fassung aus welcher Wissensänderung hervorging. Weitergehende Fragen – etwa wer eine bestimmte Alternative erneut bewertete – lassen sich nur beantworten, wenn die zugehörigen Quellen diese Information tatsächlich enthalten.\n\nEin Rollback der Software ändert den dokumentierten Wissensstand nicht von selbst. Wenn dadurch fachlich wieder eine frühere Regel gelten soll, braucht auch diese Wissensänderung eine nachvollziehbare Prüfung.\n\n## Entscheidungen über REST und MCP lesen\n\nEine Wissensbasis kann getrennte REST- und MCP-Lesewege für Entscheidungen anbieten. Anwendungen und Agenten können damit freigegebenes Entscheidungswissen lesen.\n\nDie menschliche Freigabe bleibt davon getrennt und ist eine eigene, auditierbare Handlung. Eine Entscheidung wird erst durch diese Freigabe verbindlich.\n\nDiese Trennung ist für Architekturarbeit wichtiger als ein bestimmter Toolname oder API-Pfad. Solche Schnittstellendetails können sich ändern, während die Verantwortungsgrenze bestehen bleibt: Lesen lässt sich automatisieren, die fachliche Freigabe nicht stillschweigend ersetzen.\n\n## Die Prüfung konzentriert sich auf Entscheidungstreue\n\nBei der Prüfung einer Entscheidung stellen sich andere Fragen als bei einem normalen Claim:\n\n- Wurde tatsächlich entschieden oder nur eine Möglichkeit diskutiert?\n- Deckt der Kontext den beabsichtigten Geltungsbereich ab?\n- Ist die Begründung durch die angegebenen Quellen gedeckt?\n- Sind die genannten Alternativen dort tatsächlich dokumentiert?\n- Gibt es zugehörige Claims, und widersprechen sie der Entscheidung?\n\nEine Review-Notiz kann eine notwendige Präzisierung benennen. Das folgende Fragment ist ein redaktionelles Beispiel:\n\n```json\n{\n  \"decision\": \"approve\",\n  \"note\": \"Geltungsbereich anhand der angegebenen Quelle auf den Produktionsdienst begrenzt.\"\n}\n```\n\nDie Prüfnotiz darf keine neue fachliche Tatsache einführen, die weder in der Entscheidung noch in ihrer Quelle steht. Fehlt ein Beleg, muss der Änderungsvorschlag enger formuliert oder durch eine passende Quelle ergänzt werden.\n\n## Ein Report dokumentiert noch keine Entscheidung\n\nEin Report hält eine zeitgebundene Auswertung fest. Ein Finding beschreibt eine Beobachtung. Beides belegt noch nicht, dass eine verbindliche Wahl getroffen wurde.\n\nDie Aussage „In zwei Auswertungsfenstern fehlten Nutzungsdaten“ wäre ein Finding. „Wir ersetzen deshalb den Analyseanbieter“ wäre dagegen eine Entscheidung und dürfte nicht automatisch aus der Beobachtung abgeleitet werden.\n\nSoll der Inhalt eines Berichts oder Protokolls als Entscheidung festgehalten werden, muss er als Änderungsvorschlag eingereicht und anschließend von einem Menschen geprüft und freigegeben werden.\n\n## Hypothetisches Modellierungsbeispiel: öffentliche MCP-Clients\n\nAngenommen, ein Architekturteam beschließt, dass öffentliche MCP-Clients OAuth verwenden und keine langlebigen API-Schlüssel erhalten. Die zugrunde liegende Quelle nennt den Sicherheitsgrund und zwei verworfene Alternativen.\n\nDer Inhalt einer vorgeschlagenen Entscheidung könnte so aussehen:\n\n```text\nsource:\n  architecture meeting notes\n\nproposed decision:\n  public MCP clients use OAuth\n\npossible related claim:\n  service-public-mcp / authentication / OAuth 2.1\n\nrejected alternatives:\n  long-lived API key in the client\n  anonymous read-only access\n```\n\nDieses Beispiel beschreibt eine mögliche Modellierung, keine automatische Meeting-Verarbeitung. Eine prüfende Person müsste kontrollieren, ob die OAuth-Wahl tatsächlich beschlossen wurde, ob der Geltungsbereich nur öffentliche Clients betrifft und ob die genannten Alternativen in der Quelle stehen.\n\nErst ein freigegebener Änderungsvorschlag macht die Wissensänderung verbindlich. Danach kann die Entscheidung über den REST- oder MCP-Leseweg abgerufen werden. Im Beispiel ist der zugehörige Claim optional.\n\nUnterstützt eine neue Clientplattform später einen anderen sicheren Mechanismus, darf die frühere Begründung nicht still umgeschrieben werden. Die geänderte Wahl braucht erneut Kontext, Begründung, Quellen und menschliche Freigabe.\n\n## Claims und Entscheidungen müssen zusammenpassen\n\nEin Claim hält fest, was nach dem belegten Wissensstand gilt. Die Entscheidung erklärt, warum eine Wahl unter bestimmten Bedingungen getroffen wurde. In den Modellierungsbeispielen dieses Texts wird keine feste Eins-zu-eins-Zuordnung vorausgesetzt.\n\nWo beide Objekte zusammengehören, sollten Reviewer ihre Zuordnung prüfen. Das folgende Fragment veranschaulicht die fachliche Beziehung:\n\n```json\n{\n  \"decision\": \"use-oauth-for-public-mcp\",\n  \"related_claim\": {\n    \"subject\": \"service-public-mcp\",\n    \"predicate\": \"authentication\",\n    \"object\": \"OAuth 2.1\"\n  }\n}\n```\n\nBei einer späteren Änderung müssen die betroffenen Wissensobjekte inhaltlich konsistent bleiben. Andernfalls könnte ein Claim noch eine alte Regel nennen, während die neuere Entscheidung bereits andere Voraussetzungen dokumentiert.\n\nFür das Review sollten die vorgeschlagene Entscheidung und betroffene Claim-Änderungen gemeinsam betrachtet werden, sofern beides zum selben fachlichen Änderungsvorhaben gehört.\n\n## Vom ADR zum Decision Log\n\nDer Artikel [ADR: Entscheidungen, die man versteht](https://www.mikebild.dev/de/blog/adr-entscheidungen-die-man-versteht/) erklärt, weshalb Architekturentscheidungen Kontext, Wahl und Konsequenzen festhalten sollten. Eine Markdown-Datei im Repository ist dafür weiterhin ein brauchbarer Speicherort und lässt sich wie Code reviewen.\n\nEin Decision Log kann Entscheidungen darüber hinaus als eigene Wissensobjekte behandeln. Sie sollten als Änderungsvorschlag eine menschliche Freigabe durchlaufen und können danach über getrennte REST- und MCP-Lesewege gelesen werden. Herkunft und Historie der freigegebenen Revisionen sollten erhalten bleiben.\n\nDamit verschiebt sich nicht die fachliche Verantwortung. Ein solches System strukturiert und versioniert das Entscheidungswissen; es entscheidet nicht selbst, welche Architekturwahl verbindlich sein soll.\n\n## Der Entscheidungstext muss eine spätere Prüfung tragen\n\nLange Meetingtranskripte oder Berichte können Quellen bleiben. Der eigentliche Entscheidungseintrag konzentriert sich auf Wahl, Kontext, Begründung und ernsthaft erwogene Alternativen. Eine vollständige Gesprächswiedergabe erschwert das Wiederfinden. Ein einzelnes Stichwort wie „PostgreSQL“ lässt dagegen die entscheidenden Gründe weg.\n\nFür den Umfang zählt deshalb eine praktische Frage: Kann eine Person anhand des Eintrags verstehen, weshalb die Wahl unter den dokumentierten Bedingungen vernünftig war und welche Änderungen eine Neubewertung auslösen würden?\n\nWenn der Text das leistet, bewahrt er mehr als das Ergebnis. Er erhält die Grundlage, auf der spätere Architekturarbeit aufbauen oder einer früheren Entscheidung begründet widersprechen kann.\n\n## Weiterführende Quellen\n\n- [ADR: Entscheidungen, die man versteht](https://www.mikebild.dev/de/blog/adr-entscheidungen-die-man-versteht/)\n- [Documenting Architecture Decisions – Michael Nygard](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions)\n- [PostgreSQL Transaction Isolation](https://www.postgresql.org/docs/current/transaction-iso.html)"},{"slug":"finanzen-bewusst-abtrennen","title":"Finanzen bewusst abtrennen","summary":"Finanzreports können denselben technischen Ablauf verwenden, ohne Daten, Rechte, Speicher oder Wissen mit allgemeinen Produktreports zu vermischen. Die Trennung folgt der Fachlichkeit.","tags":["Software","Architektur"],"published_at":"2026-08-18T10:00:00+00:00","updated_at":"2026-08-19T03:21:10.221522+00:00","url":"https://www.mikebild.dev/de/blog/finanzen-bewusst-abtrennen/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/706ccfb466b4c807961afeb56d69c3c067ba2e72a18ddd477a7df982eee8f9d0.mp3","audio_duration_secs":778,"text":"Ein allgemeiner Monatsreport kann veröffentlichte Inhalte, abgeschlossene Wissensvorschläge und erfolgreiche Builds zeigen. Eine Finanzübersicht kann dagegen Umsätze, offene Forderungen oder Kosten nennen. Beide Reporttypen können denselben technischen Ablauf verwenden: Produkt-API, HTTP-Connector, geplanter Workflow, JSON-Artefakte, ein geschützter Report in einem Publishing-System und optional ein abgegrenzter Wissensraum in einem Wissensdienst. Aus einer solchen gemeinsamen Veröffentlichungsstrecke folgt jedoch nicht, dass dieselben Menschen, Schlüssel, Speicherpfade und Wissensräume beide Auswertungen sehen dürfen.\n\nDer Mechanismus lässt sich wiederverwenden. Die fachliche Trennung entsteht durch begrenzte Berechtigungen an jeder Übergabe: ein eigener Zugriff an der Quelle, ein begrenztes Credential im Connector, ein getrennter Speicherbereich, eine eigene Lesergruppe und ein eigener Wissensraum. So kann der Ablauf gleich bleiben, während Daten und Rechte nicht ineinanderlaufen. Das ist eine fachliche und sicherheitstechnische Architekturentscheidung, keine Zusatzfunktion eines einzelnen Produkts.\n\n## Finanzen als eigene fachliche Domäne\n\nFinanzen sollten nicht als zusätzliche Sektion in bestehenden Produktstatistiken versteckt werden. Der Referenzentwurf nimmt ein Quellsystem an, das begrenzte Aggregate über eine API bereitstellt. API, Scopes und Antwort sind hypothetisch und keine dokumentierte Produktfähigkeit.\n\nEin solcher Vertrag könnte so aussehen:\n\n```http\nGET /v1/stats/finance?from=2026-07-01T00:00:00Z&to=2026-08-01T00:00:00Z&bucket=month\nAuthorization: Bearer <scoped-token>\nAccept: application/json\n```\n\nDie Antwort sollte nur die Werte enthalten, die für den Report vereinbart wurden. Rechnungspositionen, Kontodaten, Namen oder Belege würden nicht vorsorglich mitgeliefert. Ein monatliches Aggregat könnte etwa Gesamtbetrag, Anzahl offener Vorgänge, Währung, Abdeckungsstatus und Zeitfenster nennen:\n\n```json\n{\n  \"window\": {\n    \"from\": \"2026-07-01T00:00:00Z\",\n    \"to\": \"2026-08-01T00:00:00Z\",\n    \"bucket\": \"month\"\n  },\n  \"currency\": \"EUR\",\n  \"values\": {\n    \"invoiced_total\": 42000,\n    \"open_invoice_count\": 3\n  },\n  \"coverage\": {\n    \"complete\": true,\n    \"missing_sources\": []\n  }\n}\n```\n\nDie Reporting-Strecke kennt weder die Finanzdatenbank noch führt sie SQL gegen diese Datenbank aus. Sie verarbeitet einen dokumentierten HTTP-Vertrag. Das Quellsystem behält die Verantwortung dafür, welche Datensätze in ein Aggregat einfließen und welche Felder nicht herausgegeben werden.\n\n## Trennung der Ressourcen\n\nEin eigener API-Pfad genügt nicht, wenn ein breit berechtigter Schlüssel, ein gemeinsam freigegebener Speicherbereich und dieselbe Lesergruppe verwendet werden. Getrennte Ressourcen bilden den Fachbereich ab.\n\n```mermaid\nflowchart LR\n    A[\"Finance API<br/>begrenzter Scope\"] --> C[\"HTTP Connector<br/>eigenes Credential\"]\n    C --> W[\"Finance Workflow<br/>eigene Ausführungsidentität\"]\n    W --> S[\"Storage<br/>getrennter Bucket oder Prefix\"]\n    S --> R[\"Publishing-System<br/>eigene Gruppe und Pfadregel\"]\n    W --> K[\"Wissensdienst<br/>eigener Space und Schlüssel-Scope\"]\n```\n\nDer Connector soll ein Credential halten, das ausschließlich die vereinbarten Finanzaggregate lesen kann. Eine eigene Ausführungsidentität kann verhindern, dass der Workflow Rechte eines allgemeinen Reporting-Prozesses übernimmt, wenn die Laufzeit ausschließlich diese Identität verwendet und deren Rechte entsprechend begrenzt sind. Im Storage wären ein separater Bucket oder ein eindeutig begrenzter Prefix mögliche Entwurfsentscheidungen.\n\nEin Publishing-System kann die lesende Grenze über eigene Gruppen sowie exakte oder präfixbasierte Pfadregeln abbilden. Ein Wissensdienst kann Wissenszugriff und Agentenkontext an einen eigenen Space und einen entsprechend begrenzten Schlüssel binden. Weitergehende Schreib-, Freigabe- oder Publishing-Rechte müssen separat entworfen werden; sie folgen nicht aus diesen Lesegrenzen.\n\nWelche Ressourcen physisch getrennt werden, hängt von Verantwortlichkeiten, Berechtigungen und dem Bedrohungsmodell ab. Ein eigener Prefix ist eine mögliche Entwurfsoption, wenn Storage-Policy, Lösch- und List-Rechte sowie die zugreifenden Identitäten diese Grenze tatsächlich durchsetzen. Ein separater Bucket kann gemeinsam genutzte Berechtigungen weiter reduzieren, wenn seine Policy und die zugreifenden Identitäten ebenfalls getrennt sind. Ein eigener Space im Wissensdienst schließt bei passend begrenztem Schlüssel andere Wissensräume aus.\n\nMehr getrennte Ressourcen bedeuten mehr Konfiguration. Dafür werden Ausnahmen sichtbar: Für jede Ressource lässt sich dokumentieren, wer lesen oder schreiben darf und welche technische Identität diese Berechtigung benötigt.\n\n## Least Privilege beginnt bei der Quelle\n\nEin Fehlerbild ist eine breite Berechtigung an der Quelle, die erst am Reportende gefiltert wird. Dann besitzt der Workflow bereits Zugriff auf Daten, die er niemals anzeigen sollte. Für diesen Report sollte die Begrenzung bereits beim API-Schlüssel des Quellsystems beginnen.\n\nIm Referenzentwurf begrenzt der Scope zusätzlich Fenster und Aggregationsstufen:\n\n```json\n{\n  \"name\": \"finance-report-reader\",\n  \"scopes\": [\n    \"finance:stats:read\"\n  ],\n  \"constraints\": {\n    \"max_window_days\": 366,\n    \"allowed_buckets\": [\"day\", \"month\", \"year\"]\n  }\n}\n```\n\nDer Schlüssel sollte weder Rechnungen verändern noch Einzelbelege lesen können. Wenn die angenommene API Zeitfenster und Aggregationsstufen serverseitig begrenzt, kann eine fehlerhafte Workflowdefinition diese Grenze nicht allein umgehen.\n\nAusgehende Rechte benötigen dieselbe Prüfung. Ein Storage-Credential sollte nur in den vorgesehenen Finanzbereich schreiben können. Das Publishing-System regelt an dieser Stelle Leser, Gruppen, Pfadregeln und die gefilterte Auslieferung. Schreibrechte für die Veröffentlichung brauchen eine eigene Grenze.\n\nBindet der Wissensdienst Wissenszugriff und Agentenkontext an Spaces und Schlüssel-Scopes, kann ein auf den Finance-Space begrenzter Schlüssel andere Wissensräume ausschließen. Ein Vorschlags- oder Freigabeprozess braucht eigene Rechte und Übergaben.\n\nDiese Kette verhindert nicht jede Fehlkonfiguration. Sie begrenzt deren mögliche Reichweite. Dafür müssen die Grenzen an der Quelle, im Connector, im Speicher, bei der Veröffentlichung und im Wissenszugriff jeweils unabhängig prüfbar sein.\n\n## Drei modellierte Fehlerbilder\n\nEin Fehlerbild ist ein allgemeiner Reporting-Connector, der versehentlich auch den Finanz-Scope erhält. Selbst wenn der aktuelle Workflow den Finanzpfad nicht abfragt, könnte eine veränderte Definition diese Berechtigung später nutzen. Getrennte Credentials nehmen dem allgemeinen Connector diese unnötige Möglichkeit.\n\nEin zweites modelliertes Fehlerbild wäre ein JSON-Objekt im vorgesehenen Finance-Prefix, dessen Storage-Policy dennoch anonymen Zugriff erlaubt. Ein Login vor einer HTML-Seite würde diese direkte Ausgaberoute nicht schützen. Wenn der Entwurf Object Storage verwendet, sollte ein Test deshalb die direkte URL ohne Berechtigung abrufen und eine Ablehnung erwarten.\n\nDas dritte Fehlerbild betrifft einen zusätzlich entworfenen Freigabeprozess. Wenn dieselbe technische Identität einen Wissensvorschlag erzeugen und als geprüft markieren könnte, wäre keine unabhängige Entscheidung mehr vorhanden. Ein solcher Prozess müsste getrennte Rechte für Einreichung und Freigabe vorsehen.\n\n```text\nfailure: broad source credential\ncontrol: connector-specific read scope\n\nfailure: public storage object\ncontrol: private storage policy plus anonymous test\n\nfailure: automation approves its own proposal\ncontrol: separate submission and approval rights\n```\n\nDiese Szenarien benennen jeweils ein unerwünschtes Ergebnis und eine prüfbare Gegenmaßnahme. Daraus lassen sich konkrete Anforderungen und Tests ableiten.\n\n## Verschlüsselung ersetzt keine Trennung – und umgekehrt\n\nWenn der gewählte Object Storage gespeicherte Objekte auf Infrastrukturebene verschlüsselt, ist damit über die fachliche Trennung noch nichts entschieden. Eine gemeinsame Policy kann weiterhin mehreren Bereichen Zugriff auf dieselben verschlüsselten Daten geben. Umgekehrt ersetzt privates Publizieren weder Verschlüsselung noch eine begründete Berechtigungsarchitektur. Wer über die Anwendung oder Speicher-API lesen und verändern darf, bestimmt die Zugriffspolitik.\n\nZeitfenstergebundene Pfade erleichtern die Zuordnung:\n\n```text\nfinance/v1/windows/2026-07-01T00:00:00Z/\n  sources/finance-api.json\n  facts.json\n  analysis.json\n  manifest.json\n\nfinance/v1/latest/monthly.json\n```\n\nDer Pfad allein verhindert kein Überschreiben. Eine immutable Historie erfordert einen vom gewählten Speicher dokumentierten Schutz gegen Überschreiben und Löschen.\n\nIn diesem Referenzentwurf darf der Workflow den Latest-Pointer erst nach erfolgreicher Validierung des vollständigen Manifests aktualisieren. Wie lange frühere Artefakte erhalten bleiben, ist eine eigene Aufbewahrungsentscheidung. Finanzreports können andere Fristen benötigen als technische Reports. Entsprechende Regeln sollten deshalb auf den Finanzbereich begrenzt sein und keine fremden Prefixes erfassen. Schlüsselmaterial gehört nicht in das Reportarchiv.\n\n## Das Publishing-System filtert geschützte Ausgaben\n\nDas Publishing-System sollte Leser, Gruppen sowie exakte und präfixbasierte Pfadregeln releasegebunden verwalten. Geschützte Seiten und Medien werden dann entsprechend dieser Freigaben ausgeliefert. Die Filterung muss außerdem für Navigation, Suche und maschinenlesbare Veröffentlichungsdateien gelten.\n\n```mermaid\nflowchart TD\n    C[\"Releasegebundene Zugriffskonfiguration\"] --> G[\"Leser und Gruppen\"]\n    C --> P[\"Exakte oder präfixbasierte Pfadregeln\"]\n    G --> A[\"Freigegebene Seiten und Medien\"]\n    P --> A\n    A --> F[\"Gefilterte Navigation, Suche<br/>und maschinenlesbare Ausgaben\"]\n```\n\nDamit kann ein Pfadpräfix im Publishing-System für Finanzreports einer eigenen Lesergruppe zugeordnet werden, ohne die Finanzinhalte in allgemeinen Navigations- oder Suchausgaben offenzulegen. Voraussetzung ist, dass die Pfadregeln den vollständigen geschützten Bereich abdecken.\n\nEin `noindex`-Header wäre dafür kein Ersatz. Er beeinflusst die Aufnahme in Suchmaschinen, begründet aber keine Berechtigung. Tests sollten deshalb sowohl berechtigte als auch unberechtigte Leser gegen Seiten, Medien und maschinenlesbare Ausgaben prüfen.\n\n## Der Wissensdienst begrenzt Finanzwissen über Space und Schlüssel\n\nWiederkehrende Findings können auch im Finanzbereich relevant sein: etwa die Erklärung einer außergewöhnlichen Monatsabweichung oder eine bewusst geänderte Berechnungsregel. Solche Aussagen gehören nicht automatisch in einen allgemeinen Engineering-Space.\n\nBindet der Wissensdienst Wissenszugriff und Agentenkontext an Spaces und Schlüssel-Scopes, kann ein separater Finance-Space Wissenszugriff und Agentenkontext abgrenzen. Ein entsprechend begrenzter Schlüssel für einen anderen Bereich schließt den Finance-Space aus.\n\nFür den Agentenkontext ist diese Grenze auch bei gleich benannten Begriffen relevant. Eine Kennzahl namens `margin` kann in Produktanalyse und Finanzen unterschiedliche Bedeutungen haben. Ein auf den Finance-Space begrenzter Schlüssel schließt andere Wissensräume aus, statt deren Inhalte in denselben Kontext zu übernehmen. Ein darüber hinausgehender Review- oder Freigabeworkflow muss gesondert festgelegt werden.\n\n## Mit logischen Grenzen beginnen\n\nDie fachliche Trennung kann zunächst über klar benannte logische Ressourcen beschrieben werden. Ob daraus später eigene Installationen, Buckets oder Workflow-Umgebungen werden, ist eine separate Entscheidung.\n\n```yaml\ndomain: finance\nsource_connector: finance-stats-prod\nworkflow: finance-monthly-report\nstorage_prefix: finance/v1/\ncontent_path: reports/finance/\nreader_group: finance-readers\nknowledge_space: finance\n```\n\nDiese beispielhafte Konfiguration enthält keine Zugangsdaten. Sie beschreibt Namen und Zuordnungen. Die zugehörigen Secrets müssten außerhalb dieser Datei verwaltet und jeweils auf die benötigten Rechte begrenzt werden.\n\nVor der Freigabe sind mehrere Prüfungen nötig: Der Quellschlüssel kann nur die vorgesehenen Aggregate lesen. Ein gespeichertes Artefakt ist ohne Berechtigung nicht erreichbar. Die Pfadregel im Publishing-System gibt den Report nur für die vorgesehene Lesergruppe frei. Navigation, Suche und maschinenlesbare Ausgaben enthalten für andere Leser keine geschützten Einträge. Ein auf einen anderen Bereich begrenzter Schlüssel des Wissensdienstes kann nicht auf den Finance-Space zugreifen.\n\nIm laufenden Betrieb sollten Routinen regelmäßig prüfen, ob Leser, Credentials und Aufbewahrungsregeln noch der vorgesehenen Trennung entsprechen. Welche Rotation, Prüfintervalle oder Löschfristen gelten, bleibt eine konkrete Betriebsentscheidung und wird nicht durch Publishing-System oder Wissensdienst vorgegeben.\n\n## Abgrenzung zu den verlinkten Beiträgen\n\nDie Beiträge [Secrets im Build sind Produktentscheidungen](https://www.mikebild.dev/de/blog/secrets-im-build/) und [Markdown Reports statt Dashboard-Zwang](https://www.mikebild.dev/de/blog/markdown-reports-statt-dashboard/) behandeln benachbarte Aspekte.\n\nAllgemeine Produktreports und Finanzreports können dieselbe Produktionsumgebung verwenden und trotzdem verschiedene Fachbereiche, Leser und Berechtigungen darstellen. Die relevante Grenze verläuft daher nicht allein zwischen Entwicklungs-, Test- und Produktionsumgebung. Sie folgt den fachlich geschnittenen Zugriffen an Quelle, Connector, Speicher, Veröffentlichung und Wissen.\n\nFür die Veröffentlichung ist entscheidend, dass das Publishing-System geschützte Pfade anhand von Lesern, Gruppen und Pfadregeln ausliefert und dieselbe Freigabe bei Navigation, Suche und maschinenlesbaren Veröffentlichungsdateien berücksichtigt. Ein möglicher privater Storage-Bereich bleibt eine zusätzliche Architekturentscheidung und ist nicht Teil dieser Aussage zum Publishing-System.\n\n## Gleiches Muster, getrennte Verantwortung\n\nEine technische Reporting-Strecke kann für allgemeine und finanzielle Auswertungen wiederverwendet werden. Daraus folgt keine gemeinsame fachliche Sicherheitsdomäne. Finanzdaten, Zugänge, Speicherorte und kuratiertes Wissen benötigen eigene Verantwortlichkeiten und möglichst kleine Berechtigungen.\n\nSpätere Anforderungen können strengere Aufbewahrungsregeln, zusätzliche Freigaben oder eine eigene Ausführungsumgebung begründen. Solche Maßnahmen sollten als konkrete Entwurfsentscheidungen dokumentiert werden. Das allgemeine Reporting erhält dadurch keine breiteren Rechte.\n\nDie entscheidende Frage lautet: Würde dieselbe Person diese Daten aus demselben fachlichen Grund sehen, prüfen und freigeben? Wenn die Antwort nein ist, sollte die technische Trennung diesem Unterschied folgen. Dasselbe Trennprinzip lässt sich auch für andere sensible Domänen prüfen. Deren konkrete rechtliche, organisatorische und technische Anforderungen sind damit jedoch nicht abgedeckt.\n\nWiederverwendbar bleibt der technische Ablauf. Berechtigungen an Quelle, Connector, Speicher, Veröffentlichung und Wissen müssen dagegen einzeln sichtbar, begründet und prüfbar bleiben.\n\n## Weiterführende Quellen\n\n- [Secrets im Build sind Produktentscheidungen](https://www.mikebild.dev/de/blog/secrets-im-build/)\n- [Markdown Reports statt Dashboard-Zwang](https://www.mikebild.dev/de/blog/markdown-reports-statt-dashboard/)\n- [NIST: Least Privilege](https://csrc.nist.gov/glossary/term/least_privilege)\n- [OWASP Secrets Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html)\n- [Supabase Storage Access Control](https://supabase.com/docs/guides/storage/security/access-control)"},{"slug":"suchen-ohne-antworten-zu-erfinden","title":"Suchen ohne Antworten zu erfinden","summary":"Suche, vollständiges Lesen, Herkunft und optionale Q&A bleiben getrennte Schritte. Treffer bleiben rohe Belege; ohne Treffer entsteht keine erfundene Antwort.","tags":["Software","KI","Methodik"],"published_at":"2026-08-18T10:00:00+00:00","updated_at":"2026-08-19T03:21:10.221522+00:00","url":"https://www.mikebild.dev/de/blog/suchen-ohne-antworten-zu-erfinden/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/cfbe956e2f9beae28687c77eacaf5095ccd1f6b2ec1cd9f217448d368f903e5d.mp3","audio_duration_secs":615,"text":"Die Frage lautet: „Warum verwendet der Import ein gemeinsames Retry-Budget?“ Eine Suchfunktion muss darauf nicht sofort einen perfekten Absatz schreiben. Ihr erster Auftrag ist kleiner: relevante Stellen finden und offenlegen, was der Bestand tatsächlich enthält.\n\nEin Wissenssystem sollte dafür Schritte mit unterschiedlichen Aufgaben trennen: Die Suche liefert Treffer, der Leseweg das vollständige Konzept, der Quellenweg die Herkunft und der Historienweg die Geschichte einer Aussage. Die natürlichsprachige Query bleibt eine optionale Stufe, die von der Wissensbasis, ihren Quellen und ihrem Reviewzustand getrennt ist.\n\n## Die Suche liefert Treffer, keine Synthese\n\nDie Suche liefert Treffer aus dem Wissensbestand. Sie formuliert daraus keine verbindenden Sätze und ergänzt keine Erklärung, die im Bestand nicht vorkommt.\n\nDie Reihenfolge der Treffer ist kein Wahrheitswert. Ob ein Treffer die Frage inhaltlich beantwortet, zeigt erst das Lesen des vollständigen Konzepts und seiner Herkunft. Solange eine konkrete Rangfolgeformel nicht als Vertrag dokumentiert ist, sollten Tests keine internen Scores festschreiben.\n\n```mermaid\nflowchart LR\n    Q[\"Frage\"] --> S[\"Suche<br/>rohe Treffer\"]\n    S --> R[\"Lesen<br/>vollständiges Konzept\"]\n    R --> O[\"Quellen und Historie<br/>Herkunft prüfen\"]\n    O --> A{\"genug belegt?\"}\n    A -->|ja| N[\"Antwort oder Entscheidung\"]\n    A -->|nein| X[\"Nichtwissen benennen\"]\n```\n\n## Der Leseweg holt das vollständige Konzept\n\nEin Suchtreffer ist ein Ausgangspunkt. Wer den vollständigen Stand braucht, liest das zugehörige Konzept über den dafür vorgesehenen REST- oder MCP-Weg.\n\nIm vollständigen Konzept lässt sich prüfen, in welchem Zusammenhang die gefundene Formulierung steht. Herkunft und Geschichte werden anschließend über getrennte Wege abgerufen, wenn die Prüfung sie verlangt.\n\nEin Agentenablauf kann zunächst kompakten Kontext laden und vollständige Konzepte, Herkunft und Geschichte bei Bedarf abrufen. Ein vollständiger Dump kann dagegen den verwendeten Kontext vergrößern und für die konkrete Aufgabe irrelevante Aussagen enthalten.\n\n## Herkunft und Geschichte sind getrennte Prüfschritte\n\nQuellen und Historie beantworten andere Fragen als die Suche. Die Herkunft ermöglicht die Prüfung, worauf eine Aussage zurückgeht und ob der Beleg sie trägt. Die Geschichte zeigt, wie sich der dokumentierte Stand verändert hat.\n\n## Umstrittene Claims bleiben ein Reviewfall\n\nEin Konflikt im Wissensbestand darf nicht durch eine glatt formulierte Antwort verschwinden. Der Reviewzustand der Wissensbasis bleibt von der optionalen natürlichsprachigen Query getrennt.\n\nFür eine Person ist der Konflikt der Beginn der Prüfung. In diesem Ablauf sollte ein als `disputed` geführter Claim die automatische Migrationsplanung stoppen, bis der Geltungsbereich geklärt ist. Die Antwort soll den nächsten prüfbaren Schritt ermöglichen, statt den Konflikt sprachlich aufzulösen.\n\n## Die natürlichsprachige Query ist optional\n\nNatürlichsprachige Queries bilden einen eigenen, optionalen Zugang. Die Wissensbasis, ihre Quellen und ihr Reviewzustand bleiben davon getrennt.\n\nDie Antwort sollte jede wesentliche Aussage auf einen gefundenen Beleg zurückführen und fehlende interne Begründungen nicht durch allgemeines Modellwissen ersetzen. Das ist keine Garantie, die allein durch einen Prompt entsteht. Tests und Leser müssen weiterhin prüfen, ob die Antwort tatsächlich durch den Bestand gedeckt ist.\n\nDieser Verzicht verhindert, dass eine plausible Standarderklärung mit einer internen Entscheidung verwechselt wird. Vielleicht teilt das Team das Budget wegen einer bestimmten SLA. Vielleicht war es eine Implementierungsvereinfachung. Ohne Quelle bleibt die Ursache offen, und genau das sollte die Antwort sagen.\n\n## Begriffe brauchen Kontext\n\nBei einer begriffsgebundenen Suche kann ein vorhandenes Konzept fehlen, wenn die gesuchten Begriffe nicht im durchsuchten Text vorkommen.\n\nEin Konzept `retry-policy` kann beispielsweise `retry budget`, `attempt timeout` und einen internen Clientnamen erklären. Das verbessert die Auffindbarkeit und zugleich die Lesbarkeit. Eine unsichtbare Synonymliste außerhalb des Wissensbestands wäre schwerer zu prüfen.\n\n```md\n# Retry policy\n\nThe internal HTTP client uses one shared retry budget across all attempts.\nTeams sometimes refer to this as the request deadline or attempt budget.\n```\n\nZu viele Schlagwörter verschlechtern den Text. Begriffe lassen sich aus gemeldeten erfolglosen Suchen ergänzen, obwohl das Wissen vorhanden war. Solche Lücken werden als kleine Konzeptänderung vorgeschlagen und geprüft, statt sie unbemerkt nur in einem Suchindex zu korrigieren.\n\n## Suche getrennt testen\n\nBei festgehaltener Suchkonfiguration und festem Datenstand lässt sich ein wiederholbarer Testaufbau herstellen. Geprüft werden dokumentierte Eigenschaften, nicht eine stabile Rangfolge.\n\nAls Teststrategie bietet sich an, bekannte Konzepte anzulegen, eine Suche auszuführen und zu prüfen, ob die erwarteten Einträge in der Treffermenge enthalten sind. Das folgende Beispiel beschreibt eine mögliche Testabstraktion:\n\n```ts\nconst hits = await testClient.search({\n  space: \"engineering\",\n  query: \"retry budget\"\n});\n\nexpect(hits.map(hit => hit.slug)).toContain(\"retry-policy\");\n```\n\nFür die natürlichsprachige Antwort kann ein separater Test mit kontrollierten Antworten arbeiten. Seine Fixtures legen fest, welche Belege verfügbar sind; geprüft wird die Zuordnung zwischen Aussagen und Belegen, nicht eine reale Modellantwort als deterministisches Orakel.\n\nGrenzen gehören ebenfalls zum Testvertrag. Nach Änderungen an der Suchkonfiguration kann sich die Trefferordnung verschieben. Tests sollten deshalb keine exakten Scores oder nicht als Vertrag dokumentierten Rangfolgen festschreiben. Belastbarer sind Mengenerwartungen wie „der passende Claim ist in den Treffern enthalten“ und „ein fachfremder Begriff liefert kein Ergebnis“.\n\n## Eine Antwort bis zur Quelle verfolgen\n\nAngenommen, die natürlichsprachige Query antwortet: „Retries teilen ein gemeinsames Budget, damit die Gesamtdauer die Request-Deadline nicht überschreitet.“ Die Antwort sollte ihre beiden Aussagen auf passende Claims oder Entscheidungen zurückführen.\n\nEine Testfixture kann diese Zuordnung ausdrücklich darstellen:\n\n```text\nanswer sentence\n  -> expected claim identifier\n  -> supporting citation\n  -> source\n  -> revision history\n```\n\nFehlt die Begründung „damit die Request-Deadline nicht überschritten wird“ in allen Belegen, ist der erste Halbsatz möglicherweise gestützt und der zweite eine Erfindung. Als redaktionelles Prüfkriterium oder Testvertrag lässt sich deshalb für jeden wesentlichen Satz eine erwartete Claim-ID oder ein tragendes Zitat hinterlegen. Bei Unsicherheit sollte die Antwort enger werden: „Die Basis belegt ein gemeinsames Budget; eine Begründung ist nicht hinterlegt.“\n\nFür wichtige Entscheidungen liest ein Mensch die Quelle selbst. Die natürlichsprachige Query verkürzt den Weg, hebt die Prüfpflicht aber nicht auf.\n\n## Suche darf keine Zugriffskontrolle umgehen\n\nWenn ein System getrennte Wissensbestände oder Mandanten verwaltet, muss die Zugriffskontrolle auch für die Suche gelten. Treffer aus einem fremden Bestand dürfen weder in Trefferlisten noch in Vorschlägen auftauchen. Fehlermeldungen sollten keine Bezeichner aus Beständen verraten, auf die der Aufrufer nicht zugreifen darf.\n\nAuch Suchlogs brauchen Datenminimierung, weil eine Anfrage vertrauliche Begriffe enthalten kann. Strukturierte Logs sollten nur die für Betrieb und Fehlersuche erforderlichen Metadaten erfassen. Falls der vollständige Suchtext vorübergehend für die Diagnose benötigt wird, braucht das eine bewusste, geschützte und zeitlich begrenzte Einstellung.\n\n## Suchqualität wächst aus echten Fragen\n\nEin Team kann eine kleine Liste wiederkehrender Fragen als Evaluation pflegen. Jede Frage trägt erwartete relevante Slugs und einen Hinweis, ob ein bewusster Verzicht oder ein Konflikt erwartet wird.\n\n```json\n{\n  \"question\": \"Why do retries share one budget?\",\n  \"expected_slugs\": [\"retry-policy\", \"shared-retry-budget\"],\n  \"expected_state\": \"answerable\"\n}\n```\n\nBei Änderungen an Suche oder Konzepten läuft diese Liste erneut. Sie misst nicht sprachliche Schönheit, sondern Auffindbarkeit und erwartete Zustände. Eine zweite Gruppe absichtlich unbeantworteter Fragen prüft, dass keine neuen Scheinantworten entstehen: Ein leeres Ergebnis muss leer bleiben.\n\nScheitert eine Frage aus dieser Evaluation, kommen mehrere Ursachen infrage. Vielleicht fehlt die Quelle. Vielleicht ist das Konzept schlecht benannt. Vielleicht fehlt ein verwendetes Synonym im aktuellen Wissensstand. Der Fund wird als Wissens- oder Suchproblem klassifiziert und an der passenden Stelle behoben. Auch diese Änderungen durchlaufen Quellenprüfung und Review.\n\n## MCP hält die Wege getrennt\n\nEin Wissenssystem kann über MCP getrennte Werkzeuge für Suche, vollständiges Lesen, Quellen, Entscheidungen und Revisionshistorie anbieten. Dadurch kann ein Agent zunächst suchen, anschließend das passende Konzept lesen und Herkunft oder Geschichte nur dann abrufen, wenn die Aufgabe diese Prüfung verlangt.\n\nDie Trennung macht außerdem sichtbar, an welcher Stelle Informationen fehlen. Findet die Suche kein Ergebnis, darf der Agent die Lücke nicht unbemerkt aus Vorwissen füllen. Er kann stattdessen benennen, dass im verfügbaren Bestand kein passender Treffer vorliegt.\n\n## Vom Grounding-Ansatz zu getrennten Such- und Lesewegen\n\n[Sauberes Grounding II](https://www.mikebild.dev/de/blog/sauberes-grounding-antworten-erden/) beschrieb Retrieval als passenden Weg für Material, das nicht ständig in den Kontext gehört. Konventionen, Dokumente und API-Beschreibungen wurden nach Aufgabe und Wiederbeschaffungskosten ausgewählt.\n\nGetrennte Such- und Lesewege führen diesen Ansatz weiter. Die Suche findet rohe Treffer ohne Synthese. Der Leseweg liefert das vollständige Konzept. Quellen und Historie erklären Herkunft und Veränderung. Die natürlichsprachige Query bleibt eine optionale Stufe. Ein Agent kann stoppen, sobald die für seine Aufgabe erforderlichen Belege vorliegen.\n\n[Sauberes Grounding I](https://www.mikebild.dev/de/blog/sauberes-grounding-warum-ki-raet/) erklärte, weshalb ein Modell plausibel klingende Methoden erfindet. Liefert die Suche keinen Treffer, darf der Agent daraus keine Antwort erfinden. Eine modellgestützte Stufe sollte fehlende interne Belege nicht durch eine plausible Begründung ersetzen.\n\n## Ein Suchtest beginnt mit Nichtwissen\n\nFür den Einstieg reicht eine kleine Testmenge als Heuristik. Drei bekannte Fragen können prüfen, ob erwartete Treffer auffindbar sind; zwei absichtlich unbeantwortete Fragen prüfen den Umgang mit leeren Ergebnissen. Fünf Fälle ergeben noch keine belastbare Qualitätsmessung, machen aber Fehler im grundlegenden Ablauf sichtbar.\n\nEin Wissenssystem muss neben belegten Antworten auch Konflikte und Lücken darstellen können. Eine Suche ohne automatische Synthese hält diese Zustände sichtbar. Eine natürlichsprachige Antwort sollte erst folgen, wenn der Bestand ausreichend Material enthält.\n\nDie Reihenfolge bleibt damit prüfbar: finden, lesen, Herkunft und Geschichte prüfen, dann antworten. Wer vor dem Lesen antwortet, kann unbelegte Begründungen ergänzen. Die getrennten Schritte machen diese Lücke sichtbar.\n\n## Weiterführende Quellen\n\n- [Sauberes Grounding II](https://www.mikebild.dev/de/blog/sauberes-grounding-antworten-erden/)\n- [Sauberes Grounding I](https://www.mikebild.dev/de/blog/sauberes-grounding-warum-ki-raet/)"},{"slug":"von-der-session-zum-system-zwischenfazit","title":"Zwischenfazit und Systemblick: eine Bilanz der Serie","summary":"Monate nach dem Gespräch, mit dem alles begann, ist es Zeit für eine ehrliche Bilanz: welche Erwartungen des Auftakts eingetreten sind, welche nicht, was offen bleibt – und warum die spannendste Frage nicht mehr lautet, was die Modelle können, sondern was Organisationen daraus machen.","tags":["Software","KI","Methodik"],"published_at":"2026-08-17T10:00:00+00:00","updated_at":"2026-08-18T05:16:11.670656+00:00","url":"https://www.mikebild.dev/de/blog/von-der-session-zum-system-zwischenfazit/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/8b34c7f2574232bfaed3a2d2f3d7793624fb3a5259cddf5e0d0c53b8e9f4d667.mp3","audio_duration_secs":697,"text":"Das Gespräch, mit dem [diese Serie begann](https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/), liegt im Oktober des vergangenen Jahres; dieser Text zieht mit Stand August 2026 Bilanz. Der Auftakt endete mit einem Vorsatz: der eigenen Begeisterung erst einmal misstrauen, genau hinschauen und jedes Ergebnis an über dreißig Jahren Handwerk messen. Die [letzte Folge](https://www.mikebild.dev/de/blog/hilfsmittel-konzepte-methoden-prozesse/) hat angekündigt, diesen Vorsatz beim Wort zu nehmen. Also: Was von den Erwartungen ist eingetreten, was nicht, was bleibt offen – und welcher Systemblick ist aus den einzelnen Sitzungen des Anfangs inzwischen geworden?\n\n*Teil 13 der Serie „Von der Session zum System“ – hier geht es [zum Auftakt](https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/).*\n\n## Die Begeisterung hat die Prüfung überstanden – knapp\n\nDas Wichtigste zuerst, weil alles andere darauf aufbaut: Aus den paar Tagen Probe im Oktober ist Alltag geworden, und zwar der unspektakulärste Beleg, den ich anbieten kann. Ich denke über diese Werkzeuge nicht mehr nach, so wenig wie über den Editor oder die Versionsverwaltung. Sie sind Infrastruktur. Ein Strohfeuer sieht anders aus – Strohfeuer erkennt man daran, dass man nach drei Monaten wieder davon erzählt statt damit zu arbeiten.\n\nTrotzdem wäre es zu billig, die eigene Wahrnehmung einfach zur Bilanz zu erklären. Im Juli 2025 hat das amerikanische Forschungsinstitut METR – eine gemeinnützige Organisation, die die Fähigkeiten von KI-Systemen mit kontrollierten Experimenten vermisst – eine Studie veröffentlicht, die mich mehr beschäftigt hat als jede Benchmark: Erfahrene Open-Source-Entwickler waren mit KI-Unterstützung in randomisierten Aufgaben messbar langsamer – um rund ein Fünftel – und schätzten hinterher trotzdem, deutlich schneller gewesen zu sein. Genau die Wahrnehmung, auf die ich mich seit dem Auftakt verlassen habe, kann also systematisch täuschen. Das nehme ich ernst, denn es ist dieselbe Warnung, die ich mir im Auftakt selbst gegeben habe: Frische Begeisterung ist ein schlechter Zeuge.\n\nDeshalb prüfe ich an Dingen, die sich nicht schönreden lassen. Was ist abends tatsächlich fertig? Welche der liegen gebliebenen Vorhaben sind heute in Betrieb? Wie oft habe ich in Reviews Fehler gefunden, die ohne die Verschiebung zum Prüfen durchgerutscht wären? Nach diesem Maßstab fällt die Bilanz positiv aus – als Beobachtung, nicht als Messung; belastbare Zahlen dazu fehlen mir. Genau deshalb bleibt die Studie für meine Bewertung wichtig: Solange ich nicht mit vergleichbarer Strenge messe, muss ich einrechnen, dass auch meine Wahrnehmung täuscht. Sie misst allerdings Aufgaben, die den Entwicklern vertraut waren, in Code, den sie gut kannten. Mein größter Gewinn liegt woanders – bei den Vorhaben, die ohne diese Werkzeuge gar nicht erst angefangen worden wären. Das ist keine Widerlegung der Studie, es ist eine andere Rechnung. Beide können gleichzeitig stimmen.\n\n## Was eingetreten ist\n\nDer Auftakt hielt drei Beobachtungen fest. Alle drei haben die Monate seit dem Auftakt überlebt, aber keine ist geblieben, wie sie war.\n\nDie erste – Erzeugen wird billig, Prüfen wird der Engpass – hat sich als die tragende Beobachtung der ganzen Serie erwiesen. Rückblickend ist fast jede Folge eine Antwort auf diese eine Verschiebung: [der Harness](https://www.mikebild.dev/de/blog/harness-macht-den-unterschied/) organisiert das Prüfen um das Modell herum, die [Hausregeln](https://www.mikebild.dev/de/blog/was-ich-kritisch-sehe/) verteidigen es gegen die eigene Bequemlichkeit, [Grounding](https://www.mikebild.dev/de/blog/sauberes-grounding-antworten-erden/) verankert es in echten Quellen, und [Regeln im Pfad](https://www.mikebild.dev/de/blog/eigener-agent-pro-fachdomaene/) machen es unabhängig von der Tagesform des Prüfenden. Was ich im November noch nicht sah: Der Engpass wandert weiter. Erst war das Prüfen knapp, dann das saubere Aufschreiben dessen, was gelten soll – inzwischen ist es das Entscheiden, was überhaupt gebaut wird.\n\nDie zweite Beobachtung – die Schubladen gehen wieder auf – ist eingetreten, mit einer Einschränkung, die ich damals nicht kommen sah. Einiges von dem, was jahrelang liegen geblieben war, ist heute in Betrieb: kleiner als die ursprüngliche Idee, und das ist keine Niederlage, sondern ein Befund. Denn was die Werkzeuge nicht liefern, ist ein Urteil darüber, welche der alten Ideen es wert waren. Zwei Vorhaben habe ich nach wenigen Wochen wieder eingestellt – schneller gescheitert, als es früher möglich gewesen wäre, und genau das ist der unterschätzte Gewinn: Billige Umsetzung macht auch das Verwerfen billig.\n\nDie dritte Beobachtung – der Ton stimmt immer, auch wenn der Inhalt nicht stimmt – war im November ein Bauchgefühl. Daraus ist [eine strukturelle Erklärung](https://www.mikebild.dev/de/blog/sauberes-grounding-warum-ki-raet/) geworden und dann ein Verfahren. Das Misstrauen ist geblieben, aber es ist umgezogen: von meiner privaten Wachsamkeit in Prüfschritte, die nicht davon abhängen, ob ich einen guten Tag habe. Das ist vielleicht die kompakteste Zusammenfassung dessen, was diese Monate mit meiner Arbeitsweise gemacht haben – Sorgfalt wurde von einer Tugend zu einer Eigenschaft des Aufbaus.\n\n## Was ich falsch erwartet hatte\n\nEine Bilanz, die nur Treffer aufzählt, ist Werbung. Zwei Erwartungen haben sich nicht gehalten.\n\nDie teuerste Fehleinschätzung: dass Aufschreiben reicht. Monatelang steckte ich Arbeit in immer bessere Instruktionsdateien, ehe ich die [Chronik dieser Versuche](https://www.mikebild.dev/de/blog/vergebliche-versuche-instruktionsdateien/) ehrlich gezogen habe – aufgeschriebene Regeln bleiben Empfehlungen, egal wie gut sie formuliert sind. Aus dieser Ernüchterung ist die wichtigste Erkenntnis der Serie geworden: Regeln, die gelten sollen, brauchen einen Pfad, der sie durchsetzt – Prüfung vor Wirkung, Freigaben, Nachweise. Wenn ich einen einzigen Satz aus diesen Monaten mitnehmen dürfte, wäre es dieser.\n\nDie zweite Fehleinschätzung ist angenehmer: Erwartet hatte ich, dass die Modellwahl mit der Zeit wichtiger wird – dass sich irgendwann ein Modell absetzt und die Frage „welches?“ doch noch zur Strategiefrage wird. Das Gegenteil ist passiert. Bei meinen Aufgaben wirkten mehrere Modellwechsel seit dem Auftakt wie Qualitätsverbesserungen, und mit jedem Sprung wurde die Wahl [unwichtiger, nicht wichtiger](https://www.mikebild.dev/de/blog/gut-genug-modelle/). Die Arbeitsweise hat die Wechsel kaum bemerkt. Was sich dagegen jedes Mal bemerkbar macht: eine schlecht geschnittene Aufgabe, ein fehlendes Stück Kontext, eine Regel, die nur in Prosa existiert. Der eigene Anteil dominiert das Ergebnis – das war als These früh da, und inzwischen entscheidet sich daran konkret, ob ein Lauf trägt: am Schnitt der Aufgabe, am mitgegebenen Kontext und an den Regeln, die im Pfad durchgesetzt werden.\n\n## Was offen bleibt\n\nDrei Fragen bleiben in diesem Zwischenfazit unbeantwortet.\n\nDie Junior-Frage. Im Auftakt stand ein Verdacht: Der Hebel wirkt, weil drei Jahrzehnte Urteilsfähigkeit dahinterstehen – und ob er für jemanden ohne dieses Fundament genauso wirkt, wisse ich nicht. Auch heute weiß ich es nicht, und die [kritische Folge](https://www.mikebild.dev/de/blog/was-ich-kritisch-sehe/) hat den Verdacht eher geschärft als entkräftet: Wer funktionierenden Code bekommt, bevor der Schmerz einsetzt, aus dem Verständnis entsteht, baut womöglich nie die Substanz auf, von der das Prüfen zehrt. Verdacht ist weiterhin kein Wissen. Aber er ist die Frage, die ich für wichtiger halte als jede Modellfrage.\n\nDie Homogenisierung. Dass Teams, die dasselbe Modell fragen, ähnlichere Lösungen bauen, habe ich seit der einen Beobachtung vom Februar mehrfach wiedererkannt, aber nie gemessen. Es bleibt eine Sorge ohne Beleg, und ich lasse sie bewusst so stehen, statt sie zur These aufzublasen.\n\nDas Verlernen des eigenen Handwerks. Die Hausregeln aus dem Februar halten ungefähr so gut wie Neujahrsvorsätze: besser als nichts, schlechter als behauptet. Der bewusste Lauf ohne KI findet statt, aber seltener, als ich ihn eingeplant hatte. Der Sog der bequemen Variante ist real, jeden Tag. Genau diese Erfahrung hat mich für die zweite Serienhälfte skeptisch gegenüber allem gemacht, was auf Disziplin baut – und empfänglich für alles, was Sorgfalt in den Aufbau verlegt.\n\n## Der Bogen, im Rückblick\n\nDer Dreiklang Ich, Wir, System stand als Absicht schon im Auftakt. Was ich nicht geplant hatte, ist die Mechanik, die sich beim Schreiben zeigte: Jede Ebene erbt das ungelöste Problem der vorherigen. Die Disziplin des Einzelnen [skaliert nicht aufs Team](https://www.mikebild.dev/de/blog/mein-erfolg-laesst-sich-nicht-kopieren/), also schreibt das Team seine Regeln auf; aufgeschriebene Regeln binden niemanden, also braucht es Wissen mit Besitzern und [einen Ort, der die Session überlebt](https://www.mikebild.dev/de/blog/wissen-das-die-session-ueberlebt/); kuratiertes Wissen beantwortet noch keine Verbindlichkeitsfrage, also wandern die harten Regeln [in den Pfad eines eigenen Agenten](https://www.mikebild.dev/de/blog/eigener-agent-pro-fachdomaene/); und damit das kein Reißbrett-Entwurf bleibt, braucht es [Konzepte, Methoden und Prozesse](https://www.mikebild.dev/de/blog/hilfsmittel-konzepte-methoden-prozesse/), die den Anfang klein halten.\n\n```mermaid\nflowchart LR\n    subgraph AKT1[\"Akt 1 · Ich\"]\n        S[\"Session<br/>Erzeugen wird billig\"] --> P[\"Arbeitsweise<br/>Prüfen wird der Engpass\"]\n    end\n    subgraph AKT2[\"Akt 2 · Wir\"]\n        P --> V[\"Team-Varianz<br/>Disziplin skaliert nicht\"] --> D[\"Instruktionsdateien<br/>Empfehlung statt Regel\"]\n    end\n    subgraph AKT3[\"Akt 3 · System\"]\n        D --> W[\"Kuratiertes Wissen<br/>mit Besitzern\"] --> R[\"Regeln im Pfad<br/>Agent pro Fachdomäne\"] --> B[\"Systemarchitektur<br/>Oberfläche, Verträge, Freigaben\"]\n    end\n```\n\nDer Satz, der diesen Bogen zusammenhält, fiel in der Folge über kuratiertes Wissen: Nicht das Modell wird schlauer – die Organisation kuratiert ihr Wissen.\n\n## Der heutige Systemblick\n\nIm Mittelpunkt steht die Eigenschaft, um die es dieser Serie von Anfang an ging: die Trennung von Vorschlag und Wirkung, gebaut als eigene Freigabegrenze, nicht als pauschaler Schalter. Eine Workflow-Engine kann externe Writes bis zur Bestätigung zurückhalten. Ein Publishing-System kann die Promotion einer Vorschau an einen Menschen binden. Ein Wissenssystem kann Änderungen bis zur menschlichen Entscheidung als Vorschlag stehen lassen. Eine Auftragssteuerung kann Ausführung an freigegebene Aufträge binden. Dasselbe Prinzip, jeweils anders geschnitten – weil die Frage, was „wirken“ bedeutet, in jeder Fachdomäne anders beantwortet werden muss. Ein veröffentlichter Inhalt wirkt anders als eine freigegebene Wissensrevision, und beide anders als ein Workflow, der in ein Fremdsystem schreibt.\n\nDamit hat sich auch die Frage verschoben, die der Auftakt stellte. Sie lautete: Was können die Modelle? Sie lautet jetzt: Was macht eine Organisation daraus? Der Zugang zu einer externen Modell-API ist nicht der Teil dieser Architektur, den die Organisation dauerhaft selbst kontrolliert. Eine Oberfläche, hinter der eine sauber geschnittene Fachdomäne steht, maschinelle Verträge, auf die sich andere Systeme verlassen können, und Freigabepfade, die verhindern, dass ein Vorschlag ohne die vorgesehene menschliche Entscheidung wirkt: Das muss eine Organisation selbst aufbauen, und genau darin liegt der Anteil, der ihr unabhängig vom einzelnen Modell bleibt.\n\n## Position statt Prognose\n\nÜber Modellfähigkeiten in einem Jahr, über Produktivitätskurven ganzer Branchen oder über Arbeitsmärkte habe ich keine Urteilsgrundlage, und diese Monate haben mir vor allem beigebracht, wie schnell solche Aussagen altern. Ich formuliere deshalb keine Vision und markiere ausdrücklich, was dieses Zwischenfazit ist: mein begrenzter Erfahrungsstand, keine Prognose über unvermeidliche Markt- oder Organisationsentwicklungen.\n\nWas ich vertreten kann, weil es sich in diesen Monaten wiederholt bestätigt hat: Der dauerhafte Wert liegt weniger im einzelnen Modell als in eigenem Wissen, durchsetzbaren Regeln, klaren Fachdomänen und überprüfbaren Abläufen. Das ist keine Zukunftsaussage, sondern eine Beschreibung dessen, was nach jedem Modellwechsel übrig blieb – und dessen, was jedes Mal fehlte, wenn ein Ergebnis nicht trug.\n\nUnd für meinen eigenen Tisch bleibt eine Erwartung, an der man mich messen kann: der erste Fachdomänen-Agent, der lange genug im Betrieb ist, dass seine Ablehnungen Geschichten erzählen – richtige Neins, zu enge Neins, Sonderfälle, die ein eigenes Verb verdienen. Erst dann zeigt sich, ob der Kreislauf aus der letzten Folge trägt oder nur gut klingt.\n\n## Zwischenfazit, kein Schlusswort\n\nDiese Serie pausiert hier, und zwar aus einem einfachen Grund: Der Vorrat an Beobachtungen ist aufgebraucht. Was jetzt käme, wäre Extrapolation, und davon gibt es genug. Was diesem Zwischenfazit fehlt, ist Betriebszeit. Wenn wieder genug Neues beisammen ist, wenn die Neins des ersten Fachdomänen-Agenten etwas erzählen, schreibe ich weiter. Bis dahin gilt der Vorsatz vom Auftakt unverändert: der eigenen Begeisterung misstrauen, an echten Aufgaben messen, jedes Ergebnis am eigenen Handwerk prüfen. Das Hinschauen lohnt sich, und es hört nicht auf.\n\n## Weiterführende Quellen\n\n- [Measuring the Impact of Early-2025 AI on Experienced Open-Source Developer Productivity (METR, Juli 2025)](https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/) – die Studie, die gemessene Verlangsamung und gefühlte Beschleunigung nebeneinanderlegt\n- [The Anthropic Economic Index](https://www.anthropic.com/economic-index) – laufende Datenerhebung dazu, wofür KI in der Wirtschaft tatsächlich eingesetzt wird\n- [Things we learned about LLMs in 2024 (Simon Willison)](https://simonwillison.net/2024/Dec/31/llms-in-2024/) – nüchterner Jahresrückblick als Gegenmittel zu Prognosen-Prosa"},{"slug":"mcp-http-webhooks-a2a-einfach-erklaert","title":"MCP, HTTP, Webhooks und A2A einfach erklärt","summary":"MCP, HTTP mit OpenAPI, Webhooks und A2A verbinden Systeme auf unterschiedlichen Ebenen. Entscheidend ist, welche Verantwortung an der jeweiligen Grenze liegt.","tags":["Software","KI","Architektur"],"published_at":"2026-08-17T10:00:00+00:00","updated_at":"2026-08-17T05:36:19.088608+00:00","url":"https://www.mikebild.dev/de/blog/mcp-http-webhooks-a2a-einfach-erklaert/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/eae831362c2ce11a5d6057ede7c48de111635336991481077e3caf29cf5cb649.mp3","audio_duration_secs":630,"text":"Vier Verbindungen können technisch über HTTP laufen und trotzdem vier grundverschiedene Aufgaben erfüllen. Eine Agentenanwendung entdeckt ein Werkzeug. Ein festes Programm ruft einen dokumentierten Endpoint auf. Ein Dienst meldet ein bereits eingetretenes Event. Ein eigenständiger Agent übernimmt einen Auftrag mit eigener Laufzeit.\n\nMCP, HTTP mit OpenAPI, Webhooks und A2A beschreiben diese Grenzen. Keines der Verfahren ersetzt die fachliche Entscheidung darüber, wer lesen, schreiben, freigeben oder veröffentlichen darf. Ein Produkt kann mehrere dieser Maschinenoberflächen anbieten und zusätzlich eine eigene Weboberfläche für Menschen besitzen. Es bleibt trotzdem eine Fachdomäne mit einem maßgeblichen Produktzustand und denselben fachlichen Regeln.\n\n## Vier Fragen statt einer Rangliste\n\nEine Schnittstelle ist eine vereinbarte Grenze zwischen Systemen. Sie legt Eingaben, Ergebnisse und Fehler fest. Der Übertragungsweg beantwortet jedoch noch nicht, welche Verantwortung an dieser Grenze übergeben wird.\n\n| Technik | Leitfrage | Typische Aufgabe |\n| --- | --- | --- |\n| MCP | Welche Fähigkeiten kann eine Agentenanwendung entdecken und verwenden? | Werkzeuge und Kontext für eine offene Aufgabe bereitstellen |\n| HTTP mit OpenAPI | Welchen bekannten Maschinenbefehl kann ein Client ausführen? | Einen stabilen Dienstvertrag aufrufen und prüfen |\n| Webhook | Welches eingetretene Event soll ein anderes System erfahren? | Einen nachgelagerten Ablauf zeitnah anstoßen |\n| A2A | Welchen begrenzten Auftrag kann ein eigenständiger Agent übernehmen? | Zustandsbehaftete Arbeit an ein anderes Agentensystem delegieren |\n\nDie vier Optionen stehen nicht in einer Rangfolge und liegen nicht auf derselben technischen Schicht. HTTP ist ein Übertragungsprotokoll, OpenAPI beschreibt eine Schnittstelle, Webhooks verwenden HTTP für Ereigniszustellungen, und MCP sowie A2A bringen eigene Interaktionsverträge mit. Ein A2A-Agent kann intern MCP-Werkzeuge und normale HTTP-APIs aufrufen.\n\n## MCP stellt Fähigkeiten für Agentenanwendungen bereit\n\nMCP steht für Model Context Protocol. Eine Agentenanwendung kann damit etwa die Suche eines Wissensdienstes als Werkzeug entdecken; die Sichtbarkeit dieses Werkzeugs ist noch keine fachliche Berechtigung und kein Ersatz für einen festen Workflow.\n\nEin Server kann lokal als Prozess oder entfernt als Webdienst laufen. Das Wort „Server“ beschreibt seine Rolle im Protokoll, nicht seinen Standort.\n\n```json\n{\n  \"mcpServers\": {\n    \"knowledge\": {\n      \"type\": \"http\",\n      \"url\": \"https://knowledge.example.com/mcp\"\n    }\n  }\n}\n```\n\nNach dem Verbinden kann die Agentenanwendung das Eingabeschema prüfen und das Werkzeug aufrufen. Das Modell erhält dadurch keine pauschale Berechtigung. Der Host steuert, welche Fähigkeiten sichtbar sind und welche Aufrufe eine Zustimmung verlangen. Der Server authentifiziert und autorisiert den Aufruf. Der zuständige Dienst setzt zusätzlich seine fachlichen Regeln für die Operation durch.\n\nMCP passt zu offenen Aufgaben. Eine Agentenanwendung kann in einem Wissensdienst nach freigegebenem Wissen suchen, Quellen lesen und abhängig vom Ergebnis entscheiden, welcher nächste Schritt sinnvoll ist. Für wiederholte Veröffentlichungen mit identischer Schrittfolge ist diese Freiheit unnötig. Ein gespeicherter, freigegebener Workflow ist dort leichter zu prüfen und zu wiederholen.\n\n## HTTP und OpenAPI bilden einen ausdrücklichen Maschinenvertrag\n\nHTTP regelt Anfrage und Antwort. OpenAPI beschreibt darauf aufbauende APIs maschinenlesbar: Pfade, Methoden, Eingaben, Antworten und Sicherheitsverfahren. Menschen können die Beschreibung lesen, Programme daraus Clients erzeugen und Prüfungen die Implementierung mit dem Vertrag vergleichen.\n\nDer folgende Aufruf ist ein schematisches Beispiel:\n\n```http\nPOST /v1/sites/demo/previews HTTP/1.1\nHost: content.example.com\nAuthorization: Bearer <token>\nContent-Type: application/json\n\n{\n  \"revisionIds\": [\"rev-123\", \"rev-456\"]\n}\n```\n\nDer Aufruf fordert eine Vorschau an. Er sagt nichts darüber aus, wer den Inhalt fachlich geprüft hat oder ob diese Vorschau veröffentlicht werden darf. OpenAPI kann den technischen Vertrag präzisieren. Die Freigaberegel gehört in die Fachdomäne und in den kontrollierten Ablauf.\n\nIn einem Publishing-System kann die HTTP- und OpenAPI-Oberfläche feste Integrationen bedienen, ein Remote-MCP-Endpoint ausgewählte Fähigkeiten an Agentenanwendungen richten und eine Weboberfläche die menschliche Arbeit in derselben Publishing-Domäne tragen. Alle drei Zugänge arbeiten dann mit denselben Revisionen, Vorschauen, Releases und Berechtigungsgrenzen; keiner führt einen zweiten Persistenzpfad ein.\n\n## Ein Webhook meldet ein Event\n\nBei einem normalen API-Aufruf fragt der Client aktiv nach. Bei einem Webhook sendet die Event Source eine HTTP-Anfrage an eine vorher registrierte Adresse. Der Empfänger muss dadurch nicht fortlaufend prüfen, ob sich etwas geändert hat.\n\n```http\nPOST /webhooks/knowledge HTTP/1.1\nHost: orchestration.example.com\nwebhook-id: evt-789\nwebhook-timestamp: 1785585600\nwebhook-signature: v1,<signature>\nContent-Type: application/json\n\n{\n  \"type\": \"proposal.approved\",\n  \"data\": {\n    \"space\": \"product-docs\",\n    \"concepts\": [\"integration-options\"]\n  }\n}\n```\n\nDer Event-Typ im Beispiel ist absichtlich generisch. In diesem Ablauf transportiert der Webhook nur das Event und die Referenz auf den maßgeblichen Stand. Der Empfänger prüft Signatur und Alter, erkennt Mehrfachzustellungen über die Event-ID und liest den Stand anschließend mit eigener Berechtigung aus dem verantwortlichen System.\n\nMehrfache Zustellung gehört zum normalen Fehlerbild. Der Empfänger muss idempotent arbeiten: Dieselbe Event-ID darf fachlich nur einmal wirken. Er antwortet schnell und verschiebt längere Arbeit in einen gespeicherten Ablauf. Ein Wissensdienst kann dafür signierte Standard Webhooks liefern. Eine Workflow-Engine kann einen solchen Eingang signaturgeprüft und gegen Wiederholungen geschützt an genau einen versionierten Workflow binden, ohne die Wissens- oder Publishing-Regeln der beteiligten Systeme zu übernehmen.\n\n## A2A delegiert an ein eigenständiges Agentensystem\n\nA2A steht für Agent2Agent. Das Protokoll verbindet einen aufrufenden Agenten mit einem eigenständigen entfernten Agentensystem. Der Client kennt dessen veröffentlichte Fähigkeiten, aber nicht dessen internen Plan, Speicher oder Werkzeuge. Der entfernte Agent bleibt dafür verantwortlich, wie er den Auftrag innerhalb seiner Grenzen bearbeitet.\n\nEine Agent Card beschreibt Identität, Endpoint, Fähigkeiten und Anforderungen an die Anmeldung. Eine Message überträgt einen Gesprächsschritt aus Text, Dateien oder strukturierten Daten. Länger laufende Arbeit erhält einen Task mit eigener ID und einem Lifecycle. Ergebnisse werden als Artifacts zurückgegeben. Rückfragen und andere notwendige Entscheidungen erscheinen als Zustandsübergänge des Tasks.\n\nDas unterscheidet Delegation von einem Werkzeugaufruf. Bei MCP ruft eine Agentenanwendung eine beschriebene Fähigkeit auf. Bei A2A übergibt ein Agent ein Ziel an ein anderes System, das selbst planen, Werkzeuge verwenden und mehrere Schritte ausführen kann.\n\n## Weboberflächen und Maschinenoberflächen erfüllen verschiedene Aufgaben\n\nEine Weboberfläche für Menschen ist keine fünfte Integrationsart. MCP, HTTP mit OpenAPI, Webhooks und A2A verbinden Maschinen. Weboberfläche und Maschinenoberflächen arbeiten mit dem Zustand derselben Fachdomäne und führen keinen zweiten Persistenzpfad ein.\n\nJede Weboberfläche verwendet dafür die Begriffe und Entscheidungen ihrer eigenen Domäne. In einem Wissensdienst ist eine ausstehende Wissensänderung ein Änderungsvorschlag, in einer Workflow-Engine gehört eine Ausführung zu einer bestimmten Workflow-Version.\n\nAus dieser Trennung folgen zwei Grenzen:\n\n- Eine Weboberfläche wird nicht zum zweiten fachlichen System mit abweichenden Regeln.\n- Ein Maschinenprotokoll wird nicht zur unbeabsichtigten Umgehung der menschlichen Entscheidung.\n\n## Kein Protokoll erteilt von selbst Berechtigungen\n\nAuthentifizierung beantwortet: Wer stellt die Anfrage? Autorisierung beantwortet: Was darf diese Identität tun? Ein gültiger Schlüssel sollte Lesen, Schreiben und Veröffentlichen nicht pauschal zusammenfassen.\n\nEin Agent, der Wissen sucht, braucht nur Leserechte. Ein Schritt, der eine Vorschau baut, braucht die dafür erforderlichen Schreibrechte. Eine Veröffentlichung darf eine eigene Berechtigung und eine sichtbar gebundene menschliche Freigabe verlangen. Wenn eine Operation über mehrere Oberflächen angeboten wird, müssen für sie dieselben fachlichen Freigabegrenzen gelten.\n\nZuverlässigkeit entsteht ebenfalls nicht durch den Namen des Protokolls. Ein HTTP-Aufruf kann nach erfolgreicher Verarbeitung abbrechen, bevor die Antwort beim Client ankommt. Ein Webhook kann mehrfach eintreffen. Ein A2A-Task kann lange auf eine Entscheidung warten. Jeder schreibende Schritt braucht deshalb eine stabile Kennung, eine klare Wiederholungsregel und einen gespeicherten Zustand.\n\nFür schreibende oder freigabepflichtige Integrationen müssen drei Verträge zusammenpassen:\n\n- Der technische Vertrag beschreibt Eingaben, Ergebnisse und Fehler.\n- Der fachliche Vertrag legt fest, welches System Wissen, Freigabe und Veröffentlichung besitzt.\n- Der betriebliche Vertrag regelt Zeitlimits, Wiederholungen, Logging und den Umgang mit Ausfällen.\n\n## Ein Ablauf mit allen vier Grenzen\n\n```mermaid\nflowchart LR\n    U[\"Mensch<br/>formuliert ein Ziel\"] --> A[\"Agentenanwendung<br/>bearbeitet offene Aufgabe\"]\n    A -->|\"MCP<br/>suchen und lesen\"| K[\"Wissensdienst<br/>geprüftes Wissen\"]\n    A -->|\"MCP oder HTTP / OpenAPI<br/>Revision und Vorschau\"| C[\"Publishing-System<br/>Publishing-Domäne\"]\n    K -->|\"signierter Webhook<br/>Freigabeereignis\"| S[\"Workflow-Engine<br/>versionierter Workflow\"]\n    S -->|\"HTTP / OpenAPI<br/>festgelegte Schritte\"| C\n    A -.->|\"A2A<br/>begrenzten Auftrag delegieren\"| R[\"Agentensystem<br/>eigenständiger Task\"]\n    H[\"Mensch in der jeweiligen Weboberfläche\"] -.-> K\n    H -.-> S\n    H -.-> C\n    H -.-> R\n```\n\nDer erste Durchlauf kann begleitet sein. Eine Agentenanwendung liest im Wissensdienst, erzeugt Markdown und baut im Publishing-System eine Vorschau. Ein Mensch sieht die Ergebnisse und kann die Reihenfolge noch verändern.\n\nSobald sich die Folge stabil wiederholt, gehört sie als versionierter Workflow in eine Workflow-Engine. Der Workflow liest eine konkrete freigegebene Wissensrevision, erzeugt eine Content-Revision und baut eine Vorschau. Er veröffentlicht nicht deshalb automatisch, weil ein Webhook ihn gestartet hat. Das Publishing-System behält die Freigabe- und Release-Grenze.\n\nA2A kommt hinzu, wenn tatsächlich ein eigenständiges Agentensystem einen begrenzten Auftrag mit eigener Laufzeit übernimmt. Ein einfacher API-Aufruf wird durch die Bezeichnung „Agent“ nicht zu einer Delegation.\n\n## Die Entscheidung liegt vor dem Protokoll\n\nDie Tabelle hilft erst, wenn drei Fragen beantwortet sind: Welches System besitzt den Zustand? Welche Identität darf ihn verändern? Welche Wirkung benötigt eine menschliche Entscheidung? Daraus folgen die Regeln für Wiederholungen und die Ansicht, in der ein Mensch den aktuellen Stand prüfen kann.\n\nZuerst werden der Besitzer von Zustand und Freigabe festgelegt. Danach folgt das Protokoll.\n\n## Weiterführende Quellen\n\n- [Model Context Protocol: Architektur](https://modelcontextprotocol.io/docs/learn/architecture)\n- [Model Context Protocol: Einführung](https://modelcontextprotocol.io/docs/getting-started/intro)\n- [OpenAPI Specification](https://spec.openapis.org/oas/)\n- [Standard Webhooks](https://www.standardwebhooks.com/)\n- [A2A: zentrale Konzepte](https://a2a-protocol.org/latest/topics/key-concepts/)\n- [A2A und MCP im Vergleich](https://a2a-protocol.org/latest/topics/a2a-and-mcp/)"},{"slug":"widersprueche-duerfen-bleiben","title":"Widersprüche dürfen bleiben","summary":"Eine Wissensbasis, die belegte Konflikte sichtbar hält, verhindert, dass ein neuer Wert einen bestehenden Widerspruch verdeckt. Funktionale Prädikate, disputed Claims und die Beziehung contradicts machen die offene Frage prüfbar – die fachliche Auflösung bleibt einer geprüften Revision vorbehalten.","tags":["Software","KI","Methodik"],"published_at":"2026-07-22T10:00:00+00:00","updated_at":"2026-08-18T18:22:46.983957+00:00","url":"https://www.mikebild.dev/de/blog/widersprueche-duerfen-bleiben/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/d6ad20afb3c834fe12dc1b3ce4bd94bf3a30731ac4829e58823f62cd3e6461cc.mp3","audio_duration_secs":701,"text":"Ein hypothetisches Beispiel: Im Architekturprotokoll eines Produkts steht „PostgreSQL is the primary database.“ Die Deployment-Dokumentation desselben Produkts sagt: „SQLite is the primary database for edge installations.“ Beide Aussagen sind belegt. Ein maschinell erkennbarer Konflikt entsteht daraus erst, wenn beide Aussagen demselben Subjekt und Prädikat zugeordnet sind und `primary_database` im Space-Vertrag als funktionales Prädikat konfiguriert ist – also als Beziehung, die für ein Subjekt genau einen aktuellen Wert haben darf.\n\nEine Wissensbasis darf solche Spannungen nicht mit einer flüssigen Zusammenfassung verdecken. Hinter dem Konflikt können etwa ein unterschiedlicher Geltungsbereich (PostgreSQL allgemein, SQLite nur für Edge-Installationen), eine zeitliche Änderung der Architektur oder ein schlichter Quellenfehler stecken. Eine Zusammenfassung darf den offenen Konflikt nicht zu einer eindeutigen Aussage verdichten. Sind beide Aussagen demselben Subjekt und einem als funktional konfigurierten Prädikat zugeordnet und besitzen sie verschiedene Objekte, sollte die Wissensbasis beide Claims als `disputed` markieren, sie über eine `contradicts`-Beziehung verbinden und keinen von beiden löschen, bis eine geprüfte Revision die Frage fachlich klärt.\n\n## Wann eine Wissensbasis einen Widerspruch erkennen kann\n\nClaims bestehen aus Subjekt, Prädikat und Objekt. Manche Beziehungen dürfen mehrere Objekte gleichzeitig tragen: Ein System kann mehrere Exportformate unterstützen, `supports_format=markdown` und `supports_format=okf` ergänzen einander. Andere Beziehungen sollen für ein Subjekt genau einen aktuellen Wert besitzen. Der Space-Vertrag legt diese zweite Gruppe als funktionale Prädikate fest. Ein vereinfachtes Beispiel für diese Konfiguration:\n\n```json\n{\n  \"settings\": {\n    \"functional_predicates\": [\n      \"primary_database\",\n      \"current_owner\",\n      \"default_retention_days\"\n    ]\n  }\n}\n```\n\nNur bei ausdrücklich als funktional konfigurierten Prädikaten wird ein Konflikt erkannt – genau dann, wenn derselbe Gegenstand und dasselbe Prädikat verschiedene Objekte besitzen. Diese Regel ist absichtlich enger als semantische Ähnlichkeit. Die Wissensbasis versucht nicht, jeden inhaltlichen Widerspruch automatisch zu verstehen; ein Modell soll aus zwei ähnlich klingenden Sätzen keinen Konflikt erraten und erst recht keinen wegdiskutieren. Erkannt wird ausschließlich, was der Space-Vertrag als eindeutig deklariert hat. Das macht die Erkennung deterministisch und ihre Fehlermöglichkeiten benennbar: Ein zu breit gewähltes Subjekt erzeugt Scheinkonflikte, ein fehlendes funktionales Prädikat lässt echte Konflikte unentdeckt.\n\n```mermaid\nflowchart TD\n    A[\"service<br/>primary_database<br/>PostgreSQL\"] --> C{\"gleiches Subjekt und Prädikat<br/>anderes Objekt?\"}\n    B[\"service<br/>primary_database<br/>SQLite\"] --> C\n    C -->|funktionales Prädikat| D[\"beide Claims disputed<br/>contradicts-Beziehung\"]\n    C -->|mehrwertiges Prädikat| V[\"kein Konflikt<br/>beide Claims bleiben bestehen\"]\n```\n\n## `disputed` und `contradicts` erhalten den Konflikt\n\nTrifft bei der atomaren Freigabe ein neuer Claim auf einen bestehenden Wert eines funktionalen Prädikats, werden beide kollidierenden Claims als `disputed` markiert und durch eine `contradicts`-Beziehung verbunden. Keine der beiden Aussagen wird gelöscht, keine wird still ersetzt. Ein neuer Beleg darf einen älteren Konflikt nicht einfach überschreiben – auch dann nicht, wenn er später hochgeladen wurde.\n\n```json\n{\n  \"subject\": \"service-runtime\",\n  \"predicate\": \"primary_database\",\n  \"object\": \"PostgreSQL\",\n  \"status\": \"disputed\",\n  \"relations\": [\n    {\n      \"kind\": \"contradicts\",\n      \"to_claim_id\": \"claim_sqlite\"\n    }\n  ]\n}\n```\n\nEin umstrittener Claim bleibt sichtbar. Der Status warnt: Es gibt einen belegten Konflikt, der noch nicht aufgelöst ist. Ein nachgelagertes System sollte diesen Claim deshalb nicht wie eine unbestrittene Tatsache verwenden.\n\nStilles Überschreiben wäre einfacher und gefährlicher. Die aktuelle Seite könnte plötzlich SQLite nennen, während frühere Entscheidungen, Deployments und Runbooks weiter von PostgreSQL ausgehen. Ohne Konfliktstatus würde ein Agent die neue Formulierung mit derselben Sicherheit lesen wie jede andere freigegebene Aussage. `disputed` schafft stattdessen einen expliziten, abfragbaren Konfliktzustand: Für dasselbe funktionale Prädikat liegen zwei als `disputed` markierte Werte vor. Diese Information ist für eine Entscheidung wertvoller als eine zufällig gewählte Quelle.\n\n## Der Geltungsbereich löst viele Scheinkonflikte\n\nIm Eingangsbeispiel beziehen sich die Quellen möglicherweise auf verschiedene Deployments. Dann ist keines der Objekte falsch; das Subjekt ist zu breit. Aus `service-runtime` werden `service-standard-deployment` und `service-edge-deployment`.\n\n```json\n[\n  {\n    \"subject\": \"service-standard-deployment\",\n    \"predicate\": \"primary_database\",\n    \"object\": \"PostgreSQL\"\n  },\n  {\n    \"subject\": \"service-edge-deployment\",\n    \"predicate\": \"primary_database\",\n    \"object\": \"SQLite\"\n  }\n]\n```\n\nDer Review korrigiert damit das Modell, nicht die Quellen. Beide Originalzitate bleiben erhalten. Neue Claims beschreiben ihren tatsächlichen Geltungsbereich; was mit den zu breit geschnittenen alten Claims geschieht, entscheidet die geprüfte Revision – automatisch verschwindet keiner von ihnen.\n\nAndere Konflikte sind zeitlich. Eine Retention betrug früher 30 Tage und heute 90. Auch hier können beide Quellen stimmen: Der aktuelle Claim nennt 90 Tage, die alte Aussage bleibt als frühere Fassung in ihrer Revision erhalten. Kategorisierungen und Beziehungen bleiben revisionsfähige, belegte Aussagen.\n\nEin echter fachlicher Widerspruch bleibt übrig, wenn Geltungsbereich und Zeitraum identisch sind. Dann muss die prüfende Person eine Quelle als maßgeblich bestimmen, eine neue Entscheidung anlegen oder die Frage bewusst offenhalten.\n\n## Review durch Menschen\n\nDie fachliche Auflösung geschieht nicht in der Erkennungslogik, sondern in einer späteren, geprüften Revision. Dafür braucht es eine Les- und Reviewoberfläche für Menschen: Dort liest eine Person die betroffenen Claims mit ihren Belegen und entscheidet, ob Geltungsbereich, Zeit oder Inhalt kollidieren. Ein Änderungsvorschlag, der nur den neueren Claim betrachtet, könnte die Entscheidung zugunsten des jüngeren Belegs verzerren – deshalb gehören beide Seiten des Konflikts in den Review.\n\nEin praktischer Prüfpfad:\n\n- Subjekt und Prädikat auf zu groben Geltungsbereich prüfen.\n- Quellenzeitpunkt und betroffene Produktversion vergleichen.\n- Autorität der Quellen einordnen, ohne ältere Belege zu entfernen.\n- Entscheidung mit kurzer Begründung festhalten und in einer neuen Revision freigeben – oder den Konflikt bewusst `disputed` lassen.\n\nDie prüfende Person darf einen Konflikt offenlassen. Fehlende Information ist ein legitimer Zustand; eine spätere Quelle oder eine zuständige Person kann die Auflösung nachholen. Wichtig ist die Rollenverteilung: Die Wissensbasis legt den Widerspruch offen und hält ihn stabil. Sie entscheidet nicht ohne Review, welche Aussage wahr ist – weder über sprachliche Plausibilität noch über Datumsstempel.\n\n## Suche und Antworten sollten den Status respektieren\n\nEin Konflikt, den nur der Reviewprozess kennt, verschwindet für alle anderen Leser. Deshalb sollten Such- und MCP-Lesezugriffe auch disputed Claims zurückgeben. Für nachgelagerte Systeme folgt daraus eine Soll-Regel: Eine natürlich formulierte Antwort sollte den Widerspruch benennen und beide Belege angeben, statt eine Seite auszuwählen. Ein hypothetisches Antwortformat eines solchen Systems könnte so aussehen:\n\n```json\n{\n  \"answer\": \"The knowledge base contains conflicting primary-database claims for the runtime.\",\n  \"citations\": [\"claim_postgresql\", \"claim_sqlite\"],\n  \"status\": \"conflict\"\n}\n```\n\nFindet ein nachgelagerter Agent keine freigegebene Auflösung, sollte er keine eindeutige Antwort geben und den internen Konflikt nicht mit allgemeinem Vorwissen scheinbar lösen. Er sieht die disputed Claims und darf daraus keinen eindeutigen Wert ableiten: Diese Frage ist gerade offen, und die weitere Planung muss das berücksichtigen.\n\n## Drei Revisionen zeigen den Unterschied\n\nBei `default_retention_days` unterscheiden sich Ablösung und Widerspruch so: Revision 1 enthält den freigegebenen Claim `default_retention_days=30`. Eine spätere Entscheidung erhöht den Wert auf 90; die geprüfte Revision 2 übernimmt den neuen Wert als aktuelle Aussage und behält die alte als Geschichte. Es gibt keinen offenen Konflikt, weil Quelle und Entscheidung die zeitliche Ablösung erklären.\n\nFür Revision 3 trifft ein Runbook ein, das weiterhin 30 Tage als aktuellen Default bezeichnet. Damit kollidiert der vorgeschlagene Anspruch mit dem aktuellen 90-Tage-Claim. Wird der Vorschlag als Revision 3 atomar freigegeben, werden beide aktuellen Behauptungen `disputed` und über `contradicts` verbunden, bis geklärt ist, ob das Runbook veraltet ist oder die Entscheidung nie umgesetzt wurde. Der vorgeschlagene Claim wird nicht allein wegen seines späteren Uploadzeitpunkts bevorzugt.\n\n```text\nrevision 1:\n  aktueller Wert: 30 Tage\n\nrevision 2:\n  aktueller Wert: 90 Tage\n  alte Aussage bleibt als Geschichte erhalten\n\nrevision 3 (Vorschlag):\n  Runbook nennt 30 Tage als aktuellen Default\n  bei atomarer Freigabe:\n    -> beide aktuellen Werte werden disputed\n    -> contradicts verbindet die kollidierenden Claims\n```\n\nDiese Sequenz trennt Ablösung von Widerspruch. Eine begründete zeitliche Änderung erzeugt eine neue aktuelle Aussage und bewahrt die alte als Geschichte. Ein Beleg, der gleichzeitig einen anderen aktuellen Wert beansprucht, öffnet bei der atomaren Freigabe einen Konflikt. Die prüfende Person kann die Produktionskonfiguration als zusätzliche Quelle heranziehen: Zeigt sie 90 Tage, wird der Runbook-Claim abgelehnt und eine Dokumentationskorrektur angestoßen. Zeigt sie 30, muss geklärt werden, ob die Entscheidung falsch umgesetzt oder zurückgenommen wurde. Die Wissensbasis soll diese Betriebsentscheidung nicht aus Zeitstempeln erraten.\n\n## Veraltet ist nicht automatisch widersprüchlich\n\nEin Claim kann jahrelang unverändert und trotzdem korrekt sein; ein anderer ist nach zwei Tagen überholt. Staleness ist deshalb eine eigene Qualitätsdimension und kein Beweis für einen Konflikt. Eine Preisangabe kann einen kurzen Prüfzyklus verlangen, während eine mathematische Definition oft seltener geprüft werden kann. Welche Wissensarten wann erneut betrachtet werden, ist eine Festlegung des Teams für seinen Space.\n\nAls Reviewpolicy formuliert: Alter sollte zu einer erneuten Prüfung führen, nicht automatisch zu einer Abwertung des Claims; erst eine neue Quelle oder eine Entscheidung sollte den Status verändern. Umgekehrt kann ein sehr junger Claim sofort umstritten sein, wenn eine gleich aktuelle Quelle widerspricht. Diese Trennung verhindert eine mechanische Aktualitätsgläubigkeit: „Neuer“ ist ein wichtiges Signal, aber keine fachliche Autorität. Herkunft, Geltungsbereich und Entscheidung bleiben nötig.\n\n## Einordnung gegenüber früheren Beiträgen\n\nZwei frühere Beiträge dieser Seite behandeln benachbarte Fragen: [ADR: Entscheidungen, die man versteht](https://www.mikebild.dev/de/blog/adr-entscheidungen-die-man-versteht/) und [Wissen, das die Session überlebt](https://www.mikebild.dev/de/blog/wissen-das-die-session-ueberlebt/). Neu ist hier die Verbindung zwischen Entscheidung, einzelnen Claims und ihren Quellen: Ein Dokument kann in einem Ordner veralten, ohne dass eine neuere Produktdokumentation den Gegensatz bemerkt. Bei gleichem Subjekt und funktionalem Prädikat werden die kollidierenden Claims markiert und verbunden. Der Review kann den Konflikt klären oder bewusst offenhalten. Alt und Neu dürfen in der Historie bleiben, aber ihr Status und ihre Beziehung müssen klar sein – Aufbewahren ist etwas anderes, als beide gleichzeitig für gültig zu erklären.\n\n## Sichtbare Konflikte machen die Wissensbasis prüfbar\n\nEine Wissensbasis ohne sichtbare Widersprüche kann besonders sauber oder besonders blind sein. Entscheidend ist, ob sie überhaupt einen Mechanismus besitzt, Konflikte zu erkennen und offen zu halten. Der hier beschriebene Ansatz konzentriert sich dafür auf explizite, kontrollierte Prädikate und belegte Claims. Diese Begrenzung macht das Ergebnis vorhersehbar; die fachlichen Nuancen bleiben beim Review und werden in einer geprüften Revision festgeschrieben.\n\nFür Teams ändert sich damit die Gesprächsgrundlage. Statt „Im Wiki steht aber …“ liegen zwei konkrete, verbundene Claims mit Quelle und Status im Review vor. Der Review kann nun Geltungsbereich, Zeitraum oder Quelle gezielt prüfen, eine neue Entscheidung anstoßen oder das Nichtwissen ausdrücklich stehen lassen.\n\nFür den ersten Einsatz spricht viel dafür, nur wenige fachlich eindeutige Prädikate zu konfigurieren: aktueller Owner, primäre Produktionsdatenbank, freigegebener Retention-Default. An den ersten realen Konflikten lässt sich dann prüfen, ob die Subjekte präzise genug geschnitten sind und welche Reviewinformationen fehlen. Erst danach sollte das kontrollierte Vokabular wachsen. So bleibt die Widerspruchserkennung ein verständliches Fachmodell – und wird nicht zu einem Schema, das jede neue Quelle erst passend machen muss.\n\n## Weiterführende Quellen\n\n- [ADR: Entscheidungen, die man versteht](https://www.mikebild.dev/de/blog/adr-entscheidungen-die-man-versteht/)\n- [Wissen, das die Session überlebt](https://www.mikebild.dev/de/blog/wissen-das-die-session-ueberlebt/)\n- [W3C PROV-O](https://www.w3.org/TR/prov-o/)\n- [PostgreSQL Full Text Search](https://www.postgresql.org/docs/current/textsearch.html)"},{"slug":"a2a-agenten-zusammenarbeit","title":"A2A: Wenn Agenten zusammenarbeiten","summary":"Agent Card, Message, Task und Artifact verständlich erklärt: wie A2A 1.0 eigenständige Agentensysteme verbindet, was das Protokoll von MCP unterscheidet und wann sich die Delegation an ein eigenständiges System lohnt.","tags":["Software","KI","Architektur"],"published_at":"2026-07-22T10:00:00+00:00","updated_at":"2026-08-18T18:22:46.983957+00:00","url":"https://www.mikebild.dev/de/blog/a2a-agenten-zusammenarbeit/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/ff952355488811c160d6a793a3334543ee0ccefc7fcf72f832060a239ee9e80f.mp3","audio_duration_secs":691,"text":"Ein Agentensystem braucht eine begrenzte Codeänderung in einem fremden Repository. Es könnte selbst Werkzeuge laden, den Code lesen und die Änderung entwerfen. Es kann den Auftrag aber auch an ein eigenständiges System übergeben, das in einer anderen Umgebung läuft, eigene Berechtigungen besitzt und später ein prüfbares Ergebnis zurückmeldet.\n\nA2A 1.0 – die Abkürzung steht für Agent2Agent – beschreibt für diesen zweiten Fall die Übergabe und Verfolgung eines Auftrags zwischen eigenständigen Systemen. Die veröffentlichte Spezifikation des offenen Protokolls behandelt, wie ein Auftraggeber einen entfernten Agenten findet, ihm Messages übermittelt, länger laufende Aufgaben verfolgt und Ergebnisse entgegennimmt. Welches Sprachmodell, welche Programmiersprache oder welches Framework intern arbeitet, schreibt sie nicht vor.\n\n## Eigenständig ist das entscheidende Wort\n\nNicht jede benannte Rolle ist ein eigener Agent. Wenn eine Anwendung nacheinander drei Prompts ausführt und alle Werkzeuge, Zugangsdaten und Zustände zentral verwaltet, sind das aus Sicht der Architektur drei Arbeitsschritte in einem Agentensystem. A2A bringt dort wenig.\n\nEin entfernter A2A-Agent ist dagegen ein eigenständiger Dienst. Er entscheidet innerhalb seines Auftrags selbst, wie er arbeitet. Der aufrufende Agent sieht die angebotenen Fähigkeiten und die ausgetauschten Daten, aber nicht automatisch den internen Speicher, die Überlegungen oder die Werkzeuge des anderen Systems. Diese Kapselung trennt die Berechtigungs- und Task-Grenzen beider Systeme: Der entfernte Agent arbeitet mit eigenen Zugriffsrechten, und ausgetauscht werden sollte nur, was der Auftrag vorsieht.\n\nIm Protokoll nimmt ein A2A-Client im Auftrag des Benutzers Kontakt auf. Der A2A-Server stellt den entfernten Agenten bereit. Client und Server sind Kommunikationsrollen, keine Systemklassen: Bei einem anderen Auftrag kann dasselbe System die andere Rolle einnehmen.\n\n```mermaid\nflowchart LR\n    U[\"Benutzer<br/>gibt ein Ziel vor\"] --> C[\"Aufrufender Agent<br/>A2A-Client\"]\n    C -->|\"Agent Card lesen\"| S[\"Entfernter Agent<br/>A2A-Server\"]\n    C -->|\"Message<br/>Auftrag und Kontext\"| S\n    S -->|\"Task<br/>Zustand und Rückfragen\"| C\n    S -->|\"Artifacts<br/>Ergebnisse\"| C\n    S --> M[\"Interne Modelle, Tools<br/>und Datenquellen\"]\n    C -.->|\"kein automatischer Zugriff\"| M\n```\n\n## Die Agent Card ist die Beschreibung, nicht die Berechtigung\n\nBevor ein Client einen Auftrag sendet, muss er wissen, was der andere Agent anbietet und wie er erreichbar ist. Dafür dient die Agent Card, ein JSON-Dokument mit Metadaten. Die veröffentlichte A2A-1.0-Spezifikation beschreibt darin unter anderem Identität, unterstützte Schnittstellen und angebotene Skills; als Bezugswege nennt sie eine standardisierte Adresse (`https://{domain}/.well-known/agent-card.json`), daneben Kataloge oder eine feste Konfiguration.\n\nDas folgende fiktive Publishing-Beispiel ist auf die hier benötigten Felder reduziert und beansprucht keine vollständige A2A-1.0-Agent-Card.\n\n```json\n{\n  \"name\": \"Publishing Agent\",\n  \"description\": \"Builds previews and prepares approved releases\",\n  \"supportedInterfaces\": [\n    {\n      \"url\": \"https://publishing.example.com/a2a\",\n      \"protocolBinding\": \"HTTP+JSON\",\n      \"protocolVersion\": \"1.0\"\n    }\n  ],\n  \"version\": \"1.0.0\",\n  \"capabilities\": {\n    \"streaming\": true,\n    \"pushNotifications\": true,\n    \"extendedAgentCard\": true\n  },\n  \"defaultInputModes\": [\"text/plain\", \"application/json\"],\n  \"defaultOutputModes\": [\"text/markdown\", \"application/json\"],\n  \"skills\": [\n    {\n      \"id\": \"prepare-publication\",\n      \"name\": \"Prepare publication\",\n      \"description\": \"Creates a preview from approved knowledge\",\n      \"tags\": [\"publishing\", \"preview\", \"content\"]\n    }\n  ]\n}\n```\n\nDie Agent Card ist mit einer öffentlichen Leistungsbeschreibung vergleichbar. Dass dort ein Skill aufgeführt ist, erlaubt noch keinen Aufruf. Anmeldung und Autorisierung bleiben eigene Sicherheitsentscheidungen: Der Server muss bei jedem Auftrag prüfen, wer anfragt und was dieser Auftraggeber tun darf. Eine öffentliche Agent Card sollte außerdem keine internen Fähigkeiten oder vertraulichen Endpoints verraten.\n\n## Message, Part, Task und Artifact\n\nMessage, Part, Task und Artifact bezeichnen unterschiedliche Bausteine eines Auftrags.\n\n| Begriff | Bedeutung | Beispiel bei delegierter Arbeit |\n| --- | --- | --- |\n| Message | Ein Gesprächsbeitrag des Benutzers oder Agenten | „Analysiere diese Abhängigkeit und schlage eine Änderung vor.“ |\n| Part | Ein Bestandteil einer Message oder eines Artifacts | Anweisung als Text, eine Datei oder strukturierte Daten |\n| Task | Eine zustandsbehaftete Arbeitseinheit mit eindeutiger ID | Der Auftrag wartet auf eine Rückfrage oder arbeitet weiter |\n| Artifact | Ein vom Agenten erzeugtes Ergebnis | Analysebericht, vorbereitete Änderung, Vorschau-URL |\n\nEine Message kann sofort mit einer anderen Message beantwortet werden. Das passt zu einer kurzen Unterhaltung ohne dauerhaften Arbeitszustand. Muss die Arbeit weiterlaufen oder über mehrere Gesprächsrunden erhalten bleiben, antwortet der entfernte Agent mit einem Task.\n\nDer Task ist mehr als eine technische Anfrage-ID. Er hält den Zustand einer fachlichen Arbeitseinheit. Der Client kann ihn später lesen, auf eine Rückfrage antworten oder eine Stornierung anfordern. Nach der Spezifikation können mehrere Messages über eine `contextId` zu demselben Gesprächszusammenhang gehören; eine `taskId` bezeichnet die konkrete Arbeit. Ein vereinfachtes, nicht normatives Beispiel:\n\n```http\nPOST /message:send HTTP/1.1\nHost: agents.example.com\nAuthorization: Bearer <token>\nContent-Type: application/a2a+json\nA2A-Version: 1.0\n\n{\n  \"message\": {\n    \"role\": \"ROLE_USER\",\n    \"messageId\": \"msg-123\",\n    \"parts\": [\n      {\n        \"text\": \"Prepare a limited change proposal. Do not merge it.\"\n      },\n      {\n        \"data\": {\n          \"repository\": \"product-service\",\n          \"scope\": [\"dependency-upgrade\"]\n        },\n        \"mediaType\": \"application/json\"\n      }\n    ]\n  }\n}\n```\n\nDie Parts trennen unterschiedliche Inhalte innerhalb derselben Message: Text trägt eine Anweisung, eine Datei übergibt ein Dokument, strukturierte Daten erlauben ein vereinbartes Schema ohne erneutes Auslesen aus Prosa. Der Empfänger muss Medientyp, Größe, Herkunft und Berechtigung jedes Inhalts prüfen.\n\nArtifacts sind die Arbeitsergebnisse, nicht bloß Gesprächsbeiträge. Laut Spezifikation kann ein Task mehrere Artifacts liefern, und ein Artifact kann während eines Datenstroms schrittweise ergänzt werden. Der Client sollte deshalb Artifact-ID und Task-ID speichern, statt nur den letzten sichtbaren Text zu kopieren. Eine typische erste Antwort sieht so aus – der Server hat angenommen, aber noch nicht abgeschlossen:\n\n```json\n{\n  \"task\": {\n    \"id\": \"task-456\",\n    \"contextId\": \"context-789\",\n    \"status\": {\n      \"state\": \"TASK_STATE_WORKING\",\n      \"timestamp\": \"2026-08-07T10:05:00Z\"\n    },\n    \"artifacts\": []\n  }\n}\n```\n\n## Der Lifecycle macht längere Arbeit sichtbar\n\nA2A ist auf asynchrone Aufgaben ausgelegt. Ein Server kann sofort einen Task zurückgeben und im Hintergrund weiterarbeiten. Für Aktualisierungen sieht die Spezifikation drei Zustellwege vor: Queries, einen fortlaufenden Datenstrom oder Push Notifications.\n\n```mermaid\nstateDiagram-v2\n    [*] --> SUBMITTED\n    SUBMITTED --> WORKING\n    WORKING --> INPUT_REQUIRED: Rückfrage\n    INPUT_REQUIRED --> WORKING: weitere Message\n    WORKING --> AUTH_REQUIRED: zusätzliche Freigabe\n    AUTH_REQUIRED --> WORKING: Berechtigung erteilt\n    WORKING --> COMPLETED\n    WORKING --> FAILED\n    SUBMITTED --> REJECTED\n    WORKING --> CANCELED\n    COMPLETED --> [*]\n    FAILED --> [*]\n    REJECTED --> [*]\n    CANCELED --> [*]\n```\n\nDas Diagramm vereinfacht den in der Spezifikation von Version 1.0 definierten Zustandsraum. `INPUT_REQUIRED` bedeutet, dass der Agent weitere fachliche Angaben braucht. `AUTH_REQUIRED` signalisiert eine während des Auftrags nötige Autorisierung; Zugangsdaten gehören dabei nicht als Klartext in eine Message, sondern laufen über einen getrennten, zuvor vereinbarten Weg. Als Endzustände definiert die Spezifikation `COMPLETED`, `FAILED`, `CANCELED` und `REJECTED`. Ein Client darf einen Endzustand nicht durch blindes Wiederholen wiederbeleben, sondern muss bewusst einen neuen Task anlegen.\n\nPolling, Streaming und Push lösen dasselbe Zustellungsproblem auf unterschiedliche Weise. Beim Polling fragt der Client in sinnvollen Abständen nach dem Task – einfach, bei langen Pausen aber verschwenderisch. Beim Streaming bleibt eine Verbindung offen und der Server sendet Status- und Artifact-Updates als Events; nach einer Unterbrechung muss der Client den gespeicherten Task erneut lesen, denn ein Datenstrom ist kein Archiv. Push Notifications ersparen die dauerhafte Verbindung, verlangen dafür einen abgesicherten HTTP-Endpoint beim Client, inklusive Authentifizierung, Schutz vor internen Zieladressen und Behandlung mehrfacher Zustellungen.\n\n## Delegation erzeugt keine neue Berechtigung\n\nA2A nimmt einem System keine Sicherheitsentscheidung ab. Ein aufrufender Agent sollte einer gefundenen Agent Card nicht allein deshalb vertrauen, weil sie formal korrekt ist. Vor einem Auftrag muss feststehen, welchem Anbieter das eigene System vertraut, welche Daten es verlassen dürfen und welche Wirkung der entfernte Agent auslösen kann.\n\nDer A2A-Server steht in derselben Pflicht: Er muss bei jedem Auftrag prüfen, wer anfragt und was dieser Auftraggeber tun darf, und Messages als nicht vertrauenswürdige Eingaben behandeln. Der wichtigste Grundsatz dahinter: Delegation darf keine neue Berechtigung erzeugen. Wer einen Auftrag übermittelt, darf dadurch nicht mehr Rechte im Zielsystem erhalten, und der ausführende Agent darf nicht stillschweigend die Rechte seines Auftraggebers erben. Der Auftrag bleibt begrenzt.\n\nEin Zielsystem kann diese Begrenzung explizit machen: Ein delegierter Auftrag ist dann an einen aktiven, menschlich freigegebenen Auftragsstand gebunden. Was dieser Stand nicht erlaubt, kann auch ein technisch korrekter A2A-Aufruf nicht auslösen. Ein menschlicher Freigabeschritt bleibt damit eine fachliche Entscheidung im Zielsystem – ein Agent darf eine Freigabe nicht aus einer freundlichen Formulierung in einer Message ableiten.\n\n## MCP, A2A und Weboberfläche sind verschiedene Zugänge, keine Etiketten\n\nMCP stellt einer Agentenanwendung Werkzeuge und Kontext bereit; die Verantwortung für die offene Aufgabe bleibt in der Anwendung. A2A verbindet eigenständige Agentensysteme: Der Auftraggeber übergibt ein Ziel, der entfernte Agent verantwortet die Ausführung und führt einen eigenen Task-Lifecycle. MCP und A2A modellieren damit unterschiedliche Verantwortlichkeiten und Lifecycles.\n\n| Frage | MCP | A2A |\n| --- | --- | --- |\n| Gegenüber | Fähigkeitsserver | Eigenständiges Agentensystem |\n| Auffindbare Beschreibung | Werkzeuge und Kontextquellen | Agent Card mit Skills und Schnittstellen |\n| Typische Arbeit | Werkzeug auswählen und aufrufen | Ziel oder Teilaufgabe delegieren |\n| Länger laufender Zustand | Durch die Anwendung modelliert | Task-Lifecycle ist Protokollkonzept |\n| Ergebnis | Tool-Ergebnis oder Resource | Message oder ein oder mehrere Artifacts |\n| Interne Umsetzung | Fähigkeit mit definiertem Schema | Entfernter Agent bleibt intern eigenständig |\n\nDazu kommt eine Weboberfläche als Zugang für Menschen. Diese Zugänge haben unterschiedliche Aufgaben, und ein System kann mehrere davon anbieten, ohne dass daraus mehrere Fachmodelle werden.\n\nEin System kann etwa begrenzte Analyse- und Änderungsaufträge über A2A 1.0 anbieten, während REST, MCP, A2A und Weboberfläche denselben strukturierten Task- und Ergebnisdatensatz lesen.\n\n## Wann A2A sinnvoll ist\n\nA2A lohnt sich, wenn mehrere Bedingungen gleichzeitig zutreffen: Die beteiligten Agenten werden unabhängig entwickelt oder betrieben. Die Arbeit dauert möglicherweise länger als eine einzelne Anfrage, benötigt Rückfragen oder erzeugt mehrere Ergebnisse. Der Auftraggeber legt Ziel und zulässige Wirkung fest, der entfernte Agent verantwortet die interne Ausführung.\n\nFür eine bekannte Folge fester Schritte ist ein deterministischer Workflow meist einfacher. Bloß verschiedene Rollennamen, mehrere Sprachmodelle oder getrennte Prompts rechtfertigen noch keine verteilte Agentenarchitektur – sie erzeugen nur eine zusätzliche Netzgrenze ohne zusätzliche Verantwortungsgrenze.\n\nDie Entscheidung lässt sich daran festmachen, wer die Verantwortung für die offene Aufgabe trägt. Bleibt sie in der eigenen Anwendung und benötigt sie lediglich Werkzeuge oder Kontext, kann MCP genügen. Übernimmt ein unabhängiges System einen begrenzten Auftrag mit eigenem Task-Lifecycle, ist A2A der passende Vertrag. Wer diese Ebenen auseinanderhält, baut keine unnötigen Agenten – und erkennt, welche Merkmale die hier beschriebene Delegation besitzt: eine Agent Card, ein authentifizierter Aufruf, ein nachverfolgbarer Task und ein begrenzter Auftrag.\n\n## Weiterführende Quellen\n\n- [A2A Protocol Specification 1.0](https://a2a-protocol.org/latest/specification/)\n- [A2A: Core Concepts](https://a2a-protocol.org/latest/topics/key-concepts/)\n- [A2A: Life of a Task](https://a2a-protocol.org/latest/topics/life-of-a-task/)\n- [A2A und MCP im Vergleich](https://a2a-protocol.org/latest/topics/a2a-and-mcp/)\n- [Model Context Protocol: Architektur](https://modelcontextprotocol.io/docs/learn/architecture)\n- [MCP, HTTP, Webhooks und A2A einfach erklärt](https://www.mikebild.dev/de/blog/mcp-http-webhooks-a2a-einfach-erklaert/)"},{"slug":"vom-json-zum-privaten-report","title":"Vom JSON zum privaten Report","summary":"Private Reports bleiben nachvollziehbar, wenn Quelldaten, Analyse, Manifest und redaktionelle Aussage getrennte Artefakte bleiben – und Zugriffsregeln gemeinsam mit dem Release für geschützte Seiten, Medien und maschinenlesbare Ausgaben wirksam werden.","tags":["Software","Architektur","Security","Publishing"],"published_at":"2026-07-21T10:00:00+00:00","updated_at":"2026-08-18T15:35:19.820644+00:00","url":"https://www.mikebild.dev/de/blog/vom-json-zum-privaten-report/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/0a51460f1f0239282d7d7020267daa9e409b3e9db7f992f1da615c813a6b5cdd.mp3","audio_duration_secs":799,"text":"Angenommen, jemand zweifelt eine Zahl auf einer privaten Report-Seite an – etwa die Zahl der Workflow-Läufe in der abgeschlossenen Stunde. Existiert diese Zahl nur im gerenderten HTML, liefert die Seite selbst keinen belastbaren Herkunftsnachweis; ein späterer Abruf der Produkt-APIs kann bereits andere Daten liefern. Genau dieser Prüffall bestimmt die Architektur, die dieser Artikel beschreibt: Ein privater Report beginnt hier nicht als Seite. Zuerst entstehen Quell-Snapshots, danach ein gemeinsamer Faktensatz, eine Analyse und ein Manifest; erst wenn diese Artefakte ihre Schemas, Hashes und Zeitgrenzen erfüllen, wird daraus eine Publishing-Revision. Diese Kette ist eine Entwurfsentscheidung der hier beschriebenen Installation, kein Produktvertrag. Sie erlaubt später zu prüfen, ob der für den Report verwendete Faktensatz noch zu dem im Manifest gespeicherten Hash passt; eine Herkunftsreferenz für jeden einzelnen Messwert behauptet sie nicht.\n\nEine fehlerhafte Überschrift erfordert keinen neuen Abruf aller Produkt-APIs. Eine geänderte Analyse kann gegen denselben Faktensatz geprüft werden. Rohdaten und redaktionelle Aussage bleiben getrennt prüfbar, auch wenn das Publishing-System daraus eine gemeinsame Report-Seite rendert.\n\n## Fünf Artefaktarten statt einer großen Datei\n\nGezählt werden fünf Artefaktarten im Bucket: Quell-Snapshots, Faktensatz, Analyse, Manifest und Latest-Pointer. Die Publishing-Revision ist keine sechste Bucket-Artefaktart, sondern ein nachgelagertes Artefakt; veröffentlicht wird sie über einen Release. Sie entsteht aus dem Report-Dokument, das die Pipeline aus den geprüften Artefakten erzeugt. In der hier beschriebenen Pipeline erhält jede abgeschlossene Stunde einen eigenen Pfad im privaten Bucket. Die Quellen bleiben getrennt, damit ein Ausfall oder eine Schemaänderung einem Produkt zugeordnet werden kann. Der Faktensatz normalisiert nur jene Felder, die für die gemeinsame Auswertung benötigt werden. Die Analyse benennt Befunde und Lücken. Das Manifest verbindet alles über Pfade, Größen und Hashes und ermöglicht damit die Prüfung, ob alle von ihm erwarteten Artefakte vorhanden sind und zu den angegebenen Hashes passen. Ein kleiner Latest-Pointer verweist auf den neuesten vollständigen Stand; er enthält nicht den Report und überschreibt keine Historie.\n\n```mermaid\nflowchart LR\n    S[\"Source Snapshots\"] --> F[\"Fact Set\"]\n    F --> A[\"Analysis\"]\n    S --> M[\"Manifest<br/>paths · bytes · hashes\"]\n    F --> M\n    A --> M\n    M --> P[\"Latest Pointer\"]\n    M --> C[\"Publishing-Revision\"]\n```\n\nDiese Aufteilung ähnelt einem kleinen Build. Eingaben, abgeleitete Daten und veröffentlichte Ausgabe haben getrennte Lifecycles. Keine Datei muss gleichzeitig Rohquelle, Analyseprotokoll und UI-Modell sein.\n\n## Was sich seit damals geändert hat\n\nDer ältere Beitrag [Markdown Reports statt Dashboard-Zwang](https://www.mikebild.dev/de/blog/markdown-reports-statt-dashboard/) hat den Report als versioniertes, diffbares Artefakt beschrieben. Damals war die zentrale Abgrenzung: Eine laufend neu berechnete Ansicht beantwortet eine andere Frage als ein festgehaltener Stand. Markdown war das Mittel, um eine Auswertung lesbar, archivierbar und vergleichbar zu machen.\n\nDiese Grundidee ist unverändert. Neu ist, dass der Text weder das einzige Artefakt noch der Beginn der Kette ist. Vor dem Markdown liegen ein maschinenlesbarer Faktensatz, eine getrennte Analyse und ein prüfbares Manifest. Der Report kann dadurch ansprechend dargestellt werden, ohne selbst zur einzigen Wahrheit zu werden. Ein Diagramm ist eine Ansicht auf Zahlen, nicht ihr Ursprungsort.\n\nAuch die Veröffentlichung ist konkreter geworden. Die Pipeline nutzt Revisionen und Releases heute für regelmäßig erzeugte private Reports. Report-Seiten tragen Frage, Schlussfolgerung, Handlung und semantische Messwerte in einem Dokument; das Publishing-System erzeugt daraus die responsive Seite und eine begrenzte immutable Historie. Wie weit diese Historie zurückreicht, ist damit nicht zugesichert; für ältere Stände bleibt der Bucket-Bestand die Referenz, soweit ihn die vereinbarte Retention erhält. Der Weg sieht heute so aus:\n\n```text\nclosed time window\n  -> immutable JSON artifacts\n  -> validated Markdown revision\n  -> authenticated release\n  -> readable report with source trail\n```\n\nNeu ist vor allem eine Prüfebene: Der Faktensatz, aus dem die Seite erzeugt wurde, lässt sich gegen den im Manifest gespeicherten Hash prüfen. Selbst wenn sich Layout, Farben oder Renderer später ändern, bleiben Manifest und Faktensatz innerhalb der vereinbarten Aufbewahrungsfrist erhalten. Der alte Artikel fragte: „Wie sah der Report damals aus?“ Die heutige Kette kann zusätzlich beantworten: „Aus welchen zu diesem Manifest passenden Daten wurde er gebaut?“\n\n## Das Manifest macht eine Stunde prüfbar\n\nEin Dateiname allein beweist nicht, dass ein Objekt vollständig geschrieben wurde. Das Manifest wird in dieser Pipeline zuletzt erzeugt und enthält für jedes vom Manifest referenzierte Stundenartefakt den erwarteten SHA-256-Hash. Ein Leser prüft erst das Manifest und danach die referenzierten Objekte. Der Latest-Pointer wird erst aktualisiert, wenn sämtliche referenzierten Objekte lesbar und gültig sind. Dadurch kann ein abgebrochener Lauf einzelne neue Dateien hinterlassen, ohne den zuletzt bekannten vollständigen Stand zu beschädigen. Weil das Manifest im selben Speicher liegt wie die Artefakte, beweist es weder Herkunft noch einen früheren Zustand; es leistet eine Integritätsprüfung – passen die Objekte vollständig und unverändert zu genau diesem Manifest.\n\nDie Validierung prüft mehr als Datentypen, aber nicht alles davon leistet JSON Schema selbst. Dass das Zeitende nach dem Zeitbeginn liegt, stellt in dieser Pipeline eine zusätzliche Anwendungsvalidierung sicher; Produktnamen aus einer bekannten Menge und Pflichtfelder für Befunde – Schweregrad, Belegreferenzen, ein verständlicher Text – stehen im Schema. Unerwartete zusätzliche Felder können abgelehnt werden, damit ein versehentlich übernommenes internes Objekt nicht still im Bucket landet.\n\nDie Ablage liegt in dieser Pipeline in einem privaten Bucket hinter einem S3-kompatiblen Endpoint. Der Endpoint bietet hier keine S3-Versionierung; die Immutability entsteht deshalb durch zeit- oder inhaltsgebundene Objektnamen und die Anwendung, nicht durch eine Bucket-Funktion. Ein veröffentlichter Stundenpfad wird nicht inhaltlich umgedeutet. Eine Korrektur erhält eine nachvollziehbare neue Revision oder einen neuen Artefaktpfad. Die Storage-Credentials liegen als Credential der Orchestrierung vor – nicht in der Workflowdefinition, nicht im Manifest und nicht im Report-Dokument.\n\n## Das Publishing-System veröffentlicht, speichert aber nicht die Fakten neu\n\nIn dieser Pipeline liest der Report-Workflow Faktensatz und Analyse über einen privaten Connector und erzeugt daraus Markdown. Das Publishing-System erzeugt aus dem Report-Dokument die Seite, hält Revision und Release und regelt den Lesezugriff. Der Bucket bleibt die maschinenlesbare Übergabe; die Publishing-Revision ist die lesbare Fassung.\n\n```md\n---\nkind: page\nlayout: composition\ncomposition:\n  format: report\n  intent: status\n  question: Did the latest closed hour complete, and does an exception require action?\n  thesis: The closed hour is complete and no selected failure counter is above zero.\n  conclusion: No technical intervention is required for this window.\n  action: Review the next closed hour.\nreportCadence: hourly\ntitle: Hourly Report 2026-07-18 14:00 UTC\nlocale: en\nslug: report-2026-07-18-14\n---\n\n::metric{label=\"Workflow runs\" value=\"11\" period=\"closed hour\" tone=\"neutral\"}\n```\n\nDer Markdown-Vertrag ist bewusst kleiner als eine Chart-Bibliothekskonfiguration. Er beschreibt Bedeutung und Erzählabsicht – Frage, These, Schlussfolgerung, Handlung – aber keine Koordinaten. Kennzahlen, Status und Tabellen entstehen aus kontrollierten Direktiven; das Publishing-System löst daraus Komposition, Layout und Ausgabe auf.\n\nDie Trennung hilft auch bei fehlenden Daten. Der Generator kann einen Abschnitt als `unavailable` markieren und erklären, warum. Das Publishing-System entscheidet nicht, ob ein Wert fachlich fehlt; es stellt den geprüften Text und die vorhandenen Zahlen zuverlässig dar.\n\n## Zugriff gilt auf jeder Route, nicht nur auf der HTML-Seite\n\nEine nicht verlinkte URL ist kein Zugriffsschutz. Wer eine Seite lediglich aus der Navigation entfernt, hat ihre Erreichbarkeit nicht verändert – nur ihre Auffindbarkeit über die Navigation. Privat bedeutet mehr: Auch Suche, Feeds, Maschinenausgaben und Medien müssen dieselbe Freigabegrenze beachten.\n\nIn dieser Installation geschieht das über release-gebundene Regeln. Einzelne Dokumente, Pfadbereiche und Medien werden geschützt; geschützte Titel und Inhalte bleiben vor der Anmeldung aus Navigation, Suche, Sitemap, Feeds, `llms.txt` und JSON-LD ausgeschlossen. Weil die Zugriffsregel Teil des Releases ist, wechseln Seite und Sichtbarkeit gemeinsam.\n\n```mermaid\nflowchart TD\n    Q[\"Request\"] --> T{\"Welche Route?\"}\n    T -->|geschütztes Objekt| G{\"Zugriffsregel erfüllt?\"}\n    G -->|nein| L[\"Login oder abgelehnt\"]\n    G -->|ja| O[\"HTML, Markdown oder Medien\"]\n    T -->|öffentliche Discovery| S[\"Antwort ohne geschützte Einträge\"]\n```\n\nGenauso wichtig ist die Trennung der Zugänge. Die Verwaltungsoberfläche des Publishing-Systems ist der menschliche Verwaltungszugang zu Revisionen und Releases – mit einer Browser-Sitzung und den Rechten einer Person. Automatisierte Leser laufen nicht über diesen Weg. Ein auf die Site begrenzter Maschinenzugang mit reinem Leserecht kann denselben privaten, immutable Release lesen, ohne eine Browser-Sitzung oder Publisher-Rechte zu erhalten. Ein Auswertungsagent, der den Stundenreport der Vorwoche zitieren soll, braucht Lesezugriff auf genau diese Site – nicht die Fähigkeit, Releases zu aktivieren oder Regeln zu ändern. Browser-Sitzung und Maschinenschlüssel werden getrennt authentifiziert und berechtigt.\n\nDer Produktionstest dieser Installation folgt dieser Logik – als lokale Testsuite, nicht als Produktzusage. Er beginnt anonym: Die geschützte Startseite muss zum Login führen, direkte Objektwege müssen ohne Anmeldung scheitern, und öffentliche Discovery-Ausgaben dürfen geschützte Einträge nicht enthalten. Danach meldet er sich als echter Leser an und öffnet Startseite und Report. Ein HTTP `200` auf der gerenderten Seite beweist noch keinen vollständigen Schutz – erst die Kombination aus anonymer Ablehnung auf den tatsächlich geprüften Objektrouten, gefilterten öffentlichen Ausgaben und authentifiziertem Erfolg trägt diese Aussage, und auch nur für diese Routen.\n\n## Wie man einen Report liest\n\nEine private Auswertung soll nicht voraussetzen, dass jemand die gesamte Architektur kennt. Deshalb beginnt jeder Report mit einer kurzen Einordnung des Zeitraums und der Datenlage. Vier Anforderungen muss er dafür erfüllen.\n\nErstens muss der Zeitraum eindeutig sein. Bei einem Stundenreport sind das etwa 14:00 bis 15:00 UTC – nicht die seit 15:00 eingetroffenen Events. Der Zeitstempel beschreibt das Datenfenster, nicht den Moment des Seitenbaus.\n\nZweitens muss die Datenlage sichtbar sein. Eine grün gemeldete technische Ausführung bedeutet noch nicht, dass jede Produkt-API verwertbare Daten geliefert hat. Der Bereich Datenqualität nennt vorhandene, fehlende und veraltete Quellen getrennt. `Unavailable` bedeutet, dass für diese Aussage gerade eine belastbare Beobachtung fehlt; daraus darf kein Wert 0 abgeleitet werden.\n\nDrittens muss der Report Messung und Einordnung trennen. Metrikkarten und Tabellen stammen aus dem validierten Faktensatz. Befunde fassen mehrere Fakten zusammen oder weisen auf eine auffällige Veränderung hin – mit Belegreferenzen. So bleibt eine Einordnung nützlich, ohne mit einem gemessenen Wert verwechselt zu werden.\n\nViertens muss erkennbar sein, ob etwas folgen soll. Dieser stündliche Report löst selbst keinen Alarm aus. Ein einzelner Ausschlag darf sichtbar bleiben, ohne sofort eine Handlung auszulösen. Erst wiederholte Lücken, nachvollziehbare Trends oder klar formulierte Befunde werden zu Kandidaten für eine Entscheidung. Das hält die Seite ruhig genug für regelmäßiges Lesen und konkret genug für eine spätere Prüfung.\n\nKurze Hinweise direkt an Tabellen und Diagrammen benennen, was gezeigt wird und wie fehlende Daten zu verstehen sind. Gerade bei wöchentlichen oder jährlichen Berichten zählt das: Wer sie selten öffnet, sollte die Bedeutung einer Farbe nicht erst rekonstruieren müssen.\n\n## Navigation ist Teil der Verständlichkeit\n\nEine Reportseite kann valide sein und trotzdem ins Leere laufen: wenn die Startseite sie nicht auflistet, wenn in einer Berichtsreihe ein Zeitraum fehlt oder wenn gemischte Datumsformate die Sortierung der Karten verfälschen. Deshalb gehört Navigation in dieser Installation zur geprüften Erwartung. Die Startseite führt die Zeiträume mit jeweils dem neuesten privaten Report auf und trennt sie vom Stundenverlauf. Der Test prüft nicht Linktexte, sondern Verhalten: höchstens eine aktuelle Karte pro Berichtsreihe, ein Link auf einen geschützten `200`-Report und eine Sortierung nach Reportdatum.\n\n## Löschen und Aufbewahren bleiben getrennt\n\nStundenartefakte wachsen regelmäßig. Aufbewahrung darf jedoch keinen halb geschriebenen Lauf zum Anlass nehmen, den letzten vollständigen Stand zu löschen. Der Retention-Lauf dieser Installation folgt darum einer einfachen lokalen Policy: Entfernt werden nur Artefakte außerhalb der vereinbarten Frist; der Latest-Pointer und die von ihm referenzierten Manifeste bleiben stehen. Eine belastbare Garantie unter konkurrierenden Läufen ist das nicht – wer parallel schreibt und löscht, braucht einen expliziten Mechanismus zur Referenzprüfung. Für Backups gilt dieselbe Vorsicht: Erst ein geprüfter, als vollständig markierter neuer Satz erlaubt das Entfernen eines älteren. „Die jüngste Datei“ ist kein Ersatz für „der jüngste vollständige Satz“.\n\nSo bleibt der Weg vom JSON zur Seite klein, aber nicht leichtfertig. Der Bucket hält maschinenlesbare, gehashte Stände. Das Publishing-System hält Revisionen und Releases samt Zugriffsregeln. Die Verwaltungsoberfläche bleibt der menschliche Verwaltungszugang, ein reiner Lese-Schlüssel der minimal berechtigte Maschinenweg. Bucket-Artefakte und Release lassen sich dadurch getrennt prüfen: die einen gegen Manifest und Hashes, der andere gegen die release-gebundenen Zugriffsregeln.\n\n## Weiterführende Quellen\n\n- [Markdown Reports statt Dashboard-Zwang](https://www.mikebild.dev/de/blog/markdown-reports-statt-dashboard/)\n- [JSON Schema Specification](https://json-schema.org/specification)\n- [Supabase Storage S3 Compatibility](https://supabase.com/docs/guides/storage/s3/compatibility)"},{"slug":"stuendlich-ist-nicht-echtzeit","title":"Stündlich ist nicht Echtzeit","summary":"Ein abgeschlossener Stundenreport beantwortet andere Fragen als laufende Betriebsbeobachtung – und wird einfacher, sobald Zeitfenster, Verspätung und Messlücken ausdrücklich modelliert sind.","tags":["Software","Architektur","Observability","Delivery"],"published_at":"2026-07-20T10:00:00+00:00","updated_at":"2026-08-18T15:35:19.820644+00:00","url":"https://www.mikebild.dev/de/blog/stuendlich-ist-nicht-echtzeit/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/b45ed288a8d569fdb4c59829523ded08e78925e4a33d135146699a3d8b88a416.mp3","audio_duration_secs":779,"text":"Ein stündlicher Report fragt nicht nach dem aktuellen Zustand, sondern nach der gerade abgeschlossenen Stunde. Was um 14:00:00 UTC passiert, gehört bereits in das nächste Zeitfenster. Der Lauf beginnt deshalb erst, wenn das Zeitfenster geschlossen ist, und veröffentlicht seinen Stand einige Minuten später. Für einen Alarm wäre das viel zu spät. Für eine verlässliche Zusammenfassung ist es genau die gewünschte Verzögerung.\n\nDiese zeitliche Entscheidung trennt den Stundenreport von laufender Betriebsbeobachtung. Ein abgeschlossener Report beantwortet eine andere Frage als Monitoring: Er hält einen begrenzten Zeitraum als prüfbaren Stand der Beobachtungen fest, während Monitoring auf neue Signale reagieren muss. Der Unterschied liegt nicht zuerst in Prometheus, Grafana oder Markdown. Er liegt darin, wie schnell ein neuer Wert eine Reaktion auslösen muss und ob sich die betrachtete Aussage später noch verändern darf.\n\nDer Beitrag [„Markdown Reports statt Dashboard-Zwang“](https://www.mikebild.dev/de/blog/markdown-reports-statt-dashboard/) beschreibt bereits den Unterschied zwischen flüchtiger Ansicht und prüfbarem Stand. Hier geht es enger um die Zeitmechanik eines regelmäßigen Produktreports: geschlossene Zeitfenster, verspätete Daten, Wiederholungen und die Grenze zu echter Betriebsbeobachtung.\n\n## Zwei Zeitachsen, zwei Aufgaben\n\nMonitoring arbeitet möglichst nah am Event. Eine Fehlerrate steigt, ein Alarm wird ausgewertet, jemand öffnet Logs und Traces. Neue Daten verändern die Darstellung fortlaufend. Ein Report wartet dagegen, bis sein Zeitraum beendet ist. Er kann dann feststellen, welche Quellen vollständig vorlagen, seine Fakten hashen und denselben Stand später erneut ausliefern.\n\n```mermaid\nflowchart LR\n    E[\"Ereignis\"] --> M[\"Live-Metrik<br/>Sekunden bis Minuten\"]\n    M --> A[\"Alarm oder Untersuchung\"]\n    E --> W[\"geschlossenes Stundenfenster\"]\n    W --> R[\"unveränderlicher Report\"]\n    R --> V[\"Vergleich und Einordnung\"]\n```\n\nBeide Wege dürfen dasselbe Event sehen und trotzdem zu verschiedenen Ergebnissen kommen. Das Monitoring kann um 13:42 auf fünf Fehler in zwei Minuten reagieren. Der Stundenreport hält später fest, dass im Zeitfenster von 13 bis 14 Uhr fünf Fehler bei 1.200 Ausführungen auftraten, welche Quellen abgedeckt waren und ob die Häufung im Vergleich zu früheren Zeitfenstern auffällt.\n\nEine laufend aktualisierte Oberfläche für die zweite Aufgabe würde unvollständige Zwischenstände zeigen. Während des laufenden Zeitfensters sind Zähler unvollständig. Ein Wert von null um 13:01 sagt fast nichts über die Stunde aus. Ein wachsender Balken lädt dazu ein, Zwischenstände ungekennzeichnet mit abgeschlossenen Stunden zu vergleichen. Ohne Berücksichtigung der unterschiedlichen Abdeckung ist dieser Vergleich nicht belastbar.\n\n## Die Grenzen des Zeitfensters\n\nDie Sammlung verwendet halboffene UTC-Intervalle. Anfang eingeschlossen, Ende ausgeschlossen. Diese Schreibweise verhindert, dass ein Event an der Grenze in zwei Reports landet.\n\n```ts\ntype ClosedWindow = {\n  from: string; // inclusive RFC 3339 timestamp\n  to: string;   // exclusive RFC 3339 timestamp\n  bucket: \"hour\";\n};\n\nconst window: ClosedWindow = {\n  from: \"2026-07-18T13:00:00.000Z\",\n  to: \"2026-07-18T14:00:00.000Z\",\n  bucket: \"hour\"\n};\n```\n\nUTC vermeidet einen unangenehmen Sonderfall: In Europa hat der Tag der Sommerzeitumstellung nicht immer 24 lokale Stunden. Speicherung und Sammlung bleiben deshalb in UTC. Die Reportseite kann ein Datum später für ihre Leser lokalisieren, ohne die fachliche Grenze zu verändern.\n\nZwischen dem Ende des Zeitfensters und der Frischeprüfung sieht dieser Ablauf eine ausdrücklich festgelegte Reserve vor. Wird die Sammlung umfangreicher, müssen Startzeitpunkt, Timeout und Prüfgrenze gemeinsam angepasst werden. Eine heimlich verlängerte Reserve würde echte Verzögerungen nur später sichtbar machen.\n\n## Verspätete Daten und fehlende Läufe getrennt halten\n\nEin HTTP-Aufruf kann vorübergehend fehlschlagen. Ein Workflow kann nach dem Schreiben des Artefakts abbrechen, bevor er seinen Status aktualisiert. Ein Release kann erfolgreich sein, obwohl die Antwort beim Client nicht mehr ankommt. Darum darf ein Retry nicht einfach „noch einmal alles“ bedeuten.\n\nJeder Lauf hat ein eindeutiges Zeitfenster. Cadence-markierte Reports werden hier als immutable Veröffentlichungen behandelt: Ein einmal veröffentlichter Stundenstand ändert sich nicht mehr. Ein wiederholter erfolgreicher Versuch darf deshalb keinen zweiten, abweichenden Stundenreport für dasselbe Zeitfenster sichtbar machen.\n\nErst nach der vorgesehenen Reserve wird zwischen verspätet und fehlend unterschieden. Ein verspäteter Lauf kann noch erfolgreich abschließen und denselben Report veröffentlichen. Ein fehlender Lauf hinterlässt eine Lücke in der erwarteten Stundenfolge. Verspätung, Messlücke und geschlossenes Zeitfenster bleiben damit drei getrennte Sachverhalte, die der Report getrennt benennen sollte.\n\n## Was aus einer fehlenden Beobachtung folgt\n\nEin kritischer Fall für einen Stundenreport ist eine Stunde ohne Daten. Ein Beobachtungsdienst sollte dafür ausdrücklich vier Zustände unterscheiden: Eine Quelle wurde noch nie beobachtet, sie wurde geprüft und der erwartete Inhalt ist nicht vorhanden, sie ist zugangsgeschützt, oder sie ist tatsächlich fehlerhaft. Eine fehlende Beobachtung darf dabei weder als gesunder Zustand noch als Zahlenwert null ausgegeben werden.\n\nFür einen Stundenreport heißt das: Liegt für eine Stunde keine Messung vor, existiert für diese Stunde kein Wert. Der Report darf dann nicht behaupten, es habe null Events gegeben, denn diese Aussage würde eine Messung unterstellen, die nie stattgefunden hat. Er muss stattdessen ausweisen, dass die Quelle im betrachteten Zeitraum nicht beobachtet wurde und die Aussage offen bleibt. Wurde die Quelle dagegen geprüft und der erwartete Inhalt nicht gefunden, ist dieser Zustand beobachtet; daraus folgt aber nicht automatisch ein allgemeiner Fehlerzähler von null.\n\n## Wann Prometheus und Grafana fehlen würden\n\nWerkzeuge wie Prometheus und Grafana unterstützen unter anderem die laufende Untersuchung: Eine Query lässt sich während der Analyse anpassen und der Zeitraum verschieben. Diese Arbeitsweise ist wertvoll, wenn die Frage nicht vorab feststand.\n\nEine Oberfläche für Stundenreports kann dagegen auf abgeschlossene Reportzeiträume ausgerichtet sein. Diese Ausrichtung passt, solange die Oberfläche abgeschlossene Zeiträume erklären soll. Für eine Incident-Analyse kämen Warnungen dagegen zu spät, und die stündliche Auflösung reicht für eine Ursachensuche bis auf einzelne Requests nicht aus. Ein stündlicher Report ersetzt weder Alarmierung noch laufende Betriebsbeobachtung; er setzt beides als eigenständige Aufgaben voraus.\n\n## Vier Fragen vor der Werkzeugwahl\n\n„Dashboard oder Report?“ ist zu grob. Vier Fragen trennen die Anforderungen: Reaktionszeit, Veränderlichkeit, Untersuchung und Auflösung.\n\nDie erste betrifft die Reaktionszeit. Muss jemand innerhalb von Sekunden oder wenigen Minuten handeln, gehört die Beobachtung in ein Monitoring- und Alerting-System. Darf ein Team den Stand im nächsten regelmäßigen Arbeitszyklus prüfen, kann ein stündlicher oder täglicher Report reichen.\n\nDie zweite betrifft die Veränderlichkeit. Soll dieselbe URL morgen neue Werte zeigen, ist eine Live-Query passend. Muss später noch genau der Stand vom 18. Juli lesbar sein, braucht es ein immutable Artefakt oder einen ausdrücklich gespeicherten Snapshot.\n\nDie dritte betrifft die Untersuchung. Sind die Fragen vorab bekannt – Anzahl Releases, Fehler, Quellenabdeckung –, kann ein begrenztes Schema sie präzise abbilden. Entstehen die Fragen erst beim Blick auf eine Auffälligkeit, werden Query Language, variable Zeiträume und Drill-down wichtig.\n\nDie vierte betrifft die Auflösung. Ein stündlicher Zähler kann zeigen, dass etwas häufiger fehlschlug. Er kann nicht erklären, welcher einzelne Request langsam war oder welche Logzeile zu einem Trace gehört. Für diese Untersuchung müssen die hochaufgelösten Daten in ihrem Betriebswerkzeug bleiben.\n\n```yaml\ndecision:\n  reaction_time: next_work_cycle\n  immutable_history: required\n  questions_known_in_advance: true\n  per_request_drilldown: false\nresult: scheduled_report\n```\n\nSobald eine Antwort kippt, kann auch das passende Werkzeug wechseln. Ein Report und ein Live-System dürfen denselben Dienst beobachten, solange sie nicht vortäuschen, dieselbe Aufgabe zu erfüllen.\n\n## Der Scheduler ist Teil der Aussage\n\nEin manuell erzeugter Report belegt nur, dass irgendwann jemand einen Knopf gedrückt hat. Ein geplanter Lauf macht zusätzlich sichtbar, ob die erwartete Regelmäßigkeit eingehalten wurde. Eine Prüfung dieser Regelmäßigkeit muss deshalb zwischen geplanten und manuell ausgelösten Läufen unterscheiden: Manuelle Läufe dürfen eine ausgefallene geplante Stunde nicht verdecken, sonst zählt die Prüfung Erfolge, die nichts über die Zusage der Regelmäßigkeit aussagen.\n\nAuch eine Pause ist kein Fehler, wenn sie während eines Releases ausdrücklich gesetzt wurde. Sie muss jedoch sichtbar sein und nach dem Rollout bewusst aufgehoben werden. Ein vergessener Pause-Schalter ist kein Datenproblem, sondern eine unterbrochene Zusage über Regelmäßigkeit.\n\n## Was ein Stundenreport erklären muss\n\nOhne ausgewiesene Quellenabdeckung kann ein Operator eine Messlücke als Ergebnis lesen. Ein Stundenreport muss deshalb selbst erklären, worauf er sich stützt. Überblick, Frage, Schlussfolgerung und nächste Handlung lassen sich aus demselben semantischen Report-Dokument ableiten; ein zweiter, parallel gepflegter Dashboard-Datensatz ist dafür nicht nötig. Eine passende Oberfläche zeigt diese Antwort in abnehmender Wichtigkeit: zuerst Zustand und Ausnahme, dann wenige Signale, danach Evidenz und Provenienz.\n\nEine Seite mit dreißig Metrics ohne Frage ist schwerer zu prüfen als eine Seite mit einer klaren Frage, einem belegten Stand und einer benannten nächsten Entscheidung. Die Faktendefinition muss dafür auch Datenlücken abbilden: Sie sollte vorhandene, fehlende und veraltete Quellen getrennt nennen, damit eine offene Aussage nicht wie eine beantwortete aussieht.\n\n```md\n## Data quality\n\nThe source window is complete for four product APIs.\nExternal acquisition data is not connected yet and is shown as unavailable.\nNo value is inferred from missing input.\n```\n\nAuch die Aggregation braucht eine Erklärung. Eine wöchentliche Median-Laufzeit beantwortet eine andere Frage als das Maximum der langsamsten Stunde. Der Report nennt Zeitraum, erwartete und vorhandene Unterfenster sowie die verwendete Verdichtung. Sonst sieht eine präzise Zahl geprüfter aus, als sie ist.\n\n## Verdichtung über größere Zeiträume\n\nTages-, Wochen-, Monats- und Jahresreports entstehen aus bereits abgeschlossenen Artefakten. Ein Tagesreport kann auf den immutable Stundenständen aufsetzen, statt dieselben Quellen für jede Stunde erneut abzufragen, und dabei ausweisen, welche Stundenfenster ihm fehlen. Cadence-markierte Reports bleiben immutable Veröffentlichungen; ein Rollup ersetzt keinen Stundenstand, er verdichtet ihn.\n\n```mermaid\nflowchart TD\n    H[\"24 Stundenartefakte\"] --> D[\"Tagesreport\"]\n    D --> W[\"Wochenreport\"]\n    W --> M[\"Monatsreport\"]\n    M --> Y[\"Jahresreport\"]\n    H --> Q[\"Abdeckung und fehlende Fenster\"]\n    Q --> D\n```\n\nEin Tagesreport, dem einzelne Stundenfenster fehlen, kann trotzdem hilfreicher sein als gar kein Report – aber nur, wenn er sich klar als unvollständig ausweist. Er darf nicht dieselbe visuelle Sicherheit ausstrahlen wie ein vollständig abgedeckter Zeitraum. Die Datenqualität gehört neben die Kennzahlen, nicht in eine Fußnote.\n\nEine Oberfläche kann den neuesten abgeschlossenen Zeitraum vor weiteren Entscheidungshorizonten und einer begrenzten Historie anordnen. Welche Frage jeder gröbere Report beantwortet, ist eine Gestaltungsentscheidung. Eine naheliegende Aufteilung: Die Stunde prüft den Datenfluss und frische technische Signale. Der Tag beantwortet, was heute Aufmerksamkeit braucht. Die Woche vergleicht Tagesverläufe und wiederkehrende Ausnahmen. Der Monat ordnet Aktivitätsmuster, Betriebsrisiken und Messlücken für die nächste Planung ein. Das Jahr verdichtet nur jene Evidenz, die eine strategische Entscheidung tragen kann. Nur den Datumsfilter zu verbreitern wäre formal korrekt und inhaltlich schwach.\n\nAuch Zahlen sind dabei nicht bloß Werte. Eine Faktendefinition sollte Zähler, Bestände, Laufzeiten, Verhältnisse und Datenmengen unterscheiden, die Einheit nennen und festlegen, ob ein Rollup Summe, letzten Wert oder Mittelwert bevorzugt. Eine Maschine muss dann nicht aus einem Metriknamen raten, wie der Wert über mehrere Intervalle gelesen werden soll.\n\n## Einfache Technik braucht eine klare Grenze\n\nDer stündliche Ansatz kann ohne zusätzliche, dauerhaft laufende Auswertungsplattform auskommen, wenn vorhandene Systeme Sammlung, Planung und Veröffentlichung übernehmen. Seine Fragen stehen vor dem Lauf fest, und sein Ergebnis erscheint als immutable Veröffentlichung. Entscheidend ist die Verantwortungsgrenze: Der Report sammelt, schließt ab und veröffentlicht; die laufende Beobachtung bleibt bei den dafür gebauten Systemen.\n\nDiese Einfachheit bleibt nur erhalten, wenn das System zu seiner Aufgabe steht. Ein Report darf eine fehlende Datenquelle zeigen, statt eine neue Datenerfassung heimlich einzubauen. Er darf auf eine externe Incident-Ansicht verweisen, statt selbst zur Leitstelle zu werden. Und er darf eine Frage unbeantwortet lassen, wenn dafür weder Fakt noch Connector vorhanden ist.\n\nStündlich bedeutet hier nicht „fast live“. Es bedeutet: regelmäßig, abgeschlossen, wiederholbar und langsam genug, um einen vollständigen Stand zu prüfen. Wer schneller reagieren muss, braucht Monitoring und Alarmierung. Wer später nachvollziehen will, was in einem klaren Zeitraum bekannt war, braucht einen Stundenreport. Beide bestehen nebeneinander, mit klar zugeordneten Aufgaben: Monitoring liefert laufende Signale, Alarmierung stößt daraus eine Reaktion an, und der Stundenreport bewahrt den abgeschlossenen, prüfbaren Beobachtungsstand.\n\n## Weiterführende Quellen\n\n- [Prometheus Overview](https://prometheus.io/docs/introduction/overview/)\n- [Grafana Dashboards](https://grafana.com/docs/grafana/latest/visualizations/dashboards/)\n- [W3C Trace Context](https://www.w3.org/TR/trace-context/)"},{"slug":"claims-klein-belegt-pruefbar","title":"Claims: klein, belegt, prüfbar","summary":"Claims zerlegen Wiki-Seiten in einzelne Aussagen mit Subjekt, Prädikat, Objekt, Confidence, Status und wörtlichem Beleg. Dadurch werden Zustimmung und Widerspruch präzise.","tags":["Software","KI","Methodik"],"published_at":"2026-07-20T10:00:00+00:00","updated_at":"2026-08-18T06:39:59.272448+00:00","url":"https://www.mikebild.dev/de/blog/claims-klein-belegt-pruefbar/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/1f809af816f67e9351b254759bb71f152c836d42d3111514399aff3db6f09450.mp3","audio_duration_secs":736,"text":"„Unser Publishing ist sicher, schnell und zuverlässig.“ Der Satz klingt gut und taugt kaum als Claim. Drei Bewertungen stecken darin, keine besitzt ein Kriterium, und eine Quelle könnte höchstens Teile davon belegen. Eine prüfende Person müsste den ganzen Satz annehmen oder ablehnen, obwohl sie vielleicht nur einen belegten Rollback-Mechanismus als Teil der Zuverlässigkeit bestätigen kann.\n\nEine Wissensbasis sollte deshalb kleine Aussagen als `subject / predicate / object` speichern. Der Seitentext bleibt für Menschen lesbar. Claims bilden darunter jene Einheiten, die einzeln zitiert, geprüft, bestritten und stillgelegt werden können. Confidence und Status beantworten dabei verschiedene Fragen.\n\n## Ein Claim sagt genau eine Sache\n\nDas Subjekt benennt den Gegenstand, das Prädikat die Beziehung oder Eigenschaft und das Objekt den konkreten Wert. Ein guter Claim lässt sich ohne den restlichen Seitenabsatz verstehen.\n\n```json\n{\n  \"subject\": \"content-release\",\n  \"predicate\": \"rollback_mechanism\",\n  \"object\": \"switch the active pointer to an immutable revision\"\n}\n```\n\nDie Form ähnelt einem einfachen RDF-Triple, ist aber kein Zwang, die gesamte Fachlichkeit als universellen Graphen zu modellieren. Innerhalb eines Space entsteht ein kontrolliertes Vokabular, das zu dessen Fragen passt. `rollback_mechanism` kann dort sinnvoll sein, wo Releases verglichen werden; in einem HR-Space wäre es ohne Bedeutung.\n\nZu große Claims verbinden mehrere prüfbare Aussagen. Zu kleine Claims verlieren ihren Sinn. `content-release / uses / pointer` wäre zwar atomar, erklärt aber nicht, wofür der Pointer verwendet wird. Die Formulierung sollte eng genug für einen Widerspruch und vollständig genug für einen Leser sein.\n\n```mermaid\nflowchart TD\n    A[\"Aussage aus Quelle\"] --> Q{\"eine fachliche<br/>Behauptung?\"}\n    Q -->|mehrere| S[\"in mehrere Claims teilen\"]\n    Q -->|eine| C{\"ohne Absatz<br/>verständlich?\"}\n    C -->|nein| E[\"Objekt präzisieren\"]\n    C -->|ja| V[\"Zitat und Status ergänzen\"]\n```\n\n## Das Zitat trägt die Aussage\n\nJeder Claim verweist über Citations auf eine oder mehrere Quellen. Die Citation enthält einen wörtlichen Ausschnitt und optional einen Locator. Eine Zitatprüfung kann sicherstellen, dass das Zitat wörtlich im archivierten Quelltext vorkommt. Ein Claim, dessen Beleg nur paraphrasiert oder erfunden ist, sollte verworfen werden, bevor er den Review erreicht.\n\n```json\n{\n  \"subject\": \"content-release\",\n  \"predicate\": \"rollback_mechanism\",\n  \"object\": \"switch the active pointer to an immutable revision\",\n  \"confidence\": 0.94,\n  \"citations\": [\n    {\n      \"source_id\": \"source_release_docs\",\n      \"quote\": \"Rollback changes the active release pointer to a previous immutable revision.\",\n      \"locator\": \"Rollback\"\n    }\n  ]\n}\n```\n\nEin Zitat muss die Aussage in ihrem Geltungsbereich stützen. Wenn die Quelle nur von einer Preview-Umgebung spricht, darf der Claim daraus keine allgemeine Produktionsregel machen. Diese Prüfung kann nicht allein über Zeichenketten erfolgen. Die prüfende Person liest den Kontext.\n\nMehrere Citations sind nützlich, wenn eine Aussage aus Vertrag und beobachtetem Verhalten gemeinsam gestützt wird. Die API-Dokumentation kann den vorgesehenen Mechanismus beschreiben, ein Produktionsreport seine tatsächliche Nutzung belegen. Viele schwache Quellen machen einen Claim jedoch nicht automatisch stärker.\n\n## Confidence ist keine Wahrheitsskala\n\n`confidence` beschreibt, wie sicher die erzeugende Synthese bei ihrer Zuordnung war. Sie kann der prüfenden Person helfen, unsichere Vorschläge zuerst anzusehen. Sie ist kein Ersatz für Status und keine Wahrscheinlichkeit, dass eine Aussage in der Welt wahr ist.\n\n```ts\ntype ClaimStatus = \"proposed\" | \"verified\" | \"disputed\" | \"deprecated\";\n\ntype Claim = {\n  subject: string;\n  predicate: string;\n  object: string;\n  confidence: number;\n  status: ClaimStatus;\n};\n```\n\nEin Claim mit `confidence: 0.99` bleibt vor der Freigabe `proposed`. Ein freigegebener Claim mit `confidence: 0.78` kann `verified` sein, weil ein Mensch Quelle und Formulierung geprüft hat. Später kann ein neuer Beleg ihn `disputed` machen, ohne den alten Confidence-Wert umzudeuten.\n\nDiese Trennung verhindert eine bequeme Abkürzung. Eine Grenze wie „ab 0.9 automatisch freigeben“ würde Modellselbstsicherheit mit fachlicher Verbindlichkeit verwechseln. Sinnvoller ist es, aus jeder Wissensänderung einen Änderungsvorschlag zu machen, unabhängig vom Wert in `confidence`. Er wird erst nach menschlicher Freigabe übernommen.\n\n## Status bildet den Lifecycle ab\n\n`proposed` ist Staging und für normale Leser unsichtbar. `verified` bezeichnet eine freigegebene Aussage. `disputed` macht einen nachvollziehbaren Konflikt sichtbar. `deprecated` hält eine überholte Aussage in der Geschichte, ohne sie als aktuellen Stand zu behandeln.\n\nDer Lifecycle erlaubt präzisere Antworten als Löschen. Wenn ein Dienst bis Version 2 einen statischen Retry-Wert verwendete und seit Version 3 ein Budget teilt, sind beide Aussagen historisch relevant. Die alte wird nicht aus allen Revisionen entfernt. Sie erhält einen Status und bleibt mit ihrer Quelle verbunden.\n\n```mermaid\nstateDiagram-v2\n    [*] --> proposed\n    proposed --> verified: human approval\n    proposed --> [*]: rejection\n    verified --> disputed: conflicting evidence\n    disputed --> verified: conflict resolved\n    verified --> deprecated: superseded\n    disputed --> deprecated: superseded\n```\n\nDer Übergang von `proposed` zu `verified` geschieht nicht automatisch, sondern erst nach einer menschlichen Entscheidung. Der sichtbare Seitentext kann den aktuellen Stand anschließend flüssig erklären. Die Claims darunter bewahren, welche Einzelaussagen diese Erklärung tragen und welche Konflikte noch offen sind.\n\n## Funktionale Prädikate machen Konflikte exakt\n\nManche Beziehungen dürfen gleichzeitig mehrere Objekte haben. Ein System kann mehrere Exportformate unterstützen. `supports_format=markdown` widerspricht `supports_format=okf` nicht. Andere Beziehungen sollen für ein Subjekt nur einen aktuellen Wert besitzen, etwa `primary_database`.\n\nEin Space kann solche funktionalen Prädikate deklarieren. Treffen dort zwei unterschiedliche Objekte für dasselbe Subjekt und Prädikat aufeinander, kann die Wissensbasis beide Claims als `disputed` markieren und über `contradicts` verbinden.\n\n```json\n{\n  \"settings\": {\n    \"functional_predicates\": [\n      \"primary_database\",\n      \"current_owner\",\n      \"default_retention_days\"\n    ]\n  }\n}\n```\n\nDiese Regel bleibt deterministisch. Ein Modell muss nicht entscheiden, ob zwei frei formulierte Absätze semantisch kollidieren. Das kontrollierte Prädikat benennt vorher, wo genau ein einzelner aktueller Wert erwartet wird.\n\nZu viele funktionale Prädikate wären schädlich. `supports_format` als funktional zu markieren würde ergänzende Fakten fälschlich in Konflikt setzen. Das Vokabular ist deshalb eine fachliche Entscheidung des Space und gehört ins Review.\n\n## Einen Absatz in Claims zerlegen\n\nEin praktisches Beispiel zeigt die Größenentscheidung. Angenommen, eine Quelle sagt: „The service runs as a single binary, migrates PostgreSQL on startup, and exposes health and readiness endpoints.“ Daraus sollte kein Claim mit dem Objekt `single binary with migrations and probes` entstehen. Drei Eigenschaften ändern sich unabhängig voneinander.\n\n```json\n[\n  {\n    \"subject\": \"service-runtime\",\n    \"predicate\": \"distribution\",\n    \"object\": \"single self-contained binary\"\n  },\n  {\n    \"subject\": \"service-runtime\",\n    \"predicate\": \"database_migration\",\n    \"object\": \"runs automatically on startup\"\n  },\n  {\n    \"subject\": \"service-runtime\",\n    \"predicate\": \"exposes_probe\",\n    \"object\": \"/health and /ready\"\n  }\n]\n```\n\nWenn später ein Sidecar hinzukommt, betrifft das den Distribution-Claim. Die Migration und die Probes können unverändert bleiben. Eine prüfende Person sieht genau den kleinen Diff. Ein Agent kann weiterhin die beiden bestätigten Aussagen verwenden, während die erste geprüft wird.\n\nDie Gegenprobe hilft: Würde eine Änderung des Objekts automatisch die anderen Teile der Aussage ändern? Falls nein, sind mehrere Claims angemessen. Würde ein Claim ohne Nachbarsatz missverständlich, braucht er ein genaueres Subjekt oder Objekt.\n\nAuch Zeit und Geltungsbereich gehören in die Formulierung. `uses PostgreSQL` passt zu einer aktuellen Architektur, wenn die Quelle den gesamten Dienst beschreibt. Bezieht sie sich nur auf die Produktionsbereitstellung, sollte das Subjekt `service-production-deployment` lauten. Präzision entsteht oft besser über ein engeres Subjekt als über einen langen Objekttext.\n\nEin Claim ist kein vollständiger Satz für die Blogprosa. Er darf maschinenlesbar und knapp bleiben. Die Konzept-Seite erklärt anschließend, wie die drei Eigenschaften zusammenwirken.\n\n## Ein Konzept ist mehr als seine Claims\n\nClaims allein ergeben keine gut lesbare Erklärung. Ein Konzept besitzt weiterhin Titel, Zusammenfassung, Markdown und Beziehungen zu anderen Konzepten. Die Seite kann ein Beispiel, Einschränkungen und den historischen Zusammenhang erklären.\n\nDie Claims erfüllen eine andere Aufgabe. Sie erlauben:\n\n- eine einzelne Aussage bis zum Originalzitat zurückzuverfolgen;\n- einen Widerspruch ohne Ablehnung der ganzen Seite zu markieren;\n- bei einer neuen Revision nur die tatsächlich veränderten Behauptungen zu prüfen;\n- Suchtreffer und Agentenantworten auf sichtbare, freigegebene Einheiten zu begrenzen.\n\nSeitentext und strukturierte Ebene sollten gemeinsam revisioniert werden. Ein Vorschlag darf nicht bereits neuen Text zeigen, während seine Claims für Leser unsichtbar oder unvollständig sind. Die atomare Freigabe macht die neue Revision als Einheit aktuell.\n\n## Beziehungen verbinden, Claims belegen\n\nNeben Claims kann eine Wissensbasis gerichtete Beziehungen zwischen Konzepten kennen: `related`, `part_of`, `depends_on`, `contradicts` und `supersedes`. Eine Relation beantwortet, wie zwei Seiten zusammenhängen. Sie ersetzt keinen Claim und keine Citation.\n\n```json\n{\n  \"from_slug\": \"content-release\",\n  \"kind\": \"depends_on\",\n  \"to_slug\": \"immutable-revision\"\n}\n```\n\nDie Relation macht Navigation und Kontext möglich. Der belegte Sachverhalt bleibt als Claim auf einer der Seiten. Sonst würde ein bloßer Link eine fachliche Aussage tragen, ohne ihren Wortlaut oder ihre Quelle zu nennen.\n\n`contradicts` kann als Folge der funktionalen Prädikatlogik entstehen. `supersedes` eignet sich für eine nachvollziehbare Ablösung, bei der der neue Begriff oder die neue Regel den alten Stand ersetzt. Beziehungen selbst werden mit der Konzept-Revision geprüft und veröffentlicht.\n\n## Revisionen beantworten die Zeitfrage\n\nEin aktueller Claim erklärt nicht allein, wann er eingeführt wurde. Eine Konzept-History sollte freigegebene, abgelehnte und vorgeschlagene Revisionen mit ihrer Herkunft führen: Welches Modell und welche Prompt-Version haben den Vorschlag erzeugt, welche Quellen wurden verwendet und welche Person hat freigegeben.\n\nDiese Angaben machen ein Modell nicht zum Autor im rechtlichen oder fachlichen Sinn. Sie dokumentieren, welcher technische Syntheseweg den Vorschlag erzeugte. Prüfende Person, Notiz und Zeitstempel erklären die menschliche Entscheidung. Freigabe, Status und Revision zusammen machen sichtbar, wie eine Aussage in den gültigen Wissensstand gelangt ist – und wie sie ihn wieder verlassen kann.\n\nDie History erlaubt eine zeitbezogene Frage: Welche Aussage war im März sichtbar, bevor die Retention-Regel geändert wurde? Ein Wissensdienst, der immer nur den aktuellen Absatz zurückgibt, kann sie nicht zuverlässig beantworten. Revisionen erhalten den damaligen Stand, ohne alte Claims als heute gültig auszugeben.\n\nFür Teams wird diese Spur besonders nach Modell- oder Promptwechseln nützlich. Wenn eine neue Syntheseversion auffällig andere Claims vorschlägt, lassen sich betroffene Revisionen identifizieren und gezielt prüfen. Damit lässt sich neben der Quelle auch nachvollziehen, welches Modell und welche Prompt-Version den Vorschlag erzeugt haben.\n\n## Ein Schritt über „Sauberes Grounding II“ hinaus\n\n[Sauberes Grounding II](https://www.mikebild.dev/de/blog/sauberes-grounding-antworten-erden/) sortierte Grounding in Konventionen, Dokumente und API-Beschreibungen. Die zentrale Frage lautete, welches Material für eine konkrete Aufgabe in den knappen Kontext gehört und wie teuer seine Wiederbeschaffung wäre. Das war ein Arbeitsmodell für einzelne Sessions.\n\nHinzu kommt eine kleinere, dauerhafte Einheit zwischen Dokument und Kontext: der Claim. Ein Agent muss nicht jedes Mal eine ganze Seite als gleichmäßig verbindlich behandeln. Er kann die aktuelle Konzept-Revision lesen, sichtbare Claims samt Status prüfen und bei Bedarf ihre Citations öffnen. Aus „passendes Dokument in den Kontext“ wird „passende, freigegebene Aussage mit Rückweg zur Quelle“.\n\nDer ältere Artikel [Fuzzy Matching mit expliziten Grenzen](https://www.mikebild.dev/de/blog/fuzzy-matching-mit-grenzen/) trennte eine Behauptung mit Confidence von einem Fakt. Dieselbe Vorsicht gilt hier. Confidence hilft beim Sortieren eines Vorschlags. Erst Beleg, Review und Status bestimmen, wie die Aussage in der Wissensbasis erscheint.\n\n## Claims im Review prüfen\n\nBei einem neuen Vorschlag genügen vier Fragen. Behauptet der Claim genau eine Sache? Ist er ohne den umgebenden Absatz verständlich? Trägt das Zitat genau diesen Geltungsbereich? Würde ein abweichender neuer Wert als klarer Widerspruch erkennbar?\n\nWenn eine Antwort nein lautet, wird der Claim geteilt, enger formuliert oder abgelehnt. Das ist keine formale Wortübung. Kleine Claims senken später den Aufwand. Eine geänderte Retention-Regel erfordert dann keine komplette Neufassung der Seite über Storage. Nur die betroffene Aussage und ihre Erklärung werden angepasst.\n\nStrukturiert werden nur Behauptungen, die für Suche, Review, Konflikte oder Herkunft relevant sind. Es geht nicht darum, jeden Satz als universelle Ontologie abzubilden. Alles andere bleibt Prosa; dafür entsteht kein Claim.\n\n## Weiterführende Quellen\n\n- [W3C RDF 1.1 Concepts](https://www.w3.org/TR/rdf11-concepts/)\n- [W3C PROV-O](https://www.w3.org/TR/prov-o/)\n- [Sauberes Grounding II](https://www.mikebild.dev/de/blog/sauberes-grounding-antworten-erden/)\n- [Fuzzy Matching mit expliziten Grenzen](https://www.mikebild.dev/de/blog/fuzzy-matching-mit-grenzen/)"},{"slug":"produkte-berichten-ueber-ihre-api","title":"Produkte berichten über ihre API","summary":"Produktstatistiken bleiben verständlich, wenn jedes System seine Daten selbst verdichtet und über einen kleinen, zeitlich einheitlichen HTTP-Vertrag anbietet.","tags":["Software","Architektur","API","Delivery"],"published_at":"2026-07-19T10:00:00+00:00","updated_at":"2026-08-18T06:39:59.272448+00:00","url":"https://www.mikebild.dev/de/blog/produkte-berichten-ueber-ihre-api/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/6658d74f36059d7606e0037540fafdcbdf20f4f8c14502c97c9180ba38e9086c.mp3","audio_duration_secs":871,"text":"Eine Statistik-API ist eine Produktfunktion. Dieser Satz klingt selbstverständlich, wurde in der Arbeit an einem produktübergreifenden Reporting aber erst nach zwei schlechteren Entwürfen klar. Zunächst sollte ein zentraler Sammler direkt auf mehrere PostgreSQL-Datenbanken zugreifen. Danach standen besondere private Reporting-Endpoints und eigene Schlüssel im Raum. Beides hätte den Report tiefer in die Produkte eingebaut, als es für seinen Zweck nötig ist.\n\nÜbrig blieb ein kleinerer Vertrag. Ein Publishing-System, eine Wissensbasis und ein Orchestrator verdichten ihre eigenen Daten selbst und bieten ihre Sicht über normale, authentifizierte HTTP-Endpoints an. Ein nachgelagerter Ablauf führt die Aggregate für ausdrücklich begrenzte Zeitfenster zusammen – er sieht weder Tabellen noch Markdown, Namen, Schlüssel oder interne IDs.\n\nDiese Aufteilung ist kein spezieller Kniff für Dashboards. Jeder autorisierte Verbraucher kann dieselben Statistikressourcen verwenden: ein Kapazitätsreport, eine Qualitätsprüfung, eine lokale Auswertung oder ein späteres Exportwerkzeug.\n\n## Datenbesitz ist mehr als Speicherort\n\nWer eine Tabelle besitzt, kennt ihre Bedeutung. Ein Publishing-System weiß, wann eine Revision erstellt und ein Release tatsächlich aktiviert wurde. Eine Wissensbasis unterscheidet einen gestarteten Ingest von einem geprüften Wissensstand. Ein Orchestrator verdichtet seine Ausführungsdaten dort, wo die Läufe tatsächlich stattfinden. Ein zentraler Sammler müsste diese Bedeutungen nachbauen und bei jeder Produktänderung mitziehen.\n\nDirektes SQL macht diesen Nachbau besonders leicht – und damit gefährlich. Eine Query funktioniert, obwohl sie vielleicht einen Zwischenstatus mitzählt, den das Produkt selbst niemals als abgeschlossen anzeigen würde. Eine neue Migration kann den Report brechen, ohne dass sich die öffentliche Produkt-API geändert hat. Der Datenbankzugriff umgeht außerdem die normale Rechteprüfung und zieht Credentials mehrerer Systeme an eine Stelle.\n\nMit einer Produkt-API liegt die fachliche Verdichtung dort, wo auch die Zustandsänderung implementiert ist:\n\n:::process{title=\"Fachliche Verdichtung bleibt im Produkt\" role=\"primary\" preferredPattern=\"connected-process\"}\n1. Produktaktion · erzeugt einen fachlich definierten Zustandsübergang\n2. Eigener Datenspeicher · bewahrt den fachlich maßgeblichen Zustand des Produkts\n3. Aggregation · übersetzt interne Zustände in begrenzte fachliche Kennzahlen\n4. Statistik-API · liefert authentifizierte Werte mit Zeitraum und Bedeutung\n5. Collector · führt kompatible Antworten zusammen, ohne sie neu zu definieren\n6. Report · erklärt den eingefrorenen gemeinsamen Stand\n:::\n\nDer Sammler darf die Antworten zusammenführen, aber nicht neu deuten, was ein erfolgreicher Release oder ein verifizierter Claim ist. Diese Begriffe gehören dem Produkt.\n\n## Einheitlich ist die Zeit, nicht die Fachlichkeit\n\nEin gemeinsamer Vertrag bedeutet nicht, dass alle Produkte dieselben Kennzahlen liefern. Das wäre künstliche Gleichmacherei. Gemeinsam sind nur die Eigenschaften, die ein Collector für zuverlässige Zeitfenster benötigt:\n\n- `bucket` bestimmt die Auflösung: Stunde, Tag, Monat oder Jahr.\n- `from` ist der eingeschlossene Anfang des Fensters.\n- `to` ist das ausgeschlossene Ende.\n- `tz` macht die Zeitzone ausdrücklich, hier durchgehend UTC.\n- `buckets` enthält eine dichte Reihe einschließlich leerer Intervalle.\n- `totals` fasst genau das angefragte Fenster zusammen.\n\nDie Nutzlast bleibt fachlich benannt. Ein Publishing-System kann etwa auf eine Site begrenzte Aggregate für Releases, Inhalte und Leserzugriffe bereitstellen, eine Wissensbasis auf einen Space begrenzte Aggregate für Nutzung, Abdeckung und Reviews, ein Orchestrator auf einen Tenant begrenzte Laufzeitstatistiken für Ausführungen und Connectoren. Kein Produkt muss die Begriffe eines anderen Produkts übernehmen; alle müssen denselben Zeitvertrag erfüllen.\n\n```json\n{\n  \"bucket\": \"hour\",\n  \"tz\": \"UTC\",\n  \"from\": \"2026-07-18T10:00:00.000Z\",\n  \"to\": \"2026-07-18T12:00:00.000Z\",\n  \"buckets\": [\n    { \"ts\": \"2026-07-18T10:00:00.000Z\", \"items_created\": 2 },\n    { \"ts\": \"2026-07-18T11:00:00.000Z\", \"items_created\": 0 }\n  ],\n  \"totals\": { \"items_created\": 2 }\n}\n```\n\nDie halboffene Schreibweise `[from,to)` verhindert Doppelzählungen. Das Fenster von 10 bis 11 Uhr enthält ein Event um 10:59:59, aber keines um 11:00:00. Das nächste Fenster beginnt genau dort. Ein Retry fragt dasselbe Zeitfenster erneut ab und erhält dieselbe Bedeutung.\n\n## Gemessene Null, fehlende Beobachtung, unvollständiges Fenster\n\nEine dichte Zeitreihe enthält auch leere Buckets. Das ist praktisch, weil der Collector keine fehlenden Stunden erraten oder auffüllen muss. Eine Null ist dabei ein gemessener Wert: Das Produkt hat das Fenster ausgewertet und kein entsprechendes Event gefunden.\n\nDavon zu unterscheiden sind zwei andere Zustände. Fehlend bedeutet, dass für die Kennzahl keine Beobachtung vorliegt – etwa weil eine Quelle nicht angeschlossen ist oder eine Quote ohne Grundmenge nicht berechnet werden kann. Im hier beschriebenen Reporting-Ablauf bestimmt der nachgelagerte Report außerdem die Unvollständigkeit: Ein erwarteter Snapshot fehlt, oder das Fenster war zum Zeitpunkt der Erfassung noch nicht abgeschlossen. Diese drei Fälle dürfen im Report nicht gleich aussehen, denn sie tragen verschiedene Konsequenzen: Null technische Fehler sind beruhigend, null Seitenaufrufe können stimmen, ein fehlender Analytics-Anschluss ist eine offene Verbindung und keine Messung.\n\nEin Produkt kann die Unterscheidung zwischen gemessen und fehlend zum Teil der Antwort machen und Messwerte etwa mit `value_state` und `value_kind` kennzeichnen. Eine Quote ohne Beobachtungen trägt den Zustand `missing` und wird nicht als gemessene Null ausgegeben:\n\n```json\n{\n  \"metric\": \"engagement_rate\",\n  \"value_kind\": \"ratio\",\n  \"value_state\": \"missing\"\n}\n```\n\nFür jemanden, der einen Report liest, ist dieser Zustand wichtiger als eine weitere Nachkommastelle. Zeitfenster, Datenzustand und Messbedeutung gehören deshalb in den Vertrag selbst – nicht in eine Fußnote der Reportseite.\n\n## Datenschutz beginnt bei der Antwortform\n\nAggregate sollten Informationen weglassen, die der Zweck nicht benötigt. Eine Statistik kann erfolgreiche, fehlgeschlagene und rate-limitierte Leserzugriffe zählen, ohne dafür Benutzernamen, IP-Adresse, Session-ID, Markdown-Inhalte, URLs, Job-IDs oder Credentials in der Statistikstabelle zu speichern. Eine Wissensbasis muss über ihre Statistikressourcen keine Quellentexte ausgeben.\n\nEine solche Negativliste gehört in die Dokumentation und in Vertragstests. „Wir geben aktuell keine Namen zurück“ ist schwächer als ein Schema, in dem es für Namen gar kein Feld gibt. Der Collector kann dann keinen sensiblen Wert versehentlich in ein JSON-Artefakt oder eine Reportseite übernehmen.\n\nAuch Trace-Daten bleiben auf ihren Zweck begrenzt. W3C Trace Context standardisiert `traceparent` und `tracestate`, damit ein Aufruf über mehrere Dienste korreliert werden kann. Die W3C-Empfehlung warnt ausdrücklich davor, personenbezogene oder andere sensible Informationen in diesen Headern zu transportieren. Eine Trace-ID verbindet technische Schritte; sie beschreibt keinen Menschen.\n\n```http\nGET /v1/sites/example/stats/releases?bucket=hour&from=2026-07-18T10:00:00Z&to=2026-07-18T11:00:00Z\nAuthorization: Bearer <site-read-key>\ntraceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\n```\n\n## Zero-config heißt: vorhandene Begriffe verwenden\n\nVollständig konfigurationslos ist Telemetrie selten. Ein Dienst braucht zumindest eine dauerhafte Ablage und eine Information darüber, ob er in Entwicklung, Test oder Produktion läuft. „Zero-config“ ist hier enger gemeint: Für den Reporting-Ablauf wird im Produkt kein Sondermodus aktiviert, kein besonderer Schlüssel erzeugt und kein Reportziel eingetragen.\n\nDie Statistik-Endpoints können vorhandene Produktgrenzen verwenden: ein Publishing-System etwa die Site und einen normalen Lese-Key, eine Wissensbasis den Space und ihren Lese-Scope, ein Orchestrator den Mandanten.\n\nOpenTelemetry liefert dafür ein brauchbares gemeinsames Vokabular. `service.name` benennt den logischen Dienst, `deployment.environment.name` unterscheidet `development`, `staging`, `production` und `test`; Stand August 2026 ist der ältere Schlüssel `deployment.environment` in den OpenTelemetry-Konventionen als veraltet markiert. Solche standardisierten Felder verhindern, dass ein Dienst `prod`, ein anderer `live` und ein dritter `production-eu` als dasselbe Konzept behandelt. Das Produkt setzt diese Werte einmal für seine Laufzeit; der Reporting-Collector muss keine Etiketten reparieren.\n\nDie gemeinsame Bezeichnung allein genügt allerdings nicht. Liest ein Aggregator andere Feldnamen, als ein älterer Schreibpfad noch liefert, können erfolgreiche Aufrufe als Fehler erscheinen. Ein solcher Übergang lässt sich in beide Richtungen kompatibel machen: Der Leser versteht alte und neue Felder, der Schreiber liefert vorerst beide, und Tests verwenden echte Auditzeilen statt handgebauter Idealobjekte.\n\n## Begrenzte Queries statt versteckter Last\n\nEine allgemeine Statistik-API braucht harte Grenzen, etwa eine maximale Fensterlänge je Auflösung. Ungültige oder zu große Fenster liefern `422`, statt die Produktdatenbank mit einer unerwarteten Vollauswertung zu belasten.\n\nDiese Grenzen gehören zum Produktvertrag, weil nur das Produkt seine Kosten kennt. Ein Collector sollte nicht versuchen, sie mit vielen überlappenden Queries zu umgehen. Für einen langen Zeitraum verwendet er die gröbere Auflösung oder arbeitet in eindeutigen, nicht überlappenden Seiten.\n\nDasselbe gilt für Aufbewahrung. Anonyme Rohereignisse können im Produkt eine eigene Aufbewahrungsfrist haben. Ein Reportartefakt kann länger bestehen, wenn sein verdichteter Inhalt dafür vorgesehen ist. Rohereignis, Produktaggregat und veröffentlichter Report haben verschiedene Lifecycles; sie müssen nicht in einem Speicher enden.\n\n## Bestehende Schlüssel statt einer Hintertür\n\nEin eigener „Reporting-Key“ wirkt zunächst ordentlich, schafft aber eine zweite Sicherheitslogik. Was darf dieser Schlüssel lesen? Gilt er für eine Site, einen Space oder das ganze Produkt? Wer rotiert ihn? Sobald jedes Produkt darauf eine andere Antwort gibt, wird aus dem vermeintlich einfachen Collector ein Sonderfall mit weitreichenden Rechten.\n\nDie Statistik-APIs sollten deshalb vorhandene Scopes verwenden. Ein reiner Lese-Key einer Site darf dann genau die Statistiken der Site lesen, für die er ausgestellt wurde. Ein Lese-Key der Wissensbasis bleibt auf seinen Space begrenzt. Ohne passenden Schlüssel bleibt jeder dieser Endpoints geschlossen.\n\nFür den Collector gilt damit dieselbe Regel wie für andere API-Nutzer: pro Produkt ein eigener, möglichst enger Schlüssel; Secrets ausschließlich in der Credential-Ablage; keine Schlüssel in Workflowdefinition, JSON-Artefakt, Log oder Report. Der Workflow kennt nur eine `credential_ref`; der tatsächliche Wert wird beim HTTP-Aufruf eingesetzt und erscheint weder im gespeicherten Ablauf noch in dessen Ergebnis. Diese Trennung lässt sich testen: Ein Aufruf ohne Schlüssel muss `401` liefern, ein Schlüssel ohne passenden Scope `403`, ein korrekter Schlüssel `200`.\n\n## PostgreSQL ist intern, nicht verboten\n\nDie Ablehnung direkter Datenbankabfragen bedeutet nicht, dass Aggregate außerhalb der Produktdatenbank liegen müssen. Für ein Publishing-System oder eine Wissensbasis kann PostgreSQL gerade der passende Ort sein. Event und fachlicher Zustand lassen sich dort transaktional erfassen, nach Site oder Space begrenzen und mit denselben Migrationen weiterentwickeln wie der Rest des Produkts.\n\nWichtig ist die Richtung der Abhängigkeit. Das Produkt darf seine eigene Datenbank kennen. Ein fremder Collector darf sie nicht kennen müssen. Ob eine Antwort aus einer vorberechneten Tabelle, einer begrenzten SQL-Aggregation oder einem Cache entsteht, bleibt eine interne Entscheidung. Entscheidend ist, dass jedes Produkt seine interne Ablage nach dem eigenen Betriebsmodell gestalten kann und nach außen denselben verlässlichen Zeitvertrag erfüllt.\n\n## Dokumentation gehört zum Vertrag\n\nEin neuer Endpoint ist erst fertig, wenn OpenAPI, `llms.txt`, `llms-full.txt`, README und die ausführliche Produktdokumentation dasselbe erzählen. Sonst findet ein Mensch einen Scope in der README, ein Client generiert aus einer älteren OpenAPI-Datei und ein Agent liest eine dritte Variante.\n\nDarum prüfen die Repositories ihre generierten Dokumente auf Drift. Vertragstests schicken gültige, ungültige, zu große und nicht autorisierte Fenster an die Handler. Integrationstests verwenden die echte Produktablage. Ein Binärtest stellt sicher, dass die ausgelieferte Datei dieselben Routen und dieselbe Versionsnummer enthält wie der Quellstand. Diese Prüfungen verhindern, dass der Collector von einer veralteten oder nur intern bekannten Antwortform abhängt.\n\n## APIs machen Austausch möglich\n\nDer unmittelbare Effekt dieser Trennung zeigt sich beim Testen. Jede Produkt-API lässt sich mit einem festen Fenster gegen ihr eigenes Schema prüfen. Der Collector erhält Fixtures ohne Zugriff auf die Datenbank. Ein Produktrelease lässt sich auf API-Ebene unabhängig zurückrollen, solange der veröffentlichte Vertrag erhalten bleibt.\n\nEin neuer Verbraucher braucht keine neue Tabelle im Produkt. Ein anderes Reporting-Werkzeug kann dieselbe API ebenso aufrufen. Ein Produkt darf seine interne Ablage ändern, ohne dass die Statistikressource neu erfunden wird.\n\nDas hält auch die Produkte verständlich. Jedes Produkt behält seine Rolle – als Publishing-System, Wissensbasis oder Orchestrator. Ihre Statistikressourcen beschreiben die eigene Arbeit als begrenzte, fachlich benannte Aggregate. Alles Weitere geschieht außerhalb ihrer Grenzen. Solange der veröffentlichte Vertrag erhalten bleibt, kann ein Teil ersetzt werden, ohne die anderen anzupassen.\n\n## Was sich seit damals geändert hat\n\nDer Beitrag [HTTP-APIs: klein, stabil, lesbar](https://www.mikebild.dev/de/blog/http-apis-klein-stabil-lesbar/) formulierte 2014 einen Endpoint als langfristiges Versprechen an unbekannte Clients. Kleine Ressourcen, eindeutige Zeitstempel, stabile Bedeutungen und lesbare Fehler sollten verhindern, dass jeder Client eine eigene Abwehrschicht baut. Diese Anforderungen gelten weiterhin.\n\nNeu ist der Blick auf interne Produktstatistiken. Ein Statistikpfad wird nicht für ein bestimmtes Dashboard gebaut, sondern als begrenzte Projection der eigenen Produktdaten. Er verwendet bestehende Schlüssel und Scopes, liefert Zeitgrenzen und Datenzustand mit und veröffentlicht nur Aggregate. Damit bleibt die API klein, unabhängig davon, ob ein Mensch, ein Workflow oder ein Agent sie aufruft.\n\n## Weiterführende Quellen\n\n- [W3C Trace Context](https://www.w3.org/TR/trace-context/)\n- [OpenTelemetry Service Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/resource/service/)\n- [OpenTelemetry Deployment Attributes](https://opentelemetry.io/docs/specs/semconv/registry/attributes/deployment/)\n- [HTTP-APIs: klein, stabil, lesbar](https://www.mikebild.dev/de/blog/http-apis-klein-stabil-lesbar/)"},{"slug":"vom-organigramm-zum-arbeitsfluss","title":"Vom Organigramm zur Produktverantwortung","summary":"KI macht den Weg vom Kundenproblem bis zur Wirkung durchgängiger.","tags":["Software","KI","Methodik"],"published_at":"2026-07-18T10:00:00+00:00","updated_at":"2026-08-18T06:39:59.272448+00:00","url":"https://www.mikebild.dev/de/blog/vom-organigramm-zum-arbeitsfluss/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/709ee89e98f6dcab9390632ddf2c285dff0aeb1474956056141dda882d4c5f92.mp3","audio_duration_secs":745,"text":"Ein KI-Assistent erzeugt einen Entwurf – und danach wartet der Vorgang auf Freigabe, Review und Release wie zuvor. Wer trägt ihn vom beobachteten Kundenproblem bis zur messbaren Wirkung? Diese Frage beantwortet keine noch so schnelle Entwurfserzeugung. KI kann den Arbeitsfluss vom ersten Kundensignal über Plan und Umsetzung bis zu Review und Wirkung durchgängiger machen – wenn die Verantwortung für Problem, Entscheidung, Prüfung und Wirkung an einer Stelle zusammengeführt wird.\n\nKI beseitigt Organisationsgrenzen nicht automatisch. Sie senkt die Kosten, sich in den nächsten Schritt einzuarbeiten. Ob daraus ein zusammenhängender Vorgang wird oder nur ein schnellerer Zulauf auf dieselben Wartepunkte, entscheidet die Organisation, nicht das Modell.\n\n## Der Arbeitsfluss vom Kundenproblem bis zur Wirkung\n\nMit Arbeitsfluss meine ich hier eine konkrete Kette: Ein Kundensignal wird beobachtet, daraus entsteht eine Hypothese, aus der Hypothese ein Plan, aus dem Plan eine Umsetzung, aus der Umsetzung ein Review mit fachlicher Abnahme, danach ein Release und schließlich eine beobachtbare Wirkung, die wieder als Signal in den nächsten Durchlauf einfließt.\n\n```mermaid\nflowchart LR\n    S[Kundensignal] --> H[Hypothese und Plan]\n    H --> E[Entwurf und Umsetzung]\n    E --> Q[Review und Abnahme]\n    Q --> R[Release]\n    R --> W[Wirkung beobachten]\n    W --> S\n    A[KI: Kontext, Varianten,<br/>Tests und Dokumentation] --> H\n    A --> E\n    A --> Q\n```\n\nDie Ausgangshypothese lautet, dass diese Kette in arbeitsteiligen Organisationen selten durchgängig ist: Ein Ticket beschreibt den Wunsch, ein anderer Mensch entscheidet über das Design, ein dritter baut die Schnittstelle, ein vierter prüft den Release. Jede Rolle kann ihre Aufgabe sehr gut erledigen, während das Ergebnis für den Kunden trotzdem unvollständig bleibt. Wer nur einen kleinen Ausschnitt sieht, kann kaum beurteilen, ob eine neue Funktion das Kundenproblem löst oder lediglich einen Teilprozess sauberer macht.\n\nKI verändert an dieser Kette vor allem einen Kostenfaktor: das Einarbeiten in den jeweils nächsten Schritt. Ein Assistent kann einen unbekannten Service erklären, vorhandene Entscheidungen zusammentragen, einen Datenfluss skizzieren und passende Tests vorschlagen. Er ersetzt dabei kein Fachwissen. Er senkt die Hürde, Fachwissen aus anderen Teilen der Organisation rechtzeitig einzubeziehen – bevor eine Entscheidung getroffen ist, nicht nachdem eine fertige Lösung abgeliefert wurde.\n\n## Übergaben verkürzen sich nicht automatisch\n\nMeine These ist: KI kann Übergaben verkürzen, wenn die Verantwortung für Problem, Entscheidung, Prüfung und Wirkung zusammengeführt wird. Das ist eine Hypothese über Organisation, kein nachgewiesenes Gesetz. Bleibt die Verantwortung verteilt wie zuvor, beschleunigt KI lediglich die Produktion von Zwischenständen, die weiterhin auf dieselben Freigaben warten.\n\nDer [Beitrag über schnellere Entwicklung und unveränderte Prozesse](https://www.mikebild.dev/de/blog/ki-im-konzern-schnellere-entwicklung-andere-prozesse/) beschreibt diese Kehrseite: Wenn eine Organisation denselben alten Ablauf nur schneller füllt, verschiebt sich der Stau. Mehr Entwürfe warten auf dieselben Reviewer, und mehr Dokumente widersprechen sich. Der Gewinn entsteht erst, wenn die frei gewordene Zeit in das Verständnis des gesamten Vorgangs fließt.\n\nZusammengeführte Verantwortung bedeutet dabei nicht, dass ein Mensch gleichzeitig Produktmanager, Designer, Sicherheitsfachmann und Betriebsverantwortlicher sein muss. Gemeint ist, dass ein klar begrenzter Ausschnitt des Produkts von derselben Stelle weiterverfolgt wird: vom beobachteten Problem über eine Hypothese und eine Änderung bis zur Rückmeldung aus der Nutzung. Spezialdisziplinen bleiben unverzichtbar. Der Unterschied liegt im Zeitpunkt: Spezialisten werden früher und gezielter einbezogen, statt am Ende einer langen Kette eine fertige Lösung entgegenzunehmen.\n\nDas Organigramm verschwindet dadurch nicht. Menschen brauchen klare Zuständigkeiten, Entwicklungsperspektiven und Entscheidungen, die nicht zwischen Teams verloren gehen. Aber der durchgängige Arbeitsfluss kann wichtiger werden als die Grenzen zwischen den Kästchen – dort, wo Verantwortung tatsächlich zusammengeführt ist.\n\n## Vertikale Schnitte als überprüfbare Hypothese\n\nVertikale Arbeitspakete machen diese These überprüfbar. Statt eine Änderung nach technischen Schichten zu zerlegen – zuerst Datenmodell, dann API, dann Oberfläche – wird ein kleiner, nutzbarer Ausschnitt durch alle Schichten geführt. Ein solcher Schnitt hat einen klaren Zweck, eine sichtbare Wirkung und eine überschaubare Zahl offener Entscheidungen.\n\nBereits ohne generative KI war diese Arbeitsweise sinnvoll. Ein möglicher Hinderungsgrund war der Aufwand, sich in fremde Teile des Systems einzuarbeiten: Ein Entwickler kannte etwa das Frontend, nicht die Regeln im Backend. Das Produktteam kannte die Wirkung für Anwender, nicht die Folgen für Betrieb oder Datenmodell. Also wurden Arbeitspakete entlang von Zuständigkeiten geschnitten und anschließend über viele Meetings wieder zusammengesetzt.\n\n```mermaid\nflowchart TD\n    P[Produktproblem] --> V[Vertikaler Schnitt]\n    V --> U[Oberfläche]\n    V --> F[Fachlogik]\n    V --> D[Daten und Verträge]\n    V --> B[Betrieb und Beobachtung]\n    U --> X[Ein überprüfbares Ergebnis]\n    F --> X\n    D --> X\n    B --> X\n    K[KI: Abhängigkeiten und<br/>Vorschläge sichtbar machen] --> V\n```\n\nKleinere vertikale Schnitte sind eine überprüfbare Organisationshypothese, keine pauschale Aussage über jedes Team. Die Hypothese lautet, dass ein Team mit KI-Unterstützung mehr von diesem Zusammenhang selbst erkunden kann – welche Services betroffen sind, welche Verträge sich ändern, welche Testfälle fehlen, welche Dokumentation angepasst werden muss. Ob die Beobachtungen im konkreten Kontext für diese Hypothese sprechen, lässt sich anhand von Wartezeit, Zahl der Übergaben, Nacharbeit und Zeit bis zum Nutzerfeedback prüfen. Die Werte werden mit identischer Definition vor und nach einem begrenzten Piloten erhoben; andere Prozessänderungen müssen festgehalten werden, weil ein solcher Vergleich keine eindeutige Ursache beweist. Fällt die Messung anders aus als erwartet, ist die Hypothese für diesen Kontext zunächst nicht bestätigt, nicht das Team gescheitert.\n\nEin vertikaler Schnitt darf nicht zum Vorwand werden, jede Spezialdisziplin auf wenige Menschen abzuwälzen. Wo Sicherheit, Regulierung oder tiefe Fachkenntnis nötig sind, bleibt Zusammenarbeit Pflicht. Der Schnitt verändert die Reihenfolge der Beteiligung, nicht ihre Notwendigkeit.\n\n## Technologie wird wieder zur Option\n\nViele Technologiewahlen entstehen aus dem, was ein Team gerade kennt. Das ist verständlich: Eine unbekannte Plattform verlangt Recherche, kleine Versuche und die Sorge, später allein damit dazustehen. Darum werden Anforderungen gelegentlich an die vorhandenen Werkzeuge angepasst, obwohl ein anderer Weg besser passen könnte.\n\nKI verändert diese Rechnung. Sie kann Dokumentation durchsuchen, Beispiele aus einem Repository erklären, Varianten gegenüberstellen und einen kleinen Prototypen vorbereiten. Aus einer unbekannten Technologie wird dadurch noch keine gute Entscheidung. Aber es wird günstiger, die Entscheidung auf Basis eines echten Versuchs zu treffen statt auf Basis der Gewohnheit.\n\nEine brauchbare Untersuchung hält dabei mehrere Möglichkeiten offen:\n\n- Was ist die kleinste Änderung, mit der wir etwas über das Kundenproblem lernen?\n- Welche vorhandenen Systeme, Daten und Verträge müssen wir wirklich verstehen?\n- Welche Annahme übernimmt die KI, und wie lässt sie sich prüfen?\n- Welche Wirkung erwarten wir nach dem Release, und woran merken wir das Gegenteil?\n\nDie Antworten müssen nicht immer wie eine Architekturentscheidung dokumentiert werden. Bei einer kleinen Änderung reicht oft ein sauberer Pull Request mit Tests und einer kurzen Begründung. Entscheidend ist, dass die KI nicht bloß mehr Optionen produziert, sondern hilft, Optionen schneller zu bewerten und schlechte Ideen früher zu verwerfen.\n\n## Erstellen wird billiger, Review und Abnahme werden wichtiger\n\nDas sichtbarste Ergebnis von KI ist oft ein Entwurf: Code, ein Test, eine Zusammenfassung, eine API-Beschreibung, ein Release-Hinweis. Der Entwurf kann erstaunlich gut sein. Er kann aber auch eine falsche Voraussetzung mit großer Überzeugung weitertragen. Je günstiger das Erstellen wird, desto stärker wächst deshalb der Wert von Review und fachlicher Abnahme.\n\nReview meint hier mehr als den Pull Request. Es umfasst die fachliche Prüfung einer Anforderung, den Blick auf Widersprüche im vorhandenen Wissen, den Test unter realistischen Bedingungen und die Frage, ob der Release das Produkt verbessert. Qualitätssicherung ist damit kein letztes Tor vor dem Ausrollen. Sie begleitet den gesamten Schnitt.\n\n```yaml\nreview_focus:\n  product_question: \"Does this solve the observed customer problem?\"\n  domain_question: \"Which business rule could make this wrong?\"\n  technical_question: \"Which contract or dependency can break?\"\n  quality_signal: \"What evidence would change our decision?\"\n  release_check: \"Can we observe the result and recover safely?\"\n```\n\nDieses Beispiel ist keine neue Checkliste für jede Kleinigkeit. Es zeigt die Verschiebung: Bei KI-gestützten Entwürfen kann sich der Engpass von der Erzeugung zur Prüfung verschieben. Wichtiger wird dann, schnell und zuverlässig zu entscheiden, was von einem Entwurf behalten, geändert oder verworfen werden muss.\n\nDie NBER-Studie „Generative AI at Work“ liefert dazu einen begrenzten empirischen Hinweis. Bei mehr als 5.000 Beschäftigten im Kundensupport erhöhte ein KI-Assistent die Zahl gelöster Fälle pro Stunde im Mittel um 14 Prozent, wobei weniger erfahrene Beschäftigte besonders profitierten. Diesen Befund zu den weniger erfahrenen Beschäftigten lese ich als Hinweis darauf, dass KI bewährte Vorgehensweisen verbreiten kann – das ist meine Interpretation, kein direkter Studienbefund. Ein Beleg für dieselbe Wirkung in jeder Aufgabe oder gar in der Produktentwicklung ist die Studie ohnehin nicht. Für die Produktarbeit bleibt daraus vor allem: Der erste Entwurf wird zugänglicher. Erfahrung entwertet das nicht. Erfahrung wird sichtbarer dort, wo sie zählt – bei der Auswahl einer Richtung, beim Erkennen eines untypischen Falls und beim Urteil über Qualität.\n\n## Verantwortung technisch verankern\n\nWenn diese These ernst gemeint ist – zusammengeführte Verantwortung für Problem, Entscheidung, Prüfung und Wirkung –, dann muss sie sich auch in den Systemen wiederfinden, durch die Arbeit fließt. Sonst bleibt sie ein Vorsatz. Zwei Beispiele zeigen, wie eine solche Verankerung aussehen kann.\n\nEine Auftragssteuerung kann HTTP, MCP, A2A, Webhooks und Chat in denselben Planungspfad führen und Ausführung an explizite Auftragsautorität, Budgets, Verträge, Evidenz und Review binden. Das ist die technische Form dieser Verantwortungsbündelung: Egal über welchen Kanal ein Auftrag eintrifft, er läuft durch dieselbe Planung, und die Ausführung ist an explizite Autorität gebunden.\n\nEine Workflow-Engine kann wiederkehrende Arbeit über mehrere Systeme als genehmigten, protokollierten Workflow modellieren und Schritte mit Außenwirkung bis zur Bestätigung zurückhalten. Auch hier trennt das Design Vorbereitung und Ausführung von der bestätigten Außenwirkung: Ein Vorgang darf vorbereitet, geprüft und wiederholt werden, aber der Schritt, der nach außen wirkt, wartet auf die Abnahme.\n\nDie Übertragung auf die Organisation ist eine Hypothese, keine Garantie. Wenn Wissen auffindbar ist, wenn Releases so vorbereitet sind, dass ein Team eine Änderung kontrolliert bis zur Wirkung verfolgen kann, und wenn Produkt- und Serviceinformationen aus denselben geprüften Quellen entstehen statt in getrennten Kopien zu altern – dann kann ein Vorgang mehr Lücken auf einmal schließen: Eine Produktänderung liefert zugleich Testfälle, Betriebsinformationen und einen Entwurf für die Kundendokumentation. Wiederkehrende Supportfragen zeigen früh, welche Produktregel oder Erklärung fehlt. Fehler und Rückfragen fließen zurück in Regeln, Tests und spätere Arbeitspakete. Ob diese Bedingungen erfüllt sind, muss jede Organisation an ihrem eigenen Arbeitsfluss prüfen.\n\n## Ein vollständiger Schnitt als Test\n\nDie Veränderung beginnt nicht mit einem neuen Organigramm. Sie beginnt mit einem echten, begrenzten Produktproblem. Ein Team nimmt einen Vorgang, der heute mehrere Übergaben hat, und führt ihn einmal vollständig durch: Kundenfrage verstehen, Annahme prüfen, Änderung bauen, Qualität bewerten, veröffentlichen und die Wirkung ansehen.\n\n```yaml\nvertical_slice:\n  customer_problem: \"Describe the observed problem\"\n  smallest_change: \"Deliver one useful outcome\"\n  ai_support:\n    - \"Find relevant context\"\n    - \"Prepare tests and documentation\"\n  human_review:\n    - \"Validate domain assumptions\"\n    - \"Accept quality and release risk\"\n  outcome_signal: \"Measure customer use and failures\"\n```\n\nDanach ist die Diskussion über Rollen und Struktur konkreter. Vielleicht zeigt sich, dass ein Team eine Schnittstelle selbst verantworten kann. Möglicherweise fehlt eine gemeinsame Wissensquelle. Oder ein Spezialist bleibt dauerhaft Teil des Ablaufs. Entscheidend ist, dass die Organisation aus beobachteter Arbeit lernt und nicht aus einem abstrakten Umbauplan – und dass sie die Hypothese verwirft, wo die Messung dagegen spricht.\n\nKI macht vollständige Produktentwicklung nicht einfach. Sie macht sie dort öfter möglich, wo Verantwortung, Review und Wirkung zusammengehören und nicht über viele Stationen verteilt sind. Recherche, Entwurf, Tests, Dokumentation und Release-Vorbereitung rücken näher zusammen. Das Organigramm bleibt. Aber die Entscheidung, die jede Organisation jetzt treffen kann, ist konkret: einen ausgewählten, begrenzten Arbeitsfluss einmal durchgängig verantworten und an seiner Wirkung messen, ob die Hypothese trägt.\n\n## Weiterführende Quellen\n\n- [NBER: Generative AI at Work](https://www.nber.org/papers/w31161)\n- [KI im Enterprise-Umfeld: Schnellere Entwicklung braucht andere Prozesse](https://www.mikebild.dev/de/blog/ki-im-konzern-schnellere-entwicklung-andere-prozesse/)"},{"slug":"llms-txt-und-llms-full-txt","title":"llms.txt: Doku für Maschinen","summary":"Kontext für Modelle, nicht fürs Ranking: warum llms.txt und llms-full.txt Coding-Agenten helfen – und was ein MCP-Server daraus macht.","tags":["Software","KI","Methodik"],"published_at":"2026-07-15T10:00:00+00:00","updated_at":"2026-08-18T05:16:11.670656+00:00","url":"https://www.mikebild.dev/de/blog/llms-txt-und-llms-full-txt/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/aafa9dbe6e0b20c8d98403a06961eac6b3e4515fd6ebb904366e505a23ad0d9d.mp3","audio_duration_secs":803,"text":"`llms.txt` liegt im Wurzelverzeichnis einer Website und sieht damit aus wie das nächste Signal für Suchmaschinen – ein Nachbar von `robots.txt` und `sitemap.xml`, angelegt in der Hoffnung auf Sichtbarkeit. Genau dieser Schluss geht am Zweck vorbei. Die Datei war nie als Ranking-Signal gedacht, und für eine Ranking-Wirkung liegt kein Beleg vor. Ihr Nutzen liegt nicht in Crawler-Signalen, sondern darin, Agenten kuratierten Dokumentationskontext bereitzustellen.\n\nEs gibt zwei Dateien, `llms.txt` und `llms-full.txt`. Sie sehen nach Marketing-Infrastruktur aus, und ihr Nutzen liegt an einer anderen Stelle: bei der Frage, wie ein Modell an sauberen, kuratierten Kontext kommt, ohne sich vorher durch Navigationsleisten, Cookie-Banner und JavaScript zu arbeiten. Beide Dateien sind maschinenlesbare Selbstdokumentation eines Produkts.\n\n## Zwei Dateien, ein Problem\n\nDen Vorschlag hat Jeremy Howard, Mitgründer von Answer.AI, am 3. September 2024 veröffentlicht; die Beschreibung steht unter `llmstxt.org`. Der Ausgangspunkt ist eine nüchterne Beobachtung: Modelle stützen sich zunehmend auf Webinhalte, aber ihr Kontextfenster ist zu klein, um eine ganze Website am Stück aufzunehmen. Und rohes HTML in brauchbaren Text zu verwandeln, ist mühsam und ungenau – zwischen dem eigentlichen Inhalt steckt jede Menge Beiwerk, das nur Platz frisst.\n\nDie Antwort besteht aus zwei Bausteinen, die man sauber auseinanderhalten sollte.\n\n`llms.txt` liegt im Wurzelverzeichnis einer Site und ist ein kuratierter Index in Markdown. Das Format ist bewusst eng: eine H1-Überschrift mit dem Projektnamen, darunter ein Zitatblock mit einer kurzen Zusammenfassung, optional ein paar Sätze Kontext, danach H2-Abschnitte mit Linklisten. Jeder Link trägt eine knappe Beschreibung. Ein eigener Abschnitt `## Optional` markiert, was ein Modell bei knappem Platz zuerst weglassen darf. So kompakt sieht das aus:\n\n```markdown\n# Orderflow API\n\n> Orderflow is a REST API for creating, tracking, and refunding orders.\n> These docs are written for LLMs and coding agents.\n\nEvery write endpoint accepts an `Idempotency-Key` header, so retries stay safe.\n\n## Core Documentation\n\n- [Quick Start](https://docs.example.com/quickstart.md): Authenticate and create your first order\n- [API Reference](https://docs.example.com/api.md): Endpoints, parameters, and error codes\n- [Webhooks](https://docs.example.com/webhooks.md): Event types and signature verification\n\n## Optional\n\n- [Changelog](https://docs.example.com/changelog.md): Version history and breaking changes\n```\n\n`llms-full.txt` ist der zweite Baustein und konzeptionell schlichter: der vollständige Text der wichtigen Seiten, in einer einzigen Markdown-Datei aneinandergehängt. Kein Index mehr, sondern der ganze Inhalt am Stück, ohne HTML-Rauschen. Ein Agent oder Client kann den gebündelten Inhalt mit einer einzigen Anfrage laden, statt sich Seite für Seite durchzuhangeln – ob davon alles zur konkreten Aufgabe passt, ist eine andere Frage.\n\nErgänzend schlägt Howard vor, einzelne Seiten zusätzlich als saubere Markdown-Fassung unter derselben Adresse mit angehängtem `.md` bereitzustellen – aus `.../api` wird `.../api.md`. Das ist der Klebstoff zwischen Index und Inhalt: Der Index verweist auf Seiten, die schon in der Form vorliegen, die ein Modell ohnehin am besten verarbeitet.\n\nWichtig ist die Abgrenzung, weil hier viel durcheinandergeht. `robots.txt` regelt, was ein Crawler anfassen darf. `sitemap.xml` hilft beim Indexieren. `llms.txt` tut keines von beidem: Die Datei ordnet ein und kuratiert, gedacht für den Moment der Inferenz, nicht für den Crawl. Ein Agent, der sie liest, bekommt eine redaktionelle Aussage darüber, was an einem Produkt zählt – kein Versprechen an eine Suchmaschine. Die drei Dateien schließen sich nicht aus, sie beantworten verschiedene Fragen.\n\n```mermaid\nflowchart TD\n    A[Coding-Agent] -->|kennt llms.txt| B[llms.txt<br/>kuratierter Index]\n    B --> C{Welche Seite<br/>brauche ich?}\n    C -->|gezielt| D[seite.md<br/>saubere Markdown-Fassung]\n    C -->|alles auf einmal| E[llms-full.txt<br/>gesamter Inhalt]\n    A -.->|Alternative ohne llms.txt| F[HTML-Seite crawlen<br/>Navigation, Werbung, JS]\n    F -.->|viel Rauschen| G[Kontextfenster voll,<br/>Inhalt verwässert]\n```\n\n## Warum das etwas bringt\n\nBeim Anbinden einer fremden Bibliothek lädt ein Agent ohne Markdown-Sicht häufig die vollständige HTML-Seite oder improvisiert fehlende Signaturen. Im ersten Fall zieht er Menü, Fußzeile und drei Codebeispiele herein, die er gar nicht braucht – ein spürbarer Teil des Fensters ist weg, bevor die eigentliche Aufgabe beginnt. Im zweiten Fall rät er. Mir ist in einem Code-Review aufgefallen, dass ein Agent eine API-Signatur schlicht erfunden hatte, plausibel und falsch, weil ihm die exakte Referenz zu teuer war, sie sich zu holen. Beides sind Symptome desselben Problems: zu viel Rauschen pro Token, zu wenig verlässliche Substanz.\n\n`llms.txt` setzt genau dort an. Kuratiertes Markdown ohne Layout-Ballast bedeutet, dass mehr vom Fenster für den eigentlichen Inhalt übrig bleibt. Der Index zwingt außerdem zu einer Entscheidung: Nicht jede Seite ist gleich wichtig, und jemand mit Domänenwissen muss benennen, welche dreißig bis hundert Seiten den Kern ausmachen. Und wenn das Modell die genaue Fehlermeldung oder den exakten Parameter braucht, holt es sich die passende `.md`-Seite, statt zu improvisieren.\n\nDer Punkt mit der Kuratierung wird oft übersehen. Eine gute `llms.txt` ist keine beliebige Linkliste, sondern eine redaktionelle Aussage darüber, was an einem Produkt zählt. Diese Festlegung ist die eigentliche Arbeit – nicht die Datei selbst.\n\n## Wo es sich lohnt\n\nFür ein Blog oder eine Marketing-Seite bringt `llms.txt` wenig: Für eine Ranking-Wirkung liegt kein Beleg vor, und wer die Datei in der Hoffnung auf bessere Sichtbarkeit anlegt, wird enttäuscht. Wer eine solche Wirkung verspricht, verspricht zu viel.\n\nDie Ausnahme ist eng, aber real: technische Dokumentation und Coding-Agenten. Eine API-Referenz, eine Komponentenbibliothek, ein SDK – Inhalte, die ohnehin in Markdown gepflegt werden und die ein Agent in der IDE gezielt nachschlägt.\n\nFür reine Marketingseiten ohne nachweisbare Agentennutzung rechtfertigt der Pflegeaufwand die Datei meist nicht; bei regelmäßig abgefragter technischer Dokumentation kann er sich lohnen. Server-Logs mit Anfragen auf `/llms.txt` liefern dafür einen Nutzungshinweis, tragen die Entscheidung aber nicht allein: Ausbleibende direkte Zugriffe belegen nicht zwingend fehlende Nutzung, etwa wenn dieselben Inhalte über andere Wege wie MCP-Ressourcen abgerufen werden.\n\n## Aus der Quelle erzeugt, nicht von Hand gepflegt\n\nEin zentrales Risiko beim Einsatz ist organisatorisch, nicht technisch: zwei Fassungen desselben Inhalts, die getrennt gepflegt werden. Die Seite für Menschen hier, die Markdown-Fassung für Modelle dort. Läuft die `llms-full.txt` der echten Doku hinterher, füttert man Modelle mit veralteten Ständen – und das ist schlimmer als gar keine Datei, weil es falsches Vertrauen erzeugt. Ohne automatische Erzeugung aus derselben Quelle ist Drift nur eine Frage der Zeit.\n\nDeshalb gehören beide Dateien in den Build, nicht in den Editor. Der Build kann pro Site und Locale `llms.txt`, `llms-full.txt` und Markdown-Zwillinge aus dem veröffentlichten Stand erzeugen. Entscheidend ist die gemeinsame Quelle für menschliche und maschinenlesbare Ausgaben; eine zweite, von Hand gepflegte Dokumentation würde erneut Drift ermöglichen.\n\nDas ist die zweite Hälfte der Kuratierungsarbeit. Die redaktionelle Entscheidung, was in den Index gehört, trifft ein Mensch. Die Erzeugung der Dateien übernimmt der Build. Wer beides von Hand macht, hat sich eine zweite Doku eingehandelt, die leicht vom veröffentlichten Stand abweicht.\n\n## Was ein MCP-Server daraus macht\n\nBis hierhin ist `llms.txt` eine statische Datei, die jemand irgendwann abholt. MCP ergänzt den statischen Index um gezielten Zugriff auf Ressourcen und Operationen. Das Model Context Protocol ist das offene Protokoll, das Anthropic Ende 2024 vorgestellt hat und über das ein Agent standardisiert mit externen Werkzeugen und Datenquellen spricht.\n\n`llms.txt` beschreibt als Index, welche Inhalte es gibt und wo sie liegen; ein MCP-Server stellt Ressourcen und Operationen gezielt bereit. Das LangChain-Team hat mit `mcpdoc` genau so einen Doku-Server gebaut. Er bekommt eine Liste von `llms.txt`-Adressen und stellt dem Agenten zwei Werkzeuge bereit: eines, das die registrierten Quellen aufzählt, und eines, das eine bestimmte Seite daraus nachlädt.\n\nEin darauf ausgelegter MCP-Doku-Server kann Kontext gezielt nachladen, Aufrufe protokollierbar machen und Zugriffe beschränken – drei Verbesserungen gegenüber dem bloßen Herunterladen der Datei.\n\nErstens die Dosierung. Statt `llms-full.txt` komplett ins Fenster zu kippen, fragt der Agent über den Index nur die eine Seite ab, die zur Aufgabe passt – der Bereich zwischen „gar kein Kontext“ und „alles auf einmal“.\n\nZweitens die Nachvollziehbarkeit. Bei einem Doku-Server wie `mcpdoc` läuft das Nachladen über Werkzeugaufrufe, die man prüfen kann: welche Adresse geladen wurde und was zurückkam. Für Ressourcenabrufe gilt das nicht automatisch; dort braucht es eine eigene Logging auf der Serverseite. Ein direkter Download der Datei hinterlässt diese Spur im Agenten-Workflow nicht zwangsläufig. Bei interner oder geschützter Dokumentation kann diese Prüfbarkeit den zusätzlichen Betriebsaufwand rechtfertigen.\n\nDrittens die Zugriffskontrolle. Der Server lässt sich auf erlaubte Domains beschränken, und für interne oder geschützte Doku legt man Authentifizierung davor. Wer berechtigungsgebundenen Zugriff braucht, kann die Dokumentation über einen authentifizierten MCP-Server als Ressource anbieten und dort Berechtigungen für jeden Abruf prüfen. Damit trägt die Konvention auch hinter der Firmen-Firewall: Der Server zeigt auf eine private `llms.txt`, und Modelle im Team bekommen kuratiertes Wissen, ohne dass die Inhalte offen im Netz stehen.\n\nKommt beides aus einem Produkt, kann es neben den erzeugten Dateien eine Remote-MCP-Oberfläche anbieten. In einem solchen Aufbau sind MCP-Ressourcen anwendungsverwalteter Kontext, den der Server dem Client als Dokumentation anbietet. Werkzeuge bleiben davon getrennt: Sie bilden domänenbezogene Operationen ab und sollten erst mit dem passenden Scope sichtbar werden. Ein solches System kann seine eigene `llms.txt`-Dokumentation zusätzlich als MCP-Ressource bereitstellen, sodass ein Client Werkzeugmodell und API-Grenzen lesen kann, ohne eine Webseite zu erraten. MCP ergänzt die statische Dokumentation also um gezielten Zugriff und um Operationen; berechtigungsgebunden wird dieser Zugriff erst, wenn der Server Ressourcenabrufe authentifiziert und autorisiert. Die Dokumentation ersetzt MCP nicht – ohne erzeugte, aktuelle Maschinensichten fehlte diesen Dokumentationsressourcen eine verlässliche Grundlage.\n\n```mermaid\nflowchart LR\n    H[Agent-Host<br/>Cursor, Claude Code] --> M[MCP Docs-Server<br/>z. B. mcpdoc]\n    M -->|Quellen auflisten| L[llms.txt<br/>registrierte Indizes]\n    M -->|Seite nachladen| P[nur die benötigte<br/>.md-Seite]\n    P --> H\n```\n\nBesonders `llms-full.txt` gewinnt in dieser Kombination. Man kann die Datei direkt als Wissensquelle an einen MCP-Server hängen – ohne Embedding-Pipeline, ohne Vektordatenbank. Treffender als „Ersatz für RAG“ ist die Beschreibung als werkzeuggestützte, prüfbare Variante davon: kein unvorhersehbares Ziehen aus einem großen Vektorindex, sondern nachvollziehbarer Zugriff auf kuratierte Seiten.\n\n## Wo es hakt\n\n`llms.txt` ist ein Vorschlag, kein verabschiedeter Standard. Es gibt kein Gremium dahinter und keine Garantie, dass ein bestimmtes Modell die Datei liest. Man stellt sie bereit; erzwingen lässt sich der Konsum nicht.\n\nDie Größe ist eine echte Grenze. Eine mehrere Megabyte große `llms-full.txt` kann je nach Tokenisierung, Modell und Clientlimit nicht vollständig in den verfügbaren Kontext passen und wird dann nur in Bruchstücken geladen – womit der Vorteil „alles in einer Anfrage“ dahin ist. Für große Sites bleibt es beim kuratierten Index auf die wirklich zentralen Seiten, ergänzt um gezieltes Nachladen einzelner `.md`-Fassungen.\n\nUnd schließlich die Vertrauensfrage. Sobald ein Agent eine fremde `llms-full.txt` ungeprüft übernimmt, übernimmt er Inhalt, den er nicht kontrolliert. Das ist dieselbe Angriffsfläche wie bei jeder externen Datenquelle, die in den Kontext wandert – ein guter Grund, über einen MCP-Server mit Domain-Beschränkung zu gehen, statt beliebige Adressen einzusammeln.\n\n## Fazit\n\n`llms.txt` und `llms-full.txt` sind maschinenlesbare Selbstdokumentation. Der Index sagt, was zählt und wo es liegt; die Vollfassung liefert den Inhalt am Stück; die `.md`-Spiegel geben einzelne Seiten in der Form, die Modelle bevorzugen. Ein belastbarer Ranking-Faktor ist keines davon, und wer die Dateien mit dieser Erwartung anlegt, misst am falschen Ziel. Ihr Wert entsteht dort, wo Dokumentation auf Agenten trifft – bei SDKs, API-Referenzen und Coding-Werkzeugen.\n\nDaraus folgen zwei Entscheidungen. Erstens: Die Dateien werden aus derselben Quelle erzeugt wie die Dokumentation für Menschen, oder man lässt sie weg – ein handgepflegter Doppelbestand driftet und untergräbt genau das Vertrauen, das er stiften soll. Zweitens: Wer gezielten oder berechtigungsgebundenen Zugriff braucht, kann die Dokumentation über einen authentifizierten MCP-Server als Ressource anbieten und dort Berechtigungen prüfen. MCP kann die statischen Sichten so um dosierten, prüfbaren und – bei serverseitiger Autorisierung – berechtigungsgebundenen Zugriff ergänzen; es bleibt darauf angewiesen, dass die Dokumentation dahinter stimmt.\n\n## Weiterführende Quellen\n\n- Der ursprüngliche Vorschlag und die Formatbeschreibung: [llmstxt.org](https://llmstxt.org/)\n- Repository zum Vorschlag: [AnswerDotAI/llms-txt](https://github.com/answerdotai/llms-txt)\n- MCP-Doku-Server, der `llms.txt` als Quelle liest: [langchain-ai/mcpdoc](https://github.com/langchain-ai/mcpdoc)\n- Model Context Protocol: [modelcontextprotocol.io](https://modelcontextprotocol.io/)"},{"slug":"hilfsmittel-konzepte-methoden-prozesse","title":"Hilfsmittel, die tragen: Konzepte, Methoden, Prozesse","summary":"Einen Agenten für die eigene Fachdomäne kann man nicht installieren – den Weg dorthin aber sehr wohl lernen. Welche Konzepte tragen, welche Methoden den Anfang klein halten und welche Prozesse verhindern, dass Regeln veralten.","tags":["Software","KI","Methodik","Delivery"],"published_at":"2026-07-15T10:00:00+00:00","updated_at":"2026-08-18T05:16:11.670656+00:00","url":"https://www.mikebild.dev/de/blog/hilfsmittel-konzepte-methoden-prozesse/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/85c6b39b0ba8daf83a88bdf1232b28c9d8e82e9ca43ba5afea716a459aec19fc.mp3","audio_duration_secs":892,"text":"Für einen Agenten, der eine Fachdomäne verbindlich vertritt, gibt es kein Installationspaket. Installieren lassen sich verfügbare Modellgewichte und Laufzeiten; Protokolle lassen sich implementieren oder anbinden. Modellgewichte und Laufzeiten allein erzwingen jedoch keine fachliche Freigabe und erzeugen keinen fachlich aussagekräftigen Nachweis darüber, wer was entschieden hat. Der verbindliche Teil entsteht erst in der Auseinandersetzung mit der eigenen Domäne – mit ihren Begriffen, ihren Regeln und den Menschen, die für beides einstehen. Der Weg dorthin ist allerdings lernbar, und er ist unspektakulärer, als viele Framework-Debatten vermuten lassen.\n\n*Teil 12 der Serie „Von der Session zum System“ – hier geht es [zum Auftakt](https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/).*\n\nDie [letzte Folge](https://www.mikebild.dev/de/blog/eigener-agent-pro-fachdomaene/) hat beschrieben, was so ein Agent können muss: Pläne prüfen, bevor sie wirken; Freigaben, die kein Modell wegformulieren kann; Nachweise, die Fragen von übermorgen beantworten. Offen geblieben ist der Weg dorthin. Er führt über drei Sorten von Hilfsmitteln: Konzepte, die tragen. Methoden, die den Anfang klein halten. Und Prozesse, die das Ganze im Betrieb am Leben erhalten, wenn die Aufmerksamkeit weiterzieht. Der Reihe nach.\n\n## Konzepte: nichts davon ist neu\n\nWer die Fähigkeiten-Liste aus der letzten Folge durchgeht, findet darin keine einzige KI-Erfindung. Fachsprache und Domänenschnitt – dass ein System die Begriffe seiner Domäne sprechen soll und dass diese Begriffe nur innerhalb eines abgesteckten Zusammenhangs eindeutig sind – hat Eric Evans vor gut zwanzig Jahren in „Domain-Driven Design“ als ubiquitous language und Bounded Context beschrieben. Die Trennung von Vorschlag und Wirkung kennt jede Organisation, die Änderungen erst prüft und dann ausrollt, vom Vier-Augen-Prinzip in der Buchhaltung bis zu `terraform plan` und `apply`. Validierung, Autorisierung, Audit sind das Handwerkszeug jeder ernsthaften Fachanwendung. Und dass Antworten [auf echten Quellen stehen müssen statt auf plausiblem Raten](https://www.mikebild.dev/de/blog/sauberes-grounding-antworten-erden/) und [kuratiertes Wissen einen Kreislauf mit Besitzern braucht](https://www.mikebild.dev/de/blog/wissen-das-die-session-ueberlebt/), haben die mittleren Folgen dieser Serie behandelt – auch das ist im Kern Dokumentations- und Bibliotheksarbeit, kein Hexenwerk.\n\nNeu ist allein der Abnehmer: eine Schleife, in der ein Modell handelt, statt einer Oberfläche, in der Menschen klicken. Für die Konzeptauswahl ist das eine kleine Verschiebung. Mir hilft seither eine einfache Prüffrage: Hat dieses Konzept schon getragen, bevor es KI gab? Domänenschnitt, Freigabe-Workflows, Versionierung, Audit – ja, jahrzehntelang. Ein Orchestrierungs-Framework, das es seit vier Monaten gibt – eher nicht. Das ist keine Abwertung von allem Neuen; irgendwo entsteht gerade tatsächlich das nächste tragende Konzept. Aber die Beweislast liegt beim Neuen, nicht beim Bewährten.\n\nDass sich die alten Konzepte direkt in Agentensysteme übertragen lassen, zeigt ein naheliegender Baustein: Eine Workflow-Engine kann Mission, Plan, Lauf und die dazu erteilte Autorität trennen – und für HTTP, MCP, A2A, Webhooks und Chat denselben Planungspfad nutzen. Das ist die alte Trennung von Vorschlag und Wirkung, konsequent auf jeden Eingangskanal angewendet: Egal, woher ein Vorhaben kommt, es nimmt denselben Planungspfad.\n\nFür den Reflex, gleich die ganze Organisation als Agenten-Landschaft zu entwerfen, gibt es ebenfalls ein altes Konzept, und es trifft härter als jede Technikfrage. John Gall hat es in den Siebzigern als Beobachtung über Systeme formuliert: Ein komplexes System, das funktioniert, ist ausnahmslos aus einem einfachen System hervorgegangen, das funktioniert hat. Ein komplexes System, das am Reißbrett entworfen wurde, funktioniert nicht und lässt sich auch nicht reparieren – man muss von vorn anfangen, mit einem einfachen System. Zwölf verbundene Agenten auf einem Whiteboard sind der Reißbrett-Entwurf. Der erste Vorgang, der wirklich läuft und ein prüfbares Ergebnis liefert, ist der Anfang des Systems.\n\n## Methode: erst die Regeln inventarisieren\n\nDie erste der drei Fragen vom Ende der letzten Folge lautete: Welche Regeln gehören in den Pfad, welche dürfen Prosa bleiben? Die Antwort beginnt nicht bei der Technik, sondern bei einer Bestandsaufnahme, die unbeliebter ist, als sie sein müsste. Für die Inventur würde ich zunächst vier Fundorte prüfen: das Wiki, Instruktionsdateien wie CLAUDE.md oder AGENTS.md, Code-Review-Kommentare – und Köpfe, als das Wissen der einen Kollegin, die man sicherheitshalber vor jeder Preisänderung fragt. Vollständig ist diese Liste nicht; je nach Organisation kommen Tickets, Richtlinien, Konfiguration oder Fachverfahren hinzu. Die Inventur holt die Regeln an einen Ort und notiert zu jeder vier Dinge: Wo steht sie? Wie verbindlich ist sie gemeint? Was passiert bei einem Verstoß? Und wie oft wurde sie zuletzt tatsächlich verletzt?\n\nDie letzte Spalte ist die wichtigste, denn sie ersetzt Meinung durch Befund. In der [Chronik meiner Instruktionsdateien](https://www.mikebild.dev/de/blog/vergebliche-versuche-instruktionsdateien/) war die Ernüchterung, dass aufgeschriebene Regeln Empfehlungen bleiben; in der [Folge über Tools](https://www.mikebild.dev/de/blog/agent-modell-fachdomaene/) wurde daraus ein Übergangskriterium: Wenn dieselbe Regel zum dritten Mal in einer Instruktionsdatei auftaucht und zum zweiten Mal ignoriert wurde, ist sie ein Kandidat für die Durchsetzung im Pfad. Die Inventur macht aus diesem Bauchgefühl eine Liste:\n\n```yaml\n# rule inventory, one entry per rule\n- rule: \"Credit notes above 500 EUR require team lead approval\"\n  source: wiki, AGENTS.md\n  violation_impact: money leaves the company\n  violated_recently: twice in Q2\n  decision: enforce   # goes into the path\n\n- rule: \"Use the customer number, not the invoice number, in replies\"\n  source: onboarding notes\n  violation_impact: confusion, no damage\n  violated_recently: unknown\n  decision: prose     # stays a convention\n```\n\nFür die Einordnung halte ich drei Fragen für einen tragfähigen Startpunkt, und sie decken sich mit den Kriterien, die sich in dieser Serie nach und nach herausgeschält haben: Bewegt ein Verstoß Geld, personenbezogene Daten oder Wirkung nach außen? Muss hinterher jemand nachweisen können, wer was wann entschieden hat? Wurde die Regel trotz sauberer Dokumentation wiederholt gebrochen – von Menschen oder vom Modell? Ein Ja bei einer dieser Fragen ist für mich der Ausgangspunkt, die Regel in den Pfad zu nehmen: Prüfung, Freigabe, Nachweis. Eine Startheuristik bleibt das allerdings – wo der mögliche Schaden gering ist, ein Fehler sich leicht zurücknehmen lässt oder bestehende Kontrollen ihn ohnehin abfangen, kann eine Regel auch bei einem Ja Prosa bleiben. Dreimal Nein, und sie bleibt Prosa – als Konvention, die das Modell liest und meistens befolgt, und deren verbleibendes Risiko ausdrücklich akzeptiert wurde.\n\nDie Inventur hat einen zweiten Ertrag, der leicht übersehen wird: Sie findet tote Regeln. Vorgaben, die seit Jahren niemand mehr braucht, deren Anlass längst verschwunden ist, die aber weiter in jedem Dokument mitgeschleppt werden. Die fliegen raus – nicht in den Pfad, sondern ganz. Ein Agent, der eine Fachdomäne vertritt, ist auch eine Gelegenheit, die Domäne selbst zu entrümpeln.\n\n## Methode: die erste Domäne klein schneiden\n\nDie zweite Frage: Wie schneidet man die erste Domäne, ohne sich zu verheben? Die kurze Antwort: einen Vorgang, keine Abteilung. Eine Abteilung bündelt viele Zustände und Verantwortlichkeiten, die nie gleichzeitig fertig sind; ein Vorgang hat einen prüfbaren Anfang und ein prüfbares Ende. „Rechnungswesen“ ist deshalb kein Schnitt, sondern ein Organigramm-Kästchen. „Gutschriften und Mahnstopps“ ist einer – ein abgrenzbarer Vorgang mit Anfang, Ende und einer Handvoll Verben.\n\nWoran man einen tauglichen ersten Kandidaten erkennt:\n\n- Der Vorgang hat einen klaren Anfang und ein klares Ende – kein Dauerzustand, kein Sammelbegriff.\n- Eine Handvoll Verben genügt: Spätestens wenn die Tool-Liste beim Modellieren zweistellig wird, würde ich den Schnitt erneut prüfen.\n- Es gibt heute schon eine fachlich zuständige Person – die Rolle, die auch ohne KI freigibt und entscheidet.\n- Der Schmerz ist dokumentiert: Verstöße sind in der Vergangenheit tatsächlich passiert und haben nachweislich geärgert.\n- Das Schadenspotenzial rechtfertigt den Aufwand, bleibt aber überschaubar – Gutschriften ja, Zahlungsverkehr mit dem Ausland vielleicht erst im zweiten Jahr.\n\nDer letzte Punkt verdient einen Moment. Der erste Domänen-Agent ist nicht nur ein Werkzeug, er ist auch das Stück, an dem ein Team die drei Fähigkeiten – Prüfung, Freigabe, Nachweis – zum ersten Mal baut und betreibt. Solche ersten Stücke wählt man so, dass Fehler lehrreich sind, nicht existenzbedrohend.\n\nZum kleinen Schnitt gehört auch ein kleiner Übergang. Niemand reißt die bestehende Arbeitsweise ab und ersetzt sie durch den Agenten; die Prosa-Variante bleibt der Prototyp, aus dem Regeln einzeln in den Pfad wandern, sobald ihre Verbindlichkeit sich erwiesen hat. Martin Fowler hat für dieses Muster – das Neue wächst am Alten entlang, bis das Alte entbehrlich ist – das Bild der Würgefeige geprägt, die einen Baum umwächst. Auf Regeln übertragen: Erst gilt die 500-Euro-Regel als Satz in der Instruktionsdatei, dann zusätzlich als Prüfung im Pfad, und irgendwann ist der Satz nur noch Dokumentation der Prüfung. Kein großer Umstellungstag, keine riskante Migration über ein Wochenende.\n\nDer Rest der großen Skizze stirbt dabei nicht – er wird Backlog, sortiert nach genau den Kriterien oben. Das kann sich wie ein Rückzug anfühlen. Es ist das Gegenteil: der erste Schritt, der tatsächlich gegangen werden kann und ein überprüfbares Ergebnis liefert.\n\n## Prozesse: Besitzer, Änderungsweg, Rhythmus\n\nBleibt die dritte Sorte Hilfsmittel, und sie ist die am leichtesten unterschätzte. Konzepte kann man lernen, Methoden kann man anwenden – Prozesse muss man im Betrieb durchhalten. Drei halte ich für tragend.\n\nErstens: Jede Regel hat einen Besitzer, und der sitzt im Fachbereich. Die Entwicklung besitzt die Durchsetzung – den Code, der prüft, das Gatter, das wartet, das Protokoll, das schreibt. Aber ob 500 Euro die richtige Schwelle ist, ob „Kulanz“ und „Preisfehler“ verschiedene Wege gehen, entscheidet die Fachseite, die diese Verantwortung schon immer trug. Die Diskussion über das Vorschlagen, Prüfen und Freigeben von Regeln ist dabei kein Hindernis auf dem Weg zum Prozess. Sie ist der Prozess – wer sie überspringt, hat Regeln ohne Legitimation, und die erodieren beim ersten Konflikt.\n\nZweitens: Regeländerungen gehen denselben Weg wie Regeln. Vorschlag, Prüfung, Freigabe, Versionierung – dieselbe Sorgfalt, die für Code selbstverständlich ist. Dass der Nachweis die geltende Regelversion festhält, war in der letzten Folge ein Detail am Rand; im Betrieb wird es zur Voraussetzung dafür, dass man Regeln überhaupt anfassen kann, ohne die Vergangenheit umzudeuten. Wie das aussehen kann, zeigen zwei Bausteine: Ein Wissenssystem kann fachliche Leitlinien in einem versionierten, menschenverantworteten Charter halten und Änderungen am abgeleiteten Wissensbestand durch einen Review-Prozess führen. Eine Workflow-Engine kann genehmigte Abläufe als wiederholbar ausgeführte Workflows beschreiben und jeden Lauf protokollieren – nicht beschriebene und nicht genehmigte Arbeit wird nicht improvisiert. Beides sind Ausprägungen desselben Prinzips: Der Änderungsweg ist in das System eingebaut.\n\nDrittens, und am wichtigsten: aus den Neins lernen, in festem Rhythmus. Der Agent erzeugt im Betrieb zwei Ströme – Ausführungen und Ablehnungen. Für zu enge oder fehlende Regeln sind die Ablehnungen oft der ergiebigere Ausgangspunkt; Stichproben erfolgreicher Läufe bleiben trotzdem nötig, weil auch eine durchgewunkene Ausführung eine falsche Entscheidung enthalten kann. Das Ablehnungsprotokoll gehört deshalb regelmäßig auf den Tisch – anfangs in einem festen Abstand von wenigen Wochen, später abhängig von Ablehnungsrate und Schadenspotenzial – mit drei Fragen: Welche Neins waren richtig und bleiben? Welche waren zu eng und brauchen eine angepasste Schwelle oder einen neuen Gutschriftsgrund? Und welche Sonderfälle tauchen so regelmäßig auf, dass sie ein eigenes Verb verdienen? Das ist derselbe Kreislauf, den [die Folge über kuratiertes Wissen](https://www.mikebild.dev/de/blog/wissen-das-die-session-ueberlebt/) beschrieben hat, nur mit Regeln statt Erkenntnissen: Der Betrieb liefert Kandidaten, die Kuration macht Verbindliches daraus – die Organisation ändert dabei Regeln und Schnittstellen; das Modell bleibt unverändert.\n\n```mermaid\nflowchart LR\n    I[\"Inventur<br/>Regeln sammeln und bewerten\"] --> K[\"Einordnung<br/>Pfad oder Prosa\"]\n    K --> B[\"Bauen<br/>Verben · Prüfung · Freigabe\"]\n    B --> O[\"Betrieb<br/>Ausführungen und Ablehnungen\"]\n    O --> S[\"Sichtung im Rhythmus<br/>aus den Neins lernen\"]\n    S --> I\n```\n\nZusammen ergeben die drei Prozesse das, was in der letzten Folge nur behauptet wurde: dass so ein Agent ein Produkt mit Lebenszyklus ist. Ein Produkt hat Besitzer, einen Änderungsweg und einen Takt, in dem es besser wird. Fehlt eines davon, verfällt es – bei Regeln schneller als bei Code, weil eine veraltete Schwelle keinen Build fehlschlagen lässt und deshalb gezielt durch Review oder Betriebsmetriken erkannt werden muss.\n\n## Moden, die man auslassen kann\n\nUnd die Gegenliste? Vier Muster halte ich für Aufmerksamkeitsfresser, die weder Prüfung noch Freigabe oder Nachweis verbessern – das ist ein Urteil, kein Messergebnis.\n\nDas Multi-Agenten-Orchester ab Tag eins: viele Agenten, die einander Messages schicken, bevor auch nur einer eine einzige verbindliche Regel durchsetzt – der Reißbrett-Entwurf in Software gegossen. Der häufige Framework-Wechsel, bei dem die Fachlogik jedes Mal ein Stück tiefer in Anbieter-Konventionen einsinkt; das Gegenmittel – Fachlogik in eigene Dienste, Tool-Schicht als dünner Adapter – stand schon [in der Folge über Agent, Modell und Fachdomäne](https://www.mikebild.dev/de/blog/agent-modell-fachdomaene/). Die Modell-Vergleichstabelle als Strategieersatz, gegen die [diese Serie früh argumentiert hat](https://www.mikebild.dev/de/blog/gut-genug-modelle/): Ob mehrere Modelle die eigenen Aufgaben gut genug lösen, zeigt eine konkrete Prüfung gegen genau diese Aufgaben – und dort, wo das der Fall ist, gehört die Energie in den eigenen Anteil, nicht in die Tabelle. Und das endlose Feilen an Formulierungen: Wenn dieselbe Anweisung zum dritten Mal umformuliert wird, damit das Modell sie „endlich versteht“, ist das kein Schreibproblem. Es ist das Signal, dass die Regel in den Pfad gehört, nicht in schönere Worte.\n\nDie Prüffrage ist immer dieselbe, und sie macht die Liste kurz: Hilft es, Prüfung, Freigabe oder Nachweis zu bauen – oder fühlt es sich nur nach Fortschritt an? Viele derzeit diskutierte Frameworks und Agentenmuster kann man in Ruhe reifen lassen. Sie laufen nicht weg, und was davon trägt, ist in einem Jahr noch da.\n\n## Zwischenstand\n\nDamit sind die drei Fragen vom Ende der letzten Folge beantwortet, so gut ich es nach diesen Monaten kann: mit einer Inventur, die Befunde statt Meinungen liefert, einem Schnitt von der Größe eines Vorgangs und Prozessen, die Besitzer, Änderungsweg und Rhythmus festlegen. Beginnen würde ich deshalb mit einem belegten Regelverstoß in einem bereits klein geschnittenen Vorgang – dort ist der Schmerz dokumentiert, der Schnitt klein und das Ergebnis prüfbar. Die Konzepte dahinter sind alt, und das ist ihre wichtigste Eigenschaft: Sie haben schon einmal gehalten.\n\nVor gut acht Monaten begann diese Serie mit einer Session auf meinem Rechner und der Beobachtung, dass sich meine Arbeit verändert; jetzt endet der Weg vorläufig bei Organisationen, die Regeln, Wissen und Verantwortung in eigene Agenten legen. Die nächste Folge prüft die Erwartungen aus dem Auftakt: Was davon ist eingetreten, was nicht – und wohin führt das alles?\n\n## Weiterführende Quellen\n\n- [Domain-Driven Design Reference (Eric Evans)](https://www.domainlanguage.com/ddd/reference/) – ubiquitous language und Bounded Context als Entwurfsarbeit\n- [John Gall: Systemantics](https://en.wikipedia.org/wiki/John_Gall_%28author%29) – warum funktionierende komplexe Systeme aus einfachen funktionierenden Systemen entstehen\n- [Martin Fowler: Strangler Fig Application](https://martinfowler.com/bliki/StranglerFigApplication.html) – das Muster für Übergänge ohne großen Umstellungstag\n- [Building Effective AI Agents (Anthropic)](https://www.anthropic.com/engineering/building-effective-agents) – der Rat, mit dem einfachsten funktionierenden Aufbau zu beginnen"},{"slug":"eigener-agent-pro-fachdomaene","title":"KI-Agenten pro Fachdomäne: Regeln, Freigaben und Nachweise","summary":"Wie ein KI-Agent fachliche Regeln über mehrere Tool-Aufrufe hinweg durchsetzt: mit Vorgangszustand, geprüften Plänen, Freigaben und nachvollziehbarer Ausführung.","tags":["Software","KI","Methodik","Delivery"],"published_at":"2026-07-14T10:00:00+00:00","updated_at":"2026-11-08T13:00:00+00:00","url":"https://www.mikebild.dev/de/blog/eigener-agent-pro-fachdomaene/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/70f30f9c15d391ac21e974541fb6236207eb633fc60bb961e78f6c3e54d5ffdf.mp3","audio_duration_secs":996,"text":"Ein KI-Agent pro Fachdomäne verbindet die Werkzeuge eines Arbeitsfelds mit dessen Regeln, Rechten und Vorgangszustand. Entscheidend ist nicht ein weiterer Chat, sondern ein ausführbarer Pfad, der zusammengehörige Aktionen prüft, bevor sie wirken. Dieser Artikel zeigt, welche Verantwortung dafür beim Modell, beim Tool und beim betriebenen Agentensystem liegt.\n\nEin konstruiertes Beispiel: Ein Agent soll 740 Euro Kulanz erstatten. Über 500 Euro braucht es die Freigabe der Teamleitung. Der Tool-Aufruf über 740 Euro wird abgelehnt; zwei Aufrufe über je 370 Euro würden eine rein aufrufbezogene Prüfung dagegen bestehen. Jeder Aufruf wäre einzeln zulässig, die gemeinsame Wirkung nicht. Genau diese Lücke muss eine Regel über den gesamten Vorgang schließen.\n\n*Teil 11 der Serie „Von der Session zum System“ – hier geht es [zum Auftakt](https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/).*\n\nDas Beispiel beschreibt eine mögliche Schwachstelle, keinen beobachteten Vorfall. Ob ein Modell tatsächlich zwei Teilbeträge vorschlägt, ist offen. Die Architektur muss eine solche Umgehung trotzdem verhindern: Eine Vorgangsregel lässt sich nicht allein als isolierte Prüfung einzelner Aufrufe ausdrücken.\n\nDiese Folge löst damit zwei Versprechen ein, die in der Serie offen sind. Die [Chronik meiner Instruktionsdateien](https://www.mikebild.dev/de/blog/vergebliche-versuche-instruktionsdateien/) endete mit dem Befund, dass nur das eine Regel ist, was die Umgebung erzwingt. Die [letzte Folge](https://www.mikebild.dev/de/blog/agent-modell-fachdomaene/) schloss mit der Frage, warum verbindliche Regeln auf einen eigenen, an die Fachdomäne gebundenen Agenten hinauslaufen – und was der können muss. Die Gutschriften-Konstruktion gibt die Antwort in Kurzform: Regeln brauchen einen Ort, an dem der Zusammenhang sichtbar ist.\n\n## Ein Tool sieht den Aufruf, nicht den Vorgang\n\nZur Erinnerung, wo die Serie steht: Die letzte Folge hat die Fachdomäne aus dem Prompt in die Tools verlegt – typisierte Operationen, deren Namen aus der Fachsprache kommen, deren Eingaben validiert und deren Aufrufe autorisiert werden. Die Prüfung läuft damit im ausführbaren Pfad statt im Prompt: Die 500-Euro-Regel wurde von einer Empfehlung, die das Modell lesen und abwägen kann, zu Code, der bei jedem Aufruf gleich entscheidet.\n\nIn diesem Beispiel sieht die Prüfung nur einen Aufruf. Ein Tool kann auch Vorgangszustand laden und prüfen; entscheidend ist, dass diese Verantwortung ausdrücklich umgesetzt wird. Ohne sie bleiben drei Dinge verborgen.\n\nDer Zusammenhang. Fachliche Regeln sind selten Regeln über Aufrufe – sie sind Regeln über Vorgänge. „Nicht mehr als 500 Euro Kulanz“ meint: pro Fall, pro Kunde, pro Zeitraum. Zwei Gutschriften à 370 Euro zum selben Vorgang verletzen die Regel, ohne dass irgendein einzelner Aufruf sie verletzt. Eine Autorisierung, die nur die Eingabe des aktuellen Aufrufs erhält, kann das nicht bemerken – nicht weil das Tool technisch unfähig wäre, sondern weil ihr der gebundene Vorgangszustand fehlt.\n\nDie Wirkung. Manche Fehler erkennt man nicht am einzelnen Schritt, sondern erst am Gesamtbild – an dem, was nach zwanzig Schritten entstanden sein wird. Wer erst nach der Ausführung prüft, prüft zu spät; die Gutschrift ist dann schon beim Kunden.\n\nDas Gedächtnis. Ob ein Muster auffällig ist, entscheidet sich über Sessions hinweg: dieselbe Rechnung, dreimal in zwei Wochen, von zwei verschiedenen Personen angestoßen. Der einzelne Aufruf weiß davon nichts, und das einzelne Tool ebenso wenig.\n\nWas dem einzelnen Tool-Aufruf fehlt, kann der Agent zusammenführen: den Verlauf, einen materialisierten Plan und persistenten Vorgangszustand – vorausgesetzt, diese Zustände werden explizit gespeichert und an den Vorgang gebunden. Deshalb wandert die Durchsetzung eine Ebene nach oben – aus dem Tool in den Agenten, der die Schleife betreibt. Damit ist der Agent kein austauschbares Zubehör mehr, sondern der Ort, an dem die verbindlichen Regeln einer Fachdomäne liegen: ein betriebenes Softwaresystem mit Plan, Rechten, Freigaben, Nachweisen und klarer Zuständigkeit.\n\n## Vom Vorschlag zum Nachweis: fünf Stationen\n\nWie sieht diese Durchsetzung aus? Der tragende Gedanke ist alt und stammt nicht aus der KI-Welt: Bauen und Ausführen trennen. Wer Infrastruktur als Code betreibt, kennt ihn als `terraform plan` und `terraform apply` – erst entsteht eine vollständige Vorschau dessen, was sich ändern würde, dann entscheidet sich getrennt davon, ob das auch geschieht. Zwischen Vorschlag und Wirkung liegt eine Prüfung, und die sieht nicht den einzelnen Handgriff, sondern das ganze Vorhaben.\n\nAuf Agenten übertragen umfasst der Weg von der Idee zur Wirkung fünf Stationen.\n\nAm Anfang steht der Modellvorschlag: eine textuelle oder strukturierte Ausgabe, so eloquent wie unverbindlich. Ob das Modell Prosa liefert oder fertig formatierte Tool-Aufrufe – es erzeugt damit zunächst nur eine Absichtserklärung ohne autorisierte Wirkung. Daraus wird zweitens ein ausführbarer Plan: eine geordnete Folge typisierter Operationen mit konkreten Parametern, als immutable Revision gespeichert und an den geprüften Zustand gebunden; die Operationen und Parameter sind maschinell auswertbar, eine Begründung kann ergänzend Freitext bleiben. Drittens die fachliche Autorisierung: Der Plan wird als Ganzes gegen die Regeln der Fachdomäne geprüft. Hier fallen zwei Gutschriften zum selben Vorgang auf, hier greifen Summen- und Häufigkeitsregeln, hier entscheidet sich, ob das Vorhaben zulässig, abzulehnen oder freigabepflichtig ist. Viertens, wo die Regeln es verlangen, die menschliche Freigabe: eine Bestätigung durch eine Rolle der Fachdomäne, die das Modell weder erteilen noch ersetzen kann – es darf Vorschläge machen, aber keine menschliche Entscheidung selbst bestätigen. Und fünftens der Nachweis, in zwei Formen: ein Entscheidungsnachweis für jede Ablehnung und jede Freigabe, ein Ausführungsnachweis für jede tatsächlich ausgeführte Operation – wer, was, wann, mit welchem Ergebnis, unter welcher Regelversion.\n\n```mermaid\nflowchart LR\n    M[\"Modellvorschlag<br/>textuell oder strukturiert, unverbindlich\"] --> P[\"Plan<br/>alle geplanten Operationen\"]\n    P --> R{\"fachliche Autorisierung<br/>Regeln der Fachdomäne\"}\n    R -- \"verletzt\" --> N[\"Ablehnung<br/>begründet und protokolliert\"]\n    R -- \"freigabepflichtig\" --> H[\"menschliche Freigabe\"]\n    R -- \"zulässig\" --> D{\"gebundener Zustand<br/>noch aktuell?\"}\n    H -- \"bestätigt\" --> D\n    H -- \"abgelehnt\" --> A\n    D -- \"ja\" --> E[\"Ausführung\"]\n    D -- \"nein\" --> R\n    E --> A[\"Entscheidungs- oder Ausführungsnachweis<br/>wer · was · wann · Regelversion\"]\n    N --> A\n```\n\nIm Kleinen kennt das jeder, der mit Coding-Agenten arbeitet: erst der Diff, dann der Commit. Der Unterschied ist, dass dort ein Mensch freiwillig hinschaut – und am Freitagnachmittag eben auch mal nicht. In einem Agenten, der eine Fachdomäne verantwortet, ist die Prüfung keine Gewohnheit, sondern Teil des Pfads. Sie findet statt, ob jemand daran denkt oder nicht.\n\n## Ein Nein, das ein Nein bleibt\n\nDie zweite Fähigkeit folgt direkt aus der Konstruktion vom Anfang. Ein Modell, das auf eine Ablehnung trifft, kann umformulieren, verhandeln, Umwege suchen. Das wäre keine Böswilligkeit: Weitersuchen ist genau das Verhalten, das wir an anderer Stelle schätzen, wenn ein Modell einen Testfehler dreimal anders angeht, bis der Build grün ist. Statt diese Beharrlichkeit wegtrainieren zu wollen, kann man ihr eine Grenze setzen, die nicht mitverhandelt.\n\nDafür muss die Freigabe außerhalb des Gesprächs liegen. Eine freigabepflichtige Operation wartet, bis ein Mensch sie bestätigt – und kein noch so eloquenter Text im Kontext bewegt dieses Gatter, weil das Gatter den Text gar nicht liest. Das Modell kann seine zwei 370-Euro-Gutschriften vorschlagen, so oft es will: Die Summenregel schlägt beim Plan an, die Freigabe bleibt offen, der Vorgang liegt beim zuständigen Menschen. Wichtig ist dabei, wer dieses Ja gibt: nicht „irgendein Admin“, sondern eine Rolle aus der Fachdomäne – die Teamleitung, die auch ohne KI über 500-Euro-Kulanzen entscheidet. Die Freigabe bildet eine bestehende fachliche Verantwortung ab, sie erfindet keine neue.\n\nSkizziert an der Tool-Definition aus der letzten Folge sieht die Erweiterung ungefähr so aus. Der Codeblock ist eine vereinfachte, illustrative Skizze: Die API `defineTool` und die Rolle `billing_team_lead` sind frei gewählte Platzhalter.\n\n```ts\nconst issueCreditNote = defineTool({\n  name: \"issue_credit_note\",\n  input: z.object({\n    invoiceId: z.string(),\n    amount: z.number().positive(),\n    reason: z.enum([\"complaint\", \"goodwill\", \"pricing_error\"]),\n  }),\n  approval: {\n    required: ({ input, history }) =>\n      input.amount + history.totalCreditedFor(input.invoiceId) > 500,\n    approvers: [\"billing_team_lead\"],\n  },\n  audit: { record: [\"user\", \"input\", \"approval\", \"result\"] },\n});\n```\n\nDie entscheidende Zeile ist die mit `history`. Die Freigabepflicht hängt nicht am einzelnen Betrag, sondern an der Summe über den Vorgang. Autoritativ liegt dieser Zustand in der Fachdomäne, nicht im Gespräch: Der Agent bindet einen geprüften Stand des Vorgangs an den Plan und reicht ihn als Eingabe in die Autorisierung – genau die Sicht, die der isolierten Einzelaufrufprüfung fehlt. Eine Bindung allein genügt allerdings nicht: Damit parallele Aufrufe die 500-Euro-Grenze nicht doch überschreiten, muss die Ausführung den gebundenen Stand vorab erneut gegen den aktuellen Domänenzustand prüfen und bei zwischenzeitlichen Änderungen abbrechen oder neu autorisieren.\n\n## Nachweis statt Erinnerung\n\nDie dritte Fähigkeit ist die unscheinbarste: Jede Ausführung hinterlässt einen Nachweis. Wer hat den Vorgang angestoßen, welche Planrevision wurde geprüft, was genau wurde ausgeführt, mit welchen Eingaben, was kam heraus, wer hat freigegeben – und, leicht zu übersehen: welche Fassung der Regeln galt dabei. Fachregeln ändern sich. Aus 500 Euro werden 300, ein Freigabeweg wird umgebaut. Wenn drei Monate später jemand fragt, warum eine Gutschrift damals durchging, muss die Antwort lauten können: nach Regelstand vom März war sie zulässig, hier ist der Eintrag.\n\nEin solches Protokoll existiert nicht, weil jemand daran gedacht hat mitzuschreiben. Es existiert, weil der Pfad es erzeugt – Ablehnungen eingeschlossen. Auch das Nein hinterlässt eine Spur, und gerade die Neins zeigen, wo Regeln zu eng oder Tools zu grob geschnitten sind. Auditierbarkeit ist keine Compliance-Übung, die man hinterher anbaut. Sie ist eine Eigenschaft des Pfads, oder sie ist nicht.\n\n## Warum pro Fachdomäne\n\nBis hierher ist begründet, warum verbindliche Regeln in prüfbare Zustandsübergänge und Autorisierungsgrenzen gehören statt nur in Prompt-Prosa. Offen ist der zweite Teil der Frage aus der letzten Folge: Warum ein *eigener* Agent pro Fachdomäne – und nicht ein großer, der alles kann?\n\nWeil jede der drei Fähigkeiten Fachlichkeit voraussetzt. Eine Plan-Prüfung kann nur bewerten, was sie versteht: Dass zwei Gutschriften zum selben Vorgang gehören, dass ein Kunde im Mahnverfahren steckt, dass „Kulanz“ und „Preisfehler“ verschiedene Folgen haben – das sind Begriffe und Zusammenhänge einer Domäne, keine generischen Konzepte. Die Freigabe braucht Rollen, die es nur dort gibt. Der Nachweis muss in der Sprache geführt werden, in der später jemand fragt. Ein generischer Agent kann Dateien lesen und Kommandos ausführen; er kann nicht wissen, was eine unzulässige Gutschrift ist.\n\nDazu kommen die Rechte. Ein Agent, der die Tools aller Domänen trägt, kann einen entsprechend breiten Berechtigungsumfang benötigen, wenn Rechte nicht pro Aufruf delegiert und begrenzt werden – und wird mit solchen dauerhaften Rechten zu einem lohnenden Angriffsziel. Die OWASP führt „Excessive Agency“ in ihren Top-10-Risiken für LLM-Anwendungen: zu viele Tools, zu weite Berechtigungen, zu viel Autonomie in einem einzigen System. Simon Willison hat mit seiner „lethal trifecta“ die giftigste Kombination benannt – Zugriff auf private Daten, Kontakt mit nicht vertrauenswürdigen Inhalten und die Fähigkeit, nach außen zu kommunizieren, in einem Agenten vereint. Der Zuschnitt nach Fachdomänen ist deshalb auch eine Sicherheitsentscheidung – eine durchsetzbare Grenze wird er allerdings erst mit getrennten Identitäten, Rechten, Zuständen und Schnittstellen: wenige Tools, eng gefasste Berechtigungen, klarer Wirkungskreis. Der Gutschriften-Agent kann keine Deployments anstoßen, und der Deployment-Agent kennt keine Kundendaten.\n\nUnd schließlich das Wissen. Kuratierte Erkenntnisse, [geerdete Antworten](https://www.mikebild.dev/de/blog/sauberes-grounding-antworten-erden/), [Wissen, das die Session überlebt](https://www.mikebild.dev/de/blog/wissen-das-die-session-ueberlebt/) – all das gehört immer zu einer bestimmten Domäne. Ein Agent, der eine Fachdomäne verantwortet, trägt ihre Quellen, ihre Konventionen, ihre Sonderfälle. Nicht als domänenübergreifendes Sammelsurium, sondern als gepflegter, überschaubarer Bestand mit Besitzern.\n\nAn dieser Stelle schließt sich auch der Bogen zur [Varianz aus dem Team-Teil dieser Serie](https://www.mikebild.dev/de/blog/mein-erfolg-laesst-sich-nicht-kopieren/). Damals war der Befund: Zwischen Personen, Aufgaben und Sessions streut das Ergebnis, und Dateien mit Regeln ändern daran wenig. Der Domänen-Agent löst dieses Problem nicht, indem er Menschen gleich macht. Er verschiebt die Grenze, unterhalb derer Varianz Schaden anrichten kann. Oberhalb bleibt alles verschieden: Wie jemand formuliert, erkundet, delegiert, welchen Weg eine Session nimmt. Unterhalb ist es gleich: Was am Ende wirkt, geht durch dieselbe Autorisierung, dieselbe Freigabe, denselben Nachweis – bei der erfahrenen Kollegin genauso wie beim Praktikanten, am Freitagnachmittag genauso wie am Dienstagmorgen.\n\n## Was der eigene Agent können muss\n\nDamit lässt sich die Frage aus der letzten Folge kompakt beantworten. Ein Agent, der eine Fachdomäne verbindlich vertreten soll, braucht:\n\n- Tools in der Fachsprache der Domäne – typisiert, validiert, autorisiert\n- die Trennung von Modellvorschlag und Wirkung, mit Prüfung des ganzen Plans statt einzelner Aufrufe\n- Regeln über Vorgänge: Summen, Häufigkeiten, Zusammenhänge über Aufrufe und Sessions hinweg\n- Freigaben im Pfad, außerhalb des Gesprächs, durch Rollen der Fachdomäne\n- Nachweis jeder Ausführung und jeder Ablehnung – mit Planrevision, Regelrevision, Freigabeentscheidung, Aufrufer, Ergebnis und Fehlerstatus\n- die Rechte des jeweiligen Aufrufers statt eigener Allmacht – eng geschnitten, domänenbezogen\n- kuratierte Wissensquellen der Domäne, mit Besitzern und Pflege\n\nAuf dieser Liste steht nichts, was ein erfahrenes Team nicht längst gebaut hätte: Validierung, Autorisierung, Workflow, Audit, Berechtigungsmodell. Neu ist allein, wofür – nicht für eine Oberfläche, in der Menschen klicken, sondern für eine Schleife, in der ein Modell handelt. Wer so einen Agenten baut, legt gewöhnliche, solide Software um variable Modellvorschläge herum. Genau deshalb ist die Aufgabe zu schaffen.\n\nZu schaffen heißt nicht nebenbei. Was hier entsteht, ist ein betriebenes Softwaresystem mit Lebenszyklus, kein Wochenendprojekt – die Gegenrechnung aus der letzten Folge gilt unverändert, vom Pflegeaufwand bis zur Gefahr, aus lauter Vorsicht ein Formular mit Chat-Oberfläche zu bauen. Und es gehört niemandem allein: Die Regeln kommen aus dem Fachbereich, die Durchsetzung aus der Entwicklung, und keiner von beiden kann den Teil des anderen übernehmen. Unabhängig vom Implementierungsaufwand muss das Team klären, wer solche Regeln künftig vorschlagen, prüfen und freigeben darf – das ist Organisationsarbeit.\n\n## Zwischenstand\n\nDer Weg dieser Serie lässt sich jetzt in drei Stationen erzählen. Regeln als Prosa: gelesen, gewichtet, gelegentlich überstimmt – Empfehlungen, [wie ich sie ein halbes Jahr lang vergeblich verschärft habe](https://www.mikebild.dev/de/blog/vergebliche-versuche-instruktionsdateien/). Regeln im Tool: geprüft bei jedem Aufruf, verlässlich im Einzelnen, blind für den Zusammenhang. Und Regeln im Pfad: ein eigener Agent, der die Fachdomäne kennt, Pläne prüft, bevor sie wirken, Freigaben einholt, die kein Modell wegformulieren kann, und Nachweise erzeugt, die Fragen von übermorgen beantworten. Erst auf dieser Stufe ist aus der Empfehlung eine Regel geworden, die auch dann hält, wenn niemand hinschaut.\n\nWas diese Folge bewusst offenlässt: den Weg dorthin. Die Regeln einer Fachdomäne liefert kein installiertes Produkt automatisch mit – sie entstehen in der Auseinandersetzung mit der Domäne selbst, und die wirft sofort praktische Fragen auf. Wie findet ein Team heraus, welche Regeln in den Pfad gehören und welche Prosa bleiben dürfen? Wie schneidet man die erste Domäne, ohne sich zu verheben? Welche Konzepte, Methoden und Prozesse tragen dabei – und welche Moden kann man getrost auslassen? Von diesen Hilfsmitteln handelt die nächste Folge.\n\n## Was SubKit dazu beiträgt\n\n[SubKit](https://subkit.mikebild.dev/de/) ist mein Werkzeug für wiederkehrende Abläufe: Ein Assistent entwirft den Workflow, eine Person prüft ihn und gibt seine Reichweite frei. Die Ausführung folgt den festgelegten Schritten; Rechte und Ergebnisse werden nachvollziehbar. KI-Clients können freigegebene Workflows über MCP als Werkzeuge nutzen.\n\nDas ersetzt nicht die Modellierung der Fachdomäne. Eine fallbezogene Kulanzgrenze, gemeinsam genutzter Vorgangszustand und der Umgang mit parallelen Anfragen müssen im jeweiligen Ablauf und den angebundenen Systemen ausdrücklich umgesetzt werden. SubKit liefert dafür nicht automatisch die Geschäftsregeln. Die [Anwendungsfälle](https://subkit.mikebild.dev/de/anwendungsfaelle/) zeigen mögliche Einstiege; die Rolle der Schnittstelle erläutert der Artikel [MCP: Wenn Software Fähigkeiten anbietet](https://www.mikebild.dev/de/blog/mcp-softwarefaehigkeiten-agenten/).\n\n## Weiterführende Quellen\n\n- [Terraform: plan und apply](https://developer.hashicorp.com/terraform/cli/commands/plan) – die Trennung von Vorschlag und Wirkung im Infrastruktur-Alltag\n- [OWASP Top 10 für LLM-Anwendungen: Excessive Agency](https://genai.owasp.org/llmrisk/llm062025-excessive-agency/) – zu viele Tools, zu weite Rechte, zu viel Autonomie\n- [Simon Willison: The lethal trifecta](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/) – warum die Kombination aus Datenzugriff, Fremdinhalten und Außenkommunikation gefährlich ist\n- [Domain-Driven Design Reference (Eric Evans)](https://www.domainlanguage.com/ddd/reference/) – Fachsprache und Domänenschnitt als Entwurfsarbeit"},{"slug":"mcp-softwarefaehigkeiten-agenten","title":"MCP-Sicherheit: Tools, Rechte und Freigaben für KI-Agenten","summary":"Wie du MCP-Tools sicher in Anwendungen einbindest: serverseitige Rechteprüfung, menschliche Freigaben, Schutz vor Prompt Injection und nachvollziehbare Aufrufe.","tags":["Software","KI","Architektur"],"published_at":"2026-07-13T10:00:00+00:00","updated_at":"2026-11-09T06:23:14.832389+00:00","url":"https://www.mikebild.dev/de/blog/mcp-softwarefaehigkeiten-agenten/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/c4ef3d567945612c3bc3cbaff94a961e16e47f096c6e921630ccea1ed0062962.mp3","audio_duration_secs":874,"text":"# MCP-Sicherheit: Tools, Rechte und Freigaben für KI-Agenten\n\nEin MCP-Tool kann Daten lesen, eine Revision veröffentlichen oder einen externen Dienst aufrufen. Damit wird aus einer Modellantwort eine Handlung mit Folgen. Für Entwickler und Betreiber lautet die Sicherheitsfrage deshalb: Wer darf welche Operation ausführen, an welchen Daten und nach welcher Freigabe?\n\nDas Model Context Protocol standardisiert die Verbindung zu solchen Fähigkeiten. Die Regeln deiner Anwendung musst du weiterhin selbst durchsetzen. Dieser Artikel zeigt das an einem konstruierten Publishing-Beispiel: dieselben Rechte über Weboberfläche, HTTP und MCP, eine überprüfbare Freigabe vor der Veröffentlichung und ein nachvollziehbarer Umgang mit Wiederholungen und fremden Inhalten.\n\nZur Einordnung der Schnittstellen findest du hier die [Unterschiede zwischen MCP, HTTP, Webhooks und A2A](/de/blog/mcp-http-webhooks-a2a-einfach-erklaert/).\n\n## Nicht die App, sondern die Fähigkeit\n\nDas Model Context Protocol ist ein offener Standard, mit dem KI-Anwendungen externe Systeme anbinden können. Die offizielle Dokumentation beschreibt MCP als Verbindung zwischen KI-Anwendungen und Datenquellen, Tools und Workflows. Die Spezifikation spricht von Hosts, Clients und Servern: Eine LLM-Anwendung ist der Host, darin sitzt ein Client, und ein Server stellt Kontext oder Fähigkeiten bereit.\n\nDas klingt zunächst nach Integrationsinfrastruktur. Tatsächlich steckt darin eine größere Verschiebung. Eine Anwendung ist dann nicht mehr allein eine Oberfläche für Menschen. Sie ist zugleich eine Menge adressierbarer Fähigkeiten für Agenten.\n\nDer klassische Bedienpfad sieht so aus:\n\n```mermaid\nflowchart LR\n  Human[Mensch] --> UI[Benutzeroberfläche]\n  UI --> App[Anwendung]\n  App --> Result[Ergebnis]\n```\n\nDer Mensch öffnet eine Oberfläche, sucht die richtige Stelle, füllt Felder aus, klickt auf Vorschau, klickt auf Veröffentlichen, prüft danach Feed und Suche. Die Anwendung ist das sichtbare Bündel aus Bedienung, Fachlogik und Zustand.\n\nMit einem Agenten kommt ein zweiter Pfad hinzu:\n\n```mermaid\nflowchart LR\n  Human[Mensch] --> Agent[Agent]\n  Agent --> Validate[validate_markdown]\n  Agent --> Preview[create_preview]\n  Agent --> Publish[publish_revision]\n  Agent --> Rollback[rollback_release]\n  Agent --> Search[rebuild_search_index]\n```\n\nDie Oberfläche verschwindet dadurch nicht, und sie wird auch nicht zweitrangig. Aber sie ist nicht mehr der einzige Zugang zur Arbeit. Ein Agent muss nicht wissen, wo der Button sitzt. Er muss wissen, welche Fähigkeit existiert, welche Eingaben sie erwartet, welche Ergebnisse zurückkommen und welche Regeln gelten.\n\nDas ist der Kern: MCP macht Softwarefähigkeiten für Agenten adressierbar. Was es nicht tut: die Produktdomäne ersetzen oder ihre menschliche Bedienoberfläche verdrängen.\n\n## Ein Publishing-System als Beispiel\n\nDas folgende Publishing-System ist ein konstruiertes Architekturbeispiel, keine fertige Integration und kein Kundenfall. Der Ablauf ist vertraut: Markdown kommt hinein, eine Vorschau wird gebaut, jemand prüft sie, danach wird eine Revision veröffentlicht. Im Fehlerfall braucht es Rollback. Nach dem Release müssen Index, Feed und Cache stimmen.\n\nIn einer klassischen Oberfläche sind diese Schritte an Seiten und Buttons gebunden. Für Menschen ist das sinnvoll: Eine gute GUI zeigt Kontext, kann ungültige Eingaben blockieren und macht Zustände wie Freigabestände sichtbar. Für einen Agenten ist dieselbe Oberfläche dagegen Umweg und Fehlerquelle. Er müsste klicken, warten, aus HTML lesen, interpretieren, wieder klicken. Das ist Automatisierung auf der falschen Ebene.\n\nAls MCP-Fähigkeit sähe derselbe Arbeitsschritt anders aus:\n\n```ts\nconst publishRevision = defineTool({\n  name: \"publish_revision\",\n  description: \"Publish an approved content revision\",\n  inputSchema: {\n    type: \"object\",\n    properties: {\n      site: { type: \"string\" },\n      revisionId: { type: \"string\" },\n      reason: { type: \"string\" }\n    },\n    required: [\"site\", \"revisionId\", \"reason\"]\n  }\n});\n```\n\nDer wichtige Teil ist nicht die konkrete Syntax. Der wichtige Teil ist die Verschiebung: Aus einem Bedienpfad wird eine benannte Fähigkeit. `publish_revision` ist nicht „klicke auf den grünen Button“. Es ist eine fachliche Operation mit Eingaben, Ergebnis, Rechten und Fehlern.\n\nSobald die Operation so vorliegt, kann ein Agent damit arbeiten. Er kann vorher `validate_markdown` aufrufen, bei Warnungen zurückfragen, eine Vorschau anfordern, den Freigabestand prüfen und erst danach veröffentlichen. Der Agent bedient dabei keinen Bildschirm. Der hier beschriebene MCP-Server stellt ihm fachliche Werkzeuge mit Eingabeschema und Berechtigungsprüfung bereit.\n\n## Drei Zugänge, eine Domäne\n\nEin solches Publishing-System kann unter `/mcp` eine domänenorientierte Werkzeugoberfläche bereitstellen, die über dieselbe Veröffentlichungsdomäne läuft wie REST und die Weboberfläche. Die MCP-Werkzeuge bleiben dabei an die Produkt-Scopes gebunden.\n\nEntscheidend ist, was diese Konstruktion nicht ist: MCP ist kein zweites System neben dem Produkt. Weboberfläche, HTTP und MCP greifen auf dieselbe Veröffentlichungsdomäne mit ihren Revisionen und Berechtigungsgrenzen zu. Die gemeinsame Domänenlogik entscheidet, was gilt – nicht der Kanal, über den die Anfrage kam.\n\nDas hat eine praktische Folge für die Werkzeugliste selbst. Werkzeuge, die der aktuelle Principal nicht verwenden darf, müssen gar nicht erst in der Liste erscheinen. Der Agent entdeckt also nur Fähigkeiten, die seine Scopes hergeben. Und wo eine menschliche Freigabe vorgesehen ist, liefert der Agent diese Freigabe nicht selbst – sie bleibt ein eigener Schritt außerhalb seiner Reichweite.\n\nDiese Filterung ist mehr als Komfort, aber sie ist Sichtbarkeit, nicht der eigentliche Schutz. Ein Agent, der ein Werkzeug nicht in der Liste sieht, entdeckt es nicht auf regulärem Weg. Der Schutz vor unberechtigten Aufrufen entsteht durch die serverseitige Autorisierung; die Werkzeuge bleiben an die Scopes des jeweiligen Produkts gebunden. Ein fehlendes Release-Recht lässt sich deshalb auch nicht dadurch beschaffen, dass man von der Weboberfläche zu MCP wechselt. Der Zugang wechselt, die Berechtigung nicht.\n\n## Tool-Zugriff ist eine Berechtigung\n\nEin Agent, der Text schreibt, kann Unsinn produzieren. Ein Agent, der Tools aufruft, kann Wirkung erzeugen. Er kann Daten lesen, Dateien ändern, Releases auslösen oder externe Systeme benachrichtigen. Deshalb ist Tool-Zugriff kein Komfortfeature, sondern eine Berechtigung. Dass ein Werkzeug auffindbar ist, ist noch keine Erlaubnis zur Wirkung; der Aufruf bleibt eine autorisierte Operation, die das System prüft. In einem Remote-MCP-Zugang sind diese Aufgaben verteilt: Die Zustimmung des Nutzers vor einem Tool-Aufruf ist nach der MCP-Spezifikation Sache des Hosts, während das Produkt jeden Aufruf serverseitig gegen die Scopes des Principals prüft.\n\nDie MCP-Spezifikation sagt selbst, dass solche Fähigkeiten Sicherheits- und Vertrauensfragen mitbringen: Nutzer müssen Datenzugriff und Operationen verstehen und kontrollieren können, Tools sind mit Vorsicht zu behandeln, Hosts müssen Zustimmung vor Tool-Aufrufen einholen. Zustimmung und Zugriffskontrolle gehören deshalb in den Ausführungspfad jedes Tool-Aufrufs.\n\nIn einem produktiven System reicht deshalb eine Tool-Definition nicht aus. Daneben gehört eine Policy:\n\n```ts\ntype ToolPolicy = {\n  tool: string;\n  allowedRoles: string[];\n  requiresApproval: boolean;\n  requiresIdempotencyKey: boolean;\n  maxRuntimeMs: number;\n};\n\ntype ToolAuditEntry = {\n  tool: string;\n  inputHash: string;\n  idempotencyKey?: string;\n  decision: \"allowed\" | \"blocked\" | \"approved\";\n  outcome: \"succeeded\" | \"failed\" | \"unknown\";\n  actorId: string;\n  runId: string;\n};\n```\n\nDamit wird aus „der Agent darf veröffentlichen“ eine überprüfbare Regel. Wer durfte welchen Aufruf machen? Welche Eingabe wurde verwendet? War eine Freigabe nötig? Wurde der Aufruf ausgeführt oder blockiert? Bei schreibenden Operationen kommt eine weitere Frage hinzu: Was passiert, wenn der Agent nach einem Timeout denselben Aufruf wiederholt? Ein Idempotenzschlüssel hilft nur, wenn der Server ihn an die Operation und ihre Eingaben bindet, konkurrierende Wiederholungen abfängt und das Ergebnis zuverlässig speichert. Bei externen Nebenwirkungen muss auch der nachgelagerte Dienst Wiederholungen erkennen oder das System den unklaren Ausgang abgleichen. Ein Schlüssel allein garantiert keine einmalige Ausführung.\n\nMCP standardisiert Auffindbarkeit und Aufruf eines Tools. Es macht die Operation weder fachlich sicher noch automatisch idempotent oder auditierbar. Diese Eigenschaften muss die angebotene Fähigkeit selbst tragen.\n\n## Prompt Injection: Inhalte dürfen keine Rechte vergeben\n\nEin eingelesener Artikel kann Anweisungen enthalten, etwa eine andere Revision zu veröffentlichen oder Daten an eine fremde Adresse zu schicken. Behandle solche Inhalte als nicht vertrauenswürdige Daten. Ein Prompt, der das Modell zum Ignorieren auffordert, ist keine ausreichende Zugriffskontrolle. Die erlaubten Operationen, Zieladressen und Freigaben müssen außerhalb des gelesenen Dokuments geprüft werden.\n\nAuch Tool-Beschreibungen und Kennzeichnungen wie „nur lesend“ sind kein Beweis für das tatsächliche Verhalten eines fremden Servers. Prüfe, welchen Server du verbindest und welche Rechte du ihm gibst. Die [Sicherheitsgrundsätze der MCP-Spezifikation](https://modelcontextprotocol.io/specification/2025-06-18#security-and-trust--safety) benennen diese Vertrauensgrenze ausdrücklich.\n\nFür einen Remote-MCP-Server kommt die Absicherung der Anmeldung hinzu: Prüfe, ob ein Zugriffstoken für deinen Server bestimmt ist; reiche empfangene Tokens nicht ungeprüft an nachgelagerte Dienste weiter. Fordere nur die benötigten Rechte an. Diese Maßnahmen und die Risiken von Token-Weitergabe beschreibt die offizielle Dokumentation zu [MCP Security Best Practices](https://modelcontextprotocol.io/docs/2025-11-25/tutorials/security/security_best_practices).\n\nFür das Publishing-Beispiel würde ich vor dem Betrieb mindestens folgende Fälle prüfen:\n\n- Ein Nutzer ohne Veröffentlichungsrecht ruft das Tool direkt auf: Der Server lehnt ab, auch wenn der Tool-Name bekannt ist.\n- Eine Freigabe gilt für Revision A, der Aufruf nennt Revision B: Der Server lehnt ab.\n- Ein Dokument verlangt den Versand an eine fremde Adresse: Die konfigurierte Grenze bleibt wirksam.\n- Ein Aufruf läuft in einen Timeout: Vor einem erneuten Schreibversuch wird sein Ergebnis geklärt oder die Wiederholung wirksam dedupliziert.\n- Eine fehlgeschlagene oder abgelehnte Operation erscheint im Verlauf mit ihrem tatsächlichen Ergebnis.\n\nDas ist eine Prüfliste für dieses Architekturbeispiel, kein vollständiger Sicherheitsnachweis.\n\n## Der falsche und der richtige Schnitt\n\nIch glaube nicht, dass Anwendungen in kleine MCP-Dienste zerfallen und danach keine Produkte mehr sind. Eine gute Anwendung bündelt mehr als Funktionen: Sie bündelt Verständnis, Bedienung, Datenmodell, Rechte, Feedback und Verantwortung. Aber an den Rändern werden Fähigkeiten einzeln adressierbar, die bisher an Bedienpfade gebunden waren.\n\nEine Fähigkeit ist gut geschnitten, wenn sie fachlich verständlich, technisch prüfbar und betrieblich verantwortbar ist. Der falsche Schnitt wäre ein generisches `run_sql` oder `click_button` – ein Werkzeug, das die Fachregeln umgeht, statt sie zu tragen. Der bessere Schnitt ist eine Operation mit Fachabsicht: `publish_revision`, `rollback_release`, `create_preview`. Der Agent soll nicht an der Datenbank vorbeiarbeiten. Er soll innerhalb derselben Regeln arbeiten, die auch für Weboberfläche und HTTP gelten.\n\nEin Standard nimmt dabei Integrationsarbeit weg, aber keine Produktverantwortung. Anthropic hat MCP Ende 2024 gegen genau das Problem eingeführt, dass jede neue Datenquelle eine eigene Anbindung brauchte. Die Spezifikation standardisiert den Austausch über JSON-RPC und beschreibt Resources, Prompts und Tools. Schlechte Fähigkeiten bleiben schlecht, auch wenn sie standardisiert angeboten werden.\n\n## Was mit Bedienoberflächen passiert\n\nDie stärkste Version der Zukunftsthese lautet: Wenn Agenten Software bedienen, werden GUIs zweitrangig. Ich halte das für falsch.\n\nRoutinepfade werden sich verschieben. Wenn ich einem Agenten sagen kann, er soll einen Entwurf prüfen, eine Vorschau bauen und mir nur die Abweichungen zeigen, dann will ich dafür nicht durch fünf Masken klicken. Das gilt besonders für wiederkehrende Arbeit, die gut beschrieben und sauber begrenzt ist.\n\nAber Menschen brauchen Oberflächen für Exploration, Kontrolle, Ausnahmefälle und Vertrauen. Eine Vorschau ist eine GUI. Ein Diff ist eine GUI. Ein Audit-Log ist eine GUI. Ein Freigabedialog ist eine GUI. Gerade wenn Agenten mehr ausführen, werden gute Kontrolloberflächen wichtiger. Die Kontrolloberfläche dient dazu, dass ein Mensch versteht, was ein Agent getan hat, und dort eingreift, wo sein Urteil gefragt ist.\n\nKontrolloberfläche und MCP konkurrieren also nicht um dieselbe Aufgabe. Die Kontrolloberfläche richtet sich an menschliche Bedienung und Kontrolle, MCP an agentische Aufrufe. Für beide gelten die Berechtigungsgrenzen derselben Domäne.\n\n## Was ich daraus mitnehme\n\nMCP ist für mich weniger eine spektakuläre Technik als ein guter Anlass, Software anders zu beschreiben. Nicht allein über die Daten, die ein System hält, und nicht allein über die Oberfläche, die es anbietet – sondern über die Arbeit, die es sicher, nachvollziehbar und prüfbar erledigen kann.\n\nDas ist eine Architekturfrage. Wer Fähigkeiten nur als dünne Hülle um interne Funktionen veröffentlicht, baut wahrscheinlich neue Risiken. Ein fachlicher Schnitt, Rechte, Idempotenz und Auditierbarkeit schaffen eine kontrollierbare Grundlage für Agenten – eine Qualitätsgarantie sind sie nicht.\n\nNicht jede Anwendung braucht sofort einen MCP-Server. Viele Systeme sind mit einer guten API, einem CLI oder einer klaren Oberfläche besser bedient. Wo es passt, gilt aber: Software, die von Agenten genutzt werden soll, muss ihre Fähigkeiten explizit machen – und sie muss dieselben Berechtigungsgrenzen über jeden Zugang durchsetzen.\n\n## Wo SubKit dazu passt\n\nBei [SubKit](https://subkit.mikebild.dev/de/) hilft die KI beim Entwerfen eines Workflows. Eine Person prüft die Vorschau und gibt seine Reichweite frei. Danach läuft der festgelegte Workflow; die KI entscheidet beim Ausführen nicht über den weiteren Weg. Jeder Lauf wird auf Rechte geprüft und aufgezeichnet.\n\nMCP wird in beide Richtungen genutzt: Ein Workflow kann als Tool für einen KI-Client dienen; ein externer MCP-Server kann als Konnektor angebunden werden. Welche externen Tools verwendet werden dürfen, wird dabei gesondert festgelegt. Der zusätzliche Zugang ersetzt weder die Freigabe noch die Rechte der aufrufenden Person.\n\nDas ist ein konkreter Produktbezug, keine Behauptung, dass SubKit jeden MCP-Server sicher macht oder fachliche Regeln automatisch kennt. Die Regeln deiner Domäne und die Rechte externer Systeme bleiben zu prüfen. Wie solche Regeln von Modellentscheidungen getrennt werden können, vertieft der Artikel [KI-Agenten pro Fachdomäne: Regeln, Freigaben und Nachweise](/de/blog/eigener-agent-pro-fachdomaene/).\n\n:::note\n**Einordnung und Quellenstand**\n\nDas Publishing-System und die Codeausschnitte sind konstruiert und illustrieren Architekturentscheidungen. Der Beitrag verwendet die MCP-Spezifikation vom 18. Juni 2025 und die verlinkten Security Best Practices. Die Sicherheitsabschnitte wurden am 9. November 2026 gegen diese Primärquellen geprüft; daraus folgt keine Aussage über die Sicherheit einer konkreten Installation. Der SubKit-Abschnitt beruht auf der bestätigten Produktbeschreibung vom 8. November 2026.\n:::\n\n## Weiterführende Quellen\n\n- [Model Context Protocol: Spezifikation 2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18)\n- [Model Context Protocol: Security Best Practices](https://modelcontextprotocol.io/docs/2025-11-25/tutorials/security/security_best_practices)\n- [Model Context Protocol: Einführung](https://modelcontextprotocol.io/docs/getting-started/intro)\n- [SubKit: Workflows, Freigaben und Betrieb](https://subkit.mikebild.dev/de/)"},{"slug":"agent-modell-fachdomaene","title":"Agent, Modell, Fachdomäne","summary":"Das Modell ist gemietet, der Agent kann unter eigener Kontrolle stehen – entscheidend ist, ob die Fachdomäne als Prosa im Prompt liegt oder als geprüfte Schnittstelle im Agenten.","tags":["Software","KI","Methodik","Delivery"],"published_at":"2026-07-13T10:00:00+00:00","updated_at":"2026-08-17T17:34:45.985779+00:00","url":"https://www.mikebild.dev/de/blog/agent-modell-fachdomaene/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/3e0e35359b093a19a35f870d05b2c12e1da9b740e9f004e68375982acb764d0a/agent-modell-fachdomaene-vorlesen.mp3","audio_duration_secs":861,"text":"Vor einiger Zeit saß ich in einem großen Konzern in einer Runde, die eine „Agenten-Strategie“ für das kommende Jahr beschließen sollte. An der Wand eine Folie mit drei Spalten: drei Anbieter, darunter Häkchen für Funktionen, Preise pro Nutzer, Compliance-Siegel. Eine Stunde lang drehte sich das Gespräch um die Frage, welcher Agent der beste sei. Irgendwann habe ich eine andere Frage gestellt: Was von alledem gehört uns hinterher eigentlich? Wenn wir in drei Jahren den Anbieter wechseln – was nehmen wir mit? Es wurde still. Nicht, weil die Frage besonders originell gewesen wäre, sondern weil die Folie auf sie keine Antwort hatte.\n\n*Teil 10 der Serie „Von der Session zum System“ – hier geht es [zum Auftakt](https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/).*\n\nDie [letzte Folge](https://www.mikebild.dev/de/blog/wissen-das-die-session-ueberlebt/) endete mit der Frage, wem ich kuratiertes Wissen in die Hand gebe – und wo der generische Agent an seine Grenzen kommt. Um sie zu beantworten, muss ich zuerst auseinandernehmen, was in dem Wort „Agent“ alles zusammengepackt wird. Denn die stille Minute im Konferenzraum hatte einen einfachen Grund: Die Runde hat über Agenten gesprochen wie über ein Gerät, das man kauft, aufstellt und benutzt. Dieses Bild führt in die Irre – und zwar genau an der Stelle, an der es fürs Unternehmen teuer wird.\n\n## Ein Agent ist Software\n\nWas ein Agent wirklich ist, lässt sich in einem Satz sagen. Simon Willison formulierte im Herbst 2025 eine dafür brauchbare Arbeitsdefinition: Ein Agent ist ein Modell, das in einer Schleife Tools aufruft, um ein Ziel zu erreichen. Mehr steckt nicht dahinter. Kein Bewusstsein, keine Absicht, keine Magie.\n\nDa ist eine Schleife: Das Modell bekommt eine Aufgabe und den bisherigen Verlauf, schlägt den nächsten Schritt vor, der Agent führt ihn aus, das Ergebnis fließt zurück – bis eine Stoppbedingung greift. Da sind Tools: Funktionen, die der Agent dem Modell anbietet und deren Aufrufe er ausführt. Da ist Kontext: alles, was der Agent dem Modell mitgibt, von Dateien über Suchergebnisse bis zu Instruktionen. Da ist Zustand: was zwischen den Schritten und über die Session hinaus erhalten bleibt. Und da sind Rechte: was der Agent überhaupt ausführen darf, mit wessen Berechtigungen, mit welchen Nachweisen.\n\nJedes einzelne dieser fünf Dinge ist gewöhnliche Software. In [Folge 3](https://www.mikebild.dev/de/blog/harness-macht-den-unterschied/) habe ich beschrieben, dass genau dieser Rahmen – der Harness – den Unterschied zwischen einem beeindruckenden und einem brauchbaren Werkzeug ausmacht. Damals meinte ich das persönlich: mein Rechner, meine Werkzeuge, meine Arbeitsweise. Heute lese ich denselben Satz organisatorisch. Was für meine Kommandozeile gilt, gilt für das Unternehmen erst recht: Das Modell erzeugt Ausgaben, etwa Vorschläge, Klassifikationen und Formulierungen. Der Agent bestimmt Schleife, Kontext, Zustand, Rechte und Tool-Anbindung. Diese Teile lassen sich gestalten.\n\n## Was man mietet und was man kontrollieren kann\n\nDiese Arbeitsteilung lohnt einen genauen Blick, weil sie ökonomisch zwei völlig verschiedene Dinge beschreibt.\n\nEin proprietäres Modell, das man über die API eines Anbieters bezieht, mietet man: Es gehört dem Anbieter, und über Versionswechsel bestimmt man nur in dem Rahmen mit, den API-Vertrag und Anbieter vorgeben. Man besitzt daran nichts – und das ist verkraftbar, wenn mehrere Modelle die eigenen, konkret geprüften Aufgaben hinreichend gut lösen. Wer seine Strategie auf „das beste Modell“ baut, baut auf etwas, das er weder besitzt noch kontrolliert. Den ausführlichen Gedanken dazu habe ich [früh in dieser Serie](https://www.mikebild.dev/de/blog/gut-genug-modelle/) beschrieben.\n\nDer Agenten-Layer kann dagegen unter eigener Kontrolle stehen. Die Schleife, der Kontextaufbau, der Zustand, die Rechte, vor allem die Tools – all das kann eigener Code und eigene Konfiguration sein, versionierbar, testbar und überprüfbar. Auch die Anbindung wird weniger proprietär: MCP liegt seit Dezember 2025 zusammen mit AGENTS.md und goose bei der Agentic AI Foundation unter dem Dach der Linux Foundation. Eine Garantie, dass jeder Anbieter dieselben Fähigkeiten auf dieselbe Weise bereitstellt, ist das nicht.\n\n```mermaid\nflowchart TB\n    subgraph rented[\"gemietet – austauschbar\"]\n        M[\"Modell<br/>Vorschläge · Klassifikation · Sprache\"]\n    end\n    subgraph owned[\"gestaltbar – unter eigener Kontrolle\"]\n        A[\"Agent<br/>Schleife · Kontext · Zustand · Rechte\"]\n        W[\"Tools und Schnittstellen<br/>typisierte Operationen\"]\n        F[\"Fachdomäne<br/>Begriffe · Regeln · Abläufe\"]\n        A --> W\n        W --> F\n    end\n    A -- \"fragt an\" --> M\n    M -- \"schlägt Schritte vor\" --> A\n```\n\nWenn das Modell gemietet und die Anbindung zunehmend standardisiert ist, bleibt ein besonders wichtiger eigener Anteil: das, was ein Unternehmen in den Agenten hineinlegt. Seine Begriffe, seine Regeln, seine Abläufe, seine Daten – kurz: seine Fachdomäne. Damit steht die Frage dieses Beitrags im Raum. Wo liegt die Fachdomäne?\n\n## Variante A: die Fachdomäne als Prosa\n\nDer naheliegende Ort ist der Prompt. In der Praxis heißt das: Instruktionsdateien wie CLAUDE.md oder AGENTS.md, Systemprompts, angehängte Richtlinien-Dokumente. Dort steht dann so etwas wie: „Gutschriften über 500 Euro darf nur eine Teamleitung auslösen. Kunden im laufenden Mahnverfahren erhalten keine Kulanzgutschrift. Preisänderungen gelten erst ab dem Folgemonat.“\n\nIch will diese Variante nicht kleinreden. Sie ist schnell: Wenn es einen freigegebenen Pflegeweg gibt, kann die Fachabteilung den Text ändern, ohne die Fachanwendung anzufassen. Die geänderte Fassung steht dem Modell zur Verfügung, sobald der Agent sie lädt. Für exploratives Arbeiten reicht das oft – und am Anfang ist es fast immer der richtige Weg, weil man erst einmal herausfinden muss, welche Regeln überhaupt gebraucht werden.\n\nAber sie ist fragil, und zwar strukturell, nicht bloß handwerklich. Erstens ist Prosa ungeprüft: Niemand validiert sie, kein Compiler meldet, dass Satz 12 und Satz 47 sich widersprechen, keine Typprüfung merkt, dass „500 Euro“ an einer Stelle brutto und an der anderen netto gemeint war. Zweitens ist Prosa für das Modell eine Empfehlung, kein verlässlicher Enforcement-Mechanismus – das war die zentrale Ernüchterung meiner [vergeblichen Versuche mit Instruktionsdateien](https://www.mikebild.dev/de/blog/vergebliche-versuche-instruktionsdateien/): Das Modell kann Anweisungen befolgen, ihre Einhaltung lässt sich damit aber nicht erzwingen. Drittens greift die Session-Lotterie: Derselbe Satz wird heute beachtet und morgen überlesen, je nachdem, was sonst noch im Kontext liegt. Und viertens – im Unternehmen wiegt das am schwersten – lässt sich zwar die Datei versionieren, aber nicht das Verhalten. Die Instruktionsdatei allein stellt keine deterministische, blockierende Prüfung bereit, die eine regelwidrige Gutschrift verhindert; ohne zusätzliche Prüfung fällt der Verstoß erst hinterher auf.\n\nProsa skaliert mit der Menge des Wissens. Sie skaliert nicht mit dessen Verbindlichkeit.\n\n## Variante B: die Fachdomäne als Schnittstelle\n\nDie Alternative: das Fachwissen nicht neben den Agenten legen, sondern in seine Tools einbauen. Statt dem Modell generischen Datenbank- und Dateizugriff zu geben und die Regeln als Text daneben zu stellen, bekommt der Agent typisierte Operationen, deren Namen aus der Fachsprache kommen und deren Eingaben geprüft werden:\n\n```ts\nconst issueCreditNote = defineTool({\n  name: \"issue_credit_note\",\n  description: \"Issue a credit note for an existing invoice\",\n  input: z.object({\n    invoiceId: z.string(),\n    amount: z.number().positive(),\n    reason: z.enum([\"complaint\", \"goodwill\", \"pricing_error\"]),\n  }),\n  authorize: ({ user, input }) =>\n    input.amount <= 500 || user.hasRole(\"team_lead\"),\n  execute: (input, ctx) => billing.issueCreditNote(input, ctx.audit),\n});\n```\n\nDer Unterschied ist grundsätzlicher, als der kurze Codeblock aussieht. Die 500-Euro-Regel ist keine Bitte mehr, sondern Code: Sie wird bei jedem Aufruf geprüft, in jeder Session gleich, unabhängig von Tagesform und Kontextlage des Modells. Es gibt sie genau einmal, sie hat einen Besitzer, ein Review, einen Test. Die KI formuliert, die Schnittstelle entscheidet, was überhaupt möglich ist.\n\nDas ist die konsequente Fortsetzung von zwei Gedanken aus den letzten Folgen. In [Folge 8](https://www.mikebild.dev/de/blog/sauberes-grounding-antworten-erden/) ging es darum, Antworten zu erden: nachschlagen statt raten, echte Quellen statt plausibler Vermutungen. Ein Tool ist geerdetes Wissen in ausführbarer Form – der Agent muss die Gutschriften-Regeln nicht erraten, er kann nur innerhalb von ihnen handeln. Und in [Folge 9](https://www.mikebild.dev/de/blog/wissen-das-die-session-ueberlebt/) ging es um Wissen, das die Session überlebt: Eine Tool-Definition überlebt sie von selbst, weil sie Teil des Systems ist und nicht Teil des Gesprächs.\n\nDie Tool-Namen schließen dabei direkt an Domain-Driven Design an: Sie werden zur verbindlichen Fachsprache. Was Eric Evans vor gut zwanzig Jahren „ubiquitous language“ nannte – eine gemeinsame, präzise Sprache zwischen Fachbereich und Entwicklung – bekommt eine sehr handfeste Existenzform. Wenn `issue_credit_note` und `waive_dunning_fee` die einzigen Verben sind, die der Agent kennt, muss die Organisation sich einigen, was diese Verben genau bedeuten. Das Modellieren der Tools ist Domänenarbeit, keine Infrastrukturarbeit. Die Schnittstelle kann Eingaben und Berechtigungen einzelner Aufrufe verlässlich prüfen; Ablaufregeln über mehrere Aufrufe brauchen zusätzliche Zustandsprüfung und gegebenenfalls eine Prüfung des geplanten Ablaufs.\n\n## Die Gegenrechnung\n\nSo weit die Chance. Zur selben Rechnung gehören Lock-in, Pflegeaufwand, Verantwortung und Übersteuerung.\n\nDa ist zuerst der Lock-in. Tool-Definitionen, Skill-Formate, Agent-Konfigurationen folgen heute Anbieter-Konventionen. MCP normt den Transport, nicht die Semantik: Das Protokoll legt fest, wie ein Tool aufgerufen wird, nicht, wie meine Gutschriften-Logik strukturiert ist. Wer seine Fachlogik direkt in die Formate eines Agent-Frameworks gießt, hat sie in drei Jahren genau dort liegen – und der Umzug kostet. Das Gegenmittel ist ein altes: die Fachlogik in eigene Dienste legen und die Tool-Schicht als dünnen Adapter bauen. Aber das muss man tun, es passiert nicht von allein, und unter Termindruck passiert regelmäßig das Gegenteil.\n\nDann der Pflegeaufwand. Eine eigene Tool-Schicht verursacht dauerhaften Pflege- und Betriebsaufwand. Fachregeln ändern sich: Aus der 500-Euro-Schwelle werden 300, ein neuer Gutschriftsgrund kommt dazu, eine Rolle wird umgebaut. Jemand muss das pflegen, priorisieren, testen und ausrollen – neben allem anderen. Die Prosa-Variante war auch deshalb so verführerisch, weil sie diesen Aufwand versteckt hat. Die Tool-Variante macht ihn sichtbar, und Sichtbarkeit fühlt sich zunächst wie Verteuerung an, obwohl der Aufwand vorher nur woanders lag: in den Korrekturen hinterher.\n\nDrittens Prüfbarkeit und Verantwortung. Wer haftet für eine Tool-Definition? Die Frage klingt juristisch, ist aber vor allem organisatorisch. Bei rein beratender Nutzung kann ein Mensch die Antwort verwerfen. Darf der Agent Tools ohne Freigabe ausführen, kann ein Fehler in deren Autorisierung unmittelbar wirken: Eine falsche Obergrenze gilt dann verlässlich falsch, in jeder Session, für jeden Nutzer. Tool-Definitionen brauchen deshalb dieselbe Sorgfalt wie jede produktive Schnittstelle: Review, Tests, klare Zuständigkeit, Änderungshistorie. Und die Verantwortung verschiebt sich spürbar: weg vom Einzelnen, der einen Prompt formuliert hat, hin zum Team, das das Tool definiert hat. Das ist konsequent und richtig – aber eine Organisation muss das wollen, entscheiden und aushalten.\n\nUnd schließlich die Übersteuerung. Es ist verlockend, nach dem ersten Zwischenfall jede denkbare Abweichung wegzudefinieren, bis der Agent nur noch einen einzigen, engen Pfad kennt. Dann hat man ein Formular gebaut, mit einer Chat-Oberfläche davor – teurer, langsamer und launischer, als das Formular es gewesen wäre. Die Stärke des Modells liegt gerade im Unvorhergesehenen: im Sonderfall, in der Rückfrage, im Zusammenhang zwischen zwei Vorgängen, an den beim Entwurf niemand gedacht hat. Wer dem Agenten nur Tools für das Erwartbare gibt, hätte klassische Automatisierung billiger haben können. Zu enge Tools können Risiken begrenzen, aber auch so viel Handlungsspielraum nehmen, dass klassische Automatisierung die passendere Lösung wäre.\n\n## Wann generische Tools reichen – und wann nicht\n\nFür meine Entscheidung sind vier Kriterien maßgeblich: die Art der Arbeit, die Verantwortlichen, das Schadenspotenzial und der Reifegrad des Fachwissens. Generische Tools – Dateizugriff, Shell, Suche – reichen erstaunlich weit, solange ein paar Bedingungen zusammenkommen:\n\n- Die Arbeit ist explorativ: analysieren, prototypisieren, recherchieren, migrieren – Aufgaben, deren Ergebnis ohnehin ein Mensch begutachtet.\n- Es arbeitet eine Einzelperson, die das Ergebnis selbst verantwortet und selbst beurteilen kann.\n- Das Schadenspotenzial ist klein: alles reversibel, nichts verlässt das Haus, kein Geld bewegt sich.\n- Das Fachwissen ist noch im Fluss – es wäre schlicht zu früh, es in Schnittstellen zu gießen.\n\nUmgekehrt gehört die Fachdomäne in den Agenten, sobald sich das Bild dreht:\n\n- Ein Ablauf wiederholt sich im Team: dieselbe Operation, viele Personen, unterschiedliche Sorgfalt und Erfahrung.\n- Regeln sind verbindlich, nicht empfohlen – sie müssen auch am Freitagnachmittag um 17 Uhr gelten.\n- Es gibt Audit- oder Nachweispflichten: Wer hat wann was ausgelöst, mit welcher Begründung?\n- Das Schadenspotenzial ist hoch: Geld, personenbezogene Daten, Wirkung nach außen.\n\nDazwischen nutze ich einen pragmatischen Übergang: Die Prosa-Variante ist der Prototyp der Schnittstelle. Wenn dieselbe Regel zum dritten Mal in einer Instruktionsdatei auftaucht – und zum zweiten Mal ignoriert wurde –, ist sie für mich ein Kandidat für ein Tool. So wandert die Fachdomäne Stück für Stück von der Empfehlung in die Struktur, entlang ihrer tatsächlichen Verbindlichkeit statt entlang eines großen Vorabplans.\n\n## Zwischenstand\n\nFür mich verschiebt die bisherige Serie die Verantwortung von der Session zum System. Am Anfang stand die Session: die Erfahrung, dass die Modelle für meine Arbeit gut genug sind und der Unterschied in meinem eigenen Rahmen liegt – Tools, Kontext, Arbeitsweise. Dann kam das Team: die Ernüchterung, dass sich mein Arbeitserfolg nicht per Datei kopieren lässt und Instruktionsdateien Empfehlungen bleiben, so sorgfältig man sie auch schreibt. Dann das Wissen: sauberes Grounding statt überzeugendem Raten, kuratierte Erkenntnisse, die Sessions überleben. Und jetzt das System: die Einsicht, dass das Modell gemietet ist und gemietet bleibt – und dass der dauerhaft kontrollierbare Anteil im Agenten liegt. Genauer: in den Tools, mit denen der Agent die Fachdomäne berührt.\n\nOffen bleibt die Frage, wie verbindliche Regeln über einzelne Tool-Aufrufe hinweg durchgesetzt werden. Tools entscheiden, was in einem einzelnen Aufruf möglich ist – darüber hinaus braucht es Freigaben, bevor etwas wirkt; Nachvollziehbarkeit, nachdem es gewirkt hat; ein Nein, das ein Nein bleibt, auch wenn das Modell freundlich weiterformuliert. Warum das am Ende auf einen eigenen, an die Fachdomäne gebundenen Agenten hinausläuft – und was der können muss –, ist einen eigenen Beitrag wert.\n\n## Weiterführende Quellen\n\n- [Building Effective AI Agents](https://www.anthropic.com/engineering/building-effective-agents) – Anthropics Unterscheidung von Workflows und Agenten\n- [Writing effective tools for AI agents](https://www.anthropic.com/engineering/writing-tools-for-agents) – Tool-Design für Agenten aus der Praxis\n- [Designing agentic loops](https://simonwillison.net/2025/Sep/30/designing-agentic-loops/) – Simon Willison über Schleifen, Tools und Stoppbedingungen\n- [Model Context Protocol](https://modelcontextprotocol.io/) – Spezifikation und Dokumentation des Tool-Protokolls\n- [Gründung der Agentic AI Foundation](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation) – MCP, AGENTS.md und goose unter dem Dach der Linux Foundation"},{"slug":"wissen-das-die-session-ueberlebt","title":"Wissen, das die Session überlebt","summary":"Session-Wissen verdampft, wenn niemand es kuratiert – warum Teams ihr KI-Arbeitswissen wie Code behandeln sollten und dabei nicht das Modell schlauer wird, sondern die Organisation lernt.","tags":["Software","KI","Methodik","Delivery"],"published_at":"2026-06-23T10:00:00+00:00","updated_at":"2026-08-17T17:34:45.985779+00:00","url":"https://www.mikebild.dev/de/blog/wissen-das-die-session-ueberlebt/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/b306e810309aeef8c9cc026bd69992b31f33e4dfcd273dd99dfbecbda8a4a7e8.mp3","audio_duration_secs":943,"text":"Dienstagvormittag, dritte Session in dieser Woche. Ich tippe zum dritten Mal dieselbe Erklärung. Unser Fehlermodell unterscheidet zwischen erwartbaren und unerwarteten Fehlern; erwartbare Fehler kommen als Werte zurück, und um Serviceaufrufe sollen keine try/catch-Kaskaden entstehen. Die Session davor wusste das. Sie hatte es verstanden, angewendet und sogar einen Grenzfall sauber gelöst, auf den ich selbst nicht gekommen wäre. Jetzt sitze ich wieder in einer neuen Session ohne diese Erklärung und schreibe sie noch einmal, mit anderen Worten, etwas ungeduldiger als beim letzten Mal.\n\n*Teil 9 der Serie „Von der Session zum System“ – hier geht es [zum Auftakt](https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/).*\n\nDas ist die Session-Amnesie als Alltagserfahrung. Nicht als theoretisches Problem von Kontextfenstern und Token-Budgets, sondern als das leise Ärgernis, dass ich mit einem Werkzeug arbeite, das jeden Morgen zum ersten Mal von mir hört. Die Anbieter arbeiten daran. Stand August 2026 gibt es dafür unterschiedliche Speichermechanismen: Anthropics Memory Tool erlaubt Agenten, Informationen außerhalb des unmittelbaren Kontextfensters dateibasiert abzulegen und später wieder zu laden; Claude Code stellt Projektanweisungen über hierarchische CLAUDE.md-Dateien und persönliche Notizen über Auto Memory bereit, beides über Sessions hinweg. Persönliches Auto Memory setzt dabei bei der einzelnen Person an; gemeinsam gepflegte CLAUDE.md-Dateien wären davon zu unterscheiden. Die Variante des Problems, die mich wirklich beschäftigt, löst keine dieser Funktionen von allein.\n\nDenn die Team-Variante ist schlimmer. Mittwoch erzählt mir ein Kollege, er habe „seiner“ Session unser Fehlermodell erklärt. Ich frage nach, wie. Er hat es anders erklärt als ich. Nicht falsch – anders. Andere Begriffe, andere Beispiele, eine Nuance, die ich weglassen würde, eine, die mir fehlt. Ein dritter Kollege hat es gar nicht erklärt, weil er nicht wusste, dass es da etwas zu erklären gibt; seine Session hat try/catch-Kaskaden gebaut, und der Pull Request sah entsprechend aus. Vier Leute, vier private Erklärungen derselben Konvention, vier leicht verschiedene Ergebnisse. Das ist exakt das Varianzproblem, das ich in [„Mein Erfolg lässt sich nicht kopieren“](https://www.mikebild.dev/de/blog/mein-erfolg-laesst-sich-nicht-kopieren/) beschrieben habe – nur dass es hier nicht um Arbeitsweisen geht, sondern um Wissen. Jeder von uns füttert seine Sessions mit einer eigenen, mündlichen Überlieferung dessen, was das Team eigentlich gemeinsam wissen sollte. Persönliches Auto Memory ändert daran nichts: Es merkt sich meine Version der Erklärung, nicht unsere. Versionierte, gemeinsam gepflegte CLAUDE.md-Dateien wären der Ort für eine geteilte Version – aber nur, wenn das Team sie bewusst kuratiert.\n\nDie Kernfrage dieser Folge ist also: Wie wird aus Session-Wissen Team-Wissen – und irgendwann Unternehmenswissen? Wie kommt das, was in einer guten Session entsteht, in die nächste Session eines anderen Menschen?\n\n## Zwei Sorten von Wissen\n\nUm darüber vernünftig zu reden, hilft mir eine Unterscheidung, die ich lange nur implizit mit mir herumgetragen habe: kuratiertes Wissen und emergentes Wissen.\n\nKuratiertes Wissen ist bewusst destilliert. Jemand hat sich hingesetzt, eine Erfahrung oder eine Entscheidung in Worte gefasst, jemand anderes hat es gegengelesen, und das Ergebnis steht an einem Ort, an dem es gefunden wird. Architektur-Entscheidungen in ADRs, Konventionen in einer CLAUDE.md oder AGENTS.md, ein Runbook für den Deployment-Sonderfall, der alle sechs Monate zuschlägt. Kuratiertes Wissen ist teuer – es kostet die Zeit eines Menschen, der es aufschreibt, und die eines zweiten, der es prüft. Dafür ist es adressierbar: Ich kann darauf zeigen, es verlinken, es einer Session mitgeben.\n\nEmergentes Wissen entsteht nebenbei. In der Session vom Dienstag ist ein halbes Dutzend kleiner Erkenntnisse angefallen: dass unser Test-Setup eine Eigenheit beim Mocken des Message-Brokers hat, dass eine bestimmte Bibliothek in Version 4 ein anderes Default-Verhalten hat als in unserer Doku steht, dass mein Prompt-Zuschnitt für Refactorings besser funktioniert, wenn ich das Zielbild vor die Einschränkungen stelle. Nichts davon habe ich aufgeschrieben. Die Session endete, und mit ihr verdampfte alles, was nicht in Code oder Commit-Message gerann. Emergentes Wissen ist billig in der Entstehung – und für spätere Sessions nicht verlässlich verfügbar, sofern es nicht gespeichert wurde.\n\nDas Gefälle zwischen beiden ist das eigentliche Problem. Teams produzieren in KI-Sessions heute mehr emergentes Wissen als je zuvor – jede Session ist ja ein kleines Labor, in dem jemand ausprobiert, formuliert, scheitert, nachjustiert. Und fast nichts davon überlebt. Was überlebt, ist der Code. Aber der Code enthält nur das Ergebnis, nicht die Begründung, nicht den verworfenen Irrweg, nicht die Formulierung, die den Knoten gelöst hat.\n\n## Wissen wie Code behandeln\n\nDas destillierte Arbeitswissen eines Teams verdient dieselbe Behandlung wie sein Code. Versioniert. Reviewt. Mit Ownership. Und mit einem Lebenszyklus, der auch das Sterben vorsieht. Darauf läuft diese Folge hinaus.\n\nDas klingt erst einmal nach einer Banalität – Docs as Code bezeichnet die Behandlung von Dokumentation mit denselben versionierten Arbeitsweisen wie Quellcode, einschließlich Review und automatisierter Prüfungen. Aber im KI-Kontext bekommt jede dieser vier Eigenschaften eine neue Schärfe:\n\n- Versioniert, weil das Wissen jetzt maschinell konsumiert wird. Wenn eine Instruktionsdatei sich ändert, kann sich das Verhalten der Sessions ändern, die sie laden. Solche Änderungen wirken auf das Verhalten des Systems – und Verhaltensänderungen gehören in die Historie, mit Diff und Datum und der Möglichkeit, zurückzugehen.\n- Reviewt, weil eine falsche Konvention jetzt neben menschlichen Lesern auch mehrere Sessions in die Irre führt. Ein Mensch, der einen schlechten Wiki-Artikel liest, merkt oft, dass etwas nicht stimmt. Eine Session kann den Fehler nicht erkennen und die falsche Konvention anwenden, wie ich es in [„Sauberes Grounding II: Antworten erden“](https://www.mikebild.dev/de/blog/sauberes-grounding-antworten-erden/) beschrieben habe: Was im Kontext steht, wird zur Realität der Session, ob es stimmt oder nicht.\n- Mit Ownership, weil herrenloses Wissen niemand pflegt. Jede Konventionsdatei, jedes Runbook braucht einen Menschen, der sich zuständig fühlt. Das ist keine Bürokratie – es ist die schlichte Antwort auf die Frage „Wen frage ich, ob das noch gilt?“.\n- Mit Lebenszyklus, weil veraltetes Wissen schlimmer ist als keins. Das ist der Punkt, den ich am häufigsten unterschätzt sehe. Eine Session ohne passende Quelle kann raten oder nachfragen; eine Session mit veraltetem Grounding rät mit Quellenangabe. Sie zitiert die Konvention von 2024, die wir im Herbst gekippt haben, und klingt dabei so belegt, dass niemand nachprüft.\n\nDer letzte Punkt verdient einen eigenen Absatz, weil er kontraintuitiv ist. Wir sind darauf trainiert, Dokumentation als „besser als nichts“ zu betrachten. Im KI-Kontext stimmt das nicht mehr. Falsches, altes, halbgares Wissen im Grounding einer Session ist aktiv schädlich, weil es die Ratlosigkeit des Modells in falsche Sicherheit verwandelt. Ein leeres Verzeichnis zwingt mich, nachzudenken, was die Session wissen muss. Ein volles, nie ausgemistetes Verzeichnis gaukelt mir vor, das sei erledigt. Deshalb gehört zum Lebenszyklus auch die regelmäßige Kontrolle gegen die Quellen: Gilt die Konvention noch? Beschreibt das Runbook noch das System, das läuft?\n\n## Der Kreislauf\n\nWenn man die Sortierung ernst nimmt – emergentes Wissen entsteht ständig, kuratiertes Wissen wirkt – dann ergibt sich fast zwangsläufig ein Kreislauf. Aus diesen Beobachtungen leite ich vier Stationen ab.\n\nErstens: aus echten Läufen lernen. Nicht aus Meinungen, nicht aus Blogposts, sondern aus dem, was in den eigenen Sessions tatsächlich passiert ist. Welche Erklärung hat funktioniert? An welcher Stelle taucht dieselbe Rückfrage wiederholt auf? Welcher Fehler kommt in mehreren Pull Requests unabhängig voneinander vor? Das Rohmaterial liegt herum – man muss es nur als Rohmaterial erkennen.\n\nZweitens: ein Mensch kuratiert und gibt frei. Das ist die entscheidende Station, und sie ist bewusst nicht automatisiert. Destillieren heißt auswählen, verdichten, wegwerfen. Aus zehn Beobachtungen wird ein Absatz; aus einem Absatz wird vielleicht eine Zeile in der Konventionsdatei; acht der zehn Beobachtungen werden verworfen, weil sie Einzelfälle waren. Diese Entscheidung muss das Team treffen, weil nur es festlegen kann, was künftig verbindlich gilt: was bei uns Regel ist, was eine Ausnahme war und was ab morgen gelten soll. Und die Freigabe – ein zweites Paar Augen, ein echtes Review – macht aus einer privaten Notiz eine geteilte Verbindlichkeit.\n\nDrittens: das Destillat fließt ins Grounding der nächsten Session. Es steht in der Datei, die jede Session liest, oder in der Wissensquelle, die beim Arbeiten herangezogen wird. Ab diesem Moment muss nicht mehr jeder Kollege „seiner“ Session das Fehlermodell mündlich und verschieden erklären – die Erklärung ist dieselbe, für alle, geprüft.\n\nViertens: die nächste Arbeit kann besser werden. Nicht dramatisch, nicht auf einen Schlag. Ob der Kreislauf wirkt, lässt sich an konkreten Fragen prüfen: Kommt die Rückfrage, die sich zuletzt wiederholt hat, seltener? Taucht der wiederkehrende Fehler in neuen Pull Requests noch auf? Und die nächsten Läufe produzieren neues Rohmaterial – idealerweise auf einem etwas höheren Niveau.\n\n```mermaid\nflowchart LR\n    A[\"Echte Läufe<br/>und Erfahrungen\"] --> B[\"Beobachten und<br/>Rohmaterial sammeln\"]\n    B --> C[\"Mensch kuratiert,<br/>Review gibt frei\"]\n    C --> D[\"Destillat im Grounding<br/>der nächsten Session\"]\n    D --> A\n```\n\nDas ist kein Framework und keine Zeremonie. Als Einstieg reicht ein regelmäßiger, kurzer Durchgang, in dem das Team bespricht, was hängen geblieben ist, plus die Disziplin, Änderungen an den Wissensdateien wie Code-Änderungen zu behandeln: Branch, Review, Merge. Wie viel Aufwand angemessen ist, hängt von Team, Umfang und Risiko ab.\n\n## Nicht das Modell wird schlauer\n\nDie Pointe dieses Kreislaufs ist mir erst mit der Zeit klar geworden, und sie hat meinen Blick auf die ganze Modell-Debatte verändert: In diesem Kreislauf wird nicht das Modell schlauer. Die Organisation lernt: Menschen wählen Erkenntnisse aus, begründen sie und machen sie für andere wiederverwendbar. Die ausgewählten Erkenntnisse schlagen sich als versionierte Änderungen des kuratierten Wissens nieder – nicht darin, dass ein Modell mehr Kontext behält.\n\nAm Ende des Quartals hat das Modell von unseren Sessions nichts automatisch als Teamwissen behalten. Die angebotene Version kann sich in dieser Zeit durchaus geändert haben – unser Fehlermodell kennt sie deshalb noch lange nicht. Verbessert hat sich die Schicht darum herum: unser kuratiertes Wissen, unsere Erklärungen, die Qualität dessen, was wir jeder Session mitgeben. Und – das ist der entscheidende Unterschied – diese Schicht gehört uns. Wir können sie lesen, prüfen, korrigieren, zurückrollen. Wir können sie steuern. Welche Modellversion ein Anbieter ausliefert und wie sie sich verhält, können wir weit weniger beeinflussen.\n\nWer die Lernfähigkeit seiner KI-Arbeit im Modell verortet, hat deshalb ein Steuerungsproblem: Er wartet auf Fortschritt, den er nicht beeinflussen kann. Wer sie in der Kuratierung verortet, hat ein Arbeitsproblem – anstrengender, aber lösbar. Ich halte das für die nüchternste Antwort auf die Frage, wo ein Team investieren sollte: in den Teil, den es besitzt.\n\n## Die Anti-Pattern\n\nWas ich an Versuchen gesehen habe, Session-Wissen festzuhalten, ist überwiegend gescheitert. Und zwar auf drei wiedererkennbare Arten.\n\nDie Prompt-Sammlung im Wiki. Irgendwer legt eine Seite an: „Unsere besten Prompts“. Drei Wochen lang wird eingezahlt, dann nie wieder. Nach einem halben Jahr enthält die Seite veraltete Prompts ohne Einsatzkontext: für Modelle, die keiner mehr benutzt, für Probleme, die keiner mehr hat, ohne Angabe, wann sie funktionieren und wann nicht. Niemand traut sich zu löschen, niemand mag pflegen. Der Fehler steckt schon in der Form: Ein Prompt ohne die Situation, für die er destilliert wurde, ist kein Wissen, sondern ein Souvenir.\n\nDie Instruktionsdateien-Halde. Das habe ich in [„Vergebliche Versuche: Skills, AGENTS.md und die Grenze der Empfehlung“](https://www.mikebild.dev/de/blog/vergebliche-versuche-instruktionsdateien/) schon von einer anderen Seite beleuchtet: Konventionsdateien, in die jeder mal etwas hineinschreibt und aus denen nie etwas herausfällt. Nach einem Jahr sind es zweitausend Zeilen, in denen sich Regeln widersprechen, weil die von März die von Oktober nicht kennt. Die Sessions lesen alles und gewichten irgendwie – das Ergebnis ist Lotterie. Eine Wissensdatei ohne Löschdisziplin ist keine Wissensdatei, sondern ein Sedimentbecken.\n\nDas Einmal-Aufgeschriebene. Die dritte Variante ist die leiseste: Ein motivierter Kollege schreibt in einer guten Woche alles auf – gründlich, strukturiert, wirklich brauchbar. Dann wird es nie wieder angefasst. Kein Ownership, kein Review-Rhythmus, kein Anlass, es zu aktualisieren. Zwölf Monate später können Teile davon veraltet sein, und weil niemand weiß, welche, traut niemand mehr dem Rest. Der einmalige Kraftakt ist der natürliche Feind des Kreislaufs: Er fühlt sich wie eine Lösung an und verhindert genau die Routine, die eine wäre.\n\nAllen drei Mustern fehlt dasselbe: der Rückweg von der echten Arbeit ins Wissen. Sie behandeln Aufschreiben als Event statt als Kreislauf.\n\n## Die Chance, die Arbeit kostet\n\nUnd trotzdem – oder gerade deshalb – halte ich diesen Moment für eine ungewöhnliche Gelegenheit. Organisationen reden seit Jahrzehnten davon, implizites Wissen zu explizieren. Wissensmanagement-Initiativen, Wikis, Communities of Practice: Meine These ist, dass ein fehlender unmittelbarer Abnehmer zum Versanden vieler dieser Initiativen beigetragen hat. Wer sein Wissen aufschrieb, schrieb für ein abstraktes „später“ und ein hypothetisches „jemand“. Die Rückkopplung fehlte; das Aufschreiben blieb ein Opfer ohne sichtbaren Ertrag.\n\nDas ändert sich gerade. KI-Sessions bilden einen zusätzlichen unmittelbaren Abnehmer für explizites Wissen. Wer heute eine Konvention sauber destilliert, kann es in der nächsten Session prüfen: Verhält sich die KI anders? Hält der Pull Request die Konvention ein? Bleibt die bekannte Rückfrage aus? Die Rückkopplungsschleife, die in diesen Fällen fehlte, kann sich in solchen Arbeitsabläufen deutlich verkürzen. KI schafft damit einen unmittelbaren Anreiz, implizites Wissen zu explizieren – und die nächste Session liefert früh Hinweise darauf, ob das Aufgeschriebene trägt.\n\nIch will die Kritik daran nicht unterschlagen. Kuratieren kostet die Zeit genau der Leute, die am wenigsten davon haben. Es besteht die reale Gefahr, dass der Kreislauf ausufert und jede Zeile Konvention ein eigenes Freigabegremium durchlaufen muss. Und nicht jedes Wissen will eingefroren werden – manches ist absichtlich im Fluss, und wer es zu früh destilliert, zementiert einen Zwischenstand. Die Antwort darauf ist Maß, nicht Verzicht: klein anfangen, nur destillieren, was sich wiederholt hat, und das Löschen genauso ernst nehmen wie das Schreiben.\n\nWas mich am Ende dieser Folge umtreibt, ist eine Frage, die eine Ebene tiefer liegt. Angenommen, der Kreislauf steht: Das Team kuratiert, das Wissen überlebt die Session, die Organisation lernt. Dann liegt da ein wachsender Bestand aus destillierten Konventionen, Fachbegriffen, Entscheidungen – und ich gebe ihn Session für Session einem generischen Assistenten in die Hand, der von meiner Fachdomäne nichts weiß und jedes Mal alles aus dem Kontext rekonstruiert. Irgendwann stellt sich die Frage, wem ich dieses Wissen eigentlich anvertraue: Reicht der generische Agent mit gutem Grounding – oder braucht die Fachdomäne ein Gegenüber, das für sie gebaut ist? Wo genau der generische Agent endet, ist die Frage der nächsten Folge.\n\n## Weiterführende Quellen\n\n- [AGENTS.md – das offene Format für Agenten-Instruktionen](https://agents.md/)\n- [Anthropic: Memory Tool – Wissen über Sessions hinweg](https://platform.claude.com/docs/en/agents-and-tools/tool-use/memory-tool)\n- [Claude Code: How Claude remembers your project](https://code.claude.com/docs/en/memory)\n- [Ikujiro Nonaka: The Knowledge-Creating Company (Harvard Business Review)](https://hbr.org/2007/07/the-knowledge-creating-company)\n- [Write the Docs: Docs as Code](https://www.writethedocs.org/guide/docs-as-code/)"},{"slug":"sauberes-grounding-antworten-erden","title":"Sauberes Grounding II: Antworten erden","summary":"Mein Arbeitsmodell für Grounding unterscheidet Konventionen, Dokumente und API-Beschreibungen. Was im knappen Kontext bleibt, priorisiere ich nach Aufgabe und Wiederbeschaffungskosten.","tags":["Software","KI","Methodik","Delivery"],"published_at":"2026-06-03T10:00:00+00:00","updated_at":"2026-08-17T17:34:45.985779+00:00","url":"https://www.mikebild.dev/de/blog/sauberes-grounding-antworten-erden/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/3a3c91bf6c3b3fd52a36848dae71e5ea6e149d6ff41fa2a097c3497933a6ab22.mp3","audio_duration_secs":792,"text":"Vor ein paar Wochen saß ich mit einem Kollegen vor einem halbfertigen Integrationsstück. Die KI sollte einen Client für eine interne API schreiben, und sie tat, was sie immer tut: Sie schlug `customerId` vor. Das Feld heißt bei uns `customer_ref` – aus historischen Gründen, die niemand mehr verteidigt, aber jeder kennt. Wir hatten das dem Assistenten in dieser Woche schon zweimal erklärt. Diesmal habe ich etwas anderes gemacht: Ich habe ihm nicht widersprochen, sondern die OpenAPI-Beschreibung der API in den Kontext gegeben. Der nächste Vorschlag stimmte. Nicht ungefähr, sondern exakt – inklusive der etwas eigenwilligen Paginierung, die sich kein Modell der Welt hätte ausdenken können, weil sie schlicht eine Entscheidung von uns war. Gleiches Modell, gleicher Tag, gleiche Aufgabe. Der Unterschied: Es musste nicht mehr raten.\n\n*Teil 8 der Serie „Von der Session zum System“ – hier geht es [zum Auftakt](https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/).*\n\nIn der [letzten Folge](https://www.mikebild.dev/de/blog/sauberes-grounding-warum-ki-raet/) habe ich beschrieben, warum die KI so überzeugend rät: Ein Sprachmodell verweist mit Symbolen auf Symbole, nicht auf meine Systeme. Es kennt tausend APIs, die so ähnlich aussehen wie meine – und genau deshalb erfindet es plausibel. Das war die Diagnose. Diese Folge ist die konstruktive Seite: Wie gebe ich der KI meine Realität mit? Ich nenne das für mich sauberes Grounding – Antworten erden, statt sie in der Schwebe zu lassen. Und wie so oft ist der Kern weniger ein Werkzeug als eine Sortierung: Welches Wissen gibt es überhaupt, wo kommt es her, und wann gehört es in den Kontext?\n\n## Mein Arbeitsmodell: drei Wissensschichten\n\nWenn ich mir anschaue, was meinen Antworten in der Praxis Boden gibt, lande ich immer wieder bei drei Schichten. Das ist meine Sortierung für diese Arbeit, keine allgemeine Taxonomie von Wissen. Die Schichten unterscheiden sich nicht allein im Inhalt, sondern in ihrem Charakter – und das ist der eigentliche Punkt.\n\n```mermaid\nflowchart TB\n    subgraph Schichten[\"Drei Wissensschichten\"]\n        K[\"Konventionen<br/>wie wir arbeiten<br/>präskriptiv, kuratiert\"]\n        D[\"Dokumente<br/>Runbooks, Specs, Entscheidungen<br/>deskriptiv, alternd\"]\n        S[\"Schnittstellenbeschreibungen<br/>OpenAPI, GraphQL-Introspection, Schemas<br/>maschinell prüfbar\"]\n    end\n    K --> E[\"Erdung beim Bauen<br/>und Vorbereiten\"]\n    D --> E\n    S --> E\n    E --> A[\"Geprüftes Arbeitsergebnis<br/>Dokument, Plan, Code, konfigurierter Ablauf\"]\n```\n\nDie erste Schicht sind unsere **Konventionen und Arbeitsweisen**. Das ist präskriptives Wissen: nicht „so ist die Welt“, sondern „so machen wir das hier“. Error Handling gehört an den Aufrufer, Migrationen laufen nie automatisch, Commit-Botschaften erklären das Warum, Reviews prüfen zuerst die Schnittstelle. Nichts davon steht in einer API-Beschreibung, und nichts davon kann ein Modell erraten – es sind Entscheidungen, keine Fakten. In [Folge 6](https://www.mikebild.dev/de/blog/vergebliche-versuche-instruktionsdateien/) habe ich erzählt, wie ich versucht habe, genau dieses Wissen in Instruktionsdateien wie AGENTS.md oder CLAUDE.md zu packen – und wo Empfehlungen an ihre Grenzen kommen. Aufgeben war daraus nicht die Lehre. Diese Schicht muss klein, dicht und kuratiert sein: Eine kurze Liste von Regeln, hinter denen das Team steht, lässt sich befolgen und im Review einfordern. Eine lange Sammlung von Hinweisen konkurriert mit sich selbst um Aufmerksamkeit.\n\nDie zweite Schicht sind unsere **Dokumente**: Runbooks, Spezifikationen, Architekturentscheidungen, Betriebsnotizen. Das ist deskriptives Wissen – es beschreibt, was bei uns gilt und warum es so gekommen ist. Eine festgehaltene Architekturentscheidung kann verhindern, dass die KI mit ihrem Vorschlag drei Jahre alte Systemgrenzen übergeht. Ein Runbook erklärt, was bei einem bestimmten Alarm wirklich zu tun ist, statt was generisch sinnvoll klingt. Aber ich habe gelernt, dieser Schicht mit einer gewissen Skepsis zu begegnen: Dokumente altern. Sie beschreiben den Zustand von damals, nicht von heute. Ein Runbook, das seit zwei Jahren niemand angefasst hat, kann die KI genauso in die Irre führen wie ihr eigenes Raten – nur klingt die Antwort dann noch überzeugender, weil sie sich auf „unsere Doku“ beruft.\n\nDie dritte Schicht sind **echte API-Beschreibungen** – und sie ist meine liebste, weil sie einen Charakter hat, den die anderen beiden nicht haben: Werkzeuge können ihre Struktur lesen, auch wenn Schema und Laufzeitverhalten auseinanderlaufen können. Eine OpenAPI-Beschreibung, ein per [Introspection](https://graphql.org/learn/introspection/) abgefragtes GraphQL-Schema, ein JSON-Schema, ein Datenbankschema – das ist nicht die Erinnerung eines Kollegen an das System, sondern eine maschinenlesbare Struktur- oder API-Beschreibung. Introspection zeigt das angebotene Schema dieser Umgebung zu diesem Zeitpunkt: Wenn das Feld `customer_ref` heißt, steht es dort. Das beweist weder korrekte Resolver noch die Übereinstimmung mit anderen Deployments oder mit datenabhängigen Antworten. Je nach Artefakt kann ich daraus Feldnamen, Typen oder Endpoints ablesen, statt sie aus Konventionen oder Dokumenten zu erraten.\n\nDie drei Schichten beantworten unterschiedliche Fragen, und sie ersetzen einander nicht:\n\n- Konventionen sagen, **wie** etwas gebaut werden soll – Stil, Schnitt, Verantwortlichkeiten.\n- Dokumente sagen, **warum** die Dinge so sind, wie sie sind – Kontext und Geschichte.\n- Die jeweilige API-Beschreibung nennt Felder, Typen oder Endpoints. Ob die Implementierung diesem Stand entspricht und funktioniert, muss separat geprüft werden.\n\nWer nur die dritte Schicht liefert, bekommt Code, der zum beschriebenen Vertrag passt, aber nicht zwingend zur Laufzeit funktioniert. Wer nur die erste liefert, bekommt stilistisch sauberen Code gegen eine erfundene API. Erst zusammen erden sie eine Antwort.\n\n## Nachschlagen statt Raten\n\nDas Prinzip hinter allen drei Schichten ist dasselbe, und es ist so unspektakulär, dass man es leicht überliest: **nachschlagen statt raten**. Am leichtesten versteht man es an einer Analogie, die jeder aus dem Editor kennt: IntelliSense.\n\nWenn ich in meinem Editor `customer.` tippe, rät der Editor nicht, welche Felder ein Kunde vermutlich hat. Er schlägt nach. Ein typbasierter Editor leitet Vorschläge aus der verfügbaren Typdefinition ab, nicht aus statistischer Ähnlichkeit. Genau diese Ableitung fehlt einem Sprachmodell, wenn wir es ohne Erdung arbeiten lassen.\n\nEin Agent mit passenden Werkzeugen und Leserechten kann nachschlagen. Er kann eine OpenAPI-Datei lesen, ein GraphQL-Schema per Introspection abfragen, eine Typdefinition im Repository aufschlagen. Das [Model Context Protocol](https://modelcontextprotocol.io/) stellt dafür Tools und Kontextquellen über einen Protokollvertrag bereit. Und ein solcher Agent kann noch einen Schritt weitergehen, der über IntelliSense hinausreicht: Er kann Endpoints **probieren**. Ein lesender Aufruf gegen die echte API, die tatsächliche Antwortstruktur anschauen, und der nächste Vorschlag basiert auf beobachteten Daten statt auf Erfindung. Aus „dieses Feld gibt es wahrscheinlich“ wird „dieses Feld habe ich gesehen – in dieser Antwort, mit diesen Rechten, in dieser Umgebung und zu diesem Zeitpunkt“. Das ist ein kategorialer Unterschied, kein gradueller, auch wenn eine einzelne Antwort nicht das Verhalten für andere Daten, Rechte, Umgebungen und Zeitpunkte belegt.\n\nErkundende Lesezugriffe müssen autorisiert sein; ihre fachliche Freiheit von Side Effects muss vorher geklärt werden. Solche Aufrufe sind für die Recherche wertvoll. Schreibende Aufrufe „zum Ausprobieren“ sind ein eigenes Thema mit eigenen Sicherungen – dazu komme ich in dieser Serie noch. Für das Erden von Antworten reicht die lesende Seite fast immer aus.\n\n## Grounding wiederholbarer Abläufe gehört ans Bauen\n\nDas zweite Prinzip hat länger gebraucht, bis es bei mir angekommen ist, und es ist das mit den größten Konsequenzen: **Wann** wird geerdet? Überall dort, wo ein wiederholbarer Ablauf ein vorab prüfbares Artefakt hat, gehört die Erdung ans Bauen und Vorbereiten – nicht in den kritischen Pfad der Ausführung. Entsteht die Frage dagegen erst zur Laufzeit, muss auch die Erdung dort stattfinden.\n\nDie Grafik oben zeigt diesen Fall für ein vorab prüfbares Artefakt.\n\nAn dieser Stelle muss ich RAG einordnen, weil es das erste Wort ist, das im Raum steht, sobald jemand „eigenes Wissen“ und „KI“ im selben Satz sagt. Retrieval-Augmented Generation – 2020 von [Lewis und Kollegen](https://arxiv.org/abs/2005.11401) beschrieben – bedeutet: Zum Zeitpunkt der Anfrage werden passende Textstücke aus einem Korpus gesucht und dem Modell mitgegeben. Das ist genau richtig, wenn die Frage erst zur Laufzeit entsteht und die Antwort aus einem Dokumentbestand kommen muss – ein Assistent, der Fragen zum Handbuch beantwortet, eine Suche über interne Notizen, Support-Fälle. Solche Fälle entsprechen dem im RAG-Papier beschriebenen Muster aus Retrieval und Generierung.\n\nSchief wird es, wenn RAG zum Reflex für alles wird. Denn Retrieval zur Laufzeit hat zwei Eigenschaften, die im Tagesgeschäft wehtun: zusätzliche Latenz und mögliche Ergebnisänderungen bei verändertem Index, Ranking oder Suchverfahren. Jede Ausführung sucht neu, und was gefunden wird, hängt an Einbettungen, Rankings, am Zustand des Index – derselbe Ablauf kann heute andere Textstücke sehen als gestern und sich deshalb anders verhalten. Für eine explorative Frage ist das verschmerzbar. Bei einem Ablauf, der jede Nacht läuft und auf dessen Ergebnis sich Leute verlassen, können wechselnde Treffer zu nicht reproduzierbaren Ergebnissen führen.\n\nDeshalb ziehe ich für solche wiederholbaren Abläufe die Erdung nach vorn. Das Wissen – die Konventionen, die Dokumente, die API-Beschreibungen – fließt ein, während etwas **entsteht**: ein Dokument, ein Plan, ein Stück Code, ein konfigurierter Ablauf. Dieses Arbeitsergebnis wird geprüft, von mir oder von einem Kollegen, und ab dann steht es fest. Die spätere Ausführung würfelt nicht neu, sie führt aus, was geprüft wurde. Der Unterschied ist derselbe wie zwischen einem Programm, das bei jedem Start seine Abhängigkeiten aus dem Netz rät, und einem Build mit festgeschriebenen Versionen: In den flüchtigen, kreativen Moment gehört die ganze Unsicherheit – in das, was danach jede Nacht wieder abläuft, gehört sie nicht.\n\nDas ist keine Absage an RAG, sondern eine Zuständigkeitsklärung. Suche über Dokumente zur Laufzeit, wo die Frage zur Laufzeit entsteht. Erdung beim Bauen, wo das Ergebnis ein Artefakt ist, das Bestand haben soll. In meiner Arbeit ist der zweite Fall der häufigere – diskutiert wird er trotzdem mit dem Vokabular des ersten.\n\n## Kontext-Ökonomie: was zuletzt fliegt\n\nDas dritte Prinzip klingt banal und entscheidet in der Praxis erstaunlich viel: Der Kontext ist knapp. Ja, die Fenster sind groß geworden. Aber wer länger mit Agenten-Sessions arbeitet, weiß, dass „groß“ nicht „unerschöpflich“ heißt – lange Sitzungen, gelesene Dateien, Tool-Ausgaben und Zwischenstände füllen jedes Fenster, und irgendwann wird getrimmt, zusammengefasst, weggelassen. Anthropic hat das unter dem Begriff Context Engineering auf den Punkt gebracht: Kontext ist eine endliche Ressource, relevante Informationen werden gezielt gefunden und nur bei Bedarf geladen. Ein großer, ungefilterter Kontext ist kein Qualitätsmerkmal – die für die Aufgabe maßgebliche Quelle gehört zum Zeitpunkt der Bearbeitung hinein.\n\nFür mein Grounding folgt daraus eine Priorisierungsregel: Wenn unter Druck etwas aus dem Kontext fliegen muss, entferne ich zuerst Material, das sich gezielt erneut nachschlagen lässt. Ein langes Runbook bleibt nur mit den gerade relevanten Absätzen im Kontext. Kleine, aufgabenrelevante Konventionen halte ich länger fest, weil ihr Verlust schwerer zu bemerken ist. Bei einer anderen Aufgabe kann diese Reihenfolge anders aussehen; entscheidend sind Relevanz und Wiederbeschaffungskosten, nicht die Bezeichnung der Schicht.\n\nDeshalb halte ich Konventionsdokumente klein und auf die Aufgabe zugeschnitten. Ein ausuferndes Dokument konkurriert mit Quellen und Tool-Ausgaben um Platz. Kuratieren heißt hier, bewusst zu entscheiden, was für den nächsten Schritt im Kontext bleiben muss.\n\n## Was Grounding nicht leistet\n\nZur Ehrlichkeit gehört die Gegenrechnung, und ich will sie nicht ans Ende quetschen, als wäre sie eine Fußnote. Grounding beseitigt das Raten nicht. Beim nächsten Vorschlag traten mit der geladenen OpenAPI-Beschreibung keine erfundenen Feldnamen oder Strukturen auf. Aber ein Sprachmodell bleibt ein Sprachmodell: Es kann eine korrekte Spezifikation im Kontext haben und trotzdem einen Schluss daraus ziehen, der nicht trägt. Es kann zwei Dokumente zusammenlesen, die sich widersprechen, und den Widerspruch elegant überbrücken, statt ihn zu melden. Und es kann an einem gealterten Runbook geerdet sein – dann ist die Antwort sauber begründet und trotzdem falsch, nur schwerer als falsch zu erkennen.\n\nPrüfen bleibt deshalb Pflicht. Das geerdete Arbeitsergebnis ist ein besserer Kandidat, kein fertiges Urteil. Was sich für mich geändert hat, ist die Art der Prüfung: Ich suche seltener nach frei erfundenen Feldern und häufiger nach falschen Schlüssen aus richtigen Quellen. Das ist ein besseres Problem – dieselbe Sorte Problem, die ich auch bei einem menschlichen Kollegen hätte, der sich gewissenhaft eingelesen hat. Aber es ist ein Problem, und Grounding ersetzt weder Konsistenzprüfung noch Tests – ohne solche Prüfungen können falsche Schlüsse unentdeckt bleiben.\n\nEine Frage lässt mich seither nicht los. Alles, was ich hier beschrieben habe, erdet eine Session: Die Konventionen, die Dokumente, die Schnittstellen fließen in einen Arbeitszusammenhang ein, und am Ende steht ein geprüftes Ergebnis. Aber die Session endet. Das Fenster schließt sich, der Kontext ist weg – und mit ihm alles, was die KI in diesen zwei Stunden über unser System, unsere Eigenheiten, unsere Korrekturen gelernt hat. Morgen erkläre ich `customer_ref` zum dritten Mal, oder ich habe einen Weg gefunden, dass Wissen die Session überlebt. Wie das aussehen kann – ohne veraltete, unauffindbare oder unbelegte Einträge anzusammeln – davon handelt die nächste Folge.\n\n## Weiterführende Quellen\n\n- [Effective context engineering for AI agents](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents) – Anthropics Engineering-Beitrag zu Kontext als endlicher Ressource\n- [Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks](https://arxiv.org/abs/2005.11401) – das RAG-Originalpapier von Lewis et al. (2020)\n- [GraphQL Introspection](https://graphql.org/learn/introspection/) – wie ein GraphQL-Schema über sich selbst Auskunft gibt\n- [OpenAPI Specification](https://spec.openapis.org/oas/latest.html) – der Standard für maschinenlesbare HTTP-API-Beschreibungen\n- [Model Context Protocol](https://modelcontextprotocol.io/) – offener Standard, um KI-Werkzeugen echte Datenquellen und Systeme zugänglich zu machen"},{"slug":"ki-im-konzern-schnellere-entwicklung-andere-prozesse","title":"KI im Enterprise-Umfeld: Schnellere Entwicklung braucht andere Prozesse","summary":"KI verkürzt Recherche, Entwurf und Implementierung. Bleiben Übergaben, Ticket-Hierarchien und Freigaben unverändert, verlagert sich der Engpass – Großunternehmen müssen deshalb Arbeitsfluss und Governance gemeinsam neu ordnen.","tags":["Software","KI","Architektur","Delivery"],"published_at":"2026-05-19T10:00:00+00:00","updated_at":"2026-08-17T17:34:45.985779+00:00","url":"https://www.mikebild.dev/de/blog/ki-im-konzern-schnellere-entwicklung-andere-prozesse/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/442b997a3bb1e71dc90aae7da2c38199a3c3dbec6adf788b81c2f5499646041b.mp3","audio_duration_secs":928,"text":"Wenn die Implementierung statt fünf Tagen nur noch zwei dauert, die Änderung danach aber zwölf Tage auf Reviews, Freigaben und ein gemeinsames Testfenster wartet, ist das Produkt kaum früher beim Nutzer. Die lokale Arbeit wurde schneller. Der Gesamtprozess nicht.\n\nGenau diese Verschiebung macht KI in großen Unternehmen sichtbar. Ein Entwickler kann Code, Tests und eine erste Dokumentation in einem Bruchteil der bisherigen Zeit vorbereiten. In Forschung und Entwicklung (R&D) lassen sich Hypothesen schneller prüfen und Prototypen früher zeigen. Gleichzeitig bleiben Abhängigkeiten zwischen Teams, widersprüchliche Dokumentation und gewachsene Freigaberunden unverändert. Der Engpass rückt aus der Implementierung in die Organisation.\n\nDas ist kein Argument gegen große Unternehmen. Ihre Formalitäten sind selten aus reiner Freude an Bürokratie entstanden. Regulierung, Produkthaftung, wechselnde Mitarbeiter, verteilte Systeme und jahrzehntelang betriebene Software brauchen Nachvollziehbarkeit. Problematisch wird es, wenn niemand mehr unterscheiden kann, welche Formalität ein echtes Risiko beherrscht und welche nur eine frühere Unsicherheit verwaltet.\n\n## KI verstärkt den vorhandenen Arbeitsfluss\n\nDie Zahlen zur Produktivität passen nur auf den ersten Blick nicht zusammen. Drei Feldexperimente bei Microsoft, Accenture und einem weiteren Fortune-100-Unternehmen mit insgesamt 4.867 Entwicklern ergaben für Nutzer eines Coding-Assistenten rund 26 Prozent mehr erledigte Aufgaben; die Wirkung unterschied sich nach Erfahrung und Kontext. Eine randomisierte METR-Studie mit erfahrenen Open-Source-Entwicklern kam dagegen für frühe 2025er-Werkzeuge auf 19 Prozent längere Bearbeitungszeiten. Im Februar 2026 berichtete METR von Hinweisen auf bessere Ergebnisse mit neueren agentischen Werkzeugen, hielt die Daten wegen Auswahleffekten aber selbst für zu unsicher, um die Größe des Nutzens zu beziffern.\n\nDie Befunde widersprechen sich weniger, als es scheint. Aufgabe, Codebasis, Erfahrung, Werkzeug und Abnahmekriterium verändern das Ergebnis. Der [DORA-Report 2025](https://dora.dev/research/2025/dora-report/) fasst den organisatorischen Teil treffend zusammen: KI wirkt als Verstärker. Gute Feedbackschleifen, gepflegte interne Daten und kleine Änderungen profitieren. Unklare Zuständigkeiten und schwerfällige Abläufe werden ebenfalls verstärkt.\n\nIn dieselbe Richtung weist ein Befragungsergebnis aus dem [Atlassian State of Developer Experience Report 2025](https://www.atlassian.com/teams/software-development/state-of-developer-experience-2025): Entwickler berichteten dort von Zeitersparnis durch KI und zugleich von wöchentlichen Zeitverlusten durch organisatorische Reibung. Solche Selbstauskünfte sind keine Stoppuhr. Als Diagnose sind sie trotzdem nützlich: Schnellerer Code beseitigt keine Wartezeit.\n\n## Der Ticketbaum ist nicht das Produkt\n\nIn einem großen Unternehmen kann aus einer Produktidee zunächst eine Initiative werden, darunter ein Epic, mehrere Features, Stories und Subtasks. Die Fachbeschreibung liegt in Confluence, eine Architekturentscheidung in einem Kommentar, die Abnahmebedingung in einem Ticket und die tatsächliche Regel im Code. Jede Ebene ist für sich nachvollziehbar. Zusammen entstehen mehrere Wahrheiten.\n\nDaraus entsteht häufig ein halb agiles, halb wasserfallartiges Vorgehen. Das Team arbeitet in Sprints, muss den Inhalt aber Monate vorher für Budget, Abhängigkeiten und Gremien festlegen. Rückfragen landen als Ticketkommentare, weil das gesprochene Wort nicht als belastbar gilt. Also wird mehr geschrieben, ohne dass die Dokumentation dadurch konsistenter wird. Bei der Integration mehrerer Systeme wächst die Zahl der Abstimmungen schneller als die Zahl der fachlichen Entscheidungen.\n\nKI sollte diesen Apparat nicht automatisch befüllen. Sonst entstehen nur mehr Epics, längere Zusammenfassungen und noch überzeugender klingende Kommentare. Der bessere Ansatz beginnt mit einer einzigen, kleinen Beschreibung der Absicht. Alles Weitere wird daraus abgeleitet und bleibt mit der Änderung versioniert.\n\n```yaml\nchange:\n  id: customer-address-v2\n  outcome: reduce failed deliveries\n  constraints:\n    - preserve existing API clients\n    - keep customer consent auditable\n  owners:\n    product: customer-experience\n    technical: customer-platform\n  evidence:\n    - contract-tests\n    - migration-report\n    - rollback-plan\n  risk: medium\n```\n\nDas ist kein neues Pflichtformular. Es ist ein Beispiel für ein kleines, versioniertes Änderungsartefakt, über das man sinnvoll streiten kann. Mit lesendem Zugriff auf einen indexierten Code- und Entscheidungsbestand darf ein Agent daraus betroffene Repositories, frühere Entscheidungen, Verträge und Tests zusammensuchen. Er darf einen Plan, Akzeptanzbeispiele und Dokumentationsänderungen vorschlagen. Outcome, Einschränkungen, Risiko und Eigentümer bestimmt weiterhin das Team.\n\nDer Unterschied zum Ticketbaum liegt in der Richtung: Menschen pflegen nicht fünf Darstellungen derselben Änderung. Die unterschiedlichen Sichten werden aus wenigen versionierten Quellen erzeugt. Das Ticket verweist auf das Artefakt, der Pull Request zeigt den Diff, und die Dokumentation verweist auf die Entscheidung. Eine Zusammenfassung für das Management darf anders aussehen als die technische Sicht, aber beide müssen auf denselben Stand zurückführbar sein.\n\n## Integration braucht Verträge statt mehr Abstimmung\n\nKomplexe Integration wird durch KI nicht automatisch einfach. Ein Agent kann einen unbekannten Service schnell lesen und trotzdem eine fachliche Annahme übersehen, die nur das betreibende Team kennt. Gerade deshalb sollte die Zusammenarbeit weniger von Prosa und stärker von überprüfbaren Verträgen abhängen.\n\nBeim Beispiel der geänderten Kundenadresse könnte das relevante Event als Schema vorliegen:\n\n```json\n{\n  \"$id\": \"CustomerAddressChanged.v2\",\n  \"type\": \"object\",\n  \"required\": [\"customerId\", \"address\", \"consentId\"],\n  \"properties\": {\n    \"customerId\": { \"type\": \"string\" },\n    \"address\": { \"$ref\": \"Address.v2.json\" },\n    \"consentId\": { \"type\": \"string\" }\n  }\n}\n```\n\nDie KI kann im Code nach Produzenten und Konsumenten suchen, einen Kompatibilitätsbericht erstellen, Contract Tests ergänzen und Testdaten vorbereiten. Der Kompatibilitätsbericht muss für jede Aussage Repository, Revision und Fundstelle ausweisen – etwa dafür, dass das CRM `consentId` bereits liefert, der Versandservice das Feld ignoriert und ein alter Export mit `Address.v1` arbeitet. So lässt sich die Recherche prüfen, bevor daraus eine Entscheidung entsteht.\n\nEntscheiden muss sie nicht. Ob eine alte Adresse noch verarbeitet werden darf, ist eine fachliche Frage. Ob beide Versionen parallel laufen oder ein Adapter nötig ist, ist eine Architekturentscheidung. Der Agent sammelt Evidenz; die beteiligten Teams verantworten den Vertrag. Genau dafür sind [ADRs](https://www.mikebild.dev/de/blog/adr-entscheidungen-die-man-versteht/) und ausführbare Spezifikationen wertvoll: Sie halten nicht jedes Gespräch fest, sondern das Ergebnis und seine Gründe.\n\n## Dokumentation wird abgeleitet, nicht abgeschafft\n\nConfluence bleibt für Erklärungen, Entry Points und Übersichten nützlich. Es sollte nur nicht länger die dritte manuell gepflegte Kopie dessen sein, was Ticket und Code ebenfalls beschreiben. Eine Seite braucht einen benannten Eigentümer, einen überprüfbaren Bezug zu Vertrag, ADR oder Repository und ein sichtbares Aktualisierungsdatum. Fehlt diese Verbindung, darf ein Agent die Seite als möglicherweise veraltet markieren, aber nicht stillschweigend zur Wahrheit erklären.\n\nKI kann aus einer freigegebenen Änderung verschiedene Sichten erzeugen: Release Notes für Nutzer, einen technischen Integrationshinweis und eine kurze Zusammenfassung für das Portfolio. Der jeweilige Text bleibt überprüfbar, weil er auf dieselben Artefakte verweist. Menschen müssen dann nicht jeden Satz dreimal schreiben. Sie prüfen, ob die abgeleitete Sicht für ihre Leser stimmt. Die Dokumentation wird dadurch konsistenter und die Pflicht zugleich kleiner: Festgehalten werden Entscheidung, Vertrag und Betriebsfolge – nicht jede Zwischenstufe der Diskussion.\n\n```mermaid\nflowchart LR\n  Idea[Produktidee] --> Ticket[Ticket-Hierarchie]\n  Ticket --> Docs[Separate Dokumentation]\n  Docs --> Meetings[Abstimmungsrunden]\n  Meetings --> Build[Große Umsetzung]\n  Build --> Test[Gemeinsames Testfenster]\n  Test --> Approval[Externe Freigabe]\n\n  Intent[Versionierte Absicht] --> SmallChange[Kleine Änderung]\n  SmallChange --> Evidence[Tests, Vertrag und Diff]\n  Evidence --> Review[Peer Review]\n  Review --> Release[Release]\n  Release --> Feedback[Nutzer- und Betriebssignal]\n  Feedback --> Intent\n```\n\n## Agil wird man durch die Kosten einer Änderung\n\nAgilität ist kein bestimmtes Board und keine Zahl von Meetings. Das ursprüngliche Agile Manifesto stellt funktionierende Software über umfassende Dokumentation und die Reaktion auf Veränderung über das Befolgen eines Plans. Beide Aussagen verlangen keine Sorglosigkeit. Sie verlangen, dass neue Information den Arbeitsstand tatsächlich verändern kann.\n\nOb KI Recherche, Prototyping oder Testentwurf in einem konkreten Wertstrom verkürzt, muss dort gemessen werden. Wenn das gelingt, sinken die Kosten kleiner Experimente. R&D kann dann stärker mit Hypothesen arbeiten: Was wollen wir lernen, welches kleinste Experiment liefert Evidenz, und welches Ergebnis würde unsere Richtung ändern? Produktentwicklung kann denselben Rhythmus nutzen.\n\nDie Voraussetzung sind kleine Batches. Mehr parallel erzeugter Code verschlechtert den Fluss, wenn die Review-Kapazität gleich bleibt. Die Folge wäre eine neue Form von Lagerbestand – halbfertige Pull Requests statt halbfertiger Tickets. Wie bei [Developer Productivity](https://www.mikebild.dev/de/blog/developer-productivity-verkuerzte-zyklen/) zählt deshalb nicht die Tippgeschwindigkeit, sondern die Zeit bis zu belastbarem Feedback. [DORA-Metrics](https://www.mikebild.dev/de/blog/dora-metriken-was-zyklen-verkuerzt/) helfen beim Diagnostizieren; sie dürfen nicht zum Ziel für einzelne Mitarbeiter werden.\n\n## Governance ohne pauschale Queue\n\nKontrolle bleibt nötig, aber nicht jede Änderung verdient denselben Weg. Eine Textkorrektur, eine rückwärtskompatible API-Erweiterung und ein neues Berechtigungsmodell haben verschiedene Schadenspotenziale. Gute Governance macht diesen Unterschied ausführbar. Das NIST AI Risk Management Framework verfolgt denselben Gedanken auf der Ebene der KI-Systeme selbst: Es strukturiert Risikomanagement über Governance, Messung und laufende Steuerung statt über eine einzelne pauschale Freigabe.\n\n```yaml\napproval_policy:\n  - risk: low\n    requires: [tests, policy-checks, peer-review]\n    decision: peer-approved\n  - risk: medium\n    requires: [tests, contract-checks, owner-review, rollback-plan]\n    decision: peer-approved\n  - risk: high\n    requires: [security-review, compliance-review, business-owner, audit-record]\n    decision: manually-approved\n```\n\nAuch diese YAML-Datei ist nur eine Skizze, keine fertige Policy-Sprache. Automatisierte Kontrollen erzeugen bei jeder Änderung dieselbe Evidenz. Menschen prüfen dort, wo Kontext, Haftung oder schwer rückbaubare Wirkung hinzukommen. DORA empfiehlt, schwere externe Change Approvals durch Peer Review, kontinuierliche Tests, Monitoring und gezielte Zusatzprüfung riskanter Änderungen zu ersetzen. Das ist weniger Bürokratie und zugleich präzisere Kontrolle.\n\n```mermaid\nflowchart TD\n  Change[Änderung mit Evidenz] --> Risk{Risikoklasse}\n  Risk -->|niedrig| Guardrails[Automatische Guardrails]\n  Guardrails --> Peer[Peer Review]\n  Peer --> Release[Release]\n  Risk -->|mittel| Owner[Fachlicher und technischer Owner]\n  Owner --> Release\n  Risk -->|hoch| Specialist[Security, Compliance und Business]\n  Specialist --> Approval{Freigabe}\n  Approval -->|ja| Release\n  Approval -->|nein| Rework[Überarbeiten oder verwerfen]\n```\n\nDer EU AI Act macht eine zweite Unterscheidung wichtig. Artikel 4 der [Verordnung (EU) 2024/1689](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A32024R1689) zur KI-Kompetenz gilt seit dem 2. Februar 2025: Anbieter und Betreiber müssen Maßnahmen treffen, damit Menschen, die in ihrem Auftrag mit KI-Systemen arbeiten, über ein ausreichendes Maß an KI-Kompetenz verfügen. Anhang III Nr. 4 der Verordnung erfasst – abhängig vom bestimmungsgemäßen Verwendungszweck – unter anderem Systeme, die Beschäftigte bewerten oder überwachen oder Aufgaben anhand persönlichen Verhaltens oder persönlicher Merkmale zuweisen. Ein Produktivitätsranking aus Prozessdaten fällt nicht automatisch darunter. Wird es jedoch für Personalentscheidungen, Leistungsbewertung oder Aufgabenzuweisung genutzt, liegt es in einem rechtlich besonders sensiblen Bereich. Governance soll die Arbeit absichern, nicht Menschen unter Generalverdacht stellen.\n\n## Die Organisation muss dem Arbeitsfluss folgen\n\nEin neuer Assistent allein ändert kein Organigramm. Auf Dauer passt aber eine Organisation mit vielen Übergaben schlecht zu einer Arbeitsweise, die in kurzen Schleifen lernen soll. Für Wertströme mit häufigen teamübergreifenden Änderungen werden drei Strukturen wichtiger:\n\n- langlebige, funktionsübergreifende Produktteams, die eine Wirkung von der Idee bis zum Betrieb verantworten und kleine Änderungen selbst freigeben können\n- eine interne Plattform, die sicheren Zugriff auf Code, Dokumentation, Testumgebungen und freigegebene KI-Modelle als Standardweg anbietet\n- Governance-, Security- und Architekturgruppen, die Regeln, Referenzlösungen und Ausnahmen gestalten, statt jede normale Änderung einzeln durchzuwinken\n\nDas entspricht dem Muster, das ich schon bei [einfacherem Entwurf und weniger Prozess](https://www.mikebild.dev/de/blog/einfach-schlagen-elegant-statt-prozess/) beschrieben habe: Erst die Änderung klein und ihre Erwartungen explizit machen, dann den Prozessschritt entfernen, der dadurch überflüssig wird. Eine lokal gemessene Zeitersparnis ersetzt diese Architekturarbeit nicht.\n\nFür die beteiligten Rollen verschiebt sich der Schwerpunkt:\n\n| Rolle | Neuer Schwerpunkt |\n| --- | --- |\n| Entwickler | Aufgaben schneiden, Kontext bereitstellen, generierte Änderungen prüfen, Tests und Betriebssignale entwerfen |\n| Architekten | Systemverträge, automatisierte Architekturregeln, Rückbaubarkeit und sichere Standardwege gestalten |\n| Product Owner | Outcomes, Akzeptanzbeispiele und Nutzerfeedback schärfen, statt den Ticketbestand zu verwalten |\n| Projektmanagement | Abhängigkeiten, Risiken, Wartezeiten und Work in Progress verringern, statt Tätigkeiten im Detail vorzuplanen |\n\nKeine dieser Rollen verschwindet. Im Gegenteil: Je billiger die Erzeugung von Vorschlägen wird, desto wertvoller werden Auswahl, Urteil und Verantwortung. Das [Ergebnis eines einzelnen KI-Power-Users lässt sich nicht einfach auf ein Enterprise übertragen](https://www.mikebild.dev/de/blog/mein-erfolg-laesst-sich-nicht-kopieren/). Teams brauchen gemeinsame Prüfkriterien, Lernzeit und einen Arbeitsfluss, der Unterschiede sichtbar macht.\n\n## Klein anfangen und den Gesamtzyklus messen\n\nEin unternehmensweites Transformationsprogramm wäre ein widersprüchlicher Anfang. Besser ist ein einzelner Wertstrom mit einer wiederkehrenden Änderung, deren Risiko bekannt ist. Dort lässt sich der heutige Weg vom Wunsch bis zum Betrieb aufzeichnen: Wie lange wird gearbeitet, wie lange gewartet, wie oft wird dieselbe Information übertragen, und an welcher Stelle entsteht Nacharbeit?\n\nFür einen ersten Versuch reichen wenige Schritte:\n\n- ein versioniertes Änderungsartefakt und die führenden Quellen festlegen\n- der KI nur lesenden Zugriff geben und ihren Kontext nachvollziehbar protokollieren\n- Tests, Contract Checks und Dokumentations-Diffs automatisch als Evidenz erzeugen\n- zunächst ausschließlich kleine, reversible Änderungen durch den neuen Pfad schicken\n- Lead Time, Wartezeit, Zahl der Übergaben, Nacharbeit, Change Fail Rate und Zeit bis zum Nutzerfeedback vergleichen\n\nNicht messen würde ich erzeugte Codezeilen, geschlossene Tickets oder individuelle Prompt-Aktivität. Solche Zahlen belohnen Volumen. Messen würde ich den Bestand halbfertiger Arbeit, den Anteil einer vorab festgelegten Dokumentstichprobe, der einem benannten Vertrag, ADR oder Repository-Stand widerspricht, und die Zeit vom Öffnen einer Review-Anfrage bis zur ersten Freigabe oder konkreten Änderungsanforderung. Der Anteil der Releases, die über den kontrollierten Pfad laufen, misst die Prozessabdeckung und nicht die Wirkung.\n\n## Fazit\n\nDie schwerfälligen Abläufe großer Unternehmen sind kein Naturgesetz, aber auch nicht bloß Dummheit aus der Vergangenheit. Sie bewahren Zuständigkeit, Wissen und Kontrolle dort, wo technische Mittel lange nicht ausgereicht haben. KI verändert diese Rechnung, weil sie Kontext schneller zusammentragen, Evidenz vorbereiten und wiederkehrende Prüfungen unterstützen kann.\n\nDer Gewinn entsteht erst, wenn die Organisation die frei werdende Zeit nicht mit mehr Output füllt. Dann werden Änderungen kleiner, Verträge ersetzen einen Teil der Abstimmung und versionierte Artefakte vermeiden widersprüchliche Kopien. Freigaben richten sich nach dem Risiko statt nach einer pauschalen Queue. Menschen behalten die Entscheidungen, bei denen Fachlichkeit, Haftung und Folgen zählen.\n\nSo kann KI Entwicklung und R&D tatsächlich agiler machen: nicht indem sie den alten Prozess schneller ausfüllt, sondern indem sie hilft, seine Umwege überflüssig zu machen.\n\n## Weiterführende Quellen\n\n- [DORA – State of AI-assisted Software Development 2025](https://dora.dev/research/2025/dora-report/)\n- [DORA – Capability Catalog](https://dora.dev/capabilities/)\n- [DORA – Streamlining Change Approval](https://dora.dev/capabilities/streamlining-change-approval/)\n- [DORA – Working in Small Batches](https://dora.dev/capabilities/working-in-small-batches/)\n- [DORA – Repository](https://github.com/dora-team/dora.dev)\n- [Microsoft Research – The Effects of Generative AI on High-Skilled Work](https://www.microsoft.com/en-us/research/publication/the-effects-of-generative-ai-on-high-skilled-work-evidence-from-three-field-experiments-with-software-developers/)\n- [METR – Measuring the Impact of Early-2025 AI on Experienced Open-Source Developer Productivity](https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/)\n- [METR – We Are Changing Our Developer Productivity Experiment Design](https://metr.org/blog/2026-02-24-uplift-update/)\n- [Atlassian – State of Developer Experience Report 2025](https://www.atlassian.com/teams/software-development/state-of-developer-experience-2025)\n- [Manifesto for Agile Software Development](https://agilemanifesto.org/)\n- [NIST – AI Risk Management Framework](https://airc.nist.gov/airmf-resources/airmf/5-sec-core/)\n- [Europäische Kommission – AI Literacy: Questions & Answers](https://digital-strategy.ec.europa.eu/en/faqs/ai-literacy-questions-answers)\n- [Verordnung (EU) 2024/1689 – Artificial Intelligence Act](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A32024R1689)"},{"slug":"sauberes-grounding-warum-ki-raet","title":"Sauberes Grounding I: Warum die KI so überzeugend rät","summary":"Warum Sprachmodelle plausible Dinge erfinden, weshalb mehr Training das Problem nicht zuverlässig beseitigt und was Grounding damit zu tun hat.","tags":["Software","KI","Methodik","Delivery"],"published_at":"2026-05-06T10:00:00+00:00","updated_at":"2026-08-17T16:28:14.564928+00:00","url":"https://www.mikebild.dev/de/blog/sauberes-grounding-warum-ki-raet/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/dc9891b2fd00b8bc42f7a52453cf4dea79b198a40f96c8eb178b49501009946c/sauberes-grounding-warum-ki-raet-vorlesen.mp3","audio_duration_secs":679,"text":"Vor ein paar Wochen saß ich in einem Review über generiertem Code. Der Assistent hatte eine Retry-Logik für unseren internen HTTP-Client vorgeschlagen – einen Client, den es nur bei uns gibt, eine dünne TypeScript-Schicht über `fetch` mit ein paar hauseigenen Konventionen. Der Vorschlag: `client.withRetry({ backoff: \"exponential\", maxAttempts: 3 })`. Sauber benannt, im Stil unserer Codebasis, mit einem Kommentar, warum exponentielles Backoff hier die richtige Wahl sei. Dazu eine kurze Erklärung im Chat, wie sich die Methode zu unserem Timeout-Handling verhält.\n\nDie Methode existiert nicht. Sie hat nie existiert. Unser Client konfiguriert Retries an einer ganz anderen Stelle, beim Erzeugen der Instanz, nicht am Aufruf. Aber das Erschreckende war nicht der Fehler. Fehler mache ich auch. Das Erschreckende war die Qualität der Erfindung: Der Name passte perfekt zu unseren Konventionen, die Signatur sah aus wie von uns, die Begründung war fachlich einwandfrei. Wäre die Methode von einem Menschen vorgeschlagen worden, hätte ich gesagt: Der kennt unseren Code richtig gut. Nur dass der Vorschlag komplett geraten war – und mit derselben Souveränität vorgetragen wurde wie die zwanzig korrekten Vorschläge davor.\n\n*Teil 7 der Serie „Von der Session zum System“ – hier geht es [zum Auftakt](https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/).*\n\nWer ernsthaft mit Copilot, Claude Code oder Cursor arbeitet, kann so etwas erleben. Ich habe solche Fehler anfangs wie einen Bug betrachtet, der mit der nächsten Modellgeneration verschwinden müsste. Heute verlasse ich mich nicht darauf. Bessere Modelle können Halluzinationen reduzieren, beseitigen sie aber nicht zuverlässig – und seit mir das klar geworden ist, arbeite ich anders mit diesen Systemen.\n\n## Das Modell kennt seine Wissensgrenze nicht zuverlässig\n\nUm zu verstehen, warum die Erfindung so überzeugend klang, musste ich mir klarmachen, was ein Sprachmodell eigentlich tut: Es setzt Text fort. Aus Milliarden von Beispielen hat es gelernt, welche Fortsetzung nach einem gegebenen Anfang wahrscheinlich ist. Das ist keine abwertende Vereinfachung, sondern der Mechanismus – und er ist erstaunlich mächtig, wie ich [in Folge 2 dieser Serie](https://www.mikebild.dev/de/blog/gut-genug-modelle/) beschrieben habe. Aber er hat eine Eigenschaft, die man nicht wegdiskutieren kann: Wahre und falsche Aussagen entstehen beide als wahrscheinliche Fortsetzungen; eine Wahrheitsprüfung folgt aus diesem Mechanismus nicht automatisch.\n\nEin menschlicher Entwickler hat – bei aller Fehlbarkeit – ein Gefühl dafür, ob er etwas weiß oder vermutet. Wenn ich einen Kollegen nach einer Methode unserer internen Bibliothek frage, antwortet er entweder „die heißt so“ oder „ich müsste nachschauen“. Dieses Nachschauen-Müssen ist Metakognition: Wissen über das eigene Wissen, ein Gespür für die eigene Unsicherheit. Genau diese Instanz fehlt dem Modell. Es hat keinen inneren Zustand, der zwischen „das stand tausendfach im Trainingskorpus“ und „das klingt so, wie es klingen müsste“ unterscheidet.\n\nDeshalb ist die Souveränität, die uns so irritiert, keine Anmaßung, sondern ein Artefakt. Der Ton eines Textes ist Teil der Fortsetzung. Technische Dokumentation, Stack-Overflow-Antworten, Code-Kommentare – das Material, aus dem das Modell gelernt hat, ist überwiegend im Ton der Gewissheit geschrieben. Niemand dokumentiert eine API mit „ich glaube, diese Methode gibt es“. Also klingt auch die erfundene Methode nach Dokumentation. Der überzeugte Ton ist kein Signal für Korrektheit. Er war es nie.\n\nZwei Arbeiten stützen Teile dieser Erklärung. Ziwei Xu und Kollegen haben 2024 formal argumentiert, dass Halluzinationen für große Sprachmodelle nicht vollständig eliminierbar sind. Und OpenAI beschreibt in einer eigenen Veröffentlichung – Stand August 2026 online abrufbar – einen zweiten Mechanismus: Trainings- und Evaluationsanreize können Modelle zum Raten statt zum Eingestehen von Unsicherheit bewegen. Ein Modell, das bei Unsicherheit „weiß ich nicht“ sagt, kann in Benchmarks, die richtige Antworten belohnen, Enthaltungen aber nicht angemessen berücksichtigen, schlechter abschneiden als eines, das selbstbewusst rät – so wie ein Multiple-Choice-Test Studenten beibringt, bei Unwissen auf gut Glück anzukreuzen, statt das Feld leer zu lassen.\n\n## Symbole, die auf Symbole verweisen\n\nEs gibt noch eine tiefere Ebene, und sie ist älter als die aktuellen Modelle. 1990 – lange vor heutigen großen Sprachmodellen – hat der Kognitionswissenschaftler Stevan Harnad das *Symbol Grounding Problem* formuliert. Seine Frage: Wie kommt Bedeutung in ein System, das nur Symbole nach formalen Regeln manipuliert? Sein Bild: Man versuche, Chinesisch ausschließlich aus einem Chinesisch-Chinesisch-Wörterbuch zu lernen. Jedes Zeichen wird durch andere Zeichen erklärt, diese wiederum durch weitere Zeichen – man dreht sich im Kreis, ohne je bei der Sache selbst anzukommen. Symbole verweisen auf Symbole, nirgends berührt das System die Welt.\n\nIch zögere normalerweise, Paper aus der Kognitionswissenschaft in Alltagsdebatten zu ziehen, weil dabei oft mehr Nebel als Klarheit entsteht. Aber hier passt die Analogie unangenehm gut. Ein Sprachmodell kennt Text über die Welt, nicht die Welt. Es hat gelesen, was Menschen über HTTP-Clients, über Retries, über exponentielles Backoff geschrieben haben – und daraus statistische Zusammenhänge zwischen Token gelernt. Die Antwort in meiner Eingangsszene lieferte jedenfalls keinen Hinweis auf eine Prüfung an der Sache selbst: kein Compiler, der den Aufruf ablehnt, kein Test, der rot wird, keine Produktion, die um drei Uhr nachts alarmiert. Wo ich als Entwickler meine Begriffe an Erfahrung geeicht habe, hat das Modell seine Begriffe an anderen Begriffen geeicht. Das Wörterbuch erklärt sich selbst, und es erklärt sich flüssig.\n\nMan muss Harnads Problem nicht in voller philosophischer Schärfe übernehmen, um den praktischen Kern zu sehen: Ein System, dessen gesamtes „Wissen“ aus Textstatistik besteht, kann eine plausible Erfindung nicht von einer Tatsache unterscheiden, weil beide aus demselben Stoff sind. `withRetry` war für das Modell genauso real wie jede Methode, die es wirklich gibt – ein wahrscheinliches Symbol unter anderen Symbolen.\n\n## Warum größere Modelle das Problem mildern, aber nicht lösen\n\nDer Einwand liegt nahe: Die Modelle sind doch besser geworden. In meiner Arbeit erwische ich aktuelle Modelle im öffentlichen, gut dokumentierten Wissen jedenfalls nur noch selten bei freien Erfindungen.\n\nAber „seltener“ heißt nur „weniger oft“, nicht „grundsätzlich anders“. Der Mechanismus bleibt derselbe: Ein größeres Modell hat feinere statistische Zusammenhänge gelernt – es rät seltener, weil es mehr gesehen hat, nicht weil es aufgehört hätte zu raten. An der Stelle, wo sein Material endet, produziert es weiterhin die wahrscheinlichste Fortsetzung, im selben souveränen Ton. Die Grenzlinie zwischen Wissen und Vermutung verschiebt sich nach außen, aber sie verschwindet nicht, und das Modell zeigt sie mir nach wie vor nicht an. Genau das macht die restlichen Fälle so tückisch: Je seltener der Fehler, desto weniger rechne ich mit ihm – und desto ungeprüfter geht der Vorschlag durch.\n\n## Ohne interne Quellen bleiben organisationsspezifische Fakten Vermutung\n\nUnd damit zu dem Punkt, der mich in meiner täglichen Arbeit am meisten beschäftigt. Alles bisher Gesagte gilt für öffentliches Wissen – React, PostgreSQL, die AWS-APIs. Dort erlebe ich selten, dass das Modell rät – das öffentliche Material ist riesig. In dem Moment aber, in dem ich die Tür zu einer konkreten Organisation aufmache, kippt das Verhältnis. Denn was dort zählt, ist dem Modell ohne bereitgestellte interne Quellen nicht verlässlich zugänglich:\n\n- die internen Bibliotheken und Services mit ihren tatsächlichen Signaturen und Eigenheiten\n- die hauseigenen Konventionen – wie Fehler behandelt werden, wie Module geschnitten sind, was ein Deployment durchlaufen muss\n- die Begriffe der Fachdomäne, die im Unternehmen etwas Präzises bedeuten und außerhalb etwas anderes oder gar nichts\n\nWie weit das geht, hat mir der Fall aus der Eingangsszene gezeigt. Das Modell hat unseren HTTP-Client nie gesehen, also hat es ihn erfunden – und zwar so, wie ein solcher Client typischerweise aussieht. Deshalb war der Vorschlag so gut: Er war die Idealform eines internen HTTP-Clients, destilliert aus öffentlichen Mustern. Nur eben nicht unserer. Ohne interne Quelle waren die tatsächlichen Signaturen für das Modell nicht verlässlich ableitbar; die Erfindung lag nahe und sah dem echten Wissen zum Verwechseln ähnlich, weil das Modell so gut darin ist, den Stil einer Codebasis fortzusetzen.\n\nDas erklärt im Nachhinein auch, warum meine Versuche mit Instruktionsdateien so ernüchternd verliefen – ich habe das [in der letzten Folge](https://www.mikebild.dev/de/blog/vergebliche-versuche-instruktionsdateien/) ausführlich erzählt. Damals dachte ich noch, das Problem sei die Verbindlichkeit: Die KI hält sich nicht an unsere Regeln, also müssen die Regeln klarer, strenger, prominenter formuliert werden. Aber selbst wenn eine AGENTS.md perfekt befolgt würde – sie beantwortet die falsche Frage. Eine Instruktion sagt dem System, wie es sich verhalten soll. Sie kann ihm nicht geben, was es tatsächlich nicht hat: die Fakten. „Erfinde keine Methoden“ ist eine hilflose Anweisung an ein System, das nicht merkt, wann es erfindet. Dem Modell fehlt nicht der Wille, es fehlt das Material. Ich habe an der Motivation eines Systems gearbeitet, das kein Motivationsproblem hat, sondern ein Wissensproblem.\n\n## Grounding: interne Fakten im Arbeitskontext bereitstellen\n\nFür diese Antwort auf das Wissensproblem verwende ich hier den Begriff Grounding – das Erden von Antworten in überprüfbaren Fakten. Die Idee ist im Kern einfach. Wenn das Modell meine Realität nicht aus dem Training kennen kann, dann muss ich sie ihm zum Zeitpunkt der Arbeit mitgeben: die echte Signatur statt der wahrscheinlichen, das tatsächliche Schema statt des typischen, unsere Konvention statt der branchenüblichen. Nicht als Verhaltensregel, sondern als Material. Das Modell soll nachschlagen können statt raten zu müssen – ungefähr so, wie mich eine gute IDE mit Typinformationen davon abhält, Methodennamen aus dem Gedächtnis zu tippen.\n\nHier verwende ich Grounding für die Bereitstellung konkreten Quellenmaterials im Arbeitskontext und unterscheide es damit von bloßen Instruktionen, die Verhaltensregeln formulieren. Grounding ist kein Produkt und kein Feature, das man einschaltet. Es ist zunächst eine Einsicht über die Arbeitsteilung: Das Modell liefert Sprachverständnis, Mustererkennung, Syntheseleistung – die Fakten über meine Welt muss meine Welt selbst liefern. Und es ist, ehrlich gesagt, auch keine vollständige Lösung. Ein geerdetes Modell kann die mitgegebenen Fakten immer noch falsch verknüpfen, und wer schon einmal versucht hat, „einfach alle Dokumente“ in den Kontext zu kippen, weiß, dass man sich damit neue Probleme einhandelt. Der Unterschied ist ein anderer: Bei einem geerdeten System streite ich über die Qualität meiner Quellen. Ohne Quellen prüfe ich freie Ergänzungen aus allgemeinen Mustern.\n\nDamit steht die Konsequenz dieser Folge fest, auch wenn sie niemandem Arbeit erspart: Bessere Modelle und strengere Anweisungen beseitigen überzeugende Falschantworten nicht zuverlässig. Grounding stellt dort konkretes Quellenmaterial bereit, wo das System sonst aus allgemeinen Mustern ergänzen müsste. Womit die eigentliche Arbeit beginnt – denn „interne Fakten mitgeben“ ist schnell gesagt. Welche Fakten eigentlich, in welcher Form, an welcher Stelle des Arbeitsflusses? Das ist keine rhetorische Frage, sondern die nächste Folge.\n\n## Weiterführende Quellen\n\n- [Stevan Harnad: The Symbol Grounding Problem (Physica D, 1990)](https://doi.org/10.1016/0167-2789(90)90087-6) – der Originalbeitrag mit dem Wörterbuch-Argument\n- [Ziwei Xu et al.: Hallucination is Inevitable – An Innate Limitation of Large Language Models (2024)](https://arxiv.org/abs/2401.11817) – die formale Argumentation, dass Halluzination für diese Modellklasse nicht eliminierbar ist\n- [OpenAI: Why Language Models Hallucinate](https://openai.com/index/why-language-models-hallucinate/) – warum Trainings- und Evaluationsanreize das Raten belohnen können (abgerufen im August 2026)\n- [Lilian Weng: Extrinsic Hallucinations in LLMs (2024)](https://lilianweng.github.io/posts/2024-07-07-hallucination/) – ausführlicher Überblick über Ursachen, Messung und Gegenmaßnahmen"},{"slug":"vergebliche-versuche-instruktionsdateien","title":"Vergebliche Versuche: Skills, AGENTS.md und die Grenze der Empfehlung","summary":"Ein halbes Jahr lang habe ich versucht, Teamregeln über CLAUDE.md, AGENTS.md und Skills durchzusetzen – die Chronik einer Ernüchterung und die Einsicht, dass Instruktionsdateien Empfehlungen an ein probabilistisches System sind, keine Regeln.","tags":["Software","KI","Methodik","Delivery"],"published_at":"2026-04-15T10:00:00+00:00","updated_at":"2026-08-17T16:28:14.564928+00:00","url":"https://www.mikebild.dev/de/blog/vergebliche-versuche-instruktionsdateien/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/71824a2064893b5af0b905292a443d9120c1d6a9bbf3e51db4d51839e7f21cba/vergebliche-versuche-instruktionsdateien-vorlesen.mp3","audio_duration_secs":623,"text":"Dienstagmorgen, Code-Review. In unserer AGENTS.md steht seit Wochen ein Abschnitt „Hard rules“, und die erste Zeile darin lautet sinngemäß: Migrationsdateien werden niemals von Hand editiert. Der Pull Request, den ich vor mir habe, wurde von einem Agenten erzeugt, und er enthält genau das – eine von Hand nachgezogene Migrationsdatei, hübsch formatiert, mit einem Kommentar, der erklärt, warum das hier ausnahmsweise der sauberste Weg sei. Ich scrolle durch das Session-Protokoll. Der Agent hat die AGENTS.md gelesen. Sie stand in seinem Kontext. Er hat sich trotzdem anders entschieden.\n\n*Teil 6 der Serie „Von der Session zum System“ – hier geht es [zum Auftakt](https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/).*\n\nDas ist keine Anekdote über ein schlechtes Modell. Es war ein aktuelles, fähiges Modell, und die Regel war unmissverständlich formuliert. Es ist auch keine Anekdote über einen schlampigen Kollegen – der hatte alles richtig gemacht, was man nach Stand der gängigen Empfehlungen richtig machen kann. Es ist die Anekdote, auf die diese ganze Folge zuläuft, und sie hat eine Vorgeschichte von etwa einem halben Jahr. In [Folge 5](https://www.mikebild.dev/de/blog/mein-erfolg-laesst-sich-nicht-kopieren/) hatte ich beschrieben, warum sich mein persönlicher Erfolg mit KI-Werkzeugen nicht auf ein Team kopieren lässt: zu viel steckt in impliziten Arbeitsweisen, zu groß ist die Varianz zwischen Personen, Aufgaben und Sessions. Die naheliegende Antwort darauf schien mir damals offensichtlich – wir schreiben das Implizite auf. In Dateien, die die KI liest. Das muss doch reichen.\n\nEs hat nicht gereicht. Und ich will ehrlich erzählen, wie ich das herausgefunden habe, Stufe für Stufe, denn jede Stufe habe ich mit echter Hoffnung begonnen.\n\n## Erster Anlauf: eine Datei im Repo\n\nDer Anfang war eine CLAUDE.md im Repository, kurz darauf ergänzt um eine AGENTS.md – ein offenes Format, mit dem Repository-spezifische Hinweise für Coding-Agenten versioniert bereitgestellt werden, inzwischen zusammen mit MCP in die Agentic AI Foundation unter dem Dach der Linux Foundation eingebracht. Die Idee: eine README für Agenten. Dort liegen Build-Kommandos, Testläufe, Konventionen und Projektwissen versioniert im Repository – für Werkzeuge, die das Format unterstützen, und für das Team.\n\nUnsere erste Fassung sah ungefähr so aus, wie sie in vielen Repositories aussieht:\n\n```markdown\n# AGENTS.md\n\n## Build & Test\n- Install dependencies with `pnpm install`\n- Run `pnpm test` for unit tests, `pnpm test:e2e` for integration\n- Run `pnpm lint` before every commit\n\n## Conventions\n- TypeScript strict mode, no `any` in new code\n- New endpoints follow the router structure in `src/api/`\n- Tests follow Arrange/Act/Assert, one behavior per test\n\n## Hard rules\n- NEVER edit files in `migrations/` by hand\n- NEVER modify the production schema without a migration\n- Do not commit directly to `main`\n```\n\nUnd zunächst wirkte es. In den Sessions tauchten häufiger die richtigen Testkommandos auf statt geratener, der Code folgte dem Stil des Projekts statt dem von Stack Overflow, und im Review gab es weniger Grundsatzdiskussionen. Ich war in dieser Phase ziemlich zuversichtlich und habe die Datei gepflegt wie Dokumentation, die zum ersten Mal wirklich gelesen wird.\n\nDie Ernüchterung kam schleichend. Erst waren es Kleinigkeiten – ein Agent benannte Dinge anders als vereinbart, einer übersprang den Lint-Lauf. Dann waren es keine Kleinigkeiten mehr. Die Migrationsgeschichte vom Anfang war nicht die erste ihrer Art, nur die anschaulichste. Das Muster war immer dasselbe: mal wurde die Datei befolgt, mal halb, mal gar nicht. Und zwar ohne erkennbares System – dieselbe Regel, dieselbe Aufgabe, zwei Sessions, zwei Ergebnisse.\n\n## Die weiteren Anläufe: lauter, modularer, prominenter\n\nDanach habe ich an drei Stellen nachgeschärft. Zuerst wurde aus „please avoid“ ein „NEVER“, teilweise mehrfach in derselben Datei. Danach beobachtete ich weniger Verstöße, doch aus der Vorgabe wurde keine Garantie, und nebenbei blähte sich der Kontext auf.\n\nAls zweite Stufe probierten wir Agent Skills aus, die Anthropic im Oktober 2025 beschrieben hat: modulare Anleitungen mit Beispielen, Skripten und Checklisten, die ein Agent passend zur Aufgabe laden kann. Für klar geschnittene Abläufe funktionierten sie bei uns besser als ein langer Absatz in der AGENTS.md. Das Problem verschob sich damit nur: Der Skill musste passend ausgewählt und bis zum Ende befolgt werden. Auch geladen war er weiterhin Text im Kontext.\n\nZuletzt habe ich wichtige Vorgaben in Systemprompts und Abschlusschecklisten gehoben. Das gab ihnen mehr Gewicht und verbesserte die Ergebnisse noch einmal. Nach diesen drei Versuchen war die Frage für mich aber nicht mehr, wie ich denselben Satz noch deutlicher formulieren könnte. Ich wollte wissen, warum selbst sehr klarer Text eine Vorgabe nur wahrscheinlicher, nicht verbindlich macht.\n\n## Der Befund: Empfehlungen, keine Regeln\n\nWas am Ende dieser vier Stufen stand, ist die Kernthese dieser Folge: **Instruktionsdateien sind Empfehlungen an ein probabilistisches System, keine Regeln.** Nicht, weil die Dateien schlecht geschrieben wären. Nicht, weil das Modell zu schwach wäre. Sondern weil es strukturell gar nicht anders sein kann.\n\nDas lässt sich an vier Mechanismen festmachen, die ich alle in echten Projekten beobachtet habe:\n\n- Die Datei konkurriert im Kontextfenster mit allem anderen. Eine AGENTS.md ist ein Textblock unter vielen – neben dem Systemprompt des Werkzeugs, der Aufgabenbeschreibung, dem gelesenen Code, den Fehlermeldungen, dem bisherigen Gesprächsverlauf. Wenn ein Werkzeug lange Verläufe kürzt oder zusammenfasst, kann dabei auch eine frühere Instruktion an Gewicht verlieren. Meine Regel von Zeile 14 muss sich in jedem einzelnen Moment gegen zehntausende andere Tokens behaupten.\n- Instruktionen widersprechen einander. Angenommen, der Systemprompt eines Werkzeugs verlangt sinngemäß, hilfreich zu sein und die Aufgabe zu erledigen; meine Datei sagt „fass die Migration nicht an“, und die Aufgabe verlangt etwas, das ohne Migrationsänderung schwer geht. Das Modell muss diesen Konflikt auflösen. Der freundliche Kommentar im Pull Request – „ausnahmsweise der sauberste Weg“ – ist genau das: eine plausible Konfliktauflösung. Nur eben nicht meine.\n- Das System ist nichtdeterministisch. Dieselbe Eingabe erzeugt nicht dieselbe Ausgabe. Was in neun Sessions klappt, kippt in der zehnten – ohne dass sich an Datei, Aufgabe oder Modell irgendetwas geändert hätte. Das ist dieselbe Varianz, die ich in [Folge 5](https://www.mikebild.dev/de/blog/mein-erfolg-laesst-sich-nicht-kopieren/) zwischen Menschen beschrieben habe, nur dass sie hier innerhalb ein und desselben Werkzeugs auftritt, von Session zu Session.\n- Und der grundlegendste Punkt: Der Text selbst kann Gehorsam nicht erzwingen. In der reinen Modellinteraktion gibt es keinen separaten Interpreter, der „NEVER“ als harte Bedingung prüft und einen Verstoß technisch verhindert. Die Instruktion beeinflusst die Ausgabe, aber erst ein Mechanismus außerhalb der Textgenerierung – etwa Berechtigungen, Validierung oder ein blockierender Test – kann daraus eine durchgesetzte Regel machen.\n\nDamit ist der Verstoß vom Anfang kein Beleg dafür, dass die Datei ungelesen blieb. Er ist auch kein Bug einer Version, der mit dem nächsten Release verschwindet. Eine Quote bleibt eine Quote. Der Abstand zwischen 95 und 100 Prozent ist kein gradueller Abstand, den das nächste Modell schließt. Es ist ein kategorialer.\n\n## Was die Dateien trotzdem leisten\n\nInstruktionsdateien transportieren Konventionen, Ton, Projektwissen und sinnvolle Defaults, und ich pflege unsere Stand April 2026 mit einiger Sorgfalt. Sie ersparen jedem im Team das immer gleiche Vorgeplänkel am Session-Anfang. Sie heben den Durchschnitt aller Sessions. Bei uns mussten Build-Kommandos und Projektkonventionen ohne eine solche Datei häufiger pro Session neu vermittelt werden.\n\nEntscheidend ist die Art der Anforderung. Als Rechenbeispiel: „Schreib Tests im Arrange/Act/Assert-Stil“ ist eine Anforderung, die 90 Prozent Befolgung bestens verträgt – die restlichen zehn Prozent fallen im Review auf und werden korrigiert; in diesem Review-Fall entsteht kein Schaden. „Berühre nie das Produktionsschema“ ist eine Anforderung, die keine 99 Prozent verträgt. Angenommen, ein Team führt tausend Agenten-Sessions im Quartal durch: 99 Prozent Befolgung bedeuten zehn Vorfälle, und einer davon reicht. Für die erste Sorte sind Instruktionsdateien das richtige Werkzeug. Für die zweite sind sie es nicht – nicht, weil sie schlecht wären, sondern weil sie kategorial nicht das sein können, was diese Anforderung braucht: eine ausführbare Prüfung, begrenzte Rechte oder eine menschliche Freigabe außerhalb der Modellentscheidung.\n\nIch benutze dafür inzwischen gern einen Vergleich aus der klassischen Softwarearchitektur: Eine Coding-Guideline im Wiki und ein Typsystem im Compiler adressieren beide „so soll Code aussehen“ – aber niemand käme auf die Idee, sie für dieselbe Kategorie zu halten. Die Guideline hebt den Durchschnitt; ein aktivierter Typcheck weist dieselbe codierte Typverletzung bei gleicher Eingabe und Konfiguration reproduzierbar zurück. In unseren Teams trennen wir diese Kategorien bereits. Bei der Arbeit mit KI haben wir bisher fast nur die Guideline-Kategorie – und schreiben Compiler-Anforderungen hinein, in der Hoffnung, dass Großbuchstaben den Unterschied machen.\n\n## Empfehlung und Regel sind zwei Kategorien\n\nDamit ist die Chronik am Ende, und das Fazit fällt nüchterner aus, als ich es beim Start dieser Versuche im Herbst 2025 erwartet hätte. Alle vier Stufen – Repo-Dateien, verschärfter Wortlaut, Skills, Systemprompts samt Checklisten – arbeiten mit demselben Material: Prosa im Kontext. Prosa im Kontext kann empfehlen, prägen, verschieben. Sie kann nicht garantieren. Wer eine Garantie braucht, braucht einen anderen Ort als den Kontext – einen Ort, an dem eine Regel geprüft und durchgesetzt wird, unabhängig davon, wie das Modell in der einzelnen Session entscheidet. In [Folge 3](https://www.mikebild.dev/de/blog/harness-macht-den-unterschied/) habe ich beschrieben, dass der Harness – die Umgebung um das Modell herum, mit ihren Tools und Berechtigungen – den Unterschied zwischen den KI-Werkzeugen ausmacht. Hier zeigt sich dieselbe Trennlinie noch einmal schärfer: Was das Modell liest, ist Empfehlung. Nur was die Umgebung erzwingt, ist Regel. Wie man solche Regeln baut, die wirklich halten, ist einen eigenen Beitrag wert, und der kommt später in dieser Serie.\n\nVorher lässt mir aber etwas anderes keine Ruhe, und es steckt schon in der Szene vom Anfang. Der Agent, der unsere Migrationsregel brach, hat das nicht kleinlaut getan. Er hat es begründet – flüssig, plausibel, mit dem Ton eines erfahrenen Kollegen, der genau weiß, was er tut. Kein Zögern, kein Hinweis, keine Unsicherheit. Das System hält sich nicht verlässlich an das, was ich ihm mitgebe, und klingt dabei in jedem einzelnen Fall vollkommen überzeugt – wenn es richtig liegt genauso wie wenn es danebenliegt, und ich höre dem Ton den Unterschied nicht an. Woher diese Überzeugtheit kommt und warum sie kein Versehen ist, sondern zur Bauart gehört, schaue ich mir als Nächstes an.\n\n## Weiterführende Quellen\n\n- [AGENTS.md – offizielle Seite der Konvention](https://agents.md/)\n- [InfoQ: AGENTS.md Emerges as Open Standard for AI Coding Agents (August 2025)](https://www.infoq.com/news/2025/08/agents-md/)\n- [Linux Foundation: Gründung der Agentic AI Foundation – u. a. mit MCP und AGENTS.md](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation)\n- [Anthropic Engineering: Equipping agents for the real world with Agent Skills (Oktober 2025)](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills)\n- [Anthropic Engineering: Claude Code Best Practices (u. a. zu CLAUDE.md)](https://www.anthropic.com/engineering/claude-code-best-practices)\n- [Anthropic Engineering: Effective context engineering for AI agents](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents)"},{"slug":"mein-erfolg-laesst-sich-nicht-kopieren","title":"Mein Erfolg lässt sich nicht kopieren","summary":"Ein Kollege übernimmt mein KI-Setup und bekommt andere Ergebnisse. Werkzeuge, Tests und Konventionen sind übertragbar; Aufgabenzuschnitt, Prüfblick und der einzelne Sessionverlauf sind es nur begrenzt.","tags":["Software","KI","Methodik","Delivery"],"published_at":"2026-03-04T11:00:00+00:00","updated_at":"2026-08-17T16:28:14.564928+00:00","url":"https://www.mikebild.dev/de/blog/mein-erfolg-laesst-sich-nicht-kopieren/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/9528d86b5e7adbc4b132902fcd66fab4f8661830a87775382f617f8432957cbe/mein-erfolg-laesst-sich-nicht-kopieren-vorlesen.mp3","audio_duration_secs":728,"text":"Im Februar fragte mich ein Kollege – erfahrener Entwickler, gutes Urteilsvermögen, kein Anfänger – ob er mein Setup haben kann. Er hatte über Wochen zugesehen, wie ich mit Claude Code arbeite, und wollte dasselbe: dieselben Werkzeuge, dieselbe Konfiguration, dieselben Ergebnisse. Ich habe ihm alles gegeben. Die komplette Werkzeugkette. Meine Instruktionsdateien, gewachsen über Monate. Meine Prompt-Sammlung für wiederkehrende Aufgaben, meine Checklisten für Reviews, sogar meine Notizen dazu, welche Aufgaben ich delegiere und welche nicht. Es gab keinen geheimen Rest. Er hatte wirklich alles, was ich habe.\n\n*Teil 5 der Serie „Von der Session zum System“ – hier geht es [zum Auftakt](https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/).*\n\nZwei Wochen später saßen wir beim Kaffee, und er war ernüchtert. Nicht frustriert im Sinne von „das Werkzeug taugt nichts“ – er hatte durchaus brauchbare Momente erlebt. Aber das, was er bei mir gesehen hatte, diese Selbstverständlichkeit, mit der Aufgaben rausgehen und geprüfte Ergebnisse zurückkommen, stellte sich bei ihm nicht ein. Bei ihm lief die KI dreimal in die falsche Richtung, baute Dinge, die er nicht wollte, und er verbrachte mehr Zeit mit Aufräumen als vorher mit Selberschreiben. Sein Satz ist mir geblieben: „Ich habe dein Setup. Warum habe ich nicht deine Ergebnisse?“\n\nDie Frage hat mich länger beschäftigt als jedes Tool-Problem der letzten Monate. Denn die naheliegenden Antworten stimmen alle nicht. Es lag nicht am Modell – wir nutzten dasselbe. Nicht an der Konfiguration – sie war identisch, Datei für Datei. Nicht an mangelnder Erfahrung als Entwickler – er ist mindestens so gut wie ich. Was ihm fehlte, stand in keiner der Dateien, die ich ihm gegeben hatte. Und genau das ist der Punkt, an dem diese Serie den Sprung machen muss: vom Ich zum Team. Bisher habe ich beschrieben, [warum der Harness mehr ausmacht als das Modell](https://www.mikebild.dev/de/blog/harness-macht-den-unterschied/) und [was mir an meiner eigenen Arbeitsweise Unbehagen bereitet](https://www.mikebild.dev/de/blog/was-ich-kritisch-sehe/). Beides war die Perspektive eines Einzelnen. Sobald ein zweiter Mensch ins Spiel kommt, taucht ein Problem auf, das ich vorher nur geahnt hatte: Varianz. Und zwar gleich dreifach.\n\n## Erste Ebene: dieselbe Aufgabe, zwei Sessions, zwei Ergebnisse\n\nDie erste Ebene hat noch gar nichts mit meinem Kollegen zu tun. Ich kann sie allein an meinem eigenen Rechner vorführen.\n\nIch gebe derselben KI dieselbe Aufgabe zweimal – unter weitgehend gleichen sichtbaren Bedingungen: gleicher Prompt, gleiches Repository, gleiche Instruktionsdateien. Session eins entscheidet sich, erst die Tests zu lesen, findet dort ein bestehendes Muster und erweitert es sauber. Session zwei springt direkt in den Produktionscode, hält eine Hilfsfunktion für unbenutzt, räumt sie „bei der Gelegenheit“ mit weg und liefert eine Lösung, die zwar läuft, aber anders geschnitten ist als alles Umliegende. Beide Sessions haben denselben Auftrag bekommen. Beide berichten selbstbewusst, sie seien fertig. Die Ergebnisse unterscheiden sich in Struktur, Stil und Nebenwirkungen.\n\nIn meinen Durchläufen unterschieden sich schon frühe Entscheidungen – etwa welche Datei zuerst gelesen wurde oder welches Muster als Vorbild diente. Danach liefen auch die weiteren Schritte auseinander.\n\nFür mich allein ist das handhabbar. Ich habe gelernt, Ergebnisse zu prüfen statt Wege vorzuschreiben, und in [Folge 3](https://www.mikebild.dev/de/blog/harness-macht-den-unterschied/) habe ich beschrieben, wie viel davon der Harness abfängt: Tools, Feedback-Schleifen, Prüfschritte. Aber schon hier zeigt sich der Fehler, „Best Practices“ mit reproduzierbaren Ergebnissen zu verwechseln: Derselbe Erfolgsfall lässt sich nicht exakt wiederholen. Wenn ich meinem Kollegen zeige, wie großartig eine Session gestern lief, zeige ich ihm einen einzelnen Durchlauf – auch bei mir würde der nächste anders aussehen.\n\n## Zweite Ebene: das Wissen, das in keiner Datei steht\n\nDie zweite Ebene ist die, an der mein Kollege tatsächlich gescheitert ist. Und sie ist unangenehmer, weil sie sich als Werkzeugfrage tarnt, aber eine Erfahrungsfrage ist.\n\nAls wir seine ernüchternden zwei Wochen durchgesprochen haben, fiel mir auf: Er hat dieselben Dinge getan wie ich – nur an anderen Stellen. Er hat delegiert, aber die falschen Aufgaben. Er hat geprüft, aber das Falsche angeschaut. Er hat abgebrochen, aber zu spät. Drei Beispiele aus unserem Gespräch:\n\n- Er gab der KI eine Aufgabe, die ich nie im Ganzen delegieren würde – eine Änderung quer durch drei Module mit unklarer Fachlogik – und wunderte sich über das Chaos. Ich hätte sie vorher in drei prüfbare Häppchen geschnitten. Dieser Zuschnitt steht in keinem meiner Prompts, ich mache ihn im Kopf, bevor ich überhaupt tippe.\n- Er las die Erklärungen der KI und fand sie überzeugend. Ich lese die Erklärungen kaum noch – ich schaue auf das Diff, die Tests und die Stellen, an denen erfahrungsgemäß unerwünschte Nebenwirkungen auftreten. Wo man hinschaut, ist Urteilskraft, keine Konfiguration.\n- Er ließ eine Session dreimal nachbessern, ehe er aufgab. Ich breche nach dem ersten gescheiterten Nachbesserungsversuch ab und formuliere die Aufgabe neu, denn ich habe gelernt: Wenn die Richtung einmal falsch ist, wird Nachsteuern teurer als Neustarten. Auch das steht nirgends – diese Abbruchregel stammt aus wiederholten Fehlversuchen.\n\nAufgabenzuschnitt, Prüfblick, Abbruchinstinkt – das ist implizites Wissen im klassischen Sinn. Man erwirbt es durch Reibung, nicht durch Lektüre. Meine Instruktionsdateien sind das Sediment dieses Wissens, aber sie sind nicht das Wissen selbst. Sie sagen der KI, was sie tun soll. Sie sagen meinem Kollegen nicht, was *er* tun soll, während die KI arbeitet.\n\nDie [METR-Studie vom Sommer 2025](https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/) hat einen verwandten Effekt messbar gemacht: Bei erfahrenen Open-Source-Entwicklern dauerten Aufgaben mit frühen 2025er-KI-Werkzeugen im Mittel 19 Prozent *länger* – obwohl die Teilnehmenden einen Zeitgewinn erwarteten. Man kann über die Übertragbarkeit der Studie streiten, die Bedingungen waren speziell. Aber der belegte Befund passt zu dem, was ich bei meinem Kollegen gesehen habe: Erwarteter Zeitgewinn und gemessene Bearbeitungszeit können auseinanderliegen.\n\n## Dritte Ebene: es gibt keinen identischen Durchlauf\n\nDie dritte Ebene wird gern übersehen, weil sie so banal klingt: Mein Kollege arbeitet an anderen Aufgaben als ich.\n\nNicht grundsätzlich anders – gleiche Firma, verwandte Systeme, ähnlicher Stack. Aber seine konkrete Aufgabe an einem konkreten Dienstag ist nie meine konkrete Aufgabe. Sein Modul hat eine andere Geschichte, andere Altlasten, andere ungeschriebene Konventionen. Seine Bugs sitzen an anderen Stellen. Selbst wenn er meine Urteilskraft hätte und die Session-Lotterie zu seinen Gunsten ausginge: Er könnte meinen Erfolg trotzdem nicht reproduzieren, weil es den Versuchsaufbau „gleiche Aufgabe“ in echter Arbeit schlicht nicht gibt.\n\nDas unterscheidet KI-Werkzeuge fundamental von fast allem, was wir vorher im Team eingeführt haben. Formatter und CI-Pipelines zielen unter gleicher Version, Konfiguration und Umgebung auf reproduzierbare Ausführung; eine Framework-API stellt denselben Vertrag bereit. Bei diesen Werkzeugen gilt: Einer findet die gute Konfiguration, alle übernehmen sie, alle haben den Nutzen. Genau dieses Muster haben wir alle im Kopf, wenn wir „Setup teilen“ sagen – und genau dieses Muster greift hier nicht. Der Nutzen einer KI-Session entsteht aus dem Zusammenspiel von Aufgabe, Kontext, Person und Zufall. Drei dieser vier Faktoren sind bei jedem Teammitglied andere.\n\n## Was Organisationen daraus regelmäßig falsch schließen\n\nAuf Organisationsebene können diese Unterschiede zu falschen Produktivitätsannahmen führen. Denn aus der Einzelbeobachtung „bei Mike funktioniert das“ liegen drei Fehlschlüsse nahe.\n\nDer erste Fehlschluss: Lizenz gekauft heißt Team produktiv. Ein Konzern beschafft für alle Entwickler Zugänge, verbucht das als „KI-Adoption“ und erwartet den Produktivitätssprung, den die Power-User im Pilotprojekt gezeigt haben. Aber die Power-User waren ja gerade deshalb schnell, weil sie über Monate implizites Wissen aufgebaut haben. Die Lizenz kauft das Werkzeug, nicht die zweite Ebene. Der [DORA-Report 2025](https://cloud.google.com/blog/products/ai-machine-learning/announcing-the-2025-dora-report) bringt es auf eine Formel, die ich seither ständig zitiere: KI verstärkt, was da ist. Teams mit guten Feedback-Schleifen und sauberer Architektur werden besser, Teams mit schwachen Prozessen bekommen ihre Schwächen verstärkt zurück.\n\nDer zweite Fehlschluss: Prompt-Bibliothek teilen ist ein Rollout. Das war mein eigener Irrtum mit dem Kollegen, nur in klein. Prompts und Instruktionsdateien sind Sediment – nützlich, aber tot ohne die Praxis, aus der sie entstanden sind. Eine geteilte Prompt-Sammlung ohne geteilte Urteilskraft produziert Leute, die überzeugende Erklärungen lesen statt Diffs zu prüfen. Die [Stack-Overflow-Umfrage 2025](https://survey.stackoverflow.co/2025/ai/) zeigt die Folgen im Großen: KI-Werkzeuge werden breit genutzt, aber das Vertrauen bleibt begrenzt, und eine häufig genannte Frustration ist Code, der „fast richtig“ ist. Fast richtig zu erkennen ist exakt die Fähigkeit, die sich nicht allein per Datei verteilen lässt.\n\nDer dritte Fehlschluss: Was beim Piloten klappt, skaliert linear. Tut es nicht, wegen aller drei Varianz-Ebenen gleichzeitig. Ein Pilot kann motivierte Power-User enthalten (Personen-Varianz), ausgewählte, gut schneidbare Aufgaben (Aufgaben-Varianz) und selektiv berichtete Erfolgsgeschichten – missratene Sessions können in der Management-Präsentation fehlen (Session-Varianz). Wer daraus eine Hochrechnung für 200 Entwickler baut, kann mit einem nicht repräsentativen Durchschnitt rechnen.\n\nIch formuliere es zugespitzt, weil ich den Satz in [Folge 4](https://www.mikebild.dev/de/blog/was-ich-kritisch-sehe/) schon einmal gebraucht habe und er hier die organisatorische Konsequenz beschreibt: Die Erfolgsgeschichten der Einzelnen sind echt, und sie sind trotzdem kein Bauplan. Werkzeuge, Tests, Konventionen und Abnahmekriterien lassen sich kopieren. Mein konkretes Ergebnis entsteht zusätzlich aus Aufgabe, Kontext, Prüfblick und Sessionverlauf. Diesen Teil überträgt keine Setup-Datei.\n\n## Die Varianz ist auch eine Lernquelle\n\nDas klingt jetzt düsterer, als ich es meine. Denn dieselbe Varianz, die das Kopieren verhindert, ist eine brauchbare Diagnosequelle für ein Team.\n\nAls mein Kollege und ich seine zwei Wochen seziert haben, ist etwas Bemerkenswertes passiert: Ich musste zum ersten Mal aussprechen, *warum* ich Aufgaben so schneide, wie ich sie schneide. Warum ich Erklärungen ignoriere und Diffs lese. Warum ich nach einem gescheiterten Nachbesserungsversuch abbreche. Das war implizites Wissen, das ich selbst nicht als Wissen wahrgenommen hatte – für mich war es einfach „wie man das halt macht“. Erst der Unterschied zwischen seiner Arbeitsweise und meiner hat es sichtbar gemacht. Genau dort, wo zwei Leute dasselbe verschieden machen, liegt das implizite Wissen offen zutage. Man muss nur hinschauen, statt die Differenz als Versagen des Langsameren abzutun.\n\nMeine Schlussfolgerung nach diesem Februar: Wenn Einzelerfahrungen im Team zusammenfließen sollen, sind nicht die Prompts das Wertvolle. Es sind die Unterschiede. Zwei Entwickler, dieselbe Aufgabenklasse, verschiedene Ergebnisse – das ist kein Ärgernis, das ist ein Befund. Jede dieser Differenzen zeigt auf eine unausgesprochene Regel, eine Heuristik, ein Stück Urteilskraft, das bislang nur in einem Kopf existierte.\n\nMein Kollege arbeitet übrigens weiter mit dem Setup – inzwischen mit eigenen Instruktionsdateien, die sich von meinen zunehmend unterscheiden, und mit deutlich besseren Ergebnissen. Nicht weil er mich jetzt besser kopiert, sondern weil er aufgehört hat, es zu versuchen. Was er von mir übernommen hat, sind nicht die Dateien. Es sind die Fragen, die er sich vor jeder Delegation stellt.\n\nUnd damit stehe ich vor der Konsequenz, die sich jedes Team an dieser Stelle vornimmt, meines eingeschlossen: Wenn das entscheidende Wissen implizit ist, dann machen wir es eben explizit. Wir setzen uns hin, sprechen die Regeln aus, die bisher nur in Köpfen existierten, und schreiben sie auf – in genau die Dateien, die die KI bei jeder Session liest. Aufgabenzuschnitt, Prüfregeln, Abbruchkriterien, alles rein. Das muss doch reichen. Warum es das nicht tut – und warum die KI sich an unsere sorgfältig aufgeschriebenen Regeln erstaunlich selektiv hält – davon handelt die nächste Folge.\n\n## Weiterführende Quellen\n\n- [METR: Measuring the Impact of Early-2025 AI on Experienced Open-Source Developer Productivity](https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/)\n- [Google Cloud: Announcing the 2025 DORA Report](https://cloud.google.com/blog/products/ai-machine-learning/announcing-the-2025-dora-report)\n- [Stack Overflow Developer Survey 2025: AI](https://survey.stackoverflow.co/2025/ai/)"},{"slug":"was-ich-kritisch-sehe","title":"Was ich kritisch sehe","summary":"Vier Beobachtungen und eine offene Hypothese nach intensiven Monaten mit KI – vom möglichen Kompetenzverlust bis zur Sorge vor gleichförmigeren Lösungen und meinen praktischen Gegenmaßnahmen.","tags":["Software","KI","Methodik"],"published_at":"2026-02-10T11:00:00+00:00","updated_at":"2026-08-17T16:28:14.564928+00:00","url":"https://www.mikebild.dev/de/blog/was-ich-kritisch-sehe/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/22c58d3306f742f4179d5bbf17356541eca1e4fa9f11ba3cb8e3f805b80d5626.mp3","audio_duration_secs":623,"text":"An einem Donnerstagabend im Januar habe ich einen Pull Request beinahe durchgewunken, den ich selbst in Auftrag gegeben hatte. Claude Code hatte eine Datenbank-Migration geschrieben, sauber formatiert, mit Kommentaren, mit Rollback-Pfad. Ich habe sie gelesen, genickt, den Mauszeiger schon über dem Merge-Button gehabt – und dann, eher aus Gewohnheit als aus Misstrauen, doch noch die betroffene Tabelle im Staging-System angeschaut. Die Migration hätte funktioniert. Sie hätte nur nebenbei eine Spalte umbenannt, auf die ein Report zugreift, von dem die Session nichts wissen konnte. Kein Drama, schnell gefunden. Aber der Moment davor beschäftigt mich bis heute: Ich hatte nicht geprüft. Ich hatte gelesen, wie man einen gut geschriebenen Text liest – wohlwollend, mit dem Strom.\n\n*Teil 4 der Serie „Von der Session zum System“ – hier geht es [zum Auftakt](https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/).*\n\nIn der [letzten Folge](https://www.mikebild.dev/de/blog/harness-macht-den-unterschied/) habe ich beschrieben, warum das Delegieren an KI für mich erst mit einem guten Harness funktioniert – mit Tools, Rückkopplung und Leitplanken um das Modell herum. Am Ende stand ein Unbehagen, das mit jeder delegierten Aufgabe lauter geworden ist. Dieses Unbehagen will ich heute ernst nehmen. Nicht als Alarmruf – ich arbeite weiter jeden Tag mit diesen Werkzeugen, gern und produktiv. Sondern als das, was Kritik eigentlich sein sollte: genau hinschauen, bevor man weitermacht. Ich trenne dabei vier Beobachtungen von einer offenen Hypothese.\n\n## Wer nur noch prüft, verlernt das Bauen\n\nWenn ich Code nur selten selbst schreibe, muss ich inzwischen genauer unterscheiden: Fehlt mir nur eine API-Signatur, oder fehlt mir Übung in der Routine? Wer eine selten genutzte Bibliotheksfunktion braucht, schlägt ihre Signatur nach – das war immer so und ist kein Problem. Das Problem wäre die Richtung der Bewegung: Wenn nicht mehr nur die Ausnahme nachgeschlagen wird, sondern die Routine ganz entfällt, fehlt die Wiederholung, aus der Geläufigkeit entsteht. Ob und wie schnell das bei mir passiert, kann ich nicht messen. Deshalb erledige ich bestimmte Routineaufgaben bewusst ohne KI und achte darauf, wo mir Übung fehlt.\n\nDie CHI-2025-Studie von Microsoft Research und Carnegie Mellon befragte dazu 319 Wissensarbeiter. Höheres Vertrauen in generative KI hing in den Selbstauskünften mit weniger eingesetztem kritischem Denken zusammen. Das ist ein Zusammenhang in einer Befragung, kein Nachweis dafür, dass Fähigkeiten bereits abgebaut wurden. Meine Sorge geht einen Schritt weiter als das Ergebnis der Studie: Wer Routinearbeit abgibt und nur noch die Ausnahmen behandelt, könnte genau die Übung verlieren, die er für die Ausnahmen braucht.\n\nFür mich ist das ein schleichendes Risiko, das ich aktiv managen kann. Mehr treibt mich um, wie die Rechnung für Berufsanfänger aussieht. Ich habe mein Handwerk gelernt, indem ich oft genug auf die Nase gefallen bin: Speicherlecks selbst gejagt, Race Conditions selbst gebaut und selbst verstanden. Wer in der Lernphase überwiegend generierten Code prüft, bekommt möglicherweise weniger Gelegenheit, diese Substanz aufzubauen – das ist eine Vermutung, keine Messung, und die zitierte Befragung sagt dazu nichts. Konkret würde dann die Erfahrung fehlen, die gutes Prüfen erst möglich macht: einschätzen zu können, ob ein Timeout, eine Sperre oder eine Error Handling zum eigenen System passt, weil man solche Entscheidungen selbst getroffen und ihre Folgen getragen hat.\n\n## Plausibel ist nicht richtig\n\nDie Szene vom Anfang hat einen Namen: Automation Bias. Eine systematische Übersicht von Goddard, Roudsari und Wyatt beschreibt ihn als Neigung, automatisierten Empfehlungen zu folgen und widersprechende Hinweise zu übersehen – die Forschung dazu ist älter als die aktuellen Sprachmodelle. Bei meiner Migration verstärkten die saubere Formatierung, die Kommentare und der mitgelieferte Rollback-Pfad genau diesen Vertrauenseindruck: Ich hätte beinahe freigegeben, was ich nicht geprüft hatte.\n\nOb Review und Vier-Augen-Prinzip gegen solche plausiblen Ausgaben systematisch schlechter greifen als gegen erkennbar schlampigen Code, kann ich nicht belegen.\n\nPassend dazu haben Perry und Kollegen in einer kontrollierten Studie gezeigt, dass Teilnehmende mit KI-Assistent häufiger unsicheren Code schrieben und zugleich überzeugter waren, sicheren geschrieben zu haben. Die Studie untersucht keine Code-Reviews. Für meine Review-Praxis ist die Kombination deshalb ein Warnsignal, kein Wirksamkeitsbeleg.\n\n## „Das hat die KI so gemacht“ ist keine Antwort\n\nIm Review reicht mir „Das hat mir die KI so vorgeschlagen“ nicht als Begründung. Der Satz erklärt die Herkunft, aber nicht die Entscheidung. Warum ist der Timeout hier auf dreißig Sekunden gesetzt? Warum ist das für unser System richtig? Wenn darauf keine Antwort folgt, ist die Entscheidung im Review nicht begründet – denn die Session, die den Wert gewählt hat, ist längst beendet und hat ihre Gründe, falls es welche gab, mitgenommen.\n\nIn einem Review gibt es keine dritte Partei. Es gibt den Menschen, der den Code einbringt, und den Menschen, der ihn freigibt – beide mit Namen, beide ansprechbar, beide morgen noch da. Was das Werkzeug vorgeschlagen hat, ist so relevant wie die Frage, in welchem Editor der Code getippt wurde. Wer einen Pull Request stellt, sagt damit: Ich stehe dafür ein. Wenn diese Zusage sich auflöst, weil „die KI“ als diffuser Mitautor im Raum steht, verliert das Review seinen Kern. Dann prüfen zwei Menschen etwas, für das sich keiner zuständig fühlt.\n\n## Schulden, die keiner aufgenommen hat\n\nTechnische Schulden können aus bewussten Abkürzungen entstehen: Wir bauen jetzt die schnelle Lösung und räumen später auf. Man kannte die bewusst eingegangenen Abkürzungen und ihre Folgen. Daneben sehe ich eine mögliche zweite Variante: Code, der funktioniert, alle Tests besteht – und den niemand im Team wirklich versteht. Nicht, weil er unverständlich wäre, sondern weil ihn nie jemand verstehen musste. Er wurde generiert, er lief, er wurde gemerged.\n\nWenn ein Team einen generierten Mechanismus beim Einbau nur im Groben nachvollzieht, kann es bei der nächsten Änderung nötig sein, Annahmen und Entscheidungsgründe zu rekonstruieren, bevor der Mechanismus sicher geändert werden kann. Dann kann technische Schuld entstanden sein, ohne dass sie jemand bewusst aufgenommen hätte – kein Vermerk, keine Entscheidung, kein „später räumen wir auf“. Solche Schulden können unbemerkt entstehen, weil keine bewusste Abkürzung dokumentiert wurde. Ein System, das zu größeren Teilen aus solchem Code besteht, ist nicht kaputt. Es ist etwas Schlimmeres: unverstanden bei voller Funktion.\n\n## Wenn alle dasselbe Modell fragen, können Entwürfe ähnlicher werden\n\nDie letzte Sorge ist eine offene Hypothese. Ein Gedankenexperiment macht sie greifbar: Vier Gruppen bekommen dieselbe offen gehaltene Entwurfsaufgabe, und alle vier stellen ihre erste Frage demselben Modell. Wie unterschiedlich fallen die Ergebnisse aus? Wenn alle mit einer sehr ähnlichen Erstantwort starten, kann diese Antwort den weiteren Entwurf prägen. Der Effekt ist nicht gemessen und hängt unter anderem von Prompt, Kontext, Sampling und Modell ab. Die erwähnte Microsoft-Studie untersucht ihn nicht.\n\nMan kann mit den Achseln zucken und sagen: Konvergenz auf bewährte Muster ist doch gut. Teilweise stimmt das – ich bin der Letzte, der eine ausgefallene Lösung schon deshalb gut findet, weil sie ausgefallen ist. Aber Architektur-Vielfalt ist kein Selbstzweck, sondern ein Suchverfahren. Unterschiedliche Entwürfe machen alternative Annahmen und Zielkonflikte sichtbar. Meine Sorge lautet deshalb: Wenn viele Entwurfsentscheidungen durch denselben Filter laufen, verengt sich der Raum der ernsthaft geprüften Alternativen. Messen kann ich das nicht.\n\n## Kritik heißt nicht Ablehnung\n\nAn dieser Stelle könnte der Text kippen – in Kulturpessimismus, in „früher haben wir noch richtig programmiert“. Das wäre bequem und falsch. Keiner dieser Punkte ist ein Argument gegen die Werkzeuge. Jeder einzelne ist ein Argument gegen einen bestimmten Umgang mit ihnen: den passiven. Kompetenz verfällt nicht, weil KI existiert, sondern weil ich aufhöre zu üben. Automation Bias schlägt zu, weil ich Plausibilität mit Korrektheit verwechsle. Verantwortung diffundiert, weil ich sie diffundieren lasse. Das ist der Haken an der Sache: Die Antwort liegt nicht im nächsten Modell-Release. Sie liegt bei mir.\n\nIch habe für mich ein paar Regeln aufgestellt. Keine Weisheiten, eher Hausregeln:\n\n- Regelmäßig bewusst ohne KI arbeiten. Kein Verzichtsritual, eher ein Lauf ohne Uhr: bestimmte Aufgaben – ein Algorithmus, ein Datenmodell, ein kniffliger Bugfix – von Hand, um zu spüren, wo es hakt. Wo es hakt, prüfe ich, ob mir Übung fehlt oder nur eine selten genutzte API nicht präsent ist.\n- Verstehens-Pflicht vor dem Merge. Nichts geht in den Hauptzweig, was ich nicht einem Kollegen an der Tafel erklären könnte – Zeile für Zeile bei kritischen Stellen. Kritische Stellen sind zum Beispiel möglicher Datenverlust, Rechteprüfungen, Concurrency und öffentliche APIs. „Im Groben nachvollzogen“ zählt nicht.\n- Erst das eigene Urteil, dann das Modell. Bei Entwurfsfragen schreibe ich meine Position in drei Sätzen auf, bevor ich frage. Sie muss nicht die bessere sein; sie ist ein Anker gegen die plausible Erstantwort – Abweichungen werden dann zu echter Information.\n- Herkunft benennen, Verantwortung behalten. Wenn Code generiert wurde, sage ich das im Review offen – und übernehme ihn trotzdem so, als hätte ich ihn getippt.\n\nNichts davon ist originell. Die zusätzliche Prüfung kostet mich Zeit, macht die Freigabe aber nachvollziehbarer. Und ich merke: Sie kostet Kraft. Im Alltag ist es einfacher, sie auszulassen.\n\nGenau da endet allerdings die Reichweite dieses Textes. Alles, was ich hier beschrieben habe, sind Maßnahmen eines Einzelnen – meine Regeln, meine Disziplin, meine Hausregeln. Das trägt genau so weit, wie meine Selbstkontrolle reicht, und an schlechten Tagen reicht sie erstaunlich wenig weit. Ein Team kann sich nicht auf die private Disziplin seiner Mitglieder verlassen; zehn Leute mit zehn verschiedenen Hausregeln ergeben noch keine gemeinsame Arbeitsweise. Was mit diesen Beobachtungen passiert, wenn nicht einer so arbeitet, sondern ein ganzes Team – mit unterschiedlicher Erfahrung, unterschiedlicher Sorgfalt und Termindruck obendrauf – das ist die Frage, die ich mir für die nächste Folge vorgenommen habe.\n\n## Weiterführende Quellen\n\n- [The Impact of Generative AI on Critical Thinking (Microsoft Research / Carnegie Mellon, CHI 2025)](https://www.microsoft.com/en-us/research/publication/the-impact-of-generative-ai-on-critical-thinking-self-reported-reductions-in-cognitive-effort-and-confidence-effects-from-a-survey-of-knowledge-workers/)\n- [Kate Goddard, Abdul Roudsari, Jeremy C. Wyatt: Automation bias – a systematic review of frequency, effect mediators, and mitigators (JAMIA, 2012)](https://academic.oup.com/jamia/article/19/1/121/732254)\n- [Neil Perry, Megha Srivastava, Deepak Kumar, Dan Boneh: Do Users Write More Insecure Code with AI Assistants? (arXiv, 2022)](https://arxiv.org/abs/2211.03622)"},{"slug":"harness-macht-den-unterschied","title":"Delegieren statt Diktieren: der Harness macht den Unterschied","summary":"Wie Tools, Kontextverwaltung und ausführbare Prüfungen aus einer Aufgabe einen reviewbaren Branch machen können – und warum die Abnahme trotzdem beim Menschen bleibt.","tags":["Software","KI","Methodik"],"published_at":"2026-01-21T11:00:00+00:00","updated_at":"2026-08-17T16:28:14.564928+00:00","url":"https://www.mikebild.dev/de/blog/harness-macht-den-unterschied/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/bd5e52656e4e61ff8b27a781345c969aefb695920f9185303e04b2e23eaf1076.mp3","audio_duration_secs":868,"text":"Im Januar lag in einem Projekt ein Refactoring an, das ich seit Wochen vor mir herschob: ein Node-Service, in dem sich über die Jahre drei Generationen von Error Handling angesammelt hatten – error-first-Callbacks, nackte Promise-Ketten, dazwischen frisches async/await. Nichts davon war schwer. Es war nur viel: rund sechzig Dateien, jede einzeln anzufassen, jede mit der Chance, beim Umbau einen Randfall zu zerlegen. Klassische Arbeit für einen verregneten Nachmittag und zwei Kannen Tee.\n\n*Teil 3 der Serie „Von der Session zum System“ – hier geht es [zum Auftakt](https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/).*\n\nAn diesem Nachmittag habe ich etwas anderes versucht. Statt die Dateien nacheinander in ein Chat-Fenster zu kopieren, Vorschläge zurückzukopieren und dabei langsam den Verstand zu verlieren, habe ich Claude Code im Terminal geöffnet und die Aufgabe in sechs Sätzen beschrieben: was umgebaut werden soll, welche Konventionen im Repository gelten, dass die bestehende Testsuite nach jedem Teilschritt grün bleiben muss, und dass ich bei Unklarheiten lieber eine Rückfrage will als eine mutige Annahme. Dann bin ich in ein Meeting gegangen.\n\nAls ich zurückkam, lag da ein Branch. Ein sauberer Diff über die betroffenen Dateien, die Testsuite grün, dazu eine kurze Zusammenfassung: was geändert wurde, in welcher Reihenfolge, und zwei Stellen, die das Werkzeug als unsicher markiert hatte – mit konkreten Fragen an mich. Eine der Fragen war berechtigt und hätte mich beim Selbermachen vermutlich auch aufgehalten. An einer dritten Stelle hatte es eine Eigenheit unseres Codes übersehen, die kein Test abdeckte; die habe ich im Review gefunden und nachgezogen. Unterm Strich: Arbeit, für die ich einen halben Tag veranschlagt hatte, in gut vierzig Minuten Maschinenzeit plus einer halben Stunde Review. In einer Qualität, die ich von einem soliden Pull Request eines neuen Kollegen erwartet hätte.\n\nDer Moment, der hängen blieb, war aber nicht die Zeitersparnis. Die beiden Erfahrungen – die mittelmäßigen Fragmente im Chat-Fenster und der reviewfähige Branch im Terminal – fanden in unterschiedlichen Arbeitsumgebungen statt. Ob dabei dieselbe Modellversion oder dieselbe Wissensbasis zum Einsatz kam, kann ich nicht belegen; ich habe weder Modellkennung noch Konfiguration protokolliert. Der Vergleich isoliert deshalb keine einzelne Ursache. Repository-Zugriff, Werkzeugaufrufe und ausführbare Prüfungen kommen als Erklärung für die Differenz infrage.\n\n## Die praktische Folgerung aus der These vom Dezember\n\nIm Dezember habe ich [argumentiert](https://www.mikebild.dev/de/blog/gut-genug-modelle/), dass die Modelle für die meisten Aufgaben in meinem Arbeitsalltag längst gut genug sind – und dass der Engpass woanders liegen muss, wenn die Ergebnisse trotzdem enttäuschen. Das war im Dezember eine These, abgeleitet aus Beobachtungen. Der Januar-Nachmittag zeigte im Projekt, dass die Terminal-Umgebung einen reviewfähigen Branch lieferte, während ich aus früheren Chat-Sitzungen oft nur Fragmente erhielt. Für meine Arbeitsweise folgt daraus: mehr Aufwand für Repository-Kontext, Werkzeugzugriffe und Prüfungen statt für die Suche nach dem nächsten Modell.\n\nFür dieses Drumherum hat sich im Englischen ein Wort etabliert, das ich seither ständig benutze: **Harness**.\n\n## Was ein Harness ist\n\nDas Wort kommt vom Zuggeschirr – dem Lederzeug, das ein Pferd mit dem Wagen verbindet und seine Kraft in eine Richtung lenkt. Softwareentwickler kennen den Begriff vom Test-Harness: der Apparat, der Testfälle einspannt, ausführt und Ergebnisse einsammelt. Bei Sprachmodellen meint Harness genau das: alles, was das rohe Modell einspannt und seine Fähigkeit in nutzbare Arbeit übersetzt. In diesem Artikel bezeichnet Coding-Harness die Verbindung eines Modells mit Kontext, Werkzeugen und Prüfungen. Anthropic unterscheidet in „Building Effective Agents“ feste Workflows von Agenten – Systemen, die Werkzeuge in einer Rückkopplungsschleife selbstständig auswählen und verwenden.\n\nTechnisch trenne ich hier Modell und ausführende Umgebung: Das Modell erzeugt Text oder schlägt, abhängig von seiner Schnittstelle, Werkzeugaufrufe vor. Die umgebende Software setzt diese Aufrufe mit den ihr erteilten Rechten um. Im Chat habe ich diese Verbindung von Hand hergestellt: Code hineinkopiert, Vorschläge ausgeführt und Fehlermeldungen zurückgegeben. Ein Harness übernimmt diesen Ablauf als programmatische Schleife. Vier Dinge machen sie aus.\n\nErstens **Tools**: Das Modell bekommt die Fähigkeit, Aktionen anzufordern – eine Datei lesen, eine Datei ändern, im Repository nach einem Bezeichner suchen, ein Kommando ausführen, die Testsuite starten. Der Harness führt die Aktion aus und gibt das Ergebnis als Text zurück. Damit kann das Modell Datei- und Kommandozugriffe anfordern und deren Ergebnisse im nächsten Schritt verarbeiten.\n\nZweitens **Kontextverwaltung**: Das Kontextfenster eines Modells ist endlich, ein reales Repository passt nicht hinein. Ein guter Harness entscheidet deshalb laufend, was das Modell zu sehen bekommt – er lässt es gezielt suchen statt alles einzulesen, hält Zwischenergebnisse fest und fasst lange Verläufe zusammen, wenn der Platz knapp wird. Fehlt der relevante Ausschnitt, beruhen Änderungen auf Annahmen. Mit den passenden Dateien, Fehlermeldungen und Konventionen kann das Modell seine Vorschläge am tatsächlichen Projektzustand ausrichten.\n\nDrittens **Planen und Nachfassen**: Für längere Aufgaben sollte ein Harness die vorgesehenen Schritte festhalten und nach jedem Werkzeugergebnis prüfen, ob der Plan noch trägt. So bleibt das Ziel über mehrere Änderungen und Prüfungen hinweg sichtbar, statt nur auf die jeweils letzte Rückmeldung zu reagieren.\n\nViertens – und für mich der eigentliche Hebel – **ausführbare Verifikation**: Der Harness startet externe Prüfungen und gibt deren Ergebnisse an das Modell zurück. Tests ausführen, den Compiler befragen, die Fehlermeldung lesen, nachbessern, erneut prüfen. Genau die Schleife, die ich im Chat von Hand gedreht habe, kann der Harness für Prüfungen und Nachbesserungen selbstständig wiederholen. Anthropics eigener Praxisleitfaden zu Claude Code empfiehlt dieses Prinzip: dem Agenten Möglichkeiten geben, seine Arbeit eigenständig zu prüfen, und Aufgaben so schneiden, dass Ergebnisse überprüfbar bleiben.\n\nAls Bild sieht die Schleife so aus:\n\n```mermaid\nflowchart TD\n    A[Aufgabe<br/>Ziel, Kontext, Abnahmekriterien] --> B[Plan<br/>Aufgabe in Schritte zerlegen]\n    B --> C[Tool ausführen<br/>lesen, ändern, suchen, testen]\n    C --> D{Ergebnis prüfen<br/>Tests grün? Fehler? Plan noch gültig?}\n    D -->|Fehler oder Abweichung| E[Nachbessern<br/>Fehlermeldung lesen, Schritt anpassen]\n    E --> B\n    D -->|Kriterien erfüllt| F[Fertig<br/>Diff und Zusammenfassung an den Menschen]\n```\n\nNeu ist an diesem Diagramm nichts. Es ist dieselbe Schleife, mit der jeder von uns arbeitet: verstehen, planen, ändern, prüfen, nachbessern. Das Bemerkenswerte ist, *wo* sie läuft. Im Chat verläuft jede Runde dieser Schleife durch mich – ich bin ihre Latenz, ihre Fehlerquelle und ihr Flaschenhals. Ich kopiere unvollständigen Kontext hinein, ich übersehe beim Zurückkopieren eine Zeile, ich habe nach der fünften Runde keine Lust mehr. Der Harness nimmt mich aus der Schleife heraus und setzt mich an ihre Enden: Auftrag vorn, Abnahme hinten.\n\n## Diktieren und Delegieren sind zwei verschiedene Tätigkeiten\n\nIm Januar 2026 hatte ich Claude Code und OpenAIs quelloffene Codex CLI seit Monaten installiert. Benutzt habe ich sie trotzdem lange wie einen besseren Chat: jeden Schritt beobachtet, jede Dateiänderung einzeln abgenickt, zwischendurch hineinregiert. Ich saß daneben wie ein Fahrlehrer mit dem Fuß über dem Zweitpedal.\n\nDie eigentliche Umstellung war deshalb keine Werkzeug-, sondern eine Haltungsfrage – und sie hat länger gedauert als jede Installation. Diktieren heißt: „Ändere in dieser Datei die Funktion so.“ Ich behalte die Lösung, das Modell übernimmt das Tippen. Delegieren heißt: „Hier ist das Ziel, hier sind die Rahmenbedingungen, so erkennst du, dass du fertig bist.“ Ich behalte das Problem, das Werkzeug übernimmt die Lösung – und ich prüfe das Ergebnis.\n\nDas ist exakt die Unterscheidung, die man aus der Zusammenarbeit mit Menschen kennt, und sie stellt dieselben Anforderungen an den Auftraggeber. Ein schlecht formulierter Auftrag liefert schlechte Ergebnisse, bei Menschen wie bei Maschinen – nur beschwert sich die Maschine nicht, sie liefert einfach selbstbewusst das Falsche. Ich habe für mich ein Bild gefunden, das meine Erwartung zurechtrückt: Ich delegiere an einen sehr schnellen, sehr belesenen Mitarbeiter, der jeden Morgen zum ersten Mal im Projekt ist. Repository-Inhalte kann das Werkzeug nachschlagen. Für den jeweiligen Modellschritt ist aber nur verfügbar, was der Harness in den aktiven Kontext aufnimmt; Dateien und Werkzeugzustand bleiben externe Quellen. Was das Modell über unsere Konventionen, Altlasten und Abnahmekriterien berücksichtigen soll, muss deshalb im Auftrag stehen oder im Repository auffindbar sein.\n\nMit dieser Haltung ändert sich, was ich überhaupt aufschreibe. Meine Aufträge an Claude Code sehen inzwischen aus wie gute Tickets: ein Ziel, der relevante Kontext, explizite Rahmenbedingungen, ein prüfbares Fertig-Kriterium. Der Aufwand, den ich früher ins Zerlegen und Diktieren gesteckt habe, steckt jetzt in der Auftragsklärung. Das fühlt sich zunächst wie Mehrarbeit an. Es ist dieselbe Sorgfalt, die gutes Delegieren an Menschen verlangt. Klare Kriterien machen Abweichungen im Diff oder in ausführbaren Prüfungen eher sichtbar.\n\n## Wo Delegation trägt\n\nNach einigen Wochen mit dieser Arbeitsweise zeichnet sich für mich klar ab, welche Aufgaben sich gut delegieren lassen:\n\n- Mechanische Refactorings mit Sicherheitsnetz: eine API-Migration über viele Dateien, eine konsequente Umbenennung, das Vereinheitlichen von Mustern – überall dort, wo eine Testsuite oder der Compiler relevante Abweichungen innerhalb ihrer Abdeckung meldet.\n- Recherche im Repository: Wo wird dieser Konfigurationswert gesetzt, wer ruft diese Funktion, welche Stellen behandeln diesen Fehlerfall? Der Harness durchsucht das Repository und liefert Fundstellen mit Begründung; ich muss die relevanten Verzeichnisse nicht einzeln abarbeiten.\n- Testabdeckung erweitern: Für bestehendes Verhalten Tests nachziehen ist Fleißarbeit mit eingebauter Verifikation – der neue Test muss laufen und bei mutwillig kaputtem Code fehlschlagen.\n- Routinearbeit mit klarer Vorlage: Boilerplate, Migrationsskripte, das Nachziehen eines Musters, das im Repository schon dreimal existiert, kleine Bugfixes mit reproduzierbarem Fehlerbild.\n\nDiese Beispiele haben einen gemeinsamen Nenner: Delegation trägt dort, wo sich das Ergebnis mit vertretbarem Aufwand prüfen lässt – am besten maschinell, notfalls durch einen gezielten Review. Die Schleife im Diagramm oben hat einen Prüfschritt, und die ganze Konstruktion ist genau so gut wie dieser Schritt. Ein mechanischer Refactor unter einer dichten Testsuite spielt alle Stärken des Harness aus. Ohne passende Tests können Verhaltensänderungen bis zum Review oder Betrieb unentdeckt bleiben.\n\n## Wo sie bricht\n\nIn denselben Wochen sind mehrere delegierte Aufgaben gescheitert. Die Ursachen ähnelten sich.\n\nLange Aufgaben kippen. Das Kontextfenster ist endlich, und irgendwann beginnt der Harness, den bisherigen Verlauf zusammenzufassen, um Platz zu schaffen. Mit jeder Komprimierung können Details verloren gehen – ausgerechnet die Randbedingungen aus dem ursprünglichen Auftrag sind dafür ein naheliegendes Opfer. Ich habe erlebt, wie eine Session nach zwei Stunden eine Konvention verletzte, die sie in der ersten halben Stunde noch sauber eingehalten hatte. Meine Vermutung: Die Regel war bei einer dieser Komprimierungen aus dem Kontext gefallen und damit aus dem Verhalten. Nachweisen kann ich das nicht – ich sehe nur das Ergebnis, nicht den inneren Zustand der Session. Die Konsequenz bleibt dieselbe: Große Vorhaben muss ich selbst in Etappen schneiden, die einzeln in eine Session passen. Diese Zerlegung kann ich bisher nicht delegieren.\n\nGravierender ist die Verifikationslücke. Die Selbstprüfung des Harness reicht exakt so weit wie das, was sich ausführen und messen lässt. Ob die Tests grün sind, kann er prüfen. Ob der Schnitt eines Moduls klug ist, ob eine öffentliche API in zwei Jahren noch tragfähig ist, ob eine Namensgebung das Fachliche trifft, ob der Code unter Last hält, ob eine Änderung ein Sicherheitsproblem aufreißt, das kein Test kennt – all das prüft niemand, wenn ich es nicht tue. Grüne Tests heißen nur: Die ausgeführten Assertions bestehen unter den geprüften Bedingungen. Alles jenseits davon fällt zurück an mich: Delegation verschiebt meine Arbeit von der Produktion in die Abnahme. Ich schreibe weniger Code und lese mehr Code, den ich nicht geschrieben habe. Die fachliche Abnahme und die Verantwortung für den Merge bleiben bei mir – kein Prüfschritt in der Schleife nimmt sie mir ab. Wer schon einmal große Pull Requests reviewt hat, weiß, dass das die anstrengendere Tätigkeit ist – und die, bei der Nachlässigkeit am teuersten wird.\n\nUnd schließlich das Übervertrauen, auf beiden Seiten. Der Harness fragt seltener nach, als er sollte; im Zweifel trifft er eine plausible Annahme und arbeitet souverän weiter. Die Zusammenfassung am Ende klingt bei einem tadellosen Ergebnis genauso überzeugt wie bei einem, das an einer übersehenen Eigenheit vorbeigebaut ist – meine dritte Stelle aus der Eingangsszene stand in keiner Selbstauskunft. Auf meiner Seite wächst parallel eine bequeme Nachgiebigkeit: Je öfter die Ergebnisse gut sind, desto flüchtiger wird mein Review. Das eigentliche Risiko entsteht, wenn eine selbstsicher formulierte Zusammenfassung die tatsächliche Unsicherheit nicht erkennen lässt und der Mensch zugleich immer seltener genau hinsieht. Der einzelne Fehler ist dabei das kleinere Übel. Teuer wird die Gewöhnung.\n\n## Das Gehäuse verändert die Arbeit – und stellt die nächste Frage\n\nDie nüchterne Bilanz des Januars: Ob sich die Modellseite unterschied, kann ich nicht sagen. Geändert hat sich meine Art, das Modell einzuspannen. In der Terminal-Umgebung mit Repository-Zugriff, Werkzeugaufrufen und ausführbaren Prüfungen entstand ein prüfbarer Branch – solange die Aufgabe in eine Session passt und ihr Ergebnis prüfbar bleibt. Delegieren statt Diktieren steht in keinem Funktionsmenü; es ist eine Fähigkeit des Auftraggebers und will genauso gelernt sein wie beim Delegieren an Menschen.\n\nDeshalb lautet meine Regel: nur Aufgaben mit klaren, mit vertretbarem Aufwand prüfbaren Abnahmekriterien delegieren und jeden Diff vor dem Merge prüfen.\n\nJe mehr ich delegiere, desto deutlicher wird zugleich ein Unbehagen. Es hat nichts mit der Qualität der Ergebnisse zu tun – die wird eher besser. Es hat damit zu tun, was auf meiner Seite passiert, während die Schleife läuft: was ich nicht mehr selbst durchdenke, was ich nur noch abnicke, wovon ich mich abhängig mache. Diesem Unbehagen will ich in der nächsten Folge in Ruhe nachgehen, statt es schönzureden.\n\n## Weiterführende Quellen\n\n- [Building Effective Agents](https://www.anthropic.com/research/building-effective-agents) – Anthropics Grundlagentext zur Unterscheidung fester Workflows von Agenten, die Werkzeuge in einer Rückkopplungsschleife selbstständig auswählen und verwenden\n- [Claude Code: Best Practices for Agentic Coding](https://www.anthropic.com/engineering/claude-code-best-practices) – Anthropics Praxisleitfaden: dem Agenten Selbstprüfung ermöglichen, Aufgaben überprüfbar schneiden\n- [OpenAI Codex CLI](https://github.com/openai/codex) – der quelloffene Terminal-Coding-Agent von OpenAI\n- [OpenAI debuts Codex CLI](https://techcrunch.com/2025/04/16/openai-debuts-codex-cli-an-open-source-coding-tool-for-terminals/) – TechCrunch zur Veröffentlichung im April 2025\n- [Simon Willison über Building Effective Agents](https://simonwillison.net/2024/Dec/20/building-effective-agents/) – lesenswerte Einordnung des Agenten-Begriffs"},{"slug":"gut-genug-modelle","title":"Gut genug: das Modell ist selten der Engpass","summary":"Bei den Alltagsaufgaben, die ich selbst mit KI bearbeite, ist die Modellwahl selten der erste Engpass. Aufgabenzuschnitt, Kontext und Prüfung wirken meist stärker.","tags":["Software","KI","Methodik"],"published_at":"2025-12-09T11:00:00+00:00","updated_at":"2026-07-14T20:56:33.58758+00:00","url":"https://www.mikebild.dev/de/blog/gut-genug-modelle/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/28d2f099078753ccde0ebc93fdf0571b346426172c2849fc5025b3f0ef956a30.mp3","audio_duration_secs":571,"text":"*Teil 2 der Serie „Von der Session zum System“ – hier geht es [zum Auftakt](https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/).*\n\nEnde November, ein Architektur-Review mit einem Team, das ich begleite, zweiter Tag, erste Kaffeepause. Ein Entwickler kommt mit der Tasse in der Hand zu mir und stellt die Frage, die seit zwei Jahren in jedem Teamgespräch und jedem Flurgespräch auf Konferenzen fällt: „Welches Modell nehmt ihr denn?“ Diesmal mit besonderem Nachdruck, denn der November 2025 war verrückt: GPT-5.1 am 12., Grok 4.1 am 17., Gemini 3 am 18., Claude Opus 4.5 am 24. Vier Flaggschiff-Ankündigungen in zwölf Tagen, dazu die üblichen Benchmark-Screenshots, die durch die Timelines wandern. Sein Team, erzählt er, diskutiert seit Wochen, ob man wechseln müsse.\n\nIch habe diese Frage früher ernsthaft beantwortet, mit Modellnamen und Begründung. Jede dieser Antworten war nach sechs Wochen veraltet. Inzwischen antworte ich anders: „Das ist die zweite Frage. Lass uns über die erste reden.“\n\n## Die Konvergenz, über die niemand gern spricht\n\nNüchtern und ohne Anbieter-Loyalität betrachtet: Bei dem großen Block an Alltagsaufgaben, für den ich KI einsetze, lagen die großen Modelle Ende 2025 in meinen eigenen Versuchen nah genug beieinander. Code lesen und erklären, ein Refactoring vorschlagen, Tests entwerfen, ein Konzeptpapier gegenlesen, eine API-Dokumentation zusammenfassen, einen Migrationspfad skizzieren – das bekam ich mit GPT-5, Claude Sonnet 4.5 und Gemini in brauchbarer Qualität hin. Nicht gleich gut in jedem einzelnen Lauf, aber gut genug, dass die Modellwahl selten meine erste Baustelle war.\n\nNatürlich sind sie nicht identisch. Jedes Modell hat einen eigenen Ton, eigene Vorlieben, eigene Schwächen, und bei einzelnen Aufgaben können die Unterschiede groß sein. Öffentliche Benchmarks helfen mir nur begrenzt, weil sie weder meinen Kontext noch meine Prüfkriterien abbilden und sich mit neuen Versionen schnell verschieben. Für eine Architekturentscheidung brauche ich deshalb Messungen an den eigenen Aufgaben statt eine allgemeine Rangliste.\n\nDas erzähle ich in solchen Runden ungern als Erster, weil es die Diskussion abkühlt, auf die sich alle gefreut haben. Der Modellvergleich ist unterhaltsam, er fühlt sich nach Ingenieursarbeit an, und er hat den Charme, dass man dabei nichts an der eigenen Arbeitsweise ändern muss. Aber er beantwortet die falsche Frage.\n\n## Das Rauschen im Alltag ist größer als der Unterschied zwischen Modellen\n\nWenn bei mir eine Session mit Claude oder ChatGPT schiefgeht, dann liegt das fast nie am Modell. Es liegt daran, dass ich die Aufgabe unklar gestellt habe. Dass das Modell den Kontext nicht kannte, der für die Lösung entscheidend war – die Architekturentscheidung von vor zwei Jahren, die ungeschriebene Konvention im Team, den Grund, warum dieser eine Service anders aufgebaut ist als alle anderen. Oder daran, dass ich das Ergebnis nicht geprüft habe und der Fehler erst im Review auffiel.\n\nEin Beispiel aus einem Teamgespräch in einem großen Konzern, anonymisiert wie immer: Zwei Kollegen, dasselbe Modell, dieselbe Aufgabe – ein Stück Legacy-Code verstehen und einen Umbau vorschlagen. Der eine bekam einen brauchbaren, konkreten Plan. Der andere bekam generischen Lehrbuch-Text. Der Unterschied war nicht das Modell, nicht einmal der Prompt im engeren Sinne. Der eine hatte dem Modell die relevanten Dateien, die Testfälle und die Randbedingungen mitgegeben. Der andere hatte eine Frage in ein leeres Fenster getippt.\n\nIn meinen eigenen Sessions schwankt das Ergebnis durch Aufgabenzuschnitt, Kontext und Prüfung oft stärker als durch den Wechsel zwischen den betrachteten Spitzenmodellen. Das ist keine allgemeine Messung des Marktes, sondern eine Beobachtung aus dieser Arbeit. Für mich folgt daraus: Bevor ich das Modell wechsle, prüfe ich zuerst, ob Aufgabe, Kontext und Abnahmekriterium sauber genug sind.\n\n## Wer auf das beste Modell wettet, wettet auf eine Commodity\n\nDamit zur These, die ich für die wichtigste dieses Beitrags halte: Wer seine Arbeitsweise oder gar sein Produkt auf „das beste Modell“ baut, wettet auf eine Commodity, die er nicht besitzt.\n\nDie Modelle gehören einer Handvoll Anbieter. Ich miete Zugriff über einen API-Schlüssel oder ein Abo, genau wie mein Wettbewerber, genau wie jedes andere Team. Ich kann nicht steuern, wann eine Version abgekündigt wird, wann sich das Verhalten nach einem Update leise verschiebt, wann der Preis fällt oder das nächste Modell die Rangliste umwirft. Der November hat das vorgeführt: Wer im Oktober seine Wahl auf einen Benchmark-Vorsprung gestützt hat, sah diesen Vorsprung innerhalb von zwei Wochen dreimal den Besitzer wechseln.\n\nWas ich dagegen besitzen kann, ist die Schicht um das Modell herum. Das ist keine Plattform und kein Produkt, sondern schlicht alles, was ich selbst in der Hand habe, bevor und nachdem das Modell rechnet:\n\n- der Zuschnitt der Aufgaben, also die Fähigkeit, Arbeit so zu zerlegen, dass ein Stück davon delegierbar wird\n- der Kontext, den eine Session am Anfang bekommt, statt dass das Modell meine Realität erraten muss\n- das Wissen des Teams in abrufbarer Form, von Konventionen bis zu den Gründen hinter alten Entscheidungen\n- der Prozess, der regelt, wann die KI arbeitet, wann ein Mensch prüft und was in den Main-Branch darf\n- die Prüfung selbst, also verlässliche Wege festzustellen, ob ein Ergebnis stimmt, bevor es etwas bewirkt\n\nDiese Schicht ist das Gegenteil einer Commodity. Sie ist spezifisch für mein Team, meine Codebasis, meine Domäne. Niemand kann sie mir durch ein Release wegnehmen, und der Wettbewerber kann sie nicht einfach mitmieten. Vor allem aber: Sie überlebt Modellwechsel. Eine Stunde, die ich in bessere Aufgabenbeschreibungen, in eine gepflegte CLAUDE.md oder AGENTS.md, in Review-Kriterien investiere, zahlt sich auch mit dem übernächsten Modell noch aus. Eine Stunde Benchmark-Studium ist beim nächsten Release entwertet.\n\nEs gibt einen angenehmen Side Effect, den ich aus der Architektur-Arbeit kenne: Wer die Schicht besitzt, für den sinken die Wechselkosten. Wenn Aufgabenzuschnitt, Kontext und Prüfung sauber stehen, ist das Modell dahinter austauschbar wie eine Abhängigkeit hinter einer klaren Schnittstelle. Dann kann ich sogar gelassen wechseln, wenn es einen echten Grund gibt – weil mein eigentliches Kapital nicht am Modellnamen hängt.\n\n## Wo die Modellwahl doch zählt\n\nEhrlichkeit gehört dazu, sonst wird aus der These ein Dogma. Es gibt Situationen, in denen die Modellwahl eine echte, messbare Rolle spielt. Drei begegnen mir regelmäßig.\n\nErstens die Spezialfälle. Wer mit exotischen Sprachen oder Nischen-Frameworks arbeitet, stark multimodale Aufgaben hat oder aus Datenschutzgründen on-premises bleiben muss, landet schnell bei kleineren offenen Modellen – und dort sind die Qualitätsunterschiede real und die Abwägungen andere. „Gut genug“ gilt für die großen Frontier-Modelle bei Alltagsaufgaben, nicht für jede Modellklasse bei jeder Aufgabe.\n\nZweitens Kosten und Latenz. Sobald Modelle nicht mehr interaktiv, sondern in Volumen laufen – Klassifikation in einer Pipeline, Autovervollständigung im Editor, Verarbeitung tausender Dokumente –, werden Preis pro Token und Antwortzeit zu harten Kriterien. Da liegen zwischen den Modellen Faktoren, nicht Prozentpunkte, und es lohnt sich zu messen.\n\nDrittens lange, agentische Aufgaben. Hier trennen sich die Modelle Ende 2025 tatsächlich noch spürbar: wie lange sie an einer mehrstufigen Aufgabe fokussiert bleiben, wie zuverlässig sie Tools bedienen, wie sie mit eigenen Fehlern umgehen. Nicht zufällig wurden Claude Sonnet 4.5 im September und Opus 4.5 im November genau über diese Fähigkeit beworben. Wer ganze Arbeitspakete delegiert statt einzelner Fragen, darf bei der Modellwahl wählerisch sein.\n\nAber – und das ist mir wichtiger als die drei Ausnahmen selbst – all das ist die zweite Frage. Sie lässt sich erst dann seriös beantworten, wenn die erste beantwortet ist. Ohne klaren Aufgabenzuschnitt und ohne verlässliche Prüfung kann ich Modelle nämlich gar nicht vergleichen; ich vergleiche dann nur Bauchgefühle an unterschiedlichen Tagen. Erst wenn die Schicht steht, wird die Modellwahl zu dem, was sie sein sollte: eine Optimierung mit Messwerten, ungefähr so aufregend wie die Wahl eines Datenbank-Index. Und wie dort gilt: gelegentlich neu bewerten, Versionen bewusst festhalten, Verhaltensänderungen nach Updates ernst nehmen. „Gut genug“ heißt weder „alle gleich“ noch „für immer“ – es heißt nur: nicht der Engpass.\n\n## Die erste Frage\n\nZurück in die Kaffeepause. Bleibt bei dem Modell, das ihr habt, habe ich ihm gesagt, und steckt die Diskussionsenergie eures Teams in die Fragen davor und danach. Woran erkennt ihr, dass ein Ergebnis stimmt? Was müsste eine Session über euer System wissen, um nicht zu raten? Welche eurer Aufgaben sind überhaupt so geschnitten, dass man sie abgeben kann? Wer diese drei Fragen gut beantwortet, holt aus jedem der aktuellen Modelle mehr heraus als der Nachbar mit dem Benchmark-Sieger und leerem Kontextfenster.\n\nEr war sichtlich unzufrieden – die Antwort ist weniger spektakulär als ein Modellvergleich, und sie macht Arbeit. Aber sie hat eine Konsequenz, die mich seither beschäftigt und die den Rest dieser Serie trägt: Wenn das Modell selten der Engpass ist, dann macht irgendetwas anderes den Unterschied zwischen der Session, die liefert, und der, die Zeit verbrennt. Bei mir läuft es jedes Mal auf die Umgebung hinaus, in der das Modell arbeitet – wie viel sie ihm zeigt, was sie ihm erlaubt und wogegen sie seine Ergebnisse hält. Genau dort geht es in der nächsten Folge weiter.\n\n## Weiterführende Quellen\n\n- [Anthropic: Introducing Claude Opus 4.5](https://www.anthropic.com/news/claude-opus-4-5)\n- [Google: Gemini 3 – das bislang leistungsfähigste Modell von Google](https://blog.google/products/gemini/gemini-3/)\n- [Simon Willison: Claude Sonnet 4.5 is probably the best coding model in the world (at least for now)](https://simonwillison.net/2025/Sep/29/claude-sonnet-4-5/)\n- [Anthropic Engineering: Effective context engineering for AI agents](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents)"},{"slug":"von-der-session-zum-system-auftakt","title":"Vom Werkzeug zur Arbeitsweise","summary":"Wie ein Gespräch in Berlin mich nach über dreißig Jahren Softwarehandwerk zum täglichen Arbeiten mit Claude, ChatGPT und Claude Code gebracht hat – und warum ich meiner Begeisterung trotzdem erst einmal misstraue.","tags":["Software","KI","Methodik"],"published_at":"2025-11-18T11:00:00+00:00","updated_at":"2026-07-14T20:54:48.643736+00:00","url":"https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/fd5beb8418445d1ea6ac4d2781f0b463829f64a864e1ab948f305ad7d91fe5e5.mp3","audio_duration_secs":697,"text":"*Auftakt der Serie „Von der Session zum System“.*\n\nVor einiger Zeit saß ich in Berlin mit [Sebastian Korfmann](https://skorfmann.com/) zusammen. Sebastian kenne ich als Software-Engineer aus dem Cloud- und Infrastruktur-Umfeld – jemand, der Werkzeuge nicht nach Marketing-Folien beurteilt, sondern danach, ob sie ihm im Alltag Arbeit abnehmen. Er erzählte von seinen Erfahrungen mit KI-Werkzeugen: was er damit baut, wie sich seine Arbeit verändert hat, wo es hakt. Ich habe interessiert zugehört – und dabei gemerkt, dass ich selbst erstaunlich wenig beisteuern konnte.\n\nDas muss ich erklären. Ich arbeite seit über dreißig Jahren an Software, einen großen Teil davon an verteilten Systemen – Node, TypeScript, GraphQL, Cloud-Architekturen – und habe über viele Jahre Training für Softwareentwickler und Architekten gegeben. Was mich an dieser Arbeit am meisten reizt: digitale Produkte zu bauen, und zwar ganzheitlich. Die Fachdomäne wirklich verstehen, Richtungsentscheidungen treffen, gestalten – es geht mir schon lange nicht mehr nur um Programmierung und Architektur. Mit den Jahren haben sich auch meine Schwerpunkte verschoben: weg davon, jedes feine Detail selbst in der Hand zu haben, hin zu Struktur, Konsistenz, Prozessen, Produktivität, Methoden und Konzepten. Mit KI hatte ich bis dahin trotzdem noch nie gearbeitet. Keine einzige Session, kein Copilot. Nicht aus Ablehnung – grundsätzliche Skepsis gegenüber neuer Technik ist nicht meine Art. Es hatte sich schlicht nie ergeben. Der Rummel seit dem ChatGPT-Start Ende 2022 war zwar nicht zu überhören – Schlagzeilen, Konferenz-Keynotes, Kollegen, die davon erzählten –, aber was ich hörte und las, klang für mich nach beeindruckenden Demos, und meine Tage waren voll. Wenn überhaupt, war ich ein bisschen zurückhaltend – so, wie man eben zurückhaltend ist bei Dingen, die man noch nicht selbst ausprobiert hat.\n\nDazu kam noch etwas anderes, und darüber rede ich nicht so gern, weil es nichts mit Technik zu tun hat, sondern mit mir. Ich war müde geworden. Nicht am Beruf, sondern an einer bestimmten Sorte Vorhaben: eigene, größere Projekte, digitale Produkte, die ich lange im Kopf hatte. Ich kenne die Komplexität solcher Vorhaben gut genug, um zu wissen: Viele dieser Themen kann ich allein nicht in der Qualität umsetzen, die ich selbst erwarte. Die Ideen waren da, teils detailliert ausgearbeitet, in Notizen und halb angefangenen Repositories. Was fehlte, war die Kraft für die schiere Menge an Umsetzungsarbeit – neben Anstellung und allem anderen. Wer einmal versucht hat, ein komplettes Produkt allein zu bauen, kennt das Verhältnis: Auf eine Stunde, in der man wirklich gestaltet – eine Richtungsentscheidung, ein Konzept, ein Stück Fachlichkeit –, kommen zwanzig Stunden Fleißarbeit. Boilerplate, Konfiguration, Formulare, Error Handling, Deployment, Dokumentation, dazu das ganze Organisatorische drumherum. Irgendwann habe ich aufgehört, neue Schubladen aufzumachen, weil die alten schon voll waren.\n\nIn dieser Verfassung traf ich also Sebastian. Was mich an dem Gespräch gepackt hat, war seine Art zu erzählen: keine Superlative, keine „das verändert alles“-Rhetorik, sondern die nüchterne Art, in der man einem Kollegen von einem Werkzeug berichtet, das sich im Alltag bewährt hat. Diese Tonlage kannte ich, und sie machte mich neugierig. Also habe ich mir vorgenommen, das Ganze endlich selbst auszuprobieren. Nicht zehn Minuten, sondern ein paar Tage, an echten Aufgaben.\n\n## Die ersten Tage\n\nIch habe mit Claude und ChatGPT angefangen, im Browser, an Aufgaben aus meinem tatsächlichen Alltag: ein Architekturkonzept gegenlesen, eine API-Migration skizzieren, einen Datenimport schreiben, den ich sonst vor mir hergeschoben hätte. Kurz darauf kamen Claude Code und Codex dazu – also die Werkzeuge, die nicht nur antworten, sondern im Terminal direkt am Repository arbeiten: Dateien lesen, ändern, Tests laufen lassen. Anthropic hatte Ende September Claude Sonnet 4.5 veröffentlicht, OpenAI etwa zeitgleich sein Codex-Update; ich bin, ohne es zu planen, in einem ziemlich guten Moment eingestiegen.\n\nUnd dann passierte etwas, womit ich nicht gerechnet hatte: Ich war binnen Tagen sehr angetan. Nicht milde interessiert – angetan. Drei Dinge haben mich erwischt:\n\n- Die Geschwindigkeit. Aufgaben, für die ich einen halben Tag angesetzt hätte, waren in einer Stunde in einem Zustand, den ich ernsthaft reviewen konnte.\n- Die Produktivität über den ganzen Tag. Nicht ein einzelner beeindruckender Moment, sondern die Summe: Es blieb abends messbar mehr fertig liegen als vorher.\n- Die Qualität. Das hatte ich am wenigsten erwartet. Der erzeugte Code war nicht fehlerfrei, aber deutlich besser, als ich es nach allem, was ich über generierten Code gehört und gelesen hatte, erwartet hätte – strukturiert, mit sinnvoller Error Handling, oft mit Tests.\n\nAus den paar Tagen Probe wurden Wochen täglicher Arbeit. Inzwischen arbeite ich jeden Tag mit diesen Werkzeugen und steige immer tiefer ein – gerade erst hat Anthropic mit den Skills eine Möglichkeit nachgelegt, Claude wiederverwendbare Arbeitsanweisungen mitzugeben, und ich merke, dass ich anfange, solche Ankündigungen zu lesen wie früher Release-Notes von Node.\n\nEin paar Wochen sind keine Erfahrung. Das ist mir bewusst, und genau deshalb schreibe ich das hier auf. Wer so schnell zum täglichen Nutzer wird, sollte der eigenen Begeisterung erst einmal misstrauen – frische Begeisterung ist ein schlechter Zeuge. Also versuche ich, genau hinzuschauen und zu notieren, was ich tatsächlich beobachte – mit dem einzigen Prüfstein, den ich habe: über dreißig Jahre Handwerk, an denen ich jedes Ergebnis messen kann.\n\n## Beobachtung eins: Erzeugen wird billig, Prüfen wird der Engpass\n\nDie erste Verschiebung habe ich schon nach wenigen Tagen bemerkt, und sie ist fundamentaler, als sie klingt. Der teure Teil meiner Arbeit war immer das Erzeugen: Code schreiben, Dokumente formulieren, Konzepte ausarbeiten. Das Prüfen – Reviews, Gegenlesen, Abnahme – war der kleinere Posten. Dieses Verhältnis kippt gerade. Erzeugen ist plötzlich billig; in einer Stunde entsteht mehr Text und Code, als ich früher an einem Tag geschafft hätte. Was nicht billiger geworden ist: beurteilen, ob das Erzeugte stimmt. Meine Arbeit verlagert sich sichtbar vom Schreiben zum Lesen, vom Produzieren zum Begutachten.\n\nIm Gespräch mit einem Kollegen habe ich dafür eine Formulierung gefunden, die hängen geblieben ist: Das Werkzeug ist weniger Assistent, mehr Sekretär. Ein Assistent, wie ihn die Produktnamen versprechen, ergänzt hier eine Zeile und schlägt dort ein Wort vor – die Arbeit bleibt meine. Ein Sekretär im alten Sinn nimmt ganze Vorgänge entgegen: Ich diktiere die Absicht, er fertigt das Schriftstück, und ich zeichne ab. Das klingt bequemer, ist aber eine andere Verantwortung. Beim Assistenten prüfe ich Vorschläge im Vorbeigehen; beim Sekretär muss ich das fertige Stück wirklich lesen, denn meine Unterschrift steht darunter. Wer abzeichnet, ohne zu lesen, hat nicht delegiert, sondern kapituliert. Ich ahne, dass in dieser Verschiebung der eigentliche Umbau meiner Arbeitsweise steckt – nicht im Werkzeug selbst.\n\n## Beobachtung zwei: Die Schubladen gehen wieder auf\n\nDie zweite Beobachtung ist persönlicher, und sie hat mich mehr überrascht als jede Benchmark. Die Müdigkeit, von der ich oben geschrieben habe, ist nicht weg – aber sie hat ihren Grund verloren. Die Produktideen, die liegen geblieben sind, weil die Umsetzung für einen Einzelnen zu groß war, sind plötzlich wieder in Reichweite. Nicht, weil die KI das Produkt baut. Sondern weil sie genau den Teil übernimmt, der mich zermürbt hat: die zwanzig Stunden Fleißarbeit rund um die eine Stunde, die wirklich Nachdenken verlangt. Das Formular-Grundgerüst, die Testabdeckung, der Import-Adapter, das Deployment-Skript – all das entsteht jetzt so schnell, dass ich – wie sagt man so schön – das Eisen schmieden kann, solange es heiß ist.\n\nVielleicht hat mich das Ganze auch deshalb so schnell für sich eingenommen: Meine Schwerpunkte hatten sich über die Jahre ohnehin genau dorthin verschoben, wo diese Werkzeuge nichts wegnehmen – Struktur, Konsistenz, Konzepte, die Fachdomäne, die Richtungsentscheidungen. Die Detailarbeit dagegen und die wiederkehrenden Aufgaben, auch die organisatorischen, gehen mir jetzt wieder leichter von der Hand. Nicht alle. Aber genug, dass es wieder Spaß macht.\n\nIch habe in den letzten Wochen abends wieder an eigenen Sachen gebaut, freiwillig und mit einer Freude, die ich eine Weile nicht hatte. Das ist die Stelle, an der ich vorsichtig formulieren muss, weil sie sich so leicht zur Heilsbotschaft aufblasen lässt. Also nüchtern: KI ist für mich gerade vor allem ein Hebel für den Einzelnen mit Erfahrung. Die Betonung liegt auf beidem. Der Hebel wirkt, weil ich nach dreißig Jahren weiß, was ich bauen will, wie es geschnitten sein muss und woran ich erkenne, dass ein Ergebnis taugt. Ob derselbe Hebel auch für jemanden wirkt, der diese Urteilsfähigkeit noch nicht hat, weiß ich schlicht nicht – ich habe da einen Verdacht, aber Verdacht ist kein Wissen, und für diese Serie will ich beides sauber auseinanderhalten.\n\n## Beobachtung drei: Der Ton stimmt immer\n\nDie dritte Beobachtung halte ich nur kurz fest, denn ich verstehe sie noch nicht gut genug, um mehr daraus zu machen. Mir fällt auf, *wie* diese Systeme antworten. Im Berufsleben gibt es dafür ein augenzwinkerndes Kürzel: SABTA – sicheres Auftreten bei totaler Ahnungslosigkeit. Die Psychologie kennt das Muster als Dunning-Kruger-Effekt: Am selbstbewusstesten tritt oft auf, wer am wenigsten weiß. Genau so wirken diese Systeme auf mich – die richtige Auskunft und die falsche kommen im selben ruhigen, kompetenten Ton. In einem Code-Review verrät sich ein unsicherer Kollege durch Formulierungen – „ich glaube“, „müsste eigentlich“. Hier gibt es dieses Signal nicht. Zweimal in den vergangenen Wochen hätte ich beinahe eine Behauptung übernommen, die sich bei der Prüfung als frei erfunden herausstellte – eine API-Signatur, die es nie gab, souverän vorgetragen. Beide Male hat mich nur die Gewohnheit gerettet, Dinge nachzuschlagen. Dieses sichere Auftreten verdient ein grundsätzliches Misstrauen, und ich will verstehen, woher es kommt – strukturell, nicht anekdotisch. Das hebe ich mir für später in dieser Serie auf, wenn die Beobachtungen dafür beisammen sind.\n\n## Was diese Serie werden soll\n\nIch beginne hier eine Serie, weil ich ahne, dass die Geschichte größer ist als mein Schreibtisch. Im Moment beobachte ich mich selbst: einen Einzelnen in der Session, mit direkter Kontrolle über jedes Ergebnis. Aber ich arbeite nicht allein – ich arbeite in und mit Teams, und ein Team ist etwas anderes als die Summe einzelner Sessions. Und über den Teams stehen Unternehmen mit ihren Prozessen, ihrem Wissen, ihrer Verantwortung. Diesen Bogen will ich in den kommenden Folgen Schritt für Schritt durchgehen: vom Ich über das Wir zum System. Nicht als Theorie, sondern entlang dessen, was ich tatsächlich erlebe und ausprobiere – inklusive der Sackgassen, von denen es absehbar einige geben wird.\n\nDie nächste Frage drängt sich dabei schon auf. Im Team, mittags unter Kollegen, zuletzt auf einem Konferenzflur: Das Gespräch landet nach wenigen Minuten an derselben Stelle. Welches Modell ist das beste? Claude oder GPT, welche Version, welche Benchmark. Ich habe diese Diskussion selbst geführt und dabei ein Unbehagen entwickelt, das ich erst allmählich einordnen kann: Nach diesen ersten Wochen täglicher Arbeit fällt mir auf, dass die Wahl des Modells meine Ergebnisse weniger verändert hat als so ziemlich alles andere an meiner Arbeitsweise. Alle reden über das beste Modell – aber ist das überhaupt die richtige Frage? Der gehe ich in der nächsten Folge nach.\n\n## Weiterführende Quellen\n\n- [Introducing Claude Sonnet 4.5](https://www.anthropic.com/news/claude-sonnet-4-5) – Anthropics Ankündigung des Modells, mit dem ich eingestiegen bin (September 2025)\n- [Claude Sonnet 4.5 is probably the „best coding model in the world“](https://simonwillison.net/2025/Sep/29/claude-sonnet-4-5/) – Simon Willisons nüchterne Ersteinschätzung\n- [Introducing upgrades to Codex](https://openai.com/index/introducing-upgrades-to-codex/) – OpenAIs Codex-Update mit GPT-5-Codex (September 2025)\n- [Equipping agents for the real world with Agent Skills](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills) – Anthropics Engineering-Beitrag zu Skills (Oktober 2025)"},{"slug":"react-ueberblick-von-jsx-zur-architektur","title":"React-Überblick: Von JSX zur Komponentenarchitektur","summary":"Ein kompletter Durchgang durch React, wie ich es heute unterrichte – vom deklarativen Rendern über die Frage, wem ein State gehört, bis zur Architektur einer wachsenden Anwendung. Die Details zu einzelnen Stationen kommen später als eigene Beiträge.","tags":["Software","Frontend","React"],"published_at":"2024-12-30T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/react-ueberblick-von-jsx-zur-architektur/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/38edf579e81db16b717a5fea322df073216fd1ee50c4909af7cec8b15f79f10c/react-ueberblick-von-jsx-zur-architektur-vorlesen.mp3","audio_duration_secs":1174,"text":"# React-Überblick: Von JSX zur Komponentenarchitektur\n\nReact nimmt einem das Rendern ab, nicht die Architektur. Die Bibliothek berechnet aus einem State eine Oberfläche und schreibt die Unterschiede ins DOM – aber wo dieser State liegt, wie lange er lebt und wer ihn ändern darf, entscheidet sie an keiner Stelle. Genau dort, an dieser unscheinbaren Grenze, entstehen die Probleme, die sich später mit keinem Hook und keiner Bibliothek mehr wegräumen lassen.\n\nDieser Beitrag geht React deshalb einmal am Stück durch – vom ersten Stück JSX bis zu der Stelle, an der aus einzelnen Komponenten ein System wird. Es ist bewusst ein Überblick. Zu den meisten Stationen, die ich hier nur kurz betrete, folgen später eigene Beiträge, die tiefer graben. Heute geht es darum, den großen Bogen im Kopf zu haben, bevor man sich in ein Detail verliert.\n\nIch stütze mich dabei auf zwei Repositories, die ich gerade zusammenführe: `introduction-react` für die Grundlagen und `advanced-react` für die fortgeschrittenen Muster. Beide sind kein Foliensatz in Textform, sondern lauffähiger Code, an dem wir gemeinsam üben. Der Stand, den ich hier beschreibe, ist React 19 – die stabile Version von Ende 2024, mit allem, was seither zum Alltag geworden ist.\n\n## Das mentale Modell: die Oberfläche ist eine Funktion des States\n\nBevor eine einzige Zeile JSX Sinn ergibt, muss ein Gedanke sitzen. React dreht die Art um, wie man über eine Oberfläche nachdenkt. Statt Schritt für Schritt zu beschreiben, wie sich das DOM verändern soll – hier ein Element einfügen, dort ein Attribut ändern, da einen Knoten entfernen –, beschreibt man nur, wie die Oberfläche für einen gegebenen State aussehen soll. React kümmert sich um den Weg dorthin.\n\nMan kann das in eine einzige Zeile fassen, die den ganzen Rest trägt: Die Oberfläche ist eine Funktion des States. Ändert sich der State, ruft React die Funktion erneut auf, vergleicht das Ergebnis mit dem vorherigen und schreibt nur die tatsächlichen Unterschiede ins echte DOM. Dieser Abgleich läuft über eine interne, leichtgewichtige Repräsentation des Baums, oft Virtual DOM genannt. Der Name ist weniger wichtig als die Konsequenz: Man beschreibt das Ziel, nicht den Weg. Wie dieser Abgleich Schritt für Schritt abläuft, zeige ich ausführlich im Beitrag [Wie React den Baum rendert und aktualisiert](https://www.mikebild.dev/de/blog/wie-react-den-baum-rendert-und-aktualisiert/).\n\n```mermaid\nflowchart LR\n  State[\"Zustand (state)\"] --> Render[\"Komponente rendert\"]\n  Render --> View[\"beschriebene Oberfläche\"]\n  View --> DOM[\"React gleicht ab und schreibt ins DOM\"]\n  User[\"Nutzerinteraktion\"] --> Event[\"Event / setState\"]\n  Event --> State\n```\n\nDer zweite tragende Gedanke ist der unidirektionale Datenfluss. Daten fließen von oben nach unten, von der Elternkomponente zum Kind, über Props. Events fließen in die andere Richtung, als Callbacks nach oben. Es gibt keine geheimnisvolle Zweiwegbindung, bei der ein Eingabefeld und eine Variable magisch synchron bleiben. Das wirkt am Anfang umständlicher als in älteren Frameworks, aber es ist der Grund, warum sich React-Anwendungen auch nach zwei Jahren noch erklären lassen: Man kann für jede Stelle der Oberfläche sagen, woher ihre Daten kommen und wer sie ändern darf.\n\n## JSX und Komponenten: die kleinste Einheit ist eine Funktion\n\nJSX sieht aus wie HTML im JavaScript, ist aber nur eine bequeme Schreibweise für Funktionsaufrufe. Ein `<button>` wird zu einem Aufruf, der ein Objekt beschreibt, kein DOM-Element erzeugt. Deshalb kann man mitten im JSX rechnen, Listen abbilden und bedingt rendern – es ist ja alles nur JavaScript:\n\n```jsx\nfunction InvoiceList({ invoices }) {\n  if (invoices.length === 0) return <p>No invoices.</p>;\n\n  return (\n    <ul>\n      {invoices.map((i) => (\n        <li key={i.id}>\n          {i.number} - {i.paid ? \"paid\" : \"open\"}\n        </li>\n      ))}\n    </ul>\n  );\n}\n```\n\nDrei Details in diesem kleinen Beispiel sind in Wahrheit Architekturthemen. Das `key`-Attribut ist kein kosmetisches Detail, sondern die Information, an der React beim erneuten Rendern erkennt, welches Listenelement welchem entspricht. Das bedingte Rendern mit `?` oder mit einem frühen `return` ist die Stelle, an der Lade-, Fehler- und Leerzustände sichtbar werden – genau die States, die im Demo-Modus gern unterschlagen werden und im Betrieb dann fehlen. Und `props` ist eine schreibgeschützte Schnittstelle: Eine Komponente bekommt ihre Daten von außen und darf sie nicht verändern. Das macht Komponenten zu Bausteinen, deren Verhalten man von außen versteht. Die genauen Regeln von JSX – was Ausdruck ist, was beim Rendern verschwindet, wie Keys wirklich funktionieren – behandle ich im Detail im Beitrag [JSX genau: Die Regeln hinter dem Markup](https://www.mikebild.dev/de/blog/jsx-genau-die-regeln-hinter-dem-markup/).\n\nDer Standard ist heute die Functional Component. Sie ist eine Funktion, die Props entgegennimmt und JSX zurückgibt, und mit Hooks kann sie inzwischen alles, wofür man früher Klassen brauchte. Klassenkomponenten mit `render`-Methode und Lifecycle-Methoden wie `componentDidMount` existieren noch, und man trifft sie in älterem Code. Wer sie in einem gewachsenen Projekt vor sich hat, muss nicht erschrecken – sie sind eine historische Schicht, mehr nicht. Aber neuer Code entsteht mit Funktionen. Das ist keine Frage der Mode, sondern eine echte Vereinfachung: Ein Effect, der beim Einhängen und beim Aushängen etwas tun soll, war früher über zwei getrennte Lifecycle-Methoden verstreut. Heute steht er an einer Stelle.\n\nKomposition ersetzt Vererbung. Statt eine Basiskomponente zu erweitern, steckt man Komponenten ineinander und reicht Inhalt über `children` durch:\n\n```jsx\nfunction Card({ title, children }) {\n  return (\n    <section className=\"card\">\n      <h2>{title}</h2>\n      {children}\n    </section>\n  );\n}\n```\n\nEine solche `Card` weiß nichts über ihren Inhalt und ist gerade deshalb überall verwendbar. Wer diesen Reflex verinnerlicht – lieber zusammenstecken als vererben –, schreibt fast automatisch entkoppelte Oberflächen.\n\n## State ist kein Ort\n\nJetzt kommt die Frage vom Anfang, und sie ist die wichtigste im ganzen Beitrag. State ist kein Ort im Code, sondern eine Entscheidung über Besitz, Lebensdauer und Synchronisation. Bevor man `useState` schreibt, sollte man wissen, wem dieser State eigentlich gehört.\n\nDer lokale Fall ist der einfachste. Ein Zähler, ein aufgeklapptes Menü, der Text in einem Eingabefeld – solcher State gehört in die Komponente, die ihn anzeigt:\n\n```jsx\nimport { useState } from \"react\";\n\nfunction Counter({ start = 0 }) {\n  const [count, setCount] = useState(start);\n  return <button onClick={() => setCount((n) => n + 1)}>{count}</button>;\n}\n```\n\nDie Regel, die React an dieser Stelle einfordert, heißt: Komponenten rein halten. Für dieselben Eingaben soll dieselbe Ausgabe herauskommen. Der State wird nicht direkt verändert, sondern über die Setter-Function ersetzt, und React entscheidet, wann neu gerendert wird. Wer das ernst nimmt, bekommt Komponenten, die man ohne laufenden Browser im Kopf durchspielen kann.\n\nInteressant wird es, sobald ein State nicht mehr in eine einzelne Komponente passt. Dann muss man ehrlich beantworten, wo er hingehört, und die Antwort ist selten „in einen globalen Store“. Ich unterscheide vier Orte, und die Wahl zwischen ihnen ist die eigentliche Architekturarbeit im Frontend:\n\n```mermaid\nflowchart TB\n  Frage[\"Wem gehört dieser Zustand?\"]\n  Frage --> Lokal[\"Komponente<br/>(useState) – UI-Detail\"]\n  Frage --> URL[\"URL<br/>Filter, Seite, ausgewähltes Objekt\"]\n  Frage --> Server[\"Server-Cache<br/>geladene Daten, die dem Backend gehören\"]\n  Frage --> Global[\"globaler Store<br/>app-weite, geteilte Entscheidungen\"]\n```\n\nVieles, was Teams reflexhaft in einen globalen Store legen, gehört eigentlich in die URL – welcher Filter aktiv ist, welche Seite angezeigt wird, welches Objekt ausgewählt ist. In der URL ist dieser State teilbar, mit einem Lesezeichen speicherbar und überlebt einen Reload. Und ein großer Teil dessen, was wie Anwendungszustand aussieht, ist in Wahrheit gar keiner: Es sind Daten, die dem Server gehören und die man nur zwischengespeichert hat. Diese Unterscheidung zwischen echtem Client State und gecachten Serverdaten ist die folgenreichste im ganzen Frontend, und ich widme ihr einen eigenen Beitrag: [Client State vs. Server-Cache](https://www.mikebild.dev/de/blog/client-zustand-und-server-cache/).\n\n## Effects sind der Rand, nicht die Mitte\n\n`useEffect` ist der Hook, der in den Teams, mit denen ich gearbeitet habe, die meisten Missverständnisse produziert. Er synchronisiert eine Komponente mit etwas außerhalb von React – einem Netzwerkaufruf, einem Abonnement, dem Titel des Browsertabs, einem Timer. Der entscheidende Satz lautet: Ein Effect ist der Rand des Systems, nicht seine Mitte. Alles, was sich aus dem State berechnen lässt, gehört nicht in einen Effect, sondern direkt ins Rendern.\n\n```jsx\nimport { useState, useEffect } from \"react\";\n\nfunction useInvoice(id) {\n  const [data, setData] = useState(null);\n  const [error, setError] = useState(null);\n\n  useEffect(() => {\n    let active = true;\n    fetch(`/api/invoices/${id}`)\n      .then((r) => r.json())\n      .then((d) => active && setData(d))\n      .catch((e) => active && setError(e.message));\n    return () => {\n      active = false;\n    };\n  }, [id]);\n\n  return { data, error };\n}\n```\n\nZwei Dinge an diesem Beispiel sind die eigentliche Lektion. Das Abhängigkeits-Array `[id]` bestimmt, wann der Effect erneut läuft – vergisst man dort einen Wert, arbeitet die Komponente mit veralteten Daten weiter. Und die zurückgegebene Aufräumfunktion, die `active` auf `false` setzt, verhindert, dass eine spät eintreffende Antwort einen bereits ausgehängten oder inzwischen überholten State überschreibt. Genau dieser Wettlauf zwischen zwei Anfragen ist der Fehler, der im Test mit schnellen Antworten nie auftritt und erst im Betrieb mit langsamem Netz zuschlägt.\n\nIch habe die Logik hier bewusst in einen eigenen Custom Hook `useInvoice` gezogen. Damit sind wir schon beim heutigen Weg, Logik wiederzuverwenden – dazu gleich mehr. Wichtig ist an dieser Stelle nur die Haltung: Effects begrenzen, nicht häufen. Ein Frontend, in dem Datenladen, Ableitung, Synchronisation und Nutzerereignisse in einem einzigen Effektklumpen landen, ist fragil, weil niemand mehr sagen kann, warum welcher Effect wann läuft.\n\nDer häufigste Fehler, den ich in Code-Reviews sehe, ist genau der umgekehrte Reflex: einen Wert, der sich aus anderem State berechnen lässt, über einen Effect in einen zweiten State zu spiegeln. Wer den vollständigen Namen aus Vor- und Nachnamen per Effect in eine eigene Variable schreibt, hat sich zwei Quellen der Wahrheit gebaut, die auseinanderlaufen, sobald ein Rendern dazwischenkommt. Der berechnete Wert gehört nicht in einen Effect und auch nicht in einen zweiten `useState`, sondern schlicht ins Rendern, wo er bei jedem Durchlauf frisch entsteht. Was ableitbar ist, wird abgeleitet und nicht gespeichert – diese eine Regel erspart mehr Fehler als jede Bibliothek. Ein Effect ist erst dann berechtigt, wenn wirklich etwas außerhalb von React im Spiel ist.\n\nFür das Laden von Serverdaten muss man diesen Mechanismus in echten Projekten übrigens selten von Hand schreiben. In der Praxis übernimmt eine darauf spezialisierte Bibliothek das Caching, das erneute Laden und die Konsistenz. React 19 geht mit dem `use()`-Hook und Suspense noch einen Schritt weiter und erlaubt es, ein Promise direkt im Rendern abzuwarten, sodass Lade- und Fehlerzustände weiter nach außen an eine umschließende `<Suspense>`-Komponente wandern. Dieses eigene große Thema behandle ich im Beitrag [Suspense und der use-Hook](https://www.mikebild.dev/de/blog/react-suspense-und-use/).\n\n## Von HOCs und Render Props zu Custom Hooks\n\nDie fortgeschrittenen React-Pattern, die im `advanced-react`-Material einen eigenen Block bilden, sind fast alle Antworten auf dieselbe Frage: Wie teile ich Logik zwischen Komponenten, ohne sie zu kopieren? Die Antwort hat sich über die Jahre verschoben, und dieser Wandel ist selbst lehrreich.\n\nFrüher gab es dafür zwei etablierte Muster. Eine Higher-Order Component ist eine Funktion, die eine Komponente entgegennimmt und eine erweiterte zurückgibt – ein Wrapper, der Verhalten hinzufügt. Render Props lösen dasselbe Problem, indem eine Komponente ihre Logik über eine Funktion als Kind nach außen gibt. Beide funktionieren, aber beide haben denselben Preis: Sie verschachteln den Komponentenbaum und machen aus einer flachen Idee eine tiefe Struktur.\n\n[Custom Hooks](https://www.mikebild.dev/de/blog/von-hocs-und-render-props-zu-custom-hooks/) lösen genau dieses Problem, ohne den Baum zu verschachteln. Ein Custom Hook ist einfach eine Funktion, deren Name mit `use` beginnt und die andere Hooks benutzt. Das `useInvoice` von oben ist bereits einer. Die Logik steht in einer normalen Funktion, wird von mehreren Komponenten aufgerufen und taucht im Baum überhaupt nicht auf:\n\n```jsx\nfunction InvoiceView({ id }) {\n  const { data, error } = useInvoice(id);\n  if (error) return <ErrorMessage text={error} />;\n  if (!data) return <LoadingIndicator />;\n  return <InvoiceDetails invoice={data} />;\n}\n```\n\nDieselbe Bewegung findet man beim Kontext. Reichen Daten über viele Ebenen durch, ohne sie durch jede Zwischenkomponente als Prop zu fädeln, ist das die Aufgabe von Context: ein Provider oben im Baum, ein `useContext` unten. Der wichtige Hinweis dabei: Context ist zum Verteilen gedacht, nicht als Ersatz für einen durchdachten State-Besitz. Wer jeden veränderlichen Wert in einen Context legt, verlagert das Kopplungsproblem nur eine Ebene höher. Auch das – Context als Verteilweg gegen Context als heimlicher globaler State – behandle ich im Beitrag [Context richtig einsetzen](https://www.mikebild.dev/de/blog/context-richtig-einsetzen/).\n\n## Wenn geteilter State wirklich global ist: Redux und die Alternativen\n\nManchmal ist State tatsächlich app-weit und wird von vielen, weit auseinanderliegenden Stellen gelesen und geändert. Für diesen Fall gibt es etablierte Werkzeuge, und Redux ist das bekannteste. Sein Kern ist erstaunlich klein: Der gesamte State liegt in einem Store, geändert wird er nur durch das Verschicken einer Action – einem einfachen Objekt mit einem `type` –, und ein Reducer ist eine Pure Function, die aus altem State und Action den neuen State berechnet.\n\n```js\nfunction invoicesReducer(state = [], action) {\n  switch (action.type) {\n    case \"invoice/paid\":\n      return state.map((i) =>\n        i.id === action.id ? { ...i, paid: true } : i\n      );\n    default:\n      return state;\n  }\n}\n```\n\nDer Reiz dieses Musters ist, dass jede State-Änderung ein benanntes, protokollierbares Event wird. Man kann die Abfolge der Actions nachvollziehen, aufzeichnen und im Test durchspielen. Der Preis ist eine gewisse Menge an Struktur. Mir sind in Projekten drei Arten begegnet, diese Struktur zu organisieren, und die Wahl entscheidet darüber, ob ein UI-System fachlich oder technisch wächst: den Rails-Stil mit getrennten Ordnern für Actions, Reducer und Container; den Domain-Stil, der stattdessen pro Feature bündelt; und das Ducks-Pattern, das Actions und Reducer einer Sache in eine Datei legt. Der Domain-Schnitt ist fast immer der bessere, weil er die Datei-Grenzen entlang der Fachlichkeit zieht und nicht entlang technischer Rollen.\n\nHeute schreibt kaum jemand Redux mehr von Hand so aus. Das offizielle Redux Toolkit nimmt einem den größten Teil der Wiederholung ab, und für viele Anwendungen reicht eine leichtere Bibliothek oder schlicht Context mit einem Reducer. Reaktive Ansätze wie MobX verfolgen dasselbe Ziel mit einem anderen Modell, bei dem sich abgeleitete Werte automatisch aktualisieren. Welches dieser Werkzeuge passt, hängt weniger von der Mode ab als von der ehrlichen Antwort auf die Besitzfrage von vorhin – dem widme ich den eigenen Vergleich [State-Management in React: Redux Toolkit, leichte Stores, MobX](https://www.mikebild.dev/de/blog/state-management-vergleich/).\n\nIch mache in Architektur-Diskussionen gern die Gegenprobe: Für wie viel des vermeintlich globalen States braucht man den Store wirklich? Meistens schrumpft die Liste dramatisch, sobald man die URL für Auswahl und Filter nutzt und geladene Serverdaten als das behandelt, was sie sind – Cache, nicht State. Übrig bleibt oft nur ein kleiner Kern echter, app-weiter Entscheidungen: die angemeldete Person, das gewählte Sprach- oder Theme-Setting, vielleicht ein Warenkorb. Für diesen Kern lohnt sich ein Store, für den Rest ist er Ballast. Ein globaler Store ist also nicht die Standardantwort, sondern die Ausnahme, die man begründen können muss.\n\n## Router, Server und die Ränder der Anwendung\n\nZwei Ränder fehlen noch. Der erste ist die Navigation. In einer Single-Page-Anwendung wechselt man nicht die Seite, sondern bildet URLs auf Komponenten ab. Der Router hält Adresszeile und angezeigte Komponente synchron, sodass Vor- und Zurück-Knopf, Lesezeichen und geteilte Links weiter funktionieren:\n\n```jsx\nimport { BrowserRouter, Routes, Route } from \"react-router-dom\";\n\nfunction App() {\n  return (\n    <BrowserRouter>\n      <Routes>\n        <Route path=\"/\" element={<Start />} />\n        <Route path=\"/invoices/:id\" element={<InvoiceView />} />\n        <Route path=\"*\" element={<NotFound />} />\n      </Routes>\n    </BrowserRouter>\n  );\n}\n```\n\nDer Router ist damit auch der natürliche Ort für den State, der in die URL gehört. Der Parameter `:id`, den man in der Komponente über `useParams` ausliest, ist genau das ausgewählte Objekt, über das ich weiter oben gesprochen habe – kein Grund, es zusätzlich in einem Store zu halten. Für neue Projekte empfiehlt React Router inzwischen die Data-Router-Variante mit `createBrowserRouter`, bei der Routen zusätzlich Lade- und Schreibfunktionen bekommen; das ist der modernere Weg, dem ich den Beitrag [React Router Data Router](https://www.mikebild.dev/de/blog/react-router-data-router/) widme.\n\nDer zweite Rand ist das Rendern auf dem Server. Für Startzeit, Suchmaschinen und schwache Geräte ist es oft sinnvoll, die erste Fassung der Seite auf dem Server zu erzeugen und im Browser nur noch lebendig zu machen. Frameworks wie Next.js nehmen einem diese Mechanik ab. React 19 hat das Thema mit den Server Components noch einmal grundlegend erweitert: Komponenten können auf dem Server laufen, dort Daten laden und nur ihr Ergebnis an den Browser schicken, während die Server Actions das Gegenstück für schreibende Vorgänge bilden. Das verschiebt die alte Grenze zwischen Client und Server spürbar, und es ist ein so großes Thema, dass ich ihm den Beitrag [React Server Components und Server Actions](https://www.mikebild.dev/de/blog/react-server-components-und-server-actions/) widme; hier steht es nur als Wegweiser.\n\n## Betrieb: Tests, Werkzeugkette, Typen\n\nEin Überblick, der beim Rendern aufhört, wäre unvollständig, denn all die Grenzen, die ich beschrieben habe – State-Besitz, Effect-Disziplin, Datenfluss –, lassen sich unbemerkt kaputtmachen, wenn niemand eine Erwartung daran festhält. Tests sind im Frontend keine Pflichtübung, sondern der Ort, an dem diese Erwartungen sichtbar bleiben.\n\nIch teste Komponenten heute mit Vitest und der React Testing Library, und deren Leitidee passt genau zur Architektur dieses Beitrags: Man prüft, was Nutzer sehen und tun, nicht wie die Komponente innen gebaut ist. Ein Test, der an das sichtbare Ergebnis gebunden ist, überlebt ein Refactoring; ein Test, der an Implementierungsdetails klebt, bricht bei jeder Umbenennung.\n\n```jsx\nimport { describe, it, expect } from \"vitest\";\nimport { render, screen } from \"@testing-library/react\";\nimport userEvent from \"@testing-library/user-event\";\nimport { Counter } from \"./Counter\";\n\ndescribe(\"Counter\", () => {\n  it(\"counts up on click\", async () => {\n    render(<Counter start={0} />);\n    const button = screen.getByRole(\"button\", { name: \"0\" });\n    await userEvent.click(button);\n    expect(screen.getByRole(\"button\", { name: \"1\" })).toBeInTheDocument();\n  });\n});\n```\n\nDahinter steht dieselbe Testpyramide wie überall: viele schnelle Komponententests an der Basis, weniger Integrationstests in der Mitte, eine Handvoll Ende-zu-Ende-Tests mit einem Werkzeug wie Playwright an der Spitze. Wer diese Verteilung auf den Kopf stellt, bezahlt es später mit einer trägen Build-Pipeline.\n\nAuch die Werkzeugkette ist eine Architekturentscheidung, keine Geschmacksfrage. Ob ein Projekt mit Vite, Next.js, Parcel oder einer handgeschriebenen Webpack-Konfiguration startet, legt fest, wie schnell die Rückmeldung beim Entwickeln ist, wie einfach das Server-Rendern wird und wie viel man selbst wartet. Und über all dem liegt [TypeScript](https://www.mikebild.dev/de/blog/typescript-in-react/), das aus Props und State überprüfbare Verträge macht. Ein falsch getippter Prop wird dann nicht zum Laufzeitfehler beim Nutzer, sondern zum roten Kringel im Editor. Der React Compiler, den das React-Team als eigenes Projekt neben der Bibliothek entwickelt, nimmt einem schließlich einen Teil der manuellen Optimierung ab: Er analysiert Komponenten beim Bauen und merkt sich automatisch, was sich zwischen zwei Renderdurchläufen nicht geändert hat, sodass viele Aufrufe von `useMemo`, `useCallback` und `memo` überflüssig werden.\n\n## Das Repository ist selbst schon Architektur\n\nEin Blick zum Schluss noch auf die Struktur der Repositories selbst. `introduction-react` und `advanced-react` sind nicht ein großer Klumpen, sondern viele kleine, eigenständige Beispiele – eine Basis-App, eine Redux-App, eine Router-App, eine mit Server-Rendern, dazu die fortgeschrittenen Ordner für asynchrone Muster, funktionale Komposition und Struktur. Jedes Beispiel steht für sich und lässt sich einzeln starten.\n\nDass ich die beiden Repositories gerade zusammenlege, ist deshalb selbst eine kleine Architekturübung. Die Frage ist dieselbe wie bei jedem wachsenden System: Was darf geteilt werden, und was muss getrennt bleiben, damit eine Änderung an einer Stelle nicht überraschend an einer anderen etwas kaputt macht? Ein sauber geschnittenes Monorepo aus Grundlagen- und Fortgeschrittenen-Beispielen löst genau dieselbe Aufgabe wie eine Anwendung, die aus vielen Komponenten und Modulen besteht. Die Grenzen, die man zieht, sind an beiden Stellen die eigentliche Arbeit.\n\n```mermaid\nflowchart TB\n  Repo[\"React-Monorepo\"]\n  Repo --> Grund[\"Grundlagen<br/>JSX, Komponenten, Hooks, Router\"]\n  Repo --> Fort[\"Fortgeschritten<br/>async, funktional, Struktur\"]\n  Repo --> Geteilt[\"geteilte Bausteine<br/>UI-Komponenten, Testhilfen\"]\n  Grund --> Geteilt\n  Fort --> Geteilt\n```\n\n## Was am Ende bleiben soll\n\nVon so einem Durchgang bleibt im besten Fall nicht der Satz „ich kenne jetzt die Hooks“ übrig, sondern ein Reflex: bei jedem State zuerst zu fragen, wem er gehört und wie lange er leben soll. Genau das ist der Kern. React gibt einem ein sehr gutes Werkzeug, um Oberflächen zu beschreiben, aber es trifft die Architekturentscheidungen nicht für einen. Wem ein State gehört, wo ein Effect seinen Rand hat, welcher State in der URL, im Server-Cache oder im Store liegt – das bleibt die Arbeit, die ein Mensch machen muss.\n\nDeshalb ist dieser Beitrag ein Überblick und kein Handbuch. Ich habe viele Stationen nur kurz betreten: den Unterschied zwischen Client State und Server-Cache, Context als Verteilweg, die Wahl zwischen Redux und seinen leichteren Alternativen, Suspense und Server Components, das Testen ohne Theater. Zu jeder dieser Stationen gibt es mehr zu sagen, als ein Überblick tragen kann, und genau das ist der Plan: Die Details folgen als eigene Beiträge, jeder mit einem konkreten Beispiel und mindestens einem Fehlerfall. Wer den großen Bogen im Kopf hat, weiß dann, an welcher Stelle er gerade steht.\n\n## Weiterführende Quellen\n\n- Repository introduction-react (Grundlagen): https://github.com/mikebild/introduction-react\n- Repository advanced-react (fortgeschrittene Muster): https://github.com/mikebild/advanced-react\n- Folienübersicht zu diesem Überblick: https://slides.mikebild.dev/introduction-react.html\n- React, offizielle Dokumentation (Thinking in React): https://react.dev/learn/thinking-in-react\n- React 19, Ankündigung der stabilen Version: https://react.dev/blog/2024/12/05/react-19\n- React, State verwalten und teilen: https://react.dev/learn/managing-state"},{"slug":"formulare-in-react-19-mit-actions","title":"Formulare in React 19: Actions, useActionState und useOptimistic","summary":"React 19 macht das Formular zur eigentlichen Schnittstelle: eine Funktion als form action, dazu drei Hooks für Ergebnis, Pending State und sofortiges UI-Feedback. Dieser Beitrag zeigt das Zusammenspiel – und wo optimistisches Rendern ohne sauberen Fehlerpfad kippt.","tags":["Software","Frontend","React"],"published_at":"2024-12-23T10:00:00+00:00","updated_at":"2026-07-13T08:57:08.736063+00:00","url":"https://www.mikebild.dev/de/blog/formulare-in-react-19-mit-actions/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/18563b58aba9422349aa8b42b47fdd6d084001eda56306ecba544a7621e8e458/formulare-in-react-19-mit-actions-vorlesen.mp3","audio_duration_secs":761,"text":"Ein optimistisch eingefügter Eintrag, der bei einem Fehler wieder verschwindet und keine Meldung hinterlässt, ist für den Nutzer eine Falschaussage: Er hat abgeschickt, er hat es kurz gesehen, dann war da nichts mehr. Das ist die eine Stelle, an der die Formular-APIs von React 19 Arbeit machen statt sie abzunehmen – und deshalb steht sie schon hier am Anfang.\n\nAlles andere daran ist Entlastung. Bis React 18 bestand ein Formular, das einen Namen speichert, aus `onSubmit`, `event.preventDefault()`, einem `useState` für den Ladezustand, einem zweiten für die Fehlermeldung und irgendwo einem `try/catch` – erstaunlich viel Handarbeit für eine erstaunlich alltägliche Aufgabe. React 19, stabil seit dem 5. Dezember 2024, übergibt dem `<form>`-Element stattdessen direkt eine Funktion und stellt drei Hooks daneben: `useActionState`, `useFormStatus` und `useOptimistic`. Sie kümmern sich um genau die Dinge, die man vorher von Hand zusammenbauen musste. Ich gehe der Reihe nach durch, was jeder dieser Bausteine übernimmt, wie sie zusammenspielen und wo der Fehlerpfad aus dem ersten Absatz hingehört.\n\n## Von onSubmit zu action\n\nDer Ausgangspunkt ist eine kleine, aber folgenreiche Änderung am `<form>`-Element. In React 19 akzeptiert das `action`-Attribut eine Funktion – nicht mehr nur einen URL-String wie im klassischen HTML. Diese Funktion bekommt beim Absenden ein `FormData`-Objekt und wird von React aufgerufen. React ruft dabei selbst `preventDefault` auf, verpackt den Aufruf in eine Transition und – ein Detail, das gerne übersehen wird – setzt ein unkontrolliertes Formular nach erfolgreicher Action automatisch zurück.\n\nSo sieht das im Vergleich aus. Vorher:\n\n```jsx\nfunction NameForm() {\n  const [isSaving, setIsSaving] = useState(false);\n  const [error, setError] = useState(null);\n\n  async function handleSubmit(event) {\n    event.preventDefault();\n    setIsSaving(true);\n    setError(null);\n    const formData = new FormData(event.currentTarget);\n    const result = await save(formData.get(\"name\"));\n    setIsSaving(false);\n    if (result.error) setError(result.error);\n  }\n\n  return (\n    <form onSubmit={handleSubmit}>\n      <input name=\"name\" />\n      <button disabled={isSaving}>{isSaving ? \"Saving…\" : \"Save\"}</button>\n      {error && <p>{error}</p>}\n    </form>\n  );\n}\n```\n\nUnd nachher, mit einer Client Action:\n\n```jsx\nfunction NameForm() {\n  async function updateName(formData) {\n    await save(formData.get(\"name\"));\n  }\n\n  return (\n    <form action={updateName}>\n      <input name=\"name\" />\n      <button>Save</button>\n    </form>\n  );\n}\n```\n\nDie zweite Variante ist nicht nur kürzer, sie verlagert die Kontrolle. React weiß jetzt, dass hier eine Übergabe stattfindet, hält den Pending State konsistent und kümmert sich um das native Submit-Verhalten. Eine Funktion, die auf diese Weise Übergänge auslöst, nennt React in der Dokumentation schlicht eine „Action“. Was in der zweiten Variante noch fehlt, ist alles, was den ersten Codeblock eigentlich ausgemacht hat: der Ladezustand und die Error Handling. Genau die holen die drei Hooks zurück – aber ohne die Handarbeit.\n\n## Wer übernimmt was\n\nDie drei Hooks klingen auf den ersten Blick ähnlich, haben aber sehr unterschiedliche Aufgaben. Deshalb vor dem Code die Zuständigkeiten:\n\n- `useActionState` bündelt den kompletten Lifecycle einer Action an einer Stelle: Es umschließt deine Action-Function, hält das zurückgegebene Ergebnis als State, liefert ein `isPending`-Flag und reicht dir eine umschlossene `formAction` zurück, die du ans Formular hängst.\n- `useFormStatus` liest den Pending State des nächstgelegenen umgebenden `<form>` – gedacht für Kindkomponenten wie einen Submit-Button, die nichts über die Action selbst wissen müssen, aber wissen wollen, ob gerade gesendet wird.\n- `useOptimistic` zeigt sofort einen erwarteten Zwischenzustand an, während die Action noch läuft, und konvergiert danach automatisch auf den realen State zurück – bei Erfolg auf den Serverwert, bei Fehler auf den alten Wert.\n\nMan kann die drei einzeln oder zusammen einsetzen. In einem echten Formular greifen sie meistens ineinander, und das Zusammenspiel ist genau der Punkt, an dem es interessant wird.\n\n## useActionState: Ergebnis und Pending an einer Stelle\n\n`useActionState` importiert man aus `react` – nicht aus `react-dom`, was eine häufige erste Fehlerquelle ist. Die Signatur:\n\n```jsx\nconst [state, formAction, isPending] = useActionState(action, initialState, permalink);\n```\n\nDer entscheidende und für viele überraschende Punkt steckt in der Signatur der übergebenen Action-Function. Sie bekommt **nicht** nur die `FormData`, sondern als erstes Argument den vorherigen State und erst als Zweites die Formulardaten:\n\n```jsx\nimport { useActionState } from \"react\";\n\nasync function updateName(previousState, formData) {\n  const name = formData.get(\"name\");\n  const error = await save(name);\n  if (error) {\n    return { error };\n  }\n  return { ok: true };\n}\n\nfunction NameForm() {\n  const [state, formAction, isPending] = useActionState(updateName, {});\n\n  return (\n    <form action={formAction}>\n      <input name=\"name\" disabled={isPending} />\n      <SubmitButton />\n      {state.error && <p role=\"alert\">{state.error}</p>}\n      {state.ok && <p>Saved.</p>}\n    </form>\n  );\n}\n```\n\nWas hier passiert, ist der ganze erste Codeblock von oben – nur ohne die Handarbeit. Der Rückgabewert der Action wird zum neuen `state`, das `isPending`-Flag verwaltet React, und `formAction` ist die umschlossene Funktion, die ans `action`-Attribut gehängt wird. Die Fehlermeldung ist kein separates `useState` mehr, sondern fällt einfach aus dem zurückgegebenen State heraus.\n\nWenn ich in einem Code-Review sehe, dass der State plötzlich `undefined` ist oder der vorherige State verloren geht, ist die Ursache fast immer dieselbe: Die Action wurde als `(formData) => …` geschrieben statt als `(previousState, formData) => …`. Der erste Parameter ist eben nicht die FormData. Wer aus der alten `onSubmit`-Welt kommt, hat diesen Reflex im Finger, und er kostet regelmäßig ein paar Minuten Suchen.\n\nEin historischer Hinweis, damit ältere Tutorials nicht verwirren: Dieser Hook hieß in den Canary-Versionen `useFormState` und steckte in `react-dom`. Mit React 19 wurde er nach `react` verschoben und in `useActionState` umbenannt; das dritte Element `isPending` kam dabei hinzu. `useFormState` gilt seither als deprecated – wenn du es noch irgendwo siehst, ist der Code auf einem älteren Stand.\n\n## useFormStatus: Pending im Kind lesen\n\nJetzt zum Submit-Button, den ich oben schon als `<SubmitButton />` eingesetzt habe. Man könnte fragen, warum der ausgelagert ist – schließlich hätte ich `isPending` aus `useActionState` auch direkt an einen Button im Formular geben können. Beides ist möglich, aber `useFormStatus` löst ein spezielles Problem: eine wiederverwendbare Komponente, die den Pending State kennt, ohne dass man ihn ihr durchreichen muss.\n\n```jsx\nimport { useFormStatus } from \"react-dom\";\n\nfunction SubmitButton() {\n  const { pending } = useFormStatus();\n  return (\n    <button disabled={pending}>\n      {pending ? \"Saving…\" : \"Save\"}\n    </button>\n  );\n}\n```\n\nDer Hook kommt aus `react-dom`, nimmt keine Parameter und liefert neben `pending` noch `data`, `method` und `action` des umgebenden Formulars. Die eine Regel, die man kennen muss und die für die meisten Fehlermeldungen an dieser Stelle verantwortlich ist: `useFormStatus` liest den Status des **nächstgelegenen Eltern-`<form>`** und muss deshalb in einer Kindkomponente stehen, nicht in derselben Komponente, die das `<form>` rendert.\n\nSteht der Hook versehentlich in der Formularkomponente selbst, ist `pending` schlicht immer `false` – es gibt aus dieser Perspektive kein umgebendes Formular, dessen Status man lesen könnte. Der Button muss ein Kind sein. Die eigene `SubmitButton`-Komponente ist deshalb keine Geschmacksfrage: Ohne diese Verschachtelung sieht der Hook überhaupt nichts.\n\n## useOptimistic: den erwarteten State sofort zeigen\n\nDer dritte Hook ist der beeindruckendste – und der gefährlichste, wenn man ihn halb versteht. `useOptimistic` zeigt den State, den man nach erfolgreicher Action erwartet, sofort an, ohne auf die Serverantwort zu warten. Das klassische Beispiel ist eine Nachrichtenliste, in der die eigene Nachricht sofort erscheint:\n\n```jsx\nimport { useOptimistic, useActionState } from \"react\";\n\nfunction Thread({ messages, sendMessage }) {\n  const [optimisticMessages, addOptimistic] = useOptimistic(\n    messages,\n    (currentMessages, newText) => [\n      ...currentMessages,\n      { text: newText, sending: true },\n    ]\n  );\n\n  const [state, formAction] = useActionState(\n    async (previousState, formData) => {\n      const text = formData.get(\"message\");\n      addOptimistic(text);\n      const error = await sendMessage(text);\n      if (error) {\n        return { error };\n      }\n      return { ok: true };\n    },\n    {}\n  );\n\n  return (\n    <div>\n      <ul>\n        {optimisticMessages.map((message, index) => (\n          <li key={index}>\n            {message.text}\n            {message.sending && <span> (sending…)</span>}\n          </li>\n        ))}\n      </ul>\n      <form action={formAction}>\n        <input name=\"message\" />\n        <SubmitButton />\n        {state.error && <p role=\"alert\">{state.error}</p>}\n      </form>\n    </div>\n  );\n}\n```\n\n`useOptimistic` bekommt den realen State und eine reine Reducer-Function, die aus aktuellem State und optimistischem Wert den nächsten Zwischenzustand berechnet. Der Aufruf von `addOptimistic` muss innerhalb einer Action beziehungsweise Transition passieren – hier also innerhalb der async-Action, die `useActionState` verwaltet. Ruft man ihn außerhalb auf, gibt es eine Warnung und kein optimistisches Rendering.\n\nDas eigentlich Elegante ist die automatische Konvergenz: Der optimistische Wert gilt nur, solange die Action läuft. Sobald sie abgeschlossen ist, fällt der angezeigte State auf den realen `messages`-State zurück. Bei Erfolg ist das der aktualisierte Serverwert, bei Fehler der alte Wert – die optimistisch eingefügte Nachricht verschwindet also von selbst wieder. Man muss nichts manuell zurücksetzen.\n\n## Der Fallstrick: optimistisch ohne Fehlerpfad\n\nDie automatische Konvergenz verführt dazu, den Fehlerfall für erledigt zu halten. Erledigt ist er nicht – hier entstehen die Bugs, die im Test niemand sieht und im Support jeder meldet.\n\nStell dir vor, `sendMessage` schlägt fehl. Der optimistische Wert wird automatisch zurückgenommen – die Nachricht verschwindet aus der Liste. Aus Sicht des Nutzers heißt das: Er hat etwas abgeschickt, es erschien kurz, und dann war es weg. Ohne weitere Rückmeldung. Kein Nutzer interpretiert das als „Fehler beim Senden“; die meisten denken, sie hätten sich verklickt, und probieren es einfach nochmal – im schlimmsten Fall entsteht so ein Duplikat, sobald der Server doch geantwortet hat.\n\nDer Denkfehler ist, `useOptimistic` als Error Handling misszuverstehen. Der Hook kümmert sich um den *sichtbaren Zwischenzustand*, nicht um die *Kommunikation des Ergebnisses*. Die beiden Aufgaben muss man trennen:\n\n- `useOptimistic` sorgt dafür, dass der optimistische Wert bei einem Fehler automatisch zurückgenommen wird – das ist der Rollback des UI States, und den bekommt man geschenkt.\n- Die sichtbare Fehlermeldung ist eine eigene Verantwortung. Sie muss aus dem `state` von `useActionState` kommen: Die Action fängt den Fehler, gibt ihn als `{ error }` zurück, und die UI leitet daraus eine Meldung ab.\n\nBei konkurrierenden Änderungen reicht dieser UI-Rollback nicht. Doppelte Übermittlungen, veraltete Serverstände und Konflikte brauchen weiterhin Idempotenzschlüssel, Versionsprüfung oder eine fachliche Konfliktregel auf dem Server.\n\nIm Code oben passiert genau das: Die Action gibt im Fehlerfall `{ error }` zurück, und `{state.error && <p role=\"alert\">…</p>}` macht ihn sichtbar. Der optimistische Wert verschwindet automatisch, aber der Nutzer sieht *warum*. Wer den Fehler dagegen im `catch` verschluckt oder gar nicht erst zurückgibt, hat ein UI, das bei Fehlern stumm den falschen Eindruck erweckt, es sei nichts passiert. Optimistisches Rendern ohne echten Fehlerpfad ist schlimmer als gar kein optimistisches Rendern.\n\n## Das Zusammenspiel im Bild\n\nWenn man die vier Beteiligten – das Formular, die Action, den Pending State und den optimistischen Wert – zeitlich anordnet, wird das Muster deutlich:\n\n```mermaid\nflowchart LR\n  A[\"form action(fn)<br/>Submit ausgelöst\"] --> B[\"isPending = true<br/>Button disabled<br/>via useFormStatus\"]\n  B --> C[\"addOptimistic()<br/>erwarteter Zustand<br/>sofort sichtbar\"]\n  C --> D[\"await save()\"]\n  D -->|Erfolg| E[\"realer State<br/>state.ok<br/>Formular-Reset\"]\n  D -->|Fehler| F[\"optimistisch revertiert<br/>state.error sichtbar\"]\n```\n\nDer obere Pfad ist der Normalfall, der untere der, den man aktiv gestalten muss. React nimmt einem den Weg von A bis D fast vollständig ab; die bewusste Design-Entscheidung liegt in der Verzweigung bei D – und darin, dass der Fehlerzweig nicht ins Leere läuft.\n\nDas zweite Diagramm ordnet noch einmal die Zuständigkeiten, weil die Verwechslungsgefahr zwischen den dreien real ist:\n\n```mermaid\nflowchart TB\n  subgraph Form[\"React 19 Formular\"]\n    UAS[\"useActionState<br/>Aufruf + Ergebnis + Pending\"]\n    UFS[\"useFormStatus<br/>liest Pending des Eltern-Forms<br/>im Kind\"]\n    UO[\"useOptimistic<br/>sofortiger Zwischenzustand<br/>mit Auto-Konvergenz\"]\n  end\n  UAS -->|reicht isPending<br/>und state| UI[\"Fehler- und Erfolgs-Anzeige\"]\n  UFS -->|pending| Button[\"SubmitButton\"]\n  UO -->|optimisticState| Liste[\"Live-Liste\"]\n```\n\n## Ein Wort zu Server Functions\n\nBisher habe ich von Client Actions gesprochen – Funktionen, die im Browser laufen. Der gleiche Mechanismus trägt aber auch über die Netzwerkgrenze. Mit der Direktive `\"use server\"` am Anfang einer Datei oder einer Funktion markiert man Server Functions, die vom Client aus aufrufbar sind, aber auf dem Server ausgeführt werden:\n\n```js\n\"use server\";\n\nexport async function updateName(previousState, formData) {\n  const name = formData.get(\"name\");\n  await db.users.update({ name });\n  return { ok: true };\n}\n```\n\nDiese Funktion lässt sich exakt wie eine Client Action an `useActionState` oder direkt an `<form action={…}>` übergeben – die Signatur ist dieselbe. Wichtig ist, `\"use server\"` nicht mit `\"use client\"` zu verwechseln; die beiden Direktiven markieren gegensätzliche Ausführungsorte. Und Server Functions setzen ein Framework beziehungsweise einen Bundler voraus, der React Server Components unterstützt; in einer reinen Client-App gibt es sie nicht.\n\nEin hübscher Side Effect in diesem Kontext ist Progressive Enhancement. Übergibt man `useActionState` als drittes Argument ein `permalink` – eine URL –, funktioniert das Formular auch dann, wenn JavaScript noch nicht hydriert oder gar nicht verfügbar ist: Der Browser navigiert klassisch zu dieser URL, der Server verarbeitet die FormData, und sobald React hydriert ist, übernimmt die clientseitige Action nahtlos. Das ist nur im RSC-Kontext relevant, aber es zeigt, dass das Actions-Modell bis in die Server-Kommunikation hinein durchdacht wurde und nicht bloß eine hübschere Schreibweise für `onSubmit` ist. Wer tiefer in dieses Zusammenspiel einsteigen will, findet in meinem Beitrag zu [React Server Components und Server Actions](https://www.mikebild.dev/de/blog/react-server-components-und-server-actions/) den größeren Rahmen.\n\n## Was bleibt hängen\n\nUnterm Strich läuft das auf einen Perspektivwechsel hinaus. Dem Formular gibt man in React 19 eine Funktion; das Hinterherräumen mit Event-Handlern entfällt. Die drei Hooks sind keine drei neuen Dinge zum Auswendiglernen, sondern die drei Verantwortungen, die man vorher selbst miteinander verknüpfen musste, sauber getrennt: `useActionState` für Aufruf, Ergebnis und Pending, `useFormStatus` für den Pending-Blick aus dem Kind, `useOptimistic` für das sofortige Feedback mit automatischem Rollback.\n\nDie zwei Stolperstellen, die erfahrungsgemäß am meisten Zeit kosten: Die Action von `useActionState` bekommt den vorherigen State als erstes Argument, nicht die FormData. Und optimistisches Rendern ist erst dann fertig, wenn der Fehlerfall eine sichtbare Meldung hat – der Hook nimmt dir den Rollback ab, aber nicht die Kommunikation. Mit diesen beiden Punkten im Hinterkopf werden Formulare in React 19 kürzer *und* robuster als alles, was man vorher von Hand gebaut hat.\n\n## Weiterführende Quellen\n\n- [React 19 Release-Blogpost (Actions-Überblick)](https://react.dev/blog/2024/12/05/react-19)\n- [`useActionState` – react.dev](https://react.dev/reference/react/useActionState)\n- [`useFormStatus` – react.dev](https://react.dev/reference/react-dom/hooks/useFormStatus)\n- [`useOptimistic` – react.dev](https://react.dev/reference/react/useOptimistic)\n- [`<form action>` – react.dev](https://react.dev/reference/react-dom/components/form)\n- [`\"use server\"` / Server Functions – react.dev](https://react.dev/reference/rsc/use-server)\n- [`useTransition` (Hintergrund zu Actions/Transitions) – react.dev](https://react.dev/reference/react/useTransition)"},{"slug":"react-server-components-und-server-actions","title":"React Server Components und Server Actions: Die neue Grenze","summary":"Server Components laufen außerhalb des Client-Bundles und liefern ihr Ergebnis statt ihres Codes an den Browser. Server Functions holen Schreibvorgänge auf den Server – umgesetzt wird beides durch ein passendes Framework oder einen Bundler.","tags":["Software","Frontend","React"],"published_at":"2024-12-19T10:00:00+00:00","updated_at":"2026-07-13T08:04:22.356388+00:00","url":"https://www.mikebild.dev/de/blog/react-server-components-und-server-actions/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/46c0932f5f0f771e5e2c5a4765a548a6f0a9b14f9899f6d2bc19b0451972355d/react-server-components-und-server-actions-vorlesen.mp3","audio_duration_secs":928,"text":"# React Server Components und Server Actions: Die neue Grenze\n\n```text\nError: You're importing a component that needs `useState`.\nThis React Hook only works in a Client Component.\nTo fix, mark the file (or its parent) with the \"use client\" directive.\n```\n\nDiese Meldung ist für viele der erste Kontakt mit React Server Components, und der übliche Reflex darauf lautet: `\"use client\"` in die erste Zeile, Fehler weg, weiter im Text. Der Build ist damit wieder grün und die eigentliche Frage übersprungen. Die Meldung ist nämlich kein Konfigurationsfehler. Sie fragt, auf welche Seite einer Grenze dieses Stück Oberfläche gehört – und die Antwort reicht weit über die Datei hinaus, in der die Direktive am Ende steht.\n\nDenn wer eine Architekturgrenze verschiebt, verschiebt immer mehr als Code: auch, wo getestet wird, was deployt werden muss und welches Team für welchen Teil der Oberfläche zuständig ist. Genau das ist der Trade-off, um den es hier eigentlich geht. Die schnellere Auslieferung und das kleinere Bundle sind der sichtbare Gewinn. Die eigentliche Wirkung liegt darunter: kürzere Wege vom Datenzugriff zur Anzeige, weniger Reibung an der Naht zwischen den beiden Welten – erkauft mit einer Grenze, die man von nun an bewusst ziehen und verteidigen muss.\n\n**Redaktionelle Einordnung (Juli 2026):** React 19 hat Server Components als Komponentenmodell stabilisiert. Die Schnittstellen, mit denen Frameworks und Bundler sie umsetzen, folgen innerhalb React 19.x jedoch weiterhin nicht der üblichen Semver-Stabilität. React allein liefert daher noch keine vollständige Produktionsarchitektur dafür; die Beispiele dieses Beitrags verwenden den Next.js App Router. Server Components können außerdem beim Build oder pro Anfrage laufen. Nur die dynamische Variante braucht dafür einen Laufzeitserver.\n\nDaran bleiben viele zunächst hängen: Man sucht nach einem neuen Hook und stößt stattdessen auf eine Grenze, die sich verschoben hat – mit Folgen für Test, Betrieb und Teamzuschnitt. Welcher Teil der Oberfläche auf dem Server bleibt und welcher in den Browser geht, nimmt einem das Framework von jetzt an nicht mehr ab. Darum geht es in diesem Beitrag.\n\n## Was eine Server Component wirklich ist\n\nEine Server Component läuft in einer Umgebung außerhalb des Client-Bundles: beim Build oder auf einem Server pro Anfrage. Dort kann sie mit direktem Zugriff auf Datenbank, Dateisystem oder interne Services rendern und anschließend nur ihr Ergebnis an den Browser schicken. Nicht ihren Code. Das ist der entscheidende Satz. Für eine Komponente, die bloß etwas anzeigt, landet am Ende kein eigener Komponenten-Code im Client-Bundle. Der Browser bekommt eine Beschreibung dessen, was er darstellen soll, und nicht die Komponente, die diese Beschreibung erzeugt hat.\n\nDas ist ein anderer Umgang mit dem Wort „rendern“, als man ihn gewohnt ist. Deshalb lohnt sich der Rückgriff auf einen früheren Beitrag: In [Wie React den Baum rendert](https://www.mikebild.dev/de/blog/wie-react-den-baum-rendert-und-aktualisiert/) habe ich gezeigt, dass Rendern bei React ohnehin nur eine Berechnung ist – eine Funktion, die aus Eingaben eine Beschreibung des Baums macht, die React dann mit dem letzten Stand abgleicht. Wenn Rendern aber nur eine Berechnung ist, dann stellt sich sofort die Frage: Wo läuft diese Berechnung? Genau hier setzen Server Components an. Sie verlagern einen Teil dieser Berechnung dauerhaft auf den Server, statt ihn wie bisher im Browser zu erledigen.\n\n## Der Unterschied zu SSR, den fast alle verwechseln\n\nEin Punkt gehört hier geradegerückt, weil er zuverlässig für Verwirrung sorgt: Server Components sind nicht dasselbe wie Server-Side Rendering, und der Unterschied ist nicht kosmetisch.\n\nKlassisches SSR nimmt ganz normale Client-Komponenten und rendert sie einmal auf dem Server zu HTML, damit der Browser sofort etwas sieht. Danach schickt der Server aber trotzdem den Komponenten-Code hinterher, und im Browser läuft die Hydratisierung: React hängt sich an das gelieferte HTML, spielt dieselbe Komponente noch einmal durch und macht sie interaktiv. Der Code geht also mit – SSR ist ein früher erster Eindruck, kein Sparen von JavaScript. Die Komponente existiert am Ende doppelt: einmal als HTML vom Server, einmal als lauffähiger Code im Browser.\n\nEine Server Component existiert dagegen nie im Client-Bundle. Es gibt nichts zu hydratisieren, weil es im Browser keine zweite Ausführung gibt. Der Server hat gerechnet, das Ergebnis ist da, und damit endet die Geschichte für diesen Teil des Baums. SSR beantwortet die Frage „wann sieht der Nutzer zum ersten Mal etwas“. Server Components beantworten die andere Frage: „muss dieser Code überhaupt jemals in den Browser“. Für reine Anzeige ist die Antwort jetzt nein.\n\n```mermaid\nflowchart TB\n  subgraph Server\n    SC[\"Server Component<br/>liest Daten direkt\"]\n  end\n  subgraph Browser\n    UI[\"fertiges Ergebnis<br/>wird angezeigt\"]\n    Island[\"Client-Insel<br/>(Hooks, Events)\"]\n  end\n  DB[(\"Datenbank / Service\")]\n  SC -->|await| DB\n  SC -->|nur Ergebnis,<br/>kein Code| UI\n  SC -.->|bettet ein| Island\n```\n\n## Die Grenze heißt `\"use client\"`\n\nWenn Server Components der Normalfall sind, braucht man eine Markierung für die Ausnahme – für die Stelle, an der Interaktivität beginnt. Diese Markierung ist die Direktive `\"use client\"` am Anfang einer Datei. Alles oberhalb dieser Grenze läuft auf dem Server: Daten laden, anzeigen, Struktur bauen, kein State. Ab der Direktive ist man im Browser-Land mit allem, was dazugehört – `useState`, `useEffect`, Event-Handler, Zugriff auf `window`.\n\nWichtig ist die Richtung, in der diese beiden Welten zusammenpassen. Eine Server Component darf Client Components als Kinder enthalten – man streut Inseln der Interaktivität in eine ansonsten statische, serverseitig gerenderte Seite. Der umgekehrte Weg ist eingeschränkt: Eine Client Component kann eine Server Component nicht einfach importieren, weil der Server-Code dann ja doch im Browser landen müsste. Sie kann eine Server Component nur als übergebenen `children`-Slot einbetten – der Server rendert das Kind, die Client-Komponente bekommt das fertige Ergebnis gereicht und packt ihr interaktives Drumherum darum. Diese eine Regel erklärt die meisten Fehlermeldungen, über die Leute am Anfang stolpern.\n\n```mermaid\nflowchart TB\n  Page[\"Server Component (Seite)<br/>lädt Daten, zeigt an\"]\n  Page --> List[\"Server Component<br/>Rechnungsliste\"]\n  Page --> Button[\"Client-Insel<br/>&quot;use client&quot;: Button mit onClick\"]\n  Button -->|darf als children| Slot[\"Server-Inhalt<br/>als Slot\"]\n```\n\n## Server Components konkret\n\nWeil eine Server Component nur auf dem Server läuft, darf sie etwas, das im Browser nie ging: Sie darf `async` sein und im Body direkt `await` nutzen. Das Laden von Daten passiert dann ohne `useEffect`, ohne Lade-Flag, ohne den ganzen Apparat – man schreibt es hin, als wäre es eine normale Funktion, die eben auf eine Datenbank zugreifen kann.\n\n```jsx\n// invoices-page.jsx — a Server Component, no \"use client\" here\nimport { db } from \"@/lib/db\";\nimport { PayButton } from \"./pay-button\";\n\nexport default async function InvoicesPage() {\n  const invoices = await db.invoice.findMany({ where: { open: true } });\n\n  return (\n    <section>\n      <h1>Open invoices: {invoices.length}</h1>\n      <ul>\n        {invoices.map((invoice) => (\n          <li key={invoice.id}>\n            {invoice.title} — {invoice.amount} €\n            <PayButton invoiceId={invoice.id} />\n          </li>\n        ))}\n      </ul>\n    </section>\n  );\n}\n```\n\nZwei Dinge stehen hier bewusst nebeneinander. Der `await db.invoice.findMany(...)` greift direkt auf die Datenbank zu – dieser Code verlässt den Server nie, es gibt keine API-Route dazwischen und keinen Ladezustand im Browser. Und `PayButton` ist eine eingebettete Client-Insel: alles, was einen Klick braucht, wandert in eine eigene Datei mit `\"use client\"`.\n\n```jsx\n// pay-button.jsx\n\"use client\";\n\nimport { useState } from \"react\";\n\nexport function PayButton({ invoiceId }) {\n  const [busy, setBusy] = useState(false);\n  return (\n    <button disabled={busy} onClick={() => setBusy(true)}>\n      {busy ? \"Paying…\" : \"Pay\"}\n    </button>\n  );\n}\n```\n\nWas in einer Server Component nicht geht, ist ebenso lehrreich: kein `useState`, kein `useEffect`. In dieser Umgebung gibt es keinen lokalen Client State, den man über Klicks hinweg hält. Eine Server Component kann bei einem späteren Request oder einer erneuten Server-Ausführung wieder rendern; direkte Browser-Interaktion verarbeitet sie aber nicht. Alles, was auf einen Klick unmittelbar im Browser reagieren soll, gehört in eine Client-Insel jenseits der Grenze.\n\n## Server Actions: das Schreiben zurück auf den Server\n\nServer Components lösen das Lesen und Anzeigen. Für das Schreiben gibt es Server Functions, oft Server Actions genannt, markiert mit der Direktive `\"use server\"`. Das sind Funktionen, die auf dem Server laufen, aber über die Integration des Frameworks vom Client aus aufgerufen werden – am direktesten als `action` eines `<form>`. Man baut in der Anwendung keine eigene API-Route und kein eigenes Request-Format für diesen Aufruf. Den Transport, die Serialisierung und die Sicherheitsgrenze übernimmt aber nicht React als Laufzeit allein, sondern das eingesetzte Framework.\n\n```jsx\n// invoice-actions.js\n\"use server\";\n\nimport { db } from \"@/lib/db\";\nimport { revalidatePath } from \"next/cache\";\n\nexport async function payInvoice(formData) {\n  const id = formData.get(\"invoiceId\");\n  await db.invoice.update({ where: { id }, data: { open: false } });\n  revalidatePath(\"/invoices\");\n}\n```\n\nAufgerufen wird das direkt aus dem Formular. Damit der Nutzer während der laufenden Aktion Rückmeldung bekommt, gibt es zwei Hooks, die genau dafür da sind: `useActionState` bündelt den Aufruf mit seinem Ergebnis und einem Pending-Flag, `useFormStatus` liest den Pending State des umgebenden Formulars in einem Kind-Element aus.\n\n```jsx\n// pay-form.jsx\n\"use client\";\n\nimport { useActionState } from \"react\";\nimport { useFormStatus } from \"react-dom\";\nimport { payInvoice } from \"./invoice-actions\";\n\nfunction SubmitButton() {\n  const { pending } = useFormStatus();\n  return (\n    <button type=\"submit\" disabled={pending}>\n      {pending ? \"Paying…\" : \"Pay invoice\"}\n    </button>\n  );\n}\n\nexport function PayForm({ invoiceId }) {\n  const [state, formAction] = useActionState(payInvoice, null);\n  return (\n    <form action={formAction}>\n      <input type=\"hidden\" name=\"invoiceId\" value={invoiceId} />\n      <SubmitButton />\n    </form>\n  );\n}\n```\n\nDer Reiz liegt darin, wie wenig hier steht. Kein `fetch`, kein manuelles `POST`, kein selbstgebauter Endpoint, keine Serialisierung. Man ruft eine Funktion auf, die zufällig auf dem Server läuft, und der Pending State für die Oberfläche fällt nebenbei ab.\n\n```mermaid\nflowchart LR\n  Form[\"Formular im Browser<br/>action = payInvoice\"]\n  Form -->|Absenden| Action[\"Server Action<br/>&quot;use server&quot;\"]\n  Action -->|schreibt| DB[(\"Datenbank\")]\n  Action -->|revalidatePath| Fresh[\"Server rendert<br/>betroffene Ansicht neu\"]\n  Fresh -->|neues Ergebnis| Form\n```\n\n## Was das für die Serie bedeutet\n\nDiese neue Grenze verschiebt eine andere, über die ich schon geschrieben habe. In [Client State vs. Server-Cache](https://www.mikebild.dev/de/blog/client-zustand-und-server-cache/) war der Kernpunkt: Das meiste, was wie State aussieht, ist in Wahrheit geliehener Server-Cache, den man im Browser nur zwischenspeichert. Server Components verschieben diese Grenze noch einmal. Ein großer Teil des Anzeige-Datenladens, für den man bisher eine Cache-Bibliothek im Browser gebraucht hat, wandert jetzt auf den Server – dort liest die Server Component direkt und schickt nur das Ergebnis. Der Bedarf, dieselben Daten im Client nachzuhalten, schrumpft entsprechend.\n\nDas heißt nicht, dass Server-State-Bibliotheken überflüssig werden. Sobald es interaktiv wird – Daten, die sich nach einem Klick ändern, ohne die ganze Seite neu zu laden – bleibt der Cache im Browser sinnvoll. Aber die Aufteilung verschiebt sich: weniger Anzeige im Client, mehr auf dem Server. Für das gestaffelte Ausliefern sorgt dabei [Suspense](https://www.mikebild.dev/de/blog/react-suspense-und-use/), das die serverseitig gerenderten Teile stückweise zum Browser streamt.\n\n## Was die verschobene Grenze mit Test, Betrieb und Team macht\n\nBis hierher klingt das nach einer sauberen technischen Umschichtung. Interessant wird es aber dort, wo diese Grenze auf die Art trifft, wie ein Team arbeitet, testet und ausliefert. Denn eine Architekturgrenze ist nie nur eine Zeile Code. Sie entscheidet, an welcher Stelle die Komplexität anfällt – und Komplexität hat immer eine Rechnung.\n\nFangen wir bei der Testbarkeit an, weil sich dort am deutlichsten zeigt, was die Verschiebung kostet und was sie einspart. Eine reine Anzeige-Komponente, die vorher im Browser Daten geladen hat, war überraschend aufwendig zu testen: `fetch` mocken, Ladezustand durchspielen, Fehlerpfad nachstellen, das erneute Rendern nach dem Eintreffen der Daten abwarten. Als Server Component fällt dieser ganze Apparat weg. Die Komponente ist eine `async`-Funktion, die Daten hereinbekommt und eine Beschreibung zurückgibt – testbar wie eine Funktion, ohne Browser-Umgebung, ohne Timing. Der Preis steht auf der anderen Seite der Grenze: Der direkte `await db.invoice.findMany(...)` im Komponenten-Body verwebt Datenzugriff und Darstellung, und die Komponente will für einen Test jetzt eine Datenbank sehen. Das Testen verlagert sich also, statt zu verschwinden: Server Components prüft man eher über Integrationstests gegen eine echte oder in-memory Datenquelle, die interaktiven Inseln weiterhin als isolierte Client-Tests. Wer diese Zweiteilung bewusst zieht, gewinnt; wer sie ignoriert, baut sich schwer testbare Zwitter.\n\nBeim Deployment verschiebt sich der Charakter der Anwendung, sobald Server Components pro Anfrage laufen oder Server Functions schreiben. Client-only React konnte am Ende ein Bündel statischer Dateien hinter einem CDN sein; reine, beim Build ausgeführte Server Components können ebenfalls statisch ausgeliefert werden. Die dynamische Variante braucht dagegen einen laufenden Prozess mit Zugriff auf Datenbank und interne Dienste. Das bringt Nähe – der Datenzugriff sitzt direkt neben der Komponente, ohne eigene API-Schicht dazwischen – und Betriebsverantwortung: Serverkapazität, Laufzeitfehler jenseits des Browsers und eine Grenze zwischen öffentlichem und internem Code, die zur Sicherheitsgrenze wird. Der Gewinn ist eine kürzere Kette vom Klick bis zur Persistenz. Die Kosten sind eine Betriebsfläche, die vorher nicht da war.\n\nUnd dann ist da der Teamzuschnitt, der Punkt, den man am leichtesten übersieht und am teuersten bezahlt. In der klassischen Aufteilung gab es eine harte Naht: Frontend baut die Oberfläche, Backend liefert die API, dazwischen ein Vertrag aus Endpoints und Formaten. Diese Naht war Reibung, aber auch eine klare Zuständigkeitsgrenze. Server Components und Server Actions lösen sie auf – der Datenzugriff wandert in die Komponente, das Schreiben in eine Funktion daneben, der Vertrag aus API-Routen verschwindet. Für ein Team, das über die ganze Tiefe arbeitet, ist das ein spürbarer Reibungsverlust: kürzere Wege, weniger Abstimmung über Endpoints, weniger Code, der nur Daten hin- und herträgt. Für zwei getrennte Teams mit sauberer Frontend-Backend-Trennung ist es das Gegenteil: Die Grenze, an der sie ihre Verantwortung übergeben haben, ist weg, und die neue verläuft mitten durch die Oberfläche. Ob Server Components ein Gewinn oder ein Konfliktherd sind, hängt damit weniger an der Technik als an der Organisation.\n\nDaraus ergeben sich die Entscheidungskriterien, die ich in Projekten anlege. Server Components zahlen sich aus, wenn das Team über die volle Tiefe arbeitet und die Frontend-Backend-Naht bisher eher Ballast als Schutz war, wenn ein laufender Server ohnehin zum Betrieb gehört, und wenn die Oberfläche datenlastig und wenig interaktiv ist. Sie kosten mehr, als sie bringen, wenn zwei Teams entlang einer bewusst gezogenen API-Grenze liefern, wenn der Betrieb rein statisch bleiben soll, oder wenn die Anwendung fast nur aus interaktiven Inseln besteht. Die Frage ist also nicht „ist die Technik gut“, sondern „passt die verschobene Grenze zu dem, wie wir bauen, testen und ausliefern“.\n\n## Ehrlich eingeordnet: neu und mit Reibung\n\nIch will das nicht schönreden. Server Components sind neu, und sie bringen Reibung mit sich. Man muss die Grenze zwischen Server und Client wirklich verstanden haben, sonst produziert man genau die Fehler, die ich oben beschrieben habe – einen Import über die Grenze, einen Hook auf der falschen Seite, einen Datenzugriff, der plötzlich im Browser landen soll. Das Ökosystem ist noch im Fluss: Die verbreitete Umsetzung ist heute der Next.js App Router, andere Frameworks ziehen nach, und manche Bibliothek muss erst lernen, wo sie in dieser Welt hingehört.\n\nUnd nicht jede Anwendung braucht das. Ein hochinteraktives Dashboard, das ohnehin fast vollständig im Browser läuft, gewinnt wenig, wenn man es durch die Server-Client-Grenze zwängt. Wo sich Server Components dagegen deutlich lohnen: bei datenlastigen, weitgehend statischen Oberflächen, wo viel angezeigt und wenig geklickt wird; wenn das Client-Bundle spürbar drückt und man Anzeige-Code loswerden will, der im Browser nichts zu suchen hat; und dort, wo serverseitig geliefertes HTML für Suchmaschinen und den ersten Eindruck zählt. Das ist eine ehrliche Abwägung, keine Pflicht.\n\n## Die eigentliche Entscheidung\n\nWenn ein Gedanke aus diesem Beitrag hängen bleiben soll, dann dieser: Server Components sind keine schnellere Art zu rendern. Sie sind eine Ortsentscheidung. Bei jedem Teil der Oberfläche fragt man jetzt zuerst: Bleibt das auf dem Server – weil es nur liest und anzeigt – oder gehört es in den Browser, weil ein Mensch damit interagiert? Die Direktiven `\"use client\"` und `\"use server\"` sind nichts anderes als die sichtbaren Markierungen dieser einen Entscheidung.\n\nWer die Grenze bewusst zieht, erntet die Wirkung, die unter der Technik liegt: kürzere Wege vom Datenzugriff zur Anzeige und damit schnellere Zyklen, weniger Reibung an der aufgelösten Frontend-Backend-Naht, und eine Testbarkeit, die sich sauber in serverseitige Integrationstests und Client-Inseln teilt. Wer die Grenze dagegen verwischt, bekommt das Gegenteil – schwer testbare Zwitter, unklare Zuständigkeiten, Betriebslast ohne Gegenwert. Der Unterschied liegt nicht im Framework. Er liegt darin, ob die verschobene Grenze zu dem passt, wie ein Team baut, testet und ausliefert – und ob man sie danach klar genug hält, um sie zu verteidigen. Diese Frage ist das Wesentliche – lange bevor man den ersten Hook nachschlägt.\n\n## Weiterführende Quellen\n\n- Repository introduction-react (Kapitel server-components, server-actions; App-Router-Beispiele): https://github.com/mikebild/introduction-react\n- React, „Server Components“: https://react.dev/reference/rsc/server-components\n- React, „Server Functions“: https://react.dev/reference/rsc/server-functions\n- Next.js, App Router: https://nextjs.org/docs/app"},{"slug":"react-router-data-router","title":"React Router Data Router: Die URL als State-Ort","summary":"Der Data Router von React Router macht die URL zum Koordinationspunkt – eine Route lädt ihre Daten per loader vor dem Rendern und schreibt über action, sodass Ladelogik aus dem Komponenten-Body verschwindet.","tags":["Software","Frontend","React"],"published_at":"2024-12-16T10:00:00+00:00","updated_at":"2026-07-13T13:23:13.585129+00:00","url":"https://www.mikebild.dev/de/blog/react-router-data-router/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/49c000730bd77e327e9e4bb8897ce659a67568eeadcbd65f5f729689a6e7137e/react-router-data-router-vorlesen.mp3","audio_duration_secs":702,"text":"# React Router Data Router: Die URL als State-Ort\n\nEine Route ist im Data Router von React Router keine Zuordnung von Pfad zu Komponente mehr. Sie ist eine vollständige Beschreibung dessen, was zu einer Adresse gehört: welcher Ausschnitt der Oberfläche erscheint, woher seine Daten kommen, was bei einem Schreibvorgang passiert, was im Fehlerfall zu sehen ist. Diese Beschreibungen sind Objekte, und weil sie schon vor dem ersten Rendern vorliegen, kann der Router aus ihnen ablesen, was für eine URL zu tun ist – ohne eine einzige Komponente laufen zu lassen.\n\nWie weit das trägt, zeigt der Vergleich mit dem Muster, das die klassische Variante nahelegt: Eine Route rendert eine Komponente, die Komponente hat einen `useEffect`, der Effect lädt die Daten, und daneben liegen drei `useState` für Ladezustand, Fehler und Ergebnis. Bei einer Seite geht das. Bei zwanzig verschachtelten Seiten entstehen Ladewasserfälle: Die äußere Komponente rendert, ihr Effect lädt, erst danach rendert die innere, ihr Effect lädt – nacheinander, obwohl beide von Anfang an bekannt waren. Im React-Überblick habe ich Routing noch mit `BrowserRouter` und `Routes` gezeigt und die moderne Variante bewusst liegengelassen; sie ist der Gegenstand dieses Beitrags. Der Data Router dreht die Reihenfolge um.\n\n**Redaktionelle Einordnung (Juli 2026):** React Router 8 unterscheidet drei Modi: Declarative, Data und Framework. Dieser Beitrag behandelt den Data Mode mit `createBrowserRouter`. Der Framework Mode baut darauf auf und ergänzt unter anderem typisierte Route-Module, Code-Splitting sowie mögliche SPA-, SSR- und statische Rendering-Strategien.\n\n## Die Kernidee: Die Route lädt, bevor sie rendert\n\nDer entscheidende Unterschied lässt sich in einem Satz sagen. Bei der alten Variante rendert die Komponente zuerst und lädt dann ihre Daten. Beim Data Router lädt die Route zuerst ihre Daten und rendert dann die Komponente – mit den Daten bereits in der Hand. Jede Route bekommt dafür eine Funktion, den `loader`. Er läuft beim Navigieren, noch bevor irgendetwas auf den Bildschirm kommt. Erst wenn er fertig ist, rendert React die Komponente, und die greift die Daten fertig geladen ab, statt sie selbst zu besorgen.\n\nDamit verschwindet die Ladelogik aus dem Komponenten-Body. Kein `useEffect` mehr, der beim ersten Rendern nachlädt, keine Handvoll `useState` für einen State, den es genau genommen nie geben sollte – denn wenn die Komponente rendert, sind die Daten längst da. Das schreibende Gegenstück heißt `action`. Es nimmt Formular-Übermittlungen entgegen, schreibt zum Server, und danach lädt React Router die betroffenen `loader` von selbst neu, damit die View wieder stimmt.\n\n## `createBrowserRouter` und die Route-Objekte\n\nMan beschreibt die Anwendung nicht mehr als verschachtelte JSX-Elemente, sondern als Objekte. `createBrowserRouter` nimmt ein Array von Route-Definitionen entgegen, und jede Definition trägt ihre Bausteine an einer Stelle: den Pfad, die Komponente, den `loader`, die `action`, das Fehler-Element.\n\n```jsx\nimport { createBrowserRouter } from \"react-router\";\nimport { RouterProvider } from \"react-router/dom\";\n\nconst router = createBrowserRouter([\n  {\n    path: \"/invoices\",\n    element: <InvoiceList />,\n    loader: invoiceListLoader,\n    errorElement: <RouteError />,\n    children: [\n      {\n        path: \":invoiceId\",\n        element: <InvoiceDetail />,\n        loader: invoiceDetailLoader,\n        action: invoiceDetailAction,\n      },\n    ],\n  },\n]);\n\nfunction App() {\n  return <RouterProvider router={router} />;\n}\n```\n\nStatt `path` und `element` über die halbe Anwendung verstreut zu suchen, steht alles, was eine Route ausmacht, beisammen: was sie anzeigt, woher ihre Daten kommen, wie sie auf Schreibvorgänge reagiert, was bei einem Fehler passiert. Diese Bündelung ist kein kosmetischer Vorteil. Sie ist die Voraussetzung dafür, dass React Router die Daten schon kennt, bevor gerendert wird – denn der Router kann aus dem Objektbaum ablesen, welche `loader` für eine URL zuständig sind, ohne die Komponenten erst zu rendern.\n\n## Der `loader`: Daten holen, bevor gerendert wird\n\nEin `loader` ist eine gewöhnliche Funktion. Sie bekommt unter anderem die Parameter aus dem Pfad, holt die Daten und gibt sie zurück. In der Komponente greift man sie mit `useLoaderData()` ab.\n\n```jsx\nimport { useLoaderData } from \"react-router\";\n\nasync function invoiceDetailLoader({ params }) {\n  const response = await fetch(`/api/invoices/${params.invoiceId}`);\n  if (!response.ok) {\n    throw new Response(\"Not found\", { status: 404 });\n  }\n  return response.json();\n}\n\nfunction InvoiceDetail() {\n  const invoice = useLoaderData();\n  return <h1>Invoice {invoice.number}</h1>;\n}\n```\n\nMan beachte, was hier nicht steht. Kein `useState` für die Rechnung, kein `useEffect` fürs Laden, keine Prüfung auf `null`, solange noch geladen wird. Wenn `InvoiceDetail` rendert, ist der `loader` abgeschlossen. Ob `invoice` fachlich existiert, muss der Loader selbst absichern – etwa indem er bei einem leeren Ergebnis eine 404-Response wirft. Das Warten des Routers ersetzt diese Validierung nicht.\n\nDer eigentliche Gewinn zeigt sich bei verschachtelten Routen. Öffnet jemand `/invoices/42`, sind zwei Routen betroffen: die Liste und das Detail. Bei der `useEffect`-Variante liefe das nacheinander – erst rendert die Liste und lädt, dann rendert das Detail und lädt. React Router kennt beide `loader` von vornherein und startet sie **parallel**. Damit fällt der Wasserfall weg: Alle betroffenen `loader` laufen gleichzeitig los, und die Seite ist fertig, sobald der langsamste durch ist.\n\n```mermaid\nflowchart TB\n  Nav[\"Navigation zu /invoices/42\"]\n  Nav --> Match[\"Router ermittelt betroffene Routen\"]\n  Match --> L1[\"loader Liste\"]\n  Match --> L2[\"loader Detail\"]\n  L1 --> Ready[\"alle loader fertig\"]\n  L2 --> Ready\n  Ready --> Render[\"Komponenten rendern<br/>mit useLoaderData\"]\n```\n\nHier lohnt der Bezug zum Server-Cache. Im Beitrag zu [Client State und Server-Cache](https://www.mikebild.dev/de/blog/client-zustand-und-server-cache/) habe ich gezeigt, dass Serverdaten kein State sind, den man besitzt, sondern eine Kopie, die man verwaltet – mit Schlüssel, Alter und Invalidierung, wofür sich eine Bibliothek wie TanStack Query anbietet. Der `loader` liegt eine Ebene darüber. Er sagt, *wann* geladen wird, nämlich beim Navigieren, gebunden an die Route statt an die Komponente. Ob dahinter ein nackter `fetch` steht oder ein Cache, ist davon unberührt. Beides ergänzt sich sauber: Der `loader` ruft den `queryClient` auf, und die Bibliothek entscheidet, ob sie eine frische Kopie hält oder wirklich neu lädt. Der Router bestimmt den Zeitpunkt, der Cache die Gültigkeit – sie schließen sich nicht aus, sie greifen ineinander.\n\n## Die `action`: Schreiben über `<Form>`\n\nFür schreibende Vorgänge hält React Router eine eigene Komponente bereit. `<Form method=\"post\">` sieht aus wie ein gewöhnliches HTML-Formular, wird aber vom Router abgefangen: Statt die Seite neu zu laden, ruft er die `action` der passenden Route auf und übergibt ihr die Formulardaten.\n\n```jsx\nimport { Form, redirect } from \"react-router\";\n\nasync function invoiceDetailAction({ request, params }) {\n  const formData = await request.formData();\n  await fetch(`/api/invoices/${params.invoiceId}/pay`, {\n    method: \"POST\",\n    body: formData,\n  });\n  return redirect(`/invoices/${params.invoiceId}`);\n}\n\nfunction InvoiceDetail() {\n  const invoice = useLoaderData();\n  return (\n    <Form method=\"post\">\n      <button type=\"submit\">Mark as paid</button>\n    </Form>\n  );\n}\n```\n\nDas Entscheidende passiert nach der `action`: React Router revalidiert standardmäßig die Loader der aktiven Routen. Die Rechnung wurde bezahlt, die `action` ist durch, und die View kann den frischen Stand zeigen, weil die betroffenen Loader noch einmal gelaufen sind. Mit `shouldRevalidate` lässt sich dieses Verhalten einschränken; externe Caches müssen weiterhin passend invalidiert werden. Der Ablauf ähnelt dem Browserprinzip „Formular abschicken, Seite neu holen“, nur ohne den vollen Dokument-Neuaufbau.\n\n```mermaid\nflowchart LR\n  F[\"Form method=post\"] --> A[\"action schreibt<br/>zum Server\"]\n  A --> R[\"loader werden<br/>revalidiert\"]\n  R --> U[\"Ansicht zeigt<br/>frischen Stand\"]\n```\n\nWer das mit dem `useEffect`-Dashboard aus dem Server-Cache-Beitrag vergleicht, erkennt dieselbe Krankheit und eine andere Medizin. Dort liefen die Zahl oben und die Liste daneben auseinander, weil niemand nach dem Bezahlen dafür sorgte, dass beide neu luden. Hier laufen sie nach der `action` gemeinsam neu, weil die Revalidierung alle `loader` der aktiven Route-Ebenen betrifft und nicht eine einzelne Komponente.\n\n## Verschachtelte Routen und `<Outlet />`\n\nDie Objekte aus dem ersten Beispiel hatten `children`, und das ist mehr als eine Verwaltungsfrage. Verschachtelte Routen bilden die UI-Hierarchie ab: Eine äußere Layout-Route rendert den Rahmen – Navigation, Kopfzeile, Randspalte –, und dort, wo die innere Route erscheinen soll, setzt sie einen `<Outlet />`.\n\n```jsx\nimport { Outlet, NavLink } from \"react-router\";\n\nfunction InvoiceList() {\n  const invoices = useLoaderData();\n  return (\n    <div className=\"layout\">\n      <nav>\n        {invoices.map((invoice) => (\n          <NavLink key={invoice.id} to={`/invoices/${invoice.id}`}>\n            {invoice.number}\n          </NavLink>\n        ))}\n      </nav>\n      <main>\n        <Outlet />\n      </main>\n    </div>\n  );\n}\n```\n\nNavigiert man im Data Mode von `/invoices/42` zu `/invoices/43`, bleibt die äußere Route ohne eigenen geänderten Parameter standardmäßig stehen; ihr Loader läuft in diesem Beispiel nicht erneut, und nur die innere Route wechselt ihre Daten und ihren Inhalt im `<Outlet />`. Geänderte Suchparameter oder eine eigene `shouldRevalidate`-Regel können eine breitere Revalidierung auslösen. Die URL liest sich damit wie ein Pfad durch den Baum der Oberfläche: Jedes Segment steht für eine Ebene, jede Ebene für eine Route, jede Route für ein Stück UI mit eigenen Daten. Eine Layout-Route ganz ohne `path` kann übrigens allein dem gemeinsamen Rahmen dienen, während ihre Kinder die eigentlichen Adressen tragen.\n\n## Die URL als State: Suchparameter\n\nBis hierher ging es um den Pfad. Der zweite Teil der URL ist mindestens so nützlich: die Suchparameter hinter dem `?`. Im React-Überblick habe ich dafür plädiert, dass Auswahl und Filter in die URL gehören – und `useSearchParams` ist das Werkzeug dazu. Es fühlt sich an wie `useState`, schreibt seinen Wert aber in die Adresszeile.\n\n```jsx\nimport { useSearchParams } from \"react-router\";\n\nfunction InvoiceFilter() {\n  const [searchParams, setSearchParams] = useSearchParams();\n  const status = searchParams.get(\"status\") ?? \"all\";\n\n  return (\n    <select\n      value={status}\n      onChange={(event) =>\n        setSearchParams({ status: event.target.value })\n      }\n    >\n      <option value=\"all\">All</option>\n      <option value=\"open\">Open</option>\n      <option value=\"paid\">Paid</option>\n    </select>\n  );\n}\n```\n\nDer gewählte Filter steht jetzt in der URL – `/invoices?status=open` –, und das ändert seinen Charakter grundlegend. Dieser State ist teilbar: Man schickt den Link, und der Empfänger sieht dieselbe gefilterte Liste. Er ist mit einem Lesezeichen speicherbar. Er überlebt einen Reload, weil er nicht im Komponentenspeicher liegt, sondern in der Adresse. Und er lässt sich mit dem `loader` verbinden: Da der `loader` Zugriff auf die `request`-URL hat, kann er den `status`-Parameter auslesen und gleich die passend gefilterte Liste vom Server holen. Der Filter steht in der URL, der `loader` liest ihn, der Server liefert – aus einem Stück UI State wird ein navigierbarer Ort.\n\n## Fehler und langsame Teildaten\n\nWeil der `loader` vor dem Rendern läuft, braucht das Fehlschlagen einen eigenen Platz. Wirft ein `loader` oder eine `action` – etwa die `throw new Response(..., { status: 404 })` von oben –, sucht React Router die nächste Route mit `errorElement` und rendert diese statt der Komponente. Innerhalb des Fehler-Elements kommt man mit `useRouteError()` an das, was geworfen wurde.\n\n```jsx\nimport { useRouteError } from \"react-router\";\n\nfunction RouteError() {\n  const error = useRouteError();\n  return <p role=\"alert\">Something went wrong: {error.status}</p>;\n}\n```\n\nDas ersetzt viele verstreute `if (error)`-Zweige durch eine Stelle auf Route-Ebene. Die Route Error Boundary behandelt nicht nur Fehler aus `loader` und `action`, sondern auch Renderfehler in ihrem Routenast. Erwartbare fachliche Fehler sollte die Anwendung trotzdem bewusst modellieren, statt jeden negativen State als Exception zu behandeln.\n\nEin Randthema sei nur angerissen: Manchmal soll die Seite nicht auf die langsamste Datenquelle warten. React Router 8 hat die frühere `defer`-Hilfsfunktion entfernt. Heute gibt ein Loader nicht kritische Daten als noch nicht aufgelöstes Promise zurück; in der Komponente wartet `<Await>` innerhalb einer `<Suspense>`-Grenze darauf. Kritische Daten werden im Loader weiterhin `await`et. So blockiert ein träger Teil nicht die ganze View, ohne dass eine spezielle `defer`-Response nötig ist.\n\n## Ein Wort zu den drei Modi\n\nWas ich hier gezeigt habe, heißt in der aktuellen Dokumentation Data Mode: `createBrowserRouter` in einer bestehenden Anwendung. Der Declarative Mode mit `BrowserRouter` bietet das klassische Routing ohne die Data APIs. Der Framework Mode setzt auf den Data Mode auf, organisiert Routen als Module und ergänzt Build- und Server-Functions. Das ist ein eigenes, größeres Thema, und ich lasse es hier bewusst stehen. Die Konzepte, die zählen – `loader`, `action`, Revalidierung, die URL als State-Ort –, bleiben im Data und Framework Mode dieselben.\n\n## Wohin die Daten gehören\n\nDer rote Faden dieses Beitrags ist eine einzige Verschiebung. Beim klassischen Ansatz hängen Laden und Schreiben an der Komponente: Sie rendert, ihr Effect holt, ihre Handler schreiben, und bei Verschachtelung reihen sich die Ladevorgänge zu einem Wasserfall. Der Data Router verankert beides an der Route. Der `loader` holt die Daten, bevor gerendert wird, und benachbarte Ebenen parallel; die `action` schreibt und lässt die `loader` danach von selbst revalidieren. Was in der Komponente bleibt, ist das Anzeigen – nicht das Besorgen.\n\nDamit wird aus der URL endgültig das, was sie im Überblick nur behauptet hat zu sein. Sie ist nicht mehr das Adressfeld, das anzeigt, wo man ist. Sie ist der Ort, an dem der navigierbare State wirklich liegt: Der Pfad bestimmt, welche Routen und damit welche Daten geladen werden, die Suchparameter tragen Filter und Auswahl, und beides zusammen ist teilbar, speicherbar und übersteht jeden Reload. Wer einmal so baut, gibt die Ladelogik nicht ungern aus dem Komponenten-Body her.\n\n## Weiterführende Quellen\n\n- Repository introduction-react (Routing-Kapitel, Ordner recipes zum Data Fetching): https://github.com/mikebild/introduction-react\n- React Router, offizielle Dokumentation: https://reactrouter.com/\n- React Router, „Picking a Mode“: https://reactrouter.com/start/modes\n- React Router, „Data Loading“: https://reactrouter.com/start/data/data-loading\n- React Router, „Actions“: https://reactrouter.com/start/data/actions"},{"slug":"react-transitions-mit-usetransition","title":"React Transitions: nicht dringende Updates mit useTransition","summary":"useTransition markiert Updates als nicht dringend und hält React während teurer oder suspendierender Renderings responsiv. Dazu useDeferredValue als Werkzeug für nachgelagerte Renderings.","tags":["Software","Frontend","React"],"published_at":"2024-12-13T10:00:00+00:00","updated_at":"2026-07-13T13:31:04.873406+00:00","url":"https://www.mikebild.dev/de/blog/react-transitions-mit-usetransition/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/b522d82a29a1a6d4b549ab64428a21667c7c89e5215a955db3ea1a09ed4898e1/react-transitions-mit-usetransition-vorlesen.mp3","audio_duration_secs":662,"text":"Zwei Prioritäten braucht eine React-Oberfläche: eine für das, was sofort passieren muss, und eine für das, was warten darf. Reacts Scheduler kennt unterschiedliche Prioritäten, doch ein gewöhnliches State-Update wird zunächst als direkte Aktualisierung behandelt. Erst mit einer Transition markiert die Anwendung die nachfolgende Renderarbeit ausdrücklich als nicht blockierend.\n\nAm deutlichsten wird das an einer Stelle, um die es im [Beitrag zu Suspense und dem use-Hook](https://www.mikebild.dev/de/blog/react-suspense-und-use/) schon ging: dem harten Zurückspringen in den Fallback. Der Bereich zeigt gerade noch echten Inhalt, der State ändert sich, die Daten sind weg – und der Spinner erscheint. Genau so kam in einem Projekt die Rückfrage eines Kollegen: „Der Content steckt doch in einer Suspense-Grenze. Wenn sich der State ändert, greift der Fallback. Was sollen wir dagegen machen?“ Man sagt React, dass dieses eine Update nicht dringend ist. Dafür gibt es `useTransition`.\n\n## Dringend oder nicht dringend – die einzige Unterscheidung, die zählt\n\nDie Trennlinie verläuft zwischen zwei Sorten von Updates. Auf der einen Seite die dringenden: Ich tippe einen Buchstaben, das Zeichen muss sofort im Eingabefeld stehen. Ich hake eine Checkbox an, sie muss sofort angehakt sein. Alles, was sich wie eine direkte physische Reaktion anfühlen soll, ist dringend. Verzögert React das auch nur um 100 Millisekunden, fühlt sich die App kaputt an.\n\nAuf der anderen Seite die nicht-dringenden Updates: Ich wechsle den Tab, ich ändere den Filter einer großen Liste, ich stelle die Suchanfrage um. Das Ergebnis dieser Aktion darf einen Moment brauchen. Niemand erwartet, dass 10.000 neu gefilterte Zeilen in null Millisekunden erscheinen. Was aber jeder erwartet: dass die Oberfläche in der Zwischenzeit nicht einfriert und nicht sinnlos flackert.\n\nWelche fachliche Reaktion warten darf, kann React nicht aus dem Namen eines State-Werts ableiten. Mit den Concurrent Features, die seit React 18 und dem Wechsel auf `createRoot` stabil verfügbar sind, bekommen wir die Werkzeuge, um diese Absicht mitzuteilen. `useTransition` ist das wichtigste davon.\n\n## Die Signatur ist bewusst schlicht\n\n```jsx\nimport { useTransition } from 'react';\n\nfunction TabContainer() {\n  const [isPending, startTransition] = useTransition();\n  const [tab, setTab] = useState('overview');\n\n  function selectTab(nextTab) {\n    startTransition(() => {\n      setTab(nextTab);\n    });\n  }\n\n  return (\n    <>\n      <TabButton active={tab === 'overview'} onClick={() => selectTab('overview')}>\n        Overview\n      </TabButton>\n      <TabButton active={tab === 'reports'} onClick={() => selectTab('reports')}>\n        Reports\n      </TabButton>\n      <section style={{ opacity: isPending ? 0.6 : 1 }}>\n        <TabPanel tab={tab} />\n      </section>\n    </>\n  );\n}\n```\n\nDer Hook nimmt keine Parameter und gibt ein Zwei-Tupel zurück: `isPending`, ein Boolean, und `startTransition`, eine Funktion. Aufrufen darf man ihn wie jeden Hook nur in einer Komponente oder einem Custom Hook.\n\nDer entscheidende Teil steckt in `selectTab`. Der `setTab`-Aufruf wandert in den Callback von `startTransition`. Damit sagt die Komponente React: „Dieses Update ist eine Transition.“ Wenn das neue Panel dabei innerhalb einer bereits sichtbaren Suspense-Grenze suspendiert, kann React den bisherigen Inhalt stehen lassen, statt ihn sofort durch den Fallback zu ersetzen. Neu hinzukommende Suspense-Grenzen dürfen ihren Fallback dagegen weiterhin zeigen. Solange der Übergang läuft, ist `isPending` auf `true`. Genau daran hängt in meinem Beispiel die dezente Ausgrauung über `opacity`.\n\nDas ist die konkrete Einlösung des Suspense-Versprechens: Ein Update innerhalb von `startTransition` verhindert, dass bereits sichtbarer Content in den Fallback zurückspringt.\n\n## Was im Hintergrund wirklich passiert\n\n„React macht das schon“ reicht als Erklärung nicht, deshalb der Ablauf explizit als Diagramm. Wichtig ist die Verzweigung am Ende: Eine Transition ist unterbrechbar.\n\n```mermaid\nflowchart TD\n    A[Nutzer klickt Tab] --> B[setTab läuft in<br/>startTransition]\n    B --> C[React rendert neuen Tab<br/>im Hintergrund]\n    C --> D[Alte UI bleibt sichtbar<br/>isPending = true]\n    D --> E{Neue Daten /<br/>Render fertig?}\n    E -->|ja| F[Wechsel zur neuen UI<br/>isPending = false]\n    E -->|dringendes Update<br/>kommt rein| G[Hintergrund-Render wird<br/>verworfen und neu gestartet]\n    G --> C\n```\n\nDer linke Pfad ist der Normalfall: React rendert im Hintergrund, hält die alte Oberfläche, zeigt `isPending` und wechselt am Ende. Der rechte Pfad ist der Grund, warum die App bei teuren Updates überhaupt responsiv bleibt. Kommt während des Hintergrund-Renderings ein dringenderes Update herein – etwa eine Tastatureingabe – dann verwirft React die halbfertige Arbeit und beginnt neu. Man muss nicht warten, bis das teure Rendering durch ist, um wieder tippen zu können. Diese Unterbrechbarkeit ist der eigentliche Trick hinter dem Wort „concurrent“.\n\n## startTransition ohne Komponente\n\nNicht jeder State-Wechsel entsteht in einer Komponente. In Routern und Stores sitzt die auslösende Logik oft außerhalb des Komponentenbaums. Für diesen Fall gibt es den Standalone-Import:\n\n```jsx\nimport { startTransition } from 'react';\n\nfunction navigate(url) {\n  startTransition(() => {\n    setCurrentUrl(url);\n  });\n}\n```\n\nDiese Variante funktioniert überall, hat aber einen Preis: Sie liefert kein `isPending`. Wer den Ladezustand anzeigen will, braucht den Hook. Wer nur ein Update als nicht-dringend markieren will und den Pending-State an anderer Stelle beschafft, ist mit dem Standalone-Import gut bedient. Ich verwende ihn bewusst in Router-Callbacks, wo die Navigationsanzeige ohnehin woanders sitzt.\n\nSeit React 19, stabil seit Dezember 2024, akzeptiert `startTransition` übrigens auch async-Functions; darauf bauen die neuen Actions und `<form action>` intern auf. Das ist ein eigenes Thema – hier zählt nur: Der Transition-Mechanismus selbst ist die Grundlage darunter.\n\n## Der Klassiker unter den Fehlern: das Eingabefeld einfrieren\n\nIn Code-Reviews taucht an dieser Stelle immer wieder derselbe naheliegende Fehler auf. Wenn eine Transition teure Renderings entschärft, warum wickelt man dann nicht einfach jedes Update ein – auch das des Suchfelds?\n\n```jsx\n// Do NOT do this\nfunction onChange(e) {\n  startTransition(() => {\n    setText(e.target.value);\n  });\n}\n```\n\nDas Ergebnis ist ein träges, schwammiges Eingabefeld. Der Grund: Text im Input ist ein dringendes Update. Es fühlt sich direkt physisch an, jeder Buchstabe muss sofort stehen. Packt man es in eine Transition, darf React es verzögern und unterbrechen – und tut das auch. Controlled Inputs bleiben also dringend, der Text-State wird ganz normal gesetzt. Was teuer ist, ist nicht das Tippen, sondern das, was aus dem Text folgt – die gefilterte Liste. Und für die gibt es ein eigenes Werkzeug.\n\n## useDeferredValue: wenn ein Teil der Anzeige nachlaufen darf\n\n`useTransition` markiert State-Updates an ihrer auslösenden Stelle. `useDeferredValue` setzt anders an: Der aktuelle Wert bleibt dringend und sichtbar, während ein teurer Verbraucher vorübergehend mit der vorherigen Version weiterarbeiten darf. Das ist bei Props nützlich, aber ebenso bei lokalem Input-State, dessen abgeleitete Liste langsamer nachlaufen soll.\n\n```jsx\nimport { useDeferredValue, useState } from 'react';\n\nfunction SearchPage() {\n  const [query, setQuery] = useState('');\n  const deferredQuery = useDeferredValue(query);\n  const isStale = query !== deferredQuery;\n\n  return (\n    <>\n      <input value={query} onChange={(e) => setQuery(e.target.value)} />\n      <div style={{ opacity: isStale ? 0.5 : 1 }}>\n        <SlowList query={deferredQuery} />\n      </div>\n    </>\n  );\n}\n```\n\nHier bleibt das Eingabefeld dringend: `setQuery` läuft ohne jede Transition, jeder Buchstabe erscheint sofort. Die teure `SlowList` bekommt aber nicht `query`, sondern `deferredQuery`. React rendert zuerst mit dem alten Wert weiter – die Liste bleibt kurz stehen – und rendert dann im Hintergrund mit dem neuen Wert nach. Über den Vergleich `query !== deferredQuery` weiß ich, ob die Liste gerade veraltet ist, und graue sie in der Zwischenzeit dezent aus.\n\nDie Signatur nimmt seit React 19 ein optionales zweites Argument: `useDeferredValue(value, initialValue)`. Ohne `initialValue` wird beim allerersten Render nicht deferred; gibt man es an, rendert der erste Durchlauf mit diesem Startwert und schiebt den echten Wert in den Hintergrund. Für den Alltag reicht die einargumentige Form fast immer.\n\nEin Punkt, der immer wieder zu Missverständnissen führt: `useDeferredValue` ist kein Debounce und kein Throttle. Es gibt keinen festen Delay, und es verhindert keine Netzwerk-Requests. Es ist reine Render-Priorisierung. Wer bei jedem Tastendruck einen Fetch auslöst, hat mit `useDeferredValue` denselben Fetch-Sturm wie vorher – nur das Rendering der Ergebnisse ist entzerrt. Debouncing des Requests bleibt eine getrennte Aufgabe.\n\n## Der zweite Klassiker: der verlorene Kontext nach await\n\nSobald async-Functions ins Spiel kommen, tappt fast jeder in dieselbe Falle:\n\n```jsx\n// The transition context is lost after the await\nstartTransition(async () => {\n  const data = await fetchPage(url);\n  setPage(data); // no longer part of the transition\n});\n```\n\nDas `setPage` nach dem `await` läuft nicht mehr innerhalb der Transition. React verfolgt den Transition-Kontext synchron; nach einem `await` ist der ursprüngliche Aufruf längst zurückgekehrt. Der Fix ist unspektakulär: das Update nach dem `await` erneut einwickeln.\n\n```jsx\nstartTransition(async () => {\n  const data = await fetchPage(url);\n  startTransition(() => {\n    setPage(data);\n  });\n});\n```\n\nVerwandt dazu ein weiterer Stolperstein: Ein `set`-Aufruf, der aus einem `setTimeout` heraus passiert, wird ebenfalls nicht als Transition markiert, selbst wenn der `setTimeout` innerhalb von `startTransition` steht. Der Callback läuft erst später, außerhalb des synchronen Fensters. `startTransition` erwartet ein synchrones Update; alles, was in einen späteren Tick verschoben wird, fällt aus dem Transition-Kontext heraus.\n\n## Wann welches Werkzeug – der Entscheidungsbaum\n\nOb `useTransition` oder `useDeferredValue` greift, hängt daran, was verzögert werden darf: der State-Übergang selbst oder nur ein nachgelagerter Teil der Anzeige.\n\n```mermaid\nflowchart TD\n    A[Teures Update soll<br/>Interaktion nicht blockieren] --> B{Darf der Übergang<br/>selbst warten?}\n    B -->|ja| C[useTransition /<br/>startTransition]\n    B -->|nein, nur Verbraucher| D[useDeferredValue]\n    C --> E[Update wird markiert<br/>isPending verfügbar]\n    D --> F[Aktueller Wert bleibt direkt<br/>Verbraucher läuft nach]\n    E --> G[Beide: alte UI bleibt stehen<br/>statt Fallback]\n    F --> G\n```\n\nDie Trennlinie ist also nicht „welches ist moderner“, sondern schlicht die Frage der Kontrolle. Zur Verdichtung die beiden Werkzeuge nebeneinander:\n\n- `useTransition` / `startTransition`: Ich steuere ein Update, dessen sichtbares Ergebnis warten darf. Ich will das harte Zurückspringen in den Fallback vermeiden und brauche mit dem Hook zusätzlich `isPending`. Typisch: Tab-Wechsel oder eine nicht dringende Navigation.\n- `useDeferredValue`: Der aktuelle Wert muss sofort gelten, aber ein teurer Verbraucher darf noch kurz die vorige Version sehen. Ein Pending-Flag gibt es nicht, wohl aber den Vergleich `value !== deferredValue`, um veraltete Anzeigen auszugrauen. Typisch: die große abgeleitete Liste unter einem schnellen Eingabefeld.\n\nGemeinsam ist beiden, dass weniger dringende Renderarbeit hinter einer direkten Interaktion zurückstehen darf. Bei einer bereits sichtbaren Suspense-Grenze kann dadurch der vorhandene Inhalt stehen bleiben. Der Unterschied liegt darin, ob das auslösende Update oder ein Wert für nachgelagerte Verbraucher verzögert wird; `isPending` liefert nur `useTransition`.\n\n## Was ich in der Praxis empfehle\n\nEin paar Leitplanken, die sich in Projekten bewährt haben:\n\n- State, der ein kontrolliertes Textfeld steuert, bleibt dringend und darf nicht in eine Transition. Bei anderen direkten Eingaben prüfe ich, welcher Teil sofort reagieren muss und welche Folgearbeit warten darf.\n- Navigations- und Tab-Wechsel können in `startTransition` gehören, wenn ihr Ziel suspendiert oder teuer rendert. Router können diese Koordination bereits selbst übernehmen.\n- Für teure abgeleitete Renderings unter einem schnellen Input greift `useDeferredValue`, weil der aktuelle Eingabewert sofort sichtbar bleiben soll und nur die Liste nachläuft.\n- `isPending` und `value !== deferredValue` zeigen an, dass etwas gerade veraltet ist. Ich nutze sie für dezente Ausgrauung statt für einen ganzseitigen Spinner. Der Sinn der Übung ist ja, dass der Inhalt eben nicht verschwindet.\n- Der Kontextverlust nach `await` und in `setTimeout` ist tückisch. Wenn ein Update partout dringend wirkt, obwohl es in einer Transition steht, ist das fast immer die Ursache.\n\nWer die Concurrent Features grundsätzlich einordnen möchte, findet den größeren Zusammenhang in meinem React-Überblick von JSX zur Architektur. Der springende Punkt für heute bleibt eng: React kann nicht wissen, welche fachliche Reaktion sofort sichtbar sein muss und welche Renderarbeit warten darf. `useTransition` und `useDeferredValue` drücken diese Absicht an unterschiedlichen Stellen aus. Bei teuren Renderings kann die Oberfläche dadurch responsiv bleiben; bei bereits sichtbarem Suspense-Inhalt lässt sich ein unnötiges Zurückspringen in den Fallback vermeiden.\n\n## Weiterführende Quellen\n\n- Repository: die Beispiele zu Tab-Wechsel mit `useTransition`, dem `useDeferredValue`-Suchfeld und dem Standalone-`startTransition` liegen als lauffähiges Repo bereit.\n- [react.dev – useTransition](https://react.dev/reference/react/useTransition)\n- [react.dev – startTransition](https://react.dev/reference/react/startTransition)\n- [react.dev – useDeferredValue](https://react.dev/reference/react/useDeferredValue)\n- [react.dev – Suspense](https://react.dev/reference/react/Suspense)\n- [React 19 Release (Actions und Transition-Kontext)](https://react.dev/blog/2024/12/05/react-19)"},{"slug":"react-testing-ohne-theater","title":"React Testing ohne Theater","summary":"Tests auf sichtbares Nutzerverhalten koppeln weniger an DOM-Struktur, Komponentengrenzen und internen State. Dadurch überstehen sie bestimmte Refactorings ohne Anpassung.","tags":["Software","Testing"],"published_at":"2024-12-12T10:00:00+00:00","updated_at":"2026-07-13T08:56:25.034215+00:00","url":"https://www.mikebild.dev/de/blog/react-testing-ohne-theater/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/1a3a617091034ccd5bb43d31cf0eecb3529bbe6e594fa5e67a939835a3980acf/react-testing-ohne-theater-vorlesen.mp3","audio_duration_secs":632,"text":"# React Testing ohne Theater\n\nEin grüner Test beweist nicht, dass die Anwendung funktioniert. Er beweist zunächst nur, dass der Code noch so aussieht wie an dem Tag, an dem der Test geschrieben wurde. Genau diesen Unterschied verwischen React-Suites gern: Jede Komponente ist von Mocks umstellt, jede Zusicherung kennt den Hook, den State, den Namen der internen Funktion. Solche Tests werden beim ersten Refactoring reihenweise rot, obwohl die Anwendung fachlich unverändert funktioniert – teuer im Unterhalt und ohne Aussage über das Produkt. Das ist das Theater im Titel: viel Bühne, wenig Inhalt.\n\nDer Anlass für diesen Beitrag ist banal: eine kleine Vite-React-App aus dem Repo `introduction-react`, ein einziger Test, eine sichtbare Zeile im UI. Interessant ist nicht die Zeile, sondern die Grenze, die sie zieht – zwischen dem, was ein Nutzer erlebt, und dem, wie ich es intern gebaut habe. Diese Grenze ist die eigentliche Architekturentscheidung beim Testen. Wer sie sauber zieht, bekommt Tests, die Umbauten überleben. Wer sie verfehlt, bekommt eine Suite, die bei jedem Sprint mitgepflegt werden muss und trotzdem keine Regression fängt.\n\n## Die These: Verhalten schlägt Implementierung\n\nEin Test hat genau zwei Aufgaben. Er soll ein reales Fehlverhalten fangen, bevor es beim Nutzer ankommt. Und er soll mich nicht daran hindern, den Code umzubauen. Diese beiden Ziele stehen in Spannung, und die Auflösung heißt: teste, was der Nutzer beobachtet, nicht, wie die Komponente es intern erreicht.\n\nDer Unterschied wird an zwei Varianten desselben Tests sichtbar. Zuerst die brüchige Variante, die an die Innerei koppelt:\n\n```tsx\n// Brittle: couples the test to internal state and structure\ntest(\"increments the count state\", () => {\n  const { result } = renderHook(() => useCounter());\n\n  act(() => result.current.increment());\n\n  expect(result.current.count).toBe(1);\n});\n```\n\nDieser Test kennt den Hook, seinen Rückgabewert und dass „increment“ so heißt. Sobald ich `useCounter` durch einen Reducer, einen Store oder eine ganz andere Mechanik ersetze, bricht er – obwohl der Zähler im Browser weiter tut, was er soll. Der Test misst meine Implementierung, nicht das Produkt.\n\nJetzt dieselbe Zusicherung über das beobachtbare Verhalten:\n\n```tsx\n// Robust: asserts what the user actually sees\ntest(\"shows the new count after a click\", async () => {\n  const user = userEvent.setup();\n  render(<Counter />);\n\n  await user.click(screen.getByRole(\"button\", { name: /increment/i }));\n\n  expect(screen.getByRole(\"status\")).toHaveTextContent(\"Count: 1\");\n});\n```\n\nDieser Test weiß nichts über Hooks, State oder Struktur. Er weiß, dass es einen Button gibt, den man klickt, und dass danach eine Zahl steht. Genau das ist der Vertrag mit dem Nutzer. Ich kann die gesamte Innerei austauschen, und solange dieser Vertrag hält, bleibt der Test grün. Das ist der Punkt, an dem ein Test von einer Umbau-Bremse zu einem Sicherheitsnetz wird.\n\n## Wo die Grenze physisch verläuft\n\nDiese Grenze ist keine Haltung, sie ist im Query-Design der Testing Library eingebaut. Die Reihenfolge der Queries ist eine Trade-off-Ansage: `getByRole` und `getByLabelText` zuerst, `getByText` danach, `getByTestId` als letzte Rettung. Das ist kein Stilgeschmack. Eine Rollen-Query prüft nebenbei die Accessibility – wenn der Test das Element über seine Rolle findet, findet ein Screenreader es auch. Eine `data-testid` prüft nur, dass ich mir selbst ein Etikett geklebt habe.\n\n```tsx\n// Prefer accessible queries – they double as an a11y assertion\nscreen.getByRole(\"searchbox\");\nscreen.getByRole(\"button\", { name: /search/i });\n\n// testid is an escape hatch, not the default\nscreen.getByTestId(\"product-list\");\n```\n\nDie praktische Konsequenz: Ich schreibe Komponenten so, dass sie über Rollen und Labels ansprechbar sind – semantische Elemente, `aria-label` wo nötig, echte `<button>` statt geklickter `<div>`. Der Test zwingt mich zu barrierefreiem Markup, weil unzugängliches Markup schlicht schwer testbar ist. Das ist die Art von Kopplung, die ich will: Testbarkeit und Produktqualität ziehen in dieselbe Richtung.\n\nDie `testid` ist damit nicht verboten, sie ist nur begründungspflichtig. Legitim ist sie, wenn ein Container keine sinnvolle Rolle trägt – ein Layout-Wrapper, ein Chart-Canvas, eine Liste ohne semantisches Pendant. Dann ist ein bewusst gesetztes `data-testid` ehrlicher als eine erzwungene Rolle, die es im Produkt gar nicht gibt. Der Unterschied ist die Reihenfolge: Ich greife zur `testid`, weil die zugängliche Query nicht passt, nicht weil sie mir zu umständlich war. Diese Reihenfolge ist der ganze methodische Gehalt der Query-Prioritäten – sie macht die Ausnahme sichtbar, statt sie zur Regel werden zu lassen.\n\n## Der Fehlerpfad ist die halbe Arbeit\n\nDer glückliche Pfad sieht im Demo-Modus sauber aus. Geld verdient man mit dem Rest: leerer Antwort, Timeout, 500er, doppeltem Klick, langsamer Verbindung. Bei React-Anwendungen liegt dieser Rest fast immer an der Netzgrenze, und genau dort setze ich den Mock an – nicht am Hook, nicht am `fetch`-Aufruf, sondern an der HTTP-Schicht. Mit Mock Service Worker läuft die Komponente durch ihre echte Datenlogik, nur die Antwort ist kontrolliert.\n\n```tsx\n// The happy path: results the API returned actually render\ntest(\"renders products returned by the API\", async () => {\n  const user = userEvent.setup();\n  render(<ProductSearch />);\n\n  await user.type(screen.getByRole(\"searchbox\"), \"react\");\n  await user.click(screen.getByRole(\"button\", { name: /search/i }));\n\n  expect(await screen.findByRole(\"listitem\"))\n    .toHaveTextContent(\"React Handbook\");\n});\n```\n\nUnd die Kehrseite, die im Betrieb den Unterschied macht:\n\n```tsx\n// The failure path: does the UI degrade gracefully?\ntest(\"shows an error message when the request fails\", async () => {\n  server.use(\n    http.get(\"/api/products\", () => HttpResponse.error()),\n  );\n\n  const user = userEvent.setup();\n  render(<ProductSearch />);\n\n  await user.click(screen.getByRole(\"button\", { name: /search/i }));\n\n  expect(await screen.findByRole(\"alert\"))\n    .toHaveTextContent(/something went wrong/i);\n});\n```\n\nDer zweite Test ist wichtiger als der erste. Er beschreibt, was passiert, wenn das Backend wegbricht – ob die App eine Fehlermeldung zeigt oder in einen leeren, verwirrenden Zustand fällt. Dass ich den Mock über `server.use` pro Test setze, ist Absicht: Der Default ist der glückliche Pfad, die Störung ist eine explizite, lesbare Abweichung. Wer den Fehlerpfad nicht testet, hat die Hälfte des Produkts nicht getestet. Der Nutzer erlebt beide.\n\nZum Fehlerpfad gehört auch die async-Disziplin. `findBy*` wartet auf das Erscheinen eines Elements, `waitFor` auf das Eintreten einer Bedingung – aber nur auf beobachtbare Zustände, nicht auf `setTimeout`-Raten. Wer stattdessen feste Wartezeiten einbaut, tauscht Flakiness gegen Langsamkeit und behält am Ende beides.\n\n## Specification by Example: Tests, die die Fachsprache sprechen\n\nDer stabilste Test benennt eine fachliche Absicht, keinen technischen Ablauf. Ein Testname wie „calls dispatch with INCREMENT“ beschreibt Mechanik und altert mit jeder Umbenennung. „shows the new count after a click“ beschreibt ein Versprechen an den Nutzer und altert nur, wenn sich das Versprechen ändert. Das ist der Kern von Specification by Example: Das Beispiel ist gleichzeitig Fachdokumentation, Erwartung und automatisierbare Prüfung – drei Dinge, die sonst auseinanderdriften.\n\nPraktisch heißt das, ich schreibe die `describe`- und `test`-Blöcke so, dass ein Product Owner sie lesen und bestätigen könnte, ohne den Code zu kennen:\n\n```tsx\ndescribe(\"Product search\", () => {\n  test(\"shows results matching the query\", async () => { /* ... */ });\n  test(\"shows an empty state when nothing matches\", async () => { /* ... */ });\n  test(\"shows an error when the service is unavailable\", async () => { /* ... */ });\n});\n```\n\nDiese Suite ist eine Spezifikation. Sie sagt, was das Feature kann und was es bei Störung tut. Ein Reviewer sieht sofort, ob ein Fall fehlt – der leere Zustand, der Fehlerfall – und kann fachlich widersprechen, ohne die Implementierung zu lesen. Genau darin liegt der methodische Hebel: Missverständnisse werden im Test sichtbar, nicht erst im Betrieb. Das verschiebt die teure Klärung vom Ende der Kette an den Anfang.\n\n## Die Architektur der Suite: Schichten statt Masse\n\nNicht jeder Test kostet gleich viel und bringt gleich viel. Eine tragfähige React-Suite ist geschichtet. Reine Logik – Formatierung, Validierung, Reducer – prüfe ich als schnelle Unit-Tests ohne DOM. Das Verhalten der Komponenten prüfe ich als Component-Tests mit gemockter Netzgrenze; das ist die Schicht, in die ich das meiste Vertrauen und die meiste Zeit investiere. Und ich halte eine kleine Zahl echter Integrationsläufe, die das reale Zusammenspiel der Teile einmal durchspielen, statt es zu simulieren.\n\n```mermaid\nflowchart TD\n  A[User intent<br/>Specification by Example] --> B[Component tests<br/>role and label queries]\n  B --> C[Network boundary<br/>mocked with MSW]\n  C --> D[Few integration runs<br/>real wiring]\n  D --> E[Refactor<br/>tests stay green]\n  E --> A\n```\n\nDie Verteilung ist eine bewusste Investitionsentscheidung, kein Dogma. Viele langsame End-to-End-Tests geben ein warmes Gefühl und eine kalte Pipeline: Sie sind langsam, flaky und teuer im Unterhalt, und wenn sie rot werden, weiß niemand sofort warum. Wenige, gezielte Integrationsläufe plus eine dichte, schnelle Component-Schicht fangen mehr echte Fehler pro investierter Minute. Der Testlauf gehört in die schnelle Feedbackschleife des Entwicklers, nicht nur in die nächtliche CI – deshalb Vitest und jsdom statt echtem Browser, wo immer die Aussage das hergibt.\n\n## Warum sich das rechnet\n\nTesten ist kein Selbstzweck und keine Gewissensberuhigung, es ist eine Investition mit messbarer Rendite. Der Ertrag ist Änderungsgeschwindigkeit. Eine Suite, die an Verhalten koppelt, erlaubt Refactoring ohne Angst: Ich baue `useCounter` zu einem Store um, die Tests bleiben grün, ich weiß, dass der Vertrag hält. Eine Suite, die an Implementierung koppelt, macht jedes Refactoring zu einem zweiten Projekt – erst Code umbauen, dann Dutzende Tests nachziehen. Die zweite Sorte bringt Teams dazu, Umbauten aufzuschieben. So entsteht technische Schuld nicht trotz, sondern wegen der Tests.\n\nDer konkrete Vorteil liegt in der geringeren Kopplung. Wenn ein Test über Rolle, Beschriftung und sichtbares Ergebnis zugreift, darf ich Komponenten aufteilen, internen State verschieben oder das Markup umordnen, solange das beobachtbare Verhalten gleich bleibt. Ob Reviews kürzer werden oder die Regressionsrate sinkt, hängt dagegen von Suite, Laufzeit und den tatsächlich geprüften Fehlerpfaden ab.\n\n## Typische Fehler\n\nDer erste Fehler ist, das Werkzeug mit der Lösung zu verwechseln. Testing Library erzeugt keine guten Tests, sie macht schlechte nur schwerer. Man kann auch mit ihr an `testid`s kleben, den State über Umwege inspizieren und die Netzgrenze am falschen Punkt kappen. Das Tool drückt eine Grenze aus, es zieht sie nicht.\n\nDer zweite Fehler ist, Coverage mit Sicherheit zu verwechseln. Neunzig Prozent Zeilenabdeckung sagen nichts darüber, ob der Fehlerpfad geprüft ist. Eine Suite, die jeden glücklichen Pfad abdeckt und keinen Störfall, hat hohe Zahlen und niedrigen Wert. Ich zähle lieber Fehlerfälle als Zeilen.\n\nDer dritte Fehler ist, alles zu mocken. Wer den Hook mockt, den Store mockt und die Kindkomponente mockt, testet am Ende nur noch die Mocks. Je tiefer der Mock sitzt, desto weniger echtes Verhalten läuft durch den Test. Die Netzgrenze ist fast immer der richtige Schnitt – dahinter beginnt fremder Code, davor liegt mein Produkt.\n\n## Woran ich einen Test prüfe\n\nBevor ein Test in die Suite darf, stelle ich ihm drei Fragen. Würde er rot, wenn das Feature fachlich kaputtginge? Wenn nein, prüft er nichts. Bleibt er grün, wenn ich die Implementierung umbaue, ohne das Verhalten zu ändern? Wenn nein, ist er eine Umbau-Bremse. Und beschreibt sein Name eine Nutzerabsicht, die ein Fachexperte bestätigen könnte? Wenn nein, altert er mit dem nächsten Rename.\n\nEin Test, der alle drei Fragen besteht, ist billig im Unterhalt und teuer nur einmal – beim Schreiben. Ein Test, der eine davon reißt, wird jeden Sprint erneut bezahlt. Über die Lebensdauer eines Projekts entscheidet diese Differenz darüber, ob die Suite Vertrauen schafft oder Wartungslast.\n\n## Weiterführende Quellen\n\n- Workshop-Repo introduction-react: https://github.com/MikeBild/introduction-react\n\nDas Repo zeigt die kleine Vite-React-App und die Testbeispiele im Kontext einer vollständigen Lernkurve vom Baustein bis zur Architektur. Die Docs von Testing Library und Vitest helfen beim Gegenprüfen von Query-Prioritäten und async-Verhalten – die eigentliche Aussage bleibt aber die Entscheidung, wo im konkreten Beispiel die Grenze zwischen Verhalten und Implementierung verläuft.\n\n## Schluss\n\nReact Testing wird wertvoll, wenn Tests eine Frage beantworten, die den Nutzer interessiert: Tut die Anwendung, was sie soll, auch wenn etwas schiefgeht? Ein Test, der einen Klick simuliert und ein sichtbares Ergebnis prüft, bleibt bei internen Umbauten stabil, solange der Fachfall gleich bleibt. Das ist der belastbare Vorteil; Geschwindigkeit oder Fehlerquote müssen sich im Projekt zeigen."},{"slug":"react-suspense-und-use","title":"Suspense und der use-Hook: rendern, als wären die Daten schon da","summary":"Statt in jeder Komponente einen Ladezustand zu verwalten, beschreibt man mit Suspense an einer einzigen Stelle im Baum, was während des Wartens erscheint – und der use-Hook lässt die Komponente darunter rendern, als wären die Daten längst da.","tags":["Software","Frontend","React"],"published_at":"2024-12-10T10:00:00+00:00","updated_at":"2026-07-13T08:03:33.68271+00:00","url":"https://www.mikebild.dev/de/blog/react-suspense-und-use/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/04bb5eababe12e74e92420b1d17a9d8682ca7689603d19fe74fb66d503c0f2b4/react-suspense-und-use-vorlesen.mp3","audio_duration_secs":684,"text":"# Suspense und der use-Hook: rendern, als wären die Daten schon da\n\nWer das Warten in jeder Komponente einzeln ausprogrammiert, bezahlt das an der Oberfläche: Drei datenladende Komponenten nebeneinander heißen drei Spinner, die unabhängig voneinander aufblitzen und zu drei verschiedenen Zeitpunkten wieder verschwinden. Die Seite zuckt beim Aufbau, und jede dieser Komponenten schleppt dieselben drei Fälle mit sich herum – lädt, Fehler, Daten. In jeder steht irgendwo `if (isLoading) return <Spinner />` und darunter `if (error) return <ErrorMessage />`.\n\nDaran ändert auch der Umzug auf TanStack Query oder SWR nichts. Im [Beitrag über Client State und Server-Cache](https://www.mikebild.dev/de/blog/client-zustand-und-server-cache/) habe ich gezeigt, wie Serverdaten aus dem Gebastel aus `useState` und `useEffect` herauswandern und einer Cache-Bibliothek zufallen – der Ladezustand wandert dabei nicht mit, er bleibt in jeder Komponente stehen, die Daten braucht. React 19 setzt an einer anderen Stelle an, nämlich bei der Frage, wo im Baum das Warten überhaupt stattfindet. Suspense dreht sie um: Das Warten gehört nicht mehr in jede einzelne Komponente, sondern an eine Stelle darüber. Und die Komponente darunter darf so tun, als seien die Daten schon da.\n\n**Redaktionelle Einordnung (Juli 2026):** Suspense reagiert nicht auf beliebiges Datenladen in einem Effect oder Event-Handler. Für Daten funktioniert der hier beschriebene Ablauf mit Suspense-fähigen Frameworks und Bibliotheken oder beim Lesen eines stabilen, gecachten Promise mit `use`. Eine frei nutzbare, stabile React-API zum Bau eigener Suspense-Datenquellen ist weiterhin nicht dokumentiert.\n\n## Der Ladezustand verlässt die Komponente\n\nSehen wir uns zuerst an, wie es ohne Suspense aussieht. Eine Komponente, die eine Liste von Rechnungen anzeigt, trägt die drei States selbst:\n\n```jsx\nfunction InvoiceList() {\n  const { data, isLoading, error } = useInvoices();\n\n  if (isLoading) return <Spinner />;\n  if (error) return <ErrorMessage error={error} />;\n\n  return (\n    <ul>\n      {data.map((invoice) => (\n        <li key={invoice.id}>{invoice.title}</li>\n      ))}\n    </ul>\n  );\n}\n```\n\nDas ist nicht schlimm, aber es wiederholt sich. Jede datenladende Komponente sieht so aus, jede hat ihren eigenen `Spinner`, ihre eigene Error Handling. Und wenn drei solche Komponenten nebeneinander stehen, blitzen drei Spinner unabhängig voneinander auf und verschwinden zu unterschiedlichen Zeitpunkten – ein unruhiges Bild.\n\nSuspense stellt dem eine andere Idee gegenüber. Man umschließt einen Teil des Baums einmal mit `<Suspense>` und sagt: Alles darin, das noch auf Daten wartet, soll so lange diesen Fallback zeigen.\n\n```jsx\n<Suspense fallback={<Spinner />}>\n  <InvoiceList />\n</Suspense>\n```\n\nDie `InvoiceList` selbst kennt jetzt keinen Ladezustand mehr. Sie fragt die Daten ab, und wenn die noch nicht da sind, hält React die Komponente an und zeigt stattdessen den Fallback der nächsten umschließenden `<Suspense>`-Komponente. Das Wort dafür ist *suspendieren*: Die Komponente pausiert mitten im Rendern, bis das Versprochene eintrifft. Der Fallback ist nicht mehr Sache der Komponente, sondern des `<Suspense>` darüber.\n\n## Der `use`-Hook liest ein Promise\n\nDamit eine Komponente überhaupt suspendieren kann, braucht sie eine Möglichkeit, ein noch offenes Versprechen zu „lesen“. Genau das macht der `use`-Hook, der mit React 19 stabil ist. Man übergibt ihm ein Promise, und er gibt den aufgelösten Wert zurück – oder unterbricht das Rendern, wenn der Wert noch nicht da ist.\n\n```jsx\nimport { use } from \"react\";\n\nfunction InvoiceList({ invoicesPromise }) {\n  const invoices = use(invoicesPromise);\n\n  return (\n    <ul>\n      {invoices.map((invoice) => (\n        <li key={invoice.id}>{invoice.title}</li>\n      ))}\n    </ul>\n  );\n}\n```\n\nMan liest diese Zeile fast wie synchronen Code: `const invoices = use(invoicesPromise)`, danach steht `invoices` einfach zur Verfügung. Von der Zwischenzeit, in der das Promise noch offen ist, sieht die Komponente nichts. Ist das Promise noch nicht erfüllt, suspendiert `use` die Komponente, React hält an und zeigt den Fallback der umschließenden `<Suspense>`-Komponente. Sobald das Promise erfüllt ist, rendert React die Komponente erneut, diesmal mit dem Wert in der Hand.\n\nNeben Promises nimmt `use` übrigens auch einen Context entgegen und ersetzt in dieser Rolle `useContext`. Der interessante Fall für diesen Beitrag ist aber das Promise, weil daran das Zusammenspiel mit Suspense hängt.\n\n## Warum `use` bedingt aufgerufen werden darf\n\nHier lohnt sich ein kurzer Blick auf eine Besonderheit, die auf den ersten Blick wie ein Regelbruch aussieht. Für alle anderen Hooks gilt eine eiserne Regel: Sie müssen auf oberster Ebene der Komponente stehen, in immer derselben Reihenfolge, nie in einer Bedingung oder Schleife. Der Grund ist, dass React die Hooks pro Render über ihre Aufrufreihenfolge zuordnet – gerät die Reihenfolge durcheinander, verwechselt React die States.\n\n`use` ist ausdrücklich von dieser Regel ausgenommen. Man darf ihn in einer `if`-Bedingung aufrufen, in einer Schleife, hinter einem frühen `return`:\n\n```jsx\nfunction Message({ show, messagePromise }) {\n  if (show) {\n    const message = use(messagePromise);\n    return <p>{message}</p>;\n  }\n  return null;\n}\n```\n\nDas ist mehr als eine Bequemlichkeit. `use` speichert selbst keinen State, der über eine feste Aufrufreihenfolge einer Komponente zugeordnet werden müsste – er liest nur einen Wert aus einem Objekt, das man ihm reicht. Deshalb kann er dort stehen, wo er inhaltlich hingehört, statt an den Anfang der Komponente gezwungen zu werden. Man ruft ein Promise genau dann ab, wenn man seinen Wert wirklich braucht.\n\n## Fehler übernimmt die Error Boundary\n\nBleibt der dritte State von oben: der Fehler. Wenn das Promise nicht erfüllt, sondern abgelehnt wird, wirft `use` diesen Fehler beim Rendern. Und ein geworfener Fehler landet bei der nächsten *Error Boundary* – demselben Mechanismus, mit dem React seit jeher Render-Fehler auffängt.\n\n```jsx\n<ErrorBoundary fallback={<ErrorMessage />}>\n  <Suspense fallback={<Spinner />}>\n    <InvoiceList invoicesPromise={invoicesPromise} />\n  </Suspense>\n</ErrorBoundary>\n```\n\nDamit ist die Rechnung aufgegangen. Aus den drei States, die vorher jede Komponente selbst verwaltet hat – lädt, Fehler, Daten –, sind zwei Auffangnetze im Baum geworden plus der reine Erfolgsfall in der Komponente. Das `<Suspense>` kümmert sich um das Warten, die `<ErrorBoundary>` um das Scheitern, und die Komponente selbst rendert nur noch den Fall, für den sie eigentlich da ist: die vorhandenen Daten. Das `if (isLoading)` und das `if (error)` sind ersatzlos verschwunden.\n\n```mermaid\nflowchart TB\n  R[\"Komponente rendert,<br/>ruft use(promise)\"] --> Q{\"Promise-Zustand?\"}\n  Q -->|offen| S[\"suspendiert →<br/>Suspense zeigt Fallback\"]\n  S -.->|Promise erfüllt| R\n  Q -->|erfüllt| OK[\"Inhalt erscheint<br/>mit den Daten\"]\n  Q -->|abgelehnt| ERR[\"Fehler wird geworfen →<br/>ErrorBoundary fängt ihn\"]\n```\n\nAn dieser Stelle ist eine Error Boundary noch eine Klassenkomponente oder man greift zu einem kleinen Paket wie `react-error-boundary` – eine eingebaute Funktions-Variante gibt es in React 19 noch nicht. Das ändert am Bild aber nichts: konzeptionell ist es das zweite Auffangnetz neben Suspense.\n\n## Ehrlich bleiben: `use` ist keine Cache-Bibliothek\n\nJetzt kommt der Teil, den man leicht überliest, weil die Beispiele oben so aufgeräumt wirken. Woher kommt eigentlich das `invoicesPromise`? In den Codeschnipseln habe ich es als Prop hereingereicht und die Frage damit verschoben. Sie ist aber die entscheidende.\n\nMan könnte auf die Idee kommen, das Promise einfach in der Komponente zu erzeugen:\n\n```jsx\nfunction InvoiceList() {\n  // Trap: creates a new promise on EVERY render\n  const invoices = use(fetch(\"/api/invoices\").then((r) => r.json()));\n  // ...\n}\n```\n\nDas sieht kompakt aus und ist trotzdem falsch. Jeder Render-Durchlauf ruft `fetch` erneut auf und erzeugt ein frisches Promise. React sieht ein noch offenes Promise, suspendiert, rendert nach dem Auflösen neu – und erzeugt dabei sofort das nächste neue Promise. Man baut sich eine Endlosschleife aus Ladevorgängen. `use` cacht nichts, es dedupliziert nichts, es *liest* nur, was man ihm gibt.\n\nDamit das funktioniert, muss das Promise von außerhalb des Renderns kommen: von jemandem, der es einmal erzeugt, unter einem Schlüssel ablegt und bei der nächsten Anfrage dasselbe Promise zurückgibt. Das ist genau die Buchhaltung – Schlüssel, Alter, Deduplizierung, Invalidierung –, die ich im [Beitrag über den Server-Cache](https://www.mikebild.dev/de/blog/client-zustand-und-server-cache/) beschrieben habe. `use` und Suspense lösen sie nicht, sie setzen sie voraus.\n\nIn der Praxis liefern deshalb dieselben Werkzeuge die Promises, die man ohnehin schon einsetzt. TanStack Query und SWR haben beide einen Suspense-Modus: Statt `isLoading` und `error` zurückzugeben, suspendieren sie und werfen im Fehlerfall, sodass Suspense und Error Boundary die States übernehmen. Oder ein Framework wie Next.js erzeugt die Promises auf dem Server und reicht sie hinein. Die Regel aus dem Cache-Beitrag gilt unverändert: Serverdaten brauchen einen Cache mit Schlüssel und Invalidierung. Suspense ändert nur, wie sich das Warten darauf im Baum anfühlt – nicht, dass jemand den Cache führen muss.\n\n## Wofür sich der Aufwand lohnt\n\nWenn `use` die Buchhaltung nicht abnimmt, warum dann der ganze Umbau? Der Gewinn liegt woanders, nämlich in der Koordination mehrerer Datenquellen unter einem gemeinsamen `<Suspense>`.\n\nStellen wir uns eine Profilseite vor, die Nutzerdaten, die letzten Beiträge und eine Statistik lädt – drei Quellen. Ohne Suspense blitzen drei Spinner unabhängig auf und lösen sich zu drei Zeitpunkten auf; die Seite zuckt beim Aufbau. Legt man die drei Komponenten unter ein gemeinsames `<Suspense>`, wartet React, bis alle drei so weit sind, und zeigt dann die fertige Seite in einem Stück. Ein Fallback statt drei, ein Moment des Erscheinens statt drei.\n\nGenauso oft will man das Gegenteil: nicht auf die langsamste Quelle warten, sondern das Schnelle sofort zeigen. Dann verschachtelt man mehrere `<Suspense>`-Komponenten. Das Nutzerprofil steht unter einem äußeren `<Suspense>` und erscheint früh; die langsame Statistik bekommt ihr eigenes inneres `<Suspense>` mit eigenem Fallback und schiebt sich nach, sobald sie da ist.\n\n```jsx\n<Suspense fallback={<PageSkeleton />}>\n  <UserProfile userPromise={userPromise} />\n  <Suspense fallback={<StatsSkeleton />}>\n    <Statistics statsPromise={statsPromise} />\n  </Suspense>\n</Suspense>\n```\n\nSo beschreibt man ein gestaffeltes Erscheinen als Struktur im Baum, nicht als Logik in den Komponenten. Wo etwas erscheint und in welcher Reihenfolge, liest man an der Verschachtelung der `<Suspense>`-Komponenten ab – an einer Stelle, statt über ein Dutzend `isLoading`-Abfragen verstreut. Das ist der eigentliche Zugewinn: Die Lade-UI liegt dort, wo man über Layout nachdenkt, und nicht in jeder Komponente noch einmal.\n\nDamit hängt ein zweiter, weniger sichtbarer Vorteil zusammen: Suspense wirkt Lade-Wasserfällen entgegen. Ein Wasserfall entsteht, wenn eine Komponente erst lädt, dann rendert, und ein Kind darin wiederum erst dann mit seinem eigenen Laden beginnt – die Anfragen reihen sich nacheinander auf, obwohl sie voneinander gar nicht abhängen. Genau dieses Muster produziert das klassische `useEffect`-Laden fast von selbst, weil jede Komponente erst dann fetcht, wenn sie gerendert wird, und Kinder erst rendern, wenn die Eltern fertig geladen haben. Reicht man die Promises dagegen von außen hinein und startet sie früh, laufen die Anfragen parallel, während `use` sie unter einem gemeinsamen `<Suspense>` wieder einsammelt. Die Struktur des Wartens und die Struktur des Ladens fallen nicht mehr zwangsläufig zusammen.\n\n## Ein Nachladen, das die alte UI stehen lässt\n\nEin Detail sei noch angerissen, weil es in der Praxis schnell wichtig wird. Suspense zeigt seinen Fallback nicht nur beim ersten Laden, sondern auch, wenn sich die Daten unter einem `<Suspense>` ändern – etwa wenn man von einer Rechnung zur nächsten wechselt und ein neues Promise entsteht. Ohne Vorkehrung springt die Seite dann zurück in den Spinner, obwohl gerade eben noch brauchbare Daten zu sehen waren.\n\nGenau dafür gibt es `useTransition` und `startTransition`. Markiert man den Datenwechsel als Übergang, hält React die alte Oberfläche stehen und lädt im Hintergrund nach, statt sofort in den Fallback zu fallen. Man kann währenddessen anzeigen, dass etwas im Gange ist – ausgegraut, mit einem dezenten Hinweis –, ohne den bereits gerenderten Inhalt wegzuwerfen. Das verdient einen eigenen Beitrag; hier soll nur klar sein, dass das harte Zurückspringen in den Fallback kein Zwang ist.\n\n## Was bleiben soll\n\nSuspense und `use` verschieben nicht, wer die Daten besorgt – das bleibt Sache eines Caches. Sie verschieben, wo das Warten beschrieben wird. Statt in jeder Komponente drei States auszuprogrammieren, zieht man Laden und Fehler an zwei Stellen im Baum zusammen – Suspense und Error Boundary – und lässt die Komponenten darunter den Erfolgsfall annehmen. Die einzelne Komponente wird dadurch kleiner und ehrlicher: Sie tut so, als seien die Daten da, weil ein `<Suspense>` über ihr dafür sorgt, dass sie es sind, wenn die Komponente an die Reihe kommt.\n\nBeim Entwurf einer Seite bleibt damit eine Frage übrig: An welchen Stellen im Baum darf gewartet werden, und was soll dort so lange zu sehen sein? Die Antwort sind ein paar `<Suspense>`-Komponenten an den richtigen Stellen – und Komponenten, die vom Warten nichts mehr wissen müssen.\n\n## Weiterführende Quellen\n\n- Repository introduction-react (Kapitel server-access, react-hooks; Ordner recipes zum Data Fetching): https://github.com/mikebild/introduction-react\n- React, „Suspense“: https://react.dev/reference/react/Suspense\n- React, „use“: https://react.dev/reference/react/use\n- React, Suspense-fähiges Data Fetching: https://react.dev/reference/react/Suspense#displaying-a-fallback-while-content-is-loading"},{"slug":"client-zustand-und-server-cache","title":"Client State vs. Server-Cache: die folgenreichste Grenze im Frontend","summary":"Serverdaten sind State, aber nicht client-eigener State. Warum diese Unterscheidung wichtig ist, wann useEffect zum schlechten Cache wird und was eine Server-State-Bibliothek übernimmt.","tags":["Software","Frontend","React"],"published_at":"2024-12-06T10:00:00+00:00","updated_at":"2026-07-13T13:19:06.078134+00:00","url":"https://www.mikebild.dev/de/blog/client-zustand-und-server-cache/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/19c38bb5a94c192f8923863d9b56e0801b8caf9b67a9d6623099f73e63c47c45/client-zustand-und-server-cache-vorlesen.mp3","audio_duration_secs":739,"text":"# Client State vs. Server-Cache: die folgenreichste Grenze im Frontend\n\n```jsx\nconst [invoices, setInvoices] = useState([]);\n\nuseEffect(() => {\n  fetch(\"/api/invoices\")\n    .then((r) => r.json())\n    .then(setInvoices);\n}, []);\n```\n\n`invoices` ist kein client-eigener State. Es ist eine Cache-Kopie von Daten, deren Original in einer Datenbank hinter `/api/invoices` liegt. Und diesem Cache fehlt alles, was einen brauchbaren Cache ausmacht: ein Schlüssel, unter dem eine zweite Komponente dieselbe Kopie findet; ein Alter, ab dem sie als überholt gilt; eine Stelle, an der man sie nach einer Änderung für ungültig erklärt. Solange nur eine Komponente lädt und niemand etwas ändert, fällt das nicht auf.\n\nDer React-Überblick unterscheidet vier Orte für State: die Komponente, die URL, einen globalen Store und, fast beiläufig, den Server-Cache. Diese letzte Grenze verdient einen eigenen Beitrag, weil sie im Alltag die meisten Fehler verursacht und weil man sie so leicht übersieht.\n\nDiese Verwechslung ist kein Anfängerfehler, den man sich abgewöhnt. Ich sehe sie in Code-Reviews bei Leuten, die seit Jahren React schreiben, und ich habe sie selbst lange gemacht. Der Unterschied klingt akademisch, hat aber ganz praktische Folgen: Er entscheidet, welches Werkzeug man wählt, wie viele Bugs man später jagt und wie klein oder groß der Teil der Anwendung wird, den man von Hand verwalten muss.\n\n## Zwei Sorten State, die man ständig verwechselt\n\nFangen wir mit einer einfachen Frage an: Wo liegt die Wahrheit? Bei echtem Client State liegt sie im Browser. Der Text in einem Eingabefeld, ob ein Menü auf- oder zugeklappt ist, welcher Tab gerade aktiv ist – all das entsteht durch eine Nutzerinteraktion und existiert nur hier. Niemand außerhalb dieses Browsers kennt diesen Wert oder braucht ihn. Wenn die Komponente verschwindet, verschwindet er mit, und das ist völlig in Ordnung.\n\nBei Serverdaten ist es umgekehrt. Die Liste der Rechnungen, das Nutzerprofil, der Warenbestand – die Wahrheit dazu liegt im Backend, in einer Datenbank, hinter einer API. Was der Browser davon hat, ist nur eine Kopie. Und Kopien haben eine unangenehme Eigenschaft: Sie veralten. In der Sekunde, in der der Server die Antwort abschickt, kann jemand anderes den Datensatz schon geändert haben. Der Browser hält dann einen Abzug, der aussieht wie die Wahrheit, es aber nicht mehr ist.\n\n```mermaid\nflowchart TB\n  Frage[\"Wo lebt die Wahrheit?\"]\n  Frage --> Client[\"im Browser<br/>= Client-Zustand\"]\n  Frage --> Server[\"im Backend<br/>= Serverdaten\"]\n  Client --> C1[\"Eingaben, Auswahl,<br/>aufgeklappte Menüs\"]\n  Server --> S1[\"Der Browser hält nur<br/>eine Kopie – sie veraltet\"]\n```\n\nSobald man diese Frage einmal stellt, sortiert sich vieles neu. Der Zähler, das Formular, die Filterauswahl – Client State. Die geladene Liste, das Suchergebnis, der Kontostand – Serverdaten, also ein Cache. Und für einen Cache gelten andere Regeln als für State, den man selbst besitzt.\n\n## Warum `useEffect` allein keinen Server-Cache ersetzt\n\nDer übliche erste Reflex sieht so aus: Man nimmt `useState` für die Daten, `useEffect` für das Laden, und fertig. Hier noch einmal das Muster vom Anfang, diesmal mit Fehlerpfad und Aufräumfunktion, damit es überhaupt korrekt ist:\n\n```jsx\nfunction useInvoices() {\n  const [data, setData] = useState(null);\n  const [error, setError] = useState(null);\n\n  useEffect(() => {\n    let active = true;\n    fetch(\"/api/invoices\")\n      .then((r) => r.json())\n      .then((d) => active && setData(d))\n      .catch((e) => active && setError(e.message));\n    return () => {\n      active = false;\n    };\n  }, []);\n\n  return { data, error };\n}\n```\n\nDas funktioniert – für genau eine Komponente, die genau einmal lädt. Sobald die Anwendung wächst, fehlt hier fast alles, was Serverdaten wirklich brauchen. Zwei Komponenten, die dieselbe Liste anzeigen, laden sie zweimal, weil keine von der anderen weiß. Wechselt man weg und kommt zurück, wird von vorne geladen, obwohl die Daten von eben noch gut genug wären. Es gibt keinen Weg, die Kopie nach einer Änderung gezielt zu erneuern. Und der Wettlauf zwischen einer alten und einer neuen Anfrage, den die Aufräumfunktion oben entschärft, ist nur der offensichtlichste einer ganzen Familie von Problemen.\n\nEin Beispiel, das ich so ähnlich in einem echten Projekt erlebt habe: ein Dashboard, oben eine Zahl „offene Rechnungen: 12“, daneben eine Liste derselben Rechnungen. Beide Bausteine laden ihre Daten in einem eigenen Effect. Schon beim Start feuern zwei Anfragen, wo eine gereicht hätte. Ärgerlicher wird es, wenn jemand eine Rechnung bezahlt: Die Liste lädt neu und zeigt elf, die Zahl oben lädt nicht neu und zeigt weiter zwölf. Zwei Kopien derselben Wahrheit, die auseinanderlaufen – und niemand hat einen Fehler gemacht, der Code ist nur ehrlich zu Ende gedacht. Genau solche Widersprüche entstehen zwangsläufig, wenn jede Komponente ihren eigenen kleinen Cache hält, ohne dass es eine gemeinsame Stelle gibt, die weiß, welche Daten gerade gültig sind.\n\nDer eigentliche Punkt ist ein anderer. Ein Effect synchronisiert eine Komponente mit etwas außerhalb von React – das ist seine Aufgabe, und dafür ist er richtig. Aber das Verwalten eines Caches ist kein Randthema einer einzelnen Komponente. Es ist ein anwendungsweites Problem: Wer hält welche Daten, wie alt dürfen sie sein, wann werden sie erneuert, wer teilt sie sich? Dieses Problem in jedem `useEffect` einzeln und von Hand zu lösen, ist ungefähr so, als würde man in jeder Funktion eine eigene kleine Datenbank schreiben. Es geht, aber es skaliert nicht, und jede Kopie hat ihre eigenen Bugs.\n\n## Was eine Server-State-Bibliothek übernimmt\n\nAn dieser Stelle lohnt sich ein Werkzeug, das genau für diesen Fall gebaut ist. TanStack Query (früher React Query) und SWR sind die beiden verbreiteten Vertreter. Sie teilen dieselbe Grundidee, die man am Namen von SWR ablesen kann: *stale-while-revalidate*. Zeig die vorhandene, womöglich veraltete Kopie sofort an, und erneuere sie im Hintergrund. Der Nutzer sieht sofort Daten, und kurz darauf die frischen.\n\nDer Kern ist ein zentraler Cache, der Daten unter einem Schlüssel ablegt. Man beschreibt nur, was man will und woher es kommt:\n\n```jsx\nimport { useQuery } from \"@tanstack/react-query\";\n\nfunction useInvoices() {\n  return useQuery({\n    queryKey: [\"invoices\"],\n    queryFn: () => fetch(\"/api/invoices\").then((r) => r.json()),\n    staleTime: 30_000,\n  });\n}\n```\n\nDas sieht kaum kürzer aus als der `useEffect`, aber dahinter steckt eine ganze Maschinerie. Fragen zwei Komponenten `[\"invoices\"]` gleichzeitig ab, wird trotzdem nur eine Anfrage geschickt – das ist Deduplizierung. Kommt eine Komponente zurück, deren Daten jünger als die `staleTime` sind, wird gar nicht neu geladen. Sind sie älter, zeigt die Bibliothek die alte Kopie sofort und lädt im Hintergrund nach. Der `queryKey` ist dabei mehr als ein Name: Er ist die Identität der Daten im Cache und der Angelpunkt für alles Weitere.\n\nAm deutlichsten wird der Unterschied beim Schreiben. Nach einer Mutation muss der Cache erfahren, dass seine Kopie jetzt falsch ist:\n\n```jsx\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\n\nfunction usePayInvoice() {\n  const queryClient = useQueryClient();\n  return useMutation({\n    mutationFn: (id) => fetch(`/api/invoices/${id}/pay`, { method: \"POST\" }),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: [\"invoices\"] });\n    },\n  });\n}\n```\n\nDer Aufruf `invalidateQueries` markiert alle Abfragen unter `[\"invoices\"]` als veraltet und lädt die aktiven davon neu. Man sagt also nicht mehr „setze diesen State“, sondern „diese Serverdaten sind jetzt unzuverlässig, hol sie dir frisch“. Das ist genau die richtige Denkweise für einen Cache: Man besitzt die Daten nicht, man hält sie nur vorläufig, und nach einer Änderung erklärt man die Kopie für ungültig. Verwenden Zähler und Liste dieselbe Schlüsselfamilie, lassen sie sich mit dieser einen Invalidierung gemeinsam aktualisieren. Das beseitigt eine häufige Ursache für auseinanderlaufende Anzeigen; eine Garantie ersetzt es nicht, denn Schlüssel, Error Handling und Serverantworten müssen weiterhin zusammenpassen.\n\nWer will, geht einen Schritt weiter und aktualisiert die Oberfläche schon, bevor der Server geantwortet hat. Über den `onMutate`-Callback schreibt man den erwarteten neuen Stand direkt in den Cache, sodass die Rechnung sofort als bezahlt erscheint; kommt der Server dann mit einem Fehler zurück, rollt die Bibliothek den Cache auf den alten Stand zurück. Dieses optimistische Aktualisieren macht Oberflächen spürbar schneller, und es ist überhaupt nur möglich, weil die Daten an einer zentralen, benannten Stelle liegen und nicht in einem Dutzend einzelner `useState`-Werte verstreut sind.\n\n```mermaid\nflowchart LR\n  A[\"Komponente fragt<br/>queryKey\"] --> B{\"Kopie im Cache<br/>und noch frisch?\"}\n  B -->|ja| C[\"sofort anzeigen,<br/>nichts laden\"]\n  B -->|veraltet| D[\"alte Kopie zeigen +<br/>im Hintergrund neu laden\"]\n  D --> E[\"frische Daten<br/>ersetzen die Kopie\"]\n```\n\nDazu kommt eine ganze Reihe Kleinigkeiten, die man sonst von Hand bauen müsste: erneutes Laden, wenn das Fenster wieder den Fokus bekommt, Wiederholung bei Netzfehlern, `pending`- und `error`-States als sauberes Ergebnis statt als drei einzelne `useState`, und Werkzeuge, die den Cache im Browser sichtbar machen. Nichts davon ist Zauberei – es ist genau die Buchhaltung, die ein Cache braucht und die in einem `useEffect` immer fehlt.\n\n## SWR oder TanStack Query?\n\nBeide Bibliotheken lösen dasselbe Problem, setzen aber unterschiedliche Schwerpunkte. SWR ist bewusst schlank, hat eine schmale API und ist schnell verstanden. Man sagt ihr, wie lange Daten als frisch gelten, und ruft `mutate(key)` auf, um sie zu erneuern. Für lese-lastige Oberflächen mit einfachen Schreibvorgängen ist das oft genau richtig, gerade im Next.js-Umfeld.\n\nTanStack Query ist umfangreicher und gibt mehr Kontrolle: `staleTime` und `gcTime` trennen sauber, wie lange Daten frisch bleiben und wann sie aus dem Speicher fallen; `invalidateQueries` kann über einen Präfix ganze Familien von Abfragen auf einmal für veraltet erklären, was bei verschachtelten Daten viel wert ist; und `useMutation` ist eine vollständige State Machine mit Callbacks für optimistische Updates. Der Preis ist etwas mehr Gewicht und ein paar Stellschrauben mehr. Für kleine, überschaubare Fälle reicht SWR; sobald die Server-State-Komplexität zu wachsen droht, zahlt sich TanStack Query aus.\n\nDer wichtigere Punkt liegt aber quer zu dieser Wahl: Sobald man Serverdaten als das behandelt, was sie sind – einen Cache mit Schlüssel, Alter und Invalidierung –, verschwindet das ganze `useState`-und-`useEffect`-Gebastel, egal für welche der beiden man sich entscheidet.\n\n## Was dann noch echter Client State ist\n\nJetzt kommt die eigentliche Pointe, und sie überrascht auch erfahrene Kollegen regelmäßig. Wenn man die Serverdaten aus der State Management herauszieht, bleibt erschreckend wenig übrig. Die Gegenprobe ist schnell gemacht: alles auflisten, was bisher für „globalen State“ gehalten wurde, und dann Punkt für Punkt streichen. Die geladenen Listen? Cache. Die Detaildaten? Cache. Das Suchergebnis? Cache. Welcher Filter aktiv ist, welche Seite man sieht, welcher Datensatz ausgewählt ist? Das gehört in die URL – teilbar, mit Lesezeichen speicherbar, überlebt einen Reload.\n\nWas am Ende als client-eigener State übrig bleibt, ist in vielen Anwendungen ein kleiner Kern: der Text in einem Formular, während man noch tippt, UI-Details wie aufgeklappte Bereiche und einige app-weite Entscheidungen – etwa Theme oder Warenkorb. Dafür reichen häufig `useState`, Context oder ein leichter Store. Die Serverdaten bleiben ebenfalls State, haben aber eine andere Quelle, Lebensdauer und Invalidierungslogik. Genau deshalb gehören sie nicht ohne Weiteres in denselben globalen Store.\n\nDas ist der Grund, warum ich diese Grenze für die folgenreichste im ganzen Frontend halte. Sie entscheidet nicht nur über ein Werkzeug. Sie räumt die halbe State Management ab, bevor man sie überhaupt baut.\n\n## React 19: `use()` und Suspense\n\nEin Ausblick, weil sich hier gerade etwas verschiebt. React 19 bringt mit `use()` und Suspense einen weiteren Weg, Serverdaten ins Rendern zu holen: Man reicht einer Komponente ein stabiles Promise, `use()` suspendiert, bis es erfüllt ist, und eine umschließende `<Suspense>`-Komponente zeigt so lange einen Fallback. Das Promise sollte aus einem Framework oder Cache stammen und nicht bei jedem Rendern neu erzeugt werden. So rücken Lade- und Fehlerzustände an den Rand, statt in jeder Komponente einzeln behandelt zu werden.\n\nDas ersetzt eine Cache-Bibliothek nicht – irgendjemand muss die Promises weiterhin erzeugen, cachen und invalidieren, und genau das übernehmen TanStack Query und SWR inzwischen mit Suspense-Unterstützung. Aber es ändert, wie sich das Warten im Baum anfühlt. Diesem eigenen großen Thema widme ich den Beitrag Suspense und der use-Hook; hier steht es nur als Wegweiser, damit klar ist, wohin sich React hier entwickelt.\n\n## Was bleiben soll\n\nWenn von diesem Beitrag ein Satz hängen bleiben soll, dann dieser: Frag bei jedem Wert, der wie State aussieht, zuerst, wo seine Wahrheit liegt. Liegt sie im Browser, ist es echter Client State, und `useState` ist richtig. Liegt sie im Backend, ist es ein Cache, und dann braucht es Cache-Regeln – Schlüssel, Alter, Invalidierung – und ein Werkzeug, das diese Buchhaltung übernimmt, statt sie in jeden `useEffect` zu kopieren.\n\nDiese Unterscheidung zuerst zu treffen und erst danach das Werkzeug zu wählen, verhindert doppelte Caches und widersprüchliche Kopien. Serverdaten sind geliehener State: Der Client verwaltet ihre Darstellung, besitzt aber weder ihre Wahrheit noch ihren Lifecycle allein.\n\n## Weiterführende Quellen\n\n- Repository introduction-react (Kapitel server-access, react-hooks; Ordner recipes zum Data Fetching): https://github.com/mikebild/introduction-react\n- React, „Du brauchst vielleicht keinen Effect“: https://react.dev/learn/you-might-not-need-an-effect\n- React, mit Effects synchronisieren: https://react.dev/learn/synchronizing-with-effects\n- TanStack Query, Query Invalidation: https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation\n- SWR, offizielle Dokumentation: https://swr.vercel.app/"},{"slug":"scheduler-und-virtuelle-zeit-testen","title":"Scheduler und virtuelle Zeit testen","summary":"Injizierbare Zeit macht zeitabhängige Regeln schnell und deterministisch testbar. Daten, Isolation und externe Systeme bleiben davon unberührt.","tags":["Software","Reactive"],"published_at":"2024-11-12T10:00:00+00:00","updated_at":"2026-07-13T08:53:24.655184+00:00","url":"https://www.mikebild.dev/de/blog/scheduler-und-virtuelle-zeit-testen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/54183ee96e22b7fb3f5f42909db8046f0661617fdd6e8bb71425469f947738b4/scheduler-und-virtuelle-zeit-testen-vorlesen.mp3","audio_duration_secs":611,"text":"# Scheduler und virtuelle Zeit testen\n\nDie teuerste Stelle in einer asynchronen Codebasis ist selten der Algorithmus. Sie ist die Zeit. Debounce, Retry, Timeout, Polling, „warte 250 ms und feuere dann den letzten Wert“ – solche Regeln sind fachlich einfach, aber im Test brutal. Wer sie mit echter Zeit prüft, baut Suiten, die langsam laufen, gelegentlich rot werden und niemandem mehr etwas beweisen. Der Ausweg steckt in einer Stelle, die im Code kaum auffällt: dem Scheduler, über den ein Operator seine Uhr bezieht. Steht der als Argument in der Signatur statt als Framework-Default im Hintergrund, lässt sich die Uhr im Test vorspulen, statt auf sie zu warten.\n\nDie Reihenfolge, in der sich das erschließt, ist nicht die Reihenfolge der Dokumentation: MVVM, asynchrone Programmierung, Scheduler, Streams, Observable Pattern, Message Bus, Testing. Erst entsteht das Problem mit Zeit, dann kommt Rx als Modell dafür, und ganz am Ende steht der Scheduler als die Grenze, an der das Ganze überhaupt prüfbar wird. Dieser letzte Schritt trägt das meiste – er macht aus einer eleganten, aber schwer greifbaren Technik etwas, das man in Sekunden testen und im Team verteidigen kann.\n\n## Das eigentliche Problem ist nicht Async, sondern Warten\n\nReactive Programming – ob RxJS im Frontend oder ReactiveUI im Desktop-Client – modelliert asynchrone Daten als Observables mit Operatoren. Die Stärke liegt nicht in der Menge der Operatoren. Sie liegt darin, dass Zeit, Abbruch, Fehler und Transformation explizit werden. `debounce`, `switchMap`, `mergeMap` und `combineLatest` treffen jeweils eine andere Systemaussage: Wird ein alter Request abgebrochen? Dürfen Ergebnisse parallel eintreffen? Zählt nur der letzte Wert? Operatoren sind Antworten auf diese Fragen, keine Vokabelliste.\n\nSobald aber Zeit im Spiel ist, kippt die Testbarkeit. Ein Test für „debounce um 250 ms“ hat drei schlechte Optionen: real warten (langsam, und mit jeder solchen Regel summiert sich die Laufzeit), großzügige Sleeps setzen (nichtdeterministisch, weil sie auf langsamer CI anders takten) oder die Logik gar nicht testen (die Regel driftet unbemerkt). Alle drei kosten – und zwar dort, wo es am meisten weh tut: im täglichen Feedback-Zyklus des Teams. Eine Suite, die zehn Minuten läuft und alle paar Läufe grundlos rot ist, wird nicht mehr vertraut. Und einer Suite, der niemand vertraut, folgt niemand.\n\n## Die Idee: Zeit ist eine Abhängigkeit\n\nDer architektonische Hebel ist unspektakulär und genau deshalb tragfähig: Zeit wird nicht aus der Laufzeit genommen, sondern hereingereicht. Ein Scheduler ist die Abstraktion, über die Operatoren Zeit beziehen – „wann wird das nächste Event geplant, und relativ zu welcher Uhr“. In Produktion ist das eine echte Uhr. Im Test ist es eine virtuelle Uhr, die ich von Hand vorspule.\n\n```ts\nconst result$ = input$.pipe(\n  debounceTime(250, scheduler),\n  distinctUntilChanged(),\n  switchMap((query) => search(query))\n);\n```\n\nDer einzige Unterschied zur naiven Variante ist das zweite Argument von `debounceTime`. Es sieht nach einem Detail aus, verschiebt aber die Kontrolle über Zeit vom impliziten Framework-Default zu einer expliziten, austauschbaren Abhängigkeit. Das ist dasselbe Prinzip wie Dependency Injection für eine Datenbank oder einen HTTP-Client – nur angewandt auf die eine Ressource, die sich sonst nicht mocken lässt.\n\nIm Test wird dann nicht gewartet. Zeit wird bewegt.\n\n```ts\nscheduler.advanceBy(249);\nexpect(search).not.toHaveBeenCalled();\nscheduler.advanceBy(1);\nexpect(search).toHaveBeenCalledWith(\"abc\");\n```\n\nDer Test macht eine Aussage, die vorher gar nicht formulierbar war: „Bei 249 ms passiert nichts, bei genau 250 ms feuert der Aufruf.“ Er läuft in Mikrosekunden, unabhängig von der Maschine, und er dokumentiert die fachliche Regel exakt. Der Gewinn ist dabei nicht in erster Linie Tempo. Es ist die Präzision: Eine Zeitentscheidung, über die vorher nur geredet wurde, wird nachweisbar.\n\n```mermaid\nflowchart LR\n  Input[User input] --> Debounce\n  Debounce --> Distinct\n  Distinct --> SwitchMap\n  SwitchMap --> Result\n  SwitchMap -. cancel old request .-> Cancelled\n```\n\n## Warum das eine Architekturfrage ist, keine Testtechnik\n\nEs wäre bequem, virtuelle Zeit als Trick der Testschicht abzulegen. Das greift zu kurz. Die Entscheidung, Zeit injizierbar zu machen, zwingt den Produktivcode, seine zeitlichen Abhängigkeiten offenzulegen. Wo vorher ein `setTimeout` still im Inneren einer Klasse saß, steht jetzt ein Scheduler in der Signatur. Zeit wird von einem versteckten Side Effect zu einem sichtbaren Kontrakt.\n\nBeim Debounce lautet die fachliche Regel: Das Backend soll nicht jeden Tastendruck erhalten, die Oberfläche aber zeitnah reagieren. Der Scheduler macht die gewählten 250 Millisekunden zu einer austauschbaren Eingabe. Wenn der Wert zu knapp ist, ändere ich ihn zusammen mit einem Grenztest statt durch manuelles Klicken auf einer langsamen Umgebung.\n\nDer Trade-off ist real und gehört benannt. Ein Scheduler in der Signatur ist mehr Zeremonie als ein direkter Timer. Man muss ihn durchreichen, und Teams, die Rx neu einführen, empfinden das anfangs als Overhead. Spätestens mit der zweiten oder dritten zeitabhängigen Regel amortisiert sich der Aufwand. Vorher ist es Disziplin, danach ist es Selbstläufer. Wer den Scheduler von Beginn an mitführt, zahlt einen kleinen konstanten Preis; wer ihn nachrüsten muss, zahlt ihn als teures Refactoring quer durch die Aufrufkette.\n\n## Ein zweites Beispiel: Retry mit Backoff\n\nDebounce ist der freundliche Fall, weil die Zeit klein und einstellig ist. Der Fall, der ohne virtuelle Zeit praktisch untestbar bleibt, ist Retry mit exponentiellem Backoff. Die fachliche Absicht: Ein flüchtiger Netzwerkfehler soll nicht sofort durchschlagen, sondern nach 1 s, dann 2 s, dann 4 s erneut versucht werden – und nach drei Versuchen endgültig scheitern.\n\n```ts\nconst result$ = request$.pipe(\n  retryWhen((errors) =>\n    errors.pipe(\n      scan((count) => count + 1, 0),\n      delayWhen((count) => timer(2 ** count * 1000, scheduler)),\n      take(3)\n    )\n  )\n);\n```\n\nMit echter Zeit ist der Test dieser Regel eine Zumutung: Ein einziger Durchlauf wartet sieben Sekunden, und die Grenze „nach dem dritten Versuch kommt der Fehler durch“ prüft niemand gern in einer Suite, die hundertmal am Tag läuft. Mit injizierter Zeit wird daraus ein präziser, sofortiger Test:\n\n```ts\nscheduler.advanceBy(1000);\nexpect(request).toHaveBeenCalledTimes(2);\nscheduler.advanceBy(2000);\nexpect(request).toHaveBeenCalledTimes(3);\nscheduler.advanceBy(4000);\nexpect(onError).toHaveBeenCalled();\n```\n\nDas ist der Moment, in dem die Entscheidung ihren Wert zeigt. Eine Regel, die im Betrieb über Ausfallsicherheit entscheidet – zu aggressives Retry überlastet ein ohnehin schwaches Backend, zu zaghaftes lässt Nutzer im Regen stehen – wird zu einer Sache von drei Zeilen, die ich anpassen und beweisen kann. Ohne virtuelle Zeit ist genau diese Regel die, die in der Praxis ungetestet bleibt und im Zwischenfall zum Rätsel wird.\n\n## Die zweite Grenze: Besitz statt globaler Streams\n\nZeit testbar zu machen löst nur die eine Hälfte. Die andere ist die Subscription-Grenze. In UI- und Messaging-Systemen entscheidet vor allem, wer einen Stream besitzt und wann er endet. Ein Stream, der überall abonniert und nirgends beendet wird, ist nur globaler State mit einem anderen Namen – mit denselben Symptomen: Speicherlecks, doppelte Side Effects, schwer erklärbares Verhalten unter Last.\n\nGute Streams haben deshalb drei Eigenschaften, die ich im Review aktiv suche: einen klaren Besitzer, eine definierte Lebensdauer und benannte Side Effects. Das ist keine Stiltugend, sondern die Voraussetzung dafür, dass Tests überhaupt aussagekräftig bleiben. Ein Test mit virtueller Zeit ist wertlos, wenn die Subscription im echten System länger lebt als gedacht und in Produktion doppelt feuert. Zeit-Kontrolle und Lebensdauer-Kontrolle sind zwei Seiten derselben Disziplin.\n\n## Commands statt Button-Handler\n\nDenselben Gedanken – State und Lebensdauer explizit machen – trägt ReactiveUI in die Interaktion. ReactiveCommands modellieren eine UI-Aktion als ausführbare Operation mit State: kann ausgeführt werden, läuft gerade, liefert Ergebnis, liefert Fehler. Der Button trägt damit keine Business-Logik mehr; er zeigt nur noch den State eines Commands an.\n\n```mermaid\nflowchart LR\n  Button --> Command\n  Command --> CanExecute\n  Command --> IsExecuting\n  Command --> Result\n  Command --> Error\n  Result --> ViewModel\n  Error --> ErrorPolicy\n```\n\nDer Nutzer klickt nicht „eine Funktion“. Er startet eine Operation mit Lebensdauer – und die lässt sich, wieder über den Scheduler, deterministisch testen: Ist `CanExecute` während der Ausführung false? Wird der Fehlerpfad in die ErrorPolicy geleitet statt in einen verschluckten Exception-Handler? Das sind genau die Fragen, die im Demo-Modus nie auffallen und im Betrieb teuer werden. ReactiveCommands, Dynamic Data, `Bind`/`OneWayBind` und ein zentraler ExceptionHandler zeigen dieselbe Richtung: Reactive Programming heißt nicht, Operatorlisten zu lernen, sondern UI-Lebensdauer und Fehlerpfade zu modellieren.\n\n## Die methodische Konsequenz für Reviews\n\nWenn Zeit eine Abhängigkeit ist, ändert sich, worauf ich im Review schaue. Ich frage nicht mehr „gibt es einen Test“, sondern „welche Zeitentscheidung beweist dieser Test“. Feuert Debounce bei genau der Schwelle? Bricht `switchMap` den alten Request nachweislich ab? Läuft der Retry mit dem erwarteten Backoff, geprüft über eine vorgespulte Uhr statt über reales Warten?\n\nUnd ich prüfe den Fehlerpfad, nicht nur den glücklichen. Viele Systeme sehen im Demo sauber aus. Interessant wird es bei Wiederholung, Teilfehlern, Netzwerkproblemen und menschlicher Korrektur. Ein häufiger Denkfehler bleibt, das Werkzeug mit der Lösung zu verwechseln: Rx ist kein Fachmodell, ein Scheduler ist kein Test, und ein grüner glücklicher Pfad ist kein Beweis. Die Werkzeuge können gute Grenzen ausdrücken, aber sie erzeugen sie nicht von selbst.\n\n## Was der Aufwand einbringt\n\nDer Nutzen lässt sich an zwei Dingen prüfen: Zeitabhängige Fälle laufen mit virtueller Zeit in Millisekunden statt in realen Warteintervallen, und Grenzwerte wie „bei 249 nichts, bei 250 feuern“ werden exakt beschrieben. Das beseitigt zeitbasierte Flakiness. Ein roter Lauf kann trotzdem aus Daten, geteilter Isolation oder externen Systemen stammen; der Scheduler löst diese Ursachen nicht. Relevant sind deshalb die Laufzeit genau dieser Tests und ihr Anteil an den instabilen CI-Läufen.\n\n## Schluss\n\nReactive Code wird nicht besser, indem man mit Operatorlisten beginnt. Er wird verständlicher, wenn die zeitliche Semantik explizit wird. Zeit vom versteckten Default zur injizierten Abhängigkeit zu machen kostet etwas Zeremonie, erlaubt aber schnelle Tests für Debounce, Timeout und Backoff. Mehr verspricht der Scheduler nicht."},{"slug":"graphql-ueberblick-vom-schema-zur-foederierten-api","title":"GraphQL-Überblick: Vom Schema zur föderierten API","summary":"Ein Durchgang durch GraphQL – vom typisierten Schema über Queries und Resolver bis zu Echtzeit, Betrieb und föderierter Architektur, eingeordnet am Stand von Oktober 2024.","tags":["Software","API","GraphQL"],"published_at":"2024-10-24T10:00:00+00:00","updated_at":"2026-07-19T18:46:18.505233+00:00","url":"https://www.mikebild.dev/de/blog/graphql-ueberblick-vom-schema-zur-foederierten-api/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/d4a8040788908086c662f67179997b125ba8197a999dff984dd5996d35b21231/graphql-ueberblick-vom-schema-zur-foederierten-api-vorlesen.mp3","audio_duration_secs":807,"text":"# GraphQL-Überblick: Vom Schema zur föderierten API\n\nEin typisiertes Schema und mindestens ein `Query`-Einstieg reichen, um sehr unterschiedliche Fragen an eine GraphQL-API zu stellen. Häufig laufen diese Operationen über einen HTTP-Endpoint; die GraphQL-Spezifikation selbst schreibt den Transport aber nicht vor. Die entscheidende Frage dahinter lautet, wer die Form der Antwort bestimmt. Bei einer klassischen REST-Schnittstelle definiert der Server die Repräsentation einer Route. GraphQL lässt den Client daraus die benötigten Felder auswählen. Die Kontrolle bleibt beim Backend, weil jede erlaubte Auswahl vorher im Schema festgeschrieben ist.\n\nDieser Beitrag geht den Weg vom Schema bis zur verteilten Architektur einmal ganz durch, damit man die Stationen kennt, bevor man sich in einer davon verliert. Zu einzelnen Stationen – wie genau die Ausführung im Inneren abläuft – schreibe ich eigene, tiefere Beiträge, etwa [GraphQL-Konzepte: Schema, Resolver, Ausführung](https://www.mikebild.dev/de/blog/graphql-konzepte-schema-resolver-ausfuehrung/). Hier geht es um das Ganze, mit dem Werkzeugstand von Oktober 2024: Apollo Server 4 dient als Node.js-Beispiel, und der Federation-Abschnitt bezieht sich auf Apollo Federation 2. GraphQL-over-HTTP war damals und ist auch im Juli 2026 noch eine Stage-2-Draft-Spezifikation; sie beschreibt die HTTP-Semantik für Queries und Mutations, nicht den Transport von Subscriptions.\n\nGraphQL verbindet mehrere Verantwortungen, ohne sie zu einem einzigen Baustein zu machen.\n\n:::relationship{title=\"Vom Bedarf zur aufgelösten Antwort\" role=\"primary\" preferredPattern=\"architecture-map\"}\n- Client-Operation · beschreibt die benötigte Form der Antwort\n- Schema · begrenzt Typen, Felder und zulässige Operationen\n- Ausführung · prüft und plant die angeforderte Auswahl\n- Resolver · löst genau ein Feld innerhalb seines Kontexts auf\n- Datenquelle · besitzt Daten und fachliche Regeln hinter dem Resolver\n- Gateway · kann mehrere Subgraphen unter einem Vertrag koordinieren\n:::\n\nDas Schema bleibt dabei Vertrag und Orientierungspunkt. Es ersetzt weder Fachlogik noch Datenbesitz der angeschlossenen Systeme.\n\n## GraphQL ist eine Spezifikation, kein Framework\n\nDer häufigste Irrtum am Anfang: GraphQL sei eine Bibliothek, die man installiert. Ist es nicht. GraphQL ist eine Spezifikation – sie beschreibt eine Query Language und wie ein Server eine solche Query auszuwerten hat. Es gibt Referenzimplementierungen wie `graphql-js`, und darauf setzen Server wie Apollo Server oder GraphQL Yoga auf, aber das Herzstück ist ein Vertrag, keine Runtime.\n\nAus dieser einen Tatsache folgt fast alles Weitere. Weil der Vertrag typisiert ist, kann der Client vor dem ersten Aufruf wissen, welche Felder es gibt und welchen Typ sie haben. Und weil der Server die Auswahl auswertet statt nur eine festgelegte Repräsentation auszuliefern, kann ein gemeinsamer HTTP-Endpoint viele verschiedene Fragen beantworten. Ein einzelner Endpoint ist die verbreitete Bereitstellung, keine Forderung der transportagnostischen Spezifikation.\n\n```mermaid\nflowchart LR\n  Client[\"Client<br/>formuliert die Frage\"] -->|eine Query| Endpoint[\"ein GraphQL-Endpunkt\"]\n  Endpoint --> Engine[\"Ausführung<br/>gegen das Schema\"]\n  Engine --> DB[(\"Datenbank\")]\n  Engine --> REST[\"REST-API\"]\n  Engine --> Service[\"anderer Dienst\"]\n  Engine -->|genau die angefragten Felder| Client\n```\n\nWichtig dabei: Hinter dem einen Endpoint kann alles Mögliche liegen. GraphQL ist keine Datenbank und ersetzt keine – es ist eine Schicht, die verschiedene Quellen unter einem gemeinsamen, typisierten Modell zusammenführt.\n\n## Das Schema zuerst\n\nAlles beginnt beim Schema. Es wird in der Schema Definition Language geschrieben, kurz SDL, und beschreibt die Typen und ihre Beziehungen. Ein kleines Beispiel aus der Welt eines Blogs:\n\n```graphql\ntype Author {\n  id: ID!\n  name: String!\n  posts: [Post!]!\n}\n\ntype Post {\n  id: ID!\n  title: String!\n  author: Author!\n}\n\ntype Query {\n  author(id: ID!): Author\n  posts: [Post!]!\n}\n```\n\nDrei Dinge lohnen den genauen Blick. Erstens die Modifizierer: Das Ausrufezeichen bedeutet „darf nicht null sein“, die eckigen Klammern bedeuten „Liste“. `[Post!]!` ist also eine nicht-nullbare Liste von nicht-nullbaren Posts – eine sehr präzise Aussage, die ein Client sich merken kann. Zweitens die Beziehungen: `Author` verweist auf `Post` und umgekehrt. Das Schema beschreibt keinen flachen Katalog, sondern einen Graphen aus Knoten und Kanten – daher der Name.\n\nDrittens, und das ist der Kern: `Query` ist ein besonderer Typ. Optional kommen `Mutation` fürs Schreiben und `Subscription` für laufende Ergebnisse hinzu. Diese Operationstypen sind die Entry Points in den Graphen. Was dort nicht als Feld steht, kann kein Client auswählen. Damit ist das Schema ein echter Vertrag: Es sagt verbindlich, welche Operationen und Antwortformen erlaubt sind.\n\nZwei Werkzeuge machen diesen Vertrag im Alltag deutlich leichter zu handhaben. Felder lassen sich mit `@deprecated(reason: \"…\")` als veraltet markieren, ohne sie sofort zu entfernen – Clients bekommen den Hinweis, brechen aber nicht. Und für Mutationen gibt es eigene Input-Typen, damit Eingaben nicht mit Ausgabetypen verwechselt werden. Beides sind kleine Zeichen dafür, dass GraphQL für langlebige APIs gedacht ist, nicht für den Wegwerf-Endpoint.\n\n## Die Abfrageseite: der Client fragt genau\n\nEine Query sieht dem Datenteil der Antwort ähnlich, den sie erzeugt. Man beschreibt die gewünschte Auswahl, und die erfolgreiche Antwort folgt dieser Form:\n\n```graphql\nquery AuthorWithPosts($id: ID!) {\n  author(id: $id) {\n    name\n    posts {\n      title\n    }\n  }\n}\n```\n\nDer Client fragt hier nach dem Namen eines Autors und den Titeln seiner Posts – und bekommt nur das. Kein überflüssiges Feld, keine zweite Runde für die Posts. Das `$id` ist eine Variable; Queries werden nicht durch String-Bastelei parametrisiert, sondern über typisierte Variablen, die der Server prüft.\n\nZwei weitere Bausteine tauchen schnell auf. Fragmente bündeln wiederkehrende Feldmengen, damit man sie nicht in jeder Query wiederholt. Und Introspection erlaubt es, das Schema selbst abzufragen – der Server beschreibt sich, weshalb Werkzeuge wie GraphiQL Autovervollständigung und Dokumentation ohne zusätzliche Konfiguration anbieten können. Der Vertrag ist also nicht nur verbindlich, er ist auch auslesbar.\n\n## Die Ausführung: Resolver für Resolver\n\nWie wird aus der Query eine Antwort? Der Server durchläuft die Query von oben nach unten und ruft für jedes Feld eine Funktion auf, den Resolver. Jeder Resolver bekommt vier Argumente in fester Reihenfolge: das Elternobjekt (`source`), die Argumente des Feldes (`args`), einen anfragebezogenen Kontext (`context`) und Informationen über den Ausführungszustand (`info`).\n\n```js\nconst resolvers = {\n  Query: {\n    author: (source, { id }, context) => context.db.loadAuthor(id),\n  },\n  Author: {\n    posts: (author, args, context) => context.db.loadPostsForAuthor(author.id),\n  },\n};\n```\n\nMan sieht das Prinzip: Der Resolver für `author` liefert einen Autor, und dessen Rückgabewert wird zum `source` für die Feld-Resolver darunter – der Resolver für `posts` bekommt den Autor als erstes Argument und lädt darüber seine Posts. So wandert die Ausführung als Kette durch den Graphen. Ein Resolver kann synchron, per Promise oder `async` arbeiten und dabei jede Datenquelle ansprechen – eine Datenbank, eine REST-API, einen anderen Dienst.\n\nGenau hier lauert die bekannteste Falle, das N+1-Problem. Fragt jemand zehn Autoren mit ihren Posts ab, läuft der `posts`-Resolver zehnmal und macht zehn einzelne Datenbankabfragen – plus die eine für die Autoren. Die Lösung heißt Batching: Man sammelt die zehn Anfragen innerhalb eines Ausführungstakts ein und beantwortet sie mit einer einzigen Query. Die dafür übliche Bibliothek ist DataLoader.\n\n```mermaid\nflowchart TB\n  subgraph Ohne[\"Ohne DataLoader (N+1)\"]\n    Q1[\"10 Autoren\"] --> R1[\"10× posts-Resolver\"]\n    R1 --> DB1[(\"10 einzelne Abfragen\")]\n  end\n  subgraph Mit[\"Mit DataLoader\"]\n    Q2[\"10 Autoren\"] --> R2[\"10× posts-Resolver\"]\n    R2 --> L[\"DataLoader sammelt IDs\"]\n    L --> DB2[(\"1 gebündelte Abfrage\")]\n  end\n```\n\nWie die Ausführung im Detail durch den Baum läuft, in welcher Reihenfolge Geschwisterfelder ausgewertet werden und warum Mutationen anders behandelt werden als Queries – das ist ein Thema für sich, dem ich im [Konzepte-Beitrag](https://www.mikebild.dev/de/blog/graphql-konzepte-schema-resolver-ausfuehrung/) einen eigenen, tieferen Abschnitt widme. Für diesen Überblick reicht: Eine Query ist ein Pfad durch den Graphen, und jeder Knoten auf dem Pfad wird von einem Resolver bedient.\n\n## Schreiben und Echtzeit\n\nLesen ist nur die halbe Geschichte. Mutationen sind der schreibende Teil und sehen aus wie Queries, tragen aber eine Absicht:\n\n```graphql\nmutation CreatePost($input: PostInput!) {\n  createPost(input: $input) {\n    id\n    title\n  }\n}\n```\n\nDer Unterschied ist mehr als kosmetisch: Auf oberster Ebene führt der Server Mutationen nacheinander aus, nicht parallel, damit sich zwei Schreibvorgänge nicht ins Gehege kommen. Und weil eine Mutation ein Ergebnis zurückgibt – hier den angelegten Post –, kann der Client seinen Zustand direkt aus der Antwort aktualisieren, ohne neu zu laden.\n\nFür laufende Aktualisierungen gibt es Subscriptions. Der Client abonniert ein Event, und der Server schickt neue Daten, sobald sie entstehen. Klassisch lief das über WebSockets; inzwischen ist auch der Transport über Server-Sent Events verbreitet, gerade wenn eine einfache, einseitige Verbindung genügt – dazu habe ich [an anderer Stelle](https://www.mikebild.dev/de/blog/graphql-subscriptions-ueber-sse/) mehr geschrieben. Der GraphQL-over-HTTP-Entwurf formuliert die HTTP-Semantik für Queries und Mutations aus, sodass Server und Clients verschiedener Hersteller verlässlicher zusammenspielen. Subscriptions liegen ausdrücklich außerhalb seines Geltungsbereichs.\n\n## Betrieb: Fehler, Paginierung, Auth, Caching\n\nZwischen dem ersten funktionierenden Schema und einer API, die in Produktion trägt, liegen ein paar Themen, die man nicht überspringen sollte.\n\nFehler behandelt GraphQL eigen: Eine Antwort kann gleichzeitig Daten und ein `errors`-Feld enthalten. Ein fehlgeschlagenes Teilfeld macht nicht die ganze Antwort kaputt – der Rest kommt trotzdem. Das ist mächtig, verlangt aber Disziplin, damit Clients erwartbare Fehler von echten Pannen unterscheiden können.\n\nPaginierung löst man selten über simple `offset`/`limit`-Argumente, sondern meist über [cursorbasierte Verbindungen](https://www.mikebild.dev/de/blog/graphql-paginierung-und-connections/) – das Connections-Pattern mit Kanten und Seiteninformationen, das aus der Relay-Welt stammt und sich als Konvention durchgesetzt hat. Es ist etwas umständlicher zu lesen, aber stabil, wenn sich die Liste unter den Fingern ändert.\n\n[Authentifizierung und Autorisierung](https://www.mikebild.dev/de/blog/graphql-auth-im-context/) sind nicht dasselbe. Die Identität wird häufig beim Aufbau des `context` ermittelt und von dort an die Ausführung weitergegeben. Die eigentlichen Zugriffsregeln können in Resolvern, Direktiven, der Domänenschicht oder der Datenzugriffsschicht liegen. Entscheidend ist, dass jede relevante Zugriffsstelle dieselbe Policy durchsetzt und nicht bloß darauf vertraut, dass ein Feld im Schema verborgen bleibt.\n\nCaching schließlich ist anders als bei vielen REST-APIs, bei denen URL und HTTP-Methode einen naheliegenden Cache-Schlüssel bilden. GraphQL über HTTP kann ebenfalls HTTP-Caching nutzen, doch unterschiedliche Operationen teilen häufig dieselbe Adresse und liegen oft im Request-Body. Clients wie [Apollo Client](https://www.mikebild.dev/de/blog/graphql-client-caching-mit-apollo/) ergänzen deshalb einen normalisierten Objekt-Cache: Objekte mit stabiler Identität lassen sich über mehrere Queries hinweg gemeinsam aktualisieren.\n\n## Architektur: eine Schicht, die aggregiert\n\nDamit sind wir bei der eigentlichen Stärke von GraphQL im Großen. Weil hinter dem Endpoint beliebige Quellen liegen können, eignet sich GraphQL hervorragend als Aggregationsschicht – ein Backend for Frontend, das mehrere Dienste, Datenbanken und fremde APIs unter einem Modell zusammenzieht, zugeschnitten auf das, was die Oberfläche wirklich braucht.\n\n```mermaid\nflowchart TB\n  App[\"Web- & Mobile-Client\"] --> GW[\"GraphQL-Gateway<br/>(föderiertes Schema)\"]\n  GW --> S1[\"Subgraph: Nutzer\"]\n  GW --> S2[\"Subgraph: Bestellungen\"]\n  GW --> S3[\"Subgraph: Katalog\"]\n  S1 --> DB1[(\"Nutzer-DB\")]\n  S2 --> DB2[(\"Bestell-DB\")]\n  S3 --> REST[\"Katalog-REST\"]\n```\n\nWird das Schema zu groß für ein einzelnes Team, kommt [Federation](https://www.mikebild.dev/de/blog/graphql-federation-und-subgraphs/) ins Spiel. Statt eines Monolithen betreibt jedes Team einen eigenen Teilgraphen (Subgraph), und ein Gateway setzt daraus für den Client ein einziges, zusammenhängendes Schema zusammen. Der Client merkt davon nichts – er sieht weiterhin einen Graphen. Federation ist heute der übliche Weg, wenn GraphQL organisatorisch skalieren muss; die ältere Schema-Stitching-Variante trifft man noch in Bestandssystemen.\n\nBei der Wahl des Servers hat sich das Feld sortiert. Apollo Server 4 ist die sichere Standardwahl für einen gewöhnlichen Node.js-Stack – das größte Ökosystem, gute Federation-Unterstützung, breite Verwendung in der Praxis. GraphQL Yoga ist die schlankere Alternative, besonders wenn man auf Edge-Runtimes deployt, wo Apollo Server zu schwergewichtig wäre. Beide setzen auf `graphql-js` in Version 16 als gemeinsamem Fundament.\n\n## Das Repository als roter Faden\n\nWer das alles anfassen will, findet in `introduction-graphql` die Stationen als einzelne Kapitel wieder – von `intro`, `architecture` und `principles` über `schema`, `types` und `resolver` bis zu `mutations`, `subscriptions`, `paging`, `auth` und `caching`. Dazu kommen ausführbare Beispiele: ein Apollo-Monorepo, ein Server in C# als Beleg dafür, dass GraphQL sprachunabhängig ist, und ein Ordner mit Rezepten für wiederkehrende Aufgaben – DataLoader, Paginierung, Auth, Optimistic UI, serverseitiges Rendern. Die Kapitel sind bewusst so geschnitten, dass jedes für sich lesbar ist, und genau so ist auch dieser Beitrag gedacht.\n\n## Was bleiben soll\n\nWenn von diesem Überblick ein Satz hängen bleiben soll, dann dieser: GraphQL ist zuerst eine Frage der API-Gestaltung und erst danach eine Frage des Werkzeugs. Das Schema ist der Vertrag, die Resolver sind die Umsetzung, und alles darüber – Federation, Caching, der konkrete Server – ist Ausführung dieser einen Idee, dass der Client seine Frage stellt und das Backend ein explizites Modell behält.\n\nDie Tools werden sich weiter ändern; Apollo Server 4 wird nicht die letzte Generation sein, und die nächste kommt bestimmt. Was bleibt, ist das typisierte Schema und die Ausführung entlang des Graphen. Zu einzelnen Stationen gibt es eigene Beiträge – wie die Ausführung im Inneren genau abläuft und wie man [ein Schema entwirft, das mitwächst](https://www.mikebild.dev/de/blog/graphql-schema-das-mitwaechst/).\n\n## Weiterführende Quellen\n\n- Repository introduction-graphql (Kapitel, Apollo-Beispiel, Rezepte): https://github.com/mikebild/introduction-graphql\n- Folienübersicht zu diesem Beitrag: https://slides.mikebild.dev/introduction-graphql.html\n- GraphQL, offizielle Einführung: https://graphql.org/learn/\n- GraphQL-Spezifikation: https://spec.graphql.org/\n- GraphQL over HTTP: https://graphql.github.io/graphql-over-http/\n- Apollo Server, Dokumentation: https://www.apollographql.com/docs/apollo-server\n- DataLoader (Batching gegen N+1): https://github.com/graphql/dataloader"},{"slug":"nodejs-http2-und-server-sent-events","title":"HTTP/2 und Server-Sent Events in Node.js","summary":"Wie HTTP/2 viele Streams über eine Verbindung multiplext und warum Server-Sent Events oft die einfachere Antwort auf Live-Daten sind als WebSocket – mit einem lauffähigen SSE-Endpoint in Node und den üblichen Fallstricken.","tags":["Software","Node.js","Backend"],"published_at":"2024-10-22T10:00:00+00:00","updated_at":"2026-07-13T08:02:43.199724+00:00","url":"https://www.mikebild.dev/de/blog/nodejs-http2-und-server-sent-events/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/b9da14985b6997092121acd341286840e049d4df27ec16303dc1ed2aeaeea001/nodejs-http2-und-server-sent-events-vorlesen.mp3","audio_duration_secs":956,"text":"WebSocket ist kein Protokoll für Live-Daten. Es ist ein bidirektionaler Kanal, der Live-Daten nebenbei mit erledigt – und wer ihn aufmacht, bezahlt den Rückkanal, ob er ihn nutzt oder nicht: Reconnect-Logik von Hand, Upgrade-Support an jeder Zwischenstation, eigene Timeouts für langlebige Verbindungen, im Cluster meist noch Sticky Sessions und eine Pub/Sub-Schicht. Bei einem Aktienticker, einem Build-Fortschritt oder einer Benachrichtigungsleiste ist von diesem Kanal genau eine Richtung überhaupt im Einsatz.\n\nDie Transportwahl ist damit eine Entwurfsentscheidung mit direkten Betriebs- und Testfolgen, keine Geschmackssache. Sie hängt an drei Achsen: der Richtung der Daten, der Betriebsumgebung und der Testbarkeit. Wer hier reflexhaft greift, zahlt später in mehr Code, mehr Infrastruktur und mehr selbstgebauter Error Handling – nicht beim ersten Endpoint, sondern bei jedem Feature, das danach an dem Kanal hängt. Bevor es um Server-Sent Events geht, lohnt ein kurzer Blick auf die Schicht darunter, denn HTTP hat sich in den letzten Jahren stärker verändert, als im Alltag ankommt.\n\n## Was HTTP/2 an der Grundlage ändert\n\nÜber Jahre war unser mentales Modell von HTTP denkbar einfach: eine Anfrage, eine Verbindung, eine Antwort. HTTP/1.1 hat das ein Stück weit aufgebohrt mit Keep-alive und Pipelining, aber im Kern blieb es dabei, dass eine TCP-Verbindung immer nur eine Anfrage gleichzeitig sinnvoll bedienen konnte. Der Browser hat das jahrelang mit roher Gewalt umgangen: Er öffnete pro Origin bis zu sechs parallele Verbindungen. Sechs. Wer schon einmal eine Seite mit hundert kleinen Assets gebaut hat, weiß, wie sich dieser Flaschenhals anfühlt.\n\nHTTP/2 dreht dieses Modell um. Statt vieler Verbindungen gibt es eine einzige, und über diese eine Verbindung laufen viele voneinander unabhängige Streams parallel – das nennt sich Multiplexing. Jeder Stream trägt genau eine Request/Response, und alle teilen sich denselben TCP-Tunnel. Damit verschwindet das Head-of-Line-Blocking auf Anwendungsebene, und die künstliche Sechser-Grenze pro Origin ist kein Thema mehr. Für gleichzeitig offene Streams sehen die meisten Implementierungen 100 vor (`SETTINGS_MAX_CONCURRENT_STREAMS`) – so empfiehlt es die HTTP/2-Spezifikation als Untergrenze –, und der Wert lässt sich konfigurieren.\n\n```mermaid\ngraph LR\n  subgraph HTTP1[\"HTTP/1.1 – sechs Verbindungen\"]\n    C1[Client] -->|Verbindung 1| S1[Server]\n    C1 -->|Verbindung 2| S1\n    C1 -->|Verbindung 3| S1\n    C1 -->|... bis 6| S1\n  end\n  subgraph HTTP2[\"HTTP/2 – eine Verbindung, viele Streams\"]\n    C2[Client] -->|Stream 1<br/>Stream 3<br/>Stream 5| S2[Server]\n  end\n```\n\nZwei weitere Dinge sind praktisch relevant. Zum einen komprimiert HTTP/2 Header per HPACK. Wer schon einmal mitgeschnitten hat, wie oft dieselben Cookie- und User-Agent-Header über die Leitung wandern, ahnt, wie viel Redundanz da steckt. HPACK hält dafür eine statische und eine dynamische Tabelle vor und referenziert wiederkehrende Header nur noch per Index statt sie erneut auszubuchstabieren.\n\nZum anderen gibt es Server Push – und hier bin ich inzwischen ausdrücklich zurückhaltend. Die Idee war charmant: Der Server schickt Ressourcen mit, von denen er weiß, dass der Client sie gleich braucht, bevor der überhaupt danach fragt. In der Praxis war das schwer richtig einzusetzen, oft hat es Bandbreite verschwendet, und Chrome hat HTTP/2 Server Push mit Version 106 im Jahr 2022 standardmäßig entfernt. Im Node-`http2`-Modul ist die Funktion weiterhin da, aber ich würde heute niemandem raten, eine Architektur darauf zu bauen. Wer Preloading möchte, ist mit `preload`-Hints und `103 Early Hints` besser bedient.\n\nEin wichtiger Punkt für den Browserkontext: HTTP/2 sprechen Browser praktisch ausschließlich über TLS mit ALPN-Aushandlung auf `h2`. Unverschlüsseltes h2c gibt es im Node-Modul zwar, aber für alles, was ein Browser anfassen soll, brauchst du Zertifikate.\n\n## Das http2-Modul in Node\n\nNode bringt HTTP/2 als eigenes Kernmodul mit, `node:http2`, stabil seit den frühen Node-10-Zeiten. Zum Veröffentlichungszeitpunkt dieses Beitrags waren 18, 20 und 22 die relevanten Release-Linien; im Juli 2026 sind 22 und 24 LTS, 26 ist Current. Das Modul steht ohne Flag bereit. Es gibt zwei Wege, damit zu arbeiten. Der erste ist die native Core-API rund um Streams:\n\n```js\nimport { createSecureServer } from 'node:http2';\nimport { readFileSync } from 'node:fs';\n\nconst server = createSecureServer({\n  key: readFileSync('localhost-key.pem'),\n  cert: readFileSync('localhost-cert.pem'),\n});\n\nserver.on('stream', (stream, headers) => {\n  const path = headers[':path'];\n  stream.respond({\n    ':status': 200,\n    'content-type': 'text/plain; charset=utf-8',\n  });\n  stream.end(`you requested ${path}`);\n});\n\nserver.listen(8443);\n```\n\nAuffällig sind die Pseudo-Header mit führendem Doppelpunkt und in Kleinbuchstaben: `:status`, `:method`, `:path`, `:scheme`, `:authority`. Sie sind der HTTP/2-Ersatz für die alte Statuszeile und die klassischen Request-Metadaten. Das `stream`-Event ist das Herzstück – für jeden eingehenden Stream bekommst du ein eigenes Duplex-Objekt.\n\nDer zweite Weg ist die Kompatibilitäts-API. Sie bildet `req`/`res` so nach, wie man sie aus dem alten `http`-Modul kennt, sodass bestehender HTTP/1-Code fast unverändert weiterläuft:\n\n```js\nimport { createSecureServer } from 'node:http2';\nimport { readFileSync } from 'node:fs';\n\nconst server = createSecureServer(\n  {\n    key: readFileSync('localhost-key.pem'),\n    cert: readFileSync('localhost-cert.pem'),\n  },\n  (req, res) => {\n    res.writeHead(200, { 'content-type': 'text/plain' });\n    res.end('ok');\n  },\n);\n\nserver.listen(8443);\n```\n\nAuf der Client-Seite verbindest du dich mit `http2.connect(authority)` und schickst Requests über `client.request({ ':path': '/' })`. Wer tiefer in die Node-Interna und das Zusammenspiel mit dem Event Loop einsteigen möchte, findet in meinem [Node.js-Training](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/) die passende Grundlage.\n\n## Server-Sent Events: der einseitige Stream\n\nJetzt zum eigentlichen Kern. Bei sehr vielen Live-Anwendungen fließen die Daten fast nur in eine Richtung: Der Server hat etwas Neues, und der Client soll es erfahren. Ein Aktienticker, ein Build-Fortschritt, eine Benachrichtigungsleiste, ein Live-Log. Für all das braucht man keinen bidirektionalen Kanal, keinen Protokoll-Upgrade und keine eigene Reconnect-Logik. Man braucht Server-Sent Events.\n\nSSE ist kein spezielles Modul und kein neues Protokoll. Es ist gewöhnliches HTTP mit einem bestimmten Content-Type: `text/event-stream`. Der Server hält die Antwort offen und schreibt in einem simplen Zeilenformat immer neue Events hinein. Der Browser hat mit `EventSource` einen nativen Client dafür, und – das ist der eigentliche Charme – dieser Client reconnectet von allein, wenn die Verbindung abreißt.\n\nDas Wire-Format ist bewusst schlicht. Eine Message besteht aus Feldern, jedes auf einer eigenen Zeile, und endet mit einer Leerzeile:\n\n```\ndata: hello world\n\nevent: tick\ndata: {\"count\":42}\nid: 42\n\n: this is a keep-alive comment\n\n```\n\nDie wichtigsten Felder sind `data:` für den Nutzinhalt, `event:` für einen benannten Event-Typ, `id:` für eine fortlaufende Kennung und `retry:` für das Reconnect-Delay in Millisekunden. Zeilen, die mit einem Doppelpunkt beginnen, sind Kommentare und eignen sich gut als Keep-alive-Ping. Entscheidend ist die doppelte Leerzeile, das `\\n\\n`, am Ende jeder Message – vergisst man sie, wird das Event schlicht nie ausgeliefert.\n\n```mermaid\nsequenceDiagram\n  participant B as Browser (EventSource)\n  participant S as Node Server\n  B->>S: GET /events\n  S-->>B: 200 text/event-stream\n  loop alle 1s\n    S-->>B: data: { ... }<br/>(offen gehaltene Antwort)\n  end\n  Note over B,S: Verbindung bricht ab\n  B->>S: GET /events<br/>Last-Event-ID: 42\n  S-->>B: 200 text/event-stream<br/>(setzt ab id 42 fort)\n```\n\n## Ein SSE-Endpoint in Node\n\nDer Code dafür ist erfreulich kurz. Wichtig sind die Response-Header, das sofortige Flushen und – das übersehen die meisten beim ersten Mal – das Aufräumen, wenn der Client die Verbindung schließt.\n\n```js\nimport { createServer } from 'node:http';\n\nconst server = createServer((req, res) => {\n  if (req.url !== '/events') {\n    res.writeHead(404).end();\n    return;\n  }\n\n  res.writeHead(200, {\n    'Content-Type': 'text/event-stream',\n    'Cache-Control': 'no-cache',\n    'Connection': 'keep-alive',\n    'X-Accel-Buffering': 'no',\n  });\n  res.flushHeaders();\n\n  let count = 0;\n  const timer = setInterval(() => {\n    count += 1;\n    const payload = { count, time: Date.now() };\n    res.write(`id: ${count}\\n`);\n    res.write(`event: tick\\n`);\n    res.write(`data: ${JSON.stringify(payload)}\\n\\n`);\n  }, 1000);\n\n  req.on('close', () => {\n    clearInterval(timer);\n  });\n});\n\nserver.listen(3000);\n```\n\nDer `req.on('close', ...)`-Handler ist nicht optional. Ohne ihn läuft das `setInterval` munter weiter, auch wenn der Browser die Seite längst geschlossen hat. Der Handler schreibt dann in eine tote Verbindung, der Timer bleibt hängen, und bei vielen Clients hast du ein handfestes Speicherleck. In Projekten ist das der Fehler, der mir am zuverlässigsten begegnet – der Endpoint funktioniert im Happy Path prima, und erst unter Last fällt auf, dass niemand aufräumt.\n\nAuf der Browserseite ist der Client fast schon unspektakulär:\n\n```js\nconst es = new EventSource('/events');\n\nes.onmessage = (event) => {\n  // fires for messages without an explicit event: field\n  console.log('default message', event.data);\n};\n\nes.addEventListener('tick', (event) => {\n  const payload = JSON.parse(event.data);\n  render(payload);\n});\n\nes.onerror = (err) => {\n  // EventSource reconnects on its own; log and let it recover\n  console.warn('connection lost, reconnecting…', err);\n};\n\n// later, when you no longer need it\n// es.close();\n```\n\nDer Punkt, der mir dabei am wichtigsten ist: Du musst den Reconnect nicht selbst bauen. `EventSource` merkt sich die letzte gesehene `id:` und schickt sie beim Wiederverbinden im Header `Last-Event-ID` mit. Wenn dein Server diesen Header auswertet, kann er nahtlos ab der letzten Message fortsetzen. Das `retry:`-Feld steuert, wie lange der Browser vor einem neuen Versuch wartet.\n\n## Der Fallstrick, der dich garantiert erwischt\n\nEs gibt einen Klassiker, und ich habe ihn oft genug live miterlebt, um ihn hier ausdrücklich hinzuschreiben: Reverse-Proxy-Buffering. In der Entwicklung läuft alles gegen den nackten Node-Prozess und funktioniert tadellos. Sobald ein nginx davorsteht, kommen die Events plötzlich nicht mehr einzeln im Sekundentakt an, sondern gebündelt und verzögert – oder scheinbar gar nicht. Der Grund: nginx puffert Response-Bodies per Default, und ein `text/event-stream` ist für den Puffer erst einmal nur ein Body, der noch nicht fertig ist.\n\nDie Lösung ist der Response-Header `X-Accel-Buffering: no`, den nginx erkennt und daraufhin für diese eine Antwort das Buffering abschaltet. Alternativ setzt man `proxy_buffering off;` in der nginx-Konfiguration. Deshalb steht dieser Header oben schon im Beispiel – nicht, weil er in jeder Umgebung nötig ist, sondern weil er dich vor genau dem Debugging-Nachmittag bewahrt, der sonst unweigerlich kommt.\n\nZwei weitere Stolpersteine gehören dazu. Erstens das schon erwähnte Format: Zeilenumbrüche innerhalb der Nutzdaten musst du entweder auf mehrere `data:`-Zeilen aufteilen oder vermeiden – ein rohes `\\n` mitten im JSON zerreißt die Message. Zweitens eine Einschränkung von `EventSource` selbst: Der native Client kann nur GET und keine eigenen Header setzen. Authentifizierung läuft deshalb typischerweise über Cookies oder einen Token im Query-String, nicht über einen `Authorization`-Header.\n\n## Wann SSE und wann WebSocket\n\nAusschlaggebend ist die Richtung der Daten. Die Gegenüberstellung, an der sich das festmachen lässt:\n\n- Richtung: SSE ist einseitig, Server zu Client. WebSocket ist voll bidirektional.\n- Protokoll: SSE ist gewöhnliches HTTP mit einem speziellen Content-Type. WebSocket braucht einen Protokoll-Upgrade der Verbindung.\n- Reconnect: Bei SSE eingebaut inklusive `Last-Event-ID`. Bei WebSocket baust du ihn selbst.\n- Nutzdaten: SSE transportiert ausschließlich UTF-8-Text. WebSocket kann Text und Binärdaten.\n- Infrastruktur: SSE läuft durch normale Proxies, Auth und Kompression. WebSocket braucht überall Upgrade-Support.\n- Typischer Einsatz: SSE für Notifications, Live-Feeds und Fortschrittsanzeigen. WebSocket für Chat, Spiele und kollaborative Editoren.\n\nWenn der Client im Wesentlichen nur zuhört, reicht SSE. Erst wenn du einen echten Rückkanal mit niedriger Latenz brauchst – der Nutzer tippt, klickt, bewegt eine Spielfigur – ist WebSocket die richtige Investition. Für den Rest ist SSE weniger Code, weniger Infrastruktur-Gefummel und weniger selbstgebaute Error Handling.\n\n## Transportwahl als Entwurfsentscheidung\n\nDie Gegenüberstellung oben beantwortet die Frage nach der Datenrichtung. In der Praxis entscheidet aber selten die Richtung allein, sondern was die Wahl im Betrieb und im Test kostet. Drei Achsen tragen die Entscheidung, und sie lassen sich in dieser Reihenfolge durchgehen.\n\nZuerst die Datenrichtung, weil sie am härtesten ist. Fließt der Verkehr im Wesentlichen vom Server zum Client, ist der Rückkanal von WebSocket totes Gewicht, das trotzdem betreut werden will. Fließt er in beide Richtungen mit niedriger Latenz, ist SSE das falsche Werkzeug, und jeder Versuch, den Rückkanal über einen zweiten HTTP-Request nachzubauen, wird schnell unangenehmer als ein sauberer WebSocket. Diese Achse ist keine Geschmacksfrage, sondern legt fest, welche Fähigkeit du überhaupt brauchst.\n\nDann die Betriebsumgebung, weil sie über die laufenden Kosten entscheidet. SSE ist gewöhnliches HTTP. Es läuft durch dieselben Reverse-Proxies, Load-Balancer, Auth-Layer und Kompressions-Stufen wie jeder andere Request, und das bestehende Monitoring sieht es ohne Sonderbehandlung. WebSocket verlangt an jeder Zwischenstation Upgrade-Support, eigene Timeouts für langlebige Verbindungen und oft Sticky Sessions, damit ein Client bei seinem Backend bleibt. Das ist alles beherrschbar, aber es ist zusätzliche Betriebsfläche, die jemand pflegen muss. Wer horizontal skaliert, merkt den Unterschied zuerst: Eine SSE-Verbindung ist ein Stream, den jeder Knoten beenden und der Client neu aufbauen kann, während ein WebSocket-Cluster in der Regel eine eigene Pub/Sub-Schicht braucht, damit ein Event auch den Knoten erreicht, auf dem der Empfänger hängt.\n\nZuletzt die Testbarkeit, weil sie die Entwicklungszyklen bestimmt. Einen SSE-Endpoint teste ich mit Werkzeugen, die ohnehin da sind. Ein `curl -N http://localhost:3000/events` zeigt den Stream roh im Terminal, und im Browser-Test reicht der native `EventSource`, um Events und Reconnect zu prüfen. Ein WebSocket-Kanal braucht dafür meist eine eigene Client-Bibliothek im Testaufbau und mehr Choreografie, um Handshake, bidirektionalen Fluss und den selbstgebauten Reconnect abzudecken. Das schlägt nicht bei der ersten Zeile durch, sondern bei jedem Feature danach, das an dem Kanal hängt.\n\nWirtschaftlich ist die Rechnung unspektakulär und genau deshalb wichtig. Die günstigere Transportwahl ist die, die weniger fremde Systeme berührt und sich mit Bordmitteln testen lässt. Für einen Live-Feed, einen Fortschrittsbalken oder eine Benachrichtigungsleiste heißt das fast immer SSE: kürzere Zyklen, weil weniger Infrastruktur im Spiel ist, und weniger Reibung im Betrieb, weil nichts Neues überwacht werden muss. Die Grenze ist ebenso klar. Sobald echte Interaktion mit Rückkanal ins Spiel kommt, kauft der WebSocket seine höheren Betriebskosten mit einer Fähigkeit zurück, die SSE schlicht nicht hat. Die Wahl bewusst zu treffen kostet einmal ein paar Minuten Nachdenken. Sie reflexhaft zu treffen kostet über die Lebensdauer des Systems deutlich mehr.\n\n## SSE und HTTP/2 zusammen\n\nUnd hier schließt sich der Kreis zum Anfang. SSE braucht kein HTTP/2 – es läuft völlig zufrieden über HTTP/1.1. Aber es profitiert davon. Über HTTP/1.1 zählt jede offene SSE-Verbindung gegen das Sechser-Limit des Browsers pro Origin. Wer in mehreren Tabs oder mit mehreren parallelen Streams arbeitet, rennt schnell dagegen. Über HTTP/2 sind offene SSE-Verbindungen dagegen Streams innerhalb einer Verbindung. Wie viele davon gleichzeitig offen sein dürfen, handelt die Gegenstelle über `SETTINGS_MAX_CONCURRENT_STREAMS` aus; RFC 9113 empfiehlt mindestens 100, garantiert diesen Wert aber nicht. Das starre HTTP/1.1-Limit wird dadurch meist deutlich weniger relevant.\n\nWeil SSE ein langlebiger Stream ist, der Daten in Häppchen ausliefert, lohnt sich außerdem ein Blick darauf, wie Node Streams intern behandelt und was passiert, wenn ein Client langsamer liest, als der Server schreibt. Wer viele Clients bedient, sollte Backpressure verstanden haben – dazu passt mein Beitrag zu [Node.js-Streams und Backpressure](https://www.mikebild.dev/de/blog/nodejs-streams-und-backpressure/).\n\nHTTP/2 hat die Transportschicht so weit entspannt, dass viele alte Optimierungstricks überflüssig geworden sind, und Server-Sent Events bringen genau so viel Komplexität mit, wie ein einseitiger Stream nun einmal braucht – keinen Deut mehr. Die Grenze bleibt dabei scharf: Sobald die Daten wirklich in beide Richtungen fließen, ist WebSocket die richtige Investition. Nur fließen sie seltener in beide Richtungen, als der erste Reflex glauben macht. Wer die drei Achsen einmal durchgeht, bevor er sich festlegt, spart sich im günstigen Fall eine komplette Pub/Sub-Schicht.\n\n## Weiterführende Quellen\n\n- [Node.js-Training](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/) und [Node.js-Streams und Backpressure](https://www.mikebild.dev/de/blog/nodejs-streams-und-backpressure/) – die passenden Grundlagen aus meinem Blog.\n- [Node.js HTTP/2-Modul](https://nodejs.org/api/http2.html) – Core-API, Kompatibilitäts-API, `createSecureServer`, `pushStream`.\n- [MDN: Using server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) – SSE in der Praxis.\n- [MDN: EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) – das EventSource-Interface im Detail.\n- [WHATWG: Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html) – die Spezifikation des `text/event-stream`-Formats.\n- [RFC 9113 (HTTP/2)](https://www.rfc-editor.org/rfc/rfc9113) und [RFC 7541 (HPACK)](https://www.rfc-editor.org/rfc/rfc7541) – die zugrunde liegenden Standards."},{"slug":"markdown-reports-statt-dashboard","title":"Markdown Reports statt Dashboard-Zwang","summary":"Warum ein diffbarer Markdown Report für Nachvollziehbarkeit, Review und Betrieb oft mehr leistet als ein Dashboard – und wie ich das architektonisch aufziehe.","tags":["Software","Content","Publishing"],"published_at":"2024-10-12T10:00:00+00:00","updated_at":"2026-07-20T13:27:22.94021+00:00","url":"https://www.mikebild.dev/de/blog/markdown-reports-statt-dashboard/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/0bb356437d075e25eba82605b63c31743094ae4d5bff2ed6e55ee4f11720f891.mp3","audio_duration_secs":722,"text":"# Markdown Reports statt Dashboard-Zwang\n\nWer eine Auswertung nur als Dashboard baut, kann ein halbes Jahr später nicht mehr belegen, was zum Zeitpunkt der Freigabe darin stand. Die Ansicht existiert noch, die Zahlen sind aktuell – nur eben nicht mehr dieselben. Der übliche Notbehelf ist ein Screenshot, und der leistet genau das, was ein Screenshot eben leistet: nicht prüfbar, nicht durchsuchbar, nicht vergleichbar.\n\nEin Markdown Report kostet an derselben Stelle fast nichts. Er friert einen Stand ein, er besteht aus Text und ist damit diffbar, und er überlebt das System, das ihn erzeugt hat. Dieser Beitrag ist kein Loblied auf Textdateien. Er beschreibt eine architektonische Entscheidung – Report als versioniertes Artefakt statt als Live-View – und was diese Entscheidung für Review-Zyklen, Betrieb und Qualität konkret verändert.\n\nDie Formatfrage wird klarer, sobald der Leserzweck vor der Oberfläche steht.\n\n::::comparison{title=\"Live beobachten oder einen Stand belegen\" role=\"primary\" preferredPattern=\"split-comparison\"}\n:::side{label=\"Dashboard\"}\n- Frage · Was geschieht gerade?\n- Zustand · wird bei jedem Aufruf neu berechnet\n- Stärke · schnelle Reaktion auf laufende Veränderungen\n- Grenze · der frühere Stand ist ohne zusätzliches Archiv nicht belegt\n:::\n:::side{label=\"Veröffentlichter Report\"}\n- Frage · Was galt für den abgeschlossenen Zeitraum?\n- Zustand · immutable, versioniertes Artefakt\n- Stärke · prüfbar, diffbar und langfristig lesbar\n- Grenze · keine interaktive Echtzeitanalyse\n:::\n::::\n\nDie beiden Formen konkurrieren damit nicht um dieselbe Aufgabe. Sie werden erst dann verwechselt, wenn ein historischer Nachweis wie eine Live-Ansicht behandelt wird.\n\n## Die eigentliche Frage: flüchtiger Zustand oder Artefakt\n\nEin Dashboard beantwortet die Frage „Wie sieht es gerade aus?“. Das ist wertvoll, wenn ich einen laufenden Prozess überwache und in Sekunden reagieren muss – Latenzen, Fehlerraten, Auslastung. Für Operations ist ein Dashboard oft konkurrenzlos.\n\nDer Denkfehler beginnt, wenn dasselbe Muster auf Auswertungen angewendet wird, deren eigentlicher Wert in der Nachvollziehbarkeit liegt: eine Inventur, ein Abgleich, ein Publishing-Lauf, ein Datenmigrations-Report. Hier ist die interessante Frage nicht „Wie sieht es jetzt aus?“, sondern „Wie sah es zum Zeitpunkt X aus, warum, und was hat sich seitdem geändert?“. Ein Dashboard kann das strukturell nicht beantworten, weil es keinen festen Stand hat. Es rendert bei jedem Aufruf neu. Der Zustand von letzter Woche ist weg, sobald sich die Datenbasis dreht.\n\nEin Markdown Report zieht die Grenze anders. Er friert einen Stand ein, ist textuell, damit diffbar, und lässt sich als Datei ablegen, versionieren und weiterreichen. Drei Eigenschaften fallen dabei praktisch ins Gewicht:\n\nNachvollziehbarkeit heißt, dass der Report seinen eigenen Kontext trägt – Eingabestand, Regeln, Ergebnis, Ausnahmen. Wer ihn in sechs Monaten öffnet, muss nicht das erzeugende System rekonstruieren.\n\nDiffbarkeit heißt, dass zwei Läufe sich vergleichen lassen, ohne dass jemand zwei Screenshots nebeneinanderlegt. Ein `git diff` zeigt in Sekunden, welche Position neu ist, welcher Match gekippt ist, welche Zeile verschwunden ist. Review wird damit vom Interpretationsakt zum mechanischen Vorgang.\n\nArchivierbarkeit heißt, dass der Report ohne die erzeugende Infrastruktur überlebt. Kein laufender Dienst, keine Datenbankverbindung, kein Auth-Token. Eine Textdatei in einem Repository ist in zehn Jahren noch lesbar.\n\n## Der Report als Architekturartefakt\n\nAm deutlichsten wird das an einem Werkzeug, das ich für einen Inventur- und Kontenabgleich gebaut habe. Aufgabe: Inventarpositionen gegen Rechnungen und Buchungen matchen, Belege den richtigen Vorgängen zuordnen, Lücken sichtbar machen. Die naheliegende Lösung wäre ein Dashboard mit ein paar grünen und roten Kacheln gewesen. Ich habe stattdessen den Report zum zentralen Artefakt gemacht – und die gesamte Architektur darauf ausgerichtet.\n\nTragend ist eine Dreistufigkeit, die unabhängig vom Dateiformat gilt: Kontoblatt über Rechnungsnummern an PDFs koppeln, Inventarpositionen gegen Rechnungspositionen matchen, Nicht-PDF-Vendors separat über Buchungstext-Regeln behandeln. Diese Stufen sind die eigentliche Fachlogik. Das Ausgabeformat ist die Konsequenz, nicht die Entscheidung.\n\nInnerhalb der Matching-Stufe trenne ich harten Match und Fuzzy-Fallback strikt. Erst müssen definierte Tokens im Produkttitel stehen; erst wenn der harte Weg nichts findet, greift Fuzzy Matching. An dieser Reihenfolge hängt, ob die Automatisierung erklärbar bleibt. Jede Zeile im Report kann sagen, warum sie so zugeordnet wurde – `exact` oder `fuzzy` – und ein Mensch kann genau die zweite Kategorie gezielt prüfen.\n\n```ts\ntype MatchKind = \"exact\" | \"fuzzy\" | \"orphan\";\n\ntype ReconciliationRow = {\n  inventoryItem: string;\n  invoiceNumber: string | null;\n  purchaseDate: string | null;\n  matchKind: MatchKind;\n  confidence: number; // 0..1, only meaningful for fuzzy\n  note: string;\n};\n```\n\nDie OCR-Schicht behandle ich bewusst nicht als Wahrheit, sondern als Sensor. Apple Vision liefert die Basis, Tesseract korrigiert gezielt Beträge in der Soll/Haben-Region, und ein JSON-Cache macht spätere Läufe reproduzierbar. Das ist die architektonische Pointe: Ein Sensor hat einen Messfehler, und sobald der im Modell steht, wird der Umgang mit Unsicherheit ein expliziter Teil des Systems statt einer versteckten Annahme. Der Cache dient dabei nicht der Geschwindigkeit; er garantiert Determinismus – derselbe Beleg ergibt denselben Report.\n\nDas Ergebnis ist ein Report, der Inventarpositionen, Rechnungsnummern, Kaufdatum, Match-Art, verwaiste Kontoeinträge und ungenutzte Rechnungspositionen sichtbar macht:\n\n```md\n## Reconciliation 2024-10-12\n\n| item              | invoice | date       | match  | note                  |\n|-------------------|---------|------------|--------|-----------------------|\n| ThinkPad X1       | R-1042  | 2024-03-11 | exact  | token \"X1\" matched    |\n| USB-C Hub         | R-1043  | 2024-03-11 | fuzzy  | 0.82, title reworded  |\n| Monitor Stand     | —       | —          | orphan | no invoice position   |\n\nOrphans: 1 · Fuzzy: 1 · Unused invoice lines: 3\n```\n\nGenau dadurch wird aus einem Skript ein prüfbares Datensystem. Die verwaisten Einträge – der eigentlich interessante Teil – stehen nicht in einem Log, das niemand liest, sondern im selben Artefakt wie die erfolgreichen Matches. Ein Buchhalter kann diesen Report abzeichnen, ablegen und im nächsten Jahr gegen den neuen halten. Ein Dashboard hätte dieselbe Information gezeigt und beim nächsten Reload vergessen.\n\nDer konkrete Fall, der mich von diesem Zuschnitt überzeugt hat, war unspektakulär und genau deshalb lehrreich. Ein Vendor hatte auf einer Rechnung denselben Artikel zweimal gelistet, einmal als Sammelposten und einmal einzeln. Der harte Match griff beide Male, und in einer Kachelansicht wäre das als „alles grün“ durchgelaufen. Im diffbaren Report fiel es auf, weil dieselbe Rechnungsnummer plötzlich in zwei Zeilen mit unterschiedlichem Kaufdatum stand – eine Anomalie, die man beim Lesen sieht und beim Rendern übersieht. Der Fehler lag in den Daten, nicht im Code; der Report machte ihn zu einer sichtbaren Zeile statt zu einer stillen Doppelbuchung. Das ist für mich der Kern: Ein Artefakt, das man liest, deckt eine andere Klasse von Fehlern auf als eine Ansicht, die man nur überfliegt.\n\n## Publishing als dieselbe Bewegung\n\nDasselbe Prinzip trägt in einem ganz anderen Kontext, dem Publishing. Hier ist Markdown die Eingabe, aber der Weg vom Text zur veröffentlichten Seite ist kein magischer CMS-Klick. Ich zerlege ihn in getrennte, benennbare Zustände: Markdown als Eingabe, Revision als unveränderbarer Stand, Preview als Vertrauensschritt, Release als aktivierter Pointer, Rollback als kontrollierte Rückkehr.\n\n```mermaid\nflowchart LR\n  Markdown[Markdown input] --> Revision[Immutable revision]\n  Revision --> Preview[Preview build]\n  Preview --> Release[Release pointer]\n  Release --> Live[Live site]\n  Release -.rollback.-> Revision\n```\n\nDer entscheidende Trade-off steckt in der Trennung von Build und Aktivierung. Eine Revision wird gebaut und geprüft, bevor sie sichtbar wird. Aktivierung ist dann nur ein Pointer-Wechsel – und Rollback ebenfalls. Das nimmt dem Release den Nervenkitzel: Wer exakt sieht, was veröffentlicht wird, und weiß, dass ein Fehler ein Pointer-Wechsel zurück ist, braucht weniger Mut und produziert weniger Hotfixes um Mitternacht. Immutable Releases zahlen sich genau hier aus: Die Kosten eines Fehlers verschieben sich von „Rekonstruktion unter Druck“ auf „ein Kommando“.\n\nDass die Eingabe Markdown ist, hält die ganze Kette portabel. Inhalt bleibt getrennt von Darstellung und Betrieb. Aus demselben Text kann ich später eine Präsentation erzeugen oder ihn in ein anderes Zielsystem exportieren, ohne ihn neu zu schreiben. Für persönliche Seiten und technische Portfolios ist dieser Zuschnitt fast immer wertvoller als ein großes CMS – entscheidend sind Vorschau, atomare Veröffentlichung, stabile URLs und ein klarer Exportpfad, nicht der Funktionsumfang eines Editors.\n\n## Die Entscheidung vor dem Format\n\nBeim Abgleich sollen Lücken sichtbar und korrigierbar bleiben; beim Publishing soll ein Entwurf kontrolliert veröffentlicht und zurückgenommen werden können. Daraus folgen die Grenzen: OCR ist ein Sensor, Matching bleibt Fachlogik, Revision und Release sind getrennte Zustände. Erst dann entscheide ich über das Format. Für einen prüfbaren Stand passt ein versioniertes Artefakt, für laufende Beobachtung ein Dashboard.\n\n## Was das messbar verändert\n\nDie Wirkung dieser Entscheidung ist nicht nur ästhetisch, sie ist im Arbeitsalltag messbar.\n\nReview-Zyklen werden kürzer, weil Diff die Interpretation ersetzt. Statt zwei Stände visuell zu vergleichen, sieht ein Reviewer die drei geänderten Zeilen. Was vorher eine halbstündige Sichtung war, wird ein Blick auf einen Pull Request.\n\nReibung sinkt, weil Freigaben an einem konkreten Artefakt hängen statt an einer flüchtigen Ansicht. Ein abgezeichneter Report ist ein Dokument mit Zeitstempel. Eine abgenickte Dashboard-Ansicht ist eine Erinnerung.\n\nQualität steigt, weil der Fehlerpfad Teil der Ausgabe ist. Verwaiste Einträge, Fuzzy-Matches und ungenutzte Positionen stehen im selben Artefakt wie die Erfolge. Man muss nicht danach suchen; man sieht sie beim Lesen. Und beim Publishing verschiebt Rollback-als-Pointer die mittlere Fehlerbehebungszeit von Minuten unter Druck auf ein einzelnes Kommando.\n\nDiese Effekte sind der eigentliche Business-Outcome: schneller prüfen, weniger streiten, Fehler früher finden. „Wir haben ein schönes Dashboard“ steht auf dieser Liste nicht.\n\n## Wo ich beim Dashboard bleibe\n\nIch will die Entscheidung nicht dogmatisch machen. Für echtes Monitoring – Live-Metrics, Alerts, Zeitreihen mit Sekundenauflösung – ist ein Dashboard das richtige Werkzeug, und ein Markdown Report wäre absurd. Die Trennlinie ergibt sich aus der Natur der Frage, nicht aus einer Glaubensfrage Report gegen Dashboard: kontinuierliche Beobachtung eines laufenden Systems gegen prüfbaren Stand einer abgeschlossenen Auswertung. Viele Teams greifen zum Dashboard, weil es der Default ist, obwohl ihre Frage eindeutig zur zweiten Kategorie gehört. Genau diesen reflexartigen Default will ich aufbrechen.\n\n## Der Fehlerpfad als Prüfstein\n\nEine Idee ist erst belastbar, wenn auch ihr Fehlerpfad beschrieben ist. Beim Abgleich frage ich: Was passiert bei Wiederholung? Ist der Lauf idempotent? Der JSON-Cache sorgt dafür – zweimal derselbe Beleg, zweimal dasselbe Ergebnis. Beim Publishing: Kann ich das Schema erweitern, ohne bestehende Seiten zu brechen? Kann ich einen fehlerhaften Release erklären und zurücknehmen? Wenn die Antwort nein wäre, fehlte noch ein Teil der Architektur, egal wie sauber der glückliche Pfad aussieht.\n\nDer häufigste Fehler dabei ist, das Werkzeug mit der Lösung zu verwechseln. Markdown ist keine Architektur, ein CMS ist kein Publishing-Prozess, und OCR ist kein Wahrheitsbeweis. Diese Werkzeuge können gute Grenzen ausdrücken, aber sie erzeugen sie nicht von selbst. Die Grenze zieht man vorher, im Modell – und der Report macht sie nur sichtbar.\n\n## Schluss\n\nDer Reflex „bauen wir ein Dashboard“ ist so eingeübt, dass die Alternative kaum noch bewusst geprüft wird. Dabei ist ein Markdown Report in erstaunlich vielen Fällen die stärkere Wahl: diffbar, archivierbar, selbsterklärend, und damit ein echtes Artefakt statt eines flüchtigen Fensters. Der eigentliche Gewinn steckt nicht im Format. Er steckt in der Grenze, die es zieht – zwischen dem, was gerade passiert, und dem, was nachvollziehbar bleiben muss. Wer diese Grenze bewusst setzt, prüft schneller, veröffentlicht ruhiger und findet Fehler dort, wo sie noch billig sind.\n\n## Weiterführende Quellen\n\n- [CommonMark-Spezifikation 0.31.2](https://spec.commonmark.org/0.31.2/)\n- [Git: `git diff`](https://git-scm.com/docs/git-diff/2.45.0)"},{"slug":"graphql-federation-und-subgraphs","title":"GraphQL Federation: ein Schema aus vielen Subgraphs","summary":"Wenn ein Team ein Monolith-Schema nicht mehr allein tragen kann, teilt Federation den Graphen in Subgraphs auf, die ein Router zu einem Supergraph komponiert. Dieser Artikel zeigt an einem User- und Orders-Beispiel, wie @key und __resolveReference Entities über Subgraph-Grenzen hinweg zusammenhalten.","tags":["Software","GraphQL","API"],"published_at":"2024-09-24T10:00:00+00:00","updated_at":"2026-07-13T08:52:54.757214+00:00","url":"https://www.mikebild.dev/de/blog/graphql-federation-und-subgraphs/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/fbc01d1fa1a191f4cb238d7fcd765a483bd7e4c330c40f68533960b8151090db/graphql-federation-und-subgraphs-vorlesen.mp3","audio_duration_secs":696,"text":"```bash\n$ rover supergraph compose --config ./supergraph.yaml\n\nerror[E029]: Encountered 1 build error while trying to build a supergraph.\n\nCaused by:\n    INVALID_GRAPHQL: [orders] Unknown directive \"@key\".\n```\n\nDiese Meldung kommt nicht aus einem laufenden Server, sondern aus einem Build-Schritt, den ein einzelner GraphQL-Service gar nicht kennt: der Komposition. Der Orders-Service benutzt `@key`, ohne die Direktive vorher importiert zu haben – und dieser eine fehlende Eintrag reicht, damit aus mehreren Schemas kein gemeinsamer Graph wird.\n\n`@key` ist die Direktive, mit der Apollo Federation einen Typ über Service-Grenzen hinweg referenzierbar macht. Daran hängt alles Weitere: wer welchen Typ besitzt, wer welche Felder beisteuert, wer die Teile zur Antwort zusammensetzt – und die Frage, wie man ein Schema auseinanderschneidet, an dem drei Teams arbeiten, ohne dass die Clients davon etwas merken. Zu früh gezogen kostet dieser Schnitt allerdings mehr, als er einbringt; darauf komme ich am Schluss zurück.\n\n## Das Problem: ein Schema, das niemandem mehr gehört\n\nAm Anfang ist ein GraphQL-Schema wunderbar. Ein Team, ein Repository, ein `type Query`, alles liegt beieinander. Der Reiz von GraphQL ist ja gerade, dass Clients sich aus einem einzigen Endpoint genau die Felder holen, die sie brauchen. Verwandte Fragen habe ich in früheren Beiträgen schon behandelt – etwa wie man Listen mit [GraphQL-Paginierung](https://www.mikebild.dev/de/blog/graphql-paginierung-und-connections/) sauber ausliefert oder wie Berechtigungen über [GraphQL-Auth im Context](https://www.mikebild.dev/de/blog/graphql-auth-im-context/) durchgereicht werden.\n\nDas Problem beginnt mit dem Wachstum. Sobald mehrere Teams an einem Schema arbeiten, wird die Datei zum Nadelöhr. Das User-Team will ein Feld an `type Order` hängen, muss dafür aber in den Code des Order-Teams greifen. Deployments koppeln sich aneinander. Ein `type` gehört plötzlich vielen, und „vielen gehören“ heißt in der Praxis: niemandem. Was im Alltag als Merge-Konflikt auftaucht, ist ein Zuständigkeitskonflikt.\n\nFederation dreht die Frage um. Statt „Wie teilen sich Teams ein Schema?“ fragt sie: „Wie betreibt jedes Team sein eigenes Schema – und der Client sieht trotzdem nur einen Graphen?“\n\n## Die Grundidee: Subgraphs, Supergraph, ein Endpoint\n\nIn Apollo Federation 2 – das ist die Generation, die seit 2022 relevant ist und die ich hier durchgehend meine – betreibt jedes Team einen **Subgraph**. Ein Subgraph ist ein ganz normaler GraphQL-Service mit eigenem Schema, eigener Datenbank, eigenem Deployment. Die einzelnen Subgraph-Schemas werden zu einem **Supergraph-Schema** komponiert. Zur Laufzeit sitzt davor ein Router (oder ein Gateway), der die eingehende Query entgegennimmt, einen Plan erstellt und die Teilfragen an die zuständigen Subgraphs verteilt.\n\nDer entscheidende Punkt: Der Client merkt von all dem nichts. Er spricht mit einem Endpoint, sieht ein Schema, stellt eine Query. Die Aufteilung ist reine Betriebs- und Team-Topologie – für den Konsumenten unsichtbar.\n\n```mermaid\nflowchart TB\n  Client[\"Client<br/>eine Query, ein Endpoint\"]\n  Router[\"Router / Gateway<br/>führt den Query-Plan aus\"]\n  Users[\"Users-Subgraph<br/>Team A\"]\n  Orders[\"Orders-Subgraph<br/>Team B\"]\n  UDB[(\"Users-DB\")]\n  ODB[(\"Orders-DB\")]\n\n  Client --> Router\n  Router --> Users\n  Router --> Orders\n  Users --> UDB\n  Orders --> ODB\n```\n\nWer die Laufzeit betreibt, hat 2024 zwei etablierte Optionen: den **`@apollo/gateway`** in Node oder den in Rust geschriebenen **Apollo Router**. Beide sind gültig. Der Router ist der performantere Weg und die übliche Empfehlung für neue Setups, aber das Gateway ist keineswegs abgelöst – wer schon ein Node-Gateway betreibt, muss nicht umziehen.\n\n## Rollen im System\n\nBevor Code entsteht, hilft es, die drei Bausteine sauber auseinanderzuhalten:\n\n- Der **Subgraph** trägt genau seine eigenen Felder bei. Er kennt seine Datenbank, sein Schema, sein Deployment – und sonst nichts. Team-Autonomie steckt hier.\n- Die Direktive **`@key`** markiert einen Typ als Entity, also als subgraph-übergreifend referenzierbar. Ohne `@key` kann ein anderer Subgraph den Typ weder erweitern noch per Referenz auf ihn zeigen.\n- Der **Router** plant die Query. Er zerlegt eine eingehende Anfrage in Teilfragen, holt sich bei jedem Subgraph dessen Beitrag und fügt die Antwort wieder zusammen. Cross-Subgraph-Joins sind seine Aufgabe, nicht die der Teams.\n\nDiese Trennung ist der ganze Trick: Teams denken lokal, der Router denkt global.\n\n## Fed 2 ist explizit: das @link-Opt-in\n\nEin Subgraph in Federation 2 sagt ausdrücklich, welche Federation-Direktiven er nutzen will. Das geschieht über `@link` am Schema. Das ist ein bewusster Bruch mit Federation 1, wo `extend type ... @key` implizit ein bestimmtes Verhalten mitbrachte. In Fed 2 importiert man, was man braucht:\n\n```graphql\nextend schema\n  @link(\n    url: \"https://specs.apollo.dev/federation/v2.0\"\n    import: [\"@key\", \"@shareable\", \"@external\"]\n  )\n```\n\nWer das `@link` vergisst oder eine Direktive nutzt, die nicht importiert wurde, bekommt keinen Laufzeitfehler, sondern einen Kompositionsfehler – und zwar früh, beim Komponieren oder Publizieren. Das überrascht Fed-1-Umsteiger regelmäßig, ist aber der bessere Zeitpunkt für einen Fehler.\n\n## Der Users-Subgraph\n\nFangen wir mit dem einfachen Teil an. Der Users-Subgraph besitzt die `User`-Entity. Er definiert sie, markiert sie mit `@key` und liefert einen Reference-Resolver, der eine `User`-Repräsentation auflöst.\n\n```graphql\nextend schema\n  @link(url: \"https://specs.apollo.dev/federation/v2.0\", import: [\"@key\"])\n\ntype User @key(fields: \"id\") {\n  id: ID!\n  name: String!\n  email: String!\n}\n\ntype Query {\n  user(id: ID!): User\n}\n```\n\nDer Code dazu nutzt `buildSubgraphSchema` aus dem Paket `@apollo/subgraph`. Das ist der Baustein, der aus `typeDefs` und `resolvers` ein federiertes `GraphQLSchema` macht, das die Federation-Direktiven und die von der Spec verlangten Felder wie `_entities` kennt.\n\n```ts\nimport { ApolloServer } from '@apollo/server';\nimport { startStandaloneServer } from '@apollo/server/standalone';\nimport { buildSubgraphSchema } from '@apollo/subgraph';\nimport gql from 'graphql-tag';\n\nconst typeDefs = gql`\n  extend schema\n    @link(url: \"https://specs.apollo.dev/federation/v2.0\", import: [\"@key\"])\n\n  type User @key(fields: \"id\") {\n    id: ID!\n    name: String!\n    email: String!\n  }\n\n  type Query {\n    user(id: ID!): User\n  }\n`;\n\nconst resolvers = {\n  Query: {\n    user: (_parent, { id }) => fetchUserById(id),\n  },\n  User: {\n    // The router hands us a representation: { __typename: \"User\", id }\n    __resolveReference: (reference) => fetchUserById(reference.id),\n  },\n};\n\nconst server = new ApolloServer({\n  schema: buildSubgraphSchema({ typeDefs, resolvers }),\n});\n\nconst { url } = await startStandaloneServer(server, { listen: { port: 4001 } });\nconsole.log(`Users subgraph ready at ${url}`);\n```\n\nDas Herzstück ist `__resolveReference`. Diese Funktion bekommt eine **Representation** – ein Objekt mit `__typename` und den `@key`-Feldern – und liefert die vollständige Entity zurück (oder `null`, wenn es sie nicht gibt). Wichtig zu verstehen: Ein normaler Feld-Resolver reicht hier nicht, weil der Router den Auflösungsprozess nur mit den Key-Feldern startet. Ohne Reference-Resolver kann er die Lücke nicht füllen.\n\n## Der Orders-Subgraph mit Cross-Reference\n\nJetzt wird es interessant. Der Orders-Subgraph besitzt `Order`. Eine Order gehört einem User – aber der Orders-Subgraph kennt von `User` nur die ID. Er will trotzdem ein Feld `order.user` anbieten. Dafür nimmt er `User` als Stub auf: mit `@key`, aber nur mit dem Key-Feld, ohne eigene Auflösung der übrigen Felder.\n\n```graphql\nextend schema\n  @link(url: \"https://specs.apollo.dev/federation/v2.0\", import: [\"@key\"])\n\ntype Order @key(fields: \"id\") {\n  id: ID!\n  total: Int!\n  user: User\n}\n\n# User lives in the users subgraph; here it is only referenced.\ntype User @key(fields: \"id\") {\n  id: ID!\n}\n\ntype Query {\n  order(id: ID!): Order\n}\n```\n\nDer Resolver für `Order.user` macht etwas, das anfangs fast zu simpel wirkt: Er gibt nur eine Referenz zurück – `__typename` plus Key-Feld. Die eigentliche Auflösung von `name` oder `email` passiert später im Users-Subgraph.\n\n```ts\nconst resolvers = {\n  Query: {\n    order: (_parent, { id }) => fetchOrderById(id),\n  },\n  Order: {\n    __resolveReference: (reference) => fetchOrderById(reference.id),\n    // Return just a reference; the users subgraph fills in the rest.\n    user: (order) => ({ __typename: 'User', id: order.userId }),\n  },\n};\n```\n\nSo bleibt jede Verantwortung dort, wo sie hingehört. Das Orders-Team weiß, welcher User zu einer Order gehört (die `userId` liegt in seiner DB), aber es muss die User-Daten nicht selbst kennen. Der Router übernimmt den Rest.\n\n## Was der Router im Hintergrund tut\n\nNehmen wir eine Client-Query, die beides überspannt:\n\n```graphql\nquery {\n  order(id: \"42\") {\n    total\n    user {\n      name\n    }\n  }\n}\n```\n\nDer Client sieht eine Query. Der Router sieht einen Plan. Er holt zuerst `total` und die `user.id` aus dem Orders-Subgraph, schickt dann eine `_entities`-Anfrage mit der Representation `{ __typename: \"User\", id }` an den Users-Subgraph, dessen `__resolveReference` daraus den `name` besorgt, und merged am Ende alles zu einer Antwort.\n\n```mermaid\nsequenceDiagram\n  participant C as Client\n  participant R as Router\n  participant O as Orders-Subgraph\n  participant U as Users-Subgraph\n\n  C->>R: order(id: 42) { total, user { name } }\n  R->>O: order { total, user { id } }\n  O-->>R: { total: 90, user: { id: 7 } }\n  R->>U: _entities([{ __typename: User, id: 7 }])\n  U-->>R: { name: \"Ada\" }\n  R-->>C: { total: 90, user: { name: \"Ada\" } }\n```\n\nWenn dieser Ablauf einmal sitzt, macht es „klick“: `@key` sagt, woran eine Entity erkennbar ist, `__resolveReference` sagt, wie man sie aus dieser Erkennung vollständig macht, und der Router orchestriert dazwischen.\n\n## Komponieren und starten\n\nDie Subgraph-Schemas müssen zu einem Supergraph zusammengeführt werden. Das ist ein Build- oder Publikationsschritt, keine Aufgabe des Routers während einer Anfrage. Lokal geht die Komposition mit `rover supergraph compose` und einer Konfiguration, die auf die Subgraphs zeigt:\n\n```bash\n# supergraph.yaml lists each subgraph and its endpoint\nrover supergraph compose --config ./supergraph.yaml > supergraph.graphql\n\n# Start the Apollo Router with the composed supergraph\n./router --supergraph supergraph.graphql\n```\n\nIn größeren Setups übernimmt Managed Federation über Apollo Studio / GraphOS diesen Schritt: Jeder Subgraph publiziert sein Schema, die Komposition passiert zentral, der Router zieht die aktuelle Supergraph-Version. Der wichtige Punkt bleibt derselbe – die Komposition ist der Moment, in dem Fehler auffallen, nicht die Laufzeit.\n\n## Geteilte Felder: der Unterschied zu Federation 1\n\nEin Detail, an dem Fed-1-Umsteiger regelmäßig stolpern: In Federation 2 sind Nicht-Key-Felder standardmäßig **nicht** teilbar. Wenn dasselbe Feld eines Typs in mehreren Subgraphs aufgelöst wird, muss es explizit mit `@shareable` markiert sein – sonst schlägt die Komposition fehl. Das betrifft vor allem Value-Types, die in mehreren Subgraphs identisch vorkommen.\n\n```graphql\ntype Money @shareable {\n  amount: Int!\n  currency: String!\n}\n```\n\nVerwandt ist `@external`: Ein Feld, das in diesem Subgraph nur referenziert wird und einem anderen gehört, wird als `@external` markiert – häufig zusammen mit `@requires` oder `@provides`, wenn ein Subgraph für seine Logik ein fremdes Feld braucht. Fed 2 ist an dieser Stelle strenger als Fed 1, und das ist Absicht: Mehrdeutigkeiten werden zum Fehler, statt sich still in der Laufzeit zu verstecken.\n\n## Drei Fallstricke\n\nFederation löst ein Organisationsproblem, handelt sich dafür aber technische Eigenheiten ein. Drei davon fallen besonders ins Gewicht.\n\nIm Reference-Resolver lauert N+1. Der Router löst Entities einzeln oder in Batches auf, und wenn `__resolveReference` naiv pro Aufruf einen DB-Roundtrip macht, entstehen viele kleine Anfragen. Ein DataLoader pro Subgraph-Request bündelt sie – dasselbe Muster, das man auch außerhalb von Federation gegen N+1 einsetzt.\n\nDas Key-Feld muss außerdem verlässlich in der Payload landen. Ein Feld mit Argumenten oder ein Union- bzw. Interface-Rückgabetyp taugt nicht als `@key`; ein nullbares Feld ist erlaubt, aber nicht empfehlenswert. Unterschiedliche Key-Felder für dieselbe Entity über verschiedene Subgraphs sind zwar zulässig, aber fehleranfällig – im Zweifel überall denselben Key nutzen.\n\nUnd der dritte Punkt trifft jeden, der aus einer Welt kommt, in der solche Dinge erst im Betrieb knallen: Ein vergessenes `@shareable`, ein Typ-Mismatch, ein fehlender `@link`-Import – all das wird schon beim Komponieren oder Publizieren abgelehnt, nicht zur Laufzeit. Der richtige Zeitpunkt, aber ein ungewohnter.\n\n## Federation 2 oder Schema-Stitching?\n\nWer länger mit GraphQL arbeitet, kennt noch **Schema-Stitching** – den älteren Weg, mehrere Schemas zusammenzuführen, heute etwa mit `@graphql-tools/stitch`. Beim Stitching kombiniert man Remote-Schemas manuell und schreibt Delegations- und Merge-Logik selbst. Das funktioniert, aber die Kopplung liegt im Gateway-Code, nicht in einer Spec. Federation deklariert die Beziehungen stattdessen im Schema (`@key`, `@shareable`) und überlässt dem Router die Ausführung. Stitching ist nicht tot – für manche Sonderfälle mit fremden, nicht anpassbaren Schemas ist es weiterhin brauchbar. Für eine neue, teamgetriebene Architektur greife ich 2024 aber zu Federation 2.\n\n## Wann ich NICHT federiere\n\nDer wichtigste Satz zum Schluss, weil er in Architektur-Diskussionen am meisten Widerspruch auslöst: Federation ist Overhead. Router, Komposition, Reference-Resolver, ein zusätzlicher Netzwerk-Hop pro Cross-Subgraph-Join – das alles will betrieben und verstanden sein. Wenn ein einzelnes Team ein Schema betreibt, das gut in einen Service passt, dann löst Federation ein Problem, das dieses Team nicht hat. Man handelt sich verteilte Systeme ein, um eine Organisationsgrenze abzubilden, die noch gar nicht existiert.\n\nFederation folgt der Team-Struktur, nicht der Technik. Erst wenn mehrere Teams sich an einem Schema in die Quere kommen, wenn Deployments sich koppeln, wenn ein `type` niemandem mehr gehört – dann zahlt sich der Schnitt aus. Vorher ist ein guter Monolith mit klaren Modulen fast immer die bessere Wahl. Und wer clientseitig ohnehin schon sauber mit [Apollo-Client-Caching](https://www.mikebild.dev/de/blog/graphql-client-caching-mit-apollo/) arbeitet, merkt: An der Client-Seite ändert Federation nichts – dort bleibt es genau der eine Graph, der es immer war.\n\n## Weiterführende Quellen\n\n- Introduction to Apollo Federation: https://www.apollographql.com/docs/federation/\n- Entities (`@key`, `__resolveReference`): https://www.apollographql.com/docs/federation/entities\n- Implementing a Subgraph with Apollo Server: https://www.apollographql.com/docs/federation/subgraphs/\n- API `@apollo/subgraph` (`buildSubgraphSchema`): https://www.apollographql.com/docs/apollo-server/using-federation/api/apollo-subgraph\n- Moving to Federation 2 (inkl. `@shareable`): https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/reference/moving-to-federation-2\n- Subgraph-Spec (`_entities`, `_Entity`, `@link`): https://www.apollographql.com/docs/graphos/schema-design/federated-schemas/reference/subgraph-spec"},{"slug":"fuzzy-matching-mit-grenzen","title":"Fuzzy Matching mit expliziten Grenzen","summary":"Ein Fuzzy-Match ist eine Behauptung mit Konfidenz, kein Fakt – nützlich wird er erst durch versionierte Schwellwerte, drei Entscheidungszonen und einen diffbaren Report der Grauzone.","tags":["Software","Daten","Tools"],"published_at":"2024-09-12T10:00:00+00:00","updated_at":"2026-07-14T17:24:17.741369+00:00","url":"https://www.mikebild.dev/de/blog/fuzzy-matching-mit-grenzen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/82854627b7945e364ad507cfad564d78763575af06edf5d42d99f476128c0099/fuzzy-matching-mit-grenzen-vorlesen.mp3","audio_duration_secs":693,"text":"# Fuzzy Matching mit expliziten Grenzen\n\n```sql\nSELECT similarity('Schmidt Bau GmbH', 'Schmitt Bau GmbH');\n-- 0.72\n\nSELECT similarity('Bäckerei Hoffmann GmbH & Co. KG',\n                  'BAECKEREI HOFFMANN DANKE RG 4711');\n-- 0.34\n```\n\nDer erste Wert sieht aus wie ein Treffer und ist keiner: zwei verschiedene Firmen, ein Buchstabe Unterschied, beide existieren. Der zweite Wert sieht aus wie Rauschen und ist der richtige Kunde – nur eben so, wie er in einem Überweisungs-Verwendungszweck ankommt: großgeschrieben, ohne Umlaute, mit Rechnungsnummer und Grußformel im selben Feld. Wer diese beiden Zahlen nebeneinander gesehen hat, glaubt keiner Ähnlichkeitsfunktion mehr blind. Genau das ist der richtige Zustand, um mit Fuzzy Matching zu arbeiten.\n\n## Ausgangslage: Verwendungszwecke gegen offene Posten\n\nDer Kontext ist dasselbe private Datenprojekt wie im Vormonat: Rechnungs- und Zahlungsdaten abgleichen. Auf der einen Seite stehen offene Posten mit sauberen Debitorennamen aus der Stammdatenpflege. Auf der anderen Seite stehen Kontoumsätze, deren Verwendungszweck ein Freitextfeld ist. Dort schreiben Menschen und Buchhaltungssysteme, was sie wollen: mal die Rechnungsnummer, mal der Firmenname in einer von fünf Schreibweisen, mal beides, mal keins von beidem.\n\nSolange eine Rechnungsnummer im Text steht, ist der Abgleich trivial – ein regulärer Ausdruck, ein exakter Schlüssel, fertig. Interessant wird es bei den restlichen Zahlungen, bei denen nur ein Firmenname als Anker bleibt. „Müller & Söhne GmbH\" kommt als „MUELLER SOEHNE\", „Mueller u. Soehne Bau\" oder „M&S GmbH\" an. Exaktes Matching findet hier nichts, und genau dafür gibt es Fuzzy Matching. Die Frage ist nicht, ob man es einsetzt, sondern wie man verhindert, dass aus „ähnlich\" stillschweigend „gleich\" wird.\n\nMir hat dabei ein Satz geholfen, den ich mir früh an den Report geschrieben habe: Ein Match ist eine Behauptung mit einer Konfidenz, kein Fakt. Alles Weitere in diesem Beitrag ist die technische Konsequenz aus diesem Satz.\n\n## Normalisierung zuerst\n\nBevor irgendeine Distanzfunktion läuft, lohnt sich der langweiligste Schritt am meisten: Normalisierung. Ein großer Teil der scheinbaren Unschärfe ist gar keine – es sind systematische Unterschiede, die man deterministisch wegräumen kann. Rechtsformen wie GmbH, AG oder KG tragen fast keine Unterscheidungskraft, blähen aber jede Ähnlichkeitsrechnung auf. Umlaute erscheinen im Zahlungsverkehr traditionell als ae/oe/ue. Groß- und Kleinschreibung, doppelte Leerzeichen, Satzzeichen: alles Störgeräusch.\n\n```python\nimport re\n\nLEGAL_FORMS = re.compile(\n    r\"\\b(gmbh|ag|kg|ug|ohg|gbr|mbh|co)\\b\\.?\",\n    re.IGNORECASE,\n)\n\nUMLAUT_MAP = str.maketrans(\n    {\"ä\": \"ae\", \"ö\": \"oe\", \"ü\": \"ue\", \"ß\": \"ss\"}\n)\n\ndef normalize_name(raw: str) -> str:\n    text = raw.strip().lower().translate(UMLAUT_MAP)\n    text = LEGAL_FORMS.sub(\" \", text)\n    text = re.sub(r\"[^a-z0-9 ]+\", \" \", text)\n    return re.sub(r\"\\s+\", \" \", text).strip()\n```\n\nNach diesem Schritt sind „Müller & Söhne GmbH\" und „MUELLER SOEHNE\" beide `mueller soehne` – ein exakter Treffer, ganz ohne Fuzzy-Verfahren. In meinem Datensatz hat allein die Normalisierung rund die Hälfte der vorher „unscharfen\" Fälle in exakte Matches verwandelt. Das ist der Grund, warum ich Normalisierung nicht als Vorstufe des Fuzzy Matching sehe, sondern als eigenständige Stufe mit eigenem Erfolgsmaß. Jeder Fall, der hier exakt wird, muss später nicht mit einer Konfidenz verteidigt werden.\n\nWichtig ist, dass `normalize_name` auf beiden Seiten identisch läuft – auf den Debitorennamen genauso wie auf den Verwendungszwecken. Sobald zwei leicht unterschiedliche Normalisierungen im Spiel sind, jagt man Geistern hinterher.\n\n## Distanzen, Trigramme, Phonetik\n\nFür den Rest bleiben die klassischen Verfahren, und sie haben unterschiedliche Stärken. Levenshtein zählt Editieroperationen und ist gut für Tippfehler. Jaro-Winkler gewichtet Übereinstimmungen am Wortanfang stärker – passend für Firmennamen, bei denen der Anfang meist stabil ist und hinten Zusätze variieren. Trigramm-Ähnlichkeit zerlegt Strings in Dreiergruppen von Zeichen und vergleicht die Mengen; sie ist unempfindlich gegen Wortreihenfolge und in Postgres über die Erweiterung pg_trgm direkt in SQL verfügbar.\n\nDazu kommt ein Verfahren, das im deutschsprachigen Raum zu selten erwähnt wird: die Kölner Phonetik. Sie ist das deutsche Gegenstück zu Soundex und bildet Wörter auf einen Lautcode ab, der für die deutsche Aussprache entworfen wurde. „Meyer\", „Maier\" und „Mayr\" landen auf demselben Code – für Distanzfunktionen sind das drei verschiedene Strings, fürs Ohr ist es derselbe Name. Als alleiniges Kriterium ist Phonetik zu grob, als zusätzliches Signal neben einer Distanz ist sie wertvoll.\n\nIn Python bündelt rapidfuzz die Distanzverfahren in einer schnellen Bibliothek. Ich kombiniere zwei Maße zu einem Score, weil kein einzelnes Verfahren alle Fehlerarten abdeckt:\n\n```python\nfrom rapidfuzz import fuzz, distance\n\ndef score_pair(payment_name: str, debtor_name: str) -> float:\n    jaro_winkler = distance.JaroWinkler.similarity(\n        payment_name, debtor_name\n    )\n    token_sort = fuzz.token_sort_ratio(\n        payment_name, debtor_name\n    ) / 100\n    return 0.6 * jaro_winkler + 0.4 * token_sort\n```\n\n`token_sort_ratio` sortiert die Wörter vor dem Vergleich und fängt damit vertauschte Reihenfolgen ab („bau schmidt\" gegen „schmidt bau\"). Die Gewichte 0,6 und 0,4 sind kein Naturgesetz – sie sind ein Parameter, den ich an einem von Hand gelabelten Ausschnitt der Daten kalibriert habe. Auch das gehört zur Ehrlichkeit des Verfahrens: Der Score ist eine konstruierte Größe, keine Wahrheit.\n\n## Blocking gegen die quadratische Falle\n\nWer jeden Verwendungszweck gegen jeden offenen Posten vergleicht, bezahlt O(n²). Bei ein paar hundert Datensätzen ist das egal, bei zehntausenden Zahlungen gegen tausende Debitoren nicht mehr. Die Standardantwort heißt Blocking: eine billige, grobe Vorauswahl, die die Kandidatenmenge klein macht, bevor die teuren Vergleiche laufen.\n\nHier spielt pg_trgm seine zweite Stärke aus. Die Trigramm-Ähnlichkeit ist nicht nur eine Funktion, sondern auch ein indexierbarer Operator. Mit einem GIN-Index über den normalisierten Namen liefert Postgres die ähnlichsten Kandidaten, ohne die ganze Tabelle anzufassen:\n\n```sql\nCREATE EXTENSION IF NOT EXISTS pg_trgm;\n\nCREATE INDEX idx_open_invoice_debtor_trgm\n    ON open_invoice\n    USING gin (debtor_name_normalized gin_trgm_ops);\n\nSET pg_trgm.similarity_threshold = 0.45;\n\nSELECT i.invoice_no,\n       i.debtor_name_normalized,\n       similarity(i.debtor_name_normalized, :payment_name)\n           AS trigram_score\nFROM open_invoice AS i\nWHERE i.debtor_name_normalized % :payment_name\nORDER BY trigram_score DESC\nLIMIT 10;\n```\n\nDer Operator `%` prüft gegen den eingestellten Schwellwert und nutzt dabei den Index. Die 0,45 sind bewusst niedrig angesetzt: In dieser Stufe ist ein übersehener Kandidat schlimmer als zwanzig überflüssige, denn was das Blocking verwirft, sieht die Feinbewertung nie wieder. Die eigentliche Entscheidung fällt später – mit `score_pair` über die zehn Kandidaten, nicht in SQL.\n\nDiese Zweiteilung hat sich bewährt: Postgres macht die Vorauswahl grob und schnell, Python macht die Bewertung genau und nachvollziehbar. Beide Stufen haben ihren eigenen Schwellwert mit eigener Begründung.\n\n## Drei Zonen statt einer Schwelle\n\nDamit zur eigentlichen Pointe. Die meisten Fuzzy-Matching-Beispiele, die man findet, enden mit einer einzigen Schwelle: Score über X gleich Match, darunter kein Match. Das ist die Stelle, an der aus einem nützlichen Werkzeug ein gefährliches wird, denn eine einzelne Schwelle behauptet eine Trennschärfe, die die Daten nicht hergeben. Es gibt immer einen Bereich, in dem das Verfahren schlicht nicht weiß, ob zwei Namen zusammengehören – und dieses Nichtwissen gehört in die Architektur, nicht unter den Teppich.\n\nDeshalb hat der Abgleich bei mir drei Zonen statt einer Schwelle:\n\n- Oberhalb der Akzeptanzschwelle wird automatisch zugeordnet, und die Zuordnung trägt den Score als Metadatum.\n- Zwischen den Schwellen landet das Paar in einer Durchsichtsliste – ein Mensch entscheidet.\n- Unterhalb der Ablehnungsschwelle passiert nichts, die Zahlung bleibt offen.\n\n```python\nTHRESHOLDS = {\n    \"version\": \"2024-09-03\",\n    \"accept\": 0.93,\n    \"reject\": 0.72,\n}\n\ndef classify(score: float) -> str:\n    if score >= THRESHOLDS[\"accept\"]:\n        return \"accept\"\n    if score < THRESHOLDS[\"reject\"]:\n        return \"reject\"\n    return \"review\"\n```\n\nDie Schwellwerte stehen versioniert im Repository, nicht als Konstante irgendwo im Code verstreut. Jede Änderung an ihnen ist ein Commit mit Begründung. Das klingt bürokratisch, hat mir aber schon nach zwei Wochen geholfen: Als ich die Akzeptanzschwelle von 0,90 auf 0,93 angehoben habe, weil das Schmidt/Schmitt-Paar aus dem Einstieg durchgerutscht war, konnte ich im Verlauf genau sehen, welche Zuordnungen unter welchem Regelstand entstanden sind.\n\n```mermaid\nflowchart LR\n    P[Verwendungszweck] --> N[normalize_name]\n    D[Debitorenname] --> N\n    N --> B[Blocking<br/>pg_trgm, Schwelle 0.45]\n    B --> S[Feinbewertung<br/>score_pair]\n    S --> Z{Score}\n    Z -->|ab 0.93| A[automatisch<br/>zuordnen]\n    Z -->|0.72 bis 0.93| R[Durchsicht<br/>durch Menschen]\n    Z -->|unter 0.72| X[offen lassen]\n    R --> AL[Alias-Regel<br/>versioniert]\n    AL --> N\n```\n\nDie Rückkopplung unten rechts im Diagramm ist kein Detail. Jede manuelle Entscheidung aus der Durchsicht wird als Alias-Regel festgehalten – „M&S\" ist „Müller & Söhne\" – und fließt als Datum zurück in die Normalisierung. Beim nächsten Lauf ist derselbe Fall ein exakter Treffer. Die Grauzone schrumpft dadurch mit der Zeit, und zwar durch dokumentierte Entscheidungen statt durch mutigere Schwellwerte.\n\n## Der Report der Grauzone\n\nDie Durchsichtsliste ist bei mir kein Dashboard, sondern eine Markdown-Datei, die jeder Lauf deterministisch erzeugt: gleiche Eingabe, gleiche Schwellwerte, gleicher Report. Sortiert nach Score, mit beiden Namen im Original und normalisiert, dem Trigramm-Wert aus dem Blocking und dem kombinierten Score daneben.\n\nDer Grund für Markdown ist Diffbarkeit. Zwei Läufe lassen sich mit `diff` vergleichen, und die Differenz erzählt eine Geschichte: Welche Fälle sind durch neue Alias-Regeln aus der Grauzone verschwunden? Welche neuen Zweifelsfälle sind mit dem letzten Kontoauszug dazugekommen? Hat die Schwellwert-Änderung die Zone vergrößert oder verkleinert? Ein Dashboard zeigt den letzten Zustand; ein diffbarer Report zeigt die Bewegung. Für ein Abgleichsystem, dessen Kern die Frage „was hat sich geändert und warum\" ist, wiegt das schwerer als jede Oberfläche.\n\nSide Effect: Der Report ist gleichzeitig das Prüfartefakt. Wenn ich in drei Monaten wissen will, warum eine Zahlung so zugeordnet wurde, steht der Score, der Regelstand und gegebenenfalls die manuelle Entscheidung in der Historie.\n\n## Asymmetrische Kosten\n\nBleibt die Frage, wo die Schwellen liegen sollen – und die beantwortet man nicht mit Statistik allein, sondern mit den Kosten der beiden Fehlerarten. Die sind hier deutlich asymmetrisch. Eine unzugeordnete Zahlung ist ein bekanntes Problem: Sie taucht in der Liste der offenen Fälle auf, jemand schaut drauf, ordnet zu, fertig. Eine falsch zugeordnete Zahlung ist ein unbekanntes Problem: Der offene Posten des einen Kunden gilt als beglichen, der andere bekommt womöglich eine Mahnung für eine bezahlte Rechnung. Der Fehler fällt erst auf, wenn sich jemand beschwert – und dann ist die Fehlersuche teuer, weil niemand mehr weiß, welcher Lauf mit welchem Regelstand die Zuordnung erzeugt hat.\n\nDeshalb liegt die Akzeptanzschwelle bewusst hoch und die Grauzone ist breiter, als es die reine Trefferquote nahelegen würde. Ich nehme lieber zwanzig manuelle Durchsichten pro Monat in Kauf als eine stille Falschzuordnung pro Quartal. Das ist keine technische Entscheidung, sondern eine fachliche – aber sie wird erst möglich, weil die Architektur die Stellschraube explizit macht. Eine einzelne, hart codierte Schwelle bietet diese Abwägung gar nicht erst an.\n\n## Was ich daraus mitnehme\n\nFuzzy Matching hat einen schlechten Ruf bei Leuten, die einmal von einer Falschzuordnung gebissen wurden, und einen zu guten Ruf bei Leuten, die nur die Demo gesehen haben. Beides geht am Punkt vorbei. Die Verfahren – Levenshtein, Jaro-Winkler, Trigramme, Kölner Phonetik – sind seit Jahrzehnten verstanden und tun zuverlässig, was sie versprechen: Sie messen Ähnlichkeit. Was sie nicht können, ist Identität feststellen. Der Fehler liegt fast nie im Verfahren, sondern in der Systemgrenze, die aus einem Ähnlichkeitswert stillschweigend eine Tatsache macht.\n\nNützlich wird das Ganze genau in dem Moment, in dem die Grenzen explizit werden: Normalisierung als eigene Stufe, damit Deterministisches deterministisch bleibt. Blocking mit bewusst niedriger Schwelle, damit die teure Bewertung bezahlbar bleibt. Drei Zonen mit versionierten Schwellwerten, damit das Nichtwissen des Verfahrens einen definierten Ort hat. Ein diffbarer Report, damit die Grauzone beobachtbar schrumpft statt unbemerkt zu wachsen. Und Schwellen, die sich an den asymmetrischen Fehlerkosten orientieren statt an der Trefferquote.\n\nNichts davon ist neu, und genau das gefällt mir daran. Es ist dieselbe Haltung, die sich schon beim Modellieren der Rechnungsdaten im Vormonat ausgezahlt hat: Unsicherheit nicht wegabstrahieren, sondern ihr einen Namen, einen Wert und einen Platz im Datenmodell geben. Ein Match ist eine Behauptung mit Konfidenz. Systeme, die das ernst nehmen, darf man automatisieren lassen.\n\n## Weiterführende Quellen\n\n- RapidFuzz auf GitHub: https://github.com/rapidfuzz/RapidFuzz\n- Postgres 16, Erweiterung pg_trgm: https://www.postgresql.org/docs/16/pgtrgm.html\n- Kölner Phonetik (Wikipedia): https://de.wikipedia.org/wiki/K%C3%B6lner_Phonetik"},{"slug":"event-sourcing-auf-aws-dynamodb-streams","title":"Event Sourcing auf AWS: DynamoDB Streams und Lambda","summary":"Event Sourcing serverless umgesetzt: DynamoDB als append-only Event-Store, DynamoDB Streams als Change-Feed und Lambda als idempotenter Projector in CQRS-Read-Models.","tags":["Software","AWS","Cloud"],"published_at":"2024-09-10T10:00:00+00:00","updated_at":"2026-07-13T07:29:27.668417+00:00","url":"https://www.mikebild.dev/de/blog/event-sourcing-auf-aws-dynamodb-streams/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/77816269684f59e894593ce2dd8c25f52d5ad31b9e7bd74753cb5795412bd2d5/event-sourcing-auf-aws-dynamodb-streams-vorlesen.mp3","audio_duration_secs":749,"text":"Ein Event-Store ist keine Tabelle, deren Zeilen man aktualisiert. Es wird nur angehängt: Jede Zustandsänderung ist ein neues, immutable Item, und der Zustand eines Aggregats entsteht erst, wenn man dessen Events in Versionsreihenfolge faltet. In der [Konzept-Betrachtung zum Event Sourcing](https://www.mikebild.dev/de/blog/event-sourcing-der-event-store-als-logbuch/) habe ich das als Logbuch beschrieben – die Event-Folge trägt die Wahrheit, der Zustand ist nur ihr Ergebnis.\n\nAuf dem Whiteboard ist das schnell erklärt. Tragfähig wird es erst mit den Antworten auf die nächsten Fragen: Wo läuft der Store? Wer garantiert die Reihenfolge? Was passiert, wenn ein Consumer denselben Event zweimal sieht? Auf AWS lässt sich das aus verwalteten Diensten zusammensetzen, und die scharfen Kanten liegen dann bei Concurrency beim Anhängen, bei der Reihenfolge im Change-Feed und bei doppelter Zustellung an die Projection. Genau darum geht es hier – samt dem Code, den ich dafür schreibe.\n\n## Die drei Bausteine\n\nBevor ich in den Code gehe, das mentale Modell. Wer die Rollen sauber trennt, macht später deutlich weniger Fehler:\n\n- Der Event-Store ist eine DynamoDB-Tabelle, append-only: ein Item pro Event, immutable. Das ist die Source of Truth, die einzige Wahrheit im System.\n- Die Streams bilden den Change-Feed dieser Tabelle. Jede neue Zeile erzeugt einen Stream-Record. Kein zweiter Store, nur ein Fenster auf die Änderungen.\n- Die Projection ist eine Lambda, die die Stream-Records liest und daraus denormalisierte Read-Models schreibt. Das ist exakt die [Projektions-Idee der Read Models](https://www.mikebild.dev/de/blog/read-models-sind-projektionen/), nur eben in AWS-Diensten gegossen.\n\nWichtig für die spätere Fehlersuche: Der Stream ist kein Archiv. Records werden nach 24 Stunden getrimmt. Wer den kompletten Verlauf braucht, liest ihn aus der Tabelle, nicht aus dem Stream. Der Stream transportiert nur den Delta-Fluss von der Write-Side zur Read-Side.\n\nIm Zusammenhang sieht der Weg eines Commands so aus:\n\n```mermaid\ngraph LR\n  A[Command] --> B[CommandHandler]\n  B -->|Conditional Write| C[(DynamoDB<br/>Event-Store)]\n  C --> D[DynamoDB Streams]\n  D --> E[Lambda<br/>Projektor]\n  E --> F[(Read-Model-Tabelle)]\n  F --> G[Query]\n```\n\nLinks steht die Schreibseite, rechts die Leseseite. Der Stream verbindet beide Seiten asynchron. DynamoDB Streams hält jeden Änderungsdatensatz einmal vor; die Verarbeitung durch Lambda ist dagegen at-least-once. Genau deshalb muss die Projection Wiederholungen vertragen.\n\n## Der Event-Store als Tabelle\n\nDas Datenmodell ist bewusst schlicht. Der Partition-Key ist die `aggregateId`, der Sort-Key ist die `version` – zero-padded, damit die numerische Sortierung als String stimmt. Ein Aggregat ist damit eine Partition, und seine Events liegen dort aufsteigend nach Version. Das passt direkt auf die Prinzipien aus dem [Single-Table-Design](https://www.mikebild.dev/de/blog/dynamodb-datenmodellierung-single-table/): Der Zugriffspfad steht im Schlüssel.\n\nStreams aktiviere ich beim Anlegen der Tabelle über die `StreamSpecification`. Der `StreamViewType` hat genau vier zulässige Werte – `KEYS_ONLY`, `NEW_IMAGE`, `OLD_IMAGE` und `NEW_AND_OLD_IMAGES`. Für Event Sourcing brauche ich das neue Bild, denn das Event steckt in der frisch geschriebenen Zeile:\n\n```yaml\nResources:\n  EventStore:\n    Type: AWS::DynamoDB::Table\n    Properties:\n      TableName: event-store\n      BillingMode: PAY_PER_REQUEST\n      AttributeDefinitions:\n        - AttributeName: pk\n          AttributeType: S\n        - AttributeName: sk\n          AttributeType: S\n      KeySchema:\n        - AttributeName: pk\n          KeyType: HASH\n        - AttributeName: sk\n          KeyType: RANGE\n      StreamSpecification:\n        StreamViewType: NEW_IMAGE\n```\n\nEin Detail, das mich früher einmal Stunden gekostet hat: Der `StreamViewType` ist nach dem Anlegen nicht mehr änderbar. Wer ihn wechseln will, muss den Stream deaktivieren und neu anlegen – mit einer neuen Stream-ARN und damit einem neuen Event Source Mapping. Das im ersten Anlauf richtig zu setzen spart später einen unangenehmen Migrationsschritt.\n\n## Events anhängen mit Optimistic Concurrency\n\nDer interessante Teil beim Schreiben ist nicht das Schreiben selbst, sondern die Frage: Was, wenn zwei Commands gleichzeitig dasselbe Aggregat auf Version N+1 fortschreiben wollen? Genau einer darf gewinnen. Das löse ich mit Optimistic Concurrency über einen Conditional Write.\n\nDie Idee: Ich schreibe das Event für `version = expectedVersion + 1` und knüpfe die Bedingung daran, dass unter diesem Schlüssel noch kein Item existiert. Existiert es schon, hat jemand anderes die Version belegt, und DynamoDB wirft eine `ConditionalCheckFailedException`. Der Caller liest dann die aktuelle Version neu und versucht den Command erneut.\n\n```ts\nimport { DynamoDBClient } from \"@aws-sdk/client-dynamodb\";\nimport {\n  DynamoDBDocumentClient,\n  PutCommand,\n} from \"@aws-sdk/lib-dynamodb\";\nimport { ConditionalCheckFailedException } from \"@aws-sdk/client-dynamodb\";\n\nconst client = DynamoDBDocumentClient.from(new DynamoDBClient({}));\n\nexport class ConcurrencyError extends Error {}\n\ninterface DomainEvent {\n  type: string;\n  payload: Record<string, unknown>;\n}\n\nexport async function appendEvent(\n  aggregateId: string,\n  expectedVersion: number,\n  event: DomainEvent,\n): Promise<void> {\n  const nextVersion = expectedVersion + 1;\n\n  try {\n    await client.send(\n      new PutCommand({\n        TableName: \"event-store\",\n        Item: {\n          pk: aggregateId,\n          sk: String(nextVersion).padStart(12, \"0\"),\n          version: nextVersion,\n          type: event.type,\n          payload: event.payload,\n          createdAt: new Date().toISOString(),\n        },\n        ConditionExpression: \"attribute_not_exists(pk)\",\n      }),\n    );\n  } catch (err) {\n    if (err instanceof ConditionalCheckFailedException) {\n      throw new ConcurrencyError(\n        `version ${nextVersion} already exists for ${aggregateId}`,\n      );\n    }\n    throw err;\n  }\n}\n```\n\n`attribute_not_exists(pk)` prüft, ob der komplette Item-Key frei ist – DynamoDB wertet die Bedingung gegen genau das Item aus, dessen Schlüssel im `PutCommand` steht. Damit ist die Kombination aus `aggregateId` und `version` der Uniqueness-Anker. Kein zusätzlicher Lock, keine separate Sequenz-Tabelle, keine Transaktion über mehrere Systeme.\n\nWenn ich mehrere Events atomar schreiben muss – etwa bei einem Command, der zwei Domain Events auslöst – nutze ich `TransactWriteItems` statt einzelner Puts. Das bündelt bis zu 100 Items in einer atomaren Transaktion, und ich kann die gleiche Bedingung auf jedes Event legen. Für die Regel „ein Event = ein Item“ reicht der einfache Conditional Write.\n\n## Die Projection als Stream-Handler\n\nJetzt kommt die Leseseite. Sobald ein Event geschrieben ist, erscheint es im Stream, und das Event Source Mapping zieht es in eine Lambda. Deren Aufgabe: die Events auf ein Read-Model falten.\n\nDer Handler bekommt ein `DynamoDBStreamEvent` mit einem Batch von Records. Jeder Record trägt einen `eventName` – für unseren append-only Store ist das immer `INSERT`. Das neue Bild lese ich aus `dynamodb.NewImage` und unmarshalle es in ein normales Objekt.\n\n```ts\nimport type { DynamoDBStreamEvent, DynamoDBBatchResponse } from \"aws-lambda\";\nimport { unmarshall } from \"@aws-sdk/util-dynamodb\";\nimport {\n  DynamoDBDocumentClient,\n  GetCommand,\n  UpdateCommand,\n} from \"@aws-sdk/lib-dynamodb\";\nimport { DynamoDBClient } from \"@aws-sdk/client-dynamodb\";\n\nconst client = DynamoDBDocumentClient.from(new DynamoDBClient({}));\n\nclass ProjectionGapError extends Error {}\n\nexport async function handler(\n  event: DynamoDBStreamEvent,\n): Promise<DynamoDBBatchResponse> {\n  const batchItemFailures: { itemIdentifier: string }[] = [];\n\n  for (const record of event.Records) {\n    if (record.eventName !== \"INSERT\") continue;\n    if (!record.dynamodb?.NewImage) continue;\n\n    try {\n      const item = unmarshall(record.dynamodb.NewImage as never);\n      await project(item);\n    } catch (err) {\n      batchItemFailures.push({\n        itemIdentifier: record.dynamodb!.SequenceNumber!,\n      });\n    }\n  }\n\n  return { batchItemFailures };\n}\n```\n\nZwei Dinge sind hier wichtig. Erstens gebe ich am Ende `batchItemFailures` zurück. Wenn `ReportBatchItemFailures` am Event Source Mapping aktiviert ist, verwendet Lambda die kleinste gemeldete `SequenceNumber` als Checkpoint und wiederholt ab dort. Das reduziert Wiederholungen, schließt sie für bereits erfolgreiche Folgerecords aber nicht aus. Ohne partielle Rückmeldung gilt dagegen der ganze Batch als fehlgeschlagen.\n\nZweitens die eigentliche Projection. Hier lauert der teuerste Fehler des ganzen Setups, und deshalb bekommt er einen eigenen Abschnitt.\n\n## Idempotenz ist keine Option\n\nDas Event Source Mapping liefert at-least-once. Der Stream selbst enthält jeden Record genau einmal – aber die Verarbeitung durch Lambda kann bei Retries, bei `BisectBatchOnFunctionError` oder bei einem Timeout denselben Batch mehrfach zustellen. Diese beiden Aussagen werden in Sekundärquellen oft vermischt: Der Stream ist exactly-once und pro Item geordnet; die Lambda-Verarbeitung ist at-least-once. Für den Code, den ich schreibe, zählt die zweite Aussage.\n\nKonkret heißt das: Meine Projection muss denselben Event zweimal verkraften, ohne das Read-Model zu verfälschen. Bei einer Zählung wäre eine doppelte Verarbeitung sonst eine Doppelzählung. Der Guard dagegen ist ein Conditional Write auf eine lückenlos steigende Version im Read-Model. Ich schreibe die neue Projection nur, wenn ihre Version genau auf die zuletzt gespeicherte folgt:\n\n```ts\nasync function project(item: Record<string, unknown>): Promise<void> {\n  const aggregateId = item.pk as string;\n  const version = item.version as number;\n\n  const current = await client.send(\n    new GetCommand({\n      TableName: \"read-model-orders\",\n      Key: { pk: aggregateId },\n      ConsistentRead: true,\n    }),\n  );\n\n  const lastProcessedVersion =\n    (current.Item?.lastProcessedVersion as number | undefined) ?? 0;\n\n  // A repeated delivery has no effect.\n  if (version <= lastProcessedVersion) return;\n\n  // A later event must wait until the missing versions were applied.\n  if (version !== lastProcessedVersion + 1) {\n    throw new ProjectionGapError(\n      `expected version ${lastProcessedVersion + 1}, received ${version}`,\n    );\n  }\n\n  await client.send(\n    new UpdateCommand({\n      TableName: \"read-model-orders\",\n      Key: { pk: aggregateId },\n      UpdateExpression: [\n        \"SET lastProcessedVersion = :v\",\n        \"#status = :status\",\n        \"updatedAt = :now\",\n      ].join(\", \"),\n      ConditionExpression:\n        \"attribute_not_exists(pk) OR lastProcessedVersion = :previous\",\n      ExpressionAttributeNames: {\n        \"#status\": \"status\",\n      },\n      ExpressionAttributeValues: {\n        \":v\": version,\n        \":previous\": lastProcessedVersion,\n        \":status\": deriveStatus(current.Item?.status, item),\n        \":now\": new Date().toISOString(),\n      },\n    }),\n  );\n}\n```\n\nKommt ein Event doppelt an, beendet die Funktion die Arbeit vor dem Update. Kommt dagegen Version 5, während im Read Model erst Version 3 steht, wird die Lücke als Fehler gemeldet. Lambda stellt diesen Record erneut zu; Version 4 kann vorher verarbeitet werden. Die Bedingung im `UpdateCommand` schützt zusätzlich gegen zwei gleichzeitig arbeitende Projektoren. Schlägt sie nach dem Lesen fehl, wird ebenfalls wiederholt – sie darf nicht pauschal als erfolgreicher Duplikatfall verschwinden.\n\nEin Randfall, der leicht übersehen wird: Ein `PutItem` oder `UpdateItem`, das die Daten gar nicht verändert, erzeugt keinen Stream-Record. Wer seine Projektions-Erwartungen an „für jeden Write kommt ein Event“ knüpft, wundert sich sonst über fehlende Records. Im append-only Event-Store tritt das nicht auf, weil jedes Event ein neues Item ist – aber sobald man am Store herummodelliert, sollte man es im Kopf behalten.\n\n## Ordering muss die Projection selbst prüfen\n\nDer zweite Fallstrick betrifft die Reihenfolge. AWS garantiert für DynamoDB Streams: Für jedes einzelne Item erscheinen die Stream-Records in derselben Reihenfolge wie die tatsächlichen Änderungen an diesem Item. In diesem Datenmodell ist aber jede Event-Version ein eigenes Item – gleicher Partition-Key, anderer Sort-Key. Aus der Garantie pro Item folgt daher keine garantierte Reihenfolge über die Events eines Aggregats.\n\nIm Normalbetrieb kommen aufeinanderfolgende Writes häufig in der erwarteten Reihenfolge an. Darauf darf sich der Projector trotzdem nicht verlassen, erst recht nicht bei Retries, partiellen Batch-Fehlern oder einem erhöhten `ParallelizationFactor`. Deshalb prüft der Code oben auf eine lückenlose Version. Eine Lücke wird wiederholt oder gezielt aus dem Event-Store aufgefüllt, statt ein späteres Event vorzuziehen und das fehlende anschließend als veraltet zu verwerfen.\n\nGlobales Ordering über Aggregate hinweg gibt es ebenfalls nicht. Die Records verteilen sich auf Shards, und Shards werden parallel verarbeitet. Wer eine global chronologische Sicht braucht – etwa einen Feed über alle Bestellungen sortiert nach Zeit –, muss eine dafür geeignete Ordnung modellieren. Ein Zeitstempel kann zur Darstellung dienen, ist aber allein noch keine konfliktfreie globale Sequenz.\n\n## Rebuild und Snapshots\n\nEin Read-Model ist ableitbar. Das ist einer der schönsten Vorteile des Ansatzes: Wenn ich die Projektionslogik ändere oder ein Read-Model korrupt ist, werfe ich es weg und baue es neu. Der Rebuild ist ein Replay – ich lese alle Events eines Aggregats aufsteigend und falte sie:\n\n```ts\nimport {\n  DynamoDBDocumentClient,\n  QueryCommand,\n} from \"@aws-sdk/lib-dynamodb\";\n\nexport async function rebuildProjection(\n  aggregateId: string,\n  fromVersion = 0,\n): Promise<ReadModelState> {\n  const result = await client.send(\n    new QueryCommand({\n      TableName: \"event-store\",\n      KeyConditionExpression: \"pk = :id AND sk > :from\",\n      ExpressionAttributeValues: {\n        \":id\": aggregateId,\n        \":from\": String(fromVersion).padStart(12, \"0\"),\n      },\n    }),\n  );\n\n  const events = result.Items ?? [];\n  return events.reduce(applyEvent, initialState());\n}\n```\n\nBei Aggregaten mit sehr vielen Events wird das teuer – jedes Replay liest den ganzen Stream. Dagegen empfiehlt die AWS Prescriptive Guidance Snapshots: den materialisierten Zustand eines Aggregats zu Version N, periodisch abgelegt. Der Rebuild lädt dann den Snapshot und faltet nur die Delta-Events seit dieser Version. Der `fromVersion`-Parameter oben ist genau die Naht dafür – ich starte den Query bei `snapshot.version` statt bei null. Der komplette Rebuild ohne Snapshot bleibt als Reißleine für den Fall, dass sich die Fold-Logik selbst geändert hat.\n\n## Hot Partitions und der blockierte Shard\n\nDer dritte Fallstrick ist der Preis der klaren Partitionierung. Die `aggregateId` als Partition-Key bedeutet: Ein sehr aktives Aggregat konzentriert alle Writes und den gesamten Stream-Verkehr auf eine Partition und einen Shard. Das führt zu Throttling und Latenz – eine klassische Hot Partition.\n\nDazu kommt ein Reader-Limit: Pro Shard sind maximal zwei Prozesse zulässig, die gleichzeitig lesen. Ein Batch, den die Lambda nicht verarbeiten kann, blockiert außerdem den Fortschritt des Shards, bis er entweder gelingt oder ausläuft. Genau dagegen gibt es die Stellschrauben am Event Source Mapping:\n\n- `BisectBatchOnFunctionError` teilt einen fehlgeschlagenen Batch rekursiv, um den einen giftigen Record zu isolieren.\n- `MaximumRetryAttempts` und `MaximumRecordAgeInSeconds` begrenzen, wie lange ein Record blockieren darf.\n- Ein On-failure-`DestinationConfig` schiebt endgültig gescheiterte Batches nach SQS oder SNS, statt sie im Stream festzuhalten.\n\nZusammen mit `ReportBatchItemFailures` aus dem Handler ergibt das eine Error Handling, die einen einzelnen kaputten Event nicht die ganze Projection anhalten lässt. Wer das von Anfang an setzt, spart sich den Incident, bei dem eine Read-Model-Tabelle stundenlang stehenbleibt, weil ein einziger Record nicht geparst werden konnte.\n\n## Fazit\n\nEvent Sourcing auf AWS braucht erstaunlich wenig: eine DynamoDB-Tabelle mit `aggregateId` als Partition-Key und `version` als Sort-Key, einen Conditional Write für Optimistic Concurrency, DynamoDB Streams als Change-Feed und eine Lambda, die daraus idempotente Read-Models faltet. Das ist Event Sourcing plus CQRS, serverless zusammengesetzt aus verwalteten Diensten.\n\nDie Konzepte tragen genauso wie auf dem Whiteboard – aber die Betriebsdetails entscheiden, ob das System nachts durchhält. Drei Dinge nehme ich in jedes Projekt mit: Projections müssen idempotent sein, weil die Verarbeitung at-least-once ist. Die Reihenfolge der getrennten Event-Items muss über ihre Version geprüft werden. Und die Error Handling am Event Source Mapping entscheidet darüber, ob sich der Stream selbst wieder freiräumt oder stehen bleibt.\n\nWer diesen Stack einmal aufgesetzt hat, merkt schnell, wie gut er sich weiterspinnen lässt – etwa in Richtung einer GraphQL-Leseseite mit [AppSync und DynamoDB](https://www.mikebild.dev/de/blog/appsync-dynamodb-graphql-api/), die direkt auf den projizierten Read-Models sitzt. Der Event-Store bleibt die Wahrheit, die Projections bleiben ableitbar, und die API liest nur noch fertig geformte Sichten.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html\n- [DynamoDB Streams – Retention, StreamViewType, Ordering, Shards](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html)\n- [DynamoDB Streams und Lambda-Trigger](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.Lambda.html)\n- [Using AWS Lambda with DynamoDB – ESM, Batch, ParallelizationFactor, Error Handling](https://docs.aws.amazon.com/lambda/latest/dg/with-ddb.html)\n- [Condition Expressions](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html)\n- [Optimistic Locking mit Versionierung](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBMapper.OptimisticLocking.html)\n- [AWS Prescriptive Guidance – Event Sourcing Pattern](https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/event-sourcing.html)\n- [AWS Database Blog – Build a CQRS event store with Amazon DynamoDB](https://aws.amazon.com/blogs/database/build-a-cqrs-event-store-with-amazon-dynamodb/)"},{"slug":"rxjs-testen-mit-marble-diagrammen","title":"RxJS testen mit Marble-Diagrammen","summary":"Marble-Diagramme machen den zeitlichen Ablauf asynchroner Streams sichtbar. Mit TestScheduler.run und virtueller Zeit testest du RxJS-Operatoren deterministisch: kein echtes Warten, keine Flakiness.","tags":["Software","Reactive"],"published_at":"2024-08-13T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/rxjs-testen-mit-marble-diagrammen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/a6690b13769a71066712305e0d05fde3c5ce610dd9388f45f34721612e0d6c47/rxjs-testen-mit-marble-diagrammen-vorlesen.mp3","audio_duration_secs":776,"text":"Ein Test für `debounceTime(2000)` gegen die echte Uhr dauert zwei Sekunden. Zwanzig solcher Tests dauern vierzig Sekunden – und trotz dieser Wartezeit schlagen sie sporadisch fehl, sobald die Maschine unter Last steht. An dieser Rechnung scheitern reaktive Testsuiten: `setTimeout`, `done`-Callbacks, ein Framework, das pro Testfall wartet. Die Suite wird langsam, sie wird flaky, und irgendwann traut sich niemand mehr, sie ernst zu nehmen.\n\nDer `TestScheduler` rechnet stattdessen. Innerhalb von `run()` läuft eine virtuelle Uhr, in der ein Frame genau einer Millisekunde entspricht, und der komplette zeitliche Ablauf eines Streams steht als kurzer String da: `'-a-b-c|'`. Aus zwei Sekunden Wartezeit werden Mikrosekunden Rechenzeit. Wie diese Marble-Syntax funktioniert, wie man mit `TestScheduler.run` echte Operatoren testet und wo die typischen Stolperfallen liegen – darum geht es hier, durchgehend auf dem Stand von RxJS 7.\n\n## Warum Zeit das eigentliche Problem ist\n\nEin Observable ist ein Wert im zeitlichen Verlauf – oder genauer: eine Folge von Emissionen, verteilt über eine Zeitachse, abgeschlossen durch ein `complete` oder abgebrochen durch einen Fehler. Bei synchronen Werten ist das Testen trivial: subscribe, sammle die Werte in ein Array, vergleiche. Aber das Interessante an RxJS ist ja gerade der zeitliche Teil. Wann feuert ein `debounceTime`? Verschluckt ein `throttleTime` die richtigen Werte? Kommt das `complete` vor oder nach der letzten Emission?\n\nSobald du das mit echter Zeit testest, hast du drei Probleme auf einmal. Der Test dauert real so lange wie das Timing es vorgibt. Er ist nichtdeterministisch, weil die reale Uhr nun einmal nicht auf die Millisekunde genau tickt. Und er zwingt dich zu asynchronem Test-Code mit `done`-Callbacks, bei denen ein vergessener Aufruf den Test stumm hängen lässt, bis der Timeout zuschlägt.\n\nMarble-Testing dreht diese Logik um. Statt echter Zeit läuft eine virtuelle Uhr, die der `TestScheduler` kontrolliert. Zeit wird nicht abgewartet, sie wird berechnet. Und der Ablauf wird nicht in imperativem Code beschrieben, sondern als Diagramm.\n\n## Die Marble-Syntax\n\nEin Marble-Diagramm ist ein String, in dem jedes Zeichen eine Bedeutung auf der Zeitachse hat. Die Zeit läuft von links nach rechts, und jedes Symbol steht entweder für einen Zeitfortschritt oder für ein Event im Stream. Das ist die gesamte Referenz:\n\n- `-` – ein Zeit-Frame, also ein Tick der virtuellen Uhr. Innerhalb von `run()` entspricht ein Frame genau 1 ms.\n- `a`–`z` (oder jedes andere Zeichen) – eine `next`-Emission. Der konkrete Wert wird über ein `values`-Objekt gemappt, etwa `{ a: 1, b: 2 }`.\n- `|` – das `complete`-Signal, der Stream endet normal.\n- `#` – ein `error`, der Stream endet mit einem Fehler.\n- `()` – synchrone Gruppierung: mehrere Notifications im selben Frame, zum Beispiel `(ab|)`.\n- `^` – der Subscription-Punkt bei `hot`, der sogenannte Zero-Frame. Frames davor zählen negativ.\n- `!` – der Unsubscription-Punkt, relevant in Subscription-Diagrammen.\n- `<n>ms` / `<n>s` / `<n>m` – Kurzschreibweise für viele Frames, etwa `10ms` statt zehn Bindestrichen.\n\nEin Detail, das gerne übersehen wird: Leerzeichen erzeugen keinen Frame. Sie dienen nur der optischen Ausrichtung und werden ignoriert. Du kannst also Input und erwarteten Output untereinander schreiben und mit Leerzeichen sauber ausrichten, ohne das Timing zu verändern – nur `-` und die eigentlichen Symbole zählen.\n\nDer String `'-a-b-c|'` liest sich damit so: ein Frame Stille, dann Wert `a`, ein Frame, Wert `b`, ein Frame, Wert `c`, direkt gefolgt vom `complete`. Insgesamt sechs Frames, drei Werte, ein sauberer Abschluss. Genau diese Lesbarkeit ist der Punkt – du siehst das Timing, ohne es aus imperativem Code rekonstruieren zu müssen.\n\n## cold und hot\n\nBevor wir Tests schreiben, brauchst du die Unterscheidung zwischen `cold` und `hot`, denn sie bestimmt, wann die Zeit zu laufen beginnt.\n\nEin `cold`-Observable startet erst, wenn jemand subscribed. Die Zeitachse beginnt am Subscription-Zeitpunkt. Das ist das typische Verhalten für HTTP-Requests oder andere Datenquellen, die pro Abonnent neu loslaufen. Ein `hot`-Observable dagegen läuft bereits, unabhängig von Subscriptions – wie ein DOM-Event oder ein Subject, das schon feuert, während du noch gar nicht zuhörst. Hier markiert das `^` den Moment, in dem die getestete Subscription einsteigt; alles davor ist bereits passiert und liegt im negativen Frame-Bereich.\n\nWenn du die vier Subject-Typen und ihr Multicast-Verhalten noch einmal nachlesen willst, habe ich das in [Die vier Subject-Typen in RxJS](https://www.mikebild.dev/de/blog/rxjs-die-vier-subject-typen/) ausführlich behandelt – `hot`-Marbles bilden genau dieses „läuft schon“-Verhalten ab.\n\n## Der erste Test: map mit virtueller Zeit\n\nFangen wir mit dem einfachsten sinnvollen Fall an: einem `map`-Operator. Wir wollen zeigen, dass jeder eingehende Wert mit zehn multipliziert wird und Timing sowie Completion unverändert durchgereicht werden.\n\nZuerst der Aufbau des `TestScheduler`. Der Konstruktor bekommt eine Assertion-Funktion, die die Deep-Equality deines Testframeworks kapselt – hier am Beispiel von Jest, aber Jasmine oder Vitest funktionieren identisch:\n\n```ts\nimport { TestScheduler } from 'rxjs/testing';\nimport { map, debounceTime } from 'rxjs/operators';\n\nlet testScheduler: TestScheduler;\n\nbeforeEach(() => {\n  testScheduler = new TestScheduler((actual, expected) => {\n    expect(actual).toEqual(expected);\n  });\n});\n```\n\nDer eigentliche Test läuft dann komplett innerhalb von `testScheduler.run`. Der Callback bekommt ein `helpers`-Objekt, aus dem wir `cold` und `expectObservable` herausziehen:\n\n```ts\ntest('map multiplies each value by ten', () => {\n  testScheduler.run(({ cold, expectObservable }) => {\n    const source$ = cold('-a-b-c|', { a: 1, b: 2, c: 3 });\n\n    const result$ = source$.pipe(map((x) => x * 10));\n\n    expectObservable(result$).toBe('-a-b-c|', { a: 10, b: 20, c: 30 });\n  });\n});\n```\n\nDas Muster, das du hier siehst, wiederholt sich in jedem Marble-Test: Ist-Stream gegen Soll-Diagramm. Der Input ist ein `cold`-Observable mit einem Diagramm und einem `values`-Objekt. Die Operator-Pipeline hängt daran. Und `expectObservable(...).toBe(...)` beschreibt, was herauskommen soll. Das Timing des Outputs ist identisch mit dem des Inputs, weil `map` nichts an der Zeit ändert – nur die Werte-Zuordnung im zweiten Objekt ist eine andere.\n\nWarum funktioniert das ohne echtes Warten? Weil `run()` beim Zurückkehren automatisch die virtuelle Zeit durchlaufen lässt – intern über einen `flush`, den du in aller Regel nicht selbst aufrufen musst. Der ganze Ablauf ist synchron abgearbeitet, sobald der Callback endet.\n\n## Der eigentliche Gewinn: debounceTime\n\nBei `map` merkst du den Vorteil noch nicht wirklich. Er wird schlagartig deutlich, sobald ein zeitbasierter Operator ins Spiel kommt. `debounceTime(n)` gibt einen Wert erst weiter, wenn danach `n` Frames lang Stille herrscht – kommt vorher ein neuer Wert, wird der alte verworfen. Das ist genau die Art von Verhalten, die mit echter Zeit zur Qual wird und mit virtueller Zeit zur Fingerübung.\n\nDer entscheidende Punkt: Innerhalb des `run()`-Callbacks wird jeder zeitbasierte Operator automatisch auf den `TestScheduler` umgeleitet. Du musst den Scheduler nicht manuell an `debounceTime` durchreichen. `delay`, `debounceTime`, `throttleTime`, `interval`, `timer` – sie alle greifen auf die virtuelle Uhr zu, sobald sie innerhalb von `run()` laufen.\n\n```ts\ntest('debounceTime keeps only the last value after a quiet window', () => {\n  testScheduler.run(({ hot, expectObservable }) => {\n    // frame:      0    5    10\n    const source$ = hot('-a--b--c---|');\n\n    const result$ = source$.pipe(debounceTime(3));\n\n    // a is displaced by b, b by c; only c survives the 3-frame quiet window\n    expectObservable(result$).toBe('----------c|');\n  });\n});\n```\n\nRechnen wir das durch, denn genau hier passieren die meisten Fehler. `a` kommt bei Frame 1 an, sein Debounce-Fenster liefe also bei Frame 4 aus. Genau dann trifft aber `b` ein und verdrängt `a`, bevor dessen Emission ausgelöst wird. Für `b` beginnt das Fenster neu und endete bei Frame 7 – dort kommt `c`, dasselbe Spiel, `b` fällt ebenfalls weg. Nach `c` bei Frame 7 herrscht endlich lang genug Ruhe: Das Fenster läuft bei Frame 10 aus, das `complete` folgt bei Frame 11. Übrig bleibt also nur `c`, um drei Frames nach hinten verschoben. Das erwartete Diagramm bildet exakt diese Verschiebung ab.\n\nDiese Rechnerei ist kein Nebenschauplatz, sie ist der Kern. Wenn dein erwartetes Marble auch nur um einen Frame danebenliegt, schlägt der Deep-Equal fehl. Das ist anfangs nervig und wird schnell zur Stärke: Der Test zwingt dich, das Timing wirklich zu verstehen, statt es zu erahnen.\n\n## Was in einem Frame passiert\n\nDas folgende Diagramm zeigt den Ablauf für den `map`-Fall als drei parallele Zeitachsen – Input oben, Operator in der Mitte, Output unten. So sieht man am schnellsten, dass `map` die Werte transformiert, das Timing aber unangetastet lässt.\n\n```mermaid\nflowchart TB\n  subgraph IN[\"input$ – cold('-a-b-c|')\"]\n    direction LR\n    i0[\"-\"] --> i1[\"a=1\"] --> i2[\"-\"] --> i3[\"b=2\"] --> i4[\"-\"] --> i5[\"c=3\"] --> i6[\"|\"]\n  end\n  subgraph OP[\"operator\"]\n    direction LR\n    op[\"map(x =&gt; x * 10)<br/>gleiche Frames,<br/>neue Werte\"]\n  end\n  subgraph OUT[\"output$ – '-a-b-c|'\"]\n    direction LR\n    o0[\"-\"] --> o1[\"a=10\"] --> o2[\"-\"] --> o3[\"b=20\"] --> o4[\"-\"] --> o5[\"c=30\"] --> o6[\"|\"]\n  end\n  IN --> OP --> OUT\n```\n\nJedes Kästchen ist ein Frame. Die Werte wandern von oben nach unten durch den Operator, das Timing bleibt Frame für Frame erhalten. Bei einem zeitbasierten Operator wie `debounceTime` würde sich die untere Achse gegenüber der oberen verschieben und Werte fallen lassen – genau das, was wir eben durchgerechnet haben.\n\n## Subscription-Timing prüfen\n\nManchmal reicht es nicht zu wissen, welche Werte herauskommen – du willst wissen, wann und wie lange überhaupt subscribed wurde. Dafür liefern `cold` und `hot` eine `.subscriptions`-Property, die du gegen ein eigenes Diagramm prüfen kannst. `expectSubscriptions` verwendet dabei `^` für den Subscribe- und `!` für den Unsubscribe-Zeitpunkt:\n\n```ts\ntest('records subscription and unsubscription frames', () => {\n  testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => {\n    const source$ = cold('-a-b-c|');\n    const subscription = '^-----!';\n\n    expectObservable(source$).toBe('-a-b-c|');\n    expectSubscriptions(source$.subscriptions).toBe(subscription);\n  });\n});\n```\n\nDas ist besonders wertvoll, wenn du Operatoren wie `switchMap` oder `takeUntil` testest, bei denen der eigentliche Effect darin besteht, dass eine innere Subscription zum richtigen Zeitpunkt abgebrochen wird. Der Werte-Stream sieht dann oft unspektakulär aus – die Wahrheit steckt im Subscription-Diagramm.\n\n## Die drei Stolperfallen\n\nIn Code-Reviews tauchen immer dieselben drei Fehlerquellen auf. Sie stecken hinter den meisten „Warum ist mein Test rot?“-Momenten.\n\nErstens: echte statt virtueller Zeit. Die automatische Umleitung der zeitbasierten Operatoren gilt ausschließlich innerhalb des `run()`-Callbacks. Baust du dein Observable außerhalb auf oder greifst du auf den alten Stil zurück, bei dem du den Scheduler manuell durchreichst, läuft plötzlich wieder die reale Uhr. Der Test wird langsam und flaky, und der ganze Gewinn ist dahin. Alles, was Timing hat, gehört in den `run()`-Callback.\n\nZweitens: die Frame-Zählung. `debounceTime(20)` innerhalb von `run()` bedeutet 20 Frames, also 20 ms. Dein erwartetes Diagramm muss diese Verzögerung exakt abbilden – ob mit 20 Bindestrichen oder mit `20ms`. Ein Off-by-one bei der Frame-Zählung ist mit Abstand der häufigste Grund für einen fehlschlagenden Marble-Test. Nimm dir die Zeit, das Timing wirklich durchzurechnen, statt es zu schätzen.\n\nDrittens: Whitespace täuscht. Leerzeichen erzeugen keinen Frame, sie richten nur optisch aus. Wenn du Input und erwarteten Output ausrichtest, achte darauf, dass beide frame-synchron sind – und dass Werte, die synchron im selben Frame feuern, mit `()` gruppiert werden. Ein `(ab|)` ist etwas völlig anderes als `ab|`, auch wenn es fast gleich aussieht.\n\n## Der alte Stil als Kontrast\n\nZur Einordnung noch ein Wort zur Geschichte, denn du wirst online auf älteren Code stoßen. Früher hat man den `TestScheduler` ohne `run()` verwendet, den Scheduler explizit an die Operatoren durchgereicht und am Ende manuell `testScheduler.flush()` aufgerufen. In diesem Legacy-Stil entsprach ein Frame außerdem 10 ms statt 1 ms. Das funktioniert prinzipiell noch, ist aber fehleranfälliger und umständlicher.\n\nDer empfohlene Weg ist eindeutig `testScheduler.run(helpers => …)` mit der klaren Regel: ein Frame gleich 1 ms, automatische Umleitung aller zeitbasierten Operatoren, automatischer Flush am Ende. Wenn du also irgendwo `testScheduler.flush()` oder ein manuell durchgereichtes Scheduler-Argument siehst, ist das ein Hinweis auf alten Code – kein Muster, dem du folgen solltest.\n\n## Fazit\n\nMarble-Testing löst das eine Problem, das reaktive Programmierung schwer testbar macht: die Zeit. Statt sie abzuwarten, machst du sie sichtbar und berechenbar. Ein kurzer String beschreibt den kompletten zeitlichen Ablauf eines Streams, der `TestScheduler` verifiziert ihn in virtueller Zeit, und deine Suite bleibt schnell und deterministisch – keine `done`-Callbacks, keine Timeouts, keine sporadischen Fehlschläge.\n\nDer Einstieg kostet etwas Übung, vor allem beim Frame-Zählen. Aber sobald das sitzt, willst du nicht mehr zurück. Ein `debounceTime`-Test, der früher zwei Sekunden lief und gelegentlich rot war, läuft jetzt in Mikrosekunden und ist verlässlich. Und das erwartete Diagramm dokumentiert nebenbei das Verhalten so klar, dass es die halbe Doku ersetzt. Fang klein an – ein `map`, ein `debounceTime` – und rechne jedes Timing bewusst durch. Wenn du danach reaktive Ströme auch serverseitig einsetzt, findest du in [RxJS in Node.js](https://www.mikebild.dev/de/blog/rxjs-in-node-ereignisstroeme/) die passende Fortsetzung.\n\n## Weiterführende Quellen\n\n- [Testing RxJS Code with Marble Diagrams](https://rxjs.dev/guide/testing/marble-testing) – die offizielle Anleitung zu Marble-Syntax und `TestScheduler.run`.\n- [TestScheduler API](https://rxjs.dev/api/testing/TestScheduler) – die vollständige API-Referenz mit allen Helper-Signaturen."},{"slug":"rechnungsdaten-als-domaene","title":"Rechnungsdaten als Domäne","summary":"Warum ein Rechnungsimport kein CSV-Job ist, sondern eine Fachdomäne mit eigenen Begriffen, Invarianten und Zeitregeln – und wie explizite Typen und ein Ablehnungs-Report aus stillen Datenfehlern erklärbare Daten machen.","tags":["Software","Daten","Tools"],"published_at":"2024-08-12T10:00:00+00:00","updated_at":"2026-07-14T17:22:26.666484+00:00","url":"https://www.mikebild.dev/de/blog/rechnungsdaten-als-domaene/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/f9df0cf504d2e5084fef41e48c29f1adb07583f8f3ae29c58527e44826e33f13/rechnungsdaten-als-domaene-vorlesen.mp3","audio_duration_secs":733,"text":"# Rechnungsdaten als Domäne\n\nRechnungsdaten sind, fachlich betrachtet, keine Zahlenkolonnen. Eine Rechnung ist ein Beleg: ein Dokument mit Aussteller, Empfänger, Belegnummer, Positionen und Steuersätzen – und mit rechtlicher Bedeutung, die im Umsatzsteuergesetz ziemlich genau beschrieben ist. Ein Beleg hat Verwandte: die Gutschrift, die einen Betrag zurückgibt, und den Storno, der einen Beleg aufhebt und dabei zwingend auf das Original verweist. Und ein Beleg trägt Regeln in sich, die in keiner Exportdatei stehen: Die Summe der Positionen muss zur Belegsumme passen, Netto plus Steuer muss Brutto ergeben, und ein Zahlungsziel rechnet ab dem Rechnungsdatum – nicht ab dem Leistungsdatum, und erst recht nicht ab dem Tag, an dem das Geld tatsächlich fließt.\n\nWer eine Rechnung nur als Zeile mit Betrag liest, liest sie falsch. Genau das passiert aber, wenn man die Aufgabe „Rechnungsdaten zusammenführen\" als das behandelt, wonach sie auf den ersten Blick aussieht: als Import-Job.\n\n## Ein privates Datenprojekt als Auslöser\n\nDer Anlass war unspektakulär. Über mehrere Jahre hatten sich bei mir Rechnungs- und Belegdaten in drei Formen angesammelt: PDFs von Lieferanten, CSV-Exporte aus einem Buchhaltungswerkzeug und ein Konto-Export der Bank. Ich wollte sie in einer lokalen Datenbank zusammenführen, um endlich Fragen beantworten zu können, die keines der Einzelsysteme beantwortet: Welche Beträge wurden in Rechnung gestellt, aber nie bezahlt? Wie verteilen sich Ausgaben über Steuersätze? Welche Gutschriften gehören zu welchen Originalen? Alles anonymisiert und rein privat – es geht hier um die Struktur des Problems, nicht um konkrete Geschäftszahlen.\n\nDie erste Version war ein Abend Arbeit: PDFs in Text wandeln, CSVs mit Papa Parse einlesen, Spalten umbenennen, ab in SQLite. Der Import lief durch, ohne eine einzige Fehlermeldung. Und genau das war das Problem.\n\nDenn die Daten waren still kaputt. Ein Lieferant exportiert Bruttobeträge, der andere Netto – beide nennen die Spalte `amount`. Ein Export schreibt deutsche Dezimalkommas, der nächste Punkte. In einer Datei stand in der Datumsspalte das Leistungsdatum, in der anderen das Rechnungsdatum. Zwei Stornos tauchten als ganz normale Rechnungen mit negativem Betrag auf, ohne Bezug zum Original. Nichts davon ist ein Parser-Fehler. Jede einzelne Zeile war syntaktisch einwandfrei. Falsch war die Bedeutung – und Bedeutung prüft kein CSV-Parser.\n\nMir ist an diesem Punkt aufgefallen, dass ich die Aufgabe falsch einsortiert hatte. Das hier war kein Datentransport. Das war Domänenmodellierung, nur eben für Daten, die schon existieren.\n\n## Die Begriffe ernst nehmen\n\nDer erste Schritt raus aus dem stillen Kaputtgehen war kein Code, sondern eine Begriffsliste. Welche fachlichen Dinge kommen in diesen Dateien überhaupt vor? Am Ende standen erstaunlich wenige Begriffe auf dem Zettel, aber jeder davon mit scharfer Bedeutung:\n\n- Beleg – das Dokument als Ganzes, mit eindeutiger Belegnummer und einer Belegart\n- Position – eine Zeile auf dem Beleg, mit Menge, Einzelpreis und eigenem Steuersatz\n- Steuersatz – in meinem Bestand 0, 7 oder 19 Prozent, nichts anderes\n- Gutschrift – ein eigenständiger Beleg mit umgekehrtem Vorzeichen\n- Storno – hebt einen Beleg auf und referenziert dessen Belegnummer\n- Zahlungsziel – ein Datum, abgeleitet aus Rechnungsdatum plus Frist\n\nDiese Liste sieht banal aus. Sie hat aber sofort Entscheidungen erzwungen, die vorher unsichtbar im Spaltenmapping versteckt waren. Ist ein negativer Betrag eine Gutschrift oder ein Storno? Fachlich ein riesiger Unterschied: Die Gutschrift ist ein neuer Vorgang, der Storno macht einen alten ungültig. In meinen Rohdaten waren beide dieselbe Zeile mit Minuszeichen. Ohne Begriffsliste hätte ich das nie als Problem erkannt – die Zahl stimmt ja.\n\nDie Beziehungen zwischen den Belegarten lassen sich klein aufzeichnen:\n\n```mermaid\nflowchart LR\n  I[\"Rechnung<br/>INV-2023-0117\"] --> P1[\"Position 1<br/>19 %\"]\n  I --> P2[\"Position 2<br/>7 %\"]\n  C[\"Storno<br/>CAN-2023-0119\"] -- \"referenziert\" --> I\n  G[\"Gutschrift<br/>CRN-2023-0121\"] -- \"bezieht sich auf\" --> I\n```\n\nDer Storno ohne Referenz ist in diesem Bild schlicht nicht darstellbar. Genau so soll es sein: Was sich nicht zeichnen lässt, sollte sich auch nicht speichern lassen.\n\n## Invarianten: was immer gelten muss\n\nBegriffe allein reichen nicht. Die eigentliche Substanz einer Domäne steckt in ihren Invarianten – den Aussagen, die für jeden gültigen Datensatz gelten müssen, egal woher er kommt. Für Rechnungsdaten sind das vor allem drei:\n\nErstens: Summen müssen aufgehen. Die Positionssummen je Steuersatz, darauf die Steuer, das Ganze addiert – das muss den Bruttobetrag des Belegs ergeben, auf den Cent. Rundung passiert je Steuersatzgruppe, nicht je Position, sonst driftet es.\n\nZweitens: Ein Storno referenziert ein Original. Ein Storno ohne Referenz ist kein Storno, sondern ein Datenfehler mit Minuszeichen.\n\nDrittens – der Klassiker: Brutto und Netto dürfen nie verwechselbar sein. Ein Feld, das mal das eine und mal das andere enthält, ist schlimmer als gar kein Feld. Bei 19 Prozent Differenz fällt der Fehler in Summenauswertungen nicht einmal sofort auf; er verteilt sich als gleichmäßiger Nebel über alle Zahlen.\n\nMit TypeScript und Zod lässt sich das alles explizit machen. Beträge führe ich grundsätzlich als ganzzahlige Cent-Werte – Gleitkomma und Geld vertragen sich nicht:\n\n```typescript\nimport { z } from \"zod\";\n\nconst TaxRate = z.union([z.literal(0), z.literal(7), z.literal(19)]);\n\nconst InvoiceLine = z.object({\n  description: z.string().min(1),\n  quantity: z.number().positive(),\n  unitPriceNetCents: z.number().int(), // money as integer cents, never floats\n  taxRate: TaxRate,\n});\n\nconst Invoice = z.object({\n  invoiceNumber: z.string().regex(/^(INV|CRN|CAN)-\\d{4}-\\d{4}$/),\n  kind: z.enum([\"invoice\", \"credit_note\", \"cancellation\"]),\n  referencesInvoice: z.string().optional(),\n  issueDate: z.string().date(),   // invoice date\n  serviceDate: z.string().date(), // date of supply\n  dueDate: z.string().date(),     // payment target\n  lines: z.array(InvoiceLine).min(1),\n  totalGrossCents: z.number().int(),\n});\n```\n\nDas ist erst die Struktur. Die Invarianten kommen als Verfeinerung obendrauf, damit ein Verstoß denselben Weg nimmt wie ein Strukturfehler:\n\n```typescript\nconst ValidInvoice = Invoice.superRefine((inv, ctx) => {\n  // sum per tax rate group, round per group, then add up\n  const grossByRate = new Map<number, number>();\n  for (const line of inv.lines) {\n    const net = line.quantity * line.unitPriceNetCents;\n    const gross = Math.round(net * (1 + line.taxRate / 100));\n    grossByRate.set(line.taxRate, (grossByRate.get(line.taxRate) ?? 0) + gross);\n  }\n  const computedGross = [...grossByRate.values()].reduce((a, b) => a + b, 0);\n\n  if (computedGross !== inv.totalGrossCents) {\n    ctx.addIssue({\n      code: z.ZodIssueCode.custom,\n      message: `gross mismatch: lines say ${computedGross}, document says ${inv.totalGrossCents}`,\n    });\n  }\n  if (inv.kind === \"cancellation\" && !inv.referencesInvoice) {\n    ctx.addIssue({\n      code: z.ZodIssueCode.custom,\n      message: \"cancellation must reference an original invoice\",\n    });\n  }\n});\n```\n\nDer Unterschied zur ersten Version des Projekts ist grundsätzlich. Vorher hat der Import akzeptiert, was sich parsen ließ. Jetzt akzeptiert er, was fachlich stimmt. Das ist dieselbe Verschiebung, die man aus dem Domain-Driven Design kennt, nur auf der unscheinbarsten Ebene überhaupt: beim Einlesen von Dateien.\n\n## Drei Daten, drei Bedeutungen\n\nDie Zeitregeln verdienen einen eigenen Abschnitt, weil sie der Ort sind, an dem Auswertungen am leisesten falsch werden. Auf einem Beleg stehen bis zu drei Daten, und keines davon ist „das Datum\":\n\nDas Leistungsdatum sagt, wann etwas geliefert oder geleistet wurde. Das Rechnungsdatum sagt, wann der Beleg ausgestellt wurde. Das Zahldatum – das gar nicht auf dem Beleg steht, sondern aus dem Konto-Export kommt – sagt, wann Geld geflossen ist. Zwischen Leistung und Rechnung können Wochen liegen, zwischen Rechnung und Zahlung noch einmal. Eine Auswertung „Ausgaben pro Monat\" liefert je nach gewähltem Datum drei verschiedene Wahrheiten, und alle drei sind für irgendeine Frage die richtige: Das Leistungsdatum gehört zur Frage „Wann ist der Aufwand entstanden?\", das Rechnungsdatum zur Umsatzsteuer, das Zahldatum zur Liquidität.\n\nIn meinen Rohdaten hatte jede Quelle genau eine Datumsspalte, und keine zwei Quellen meinten dasselbe damit. Seit die drei Daten getrennte, verpflichtende Felder sind, muss jede Auswertung sagen, welches sie meint – die Frage lässt sich nicht mehr versehentlich überspringen. Das Zahlungsziel ist dann nur noch abgeleitete Information: Rechnungsdatum plus Frist ergibt `dueDate`, und ein Abgleich mit dem Zahldatum zeigt, was überfällig war.\n\n## Die Grenze: ablehnen statt durchwinken\n\nBleibt die Frage, was mit Zeilen passiert, die die Prüfung nicht bestehen. Die übliche Antwort ist eine Log-Zeile, und die übliche Folge ist, dass niemand sie liest. Bei einem Import, der einmal im Monat läuft, ist das gleichbedeutend mit stillem Datenverlust – nur dass diesmal die schlechten Daten draußen bleiben statt drin, was besser ist, aber immer noch unsichtbar.\n\nDie für mich brauchbare Antwort: Abgelehnte Zeilen sind ein Ergebnis des Laufs, gleichrangig mit den akzeptierten. Der Import erzeugt zwei Artefakte – die Datenbank und einen Ablehnungs-Report als Markdown-Datei, mit Quelle, Zeilennummer und dem fachlichen Grund:\n\n```typescript\ntype RejectedRow = {\n  source: string;   // e.g. \"supplier-a-2023.csv\"\n  rowNumber: number;\n  reason: string;   // human-readable, domain language\n};\n\nconst rejectedRows: RejectedRow[] = [];\n\nfor (const [rowNumber, candidate] of candidates.entries()) {\n  const result = ValidInvoice.safeParse(candidate);\n  if (!result.success) {\n    rejectedRows.push({\n      source: candidate.source,\n      rowNumber,\n      reason: result.error.issues.map((i) => i.message).join(\"; \"),\n    });\n    continue;\n  }\n  accepted.push(result.data);\n}\n// afterwards: write rejected-rows report next to the database, fail if quota exceeded\n```\n\nDer Report ist bewusst eine Datei und kein Log-Strom. Dateien kann man diffen, ablegen, im nächsten Monat mit dem Vormonat vergleichen. Läuft der Import zweimal über dieselben Eingaben, muss exakt derselbe Report entstehen – warum ich auf diese Eigenschaft so viel Wert lege, habe ich in [Deterministische CLIs](https://www.mikebild.dev/de/blog/deterministische-clis/) ausführlicher beschrieben. Und für die Werte, die aus PDFs stammen, gilt ohnehin gesteigertes Misstrauen: Was eine Texterkennung liefert, ist ein Messwert mit Unsicherheit, kein Fakt – dazu mehr in [OCR mit Cache und Misstrauen](https://www.mikebild.dev/de/blog/ocr-mit-cache-und-misstrauen/).\n\nDer komplette Fluss sieht dann so aus:\n\n```mermaid\nflowchart LR\n  A[\"PDF-Belege\"] --> N[\"Normalisieren<br/>Kommas, Encoding, Spalten\"]\n  B[\"CSV-Exporte<br/>Buchhaltung\"] --> N\n  K[\"Konto-Export<br/>Bank\"] --> N\n  N --> V{\"Domänen-Validierung<br/>Typen + Invarianten\"}\n  V -- \"gültig\" --> DB[(\"SQLite<br/>Belege + Positionen\")]\n  V -- \"abgelehnt\" --> R[\"Ablehnungs-Report<br/>Markdown, diffbar\"]\n```\n\nWichtig an dem Bild ist die eine Raute. Es gibt genau eine Stelle, an der Rohdaten zu Domänendaten werden. Hinter dieser Grenze darf jeder Code den Feldern trauen: `totalGrossCents` ist brutto, in Cent, und die Summen gehen auf. Vor der Grenze ist alles verdächtig. Diese Asymmetrie macht den Rest des Projekts so angenehm – die Auswertungen enthalten keine einzige Abwehrzeile mehr, kein „falls das Feld doch leer ist\", kein zweites Nachrechnen.\n\n## Auswerten, wenn die Daten sauber sind\n\nFür die Analysen habe ich DuckDB direkt auf die Datenbank gesetzt. Sobald die Domäne stimmt, sind die interessanten Fragen erstaunlich kurze SQL-Statements. Offene Posten zum Beispiel – Belege, deren Zahlungsziel verstrichen ist, ohne dass ein Zahldatum existiert:\n\n```sql\nSELECT i.invoice_number,\n       i.due_date,\n       i.total_gross_cents / 100.0 AS gross_eur\nFROM invoices i\nLEFT JOIN payments p ON p.invoice_number = i.invoice_number\nWHERE i.kind = 'invoice'\n  AND p.paid_date IS NULL\n  AND i.due_date < current_date\nORDER BY i.due_date;\n```\n\nDass diese Query stimmt, verdankt sie vollständig der Grenze davor. Mit den Rohdaten der ersten Version hätte dieselbe Query plausible und falsche Zahlen geliefert – Stornos als offene Posten gezählt, Brutto und Netto gemischt, das Leistungsdatum als Fälligkeit interpretiert. Nichts davon wäre aufgefallen, weil das Ergebnis nach Geld aussieht und Geldbeträge immer irgendwie glaubwürdig wirken.\n\nEin Side Effect, mit dem ich nicht gerechnet hatte: Der Ablehnungs-Report wurde zum besten Werkzeug, um die Quellen selbst zu verstehen. Häufen sich Ablehnungen aus einer Datei mit demselben Grund, ist das kein Rauschen, sondern eine Erkenntnis über den Exporteur – etwa dass ein Werkzeug bei Gutschriften das Vorzeichen anders setzt als bei Rechnungen. Solche Muster standen früher in keinem Log; jetzt stehen sie gesammelt in einer Datei, die ich tatsächlich lese.\n\n## Und die E-Rechnung?\n\nMan könnte einwenden, dass sich das Problem von selbst erledigt: Mit dem Wachstumschancengesetz ist im März 2024 beschlossen worden, dass die elektronische Rechnung im deutschen B2B-Geschäft ab 2025 schrittweise verpflichtend wird – strukturierte Formate nach EN 16931 statt PDF-Prosa. Das ist eine gute Nachricht, denn genau die Felder, um die es hier ging, sind dort formal definiert: Belegart, Steuersätze je Position, Leistungs- und Rechnungsdatum.\n\nNur verschiebt ein strukturiertes Format die Arbeit, es beseitigt sie nicht. Ob ein XML-Feld oder eine CSV-Spalte behauptet, ein Bruttobetrag zu sein – prüfen, dass die Summen aufgehen und der Storno sein Original findet, muss weiterhin die empfangende Seite. Und die Bestandsdaten der vergangenen Jahre bleiben ohnehin, was sie sind: PDFs und Exporte mit Eigenheiten. Die Domänengrenze wird also nicht überflüssig; sie bekommt künftig nur besseres Eingangsmaterial.\n\n## Was ich daraus mitnehme\n\nDer Reflex, Rechnungsdaten als Transportproblem zu behandeln, ist verständlich – die Dateien sind klein, die Formate bekannt, die Werkzeuge fertig. Aber die Schwierigkeit liegt nicht im Transport. Sie liegt darin, dass diese Daten eine Fachsprache haben, Invarianten, die kein Parser kennt, und drei Zeitachsen, die sich nicht in einer Datumsspalte unterbringen lassen. Wer das ignoriert, bekommt einen Import, der grün durchläuft und trotzdem falsche Zahlen produziert. Wer es modelliert, bekommt etwas Selteneres als korrekte Daten: erklärbare Daten. Jede Zahl in der Datenbank hat eine Herkunft, jede fehlende Zeile einen dokumentierten Grund.\n\nUnterm Strich hat das kleine private Projekt damit mehr über Softwarearchitektur gesagt als mancher Servicezuschnitt: Eine Domäne erkennt man nicht an ihrer Größe, sondern daran, dass sie eigene Begriffe, eigene Regeln und eigene Zeit hat. Rechnungsdaten haben alle drei.\n\n## Weiterführende Quellen\n\n- [Zod auf GitHub](https://github.com/colinhacks/zod) – Schema-Validierung für TypeScript\n- [Zod-Dokumentation](https://zod.dev) – Referenz zu Refinements und Error Handling\n- [DuckDB-Dokumentation](https://duckdb.org/docs/) – lokale Analysen direkt auf Dateien und Datenbanken\n- [§ 14 UStG](https://www.gesetze-im-internet.de/ustg_1980/__14.html) – Pflichtangaben einer Rechnung im deutschen Umsatzsteuerrecht\n- [Papa Parse](https://www.papaparse.com/) – CSV-Parsing im Browser und in Node.js"},{"slug":"nodejs-testing-mit-mocha","title":"Node.js testen: die Testpyramide mit Mocha","summary":"Wie sich eine Node.js-Testsuite sinnvoll auf verschiedene Testebenen verteilt und wie Mocha, node:assert, sinon und supertest dabei zusammenspielen.","tags":["Software","Node.js","Backend"],"published_at":"2024-07-16T10:00:00+00:00","updated_at":"2026-07-13T08:02:04.028363+00:00","url":"https://www.mikebild.dev/de/blog/nodejs-testing-mit-mocha/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/c279591952357f6641325189341292127b3e1bb161a52759cf3751512042aa21/nodejs-testing-mit-mocha-vorlesen.mp3","audio_duration_secs":777,"text":"Ein Test beweist nicht, dass Code funktioniert. Er hält fest, welches Verhalten jemand einmal von diesem Code erwartet hat – und schlägt Alarm, wenn diese Erwartung bricht. Eine Testsuite ist damit weniger ein Beweis als ein Gedächtnis, und derjenige, der die Erwartung bricht, ist erstaunlich oft man selbst, ein halbes Jahr später. Im Backend, wo eine falsche Berechnung still durch eine API rutscht und erst in der Buchhaltung auffällt, ist dieses Gedächtnis Gold wert.\n\nDie entscheidende Frage ist deshalb nie \"Testen wir?\", sondern \"Wie verteilen wir unseren Testaufwand?\" Genau dafür gibt es ein Bild, das seit Jahren trägt: die Testpyramide. Sie ist eine Heuristik, kein Mengenverhältnis, das für jedes System gleich aussieht. Ich schaue mir in diesem Artikel an, wie sie funktioniert, warum Suiten mit der Zeit kippen, und welche Werkzeuge im Node.js-Alltag dazu passen – Mocha als Runner, `node:assert` für Assertions, sinon für die Isolation externer Abhängigkeiten und supertest für HTTP-Endpoints. Node.js bringt inzwischen mit `node:test` selbst einen stabilen Test-Runner mit; Mocha bleibt hier die bewusst gewählte, nicht die einzig zeitgemäße Variante.\n\n## Die Pyramide: breite Basis, schmale Spitze\n\nDie Idee ist einfach. Nicht alle Tests sind gleich teuer, und nicht alle geben gleich schnell Auskunft. Ein Unit-Test läuft in Millisekunden, isoliert eine einzelne Funktion und sagt mir bei einem Fehlschlag ziemlich genau, wo das Problem sitzt. Ein End-to-End-Test fährt die ganze Anwendung hoch, klickt sich durch mehrere Systeme und braucht Sekunden bis Minuten – und wenn er rot wird, weiß ich erst mal nur, dass irgendwo auf dem langen Weg etwas kaputt ist.\n\nDaraus folgt häufig eine Verteilung mit vielen schnellen Tests an der Basis, gezielten Integrationstests in der Mitte und wenigen durchgängigen Szenarien an der Spitze. Wie breit die Schichten ausfallen, hängt von den Risiken und den Systemgrenzen ab.\n\n```mermaid\ngraph TD\n    E2E[\"E2E<br/>wenige, langsam, brüchig\"]\n    INT[\"Integration<br/>weniger, mittelschnell\"]\n    UNIT[\"Unit<br/>viele, schnell, isoliert\"]\n    E2E --> INT\n    INT --> UNIT\n    style UNIT fill:#2d6a4f,color:#fff\n    style INT fill:#40916c,color:#fff\n    style E2E fill:#74c69d,color:#000\n```\n\nDie Basis ist breit, weil Unit-Tests billig sind – schnell geschrieben, schnell ausgeführt, stabil. Die Spitze ist schmal, weil E2E-Tests teuer im Unterhalt sind. Sie brechen bei jeder Umgebungsänderung, sie werden flaky, sobald Timing ins Spiel kommt, und sie kosten in jeder CI-Runde Zeit. Ein paar davon braucht man – sie sind die einzigen, die belegen, dass die Teile auch zusammen funktionieren. Aber sie sind das Salz, nicht die Suppe.\n\n## Welches Werkzeug wofür\n\nBevor ich zu Code komme, eine kurze Einordnung, denn in Node.js verteilt sich die Arbeit auf ein kleines Ökosystem statt auf ein einzelnes Framework:\n\n- Mocha ist der Runner. Er gibt die Struktur (`describe`/`it`), führt die Tests aus, verwaltet Hooks und meldet Ergebnisse. Mocha selbst bringt bewusst keine Assertions mit.\n- `node:assert` liefert genau diese Assertions – eingebaut, ohne zusätzliche Abhängigkeit. Wer eine ausdrucksstärkere BDD-Syntax will, greift alternativ zu Chai (in der CommonJS-Welt 2024 die v4-Linie).\n- sinon stellt Test-Doubles bereit: Spies beobachten Aufrufe, Stubs ersetzen Verhalten. Damit isoliere ich externe Abhängigkeiten wie Datenbank, HTTP oder Zeit.\n- supertest testet HTTP-Endpoints auf App-Ebene, ohne einen echten Netzwerk-Port oder Browser zu brauchen.\n\nDiese Trennung irritiert Umsteiger aus anderen Sprachen anfangs. Sie ist aber eine Stärke: Man kombiniert austauschbare Bausteine, statt sich an ein Monolith-Framework zu binden.\n\n## Unit-Tests: das Fundament\n\nFangen wir unten an. Ein Unit-Test nimmt eine Funktion, gibt ihr Eingaben und prüft die Ausgabe. Nichts sonst – keine Datenbank, kein Netzwerk, kein Dateisystem.\n\nMocha strukturiert das über `describe` (ein Block, der zusammengehörige Tests gruppiert) und `it` (ein einzelner Testfall). Für die Assertion nehme ich `node:assert` im Strict-Modus. Der `node:`-Präfix macht klar, dass es ein Built-in ist, und `/strict` sorgt dafür, dass keine stillschweigende Typ-Umwandlung passiert.\n\n```js\nconst assert = require('node:assert/strict');\nconst { sum } = require('../src/math');\n\ndescribe('sum()', () => {\n  it('adds two positive numbers', () => {\n    assert.strictEqual(sum(2, 3), 5);\n  });\n\n  it('handles negative operands', () => {\n    assert.strictEqual(sum(-4, 1), -3);\n  });\n});\n```\n\nZwei Details lohnen einen zweiten Blick. Erstens die Argument-Reihenfolge: `assert.strictEqual(actual, expected)` – immer erst der tatsächliche Wert, dann der erwartete. Wer das vertauscht, bekommt bei einem Fehlschlag eine Diff-Ausgabe, die genau falsch herum liest. Zweitens `strictEqual` statt `equal`: Der Strict-Modus vergleicht mit `Object.is`/`===` und wandelt nichts stillschweigend um. `1` ist nicht `'1'`. Das klingt pedantisch, verhindert aber genau die Klasse von Bugs, die man mit Tests eigentlich fangen will.\n\nFür Objekte und Arrays nehme ich `deepStrictEqual`, das tief und typgenau vergleicht:\n\n```js\nconst assert = require('node:assert/strict');\n\nit('builds the expected payload', () => {\n  const result = buildUser({ id: 1, name: 'Ada' });\n  assert.deepStrictEqual(result, {\n    id: 1,\n    name: 'Ada',\n    role: 'guest',\n  });\n});\n```\n\n## Verhalten testen, nicht Implementierung\n\nHier kommt der Punkt, an dem in Code-Reviews die meisten Diskussionen entstehen. Ein Kollege fragte einmal, ob er nicht prüfen solle, dass seine Funktion intern `Array.map` aufruft. Besser nicht: Interessieren sollte den Test das Ergebnis – die transformierte Liste –, nicht der Weg dorthin.\n\nDer Unterschied ist praktisch und nicht akademisch. Ein Test, der auf beobachtbares Verhalten zielt, überlebt ein Refactoring: Solange die Funktion bei gleicher Eingabe die gleiche Ausgabe liefert, bleibt er grün, egal ob innen `map`, `reduce` oder eine `for`-Schleife steht. Ein Test, der Implementierungsdetails festnagelt, bricht dagegen bei jeder Umbauaktion – obwohl fachlich nichts kaputt ist. Solche Tests erziehen ein Team dazu, Refactorings zu scheuen. Und ein Team, das sich nicht mehr traut umzubauen, sammelt technische Schulden schneller an, als jeder Test sie je einfangen könnte.\n\n## Externe Abhängigkeiten isolieren mit sinon\n\nSobald eine Funktion die reine Berechnung verlässt und mit einer Datenbank, einem HTTP-Client oder der Uhr redet, wird es interessant. Diese Abhängigkeiten will ich im Unit-Test nicht echt ansprechen – sie sind langsam, unzuverlässig und schwer in einen definierten Zustand zu bringen. Stattdessen ersetze ich sie durch Test-Doubles.\n\nsinon unterscheidet dabei vor allem zwischen Spy und Stub. Ein Spy lässt die echte Funktion laufen und beobachtet nur, wie oft und womit sie aufgerufen wurde. Ein Stub ersetzt das Verhalten komplett – ich sage ihm, was er zurückgeben oder werfen soll.\n\nNehmen wir einen Service, der einen Nutzer über ein Repository lädt. Ich will testen, dass er sich bei einem DB-Fehler korrekt verhält, ohne je eine echte Datenbank anzufassen:\n\n```js\nconst assert = require('node:assert/strict');\nconst sinon = require('sinon');\nconst userRepo = require('../src/user-repo');\nconst service = require('../src/user-service');\n\ndescribe('userService.getUser()', () => {\n  afterEach(() => {\n    sinon.restore();\n  });\n\n  it('propagates a repository failure', async () => {\n    const stub = sinon\n      .stub(userRepo, 'findById')\n      .rejects(new Error('db down'));\n\n    await assert.rejects(() => service.getUser(1), /db down/);\n    assert.ok(stub.calledOnceWith(1));\n  });\n\n  it('returns the user on success', async () => {\n    sinon.stub(userRepo, 'findById').resolves({ id: 1, name: 'Ada' });\n\n    const user = await service.getUser(1);\n    assert.strictEqual(user.name, 'Ada');\n  });\n});\n```\n\nDer wichtigste Baustein hier ist nicht der Stub selbst, sondern das `afterEach(() => sinon.restore())`. Seit sinon v5 ist das Default-Objekt `sinon` selbst eine Sandbox; `restore()` setzt alle ersetzten Methoden zurück. Vergisst man das, leben die Stubs über den Testfall hinaus weiter. Der nächste Test bekommt dann unbemerkt einen manipulierten `userRepo` untergeschoben – und je nach Ausführungsreihenfolge geht er mal durch, mal nicht. Genau das meint man mit \"flaky\": ein Test, dessen Ergebnis von der Reihenfolge oder der Parallelität abhängt, nicht vom Code. Wer eine eigene Sandbox bevorzugt, nutzt `const sandbox = sinon.createSandbox()` und ruft `sandbox.restore()` auf – das Prinzip bleibt.\n\nAber Vorsicht: Test-Doubles sind ein scharfes Messer. Wer alles wegmockt, testet am Ende nur noch, dass die Mocks so reagieren, wie er sie konfiguriert hat. Solche Tests spiegeln die eigene Erwartung zurück und brechen beim ersten echten Refactoring. Ersetzen sollte man die Grenze zum externen System – nicht die eigene Logik gleich mit.\n\n## Integrationstests: HTTP-Endpoints mit supertest\n\nEine Ebene höher liegen Integrationstests. Hier will ich nicht mehr eine einzelne Funktion prüfen, sondern das Zusammenspiel – etwa dass eine Express-Route den richtigen Statuscode und Body liefert. Dafür gibt es supertest.\n\nsupertest nimmt eine Express-App oder einen `http.Server`, bindet sie an einen ephemeren Port und schickt echte HTTP-Requests dagegen. Kein Browser, kein manuell gestarteter Server, keine feste Portnummer. Die API ist fluent und liest sich fast wie eine Beschreibung:\n\n```js\nconst assert = require('node:assert/strict');\nconst request = require('supertest');\nconst app = require('../src/app');\n\ndescribe('GET /users', () => {\n  it('returns 200 and a JSON list', async () => {\n    const res = await request(app)\n      .get('/users')\n      .expect(200)\n      .expect('Content-Type', /json/);\n\n    assert.ok(Array.isArray(res.body));\n    assert.strictEqual(res.body.length, 2);\n  });\n\n  it('returns 404 for an unknown user', async () => {\n    await request(app).get('/users/999').expect(404);\n  });\n});\n```\n\nDas `await` vorne ist entscheidend. supertest gibt ein Promise zurück; ohne `await` (oder ein `return` des Promises) endet der Test, bevor die Assertions gelaufen sind – und Mocha meldet ihn fälschlich als grün. Diese Art von False Positive ist besonders tückisch, weil die Suite wochenlang beruhigend grün bleibt, obwohl sie nichts prüft.\n\nDamit ist auch klar, warum ich Async konsequent auf einem Weg abwickle. Mocha erlaubt zwei Stile: entweder `async`/Promise-returning oder den klassischen `done`-Callback. Mischen darf man sie nicht. Entweder\n\n```js\nit('waits for the promise', async () => {\n  const value = await loadValue();\n  assert.strictEqual(value, 42);\n});\n```\n\noder, wenn man mit einer Callback-basierten API arbeitet:\n\n```js\nit('waits for the callback', function (done) {\n  loadValue((err, value) => {\n    if (err) return done(err);\n    assert.strictEqual(value, 42);\n    done();\n  });\n});\n```\n\nBeides gleichzeitig – ein `done`-Parameter und `async` – lehnt Mocha mit einem klaren Fehler ab (\"Resolution method is overspecified\"). Ein Detail am Rande: Mochas Default-Timeout pro Test liegt bei 2000 ms. Wenn ein asynchroner Test nie sein `done` aufruft oder ein Promise nie auflöst, läuft er in diesen Timeout und wird rot – oft der erste Hinweis auf ein vergessenes `await` oder `done`.\n\n## Der häufigste Fehler: die Pyramide auf dem Kopf\n\nJetzt zum häufigsten Fallstrick. Ein Team startet mit den besten Absichten, schreibt aber vor allem E2E-Tests, weil die sich so schön \"echt\" anfühlen. Nach einem Jahr sieht die Verteilung so aus:\n\n```mermaid\ngraph TD\n    UNIT[\"Unit<br/>kaum vorhanden\"]\n    INT[\"Integration<br/>ein paar\"]\n    E2E[\"E2E<br/>viele, langsam, flaky\"]\n    UNIT --> INT\n    INT --> E2E\n    style E2E fill:#9d0208,color:#fff\n    style INT fill:#dc2f02,color:#fff\n    style UNIT fill:#f48c06,color:#000\n```\n\nMan nennt das die \"Ice-Cream-Cone\" – die umgedrehte Pyramide mit der breiten, teuren Spitze oben. Die Symptome sind immer gleich: Die Suite läuft zehn Minuten statt zehn Sekunden. Ein einzelner roter Test sagt nicht, wo der Bug sitzt, weil er ein Dutzend Komponenten gleichzeitig durchläuft. Und weil E2E-Tests von Timing und Umgebung abhängen, werden sie flaky – irgendwann lässt das Team die Pipeline einfach so lange neu laufen, bis sie grün ist. Das ist der Moment, in dem Tests aufhören, ein Gedächtnis zu sein, und zu einem Ritual werden, das niemand mehr ernst nimmt.\n\nDer Weg heraus ist unspektakulär: Für jeden Bug, der durch einen langsamen E2E-Test auffällt, schreibt man den eigentlichen Regressionstest eine Ebene tiefer – als Unit- oder Integrationstest, der dieselbe Ursache in Millisekunden fängt. Die Pyramide richtet sich nicht durch einen großen Umbau wieder auf, sondern durch die Gewohnheit, Fehler auf der billigsten Ebene festzuhalten, auf der sie reproduzierbar sind.\n\nDiese Haltung passt zu einem Gedanken, den ich schon beim Thema [Fehler als Architektur](https://www.mikebild.dev/de/blog/nodejs-fehler-als-architektur/) beschrieben habe: Ein Fehler ist kein Ärgernis, das man wegdrückt, sondern eine Information über das System. Ein Test ist die Konserve dieser Information. Und wer verstehen will, warum asynchrone Tests im Backend überhaupt so eine große Rolle spielen, findet die Grundlagen dazu im [Node.js-Training](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/) rund um Event Loop und Architektur.\n\n## Ein Wort zu Hooks\n\nDamit Tests unabhängig bleiben, braucht jeder einen sauberen Ausgangszustand. Mocha bietet dafür vier Hooks: `before` und `after` laufen einmal pro `describe`-Block, `beforeEach` und `afterEach` vor beziehungsweise nach jedem einzelnen Test. Die Reihenfolge ist verlässlich: erst alle `before`, dann pro Test `beforeEach` → Test → `afterEach`, am Ende `after`.\n\n```js\ndescribe('orderService', () => {\n  let db;\n\n  before(async () => {\n    db = await connectTestDb();\n  });\n\n  beforeEach(async () => {\n    await db.reset();\n  });\n\n  afterEach(() => {\n    sinon.restore();\n  });\n\n  after(async () => {\n    await db.close();\n  });\n\n  it('creates an order', async () => {\n    // ...\n  });\n});\n```\n\nTeure Einmal-Arbeit wie der Verbindungsaufbau gehört in `before`/`after`, das Zurücksetzen von Zustand in `beforeEach`/`afterEach`. Ein `beforeEach` innerhalb eines verschachtelten `describe` gilt nur für dessen Tests – so hält man Setup lokal und vermeidet, dass ein globaler Hook die halbe Suite unnötig verlangsamt.\n\n## Fazit\n\nTesten im Backend ist keine Fleißarbeit, die man am Ende noch draufsattelt, sondern die Art, wie ein Team seine Architektur-Erwartungen festhält und über Refactorings hinweg absichert. Die Testpyramide gibt dabei eine Richtung vor, aber keine feste Stückzahl: schnelle Tests tragen die Basis, Integrationstests sichern wichtige Grenzen ab, wenige durchgängige Szenarien prüfen das Zusammenspiel. In diesem Beispiel ist der Werkzeugkasten klein und austauschbar – Mocha strukturiert, `node:assert` prüft, sinon isoliert externe Abhängigkeiten, supertest testet HTTP-Endpoints.\n\nWas sich für mich über die Jahre als Kern herausgestellt hat: beobachtbares Verhalten testen, nicht die Implementierung, und Fehler auf der billigsten Ebene festhalten, auf der sie reproduzierbar sind. Dann bleibt die Suite schnell, aussagekräftig und – das Wichtigste – vertrauenswürdig. Eine Testsuite, der niemand mehr glaubt, ist teurer als gar keine.\n\n## Weiterführende Quellen\n\n- Mocha – Dokumentation und Hooks: [mochajs.org](https://mochajs.org/) und [mochajs.org/features/hooks](https://mochajs.org/features/hooks/)\n- node:assert – Strict Mode: [nodejs.org/api/assert.html](https://nodejs.org/api/assert.html)\n- node:test – eingebauter Test-Runner: [nodejs.org/api/test.html](https://nodejs.org/api/test.html)\n- sinon – Test-Doubles und Stubs: [sinonjs.org](https://sinonjs.org/) und [sinonjs.org/concepts/stubs](https://sinonjs.org/concepts/stubs/)\n- supertest: [npmjs.com/package/supertest](https://www.npmjs.com/package/supertest)\n- Chai (Alternative zu assert): [chaijs.com](https://www.chaijs.com/)\n- Begleitendes Repository mit Testing-Kapitel: [github.com/mikebild/introduction-nodejs](https://github.com/mikebild/introduction-nodejs)"},{"slug":"deterministische-clis","title":"Deterministische CLIs","summary":"Determinismus ist eine Architekturentscheidung: gleiche Eingaben, gleicher Report, reviewbar per Diff. Er lohnt sich dort, wo ein Artefakt wiederholt geprüft wird.","tags":["Software","Daten","Tools"],"published_at":"2024-07-12T10:00:00+00:00","updated_at":"2026-07-20T13:27:22.94021+00:00","url":"https://www.mikebild.dev/de/blog/deterministische-clis/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/4cf2d80358bbfdd332b59ab886bde6046f5efff5cd805904aa941dc98bc69531/deterministische-clis-vorlesen.mp3","audio_duration_secs":654,"text":"# Deterministische CLIs\n\nZwei Läufe, dieselben Eingabedateien, zwei verschiedene Reports. Der Unterschied besteht aus einem Zeitstempel im Kopf und einer Tabelle, deren Zeilen diesmal in anderer Reihenfolge stehen. Fachlich hat sich nichts geändert, aber der Diff zeigt hundert Zeilen an, und keine davon bedeutet etwas. Also liest der Reviewer den ganzen Report noch einmal. Beim nächsten Mal liest er ihn gar nicht mehr.\n\nEin deterministisches CLI dreht diese Lage um: Was im Diff auftaucht, hat sich auch fachlich geändert. Byte für Byte. Das klingt nach einem Implementierungsdetail, ist in Wirklichkeit aber eine Entscheidung über Systemgrenzen.\n\nDeterminismus wird meist als Side Effect behandelt, den man „auch noch“ mitnimmt. In Wahrheit ist er die Voraussetzung dafür, dass ein Ergebnis reviewbar wird. Und Reviewbarkeit ist die Währung, in der digitale Produktentwicklung tatsächlich bezahlt: Was ich nicht vergleichen kann, kann ich nicht freigeben, nicht widerlegen und nicht mit gutem Gewissen automatisieren.\n\n## Warum Determinismus ein Architektur-Hebel ist\n\nDer Reflex ist, Determinismus als Eigenschaft der Ausgabe zu sehen. Ich sehe ihn als Eigenschaft der Systemgrenze. Ein CLI, das reproduzierbar arbeitet, zwingt mich, jede Quelle von Nichtdeterminismus explizit zu machen: Zeitstempel, Zufallszahlen, unsortierte Maps, Netzwerkantworten, OCR-Ergebnisse, Modell-Outputs. Jede dieser Quellen muss ich entweder einfrieren, sortieren, cachen oder aus dem Ergebnis heraushalten.\n\nGenau diese Übung ist wertvoll, unabhängig vom Werkzeug. Wer Nichtdeterminismus benennt, hat die Zustands- und Abhängigkeitsgrenzen eines Systems schon halb dokumentiert. Die Frage „warum ist dieser Lauf nicht reproduzierbar?“ ist fast immer dieselbe wie „welcher Teil meines Systems ist verstecktes, ungezähmtes Ich-weiß-nicht?“.\n\nDer Business-Outcome ist konkret. Ein deterministischer Report lässt sich per `diff` reviewen. Ein Reviewer sieht in Sekunden, was sich zwischen zwei Läufen geändert hat, statt eine ganze Ausgabe erneut zu lesen. Das verkürzt den Feedback-Zyklus von „ich lese das komplett neu“ auf „ich schaue mir die drei geänderten Zeilen an“. In Teams, die täglich solche Reports produzieren, ist das der Unterschied zwischen Reibung und Fluss.\n\n## Das Kostenmodell – Determinismus ist nicht gratis\n\nDeterminismus hat einen Preis. Wer ihn verschweigt, liefert eine Werbebotschaft statt einer Abwägung.\n\nMan zahlt in expliziter Zustandskontrolle. Zeit muss injiziert statt gelesen werden. Zufall braucht einen Seed. Externe Sensoren – OCR, HTTP, ein Modell – brauchen einen Cache, damit ein zweiter Lauf dasselbe sieht wie der erste. Sortierung wird zur Pflicht, nicht zur Kür, weil sonst die Reihenfolge einer Hash-Map den Report verändert. Das ist zusätzliche Arbeit, und bei kleinen Wegwerf-Skripten lohnt sie sich oft nicht.\n\nDer Gegenwert ist Reviewbarkeit, Auditierbarkeit und Testbarkeit in einem. Ein deterministischer Report ist automatisch ein Golden-Master-Test: Ich checke einen erwarteten Report ein und lasse die CI gegen ihn diffen. Ich brauche keine gesonderte Assertion-Wüste, das Artefakt selbst ist der Test. Die Kosten fallen einmalig an, die Wirkung wiederholt sich bei jedem Lauf. Genau deshalb setze ich Determinismus dort ein, wo etwas oft läuft und oft geprüft wird – und lasse ihn dort weg, wo ein Skript einmal etwas kippt und dann verschwindet.\n\nDiese Abwägung ist die eigentliche Ingenieursleistung, nicht die Implementierung. Ich habe schon Teams gesehen, die jedes Skript deterministisch zwingen wollten und über Seed-Injektion in Wegwerf-Tools stolperten. Und ich habe das Gegenteil gesehen: einen täglichen Report, den niemand mehr las, weil er bei jedem Lauf anders aussah und ein Review Stunden gekostet hätte. Die Grenze verläuft nicht zwischen „immer“ und „nie“. Sie verläuft an der Frage, ob das Artefakt wiederholt geprüft wird. Wird es das, zahlt sich der Preis um Größenordnungen aus. Wird es das nicht, ist Determinismus Gold-Plating.\n\n## Die Grenze vor dem Werkzeug\n\nVor der Toolwahl kläre ich, welche Daten führend sind, was später nachvollziehbar sein muss und welche Teile unabhängig geändert werden sollen. So kann ich den PDF-Parser tauschen, ohne Matching-Regeln oder Reportformat anzufassen. Determinismus hilft beim letzten Punkt, ersetzt aber keine klare Fachgrenze: Ein reproduzierbarer Report kann auch Unklarheit zuverlässig wiederholen.\n\n## Ein konkretes Beispiel – Belege gegen Buchungen matchen\n\nIch zeige das an einem Inventur-Werkzeug, das ich als Muster immer wieder verwende. Die Aufgabe: ein Kontoblatt über Rechnungsnummern an PDF-Belege koppeln, Inventarpositionen gegen Rechnungspositionen matchen und die Lücken sichtbar machen. Klingt nach Buchhaltung, ist in Wahrheit eine Architekturübung über unsichere Daten.\n\nDrei Design-Entscheidungen tragen das Ganze, und keine davon ist ein Werkzeug.\n\nDie erste ist die Dreistufigkeit: Kontoblatt an Belege koppeln, Positionen matchen, Nicht-PDF-Vendors separat über Buchungstext-Regeln behandeln. Diese Stufen sind wichtiger als jedes Dateiformat. Sie sind die Fachgrenze, an der ich später unabhängig etwas ändern kann.\n\nDie zweite ist die saubere Trennung von hartem Match und Fuzzy-Fallback. Zuerst müssen definierte Tokens im Produkttitel stehen; Fuzzy Matching kommt nur zum Zug, wenn der harte Weg nichts findet. Damit bleibt Automatisierung erklärbar. Ich kann für jede Zeile sagen, warum sie zugeordnet wurde – hart über eine Regel oder weich über eine Ähnlichkeit oberhalb einer Schwelle. Ein Fuzzy-Match, den niemand mehr begründen kann, ist kein Feature – er ist die Fehlersuche von übermorgen.\n\nDie dritte ist die Behandlung von OCR als unsicherer Sensor. Vision liefert die Basis, Tesseract korrigiert gezielt Beträge in der Soll/Haben-Region, ein JSON-Cache macht spätere Läufe reproduzierbar. Der entscheidende Satz: Der erkannte Text ist ein Messwert, keine Wahrheit. Beträge, Namen und Nummern können falsch gelesen werden. Deshalb bekommt diese Schicht Cache, Schwellenwerte, manuelle Alias-Regeln und – das ist der Kern – Diffbarkeit.\n\nDer Report selbst ist dann ein Architekturartefakt, kein Nebenprodukt. Inventarpositionen, Rechnungsnummern, Kaufdatum, Match-Art, verwaiste Kontoeinträge und ungenutzte Rechnungspositionen stehen sichtbar nebeneinander. Genau dadurch wird aus einem Skript ein prüfbares Datensystem: Man sieht nicht nur, was gematcht wurde, sondern auch, was nicht passt – und das ist meist die interessantere Information.\n\n## Wie das aufgerufen wird\n\n```bash\ninventory-correlate \\\n  --account account.pdf \\\n  --inventory inventory.pdf \\\n  --out report.md\n\n# review is just a diff against the last known-good run\ndiff last-report.md report.md\n```\n\nDas Beispiel ist bewusst klein. Es soll keine vollständige Anwendung simulieren, sondern die Grenze zeigen. Ein gutes Beispiel hat nicht die meisten Features. Es hat die eine Entscheidung, über die man nach fünf Minuten streiten kann. Hier steckt sie im zweiten Aufruf: Weil der Report deterministisch ist, ist der Review ein `diff`. Kein Dashboard, kein State, kein „lies das nochmal ganz“.\n\n```mermaid\nflowchart LR\n  PDFs --> Parser\n  Parser --> Normalize\n  Normalize --> Match[\"Hard match<br/>then fuzzy fallback\"]\n  Match --> Report[\"Deterministic<br/>Markdown report\"]\n  Report --> Diff[\"Review via diff\"]\n```\n\n## Markdown als Audit-Artefakt\n\nWarum Markdown und nicht sofort eine Oberfläche? Weil ein Markdown-Report reviewbar, diffbar und archivierbar ist, ohne dass ich ein Dashboard bauen muss. Bei Datenwerkzeugen ist Diffbarkeit ein starkes Qualitätsmerkmal. Liegt ein Report als Markdown vor, kann eine Änderung im Review gesehen werden – Zeile für Zeile, mit voller Historie im Git. Das ist oft wertvoller als eine Oberfläche, die nur den letzten Zustand zeigt und die Frage „was hat sich geändert?“ gar nicht beantworten kann.\n\nDasselbe Prinzip trägt weit über Buchhaltung hinaus. Publishing-Werkzeuge können nach demselben Muster arbeiten: Eingabe rein, deterministisches Artefakt raus, Änderungen als Diff sichtbar. Ob ich Belege zuordne oder Präsentationen erzeuge – die Architektur ist identisch, weil das Qualitätsproblem identisch ist: Ich will Änderungen sehen, nicht Zustände raten.\n\n## Woran ich den Nutzen prüfe\n\nEin Diff begrenzt den Review auf die tatsächlich geänderten Zeilen. Ob das Zeit spart, lässt sich an der Review-Dauer eines wiederkehrenden Reports ablesen. Ein erwarteter Report kann außerdem als Golden Master dienen; ein unerwarteter Diff ist dann ein konkretes Prüfsignal. Dafür investiere ich in Caches, Seeds und explizite Sortierung – nicht, weil Determinismus für jedes Skript ein Selbstzweck wäre.\n\n## Worauf ich prüfe, bevor ich das produktiv nenne\n\nEine Idee ist erst belastbar, wenn auch der Fehlerpfad beschrieben ist. Was passiert bei Wiederholung? Was ist idempotent? Wo liegt die Transaktionsgrenze? Welche Logs oder Reports helfen später bei der Analyse? Diese Fragen wirken trocken, sind aber oft der Unterschied zwischen Demo und Betrieb.\n\nBeim deterministischen CLI kommt eine spezifische Frage dazu: Ist der Nichtdeterminismus wirklich eingefangen, oder nur zufällig gerade still? Ein Report, der heute stabil ist, weil die Map zufällig sortiert ausliefert, kippt morgen bei einer anderen Laufzeit. Deshalb erzwinge ich Sortierung explizit, versioniere Caches und behandle manuelle Korrekturen als Daten, nicht als Handgriffe. Fuzzy Matching braucht explizite Schwellen und einen Weg, harte Regeln vorzuziehen – sonst ist das Ergebnis nicht mehr erklärbar. Ein Tool darf unsicher sein, aber es muss seine Unsicherheit zeigen.\n\nVor einem produktiven Einsatz prüfe ich drei Dinge:\n\n- Gleiche Eingaben erzeugen byteweise dasselbe Ergebnis.\n- Uhrzeit, Zufall, Reihenfolge und externe Antworten sind explizit begrenzt.\n- Fehler und unsichere Zuordnungen erscheinen im prüfbaren Artefakt.\n\n## Typische Fehler\n\nDer erste Fehler ist, das Werkzeug mit der Lösung zu verwechseln. Docker ist keine Architektur. GraphQL ist kein Fachmodell. React ist kein State-Konzept. Ein LLM ist kein Prozess. Diese Werkzeuge können gute Grenzen ausdrücken, aber sie erzeugen sie nicht automatisch. Determinismus ist genauso: Er ist eine Entscheidung über Systemgrenzen, kein Flag, das man setzt.\n\nDer zweite Fehler ist, den glücklichen Pfad zu überbewerten. Viele Systeme sehen im Demo-Modus sauber aus. Interessant wird es bei Wiederholung, Teilfehlern, alten Daten, Netzwerkproblemen, falschen Berechtigungen oder menschlicher Korrektur. Genau dort zeigt sich, ob das Modell trägt – und ob der Report morgen noch denselben Diff liefert wie heute.\n\n## Schluss\n\nIch investiere in Determinismus, wenn ein Ergebnis wiederholt geprüft wird. Dann zeigt der Diff die Änderung, und Caches, Seeds oder explizite Sortierung haben einen konkreten Zweck. Für ein einmaliges Wegwerfskript wäre derselbe Aufwand Gold-Plating.\n\n## Weiterführende Quellen\n\n- [Git: `git diff`](https://git-scm.com/docs/git-diff/2.45.0)\n- [Reproducible Builds: Definition](https://reproducible-builds.org/docs/definition/)"},{"slug":"observability-mit-opentelemetry","title":"Observability mit OpenTelemetry: Logs, Traces und Metrics","summary":"Logs, Traces und Metrics mit OpenTelemetry einheitlich instrumentieren, über Trace-IDs verbinden und per OTLP an geeignete Backends ausgeben.","tags":["Software","Platform"],"published_at":"2024-07-09T10:00:00+00:00","updated_at":"2026-07-19T18:46:18.505233+00:00","url":"https://www.mikebild.dev/de/blog/observability-mit-opentelemetry/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/a6e5d7c260075d0b6d39a00bfe90ca04d4210f81ad976003e634fe340296cb9e/observability-mit-opentelemetry-vorlesen.mp3","audio_duration_secs":805,"text":"```json\n{\"ts\":\"2024-07-09T12:14:03.221Z\",\"level\":\"error\",\"msg\":\"stock reservation failed\",\"order_id\":\"4711\"}\n```\n\nDiese Zeile ist strukturiert, hat einen Zeitstempel, ein sauberes Feld für die Bestell-ID – und beantwortet trotzdem keine der Fragen, die nach einem Ausfall zählen. Welcher Request hat sie ausgelöst? Welcher Aufruf lief davor, welcher danach? Ist das der Einzelfall oder der hundertste seit Mittag? Die Zeile weiß nichts von dem Vorgang, aus dem sie stammt, und deshalb bleibt die Suche im Log-Volumen genau das: Suche.\n\nWas fehlt, ist die Verbindung – zwischen dem einzelnen Event, dem Pfad des Requests durch die Services und der aggregierten Sicht über die Zeit. Dafür stehen Logs, Traces und Metrics, und richtig nützlich werden sie erst, wenn sie sich gegenseitig referenzieren. OpenTelemetry ist der Standard, mit dem ich sie in Node einheitlich erhebe.\n\nDie drei Telemetriesignale sind keine austauschbaren Darstellungen derselben Information. Jedes beantwortet eine andere Frage.\n\n::::comparison{title=\"Drei Signale, drei Untersuchungsfragen\" role=\"primary\" preferredPattern=\"comparison-matrix\"}\n:::side{label=\"Metrics\"}\n- Frage · Wie verändert sich ein aggregierter Wert über Zeit?\n- Stärke · Trends, Schwellen und Alarmierung\n- Grenze · erklärt keinen einzelnen langsamen Request\n:::\n:::side{label=\"Traces\"}\n- Frage · Welchen Pfad nahm eine konkrete Ausführung?\n- Stärke · Latenz und Abhängigkeiten über Servicegrenzen hinweg\n- Grenze · ersetzt keine langfristige Aggregation\n:::\n:::side{label=\"Logs\"}\n- Frage · Welches diskrete Event wurde mit welchem Kontext festgehalten?\n- Stärke · Details, Fehlermeldungen und fachliche Hinweise\n- Grenze · bleibt ohne Korrelation und Begrenzung schwer auswertbar\n:::\n::::\n\nDer praktische Gewinn entsteht beim Wechsel zwischen diesen Perspektiven: vom Trend zum betroffenen Trace und von dort zu den korrelierten Events.\n\n## Drei Signale, drei Blickwinkel\n\nBevor ich über Werkzeuge rede, lohnt sich die begriffliche Sauberkeit. Logs, Traces und Metrics sind keine drei Wege, dasselbe zu tun – sie beantworten unterschiedliche Fragen und haben unterschiedliche Kosten.\n\n- Logs sind diskrete Events mit Zeitstempel: \"Bestellung 4711 abgelehnt, weil Lagerbestand null.\" Sie liefern das Detail, den konkreten Fall, den Debug-Kontext. Dafür sind sie teuer in der Retention und werden bei hoher Kardinalität schnell unübersichtlich.\n- Traces zeigen den Pfad eines einzelnen Requests durch das System: welcher Service ruft welchen, wie lange dauert jeder Hop, wo entsteht die Latenz. Ein Trace ist ein Baum von Spans, und er endet nicht an der Servicegrenze.\n- Metrics sind aggregierte Zahlenreihen über die Zeit: Requests pro Sekunde, Fehlerrate, Latenzverteilung. Sie sind billig zu speichern, eignen sich für Alarme und Trends – aber sie sagen mir nie, *welcher* Request langsam war, nur *dass* es welche gab.\n\nDer Denkfehler, den ich immer wieder sehe, ist die Annahme, eines dieser Signale reiche aus. Metrics allein sagen mir, dass die Latenz gestiegen ist, aber nicht warum. Logs allein ertrinken im Volumen. Traces allein zeigen den Pfad, aber nicht den Trend über eine Woche. Erst zusammen ergeben sie ein Bild – und der eigentliche Gewinn entsteht dort, wo ich zwischen ihnen springen kann: von einer Latenzspitze in der Metric zum konkreten Trace, von dort in die korrelierten Logs.\n\n## OpenTelemetry trennt Instrumentierung und Backend\n\nOpenTelemetry (kurz OTel) ist ein herstellerneutrales CNCF-Projekt, und genau diese Neutralität ist der Grund, warum ich es empfehle. Früher hieß Observability oft, sich an ein Backend zu binden: Das Agent-SDK des Anbieters wurde in den Code gewoben, und ein Wechsel bedeutete, die Instrumentierung neu zu schreiben. OTel trennt die Erzeugung und den Transport der Telemetrie vom Backend. Ich instrumentiere meinen Code gegen eine standardisierte API und gebe die Daten über OTLP oder einen passenden Exporter aus. Das senkt die Wechselkosten deutlich, macht Backends aber nicht austauschbar: Abfragesprachen, Aggregationen, Aufbewahrung, unterstützte Signale und Dashboards bleiben produktspezifisch. Ein Wechsel kann deshalb bei der Collector-Konfiguration beginnen und trotzdem Anpassungen an Semantik und Auswertung verlangen.\n\nEin Wort zum Reifegrad, denn der ist Mitte 2024 nicht überall gleich. Tracing ist in JavaScript stabil, Metrics ebenfalls. Das Logs-Signal ist in JS dagegen noch experimentell – `@opentelemetry/api-logs` und `sdk-logs` tragen den Alpha-Stempel und sind als Bridge für bestehende Logger gedacht, nicht als direkte Log-API für Anwendungscode. Praktisch heißt das: Ich schreibe meine Logs weiterhin mit dem Logger meiner Wahl und sorge selbst dafür, dass die Trace-Korrelation drinsteht. Dazu später mehr.\n\n## Das SDK aufsetzen – und zwar früh genug\n\nDie Instrumentierung beginnt mit dem Node-SDK. Der entscheidende Punkt, den ich in Reviews am häufigsten anmahne: Das SDK muss starten, *bevor* irgendein anderes Modul geladen wird. OTel funktioniert über Monkey-Patching der Bibliotheken – wenn `http` oder `graphql` schon geladen sind, bevor das SDK sie patchen konnte, bekomme ich einen No-op-Tracer und wundere mich über leere Traces. Deshalb lade ich die Instrumentierung über `--require`, nicht als regulären Import in `app.ts`.\n\n```ts\n// instrumentation.ts\nimport { NodeSDK } from '@opentelemetry/sdk-node';\nimport { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';\nimport { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';\nimport { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';\nimport { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';\nimport { Resource } from '@opentelemetry/resources';\nimport { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';\n\nconst sdk = new NodeSDK({\n  resource: new Resource({\n    [SemanticResourceAttributes.SERVICE_NAME]: 'checkout-service',\n    [SemanticResourceAttributes.SERVICE_VERSION]: '1.4.0',\n  }),\n  traceExporter: new OTLPTraceExporter({\n    url: 'http://collector:4318/v1/traces',\n  }),\n  metricReader: new PeriodicExportingMetricReader({\n    exporter: new OTLPMetricExporter({\n      url: 'http://collector:4318/v1/metrics',\n    }),\n  }),\n  instrumentations: [\n    getNodeAutoInstrumentations({\n      // File-system spans are noisy; turn them off.\n      '@opentelemetry/instrumentation-fs': { enabled: false },\n    }),\n  ],\n});\n\nsdk.start();\n\nprocess.on('SIGTERM', () => {\n  sdk.shutdown().finally(() => process.exit(0));\n});\n```\n\nGestartet wird das Ganze so, dass die Instrumentierung garantiert zuerst läuft:\n\n```bash\nnode --require ./instrumentation.js ./app.js\n```\n\n`getNodeAutoInstrumentations()` deckt eine überraschende Menge ab, ohne dass ich eine Zeile Business-Code anfassen muss: HTTP und HTTPS, Express oder Fastify, GraphQL, gRPC, gängige Datenbank-Clients. Für den Anfang bekomme ich damit einen vollständigen Request-Baum durch die Standard-Schichten praktisch geschenkt. Wer sich generell fragt, wie man einem laufenden Node-Prozess auf die Finger schaut, findet in [Node.js debuggen und beobachten](https://www.mikebild.dev/de/blog/nodejs-debuggen-und-beobachten/) die Grundlagen, auf denen das hier aufbaut.\n\n## Manuelle Spans für die Business-Logik\n\nAuto-Instrumentation kennt HTTP und GraphQL, aber sie kennt nicht meine Domäne. Ob ein Checkout durch die Bonitätsprüfung fällt oder am Lagerbestand scheitert, sieht kein generischer Instrumentation-Plugin. Genau dafür setze ich manuelle Spans – dort, wo die fachlich interessanten Entscheidungen fallen.\n\n```ts\nimport { trace, SpanStatusCode } from '@opentelemetry/api';\n\nconst tracer = trace.getTracer('checkout', '1.4.0');\n\nexport async function processCheckout(order: Order): Promise<Receipt> {\n  return tracer.startActiveSpan('checkout.process', async (span) => {\n    span.setAttribute('order.id', order.id);\n    span.setAttribute('order.item_count', order.items.length);\n\n    try {\n      await reserveStock(order);\n      const receipt = await charge(order);\n      span.setAttribute('payment.provider', receipt.provider);\n      return receipt;\n    } catch (err) {\n      span.recordException(err as Error);\n      span.setStatus({ code: SpanStatusCode.ERROR });\n      throw err;\n    } finally {\n      span.end();\n    }\n  });\n}\n```\n\nDrei Dinge sind hier wichtig. `startActiveSpan` macht den Span zum aktiven Kontext, sodass alles, was innerhalb des Callbacks passiert – auch die Spans der Auto-Instrumentation für `reserveStock` und `charge` – korrekt als Kinder eingehängt wird. `recordException` plus `setStatus` markiert den Span als fehlerhaft, was ihn im Backend auffindbar macht. Und `span.end()` im `finally` sorgt dafür, dass der Span auch im Fehlerfall geschlossen wird – ein vergessenes `end()` ist ein Leck, das im Backend als hängender Span sichtbar wird.\n\nBei den Attributen halte ich mich zurück. Die Bestell-ID gehört dazu, die item-Anzahl auch. Was nicht dazugehört, ist alles, was die Kardinalität sprengt oder sensibel ist – vollständige Warenkörbe, personenbezogene Daten, freie Texte.\n\n## Der Trace hört nicht an der Servicegrenze auf\n\nDer eigentliche Wert eines Traces zeigt sich, sobald mehrere Services beteiligt sind. Ein Request trifft das Gateway, das ruft den Checkout-Service, der spricht mit der Datenbank und nebenbei mit einem Payment-Provider. Damit das ein zusammenhängender Trace bleibt und nicht in vier unverbundene Fragmente zerfällt, muss der Trace-Context über die Grenzen wandern.\n\nOpenTelemetry nutzt dafür standardmäßig den W3C-Trace-Context: einen `traceparent`-Header, der Trace-ID, Span-ID und Sampling-Entscheidung trägt. Die Auto-Instrumentation injiziert und extrahiert ihn bei HTTP-Aufrufen automatisch – der Empfänger hängt seine Spans an den Parent aus dem Header, und der Baum wächst über Prozessgrenzen hinweg weiter.\n\n```mermaid\ngraph TD\n    A[Client Request] -->|traceparent| B[API Gateway<br/>Span: http.request]\n    B -->|traceparent| C[Checkout Service<br/>Span: checkout.process]\n    C --> D[Span: db.query]\n    C -->|traceparent| E[Payment Provider<br/>Span: payment.charge]\n    B -.->|eine trace_id| F[(Ein Trace =<br/>Baum aus Spans)]\n    C -.-> F\n    D -.-> F\n    E -.-> F\n```\n\nInteressant wird es dort, wo kein HTTP-Header existiert. In einer Lambda-Function oder bei einer Message-Queue gibt es keinen automatischen Transport für den Context. Dann muss ich ihn selbst durch die Event-Payload reichen – beim Absenden mit `propagation.inject` in die Nachricht schreiben, beim Empfang mit `propagation.extract` herausziehen und den Context aktiv setzen. Bei Serverless kommen zwei weitere Punkte dazu: Der Cold Start verlängert die Init-Phase, und vor dem Freeze der Funktion muss ich einen Force-Flush auslösen, sonst gehen die letzten Spans verloren, weil der Export sie nicht mehr rausschicken konnte.\n\n## Logs an Traces koppeln – über die trace_id\n\nJetzt zum Punkt, an dem Observability aus meiner Sicht steht und fällt: der Korrelation. Ein Log ohne Trace-Bezug ist eine Zeile im Nirgendwo. Ein Log mit `trace_id` und `span_id` ist ein Sprungbrett – von der Fehlermeldung direkt in den vollständigen Request-Pfad, der sie erzeugt hat.\n\nDer aktive Span liegt im Context, und der Context wird in Node über `AsyncLocalStorage` getragen – das ist auch der Default-Context-Manager, den OTel intern verwendet. Ich muss also nur den aktiven SpanContext auslesen und seine IDs in jedes strukturierte Log hängen. Wie `AsyncLocalStorage` diesen impliziten Kontext durch asynchrone Aufrufketten trägt, habe ich in [Async-Kontext mit AsyncLocalStorage](https://www.mikebild.dev/de/blog/nodejs-async-context-mit-asynclocalstorage/) im Detail beschrieben; hier ist es der Träger, der die Trace-Korrelation überhaupt erst möglich macht.\n\n```ts\nimport { trace } from '@opentelemetry/api';\n\ntype Fields = Record<string, unknown>;\n\nexport function log(level: 'info' | 'warn' | 'error', msg: string, fields: Fields = {}): void {\n  const span = trace.getActiveSpan();\n  const ctx = span?.spanContext();\n\n  const entry = {\n    ts: new Date().toISOString(),\n    level,\n    msg,\n    ...(ctx && { trace_id: ctx.traceId, span_id: ctx.spanId }),\n    ...fields,\n  };\n\n  process.stdout.write(JSON.stringify(entry) + '\\n');\n}\n```\n\nEin Aufruf wie `log('error', 'stock reservation failed', { order_id: order.id })` mitten in der Business-Logik erzeugt jetzt eine JSON-Zeile, die dieselbe `trace_id` trägt wie der Span, in dem sie entstanden ist. Im Backend filtere ich später auf diese ID und habe Log und Trace nebeneinander. In der Praxis kapsle ich das nicht selbst, sondern lasse es einen etablierten Logger wie Pino über einen Mixin erledigen – das Prinzip bleibt dasselbe: den aktiven SpanContext auslesen und anhängen.\n\n## RED: Rate, Errors, Duration\n\nMetrics sind die aggregierte Gesundheit des Systems, und ich fange nicht mit hundert Custom-Metrics an, sondern mit dreien. Die RED-Methode – Rate, Errors, Duration – deckt für jeden Service das Wesentliche ab: Wie viele Requests kommen rein, wie viele davon scheitern, wie lange dauern sie.\n\n```ts\nimport { metrics } from '@opentelemetry/api';\n\nconst meter = metrics.getMeter('http-server');\n\nconst requestCounter = meter.createCounter('http.server.requests', {\n  description: 'Total number of HTTP requests',\n});\nconst errorCounter = meter.createCounter('http.server.errors', {\n  description: 'Total number of failed HTTP requests',\n});\nconst latencyHistogram = meter.createHistogram('http.server.duration', {\n  description: 'HTTP request duration in milliseconds',\n  unit: 'ms',\n});\n\nexport function recordRequest(route: string, statusCode: number, durationMs: number): void {\n  const attributes = { route, status_class: `${Math.floor(statusCode / 100)}xx` };\n  requestCounter.add(1, attributes);\n  if (statusCode >= 500) {\n    errorCounter.add(1, attributes);\n  }\n  latencyHistogram.record(durationMs, attributes);\n}\n```\n\nDiese drei Aufrufe stecken in einer Middleware und laufen für jeden Request. Der Counter für die Rate, der Counter für die Errors, das Histogram für die Duration – letzteres, weil mich bei Latenz nicht der Durchschnitt interessiert, sondern die Verteilung, das p95 und p99. Ein Durchschnitt von 80 Millisekunden versteckt, dass jeder zwanzigste Nutzer zwei Sekunden wartet.\n\nWichtig ist bei den Attributen die Zurückhaltung. Ich labele mit `route` und einer Statusklasse – Werte mit begrenztem, überschaubarem Wertebereich. Was ich nicht tue: die volle URL mit eingebetteter Bestell-ID als Label verwenden, oder die User-ID. Jede eindeutige Label-Kombination erzeugt eine eigene Zeitreihe, und unbegrenzte Werte lassen die Kardinalität explodieren, bis das Metric-Backend in die Knie geht. Kardinalität gehört in Traces, nicht in Metrics.\n\n## Die Fallstricke, die mich Zeit gekostet haben\n\nEin paar Fehler sehe ich so regelmäßig, dass ich sie hier ausdrücklich nenne. Der teuerste ist das erwähnte Logging ohne Korrelation – Logs, die zwar strukturiert sind, aber keine `trace_id` tragen. Sie sehen ordentlich aus und nützen im Ernstfall trotzdem wenig, weil ich sie keinem Request zuordnen kann. Der zweite ist die zu späte SDK-Initialisierung, die das Monkey-Patching ins Leere laufen lässt.\n\nDer dritte betrifft das Sampling. In Produktion jeden Trace zu erfassen ist selten sinnvoll – das Volumen und die Kosten explodieren. Also samplen. Aber naives Head-Sampling, das blind jeden hundertsten Trace behält, verwirft mit hoher Wahrscheinlichkeit gerade die Fehler-Traces, die ich sehen will. Ich setze deshalb auf `ParentBased(TraceIdRatio)`: Die Sampling-Entscheidung fällt am Anfang, wird über den `traceparent`-Header propagiert und bleibt für den gesamten Trace konsistent – kein Service entscheidet für sich allein, sodass ich keine halben Traces bekomme. Der vierte Fallstrick ist die schon erwähnte Kardinalitätsfalle bei Custom-Metrics.\n\n## Fazit\n\nObservability ist keine Frage des richtigen Dashboards, sondern der Fähigkeit, jede Frage an ein laufendes System beantworten zu können – auch die, die ich mir vorher nicht gestellt habe. Die drei Signale liefern dafür die Rohdaten: Logs für das konkrete Event, Traces für den Request-Pfad, Metrics für die aggregierte Gesundheit. Ihre eigentliche Kraft entfalten sie erst in der Korrelation, wenn ich von der Metric-Spitze zum Trace und von dort zum Log springen kann, ohne die Werkzeuge zu wechseln.\n\nOpenTelemetry hält die Instrumentierung herstellerneutraler und lässt die Exportziele getrennt davon konfigurieren. Auto-Instrumentation bringt mich schnell in die Nähe, manuelle Spans decken die Domäne ab, die Trace-Context-Propagation hält alles über Servicegrenzen zusammen, und die Kopplung von Logs an die `trace_id` über `AsyncLocalStorage` schließt den Kreis. Der Anfang darf klein sein: drei RED-Metrics, Auto-Instrumentation, korrelierte Logs. Beim Wechsel des Backends bleiben trotzdem dessen Abfragemodell, Dashboards und Eigenheiten zu prüfen. Spätestens beim nächsten Incident zahlt sich die Vorarbeit aus – dann ist das System sichtbar, bevor jemand anfängt, es zu reparieren.\n\n## Weiterführende Quellen\n\n- [OpenTelemetry JS – Getting Started (Node.js)](https://opentelemetry.io/docs/languages/js/getting-started/nodejs/)\n- [OpenTelemetry JS – Instrumentation](https://opentelemetry.io/docs/languages/js/instrumentation/)\n- [OpenTelemetry – Traces](https://opentelemetry.io/docs/concepts/signals/traces/), [Metrics](https://opentelemetry.io/docs/concepts/signals/metrics/) und [Logs](https://opentelemetry.io/docs/concepts/signals/logs/)\n- [OpenTelemetry – Context Propagation](https://opentelemetry.io/docs/concepts/context-propagation/)\n- [OpenTelemetry – Signal-Reifegrad (Status)](https://opentelemetry.io/docs/specs/status/)\n- [W3C Trace Context](https://www.w3.org/TR/trace-context/)\n- [The RED Method – Grafana Blog](https://grafana.com/blog/2018/08/02/the-red-method-how-to-instrument-your-services/)"},{"slug":"graphql-client-apollo-link-und-fragments","title":"Der GraphQL-Client: Apollo Link und Fragments","summary":"Apollo Link lässt jede GraphQL-Operation durch eine Kette von Middleware laufen: authLink setzt Header, errorLink behandelt Fehler zentral, split routet Subscriptions. Dazu Fragments als wiederverwendbare Feldmengen und warum Interfaces possibleTypes brauchen.","tags":["Software","GraphQL","API"],"published_at":"2024-06-25T10:00:00+00:00","updated_at":"2026-07-13T10:04:59.062506+00:00","url":"https://www.mikebild.dev/de/blog/graphql-client-apollo-link-und-fragments/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/13bf667a1bbcd7a8e40602bb177e0bb321b47daf4b53bcb94da4e8c3e1a9f0bb/graphql-client-apollo-link-und-fragments-vorlesen.mp3","audio_duration_secs":984,"text":"Im Beitrag über das [Apollo-Client-Caching](https://www.mikebild.dev/de/blog/graphql-client-caching-mit-apollo/) ging es darum, was mit einer Antwort passiert, sobald sie beim Client angekommen ist: wie der normalisierte Cache die Daten zerlegt, ablegt und wiederfindet. Diesmal drehe ich die Blickrichtung um. Bevor eine Antwort im Cache landen kann, muss die Anfrage erst hinaus zum Server, und auf diesem Weg fallen Entscheidungen an, die mit der einzelnen Query nichts zu tun haben: einen Token mitschicken, einen 401 zentral behandeln, Subscriptions auf einen anderen Transport leiten. Das sind Cross-Cutting-Concerns, und die eigentliche Frage lautet nicht, welche API man dafür aufruft, sondern wo diese Belange hingehören. Verteilt über jeden einzelnen `useQuery`-Aufruf oder gebündelt an einer Stelle.\n\nGenau das ist die Entscheidung, um die es hier geht, und ich halte die gebündelte Variante für die tragfähigere. Apollo Client beantwortet sie mit einer Kette aus Middleware. Der Trade-off ist überschaubar: Man akzeptiert eine Indirektion, die man einmal verstehen muss, und bekommt eine Architektur, in der Auth, Error Handling und Routing jeweils an einer benannten Stelle stehen statt verstreut in Dutzenden. Verändert sich die Auth-Strategie, ändert man einen Link, nicht vierzig Komponenten. Genau daran misst sich der Wert einer Transport-Schicht, nicht an der Zahl ihrer Optionen.\n\nSolche Anforderungen kommen gestaffelt. Zuerst steht ein `ApolloClient`, alles funktioniert. Dann heißt es: „Wir müssen einen Auth-Token mitschicken.“ Kurz darauf: „Und wenn der Server einen 401 zurückgibt, sollen alle betroffenen Operationen zentral behandelt werden.“ Und schließlich: „Ach ja, Subscriptions brauchen wir auch noch.“ Spätestens hier zeigt sich, dass der eine Netzwerk-Link aus dem Setup nur die Spitze war. Darunter steht ein komponierbares System, das genau für solche Fälle gebaut wurde: Apollo Link.\n\nDer zweite Teil dieses Beitrags widmet sich den Fragments. Sie wirken auf den ersten Blick wie eine reine Schreiberleichterung, sind aber der Baustein, mit dem sich Queries sauber zusammensetzen und Komponenten wartbar halten lassen. Ich verwende Apollo Client 3.10, die Import-Pfade und Signaturen beziehen sich auf diese Version. In Apollo Client 4 kommen React-Hooks wie `useFragment` aus `@apollo/client/react`; das gezeigte Cache-Modell bleibt gleich.\n\n## Apollo Link ist eine Kette\n\nDie Grundidee ist schnell erzählt. Jede Operation, ob Query, Mutation oder Subscription, durchläuft eine Kette von Links, bevor sie den Server erreicht, und die Antwort läuft dieselbe Kette rückwärts wieder zurück. Ein Link ist ein Stück Middleware: Er darf die Operation ansehen, sie verändern und an den nächsten Link weiterreichen. Der letzte Link in der Kette ist besonders. Er ist **terminierend**, das heißt, er reicht nicht weiter, sondern schickt die Anfrage tatsächlich ab. In den allermeisten Fällen ist das der `HttpLink`.\n\nWer schon einmal Express-Middleware oder eine Redux-Middleware-Kette gesehen hat, erkennt das Muster sofort. Jeder Link bekommt die Operation und eine `forward`-Funktion, mit der er an den nächsten Link übergibt. Und wie bei jeder Middleware-Kette gilt: Die Reihenfolge entscheidet über das Verhalten.\n\nZusammengesetzt wird die Kette mit `ApolloLink.from`, das ein Array von Links in genau der Reihenfolge verkettet, in der sie dort stehen. Für zwei Links reicht auch `linkA.concat(linkB)`. Schauen wir uns eine typische Kette an.\n\n```typescript\nimport { ApolloClient, ApolloLink, HttpLink, InMemoryCache } from '@apollo/client';\nimport { setContext } from '@apollo/client/link/context';\nimport { onError } from '@apollo/client/link/error';\n\nconst httpLink = new HttpLink({ uri: '/graphql' });\n\nconst authLink = setContext((_, { headers }) => {\n  const token = getToken();\n  return {\n    headers: {\n      ...headers,\n      authorization: token ? `Bearer ${token}` : '',\n    },\n  };\n});\n\nconst errorLink = onError(({ graphQLErrors, networkError, operation }) => {\n  if (graphQLErrors) {\n    for (const err of graphQLErrors) {\n      console.error(`[GraphQL error] ${operation.operationName}: ${err.message}`);\n    }\n  }\n  if (networkError) {\n    console.error(`[Network error] ${networkError.message}`);\n  }\n});\n\nconst link = ApolloLink.from([errorLink, authLink, httpLink]);\n\nconst client = new ApolloClient({\n  link,\n  cache: new InMemoryCache(),\n});\n```\n\nDrei Links, eine klare Aufgabenteilung. Der `errorLink` steht außen, weil er sowohl die hinausgehende Operation als auch die zurückkommende Antwort sehen soll. Fehler, die tief in der Kette entstehen, laufen auf dem Rückweg durch ihn hindurch. Der `authLink` steht kurz vor dem Netzwerk-Link, weil er den Token möglichst spät setzt, wenn er am aktuellsten ist. Und der `httpLink` steht ganz am Ende, weil er terminierend ist.\n\n## Warum die Reihenfolge zählt\n\nGenau hier liegt der Fallstrick, der mir in Code-Reviews am häufigsten begegnet. Der terminierende Link muss das letzte Element sein. Steht der `authLink` versehentlich hinter dem `httpLink`, wird er nie erreicht, denn die Anfrage ist längst abgeschickt, wenn dieser Link an der Reihe wäre. Das Ergebnis: Der Token fehlt, der Server antwortet mit 401, und die Fehlersuche beginnt an der völlig falschen Stelle, nämlich beim Auth-Server statt bei der Link-Reihenfolge.\n\nDer `authLink` verdient noch einen zweiten Blick, weil sein Name in die Irre führen kann. `setContext` verändert die HTTP-Header nicht direkt. Es liefert ein Objekt, das in den Context der Operation gemischt wird, üblicherweise unter dem Schlüssel `headers`. Erst der `HttpLink` liest diesen Context aus und übersetzt ihn in echte HTTP-Header. Der `authLink` bereitet also nur vor, gesendet wird woanders. Wer das verinnerlicht hat, versteht auch, warum die Reihenfolge so wichtig ist: Der Vorbereiter muss vor dem Absender stehen.\n\nEin Detail, das in der Praxis oft gebraucht wird: Die Funktion in `setContext` darf asynchron sein. Gibt sie ein Promise zurück, wartet Apollo Link, bevor die Operation weiterläuft. Damit lässt sich ein Token bei Bedarf erst nachladen oder erneuern, ohne die aufrufende Komponente damit zu belasten. Im Blick behalten sollte man dabei nur, dass jede Operation auf diese Auflösung wartet: Ein langsamer Token-Endpoint bremst dann sämtliche Anfragen aus. In der Regel legt man den Token deshalb in einem Speicher ab, den `setContext` synchron lesen kann, und stößt die Erneuerung getrennt davon an.\n\nDer `errorLink` unterscheidet zwei Fehlerklassen, die man nicht verwechseln sollte. `graphQLErrors` ist ein Array von Fehlern, die der Server bewusst zurückgegeben hat, etwa eine fehlgeschlagene Validierung oder ein nicht gefundener Datensatz. Der Transport hat funktioniert, die Antwort enthält nur eben Fehler im `errors`-Feld. `networkError` dagegen bedeutet, dass der Transport selbst gescheitert ist: keine Verbindung, ein 500er, ein Timeout. Diese Trennung ist der Grund, warum `onError` der richtige Ort für zentrale Behandlung ist. Man muss nicht in jedem `useQuery`-Aufruf einzeln prüfen, ob der Token abgelaufen ist, man macht es einmal, an einer Stelle.\n\nWenn ich mir dieselbe Kette als Diagramm aufmale, sieht der Weg einer Operation so aus.\n\n```mermaid\nflowchart LR\n  Op[Operation] --> E[errorLink onError]\n  E --> A[authLink setContext]\n  A --> H[httpLink terminating]\n  H --> S[(GraphQL Server)]\n  S -. response .-> H\n  H -. response .-> A\n  A -. response .-> E\n  E -. response .-> Op\n```\n\nDie durchgezogenen Pfeile zeigen die Anfrage nach außen, die gestrichelten die Antwort auf dem Rückweg. Dass der `errorLink` auf beiden Wegen beteiligt ist, macht deutlich, warum er ganz außen sitzt.\n\n## Die gängigen Links im Überblick\n\nÜber die drei genannten hinaus gibt es einige Links, die regelmäßig gebraucht werden. Hier die wichtigsten mit ihrer jeweiligen Aufgabe:\n\n- `errorLink` (`onError`): zentrale Behandlung von GraphQL- und Netzwerkfehlern, etwa Logging oder das Ausloggen bei abgelaufenem Token.\n- `authLink` (`setContext`): bereitet Header und Token vor und mischt sie in den Operation-Context.\n- `retryLink` (`RetryLink`): wiederholt Operationen bei Netzwerkfehlern, mit konfigurierbarem Backoff und Jitter.\n- `splitLink` (`split`): routet Operationen nach einem Prädikat auf verschiedene Zweige, klassisch Subscriptions gegen Queries.\n- `httpLink` (`HttpLink`): der terminierende Link, der die Anfrage über HTTP versendet.\n\nDer `RetryLink` gehört in die Kette vor den terminierenden Link, damit er die Wiederholung anstoßen kann, bevor die Anfrage endgültig hinausgeht. Eine sinnvolle vollständige Kette wäre also `from([errorLink, retryLink, authLink, httpLink])`. Wichtig dabei: Die Retry-Bedingung sollte man explizit setzen. Gibt man in `onError` bedingungslos ein `forward(operation)` zurück, um eine Operation zu wiederholen, kann daraus eine Endlosschleife werden, wenn der Fehler dauerhaft ist. Der `RetryLink` mit seiner `retryIf`-Bedingung ist der robustere Weg, weil er die Anzahl der Versuche begrenzt.\n\n```typescript\nimport { RetryLink } from '@apollo/client/link/retry';\n\nconst retryLink = new RetryLink({\n  delay: { initial: 300, max: 3000, jitter: true },\n  attempts: {\n    max: 3,\n    retryIf: (error) => Boolean(error),\n  },\n});\n```\n\n## Subscriptions abzweigen mit split\n\nSobald Subscriptions ins Spiel kommen, kann ein zweiter Transport nötig werden. Queries und Mutations laufen in diesem Beispiel über HTTP, die Subscriptions über eine dauerhafte WebSocket-Verbindung. Andere Stacks transportieren Subscriptions über HTTP und Server-Sent Events. Die Aufgabe, die Operation im hier gezeigten WebSocket-Setup auf den richtigen Zweig zu leiten, übernimmt `split`. Die Funktion bekommt ein Prädikat und zwei Links: Ist das Prädikat wahr, geht es in den ersten Zweig, sonst in den zweiten.\n\nUm zu entscheiden, ob eine Operation eine Subscription ist, hilft `getMainDefinition`. Sie zieht die Haupt-Definition aus dem Query-Dokument, sodass sich `kind` und `operation` prüfen lassen.\n\n```typescript\nimport { split } from '@apollo/client';\nimport { getMainDefinition } from '@apollo/client/utilities';\nimport { GraphQLWsLink } from '@apollo/client/link/subscriptions';\nimport { createClient } from 'graphql-ws';\n\nconst wsLink = new GraphQLWsLink(\n  createClient({ url: 'wss://example.com/graphql' }),\n);\n\nconst splitLink = split(\n  ({ query }) => {\n    const definition = getMainDefinition(query);\n    return (\n      definition.kind === 'OperationDefinition' &&\n      definition.operation === 'subscription'\n    );\n  },\n  wsLink,\n  ApolloLink.from([errorLink, authLink, httpLink]),\n);\n```\n\nDer WebSocket-Transport heißt hier `GraphQLWsLink` und baut auf der `graphql-ws`-Bibliothek auf. Für einen WebSocket-Zweig ist das die passende Variante. Die ältere Kombination aus `WebSocketLink` und `subscriptions-transport-ws` findet man noch in vielen Projekten, sie gilt aber als veraltet. Ob WebSocket überhaupt der richtige Transport ist, hängt vom Server und vom eingesetzten Subscription-Protokoll ab; SSE ist eine mögliche Alternative.\n\nEin Punkt, der leicht übersehen wird: Der WebSocket-Zweig läuft an `errorLink` und `authLink` vorbei, denn er hängt im `split` an einem eigenen Ast. Eine Subscription authentifiziert sich deshalb nicht über den HTTP-Header, sondern über die `connectionParams` des `graphql-ws`-Clients, die beim Verbindungsaufbau gesetzt werden. Wer den Token nur im `authLink` mitgibt, wundert sich sonst, warum die Query autorisiert ist, die Subscription aber nicht.\n\n## Vom Apollo-Detail zum Prinzip\n\nEin Schritt zurück, und es geht nicht mehr um Apollo. Die Link-Kette ist ein Sonderfall eines Musters, das weit über GraphQL hinausreicht: Cross-Cutting-Concerns wandern aus dem fachlichen Code heraus in eine Kette aus Middleware, die jede Operation durchläuft. Auth, Logging, Error Handling, Wiederholung, Routing. Das sind Belange, die überall gebraucht werden und nirgends fachlich hingehören. Express macht es mit seiner Middleware, Redux mit seiner Middleware-Kette, ein API-Gateway mit seiner Filter-Kette, und Apollo Client eben mit seinen Links. Wer das Muster einmal erkannt hat, sieht es überall.\n\nDer Reiz liegt in einer klaren Grenzziehung. Fachlicher Code fragt Daten an und stellt sie dar. Er soll nichts davon wissen, wie ein Token entsteht oder was bei einem 401 passiert. Die Kette nimmt ihm das ab, und zwar an einer einzigen, benannten Stelle. Der Gewinn ist nicht die Technik, sondern die Lokalität der Änderung. Ein neuer Concern kommt als zusätzlicher Link hinzu, ohne dass ein einziger `useQuery`-Aufruf angefasst werden muss. Das ist der Unterschied zwischen einer Anpassung, die man an einem Nachmittag erledigt, und einer, die sich durch vierzig Dateien zieht und mit jeder davon ein Risiko trägt.\n\nUmsonst ist das nicht. Der Preis ist Indirektion. Der Weg einer Operation steht nicht mehr linear im Komponentencode, sondern verteilt sich über eine Kette, deren Reihenfolge man kennen muss, um das Verhalten zu verstehen. Ein falsch platzierter Link ist schwerer zu finden als ein falsch gesetzter Header direkt an der Aufrufstelle. Bei einer Handvoll Operationen ohne gemeinsame Belange wäre die Kette deshalb Aufwand ohne Ertrag.\n\nDie Zuordnung lässt sich an wenigen Kriterien festmachen. Betrifft ein Belang viele oder alle Operationen, gehört er in die Kette. Betrifft er genau eine, gehört er an die Aufrufstelle. Ändert er sich unabhängig vom fachlichen Code und aus anderen Gründen als dieser, ist das ein starkes Signal für die Kette. Und je mehr solcher Belange gleichzeitig auf dem Tisch liegen, desto klarer zahlt sich die Trennung aus, weil sie verhindert, dass Auth, Fehler und Transport sich im selben Stück Code vermischen. Genau in dieser Situation, wenn mehrere Concerns zusammenkommen, ist die Kette kein Overhead, sondern das, was die Codebasis übersichtlich hält.\n\n## Fragments: Queries aus Bausteinen\n\nDamit zur zweiten Hälfte. Ein Fragment ist eine benannte, wiederverwendbare Menge von Feldern eines bestimmten Typs. Statt in jeder Query, die einen `User` lädt, dieselben Felder erneut aufzuzählen, definiert man sie einmal und setzt sie per Spread ein.\n\n```typescript\nimport { gql } from '@apollo/client';\n\nconst USER_FIELDS = gql`\n  fragment UserFields on User {\n    id\n    name\n    email\n  }\n`;\n\nconst GET_USER = gql`\n  query GetUser($id: ID!) {\n    user(id: $id) {\n      ...UserFields\n    }\n  }\n  ${USER_FIELDS}\n`;\n```\n\nZwei Dinge sind wichtig. Der Spread `...UserFields` verweist auf das Fragment, und das Fragment-Dokument muss der Query per `${USER_FIELDS}` mitgegeben werden, sonst kennt der Parser den Namen nicht. Das ist ein häufiger Anfängerfehler: Der Spread steht da, aber die Interpolation fehlt, und die Fehlermeldung ist nicht besonders sprechend.\n\nDer eigentliche Gewinn zeigt sich erst im größeren Projekt und heißt Colocation. Die Idee: Ein Fragment steht direkt bei der Komponente, die genau diese Felder anzeigt. Eine `UserCard`-Komponente definiert ihr eigenes `UserFields`-Fragment und exportiert es. Die übergeordnete Query, die mehrere Komponenten füllt, sammelt die Fragmente ihrer Kinder ein und setzt sie zusammen. Ändert sich, welche Felder die `UserCard` braucht, ändert sich nur ihr Fragment, und die Query oben zieht die Änderung automatisch mit. Die Datenanforderung steht dort, wo die Daten gebraucht werden, und nicht in einer entfernten, zentralen Query-Datei, die niemand mehr pflegen mag.\n\nSeit Apollo Client 3.8 gibt es dafür auch den Hook `useFragment`. Er erlaubt einer Komponente, genau die Felder eines Fragments aus dem Cache zu lesen, ohne selbst eine Query abzusetzen. Die Komponente wird nur dann neu gerendert, wenn sich die Felder ihres Fragments ändern, ein feinerer Schnitt als das erneute Rendern bei jeder Änderung der übergeordneten Query.\n\n```tsx\nimport { useFragment } from '@apollo/client';\n\nfunction UserCard({ id }: { id: string }) {\n  const { data } = useFragment({\n    fragment: USER_FIELDS,\n    fragmentName: 'UserFields',\n    from: { __typename: 'User', id },\n  });\n\n  return <div>{data.name} · {data.email}</div>;\n}\n```\n\n## Fragment-Matching und possibleTypes\n\nEin letzter Punkt, der oft übersehen wird und dann für schwer auffindbare Fehler sorgt. Sobald ein Fragment auf einem Interface oder einer Union sitzt statt auf einem konkreten Typ, muss der Cache wissen, welche konkreten Typen zu diesem abstrakten Typ gehören. Nehmen wir ein Interface `SearchResult`, das von `User` und `Post` implementiert wird. Eine Query möchte je nach konkretem Typ unterschiedliche Felder laden.\n\n```graphql\nquery Search($term: String!) {\n  search(term: $term) {\n    __typename\n    ... on User {\n      name\n    }\n    ... on Post {\n      title\n    }\n  }\n}\n```\n\nDamit Apollo diese Inline-Fragmente, `... on User` und `... on Post`, korrekt zuordnen kann, braucht der `InMemoryCache` eine `possibleTypes`-Angabe. Sie sagt ihm, welche konkreten Typen hinter jedem Interface oder jeder Union stehen.\n\n```typescript\nconst cache = new InMemoryCache({\n  possibleTypes: {\n    SearchResult: ['User', 'Post'],\n  },\n});\n```\n\nFehlt diese Angabe, kann der Cache das Matching nicht zuverlässig durchführen. Felder fehlen dann in den Ergebnissen, oder Apollo greift auf eine Heuristik zurück und gibt eine Warnung aus. Das Tückische daran ist, dass es in einfachen Fällen scheinbar trotzdem funktioniert und erst bei komplexeren Queries bricht. Deshalb pflegt man `possibleTypes` am besten gar nicht von Hand, sondern lässt es aus dem Schema generieren, per Introspection oder über GraphQL Codegen. So bleibt die Angabe automatisch aktuell, wenn ein neuer Typ das Interface implementiert.\n\n## Fazit\n\nDie Transport-Schicht von Apollo Client ist kein monolithischer Netzwerk-Baustein, sondern eine Kette aus kleinen, klar umrissenen Stücken. Der Ertrag zeigt sich erst in der Wirkung. Weil jeder Concern an genau einer Stelle sitzt, sind spätere Anpassungen lokal statt verstreut. Das verkürzt die Zyklen, senkt die Reibung bei jeder Änderung an Auth oder Error Handling und hält die Grenze zwischen fachlichem Code und Infrastruktur sauber. Der Preis dafür ist die Indirektion der Kette, und die meisten frustrierenden Debugging-Sessions, die ich dazu erlebt habe, lassen sich auf eine vertauschte Reihenfolge zurückführen. Das ist ein fairer Handel, solange man die Reihenfolge bewusst setzt und den terminierenden `HttpLink` ans Ende.\n\nFragments sind das Gegenstück auf der Query-Seite und folgen demselben Prinzip. Sie halten Feldmengen wiederverwendbar und ermöglichen Colocation nah an den Komponenten, sodass die Datenanforderung dort steht, wo die Daten gebraucht werden, und nicht in einer zentralen Riesen-Query, die niemand mehr pflegen mag. Ändert eine Komponente ihren Bedarf, ändert sich ihr Fragment statt einer weit entfernten Datei. Sobald abstrakte Typen ins Spiel kommen, ist `possibleTypes` die leicht vergessene Voraussetzung, ohne die das Matching bricht. Link-Reihenfolge und Fragmentgrenzen bleiben damit zwei konkrete Stellen, an denen ich Änderungen prüfe; Apollo nimmt mir diese Entscheidungen nicht ab.\n\nWie der Token, den der `authLink` setzt, überhaupt entsteht und serverseitig geprüft wird, ist ein Thema für sich; dazu passt der Beitrag über [GraphQL-Auth im Context](https://www.mikebild.dev/de/blog/graphql-auth-im-context/). Und wenn die geladenen Listen größer werden, lohnt der Blick in die [GraphQL-Paginierung und Connections](https://www.mikebild.dev/de/blog/graphql-paginierung-und-connections/), wo Fragments und `fetchMore` zusammenspielen.\n\n## Weiterführende Quellen\n\n- [Apollo Link: Overview](https://www.apollographql.com/docs/react/api/link/introduction)\n- [ApolloLink API (from, concat, split)](https://www.apollographql.com/docs/react/api/link/apollo-link)\n- [Error handling mit onError](https://www.apollographql.com/docs/react/data/error-handling)\n- [Fragments (Colocation, possibleTypes, useFragment)](https://www.apollographql.com/docs/react/data/fragments)\n- [Subscriptions (split und getMainDefinition)](https://www.apollographql.com/docs/react/data/subscriptions)"},{"slug":"ocr-mit-cache-und-misstrauen","title":"OCR mit Cache und Misstrauen","summary":"Warum sich eine OCR-Pipeline für Belegdaten erst dann zuverlässig anfühlt, wenn man jeden erkannten Wert mit Hash-Cache, Confidence-Gate und Plausibilitätsregeln absichert statt ihm zu glauben.","tags":["Software","Daten","Tools"],"published_at":"2024-06-12T10:00:00+00:00","updated_at":"2026-07-13T13:22:38.960627+00:00","url":"https://www.mikebild.dev/de/blog/ocr-mit-cache-und-misstrauen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/ddede08abe63fe47a42f2cf0a676842321513d7e954362b7f275b0c901b38961/ocr-mit-cache-und-misstrauen-vorlesen.mp3","audio_duration_secs":769,"text":"# OCR mit Cache und Misstrauen\n\nTesseract liefert für erkannte Wörter im TSV-Ausgabeformat einen Confidence-Wert, üblicherweise zwischen null und hundert; Strukturzeilen können `-1` tragen. Dieser interne Score ist ein nützliches Signal, aber keine kalibrierte Wahrscheinlichkeit dafür, dass das Wort korrekt ist. `image_to_string` gibt ihn nicht heraus. Wer nur den String nimmt, wirft damit eine wichtige Information über die Erkennung weg.\n\nAufgefallen ist mir das in einem kleinen Inventur-Projekt, in dem Buchungen aus einem Kontoblatt mit den dazugehörigen Rechnungspositionen zusammengeführt werden sollten. Klingt nach einem Nachmittag Arbeit: PDFs einlesen, Beträge, Datumsangaben und Belegnummern herausziehen, gegen die Buchungen matchen, fertig. Der Haken steckte nicht im Matching. Er steckte darin, dass ein Teil der Rechnungen als gescannte Bilder vorlag und ich die Felder per OCR herausholen musste. OCR ist keine Funktion, die Text zurückgibt. OCR ist ein Messgerät: Es trifft mit einer gewissen Wahrscheinlichkeit das Richtige und ist im Rest der Fälle eine teure Fehlerquelle.\n\nDiese Sichtweise hat die Architektur des kleinen Werkzeugs stärker geprägt als jede Matching-Regel. Sobald man OCR als unsicheren Sensor begreift, ergeben sich drei Pflichten fast von selbst: Man speichert die Messung, weil sie teuer ist und sich mit Vorverarbeitung, Konfiguration oder Engine-Version ändern kann. Man behält den Confidence-Score, weil sonst ein Lesefehler genauso aussieht wie ein korrekter Treffer. Und man prüft das Ergebnis gegen Fachregeln, weil ein plausibel aussehender Betrag noch lange nicht der richtige Betrag ist.\n\n## Der Fehler, der mich überzeugt hat\n\nDie ganze Vorsicht klingt zuerst übertrieben. Überzeugt hat mich ein einzelner Datensatz. Auf einer gescannten Rechnung stand ein Bruttobetrag von 138,00 Euro. Die OCR las daraus 133,00 Euro, weil die 8 in der verwendeten Schrift schmal gesetzt war und der Scan die obere Schleife verschluckt hatte. Eine 8, die zur 3 wird, ist optisch nicht weit entfernt; für ein Modell, das Glyphen aus Pixeln rekonstruiert, ist das ein naheliegender Fehlgriff.\n\nDas Tückische war nicht der Lesefehler. Lesefehler passieren. Das Tückische war, was danach geschah. Im Kontoblatt gab es tatsächlich eine Buchung über 133,00 Euro von einem anderen Vorgang. Das Fuzzy-Matching, das Belegnummer, Datum und Betrag gewichtet, fand für die falsch gelesene Rechnung einen vermeintlich sauberen Partner und ordnete sie der falschen Buchung zu. Der eigentliche 138-Euro-Posten blieb als verwaister Kontoeintrag liegen. Zwei Fehler, die sich gegenseitig plausibel machten. Kein Stacktrace, keine Exception, kein rotes Log. Nur ein Report, der ordentlich aussah und falsch war.\n\nGenau darin liegt die Gefahr: Ein OCR-Fehler ohne Confidence und ohne Validierung richtet mehr Schaden an als ein Absturz. Ein Absturz zwingt dich, hinzuschauen. Ein stiller Lesefehler reist durch die ganze Pipeline und tarnt sich als korrektes Ergebnis. Bei Geldbeträgen ist das keine akademische Sorge.\n\n## Warum OCR ein Sensor ist und keine Funktion\n\nEs lohnt sich, kurz auseinanderzuhalten, womit man es zu tun hat. Eine reine Textextraktion aus einem digital erzeugten PDF liest einen vorhandenen Textlayer. OCR arbeitet dagegen auf Pixeln, und das Ergebnis hängt von Auflösung, Kontrast, Schrift, Seitenausrichtung, Vorverarbeitung, Konfiguration und Engine-Version ab. Ein lokaler Tesseract-Lauf mit identischen Bytes und identischer Umgebung ist in der Regel reproduzierbar; ändert sich einer dieser Faktoren, kann auch das Ergebnis wechseln.\n\nDeshalb behandle ich beide Wege getrennt. Erst versuche ich, den eingebetteten Textlayer auszulesen. Nur wenn dieser fehlt oder leer ist, fällt die Pipeline auf OCR zurück. Dieser Fallback ist die Stelle, an der Unsicherheit ins System kommt, und genau diese Stelle muss man markieren, messen und einhegen.\n\nHilfreich ist, dass ernsthafte OCR-Engines ihre eigene Unsicherheit mitliefern. Bei Tesseract kommt die Wort-Confidence über das TSV-Ausgabeformat, zusammen mit Bounding Box und Position auf der Seite. Cloud-Dienste liefern vergleichbare Werte, oft als Float zwischen 0 und 1 pro Feld. Dieser Wert ist die wichtigste Information der ganzen Erkennung, und er landet trotzdem meist im Nichts, weil `image_to_string` eben nur einen String zurückgibt. Wer stattdessen `image_to_data` mit `tsv` nutzt, bekommt die Confidence pro Wort und kann eine Entscheidung darauf aufbauen.\n\nIn meinem Belegdatensatz waren Wortwerte unter etwa 95 oft kritisch und Werte oberhalb von 99 deutlich verlässlicher. Das sind keine allgemein gültigen Grenzen. Die Schwelle muss auf gelabelten Beispielen für Engine, Dokumenttyp und Feld kalibriert werden und hängt zusätzlich vom Schaden eines Fehlers ab. Bei einem Freitextfeld darf die Latte tiefer liegen als bei einem Betrag, der in eine Buchung wandert.\n\n## Cache per Inhalts-Hash, nicht per Dateiname\n\nBevor ich über Schwellen rede, zuerst die unspektakuläre Maßnahme, die am meisten Ruhe in die Pipeline bringt: ein Cache, der die OCR eines Dokuments genau einmal berechnet.\n\nOCR ist langsam und, bei Cloud-Diensten, teuer pro Seite. Ein Inventurlauf, den man während der Entwicklung zwanzig Mal wiederholt, würde zwanzigmal dieselben Seiten erkennen. Das kostet Zeit und Geld. Ein Cache pinnt zusätzlich das einmal beobachtete Ergebnis, sodass ein Wechsel von Engine, Konfiguration oder externem Dienst nicht unbemerkt historische Auswertungen verändert.\n\nDer entscheidende Punkt ist der Cache-Schlüssel. Der Dateiname taugt nicht. Dateien werden umbenannt, verschoben, neu exportiert, und zwei inhaltlich identische Scans können unterschiedlich heißen. Umgekehrt kann hinter demselben Namen ein veränderter Inhalt stecken. Der einzige stabile Schlüssel ist der Inhalt selbst, also ein kryptografischer Hash der Bytes, üblicherweise SHA-256. Gleicher Inhalt, gleicher Hash, gleicher Cache-Eintrag, unabhängig davon, wie die Datei gerade heißt.\n\nIn den Schlüssel gehört aber mehr als nur der Datei-Hash. Wenn ich die OCR-Konfiguration ändere, etwa den Page-Segmentation-Mode oder die Engine-Version, sind alte Ergebnisse nicht mehr vergleichbar. Deshalb fließt auch eine Versionskennung der Pipeline in den Schlüssel ein. So invalidiert eine geänderte Konfiguration den Cache automatisch, ohne dass ich von Hand aufräumen muss.\n\n```python\nimport hashlib\nimport json\nfrom dataclasses import dataclass, asdict\nfrom pathlib import Path\n\nimport pytesseract\nfrom PIL import Image\n\n# Changes to this configuration must invalidate the cache,\n# otherwise results from different setups get compared.\nOCR_VERSION = \"tesseract-5.3.4+psm6+v3\"\nCONF_MIN = 95.0          # word values below this count as uncertain\nCACHE_DIR = Path(\".ocr-cache\")\n\n\n@dataclass\nclass Word:\n    text: str\n    conf: float          # 0..100, straight from the TSV output format\n\n\ndef file_key(path: Path) -> str:\n    \"\"\"Content hash plus pipeline version. The file name deliberately plays no role.\"\"\"\n    digest = hashlib.sha256(path.read_bytes()).hexdigest()\n    return hashlib.sha256(f\"{digest}:{OCR_VERSION}\".encode()).hexdigest()\n\n\ndef run_ocr(path: Path) -> list[Word]:\n    \"\"\"Expensive; cache the observed result together with its pipeline version.\"\"\"\n    data = pytesseract.image_to_data(\n        Image.open(path),\n        config=\"--psm 6\",\n        output_type=pytesseract.Output.DICT,\n    )\n    words: list[Word] = []\n    for text, conf in zip(data[\"text\"], data[\"conf\"]):\n        text = text.strip()\n        if not text:\n            continue\n        words.append(Word(text=text, conf=float(conf)))\n    return words\n\n\ndef ocr_cached(path: Path) -> list[Word]:\n    \"\"\"Idempotent access: same content always yields the same cache entry.\"\"\"\n    CACHE_DIR.mkdir(exist_ok=True)\n    cache_file = CACHE_DIR / f\"{file_key(path)}.json\"\n    if cache_file.exists():\n        raw = json.loads(cache_file.read_text())\n        return [Word(**w) for w in raw]\n    words = run_ocr(path)\n    cache_file.write_text(json.dumps([asdict(w) for w in words], ensure_ascii=False))\n    return words\n```\n\nDer Cache liegt hier bewusst als JSON pro Dokument auf der Platte. Das ist für ein lokales Werkzeug völlig ausreichend und hat einen angenehmen Side Effect: Die Cache-Dateien sind lesbar und lokal diffbar. Bei Rechnungen und anderen personenbezogenen Dokumenten gehören solche OCR-Ausgaben allerdings nicht unverschlüsselt ins Git-Repository. Nachvollziehbarkeit braucht einen geschützten Speicher mit passender Aufbewahrung und Zugriffskontrolle.\n\n## Das Confidence-Gate\n\nMit Cache und Wort-Confidence in der Hand wird aus dem stillen Fehler von oben ein lauter. Der Trick ist, die Confidence nicht zu ignorieren, sondern sie zur Entscheidung zu machen: Felder über der Schwelle gelten als vertrauenswürdig und dürfen automatisch weiterfließen, Felder darunter werden markiert und landen in der manuellen Prüfung.\n\n```python\nimport re\n\nAMOUNT_RE = re.compile(r\"^\\d{1,3}(?:[.\\s]\\d{3})*,\\d{2}$\")  # e.g. 1.234,56\n\n\n@dataclass\nclass Field:\n    raw: str\n    conf: float\n    needs_review: bool\n    reason: str = \"\"\n\n\ndef extract_amount(words: list[Word]) -> Field:\n    \"\"\"Finds the gross amount and decides how to proceed based on the confidence.\"\"\"\n    candidates = [w for w in words if AMOUNT_RE.match(w.text)]\n    if not candidates:\n        return Field(raw=\"\", conf=0.0, needs_review=True, reason=\"no amount detected\")\n\n    # The last plausible amount on the receipt is usually the total.\n    best = candidates[-1]\n\n    if best.conf < CONF_MIN:\n        return Field(\n            raw=best.text,\n            conf=best.conf,\n            needs_review=True,\n            reason=f\"Confidence {best.conf:.0f} below threshold {CONF_MIN:.0f}\",\n        )\n    return Field(raw=best.text, conf=best.conf, needs_review=False)\n```\n\nHätte die falsch gelesene 133,00 eine niedrige Confidence gehabt, wäre sie hier hängengeblieben. Damit ist das Gate aber noch nicht fertig. OCR-Engines sind manchmal selbstbewusst falsch. Eine sauber gedruckte 3, die eigentlich eine 8 sein sollte, kann mit Confidence 98 zurückkommen. Das Modell ist sich sicher, die Glyphe heißt 3, weil sie wie eine 3 aussieht. Die Information, dass es eigentlich 8 sein müsste, steckt nicht im Pixelbild, sondern im Fachkontext. Confidence allein reicht deshalb nicht. Sie ist notwendig, aber sie fängt nur die Fälle, in denen das Modell selbst zögert.\n\n## Validierung gegen die Welt, nicht gegen sich selbst\n\nDie zweite Verteidigungslinie prüft nicht, wie sicher sich die Erkennung war, sondern ob das Ergebnis zur Realität passt. Solche Regeln kosten wenig und fangen genau die selbstbewussten Fehler, an denen das Confidence-Gate scheitert.\n\nBei Belegdaten gibt es eine Reihe naheliegender Prüfungen. Ein Bruttobetrag muss der Summe aus Nettobetrag und Steuer entsprechen; weicht das ab, stimmt mindestens einer der drei Werte nicht. Ein Rechnungsdatum muss vor dem Buchungsdatum liegen und darf nicht in der Zukunft stehen. Eine Belegnummer hat oft ein festes Format oder gar eine Prüfziffer; passt die nicht, war die Erkennung falsch. Und ein Betrag, der gegen eine Buchung gematcht werden soll, muss innerhalb einer engen Toleranz liegen, nicht innerhalb von fünf Euro.\n\nIm konkreten Fehlerfall hätte eine einzige dieser Regeln gereicht. Wenn der Beleg Netto und Steuer separat ausweist, dann ergibt 138,00 als Brutto bei 19 Prozent einen Nettowert von 115,97, während 133,00 zu 111,76 führt. Liest die OCR Netto und Brutto unabhängig und stimmt die Summenrechnung nicht, fällt die falsche 3 sofort auf, ganz egal wie sicher sich die Engine war. Die Redundanz auf dem Beleg ist das Kontrollmaß, das die Pixel nicht liefern können.\n\n```python\nfrom decimal import Decimal\n\n\ndef parse_eur(s: str) -> Decimal:\n    return Decimal(s.replace(\".\", \"\").replace(\",\", \".\"))\n\n\ndef cross_check_total(netto: Field, steuer: Field, brutto: Field) -> Field:\n    \"\"\"A high confidence is unconvincing when the sum does not add up.\"\"\"\n    if any(f.needs_review or not f.raw for f in (netto, steuer, brutto)):\n        return brutto  # missing parts are already handled by the review anyway\n\n    expected = parse_eur(netto.raw) + parse_eur(steuer.raw)\n    actual = parse_eur(brutto.raw)\n    if abs(expected - actual) > Decimal(\"0.02\"):  # tolerance only for rounding\n        return Field(\n            raw=brutto.raw,\n            conf=brutto.conf,\n            needs_review=True,\n            reason=f\"Sum {expected} differs from read gross {actual}\",\n        )\n    return brutto\n```\n\nWichtig ist die Reihenfolge im Kopf: Confidence und Validierung sind kein Entweder-oder. Ein Feld mit hoher Confidence, das eine Fachregel verletzt, gehört trotzdem in die Prüfung. Und ein Feld mit niedriger Confidence, das alle Regeln erfüllt, ist immer noch verdächtig genug, um es einem Menschen zu zeigen. Beide Tore müssen offen sein, damit ein Wert ohne Aufsicht durchläuft.\n\n## Der Entscheidungsfluss im Überblick\n\n```mermaid\nflowchart TD\n  A[Dokument] --> B{Textlayer vorhanden?}\n  B -->|ja| C[Text direkt lesen<br/>deterministisch]\n  B -->|nein| D{Im Hash-Cache?}\n  D -->|ja| E[Cache-Eintrag laden]\n  D -->|nein| F[OCR ausführen<br/>teuer, unsicher]\n  F --> G[Ergebnis unter Inhalts-Hash cachen]\n  G --> E\n  C --> H[Felder extrahieren]\n  E --> H\n  H --> I{Confidence über Schwelle?}\n  I -->|nein| R[markieren: manuelle Prüfung]\n  I -->|ja| J{Fachregeln erfüllt?}\n  J -->|nein| R\n  J -->|ja| K[automatisch ins Matching]\n  R --> L[Korrektur als Daten speichern]\n  L --> K\n```\n\nZwei Tore stehen zwischen einem erkannten Wert und seiner automatischen Verwendung. Wer sie ernst nimmt, akzeptiert, dass ein Teil der Belege manuell durch die Hände eines Menschen geht. Das ist keine Niederlage. Reife Systeme verarbeiten den Großteil der Dokumente ohne Eingriff und leiten je nach Beleglage einen kleineren Anteil in die Prüfung. Dass dieser Anteil existiert, ist das Eingeständnis, dass OCR ein Sensor bleibt und kein Orakel.\n\n## Korrekturen sind Daten, keine Einmal-Aktionen\n\nWas passiert mit den Feldern, die in der Prüfung landen? Ein Mensch schaut auf den Beleg und auf den markierten Wert und korrigiert die 133 zurück auf 138. Wenn diese Korrektur nur in den aktuellen Lauf einfließt und danach verschwindet, hat man das Problem nicht gelöst, sondern nur einmal umschifft. Beim nächsten Lauf mit derselben Datei liest die OCR wieder 133, und jemand korrigiert wieder.\n\nDeshalb behandle ich Korrekturen als vollwertige Daten. Eine Korrektur bindet sich an den Inhalts-Hash des Dokuments und überschreibt das OCR-Ergebnis für genau dieses Feld. Sie liegt geschützt neben dem Cache, ist versioniert und nachvollziehbar. Beim nächsten Lauf greift die Korrektur, bevor die unsichere Messung überhaupt zum Zug kommt. Das verwandelt die manuelle Prüfung von einer wiederkehrenden Last in eine einmalige Investition pro Dokument. Nebenbei entsteht ein sensibler Datensatz, an dem man später ablesen kann, an welchen Stellen die Erkennung systematisch schwächelt – mit denselben Schutz- und Löschpflichten wie für die Quelldokumente.\n\nDiese Trennung hat noch einen Vorteil für die Nachvollziehbarkeit. Im Report lässt sich für jeden Wert sagen, woher er stammt: aus dem deterministischen Textlayer, aus der OCR oberhalb der Schwelle, oder aus einer menschlichen Korrektur. Drei Herkünfte, drei Vertrauensstufen, und alle drei sind sichtbar. Ein Wert, der aus einer Korrektur stammt, trägt seine Geschichte mit sich, statt sich als anonymer String zu tarnen.\n\n## Was ich beim nächsten Mal gleich so bauen würde\n\nIm Rückblick sind es wenige Entscheidungen, die den Unterschied gemacht haben, und keine davon war aufwendig. Der Inhalts-Hash als Cache-Schlüssel hat eine halbe Stunde gekostet und jeden weiteren Entwicklungslauf billig und reproduzierbar gemacht. Das Confidence-Gate war eine Handvoll Zeilen, sobald ich von `image_to_string` auf `image_to_data` umgestiegen bin und aufgehört habe, die Confidence wegzuwerfen. Die Fachregeln waren am Ende die wertvollste Schicht, weil sie genau die Klasse von Fehlern fängt, bei denen das Modell sich sicher und gleichzeitig irrt.\n\nDer gemeinsame Nenner ist eine Haltung, kein Framework. Wo ein Modell einen Confidence- oder Unsicherheitswert liefert, sollte das System ihn aufbewahren, ohne ihn vorschnell als Wahrscheinlichkeit zu lesen. Wo ein solcher Wert fehlt oder schlecht kalibriert ist, müssen Fachregeln, Stichproben und menschliche Prüfung die Unsicherheit sichtbar machen. Bei Geldbeträgen ist mir eine markierte Zeile im Report lieber als eine saubere Zahl, der ich nicht trauen kann.\n\n## Weiterführende Quellen\n\n- Tesseract, TSV-Ausgabeformat mit Wort-Confidence: [Tesseract OCR](https://tesseractocr.org/) und [Erläuterung des TSV-Formats](https://tomrochette.com/tesseract-tsv-format/)\n- Confidence-Schwellen in der Praxis: [Diskussion zur Berechnung der Tesseract-Confidence](https://groups.google.com/g/tesseract-ocr/c/SN8L0IA_0D4)\n- Validierung und menschliche Prüfung bei Beleg-OCR: [Invoice OCR Error Handling](https://invoicedataextraction.com/blog/invoice-ocr-error-handling)"},{"slug":"rxjs-die-vier-subject-typen","title":"Die vier Subject-Typen in RxJS","summary":"Subject, BehaviorSubject, ReplaySubject und AsyncSubject unterscheiden sich darin, was späte Subscriber sehen und wie Abschluss oder Fehler ihr gespeichertes Verhalten beenden.","tags":["Software","Reactive"],"published_at":"2024-05-21T10:00:00+00:00","updated_at":"2026-07-13T08:51:45.630871+00:00","url":"https://www.mikebild.dev/de/blog/rxjs-die-vier-subject-typen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/38f92df673aa566d73a6a8e29513d15911a4552b342b2158c5e177786791df8c/rxjs-die-vier-subject-typen-vorlesen.mp3","audio_duration_secs":853,"text":"Ein Subject ist kein Observable mit angeschraubter `next()`-Methode. Es ist ein anderes Modell: hot statt kalt, multicast statt unicast, mit einem Lifecycle, der weiterläuft, ob gerade jemand zuhört oder nicht. Wer es nur als bequeme Abkürzung nimmt, um endlich selbst Werte in einen Stream zu schieben, handelt sich damit ein Timing-Verhalten ein, das er nicht bestellt hat.\n\nVor der Wahl zwischen den vier Typen steht deshalb eine andere Frage: Lohnt sich der Stream an dieser Stelle überhaupt, oder tut es ein schlichteres Modell genauso? Wer sie überspringt, baut Reaktivität dort ein, wo eine Variable oder ein Promise gereicht hätte. Wer sie stellt und mit Ja beantwortet, hat danach noch genau eine Entscheidung zu treffen – und die spart, richtig getroffen, eine ganze Klasse von Timing-Bugs, die schwer zu reproduzieren und teuer zu debuggen sind.\n\nEin Subject ist der Baustein, mit dem man aus imperativem Code heraus in die reaktive Welt hineinruft. Ein normales Observable ist dafür nicht gebaut – es beschreibt eine Quelle, die man abonniert, und es startet seine Arbeit erst dann, verzögert, pro Subscription neu. Genau an dieser Stelle betritt das Subject die Bühne. Und sobald diese Brücke steht, kommt die nächste Frage: Warum gibt es davon gleich vier Varianten? Die kurze Antwort lautet: Sie unterscheiden sich im Gedächtnis. Die lange Antwort ist dieser Artikel.\n\nWer die Grundlagen des Push-Modells noch einmal auffrischen möchte, findet sie in [RxJS: Push und Pull](https://www.mikebild.dev/de/blog/rxjs-push-und-pull/). Hier setze ich voraus, dass Observable und Subscription vertraut sind, und konzentriere mich auf die Entscheidung dahinter und auf die Subjects selbst. Stand ist RxJS 7.\n\n## Lohnt sich Reaktivität hier überhaupt?\n\nBevor ich zwischen den vier Typen wähle, stelle ich eine Frage davor: Braucht diese Stelle überhaupt einen Stream? Reaktivität ist kein Selbstzweck, sondern ein Modell mit Kosten. Ein Subject bringt einen Lifecycle mit, um den man sich kümmern muss – Subscriptions, die man wieder abbestellt, Puffer, die Speicher halten, ein Timing, das man im Kopf behalten muss. Diese Kosten lohnen sich, aber nicht überall.\n\nFür einen einzelnen Wert, der einmal berechnet und dann gelesen wird, reicht eine Variable. Für eine einzelne asynchrone Operation mit genau einem Ergebnis ist ein Promise das einfachere und vertrautere Werkzeug. Für eine fertige Liste, die vollständig vorliegt, tut es ein Array mit den üblichen Methoden. In all diesen Fällen einen Stream aufzuziehen, verteuert den Code, ohne etwas zurückzugeben – man handelt sich den Subscription-Lifecycle ein und bekommt dafür nichts, was das schlichtere Modell nicht auch geliefert hätte.\n\nReaktivität zahlt sich in dem Moment aus, in dem drei Dinge zusammenkommen: Werte entstehen über die Zeit verteilt, nicht auf einen Schlag; es gibt mehr als einen Interessenten für dieselben Werte; und man will diese Werte zusammensetzen, filtern, abbrechen oder zusammenführen, statt sie nur einmal entgegenzunehmen. Ein Klickstrom, ein WebSocket, ein State, den mehrere Komponenten teilen – das sind die Fälle, in denen ein Geflecht aus Variablen und Callbacks schnell unübersichtlich wird und ein Stream die Reibung herausnimmt.\n\nDie Vorabfrage kostet nichts und hält die Menge an reaktivem Code klein. Dort, wo Reaktivität trägt, spielt sie ihre Stärke aus, und der Rest bleibt einfach. Erst wenn die Frage mit Ja beantwortet ist, wird die zweite Frage relevant, um die es hier geht: welches Gedächtnis der Stream für späte Subscriber haben soll.\n\n## Ein Subject ist beides zugleich\n\nDer entscheidende Satz über Subjects steht schon in der Klassendefinition: `Subject<T>` erweitert `Observable<T>` und implementiert zugleich das Observer-Interface. Das heißt, ein Subject ist Observable und Observer in einer einzigen Instanz. Man kann es abonnieren wie jedes Observable – und man kann per `next`, `error` und `complete` Werte hineinfüttern wie in einen Observer.\n\n```ts\nimport { Subject } from 'rxjs';\n\nconst subject = new Subject<number>();\n\n// als Observable: abonnieren\nsubject.subscribe((value) => console.log('received', value));\n\n// als Observer: Werte hineingeben\nsubject.next(1);\nsubject.next(2);\nsubject.complete();\n```\n\nDiese Doppelrolle hat eine wichtige Konsequenz. Ein reines Observable ist kalt und unicast: Jeder Subscriber bekommt seine eigene Ausführung, der Code im Observable läuft pro Subscription erneut. Ein Subject dagegen ist multicast. Es hält eine Liste seiner Subscriber, und wenn `next` aufgerufen wird, geht derselbe Wert an alle gleichzeitig. Es gibt eine Quelle und viele Empfänger.\n\n```mermaid\nflowchart LR\n  P[\"Quelle<br/>next / error / complete\"] --> S((\"Subject\"))\n  S --> A[\"Subscriber A\"]\n  S --> B[\"Subscriber B\"]\n  S --> C[\"Subscriber C\"]\n```\n\nDamit hängt der zweite zentrale Charakterzug zusammen: Subjects sind hot. Werte fließen unabhängig davon, ob gerade jemand zuhört. Rufe ich `subject.next(1)` auf, bevor irgendein Subscriber existiert, ist dieser Wert bei einem einfachen `Subject` schlichtweg weg. Er wurde emittiert, aber niemand war da, um ihn entgegenzunehmen. Genau dieser Punkt – was passiert mit Werten, die vor einer Subscription geflossen sind – trennt die vier Typen voneinander.\n\n## Die vier Typen und ihr Gedächtnis\n\nBevor wir in die Details gehen, hier der Überblick. Alle vier erben von `Subject`, alle vier sind hot und multicast. Der Unterschied liegt darin, was ein Subscriber sieht, der zu spät kommt:\n\n- Ein `Subject` hat kein Gedächtnis. Wer nach einem `next` abonniert, hat den Wert verpasst. Nur was ab dem Subscribe-Zeitpunkt fließt, kommt an.\n- Ein `BehaviorSubject` merkt sich den letzten Wert und braucht einen Initialwert. Jeder neue Subscriber bekommt sofort den aktuellen Stand – ideal für den Fall \"aktueller State\".\n- Ein `ReplaySubject` puffert die letzten n Werte und spielt sie jedem neuen Subscriber ab, sogar nach `complete`.\n- Ein `AsyncSubject` emittiert nur den allerletzten Wert, und erst, wenn `complete` aufgerufen wurde.\n\nDie Liste beschreibt nur `next` und `complete`. Ein `error` beendet jeden dieser Streams. Danach liefert ein `BehaviorSubject` keinen letzten Wert mehr, ein `AsyncSubject` kein Endergebnis; ein `ReplaySubject` spielt seinen Puffer noch aus und meldet anschließend den Fehler. Diese terminalen Signale gehören deshalb genauso zur Wahl wie das Verhalten bei späten Subscriptions.\n\nSchauen wir uns jeden Typ mit einem kleinen Timing-Beispiel an, denn das Timing ist genau das, worauf es ankommt.\n\n## Subject – der Vergessliche\n\nBeginnen wir mit dem einfachen `Subject`. Es hat kein Gedächtnis. Um das sichtbar zu machen, abonnieren wir zu unterschiedlichen Zeitpunkten und beobachten, wer was mitbekommt.\n\n```ts\nimport { Subject } from 'rxjs';\n\nconst subject = new Subject<number>();\n\nsubject.next(1); // niemand hört zu -> verloren\n\nsubject.subscribe((v) => console.log('A', v));\nsubject.next(2);\n\nsubject.subscribe((v) => console.log('B', v));\nsubject.next(3);\n\n// Ausgabe:\n// A 2\n// A 3\n// B 3\n```\n\nDer Wert `1` ist verpufft, weil zum Zeitpunkt seiner Emission kein Subscriber existierte. Subscriber A steigt vor der `2` ein und sieht deshalb `2` und `3`. Subscriber B kommt erst nach der `2` und sieht nur noch `3`. Das ist kein Bug, das ist die Definition. Ein `Subject` ist ein reiner Verteiler für den Moment.\n\nWofür ist das gut? Für echte Events, bei denen Vergangenheit keine Rolle spielt. Ein Klick, ein WebSocket-Frame, ein Kommando, das ausgelöst wird. Wer nicht zuhört, wenn das Event passiert, hat es eben nicht miterlebt – und das ist völlig in Ordnung.\n\n## BehaviorSubject – der Zustandshalter\n\nJetzt der Typ, den ich in der Praxis am häufigsten sehe und empfehle: das `BehaviorSubject`. Es unterscheidet sich in zwei Punkten. Erstens braucht sein Konstruktor einen Initialwert – man kann kein `BehaviorSubject` ohne definierten Startzustand erzeugen. Zweitens merkt es sich stets den zuletzt emittierten Wert und liefert ihn synchron an jeden neuen Subscriber aus.\n\n```ts\nimport { BehaviorSubject } from 'rxjs';\n\nconst state$ = new BehaviorSubject<number>(0); // Initialwert Pflicht\n\nstate$.subscribe((v) => console.log('A', v)); // A bekommt sofort 0\nstate$.next(1);\n\nstate$.subscribe((v) => console.log('B', v)); // B bekommt sofort 1\nstate$.next(2);\n\n// Ausgabe:\n// A 0\n// A 1\n// B 1\n// A 2\n// B 2\n```\n\nSubscriber B steigt spät ein und bleibt trotzdem nicht leer. Er erhält beim Abonnieren sofort den aktuellen Wert `1` und danach den Rest. Genau das macht das `BehaviorSubject` zum Standardwerkzeug für State. Ein State hat immer einen aktuellen Wert, und wer ihn abfragt, will diesen Wert – nicht die Aussicht, irgendwann beim nächsten `next` etwas zu erfahren.\n\nPraktisch dazu: das `BehaviorSubject` bietet mit `getValue()` und der Property `value` einen synchronen Zugriff auf den aktuellen Stand, ohne zu abonnieren.\n\n```ts\nconst counter$ = new BehaviorSubject<number>(0);\ncounter$.next(42);\n\nconsole.log(counter$.getValue()); // 42\nconsole.log(counter$.value); // 42\n```\n\nWer schon einmal in [RxJS in React](https://www.mikebild.dev/de/blog/rxjs-in-react-datenfluss/) mit Streams als Datenquelle gearbeitet hat, kennt die Situation: Eine Komponente montiert und abonniert, und sie soll natürlich sofort etwas anzeigen können. Mit einem `BehaviorSubject` ist genau das garantiert.\n\nEin Detail, das gern übersehen wird: Nach `complete()` sendet ein `BehaviorSubject` seinen letzten Wert nicht mehr an neue Subscriber. Es liefert dann nur noch das Complete-Signal. Der aktuelle Wert steht also nur so lange bereit, wie der Stream aktiv ist.\n\n## ReplaySubject – das Gedächtnis mit Puffer\n\nDas `BehaviorSubject` merkt sich genau einen Wert. Manchmal braucht man mehr Historie. Dafür gibt es das `ReplaySubject`. Sein Konstruktor nimmt eine Puffergröße entgegen und spielt diese letzten n Werte jedem neuen Subscriber ab.\n\n```ts\nimport { ReplaySubject } from 'rxjs';\n\nconst replay$ = new ReplaySubject<number>(2); // die letzten 2 Werte puffern\n\nreplay$.next(1);\nreplay$.next(2);\nreplay$.next(3);\n\nreplay$.subscribe((v) => console.log('late', v));\nreplay$.next(4);\n\n// Ausgabe:\n// late 2\n// late 3\n// late 4\n```\n\nDer spät hinzukommende Subscriber bekommt nachträglich `2` und `3` – die beiden letzten gepufferten Werte – und danach `4` live. Der Wert `1` fällt aus dem Puffer, weil dieser nur zwei Plätze hat.\n\nZwei Eigenschaften machen das `ReplaySubject` besonders. Erstens gibt es neben der Puffergröße einen optionalen Zeitfenster-Parameter: `new ReplaySubject<number>(bufferSize, windowTime)`. Damit werden nur Werte abgespielt, die innerhalb der letzten x Millisekunden geflossen sind. Zweitens – und das ist der große Unterschied zum `BehaviorSubject` – spielt das `ReplaySubject` seinen Puffer auch nach `complete()` noch ab.\n\n```ts\nconst cache$ = new ReplaySubject<number>(3);\n\ncache$.next(10);\ncache$.next(20);\ncache$.next(30);\ncache$.complete();\n\n// nach complete abonnieren\ncache$.subscribe((v) => console.log('after complete', v));\n\n// Ausgabe:\n// after complete 10\n// after complete 20\n// after complete 30\n```\n\nDas eignet sich gut als kleiner Cache: Ein Ergebnis wird einmal produziert und dann jedem, der später fragt, erneut ausgeliefert. Vorsicht ist allerdings bei einer Puffergröße von `Infinity` geboten – die ist der Default. Ein `ReplaySubject` ohne Größenangabe merkt sich jeden jemals emittierten Wert und wächst unbegrenzt. In langlebigen Streams ist das eine Quelle für Speicherlecks. Deshalb setze ich die Puffergröße bewusst.\n\n## AsyncSubject – nur das Endergebnis\n\nDer vierte Typ ist der eigenwilligste. Das `AsyncSubject` emittiert nur einen einzigen Wert – den letzten – und das auch erst, wenn `complete()` aufgerufen wird. Solange kein `complete` kommt, gibt es nichts.\n\n```ts\nimport { AsyncSubject } from 'rxjs';\n\nconst async$ = new AsyncSubject<number>();\n\nasync$.subscribe((v) => console.log('result', v));\n\nasync$.next(1);\nasync$.next(2);\nasync$.next(3);\nasync$.complete(); // erst jetzt wird emittiert\n\n// Ausgabe:\n// result 3\n```\n\nDie Zwischenwerte `1` und `2` interessieren das `AsyncSubject` nicht. Es merkt sich nur den jeweils letzten, und beim `complete` gibt es diesen einen an alle Subscriber weiter – auch an die, die erst nach dem `complete` abonnieren. Das Verhalten erinnert bewusst an eine Promise: eine Berechnung, die läuft und am Ende genau ein Ergebnis liefert.\n\nDer häufigste Stolperstein hier ist das fehlende `complete`. Ohne diesen Aufruf emittiert das `AsyncSubject` niemals. Der Fehler sieht harmlos aus und kostet trotzdem eine halbe Stunde: Die Werte sind geflossen, die Konsole bleibt leer, und man sucht zunächst überall – nur nicht beim fehlenden Abschluss.\n\n## Wann welcher – die Entscheidung\n\nFassen wir die Wahl zusammen. Die Frage, die man sich stellen sollte, lautet immer: Was soll ein Subscriber sehen, der zu spät kommt?\n\n- Es geht um ein Event, und Vergangenheit ist irrelevant? **Subject.**\n- Es geht um einen aktuellen State, der immer verfügbar sein muss? **BehaviorSubject.**\n- Neue Subscriber sollen die letzten n Werte oder ein zeitliches Fenster nachgeliefert bekommen? **ReplaySubject.**\n- Es interessiert nur ein einziges Endergebnis nach Abschluss? **AsyncSubject.**\n\nIn der Praxis dominieren zwei davon. `BehaviorSubject` für State, `Subject` für Events. `ReplaySubject` und `AsyncSubject` sind Spezialwerkzeuge für konkrete Bedürfnisse.\n\n## Zwei Fallstricke aus der Praxis\n\nZum Schluss die zwei Fehler, die mir am häufigsten begegnen. Beide sind leicht zu vermeiden, wenn man sie einmal gesehen hat.\n\nDer erste ist die Verwechslung von `Subject` und `BehaviorSubject` bei State. Man baut einen Service, der einen aktuellen Wert hält, verwendet aber ein einfaches `Subject`. Dann montiert eine Komponente, abonniert – und bleibt leer, weil das letzte `next` schon vorbei war. Der Fehler äußert sich als \"manchmal ist der Wert da, manchmal nicht\", je nach Timing. Für State gilt: immer `BehaviorSubject`, damit es einen definierten aktuellen Wert gibt.\n\nDer zweite Fallstrick ist subtiler und betrifft die Architektur: Subjects überall statt reiner Observables nach außen zu geben. Wenn ich ein `Subject` als öffentliche Property exponiere, kann jeder von außen `next()` oder `complete()` darauf aufrufen. Damit ist die Kapselung dahin – jede beliebige Stelle im Code kann in den Stream hineinfunken oder ihn abschließen. Die saubere Lösung ist ein privates Subject und ein öffentliches Observable über `asObservable()`:\n\n```ts\nimport { Subject, Observable } from 'rxjs';\n\ninterface Command {\n  type: string;\n}\n\nclass CommandBus {\n  private commandsSubject = new Subject<Command>();\n\n  // nach außen nur lesbar\n  readonly commands$: Observable<Command> = this.commandsSubject.asObservable();\n\n  dispatch(command: Command): void {\n    this.commandsSubject.next(command);\n  }\n}\n```\n\nVon außen kann man `commands$` abonnieren, aber nicht hineinschreiben. Das `next` läuft ausschließlich über die kontrollierte `dispatch`-Methode. Das ist dasselbe Prinzip, das auch in [RxJS in Node.js](https://www.mikebild.dev/de/blog/rxjs-in-node-ereignisstroeme/) trägt: Events fließen über eine definierte Schnittstelle hinein und über abonnierbare Observables wieder hinaus. Ein Subject als Motor im Inneren, ein Observable als Fassade nach außen.\n\nDamit man es nicht falsch versteht: Subjects sind kein Anti-Pattern. Sie sind das richtige Werkzeug, wann immer man von imperativem Code aus in einen Stream schreiben muss. Nur sollte das Subject selbst nach Möglichkeit im Inneren bleiben.\n\n## Fazit\n\nEin Subject ist Observable und Observer in einem und multicastet dieselben Werte an alle seine Subscriber. Es ist hot – Werte fließen, ob jemand zuhört oder nicht. Die vier Varianten unterscheiden sich nur in einer einzigen Dimension: im Gedächtnis für Subscriber, die zu spät kommen. Kein Gedächtnis beim `Subject`, der letzte Wert beim `BehaviorSubject`, die letzten n Werte beim `ReplaySubject`, das Endergebnis nach Abschluss beim `AsyncSubject`.\n\nWer diese eine Frage – was sieht ein später Subscriber? – zur Gewohnheit macht, trifft die Wahl fast automatisch. Und wer die Frage davorstellt – lohnt sich der Stream hier überhaupt? – hält die Menge an reaktivem Code auf das begrenzt, was wirklich davon profitiert. Was dabei herauskommt, ist unspektakulär und genau deshalb brauchbar: Der passende Typ steht schneller fest, eine ganze Klasse von Timing-Bugs entsteht gar nicht erst, und der Datenfluss bleibt dort kontrolliert, wo er entsteht – hinter Observables nach außen, mit einem Subject als Motor im Inneren. Und wo eine Variable, ein Promise oder ein Array reicht, bleibt es dabei. Reaktivität ist das Werkzeug für Werte über die Zeit und viele Empfänger, nicht der Default für jeden Wert.\n\n## Weiterführende Quellen\n\n- [RxJS Guide: Subject](https://rxjs.dev/guide/subject)\n- [RxJS API: BehaviorSubject](https://rxjs.dev/api/index/class/BehaviorSubject)\n- [RxJS API: ReplaySubject](https://rxjs.dev/api/index/class/ReplaySubject)\n- [RxJS API: AsyncSubject](https://rxjs.dev/api/index/class/AsyncSubject)\n- [RxJS: Push und Pull](https://www.mikebild.dev/de/blog/rxjs-push-und-pull/)\n- [RxJS in Node.js](https://www.mikebild.dev/de/blog/rxjs-in-node-ereignisstroeme/)\n- [RxJS in React](https://www.mikebild.dev/de/blog/rxjs-in-react-datenfluss/)"},{"slug":"graphql-client-caching-mit-apollo","title":"Apollo Client: der normalisierte Cache und optimistische Updates","summary":"HTTP-Caching ist auch mit GraphQL möglich, aber für Client-Zustand oft weniger direkt. Apollo normalisiert deshalb Objekte und unterstützt optimistische Updates in der UI.","tags":["Software","GraphQL","API"],"published_at":"2024-05-14T10:00:00+00:00","updated_at":"2026-07-13T08:51:30.971485+00:00","url":"https://www.mikebild.dev/de/blog/graphql-client-caching-mit-apollo/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/8c0a54e18a47ab6915638c5d222e9270d31fc6efe08538037cfbfd3b9fb20a83/graphql-client-caching-mit-apollo-vorlesen.mp3","audio_duration_secs":699,"text":"Wer bei GraphQL ganze Antworten unter einem gehashten Query-String ablegt, hat keinen Cache gebaut, sondern eine Sammlung von Kopien. Ändert ein Nutzer seinen Namen, wissen diese Kopien nichts voneinander: Die Listenansicht zeigt weiter den alten Wert, die Detailansicht schon den neuen, und niemand merkt es, bis jemand hin und her klickt. Der Schlüssel, unter dem eine Antwort abgelegt wird, ist bei GraphQL das eigentliche Problem – und die Lösung liegt eine Ebene tiefer als die Antwort selbst.\n\nIn diesem Beitrag geht es ausschließlich um den Cache im Browser, also den Client. Es gibt daneben serverseitiges Caching – Request-Batching und Deduplizierung mit DataLoader zum Beispiel – aber das ist eine völlig andere Baustelle mit anderen Zielen. Hier reden wir über den `InMemoryCache` von Apollo Client 3, über Normalisierung, über optimistische Updates und über den einen Fallstrick, der schnell mal eine halbe Stunde Debugging kostet.\n\n## Warum die URL allein als Cache-Key nicht reicht\n\nBei einer klassischen REST-API ist Caching konzeptionell einfach. Jede Ressource hat ihre eigene URL, und ein `GET /users/1` liefert immer denselben Ausschnitt. Der Browser, ein CDN oder eine HTTP-Cache-Schicht kann die Antwort unter genau dieser URL ablegen und beim nächsten Aufruf zurückgeben. Der Key ist die Adresse.\n\nBei vielen GraphQL-APIs teilen sich unterschiedliche Queries einen Endpoint, und häufig laufen sie als `POST /graphql`. Klassisches HTTP-Caching ist damit nicht ausgeschlossen: GET-Anfragen, der Query-Body als Teil des Cache-Keys oder persistierte Queries können Antworten cachebar machen. Für den Zustand einer interaktiven Anwendung bleibt ein reiner Response-Cache aber unhandlich, sobald dieselbe Entity in mehreren Queries auftaucht und gezielt aktualisiert werden soll.\n\nDer Fall, an dem das sichtbar wird: Eine Listenansicht lädt alle Nutzer, eine Detailansicht lädt einen einzelnen Nutzer. Beide zeigen den Namen von `User:1`. Ändert dieser Nutzer seinen Namen, müssten bei einem Response-basierten Cache beide Einträge separat invalidiert werden – und der Cache müsste überhaupt erst wissen, dass in beiden Responses dasselbe Objekt steckt. Genau das leistet ein reiner Response-Cache nicht.\n\n## Normalisierung: ein Objekt, einmal, mit stabiler Identität\n\nApollos Antwort darauf ist Normalisierung. Statt ganze Antworten abzulegen, zerlegt der `InMemoryCache` jede Response in ihre einzelnen Objekte und speichert jedes Objekt genau einmal – flach, unter einer stabilen Cache-ID. Diese ID bildet Apollo per Default aus zwei Feldern: dem `__typename` und dem Primärschlüssel des Objekts. Das Ergebnis sieht aus wie `User:1` oder `Product:42`.\n\nAls Schlüsselfeld nimmt Apollo standardmäßig `id`, und falls das nicht existiert, `_id`. Fehlen beide und ist nichts anderes konfiguriert, passiert etwas Wichtiges, auf das ich weiter unten zurückkomme: Das Objekt wird nicht normalisiert, sondern inline im Elternobjekt eingebettet.\n\nDer Effekt der Normalisierung lässt sich am besten an dem eben genannten Fall zeigen. Zwei Queries, ein geteiltes Objekt:\n\n```mermaid\ngraph TD\n  QA[\"Query GET_USERS<br/>(Listenansicht)\"] --> N[\"User:1<br/>{ name, email }\"]\n  QB[\"Query GET_USER<br/>(Detailansicht)\"] --> N\n  N --> S[(\"Normalisierter<br/>InMemoryCache\")]\n```\n\nBeide Queries verweisen auf denselben Knoten `User:1`. Im Store liegt der Nutzer genau einmal. Ändert sich sein Name, ändert er sich für alle Views, die auf diesen Knoten zeigen. Das ist der Kern, um den sich alles andere dreht.\n\nDamit das funktioniert, muss jede Query die Identitätsfelder mitselektieren. Apollo fügt `__typename` automatisch hinzu, aber `id` müssen wir selbst anfragen:\n\n```graphql\nquery GET_USERS {\n  users {\n    id\n    name\n    email\n  }\n}\n\nquery GET_USER($id: ID!) {\n  user(id: $id) {\n    id\n    name\n    email\n  }\n}\n```\n\nDer Cache-Aufbau selbst ist unspektakulär. Man instanziiert einen `InMemoryCache` und übergibt ihn an den `ApolloClient`:\n\n```ts\nimport { ApolloClient, InMemoryCache, HttpLink } from \"@apollo/client\";\n\nconst client = new ApolloClient({\n  link: new HttpLink({ uri: \"/graphql\" }),\n  cache: new InMemoryCache(),\n});\n```\n\nWas Normalisierung konkret bringt, fasse ich so zusammen:\n\n- Jede Entity liegt genau einmal im Speicher, egal über wie viele Queries sie geladen wurde.\n- Ein Feld-Update an einer Stelle propagiert automatisch in jede View, die dasselbe Objekt zeigt – ohne manuelle Invalidierung.\n- Der Cache wird kleiner und konsistenter, weil doppelte Daten zu einer Referenz zusammenfallen.\n- Die UI kann Daten sofort aus dem Cache rendern und im Hintergrund nachladen, ohne dass verschiedene Ansichten auseinanderlaufen.\n\n## Feld-Updates passieren automatisch, Struktur-Updates nicht\n\nHier trennt sich in der Praxis, was Apollo für uns erledigt und wo wir selbst ran müssen. Der Unterschied ist wichtig, weil er die häufigste Verwirrung auflöst.\n\nÄndere ich den Wert eines Feldes an einem bereits gecachten Objekt, erledigt Apollo das automatisch. Eine Mutation, die `User:1` mit neuem Namen zurückgibt, überschreibt das Feld im Store, und jede View aktualisiert sich. Ich muss dafür nichts schreiben – vorausgesetzt, die Mutation liefert `id` und `__typename` mit zurück, damit Apollo weiß, welchen Eintrag es treffen soll.\n\n```ts\nconst UPDATE_USER = gql`\n  mutation UpdateUser($id: ID!, $name: String!) {\n    updateUser(id: $id, name: $name) {\n      id\n      name\n    }\n  }\n`;\n\n// Apollo matches the response onto User:1 and updates\n// every view that shows this user - no update function needed.\nconst [updateUser] = useMutation(UPDATE_USER);\n```\n\nAnders sieht es aus, wenn sich die Struktur ändert, also ein Element zu einer Liste hinzukommt oder aus ihr verschwindet. Lege ich per Mutation einen neuen Nutzer an, normalisiert Apollo das neue Objekt zwar brav in den Store – aber es weiß nicht, in welche Liste dieser Nutzer gehört. Die Zugehörigkeit zu einer bestimmten `GET_USERS`-Query ist keine Eigenschaft des Objekts, und deshalb kann Apollo sie nicht erraten. Ergebnis: Das neue Objekt existiert im Cache, taucht in der Listenansicht aber nicht auf, bis man neu lädt.\n\nFür diese Fälle gibt es die `update`-Funktion an der Mutation. Sie bekommt den Cache und die Response und schreibt die Liste explizit fort:\n\n```ts\nconst ADD_USER = gql`\n  mutation AddUser($name: String!) {\n    addUser(name: $name) {\n      id\n      name\n    }\n  }\n`;\n\nconst [addUser] = useMutation(ADD_USER, {\n  update(cache, { data }) {\n    const newUser = data.addUser;\n    cache.modify({\n      fields: {\n        users(existingRefs = [], { toReference }) {\n          return [...existingRefs, toReference(newUser)];\n        },\n      },\n    });\n  },\n});\n```\n\nDie Trennlinie verläuft also genau hier: Wertänderungen erledigt Apollo, die Listenzugehörigkeit erledigen wir.\n\n## Optimistische Updates: die UI reagiert, bevor der Server antwortet\n\nDas zweite große Thema ist gefühlte Geschwindigkeit. Jede Mutation läuft übers Netz, und selbst 150 Millisekunden fühlen sich bei einem Toggle-Button träge an. Optimistische Updates lösen das, indem sie den erwarteten Endzustand sofort in den Cache schreiben – bevor der Server geantwortet hat. Die UI reagiert ohne Latenz. Kommt die echte Antwort, ersetzt sie die optimistische Schicht. Schlägt die Mutation fehl, verwirft Apollo die optimistische Schicht automatisch und stellt den vorherigen Zustand wieder her. Das ist der entscheidende Punkt: Man muss den Rollback nicht selbst schreiben.\n\nDer Ablauf lässt sich als Sequenz gut zeigen:\n\n```mermaid\nsequenceDiagram\n  participant UI\n  participant Cache\n  participant Server\n  UI->>Cache: optimistisches Ergebnis schreiben\n  Cache-->>UI: sofort neu rendern\n  UI->>Server: Mutation senden\n  alt Erfolg\n    Server-->>Cache: echtes Ergebnis schreiben\n  else Fehler\n    Server-->>Cache: optimistische Schicht verwerfen\n    Cache-->>UI: vorigen Zustand wiederherstellen\n  end\n```\n\nIn Code sieht das so aus. Ein Todo lässt sich abhaken, und wir schreiben den `completed`-Zustand optimistisch:\n\n```ts\nconst TOGGLE_TODO = gql`\n  mutation ToggleTodo($id: ID!, $completed: Boolean!) {\n    toggleTodo(id: $id, completed: $completed) {\n      id\n      completed\n    }\n  }\n`;\n\nconst [toggleTodo] = useMutation(TOGGLE_TODO, {\n  optimisticResponse: {\n    toggleTodo: {\n      __typename: \"Todo\",\n      id: todo.id,\n      completed: !todo.completed,\n    },\n  },\n});\n```\n\nZwei Dinge sind hier nicht verhandelbar. Erstens muss das `optimisticResponse` dieselbe Form haben wie die echte Server-Antwort – dieselben Felder, dieselbe Verschachtelung. Zweitens muss es `__typename` und die Schlüsselfelder enthalten. Ohne `__typename` und `id` kann Apollo den optimistischen Eintrag nicht auf `Todo:...` normalisieren, der Merge trifft das falsche Ziel und der Rollback verhält sich unerwartet. Das ist einer der Fehler, die still passieren: Es sieht fast richtig aus, aber die UI flackert oder springt zurück.\n\nWer will, kann `optimisticResponse` auch als Funktion schreiben, die die Variablen bekommt – praktisch, wenn der optimistische Zustand aus den Mutation-Argumenten abgeleitet wird:\n\n```ts\noptimisticResponse: (vars) => ({\n  toggleTodo: {\n    __typename: \"Todo\",\n    id: vars.id,\n    completed: vars.completed,\n  },\n}),\n```\n\n## fetchPolicy: wie stark der Cache das Sagen hat\n\nEin Detail, das dabei leicht untergeht, ist die `fetchPolicy` einer Query. Sie steuert, in welchem Verhältnis Cache und Netzwerk stehen. Der Default ist `cache-first`: Apollo schaut erst in den Cache, und nur wenn die Daten fehlen, geht es ans Netz. Das ist meistens genau richtig und der Grund, warum die zweite Detailansicht eines schon geladenen Nutzers ohne Request auskommt.\n\nDie wichtigsten Werte, die man kennen sollte:\n\n- `cache-first` – Default; Cache zuerst, Netzwerk nur bei fehlenden Daten.\n- `cache-and-network` – rendert sofort aus dem Cache und lädt parallel frisch nach; gut für Listen, die aktuell sein sollen, aber sofort etwas zeigen dürfen.\n- `network-only` – ignoriert den Cache beim Lesen, schreibt das Ergebnis aber in den Cache.\n- `no-cache` – liest und schreibt gar nicht am Cache; die Daten existieren nur innerhalb dieser Query.\n- `cache-only` – nur Cache, nie Netzwerk; wirft, wenn nichts da ist.\n\nDen Default lasse ich so lange stehen, bis es einen konkreten Grund gibt, ihn zu ändern. `cache-and-network` ist der häufigste bewusste Griff, weil es das Beste aus beidem verbindet: sofort sichtbar und trotzdem frisch.\n\n## Der Fallstrick: Objekte ohne stabile Identität\n\nJetzt zu dem Problem, das ich weiter oben angekündigt habe – und das lange unbemerkt bleibt, weil es sich nirgends meldet. Normalisierung funktioniert nur, wenn Apollo eine stabile Identität bilden kann. Fehlt `id` und `_id`, und ist kein alternatives Schlüsselfeld konfiguriert, normalisiert Apollo das Objekt nicht. Es bettet es stattdessen still in das Elternobjekt ein. Kein Fehler, keine Warnung in der Konsole – die App läuft, aber der Cache tut nicht, was man erwartet.\n\nDie Symptome kommen später und wirken zusammenhanglos: Dieselbe Entity liegt mehrfach im Cache, ein Update trifft nur eine Kopie, und zwei Views zeigen unterschiedliche Werte für dasselbe Ding. Besonders gern passiert das bei berechneten oder aggregierten Feldern, bei Wrapper-Typen aus der Paginierung oder bei Objekten, deren id man in der Query schlicht vergessen hat mitzuselektieren. Wer mit Cursor-basierter Blätterung arbeitet, kennt solche Wrapper-Typen aus der Praxis – ich habe das Thema in [GraphQL-Paginierung](https://www.mikebild.dev/de/blog/graphql-paginierung-und-connections/) ausführlicher behandelt.\n\nEs gibt zwei saubere Lösungen. Die einfachste: das Identitätsfeld einfach mitanfragen. Oft ist die id da, sie stand nur nicht in der Selektion. Wenn der Typ tatsächlich keinen `id`-Schlüssel hat, aber ein anderes eindeutiges Feld – eine Artikelnummer etwa – dann sagt man Apollo das über `typePolicies` und `keyFields`:\n\n```ts\nconst cache = new InMemoryCache({\n  typePolicies: {\n    Product: {\n      keyFields: [\"upc\"],\n    },\n    Book: {\n      keyFields: [\"isbn\"],\n    },\n  },\n});\n```\n\n`keyFields` kann mehr als nur ein Ersatzfeld sein:\n\n- `keyFields: [\"upc\"]` – ein alternatives Schlüsselfeld statt `id`.\n- `keyFields: [\"name\", \"email\"]` – ein zusammengesetzter Schlüssel aus mehreren Feldern.\n- `keyFields: []` – ein Singleton, von dem es genau einen Eintrag pro Typ gibt (etwa ein globaler `Settings`-Typ).\n- `keyFields: false` – Normalisierung für diesen Typ bewusst abschalten, wenn Einbettung gewünscht ist.\n\nAm Rande: In Apollo Client 2 hat man dafür `dataIdFromObject` benutzt. In Version 3 existiert das noch zur Migrationserleichterung, aber es ist der alte Weg. Für neuen Code sind `typePolicies` mit `keyFields` die richtige Wahl, weil sie pro Typ präzise und lokal konfigurierbar sind statt global über eine einzige Funktion.\n\nEin verwandter Fall, in dem Identität auf dem Server entscheidet, ist die Autorisierung: Wer welches Objekt überhaupt sehen darf, gehört nicht in den Client-Cache, sondern in den Resolver-Context. Wie ich das dort verankere, steht in [GraphQL-Auth im Context](https://www.mikebild.dev/de/blog/graphql-auth-im-context/).\n\n## Was hängen bleiben sollte\n\nGraphQL kann HTTP-Antworten cachen; Apollo löst im Browser ein anderes Problem. Der Client normalisiert Entities auf Objektebene, damit mehrere Queries dieselbe Instanz teilen und gezielte Feld-Updates konsistent bleiben. Strukturänderungen an Listen bleiben unsere Aufgabe über die `update`-Funktion. Optimistische Updates reagieren sofort und rollen bei Fehlern zurück, solange das `optimisticResponse` die richtige Form samt `__typename` hat. Der häufigste leise Fehler bleibt ein Objekt ohne stabile Identität, das nicht normalisiert wird. Konsistente IDs und bewusst gesetzte `keyFields` sparen hier viel Debugging.\n\n## Weiterführende Quellen\n\n- Cache configuration – cache IDs und keyFields: https://www.apollographql.com/docs/react/caching/cache-configuration\n- Cache normalization (Konzept): https://www.apollographql.com/docs/react/caching/overview\n- Optimistic mutation results: https://www.apollographql.com/docs/react/performance/optimistic-ui\n- Mutations und die update-Function: https://www.apollographql.com/docs/react/data/mutations\n- fetchPolicy und Zusammenspiel mit dem Cache: https://www.apollographql.com/docs/react/data/queries#setting-a-fetch-policy"},{"slug":"graphql-absichern-depth-complexity-rate-limiting","title":"GraphQL absichern: Query-Tiefe, Komplexität und Rate-Limiting","summary":"Ein einziger GraphQL-Endpoint nimmt beliebig verschachtelte Queries an – das ist bequem und zugleich ein Missbrauchsrisiko. Ich zeige, wie ich mit Depth-Limit, Complexity-Budget, Pagination-Caps und Rate-Limiting eine mehrschichtige Verteidigung gegen teure Queries baue.","tags":["Software","GraphQL","API"],"published_at":"2024-04-16T10:00:00+00:00","updated_at":"2026-07-13T13:20:10.670504+00:00","url":"https://www.mikebild.dev/de/blog/graphql-absichern-depth-complexity-rate-limiting/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/fa6ae5fe5f8cc98184ce9767a5384cfde8df10c4a8606854cd0f29d91b8dab02/graphql-absichern-depth-complexity-rate-limiting-vorlesen.mp3","audio_duration_secs":675,"text":"Bei einer REST-API ist das Kostenprofil einer Route meist enger begrenzt: Wer `/users/1/posts` baut, kennt den vorgesehenen Ausführungspfad, auch wenn Parameter und Datenmenge die tatsächlichen Kosten weiter verändern. GraphQL gibt dem Aufrufer mehr Einfluss auf Form und Tiefe der Query. Für die Developer Experience ist das ein Gewinn. Für den Betrieb heißt es: Die Kosten eines Requests lassen sich erst aus dem eingehenden Dokument und dem Resolver-Verhalten abschätzen.\n\nDie Frage, an der sich alles entscheidet, lautet: Was passiert, wenn jemand eine zwanzigfach verschachtelte Query mit `first: 1000` auf jeder Ebene schickt? Wer darauf keine Antwort hat, betreibt eine API, die beim ersten unfreundlichen Client umfällt – und merkt es genau dann zum ersten Mal.\n\nIn diesem Beitrag geht es nicht um Authentifizierung oder Autorisierung – das habe ich in [GraphQL-Auth im Context](https://www.mikebild.dev/de/blog/graphql-auth-im-context/) behandelt. Hier geht es um etwas Orthogonales: den Schutz vor teuren und böswilligen Queries. Ein sauber authentifizierter, autorisierter Nutzer kann den Server trotzdem in die Knie zwingen. Wer nur Auth hat, ist gegen diese Klasse von Problemen ungeschützt.\n\n## Warum ein Endpoint gefährlich wird\n\nDas Kernproblem sind zyklische Beziehungen im Schema. Sobald ein `User` seine `posts` hat und ein `Post` seinen `author` – der wiederum seine `posts` hat – ist die Tiefe formal unbegrenzt:\n\n```graphql\nquery MaliciousDepth {\n  user(id: \"1\") {\n    posts {\n      author {\n        posts {\n          author {\n            posts {\n              author {\n                # ... and so on, as deep as the client wants\n                name\n              }\n            }\n          }\n        }\n      }\n    }\n  }\n}\n```\n\nJede zusätzliche Ebene kann die Zahl der Resolver-Aufrufe vervielfachen. Ob daraus auch entsprechend viele Datenbankzugriffe werden, hängt von Batching, Caching und den Resolvern ab. Kombiniert mit unbegrenzten Listen kann dennoch eine extrem teure Query entstehen – ein Denial-of-Service mit einem einzigen HTTP-Request. Das ist kein hypothetisches Lehrbuch-Szenario. Ich habe produktive APIs gesehen, die genau so kippten, weil ein Frontend-Bug versehentlich eine rekursive Query erzeugte.\n\nDie gute Nachricht: Verteidigung funktioniert hier gut in Schichten. Keine einzelne Maßnahme genügt, aber die Kombination macht die Angriffsfläche beherrschbar. Der Ablauf, den ich anstrebe, sieht so aus:\n\n```mermaid\nflowchart TD\n  A[Client-Request] --> B{Rate-Limit<br/>am Edge}\n  B -->|Budget überschritten| R1[429 Too Many Requests]\n  B -->|ok| C{Depth Gate<br/>graphql-depth-limit}\n  C -->|zu tief| R2[Validation Error]\n  C -->|ok| D{Complexity Gate<br/>Cost-Budget}\n  D -->|über Budget| R3[Validation Error]\n  D -->|ok| E[Resolver-Ausführung<br/>mit Pagination-Caps]\n  E --> F[Response]\n```\n\nDie entscheidende Eigenschaft: Depth- und Complexity-Prüfung greifen in der Validierungsphase, also **bevor** ein einziger Resolver läuft. Parsing und Validierung kosten ebenfalls Ressourcen, bleiben mit Größen- und Ratenlimits aber deutlich besser begrenzbar als die eigentliche Resolver-Ausführung.\n\n## Schicht 1: Query-Tiefe begrenzen\n\nDer einfachste und wirkungsvollste erste Schritt ist ein Limit auf die Schachtelungstiefe. In Apollo Server 4 hängt man dafür eine Validation Rule in die `validationRules` des Konstruktors. Die verbreitete Bibliothek dafür ist `graphql-depth-limit`:\n\n```ts\nimport { ApolloServer } from \"@apollo/server\";\nimport depthLimit from \"graphql-depth-limit\";\nimport { typeDefs } from \"./schema\";\nimport { resolvers } from \"./resolvers\";\n\nconst server = new ApolloServer({\n  typeDefs,\n  resolvers,\n  validationRules: [\n    // Reject any operation nested deeper than 7 levels.\n    depthLimit(7),\n  ],\n});\n```\n\n`depthLimit(maxDepth)` liefert eine graphql-js Validation Rule, die während der Validierung die tiefste Verschachtelung zählt und Operationen über dem Limit ablehnt. Introspektionsfelder wie `__schema` und `__type` werden per Default ignoriert, damit Tools weiter funktionieren.\n\nEin Wort der Warnung, das 2024 dazugehört: `graphql-depth-limit` (v1.1.0) wird seit 2018 nicht mehr gepflegt und zählt ausschließlich die Selektionstiefe – nicht die Tiefe von Listen. Für viele Projekte reicht das als erste Schicht, solange die Lücke bekannt ist. Wer strenger sein will, kann sich `@graphile/depth-limit` ansehen, das auch List-Tiefe berücksichtigt.\n\nWichtig ist die konzeptionelle Grenze: Depth-Limiting kennt keine Feld-Kosten. Eine flache Query, die dafür tausend teure Felder auf einer Ebene selektiert, läuft anstandslos durch. Tiefe ist eben nur eine Dimension des Missbrauchs.\n\n## Schicht 2: Complexity- und Cost-Analyse\n\nDamit sind wir bei der zweiten Schicht, die die Schwäche der ersten ausgleicht. Statt nur Tiefe zu zählen, vergebe ich Kosten pro Feld und lehne jede Query ab, die ein Gesamtbudget überschreitet. Das etablierte Werkzeug dafür ist `graphql-query-complexity` von slicknode.\n\nDer Kern ist `createComplexityRule`, das ich mit einem `maximumComplexity`-Budget und einer Liste von Estimators konfiguriere. Estimators sind die Regeln, nach denen Kosten berechnet werden – sie werden der Reihe nach befragt, bis einer einen Wert liefert:\n\n```ts\nimport { ApolloServer } from \"@apollo/server\";\nimport depthLimit from \"graphql-depth-limit\";\nimport {\n  createComplexityRule,\n  simpleEstimator,\n  fieldExtensionsEstimator,\n} from \"graphql-query-complexity\";\n\nconst server = new ApolloServer({\n  typeDefs,\n  resolvers,\n  validationRules: [\n    depthLimit(7),\n    createComplexityRule({\n      maximumComplexity: 1000,\n      estimators: [\n        // Prefer explicit costs declared on the schema fields...\n        fieldExtensionsEstimator(),\n        // ...and fall back to a flat cost of 1 per field.\n        simpleEstimator({ defaultComplexity: 1 }),\n      ],\n      onComplete: (complexity) => {\n        console.log(`Query complexity: ${complexity}`);\n      },\n    }),\n  ],\n});\n```\n\nDie eigentliche Modellierung passiert an den Feldern selbst. Über `fieldExtensionsEstimator` lese ich Kosten aus den Schema-Extensions – und genau hier bilde ich Listen realistisch ab. Ein Feld, das eine Liste zurückgibt, kostet nicht konstant, sondern proportional zum angeforderten `first`-Wert:\n\n```ts\nimport { GraphQLObjectType, GraphQLList } from \"graphql\";\nimport type { ComplexityEstimatorArgs } from \"graphql-query-complexity\";\n\nconst UserType = new GraphQLObjectType({\n  name: \"User\",\n  fields: {\n    posts: {\n      type: new GraphQLList(PostType),\n      args: { first: { type: GraphQLInt } },\n      extensions: {\n        // Cost scales with the number of requested items.\n        complexity: ({ args, childComplexity }: ComplexityEstimatorArgs) => {\n          const requested = args.first ?? 20;\n          return childComplexity * requested;\n        },\n      },\n    },\n  },\n});\n```\n\nDas ist der Punkt, an dem die Cost-Analyse ihre Stärke ausspielt. Eine tiefe Query mit kleinen Listen kann günstiger sein als eine flache mit riesigen. Das Budget von 1000 ist dabei kein magischer Wert – ich leite ihn aus realen Query-Patterns ab, indem ich `onComplete` erst einmal nur loggen lasse und beobachte, wo legitime Clients landen, bevor ich den Riegel scharf schalte.\n\nEin Detail zur Apollo-Server-4-Integration, das gern Zeit kostet: Für die reine Ablehnung reicht die Validation Rule oben. Wer die Komplexität aber im Request-Lifecycle weiterverarbeiten will – etwa fürs Kosten-Accounting pro API-Key – nutzt ein eigenes Plugin und berechnet die Komplexität im `didResolveOperation`-Hook mit `getComplexity()`. Der fertige AS3-Plugin-Wrapper `createComplexityPlugin` passt nicht mehr direkt in AS4, deshalb ist der Plugin-Weg von Hand kurz erwähnenswert:\n\n```ts\nimport type { ApolloServerPlugin } from \"@apollo/server\";\nimport { getComplexity, simpleEstimator } from \"graphql-query-complexity\";\nimport { separateOperations } from \"graphql\";\n\nconst complexityPlugin: ApolloServerPlugin = {\n  async requestDidStart() {\n    return {\n      async didResolveOperation({ request, document, schema }) {\n        const complexity = getComplexity({\n          schema,\n          operationName: request.operationName,\n          query: request.operationName\n            ? separateOperations(document)[request.operationName]\n            : document,\n          variables: request.variables,\n          estimators: [simpleEstimator({ defaultComplexity: 1 })],\n        });\n\n        if (complexity > 1000) {\n          throw new Error(\n            `Query is too expensive: ${complexity}. Maximum allowed: 1000.`,\n          );\n        }\n      },\n    };\n  },\n};\n```\n\n## Schicht 3: Pagination-Obergrenzen\n\nDie Cost-Regel kann Listen nur dann korrekt bewerten, wenn ich Listen überhaupt begrenze. Ein `first`-Argument ohne serverseitiges Maximum ist eine offene Flanke: Ein einziger Aufruf mit `first: 100000` löst zehntausende Resolver aus. Deshalb klemme ich jeden Pagination-Parameter serverseitig ab, unabhängig davon, was der Client wünscht:\n\n```ts\nconst MAX_PAGE_SIZE = 100;\nconst DEFAULT_PAGE_SIZE = 20;\n\nconst resolvers = {\n  User: {\n    posts: (parent, args, context) => {\n      // Never trust the client-supplied page size.\n      const first = Math.min(args.first ?? DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE);\n      return context.dataSources.posts.byAuthor(parent.id, { first });\n    },\n  },\n};\n```\n\nWer sein Schema sauber nach dem Connections-Pattern baut, hat hier ohnehin einen natürlichen Ort für diese Grenze – wie das strukturell aussieht, habe ich in [GraphQL-Paginierung und Connections](https://www.mikebild.dev/de/blog/graphql-paginierung-und-connections/) beschrieben. Die Cap gehört in den Resolver oder eine gemeinsame Helper-Function, nicht in die Dokumentation als Bitte an den Client.\n\n## Schicht 4: Rate-Limiting\n\nDepth und Complexity begrenzen die Kosten einer einzelnen Query. Sie sagen nichts darüber, wie viele Queries pro Sekunde jemand feuert. Rate-Limiting ist kein Apollo-Kern-Feature, und meiner Erfahrung nach ist die zuverlässigste Stelle dafür die Transport-Ebene – ein Reverse-Proxy oder eine Middleware vor dem GraphQL-Handler:\n\n```ts\nimport express from \"express\";\nimport rateLimit from \"express-rate-limit\";\n\nconst app = express();\n\nconst limiter = rateLimit({\n  windowMs: 60_000, // one minute\n  max: 120, // limit each IP to 120 requests per window\n  standardHeaders: true,\n  legacyHeaders: false,\n});\n\napp.use(\"/graphql\", limiter);\n```\n\nFür feingranulare Limits pro Feld gibt es Directive-basierte Ansätze wie `graphql-rate-limit` mit einer `@rateLimit`-Directive im Schema. Das ist mächtig, aber auch mehr Betriebskomplexität. Den größten Teil trägt ein grobes Limit am Edge; feingranulare Directives lohnen erst dort, wo ein konkretes Feld nachweislich Ärger macht.\n\n## Persisted Queries und Allowlisting – mit Vorsicht\n\nAn dieser Stelle kommt regelmäßig der Vorschlag: „Lass uns Automatic Persisted Queries (APQ) einschalten, dann ist das Problem gelöst.“ Das ist ein verbreitetes Missverständnis, das ich klarstellen möchte.\n\nAPQ ist ein Performance-Feature. Der Client sendet statt der vollen Query nur einen SHA-256-Hash; kennt der Server den Hash nicht, antwortet er mit `PERSISTED_QUERY_NOT_FOUND`, der Client schickt die Query einmalig nach, und der Server cached sie. Das spart Bandbreite. Es ist **keine** Allowlist – APQ registriert jede beliebige eingehende Operation automatisch, auch die böswillige.\n\n```ts\nconst server = new ApolloServer({\n  typeDefs,\n  resolvers,\n  // Performance optimization, NOT a security control.\n  persistedQueries: { ttl: 900 },\n});\n```\n\nEchtes Safelisting ist etwas anderes: eine vorab registrierte Liste erlaubter Operationen, gegen die der Server prüft. Alles, was nicht auf der Liste steht, wird abgelehnt – egal wie tief oder teuer. In der Apollo-Welt läuft das über eine Persisted Query List in GraphOS zusammen mit dem Apollo Router, ein Enterprise-Feature, bei dem APQ dann bewusst deaktiviert ist. Für interne APIs mit einem bekannten Satz von Client-Operationen ist das die stärkste Verteidigung überhaupt, weil die Menge möglicher Queries endlich und geprüft wird. Für eine öffentliche API mit beliebigen Clients ist es dagegen keine Option – dort tragen Depth und Complexity die Last.\n\n## Introspection in Produktion\n\nDer letzte Baustein wird oft überschätzt. Das Abschalten der Introspection in Produktion (`introspection: false`) verhindert, dass ein Angreifer das Schema bequem abfragt. Das ist ein sinnvoller Default für nicht-öffentliche APIs – aber ich nenne es bewusst nicht Sicherheit, sondern Bequemlichkeitsentzug für Angreifer. Es ist Security by Obscurity: Wer das Schema kennt oder rät, kommt an jeder deaktivierten Introspection vorbei. Introspection abzuschalten ersetzt kein einziges der echten Limits oben. Es ist ein netter Zusatz, kein Fundament.\n\n```ts\nconst server = new ApolloServer({\n  typeDefs,\n  resolvers,\n  introspection: process.env.NODE_ENV !== \"production\",\n});\n```\n\nDazu gehört auch ein serverseitiger Request-Timeout, damit eine Query, die trotz aller Gates lange läuft, nicht unbegrenzt Ressourcen bindet. Das ist die Notbremse hinter allen anderen Schichten.\n\n## Die Schichten im Überblick\n\nFür einen produktiven GraphQL-Endpoint ziehe ich die Schichten von außen nach innen ein:\n\n- Rate-Limiting am Edge begrenzt, wie viele Requests pro Zeitfenster überhaupt durchkommen.\n- Persisted Queries oder eine Allowlist lassen bei geschlossenem Client-Kreis nur bekannte Operationen zu.\n- Ein Depth-Limit kappt die Schachtelungstiefe schon vor der Ausführung.\n- Das Complexity-Budget vergibt Kosten pro Feld und lehnt zu teure Queries ab.\n- Pagination-Caps verhindern unbegrenzte Listen und machen die Cost-Analyse überhaupt erst korrekt.\n- Ein Resolver-Timeout ist die Notbremse für alles, was trotzdem durchrutscht.\n\nKeine dieser Schichten ist für sich vollständig. Depth ohne Complexity übersieht breite Queries. Complexity ohne Pagination-Caps rechnet mit falschen Multiplikatoren. Rate-Limiting ohne Cost-Analyse lässt einzelne teure Queries durch. Erst im Zusammenspiel entsteht ein Endpoint, dessen schlimmster Fall ich kenne und beherrsche.\n\n## Fazit\n\nDie Flexibilität von GraphQL verlagert Kontrolle vom Server zum Client – und damit auch die Möglichkeit, den Server zu überlasten. Der häufigste Fehler in der Praxis ist die Annahme, Authentifizierung reiche als Schutz. Sie tut es nicht: Der Angriff über teure Queries ist orthogonal zur Frage, wer der Nutzer ist. Ein legitimer, angemeldeter Client kann durch einen simplen Bug dieselbe DoS-Query erzeugen wie ein Angreifer mit Absicht.\n\nDie Reihenfolge, die sich bewährt hat: Erst das Kostenprofil sichtbar machen (Complexity loggen), dann die Gates von grob nach fein einziehen – Depth-Limit und Pagination-Caps als schnelle Gewinne, Complexity-Budget als eigentliches Rückgrat, Rate-Limiting als Rahmen, Allowlist wo der Client-Kreis es zulässt. Introspection und Timeouts runden ab, tragen aber nicht. Wer diese Schichten hat, kann die Last pro Anfrage wesentlich besser begrenzen und Abweichungen zwischen geschätzten und realen Resolver-Kosten im Betrieb beobachten.\n\n## Weiterführende Quellen\n\n- [Apollo Server – Automatic Persisted Queries](https://www.apollographql.com/docs/apollo-server/performance/apq)\n- [Apollo GraphOS – Safelisting mit Persisted Queries](https://www.apollographql.com/docs/graphos/platform/security/persisted-queries)\n- [graphql-query-complexity (slicknode)](https://github.com/slicknode/graphql-query-complexity)\n- [graphql-depth-limit (npm)](https://www.npmjs.com/package/graphql-depth-limit)\n- [@graphile/depth-limit](https://github.com/graphile/depth-limit)\n- [Apollo Server – Konfiguration und Introspection](https://www.apollographql.com/docs/apollo-server/api/apollo-server)"},{"slug":"typescript-in-node","title":"TypeScript in Node.js: Setup, Typen und Tests","summary":"TypeScript in einem Node-Projekt aufsetzen: tsconfig-Essentials verstehen, async und Handler typisieren und die Tests laufen lassen, Schritt für Schritt aus der Projektpraxis.","tags":["Software","Node.js","Backend"],"published_at":"2024-03-19T10:00:00+00:00","updated_at":"2026-07-13T07:29:12.674203+00:00","url":"https://www.mikebild.dev/de/blog/typescript-in-node/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/b9bc6c3cf251b945ad193db0bed60685a9826cebf8895e808a36415d21bee5c6/typescript-in-node-vorlesen.mp3","audio_duration_secs":895,"text":"TypeScript ist keine Node-Laufzeit und keine Sprache, die Node ausführt. Es ist eine Prüfschicht davor: Sie greift beim Übersetzen und ist danach spurlos weg. Aus dieser einen Eigenschaft folgt alles Weitere – bis hin zu der Frage, die beim Aufsetzen eines Projekts eigentlich zur Debatte steht. Sie lautet nämlich nicht, welchen Schalter man in die `tsconfig.json` schreibt, sondern wo ein Fehler auftauchen soll: am Schreibtisch, während der Cursor noch in der Zeile steht, oder beim Nutzer, während die Anfrage schon läuft. TypeScript zieht diesen Fehlerort nach vorne. Der Preis ist ein Build-Schritt und eine Handvoll bewusster Konfigurationsentscheidungen; der Ertrag ist ein Feedback-Zyklus, in dem zwischen „ich baue Mist“ und „ich sehe den Mist“ Sekunden liegen statt Minuten.\n\n**Redaktionelle Einordnung (Juli 2026):** Der Haupttext beschreibt Node 20 und TypeScript 5.4 im März 2024. Aktuelle Node-Versionen können TypeScript mit löschbarer Syntax direkt ausführen. Node entfernt dabei Typen, führt aber keine Typprüfung durch und ignoriert `tsconfig.json`; für TypeScript-spezifische Laufzeitkonstrukte oder einen vollständigen Build bleibt ein externes Werkzeug nötig. Bei modernen Node-Projekten sind außerdem `node16` oder `nodenext` die passenden Auflösungsmodi. `node10` gehört zum historischen CommonJS-Setup dieses Artikels.\n\nDer Rest ist Handwerk, und genau das folgt hier: eine Konfiguration, in der jeder Eintrag eine Begründung hat, typisierte Funktionen und Handler und zuletzt ein Test-Setup, das `.ts`-Dateien ohne Umweg ausführt.\n\nIch setze voraus, dass Node 20 (das aktive LTS) installiert ist und ein `npm init` schon gelaufen ist. Alles andere bauen wir zusammen auf. Wer die Ebene darunter noch einmal auffrischen möchte, also Event Loop, Modulsystem und Laufzeitverhalten, findet das in meinem [Node.js-Training](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/); hier bleiben wir eine Etage höher, bei Typen und Werkzeugen.\n\n## Was TypeScript in Node überhaupt macht\n\nDer entscheidende Punkt gleich am Anfang: Node führt kein TypeScript aus. Node kennt nur JavaScript. TypeScript ist eine Schicht, die zur Compile-Zeit prüft und dann verschwindet. Die Typen werden beim Übersetzen gelöscht, man nennt das Type Erasure. Zur Laufzeit existiert kein einziger Typ mehr, keine `interface`, kein `: number`. Übrig bleibt gewöhnliches JavaScript.\n\nDaraus folgt ein Ablauf, der 2024 der Normalfall ist: Eine `.ts`-Datei geht durch den Compiler `tsc` und wird zu einer `.js`-Datei. Diese `.js`-Datei führt `node` aus. Für die tägliche Entwicklung gibt es eine Abkürzung, `ts-node` übersetzt und startet in einem Rutsch, ohne dass eine sichtbare `.js`-Datei entsteht. Aber ausgeliefert wird am Ende immer kompiliertes JavaScript.\n\n```mermaid\nflowchart LR\n  A[source.ts] -->|tsc| B[dist/source.js]\n  B -->|node| C[Laufzeit]\n  A -.->|ts-node<br/>Entwicklung| C\n```\n\nDer gestrichelte Zweig ist der Dev-Komfort, der durchgezogene das, was in Produktion läuft. Diese Trennung im Kopf erspart viel Verwirrung, etwa warum ein Typfehler den Build stoppt, im laufenden Programm aber nie auftaucht.\n\nEin Hinweis noch: Node bringt 2024 keine eingebaute Möglichkeit mit, `.ts`-Dateien direkt zu starten. An `tsc` oder `ts-node` führt kein Weg vorbei, das ist der Standard. Wer einen Blogpost von „einfach `node index.ts`“ liest, liest über eine Zukunft, die es jetzt noch nicht gibt.\n\n## Typen als ausführbare Verträge\n\nDer Perspektivwechsel, der mir in Projekten am meisten gebracht hat: Ein Typ ist keine Dokumentation, sondern ein Vertrag, den eine Maschine bei jedem Build durchsetzt. `function total(items: LineItem[]): number` hält zwei Zusagen fest, eine an jeden Aufrufer und eine an mich als Autor, und der Compiler verweigert den Build, sobald eine Seite ihren Teil verletzt. Der Hebel liegt dabei im Zeitpunkt: Entscheidend ist, wann Korrektheit eingefordert wird. Ohne solche Verträge rutscht die Prüfung nach hinten, bis ein falscher Feldname oder ein vergessenes `undefined` erst beim Nutzer über einen Fehlerreport auffällt. Verträge, die vorher greifen, melden den Bruch, während der Cursor noch in der Zeile steht, und drücken die Länge des Feedback-Zyklus auf Sekunden.\n\nKostenlos ist das nicht: ein Compile-Schritt, die Disziplin, Typen zu pflegen statt sie mit `any` wegzudrücken, und Momente, in denen der Compiler auf etwas besteht, das zur Laufzeit nie gebrochen wäre. Die Entscheidung fällt deshalb an einer klaren Kante. Je länger ein Stück Code lebt, je mehr Hände es anfassen und je teurer ein Produktionsfehler ist, desto klarer gewinnt der Vertrag. Ein Wegwerfskript braucht ihn nicht; ein Zahlungspfad oder ein öffentliches API holt jede dieser Sekunden zurück.\n\n## Das Fundament: die richtigen Pakete\n\nBevor wir eine Zeile Konfiguration schreiben, brauchen wir drei Dinge als `devDependency`:\n\n```bash\nnpm install --save-dev typescript @types/node ts-node\n```\n\n`typescript` liefert den Compiler. `ts-node` ist das Dev-Werkzeug für den direkten Start. Und `@types/node` ist das Paket, das ich in Projekt-Setups am häufigsten vergessen sehe, mit spürbaren Folgen. Es enthält die Typdeklarationen für die Node-Kern-APIs: `fs`, `http`, `events`, `process`, `Buffer` und den ganzen Rest. Ohne dieses Paket weiß der Compiler schlicht nicht, dass es `process` gibt, und meldet `Cannot find name 'process'`. Die Version folgt der Node-Hauptversion, für Node 20 also die 20er-Reihe.\n\nFaustregel: Jede JavaScript-Bibliothek ohne eigene Typen braucht ein passendes `@types/*`-Paket, angekündigt durch die Meldung `Could not find a declaration file for module 'x'`.\n\n## tsconfig.json: jeder Schalter mit Absicht\n\nJetzt das Herzstück. Ich begründe jeden Eintrag, statt die Datei per Generator zu erzeugen und dann zu ignorieren. Eine Baseline für ein klassisches CommonJS-Node-Projekt:\n\n```jsonc\n{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"CommonJS\",\n    \"moduleResolution\": \"Node10\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"outDir\": \"dist\",\n    \"rootDir\": \"src\",\n    \"skipLibCheck\": true,\n    \"resolveJsonModule\": true,\n    \"sourceMap\": true\n  },\n  \"include\": [\"src/**/*\"]\n}\n```\n\nDie wichtigsten Schalter und was sie tatsächlich bewirken:\n\n- `target` legt fest, auf welche JavaScript-Version übersetzt wird. Der Default ist konservativ (historisch `ES3`), was auf einem modernen Node unnötig alten Code erzeugt. `ES2022` passt zu Node 20 und lässt `async/await`, optionales Verketten und Klassenfelder unverändert durch, statt sie umständlich umzuschreiben.\n- `module` bestimmt das Modulformat der Ausgabe. `CommonJS` ist der klassische Node-Weg mit `require`. Wer echtes ESM will, setzt hier `NodeNext`, das hat aber Folgen.\n- `moduleResolution` steuert, wie der Compiler Importpfade auflöst. Der Wert hat keinen festen Default, sondern hängt an `module`. Zu `CommonJS` passt `Node10` (früher schlicht `\"node\"` geschrieben). Zu `module: NodeNext` gehört `moduleResolution: NodeNext`. Diese Paare müssen zusammenpassen, sonst gibt es Auflösungsfehler.\n- `strict` ist der Schalter, der TypeScript erst wertvoll macht. Er ist standardmäßig `false`, und ohne ihn ist TypeScript kaum mehr als JavaScript mit Doppelpunkten. `strict: true` aktiviert eine ganze Familie: `noImplicitAny`, `strictNullChecks`, `strictFunctionTypes` und weitere. Dazu unten mehr.\n- `esModuleInterop` regelt den Import zwischen CommonJS und ESM. Er ist ebenfalls standardmäßig `false` und wird, das übersehen viele, nicht durch `strict` mitaktiviert. Man muss ihn selbst setzen. Ohne ihn bricht `import express from 'express'`.\n- `outDir` bestimmt, wohin die kompilierten `.js`-Dateien geschrieben werden. Ohne Angabe landen sie neben den Quelldateien, was das Projekt vermüllt. Die Konvention ist `dist`. Gemeinsam mit `rootDir: \"src\"` ergibt sich eine saubere Trennung: Quellen liegen in `src`, das Ergebnis in `dist`.\n\n`skipLibCheck` überspringt die Typprüfung der Deklarationsdateien in `node_modules` und kann den Build beschleunigen. Gefahrlos ist das nicht in jedem Projekt: Widersprüchliche oder fehlerhafte Deklarationen können dadurch unbemerkt bleiben. `resolveJsonModule` erlaubt `import config from './config.json'`. `sourceMap` erzeugt Source-Maps, damit der Debugger auf die `.ts`-Zeilen zeigt und nicht auf das kompilierte JavaScript.\n\n## Warum `strict` den Unterschied macht\n\nAm deutlichsten wird das am Fehlerfall. Ohne `strict`, genauer: ohne `noImplicitAny`, geht Folgendes durch:\n\n```ts\nfunction total(items) {\n  return items.reduce((sum, item) => sum + item.price, 0);\n}\n```\n\nDer Parameter `items` hat hier den impliziten Typ `any`, und `item.price` ebenso. Der Compiler schweigt, und man hat exakt null Typsicherheit, ein Tippfehler wie `item.prise` fällt erst zur Laufzeit auf. Das ist der `any`-Wildwuchs: Ein untypisiertes `any` breitet sich aus, denn alles, was man daraus liest, ist wieder `any`.\n\nMit `strict: true` verlangt der Compiler eine Angabe:\n\n```ts\ninterface LineItem {\n  price: number;\n  quantity: number;\n}\n\nfunction total(items: LineItem[]): number {\n  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);\n}\n```\n\nJetzt ist `item.prise` ein Fehler zur Compile-Zeit, und die Rückgabe ist garantiert eine `number`. Wenn wirklich einmal ein Wert von außen kommt, dessen Form man nicht kennt, greift man zu `unknown` statt zu `any`, denn `unknown` zwingt dazu, den Typ vor der Benutzung zu prüfen, statt ihn stillschweigend zu unterlaufen.\n\n## Async richtig typisieren\n\nAsynchroner Code ist in Node allgegenwärtig, und TypeScript beschreibt das präzise. Eine `async`-Funktion gibt immer einen `Promise<T>` zurück, auch wenn im Rumpf ein nackter Wert steht, wird er in ein Promise verpackt. Umgekehrt entpackt `await` einen `Promise<T>` wieder zu `T`.\n\n```ts\ninterface User {\n  id: number;\n  name: string;\n  email: string;\n}\n\nasync function fetchUser(id: number): Promise<User> {\n  const response = await fetch(`https://api.example.com/users/${id}`);\n  if (!response.ok) {\n    throw new Error(`Request failed with status ${response.status}`);\n  }\n  return response.json() as Promise<User>;\n}\n```\n\nDer Rückgabetyp `Promise<User>` ist die Zusage an alle Aufrufer: Wer `await fetchUser(1)` schreibt, bekommt einen `User`, nichts anderes. Fällt die Annotation weg, würde sich an einer Stelle wie `response.json()`, die von Haus aus `Promise<any>` liefert, wieder ein `any` einschleichen. Ein `await` kann zudem eine Ausnahme auslösen; unter `strict` fängt der `catch`-Zweig sie als `unknown`, was erneut zur bewussten Typprüfung zwingt.\n\n## Einen HTTP-Handler und einen EventEmitter typisieren\n\nZwei Muster begegnen einem in Node ständig: der HTTP-Handler und der Event-basierte Ablauf. Beide lassen sich sauber typisieren, sobald `@types/node` installiert ist.\n\n```ts\nimport { createServer, IncomingMessage, ServerResponse } from \"node:http\";\n\nconst server = createServer((req: IncomingMessage, res: ServerResponse): void => {\n  if (req.method === \"GET\" && req.url === \"/health\") {\n    res.statusCode = 200;\n    res.setHeader(\"Content-Type\", \"application/json\");\n    res.end(JSON.stringify({ status: \"ok\" }));\n    return;\n  }\n  res.statusCode = 404;\n  res.end(\"Not Found\");\n});\n\nserver.listen(3000, () => {\n  console.log(\"Server listening on port 3000\");\n});\n```\n\n`IncomingMessage` und `ServerResponse` sind die Node-Typen für Anfrage und Antwort. Der Editor weiß dadurch, dass `req.method` ein `string | undefined` ist, und genau dieses `undefined` macht die Prüfung `req.method === \"GET\"` unter `strict` nötig, nicht nur zu gutem Stil.\n\nDer `EventEmitter` ist der zweite Klassiker. Er ist von Natur aus dynamisch, aber man kann die Event-Namen und ihre Nutzlast über ein eigenes Interface festhalten:\n\n```ts\nimport { EventEmitter } from \"node:events\";\n\ninterface OrderEventMap {\n  created: [orderId: string];\n  cancelled: [orderId: string, reason: string];\n}\n\nclass OrderBus extends EventEmitter {\n  on<K extends keyof OrderEventMap>(\n    event: K,\n    listener: (...args: OrderEventMap[K]) => void\n  ): this {\n    return super.on(event, listener as (...args: unknown[]) => void);\n  }\n\n  emit<K extends keyof OrderEventMap>(event: K, ...args: OrderEventMap[K]): boolean {\n    return super.emit(event, ...args);\n  }\n}\n\nconst bus = new OrderBus();\nbus.on(\"created\", (orderId) => {\n  console.log(`Order ${orderId} was created`);\n});\nbus.emit(\"created\", \"order-42\");\n```\n\nDer Gewinn: `bus.emit(\"created\")` ohne die `orderId` ist jetzt ein Compile-Fehler, und im Listener ist `orderId` automatisch ein `string`. Man hat den losen Ereignismechanismus in einen typisierten Vertrag verwandelt, ohne die Node-Mechanik zu verändern. Wer solche Muster auch im Frontend anwenden will, findet die React-Seite davon in [TypeScript in React](https://www.mikebild.dev/de/blog/typescript-in-react/).\n\n## Der Interop-Stolperstein: CommonJS und ESM\n\nDieser Stolperstein sorgt in der Praxis für die meisten roten Unterstreichungen, deshalb gehört die Warnung hierher. Viele npm-Pakete, etwa Express, sind CommonJS-Module mit einem einzigen `module.exports`. Der intuitive Import\n\n```ts\nimport express from \"express\";\n```\n\nfunktioniert nur, wenn `esModuleInterop: true` gesetzt ist. Ohne diesen Schalter besteht TypeScript auf der sperrigen Form `import * as express from \"express\"`, die dann oft zur Laufzeit an anderer Stelle bricht. Der Schalter fügt intern die passenden Interop-Helfer ein und erlaubt so den natürlichen Default-Import.\n\nDer zweite Teil des Stolpersteins betrifft echtes ESM. Wer in der `package.json` `\"type\": \"module\"` setzt und `module: \"NodeNext\"` konfiguriert, muss relative Importe mit Dateiendung schreiben:\n\n```ts\nimport { total } from \"./billing.js\";\n```\n\nDas irritiert jedes Mal: Man schreibt `.js`, obwohl die Quelldatei `billing.ts` heißt, weil der Import den Zustand nach dem Kompilieren beschreibt. Fehlt die Endung oder passen `module` und `moduleResolution` nicht zusammen, quittiert Node das mit `ERR_MODULE_NOT_FOUND`. Für den Einstieg empfehle ich deshalb die CommonJS-Baseline; den ESM-Weg nimmt man bewusst, wenn das Projekt ihn braucht.\n\n## Tests, die TypeScript direkt verstehen\n\nBleibt die Frage, wie man `.ts`-Tests ausführt, ohne bei jeder Änderung von Hand zu kompilieren. Mit Mocha gibt es zwei gangbare Wege.\n\nDer bequeme Weg für die Entwicklung nutzt `ts-node` als Register-Hook. Eine `.mocharc.json` im Projektwurzelverzeichnis genügt:\n\n```json\n{\n  \"require\": \"ts-node/register\",\n  \"extensions\": [\"ts\"],\n  \"spec\": [\"test/**/*.spec.ts\"]\n}\n```\n\nDamit versteht Mocha `.ts`-Dateien direkt, ohne separaten Build-Schritt. Ein Test dazu, hier mit dem eingebauten `node:assert`:\n\n```ts\nimport assert from \"node:assert\";\nimport { total } from \"../src/billing\";\n\ndescribe(\"total\", () => {\n  it(\"sums price times quantity\", () => {\n    const result = total([{ price: 10, quantity: 2 }]);\n    assert.strictEqual(result, 20);\n  });\n});\n```\n\nDer zweite Weg ist der kompilierte: erst `tsc`, dann die erzeugten `.js`-Tests laufen lassen.\n\n```bash\ntsc && mocha \"dist/**/*.spec.js\"\n```\n\nIch empfehle den `ts-node`-Weg für den schnellen Entwicklungszyklus und den kompilierten Weg für die Kette vor der Auslieferung, dort testet man exakt das JavaScript, das auch produktiv geht. Die passenden Skripte in der `package.json` machen das greifbar:\n\n```json\n{\n  \"scripts\": {\n    \"dev\": \"ts-node src/index.ts\",\n    \"build\": \"tsc\",\n    \"start\": \"node dist/index.js\",\n    \"test\": \"mocha\"\n  }\n}\n```\n\n`dev` startet direkt aus dem Quellcode, `build` erzeugt das `dist`-Verzeichnis, `start` fährt das kompilierte Ergebnis, `test` liest die `.mocharc.json`. Vier Zeilen, der ganze Lifecycle.\n\n## Der rote Faden\n\nDie Ordnung dahinter ist einfach: TypeScript prüft zur Compile-Zeit und verschwindet, in der `tsconfig.json` tragen `strict` und `esModuleInterop` am meisten, `@types/node` gibt den Node-APIs überhaupt Typen, und die Tests laufen über den Register-Hook oder den kompilierten Stand.\n\nDer Fehler, den ich am häufigsten sehe, ist nicht der komplizierte Generic Type, sondern das stille `any`, das sich ohne `strict` ausbreitet, bis von der Typsicherheit nichts mehr übrig ist. Wer die Baseline aus diesem Artikel übernimmt, also `strict: true`, `esModuleInterop: true`, `@types/node` und eine saubere Trennung von `outDir` und `rootDir`, verschiebt damit den Ort, an dem Fehler auftauchen: an den Schreibtisch statt zum Nutzer. Eine Formalie ist das nicht, es ist die eigentliche Wirkung – kürzere Feedback-Zyklen, weniger Reibung im Review und höhere Qualität in dem Code, der wirklich ausgeliefert wird. Die Grenze gehört dazu, denn der Vertrag lohnt sich dort, wo Code lange bleibt und durch viele Hände geht, im Wegwerfskript dagegen nicht. Wer sie kennt, holt aus TypeScript in Node genau das heraus, was es verspricht, Fehler früh sichtbar zu machen, statt sie in die Laufzeit durchzureichen.\n\n## Weiterführende Quellen\n\n- TSConfig-Referenz: https://www.typescriptlang.org/tsconfig/\n- TypeScript Handbook, Compiler-Optionen wählen: https://www.typescriptlang.org/docs/handbook/modules/guides/choosing-compiler-options.html\n- Announcing TypeScript 5.4: https://devblogs.microsoft.com/typescript/announcing-typescript-5-4/\n- ts-node (npm): https://www.npmjs.com/package/ts-node\n- ts-node Mocha-Rezept: https://typestrong.org/ts-node/docs/recipes/mocha/\n- @types/node (npm): https://www.npmjs.com/package/@types/node\n- Mocha: https://mochajs.org/\n- [Node.js: Modules – TypeScript](https://nodejs.org/dist/latest/docs/api/typescript.html)\n- [TypeScript: `moduleResolution`](https://www.typescriptlang.org/tsconfig/moduleResolution.html)"},{"slug":"typescript-in-react","title":"TypeScript in React: Props, Hooks und Events typisieren","summary":"Wie aus Props und State überprüfbare Verträge werden – und der Editor Fehler meldet, bevor der Nutzer sie sieht. Von interface/type über useState-Generics und Event-Typen bis zu useRef und Utility-Types.","tags":["Software","Frontend","React"],"published_at":"2024-02-27T10:00:00+00:00","updated_at":"2026-07-13T08:50:39.319923+00:00","url":"https://www.mikebild.dev/de/blog/typescript-in-react/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/7852a1156d1b6eb59a0524fe55646f7387c77e9bf1af634d008377677f895db1/typescript-in-react-vorlesen.mp3","audio_duration_secs":883,"text":"Ein Prop an einer Button-Komponente wird umbenannt, der Build läuft durch, die Seite rendert. Drei Tage später kommt aus einem anderen Team ein Ticket: leerer Knopf im Checkout-Dialog. Fünf Aufrufstellen benutzen diese Komponente, keine davon hat gemeckert – JavaScript hatte keinen Grund dazu. Wer nach so einem Ticket TypeScript vorschlägt, hört als Erstes: „Bringt das nicht nur mehr Tipparbeit?“\n\nDahinter steht zuerst eine Entscheidung, keine Frage der Syntax. Die Entscheidung lautet: Wo will ich meine Integrationsfehler bezahlen? Ohne Typen bezahle ich sie spät, im Browser, beim Nutzer, oft in einem anderen Team als dem, das den Fehler eingebaut hat. Mit Typen bezahle ich sie früh, im Editor, mit einer roten Wellenlinie, bevor irgendetwas gebaut wird. Der Preis dafür ist ehrlich benennbar: ein paar Zeilen Typdeklaration und die Disziplin, sie aktuell zu halten. Die Wirkung ist es meistens wert, denn ein Fehler, den der Compiler abfängt, wird nie zum Bugreport, nie zum Rollback, nie zur Support-Anfrage.\n\nTypeScript in React ist also kein Selbstzweck und keine Bürokratie. Aus einer Props-Signatur wird ein Versprechen, das der Compiler bei jedem Speichern nachprüft: „Diese Komponente erwartet einen `label`-String und optional ein `disabled`-Flag.“ Wer sich nicht daran hält, bekommt die Rückmeldung sofort und an der richtigen Stelle. Im Alltag spielt sich das an fünf Stellen einer Komponente ab: bei den Props, beim State, bei den Event-Handlern, bei den Refs und bei den Typen, die man aus bestehenden Datenstrukturen ableitet. Mehr braucht es selten, und der Rest dieses Artikels geht die fünf der Reihe nach durch.\n\n## Der Kern: Fehler wandern nach vorn\n\nBevor wir Code schreiben, lohnt es sich, den Unterschied im Ablauf klarzuziehen. Ohne Typen baut die App durch, egal was ich in ein Prop stecke – der Fehler passiert erst zur Laufzeit, wenn jemand die Seite benutzt. Mit Typen prüft der Compiler die JSX-Nutzung gegen das Interface, und der Fehler entsteht, bevor irgendetwas gebaut wird.\n\n```mermaid\nflowchart TD\n    A[Falscher Prop im JSX] --> B{TypeScript aktiv?}\n    B -->|Nein| C[App baut durch]\n    C --> D[Laufzeitfehler<br/>beim Nutzer im Browser]\n    B -->|Ja| E[tsc / Editor prüft<br/>gegen das Interface]\n    E --> F[Compile-Fehler<br/>rot im Editor]\n    F --> G[Fix vor dem Commit]\n```\n\nDer linke Pfad kostet dich einen Bugreport, im schlimmsten Fall einen verärgerten Kunden. Der rechte Pfad kostet dich zehn Sekunden. Diese Verschiebung nach vorn ist der eigentliche Gewinn – nicht die Autovervollständigung, so schön die auch ist.\n\n## Die UI-Grenze ist die teuerste Grenze\n\nBevor es in die Details geht, lohnt es sich, den Ort zu benennen, an dem sich der Aufwand am stärksten rechnet. Jede Komponente hat eine Grenze: Props fließen hinein, Events fließen hinaus. Genau diese Grenze ist die Nahtstelle, an der zwei Stücke Code zusammenkommen – oft zwei Autoren, manchmal zwei Teams. Ein Formular, das ein `onSubmit` erwartet, eine Tabelle, die eine Zeile über einen Callback nach oben meldet, ein Design-System-Button, den fünf Feature-Teams konsumieren: Das sind keine internen Implementierungsdetails, das sind Schnittstellen. Und Schnittstellen sind die Stellen, an denen die teuren Fehler entstehen, weil der Fehler auf der einen Seite passiert und auf der anderen auffällt.\n\nEin Typ an dieser Grenze ist ein ausführbarer Vertrag. „Ausführbar“ heißt: Er ist kein Kommentar, den jemand pflegen müsste, und keine Zeile in einer Wiki-Seite, die niemand liest. Der Compiler führt ihn bei jedem Speichern aus. Wenn ich die Props-Signatur eines Buttons ändere, meldet mir das Werkzeug an Ort und Stelle jede der fünf Aufrufstellen, die nicht mehr passt – bevor irgendwer die App startet. Der Vertrag ist damit gleichzeitig die Dokumentation, der Test und die Refactoring-Absicherung dieser Grenze, in genau einem Artefakt.\n\nDer Trade-off ist real und gehört ausgesprochen. Typen kosten Schreibarbeit, sie müssen aktuell gehalten werden, und an einer schlecht gewählten Stelle erzeugen sie Reibung statt Sicherheit. Die Entscheidung, wo man sie setzt, ist deshalb eine Kosten-Nutzen-Frage. Je mehr Konsumenten eine Komponente hat, je länger sie lebt und je mehr Menschen an ihr vorbeikommen, desto klarer zahlt sich der Vertrag aus – ein geteilter UI-Baustein verdient präzise Props und Event-Typen. Ein Wegwerf-Prototyp oder eine rein interne Hilfskomponente mit einem einzigen Aufrufer verdient ihn kaum. Die Faustregel: Typisiere die Grenzen, an denen sich Verantwortung trifft, und lass die Inferenz den Rest erledigen. Nicht jede lokale Variable braucht einen Vertrag, aber jede Props- und Event-Signatur, die ein anderer aufruft, wird zu einem.\n\n## Props als Vertrag: interface oder type\n\nDer Einstieg ist unspektakulär und genau deshalb so tragfähig. Eine Komponente ist eine Funktion, ihre Props sind ein Objekt, und dieses Objekt bekommt einen Typ.\n\n```tsx\ninterface ButtonProps {\n  label: string;\n  disabled?: boolean;\n}\n\nfunction Button({ label, disabled }: ButtonProps) {\n  return <button disabled={disabled}>{label}</button>;\n}\n```\n\nDas `?` hinter `disabled` macht den Prop optional. Vergisst jemand `label`, meldet der Compiler einen fehlenden Required-Prop – kein `undefined`, das später als leerer Button endet, sondern ein Fehler an Ort und Stelle.\n\nIn Code-Reviews kommt an dieser Stelle verlässlich die Frage: `interface` oder `type`? Beides funktioniert für Props, und die pragmatische Faustregel aus dem React-TypeScript-Cheatsheet lautet: „interface until you need type.“ Anders gesagt:\n\n- `interface` unterstützt Declaration Merging, was für öffentliche Bibliotheks-APIs praktisch ist, und liest sich bei einfachen Objektformen etwas natürlicher.\n- `type` ist die richtige Wahl, sobald du Union-Typen, Mapped Types oder Kompositionen aus mehreren Typen brauchst – Dinge, die ein `interface` nicht ausdrücken kann.\n\nFür 90 Prozent der Komponenten-Props ist die Wahl reine Geschmackssache. Wichtig ist Konsistenz im Team, nicht die Grundsatzdebatte.\n\n## children ist kein Automatismus mehr\n\nSobald eine Komponente andere Elemente umschließt, braucht sie `children`. Und hier lohnt ein genauer Blick, weil sich mit `@types/react` in Version 18 etwas Grundlegendes geändert hat: Die implizite `children`-Deklaration wurde entfernt. Früher brachte `React.FC` ein automatisches `children` mit. Heute musst du es selbst deklarieren, wenn deine Komponente Kinder rendert.\n\n```tsx\ninterface CardProps {\n  title: string;\n  children?: React.ReactNode;\n}\n\nfunction Card({ title, children }: CardProps) {\n  return (\n    <section className=\"card\">\n      <h2>{title}</h2>\n      <div className=\"card-body\">{children}</div>\n    </section>\n  );\n}\n```\n\n`React.ReactNode` ist der richtige Typ für „alles, was man rendern kann“: Strings, Zahlen, Elemente, Arrays davon, `null`, `boolean`. Wenn du dagegen exakt ein einzelnes Element erzwingen willst, greifst du zu `React.ReactElement`. In den Types 18 liegt der JSX-Namespace übrigens unter `React.JSX`, ein einzelnes Element ist also auch als `React.JSX.Element` beschreibbar. Für den Alltag reicht `ReactNode` fast immer – es ist bewusst großzügig.\n\nDass `children` nicht mehr magisch erscheint, ist einer der Gründe, warum `React.FC` an Bedeutung verloren hat. Dazu gleich mehr.\n\n## State typisieren: wann Inferenz reicht und wann nicht\n\n`useState` ist ein schöner Fall, an dem man sieht, dass TypeScript nicht überall Handarbeit verlangt. Meistens leitet der Compiler den Typ aus dem Startwert ab:\n\n```tsx\nconst [count, setCount] = useState(0);        // number\nconst [name, setName] = useState('');          // string\nconst [open, setOpen] = useState(false);       // boolean\n```\n\nHier ein Generic anzugeben wäre nur Ballast. Die Inferenz ist eindeutig, und `setCount('drei')` scheitert trotzdem am Compiler. Interessant wird es, wenn der Startwert dem Compiler zu wenig verrät. Diesen Fallstrick habe ich in bestehendem Code schon oft repariert:\n\n```tsx\n// Wrong: type is inferred as `null`\nconst [user, setUser] = useState(null);\n// later setUser({ id: 1, name: 'Mike' }) -> compile error\n\n// Correct: specify the generic explicitly\nconst [user, setUser] = useState<User | null>(null);\n```\n\nStartest du mit `null`, inferiert TypeScript den Typ `null` – und dann darfst du nie wieder etwas anderes hineinschreiben. Dasselbe passiert bei `useState([])`, das zu `never[]` wird, oder bei einem leeren Objekt. Die Regel, die ich mir dafür angewöhnt habe: Wenn der Startwert leer, `null` oder ein leeres Array ist, gib den Typ mit dem Generic vor.\n\n```tsx\nconst [items, setItems] = useState<string[]>([]);\nconst [selected, setSelected] = useState<User | null>(null);\n```\n\n## Events typisieren: Schluss mit `any`\n\nDer nächste Klassiker sind Event-Handler. In JavaScript schreibt man `onChange={(e) => setValue(e.target.value)}` und denkt nicht weiter nach. In TypeScript ist `e` ohne Typ ein `any` – und `any` ist das Loch, durch das genau die Fehler zurückkommen, die wir eigentlich loswerden wollten. React bringt für die synthetischen Events präzise Typen mit. Man muss nur den passenden nehmen.\n\n```tsx\nfunction LoginForm() {\n  const [email, setEmail] = useState('');\n\n  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    setEmail(e.target.value);\n  };\n\n  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    console.log('submitting', email);\n  };\n\n  return (\n    <form onSubmit={handleSubmit}>\n      <input type=\"email\" value={email} onChange={handleChange} />\n      <button type=\"submit\">Sign in</button>\n    </form>\n  );\n}\n```\n\nZwei Dinge sind hier wichtig. Erstens muss das Generic zum Element passen: Ein `<input>` liefert `HTMLInputElement`, nur damit ist `e.target.value` überhaupt bekannt. Wer versehentlich `HTMLElement` schreibt, verliert `value`. Zweitens gibt es für `onChange`, `onSubmit`, `onClick` jeweils die passenden Typen:\n\n- `React.ChangeEvent<HTMLInputElement>` für Eingaben, analog `HTMLSelectElement` und `HTMLTextAreaElement`.\n- `React.FormEvent<HTMLFormElement>` für das Absenden eines Formulars.\n- `React.MouseEvent<HTMLButtonElement>` für Klicks.\n\nWenn du den Handler inline direkt am JSX-Attribut definierst, kannst du dir den Typ oft sparen, weil React ihn aus dem Kontext ableitet. Sobald du den Handler aber als eigenständige Funktion herausziehst, musst du den Event-Typ selbst angeben. Für Props, die einen Handler entgegennehmen, gibt es zusätzlich die Kurzformen `React.ChangeEventHandler<HTMLInputElement>` und `React.FormEventHandler<HTMLFormElement>` – praktisch, wenn du einen Callback als Prop durchreichst.\n\n## useRef: DOM-Referenz mit Null-Check\n\n`useRef` verwirrt am Anfang, weil es zwei Rollen spielt. Die häufigste ist die DOM-Referenz, um etwa ein Feld beim Mounten zu fokussieren.\n\n```tsx\nfunction SearchBox() {\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  useEffect(() => {\n    inputRef.current?.focus();\n  }, []);\n\n  return <input ref={inputRef} type=\"search\" />;\n}\n```\n\nDrei Details zählen. Das Generic `<HTMLInputElement>` sagt, worauf die Ref zeigt. Der Startwert `null` ist Absicht: Vor dem ersten Render existiert das DOM-Element noch nicht. Diese Kombination ergibt eine read-only `RefObject`, und genau die passt an das JSX-Attribut `ref`. Weil `current` vor dem Mount `null` sein kann, brauchst du beim Zugriff einen Null-Check – deshalb das `?.` in `inputRef.current?.focus()`. Ohne den Guard meckert der Compiler zu Recht.\n\nDie zweite Rolle von `useRef` ist die einer veränderlichen Instanzvariable, die kein Re-Render auslöst – etwa um eine Timer-ID oder einen vorherigen Wert festzuhalten:\n\n```tsx\nconst timerRef = useRef<number | null>(null);\n\nconst start = () => {\n  timerRef.current = window.setTimeout(() => {\n    console.log('done');\n  }, 1000);\n};\n```\n\nHier ist `current` beschreibbar. Der Unterschied entsteht aus dem Overload: Mit `null` im Union-Typ und ohne DOM-Bezug bekommst du ein `MutableRefObject`. Man muss diese Feinheit nicht auswendig können, aber es hilft zu wissen, dass der DOM-Fall bewusst read-only ist.\n\n## Utility-Types: Props ableiten statt kopieren\n\nWenn Datenstrukturen wachsen, will man Props nicht dreimal von Hand nachziehen. Dafür gibt es die Utility-Types aus der TypeScript-Standardbibliothek – nicht aus React, aber im React-Alltag ständig nützlich. Angenommen, es gibt einen `User`:\n\n```ts\ninterface User {\n  id: string;\n  name: string;\n  email: string;\n  role: 'admin' | 'member';\n}\n```\n\nEin Formular, das nur Name und E-Mail bearbeitet, braucht nicht den ganzen `User`. `Pick` schneidet die relevanten Felder heraus:\n\n```ts\ntype EditUserProps = Pick<User, 'name' | 'email'>;\n```\n\nUmgekehrt entfernt `Omit`, was du nicht willst – etwa die serverseitig vergebene `id`:\n\n```ts\ntype NewUserProps = Omit<User, 'id'>;\n```\n\n`Partial` macht alle Felder optional, was für Patch-Operationen oder Formular-Zwischenstände passt:\n\n```ts\ntype UserDraft = Partial<User>;\n```\n\nDer Gewinn: Ändert sich `User`, ziehen die abgeleiteten Typen automatisch nach. Kein Feld gerät aus dem Tritt. Neben `Pick`, `Omit` und `Partial` gehören `Required<T>` und `Readonly<T>` zum Standardrepertoire.\n\nEin React-spezifischer Trick ergänzt das Bild: Wenn deine Komponente die nativen Props eines HTML-Elements erben soll – alle Standard-Button-Attribute etwa –, nimmst du `React.ComponentPropsWithoutRef`:\n\n```tsx\ntype IconButtonProps = React.ComponentPropsWithoutRef<'button'> & {\n  icon: string;\n};\n\nfunction IconButton({ icon, ...rest }: IconButtonProps) {\n  return (\n    <button {...rest}>\n      <span className=\"icon\">{icon}</span>\n    </button>\n  );\n}\n```\n\nSo bekommst du `onClick`, `disabled`, `type` und alle anderen Button-Attribute geschenkt und typgeprüft, ohne sie einzeln aufzuzählen.\n\n## Die Alltags-Typen im Überblick\n\nDiese Übersicht zeigt die fünf Ecken, an denen man in einer typisierten React-Komponente am häufigsten mit Typen zu tun hat.\n\n```mermaid\ngraph TD\n    K[Typen im React-Alltag] --> P[Props<br/>interface / type]\n    K --> S[State<br/>useState&lt;T&gt;]\n    K --> E[Events<br/>ChangeEvent / FormEvent]\n    K --> R[Refs<br/>useRef&lt;HTMLInputElement&gt;]\n    K --> U[Ableitung<br/>Pick / Omit / Partial]\n    P --> C[children:<br/>ReactNode]\n```\n\nDiese fünf decken den größten Teil der täglichen Arbeit ab. Alles andere baut darauf auf.\n\n## Und was ist mit React.FC?\n\nDie Frage kommt garantiert, oft mit einem Codebeispiel aus einem älteren Tutorial: `const Button: React.FC<ButtonProps> = (props) => ...`. Ist das falsch? Nein. Es funktioniert, es ist 2024 verfügbar, und niemand muss bestehenden Code umschreiben. Aber der moderne Default ist die schlichte Funktion mit typisierten Props, und dafür gibt es Gründe.\n\nDer historisch wichtigste war das implizite `children`, das `React.FC` mitbrachte – Komponenten akzeptierten Kinder, auch wenn sie gar keine rendern sollten. Mit `@types/react` 18 wurde dieses implizite `children` entfernt, was einen der Hauptkritikpunkte entschärft hat. Übrig bleibt vor allem, dass `React.FC` bei generischen Komponenten im Weg steht. Wer eine typsichere Listen-Komponente über einen beliebigen Elementtyp schreiben will, fährt mit einer normalen Funktion besser:\n\n```tsx\ninterface ListProps<T> {\n  items: T[];\n  render: (item: T) => React.ReactNode;\n}\n\nfunction List<T>({ items, render }: ListProps<T>) {\n  return <ul>{items.map((item, i) => <li key={i}>{render(item)}</li>)}</ul>;\n}\n```\n\nSo etwas lässt sich mit `React.FC` nur umständlich ausdrücken. Damit bleibt `React.FC` heute eine Stil-Frage, kein Fehler. Wählt eine Variante im Team und bleibt dabei. Ich selbst schreibe normale Funktionen, weil sie bei Generics flexibler sind und weniger Sonderregeln mitbringen.\n\n## Der Bogen zurück zu Hooks\n\nWer schon eigene Hooks schreibt, merkt schnell, dass sich die hier gezeigten Bausteine dort direkt fortsetzen: Ein Custom Hook gibt typisierten State zurück, nimmt typisierte Argumente entgegen und kapselt Event-Logik. Wenn du den Weg von wiederverwendbarer Logik noch nicht gegangen bist, lohnt der Blick auf [Von HOCs und Render Props zu Custom Hooks](https://www.mikebild.dev/de/blog/von-hocs-und-render-props-zu-custom-hooks/) – die Typisierung aus diesem Artikel ist genau das, was einen Custom Hook von einer losen Sammlung von Funktionen zu einem klaren Vertrag macht.\n\n## Fazit\n\nDer Gewinn von TypeScript in React lässt sich in vier Wirkungen fassen, und keine davon steht in der API-Dokumentation. Kürzere Zyklen, weil ein Integrationsfehler in Sekunden im Editor auffällt statt in Tagen über einen Bugreport. Weniger Reibung an der teuersten Stelle, der Grenze zwischen Komponenten und Teams, weil der Vertrag automatisch prüft, was sonst mündlich abgesprochen und dann vergessen wird. Höhere Qualität, weil ein falscher Prop-Typ, ein vergessenes Pflichtfeld oder ein `null`, das nie hätte `null` bleiben dürfen, gar nicht erst in den Build gelangt. Und klare Grenzen, weil eine typisierte Props- und Event-Signatur unmissverständlich sagt, was hinein- und was herauskommt.\n\nDer Einstieg ist überschaubar. Props als `interface` oder `type`, `children` als `ReactNode` explizit deklarieren, `useState` mit Generic nur dort, wo die Inferenz zu eng wird, die passenden Event-Typen statt `any`, `useRef` mit Null-Check und den Utility-Types Props ableiten statt kopieren. Mit diesen sechs Handgriffen deckt man den Alltag ab. Alles Weitere – generische Komponenten, komplexe Unions, eigene Hooks – ist eine Fortsetzung derselben Idee: Typisiere die Grenzen, an denen sich Verantwortung trifft, sag klar, was du erwartest, und lass den Compiler dafür sorgen, dass sich alle daran halten.\n\n## Weiterführende Quellen\n\n- React TypeScript Cheatsheet: [react-typescript-cheatsheet.netlify.app](https://react-typescript-cheatsheet.netlify.app/) und das Repository [github.com/typescript-cheatsheets/react](https://github.com/typescript-cheatsheets/react)\n- react.dev – „Using TypeScript“: [react.dev/learn/typescript](https://react.dev/learn/typescript)\n- react.dev – Hooks-Referenz: [useState](https://react.dev/reference/react/useState) und [useRef](https://react.dev/reference/react/useRef)\n- TypeScript Handbook – Utility Types: [typescriptlang.org/docs/handbook/utility-types.html](https://www.typescriptlang.org/docs/handbook/utility-types.html)\n- DefinitelyTyped – `@types/react`: [github.com/DefinitelyTyped/DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react)"},{"slug":"resilience-muster-timeout-retry-circuit-breaker","title":"Resilience-Pattern: Timeout, Retry, Circuit Breaker, Idempotenz","summary":"Warum Resilienz kein Zufall ist: Timeout, Retry mit Backoff und Jitter, Circuit Breaker und Idempotenz als bewusst entworfene Muster gegen die Ausfallmodi verteilter Systeme.","tags":["Software","Architektur"],"published_at":"2024-01-30T10:00:00+00:00","updated_at":"2026-07-19T18:46:18.505233+00:00","url":"https://www.mikebild.dev/de/blog/resilience-muster-timeout-retry-circuit-breaker/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/dc9e51d19111a37e00307bb1e95cca290229c79cd25282489c8606323c1f02c7/resilience-muster-timeout-retry-circuit-breaker-vorlesen.mp3","audio_duration_secs":905,"text":"Ein verteiltes System fällt selten wegen des spektakulären Crashs um. Es fällt um, weil ein Service noch antwortet – nur ein bisschen langsamer als sonst. Dazu ein Aufruf, dem nie jemand eine Zeitgrenze verpasst hat. Ein Client, der brav weiter Anfragen nachschiebt, während die Dependency längst am Boden liegt. So reißt ein einzelner wackliger Baustein das halbe System mit, ohne dass irgendwo ein Prozess abstürzt.\n\nVerteilte Aufrufe scheitern. Über einen längeren Zeitraum betrachtet gehört das zum Normalbetrieb. Das Netzwerk ist mal langsam, ein Knoten wird neu gestartet, eine Datenbank läuft an ihr Limit. Die Frage ist nie, ob ein Remote-Call fehlschlägt, sondern was passiert, wenn er es tut. Und genau hier entscheidet sich, ob ein System resilient ist oder nur so lange stabil aussieht, bis es das erste Mal ernst wird.\n\nResilienz wird entworfen, nicht gehofft. Vier Muster prüfe ich bei verteilten Aufrufen: Timeout, Retry, Circuit Breaker und Idempotenz. Nicht jeder Call braucht alle vier; die Wahl hängt von Fehlerbild, Kosten und Semantik der Operation ab. Der TypeScript-Code weiter unten ist illustrativ – es geht um die Mechanik dahinter, nicht um eine produktionsfertige Bibliothek.\n\nDie Muster unterscheiden sich vor allem durch den Fehlerzustand, auf den sie reagieren. Ihre Namen allein sagen noch nicht, ob sie an einer Grenze sinnvoll sind.\n\n::::comparison{title=\"Resilience-Pattern nach Fehlerfrage\" role=\"primary\" preferredPattern=\"comparison-matrix\"}\n:::side{label=\"Timeout\"}\n- Frage · Wie lange darf ein einzelner Versuch dauern?\n- Wirkung · begrenzt blockierte Ressourcen\n:::\n:::side{label=\"Retry\"}\n- Frage · Ist der Fehler vorübergehend und der Versuch sicher wiederholbar?\n- Wirkung · wiederholt mit begrenztem Backoff und Jitter\n:::\n:::side{label=\"Circuit Breaker\"}\n- Frage · Soll ein erkennbar gestörtes Ziel weiter belastet werden?\n- Wirkung · unterbricht Aufrufe und prüft später kontrolliert erneut\n:::\n:::side{label=\"Bulkhead\"}\n- Frage · Wie bleibt ein Ausfall auf einen Teil des Systems begrenzt?\n- Wirkung · trennt Ressourcen und Konkurrenzbereiche\n:::\n:::side{label=\"Dead Letter Queue\"}\n- Frage · Was geschieht mit dauerhaft nicht verarbeitbaren Messages?\n- Wirkung · bewahrt Fehlerfälle für Prüfung und gezielte Wiederaufnahme\n:::\n::::\n\nErst die Fehlerfrage macht sichtbar, welche Muster sich ergänzen und welche nur zusätzlichen Verkehr oder versteckte Queues erzeugen würden.\n\n## Die Muster im Überblick\n\nBevor ich in den Code gehe, ein kurzer Überblick. Vier Muster, jedes mit einer klaren Zuständigkeit:\n\n- Ein Timeout begrenzt, wie lange ein einzelner Aufruf warten darf. Ohne Timeout wartet ein Call im Zweifel unendlich – und blockiert dabei einen Thread, eine Connection, einen Slot im Pool.\n- Ein Retry wiederholt einen fehlgeschlagenen Aufruf – aber nur bei transienten Fehlern, nur mit Exponential Backoff und Jitter, und nur mit begrenzter Versuchszahl.\n- Ein Circuit Breaker stoppt weitere Aufrufe an eine erkennbar ausgefallene Dependency, damit man sie nicht endlos weiter hämmert.\n- Idempotenz macht Retries überhaupt erst sicher, indem ein mehrfach gesendeter Aufruf denselben Effekt hat wie ein einziger.\n\nDiese vier können ineinandergreifen, sind aber kein untrennbares Paket. Ein Timeout darf bewusst ohne Retry schnell scheitern. Ein Retry auf eine nicht idempotente Operation kann Doppeleffekte produzieren. Ein Circuit Breaker braucht ein verwertbares Fehlersignal, das häufig aus einem Timeout stammt. Zusammengesetzt werden nur die Muster, die zum jeweiligen Aufruf passen.\n\n## Timeout: nie unendlich warten\n\nDer teuerste Timeout ist der, den es nicht gibt. Ein Remote-Call ohne Zeitgrenze ist nicht langsam, er ist potenziell unendlich. Und Unendlichkeit ist ein Ausfallmodus: Threads stauen sich, Connection-Pools laufen leer, und ein Service, der eigentlich nur auf einen langsamen Downstream wartet, wird selbst unerreichbar.\n\nWichtig ist die Unterscheidung zwischen Connection-Timeout (wie lange darf der Verbindungsaufbau dauern) und Request-Timeout (wie lange darf die gesamte Antwort dauern). Beide gehören gesetzt. Und beide gehören zum jeweiligen Aufruf, nicht global auf einen Wert genagelt – ein Health-Check hat eine andere Toleranz als ein Report-Export.\n\nIn der Praxis kapsle ich das in einen kleinen Wrapper. `Promise.race` gegen einen Timer, plus ein `AbortController`, damit die unterliegende Operation auch wirklich abgebrochen wird und nicht im Hintergrund weiterläuft:\n\n```ts\nexport async function withTimeout<T>(\n  fn: (signal: AbortSignal) => Promise<T>,\n  timeoutMs: number,\n): Promise<T> {\n  const controller = new AbortController();\n  const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n  try {\n    return await fn(controller.signal);\n  } finally {\n    clearTimeout(timer);\n  }\n}\n\n// usage: the signal is forwarded to fetch so the socket is released\nconst result = await withTimeout(\n  (signal) => fetch(\"https://payments.internal/charge\", { signal }),\n  2_000,\n);\n```\n\nDer `AbortSignal` ist der Teil, den man leicht vergisst. Ein `Promise.race`, das nur das Warten abbricht, aber die HTTP-Verbindung offen lässt, hat das Problem nur verschoben: Der Socket bleibt belegt. Erst das durchgereichte Signal gibt die Ressource frei.\n\nEine subtile Wechselwirkung, auf die Marc Brooker in der AWS Builders' Library hinweist: Ein zu niedrig gewählter Timeout kann selbst zum Auslöser für Retry-Storms werden. Wenn die reguläre Latenz gelegentlich über der Timeout-Grenze liegt, interpretiert der Client gesunde Antworten als Fehler und wiederholt – und erzeugt damit genau die Last, die den Server dann wirklich umkippt. Timeouts setzt man an der realen Latenzverteilung aus, nicht am Wunschdenken.\n\n## Retry: Backoff und Jitter, aber mit Verstand\n\nRetry ist das Muster, das am schnellsten nach hinten losgeht. Die naive Variante – \"bei Fehler sofort nochmal, und nochmal, und nochmal\" – hat mit Resilienz nichts zu tun. Sie ist eine Lastwaffe gegen den eigenen Downstream. Ein Server, der gerade unter Druck steht, bekommt durch synchrone Wiederholungen aller Clients erst recht den Rest.\n\nDrei Zutaten machen Retry brauchbar. **Exponential Backoff**: Die Wartezeit wächst mit jedem Versuch (`base * 2^attempt`), damit ein überlasteter Service Luft bekommt. Ein **Cap**: Die Wartezeit läuft nicht ins Unendliche, sondern gegen eine Obergrenze. Und **Jitter**: eine Zufallskomponente, die verhindert, dass alle Clients ihre Retries auf dieselbe Millisekunde ausrichten und synchronisierte Lastspitzen erzeugen.\n\nFür den Jitter gibt es mehrere Varianten – Marc Brooker hat sie 2015 im AWS Architecture Blog gegeneinander gestellt: Full Jitter, Equal Jitter, Decorrelated Jitter. Die praktische Empfehlung aus dieser Analyse ist meist Full Jitter, weil es die Wiederholungen am gleichmäßigsten über das Zeitfenster streut. Die Formel ist angenehm schlicht:\n\n```ts\ntype RetryOptions = {\n  maxAttempts: number;\n  baseDelayMs: number;\n  capMs: number;\n  isRetryable: (error: unknown) => boolean;\n};\n\nexport async function retryWithBackoff<T>(\n  fn: () => Promise<T>,\n  opts: RetryOptions,\n): Promise<T> {\n  let lastError: unknown;\n\n  for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {\n    try {\n      return await fn();\n    } catch (error) {\n      lastError = error;\n      if (!opts.isRetryable(error) || attempt === opts.maxAttempts - 1) {\n        throw error;\n      }\n\n      // full jitter: random point in the exponentially growing window\n      const window = Math.min(opts.capMs, opts.baseDelayMs * 2 ** attempt);\n      const delay = Math.random() * window;\n      await new Promise((resolve) => setTimeout(resolve, delay));\n    }\n  }\n\n  throw lastError;\n}\n```\n\nDer `isRetryable`-Callback ist bewusst nach außen gezogen. Nicht jeder Fehler ist wiederholbar. Ein `503 Service Unavailable`, ein Verbindungsabbruch oder ein Timeout kann transient sein. Ein unveränderter `400 Bad Request` bleibt dagegen falsch. Auch `409 Conflict` ist kein pauschales Nein: Nach erneutem Lesen, neuer Versionsnummer oder angepasster Operation kann ein weiterer Versuch sinnvoll sein; die identische Anfrage blind zu wiederholen meist nicht. Retry-Entscheidungen brauchen deshalb Fehlercode, Operation und verbleibendes Zeitbudget.\n\nDie AWS Well-Architected-Guidance bringt das unter REL05-BP03 (\"Control and limit retry calls\") auf den Punkt: Backoff, Jitter und eine maximale Versuchszahl gehören zusammen. Die AWS-SDKs setzen genau das in ihren Retry-Modi `standard` und `adaptive` schon eingebaut um – wer auf dieser Ebene arbeitet, sollte nicht darüber noch eine zweite eigene Retry-Schleife legen. Womit wir beim gefährlichsten Fallstrick wären.\n\n## Der Retry-Storm\n\nRetries stapeln sich. Der Client wiederholt, das SDK darunter wiederholt, das API-Gateway davor wiederholt. Drei Schichten mit je drei Versuchen sind nicht drei, sondern bis zu siebenundzwanzig Aufrufe für eine einzige Nutzeraktion. Wenn dann noch der Jitter fehlt und alle Clients gleichzeitig loslegen, entsteht ein Retry-Storm: multiplikative Last, die exakt in dem Moment auf den Server trifft, in dem er sie am wenigsten verträgt.\n\nDas Gegenmittel ist Disziplin, nicht mehr Technik. Für jede Aufrufkette sollte klar sein, welche Schicht den Retry besitzt und welches gemeinsame Zeit- und Versuchsbudget gilt. Niedrige Schichten wie ein SDK dürfen eigene Wiederholungen haben, wenn die darüberliegenden Schichten sie kennen und nicht unkoordiniert vervielfachen. Jitter und Cap gehören an die zuständige Stelle. Wer viele Clients koordiniert, ergänzt ein Retry-Budget oder clientseitiges Rate-Limiting per Token-Bucket.\n\n## Circuit Breaker: aufhören zu hämmern\n\nRetry mit Backoff federt kurze Störungen ab. Aber was, wenn eine Dependency nicht kurz hustet, sondern richtig ausfällt – für Minuten? Dann ist jeder weitere Versuch verschwendete Last, verschwendete Latenz und verschwendete Ressource. Genau hier setzt der Circuit Breaker an, ein Muster, das Michael Nygard in \"Release It!\" geprägt hat.\n\nDie Idee stammt aus der Elektrotechnik: Ein Sicherungsschalter unterbricht den Stromkreis, bevor die Leitung durchbrennt. Übertragen auf Software heißt das – wenn eine Dependency erkennbar am Boden liegt, schaltet der Breaker auf und lässt Aufrufe sofort scheitern (Fail Fast), statt sie in ein totes Timeout laufen zu lassen. Der Breaker kennt drei Zustände:\n\n```mermaid\nstateDiagram-v2\n    [*] --> CLOSED\n    CLOSED --> OPEN: Fehler >= Schwelle\n    OPEN --> HALF_OPEN: Cooldown abgelaufen\n    HALF_OPEN --> CLOSED: Probe erfolgreich\n    HALF_OPEN --> OPEN: Probe scheitert\n```\n\nIm Zustand **CLOSED** läuft alles normal, der Breaker zählt nur die Fehler mit. Überschreitet die Fehlerzahl eine Schwelle, springt er auf **OPEN**: Aufrufe werden nicht mehr durchgelassen, sondern scheitern sofort – der Client bekommt schnell eine Antwort oder einen Fallback statt einer hängenden Verbindung. Nach einem Cooldown geht der Breaker in **HALF_OPEN** und lässt eine einzelne Probeanfrage durch. Klappt sie, schließt er wieder (CLOSED). Scheitert sie, öffnet er erneut und wartet den nächsten Cooldown ab.\n\nAls kleine State Machine ist das überschaubar:\n\n```ts\ntype BreakerState = \"CLOSED\" | \"OPEN\" | \"HALF_OPEN\";\n\nexport class CircuitBreaker {\n  private state: BreakerState = \"CLOSED\";\n  private failureCount = 0;\n  private openedAt = 0;\n\n  constructor(\n    private readonly failureThreshold: number,\n    private readonly cooldownMs: number,\n  ) {}\n\n  async execute<T>(fn: () => Promise<T>): Promise<T> {\n    if (this.state === \"OPEN\") {\n      if (Date.now() - this.openedAt < this.cooldownMs) {\n        throw new Error(\"circuit is open\");\n      }\n      this.state = \"HALF_OPEN\";\n    }\n\n    try {\n      const result = await fn();\n      this.onSuccess();\n      return result;\n    } catch (error) {\n      this.onFailure();\n      throw error;\n    }\n  }\n\n  private onSuccess(): void {\n    this.failureCount = 0;\n    this.state = \"CLOSED\";\n  }\n\n  private onFailure(): void {\n    this.failureCount++;\n    if (this.state === \"HALF_OPEN\" || this.failureCount >= this.failureThreshold) {\n      this.state = \"OPEN\";\n      this.openedAt = Date.now();\n    }\n  }\n}\n```\n\nAWS ordnet den Circuit Breaker in Well-Architected unter REL05-BP01 ein – \"graceful degradation\", also das Verwandeln einer harten Abhängigkeit in eine weiche. Statt dass ein ausgefallener Empfehlungsdienst die ganze Produktseite mitreißt, liefert der Breaker im OPEN-Zustand einen Fallback (etwa eine statische Liste) und die Seite bleibt bedienbar. Das ist der eigentliche Gewinn: nicht die perfekte Antwort, sondern eine akzeptable, die das Gesamtsystem am Leben hält.\n\nIn Produktion muss man das nicht selbst schreiben – etablierte Bibliotheken wie `opossum` für Node.js, Polly für .NET oder resilience4j für Java bringen das mit, inklusive Metrics und Half-Open-Sampling. Der Code oben ist illustrativ, damit die Zustandslogik sichtbar bleibt.\n\n## Idempotenz: was Retries erst sicher macht\n\nJetzt schließt sich der Kreis zum Retry. Ein Retry ist nur dann harmlos, wenn die wiederholte Operation denselben Effekt hat wie eine einzelne. Genau das ist Idempotenz. Bei einem Lesezugriff ist das trivial – zweimal `GET` schadet nicht. Bei einem Write wird es ernst: Wenn der erste Versuch einer Zahlung eigentlich durchging, die Antwort aber im Timeout verloren ging, dann bucht der Retry ein zweites Mal ab. Der Kunde zahlt doppelt.\n\nDie HTTP-Semantik gibt hier einen Rahmen vor: `GET`, `PUT` und `DELETE` gelten als idempotent, `POST` grundsätzlich nicht. Idempotent bedeutet denselben beabsichtigten Effekt bei Wiederholung; Statuscode oder Antwort müssen dabei nicht identisch sein. Wo man Operationen entsprechend entwerfen kann – ein `PUT` auf eine natürliche ID statt eines `POST`, das jedes Mal eine neue Ressource erzeugt –, sollte man das tun. Wo das nicht geht, etwa bei „charge“ oder „send“, hilft ein expliziter **Idempotency-Key**. Der Client generiert pro fachlicher Operation einen eindeutigen Schlüssel und schickt ihn mit; der Server merkt sich Schlüssel plus Ergebnis und verhindert damit den zweiten Side Effect.\n\nDas Muster ist seit Jahren etabliert – Stripe etwa nutzt einen `Idempotency-Key`-Header. Ein IETF-Standard war zum damaligen Zeitpunkt lediglich ein Internet-Draft (`draft-ietf-httpapi-idempotency-key-header`), aber das Muster selbst funktioniert unabhängig davon. Serverseitig sieht der Kern so aus:\n\n```ts\ntype Result = { status: string; id: string };\n\ninterface IdempotencyStore {\n  get(key: string): Promise<Result | undefined>;\n  set(key: string, value: Result): Promise<void>;\n}\n\nexport async function handleRequest(\n  key: string,\n  op: () => Promise<Result>,\n  store: IdempotencyStore,\n): Promise<Result> {\n  const existing = await store.get(key);\n  if (existing) {\n    // key already processed: return the stored result, no second side effect\n    return existing;\n  }\n\n  const result = await op();\n  await store.set(key, result);\n  return result;\n}\n```\n\nZwei Details, die in echt zählen. Erstens braucht der Store einen Ablauf (TTL) – Idempotency-Keys ewig aufzuheben ist weder nötig noch billig. Zweitens gibt es ein Nebenläufigkeitsproblem: Wenn zwei Retries fast gleichzeitig ankommen, kann der zweite den `get` abschließen, bevor der erste seinen `set` schreibt. In Produktion löst man das mit einer atomaren Reservierung des Keys (etwa einem bedingten Insert), nicht mit dem naiven Get-dann-Set oben. Der Code zeigt das Prinzip, nicht die produktionsfertige Sperre.\n\n## Bulkhead und DLQ, kurz\n\nZwei Muster runden das Bild ab, ohne dass ich sie hier ausbreite. Das **Bulkhead** – benannt nach den Schotten im Schiffsrumpf – isoliert Ressourcen, damit ein überlasteter Pfad nicht das ganze System flutet. Getrennte Connection-Pools oder Thread-Pools pro Downstream sorgen dafür, dass eine hängende Dependency nur ihren eigenen Pool erschöpft und nicht alle anderen Aufrufe mit in den Abgrund zieht. Auch das stammt aus \"Release It!\".\n\nUnd die **Dead-Letter-Queue** (DLQ) ist das Auffangbecken für den Fall, dass alle Retries erschöpft sind. Statt eine nicht verarbeitbare Message endlos im Kreis zu drehen oder still zu verlieren, landet sie in einer separaten Queue – zur Inspektion, für einen manuellen Eingriff oder eine spätere Wiederholung. In AWS ist das ein Standardbaustein von SQS und SNS. Wer die Verkettung solcher Schritte sauber orchestrieren will, findet in [Serverless-Workflows mit Step Functions](https://www.mikebild.dev/de/blog/step-functions-serverless-workflows/) den passenden Rahmen, und wie man Last über mehrere Prozesse verteilt, habe ich in [child_process und Skalierung](https://www.mikebild.dev/de/blog/nodejs-child-process-und-skalierung/) beschrieben.\n\n## Fazit\n\nDrei Fehler tauchen in Post-Mortems immer wieder auf. Kein Timeout – ein Call, der unendlich warten darf und dabei Ressourcen bindet. Retry ohne Jitter und Cap – der freundlich gemeinte Wiederholungsversuch, der zum Retry-Storm wird und den schwachen Server endgültig erledigt. Und nicht-idempotente Writes, die wiederholt werden – die Doppelbuchung, die niemand kommen sah.\n\nAlle drei haben eine gemeinsame Wurzel: Resilienz wurde gehofft, nicht entworfen. Die vier Muster – Timeout, Retry, Circuit Breaker, Idempotenz – sind keine Bibliothek, die man importiert, sondern Entscheidungen pro Aufruf. Remote-Calls brauchen eine begründete Zeitgrenze. Wiederholungen brauchen Budget, Backoff und Jitter. Ein Breaker lohnt sich bei Abhängigkeiten, deren anhaltender Ausfall sonst weiter Last und Latenz erzeugt. Ein Idempotency-Key gehört zu Operationen, bei denen Wiederholung möglich und ein doppelter Effekt schädlich ist. So kann ein System unter Last kontrolliert degradieren, statt seinen Ausfall zu vervielfachen.\n\n## Weiterführende Quellen\n\n- [AWS Well-Architected, REL05-BP03 – Control and limit retry calls](https://docs.aws.amazon.com/wellarchitected/latest/reliability-pillar/rel_mitigate_interaction_failure_limit_retries.html)\n- [AWS Well-Architected, REL05-BP01 – Graceful degradation (Circuit Breaker)](https://docs.aws.amazon.com/wellarchitected/latest/reliability-pillar/rel_mitigate_interaction_failure_graceful_degradation.html)\n- [AWS Well-Architected, REL05-BP02 – Throttle requests (Token Bucket)](https://docs.aws.amazon.com/wellarchitected/latest/framework/rel_mitigate_interaction_failure_throttle_requests.html)\n- [AWS Builders' Library – Timeouts, retries, and backoff with jitter (Marc Brooker)](https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/)\n- [AWS Architecture Blog – Exponential Backoff And Jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)\n- [Michael Nygard – Release It! (2. Auflage, 2018)](https://pragprog.com/titles/mnee2/release-it-second-edition/)"},{"slug":"graphql-subscriptions-ueber-sse","title":"GraphQL-Subscriptions über SSE: Echtzeit ohne WebSocket-Zwang","summary":"Subscriptions brauchen keinen WebSocket. Server-Sent Events genügen für den einseitigen Server-zu-Client-Kanal, während Query und Mutation normales HTTP bleiben, und das Schema entscheidet, was überhaupt ein Domänen-Event ist.","tags":["Software","GraphQL","API"],"published_at":"2024-01-12T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/graphql-subscriptions-ueber-sse/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/848937170bf0bd2502a000d2ed977574598eda253259c5a5605bd889b855826e/graphql-subscriptions-ueber-sse-vorlesen.mp3","audio_duration_secs":979,"text":"Wer für einen einseitigen Update-Fluss einen WebSocket aufzieht, holt sich ein zweites Protokoll in den Betrieb: Upgrade-Handshake, Sticky Sessions, Proxys und Load-Balancer, die davon wissen müssen, dazu Reconnect und Heartbeat in eigener Verantwortung. Der Anlass dafür ist meist bescheiden – eine Bestellung wechselt ihren Status, eine Kachel im Dashboard aktualisiert sich ohne Reload. Der Anlass ist nach einem Sprint erledigt, der Transport bleibt und will jahrelang betrieben werden.\n\nHinter „Echtzeit“ steht deshalb selten ein Syntaxproblem und fast immer eine Betriebsentscheidung. Ein bidirektionaler Vollduplex-Kanal, wie ihn ein WebSocket öffnet, ist kein Feature, das man geschenkt bekommt; er ist eine eigene Betriebsklasse mit eigenem Protokoll, eigenem Handshake und eigenen Ausfallmustern. Deshalb geht es in diesem Text weniger darum, wie man Subscriptions anbindet, als um die Entscheidung davor: SSE, WebSocket oder schlichtes Polling – entlang von Betriebskosten, Komplexität und Skalierung entschieden, nicht entlang der Frage, was gerade modern klingt.\n\nDabei schreiben GraphQL-Subscriptions den WebSocket gar nicht vor. Die Spezifikation ist an dieser Stelle transport-agnostisch: Sie sagt, dass eine Subscription einen Event Stream liefert – nicht, über welche Leitung dieser Strom fließt. Und für den häufigen Fall „Server informiert Client“ gibt es einen Kandidaten, der seit Jahren in jedem Browser eingebaut ist und über gewöhnliches HTTP läuft: Server-Sent Events.\n\n## Query und Mutation bleiben, wie sie sind\n\nBevor es um den Event Stream geht, lohnt sich ein Blick auf das, was sich gar nicht ändert. Eine Query holt Daten, eine Mutation verändert Zustand – beide sind Anfrage und Antwort, ein Request, eine Response, fertig. Das ist normales HTTP, meist ein `POST /graphql`. Nichts an einer Subscription-Fähigkeit zwingt dazu, daran etwas zu drehen. Nur der dritte Operationstyp, die Subscription, braucht einen offenen Kanal, über den der Server nach und nach Events schickt.\n\nGenau hier setzt Server-Sent Events an. SSE ist ein einseitiger Kanal vom Server zum Client über gewöhnliches HTTP. Der Server antwortet mit dem Content-Type `text/event-stream` und lässt die Verbindung offen; der Client öffnet sie über die eingebaute `EventSource`-Schnittstelle. Der Browser übernimmt dabei einiges, was man bei einer selbstgebauten Lösung mühsam nachrüsten müsste: Er reconnectet automatisch, wenn die Verbindung abreißt, und schickt beim Wiederverbinden die zuletzt gesehene Event-ID im Header `Last-Event-ID` mit, damit der Server dort weitermachen kann, wo es abgerissen ist.\n\n```mermaid\nflowchart LR\n  subgraph Client\n    UI[Browser-App]\n  end\n  subgraph Server\n    GQL[GraphQL-Server]\n  end\n  UI -- \"HTTP POST /graphql<br/>Query &amp; Mutation\" --> GQL\n  GQL -- \"HTTP Antwort (JSON)\" --> UI\n  GQL -- \"text/event-stream<br/>Events via EventSource\" --> UI\n```\n\nDas Bild zeigt den Kern: zwei Kanäle, ein Transport. Query und Mutation gehen den bekannten Weg über Request und Response. Der Event Stream nutzt dieselbe HTTP-Infrastruktur, nur bleibt die Antwort offen und tröpfelt Events nach. Es gibt keinen Protokollwechsel, kein Upgrade-Handshake, keine gesonderte Behandlung durch Proxys, die WebSocket-Frames nicht kennen.\n\n## Der Serverteil: ein AsyncIterable aus PubSub\n\nAuf der Serverseite ist ein Subscription-Resolver kein gewöhnlicher Feld-Resolver. Statt einer Funktion, die einen Wert zurückgibt, ist es ein Objekt mit einer `subscribe`-Methode, die ein `AsyncIterable` liefert. Aus diesem Iterable zieht der GraphQL-Server die Events und schickt sie an den passenden Client. Das gebräuchliche Werkzeug dafür ist das Paket `graphql-subscriptions`, zum jetzigen Zeitpunkt in Version 2.0.0. Es bringt eine `PubSub`-Klasse mit, deren `asyncIterator(topic)` genau so ein Iterable erzeugt.\n\n```ts\nimport { PubSub, withFilter } from 'graphql-subscriptions';\n\nconst pubsub = new PubSub();\n\nconst ORDER_STATUS_CHANGED = 'ORDER_STATUS_CHANGED';\n\nconst resolvers = {\n  Subscription: {\n    orderStatusChanged: {\n      subscribe: withFilter(\n        () => pubsub.asyncIterator(ORDER_STATUS_CHANGED),\n        (payload, variables) =>\n          payload.orderStatusChanged.customerId === variables.customerId,\n      ),\n    },\n  },\n  Mutation: {\n    advanceOrder: (_root, { id }) => {\n      const orderStatusChanged = advanceOrderInStore(id);\n      pubsub.publish(ORDER_STATUS_CHANGED, { orderStatusChanged });\n      return orderStatusChanged;\n    },\n  },\n};\n```\n\nZwei Dinge sind hier wichtig. Erstens der `withFilter`-Helfer: Er nimmt die Iterator-Function und eine Prädikatfunktion, die pro Event entscheidet, ob es diesen Abonnenten überhaupt betrifft. So bekommt jeder Kunde nur die Statuswechsel seiner eigenen Bestellungen, obwohl alle über dasselbe Topic laufen. Zweitens die Publish-Stelle in der Mutation: Wenn sich fachlich etwas ereignet – hier: eine Bestellung rückt weiter –, wird das Event mit `pubsub.publish` in den Strom gegeben. Der `subscribe`-Resolver und die Mutation sind über das Topic entkoppelt; die Mutation weiß nichts davon, wer gerade zuhört.\n\nEin Hinweis, der in der Praxis oft zu spät auffällt: Die mitgelieferte `PubSub`-Implementierung hält ihren Zustand im Arbeitsspeicher und funktioniert nur innerhalb eines einzelnen Prozesses. Sobald mehrere Server-Instanzen hinter einem Load-Balancer stehen, verpuffen Events – eine Instanz published, aber die Abonnenten hängen an einer anderen. Für den Produktivbetrieb braucht es dann ein externes Backend, etwa eine Redis-gestützte Variante, die das Publish über die Prozessgrenze hinweg verteilt. Für die lokale Entwicklung und den Einstieg genügt die eingebaute Variante völlig.\n\n## Der SSE-Endpoint: der Strom nach draußen\n\nDamit die Events aus dem Iterable auch beim Browser ankommen, braucht es einen Endpoint, der den Strom als `text/event-stream` ausliefert. Konzeptionell sieht das in Express so aus:\n\n```ts\napp.get('/graphql/stream', (req, res) => {\n  res.set({\n    'Content-Type': 'text/event-stream',\n    'Cache-Control': 'no-cache',\n    Connection: 'keep-alive',\n  });\n  res.flushHeaders();\n\n  const iterator = pubsub.asyncIterator(ORDER_STATUS_CHANGED);\n\n  (async () => {\n    for await (const event of iterator) {\n      res.write(`event: next\\n`);\n      res.write(`data: ${JSON.stringify(event)}\\n\\n`);\n    }\n  })();\n\n  req.on('close', () => {\n    if (typeof iterator.return === 'function') iterator.return();\n  });\n});\n```\n\nDas Format eines SSE-Frames ist bewusst schlicht: Zeilen mit Feldnamen und Doppelpunkt. Das Feld `data:` trägt die Nutzlast, hier das serialisierte Event; mehrere `data:`-Zeilen würde der Browser mit einem Zeilenumbruch zusammenfügen. Mit `event:` benennt man den Event-Typ, sodass der Client gezielt darauf hören kann; ohne diese Zeile landet alles beim allgemeinen `onmessage`-Handler. Das Feld `id:` setzt die Event-ID, die der Browser bei einem Reconnect als `Last-Event-ID` zurückschickt, und `retry:` gibt in Millisekunden vor, wie lange der Browser vor einem Wiederverbindungsversuch warten soll. Ein Frame endet mit einer Leerzeile, also einem doppelten `\\n`. Zeilen, die mit einem Doppelpunkt beginnen, sind Kommentare und eignen sich als Heartbeat, um Zwischenschichten davon abzuhalten, eine ruhige Verbindung zu kappen.\n\nDer `req.on('close')`-Teil ist kein Beiwerk. Wenn der Client die Verbindung schließt, muss der Iterator sauber beendet werden, sonst sammeln sich Abonnements an und der Prozess leckt Ressourcen. Dieses Aufräumen ist die Stelle, an der eine SSE-Anbindung entweder solide oder brüchig wird.\n\n## Der Client: EventSource, mehr nicht\n\nAuf der Clientseite ist die Arbeit erstaunlich gering, weil der Browser den größten Teil übernimmt:\n\n```js\nconst es = new EventSource('/graphql/stream', { withCredentials: true });\n\nes.addEventListener('next', (e) => {\n  const payload = JSON.parse(e.data);\n  render(payload.orderStatusChanged);\n});\n\nes.onerror = () => {\n  // The browser reconnects on its own; the retry: field controls the interval.\n};\n```\n\nKein manuelles Wiederverbinden, kein Timer, kein eigenes Heartbeat-Protokoll. Fällt die Verbindung aus, versucht der Browser von sich aus, sie erneut aufzubauen, und schickt die zuletzt empfangene Event-ID mit. Die Option `withCredentials` sorgt dafür, dass Cookies über die Origin-Grenze hinweg mitgeschickt werden – praktisch, wenn die Authentifizierung über ein Session-Cookie läuft und der Endpoint weiß, wer da gerade lauscht. Wer die Autorisierung im GraphQL-Server sauber in den Ausführungskontext legt, findet in [GraphQL-Auth im Context](https://www.mikebild.dev/de/blog/graphql-auth-im-context/) das passende Muster; für den SSE-Endpoint gilt dieselbe Logik – die Identität wird an der HTTP-Stelle festgestellt und steht dem Resolver zur Verfügung.\n\n```mermaid\nsequenceDiagram\n  participant B as Browser (EventSource)\n  participant S as GraphQL-Server\n  participant P as PubSub\n  B->>S: HTTP POST advanceOrder (Mutation)\n  S->>P: pubsub.publish(topic, payload)\n  P-->>S: asyncIterator liefert Event\n  S-->>B: data:-Frame (event: next)\n  Note over B,S: Verbindung bricht ab\n  B->>S: Reconnect mit Last-Event-ID\n  S-->>B: Ereignisse ab letzter ID\n```\n\n## Das Schema ist ein Fachmodell, kein Ereignisrohr\n\nDer technische Teil ist damit umrissen, aber der wichtigere Punkt aus der Projektpraxis ist ein anderer. Sobald ein einfacher Weg da ist, Events an den Client zu schicken, wächst die Versuchung, alles Mögliche durch Subscriptions zu pumpen: jeden Tastendruck, jeden internen Tick, jede Log-Zeile. Das Schema ist aber ein Fachmodell und keine Sammelstelle für technische Signale. Eine Subscription sollte ein Event abbilden, das fachlich etwas bedeutet – „Bestellung hat den Status gewechselt“, „Kommentar wurde hinzugefügt“, „Zahlung ist eingegangen“. Ein `orderStatusChanged` sagt einem Konsumenten etwas über die Domäne. Ein `serverTick` oder `internalMetric` sagt ihm nichts, was er im Schema suchen würde.\n\nDieselbe Modellierungsdisziplin, die man bei Listen und Blättern anlegt – siehe die Überlegungen in [GraphQL-Paginierung und Connections](https://www.mikebild.dev/de/blog/graphql-paginierung-und-connections/) –, gilt hier genauso: Das Schema beschreibt die Sprache der Domäne, nicht die Interna der Übertragung. Wenn ein Event nicht Teil dieser Sprache ist, gehört es nicht in eine Subscription.\n\n## Die falsche Abstraktion vermeiden\n\nDamit sind wir bei der eigentlichen Entscheidung, und die fällt eine Ebene tiefer als GraphQL: in der Architektur. SSE, WebSocket und Messaging lösen unterschiedliche Probleme, und die häufigste Fehlerquelle ist, das eine als Ersatz für das andere zu behandeln.\n\n- Server-Sent Events sind einseitig vom Server zum Client, laufen HTTP-nativ, reconnecten von selbst und transportieren ausschließlich Text in UTF-8. Sie passen genau dann, wenn die Oberfläche Live-Updates anzeigen soll: Statuswechsel, Benachrichtigungen, ein sich aktualisierendes Dashboard.\n- WebSockets sind bidirektional und haben ein eigenes Protokoll mit eigenem Handshake. Sie sind die richtige Wahl, wenn der Client selbst laufend Daten in Echtzeit an den Server schickt – Chat, kollaboratives Bearbeiten, ein Spielzustand, alles mit dichtem Rückkanal.\n- Messaging und Broker wie Kafka, AMQP oder Redis lösen Integration zwischen Systemen. Sie bieten Persistenz, Fan-out und Entkopplung im Backend. Eine GraphQL-Subscription ersetzt das nicht. Sie deckt den letzten Meter zum Browser ab; das Nervensystem zwischen den Diensten bleibt Aufgabe des Brokers.\n\nWer diese drei sauber trennt, baut nichts Falsches. Wer sie vermischt – etwa einen WebSocket aufzieht, um einen rein einseitigen Update-Fluss zu bedienen, oder Subscriptions zweckentfremdet, um Dienste zu integrieren –, zahlt später mit Komplexität, die kein Feature rechtfertigt.\n\n## Entscheidungsrahmen: SSE, WebSocket oder Polling\n\nDie saubere Trennung der Transporte ist das eine; im Projekt muss ich mich am Ende für einen entscheiden, und dafür hilft ein Rahmen entlang von drei Achsen: Betriebskosten, Komplexität und Skalierung. Diese drei Fragen beantworten die Transportwahl zuverlässiger als jede Feature-Liste, weil sie nach dem Preis im Betrieb fragen und nicht nach dem Funktionsumfang.\n\nPolling ist der ehrliche Ausgangspunkt, den man zu schnell übergeht. Der Client fragt in einem Intervall „gibt es etwas Neues?“, und das ist über HTTP, Caching und Load-Balancer hinweg das am besten verstandene Muster überhaupt. Die Betriebskosten liegen konzeptionell nahe null, doch die Last auf dem Server wächst linear mit Client-Zahl und Frequenz und steckt die meiste Arbeit in leere Antworten. Die Komplexität ist minimal, weil es keinen offenen Kanal gibt, der aufräumen oder reconnecten müsste, und die Skalierung ist horizontal trivial, weil jede Anfrage für sich steht. Für Aktualisierungen im Sekunden- bis Minutenbereich, bei denen etwas Latenz niemandem wehtut, ist Polling oft die wirtschaftlich richtige Antwort – und die Option, die man zuerst prüfen sollte, bevor man etwas Aufwendigeres baut.\n\nSSE sitzt in der Mitte und trifft genau den Fall „Server informiert Client zeitnah“. Die Betriebskosten bestehen aus einer offenen HTTP-Verbindung pro Client, die aber HTTP-nativ bleibt und keine Sonderbehandlung in der Infrastruktur verlangt. Die Komplexität ist gering auf dem Client, weil `EventSource` Reconnect und `Last-Event-ID` mitbringt; auf dem Server liegt der Aufwand vor allem im sauberen Aufräumen der Ströme. Die Skalierung hat ihre Kostenstelle in den vielen offenen Verbindungen, und der Zustand des PubSub muss über Instanzgrenzen verteilt werden, sobald mehr als ein Prozess läuft. Der Gewinn gegenüber Polling ist niedrige Latenz ohne Leerlauf; der Preis ist der offene Kanal, den man betreiben muss.\n\nWebSocket ist die teuerste Klasse und rechtfertigt sich erst mit einem echten Rückkanal. Die Betriebskosten umfassen ein eigenes Protokoll mit Handshake, Sticky Sessions und Proxys, die das Upgrade kennen müssen. Die Komplexität steigt, weil Reconnect, Heartbeat und Zustandswiederherstellung bei mir liegen, nicht beim Browser. Die Skalierung ist am schwersten, weil langlebige, zustandsbehaftete Verbindungen sich horizontal am wenigsten leicht verteilen lassen. Dieser Aufwand zahlt sich aus, wenn der Client selbst laufend in Echtzeit sendet – Chat, kollaboratives Bearbeiten, Spielzustand. Für einen einseitigen Update-Fluss ist er Aufwand ohne Gegenwert.\n\nDas Entscheidungskriterium ist damit knapp: erst prüfen, ob Polling reicht; wenn zeitnahe Server-zu-Client-Updates ohne Rückkanal gebraucht werden, SSE; und nur bei dichtem, bidirektionalem Verkehr WebSocket. Wer in dieser Reihenfolge fragt, wählt den billigsten Transport, der die Anforderung trägt, statt den mächtigsten, den man kennt – und genau diese Reihenfolge trennt eine Betriebsentscheidung von einer Geschmacksfrage.\n\n## Woher die SSE-Idee kommt – und was heute gilt\n\nDie Verbindung von GraphQL und SSE ist nicht neu, und ihre Geschichte hilft, den heutigen Stand richtig einzuordnen. In der Frühzeit von Apollo Client, der 1.x-Ära, gab es das Paket `subscriptions-transport-sse`. Es wurde damals in einen Apollo-Client über das Konzept `networkInterface` eingehängt:\n\n```js\n// HISTORICAL — Apollo Client 1.x, not today's API.\nimport {\n  SubscriptionClient,\n  addGraphQLSubscriptions,\n} from 'subscriptions-transport-sse';\n\nconst sseClient = new SubscriptionClient('/subscriptions');\nconst client = new ApolloClient({\n  networkInterface: addGraphQLSubscriptions(httpInterface, sseClient),\n});\n```\n\nDas ist ausdrücklich Legacy und gehört in kein neues Projekt. Sowohl `networkInterface` als auch der damalige `SubscriptionManager` – der Vorläufer des heutigen Musters mit `PubSub.asyncIterator` – sind Konzepte einer längst abgelösten Generation. Ich zeige das Fragment nur, um die Herkunft der Idee sichtbar zu machen: SSE als Transport für GraphQL-Events ist ein alter Gedanke, kein experimenteller.\n\nFür die WebSocket-Seite lohnt ein Blick auf den aktuellen Stand, weil er die Transportfrage einrahmt. Das früher übliche `subscriptions-transport-ws` gilt inzwischen als nicht mehr gepflegt; sein Repository trägt seit Längerem einen Deprecation-Hinweis und wird nicht mehr aktiv weiterentwickelt. Der empfohlene Nachfolger für den WebSocket-Weg ist `graphql-ws` mit einem anderen Subprotokoll, das zum alten nicht kompatibel ist. Das ist hier nur Kontext: `graphql-ws` ist die Antwort, wenn man tatsächlich einen WebSocket braucht. Es steht nicht in Konkurrenz zu SSE; es deckt den anderen, den bidirektionalen Fall ab.\n\n## Was ich aus der Praxis mitgebe\n\nWenn jemand in einem Anforderungsgespräch „Echtzeit“ sagt, ist die erste nützliche Rückfrage nicht „WebSocket oder SSE?“, sondern „In welche Richtung fließen die Daten, und wie schnell müssen sie ankommen?“. Meist fließen sie vom Server zum Client und dürfen etwas Latenz vertragen, und dann ist Server-Sent Events die ruhigere, einfacher zu betreibende Lösung: gewöhnliches HTTP, eingebauter Reconnect, keine Sonderbehandlung in der Infrastruktur. Query und Mutation bleiben unangetastet, nur der Event Stream bekommt seinen eigenen, offenen Kanal. Serverseitig liefert der `subscribe`-Resolver ein `AsyncIterable` aus einer PubSub, Events kommen per `publish` hinein, und `withFilter` sorgt dafür, dass jeder nur bekommt, was ihn angeht.\n\nDer Gewinn dieser Zuordnung ist im Projektalltag zu spüren. Wer den billigsten tragfähigen Transport wählt, verkürzt die Zyklen bis zum funktionierenden Live-Update, weil die halbe Infrastrukturdiskussion entfällt, mit der dieser Text begann. Er senkt die Reibung im Betrieb, weil ein HTTP-nativer Strom weniger Sonderfälle in Proxys, Load-Balancern und Deployments erzeugt. Und er hebt die Qualität, weil das Schema ein Fachmodell bleibt und nur Events trägt, die in der Sprache der Domäne etwas bedeuten. Zwei Grenzen behalte ich dabei bewusst im Blick: Die eingebaute PubSub hält ihren Zustand im Speicher und braucht über mehrere Instanzen hinweg ein externes Backend, und die Transportwahl bleibt eine schlichte Zuordnung – Polling, wenn Latenz erlaubt ist, SSE für den einseitigen Weg zum Client, WebSocket nur für den echten Rückkanal. Diese klaren Grenzen tragen die kürzeren Zyklen und die geringere Reibung überhaupt erst.\n\n## Weiterführende Quellen\n\n- MDN – Using server-sent events: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events\n- MDN – EventSource: https://developer.mozilla.org/en-US/docs/Web/API/EventSource\n- graphql-subscriptions (README/API): https://github.com/apollographql/graphql-subscriptions\n- graphql-subscriptions (npm): https://www.npmjs.com/package/graphql-subscriptions\n- Apollo Server – Subscriptions: https://www.apollographql.com/docs/apollo-server/data/subscriptions\n- subscriptions-transport-ws (Legacy/archiviert): https://github.com/apollographql/subscriptions-transport-ws\n- subscriptions-transport-sse (historischer Anlass): https://github.com/CodeCommission/subscriptions-transport-sse\n- GraphQL Spec: https://spec.graphql.org/"},{"slug":"contract-testing-statt-e2e-marathon","title":"Contract Testing statt E2E-Marathon","summary":"Consumer-Driven Contracts prüfen Schnittstellen an der Grenze zwischen Services statt in einer teuren, flaky End-to-End-Suite. Das Ergebnis: ein schnelleres, deterministisches Deploy-Gate und Teams, die unabhängig releasen.","tags":["Software","Delivery"],"published_at":"2023-12-05T10:00:00+00:00","updated_at":"2026-07-19T18:46:18.505233+00:00","url":"https://www.mikebild.dev/de/blog/contract-testing-statt-e2e-marathon/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/c3e0e021c1d8452296598b7a8e47bd924c2fe90bcbbafe885f4344c4f46b2e7d/contract-testing-statt-e2e-marathon-vorlesen.mp3","audio_duration_secs":696,"text":"```text\nFAIL  e2e/checkout.spec.ts\n  ✕ Bestellung abschließen (30014 ms)\n    TimeoutError: waiting for selector \"#order-confirmation\" failed:\n    timeout 30000ms exceeded\n\nTests: 1 failed, 213 passed        Dauer: 46m 08s\n```\n\nDieser Fehlschlag sagt nichts darüber aus, ob der Code kaputt ist. Er sagt, dass irgendwo im hochgefahrenen Gesamtsystem etwas dreißig Sekunden gebraucht hat statt drei – ein Container, der träge startet, eine Animation, ein Netzwerk-Hop. Beim nächsten Lauf ist derselbe Test grün, ohne dass eine Zeile geändert wurde. Genau so verliert eine nächtliche End-to-End-Suite ihre Autorität: Sie beantwortet die Frage, ob heute deployt werden darf, mit einem Wahrscheinlichkeitswurf, und irgendwann klickt das Team die roten Läufe einfach weg.\n\nDer E2E-Marathon – die Suite, die nachts das komplette System hochfährt, sich durch echte UIs klickt und morgens rot oder grün dasteht – ist deshalb kein Disziplinproblem. Er scheitert an seiner Struktur. Und die Frage, um die es eigentlich geht, ist viel kleiner als das, was er prüft: Passen diese beiden Services noch zusammen? Die Mechanik der Antwort ist schnell erklärt; der Gewinn liegt woanders.\n\nDer wesentliche Unterschied liegt nicht in der Anzahl der Tests, sondern im Ort, an dem Kompatibilität geprüft wird.\n\n::::comparison{title=\"Kompatibilität an der richtigen Grenze prüfen\" role=\"primary\" preferredPattern=\"before-after\"}\n:::side{label=\"Gemeinsame E2E-Umgebung\"}\n- Aufbau · alle beteiligten Systeme gleichzeitig starten\n- Signal · ein kompletter Pfad ist gerade erfolgreich durchgelaufen\n- Schwäche · Fehlerursache, Timing und verantwortliche Grenze bleiben oft unklar\n- Kopplung · Releases warten auf denselben integrierten Zustand\n:::\n:::side{label=\"Consumer-Driven Contract\"}\n- Aufbau · Erwartungen an einer Servicegrenze aufzeichnen\n- Signal · Consumer und Provider erfüllen denselben Vertrag unabhängig\n- Stärke · schneller, deterministischer und einer Grenze zuordenbar\n- Grenze · ersetzt keine kleine Auswahl echter End-to-End-Prüfungen\n:::\n::::\n\nDer Vergleich streicht E2E-Tests nicht. Er verschiebt die häufige Kompatibilitätsprüfung an die Stelle, an der der Vertrag tatsächlich entsteht.\n\n## Warum der E2E-Marathon strukturell scheitert\n\nWas ein Team wirklich braucht, ist eine schnelle, verlässliche Antwort auf die Frage, ob ein neuer Release die Zusammenarbeit zwischen Services bricht. Genau diese Antwort liefert eine große E2E-Suite am schlechtesten.\n\nMartin Fowler und Ham Vocke haben das in \"The Practical Test Pyramid\" präzise auf den Punkt gebracht: UI- und End-to-End-Tests sind \"brittle, expensive to write, and time consuming to run\". Sie sind anfällig für Nicht-Determinismus – Timing, Animationen, Browser-Quirks, ein Netzwerk-Hop, der mal 50 und mal 800 Millisekunden braucht. Und genau dieser Nicht-Determinismus ist das Gift: Sobald eine Suite in einem von zwanzig Läufen aus Gründen rot wird, die nichts mit dem Code zu tun haben, fängt das Team an, Fehlschläge wegzuklicken. Der Test, der Vertrauen schaffen sollte, produziert das Gegenteil.\n\nDazu kommt der Koppelungseffekt. Eine E2E-Suite braucht alle beteiligten Services gleichzeitig, in kompatiblen Versionen, hochgefahren im selben Environment. Damit erzwingt sie einen Lockstep: Team A kann erst deployen, wenn die gemeinsame Suite mit Team Bs aktuellem Stand grün ist. Man hat die Services technisch entkoppelt und sie sich durch die Testinfrastruktur wieder aneinandergekettet.\n\nDie DORA-Forschung im Buch \"Accelerate\" von Forsgren, Humble und Kim liefert dazu die empirische Gegenseite. Zwei der stärksten Prädiktoren für erfolgreiche Continuous Delivery sind eine \"loosely coupled architecture\" und \"continuous testing\". Elite-Performer können ihre Services unabhängig voneinander ändern und deployen, ohne mit anderen Teams synchronisieren zu müssen. Der E2E-Marathon arbeitet gegen genau dieses Ziel. Er ist der synchronisierte Lockstep in Testform.\n\n## Die Grenze prüfen, nicht das ganze System\n\nDie entscheidende Einsicht ist eine der Testabstraktion. Wenn zwei Services zusammenarbeiten, muss ich nicht das gesamte integrierte System hochfahren, um zu prüfen, ob sie sich verstehen. Ich muss nur prüfen, ob die *Grenze* zwischen ihnen stimmt – ob der Consumer das schickt, was der Provider erwartet, und der Provider das zurückgibt, worauf der Consumer sich verlässt.\n\nFowlers Definition ist hier zitierfähig präzise: \"An integration contract test is a test at the boundary of an external service verifying that it meets the contract expected by a consuming service.\" Der Consumer testet gegen einen Mock, der Provider verifiziert die aufgezeichneten Interaktionen – und beide Seiten laufen vollständig isoliert. Kein gemeinsames Environment, kein Lockstep.\n\nDas Muster dahinter ist alles andere als neu. Ian Robinson hat \"Consumer-Driven Contracts: A Service Evolution Pattern\" bereits 2006 auf martinfowler.com beschrieben. Der Consumer definiert seine Erwartungen; der Provider verifiziert gegen die Vereinigung – die Union – aller Consumer-Erwartungen. Das ist kein Hype, das ist ein rund siebzehn Jahre reifes Muster. Was sich geändert hat, ist die Werkzeuglandschaft, die es heute pragmatisch in eine CI-Pipeline gießt.\n\nIn der klassischen Testpyramide wird die Rolle sofort sichtbar: Contract Tests sitzen in der Mitte, als schneller Isolationstest zwischen den vielen Unit-Tests der Basis und der schmalen, teuren E2E-Spitze.\n\n```mermaid\nflowchart TB\n    subgraph Pyramid[\"Test Pyramid\"]\n        direction TB\n        E2E[\"E2E<br/>slow / flaky<br/>few\"]\n        CT[\"Integration / Contract<br/>fast / isolated<br/>focus\"]\n        UNIT[\"Unit<br/>fast / deterministic<br/>many\"]\n        UNIT --> CT --> E2E\n    end\n```\n\nDie Botschaft der Pyramide ist nicht \"keine E2E-Tests\". Sie ist: Prüfe die Kompatibilität da, wo sie entsteht – an der Grenze – und nicht durch ein hochgefahrenes Gesamtsystem an der Spitze.\n\n## Wie ein Contract entsteht\n\nKonkret sieht das so aus. Der Consumer – sagen wir eine WebApp – beschreibt in einem Test, was er vom Provider, hier einem OrderService, erwartet. Dieser Test läuft gegen einen lokalen Mock und generiert dabei als Nebenprodukt eine Vertragsdatei, den Pact.\n\n```js\nconst provider = new Pact({ consumer: 'WebApp', provider: 'OrderService' });\n\nprovider\n  .given('an order with id 42 exists')          // provider state\n  .uponReceiving('a request for order 42')\n  .withRequest({ method: 'GET', path: '/orders/42' })\n  .willRespondWith({\n    status: 200,\n    body: {\n      id: like(42),                              // type matcher\n      status: term({ matcher: 'PAID|OPEN', generate: 'PAID' }),\n      total: like(19.99),\n    },\n  });\n// the consumer runs against the Pact mock, then publishes the pact file to the broker\n```\n\nZwei Details sind hier wichtig, weil sie den Unterschied zwischen \"brauchbar\" und \"brüchig\" ausmachen. Erstens die Matcher. `like(42)` prüft nicht auf den exakten Wert 42, sondern auf den Typ – eine Zahl. `term(...)` prüft gegen ein Muster. Damit sagt der Consumer aus, was er strukturell braucht, statt sich an konkreten Beispielwerten festzuklammern. Zu lax gesetzte Matcher geben falsche Sicherheit, zu strikte machen den Test brüchig; die richtige Körnung ist Handwerk.\n\nZweitens der Provider State, das `given('an order with id 42 exists')`. Der Consumer beschreibt damit die Vorbedingung, unter der seine Erwartung gilt. Und genau hier liegt, ehrlich gesagt, der Hauptaufwand des gesamten Ansatzes – dazu gleich mehr.\n\nAuf der Providerseite wird der Vertrag dann nicht neu geschrieben, sondern verifiziert. Der Provider stellt jeden geforderten State real her und spielt die aufgezeichneten Interaktionen gegen seine echte Implementierung.\n\n```java\n@Provider(\"OrderService\")\n@PactBroker\nclass OrderServiceContractTest {\n\n    @State(\"an order with id 42 exists\")\n    void orderExists() {\n        // set up the provider state so the recorded interaction is reproducible\n        orderRepository.save(new Order(42, \"PAID\", 19.99));\n    }\n}\n```\n\nBeide Seiten laufen isoliert in ihrer eigenen Pipeline. Der Consumer weiß nichts von der Providerimplementierung, der Provider fährt keine WebApp hoch. Und trotzdem entsteht eine belastbare Aussage über ihre Kompatibilität.\n\n## Das Deploy-Gate: eine deterministische Ja/Nein-Frage\n\nJetzt kommt der Teil, der den Marathon tatsächlich ersetzt. Die Verträge und die Verifikationsergebnisse landen in einem zentralen Pact Broker. Damit lässt sich vor jedem Release eine simple, deterministische Frage stellen: Sind diese konkreten Versionen von Consumer und Provider miteinander kompatibel?\n\n```bash\n# provider side: verify the recorded interactions and publish results to the broker\npact-provider-verifier --provider OrderService --publish-verification-results\n\n# both sides gate their release on a deterministic check\npact-broker can-i-deploy \\\n  --pacticipant WebApp --version $GIT_SHA \\\n  --to-environment production\n```\n\n`can-i-deploy` ist der Kern. Statt \"wir warten, bis die nächtliche E2E-Suite grün ist\" bekommt die Pipeline eine sofortige, reproduzierbare Antwort aus dem Broker: Ja, diese Version der WebApp ist gegen die aktuell in Produktion laufende Version des OrderService verifiziert – deploy. Oder: Nein, es fehlt eine Verifikation – halt an.\n\nDer ganze Ablauf ist eine Prüfung an der Grenze, kein hochgefahrenes Gesamtsystem:\n\n```mermaid\nsequenceDiagram\n    participant C as Consumer (WebApp)\n    participant B as Pact Broker\n    participant P as Provider (OrderService)\n    C->>B: publish pact (from mock test)\n    P->>B: verify pact, publish results\n    C->>B: can-i-deploy? (WebApp @ sha)\n    B-->>C: yes -> deploy\n    P->>B: can-i-deploy? (OrderService @ sha)\n    B-->>P: yes -> deploy\n```\n\nDer Effekt ist das eigentliche Argument. Feedback in Minuten statt über Nacht. Ein deterministisches Gate statt eines flaky Wahrscheinlichkeitswurfs. Und – das ist der eigentliche Hebel – jedes Team kann unabhängig deployen, sobald sein Teil des Vertrags erfüllt ist. Kein Lockstep, kein \"wir müssen erst mit Team B synchronisieren\". Genau die lose Koppelung, die \"Accelerate\" als Prädiktor für Delivery-Performance identifiziert, wird hier testbar.\n\n## Wo Contract Testing aufhört – die ehrliche Abgrenzung\n\nConsumer-Driven Contracts ersetzen den E2E-*Marathon* – nicht jeden E2E-Test. Wer sie als Wundermittel verkauft, verspricht zu viel.\n\nDer wichtigste Vorbehalt zuerst: Contract Tests prüfen Schnittstellen-Kompatibilität. Struktur, Felder, Status-Codes, das Format an der Grenze. Sie prüfen *nicht* echte End-to-End-Business-Flows über mehrere Hops, nicht das UI-Verhalten, nicht Netzwerk- und Infrastruktureffekte im Zusammenspiel. Ein schmales E2E-Smoke-Set, das einmal den kritischen Pfad durch das laufende Gesamtsystem zieht, bleibt sinnvoll und richtig. Die These lautet \"statt E2E-Marathon\", nicht \"null E2E\". Man tauscht eine breite, langsame, unzuverlässige Suite gegen viele schnelle, isolierte Verträge plus eine Handvoll gezielter Smoke-Tests.\n\nDer zweite Punkt ist der, an dem ich in der Praxis die meiste Zeit verbringe: die Provider States. Jedes `given('...')` muss providerseitig real herstellbar sein. Driften die Annahmen des Consumers und das tatsächliche Setup des Providers auseinander, wird der vermeintlich leichtgewichtige Test doch aufwändig. Hier liegt die Wartungsarbeit, und wer sie unterschätzt, wird enttäuscht. Es lohnt sich, die States bewusst schlank und wiederverwendbar zu halten.\n\nUnd der dritte Punkt ist gar kein technischer. Contract Testing ohne Broker und ohne `can-i-deploy` im CI bleibt Stückwerk. Verträge, die nur lokal laufen und nicht versioniert zwischen den Pipelines ausgetauscht werden, geben keine verlässliche Deploy-Freigabe. Vor allem aber sind Consumer-Driven Contracts genauso sehr eine Frage der Zusammenarbeit wie der Technik: Beide Teams müssen sich auf den Vertrag als verbindliche Absprache einlassen. Der Consumer sagt, was er braucht; der Provider verpflichtet sich, es zu liefern und nicht kaputtzumachen. Das ist eine organisatorische Vereinbarung, keine Bibliothek. Wenn die Kultur nicht mitspielt, hilft das beste Tool nicht.\n\nEin Wort zur Werkzeugwahl, ohne in Produktwerbung zu verfallen: Im JS-Umfeld generiert Pact die Verträge; als Consumer-Mocks laufen dort WireMock, Nock oder MSW. Auf der JVM ist Spring Cloud Contract eine etablierte Alternative zu pact-jvm. Der Pact Broker ist Open Source und trägt die CI-Mechanik. Es gibt kommerzielle Erweiterungen im Umfeld – für den hier beschriebenen Kern braucht man sie nicht. Die Strategie steht und fällt nicht mit dem Vendor, sondern mit der Disziplin, an der Grenze zu prüfen und sich als Teams auf Verträge zu einigen.\n\n## Fazit\n\nDer E2E-Marathon ist kein Testproblem, sondern ein Architekturproblem in Testform: Er koppelt Services über die Infrastruktur wieder aneinander, die man mit gutem Grund entkoppelt hat, und tauscht dafür Determinismus und Geschwindigkeit gegen ein trügerisches Gefühl von Vollständigkeit ein. Consumer-Driven Contracts drehen das um. Sie prüfen Kompatibilität da, wo sie entsteht – an der Grenze zwischen zwei Services –, isoliert, schnell und reproduzierbar. Das Deploy-Gate wird zu einer Ja/Nein-Frage, die in Minuten beantwortet ist, und jedes Team gewinnt die Freiheit zurück, unabhängig zu releasen.\n\nDer Gewinn taucht am Ende in keiner Test-Metric auf, er zeigt sich in der Auslieferung: kürzere Zyklen, weniger Reibung zwischen Teams, höhere Verlässlichkeit. Ein schmales E2E-Smoke-Set bleibt daneben bestehen – aber der Marathon darf gehen. Man tauscht ein großes, langsames, unzuverlässiges Sicherheitsnetz gegen viele kleine, präzise gespannte.\n\n## Weiterführende Quellen\n\n- [Consumer-Driven Contracts: A Service Evolution Pattern (Ian Robinson, martinfowler.com)](https://martinfowler.com/articles/consumerDrivenContracts.html)\n- [The Practical Test Pyramid (Ham Vocke, martinfowler.com)](https://martinfowler.com/articles/practical-test-pyramid.html)\n- [TestPyramid (Martin Fowler, Bliki)](https://martinfowler.com/bliki/TestPyramid.html)\n- [DORA: Loosely coupled architecture](https://dora.dev/capabilities/loosely-coupled-teams/)\n- [DORA: Continuous delivery](https://dora.dev/capabilities/continuous-delivery/)\n- [pact-jvm (pact-foundation, GitHub)](https://github.com/pact-foundation/pact-jvm)\n- Forsgren, Humble, Kim: *Accelerate* (2018)"},{"slug":"nodejs-fehler-als-architektur","title":"Fehler als Architektur in Node.js: Netze gegen den Absturz","summary":"Error Handling in Node.js ist keine try/catch-Kosmetik, sondern eine Entscheidung über Ebenen, Eskalation und geordnetes Beenden. Wer die vier Fehler-Ebenen kennt und Graceful Shutdown ernst nimmt, baut Dienste, die kontrolliert sterben statt korrupt weiterzulaufen.","tags":["Software","Node.js","Backend"],"published_at":"2023-11-07T10:00:00+00:00","updated_at":"2026-07-13T13:22:07.374858+00:00","url":"https://www.mikebild.dev/de/blog/nodejs-fehler-als-architektur/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/ed5bcf3778f82b40e14169a0479d786edb7dc6f4c2012320702ed003769b6be2/nodejs-fehler-als-architektur-vorlesen.mp3","audio_duration_secs":651,"text":"Ein `try/catch` fängt synchrone Exceptions im gerade laufenden Stack und Rejections von Promises, die innerhalb desselben Blocks `await`et werden. Ein späterer Callback, eine losgelöste Promise und das `'error'`-Event eines EventEmitters erreichen diesen Block dagegen nicht. Ein `try/catch` um deren bloßen Startaufruf ist dann nur Dekoration.\n\nDamit hat eine Node.js-Anwendung mindestens vier Ebenen, auf denen Fehler entstehen, und jede davon hat ihre eigene Mechanik. Wer nur die erste kennt, hat drei Löcher im Netz – und wundert sich, warum der Prozess trotzdem abstürzt, obwohl doch „überall try/catch“ steht.\n\nWo diese Netze hängen, entscheidet sich beim Entwurf und nicht in der Woche vor dem Go-live, wenn jemand noch schnell etwas Error Handling darüberlegt. Daran hängt, ob ein Dienst unter Last stehen bleibt und in Sekunden frisch wieder da ist – oder ob er halb kaputt weiterarbeitet und Daten hinterlässt, die hinterher niemand mehr geradebiegt.\n\nDabei sind zwei Achsen zu trennen. Callback, Promise und EventEmitter beschreiben, **wie** ein Fehler den Code erreicht. Operational Errors und Programmer Errors beschreiben, **ob** der konkrete Zustand noch beherrschbar ist. Ein erwartbares Timeout kann am Request-Rand beantwortet oder wiederholt werden. Eine gebrochene Invariante im eigenen Code sollte bis zur Prozessgrenze eskalieren, damit der Dienst nach einem geordneten Shutdown frisch startet. Nicht jeder Fehler verlangt den Prozessabbruch; ein unbekannter Programmzustand darf aber nicht als normaler Betriebsfehler weiterlaufen.\n\n## Die vier Ebenen, auf denen Fehler entstehen\n\nBevor wir über Netze reden, muss klar sein, welche Fehlerquellen es in einer Node.js-Anwendung überhaupt gibt. Es sind im Kern vier, und sie unterscheiden sich nicht im Schweregrad, sondern im Mechanismus, mit dem der Fehler zu dir kommt:\n\n- Synchron: Ein Fehler wird direkt im aktuellen Aufrufstapel geworfen. Ihn fängt ein klassisches `try/catch`. Wer hier nichts fängt, bekommt die Exception eine Ebene höher gereicht – bis ganz nach oben.\n- Error-first-Callbacks: Die alte, immer noch allgegenwärtige Node-Konvention. Der Fehler kommt nicht als Exception, sondern als erstes Argument der Callback-Function: `callback(err, result)`. Kein `throw`, kein `catch` – nur ein Wert, den man aktiv prüfen muss.\n- Promises und async/await: Ein Fehler wird zur Rejection der Promise. Man fängt ihn mit `.catch()` oder mit `try/catch` um ein `await`. Wird er nirgends gefangen, entsteht eine `unhandledRejection`.\n- EventEmitter: Server, Sockets, Streams. Der Fehler kommt als `'error'`-Event. Und hier lauert die böseste Falle: Fehlt der `'error'`-Listener, wird der Fehler nicht still verschluckt – er wird als Exception geworfen und reißt den Prozess mit.\n\nDiese vier Ebenen zu trennen, ist der erste Schritt. Der zweite ist zu verstehen, dass darüber noch eine fünfte Schicht liegt: das letzte Netz, das greift, wenn auf keiner der vier Ebenen jemand zuständig war.\n\n## Ebene für Ebene: wo der Fehler wirklich ankommt\n\nNehmen wir die Ebenen einzeln durch. Synchron ist einfach – das kennt jeder:\n\n```js\nfunction parseConfig(raw) {\n  try {\n    return JSON.parse(raw);\n  } catch (err) {\n    // synchronous exception, caught right here\n    return { ok: false, reason: err.message };\n  }\n}\n```\n\nDer Denkfehler beginnt, sobald Asynchronität ins Spiel kommt. Ein `try/catch` um einen error-first-Callback ist wirkungslos, weil der Callback erst später und in einem anderen Stack aufgerufen wird:\n\n```js\nconst fs = require('node:fs');\n\n// This try/catch catches NOTHING from the callback below.\ntry {\n  fs.readFile('/etc/config.json', (err, data) => {\n    if (err) return handle(err); // the error arrives HERE, as first argument\n    process.stdout.write(data);\n  });\n} catch (err) {\n  // never reached for the async error\n}\n```\n\nDer Fehler steckt im `err`-Argument, nicht in einer geworfenen Exception. Vergisst man das `if (err) return ...`, läuft der Code mit `data === undefined` weiter – und der eigentliche Absturz passiert dann später an einer Stelle, die mit der Ursache nichts zu tun hat. Das ist die häufigste Art, wie sich Fehler in Node.js verstecken.\n\nPromises drehen das wieder zurück in Richtung `try/catch` – aber nur um `await` herum:\n\n```js\nasync function loadUser(id) {\n  try {\n    const user = await db.users.findById(id);\n    if (!user) throw new Error(`user ${id} not found`);\n    return user;\n  } catch (err) {\n    // rejection AND thrown error both land here\n    logger.warn({ err, id }, 'loadUser failed');\n    throw err; // re-throw: let the caller decide\n  }\n}\n```\n\nWichtig ist die Konsequenz, wenn niemand die Rejection beobachtet. Seit Node.js 15 ist der Default-Modus von `--unhandled-rejections` gleich `throw`. Eine unbehandelte Promise-Rejection ist keine bloße Warnung mehr, sondern beendet den Prozess. Praktisch heißt das: Jede asynchrone Kette muss entweder zu einem zuständigen Aufrufer zurückgegeben oder spätestens an einer klaren Grenze mit `.catch` beziehungsweise `try/catch` behandelt werden. Losgelöste Promises in Timern oder Callbacks brauchen diese Grenze ausdrücklich.\n\nDie EventEmitter-Ebene ist die am meisten unterschätzte. Ein Stream ohne `'error'`-Listener bringt den Prozess zum Absturz, obwohl der Fehler völlig behandelbar gewesen wäre:\n\n```js\nconst fs = require('node:fs');\n\nconst stream = fs.createReadStream('/does/not/exist');\n\n// WITHOUT this line, the emitted 'error' is thrown and crashes the process.\nstream.on('error', (err) => {\n  logger.error({ err }, 'stream failed, handled gracefully');\n});\n\nstream.on('data', (chunk) => process.stdout.write(chunk));\n```\n\nDas ist Standard-Semantik von Node: Ein EventEmitter, der ein `'error'`-Event emittiert, ohne dass ein Listener registriert ist, wirft den Fehler. Deshalb ist der `'error'`-Handler bei Servern, Sockets und Streams nicht optional, sondern architektonisch Pflicht. Wer viel mit Prozessgrenzen und Streams arbeitet, kennt das Muster aus dem Umgang mit [child_process und Skalierung](https://www.mikebild.dev/de/blog/nodejs-child-process-und-skalierung/) – auch dort ist das `'error'`-Event auf dem Kind-Prozess der Unterschied zwischen „sauber behandelt“ und „harter Absturz“.\n\n## Das letzte Netz – und wofür es nicht da ist\n\nWas passiert, wenn auf keiner der vier Ebenen jemand zuständig war? Dann greift das letzte Netz: `uncaughtException` und `unhandledRejection`.\n\n```mermaid\nflowchart TD\n  E[\"Fehler entsteht\"] --> D{\"Welche Ebene<br/>fängt ihn?\"}\n  D -->|\"try/catch\"| H[\"behandeln &<br/>weitermachen\"]\n  D -->|\"error-first callback\"| H\n  D -->|\"promise .catch\"| H\n  D -->|\"emitter 'error'\"| H\n  D -->|\"niemand zuständig\"| ESC[\"eskaliert zum<br/>letzten Netz\"]\n  ESC --> LOG[\"loggen<br/>uncaughtException /<br/>unhandledRejection\"]\n  LOG --> GS[\"Graceful Shutdown<br/>starten\"]\n  GS --> CLOSE[\"server.close()\"]\n  CLOSE --> EXIT[\"process.exit(1)\"]\n  EXIT --> SUP[\"Supervisor<br/>pm2 / systemd / K8s<br/>startet frisch\"]\n```\n\nHier liegt der entscheidende und oft missverstandene Punkt. Registriert man einen `uncaughtException`-Handler, überschreibt man damit das Default-Verhalten von Node. Ohne Handler schreibt Node den Stacktrace nach stderr und beendet den Prozess mit Exit-Code 1. Sobald ein Handler existiert, passiert dieser automatische Exit nicht mehr. Die Node-Dokumentation ist an dieser Stelle explizit: „Adding a handler overrides this default behavior.“\n\nDas bedeutet: Wenn du einen `uncaughtException`-Handler registrierst, musst du selbst dafür sorgen, dass der Prozess beendet wird. Tust du das nicht, läuft er in einem undefinierten Zustand weiter. Und genau das ist das Anti-Pattern, das ich in echten Codebasen am häufigsten sehe:\n\n```js\n// ANTI-PATTERN: swallow and keep running.\nprocess.on('uncaughtException', (err) => {\n  logger.error({ err }, 'oops'); // logged, but then... nothing\n  // no process.exit — the process stays alive in a corrupt state\n});\n```\n\nNach einer uncaught Exception ist der Zustand der Anwendung potenziell korrupt: halb abgeschlossene Transaktionen, geleakte File-Handles, ein Connection-Pool in unklarem Zustand. Die Node-Doku warnt ausdrücklich davor, danach den Normalbetrieb wieder aufzunehmen. `uncaughtException` und `unhandledRejection` sind ein letztes Netz zum sauberen Beenden – nicht zum Weitermachen.\n\nRichtig sieht das Netz so aus: loggen, dann kontrolliert beenden. Für die Rejection eskaliere ich bewusst zur Exception, damit beide Wege im selben Shutdown münden:\n\n```js\nprocess.on('unhandledRejection', (reason) => {\n  logger.error({ reason }, 'unhandled rejection, escalating');\n  // escalate to uncaughtException so we exit through one path\n  throw reason;\n});\n\nprocess.on('uncaughtException', (err, origin) => {\n  logger.fatal({ err, origin }, 'uncaught exception, shutting down');\n  process.exit(1);\n});\n```\n\nEs gibt noch eine leisere Variante, die man kennen sollte: `uncaughtExceptionMonitor`. Dieser Handler dient nur zum Beobachten und Loggen – er ändert das Crash-Verhalten nicht. Ist kein `uncaughtException`-Handler registriert, crasht der Prozess weiterhin, aber der Monitor hat vorher noch geloggt. Das ist ideal, wenn man den Absturz protokollieren will, ohne ihn versehentlich zu unterdrücken:\n\n```js\n// Log the crash without changing the crash behavior.\nprocess.on('uncaughtExceptionMonitor', (err, origin) => {\n  logger.fatal({ err, origin }, 'about to crash');\n});\n```\n\n## Graceful Shutdown: kontrolliert sterben, nicht reparieren\n\nDer Absturz über das letzte Netz ist die harte Variante. Der Normalfall im Betrieb ist ein anderer: Der Supervisor – pm2, systemd oder ein Kubernetes-Orchestrator – will den Prozess beenden, etwa bei einem Deployment oder beim Herunterskalieren. Dazu schickt er ein `SIGTERM`. Und hier ist die Anwendung dafür verantwortlich, laufende Arbeit sauber abzuschließen, nicht für Selbstreparatur.\n\n```mermaid\nsequenceDiagram\n  participant O as Orchestrator\n  participant A as App\n  O->>A: SIGTERM\n  A->>A: stop accepting new requests\n  A->>A: drain in-flight requests\n  A->>A: close DB / pools\n  A-->>O: exit 0\n  O->>O: restart fresh\n```\n\nDer Ablauf ist immer derselbe: `SIGTERM` abfangen, keine neuen Verbindungen mehr annehmen, laufende Anfragen zu Ende bringen, Ressourcen schließen, dann beenden. Der Supervisor startet danach einen frischen Prozess. Wichtig ist der Default, den man dabei überschreibt: Ohne eigenen Listener beendet Node bei `SIGTERM` mit Exit-Code `128 + 15 = 143`. Sobald man einen Listener registriert, ist man selbst für das Beenden zuständig.\n\n```js\nconst http = require('node:http');\n\nconst server = http.createServer(handler);\nserver.listen(3000);\n\nlet isShuttingDown = false;\n\nfunction shutdown(signal) {\n  if (isShuttingDown) return; // guard against double invocation\n  isShuttingDown = true;\n  logger.info({ signal }, 'graceful shutdown started');\n\n  // stop accepting new connections, finish in-flight ones\n  server.close((err) => {\n    if (err) {\n      logger.error({ err }, 'error during server.close');\n      return process.exit(1);\n    }\n    logger.info('all connections drained, exiting');\n    process.exit(0);\n  });\n\n  // fallback: never hang forever if a connection refuses to close\n  setTimeout(() => {\n    logger.error('shutdown timed out, forcing exit');\n    process.exit(1);\n  }, 10_000).unref();\n}\n\nprocess.on('SIGTERM', () => shutdown('SIGTERM'));\nprocess.on('SIGINT', () => shutdown('SIGINT'));\n```\n\nZwei Details sind hier entscheidend. Erstens das `isShuttingDown`-Flag: Ein Container kann `SIGTERM` und kurz darauf noch ein Signal schicken; ohne Guard läuft der Shutdown doppelt und `server.close` wird auf einem bereits schließenden Server aufgerufen. Zweitens der Timeout mit `.unref()`. Wenn eine hängende Verbindung dafür sorgt, dass `server.close` nie zurückkommt, bleibt der Prozess sonst ewig stehen – bis der Orchestrator irgendwann hart mit `SIGKILL` nachhilft. Der Fallback-Timeout sorgt dafür, dass wir vorher selbst kontrolliert aussteigen. Das `.unref()` verhindert dabei, dass der Timer selbst den Prozess am Leben hält, falls der saubere Weg doch rechtzeitig fertig wird.\n\nDiese Aufgabenteilung ist der Kern der ganzen Architektur: Die Anwendung ist dafür zuständig, ihre laufende Arbeit sauber zu Ende zu bringen und dann zu sterben. Für das Wiederanlaufen ist der Supervisor zuständig – nicht die Anwendung selbst. Ein Prozess, der versucht, sich nach einem schweren Fehler selbst zu heilen und weiterzulaufen, macht die Sache schlimmer. Ein Prozess, der kontrolliert stirbt und frisch neu gestartet wird, ist in Sekunden wieder in einem definierten Zustand.\n\n## Warum das eine Architekturentscheidung ist\n\nDie Verantwortlichkeiten hängen als Kette zusammen: Der Fehler entsteht auf einer der vier Ebenen. Idealerweise fängt ihn genau dort ein zuständiger Handler – im `try/catch`, in der `if (err)`-Prüfung, im `.catch`, im `'error'`-Listener. Erst wenn niemand zuständig war, eskaliert er zum letzten Netz, und das letzte Netz hat genau eine Aufgabe: sauber loggen und geordnet beenden.\n\nDas ist keine Sammlung von Einzeltricks, sondern eine durchgehende Entscheidung darüber, wo ein Fehler behandelt wird und wo er nur noch zum kontrollierten Ende führt. Man entscheidet einmal, wie das Netz gespannt ist, und dann hält es überall – oder man entscheidet es nicht, und dann reißt es an der Stelle, an der man nie hingeschaut hat.\n\nWer das Zusammenspiel von Event Loop, Callbacks und Promises grundsätzlich vertiefen will, findet den Unterbau dazu im [Node.js-Training zur Event-Loop-Architektur](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/). Denn erst wenn klar ist, warum ein Callback in einem anderen Stack läuft als der `try/catch`, der ihn umgibt, wird verständlich, warum Error Handling in Node.js eben nicht mit „überall try/catch“ erledigt ist.\n\nBehandle jeden Fehler so nah wie möglich an der Ebene, auf der er entsteht. Spann das letzte Netz nur zum sauberen Sterben, nie zum Weiterleben. Und überlass das Wiederauferstehen dem Supervisor. Ein Dienst, der weiß, wie er stirbt, ist stabiler als einer, der um jeden Preis am Leben bleiben will.\n\n## Weiterführende Quellen\n\n- Repository mit Beispielen: [MikeBild/introduction-nodejs](https://github.com/MikeBild/introduction-nodejs)\n- [process – Event: 'uncaughtException'](https://nodejs.org/api/process.html#event-uncaughtexception)\n- [process – Event: 'unhandledRejection'](https://nodejs.org/api/process.html#event-unhandledrejection)\n- [process – Signal-Events](https://nodejs.org/api/process.html#signal-events)\n- [events – 'error'-Events des EventEmitter](https://nodejs.org/api/events.html#error-events)\n- [CLI – --unhandled-rejections=mode](https://nodejs.org/api/cli.html#--unhandled-rejectionsmode)\n- [Node.js Process API](https://nodejs.org/api/process.html)"},{"slug":"dora-metriken-was-zyklen-verkuerzt","title":"DORA-Metrics: was Entwicklungszyklen wirklich verkürzt","summary":"Lead Time, Deploy-Frequenz, Change-Fail-Rate und Recovery-Zeit sind Diagnosewerte statt Vanity-Velocity. Sie zeigen, wo der Zyklus klemmt – und warum Tempo und Stabilität kein Gegensatz sein müssen.","tags":["Software","Delivery"],"published_at":"2023-10-24T10:00:00+00:00","updated_at":"2026-07-19T18:46:18.505233+00:00","url":"https://www.mikebild.dev/de/blog/dora-metriken-was-zyklen-verkuerzt/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/edf438fba37832372b531e27f01f5fbb19b5371e259180e3d1d58d07288829db/dora-metriken-was-zyklen-verkuerzt-vorlesen.mp3","audio_duration_secs":667,"text":"Deployment Frequency, Lead Time for Changes, Change Failure Rate, Failed Deployment Recovery Time: vier Messwerte, die niemand schätzen und über die niemand abstimmen muss. Sie fallen in Systemen an, die ohnehin laufen – Commit-Timestamp im Versionskontrollsystem, Deploy-Event der CD-Pipeline, Incident-Log. Kein Board, keine Punkte, keine Selbstauskunft. Die Recovery-Metric hieß in älteren DORA-Texten „Time to Restore Service“.\n\n**Redaktionelle Einordnung (Juli 2026):** Dieser Beitrag beschreibt das damalige Modell aus vier Delivery-Metrics. DORA ergänzte 2024 die **Deployment Rework Rate** als fünfte Kennzahl. Der aktuelle Kanon besteht damit aus drei Throughput- und zwei Instability-Metrics; die historische Vierergruppe im Haupttext bleibt als Ausgangspunkt erhalten.\n\nEine Velocity-Kurve entsteht auf dem umgekehrten Weg. Sie summiert Story Points, die ein Team sich selbst zugeschrieben hat, und kann steil nach oben zeigen, während Releases zäh bleiben, Hotfixes sich stauen und freitags niemand deployen will. Die vier Messwerte oben können das nicht. Sie beschreiben den Weg einer Änderung vom Commit bis in Produktion – und was passiert, wenn diese Änderung Schaden anrichtet.\n\nDiese vier DORA-Keys stammen aus der DevOps-Research-and-Assessment-Arbeit rund um Nicole Forsgren, Jez Humble und Gene Kim, sind im Buch *Accelerate* (2018) statistisch unterfüttert und werden seither jährlich im *State of DevOps Report* fortgeschrieben. Ihr Wert liegt in der Diagnose: Sie zeigen, an welcher Stelle der Kette es hakt. Im Folgenden geht es darum, wo genau die Keys den Engpass sichtbar machen und wie man sie erhebt, ohne dass daraus ein neues Zahlenspiel wird.\n\nDie Messgrößen lassen sich zuerst nach der Frage ordnen, die sie beantworten. Das verhindert, dass Tempo und Instabilität zu einer scheinbar eindeutigen Gesamtnote verschmelzen.\n\n::::comparison{title=\"Delivery-Leistung hat zwei Achsen\" role=\"primary\" preferredPattern=\"comparison-matrix\"}\n:::side{label=\"Throughput\"}\n- Deployment Frequency · Wie oft erreicht eine Änderung Produktion?\n- Lead Time for Changes · Wie lange dauert der Weg vom Commit bis zum Deploy?\n:::\n:::side{label=\"Instability\"}\n- Change Failure Rate · Welcher Anteil der Änderungen verursacht eine Störung?\n- Failed Deployment Recovery Time · Wie lange dauert die Erholung nach einem fehlgeschlagenen Deploy?\n- Deployment Rework Rate · Welcher Anteil erzeugt ungeplante Nacharbeit?\n:::\n::::\n\nDie Einteilung ist keine Rangliste. Erst gemeinsam zeigen beide Seiten, ob ein Team schnell liefern kann, ohne die Kosten fehlgeschlagener Änderungen auszublenden.\n\n## Zwei Achsen statt einer Zahl\n\nDer erste Denkfehler ist, Delivery-Performance als eine einzige Größe zu behandeln. Die vier Keys spannen bewusst zwei Achsen auf.\n\nAuf der Throughput-Achse liegen **Deployment Frequency** – wie oft geht Code in Produktion – und **Lead Time for Changes** – wie lange dauert es vom Commit bis zum erfolgreichen Deploy. Das ist die Tempo-Seite: kleine Batches, kurze Wege.\n\nAuf der Stability-Achse liegen **Change Failure Rate** – welcher Anteil der Deploys eine Störung oder einen Rollback auslöst – und **Failed Deployment Recovery Time** – wie lange die Erholung nach einem fehlgeschlagenen Deployment dauert. Das ist die Seite der Robustheit: wie teuer ein Fehler wird.\n\nDie zentrale, kontraintuitive Erkenntnis aus *Accelerate*: Diese beiden Achsen stehen nicht im Gegensatz. Die Daten zeigen, dass Elite-Teams auf **beiden** gleichzeitig gewinnen – häufiger deployen *und* seltener scheitern *und* schneller wieder online sind. Der alte Reflex \"Wenn wir schneller liefern, sinkt die Qualität\" ist empirisch widerlegt. Genau deshalb ist es fahrlässig, nur zwei der vier Metrics zu messen. Wer allein Deploy-Frequenz und Lead Time trackt, optimiert Tempo und blendet die Stabilität aus – und produziert exakt die Vanity-Illusion, gegen die die DORA-Keys eigentlich antreten.\n\n```mermaid\nquadrantChart\n  title Throughput vs. Stability\n  x-axis Low Throughput --> High Throughput\n  y-axis Low Stability --> High Stability\n  quadrant-1 Elite\n  quadrant-2 Slow but Safe\n  quadrant-3 Low Performer\n  quadrant-4 Fragile Speed\n  Elite Team: [0.85, 0.85]\n  Big-Batch Team: [0.2, 0.6]\n  Move-Fast-Break-Things: [0.8, 0.25]\n  Stuck Team: [0.2, 0.2]\n```\n\nDie Matrix macht klar: Das Ziel ist rechts oben, nicht ein Kompromiss auf der Diagonale. Und sie erklärt, warum eine einzelne Zahl in die Irre führt – ein Team im Quadranten \"Fragile Speed\" sieht auf einer reinen Tempo-Metric glänzend aus und brennt trotzdem.\n\n## Wo der Zyklus klemmt\n\nDer eigentliche Wert der Keys liegt nicht im Benchmark, sondern in der Lokalisierung. Jede Metric hängt an einem anderen Segment der Wertschöpfungskette vom Commit bis zur Produktion – und zeigt damit, an welcher Stelle es hakt.\n\n```mermaid\nflowchart LR\n  A[Commit] --> B[CI Build]\n  B --> C[Test]\n  C --> D[Deploy Gate]\n  D --> E[Production]\n  E -->|Incident| F[Restore]\n  F --> E\n  A -. Lead Time for Changes .-> E\n  D -. Deployment Frequency .-> E\n  E -. Change Failure Rate .-> F\n  F -. Failed Deployment Recovery Time .-> E\n  style D fill:#e8543f,color:#fff\n```\n\nEin Beispiel aus der Praxis: Ein Team klagt über \"langsame Releases\". Die Lead Time liegt im Median bei elf Tagen. Zerlegt man sie, stecken davon neun Tage zwischen \"Feature fertig\" und \"Deploy Gate\" – die Pipeline und der manuelle Freigabeschritt sind der Engpass, nicht das Programmieren. Ein anderes Team hat eine gute Lead Time, aber eine Change Failure Rate von 30 Prozent und eine Restore-Zeit von Stunden: Hier ist nicht das Tempo das Problem, sondern die Umkehrbarkeit von Änderungen. Dieselben vier Zahlen, völlig unterschiedliche Therapie. Eine aggregierte Velocity hätte in beiden Fällen nur \"das Team ist ausgelastet\" gesagt.\n\nLead Time for Changes ist dabei mein wichtigster Frühindikator. Sie ist definiert als die Zeit vom Code-Commit bis zum erfolgreichen Lauf in Produktion und korreliert eng mit der Batch-Größe. Große Batches verlängern das Feedback, erhöhen das Fehlerrisiko und machen jedes Deployment zum Event – darauf pochen Donald Reinertsen und Jez Humble seit Jahren, und *Accelerate* belegt es mit Daten. Kurze Lead Time zwingt fast automatisch zu kleinen, kontinuierlichen Änderungen.\n\n## Change Failure Rate und Recovery-Zeit sind gekoppelt\n\nEin verbreitetes Missverständnis ist, Stabilität heiße „keine Fehler“. Das ist nicht die Aussage der DORA-Daten. Die Kopplung aus Change Failure Rate und Failed Deployment Recovery Time sagt etwas Feineres: Nicht die Abwesenheit von Fehlern zählt, sondern deren Preis. Hohe Deploy-Frequenz plus niedrige Change Failure Rate plus kurze Recovery-Zeit bedeutet, dass Änderungen klein und umkehrbar sind. Ein Fehler, den ich in Minuten zurückrolle, ist billig. Ein Zero-Defect-Anspruch, der jeden Release zur Zitterpartie macht, ist teuer – gerade weil er selten geübtes, riskantes Deployen erzwingt.\n\nDeshalb schlägt eine kurze Restore-Zeit in der Praxis den Anspruch auf Fehlerfreiheit. Und deshalb gehören die Nachbar-Techniken hier direkt dazu: Feature Flags, um Änderungen ohne Deploy scharf oder wieder unscharf zu schalten; Canary- und Blue-Green-Releases, um Blast-Radius zu begrenzen; Circuit Breaker und Bulkheads aus Michael Nygards *Release It!*, um Ausfälle einzudämmen. Keine Buzzwords: genau das sind die Hebel, mit denen man Change Failure Rate senkt und Restore-Zeit verkürzt.\n\nIm Code sieht die Kopplung von umkehrbarem Rollout und automatischem Recovery zum Beispiel so aus:\n\n```typescript\n// Canary rollout gated by the live error rate.\n// Ties Change Failure Rate and recovery time together in one control loop.\ninterface RolloutConfig {\n  canaryPercentage: number;\n  maxErrorRate: number;      // e.g. 0.02 -> roll back above 2%\n  observationWindowMs: number;\n}\n\nasync function progressiveRollout(\n  release: Release,\n  config: RolloutConfig,\n): Promise<RolloutResult> {\n  await featureFlags.enableForCohort(release.id, config.canaryPercentage);\n\n  const errorRate = await metrics.observeErrorRate(\n    release.id,\n    config.observationWindowMs,\n  );\n\n  if (errorRate > config.maxErrorRate) {\n    await featureFlags.disable(release.id); // instant, no redeploy\n    return { status: \"rolled_back\", observedErrorRate: errorRate };\n  }\n\n  await featureFlags.enableForCohort(release.id, 100);\n  return { status: \"promoted\", observedErrorRate: errorRate };\n}\n```\n\nEs geht nicht um die konkrete Bibliothek, es geht um das Prinzip: Recovery passiert per Flag-Toggle in Sekunden, nicht per Notfall-Deploy. Genau das drückt sich später in einer niedrigen Restore-Zeit aus.\n\n## Die Metrics messen, ohne sie zu erfinden\n\nDamit die Keys ehrlich bleiben, müssen sie aus Systemen kommen, die man nicht von Hand pflegt. Lead Time zum Beispiel lässt sich vollständig aus zwei Quellen ableiten: dem Commit-Timestamp im Versionskontrollsystem und dem Deploy-Event der CD-Pipeline. Keine Schätzung, keine Selbstauskunft.\n\n```typescript\n// Lead Time for Changes, derived from VCS + deploy events.\n// leadTime = deployedAt(prodDeployment) - firstCommitTime(changeSet)\nfunction leadTimeForChange(deployment: ProdDeployment): number {\n  const firstCommit = deployment.changeSet\n    .map((commit) => commit.authoredAt.getTime())\n    .reduce((min, t) => Math.min(min, t), Infinity);\n\n  return deployment.deployedAt.getTime() - firstCommit;\n}\n\n// Aggregate as a median over a rolling window -- means get skewed by outliers.\nfunction medianLeadTime(deployments: ProdDeployment[]): number {\n  const values = deployments.map(leadTimeForChange).sort((a, b) => a - b);\n  const mid = Math.floor(values.length / 2);\n  return values.length % 2 === 0\n    ? (values[mid - 1] + values[mid]) / 2\n    : values[mid];\n}\n```\n\nZwei Details sind wichtig. Erstens der Median statt des Mittelwerts – ein einzelner liegengebliebener Branch würde jeden Durchschnitt ruinieren. Zweitens das rollierende Zeitfenster: Die Zahl ist ein Trend, kein Tagestreffer.\n\nNoch wirksamer wird das Ganze, wenn man eine Metric nicht nur anzeigt, sondern in die Pipeline zurückkoppelt. Neal Ford, Rebecca Parsons und Patrick Kua nennen das in *Building Evolutionary Architecture* eine Fitness Function – eine ausführbare Regel, die eine Architektur-Eigenschaft überprüfbar macht. Eine DORA-Fitness-Function kann die Pipeline rot färben, wenn die Change Failure Rate über der Schwelle liegt:\n\n```python\n# Executable fitness function: assert a DORA metric as a CI gate.\ndef assert_change_failure_rate(window_days: int = 30, threshold: float = 0.15):\n    deployments = metrics_db.query_deployments(window_days)\n    failed = [d for d in deployments if d.caused_incident or d.rolled_back]\n\n    if not deployments:\n        return  # nothing to assert yet\n\n    change_failure_rate = len(failed) / len(deployments)\n    assert change_failure_rate < threshold, (\n        f\"Change Failure Rate {change_failure_rate:.0%} \"\n        f\"exceeds threshold {threshold:.0%} over {window_days}d\"\n    )\n```\n\nSo verbindet sich DORA mit der Idee der evolutionären Architektur: Die Metric ist keine Folie fürs Quartalsmeeting mehr, sondern eine Bedingung, die das System an sich selbst stellt.\n\n## Der Fallstrick: Metrics als Ziel\n\nDamit zur größten Gefahr. Sobald eine Metric zum Ziel wird, hört sie auf, ein gutes Maß zu sein – Goodhart's Law in Reinform. Deployment Frequency lässt sich mit trivialen Leer-Deploys aufblähen. Change Failure Rate lässt sich schönen, indem man \"Failure\" so lange umdefiniert, bis kaum noch etwas hineinfällt. Wer die Keys als Team-Ranking oder Bonus-KPI benutzt, bekommt genau dieses Gaming – und DORA selbst warnt ausdrücklich davor, Teams damit zu vergleichen. Die vier Zahlen sind Diagnose, nicht Wettbewerb.\n\nZwei weitere Verwechslungen sehe ich häufig. Erstens: Die Keys sind kein Ersatz für Story-Point-Velocity im Sinne von \"gleiche Sache, bessere Zahlen\". Es ist ein anderes Konstrukt – Velocity misst den Output eines einzelnen Teams und ist weder vergleichbar noch fälschungssicher; die Keys messen den Fluss vom Commit bis Produktion über das ganze Delivery-System. Zweitens: Schnelle Deploys ohne Wirkung sind \"faster to nowhere\". Deshalb hat DORA in den Reports 2021 und 2022 eine fünfte Dimension ergänzt, die **Reliability** beziehungsweise operative Performance. Die Aussage von 2022: Hohe Delivery-Performance zahlt nur dann auf den Organisationserfolg ein, wenn auch die Zuverlässigkeit im Betrieb stimmt. Ich behandle Reliability bewusst als jüngere, weichere Ergänzung – nicht als fünften, gleichrangigen Kanon. Aber der Gedanke gehört ins \"ehrlich statt Vanity\"-Framing: Tempo ohne Betriebsstabilität ist eine Vanity-Metric mit besserem Marketing.\n\n## Fazit\n\nDer rote Faden ist einfach. Velocity misst, was ein Team von sich behauptet. Die vier DORA-Keys messen, was tatsächlich passiert – vom Commit bis Produktion und zurück, wenn es kracht. Sie tun das auf zwei Achsen, Tempo und Stabilität, und die Kernaussage von *Accelerate* ist, dass diese Achsen kein Tradeoff sind: Die guten Teams gewinnen auf beiden.\n\nPraktisch nutze ich die Keys als Suchscheinwerfer. Lange Lead Time zeigt große Batches oder eine verstopfte Pipeline. Hohe Change Failure Rate zeigt fehlende Umkehrbarkeit. Lange Restore-Zeit zeigt, dass Recovery nicht geübt ist. Jede Zahl deutet auf eine konkrete, technische Gegenmaßnahme – kleine Batches, Trunk-Based Development, Feature Flags, Canary-Releases, Circuit Breaker. Das ist der Unterschied zwischen einer Metric, die den Zyklus verkürzt, und einer, die nur ein Dashboard füllt.\n\nUnd die Disziplin dabei: Die Keys diagnostizieren, sie belohnen nicht. In dem Moment, in dem sie zum Ziel werden, verlieren sie ihren Wert. Wer das aushält – Zahlen anschauen, ohne sie zu jagen – bekommt etwas Seltenes: einen ehrlichen Blick darauf, wo der eigene Zyklus wirklich klemmt.\n\n## Weiterführende Quellen\n\n- [DORA Metrics – dora.dev](https://dora.dev/guides/dora-metrics/)\n- [Accelerate State of DevOps Report 2022 – dora.dev](https://dora.dev/research/2022/dora-report/)\n- [DORA 2022 Report – Google Cloud Blog](https://cloud.google.com/blog/products/devops-sre/dora-2022-accelerate-state-of-devops-report-now-out)\n- [Four Keys to measure DevOps performance – Google Cloud](https://cloud.google.com/blog/products/devops-sre/using-the-four-keys-to-measure-your-devops-performance)\n- [A history of DORA's metrics – dora.dev](https://dora.dev/insights/dora-metrics-history/)"},{"slug":"nodejs-child-process-und-skalierung","title":"child_process in Node.js: Skalierung ohne Illusionen","summary":"Node.js läuft auf einem einzigen Event Loop – CPU-Arbeit blockiert alles. Dieser Beitrag zeigt, wann fork, cluster und worker_threads wirklich helfen und wann sie nur Overhead sind.","tags":["Software","Node.js","Backend"],"published_at":"2023-09-19T10:00:00+00:00","updated_at":"2026-07-13T08:00:45.524078+00:00","url":"https://www.mikebild.dev/de/blog/nodejs-child-process-und-skalierung/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/03aced76d96ec7a37df011e137c4b2045b7368918db7f9f930109c28376a74ba/nodejs-child-process-und-skalierung-vorlesen.mp3","audio_duration_secs":784,"text":"Nicht-blockierend heißt nicht parallel. Node.js nimmt Tausende gleichzeitiger Netzwerkverbindungen an, ohne dabei ins Straucheln zu geraten – Rechenarbeit auf mehrere Kerne verteilt es deshalb noch lange nicht. Die beiden Eigenschaften werden ständig in einen Topf geworfen, und aus dieser Verwechslung entsteht der Endpoint, der unter Last die gesamte Anwendung einfriert, ohne dass jemand versteht, warum.\n\nDer Unterschied liegt darin, woraus die Arbeit besteht: aus Warten oder aus Rechnen. Warten bekommt Node von allein in den Griff. Rechnen nicht. Dafür gibt es `child_process`, `cluster` und `worker_threads` – drei Werkzeuge mit ähnlichen Namen, sehr verschiedenen Kosten und einem gemeinsamen Missbrauchsmuster.\n\n## Ein Event Loop, ein Thread, ein Nadelöhr\n\nFangen wir bei der Wurzel an. Ein Node.js-Prozess hat einen Event Loop, und JavaScript läuft in diesem Prozess auf genau einem Thread. Das ist kein Bug, das ist das Designprinzip. Solange die Arbeit aus Warten besteht – auf eine Datenbankantwort, auf eine Datei, auf einen HTTP-Response – kann der Event Loop in der Zwischenzeit andere Dinge erledigen. Die eigentliche Ein- und Ausgabe passiert im Hintergrund über libuv, und dein Code bekommt am Ende nur den fertigen Callback. Wer verstehen will, wie diese Maschinerie im Detail tickt, findet in meinem [Node.js-Training zur Event-Loop-Architektur](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/) die ausführliche Fassung.\n\nDas Problem beginnt, sobald die Arbeit nicht aus Warten besteht, sondern aus Rechnen. Ein Bild skalieren, ein großes JSON parsen und transformieren, ein Passwort mit einer teuren Hash-Funktion prüfen, eine kryptografische Operation, eine synchrone Schleife über Millionen Einträge. Diese Arbeit findet auf demselben einen Thread statt. Und solange sie läuft, kann der Event Loop nichts anderes tun – keine neuen Requests annehmen, keine Timer feuern, keine Callbacks abarbeiten. Alles steht.\n\nDer folgende Code sieht harmlos aus und rutscht in Code-Reviews trotzdem regelmäßig durch:\n\n```js\nconst http = require('node:http');\n\nfunction fibonacci(n) {\n  if (n < 2) return n;\n  return fibonacci(n - 1) + fibonacci(n - 2);\n}\n\nhttp\n  .createServer((req, res) => {\n    if (req.url === '/compute') {\n      const result = fibonacci(45); // blocks the event loop for seconds\n      res.end(`result: ${result}`);\n    } else {\n      res.end('ok');\n    }\n  })\n  .listen(3000);\n```\n\nSolange `fibonacci(45)` rechnet, bekommt kein einziger anderer Client eine Antwort – auch nicht auf den harmlosen `/`-Pfad, der eigentlich in Mikrosekunden fertig wäre. Ein einziger langsamer Request legt den ganzen Server lahm. Genau das meint die offizielle Doku mit „Don't Block the Event Loop“.\n\n## Vier Werkzeuge, vier Aufgaben\n\nNode.js bietet für dieses Problem mehrere Werkzeuge an, und die werden regelmäßig verwechselt. Es lohnt sich, sie sauber auseinanderzuhalten:\n\n- `child_process` startet einen komplett eigenen Betriebssystemprozess mit eigenem V8, eigenem Heap, eigenem Event Loop. Kommunikation läuft über Pipes bzw. – bei `fork` – über einen IPC-Kanal per Message-Passing. Kein geteilter Speicher.\n- `cluster` startet mehrere Node-Instanzen hinter einem gemeinsamen Port und verteilt eingehende Verbindungen auf sie. Es baut intern auf `child_process.fork()` auf und dient dem Request-Durchsatz.\n- `worker_threads` startet einen weiteren Thread innerhalb desselben Prozesses. Jeder Worker hat sein eigenes V8-Isolate und seinen eigenen Event Loop, kann aber optional echten Speicher mit dem Hauptthread teilen. Gedacht für CPU-Arbeit.\n- Die normalen asynchronen APIs sind für alles, was I/O ist – und das braucht keine der drei oberen Optionen.\n\nDiesen letzten Punkt betone ich bewusst, weil dort der häufigste Denkfehler lauert. Aber der Reihe nach.\n\n## child_process: spawn, exec, execFile, fork\n\nDas Modul `node:child_process` bietet vier Wege, einen Kindprozess zu starten, und die Unterschiede sind praxisrelevant.\n\n`spawn(command, args, options)` startet ein Programm und gibt dir dessen `stdout` und `stderr` als Streams. Es puffert nichts und startet standardmäßig keine Shell. Das ist die richtige Wahl, wenn die Ausgabe groß sein kann oder du sie streamend verarbeiten willst.\n\n`exec(command, options, callback)` startet eine echte Shell und puffert die gesamte Ausgabe im Speicher, bevor der Callback aufgerufen wird. Das ist bequem für kurze Kommandos, hat aber zwei Tücken: Der Default für `maxBuffer` liegt bei 1 MB – wird die Ausgabe größer, killt Node den Prozess mit `ERR_CHILD_PROCESS_STDIO_MAXBUFFER`. Und weil eine Shell im Spiel ist, ist `exec` mit ungeprüftem Input eine Einladung zur Shell-Injection.\n\n`execFile(file, args, options, callback)` verhält sich wie `exec`, startet aber keine Shell. Das ist sicherer und effizienter, wenn du ohnehin nur ein konkretes Programm mit Argumenten aufrufst.\n\n```js\nconst { execFile } = require('node:child_process');\n\nexecFile('git', ['rev-parse', 'HEAD'], (err, stdout) => {\n  if (err) throw err;\n  console.log('current commit:', stdout.trim());\n});\n```\n\nDie Auswahl passt in drei Zeilen: kurze Ausgabe eines konkreten Programms – `execFile`. Große oder streamende Ausgabe – `spawn`. `exec` mit Shell nur, wenn du die Shell-Features (Pipes, Globbing) wirklich brauchst und den Input kontrollierst.\n\nDer interessanteste der vier ist `fork`. Es ist ein Spezialfall von `spawn`, der gezielt eine neue Node-Instanz startet – über `process.execPath`, denselben Node-Binary – und automatisch einen IPC-Kanal einrichtet. Damit kannst du zwischen Eltern- und Kindprozess Messages austauschen, ohne dich um Pipes und Serialisierung von Hand zu kümmern.\n\n## Das Hauptbeispiel: schwere Berechnung in einem fork\n\nNehmen wir das blockierende Fibonacci-Beispiel von oben und lagern die Rechnung in einen Kindprozess aus. Der Elternprozess bleibt reaktionsfähig, weil die CPU-Last in einem völlig anderen OS-Prozess landet.\n\nZuerst der Worker in `worker.js`:\n\n```js\n// worker.js\nfunction fibonacci(n) {\n  if (n < 2) return n;\n  return fibonacci(n - 1) + fibonacci(n - 2);\n}\n\nprocess.on('message', ({ n }) => {\n  const result = fibonacci(n);\n  process.send({ n, result });\n});\n```\n\nUnd der Elternprozess, der ihn startet und beauftragt:\n\n```js\n// main.js\nconst { fork } = require('node:child_process');\nconst http = require('node:http');\n\nconst child = fork('./worker.js');\n\nconst pending = new Map();\nchild.on('message', ({ n, result }) => {\n  const res = pending.get(n);\n  if (res) {\n    res.end(`fibonacci(${n}) = ${result}`);\n    pending.delete(n);\n  }\n});\n\nhttp\n  .createServer((req, res) => {\n    if (req.url === '/compute') {\n      pending.set(45, res);\n      child.send({ n: 45 }); // heavy work happens elsewhere\n    } else {\n      res.end('ok'); // stays responsive during the computation\n    }\n  })\n  .listen(3000);\n```\n\nDer entscheidende Punkt an dieser Stelle: Der `/`-Pfad antwortet jetzt sofort, auch während `/compute` noch rechnet. Der Event Loop des Hauptprozesses ist frei, weil die teure Schleife in einem separaten Prozess mit eigenem V8-Heap läuft. Die Kommunikation zwischen beiden geht ausschließlich über Messages – hier per `child.send()` und dem `message`-Event.\n\nGenau hier lohnt ein zweiter Blick: `send` ist kein Shared Memory. Die Message wird serialisiert und im Kindprozess als Kopie neu aufgebaut. Der Default ist `serialization: 'json'`, und JSON verliert Typen – ein `Date` wird zum String, `undefined` verschwindet, `BigInt` funktioniert gar nicht. Wer strukturierte Werte über IPC schicken will, kann `serialization: 'advanced'` setzen, was den Structured-Clone-Algorithmus nutzt und deutlich mehr Typen erhält.\n\nDer folgende Ablauf zeigt das Prinzip als Sequenz:\n\n```mermaid\nsequenceDiagram\n    participant Client\n    participant Main as Main Prozess<br/>(Event Loop frei)\n    participant Child as Forked Prozess<br/>(eigener V8-Heap)\n    Client->>Main: GET /compute\n    Main->>Child: send({ n: 45 })\n    Note over Main: Event Loop bleibt frei<br/>für andere Requests\n    Child->>Child: heavy computation\n    Child-->>Main: message({ result })\n    Main-->>Client: HTTP 200 result\n```\n\n## worker_threads: dasselbe Ziel, geteilter Speicher\n\nEin `fork` ist robust, aber teuer. Jeder Kindprozess bringt ein vollständiges V8 und einen eigenen Heap mit – das kostet Speicher und Startzeit. Wenn du CPU-Arbeit parallelisieren willst und dabei nicht die volle Isolation eines eigenen Prozesses brauchst, sind `worker_threads` die leichtere Alternative. Sie sind seit Node 12 stabil und 2023 längst produktiver Standard.\n\nEin Worker läuft im selben Prozess, aber in einem eigenen Thread mit eigenem V8-Isolate und eigenem Event Loop. Dieselbe Rechnung sieht so aus:\n\n```js\n// task.js\nconst { parentPort, workerData } = require('node:worker_threads');\n\nfunction fibonacci(n) {\n  if (n < 2) return n;\n  return fibonacci(n - 1) + fibonacci(n - 2);\n}\n\nparentPort.postMessage(fibonacci(workerData.n));\n```\n\n```js\n// main-threads.js\nconst { Worker } = require('node:worker_threads');\n\nfunction computeFib(n) {\n  return new Promise((resolve, reject) => {\n    const worker = new Worker('./task.js', { workerData: { n } });\n    worker.on('message', resolve);\n    worker.on('error', reject);\n  });\n}\n\ncomputeFib(45).then((result) => {\n  console.log('fibonacci(45) =', result);\n});\n```\n\nDer große Unterschied zum `fork`: Worker-Threads können echten Speicher teilen. Ein `SharedArrayBuffer` ist für Haupt- und Worker-Thread derselbe Speicherbereich – keine Kopie, keine Serialisierung. Alternativ lässt sich ein `ArrayBuffer` per `transferList` übergeben, was ein Zero-Copy-Transfer ist: Der Buffer wandert zum Empfänger und ist beim Sender danach unbrauchbar. Für große Datenmengen zwischen Threads ist das ein echter Vorteil, den ein separater Prozess prinzipbedingt nicht bieten kann.\n\n## Der Fallstrick, der am meisten Schaden anrichtet\n\nDer Fehler, der in echten Codebasen die meiste Zeit verbrennt, ist dieser: I/O-bound Arbeit mit `worker_threads` oder `cluster` „beschleunigen“ zu wollen. Er ist mir in mehreren Projekten begegnet, jedes Mal mit derselben Begründung.\n\nDie Motivation ist verständlich. Jemand hat gehört, dass Worker parallelisieren, sieht einen Endpoint, der viele Datenbankabfragen oder HTTP-Calls macht, und packt das Ganze in einen Worker-Pool. Das Ergebnis ist regelmäßig langsamer als vorher. Der Grund: Netzwerk- und Datei-I/O ist in Node.js bereits asynchron und blockiert den Event Loop überhaupt nicht. Da ist nichts zu entlasten. Die Doku sagt es unmissverständlich: Worker-Threads helfen bei I/O-intensiver Arbeit nicht nennenswert. Was du bekommst, ist der Overhead für Thread-Erstellung und Message-Passing – ohne jeden Gewinn.\n\nDie Regel ist also einfach und trotzdem wird ständig gegen sie verstoßen:\n\n- Ist die Arbeit I/O-bound – Datenbank, Netzwerk, Dateisystem? Dann bleib bei den normalen asynchronen APIs. Worker und Prozesse bringen hier nichts außer Komplexität.\n- Ist die Arbeit CPU-bound – Hashing, Bildverarbeitung, Kompression, Krypto, große synchrone Transformationen? Erst dann sind `worker_threads` oder `child_process` das richtige Werkzeug.\n\nEin zweiter Fallstrick sitzt gleich daneben: pro Task einen Worker oder Prozess zu erzeugen. Die Erstellung ist teuer – V8 muss initialisiert werden, Speicher wird allokiert. Wer bei jedem Request einen frischen Worker startet, macht die Sache oft langsamer, nicht schneller. Die Antwort heißt Worker-Pool: eine feste Zahl wiederverwendbarer Worker, die Aufgaben aus einer Queue abarbeiten. Dieselbe Überlegung gilt für `fork` – halte die Kindprozesse am Leben und schick ihnen nacheinander Arbeit, statt sie ständig neu zu starten.\n\n## cluster: Durchsatz, nicht Entlastung\n\nBleibt `cluster`, und auch hier gibt es ein hartnäckiges Missverständnis. `cluster` löst nicht das Problem der einzelnen blockierenden Berechnung. Es löst das Problem des Durchsatzes: Ein einzelner Node-Prozess nutzt nur einen Kern, aber deine Maschine hat mehrere. `cluster` startet pro Kern eine Node-Instanz, alle lauschen hinter demselben Port, und der Primary verteilt eingehende Verbindungen im Round-Robin.\n\n```js\nconst cluster = require('node:cluster');\nconst http = require('node:http');\nconst os = require('node:os');\n\nif (cluster.isPrimary) {\n  for (let i = 0; i < os.cpus().length; i++) {\n    cluster.fork();\n  }\n  cluster.on('exit', (worker) => {\n    console.log(`worker ${worker.process.pid} died, restarting`);\n    cluster.fork();\n  });\n} else {\n  http\n    .createServer((req, res) => res.end(`handled by pid ${process.pid}`))\n    .listen(3000);\n}\n```\n\nDamit verdoppelt oder vervierfacht sich der Durchsatz an gleichzeitigen Requests. Aber wenn ein einzelner Request eine sekundenlange synchrone Berechnung auslöst, blockiert diese immer noch den Worker, der sie bearbeitet. `cluster` macht aus vielen langsamen Requests nur dann viele parallele, wenn jeder auf einem anderen Worker landet – und die begrenzte Zahl an Workern ist irgendwann erschöpft. Für das eigentliche CPU-Problem pro Request brauchst du weiterhin `worker_threads` oder ausgelagerte Prozesse. Übrigens ist `cluster.isPrimary` seit Node 16 der bevorzugte Name; `isMaster` existiert noch, gilt aber als veraltet.\n\n## Wann was: die Entscheidung auf einen Blick\n\nDie Entscheidung lässt sich auf wenige Fragen eindampfen:\n\n```mermaid\nflowchart TD\n    A[Arbeit einordnen] --> B{I/O-bound?}\n    B -->|Ja| C[Normale async APIs<br/>keine Worker nötig]\n    B -->|Nein, CPU-bound| D{Geteilter Speicher<br/>oder leichtgewichtig?}\n    D -->|Ja| E[worker_threads<br/>SharedArrayBuffer möglich]\n    D -->|Nein, Isolation<br/>oder externes Skript| F[child_process / fork<br/>eigener Prozess + IPC]\n    A --> G{Nur mehr Durchsatz<br/>auf allen Kernen?}\n    G -->|Ja| H[cluster<br/>mehrere Instanzen, ein Port]\n```\n\nDie drei Werkzeuge beantworten verschiedene Fragen – sie konkurrieren nicht um dieselbe:\n\n- `child_process` / `fork` gibt dir einen eigenen Prozess mit eigenem Heap und Kommunikation über kopierte IPC-Messages – maximale Isolation, ideal für externe Programme oder eigenständige Node-Skripte.\n- `cluster` gibt dir mehrere Node-Instanzen hinter einem Port für mehr Request-Durchsatz – es skaliert Instanzen, nicht einzelne Berechnungen.\n- `worker_threads` gibt dir Threads im selben Prozess mit optional geteiltem Speicher – die richtige Wahl, um CPU-Arbeit zu parallelisieren, ohne einen ganzen Prozess hochzufahren.\n\n## Fazit\n\nDie Illusion, gegen die dieser Beitrag anschreibt, ist die bequemste: dass Node.js CPU-Arbeit schon irgendwie von allein auf alle Kerne verteilt. Tut es nicht. Ein Prozess, ein Event Loop, ein JS-Thread – und jede synchrone Rechenlast blockiert alles andere. Wer das akzeptiert, trifft danach die richtigen Entscheidungen fast von selbst: I/O braucht keine Worker, sondern nur die asynchronen APIs, die Node ohnehin mitbringt. Erst CPU-Arbeit rechtfertigt schwereres Geschütz – `worker_threads` für Parallelität im Prozess mit geteiltem Speicher, `fork` für isolierte Prozesse, `cluster` für reinen Durchsatz.\n\nDer teuerste Fehler ist nicht, das falsche Werkzeug zu wählen. Er ist, ein Werkzeug zu greifen, wo gar kein Problem ist – I/O mit Workern zu „beschleunigen“ und dafür Komplexität und Overhead einzukaufen, ohne eine einzige Millisekunde zu gewinnen. Miss erst, ob du überhaupt CPU-gebunden bist. Wenn du es bist, weißt du jetzt, wohin die Arbeit gehört. Wer ohnehin große Datenmengen durch seine Anwendung schiebt, findet in meinem Beitrag zu [Node.js-Streams und Backpressure](https://www.mikebild.dev/de/blog/nodejs-streams-und-backpressure/) die passende Ergänzung – denn oft ist die beste Skalierung die, bei der man Daten gar nicht erst komplett in den Speicher lädt.\n\n## Weiterführende Quellen\n\n- Repository mit den Codebeispielen: [github.com/mikebild/introduction-nodejs](https://github.com/mikebild/introduction-nodejs)\n- [child_process – offizielle Node.js-Doku](https://nodejs.org/api/child_process.html)\n- [worker_threads – offizielle Node.js-Doku](https://nodejs.org/api/worker_threads.html)\n- [cluster – offizielle Node.js-Doku](https://nodejs.org/api/cluster.html)\n- [Don't Block the Event Loop – Node.js Learn](https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop)"},{"slug":"lambda-performance-und-cold-starts","title":"Lambda-Performance und Cold Starts verstehen","summary":"Was ein Cold Start bei AWS Lambda wirklich ist, welche Hebel du steuern kannst – von Bundle-Size über Init-Code und Memory bis Provisioned Concurrency – und wie du das Ganze sauber misst.","tags":["Software","AWS","Cloud"],"published_at":"2023-08-08T10:00:00+00:00","updated_at":"2026-07-13T10:04:44.118576+00:00","url":"https://www.mikebild.dev/de/blog/lambda-performance-und-cold-starts/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/e10542d20c6b719fc93b26be410695407c31eff2ba0adeb9d4fe3422384e398a/lambda-performance-und-cold-starts-vorlesen.mp3","audio_duration_secs":829,"text":"Ein Endpoint antwortet in 40 Millisekunden, beim nächsten Aufruf in 900 – am Code hat sich nichts geändert, und das Monitoring meldet keinen Fehler. Der Unterschied liegt allein darin, ob AWS Lambda eine vorhandene Execution Environment wiederverwenden konnte oder eine neue erzeugen musste. Und jede Millisekunde Arbeit, die außerhalb des Handlers passiert, bezahlt der erste Aufrufer dieser neuen Umgebung – vollständig und sichtbar.\n\nDieser Ausreißer heißt Cold Start, und sein schlechter Ruf ist unverdient. Er ist weder ein Bug noch das Zeichen einer schlecht gebauten Funktion, sondern eine direkte Folge des Ausführungsmodells von Lambda. Wer dieses Modell einmal durchschaut hat, dem zerfällt das diffuse \"manchmal lahm\" in eine Handvoll konkreter Stellschrauben – und ein paar hartnäckige Fehlannahmen sortieren sich gleich mit aus.\n\n## Was bei einem Cold Start wirklich passiert\n\nLambda führt deinen Code nicht in einem dauerhaft laufenden Prozess aus, sondern in einer sogenannten Execution Environment. Das ist eine isolierte, kurzlebige Runtime, die Lambda bei Bedarf erzeugt, eine Weile am Leben hält und irgendwann wieder wegwirft. Der Lifecycle dieser Umgebung besteht aus drei Phasen: Init, Invoke und Shutdown.\n\nEin Cold Start ist nichts anderes als die Init-Phase einer frisch erzeugten Environment. Dabei passiert einiges, bevor deine eigentliche Logik überhaupt anläuft:\n\n- Lambda lädt deinen Code herunter – aus einem internen Speicher beziehungsweise bei Container-Images aus der Registry.\n- Die Runtime wird initialisiert, also zum Beispiel der Node.js-Prozess hochgefahren.\n- Dein Function-Init-Code läuft: alles, was du außerhalb des Handlers auf Modulebene geschrieben hast – Imports, das Anlegen von SDK-Clients, das Einlesen von Konfiguration.\n\nErst danach folgt die Invoke-Phase, in der dein Handler mit dem konkreten Event aufgerufen wird. Ein wichtiges Detail für die Praxis: Die Init-Phase hat einen harten Timeout von 10 Sekunden. Wer teure Arbeit auf Modulebene erledigt, kann diese Grenze tatsächlich reißen – dazu später mehr.\n\nNach dem Invoke wirft Lambda die Umgebung nicht sofort weg. Sie wird eingefroren (\"frozen\") und bei einem weiteren Aufruf innerhalb kurzer Zeit wieder aufgetaut (\"thawed\"). Dann sprechen wir von einem Warm Invoke: Die komplette Init-Phase entfällt, weil Runtime und Init-Code bereits gelaufen sind. Es läuft nur noch dein Handler. Das ist der Grund, warum derselbe Endpoint mal 40 und mal 900 Millisekunden braucht – der Unterschied ist schlicht, ob eine warme Environment bereitstand oder eine neue erzeugt werden musste.\n\n```mermaid\nflowchart TD\n    Req[Eingehender Invoke] --> Q{Warme Environment<br/>verfügbar?}\n    Q -->|Nein: Cold Start| C1[Code herunterladen]\n    C1 --> C2[Runtime initialisieren]\n    C2 --> C3[Init-Code außerhalb<br/>des Handlers ausführen]\n    C3 --> H[Handler ausführen]\n    Q -->|Ja: Warm Invoke| H\n    H --> F[Environment einfrieren<br/>für spätere Invokes]\n    F -.wiederverwenden.-> Q\n```\n\nDas Diagramm macht den zentralen Punkt sichtbar: Der teure Teil – Download, Runtime-Init, Function-Init – passiert nur auf dem Cold-Pfad. Die \"Init Duration\", die du gleich noch in den Logs findest, ist genau die Summe dieser drei Schritte. Bei jedem Warm Invoke springt Lambda direkt zum Handler.\n\n## Der erste Hebel: das Bundle klein halten\n\nDer offensichtlichste Beitrag zum Cold Start ist die Zeit, die es braucht, deinen Code zu laden und die Module zu parsen. Je größer das Bundle und je verzweigter der Import-Graph, desto länger dauert das. Das klingt banal, aber ich sehe regelmäßig Deployment-Pakete von mehreren Megabyte, in denen ein komplettes SDK, drei Datumsbibliotheken und ungenutzte Teile eines Frameworks mitreisen.\n\nDie pragmatische Antwort heißt Bundling mit Tree-Shaking. Ein Bundler wie esbuild wirft alles raus, was nicht tatsächlich erreicht wird, und schnürt den Rest zu einer kompakten Datei. Ein typischer Aufruf sieht so aus:\n\n```bash\nesbuild src/handler.ts \\\n  --bundle \\\n  --minify \\\n  --platform=node \\\n  --target=node18 \\\n  --external:@aws-sdk/* \\\n  --outfile=dist/handler.js\n```\n\nZwei Dinge sind hier bewusst gewählt. Das Target ist `node18` – die zu diesem Zeitpunkt aktuelle LTS-Runtime von Lambda. Und das AWS SDK v3 wird über `--external:@aws-sdk/*` aus dem Bundle herausgehalten, weil es in der Node18-Runtime ohnehin vorinstalliert ist. Es mit einzupacken würde das Paket nur unnötig aufblähen, ohne einen Vorteil zu bringen.\n\nWer mit AWS SAM oder dem CDK arbeitet, muss sich um den Aufruf oft nicht selbst kümmern: Das Construct `NodejsFunction` nutzt esbuild intern und bündelt automatisch. Der Punkt bleibt derselbe – weniger Code und weniger Dependencies bedeuten kürzeren Download und schnelleres Modul-Laden, und damit einen kürzeren Cold Start.\n\n## Der zweite Hebel: Init-Code an die richtige Stelle\n\nJetzt kommt der Teil, der in der Praxis den größten Unterschied macht und gleichzeitig am häufigsten falsch gemacht wird: Wo legst du deine Clients und Verbindungen an?\n\nAlles, was du auf Modulebene außerhalb des Handlers schreibst, läuft genau einmal pro Execution Environment – in der Init-Phase. Bei jedem folgenden Warm Invoke wird das Ergebnis wiederverwendet. Genau dorthin gehört daher alles, was wiederverwendbar und über Aufrufe hinweg stabil ist: SDK-Clients, Datenbank-Pools, geparste Konfiguration.\n\n```ts\n// module scope — runs once per execution environment (Init phase)\nimport { DynamoDBClient } from \"@aws-sdk/client-dynamodb\";\nimport { GetItemCommand } from \"@aws-sdk/client-dynamodb\";\n\n// created once, reused across every warm invoke\nconst client = new DynamoDBClient({});\n\nexport const handler = async (event: { id: string }) => {\n  const result = await client.send(\n    new GetItemCommand({\n      TableName: \"orders\",\n      Key: { id: { S: event.id } },\n    })\n  );\n\n  return { statusCode: 200, body: JSON.stringify(result.Item) };\n};\n```\n\nDer `DynamoDBClient` wird hier einmal erzeugt und bleibt danach in der Umgebung erhalten. Jeder warme Invoke greift auf dieselbe Instanz zu – inklusive der bereits aufgebauten HTTP-Verbindungen. Genau hier liegt ein oft übersehener Vorteil des AWS SDK v3: HTTP Keep-Alive ist standardmäßig aktiv. Die TCP- und TLS-Handshakes fallen also nur beim ersten Aufruf an und werden danach wiederverwendet.\n\nEin kurzer, aber praxisrelevanter Hinweis zur Migration: Beim älteren AWS SDK v2 war Keep-Alive nicht automatisch an. Dort musst du die Umgebungsvariable `AWS_NODEJS_CONNECTION_REUSE_ENABLED=1` setzen (verfügbar ab v2.463.0), um dasselbe Verhalten zu bekommen. Wer noch v2-Code betreibt, sollte das prüfen – es ist eine der billigsten Latenzverbesserungen überhaupt.\n\n### Lazy-Init für das, was nicht jeder Invoke braucht\n\nNicht jede Ressource sollte in die Init-Phase. Wenn du eine teure Verbindung nur in einem von zwanzig Aufrufen tatsächlich brauchst, verlängert ihr Aufbau auf Modulebene jeden einzelnen Cold Start – auch die, die sie nie nutzen. Hier lohnt sich Lazy-Init: Die Ressource wird erst beim ersten tatsächlichen Bedarf erzeugt und danach für die Lebensdauer der Environment gecacht.\n\n```ts\nimport { createPool, type Pool } from \"./db\";\n\nlet dbPromise: Promise<Pool> | undefined;\n\n// created on first use, then cached for the environment's lifetime\nconst getDb = (): Promise<Pool> => (dbPromise ??= createPool());\n\nexport const handler = async (event: { needsDb: boolean }) => {\n  if (!event.needsDb) {\n    return { statusCode: 204 };\n  }\n\n  const db = await getDb();\n  const rows = await db.query(\"SELECT 1\");\n  return { statusCode: 200, body: JSON.stringify(rows) };\n};\n```\n\nDer `??=`-Operator sorgt dafür, dass der Pool nur ein einziges Mal aufgebaut wird. Beim ersten Aufruf, der ihn wirklich braucht, entsteht eine kleine Verzögerung – dafür bleibt die Init-Phase schlank und schnell, und alle Invokes, die den Pool nicht benötigen, zahlen nichts dafür. Es ist die Balance zwischen \"alles vorab\" und \"alles pro Aufruf\", und die richtige Grenze hängt davon ab, wie oft eine Ressource tatsächlich gebraucht wird.\n\n## Der dritte Hebel: Memory ist auch CPU\n\nDas ist die Stellschraube, die am meisten unterschätzt wird, weil sie missverständlich benannt ist. Der Konfigurationswert heißt `MemorySize` und lässt sich zwischen 128 MB und 10.240 MB in 1-MB-Schritten einstellen. Aber er steuert eben nicht nur den Speicher: CPU wird linear proportional zum Memory zugeteilt. Bei genau 1.769 MB bekommt deine Funktion eine vollständige vCPU; am oberen Ende stehen bis zu 6 vCPUs zur Verfügung.\n\nDas hat unmittelbare Konsequenzen. Eine Funktion, die CPU-gebundene Arbeit leistet – JSON parsen, Kryptografie, Datenaufbereitung – wird mit mehr Memory schneller, obwohl sie den zusätzlichen Speicher gar nicht braucht. Und interessanterweise gilt das auch für den Cold Start selbst: Das Laden und Initialisieren der Runtime ist CPU-Arbeit, die von mehr zugeteilter Rechenleistung profitiert.\n\nDer Fehler, den ich hier am häufigsten sehe, ist eines von zwei Extremen. Entweder bleibt die Funktion aus Sparsamkeit bei 128 MB und ist dadurch bei CPU-Last unnötig langsam – oder jemand schraubt das Memory blind auf ein hohes Niveau in der Hoffnung, dass \"viel hilft viel\". Beides ist Raten. Mehr Memory kostet mehr pro Millisekunde, und ab einem bestimmten Punkt bringt es keinen Latenzgewinn mehr, weil die Funktion nicht mehr CPU-, sondern I/O-gebunden ist. Dann wartest du auf eine Datenbank oder eine externe API, und da hilft keine zusätzliche vCPU.\n\nDie einzige seriöse Antwort ist Messen. Für genau diesen Zweck gibt es das Open-Source-Tool AWS Lambda Power Tuning, das deine Funktion mit verschiedenen Memory-Werten durchprobiert und Kosten gegen Latenz aufträgt. Statt zu raten, siehst du die tatsächliche Kurve für deine konkrete Workload und wählst den Punkt, der zu deinem Ziel passt.\n\n## Der vierte Hebel: Provisioned Concurrency für planbare Latenz\n\nManchmal reicht es nicht, den Cold Start zu verkürzen – du willst ihn für bestimmte Funktionen ganz vermeiden. Das ist der Fall bei latenzkritischen, gut vorhersehbaren Lasten: eine Funktion hinter einem Login-Flow, ein Endpoint, der werktags um neun Uhr eine planbare Lastspitze bekommt.\n\nDafür gibt es Provisioned Concurrency. Du konfigurierst über `ProvisionedConcurrentExecutions` eine Anzahl von Environments, die Lambda dauerhaft vor-initialisiert bereithält – mit bereits gelaufener Init-Phase. Aufrufe treffen dann auf eine warme Umgebung, ohne dass ein Cold Start entsteht. Wichtig ist das Detail, dass Provisioned Concurrency auf einem Alias oder einer konkreten Version konfiguriert wird, nicht auf `$LATEST`. Wer diese Environments zusätzlich an eine schwankende Last anpassen will, kann sie über Application Auto Scaling dynamisch skalieren.\n\nDas kostet allerdings bares Geld, und zwar lastunabhängig: Vorgehaltene Environments werden auch dann berechnet, wenn sie im Leerlauf stehen. Provisioned Concurrency ist deshalb kein Allheilmittel, das man über jede Funktion legt, sondern ein gezieltes Werkzeug für die wenigen Stellen, an denen planbare, niedrige Latenz den Aufpreis rechtfertigt. Für die meisten Hintergrund- und Event-getriebenen Funktionen ist der gelegentliche Cold Start völlig unkritisch – dort wäre Provisioned Concurrency verschenktes Geld.\n\nEine Randnotiz, weil die Frage in Reviews oft kommt: Für Java gibt es seit Ende 2022 zusätzlich Lambda SnapStart, das über einen Snapshot der initialisierten Umgebung arbeitet. Für Node.js ist das derzeit kein Thema – SnapStart ist zu diesem Zeitpunkt eine Java-only-Option. Für unsere TypeScript-Funktionen bleiben die Hebel oben die relevanten.\n\n## Messen, nicht vermuten\n\nDer rote Faden durch all diese Hebel ist derselbe: Ohne Messung bleibt jede Optimierung ein Bauchgefühl. Zum Glück liefert Lambda die entscheidenden Zahlen frei Haus. Jeder Invoke schreibt eine REPORT-Zeile in die CloudWatch-Logs, und bei einem Cold Start enthält sie ein zusätzliches Feld:\n\n```json\n{\n  \"Duration\": \"42.11 ms\",\n  \"BilledDuration\": \"43 ms\",\n  \"MaxMemoryUsed\": \"98 MB\",\n  \"InitDuration\": \"312.44 ms\"\n}\n```\n\nDas Feld `InitDuration` erscheint ausschließlich bei einem Cold Start – es ist genau die Zeit für Download, Runtime-Init und deinen Function-Init-Code. Findest du diese Zeile in den Logs, weißt du zweierlei: Erstens war das ein Cold Start. Zweitens siehst du an der Zahl, wie teuer deine Init-Phase tatsächlich ist. Steigt `InitDuration` in Richtung Sekunden, ist das ein deutliches Signal, dass zu viel auf Modulebene passiert.\n\nWer tiefer schauen will, aktiviert AWS X-Ray. Dort taucht die Initialisierung als eigenes Subsegment im Trace auf, sauber getrennt von der Handler-Zeit. Das ist besonders wertvoll, wenn du herausfinden willst, ob deine Latenz aus dem Cold Start kommt oder aus einem langsamen Downstream-Aufruf im Handler – zwei völlig unterschiedliche Probleme, die im reinen Gesamtwert nicht auseinanderzuhalten sind.\n\n## Die Hebel im Überblick\n\nWenn ich die konkreten Stellschrauben zusammenfassen soll, ergibt sich eine kompakte Liste, die ich in Reviews gerne als Checkliste verwende:\n\n- Bundle klein halten mit esbuild und Tree-Shaking, das in der Runtime vorhandene AWS SDK als `external` markieren.\n- Wenige Dependencies einsetzen und den Import-Graph bewusst schlank halten.\n- Wiederverwendbare Clients und Verbindungen außerhalb des Handlers anlegen und über Warm Invokes wiederverwenden.\n- Lazy-Init für teure, selten genutzte Ressourcen, damit die Init-Phase schlank bleibt.\n- Memory als CPU-Regler verstehen und empirisch tunen – mit Lambda Power Tuning statt Bauchgefühl.\n- Provisioned Concurrency gezielt für die wenigen latenzkritischen, planbaren Funktionen einsetzen.\n- Init Duration in CloudWatch und X-Ray messen, statt zu vermuten.\n\n## Fazit\n\nCold Starts sind kein Makel, sondern eine Eigenschaft des Ausführungsmodells von Lambda – und genau deshalb sind sie steuerbar, sobald man das Modell versteht. Die Init-Phase läuft einmal pro Execution Environment, warme Invokes überspringen sie, und fast alles, was du an der Latenz drehen kannst, hängt daran, wie schwer oder leicht du diese Init-Phase machst.\n\nIn der Praxis gibt es hier keine einzelne große Lösung. Was wirkt, ist ein Zusammenspiel kleiner, verständlicher Entscheidungen: ein schlankes Bundle, Clients an der richtigen Stelle, ein sinnvoll gewählter Memory-Wert und – nur dort, wo es sich wirklich lohnt – Provisioned Concurrency. Und darüber steht immer das Messen. Wer die Init Duration im Blick behält, verwandelt das diffuse \"manchmal lahm\" in eine Zahl, an der man arbeiten kann. Das ist der eigentliche Gewinn: aus einem Bauchgefühl wird ein Engineering-Problem mit klaren Hebeln.\n\nIm größeren Bild bleibt Lambda nur ein Baustein. API, Persistenz, Identität, asynchrone Verarbeitung und Beobachtbarkeit müssen gemeinsam betrachtet werden; eine schnelle Funktion allein ergibt noch kein belastbares System.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-aws-serverless.html\n- [Lambda execution environment lifecycle](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtime-environment.html)\n- [Configure function memory (Memory = CPU)](https://docs.aws.amazon.com/lambda/latest/dg/configuration-memory.html)\n- [Understanding and remediating cold starts (AWS Compute Blog)](https://aws.amazon.com/blogs/compute/understanding-and-remediating-cold-starts-an-aws-lambda-perspective/)\n- [Operating Lambda: Performance optimization – Part 1](https://aws.amazon.com/blogs/compute/operating-lambda-performance-optimization-part-1/)\n- [Reuse connections with keep-alive (AWS SDK v3)](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/node-reusing-connections.html)\n- [Reuse connections with keep-alive (AWS SDK v2)](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/node-reusing-connections.html)\n- [Lambda function scaling and Provisioned Concurrency](https://docs.aws.amazon.com/lambda/latest/dg/lambda-concurrency.html)"},{"slug":"rxjs-in-node-ereignisstroeme","title":"RxJS in Node.js: Event Streams reaktiv verarbeiten","summary":"RxJS in Node.js: Wie fromEvent einen EventEmitter in ein Observable verwandelt, Operatoren serverseitige Ströme deklarativ transformieren und warum RxJS keine echte Backpressure hat.","tags":["Software","Node.js","Backend"],"published_at":"2023-07-25T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/rxjs-in-node-ereignisstroeme/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/2d01791590ae6291761154cb43575f731ad45beb04ed4a289540d9208270a4ce/rxjs-in-node-ereignisstroeme-vorlesen.mp3","audio_duration_secs":844,"text":"```js\nlet buffer = [];\nlet timer = null;\n\nemitter.on('data', line => {\n  const entry = JSON.parse(line);\n  if (entry.level !== 'error') return;\n  buffer.push(entry);\n  if (!timer) {\n    timer = setTimeout(() => {\n      saveErrors(buffer);\n      buffer = [];\n      timer = null;\n    }, 1000);\n  }\n});\n```\n\nDrei Anforderungen stecken in diesem Handler: parsen, filtern, im Sekundentakt gebündelt wegschreiben. Der Zwischenzustand liegt in zwei Modulvariablen, geprüft wird er nur noch mit Zeitgeber-Attrappen. Kommt morgen ein zweiter Konsument dazu oder soll zusätzlich nach Dienst gruppiert werden, wächst die Verschachtelung weiter – und mit ihr die Zahl der Stellen, an denen der geteilte Zustand kippen kann.\n\nRxJS setzt genau hier an: überall dort, wo aus einem Event Stream ein komponierbares Programm werden soll. Wer sich das imperative Fundament schon angesehen hat – etwa im Beitrag zu [EventEmitter und Event-Modell](https://www.mikebild.dev/de/blog/nodejs-eventemitter-und-ereignismodell/) – erkennt in RxJS dieselbe Idee, nur von der anderen Seite aufgezäumt.\n\n## Von on/emit zu deklarativen Strömen\n\nDer `EventEmitter` ist imperativ. Ich sage: „Wenn `data` passiert, führe diese Funktion aus.“ Das ist ein Befehl. Jeder Handler ist eine eigene kleine Insel, und wenn ich mehrere Schritte verketten will, verknüpfe ich sie von Hand über gemeinsamen Zustand.\n\nRxJS beschreibt denselben Event Stream deklarativ. Ich sage nicht mehr „tu dies, wenn jenes passiert“, sondern „dieser Strom ist die Quelle, transformiert durch diese Kette von Schritten“. Die Kette ist eine Beschreibung, kein Ablauf. Erst beim `subscribe` wird sie aktiviert.\n\nDie Brücke zwischen beiden Welten ist `fromEvent`. Der Operator nimmt einen Node-`EventEmitter` und einen Event-Namen und liefert ein Observable:\n\n```js\nconst { EventEmitter } = require('events');\nconst { fromEvent } = require('rxjs');\n\nconst emitter = new EventEmitter();\nconst data$ = fromEvent(emitter, 'data');\n\nconst subscription = data$.subscribe({\n  next: value => console.log('received', value),\n});\n\nemitter.emit('data', { level: 'info', message: 'server started' });\n```\n\n`fromEvent(emitter, 'data')` ändert nichts an der Semantik des Emitters. Es registriert bei jedem `subscribe` einen Listener über `addListener` und entfernt ihn bei `unsubscribe` wieder. Das Observable ist damit ein Adapter: Der Emitter bleibt die Quelle, RxJS legt nur eine komponierbare Schicht darüber. Wer den Unterschied zwischen dem Push-Modell von Observables und dem Pull-Modell von Iteratoren vertiefen will, findet das im Beitrag zu [RxJS: Push und Pull](https://www.mikebild.dev/de/blog/rxjs-push-und-pull/).\n\nEin Hinweis zur Konvention: Ich hänge an Variablen, die ein Observable halten, ein `$` an (`data$`). Das ist kein Zwang, hat sich in der RxJS-Welt aber eingebürgert und macht im Code sofort sichtbar, was ein Strom ist und was ein einzelner Wert.\n\n## Die Pipeline: Operatoren als Pure Functions\n\nDer eigentliche Gewinn liegt in den Operatoren. Sie sind Pure Functions, die ein Observable entgegennehmen und ein neues zurückgeben. Verkettet werden sie über `.pipe(...)`. In RxJS 7 importiere ich alles – Erzeugungsfunktionen wie `fromEvent` und Operatoren wie `map` – direkt aus `'rxjs'`. Der frühere Import aus `'rxjs/operators'` funktioniert noch, ist aber überholt.\n\nEin typisches serverseitiges Beispiel ist Log-Verarbeitung. Ich möchte eingehende Rohzeilen zu Objekten parsen, nur die Fehler behalten und diese pro Sekunde gebündelt wegschreiben, statt bei jeder einzelnen Zeile die Datenbank anzufassen:\n\n```js\nconst { fromEvent } = require('rxjs');\nconst { map, filter, bufferTime } = require('rxjs');\n\nconst logStream$ = fromEvent(logEmitter, 'line').pipe(\n  map(line => JSON.parse(line)),\n  filter(entry => entry.level === 'error'),\n  bufferTime(1000),\n);\n\nconst subscription = logStream$.subscribe({\n  next: batch => saveErrors(batch),\n  error: err => console.error('stream failed', err),\n});\n```\n\nWas hier steht, liest sich fast wie eine Beschreibung der Anforderung. `map` transformiert jeden Wert, `filter` lässt nur passende durch, `bufferTime(1000)` sammelt über ein Zeitfenster von einer Sekunde und emittiert dann ein Array. Der `next`-Handler bekommt also nicht einzelne Einträge, sondern jeweils ein Bündel – oder ein leeres Array, wenn im Fenster nichts kam.\n\nJeder Schritt ist für sich verständlich und für sich testbar. Und wenn morgen die Anforderung kommt, zusätzlich nach Dienst zu gruppieren, füge ich einen Operator ein, statt eine verschachtelte Callback-Struktur umzubauen.\n\nEin zweites Muster aus der Praxis: Sensordaten drosseln und dabei einen laufenden Mittelwert bilden. Angenommen, ein Gerät feuert deutlich häufiger, als ich verarbeiten will:\n\n```js\nconst { fromEvent } = require('rxjs');\nconst { throttleTime, scan } = require('rxjs');\n\nconst average$ = fromEvent(sensor, 'reading').pipe(\n  map(event => event.value),\n  throttleTime(500),\n  scan((sum, value, index) => (sum * index + value) / (index + 1), 0),\n);\n\naverage$.subscribe({ next: avg => console.log('running average', avg) });\n```\n\n`throttleTime(500)` lässt einen Wert durch und ignoriert dann für 500 Millisekunden alle weiteren. `scan` verhält sich wie `reduce`, emittiert aber jeden Zwischenstand – so bekomme ich bei jeder durchgelassenen Messung den aktualisierten Durchschnitt. Diese Kombination aus laufender Aggregation und Zeitfenster ist im Repository unter `operators.js` vorgezeichnet, wenn auch dort noch mit der älteren RxJS-6-Importweise.\n\nWenn ich pro Event eine asynchrone Weiterverarbeitung anstoßen will – etwa Details nachladen – hilft `mergeMap`. Es mappt jeden Wert auf ein inneres Observable und flacht die Ergebnisse nebenläufig ab. Der zweite Parameter begrenzt, wie viele innere Verarbeitungen gleichzeitig laufen dürfen:\n\n```js\nconst { mergeMap } = require('rxjs');\n\nconst details$ = ids$.pipe(\n  mergeMap(id => fetchDetails(id), 4),\n);\n```\n\nMit `mergeMap(id => fetchDetails(id), 4)` laufen höchstens vier Abrufe parallel. Diese Begrenzung ist wichtig, und zwar aus einem Grund, der gleich noch eine eigene Rolle spielt.\n\nDer grobe Fluss durch so eine Pipeline sieht immer gleich aus:\n\n```mermaid\nflowchart LR\n  E[EventEmitter] -->|emit data| F[\"fromEvent<br/>(Observable)\"]\n  F --> M[map]\n  M --> FI[filter]\n  FI --> B[bufferTime]\n  B --> S[Subscriber]\n```\n\n## Ein Subject: aus einem Strom viele Konsumenten machen\n\nBis hierher hatte jeder Strom genau einen Verbraucher. Sobald mehrere Teile der Anwendung denselben Event Stream brauchen, kommt das `Subject` ins Spiel. Ein `Subject` ist zugleich Observer und Observable: Ich kann Werte hineingeben und andere können es abonnieren. Es verteilt jeden Wert an alle Subscriber gleichzeitig – Multicast.\n\n```js\nconst { fromEvent, Subject } = require('rxjs');\n\nconst bus = new Subject();\n\n// one source feeds the bus\nfromEvent(emitter, 'data').subscribe(bus);\n\n// multiple consumers share the same stream\nconst loggerSub = bus.subscribe({ next: v => logger.write(v) });\nconst metricsSub = bus.subscribe({ next: v => metrics.record(v) });\n\n// clean up later\nloggerSub.unsubscribe();\nmetricsSub.unsubscribe();\n```\n\nDer Unterschied zum direkten Abonnieren des Quell-Observables ist grundlegend. Würden `logger` und `metrics` beide `fromEvent(emitter, 'data')` abonnieren, hinge am Emitter zweimal ein Listener und die Quelle würde zweimal ausgeführt. Über das `Subject` gibt es eine Ausführung, deren Werte an alle Subscriber verteilt werden.\n\nGenau das ist der Kern der Unterscheidung zwischen Unicast und Multicast:\n\n```mermaid\nflowchart LR\n  P[Producer] --> SUB[Subject]\n  SUB --> C1[Logger-Subscriber]\n  SUB --> C2[Metrics-Subscriber]\n  SUB --> C3[Audit-Subscriber]\n```\n\nNeben dem einfachen `Subject` gibt es Varianten, die je nach Anwendungsfall passen:\n\n- `BehaviorSubject(seed)` hält den letzten Wert und liefert ihn jedem neuen Subscriber sofort. Praktisch für Zustand, den ein spät hinzukommender Konsument kennen muss.\n- `ReplaySubject(bufferSize)` puffert die letzten n Werte und spielt sie neuen Subscribern vor.\n- `AsyncSubject` gibt nur den allerletzten Wert aus, und das erst beim `complete`.\n\nIm Repository unter `subject.js` sieht man das Grundprinzip: dieselbe Quelle, mehrere `subscribe`-Aufrufe. Dort steht noch die positionsbasierte `subscribe(next, error, complete)`-Form. In RxJS 7 nutze ich stattdessen durchgängig den Objekt-Observer `subscribe({ next, error, complete })` – dazu gleich mehr bei den Fallstricken.\n\n## Hot und cold: der Unterschied, der Ergebnisse verfälscht\n\nEin Punkt, an dem in Teams zuverlässig Verwirrung entsteht, ist die Frage, wann die Quelle eines Observables entsteht.\n\nEin kaltes Observable erzeugt seinen Producer pro `subscribe`. Jeder Subscriber bekommt eine eigene, frische Ausführung – Unicast. Ein `from([1, 2, 3])` ist kalt: Jeder, der es abonniert, sieht die Werte 1, 2, 3 von vorne.\n\nEin heißes Observable hat seinen Producer außerhalb. Die Werte entstehen unabhängig davon, ob jemand zuhört. Wer spät abonniert, verpasst, was vorher lief – Multicast. Ein `Subject` ist heiß, und ein `fromEvent` über einen bereits laufenden Emitter verhält sich ebenfalls heiß: Die Events feuern ohnehin, ich klinke mich nur ein.\n\nDiese Unterscheidung ist keine Theorie. Wer erwartet, dass „jeder Subscriber frisch startet“, aber tatsächlich einen heißen Strom vor sich hat, bekommt bei spät hinzukommenden Konsumenten schlicht falsche Ergebnisse – fehlende Anfangswerte, eine Aggregation, die mitten im Strom beginnt. Wenn ich will, dass auch ein später Subscriber den letzten Stand oder die Historie sieht, ist genau das der Fall für `BehaviorSubject`, `ReplaySubject` oder den Operator `shareReplay`.\n\n## Die wichtige Abgrenzung: RxJS hat keine echte Backpressure\n\nDieser Punkt gehört früh geklärt, sonst entstehen Erwartungen, die das Werkzeug nicht einlöst. Wer Node-Streams kennt, kennt Backpressure: Ein lesbarer Stream kann pausieren, ein `highWaterMark` begrenzt den Puffer, `pipe` regelt den Fluss zwischen Producer und Konsument automatisch. Der schnelle Producer wird gebremst, wenn der langsame Konsument nicht hinterherkommt.\n\nRxJS hat das nicht. Es gibt keinen eingebauten Mechanismus, mit dem ein Subscriber die Quelle zum Langsamerwerden zwingt. Ein `Subject` oder ein `fromEvent` pusht Werte, sobald sie da sind, egal ob die Verarbeitung mitkommt.\n\nWas RxJS stattdessen anbietet, sind zeitbasierte und puffernde Operatoren:\n\n- `throttleTime(ms)` lässt einen Wert durch und verwirft dann für eine Weile alle weiteren.\n- `debounceTime(ms)` wartet, bis eine Ruhephase eintritt, und emittiert dann den letzten Wert.\n- `sampleTime(ms)` greift in festen Abständen den jeweils aktuellen Wert ab.\n- `bufferTime(ms)` sammelt über ein Fenster und liefert Bündel.\n- das `concurrent`-Limit von `mergeMap` begrenzt die Zahl paralleler innerer Verarbeitungen.\n\nDer entscheidende Unterschied: Diese Operatoren verwerfen oder aggregieren Werte, aber sie bremsen die Quelle nicht. Der Producer feuert unverändert weiter; ich reduziere nur, was hinten ankommt. Für eine Log-Anzeige oder Sensordaten ist das oft genau richtig – ein paar Zwischenwerte wegzuwerfen ist unkritisch. Für eine Verarbeitung, bei der jeder einzelne Wert zählen muss und die Quelle tatsächlich gedrosselt gehört, sind Node-Streams das richtige Werkzeug, nicht RxJS.\n\nMan kann beide Welten verbinden. Das Repository zeigt unter `node.js` die Brücke über `rxjs-stream`:\n\n```js\nconst { spawn } = require('child_process');\nconst { streamToRx } = require('rxjs-stream');\nconst { map } = require('rxjs');\n\nstreamToRx(spawn('ls', ['-lha']).stdout)\n  .pipe(map(chunk => chunk.toString()))\n  .subscribe({ next: text => console.log(text) });\n```\n\n`streamToRx` macht aus einem Node-Readable ein Observable und behält dabei die Stream-Backpressure im unteren Teil. So bekomme ich die Komponierbarkeit von RxJS für die Transformation, ohne die Flusskontrolle des Streams aufzugeben. Wer die Grundlagen des Event-Loops und der Stream-Architektur dahinter auffrischen will, findet sie im [Node.js-Training](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/).\n\n## Fallstricke, die mir immer wieder begegnen\n\nZwei Fehler tauchen so regelmäßig auf, dass ich sie hier ausdrücklich nenne.\n\nDer erste ist der Subscription-Leak. `fromEvent` registriert bei jedem `subscribe` einen Listener am Emitter. Ohne `unsubscribe()` bleibt dieser Listener hängen – auch dann, wenn die Verarbeitung längst nicht mehr gebraucht wird. Das Ergebnis ist ein Memory-Leak und eine Pipeline, die unbemerkt weiterläuft. Die Regel ist einfach: Ich halte die Subscription, die `.subscribe()` zurückgibt, und rufe `unsubscribe()` auf, wenn ich fertig bin. Alternativ begrenze ich den Strom deklarativ über `take(n)` oder `takeUntil(stop$)`, sodass er sich selbst beendet:\n\n```js\nconst { fromEvent, timer } = require('rxjs');\nconst { takeUntil } = require('rxjs');\n\nconst stop$ = timer(60000);\n\nfromEvent(emitter, 'data')\n  .pipe(takeUntil(stop$))\n  .subscribe({ next: v => process(v) });\n```\n\nHier räumt `takeUntil` nach 60 Sekunden auf, ohne dass ich die Subscription selbst verwalten muss. Bei langlebigen Servern ist eine der beiden Strategien Pflicht, sonst sammeln sich Listener über die Laufzeit an.\n\nDer zweite Fallstrick ist das Verwechseln von hot und cold, das ich oben schon beschrieben habe – es äußert sich fast immer als „mein zweiter Subscriber bekommt nicht dieselben Werte“. Die Antwort ist meist ein `Subject` mit passender Variante oder `shareReplay`.\n\nUnd ein dritter, eher handwerklicher Punkt: Viele Beispiele im Netz und auch die älteren Snippets im Repository nutzen noch RxJS-6-Muster. Konkret heißt das für RxJS 7:\n\n- Statt `subscribe(next, error, complete)` mit einzelnen Argumenten den Objekt-Observer `subscribe({ next, error, complete })` verwenden – die positionsbasierte Form ist seit 6.4 überholt.\n- Statt `tap(fn, errFn)` mit mehreren Callbacks ebenfalls den Objekt-Observer nutzen.\n- Operatoren aus `'rxjs'` importieren statt aus `'rxjs/operators'`.\n\nDiese Anpassungen sind mechanisch, aber wichtig, damit der Code nicht auf einem Muster aufsetzt, das perspektivisch verschwindet.\n\n## Wann RxJS statt rohem EventEmitter?\n\nNicht jede Node-Anwendung braucht RxJS. Ein einzelner `on('data', ...)`-Handler bleibt mit purem `EventEmitter` schlanker und hat keine zusätzliche Abhängigkeit. Ich greife dann zu RxJS, wenn mindestens einer dieser Punkte zutrifft:\n\n- Ich muss mehrere Verarbeitungsschritte verketten – transformieren, filtern, aggregieren – und will das nicht über verschachtelte Callbacks und geteilten Zustand lösen.\n- Ich brauche zeitbasierte Steuerung: drosseln, entprellen, in Fenstern bündeln.\n- Denselben Event Stream sollen mehrere Konsumenten unabhängig verarbeiten (Multicast über ein `Subject`).\n- Der Strom soll sich sauber und deklarativ beenden lassen, etwa über `takeUntil`.\n- Der Verarbeitungscode soll in kleine, für sich testbare Bausteine zerfallen.\n\nUmgekehrt bleibe ich beim rohen Emitter oder greife zu Node-Streams, wenn es nur ein einziger simpler Handler ist oder wenn ich echte Backpressure über den ganzen Weg brauche.\n\n## Fazit\n\nRxJS verschiebt in Node.js die Perspektive vom Befehl zur Beschreibung. Der `EventEmitter` sagt, was bei jedem Event zu tun ist; RxJS beschreibt den ganzen Event Stream als komponierbare Kette von Pure Functions. `fromEvent` ist die Brücke dorthin, ohne dass sich an der Semantik des Emitters etwas ändert, und ein `Subject` macht aus einem Strom viele Konsumenten.\n\nZwei Dinge sollte man dabei nie vergessen. Erstens die Disziplin beim Aufräumen: Ohne `unsubscribe`, `take` oder `takeUntil` bleiben Listener und Verarbeitung am Leben. Zweitens die ehrliche Grenze des Werkzeugs: RxJS steuert Überlast nur zeitbasiert und puffernd, nicht über echte Backpressure. Wer diese beiden Punkte im Kopf behält, bekommt mit RxJS eine sehr angenehme Art, serverseitige Event Streams zu verarbeiten – lesbar, testbar und erweiterbar, ohne im Callback-Dickicht zu versinken.\n\n## Weiterführende Quellen\n\n- Repository-Beispiele: `/Users/mikebild/Documents/introduction-nodejs/EXAMPLES/RXJS/operators.js`, `.../subject.js`, `.../node.js`\n- [fromEvent](https://rxjs.dev/api/index/function/fromEvent)\n- [Subject-Guide](https://rxjs.dev/guide/subject)\n- [Observable-Guide](https://rxjs.dev/guide/observable)\n- [Operatoren-Guide](https://rxjs.dev/guide/operators)\n- [mergeMap](https://rxjs.dev/api/operators/mergeMap)\n- [throttleTime](https://rxjs.dev/api/index/function/throttleTime)\n- [bufferTime](https://rxjs.dev/api/operators/bufferTime)\n- [scan](https://rxjs.dev/api/operators/scan)\n- [subscribe-Argumente (Deprecation)](https://rxjs.dev/deprecations/subscribe-arguments)\n- [Glossar und Semantik: hot/cold, unicast/multicast](https://rxjs.dev/guide/glossary-and-semantics)\n- [Node.js events](https://nodejs.org/docs/latest-v18.x/api/events.html)"},{"slug":"evolutionary-architecture-wandel-einplanen","title":"Evolutionary Architecture: Systeme, die sich ändern dürfen","summary":"Architektur ist kein Endzustand, den man einmal festzurrt. Kleine Änderungen und passende Fitness Functions machen ausgewählte Risiken früher sichtbar, garantieren aber weder Tempo noch Qualität.","tags":["Software","Architektur"],"published_at":"2023-06-26T10:00:00+00:00","updated_at":"2026-07-13T08:50:24.240283+00:00","url":"https://www.mikebild.dev/de/blog/evolutionary-architecture-wandel-einplanen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/b3e12507901be058a349c74d29e3a58f10d51709b24ff1388fbea5bb3b5f8723/evolutionary-architecture-wandel-einplanen-vorlesen.mp3","audio_duration_secs":751,"text":"Evolutionary Architecture ist kein Freibrief für Wildwuchs und auch kein zweites Wort für \"agil\". Neal Ford, Rebecca Parsons und Patrick Kua definieren sie in *Building Evolutionary Architectures* (O'Reilly 2017, zweite Auflage 2022) als eine Architektur, die \"guided, incremental change across multiple dimensions\" unterstützt. Drei Wörter tragen diese Definition. Geführt: Es gibt benannte Eigenschaften, die geschützt werden. Inkrementell: Die einzelne Änderung ist klein genug, um sie zurücknehmen zu können. Mehrdimensional: Gemeint ist nicht nur der Code, sondern ebenso Daten, Betrieb und die Organisation, die beides trägt.\n\nDer Gegenentwurf ist das eingefrorene System. Vor drei Jahren sauber entworfen, für eine Welt, die es so nicht mehr gibt – und seither nicht mehr bewegt, weil jede Änderung zu riskant erschien. Die Diagramme hängen noch an der Wand, aber sie beschreiben ein Auto, das niemand mehr fährt. Dahinter steckt eine Grundannahme, die sich hartnäckig hält: Architektur sei etwas, das man einmal richtig macht und dann verteidigt, ein Zielbild, das man erreicht. Kein System, das produktiv genutzt wird, bleibt jedoch stehen. Anforderungen verschieben sich, Lastprofile ändern sich, Teams wachsen, Abhängigkeiten altern. Die Frage ist nie, ob sich ein System ändert, sondern nur, ob die Architektur diesen Wandel begleitet oder ihm im Weg steht.\n\nEvolutionary Architecture dreht die Perspektive deshalb um: Statt Wandel als Ausnahme zu behandeln, die man nach dem Go-live möglichst vermeidet, macht sie ihn zur Norm, für die man von Anfang an baut. Ein System, das sich ändern darf – in kleinen Schritten, und ohne dass dabei die Eigenschaften kaputtgehen, die es wertvoll machen.\n\n## Warum das Endzustands-Denken teuer wird\n\nDer klassische Reflex heißt Big Design Up Front. Man denkt möglichst weit voraus, entscheidet früh alles Wichtige und gießt das Ergebnis in ein Zielbild. Das fühlt sich verantwortungsvoll an. Es ist auch nicht grundsätzlich falsch, früh nachzudenken – im Gegenteil. Falsch ist die Erwartung, dass diese frühen Entscheidungen dauerhaft tragen.\n\nDer Denkfehler steckt im Zeitpunkt. Ganz am Anfang eines Vorhabens weiß man am wenigsten über die Domäne, die Nutzer und das reale Verhalten unter Last. Genau dann trifft man die weitreichendsten Entscheidungen. Jede spätere Erkenntnis muss sich dann gegen eine Struktur behaupten, die für sie nicht gemacht war. Das ist der Moment, in dem Teams anfangen, um die Architektur herumzuarbeiten statt mit ihr. Der Fachbegriff dafür ist architectural drift oder erosion: Das reale System entfernt sich Stück für Stück vom gedachten, bis das Diagramm an der Wand nur noch Folklore ist.\n\nDer geschäftliche Schaden ist selten ein spektakulärer Ausfall. Er ist schleichend. Änderungen dauern länger. Jede Anpassung braucht mehr Abstimmung. Neue Features kollidieren mit alten Annahmen. Die Time-to-Market steigt, während die Zuversicht sinkt. Und irgendwann steht die Frage im Raum, ob man nicht besser alles neu bauen sollte – die teuerste aller Optionen.\n\n## Der Perspektivwechsel: geführte Evolution statt fixiertes Ziel\n\nDer Ausweg ist kein anderes Zielbild, sondern ein anderer Umgang mit Zielbildern. Statt einen Endzustand zu definieren und zu verteidigen, definiert man die Eigenschaften, die das System behalten soll, und lässt die Struktur sich innerhalb dieser Leitplanken entwickeln.\n\n```mermaid\nflowchart LR\n  A[Change<br/>small increment] --> B[Deployment Pipeline]\n  B --> C{Fitness Functions<br/>guardrails}\n  C -->|pass| D[Learn & Adjust]\n  C -->|fail| E[Stop & Fix]\n  E --> A\n  D --> A\n```\n\nDer Kern ist ein Regelkreis, kein Wasserfall. Eine kleine Änderung läuft durch die Deployment-Pipeline, wird gegen definierte Leitplanken geprüft, liefert eine klare Rückmeldung – bestanden oder nicht – und daraus lernt das Team für den nächsten Schritt. Klein, geführt, wiederholbar. Das ist der Unterschied zwischen einem System, das sich entwickelt, und einem, das nur älter wird.\n\nWichtig ist die Betonung auf geführt. Evolutionary bedeutet ausdrücklich nicht \"keine Architektur\" oder \"wir lassen es wachsen, wie es will\". Das ist der häufigste Trugschluss. Wildwuchs ist keine Evolution, sondern ihr Gegenteil. Ohne bewusst benannte, priorisierte Architektur-Charakteristika gibt es nichts zu schützen und nichts zu messen. Der erste Schritt ist deshalb keine Technik, sondern eine Entscheidung: Was genau muss dieses System auszeichnen? Antwortzeit unter einer bestimmten Grenze? Lose Kopplung zwischen fachlichen Bereichen? Nachweisbare Sicherheit an den Rändern? Erst wenn diese Ziele explizit sind, kann man sie verteidigen.\n\n## Fitness Functions: Architektur, die man messen kann\n\nDer Mechanismus, mit dem diese Ziele verteidigt werden, heißt Fitness Function. Der Begriff ist aus evolutionären Algorithmen entlehnt, wo eine Fitness-Function bewertet, wie nah eine Lösung am gewünschten Ergebnis ist. Übertragen auf Architektur heißt das laut den Autoren: \"any mechanism that provides an objective integrity assessment of some architectural characteristic(s)\". Also jeder Mechanismus, der objektiv prüft, ob eine architektonische Eigenschaft noch intakt ist.\n\nDas Entscheidende an dem Wort ist objektiv. Eine Fitness Function ist kein Meeting, kein Bauchgefühl und kein Architecture Review Board, das einmal im Quartal tagt und über Slides urteilt. Sie ist ein automatisierter Test, der in der Pipeline läuft und ein eindeutiges Ergebnis liefert. Damit verschiebt sich Governance von manueller Kontrolle hin zu etwas Versioniertem, Ausführbarem, jederzeit Wiederholbarem – nicht umsonst trägt die zweite Auflage den Untertitel \"Automating Software Governance\".\n\nDas einfachste und bekannteste Beispiel sind strukturelle Regeln. Mit einer Bibliothek wie ArchUnit lässt sich eine Architekturvorgabe als ganz normaler Unit-Test formulieren:\n\n```java\n@ArchTest\nstatic final ArchRule domain_must_not_depend_on_infrastructure =\n    noClasses().that().resideInAPackage(\"..domain..\")\n      .should().dependOnClassesThat().resideInAPackage(\"..infrastructure..\");\n```\n\nDiese Regel sagt: Die Domänenschicht darf nicht auf die Infrastruktur zugreifen. Bisher stand so etwas in einem Wiki und wurde in Code Reviews hoffentlich bemerkt. Jetzt bricht der Build, sobald jemand die Grenze verletzt – am selben Tag, an dem es passiert, nicht drei Monate später in einer Refactoring-Retrospektive. Die Absicht wird vom Kommentar zum ausführbaren Fakt.\n\nUnd genau hier liegt der zweite große Trugschluss: Fitness Functions sind nicht auf solche Package- und Layering-Checks beschränkt. Das ist nur die sichtbarste Kategorie. Alles, was man an einer Architektur objektiv prüfen kann, ist Kandidat – Performance-Budgets, Security-Scans, Prüfungen auf zyklische Abhängigkeiten, sogar Chaos-Experimente oder monitoring-basierte Prüfungen im laufenden Betrieb.\n\nDie Autoren gliedern das in eine kleine Taxonomie, die im Alltag hilft, Lücken zu erkennen. Fitness Functions können atomic sein (prüfen eine einzelne Eigenschaft) oder holistic (prüfen das Zusammenspiel mehrerer). Sie können triggered sein (laufen bei einem Event, etwa im Build) oder continual (laufen dauerhaft in Produktion). Und sie können static sein (feste Schwelle) oder dynamic (Schwelle abhängig vom Kontext). Wer nur strukturelle, atomic-triggered Regeln hat, deckt eine Ecke ab und übersieht den Rest.\n\nEin Performance-Budget etwa ist eher holistisch und lässt sich als Gate in der Pipeline formulieren:\n\n```yaml\nfitness_functions:\n  - name: p95_latency_budget\n    metric: http_request_duration_p95_ms\n    threshold: 300\n    on_breach: fail_build\n  - name: max_module_coupling\n    metric: afferent_efferent_coupling_ratio\n    threshold: 0.7\n    on_breach: warn\n```\n\nDas Muster ist immer dasselbe: eine architektonische Eigenschaft, eine objektive Schwelle, eine definierte Konsequenz. Reißt die 95.-Perzentil-Latenz das Budget, bricht der Build. Steigt die Kopplung über den Grenzwert, gibt es zunächst eine Warnung. Man muss nicht alles hart erzwingen – aber man macht die Eigenschaft sichtbar und entscheidet bewusst, wie streng man sie behandelt.\n\n## Kopplung ist die eigentliche Bremse\n\nWenn ein System sich nicht mehr in kleinen Schritten ändern lässt, liegt das fast immer an Kopplung. Nicht nur an der offensichtlichen zwischen Klassen, sondern auch an versteckter zeitlicher Kopplung und an Datenkopplung. Zwei Dienste, die immer gemeinsam deployt werden müssen. Ein Schema, das drei Teams gleichzeitig anfassen müssen, um ein Feld zu ändern. Solange diese Bänder bestehen, ist inkrementeller Wandel unmöglich, egal wie gut die Absicht ist.\n\nDeshalb gehört zu den wichtigsten Fitness Functions die Kontrolle der Kopplung selbst. Und deshalb machen ein paar bewährte Muster hier den Unterschied. Die Strangler Fig Application von Martin Fowler beschreibt, wie man ein Altsystem inkrementell ablöst, indem man neue Funktionalität wie eine Würgefeige um den alten Kern wachsen lässt, bis der irgendwann verschwinden kann – ohne den einen großen, riskanten Umstieg. Auf Datenebene leistet das Expand-Contract-Pattern (auch Parallel Change) dasselbe: Man erweitert ein Schema, migriert schrittweise, entfernt das Alte erst, wenn niemand es mehr braucht. Refactoring wird so zur normalen, sicheren Bewegung statt zum Kraftakt.\n\nAuf der Betriebsebene sorgen Resilience-Pattern wie Circuit Breaker und Bulkhead aus Michael Nygards *Release It!* dafür, dass die lose gekoppelten Teile sich auch bei Störungen nicht gegenseitig mitreißen. Und an den Diensträndern hält Consumer-Driven Contract Testing die Grenzen ehrlich: Jeder Konsument beschreibt, was er von einem Dienst erwartet, und dieser Vertrag wird automatisiert geprüft. Ändert sich der Dienst inkompatibel, schlägt der Test an, bevor es der Nutzer merkt. Auch das ist eine Fitness Function – eine, die Änderbarkeit an den teuersten Bruchstellen absichert.\n\nEin Wort zu Conway's Law, denn Kopplung ist nicht nur technisch. Systeme spiegeln die Kommunikationsstrukturen der Organisationen, die sie bauen. Wer lose gekoppelte Software will, muss auch die Teams entsprechend schneiden. Das ist keine Randnotiz, sondern oft die Voraussetzung dafür, dass die technischen Leitplanken überhaupt greifen.\n\n## Der Beweis, dass es sich rechnet\n\nAn dieser Stelle kommt der Einwand: Klingt gut, aber all diese Automatisierung und dieses ständige Ändern – geht das nicht auf Kosten der Stabilität? Schneller heißt doch riskanter?\n\nDie Daten sagen das Gegenteil. Die DORA-Forschung, zusammengefasst in *Accelerate* (Forsgren, Humble, Kim, 2018), zeigt über tausende Organisationen hinweg, dass Geschwindigkeit und Stabilität keine Gegner sind, sondern gemeinsam steigen. Die leistungsstärksten Teams deployen häufiger *und* haben seltener Ausfälle *und* erholen sich schneller. Der vermeintliche Zielkonflikt zwischen Tempo und Verlässlichkeit ist ein Artefakt des alten Denkens – das Ergebnis von seltenen, großen, riskanten Releases.\n\nDie vier klassischen DORA-Metrics lesen sich selbst wie systemweite Fitness Functions:\n\n```text\nDeployment Frequency     - wie oft deployen wir?\nLead Time for Changes    - wie lange von Commit bis Produktion?\nChange Failure Rate      - wie viele Deployments verursachen einen Fehler?\nTime to Restore Service  - wie schnell sind wir wieder online?\n```\n\nSie messen nicht ein einzelnes Modul, sondern die Änderbarkeit des Gesamtsystems. Verkürzte Zyklen, weniger Reibung, höhere Qualität werden damit zu Größen, die man in dieselbe Regelkreis-Logik einhängen kann wie eine Latenzgrenze.\n\n```mermaid\nflowchart TB\n  subgraph Old[Architecture as end-state]\n    O1[Big Design Up Front] --> O2[freeze] --> O3[drift & erosion]\n  end\n  subgraph New[Architecture as guarded evolution]\n    N1[named characteristics] --> N2[fitness functions] --> N3[small guarded change] --> N1\n  end\n```\n\nDer Denkrahmen ist im Übrigen nicht auf Technik beschränkt. Er ist dasselbe Prinzip, das *The Lean Startup* für Produkte formuliert hat – Build, Measure, Learn: eine Hypothese, ein kleiner Schritt, eine Messung, eine Anpassung. Architektur-Evolution ist die technische Ausprägung derselben Haltung. Man behauptet nicht, die richtige Struktur zu kennen. Man baut so, dass man es günstig herausfinden kann.\n\n## Fazit\n\nArchitektur ist kein Endzustand. Sie ist die Fähigkeit eines Systems, sich weiter verändern zu lassen, ohne die Eigenschaften zu verlieren, die es wertvoll machen. Wer das ernst nimmt, hört auf, ein Zielbild zu verteidigen, und fängt an, die richtigen Dinge zu schützen.\n\nDer Weg dahin ist erstaunlich konkret. Erstens: die Architektur-Charakteristika benennen, die wirklich zählen – nicht alle, sondern die wenigen, die den Unterschied machen. Zweitens: für jede dieser Eigenschaften eine Fitness Function bauen, die objektiv, automatisiert und in der Pipeline prüft, ob sie noch hält – strukturell, aber eben auch bei Performance, Sicherheit und Kopplung. Drittens: Kopplung aktiv niedrig halten, technisch wie organisatorisch, damit kleine Schritte überhaupt möglich bleiben. Und viertens: dem Ganzen vertrauen, weil die Daten zeigen, dass Tempo und Stabilität zusammen wachsen.\n\nDer Ansatz schafft noch keine kurzen Zyklen und garantiert keine stabile Qualität. Er macht benannte Eigenschaften bei kleinen Änderungen früher überprüfbar. Ob dadurch Reibung sinkt oder Änderungen schneller werden, zeigt erst der reale Lieferfluss. Die beste Architektur ist für mich deshalb nicht die, die für immer richtig sein soll, sondern eine, deren Annahmen sich prüfen und bei neuen Erkenntnissen ändern lassen.\n\n## Weiterführende Quellen\n\n- Neal Ford, Rebecca Parsons, Patrick Kua: *Building Evolutionary Architectures*, O'Reilly, 1. Aufl. 2017 / 2. Aufl. 2022 (mit Pramod Sadalage)\n- Martin Fowler: [Foreword to Building Evolutionary Architectures](https://martinfowler.com/articles/evo-arch-forward.html)\n- [DORA – Four Key Metrics](https://dora.dev/guides/dora-metrics-four-keys/)\n- Nicole Forsgren, Jez Humble, Gene Kim: *Accelerate*, IT Revolution, 2018\n- [ArchUnit – Unit-Tests für Architekturregeln](https://www.archunit.org/)\n- [InfoQ – Fitness Functions for Your Architecture](https://www.infoq.com/articles/fitness-functions-architecture/)\n- Michael Nygard: *Release It!*, Pragmatic Bookshelf, 2. Aufl. 2018"},{"slug":"von-hocs-und-render-props-zu-custom-hooks","title":"Von HOCs und Render Props zu Custom Hooks","summary":"Wie teile ich Logik zwischen Komponenten ohne Copy-Paste? Custom Hooks vermeiden dafür meist verschachtelte Wrapper; HOCs und Render Props behalten einzelne Kompositionsaufgaben.","tags":["Software","Frontend","React"],"published_at":"2023-06-20T10:00:00+00:00","updated_at":"2026-07-13T08:50:07.649617+00:00","url":"https://www.mikebild.dev/de/blog/von-hocs-und-render-props-zu-custom-hooks/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/7e416769f9d0b67d68c5d5d7fb781dc6e9c108d6e40ee2abad6efd7a5a6577a3/von-hocs-und-render-props-zu-custom-hooks-vorlesen.mp3","audio_duration_secs":634,"text":"Vier Ebenen im React-DevTools-Baum, bevor die erste erscheint, die tatsächlich etwas Sichtbares rendert: `withRouter`, darunter `withLoading`, darunter `withData`, und ganz unten die eigentliche Seite. Drei dieser Ebenen tragen nichts zur Darstellung bei. Sie existieren nur, um State und Verhalten von außen nach innen zu reichen.\n\nDieser Baum ist kein Unfall, sondern die logische Folge einer Entscheidung, die React über Jahre mitgeschleppt hat. Denn stateful Logik an mehreren Stellen wiederzuverwenden, ohne sie zu duplizieren, hat das Framework in drei Generationen jeweils anders gelöst – und die ersten beiden Lösungen bezahlen dafür mit genau solchen Ebenen. Wer die Reihenfolge kennt, versteht auch, warum die älteren Muster trotzdem nicht wertlos sind.\n\n## Die immer gleiche Frage\n\nEin Beispiel, an dem sich das gut zeigen lässt: Eine Komponente soll wissen, ob der Browser gerade online ist. Das braucht State, das braucht einen Effect mit Event-Listenern, und das braucht Cleanup. Wenn drei Komponenten das brauchen, will niemand denselben Block dreimal schreiben.\n\nDie naive Lösung ist Copy-Paste. Sie funktioniert genau so lange, bis sich die Logik ändert – dann ändert man sie an einer Stelle und vergisst die anderen beiden. Ab da suchen React-Entwickler nach einem Mechanismus, der die Logik an genau einem Ort hält.\n\nHistorisch sah der Weg dorthin so aus:\n\n```mermaid\ngraph LR\n  A[Copy-Paste] --> B[Mixins<br/>deprecated]\n  B --> C[HOCs &<br/>Render Props]\n  C --> D[Custom Hooks<br/>React 16.8 / 2019]\n```\n\nMixins waren die erste Antwort in den ganz frühen Tagen mit `createClass`, wurden aber schnell als problematisch verworfen – unklare Herkunft von Properties, Namenskollisionen, implizite Abhängigkeiten. Danach kamen zwei Muster, die lange dominierten: Higher-Order Components und Render Props. Und seit React 16.8 – stabil seit Februar 2019 – gibt es Custom Hooks. Schauen wir uns die drei relevanten Antworten der Reihe nach an.\n\n## Antwort 1: Higher-Order Components\n\nEin Higher-Order Component ist im Grunde ein alter Bekannter aus der funktionalen Programmierung. So wie eine Higher-Order Function eine Funktion nimmt und eine neue Funktion zurückgibt, nimmt ein HOC eine Komponente und gibt eine neue, erweiterte Komponente zurück. Die Signatur ist:\n\n```js\n// a function that takes a component and returns a new component\nconst withSomething = (Component) => EnhancedComponent;\n```\n\nDie Namenskonvention ist `withXxx`. Nehmen wir das Klassikerbeispiel `withLoading`, das einen Ladezustand kapselt:\n\n```jsx\nimport { Fragment } from \"react\";\n\nfunction withLoading(Wrapped) {\n  return function WithLoading(props) {\n    if (props.loading) {\n      return <Spinner />;\n    }\n    return <Wrapped {...props} />;\n  };\n}\n```\n\nAngewendet wird das Ganze auf Modulebene, nicht im Render:\n\n```jsx\nconst UserPageWithLoading = withLoading(UserPage);\n```\n\nDas liest sich sauber. Und es komponiert: Man kann mehrere HOCs stapeln, `withRouter(withLoading(withData(Page)))`. Genau da hat das Ganze aber seinen Preis.\n\nDass die Anwendung außerhalb des Renders passieren muss, wirkt wie eine Formalie – bis man einmal dagegen verstößt. Erzeugt man ein HOC direkt im `render()`, entsteht bei jedem Durchlauf eine neue Komponenten-Identität. React sieht einen anderen Typ, hängt den kompletten Subtree aus dem Baum und baut ihn neu auf – der gesamte State darunter geht verloren. Das ist einer der Bugs, die man einmal live debuggt und danach nie wieder produziert.\n\nDazu kommen zwei weitere Stolpersteine, die man kennen muss:\n\n- Statische Methoden der Original-Komponente verschwinden, weil der Wrapper sie nicht automatisch mitkopiert – man braucht etwas wie `hoist-non-react-statics`.\n- Refs gehen nicht als normale Prop durch den Wrapper hindurch. Wer eine Ref auf die innere Komponente braucht, muss `React.forwardRef` bemühen (verfügbar seit React 16.3).\n\nUnd die Grundregel, die alles zusammenhält: Komposition statt Mutation. Ein HOC mutiert die übergebene Komponente nicht, es umschließt sie in einem Container. Sobald jemand anfängt, die Original-Komponente direkt zu verändern, ist das Muster kaputt.\n\n## Antwort 2: Render Props\n\nDas zweite Muster dreht den Spieß um. Statt eine Komponente in eine andere zu wickeln, gibt man einer Komponente eine Funktion mit, die ihr sagt, was sie rendern soll. Das ist eine Render Prop: „a function prop that a component uses to know what to render“. Oft nutzt man dafür direkt `children` als Funktion.\n\n```jsx\nclass OnlineStatus extends React.Component {\n  state = { isOnline: false };\n\n  handleOnline = () => this.setState({ isOnline: true });\n  handleOffline = () => this.setState({ isOnline: false });\n\n  componentDidMount() {\n    window.addEventListener(\"online\", this.handleOnline);\n    window.addEventListener(\"offline\", this.handleOffline);\n  }\n\n  componentWillUnmount() {\n    window.removeEventListener(\"online\", this.handleOnline);\n    window.removeEventListener(\"offline\", this.handleOffline);\n  }\n\n  render() {\n    return this.props.children(this.state.isOnline);\n  }\n}\n```\n\nAufgerufen wird das dann so:\n\n```jsx\n<OnlineStatus>\n  {(isOnline) => (\n    <span>{isOnline ? \"Online\" : \"Offline\"}</span>\n  )}\n</OnlineStatus>\n```\n\nDas ist flexibler als ein HOC, weil der Aufrufer die volle Kontrolle über das Rendering behält. Aber auch hier gibt es einen Preis: Wenn man die Funktion inline im JSX erzeugt, wird sie bei jedem Render neu erstellt. Für eine `PureComponent` oder eine mit `memo` optimierte Kind-Komponente ist das Gift – der Shallow-Compare schlägt fehl, weil die Funktionsreferenz jedes Mal neu ist, und die Optimierung läuft ins Leere. Man müsste die Funktion als stabile Instanzmethode auslagern, was die vermeintliche Eleganz wieder auffrisst.\n\n## Das gemeinsame Problem: Wrapper Hell\n\nBeide Muster – HOC und Render Props – teilen Logik über dieselbe Mechanik: über zusätzliche Ebenen im Komponentenbaum. Jedes HOC ist ein Wrapper, jede Render Prop eine weitere Verschachtelung. Bei einer, zwei Ebenen merkt man nichts. Bei fünf sieht der React-DevTools-Baum aus wie eine Zwiebel, und das nennt man liebevoll „wrapper hell“.\n\nGenau dieser Baum ist der entscheidende Punkt. Schauen wir ihn uns an:\n\n```mermaid\ngraph TD\n  subgraph HOC[\"HOC / Render Props: verschachtelt\"]\n    R1[withRouter] --> L1[withLoading]\n    L1 --> D1[withData]\n    D1 --> P1[Page]\n  end\n```\n\nJede Zwischenebene existiert nur, um Logik hereinzureichen. Sie hat keinen eigenen fachlichen Sinn, sie ist reine Infrastruktur im Baum. Und weil die Daten von außen nach innen durchgereicht werden, entstehen leicht Zwischenschichten mit veralteten Closures – ein weiterer Debugging-Klassiker.\n\n## Antwort 3: Custom Hooks\n\nCustom Hooks lösen das Problem an einer anderen Stelle. Ein Custom Hook ist einfach eine normale JavaScript-Funktion, deren Name mit `use` beginnt und die andere Hooks aufrufen darf. Kein Wrapper, keine zusätzliche Komponente, keine neue Ebene im Baum. Die Logik zieht in eine Funktion um, die Komponente ruft sie auf – fertig.\n\nDasselbe `withLoading`-Problem sieht als Hook so aus, dass der Hook den State liefert und die Komponente selbst entscheidet, was sie damit rendert:\n\n```jsx\nimport { useState, useEffect } from \"react\";\n\nfunction useOnlineStatus() {\n  const [isOnline, setIsOnline] = useState(true);\n\n  useEffect(() => {\n    const handleOnline = () => setIsOnline(true);\n    const handleOffline = () => setIsOnline(false);\n\n    window.addEventListener(\"online\", handleOnline);\n    window.addEventListener(\"offline\", handleOffline);\n\n    return () => {\n      window.removeEventListener(\"online\", handleOnline);\n      window.removeEventListener(\"offline\", handleOffline);\n    };\n  }, []);\n\n  return isOnline;\n}\n```\n\nUnd die Nutzung ist so unspektakulär, wie sie sein sollte:\n\n```jsx\nfunction StatusBar() {\n  const isOnline = useOnlineStatus();\n  return <span>{isOnline ? \"Online\" : \"Offline\"}</span>;\n}\n```\n\nVergleicht man das mit der `OnlineStatus`-Render-Prop von oben: dieselbe Logik, aber ohne die Verschachtelung, ohne die inline-Funktion, ohne die Klasse. Der Baum bleibt flach:\n\n```mermaid\ngraph TD\n  Page2[Page] -.ruft auf.-> H1[useData]\n  Page2 -.ruft auf.-> H2[useLoading]\n  Page2 -.ruft auf.-> H3[useRouter]\n```\n\nDie Hooks hängen seitlich an der Komponente – es sind Funktionsaufrufe, keine Baumebenen. Das ist der ganze Trick, und es ist ein großer.\n\nZwei Eigenschaften von Custom Hooks sind es wert, explizit ausgesprochen zu werden, weil sie oft missverstanden werden:\n\n- Custom Hooks teilen **Logik, nicht State**. Rufen zwei Komponenten `useOnlineStatus()` auf, hat jede ihren eigenen, unabhängigen State. Der Hook ist ein Rezept, kein geteilter Topf.\n- Custom Hooks re-rendern mit ihrer Komponente. Bei jedem Re-Render bekommt der Hook frische Props und aktuellen State – die „veralteten Closures“, die in HOC-Zwischenschichten so leicht entstehen, sind hier deutlich seltener ein Thema.\n\n## Regeln, die auch für Custom Hooks gelten\n\nDas `use`-Präfix ist keine Kosmetik. Es signalisiert dem Linter und den Lesern, dass diese Funktion selbst Hooks aufrufen darf – und dass für sie die Rules of Hooks gelten. Konkret heißt das:\n\nNur React-Komponenten und andere Hooks dürfen Hooks aufrufen. Eine gewöhnliche Funktion ohne `use` darf kein `useState` enthalten. Umgekehrt gilt genauso: Eine Funktion, die gar keine Hooks aufruft, sollte nicht `use` heißen. Denn sonst nimmt man sich die Freiheit, sie bedingt oder in einer Schleife aufzurufen – was bei echten Hooks verboten ist. Das Präfix ist also ein Vertrag in beide Richtungen. Durchgesetzt wird das Ganze vom `eslint-plugin-react-hooks`, das ich in keinem React-Projekt weglasse.\n\nEine Falle, die weniger technisch als konzeptionell ist, sehe ich in Codebasen immer wieder: der Drang, gleich generische „Lifecycle“-Hooks zu bauen, etwa ein `useMount`, das einen Effect beim Mounten kapselt. Das ist meist ein schlechter Tausch. Ein Custom Hook sollte einen konkreten, benennbaren Anwendungsfall abbilden – `useOnlineStatus`, `useChatRoom`, `useFormInput`. Wenn der Name des Hooks bereits erklärt, was er fachlich tut, ist er gut geschnitten. Wenn er nur einen React-Mechanismus umbenennt, hat man nichts gewonnen und die Abstraktion später im Weg.\n\n## Wann HOCs heute noch sinnvoll sind\n\nNach all dem klingt es, als hätten Custom Hooks die beiden älteren Muster restlos ersetzt. Für das Teilen von stateful Logik stimmt das auch weitgehend. Aber es gibt eine Handvoll Aufgaben, für die HOCs weiterhin die passende Form sind – nämlich echte Cross-Cutting-Concerns, die von außen an eine Komponente herangetragen werden.\n\nDas klassische Beispiel ist Context-Injection: ein HOC, das einer beliebigen Komponente einen Wert aus dem Context als Prop verfügbar macht, ohne dass die Komponente selbst den Context kennen muss. Solche `withXxx`-Wrapper begegnen einem in älteren Bibliotheken oft, und sie erfüllen dort einen Zweck, den ein Hook nicht sauber abdeckt – etwa wenn Klassenkomponenten im Spiel sind, die gar keine Hooks aufrufen können. Wer eine bestehende Codebasis mit Klassenkomponenten pflegt, wird HOCs also noch länger begegnen.\n\nWas ich hingegen nicht mehr empfehle, ist der Griff zu HOC-Baukästen wie `recompose`. Die Bibliothek war zu ihrer Zeit ein cleveres Werkzeug, um Funktionen wie `withState` oder `withReducer` zu komponieren:\n\n```js\n// recompose - historical, deprecated since 2018\nimport { compose, withState } from \"recompose\";\n\nconst enhance = compose(\n  withState(\"count\", \"setCount\", 0)\n);\n```\n\nDer Autor selbst hat `recompose` allerdings schon 2018 als deprecated markiert und dabei ausdrücklich auf Hooks als Nachfolger verwiesen. Man begegnet dem Paket also nur noch als historischem Kontext in älteren Repositories – als aktuelle Empfehlung taugt es nicht.\n\n## Der rote Faden\n\nDrei Antworten auf eine Frage, und die Reihenfolge erzählt eine klare Geschichte. HOCs und Render Props haben gezeigt, dass sich Logik teilen lässt, ohne sie zu kopieren – aber sie haben es über den Komponentenbaum gelöst und dafür mit Verschachtelung, verlorenen Refs und statischen Methoden, aufgehobenen `memo`-Optimierungen und „wrapper hell“ bezahlt. Custom Hooks lösen dieselbe Aufgabe über einen simplen Funktionsaufruf und lassen den Baum flach.\n\nFür geteilte Logik ist der erste Griff deshalb heute ein Custom Hook mit `use`-Präfix, der einen konkreten Use-Case kapselt. HOCs bleiben im Werkzeugkasten für die Cross-Cutting-Fälle und für die Arbeit an bestehendem Code mit Klassenkomponenten. Und Render Props tauchen noch dort auf, wo eine Komponente wirklich Kontrolle über das Rendering an den Aufrufer zurückgeben will. Aber der Standardfall – dieselbe Logik in mehreren Komponenten – hat seit 2019 eine gute, unspektakuläre Antwort. Man muss nur wissen, warum die anderen beiden vorher da waren.\n\n## Weiterführende Quellen\n\n- [Reusing Logic with Custom Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) – das aktuelle Kapitel zu Custom Hooks\n- [Higher-Order Components](https://legacy.reactjs.org/docs/higher-order-components.html) – die historische HOC-Dokumentation\n- [Render Props](https://legacy.reactjs.org/docs/render-props.html) – das Render-Props-Pattern im Original\n- [Hooks – Custom Hooks (Legacy)](https://legacy.reactjs.org/docs/hooks-custom.html) – das historische Custom-Hooks-Kapitel\n- [Rules of Hooks / Invalid Hook Call Warning](https://react.dev/warnings/invalid-hook-call-warning) – warum das `use`-Präfix und die Regeln zählen"},{"slug":"nodejs-async-context-mit-asynclocalstorage","title":"Async-Kontext in Node.js: AsyncLocalStorage, async_hooks und diagnostics_channel","summary":"Wie man request-bezogenen Kontext wie Request-ID, User und Trace durch tiefe asynchrone Aufrufketten trägt, ohne ihn durch jede Funktionssignatur zu fädeln – mit AsyncLocalStorage, dem darunterliegenden async_hooks und diagnostics_channel für entkoppeltes Tracing.","tags":["Software","Node.js","Backend"],"published_at":"2023-05-16T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/nodejs-async-context-mit-asynclocalstorage/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/5df7f260c8a8161a881ef774e940bb41c10d60b1e1dd5677006aa1c1f35177a2/nodejs-async-context-mit-asynclocalstorage-vorlesen.mp3","audio_duration_secs":912,"text":"`AsyncLocalStorage` ist keine globale Variable mit einem besseren Namen. Jeder `run`-Aufruf hält seinen eigenen Store, und zwei nebenläufige Requests kommen sich darin nicht in die Quere – genau an dieser Stelle scheitert die globale Variable, zu der man sonst reflexhaft greift. Die Verwechslung hält sich trotzdem, weil beides von außen gleich aussieht: oben etwas hinlegen, weit unten wieder herausholen.\n\nWozu das taugt, zeigt sich im Betrieb und nicht im Code. Ob eine Ursachensuche Minuten oder Stunden dauert, hängt daran, ob sich ein einzelner Request lückenlos durch Logs, Services und Traces verfolgen lässt. Node.js bringt dafür drei Bausteine mit: `AsyncLocalStorage`, das darunterliegende `async_hooks` und `diagnostics_channel`. Im Beitrag über [Node.js debuggen und beobachten](https://www.mikebild.dev/de/blog/nodejs-debuggen-und-beobachten/) hatte ich mir Async-Kontext für später aufgehoben; das hole ich hier nach.\n\nDas ist kein Randproblem. Sobald eine Anwendung mehr als ein paar Endpoints hat, will man wissen, welche Logzeile zu welchem Request gehört. Man will den eingeloggten User im Fehlerbericht sehen. Man will einen Trace über mehrere Services hinweg zusammenhalten. Und all diese Werte gehören nun einmal zum Request, nicht zur Funktion, die gerade zufällig ausgeführt wird.\n\n## Das Problem: Kontext durch die Tiefe fädeln\n\nSchauen wir uns an, wie das ohne Hilfsmittel aussieht. Ich habe einen Handler, der einen Service ruft, der ein Repository ruft, das am Ende etwas loggen will. Die Request-ID entsteht ganz oben – gebraucht wird sie ganz unten.\n\n```js\nfunction handler(req, res) {\n  const requestId = randomUUID();\n  serviceA(req.body, requestId);\n}\n\nfunction serviceA(input, requestId) {\n  // serviceA doesn't need requestId itself\n  return repository(input, requestId);\n}\n\nfunction repository(input, requestId) {\n  // repository doesn't either\n  log('writing record', requestId);\n}\n\nfunction log(message, requestId) {\n  console.log(`[${requestId}] ${message}`);\n}\n```\n\nDas funktioniert – aber es ist unangenehm. Jede Funktion dazwischen bekommt einen Parameter aufgezwungen, den sie selbst nie benutzt. Sie reicht ihn nur durch. Kommt morgen ein User-Objekt dazu und übermorgen eine Trace-ID, wächst jede Signatur mit. Und die eine Bibliotheksfunktion, die man nicht ändern kann, reicht den Parameter natürlich nicht durch – dort reißt die Kette.\n\nMan kann sich mit Umwegen behelfen: den Kontext an eine Klasseninstanz hängen, ein Objekt durchreichen, das alle Zusatzdaten sammelt, oder gar eine globale Variable setzen. Der Klasseninstanz-Weg zwingt einen in ein Objektmodell, das man vielleicht gar nicht will. Das Kontextobjekt ist nur eine hübscher verpackte Variante desselben Durchreichens. Und die globale Variable ist bei nebenläufigen Requests schlicht falsch – der nächste Request überschreibt sie, während der erste noch läuft. Keine dieser Varianten trägt wirklich.\n\nGenau für dieses „context threading“-Problem gibt es in Node.js eine saubere Antwort.\n\n## AsyncLocalStorage: der praktische Weg\n\n`AsyncLocalStorage` steckt im Modul `node:async_hooks` und ist seit Node v16.4.0 stabil. Die Idee ist einfach: Man führt einen Callback in einem sogenannten Store aus, und dieser Store bleibt für alle asynchronen Operationen erhalten, die innerhalb dieses Callbacks entstehen. Beliebig tief unten liest man ihn wieder aus – ohne einen einzigen zusätzlichen Parameter.\n\nVorweg ein ehrlicher Hinweis: Für diesen Beitrag gibt es kein Beispiel aus einem konkreten Projektbestand. Die folgenden Codeblöcke sind generische Node.js-Beispiele, die ich gegen die offizielle Dokumentation geprüft habe.\n\n```js\nimport { AsyncLocalStorage } from 'node:async_hooks';\nimport { randomUUID } from 'node:crypto';\n\nconst als = new AsyncLocalStorage();\n\nfunction handler(req, res) {\n  const store = { requestId: randomUUID(), user: req.user };\n  als.run(store, () => {\n    serviceA(req.body);\n  });\n}\n\nfunction serviceA(input) {\n  return repository(input);\n}\n\nfunction repository(input) {\n  log('writing record');\n}\n\nfunction log(message) {\n  const { requestId } = als.getStore() ?? {};\n  console.log(`[${requestId}] ${message}`);\n}\n```\n\nDer Unterschied ist deutlich. `serviceA` und `repository` wissen nichts mehr von der Request-ID. Sie kennen ihren eigentlichen Job und sonst nichts. Nur ganz oben wird der Store gesetzt – mit `als.run(store, callback)` – und nur dort, wo er wirklich gebraucht wird, wird er mit `als.getStore()` gelesen.\n\nZwei Details sind wichtig. Erstens ist `run` synchron: Der Rückgabewert des Callbacks wird durchgereicht, und wenn der Callback wirft, wird der Fehler ebenfalls weitergegeben und der Kontext wieder verlassen. Zweitens liefert `getStore()` außerhalb eines `run`-Aufrufs schlicht `undefined`. Deshalb steht im Beispiel das `?? {}` – man sollte nie davon ausgehen, dass immer ein Store vorhanden ist.\n\nAls Store eignet sich jedes beliebige Objekt. In der Praxis nehme ich gern eine `Map`, wenn im Verlauf eines Requests noch Werte dazukommen sollen, oder ein schlichtes Objekt, wenn der Inhalt von Anfang an feststeht. Wichtig ist nur, dass jeder `run`-Aufruf seinen eigenen Store bekommt – dann sind nebenläufige Requests sauber voneinander getrennt, ohne dass man sich um die Trennung selbst kümmern muss. Genau das ist der Gewinn gegenüber der globalen Variable von eben.\n\nDiese Ebene zeigt das folgende Diagramm. Der Store wird oben gesetzt, die tiefen Aufrufe lesen ihn, ohne dass ein Parameter je weitergereicht wird.\n\n```mermaid\nflowchart TD\n  Req[\"HTTP Request\"] --> Run[\"als.run(store)\"]\n  Run --> A[\"serviceA()\"]\n  A --> R[\"repository()\"]\n  R --> L[\"log()\"]\n  Store[(\"Store<br/>requestId, user\")]\n  Run -.-> Store\n  L -.->|\"reads store\"| Store\n```\n\n### enterWith – die riskante Abkürzung\n\nNeben `run` gibt es `enterWith(store)`. Der Reiz ist offensichtlich: Man muss den restlichen Code nicht in einen Callback einrücken, sondern setzt den Kontext einfach und läuft danach normal weiter. Genau das ist aber auch die Gefahr. `enterWith` gilt für den gesamten Rest der synchronen Ausführung plus alle folgenden asynchronen Aufrufe – es gibt keine natürliche Klammer, die den Kontext wieder schließt. Setzt man das in einem Event-Handler, geraten unter Umständen nachfolgende Handler in denselben Kontext.\n\n`enterWith` ist als experimentell markiert, und die Dokumentation ist ungewöhnlich direkt: Man solle `run()` benutzen, sofern es keine starken Gründe dagegen gebe. Praktisch heißt das: mit `run` anfangen. `enterWith` bleibt seltenen Sonderfällen vorbehalten und ist nicht der Normalweg.\n\n## async_hooks: das Fundament darunter\n\nWoher weiß `AsyncLocalStorage` überhaupt, welche asynchronen Operationen zu welchem `run`-Aufruf gehören? Darunter liegt `async_hooks`, die eigentliche Low-Level-Schicht. Sie stellt Lifecycle-Hooks für asynchrone Ressourcen bereit: Für jede solche Ressource durchläuft Node die Phasen `init`, `before`, `after` und `destroy`. Man kann sich in diese Phasen einklinken.\n\n```js\nimport { createHook, executionAsyncId } from 'node:async_hooks';\nimport { writeSync } from 'node:fs';\n\nconst hook = createHook({\n  init(asyncId, type, triggerAsyncId) {\n    writeSync(1, `init ${type} id=${asyncId} trigger=${triggerAsyncId}\\n`);\n  },\n  destroy(asyncId) {\n    writeSync(1, `destroy id=${asyncId}\\n`);\n  },\n});\n\nhook.enable();\n```\n\nWarum `writeSync` statt `console.log`? Weil `console.log` selbst asynchrone Ressourcen erzeugt und man sich in einem `init`-Hook damit in eine Endlosschleife schreiben würde. Das allein zeigt schon, wie heikel diese Schicht im direkten Umgang ist.\n\nUnd hier muss ich deutlich werden: `async_hooks` ist experimentell, und die Node-Dokumentation rät ausdrücklich davon ab, es direkt einzusetzen – wegen „usability issues, safety risks, and performance implications“. Der `destroy`-Hook ist besonders teuer, weil er ein Tracking von Promises über den Garbage Collector aktiviert. Das kostet messbar Leistung.\n\nDie Botschaft ist also nicht „lern async_hooks und benutz es“. Die Botschaft ist: `AsyncLocalStorage` steht auf diesem Fundament, aber es ist die stabile, empfohlene und praktische API. Man greift in aller Regel nicht selbst zu `createHook`. Ich zeige es hier nur, damit klar wird, was eine Ebene tiefer passiert – nicht als Empfehlung für den Produktionscode.\n\n## diagnostics_channel: entkoppelte Instrumentierung\n\nDer dritte Baustein löst ein anderes Problem. Angenommen, eine Bibliothek möchte melden, dass sie gerade einen Request startet – aber sie soll nicht vorschreiben, wer das mitbekommt oder was damit geschieht. Ein Logger? Ein Tracer? Ein Metric-Zähler? Die Bibliothek soll das nicht wissen müssen.\n\n`diagnostics_channel` ist seit Node v18.13.0 stabil und genau dafür da: ein entkoppelter Publish/Subscribe-Kanal. Produzenten veröffentlichen Messages auf einem benannten Kanal, Beobachter abonnieren ihn. Beide Seiten kennen sich nicht.\n\n```js\nimport diagnostics_channel from 'node:diagnostics_channel';\n\nconst channel = diagnostics_channel.channel('app:request:start');\n\nfunction handleRequest(req) {\n  if (channel.hasSubscribers) {\n    channel.publish({\n      requestId: req.id,\n      route: req.url,\n      startedAt: Date.now(),\n    });\n  }\n  // ... actual work\n}\n```\n\nAuf der anderen Seite, in einem völlig getrennten Stück Code – vielleicht in der Observability-Schicht, vielleicht in einem Plugin – abonniert man denselben Kanal:\n\n```js\nimport diagnostics_channel from 'node:diagnostics_channel';\n\ndiagnostics_channel.subscribe('app:request:start', (message, name) => {\n  console.log(`[${name}] request ${message.requestId} on ${message.route}`);\n});\n```\n\nDer `hasSubscribers`-Guard ist kein Detail, sondern der Kern der Sache. Ohne Abonnenten soll gar nichts kosten. Baut man das Nachrichtenobjekt bedingungslos – womöglich mit teuren Feldern oder serialisierten Payloads –, zahlt man diesen Preis auch dann, wenn niemand zuhört. Mit dem Guard fällt bei null Abonnenten praktisch kein Aufwand an.\n\nEin Hinweis zur API: Es gibt am Channel-Objekt auch `channel.subscribe(...)`, aber diese Variante ist seit v18.7.0 als veraltet markiert. Im eigenen Code nimmt man die Funktionen auf Modul-Ebene, also `diagnostics_channel.subscribe(name, onMessage)`.\n\n## Drei Ebenen, drei Aufgaben\n\nDie drei Module klingen ähnlich und tun Grundverschiedenes – genau hier entsteht die Verwirrung. Die Rollenverteilung:\n\n- `AsyncLocalStorage` trägt Kontext: stabil seit v16.4.0, die praktische High-Level-API. Request-ID, User und Trace durch tiefe Async-Ketten – ohne Parameter-Durchreichen.\n- `async_hooks` ist das Fundament: experimentell und ausdrücklich nicht zum direkten Gebrauch empfohlen. Es liefert den Ressourcen-Lifecycle, auf dem `AsyncLocalStorage` aufsetzt.\n- `diagnostics_channel` meldet Events entkoppelt: stabil seit v18.13.0. Produzenten und Konsumenten kennen sich nicht – ideal für Instrumentierung und Tracing.\n\nDas folgende Diagramm ordnet die drei zueinander an: die Kontext-Schicht auf ihrem Fundament, daneben der eigenständige Event-Bus.\n\n```mermaid\nflowchart TB\n  subgraph Context[\"Kontext tragen\"]\n    ALS[\"AsyncLocalStorage<br/>Stable\"]\n    AH[\"async_hooks<br/>Experimental\"]\n    ALS --> AH\n  end\n  subgraph Bus[\"Ereignisse entkoppelt melden\"]\n    Pub[\"Publisher\"] --> Ch[\"diagnostics_channel\"]\n    Ch --> S1[\"Subscriber: Logger\"]\n    Ch --> S2[\"Subscriber: Tracer\"]\n  end\n```\n\n## Request-Korrelation als messbare Betriebsqualität\n\nDie drei Module sind Mittel, kein Zweck. Der Zweck ist eine Eigenschaft des laufenden Systems: Lässt sich jeder Vorgang eindeutig einem Request zuordnen? Sobald das gegeben ist, wird aus einem diffusen „das System fühlt sich langsam an“ eine beantwortbare Frage. Man greift eine Correlation-ID, filtert Logs, Metrics und Traces darauf und sieht den einen Request in seiner ganzen Tiefe – über Service-Grenzen hinweg.\n\nDiese Nachverfolgbarkeit ist messbar, und genau das macht sie zu einer Betriebsqualität statt zu einem Bauchgefühl. Der Anteil der Logzeilen, die einer Request-ID zugeordnet sind, ist eine Kennzahl. Die Zeit von der Fehlermeldung bis zur gefundenen Ursache – die Mean Time to Resolution – ist eine Kennzahl. Beide bewegen sich spürbar, wenn Korrelation lückenlos ist statt löchrig. `AsyncLocalStorage` liefert dafür den tragenden Baustein: den Schlüssel, der ohne Parameter-Durchreichen bis in die tiefste Funktion reicht. `diagnostics_channel` liefert den zweiten: die Entkopplung, mit der eine Observability-Schicht diesen Schlüssel abgreift, ohne dass der Anwendungscode wissen muss, wer zuhört.\n\nDer Trade-off ist ehrlich zu benennen. Kontext über `AsyncLocalStorage` kostet einen kleinen, aber realen Overhead pro asynchroner Operation – für die allermeisten Backends vernachlässigbar gegenüber dem Gewinn, in Ausnahmefällen mit extrem hohem Durchsatz aber ein Punkt, den man misst statt annimmt. Der größere Preis ist Disziplin: Der `run`-Aufruf muss zuverlässig am Eingang jeder Ausführungskette sitzen, sonst reißt die Korrelation genau dort, wo man sie am dringendsten braucht.\n\nDie Entscheidung fällt also nicht an der API, sondern am Anspruch. Eine Handvoll Endpoints ohne echte Betriebslast kommt ohne all das aus. Sobald aber mehrere Services zusammenspielen, ein Bereitschaftsdienst existiert oder Fehler Geld kosten, wird Request-Korrelation vom Nice-to-have zur Grundlage. Sie ist der Baustein, auf dem Logging, Tracing und Alerting erst zu einem Bild zusammenwachsen – die Brücke von einzelnen Signalen zu echter Observability.\n\n## Fallstricke aus der Praxis\n\nDer häufigste Schmerz mit `AsyncLocalStorage` ist Kontextverlust: `getStore()` liefert plötzlich `undefined`, obwohl man sich sicher innerhalb eines `run`-Aufrufs wähnt. Das passiert typischerweise an bestimmten callback-basierten APIs oder bei eigenen, selbstgebauten „thenable“-Objekten, bei denen Node die asynchrone Verkettung nicht sauber mitverfolgen kann. Die Dokumentation nennt zwei Abhilfen: solche APIs mit `util.promisify()` in echte Promises überführen oder die Ressource mit `AsyncResource` binden. Praktischer Debug-Trick: An verdächtigen Übergängen `getStore()` loggen. Ist der Store direkt nach einem bestimmten Aufruf weg, hat man den Übergang gefunden, an dem die Verkettung reißt.\n\nBeim `enterWith`-Leak habe ich es oben schon angedeutet – hier noch einmal konkret als Warnung: Der Kontext läuft über die gesamte weitere synchrone Ausführung und danach weiter. In einem Event-Handler gesetzt, können Folge-Handler denselben Kontext erben, obwohl sie zu einem ganz anderen Request gehören. Das sind dann Fehler, die man nachts um drei sucht. `run()` bevorzugen.\n\nUnd schließlich die Kosten von `async_hooks`: Der Overhead ist real, besonders durch das Promise-Tracking im `destroy`-Hook. Zusammen mit der Tatsache, dass die API experimentell und laut Node-Team riskant ist, ergibt das eine klare Linie – nicht als Kontext-Mechanismus in Produktion einsetzen. Wer Kontext braucht, nimmt `AsyncLocalStorage`. `async_hooks` ist etwas, das man kennt, um zu verstehen, was darunter passiert, und das man sonst in Ruhe lässt.\n\nAls Randnotiz für die Neugierigen: Mit v18.16.0 kamen `AsyncLocalStorage.bind(fn)` und `AsyncLocalStorage.snapshot()` dazu, um den aktuellen Kontext an eine Funktion zu binden. Beides ist noch experimentell und für den Einstieg nicht nötig – ich erwähne es nur der Vollständigkeit halber.\n\n## Fazit\n\nDer rote Faden ist eine Schichtung. Ganz unten sorgt `async_hooks` dafür, dass Node den Lifecycle asynchroner Ressourcen überhaupt verfolgen kann – eine mächtige, aber experimentelle und teure Schicht, die man selten direkt anfasst. Darauf steht `AsyncLocalStorage` als die stabile, empfohlene API, mit der man request-bezogenen Kontext durch beliebig tiefe Aufrufketten trägt, ohne ihn durch jede Signatur zu fädeln. Und daneben steht `diagnostics_channel` als entkoppelter Event-Bus, mit dem Bibliotheken und Anwendungen strukturiert instrumentieren, ohne dass Produzent und Konsument voneinander wissen müssen.\n\nDie Wirkung zeigt sich weniger im Code als im Betrieb. Wo der Kontext sauber getragen wird, verkürzen sich die Zyklen in der Fehlersuche, weil jede Logzeile ihren Request kennt. Die Reibung sinkt, weil keine Signatur mehr einen Parameter durchschleift, den sie nie benutzt. Und die Qualität steigt messbar, weil Nachverfolgbarkeit vom Zufall zur Eigenschaft wird. Für die tägliche Arbeit heißt das: `als.run(store, cb)` oben, `als.getStore()` unten – und man ist das lästige Parameter-Durchreichen los. Wer Observability entkoppeln will, greift zu `diagnostics_channel` mit einem `hasSubscribers`-Guard. `async_hooks` bleibt das Fundament, das man verstanden haben sollte, aber im Alltag nicht selbst bedient. Die Grenze ist ebenso klar: Kein Mechanismus ersetzt die Disziplin, den `run`-Aufruf zuverlässig zu setzen – reißt die Kette dort, hilft die beste API nichts.\n\nWenn du an dieser Stelle tiefer in das Event-Modell von Node einsteigen willst, passt der Beitrag über [EventEmitter und Event-Modell](https://www.mikebild.dev/de/blog/nodejs-eventemitter-und-ereignismodell/) gut dazu. Und wer die Architektur rund um Event Loop und asynchrone Ausführung von Grund auf verstehen möchte, findet im [Node.js-Training](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/) den größeren Rahmen.\n\n## Weiterführende Quellen\n\n- [AsyncLocalStorage und Async-Kontext (Node.js v18.x)](https://nodejs.org/docs/latest-v18.x/api/async_context.html)\n- [async_hooks (Node.js v18.x)](https://nodejs.org/docs/latest-v18.x/api/async_hooks.html)\n- [diagnostics_channel (Node.js v18.x)](https://nodejs.org/docs/latest-v18.x/api/diagnostics_channel.html)"},{"slug":"graphql-auth-im-context","title":"GraphQL-Auth: Identität im Context, Autorisierung in der Fachlogik","summary":"Der GraphQL-Context transportiert die Identität eines Requests. Ob diese Identität eine Operation ausführen darf, entscheidet eine zentrale Policy in der Fachlogik.","tags":["Software","GraphQL","API"],"published_at":"2023-04-18T10:00:00+00:00","updated_at":"2026-07-13T08:49:33.50578+00:00","url":"https://www.mikebild.dev/de/blog/graphql-auth-im-context/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/8c667c9869495d73a0c642d8a67a33c5de74446fd9099b6e86ebb1385a9f755c/graphql-auth-im-context-vorlesen.mp3","audio_duration_secs":651,"text":"Ein Resolver, der nur prüft, ob ein Token da ist, gibt jedem eingeloggten Nutzer alles frei, was das Schema anbietet. Das ist kein Detailfehler in der Auth, das ist ein offenes Backend mit einem Login-Formular davor.\n\nDie Ursache steckt fast immer an derselben Stelle: In dieser einen Prüfung stecken zwei Fragen, die mit ganz verschiedenen Dingen zu tun haben – und im Code sind sie zu einer verschmolzen. \"Wir prüfen das im Resolver\" ist deshalb eine Antwort, die alles offen lässt: Geprüft wird, ob jemand eingeloggt ist, oder ob er dieses Feld sehen darf? Solange das nicht beantwortet ist, weiß niemand, wogegen die Anwendung eigentlich geschützt ist.\n\n## Zwei Fragen, nicht eine\n\nAuthentifizierung beantwortet die Frage: **Wer bist du?** Aus einem Token wird eine Identität – ein `user`. Autorisierung beantwortet eine ganz andere Frage: **Was darfst du?** Ob dieser konkrete Nutzer dieses konkrete Feld oder diese konkrete Mutation aufrufen darf.\n\nDas sind unterschiedliche Verantwortlichkeiten, und sie haben sogar unterschiedliche Fehlerbilder. Wer kein oder ein ungültiges Token schickt, bekommt einen `401` – nicht authentifiziert. Wer ein gültiges Token hat, aber die falsche Rolle, bekommt einen `403` – authentifiziert, aber nicht berechtigt. In Code-Reviews sehe ich die beiden regelmäßig verwechselt, und dann landet am Ende beides als HTTP `500` beim Client, weil ein Fehler nicht abgefangen wurde. Das ist kein kosmetisches Problem – ein Client kann auf `401` und `403` sinnvoll reagieren (Login-Flow vs. \"kein Zugriff\"), auf `500` nicht.\n\nWenn diese Trennung sitzt, kommt die eigentliche Architekturfrage: Wo gehört die Identität hin, und wie kommt sie zu jedem Resolver, der sie braucht?\n\n## Warum der context der richtige Ort ist\n\nIn GraphQL wird pro Request ein `contextValue` gebaut – ein Objekt, das an **jeden** Resolver desselben Requests durchgereicht wird. Das ist kein Zufallsdetail, das ist der ideale Aufhängepunkt für Auth.\n\nDer Grund ist die Lebensdauer. Der context wird einmal pro Request neu erzeugt und lebt genau so lange wie dieser Request. Kein State leakt zwischen zwei Requests, kein Cleanup ist nötig, und ich habe garantiert für jede Anfrage eine frisch ermittelte Identität. Genau dort gehört der `user` hin.\n\nIn Apollo Server 4 – dem aktuellen Major – ist das eine `async`-Funktion, die man dem Server-Start mitgibt. Wichtig: In Apollo Server 4 wandert die context-Funktion aus dem `new ApolloServer({...})`-Konstruktor heraus zu `startStandaloneServer` beziehungsweise `expressMiddleware`. Wer noch ältere Beispiele aus AS2/AS3 im Kopf hat, wo `context` direkt im Konstruktor stand: konzeptionell identisch, nur eingehängt wird sie heute an anderer Stelle.\n\n```ts\nimport { ApolloServer } from '@apollo/server';\nimport { startStandaloneServer } from '@apollo/server/standalone';\n\nconst server = new ApolloServer({ typeDefs, resolvers });\n\nconst { url } = await startStandaloneServer(server, {\n  context: async ({ req }) => {\n    const raw = req.headers.authorization || '';\n    const token = raw.replace('Bearer ', '');\n    const user = await getUserFromToken(token);\n    return { user };\n  },\n});\n```\n\nZwei Details, an denen in Projekten regelmäßig jemand hängen bleibt. Erstens: `req.headers.authorization` ist in Express immer kleingeschrieben – die HTTP-Header-Keys landen dort in Kleinbuchstaben, egal wie der Client sie sendet. Zweitens: Ein leerer Header darf niemals als \"eingeloggt\" durchgehen. Der `|| ''`-Fallback liefert einen leeren String, `getUserFromToken('')` gibt dann sauber `null` zurück – und `null` heißt eben \"unbekannt\", nicht \"Admin\".\n\nDer Ablauf sieht damit für jeden Request gleich aus:\n\n```mermaid\nflowchart LR\n  C[Client] -- \"Authorization:<br/>Bearer token\" --> S[GraphQL-Server]\n  S --> CTX[\"context()<br/>Token zu user\"]\n  CTX --> R[Resolver]\n  R --> BL[Business-Logik<br/>prueft user + Rolle]\n  BL -- erlaubt --> DATA[Daten]\n  BL -- verweigert --> ERR[\"GraphQLError<br/>403 FORBIDDEN\"]\n```\n\nMan sieht hier gut: Die Authentifizierung passiert genau einmal, ganz vorn in `context()`. Ab da trägt der Request eine Identität mit sich, und alles Weitere ist Autorisierung.\n\n## Was in den context gehört – und was nicht\n\nWeil der context so verlockend praktisch ist, wird er gern zur Rumpelkammer. Es lohnt sich, den Inhalt bewusst kurz zu halten. Hinein gehört:\n\n- die Identität – `user` oder `viewer`, aus dem Token ermittelt\n- die Berechtigungen dieses Nutzers – `roles` oder `scopes`\n- request-gebundene Infrastruktur – DataLoader-Instanzen, damit Batching und Deduplizierung pro Request funktionieren\n- Zugänge zur Datenschicht – `db`-Handle oder Repositories\n- request-spezifische Werte wie Locale oder ein durchzureichendes Auth-Token für nachgelagerte Services\n\nUnd ebenso klar, was **nicht** hineingehört:\n\n- globaler State, der über Requests hinweg lebt\n- Cross-Request-Caches (ein DataLoader ist genau deshalb pro Request, nicht global)\n- Secrets im Klartext, die dort nichts verloren haben\n\nDie Faustregel dahinter: Alles, was für genau diesen einen Request gilt und danach weg darf, ist im context gut aufgehoben. Alles Langlebige gehört woanders hin. Wer DataLoader-Instanzen pro Request in den context legt, kennt das Muster übrigens schon aus dem Umfeld von effizientem Datenzugriff – ich habe das im Zusammenhang mit Listen und Cursorn in [GraphQL-Paginierung](https://www.mikebild.dev/de/blog/graphql-paginierung-und-connections/) ausführlicher beschrieben.\n\n## Autorisierung im Resolver\n\nJetzt ist der `user` im context, und jeder Resolver kann ihn lesen – als **drittes Argument**, `(parent, args, contextValue, info)`. Das ist die einfachste, direkteste Form der Autorisierung.\n\n```ts\nimport { GraphQLError } from 'graphql';\n\nconst resolvers = {\n  Query: {\n    me: (parent, args, { user }) => {\n      if (!user) {\n        throw new GraphQLError('Not authenticated', {\n          extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } },\n        });\n      }\n      return user;\n    },\n    adminReport: (parent, args, { user }) => {\n      if (!user?.roles.includes('ADMIN')) {\n        throw new GraphQLError('Forbidden', {\n          extensions: { code: 'FORBIDDEN', http: { status: 403 } },\n        });\n      }\n      return buildAdminReport();\n    },\n  },\n};\n```\n\nWichtig für alle, die noch mit älteren Apollo-Beispielen arbeiten: Die früheren Klassen `AuthenticationError` und `ForbiddenError` aus dem alten `apollo-server`-Paket gibt es in Apollo Server 4 nicht mehr. Der Weg heute ist `GraphQLError` aus `graphql` mit einem `extensions.code` – `UNAUTHENTICATED` für den fehlenden Login, `FORBIDDEN` für die fehlende Berechtigung – und optional dem passenden HTTP-Status.\n\nMan erkennt an den beiden Resolvern schon die Trennung: `me` prüft nur, **ob** überhaupt jemand da ist (Authentifizierung durchgesetzt), `adminReport` prüft, **was** dieser Jemand darf (Autorisierung). Zwei Fragen, zwei Fehlercodes.\n\n## Feld-Ebene versus Query-Ebene\n\nAutorisierung ist nicht nur eine Frage von \"ganze Query erlaubt oder nicht\". GraphQL erlaubt Prüfungen auf Feld-Ebene, und das ist eine seiner stärkeren Eigenschaften.\n\nWeil jedes Feld seinen eigenen Resolver hat und die Prüfung während der Ausführung des Requests greift, sind **Partial Responses** möglich: Der Client fragt zehn Felder ab, sieben liefert der Server, für drei bekommt er einen Fehler und `null` – in einer einzigen Antwort. Das ist genau das Verhalten, das man will. Ein Nutzer soll seinen eigenen Namen sehen, aber nicht das interne Bonitäts-Feld, das nur Admins zusteht.\n\nIm Grounding-Repo taucht dafür ein deklarativer Ansatz auf: eine `@auth`-Directive mit einem Rollen-Enum.\n\n```graphql\nenum Role {\n  ADMIN\n  OWNER\n  USER\n}\n\ndirective @auth(requires: Role!) on FIELD_DEFINITION\n\ntype User {\n  id: ID!\n  name: String!\n  role: String @auth(requires: ADMIN)\n}\n\ntype Query {\n  me: User @auth(requires: USER)\n}\n```\n\nHier sieht man beide Ebenen: `me @auth(requires: USER)` schützt ein ganzes Query-Feld, `role @auth(requires: ADMIN)` schützt ein einzelnes Feld innerhalb des Typs. Die Directive selbst ist nur eine Deklaration – irgendjemand muss sie zur Laufzeit auch durchsetzen. In der aktuellen graphql-tools-Generation macht man das mit einem Schema-Transformer.\n\n```ts\nimport { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';\nimport { defaultFieldResolver, GraphQLError } from 'graphql';\n\nfunction authDirectiveTransformer(schema) {\n  return mapSchema(schema, {\n    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {\n      const directive = getDirective(schema, fieldConfig, 'auth')?.[0];\n      if (!directive) return fieldConfig;\n\n      const { requires } = directive;\n      const { resolve = defaultFieldResolver } = fieldConfig;\n\n      fieldConfig.resolve = (source, args, context, info) => {\n        if (!context.user) {\n          throw new GraphQLError('Not authenticated', {\n            extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } },\n          });\n        }\n        if (!context.user.roles.includes(requires)) {\n          throw new GraphQLError('Forbidden', {\n            extensions: { code: 'FORBIDDEN', http: { status: 403 } },\n          });\n        }\n        return resolve(source, args, context, info);\n      };\n      return fieldConfig;\n    },\n  });\n}\n```\n\nEin Hinweis für alle, die ältere Tutorials finden: Der frühere `SchemaDirectiveVisitor` ist längst raus – seit graphql-tools v6 gibt es ihn nicht mehr. Der Weg über `mapSchema` und `getDirective` ist der aktuelle. Wer nach `SchemaDirectiveVisitor` googelt und es kopiert, wundert sich dann, warum nichts importierbar ist.\n\nSo elegant die Directive aussieht: Sie ist nur die halbe Wahrheit. Und genau hier fangen im Team die interessanten Diskussionen an.\n\n## Warum Auth nicht ins Schema gehört\n\nDie `@auth`-Directive fühlt sich zunächst wie die perfekte Lösung an – Berechtigungen stehen direkt am Feld, deklarativ, lesbar. Für Prototypen und zum Lernen ist das auch völlig in Ordnung. Aber als **alleinige** Strategie führt sie in eine Sackgasse, und die offizielle GraphQL-Doku ist da erstaunlich deutlich: Autorisierung gehört in die Business-Logik-Schicht, nicht ins Schema.\n\nDer Grund ist Duplikation. Nehmen wir eine typische Regel: `OWNER` – ein Nutzer darf nur seine eigenen Posts bearbeiten. Diese Regel lässt sich rein deklarativ kaum abbilden, denn sie hängt von den konkreten Daten ab: Gehört *dieser* Post *diesem* Nutzer? Eine Directive kennt die Rolle, aber nicht die Beziehung zwischen Nutzer und Ressource.\n\nUnd selbst simple Rollenchecks laufen auseinander, sobald dieselbe Logik an mehreren Stellen gebraucht wird. Wenn dieselbe \"darf-Post-lesen\"-Entscheidung an drei Feldern hängt und irgendwann auch noch von einem REST-Endpoint oder einem Background-Job gebraucht wird, dann hat man die Regel dreimal, viermal – und sie driftet auseinander. Das ist der Kern des Arguments: eine einzige Quelle der Wahrheit statt verstreuter Kopien.\n\nDie saubere Variante ist deshalb: Der Resolver entscheidet nicht selbst, er **delegiert** an eine Business-Logik-Funktion, die den context bekommt und die Entscheidung trifft.\n\n```ts\nconst resolvers = {\n  Query: {\n    posts: (parent, args, context) => postRepository.getPosts(context),\n  },\n  Mutation: {\n    updatePost: (parent, { id, input }, context) =>\n      postRepository.updatePost(id, input, context),\n  },\n};\n\n// Business logic layer – authorization lives here\nconst postRepository = {\n  async getPosts(context) {\n    if (!context.user) {\n      throw new GraphQLError('Not authenticated', {\n        extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } },\n      });\n    }\n    // Owner rule: only the user's own posts\n    return db.posts.findMany({ where: { authorId: context.user.id } });\n  },\n};\n```\n\nDer Resolver bleibt dünn und dumm – er nimmt args und context und reicht sie weiter. Die eigentliche Entscheidung, inklusive der datenabhängigen OWNER-Regel, liegt an einer einzigen Stelle. Wenn morgen ein anderer Entry Point dazukommt, ruft der dieselbe Funktion auf. Keine Duplikation, kein Drift.\n\nIch stelle mir das gern als drei Schichten vor, mit dem Schema bewusst in der Mitte und ohne Entscheidungsgewalt:\n\n```mermaid\nflowchart TB\n  subgraph Transport[\"Transport-Schicht\"]\n    A[\"Authentifizierung<br/>Token zu user im context\"]\n  end\n  subgraph Schema[\"Schema-Schicht\"]\n    B[\"nur deklarativ<br/>optional @auth als Hinweis\"]\n  end\n  subgraph Domain[\"Business-Logik-Schicht\"]\n    D[\"Autorisierung<br/>darf user diese Ressource?\"]\n  end\n  Transport --> Schema --> Domain\n```\n\nDie Botschaft dieses Bildes: Identität wird oben ermittelt, die Berechtigungsentscheidung fällt unten. Das Schema in der Mitte beschreibt nur, es entscheidet nicht.\n\n## Der Fallstrick, der am teuersten wird\n\nEs gibt einen Fehler, den ich immer wieder sehe und der richtig weh tut: Autorisierung im Client durchsetzen. Die UI blendet den \"Löschen\"-Button aus, wenn der Nutzer kein Admin ist – und alle sind zufrieden. Nur prüft der Resolver dahinter gar nichts.\n\nDas ist keine Sicherheit, das ist Kosmetik. Jeder, der die Developer-Tools öffnet oder direkt eine GraphQL-Mutation abschickt, umgeht diese \"Prüfung\" in Sekunden. Ausgeblendete Buttons sind eine Frage der Nutzerführung, nicht der Sicherheit. Die Durchsetzung **muss** serverseitig passieren – im Resolver beziehungsweise in der Business-Logik-Schicht dahinter. Der Client darf gern zusätzlich Buttons ausblenden, damit die Oberfläche aufgeräumt ist. Aber verlassen darf man sich nur auf den Server.\n\nDamit ist auch die Ausgangsfrage beantwortet. \"Wir prüfen das im Resolver\" reicht nicht als Architekturentscheidung. Die Identität kommt einmal pro Request in den Context. Der Resolver ruft für die konkrete Operation eine zentrale Policy oder Fachfunktion auf; dort fällt die datenabhängige Berechtigungsentscheidung. Directives und ausgeblendete Buttons können das sichtbar machen, ersetzen diese Prüfung aber nicht.\n\nIn den Projekten, in denen Auth am Ende ruhig lief, war sie nie eine Schema-Frage, sondern eine Frage der Lifecycles und der Verantwortlichkeiten. Der context weiß, wer du bist. Die Business-Logik weiß, was du darfst. Bleiben die beiden getrennt, wird der Rest einfach.\n\n## Weiterführende Quellen\n\n- [Grounding-Repository MikeBild/introduction-graphql](https://github.com/MikeBild/introduction-graphql) – `introduction-graphql/auth.md` und `recipes/auth/`\n- [graphql.org – Authorization](https://graphql.org/learn/authorization/) – warum Autorisierung in die Business-Logik-Schicht gehört\n- [Apollo Server – Authentication and authorization](https://www.apollographql.com/docs/apollo-server/security/authentication) – GraphQLError-Codes, `UNAUTHENTICATED` vs. `FORBIDDEN`\n- [Apollo Server – The context object](https://www.apollographql.com/docs/apollo-server/data/context) – context und contextValue, einmal pro Request"},{"slug":"nodejs-debuggen-und-beobachten","title":"Node.js lokal debuggen und profilieren","summary":"Vom console.log-Reflex zum eingebauten Inspector: node --inspect, das debug-Modul, nodemon sowie erste CPU- und Speicherprofile für die lokale Diagnose.","tags":["Software","Node.js","Backend"],"published_at":"2023-02-14T10:00:00+00:00","updated_at":"2026-07-13T08:00:08.673184+00:00","url":"https://www.mikebild.dev/de/blog/nodejs-debuggen-und-beobachten/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/b3c049c4445ff2a46c888c5c726dc56c1984a186773ead6468b82b5d93072308/nodejs-debuggen-und-beobachten-vorlesen.mp3","audio_duration_secs":817,"text":"```js\nconsole.log(x);\nconsole.log('HIER');\nconsole.log('HIER 2');\n```\n\nDiese drei Zeilen können genau eine Sache: einen Wert an einer Stelle zeigen, die man vorher erraten hat. Abschalten lassen sie sich nur, indem jemand die Datei wieder anfasst. Den Prozess anhalten können sie nicht. Über Speicherverbrauch sagen sie gar nichts. Für alle drei Lücken bringt Node.js seit Version 6.3 eigene Werkzeuge mit, ohne dass ein zusätzliches Paket installiert werden muss – sie werden nur selten benutzt.\n\nSortieren lassen sie sich als Eskalationsleiter. Ganz unten steht `console.log` – immer an, unstrukturiert, aber sofort da. Eine Stufe höher liegt das `debug`-Modul, das sich über eine Umgebungsvariable an- und ausschalten lässt, ohne dass ich Code anfasse. Darüber sitzt der eingebaute Inspector mit interaktiven Breakpoints in Chrome DevTools oder VS Code. Und ganz oben, wenn es um Performance oder Speicher geht, nutze ich denselben Inspector als Profiler. Wer diese vier Stufen sauber trennen kann, hört auf, blind zu raten.\n\n## Warum console.log an seine Grenzen kommt\n\n`console.log` hat zwei Eigenschaften, die es im Kleinen praktisch und im Großen unangenehm machen. Erstens ist es immer aktiv: Was einmal im Code steht, wird bei jedem Durchlauf ausgeführt, auch in Produktion, wo es die Ausgabe verschmutzt und je nach Menge sogar Performance kostet. Zweitens ist es unstrukturiert – ich sehe einen Wert, aber ich kann nicht mit einem Schalter entscheiden, ob ich gerade die HTTP-Schicht oder den Datenbankzugriff sehen will, ohne die Zeilen wieder auszukommentieren.\n\nWer sich einmal mit dem [Event-Modell von Node.js](https://www.mikebild.dev/de/blog/nodejs-eventemitter-und-ereignismodell/) beschäftigt hat, kennt das Problem in verschärfter Form: Bei asynchronem Code ist die Reihenfolge der Ausgaben nicht immer die, die man erwartet, und ein `console.log` mitten in einem Callback erzählt nur einen Bruchteil der Geschichte. Genau hier lohnt sich der Schritt zu Werkzeugen, die den laufenden Prozess anhalten und untersuchen können.\n\n## Der eingebaute Inspector: --inspect und --inspect-brk\n\nDas Wichtigste zuerst, weil es in Teams immer wieder für Überraschung sorgt: Der Debugger steckt bereits in Node.js. Es gibt kein Extra-Paket zu installieren. Historisch gab es einmal ein separates Modul (`node-inspector` mit dem Befehl `node-debug app.js`), aber das ist seit Node 6 überholt und heute nur noch eine Randnotiz. Seit Node 6.3 spricht der Prozess selbst das Chrome DevTools Protocol über einen WebSocket – und genau darum funktionieren sowohl die Chrome DevTools als auch VS Code als Client.\n\nGestartet wird das mit einem Flag:\n\n```bash\nnode --inspect ./src/server.js\n```\n\nAuf stderr erscheint dann eine Zeile wie diese:\n\n```text\nDebugger listening on ws://127.0.0.1:9229/8f2c...-uuid\nFor help, see: https://nodejs.org/en/docs/inspector\n```\n\nDer Prozess bindet standardmäßig an `127.0.0.1:9229`. Wichtig ist der Unterschied zwischen den beiden zentralen Flags:\n\n- `--inspect` startet nur den Debug-Server, der Code läuft ganz normal weiter. Das nutze ich bei einem Server, der ohnehin dauerhaft läuft und auf Anfragen wartet.\n- `--inspect-brk` startet ebenfalls den Debug-Server, hält aber vor der ersten Zeile meines eigenen Codes an. Das ist ideal für kurz laufende Skripte, die sonst durchgerauscht wären, bevor ich überhaupt einen Client verbinden konnte.\n\n```bash\nnode --inspect-brk ./src/server.js\n```\n\nFür ein Skript, das nach 200 Millisekunden fertig ist, ist `--inspect-brk` praktisch die einzige sinnvolle Wahl – mit `--inspect` allein wäre der Prozess längst beendet, bevor ich in den DevTools auf \"connect\" geklickt habe. Diesen Unterschied zu kennen hat mir in der Praxis schon viel Zeit gespart.\n\nWer den Inspector nicht beim Start, sondern erst bei einem bereits laufenden Prozess aktivieren will, kann unter Unix-Systemen das Signal `SIGUSR1` schicken. Unter Windows steht dieser Weg nicht zur Verfügung.\n\n## Vom Flag zum Client\n\nDer offene Inspector-Port ist nur die halbe Miete – ich brauche noch einen Client, der sich verbindet. Zwei Wege nutze ich regelmäßig.\n\nIn Chrome (oder Edge) öffne ich `chrome://inspect` beziehungsweise `edge://inspect`. Unter \"Remote Target\" taucht der laufende Node-Prozess auf, ein Klick auf \"inspect\" öffnet die vertrauten DevTools mit Sources, Breakpoints, Call Stack und Konsole. Über \"Configure\" lassen sich Host und Port anpassen, falls ich nicht die Standardwerte verwende.\n\nIn VS Code lege ich mir eine Attach-Konfiguration in der `.vscode/launch.json` an:\n\n```json\n{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"type\": \"node\",\n      \"request\": \"attach\",\n      \"name\": \"Attach to Node\",\n      \"port\": 9229\n    }\n  ]\n}\n```\n\nDamit hänge ich mich an einen Prozess, den ich vorher mit `--inspect` gestartet habe. Alternativ gibt es in VS Code den \"Auto Attach\"-Modus, der sich automatisch an Node-Prozesse hängt, die aus dem integrierten Terminal gestartet werden. Beide Wege enden an derselben Stelle: interaktiven Breakpoints, an denen ich Variablen inspizieren und mich Schritt für Schritt durch den Code bewegen kann.\n\nDer folgende Ablauf fasst zusammen, wie Flag, Agent und Client zusammenspielen:\n\n```mermaid\nflowchart LR\n  A[\"node --inspect<br/>oder --inspect-brk\"] --> B[\"Inspector-Agent<br/>CDP über ws://127.0.0.1:9229\"]\n  B --> C[\"Chrome DevTools<br/>chrome://inspect\"]\n  B --> D[\"VS Code<br/>request: attach\"]\n  C --> E[\"Breakpoints<br/>CPU-Profil<br/>Heap-Snapshot\"]\n  D --> E\n```\n\nBeide Clients reden dasselbe Protokoll mit demselben eingebauten Agenten. Ich muss mich also nicht für ein Ökosystem entscheiden – ich wähle den Client, der mir gerade besser passt.\n\n## Das debug-Modul: schaltbares Logging ohne Codeänderung\n\nNicht jedes Problem verdient einen Breakpoint. Oft will ich nur nachvollziehen, welchen Weg eine Anfrage durch meine Schichten nimmt – aber eben nur dann, wenn ich es gerade untersuche, und ohne die Ausgabe dauerhaft im Code zu verankern. Genau dafür gibt es das `debug`-Modul, zum Stichtag in Version 4.3.4. Viele bekannte Bibliotheken nutzen es intern, und für eigenen Code ist es schnell eingebaut.\n\nDer Kern ist eine Factory, die man mit einem Namespace aufruft:\n\n```js\nconst debug = require('debug')('app:http');\n\nfunction handleRequest(req, res) {\n  debug('incoming request %s %s', req.method, req.url);\n  // ... handle the request ...\n}\n```\n\nDer Clou steckt nicht im Code, sondern im Start. Standardmäßig gibt diese Zeile nichts aus. Erst wenn ich die Umgebungsvariable `DEBUG` setze, wird der passende Namespace sichtbar:\n\n```bash\nDEBUG=app:* node server.js\n```\n\nDas `*` ist eine Wildcard, und mit einem führenden Minus schließe ich Namespaces gezielt aus. So sehe ich zum Beispiel alles aus `app`, aber nicht die gesprächige Datenbankschicht:\n\n```bash\nDEBUG=app:*,-app:db node server.js\n```\n\nDamit ist der zentrale Vorteil erreicht: Ich schalte Logging an und aus, ohne eine einzige Zeile Code zu ändern. Das `debug`-Modul bringt außerdem automatische Zeitdifferenzen zwischen den Ausgaben mit (die `+0ms`-Angaben), färbt bei einem echten Terminal die Namespaces ein und kennt Format-Platzhalter wie `%s`, `%d`, `%j`, `%o` und `%O`.\n\nEin Detail sorgt regelmäßig für Verwirrung: Die Ausgabe von `debug` geht auf **stderr**, nicht auf stdout. Wer die Ausgabe umleiten oder mit `grep` filtern will und dabei nur stdout betrachtet, sieht schlicht nichts. Und `DEBUG` greift nur für Bibliotheken, die selbst `debug` verwenden – es ist kein globaler Schalter für jede beliebige Log-Ausgabe.\n\n## nodemon: Auto-Reload in der Entwicklung\n\nEin Werkzeug, das streng genommen nicht zum Debuggen gehört, aber die Feedback-Schleife enorm verkürzt, ist nodemon (zum Stichtag rund Version 2.0.20). Es beobachtet Dateien und startet den Prozess bei jeder Änderung neu, sodass ich nicht ständig von Hand `Strg+C` und neu starten muss. Ich nehme es als Dev-Abhängigkeit ins Projekt:\n\n```bash\nnodemon --watch ./ --ignore ./node_modules ./bin/www\n```\n\nSchöner ist eine `nodemon.json` im Projektwurzelverzeichnis, weil sie die Konfiguration festhält und sich gut mit dem Inspector kombinieren lässt:\n\n```json\n{\n  \"watch\": [\"src\"],\n  \"ext\": \"js,json\",\n  \"ignore\": [\"node_modules\"],\n  \"exec\": \"node --inspect src/server.js\"\n}\n```\n\nÜber `exec` starte ich den Prozess direkt mit `--inspect`, sodass ich Auto-Reload und Debugger zusammen bekomme. Node.js selbst brachte zum Stichtag zwar bereits einen experimentellen Watch-Modus mit, aber für den Alltag setze ich weiterhin auf nodemon, weil es ausgereift und flexibel konfigurierbar ist.\n\n## Ein schneller Blick auf den Speicher\n\nBevor ich zum eigentlichen Profiling komme, gibt es einen sehr günstigen Zwischenschritt, der ohne jedes Werkzeug auskommt: `process.memoryUsage()`. Diese Funktion liefert ein Objekt mit mehreren Werten, alle in Bytes:\n\n```js\nconst { rss, heapTotal, heapUsed, external } = process.memoryUsage();\n\nconsole.error(\n  `rss=${(rss / 1e6).toFixed(1)}MB ` +\n  `heapUsed=${(heapUsed / 1e6).toFixed(1)}MB ` +\n  `heapTotal=${(heapTotal / 1e6).toFixed(1)}MB`\n);\n```\n\nDie wichtigsten Felder in der Praxis: `heapUsed` und `heapTotal` betreffen den von V8 verwalteten JavaScript-Heap, während `rss` (Resident Set Size) den gesamten Speicher des Prozesses angibt, inklusive nativer Anteile. Wenn ich in einer Schleife oder über mehrere Anfragen hinweg zusehe, wie `heapUsed` immer weiter steigt und nach der Garbage Collection nicht mehr zurückgeht, habe ich einen ersten, sehr konkreten Hinweis auf ein Speicherleck. Für einen schnellen RSS-Wert allein gibt es seit Node 15.6 zusätzlich `process.memoryUsage.rss()`.\n\nDas ersetzt kein richtiges Profiling, aber es ist ein billiger Frühwarnwert – gerade bei langlaufenden Prozessen oder bei Verarbeitung großer Datenmengen, wie sie beim Umgang mit [Streams und Backpressure](https://www.mikebild.dev/de/blog/nodejs-streams-und-backpressure/) auftreten.\n\n## Profiling über denselben Inspector\n\nWenn der grobe Blick mit `process.memoryUsage()` nicht reicht, führt der nächste Schritt zurück zum Inspector. Dieselbe Verbindung, die ich zum Setzen von Breakpoints nutze, öffnet in den DevTools auch die Tabs für Analyse: Unter \"Memory\" nehme ich einen Heap-Snapshot auf und sehe, welche Objekte wie viel Speicher belegen. Unter \"Profiler\" beziehungsweise \"Performance\" zeichne ich ein CPU-Profil auf und finde heraus, in welchen Funktionen die Zeit tatsächlich verbraucht wird.\n\nWer lieber ohne grafisches Frontend arbeitet, hat CLI-Alternativen. `node --prof` schreibt ein V8-Log, das ich anschließend mit `node --prof-process` in einen lesbaren Bericht verwandle:\n\n```bash\nnode --prof ./src/server.js\n# ... generate load, then stop the process ...\nnode --prof-process isolate-0x*.log > processed.txt\n```\n\nDaneben gibt es seit Node 12 die noch experimentellen Flags `--cpu-prof` und `--heap-prof`, die direkt Profildateien im DevTools-Format erzeugen. Im Team hat sich trotzdem meist der interaktive Weg über die DevTools durchgesetzt, weil er dieselbe Oberfläche nutzt, die aus dem Frontend ohnehin vertraut ist.\n\n## Die vier Stufen im Überblick\n\nZurück zur Eskalationsleiter vom Anfang. Ich fasse die vier Werkzeuge bewusst als Stufen zusammen, weil jede ihren eigenen Zweck hat und keine die andere vollständig ersetzt:\n\n- `console.log` – immer aktiv, unstrukturiert, sofort verfügbar. Gut für den schnellen Einzelblick, schlecht als Dauerlösung und in Produktion fehl am Platz.\n- `debug` – namespacegesteuert, über die `DEBUG`-Umgebungsvariable ohne Codeänderung schaltbar, Ausgabe auf stderr. Ideal, um in der Entwicklung gezielt Schichten sichtbar zu machen.\n- Inspector – interaktive Breakpoints in Chrome DevTools oder VS Code, über `--inspect` beziehungsweise `--inspect-brk`. Der Schritt vom Vermuten zum Untersuchen.\n- Profiler – derselbe Inspector, aber für CPU-Profile und Heap-Snapshots. Kommt zum Einsatz, wenn es nicht um Korrektheit, sondern um Performance und Speicher geht.\n\n```mermaid\nflowchart TB\n  A[\"console.log<br/>immer an, unstrukturiert\"] --> B[\"debug<br/>DEBUG-Env, namespacegesteuert\"]\n  B --> C[\"Inspector<br/>interaktive Breakpoints\"]\n  C --> D[\"Profiler<br/>CPU-Profil und Heap-Snapshot\"]\n```\n\n## Zwei Fallstricke, die teuer werden können\n\nZum Schluss zwei Dinge, die ich in Code-Reviews ausdrücklich anspreche, weil sie in Produktion echten Schaden anrichten.\n\nDer erste betrifft `console.log` in Produktion. Was in der Entwicklung harmlos aussieht, wird im Dauerbetrieb zum Problem: Die Ausgabe ist unstrukturiert, lässt sich nicht per Schalter abstellen, verschmutzt stdout und kostet je nach Menge Performance. Für die Entwicklung ist `debug` die bessere Wahl, und für Produktion gehört ein richtiger Logger her, der Log-Level, strukturierte Ausgabe und gezieltes Ein- und Ausschalten beherrscht.\n\nDer zweite Fallstrick ist gefährlicher und wird oft unterschätzt: einen offenen `--inspect`-Port stehen zu lassen oder ihn an `0.0.0.0` zu binden. Der Debug-Port erlaubt das Ausführen beliebigen Codes im Prozess – wer ihn erreicht, kann den Server übernehmen. Deshalb bindet Node.js von Haus aus nur an `127.0.0.1`, und dabei sollte es in aller Regel bleiben. Muss ich wirklich einen entfernten Prozess debuggen, tunnele ich den Port über SSH statt ihn öffentlich zu exponieren:\n\n```bash\nssh -L 9229:localhost:9229 user@remote-host\n```\n\nUnd das Debug-Flag hat in keinem Produktions-Startskript etwas verloren. Diese Trennung zwischen Entwicklungs- und Produktionsbetrieb hängt eng mit dem zusammen, was schon im [Node.js-Training zur Event-Loop-Architektur](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/) zur Sprache kommt: Werkzeuge, die in den laufenden Prozess greifen, sind mächtig – und genau deshalb muss der Zugang zu ihnen eng bleiben.\n\n## Ein Ausblick\n\nWer noch tiefer gehen will, findet in Node.js zwei fortgeschrittene Bausteine, die ich hier nur als Ausblick nenne, weil sie eigene Beiträge verdienen: `async_hooks`, um den Lifecycle asynchroner Ressourcen zu verfolgen, und `diagnostics_channel`, um strukturierte Diagnose-Events zwischen Bibliotheken und Beobachtern auszutauschen. Beide sind eher etwas für Werkzeugbauer und Bibliotheksautoren als für den täglichen Fehlerfang.\n\n## Fazit\n\nDer Weg vom `console.log`-Reflex zu bewusstem Debuggen ist kein großer Sprung, sondern eine kleine Leiter mit vier Sprossen. Für den schnellen Blick bleibt `console.log`, aber sobald es strukturierter wird, übernimmt `debug` mit seinem schaltbaren Logging. Wird es wirklich knifflig, hält der eingebaute Inspector den Prozess an und lässt mich Variablen und Aufrufstapel untersuchen – wahlweise in Chrome DevTools oder VS Code, denn beide sprechen dasselbe Protokoll. Und wenn Performance oder Speicher das Thema sind, wechselt derselbe Inspector in den Profiler-Modus, während `process.memoryUsage()` schon vorher einen billigen Frühwarnwert liefert. Wer diese Stufen kennt und den Debug-Port konsequent verschlossen hält, hört auf zu raten und fängt an zu untersuchen.\n\n## Weiterführende Quellen\n\n- Grounding aus dem Repository: `introduction-nodejs/19-quality-tools.md` (Kapitel \"Quality Tools\")\n- Debugging Node.js (offizieller Guide): https://nodejs.org/en/learn/getting-started/debugging\n- CLI-Optionen (`--inspect`, `--inspect-brk`, `--prof`, `--cpu-prof`, `--heap-prof`): https://nodejs.org/api/cli.html\n- Inspector-Modul: https://nodejs.org/api/inspector.html\n- `process.memoryUsage()`: https://nodejs.org/api/process.html#processmemoryusage\n- debug-Modul: https://www.npmjs.com/package/debug bzw. https://github.com/debug-js/debug\n- nodemon: https://github.com/remy/nodemon\n- Ausblick – diagnostics_channel: https://nodejs.org/api/diagnostics_channel.html · async_hooks: https://nodejs.org/api/async_hooks.html"},{"slug":"developer-productivity-verkuerzte-zyklen","title":"Developer Productivity: verkürzte Zyklen bei hoher Qualität","summary":"Kurze Feedbackschleifen bei verlässlicher Qualität sind mein Denkmodell für Developer Productivity. Wo tatsächlich gewartet wird, muss die eigene Durchlaufzeit zeigen.","tags":["Software","Delivery"],"published_at":"2022-12-26T10:00:00+00:00","updated_at":"2026-07-19T18:46:18.505233+00:00","url":"https://www.mikebild.dev/de/blog/developer-productivity-verkuerzte-zyklen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/3e3f2415795608033760103a086139f424a90833cf65e5153a4da8265624c051/developer-productivity-verkuerzte-zyklen-vorlesen.mp3","audio_duration_secs":755,"text":"Durchlaufzeit und Bearbeitungszeit sind zwei verschiedene Größen. Bearbeitungszeit ist die Spanne, in der jemand tatsächlich an einer Änderung arbeitet – lesen, schreiben, testen. Durchlaufzeit ist die Spanne von der ersten Zeile bis zur Rückmeldung aus der Produktion. Die Differenz zwischen beiden ist Liegezeit: Stunden und Tage, in denen eine fertige Änderung existiert und nichts mit ihr passiert.\n\nNehmen wir einen Change mit zwei Stunden Bearbeitungszeit und drei Tagen in Queues: vor dem Build, vor der Review, vor einer freien Testumgebung. In diesem Beispiel setzt der Ruf nach mehr Leuten an der kleineren Größe an. Ob das im eigenen System genauso aussieht, lässt sich aus den Zeitstempeln von Commit, Review, Build und Deployment ablesen – nicht aus einer allgemeinen Behauptung über Entwicklerarbeit.\n\nMein Denkmodell dafür lautet: **kurze Feedbackschleifen bei verlässlicher Qualität**. Das ist keine messbare Formel, sondern eine Erinnerung an zwei Grenzen. Tempo ohne Qualitätsnetz erzeugt schnellere Fehler; Qualitätssicherung ohne zeitnahe Rückmeldung erzeugt große Queues. Welche Seite gerade bremst, zeigt die gemessene Zykluszeit.\n\nProduktivität entsteht in zwei verbundenen Feedbackschleifen, deren Takt und Ergebnis nicht verwechselt werden dürfen.\n\n::::comparison{title=\"Zwei Schleifen, zwei Arten von Feedback\" role=\"primary\" preferredPattern=\"split-comparison\"}\n:::side{label=\"Delivery-Schleife\"}\n- Frage · Wie schnell erreicht eine kleine Änderung verlässlich Produktion?\n- Folge · Code, Build, Test, Review und Deploy\n- Zeitmaß · Minuten bis Stunden\n- Signal · technische Rückmeldung und Produktionsverhalten\n:::\n:::side{label=\"Discovery-Schleife\"}\n- Frage · Löst die Änderung das richtige Problem?\n- Folge · Hypothese, Umsetzung, Messung und Lernen\n- Zeitmaß · Tage bis Wochen\n- Signal · beobachtete Wirkung und validiertes Lernen\n:::\n::::\n\nEine schnelle Delivery-Schleife ohne Discovery liefert womöglich zügig das Falsche. Eine gute Hypothese ohne kurze Delivery-Schleife wartet zu lange auf belastbares Feedback.\n\n## Speed und Stability sind kein Trade-off\n\nDer hartnäckigste Denkfehler in dieser Diskussion ist die Annahme, man müsse sich zwischen Tempo und Stabilität entscheiden. „Wir könnten schneller liefern, aber dann bricht mehr“ – diesen Satz höre ich in Steering-Runden regelmäßig. Die Daten sagen das Gegenteil.\n\nDer Zentralbefund aus *Accelerate* (Forsgren, Humble, Kim, 2018), die Buchfassung der DORA-Forschung mit über 23.000 Antworten aus mehr als 2.000 Organisationen, ist eindeutig: High-Performer sind bei Geschwindigkeit **und** Qualität besser. Nirgends in den Daten erkauft sich ein Team das eine durch das andere. Die schnellen Teams sind auch die stabilen. Das ist ein statistischer Zusammenhang, keine Haltungsfrage – und das Fundament für die These, dass Zyklen und Qualität sich gegenseitig tragen.\n\nOperationalisiert wird das über die vier DORA-Metrics, die genau diese zwei Dimensionen messen. Für den Durchsatz: *Deployment Frequency* und *Lead Time for Changes* – die Zeit von Commit bis Produktion. Für die Stabilität: *Time to Restore Service* (die Zeit bis zur Wiederherstellung nach einem Incident) und *Change Failure Rate* – der Anteil der Deployments, die einen Fehler verursachen. Die Lead Time ist dabei die messbar gemachte „kurze Feedbackschleife“ aus meiner Formel. Wer sie senkt, senkt genau die Wartezeit, um die es hier geht.\n\nEin Hinweis zur Präzision, weil das oft schlampig zitiert wird: Die State-of-DevOps-Reports haben Teams jahrelang in Cluster einsortiert – 2021 noch mit einer eigenen „Elite“-Gruppe. Der 2022er Report fand nur noch drei Cluster (High, Medium, Low), kein „Elite“ mehr. Wer heute mit „laut DORA sind Elite-Teams…“ argumentiert, sollte wissen, aus welchem Jahr die Zahl stammt.\n\n## Warum der Engpass das Warten ist\n\nContinuous Integration wird oft als „Tooling“ verstanden – man richtet einen Build-Server ein, Haken dran. Der Sinn ist ein anderer. Martin Fowler definiert CI als Praxis, bei der Teammitglieder häufig integrieren und jede Integration durch einen automatisierten Build inklusive Tests verifiziert wird, um Integrationsfehler so früh wie möglich zu erkennen. Das entscheidende Wort ist „früh“. CI ist eine Latenz-Maschine: Sie verkürzt die Zeit zwischen einem Fehler und seiner Entdeckung. Der Engpass, den CI adressiert, ist Warte- und Integrationslatenz – nicht Code-Volumen.\n\nDie Deployment-Pipeline (ebenfalls Fowler) ist der bewusste Umgang mit dieser Latenz. Man zerlegt den Weg zur Produktion in Stages: Frühe Stages geben in Sekunden bis Minuten eine erste Sicherheit, spätere sind gründlicher und dürfen dafür langsamer sein. Damit wird zur Gestaltungsentscheidung, wo Feedback schnell und wo es gründlich sein soll.\n\n```yaml\npipeline:\n  - stage: commit          # target: < 10 min  -> fastest feedback\n    steps: [compile, unit_tests, static_analysis]\n  - stage: acceptance      # target: minutes   -> contract & integration\n    steps: [contract_tests, integration_tests]\n  - stage: deploy_staging  # ephemeral environment on demand\n    steps: [provision_env, smoke_tests]\n  - stage: production      # gated by feature_flag, not by branch age\n    steps: [progressive_rollout, health_check]\n```\n\nVor so einem Skelett trägt genau eine Frage: Wo geht die Zeit real verloren? Die Antwort ist selten `unit_tests`. Sie ist `compile`, wenn der Commit-Build zwölf Minuten braucht statt unter zehn. Sie ist `review`, wenn ein Pull Request zwei Tage in der Queue liegt. Sie ist `provision_env`, wenn eine Testumgebung von Hand beantragt und über Nacht bereitgestellt wird. Das sind die eigentlichen Latenzhebel. Das Schreiben der Tests, also die Zeit an der Tastatur, ist fast nie das Problem.\n\nDas erklärt auch, warum „shift left“ mehr ist als ein Buzzword. Ein Vertragsbruch zwischen zwei Services, der erst in einer nächtlichen End-to-End-Umgebung auffällt, kostet einen Tag. Derselbe Bruch, der im Commit-Build auffällt, kostet Minuten. Es ist derselbe Fehler – nur die Feedbacklatenz unterscheidet sich um Größenordnungen.\n\n## Die zwei Schleifen\n\nKurze Zyklen gibt es auf zwei Ebenen, und ich sehe oft, dass Teams nur eine davon optimieren. Die innere Schleife ist die Delivery: `code → commit → build → automated tests → review → deploy`, gemessen in Minuten. Die äußere Schleife ist die Discovery: `hypothesis → build → measure → learn`, gemessen in Tagen bis Wochen. Die innere Schleife stellt sicher, dass Sie schnell und sicher etwas ausliefern. Die äußere stellt sicher, dass es das Richtige ist.\n\nDiese äußere Schleife wird gern vergessen, dabei ist sie derselbe Gedanke auf der Produktebene. Eric Ries hat sie im *Lean Startup* (2011) als *Build–Measure–Learn* mit dem Ziel des *validated learning* beschrieben. Teresa Torres hat sie in *Continuous Discovery Habits* (2021) konkretisiert: wöchentliche Kundeninterviews, der Opportunity Solution Tree, der Chancen und Lösungsideen in eine sichtbare Ordnung bringt, systematisches Assumption Testing. Auch hier zählt nicht mehr Output, sondern kürzere Lernzyklen. Ein Team, das seine innere Schleife auf Minuten drückt, aber ein halbes Jahr wartet, bevor es merkt, dass das Feature niemand braucht, hat den falschen Engpass optimiert.\n\n```mermaid\nflowchart LR\n  subgraph inner[\"Inner loop – Delivery (minutes)\"]\n    code[\"code\"] --> commit[\"commit\"]\n    commit -->|\"wait: build\"| build[\"build\"]\n    build --> tests[\"automated tests\"]\n    tests -->|\"wait: review\"| review[\"review\"]\n    review -->|\"wait: provision env\"| deploy[\"deploy\"]\n  end\n  subgraph outer[\"Outer loop – Discovery (days / weeks)\"]\n    hypothesis[\"hypothesis\"] --> buildit[\"build\"]\n    buildit --> measure[\"measure\"]\n    measure --> learn[\"learn\"]\n    learn --> hypothesis\n  end\n  deploy --> measure\n  learn -.-> code\n```\n\nDie beschrifteten Kanten in dieser Skizze sind bewusst die Wartekanten: `build`, `review`, `provision env`. An ihnen entscheidet sich die Zykluszeit. Die DORA-Lead-Time misst die innere Schleife, die Discovery-Kadenz die äußere. Beide zusammen ergeben die Zahl, die zählt: wie oft ein Team pro Woche etwas Validiertes lernt.\n\n## Qualität ist das Netz, nicht die Bremse\n\nJetzt der Teil, den man in der Tempo-Euphorie gern überspringt. Die Formel ist multiplikativ. Kurze Zyklen ohne Qualitätsnetz bedeuten nicht „schneller liefern“, sondern „schneller kaputtgehen“. Wer die Batch-Size reduziert und häufiger deployt, ohne automatisierte Tests, Contract-Tests und eine echte Deployment-Pipeline, senkt vor allem eines: die Zeit bis zum nächsten Incident. Batch-Size-Reduktion ohne Stabilitätsmechanismen verlagert den Engpass nur – vom Warten vor dem Deploy zum Aufräumen danach.\n\nDeshalb gehört Qualität in dieselbe Pipeline wie das Tempo. Wo Services über Systemgrenzen hinweg miteinander sprechen, trägt Consumer-Driven Contract Testing einen großen Teil dieser Last. Die Idee (sie geht auf Ian Robinson und Fowler, 2006, zurück und ist mit Frameworks wie Pact breit etabliert): Der Konsument beschreibt, was er vom Provider erwartet, und diese Erwartung wird auf beiden Seiten unabhängig verifiziert – ohne eine gemeinsame, langsame End-to-End-Umgebung hochziehen zu müssen.\n\n```javascript\n// consumer side: describe the expectation, generate a pact file\nconsumerTest(\"fetches a user by id\", () => {\n  provider\n    .given(\"a user exists\")\n    .uponReceiving(\"a request for user 42\")\n    .withRequest({ method: \"GET\", path: \"/users/42\" })\n    .willRespondWith({\n      status: 200,\n      body: { id: 42, name: like(\"Ada\") }, // matcher, not a fixed value\n    });\n  // -> produces a pact contract, verified in the provider's own pipeline\n});\n```\n\nDer Gewinn ist doppelt. Der Contract-Test läuft in der `acceptance`-Stage in Sekunden statt der End-to-End-Test in Minuten in einer eigens provisionierten Umgebung – die Feedbackschleife an der Service-Grenze wird kürzer. Und er ist präziser: Er sagt nicht nur „irgendwas ist rot“, sondern „der Provider liefert `name` nicht mehr“. Das ist Qualität, die Tempo ermöglicht, statt es zu bremsen.\n\nAuf Architekturebene gehört derselbe Gedanke dazu. Michael Nygard hat in *Release It!* (2007, 2. Auflage 2018) die Stability Patterns beschrieben – Circuit Breaker, Bulkhead und Verwandte –, damit ein Fehler in einem Teil nicht das Ganze mitreißt. Und Neal Ford, Rebecca Parsons und Patrick Kua haben in *Building Evolutionary Architecture* (2017) die Idee der Fitness Functions eingeführt: automatisierte, ausführbare Prüfungen für Architektureigenschaften wie Latenz, Kopplung oder Sicherheit. Beides ist dieselbe Bewegung wie bei den Tests – Qualität wird zu etwas Ausführbarem gemacht, das in der Pipeline schnell Feedback gibt, statt in einem Review-Dokument zu verstauben.\n\n## Wie man kurze Zyklen konkret baut\n\nAus der Praxis ein paar Hebel, die zuverlässig wirken – geordnet nach dem, was die Wartekanten am direktesten trifft.\n\nDer Commit-Build muss schnell sein. Fowlers Faustregel vom „ten-minute build“ ist keine Nostalgie, sondern eine psychologische Grenze: Dauert der Build länger, hört das Team auf, häufig zu integrieren, und die Batches wachsen wieder. Eine schnelle, breite Basis aus Unit-Tests und eine schmale Spitze aus langsamen End-to-End-Tests – die klassische Testpyramide – hält den Build schnell, ohne an Abdeckung zu verlieren.\n\nBranches kurz halten. Trunk-Based Development mit kurzlebigen Feature-Branches und Feature Flags entkoppelt „Code ist gemerged“ von „Feature ist an“. Das ist der Punkt, an dem `production` durch ein Feature-Flag gated wird, nicht durch das Alter eines Branches. Lang lebende Branches sind der klassische Weg, wie sich Integrations-Wartezeit unsichtbar aufstaut, bis der Merge zum Tagesprojekt wird.\n\nUmgebungen on demand. Wenn `provision_env` ein Ticket an ein anderes Team ist, ist Ihre Lead Time keine technische, sondern eine organisatorische Größe. Ephemere, per Skript erzeugte Umgebungen nehmen diese Kante aus der Queue.\n\nUnd der Punkt, der am meisten Konflikt auslöst: Messen Sie das System, nicht die Person. Die vier DORA-Metrics sind Team- und System-Outcome-Metrics. Als individuelles Entwickler-Ranking missbraucht, erzeugen sie sofort Goodhart-Effekte – man optimiert die Zahl statt die Sache. Wer an „Deployments pro Entwickler“ gemessen wird, produziert Micro-Deploys; wer an der Change Failure Rate gemessen wird, deklariert Incidents um. Forsgren und Kollegen haben explizit vor solchen Vanity- und Individual-Metrics gewarnt. Die Metrics zeigen die Gesundheit eines Flusses, nicht die Leistung eines Einzelnen.\n\n## Fazit\n\nProduktivität in der Softwareentwicklung ist nicht sinnvoll über geleistete Stunden allein beschrieben. In vielen Flüssen steckt ein großer Teil der Durchlaufzeit im Warten auf Build, Review oder Umgebung; ob das hier der Engpass ist, muss die Messung zeigen. DORA-Metrics beschreiben die Lieferfähigkeit des Systems. Contract-Tests, Stability Patterns und Fitness Functions können ausgewählte Qualitätsrisiken früher sichtbar machen, während die Discovery-Schleife prüft, ob das Team überhaupt am richtigen Problem arbeitet.\n\nWenn Sie morgen anfangen wollen: Zeichnen Sie Ihre innere Schleife auf und schreiben Sie an jede Kante die reale Wartezeit. Die längste davon ist ein guter Kandidat für die nächste Verbesserung. Danach messen Sie erneut. Kurze Zyklen und verlässliche Qualität bleiben dabei zwei getrennte Größen; keine darf die andere verdecken.\n\n## Weiterführende Quellen\n\n- Nicole Forsgren, Jez Humble, Gene Kim: *Accelerate* (2018) – der Befund, dass Speed und Stability kein Trade-off sind: <https://itrevolution.com/product/accelerate/>\n- Four Keys / DORA-Metrics erklärt: <https://cloud.google.com/blog/products/devops-sre/using-the-four-keys-to-measure-your-devops-performance>\n- DORA-Metrics und die Cluster-Einordnung: <https://dora.dev/guides/dora-metrics-four-keys/>\n- Martin Fowler: *Deployment Pipeline*: <https://www.martinfowler.com/bliki/DeploymentPipeline.html>\n- Martin Fowler: *Continuous Delivery*: <https://martinfowler.com/bliki/ContinuousDelivery.html>\n- Teresa Torres: *Continuous Discovery Habits* (2021): <https://businessofsoftware.org/talks/continuous-discovery/>"},{"slug":"svelte-slots-context-und-web-components","title":"Slots, Context und Web Components in Svelte","summary":"Drei Kompositions-Werkzeuge in Svelte 3: Slots reichen Inhalt von außen hinein, die Context-API vermeidet Prop-Drilling, und Custom Elements machen eine Komponente framework-neutral wiederverwendbar.","tags":["Software","Frontend","Svelte"],"published_at":"2022-11-22T10:00:00+00:00","updated_at":"2026-07-13T07:59:51.626923+00:00","url":"https://www.mikebild.dev/de/blog/svelte-slots-context-und-web-components/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/4b87296fcc1edbc520cd0889db5380746903c20eb4eaf4037294e16a0f16fb2f/svelte-slots-context-und-web-components-vorlesen.mp3","audio_duration_secs":711,"text":"Eine wiederverwendbare Card, in die jedes Team seinen eigenen Header und seinen eigenen Inhalt stecken kann: Die Anforderung klingt harmlos, und mit Props allein bekommt man sie trotzdem nicht sauber gelöst. Im Review tauchen dann die Ausweichmanöver auf – ein Store, wo ein Slot gereicht hätte; ein Prop, das durch sieben Komponenten gefädelt wird, obwohl die Context-API der kürzere Weg gewesen wäre; eine Komponentenbibliothek, die nur unter Svelte läuft, obwohl framework-neutrale Wiederverwendung das eigentliche Ziel war.\n\nSlots, die Context-API und Custom Elements tauchen in denselben Komponenten nebeneinander auf und werden deshalb gern verwechselt. Auseinander hält sie die Richtung, in die etwas fließt: Markup von außen in eine Komponente hinein, Daten von innen an dieses Markup zurück, Werte nach unten durch beliebig viele Ebenen des Komponentenbaums. Wer vor der Werkzeugwahl kurz klärt, welche dieser Richtungen er gerade braucht, hat die Entscheidung meist schon getroffen.\n\n**Redaktionelle Einordnung (Juli 2026):** Der Titel meint ausdrücklich Svelte 3. In Svelte 5 sind Snippets und `{@render ...}` die bevorzugte Komposition anstelle von Slots; Callback-Props ersetzen für neuen Code viele Component Events. Context und Custom Elements bestehen weiter. Die historische Syntax bleibt hier stehen, die Quellen zeigen zusätzlich die heutigen Gegenstücke.\n\n## Slots: Inhalt von außen hineinreichen\n\nEin Slot ist ein Platzhalter in einer Komponente, den der Aufrufer von außen füllt. Die Card-Komponente entscheidet über Rahmen, Abstände und Layout – was innen steht, bestimmt der Aufrufer. Der einfachste Fall ist der Default-Slot:\n\n```svelte\n<!-- Card.svelte -->\n<div class=\"card\">\n  <slot>Nothing here yet</slot>\n</div>\n\n<style>\n  .card {\n    border: 1px solid #ddd;\n    border-radius: 8px;\n    padding: 1rem;\n  }\n</style>\n```\n\nDer Inhalt zwischen den `<slot>`-Tags ist der Fallback: Er erscheint nur, wenn der Aufrufer nichts übergibt. Genutzt wird die Card dann so:\n\n```svelte\n<Card>\n  <p>This paragraph lands inside the card.</p>\n</Card>\n```\n\nInteressant wird es mit benannten Slots. Eine Card hat oft mehr als eine Zone – einen Header und einen Body etwa. In der Kind-Komponente vergibt man dafür Namen, im Aufruf ordnet man Markup diesen Namen zu:\n\n```svelte\n<!-- Card.svelte -->\n<div class=\"card\">\n  {#if $$slots.header}\n    <header class=\"card-header\">\n      <slot name=\"header\"></slot>\n    </header>\n  {/if}\n  <div class=\"card-body\">\n    <slot></slot>\n  </div>\n</div>\n```\n\n```svelte\n<Card>\n  <h2 slot=\"header\">Release notes</h2>\n  <p>Everything else fills the default slot.</p>\n</Card>\n```\n\nDas Objekt `$$slots` verdient einen kurzen Blick. Seine Schlüssel sind die tatsächlich übergebenen Slot-Namen, und damit lässt sich die Struktur bedingt rendern. Der `<header>`-Rahmen erscheint nur, wenn der Aufrufer wirklich einen Header übergeben hat – sonst hätte man einen leeren, umrandeten Kasten. Das ist der Unterschied zwischen einer Komponente, die man gerne benutzt, und einer, die man umständlich findet.\n\nWichtig ist dabei, dass `$$slots` nur die Anwesenheit eines Slots meldet, nicht dessen Inhalt zur Laufzeit. Ob ein benannter Slot befüllt ist, entscheidet sich beim Kompilieren anhand des Aufrufs, nicht durch das, was der Aufrufer später hineinrendert. Für die typische Aufgabe – Rahmen weglassen, wenn niemand einen Header liefert – reicht das genau aus, und man sollte `$$slots` nicht mit einer reaktiven Prüfung auf leeren Inhalt verwechseln. Ein zweiter Punkt, der in Reviews regelmäßig auffällt: Ein benannter Slot lässt sich nur an einer Stelle einer Komponente ausgeben. Wer denselben benannten Slot zweimal rendert, bekommt keinen doppelten Inhalt, sondern ein Verhalten, auf das man sich nicht verlassen sollte. Braucht man denselben übergebenen Inhalt an mehreren Stellen, führt der Weg über slot props oder über eine Umstrukturierung der Komponente, nicht über ein zweites `<slot name=\"…\">`.\n\n### Slot props: Daten von innen nach außen\n\nBis hierher fließt Inhalt nur in eine Richtung: von außen nach innen. Der zweite Teil dreht diese Richtung um, und genau das wird am häufigsten verwechselt. Slot props erlauben es der Kind-Komponente, Daten nach außen an das eingesetzte Markup zu geben. Der klassische Fall ist eine Liste, die das Iterieren übernimmt, aber die Darstellung jedes Eintrags dem Aufrufer überlässt:\n\n```svelte\n<!-- List.svelte -->\n<script>\n  export let items = [];\n</script>\n\n<ul>\n  {#each items as item, index}\n    <li>\n      <slot name=\"item\" {item} {index}>\n        {item}\n      </slot>\n    </li>\n  {/each}\n</ul>\n```\n\nDie Kind-Komponente gibt `item` und `index` über den Slot nach außen. Der Aufrufer empfängt diese Werte mit der `let:`-Directive und darf frei entscheiden, wie ein Eintrag aussieht:\n\n```svelte\n<List items={users}>\n  <span slot=\"item\" let:item let:index>\n    {index + 1}. {item.name} – {item.role}\n  </span>\n</List>\n```\n\nDie Liste kümmert sich um Schleife, Schlüssel und Struktur; der Aufrufer bringt die konkrete Darstellung mit und bekommt dafür genau die Daten, die er braucht. Diese beiden Richtungen sauber auseinanderzuhalten ist der Kern des Slot-Verständnisses: Markup fließt von außen hinein, Daten fließen über slot props von innen nach außen. Das folgende Diagramm hält beide Wege nebeneinander fest.\n\n```mermaid\nflowchart LR\n  Parent[\"Parent<br/>&lt;List let:item&gt;\"]\n  Child[\"List.svelte<br/>&lt;slot name='item' {item}&gt;\"]\n  Render[\"Rendered &lt;li&gt;\"]\n\n  Parent -- \"markup (outside in)\" --> Child\n  Child -- \"slot props: item, index (inside out)\" --> Parent\n  Child --> Render\n```\n\n## Context: durch den Baum reichen ohne Prop-Drilling\n\nSlots lösen die Frage, wie man Inhalt an eine direkte Kind-Komponente gibt. Sie helfen nicht, wenn ein Wert mehrere Ebenen tief benötigt wird. Stellen wir uns eine Toolbar vor, deren einzelne Buttons sich bei ihr registrieren wollen. Zwischen Toolbar und Button liegen vielleicht noch Gruppen, Trenner, Wrapper. Den Registrierungs-Callback durch jede dieser Ebenen als Prop zu reichen, ist mühsam und macht Zwischenkomponenten von Details abhängig, die sie gar nichts angehen. Dieses Weiterreichen nennt man Prop-Drilling, und die Context-API ist die direkte Antwort darauf.\n\nDie Provider-Komponente legt einen Wert unter einem Schlüssel ab, jede Nachfahr-Komponente kann ihn unter demselben Schlüssel wieder abrufen:\n\n```svelte\n<!-- Toolbar.svelte -->\n<script>\n  import { setContext } from 'svelte';\n\n  const buttons = [];\n  function registerButton(id) {\n    buttons.push(id);\n  }\n\n  setContext('toolbar', { registerButton });\n</script>\n\n<div class=\"toolbar\">\n  <slot></slot>\n</div>\n```\n\n```svelte\n<!-- ToolbarButton.svelte -->\n<script>\n  import { getContext } from 'svelte';\n\n  export let id;\n  const toolbar = getContext('toolbar');\n  toolbar.registerButton(id);\n</script>\n\n<button on:click>\n  <slot></slot>\n</button>\n```\n\nDer Button findet seine Toolbar, egal wie viele Ebenen dazwischenliegen. Kein Prop wandert durch die Zwischenkomponenten. Der Schlüssel darf übrigens ein beliebiger Wert sein – ein String wie hier, ein Symbol oder ein Objekt; ein Symbol schützt zuverlässig vor Namenskollisionen in größeren Repositories.\n\nDer Geltungsbereich von Context ist dabei streng auf den Teilbaum unterhalb des Providers beschränkt. Eine Nachbarkomponente, die nicht unter derselben `setContext`-Komponente hängt, sieht den Wert nicht – Context fließt ausschließlich nach unten, nie zur Seite und nie nach oben. Ruft eine Kind-Komponente ihrerseits `setContext` mit demselben Schlüssel auf, überschattet sie den Wert für ihren eigenen Teilbaum, ohne den Wert weiter oben zu verändern; die Auflösung folgt also der Instanzhierarchie, nicht der Reihenfolge im Quelltext. Wer sich nicht sicher ist, ob ein Schlüssel überhaupt gesetzt wurde, kann das mit `hasContext` prüfen, statt sich auf ein `undefined` von `getContext` zu verlassen – das macht den Fehlerfall explizit statt still.\n\nZwei Fallstricke tauchen dabei mit schöner Regelmäßigkeit auf. Der erste: `setContext` muss synchron während der Initialisierung der Komponente aufgerufen werden. Wer den Aufruf in `onMount`, einen Event-Handler oder eine `async`-Funktion verschiebt, bekommt keinen Fehler, sondern etwas Schlimmeres – die Kinder erhalten beim späteren `getContext` schlicht `undefined`, und die Ursache ist schwer zu finden. Der zweite Fallstrick betrifft Reaktivität.\n\n```mermaid\ngraph TD\n  Provider[\"Toolbar<br/>setContext('toolbar', …)\"]\n  Group[\"ButtonGroup<br/>(keine Props)\"]\n  Divider[\"Divider<br/>(keine Props)\"]\n  Consumer[\"ToolbarButton<br/>getContext('toolbar')\"]\n\n  Provider --> Group --> Divider --> Consumer\n```\n\n### Context ist nicht dasselbe wie ein Store\n\nContext ist an die Komponenteninstanz gebunden und für sich genommen nicht reaktiv. Lege ich einen einfachen Zahlenwert in den Context und ändere ihn später, aktualisieren sich die Kinder nicht – sie haben ihre Kopie beim Init gelesen, und dabei bleibt es. Ein Store dagegen ist global teilbar und reaktiv; mit `$store` abonniert man Änderungen und rendert bei jeder Aktualisierung neu. Über den State außerhalb der Komponente und die Store-Mechanik habe ich in [Svelte Stores](https://www.mikebild.dev/de/blog/svelte-stores-zustand-ausserhalb-der-komponente/) ausführlicher geschrieben, und die zugrundeliegende Reaktivität ist Thema von [Svelte-Reaktivität](https://www.mikebild.dev/de/blog/svelte-reaktivitaet-compiler-ohne-virtual-dom/).\n\nDie beiden schließen sich nicht aus, im Gegenteil. Die in der Praxis nützlichste Kombination legt einen Store *in* den Context. Damit bekommt man beides: die implizite Verteilung durch den Baum von Context und die Reaktivität eines Stores.\n\n```svelte\n<!-- ThemeProvider.svelte -->\n<script>\n  import { setContext } from 'svelte';\n  import { writable } from 'svelte/store';\n\n  const theme = writable('light');\n  setContext('theme', theme);\n</script>\n\n<slot></slot>\n```\n\n```svelte\n<!-- ThemeToggle.svelte -->\n<script>\n  import { getContext } from 'svelte';\n\n  const theme = getContext('theme');\n</script>\n\n<button on:click={() => theme.update((t) => (t === 'light' ? 'dark' : 'light'))}>\n  Current theme: {$theme}\n</button>\n```\n\nJetzt reicht Context den Store an jede Nachfahr-Komponente, und weil es ein Store ist, reagiert `{$theme}` auf jede Änderung – ohne ein einziges Prop durch den Baum zu fädeln.\n\n## Web Components: framework-neutrale Wiederverwendung\n\nSlots und Context lösen Kompositionsprobleme innerhalb einer Svelte-Anwendung. Das dritte Werkzeug zielt nach außen. Manchmal soll eine Komponente auch dort funktionieren, wo kein Svelte läuft – in einer bestehenden Seite, in einem anderen Framework, in reinem HTML. Svelte kann eine Komponente als Custom Element kompilieren, also als echtes Web Component nach Browser-Standard.\n\nZwei Dinge braucht es dafür. In der Komponente vergibt man über `<svelte:options>` einen Tag-Namen, und beim Kompilieren aktiviert man die Option `customElement`:\n\n```svelte\n<!-- UserBadge.svelte -->\n<svelte:options tag=\"user-badge\" />\n\n<script>\n  export let name = 'Unknown';\n  export let status = 'offline';\n</script>\n\n<span class=\"badge\" data-status={status}>\n  {name}\n</span>\n\n<style>\n  .badge {\n    display: inline-flex;\n    padding: 0.25rem 0.5rem;\n    border-radius: 999px;\n  }\n  .badge[data-status='online'] {\n    background: #d1fae5;\n  }\n</style>\n```\n\nBeim Bundling schaltet man den Compiler auf Custom-Element-Ausgabe. Mit Rollup und dem offiziellen Plugin sieht der relevante Teil so aus:\n\n```js\n// rollup.config.js\nimport svelte from 'rollup-plugin-svelte';\n\nexport default {\n  // ...\n  plugins: [\n    svelte({\n      compilerOptions: {\n        customElement: true,\n      },\n    }),\n  ],\n};\n```\n\nDanach ist `<user-badge>` ein ganz normales HTML-Element. Es lässt sich in jeder Seite verwenden, ganz ohne Svelte-Laufzeit im Aufrufer:\n\n```html\n<script src=\"./user-badge.js\"></script>\n\n<user-badge name=\"Ada\" status=\"online\"></user-badge>\n```\n\nDie exportierten Props werden zu Element-Properties und -Attributen, sodass man das Element wie jedes andere DOM-Element ansprechen kann. Svelte kapselt die Styles per Shadow DOM – die Badge-Styles greifen nicht auf die umgebende Seite über, und Seiten-Styles dringen nicht unkontrolliert hinein. Das ist meist erwünscht, überrascht aber, wenn man globale Styles erwartet; `:global`-Regeln verhalten sich hier anders.\n\nAn dieser Stelle lohnt ein ehrlicher Blick auf die rauen Kanten, denn Ende 2022 ist der Custom-Element-Modus von Svelte funktional, aber nicht spannungsfrei. Der wichtigste Punkt betrifft die Datenübergabe: HTML-Attribute sind immer Strings. Ein `name=\"Ada\"` im Markup kommt sauber an, aber ein Objekt oder ein Array lässt sich nicht als Attribut übergeben. Solche Werte muss man als JavaScript-Property auf dem Element setzen, etwa `el.items = [...]` – ein Detail, das man leicht übersieht, wenn man das Element nur deklarativ im HTML verwendet. Zweitens sind Custom Elements nach dem Definieren global registriert; derselbe Tag-Name lässt sich im selben Dokument nicht zweimal belegen, was beim Einbinden mehrerer Bundles zu Konflikten führen kann. Und drittens ist die Shadow-DOM-Kapselung zwar ein Gewinn für die Isolation, nimmt einem aber den bequemen Durchgriff von außen: Wer das Element von der Seite aus gestalten will, muss die dafür vorgesehenen Stellen selbst öffnen und CSS Custom Properties bewusst hineinreichen. Nichts davon ist ein Ausschlusskriterium, aber es sind die Stellen, an denen die Praxis von der ersten glatten Demo abweicht.\n\nEin praktischer Hinweis aus Projekten: `customElement: true` gehört nur auf die Komponenten, die man tatsächlich exportieren will, nicht projektweit. Sonst kompiliert man die ganze App zu Custom Elements und verliert den regulären Svelte-Komponentenmodus mit seinen Vorteilen. In der Praxis pflege ich dafür einen separaten Build-Eintrag, der genau die zu exportierenden Komponenten bündelt.\n\n## Wann welches Werkzeug\n\nDie drei Mechanismen lassen sich klar nach ihrem Zweck sortieren:\n\n- Slots wählt man, wenn eine Komponente ihr Layout vorgibt, den Inhalt aber vom Aufrufer bekommen soll – mit slot props auch dann, wenn der Aufrufer für die Darstellung Daten aus dem Inneren braucht.\n- Context wählt man, wenn ein Wert oder eine Funktion mehreren Ebenen tiefer benötigt wird und Prop-Drilling die Zwischenkomponenten unnötig belasten würde; für reaktive Werte legt man einen Store in den Context.\n- Web Components wählt man, wenn eine Komponente außerhalb der Svelte-Welt laufen soll – in fremden Frameworks oder in reinem HTML.\n\nEin kurzer Hinweis zur Einordnung Ende 2022: Ich beziehe mich hier durchgehend auf Svelte 3, die aktuelle Version dieser Tage. Die gezeigte Syntax `tag=\"user-badge\"` ist der Stand von Svelte 3; spätere Versionen haben an dieser Stelle Änderungen gebracht, die hier bewusst außen vor bleiben. Wer das Repository, an dem er arbeitet, auf Svelte 3 hält, ist mit allem hier Gezeigten auf sicherem Boden.\n\n## Fazit\n\nSlots, Context und Web Components wirken zunächst wie drei zusammenhanglose Features, sind aber ein zusammengehöriges Set von Kompositions-Werkzeugen, jedes mit einer eigenen Richtung. Slots reichen Markup von außen hinein und über slot props Daten von innen wieder heraus. Context verteilt Werte implizit durch den Komponentenbaum und erspart das Prop-Drilling – reaktiv wird es erst mit einem Store darin. Und Custom Elements lösen eine Komponente aus der Svelte-Welt heraus, sodass sie überall dort läuft, wo Standard-HTML läuft. Wer sich bei jedem Kompositionsproblem kurz fragt, in welche Richtung Inhalt oder Daten fließen sollen und wie weit sie reichen müssen, greift fast von selbst zum passenden der drei Werkzeuge. Diese eine Frage erspart im Projekt die meisten Umwege.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-svelte.html\n- [Svelte 5: Snippets](https://svelte.dev/docs/svelte/snippet)\n- [Svelte: Slots im Legacy-Modus](https://svelte.dev/docs/svelte/legacy-slots)\n- [Svelte: Context-API](https://svelte.dev/docs/svelte/context)\n- [Svelte: Custom Elements](https://svelte.dev/docs/svelte/custom-elements)\n- [Svelte: `<svelte:options>`](https://svelte.dev/docs/svelte/svelte-options)"},{"slug":"build-vs-runtime-webprojekte","title":"Build vs. Runtime in Webprojekten","summary":"Der Rendering-Zeitpunkt wird pro Route entschieden. Statische, dynamische und inkrementelle Modelle lassen sich nach Datenfrist, Kosten und Fehlerverhalten kombinieren.","tags":["Software","Frontend"],"published_at":"2022-11-12T10:00:00+00:00","updated_at":"2026-07-13T08:48:49.883691+00:00","url":"https://www.mikebild.dev/de/blog/build-vs-runtime-webprojekte/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/93ecafc070d67f1f8cef9a2686991c749fc38612bea19c244d13328b73a59e06/build-vs-runtime-webprojekte-vorlesen.mp3","audio_duration_secs":651,"text":"# Build vs. Runtime in Webprojekten\n\nBeim Aufsetzen eines Projekts fällt diese Entscheidung in einer Sekunde: Man wählt ein Framework, übernimmt den Default und rendert ab da alles serverseitig. Bemerkbar macht sich diese Sekunde erst Monate später – wenn der Build zwanzig Minuten braucht oder die Server bei der ersten Lastspitze volllaufen.\n\nDer Rendering-Zeitpunkt einer Seite ist eine Architekturgrenze und keine Einstellung, die man im Build-Setup wegkonfiguriert. Er legt fest, wann eine Seite entsteht, wie frisch ihre Daten sind, was ein Fehler kostet und wie schnell ein Team liefern kann. Beantwortet wird die Frage – rendern wir zur Build-Zeit oder zur Laufzeit? – trotzdem meist implizit. Wer die Grenze bewusst zieht, verkürzt Zyklen, senkt Kosten und nimmt Reibung zwischen Content, Entwicklung und Betrieb heraus.\n\n## Die zwei Zeitpunkte\n\nJede Ausgabe im Web entsteht zu einem von zwei Zeitpunkten. Zur **Build-Zeit** läuft Arbeit einmal, bevor irgendein Nutzer ankommt: Templates werden zu HTML, Daten werden eingefroren, Assets werden gebündelt und optimiert. Zur **Laufzeit** läuft Arbeit pro Anfrage oder im Browser, während ein Nutzer wartet: Daten werden geholt, Rechte geprüft, HTML zusammengesetzt, State hydriert.\n\nDer entscheidende Unterschied ist nicht „schnell vs. langsam“, sondern **wer wartet und wie oft die Arbeit passiert**. Build-Arbeit passiert einmal und wird beliebig oft ausgeliefert – der Nutzer wartet nie darauf. Runtime-Arbeit passiert bei jeder Anfrage erneut – dafür kennt sie den aktuellen Moment, den eingeloggten Nutzer und den letzten Datenstand.\n\n```mermaid\nflowchart LR\n  subgraph Build[\"Build-Zeit (einmal)\"]\n    Content --> Prerender\n    Prerender --> StaticHTML[\"Static HTML + Assets\"]\n  end\n  subgraph Runtime[\"Laufzeit (pro Request)\"]\n    Request --> Fetch[\"Fetch data + auth\"]\n    Fetch --> Render[\"Render + hydrate\"]\n  end\n  StaticHTML --> CDN\n  CDN --> User\n  Render --> User\n```\n\nZwischen diesen Polen liegt ein Spektrum, kein Entweder-oder: Static Site Generation friert alles zur Build-Zeit ein. Server-Side Rendering baut pro Request neu. Client-Side Rendering schiebt die Arbeit in den Browser. Und Zwischenformen – prerender plus clientseitiges Nachladen, oder statisches HTML mit inkrementeller Revalidierung – mischen beide Zeitpunkte pro Route. Die Kunst besteht darin, die Grenze pro Seite an der richtigen Stelle zu ziehen, statt ein Verfahren für alles auszurufen.\n\n## Der Trade-off, konkret\n\nIch mache den Trade-off an drei Größen fest, weil genau diese drei ein Produkt tragen oder bremsen: Frische der Daten, Kosten pro Anfrage und Time-to-Feedback im Team.\n\nEine Marketing- oder Doku-Seite ändert sich selten und ist für alle gleich. Sie zur Build-Zeit einzufrieren ist fast immer richtig: Sie liegt danach als statische Datei im CDN, kostet pro Aufruf praktisch nichts, hat keine Server-Latenz und keinen Server, der ausfallen kann.\n\n```js\n// Build-time: runs once, per deploy\nexport async function getStaticProps() {\n  const posts = await cms.getPublishedPosts();\n  return { props: { posts } };\n}\n```\n\nEin Kontostand, ein personalisiertes Dashboard oder eine Preisliste, die sich stündlich ändert, verträgt kein Einfrieren. Diese Daten gehören an die Laufzeit – entweder pro Request auf dem Server oder als clientseitiger Fetch nach dem ersten Paint.\n\n```js\n// Runtime: runs on every request, knows \"now\" and the user\nexport async function getServerSideProps(ctx) {\n  const user = await auth(ctx.req);\n  const balance = await ledger.getBalance(user.id);\n  return { props: { balance } };\n}\n```\n\nDie eigentliche Frage lautet nicht „SSG oder SSR?“, sondern: **Wie lange darf diese Ausgabe alt sein, ohne falsch zu werden?** Ich nenne das die Datenfrist. Ist die Frist praktisch unendlich (ein Blogpost von 2019), gehört die Seite in den Build. Ist die Frist null (der aktuelle Kontostand), gehört sie an die Laufzeit. Und dazwischen – die Frist beträgt Minuten oder Stunden – wird es interessant: Dann will ich die Auslieferungskosten eines statischen Assets, aber eine kontrollierte Auffrischung. Genau dafür existieren Caching mit Revalidierung und inkrementelle Regeneration. Sie sind die technische Antwort auf eine endliche Datenfrist, kein Framework-Gimmick.\n\n## Was das messbar bewirkt\n\nDer Grund, warum ich diese Grenze so ernst nehme, ist ihre Wirkung auf harte Zahlen.\n\nEine statisch ausgelieferte Seite antwortet mit der Latenz des nächsten CDN-Knotens – zweistellige Millisekunden, unabhängig von der Last. Dieselbe Seite pro Request serverseitig zu rendern bedeutet Datenbank-Roundtrips, Template-Rendering und einen Server, der mit dem Traffic skalieren muss. Bei einem Lastspitzen-Event ist das der Unterschied zwischen „das CDN liefert eine Datei“ und „unsere Instanzen laufen voll und die Autoscaling-Rechnung explodiert“. Für Seiten ohne echte Datenfrist ist Runtime-Rendering also nicht nur teurer, sondern auch fragiler – man kauft Komplexität, ohne einen Gegenwert zu bekommen.\n\nUmgekehrt kostet zu viel Build-Arbeit Feedback. Wenn jede Änderung an einem Beitrag einen Full-Rebuild von zehntausend Seiten auslöst, wird der Build zum Nadelöhr: Ein Redaktions-Typo braucht 20 Minuten bis zur Sichtbarkeit, und niemand traut sich mehr, kurz vor Feierabend zu deployen. Das ist kein reines Performance-, sondern ein Produktivitätsproblem. Die Konsequenz daraus heißt nicht „mehr Runtime“, sie heißt feinere Grenze: nur die betroffenen Seiten neu erzeugen, statt alles. Kürzere Feedback-Schleifen sind hier der eigentliche Gewinn – sie entscheiden, wie oft ein Team liefert und wie ruhig es das tut.\n\nEin Fall, der mir in Projekten begegnet ist: Ein Content-lastiges Portal rendert historisch alles serverseitig, weil das mal der Framework-Default war. Unter normaler Last läuft es, aber die Server-Rechnung wächst linear mit dem Traffic, und jede Pressemeldung, die das Portal kurz viral gehen lässt, wird zum Incident. Verschiebt man die zwei Handvoll wirklich statischer Seitentypen – Artikel, Kategorien, Landingpages – in den Build und lässt nur Suche und personalisierte Bereiche an der Laufzeit, verändert sich die Kurve grundlegend. Der Löwenanteil des Traffics wird zur CDN-Datei, die Server tragen nur noch den kleinen dynamischen Rest, und die Lastspitze ist plötzlich ein Nicht-Event. Es wurde keine Zeile Fachlogik umgeschrieben – nur die Grenze zwischen Build und Runtime an die tatsächliche Datenfrist angepasst.\n\nIch fasse die Entscheidung deshalb gern in einer kleinen Tabelle zusammen, bevor ich ein Tool anfasse:\n\n| Seite | Datenfrist | Zeitpunkt |\n| --- | --- | --- |\n| Landingpage, Docs | quasi unendlich | Build |\n| Produktkatalog | Stunden | Build + Revalidierung |\n| Suchergebnis, Feed | Sekunden | Runtime (Server) |\n| Dashboard, Kontostand | null, personalisiert | Runtime (Server/Client) |\n\nDie Tabelle ist bewusst schlicht. Sie erzwingt genau die eine Frage, die man sonst überspringt: Wie alt darf das hier sein?\n\n## Wie ich die Grenze ziehe\n\nVor der Tool-Frage kläre ich pro Route: Ist die Ausgabe für alle Nutzer gleich oder personalisiert? Wie lange darf sie alt sein? Was passiert, wenn die Datenquelle beim Rendern nicht antwortet? Und lässt sich der Zeitpunkt später ändern, ohne die halbe Anwendung umzubauen? Wenn ich eine Seite so schneide, dass ich ihren Rendering-Zeitpunkt isoliert verschieben kann – von Build zu Runtime, inkrementell oder zurück –, ohne Nachbarrouten anzufassen, dann ist die Grenze brauchbar.\n\nEin häufig unterschätzter Teil davon ist die Lebensdauer von State. Im Frontend ist die schwierigste Entscheidung selten der Komponentenbaum, sondern die Frage, wo State gehalten wird: in einer Komponente, in der URL, in einem Cache, auf dem Server oder in einem lokalen Modell. Diese Frage ist die Build-vs-Runtime-Frage im Kleinen. State, der in die URL gehört, kann serverseitig gerendert und geteilt werden. State, der nur clientseitig existiert, verlangt Laufzeit im Browser und ist im statischen HTML schlicht nicht vorhanden. Wer diese Zuordnung sauber trifft, bekommt teilbare, indexierbare, testbare Seiten fast geschenkt.\n\n## Progressive Enhancement als dieselbe Grenze\n\nDiese Denkweise ist nicht neu, sie ist nur alt geworden. Progressive Enhancement fragt seit jeher: Was liefere ich als robuste Basis, und was reichere ich zur Laufzeit an, wenn der Client es kann? Schon ein Canvas-Element trägt die Haltung in sich – erst prüfen, ob eine Fähigkeit da ist, dann ein Verhalten aktivieren, sonst einen Fallback anbieten.\n\n```html\n<canvas id=\"clock\" width=\"150\" height=\"150\">\n  <img src=\"clock.png\" alt=\"Clock fallback\" />\n</canvas>\n```\n\n```js\nconst canvas = document.getElementById(\"clock\");\nif (canvas.getContext) {\n  const ctx = canvas.getContext(\"2d\");\n  // enhance at runtime, only when supported\n}\n```\n\nGenau dieselbe Reihenfolge – Fähigkeit, Grenze, Verhalten – gilt für serverseitig geliefertes HTML plus clientseitige Hydration. Die statische Basis ist die Build-Ausgabe. Die Anreicherung ist die Laufzeit. Wenn beide Ebenen sauber getrennt sind, funktioniert die Seite auch dann, wenn die Laufzeit-Schicht langsam ist oder ausfällt. Das ist kein Legacy-Wissen, sondern die konzeptionelle Wurzel der ganzen Rendering-Diskussion.\n\n## Der Fehlerpfad entscheidet\n\nEine Rendering-Entscheidung ist erst belastbar, wenn ihr Fehlerpfad beschrieben ist. Was passiert, wenn das CMS beim Build nicht antwortet – bricht der Deploy ab oder geht die Seite mit Lücke live? Was passiert, wenn eine API beim serverseitigen Rendern hängt – blockiert die ganze Antwort, oder rendert die Seite ohne den Teil und lädt ihn clientseitig nach? Diese Fragen wirken trocken, sind aber der Unterschied zwischen Demo und Betrieb.\n\nBuild-Rendering verlagert das Ausfallrisiko in die Deploy-Pipeline: Fehler passieren einmal, sichtbar, vor dem Nutzer. Runtime-Rendering verteilt das Risiko auf jede einzelne Anfrage: Fehler passieren live, unter Last, im schlechtesten Moment. Beides ist legitim – aber man sollte wissen, welches Risiko man kauft. Ich prüfe deshalb immer, ob die Entscheidung rückbaubar ist: Kann ich eine Route von Runtime auf Build umstellen, wenn die Last steigt? Kann ich einen Cache gezielt invalidieren, ohne alles neu zu bauen? Wenn die Antwort nein ist, fehlt noch ein Teil der Architektur.\n\n## Typische Fehler\n\nDer erste Fehler ist, das Werkzeug mit der Entscheidung zu verwechseln. Ein SSG-Framework ist keine Architektur, SSR ist kein Datenmodell, ein CDN ist keine Frische-Strategie. Diese Werkzeuge können die Grenze zwischen Build und Runtime sauber ausdrücken, aber sie ziehen sie nicht für dich. Wer den Default eines Frameworks übernimmt, hat keine Entscheidung getroffen, sondern eine ausgelassen.\n\nDer zweite Fehler ist, sich am Gutfall zu orientieren. Im Demo-Modus sieht jede Variante schnell aus – ein Datensatz, ein Nutzer, keine Last. Interessant wird es bei zehntausend Seiten, bei Traffic-Spitzen, bei einer langsamen Upstream-API, bei einem Redakteur, der eine Korrektur sofort sehen will. Genau dort zeigt sich, ob die Grenze zwischen Build und Runtime an der richtigen Stelle liegt.\n\n## Validierung\n\nDrei Checks reichen mir meistens. Erstens: Für jede Route ist benannt, ob sie zur Build- oder zur Laufzeit entsteht – und warum, ausgedrückt als Datenfrist. Zweitens: Für jede Runtime-Route ist der Fehlerpfad beschrieben, inklusive des Verhaltens bei ausgefallener Datenquelle. Drittens: Die Entscheidung ist pro Route umstellbar, ohne Nachbarrouten anzufassen. Wenn diese drei Punkte stehen, ist die Architektur robust gegen genau die Überraschungen, die solche Projekte sonst spät und teuer einholen.\n\n## Weiterführende Quellen\n\n- React: Thinking in React: https://react.dev/learn/thinking-in-react\n- React: Managing State: https://react.dev/learn/managing-state\n\nDie Quellen helfen beim Gegenprüfen von Standards und Plattformverhalten. Die eigentliche Aussage bleibt die Architekturentscheidung im konkreten Projekt.\n\n## Schluss\n\nBuild vs. Runtime ist am Ende eine Frage nach der Datenfrist jeder einzelnen Seite. Ein immutable Beitrag kann als Datei ins CDN, ein Kontostand braucht aktuelle Daten, und viele Routen liegen mit Revalidierung dazwischen. Wenn diese Entscheidung pro Route verschiebbar bleibt, muss ein neuer Frischebedarf nicht das Rendering der gesamten Anwendung umwerfen."},{"slug":"nodejs-streams-im-objektmodus","title":"Streams im Objektmodus: Datenpipelines jenseits von Bytes","summary":"Node.js-Streams transportieren nicht nur Bytes: Mit objectMode:true werden sie zu komponierbaren Datenpipelines aus parsen, filtern und aggregieren – inklusive der Brücke zu Async-Iteratoren.","tags":["Software","Node.js","Backend"],"published_at":"2022-11-08T10:00:00+00:00","updated_at":"2026-07-13T07:59:30.815444+00:00","url":"https://www.mikebild.dev/de/blog/nodejs-streams-im-objektmodus/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/0f8df286685c4924529ab1db8a021aa0c9421e3b7ec391b507d3df9124612b21/nodejs-streams-im-objektmodus-vorlesen.mp3","audio_duration_secs":766,"text":"Wer einen Stream auf `objectMode: true` umstellt und dabei sein gewohntes `highWaterMark: 16384` mitnimmt, puffert keine 16 KiB mehr, sondern 16384 Datensätze. Gleiche Zahl, andere Einheit, ein Speicherbedarf um Größenordnungen daneben. Der Objektmodus sieht aus wie eine beiläufige Konstruktoroption, aber er ändert, was ein Stream überhaupt durch die Pipeline schiebt – und damit auch, wann gedrosselt wird.\n\nIm [früheren Beitrag zu Node.js-Streams und Backpressure](https://www.mikebild.dev/de/blog/nodejs-streams-und-backpressure/) drehte sich alles um Bytes: Buffer, die durch eine Pipe fließen, und die Kunst, den Fluss zu drosseln, bevor der Speicher überläuft. Das ist der Standardfall für Datei- und Netzwerk-I/O. Sobald aber keine Dateien kopiert, sondern Datensätze verarbeitet werden – CSV-Zeilen, JSON-Objekte, Log-Einträge –, reicht dieses Bild nicht mehr weit genug.\n\nArbeitet ein Stream im Objektmodus, transportiert er keine Buffer und keine Strings mehr, sondern beliebige JavaScript-Werte – ganze Objekte, Arrays, Zahlen. Damit wird aus dem Werkzeug für rohe Byteströme ein Werkzeug für Datenverarbeitung. Und das Schöne daran: Die Mechanik bleibt dieselbe. Man verkettet Stufen, jede Stufe macht eine Sache, und am Ende steht eine Pipeline, die einen Datensatz nach dem anderen durchreicht, ohne alles auf einmal in den Speicher zu laden.\n\n## Was der Objektmodus wirklich ändert\n\nEin Stream im Standardmodus kennt genau zwei Datentypen: `Buffer` und `String`. Alles, was man hineinschiebt, muss eines von beiden sein. Der Objektmodus hebt diese Einschränkung auf. Ein `new stream.Transform({ objectMode: true })` akzeptiert und liefert beliebige JS-Werte. Die einzige Ausnahme ist `null` – dieser Wert ist reserviert und signalisiert das Ende des Streams. Dazu später mehr, denn genau hier lauert ein Fallstrick.\n\nDer Objektmodus wird pro Stream über die Konstruktoroption gesetzt, und der Standardwert ist `false`. Man aktiviert ihn also bewusst. In den Beispielen aus meinem [Node.js-Repository](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/) taucht das Muster bereits durchgehend auf – dort allerdings noch in der ES5-Schreibweise mit `util.inherits` und `Readable.call(this, { objectMode: true })`. Ein `Readable`, der zwei kleine Objekte durchreicht, sieht dort so aus:\n\n```javascript\nconst { Readable } = require('stream');\nconst util = require('util');\n\n// ES5 pattern from the training repo\nconst ReadStream = function () {\n  Readable.call(this, { objectMode: true });\n  this.data = [\n    { id: 0, value: 1 },\n    { id: 1, value: 2 },\n  ];\n  this.curIndex = 0;\n};\nutil.inherits(ReadStream, Readable);\n\nReadStream.prototype._read = function () {\n  if (this.curIndex === this.data.length) {\n    return this.push(null); // signals end of stream\n  }\n  this.push(this.data[this.curIndex++]);\n};\n```\n\nDas funktioniert und ist historisch gewachsen. Ich zeige es trotzdem lieber in der modernen Klassensyntax, weil sie näher an dem liegt, was man heute in Codebasen antrifft. Mit `class ... extends Transform` wird aus dem Konstruktor-Ritual ein `super({ objectMode: true })`, und die Verarbeitungslogik steht in einer klar benannten Methode.\n\n## Die eine Kernmethode: `_transform`\n\nEin Transform-Stream hat im Kern eine einzige Methode, die man selbst schreibt: `_transform(chunk, encoding, callback)`. Für jeden eintreffenden Wert wird sie einmal aufgerufen. Man gibt Ergebnisse mit `this.push(value)` aus und meldet mit `callback()`, dass dieser Wert fertig verarbeitet ist. Im Objektmodus ist `chunk` der rohe JS-Wert – das Argument `encoding` wird ignoriert, weil es hier nichts zu dekodieren gibt.\n\nDer entscheidende Punkt: `push` und `callback` sind zwei getrennte Dinge. `push` schiebt Ausgaben in die nächste Stufe, `callback` signalisiert „ich bin mit diesem Eingabewert durch“. Man kann pro Eingabe null-, ein- oder mehrmals `push`en und ruft am Ende genau einmal `callback` auf. Ruft man nur `callback()` ohne `push`, verschwindet der Wert – das ist die natürliche Art, in einem Filter etwas wegzulassen.\n\nHier eine Verarbeitungsstufe, die rohe CSV-Zeilen zu Objekten parst:\n\n```javascript\nconst { Transform } = require('stream');\n\nclass ParseRow extends Transform {\n  constructor() {\n    super({ objectMode: true });\n  }\n\n  _transform(line, _encoding, callback) {\n    const [id, name, status] = String(line).split(',');\n    this.push({ id: Number(id), name, status });\n    callback();\n  }\n}\n```\n\nUnd eine Filterstufe, die nur aktive Datensätze durchlässt. Hier zeigt sich das Weglassen: Ist die Bedingung nicht erfüllt, wird nichts gepusht, sondern nur `callback()` aufgerufen.\n\n```javascript\nconst { Transform } = require('stream');\n\nclass FilterActive extends Transform {\n  constructor() {\n    super({ objectMode: true });\n  }\n\n  _transform(record, _encoding, callback) {\n    if (record.status === 'active') {\n      this.push(record);\n    }\n    // no push means the record is dropped\n    callback();\n  }\n}\n```\n\nEine Formatierungsstufe rundet das Bild ab. Sie nimmt das Objekt und formt eine Ausgabezeile daraus:\n\n```javascript\nconst { Transform } = require('stream');\n\nclass FormatOutput extends Transform {\n  constructor() {\n    super({ objectMode: true });\n  }\n\n  _transform(record, _encoding, callback) {\n    this.push(`#${record.id} ${record.name}\\n`);\n    callback();\n  }\n}\n```\n\nDrei kleine Stufen, jede mit einer einzigen Verantwortung. Genau so entsteht Komponierbarkeit.\n\n## Stufen verketten: parsen, filtern, formatieren\n\nDie eigentliche Pipeline ist dann fast unspektakulär – und das ist ein gutes Zeichen. Ich verkette die Stufen mit `pipeline()` aus dem `stream`-Modul, nicht mit einer Kette aus `pipe()`. Der Grund: `pipeline()` propagiert Fehler und räumt die beteiligten Streams auf, wenn einer scheitert. Eine reine `pipe()`-Kette tut das nicht – dort muss man Fehler und Cleanup an jeder Naht selbst behandeln, und genau das wird in der Praxis vergessen.\n\n```javascript\nconst { Readable, pipeline } = require('stream');\n\nconst csvLines = ['0,Ada,active', '1,Linus,inactive', '2,Grace,active'];\n\npipeline(\n  Readable.from(csvLines),\n  new ParseRow(),\n  new FilterActive(),\n  new FormatOutput(),\n  process.stdout,\n  (err) => {\n    if (err) {\n      console.error('pipeline failed:', err);\n    } else {\n      console.log('pipeline finished');\n    }\n  }\n);\n```\n\nDie Quelle ist hier `Readable.from(csvLines)` – dazu gleich mehr. Die Senke ist `process.stdout`, ein ganz normaler Writable-Stream. Dazwischen fließt ein Datensatz nach dem anderen: erst als String, nach `ParseRow` als Objekt, durch `FilterActive` gefiltert, in `FormatOutput` wieder zu einer Zeile geformt. Zu keinem Zeitpunkt liegt die gesamte Datenmenge im Speicher – es ist immer nur ein kleiner Vorrat gepufferter Werte unterwegs.\n\nAls Diagramm sieht dieser Datenfluss so aus:\n\n```mermaid\nflowchart LR\n  A[\"Source<br/>Readable.from\"] -->|\"CSV line (string)\"| B[\"Parse<br/>_transform\"]\n  B -->|\"JS object\"| C[\"Filter<br/>_transform\"]\n  C -->|\"JS object\"| D[\"Format<br/>_transform\"]\n  D -->|\"output line\"| E[\"Sink<br/>Writable\"]\n```\n\nAn den Kanten steht, was tatsächlich fließt: erst eine Zeile als String, dann JS-Objekte durch die mittleren Stufen, am Ende wieder Text. Jede Stufe kümmert sich nur um ihre eigene Umwandlung und weiß nichts von den Nachbarn.\n\n## Aggregieren mit `_flush`\n\nFiltern und Umformen arbeiten Datensatz für Datensatz. Für Aggregationen – Summen, Zählungen, Gruppierungen – braucht man einen anderen Rhythmus: sammeln, während die Werte eintreffen, und das Ergebnis erst ausgeben, wenn die Quelle erschöpft ist. Dafür gibt es `_flush(callback)`. Diese Methode wird genau einmal aufgerufen, kurz bevor der Stream endet. In `_transform` sammelt man, in `_flush` gibt man aus.\n\n```javascript\nconst { Transform } = require('stream');\n\nclass CountByStatus extends Transform {\n  constructor() {\n    super({ objectMode: true });\n    this.counts = {};\n  }\n\n  _transform(record, _encoding, callback) {\n    this.counts[record.status] = (this.counts[record.status] || 0) + 1;\n    callback(); // nothing pushed yet, just accumulate\n  }\n\n  _flush(callback) {\n    this.push(this.counts); // emit the aggregate once at the end\n    callback();\n  }\n}\n```\n\nDiese Stufe schluckt viele Objekte und gibt am Ende ein einziges aus – das Aggregat. Sie zeigt gut, dass eine Stream-Stufe ihre Ein- und Ausgabemenge nicht eins zu eins halten muss.\n\n## Backpressure zählt jetzt Objekte\n\n`highWaterMark` bedeutet im Objektmodus etwas anderes als im Bytemodus – das wird regelmäßig überlesen, mit spürbaren Folgen. `highWaterMark` ist die Schwelle, ab der ein Stream als „voll genug“ gilt und Backpressure auslöst. Im Bytemodus wird diese Schwelle in Bytes gemessen; 2022 waren allgemein 16 KiB der Default, seit Node 22 sind es 64 KiB. Im Objektmodus wird sie in Objekten gemessen, weiterhin mit einem Standardwert von 16 Objekten.\n\nDas ist keine Kleinigkeit. Wer aus dem Bytemodus kommt und `highWaterMark: 16` liest, denkt reflexartig an 16 Bytes und hält das für absurd klein. Im Objektmodus sind es aber 16 gepufferte Objekte – ein völlig sinnvoller Vorrat. Umgekehrt kann ein aus Byte-Gewohnheit gesetztes `highWaterMark: 16384` im Objektmodus bedeuten, dass man 16384 Objekte puffert, bevor Backpressure greift. Das ist selten gewollt.\n\nWichtig ist auch: `highWaterMark` ist eine Schwelle, kein harter Riegel. Der Puffer darf sie überschreiten – die Schwelle ist das Signal, ab dem `push()` den Wert `false` zurückgibt und nachgelagerte Stufen zum Drosseln auffordert. Bei `pipeline()` und `pipe()` geschieht dieses Pausieren und Fortsetzen automatisch. Man verlässt sich also im Normalfall auf dieses automatische Zusammenspiel und muss den Rückgabewert von `push` nur dann selbst auswerten, wenn man eine Quelle von Hand steuert. Backpressure funktioniert im Objektmodus genauso zuverlässig wie im Bytemodus, nur ist die Einheit eine andere.\n\nZur Abgrenzung die beiden Modi nebeneinander:\n\n- Der Bytemodus (Standard) transportiert `Buffer` oder `String`; `highWaterMark` zählt hier Bytes. Der allgemeine Default hängt von der Node-Version ab und liegt seit Node 22 bei 64 KiB.\n- Der Objektmodus (`objectMode: true`) transportiert beliebige JS-Werte außer `null`, `highWaterMark` zählt hier Objekte mit Standardwert 16, und die typischen Einsätze sind Datenpipelines aus parsen, filtern, transformieren und aggregieren.\n\n## Die Brücke zu Async-Iteratoren\n\nBis hierhin habe ich Streams als Klassen gebaut. Das ist nicht immer nötig, und oft ist es sogar umständlich. Node bietet zwei moderne Verbindungsstücke, die den Objektmodus mit der Welt der Async-Iteratoren zusammenbringen: `Readable.from(iterable)` und `for await ... of`.\n\n`stream.Readable.from(iterable)` nimmt ein beliebiges Iterable oder Async-Iterable – ein Array, einen Generator, einen Async-Generator – und macht daraus einen Readable-Stream. Standardmäßig arbeitet dieser Stream im Objektmodus. Das ist die eleganteste Art, eine Datenquelle in eine Pipeline einzuspeisen, ohne eine eigene `Readable`-Klasse zu schreiben. Genau das habe ich oben mit `Readable.from(csvLines)` bereits genutzt.\n\nDie Gegenrichtung ist mindestens so praktisch: Ein Readable-Stream ist selbst ein Async-Iterable. Man kann ihn also direkt mit `for await ... of` konsumieren, und das Sprachkonstrukt kümmert sich um Pausieren, Fortsetzen, Backpressure und das Ende – alles automatisch.\n\n```javascript\nconst { Readable } = require('stream');\n\nasync function* generateRecords() {\n  yield { id: 0, value: 1 };\n  yield { id: 1, value: 2 };\n  yield { id: 2, value: 3 };\n}\n\nasync function main() {\n  const source = Readable.from(generateRecords());\n\n  for await (const record of source) {\n    console.log('received:', record);\n  }\n}\n\nmain();\n```\n\nMan kann Async-Generatoren sogar als Pipeline-Stufe einsetzen. Ein Generator, der eine Quelle konsumiert und gefilterte Werte weiterreicht, ersetzt eine ganze Transform-Klasse durch wenige Zeilen:\n\n```javascript\nasync function* filterEven(source) {\n  for await (const n of source) {\n    if (n % 2 === 0) {\n      yield n;\n    }\n  }\n}\n```\n\nOb man eine Transform-Klasse schreibt oder einen Async-Generator, ist zu einem guten Teil Geschmackssache. Ich greife zur Klasse, wenn ich internen Zustand über viele Werte halten muss, etwa bei einer Aggregation mit `_flush`. Für reine Filter und Umformungen sind Async-Generatoren oft der kürzere, besser lesbare Weg.\n\n## Zwei Fallstricke, die immer wieder zuschlagen\n\nDer erste Fallstrick ist das Mischen von Byte- und Objektmodus in einer Pipeline. Man beginnt mit `fs.createReadStream(...)`, das ist ein Byte-Stream und liefert Buffer. Direkt danach hängt man eine Objektmodus-Stufe, die mit `record.status` arbeiten will – und bekommt einen Buffer statt eines Objekts. Zwischen einem Byte-Readable und der ersten Objektstufe muss immer eine Parse-Schicht liegen, die Buffer zu Werten dekodiert. Die umgekehrte Naht ist genauso heikel: Schiebt man ein Objekt in einen Nicht-Objektmodus-Writable, quittiert Node das mit einem Typfehler (`ERR_INVALID_ARG_TYPE`), der einen String, Buffer oder ein Uint8Array verlangt. Man sollte sich an jeder Nahtstelle bewusst machen, was für ein Datentyp gerade fließt.\n\nDer zweite betrifft `null` als legitimen Nutzwert. Weil `push(null)` das Ende des Streams bedeutet, kann man einen echten `null`-Wert nicht durch einen Objektmodus-Stream schicken – er würde als Schlusssignal missverstanden und die restlichen Daten abschneiden. Wenn `null` in den Nutzdaten eine echte Bedeutung hat, braucht es ein Ersatzobjekt, einen sogenannten Sentinel, etwa `{ value: null }` statt eines nackten `null`.\n\nEin dritter Punkt gehört zur Sorgfalt: Fehler in `_transform` gibt man an `callback(err)` weiter, statt sie zu werfen. Nur so landen sie im Fehlerkanal des Streams und werden von `pipeline()` sauber propagiert. Ein geworfener Fehler mitten in der asynchronen Verarbeitung ist deutlich schwerer einzufangen – auch das ist ein Argument für `pipeline()` gegenüber einer nackten `pipe()`-Kette.\n\n## Fazit\n\nDer Objektmodus verwandelt Node.js-Streams von einem I/O-Werkzeug für Bytes in ein Werkzeug für Datenverarbeitung. Die Mechanik bleibt vertraut – `_transform` mit `push` und `callback`, `_flush` fürs Aggregieren, Verkettung über `pipeline()` –, aber der Inhalt sind jetzt ganze JS-Werte. Damit lassen sich Pipelines aus parsen, filtern, transformieren und aggregieren als getrennte, komponierbare Stufen bauen, die immer nur einen kleinen Vorrat an Datensätzen im Speicher halten.\n\nZwei Dinge nehme ich als Kernbotschaft mit. Erstens: `highWaterMark` zählt im Objektmodus Objekte, nicht Bytes – wer das verwechselt, dimensioniert seinen Puffer um Größenordnungen falsch. Zweitens: `Readable.from` und `for await ... of` sind die moderne Brücke zwischen Streams und Async-Iteratoren und ersparen einem in vielen Fällen die eigene Stream-Klasse. Wer den byteorientierten Teil aus dem Beitrag zu [Buffers und Binärdaten](https://www.mikebild.dev/de/blog/nodejs-buffers-und-binaerdaten/) verstanden hat und den Objektmodus dazunimmt, hat beide Hälften der Stream-Welt beisammen – die rohe und die strukturierte.\n\n## Weiterführende Quellen\n\n- [Node.js Stream API – Object mode](https://nodejs.org/api/stream.html#object-mode)\n- [Node.js Stream API – Buffering](https://nodejs.org/api/stream.html#buffering)\n- [Node.js Stream API – stream.Readable.from](https://nodejs.org/api/stream.html#streamreadablefromiterable-options)\n- [Node.js Stream API – readable[Symbol.asyncIterator]](https://nodejs.org/api/stream.html#readablesymbolasynciterator)\n- [Node.js Stream API – Class: stream.Transform](https://nodejs.org/api/stream.html#class-streamtransform)"},{"slug":"reproduzierbare-testpipelines-kein-flaky","title":"Reproduzierbare Testpipelines: kein Flaky, kein Zufall","summary":"Flakiness weist auf unkontrollierte Eingaben oder Abhängigkeiten hin. Wer Zeit, Daten und Isolation sichtbar macht, kann die Ursache eingrenzen und das Merge-Gate wieder belastbar machen.","tags":["Software","Delivery"],"published_at":"2022-10-26T10:00:00+00:00","updated_at":"2026-07-13T13:23:46.335954+00:00","url":"https://www.mikebild.dev/de/blog/reproduzierbare-testpipelines-kein-flaky/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/2714517852c87864d04f2c7f2ab983776d9a61f1e064a8a1c43dfdf1276d1b66/reproduzierbare-testpipelines-kein-flaky-vorlesen.mp3","audio_duration_secs":672,"text":"```text\n$ git rev-parse HEAD\n9f2c1ab\n\nLauf 412   FAIL  checkout.spec.ts › Rabatt gilt bis Monatsende   (2.1s)\nLauf 413   PASS  checkout.spec.ts › Rabatt gilt bis Monatsende   (2.0s)\n```\n\nDerselbe Commit, zwei Läufe, zwei Ergebnisse. Wenn sich am Code nichts geändert hat und das Ergebnis trotzdem kippt, dann liest der Test eine weitere Eingabe, die nicht im Repository steht. „Lass den Build noch einmal laufen, der ist manchmal einfach rot“ ist deshalb keine Diagnose. Es ist das Eingeständnis, dass niemand weiß, welche Eingabe das ist.\n\nEin Test, der ohne Codeänderung sein Ergebnis wechselt, reagiert auf eine weitere Eingabe. Häufig steckt sie in Zeit, Daten oder fehlender Isolation. Bei Integrationstests können auch Netz, Cloud-Dienste oder andere externe Systeme real schwanken. Nicht jede Abhängigkeit lässt sich einfrieren; sichtbar machen, begrenzen und im Testtyp benennen muss ich sie trotzdem. Sonst gewöhnt sich das Team daran, dass die eigene Testsuite mal grün und mal rot meldet, und verliert das Vertrauen in sein Merge-Gate.\n\n## Warum das ein Business-Problem ist, kein Test-Detail\n\nBevor es zur Mechanik geht, der Grund, warum das Thema nicht im Testverzeichnis endet: Eine flaky Suite bleibt kein Ärgernis der Entwickler. Sie bremst die gesamte Lieferfähigkeit.\n\nMartin Fowler hat das schon 2011 auf den Punkt gebracht. Ein nicht-deterministischer Test ist nicht nur nutzlos – er ist eine „virulente Infektion“. Sobald ein einziger Test gelegentlich grundlos rot wird, gewöhnen sich alle daran, rote Builds zu ignorieren. Genau in diesem Moment schützt die Suite nichts mehr, denn ein echter Fehler sieht jetzt aus wie das übliche Rauschen. Der Schaden ist nicht der eine flaky Test, sondern der Vertrauensverlust, der sich auf die ganze Suite ausbreitet.\n\nDas ist kein akademisches Argument. Google hat 2016 im Testing Blog Zahlen genannt: rund 1,5 Prozent aller Testläufe sind flaky. Das klingt nach wenig, bis man es hochrechnet. Flaky Tests verstopfen die Pipeline, sperren Branches, erzeugen Duplikate von Bug-Tickets und verbrennen Entwicklerzeit im großen Stil – Zeit, die in Diagnose von Fehlern fließt, die gar keine sind.\n\nUnd genau hier schließt sich der Kreis zur Delivery-Performance. Accelerate (Forsgren, Humble, Kim, 2018) verbindet die vier bekannten Metrics – Deployment Frequency, Lead Time for Changes, Change Failure Rate und Time to Restore – mit gelebter Continuous Integration und *zuverlässiger* Testautomatisierung. Wichtig ist das Wort \"zuverlässig\". Ein Elite-Team, das on-demand deployt, kann das nur, weil die Suite ein vertrauenswürdiges Gate ist. Eine flaky Suite ist kein Gate, sondern eine Ampel, die zufällig schaltet – und niemand deployt auf Basis einer zufälligen Ampel. Ich lese die vier Metrics übrigens bewusst immer gemeinsam: eine hohe Deploy-Frequenz bei gleichzeitig hoher Change Failure Rate ist wertlos. Determinismus in der Suite ist eine der Bedingungen, unter denen diese Metrics überhaupt ehrlich werden.\n\nDer Gewinn liegt also nicht in \"schöneren Tests\". Er liegt in verkürzten Zyklen, weniger Reibung im Team und einer höheren Auslieferungsqualität. Determinismus gehört deshalb in die Architekturdiskussion und nicht auf die QA-Checkliste.\n\n## Die drei Achsen als Gate\n\nDie Diskussion lässt sich auf ein einfaches Bild reduzieren. Eine Pipeline läuft von links nach rechts: commit, build, test, merge. Über der Test-Stufe stehen drei Regler. Nur wenn alle drei auf \"kontrolliert\" stehen, wird das Gate grün und der Merge frei. Kippt einer in \"unkontrolliert\", entsteht flaky Rot.\n\n```mermaid\nflowchart LR\n  C[commit] --> B[build] --> T[test gate] --> M[merge]\n  A1[TIME<br/>frozen clock] --> T\n  A2[DATA<br/>seeded fixtures] --> T\n  A3[ISOLATION<br/>hermetic sandbox] --> T\n  T -->|all three controlled| M\n  T -->|any axis uncontrolled| F[flaky red]\n```\n\nDer Wert dieses Bildes liegt in der Diagnose. Wenn ein Build flaky ist, frage ich nicht \"welcher Test spinnt\", sondern \"welche der drei Achsen ist unkontrolliert\". Das führt fast immer schneller zur Ursache, weil es die Suche strukturiert. Gehen wir die Achsen einzeln durch.\n\n## Achse 1: Zeit\n\nDie häufigste versteckte Ursache. Sobald Domänencode direkt `new Date()` oder `Instant.now()` aufruft, ist er an die Wall-Clock gekoppelt – und die Wall-Clock ändert sich zwischen zwei Testläufen garantiert. Ein Test, der \"der Rabatt gilt bis Monatsende\" prüft, wird am 31. anders reagieren als am 1., ein Test mit `expiresAt` in genau 24 Stunden kippt an Zeitzonen- und DST-Grenzen.\n\nDie Lösung ist keine Bibliothek, sondern eine Entwurfsentscheidung: Die Uhr wird ein injizierter Dienst. Der Domänencode fragt nie das Betriebssystem, sondern immer die Abstraktion.\n\n```typescript\ninterface Clock {\n  now(): Date;\n}\n\nclass SystemClock implements Clock {\n  now(): Date {\n    return new Date();\n  }\n}\n\nclass FixedClock implements Clock {\n  constructor(private readonly instant: Date) {}\n  now(): Date {\n    return this.instant;\n  }\n}\n\n// domain code never calls `new Date()` directly:\nfunction isExpired(subscription: Subscription, clock: Clock): boolean {\n  return subscription.expiresAt.getTime() <= clock.now().getTime();\n}\n```\n\nIn Produktion läuft die `SystemClock`, im Test ein `FixedClock(new Date(\"2022-01-01T00:00:00Z\"))`. Das ist keine exotische Idee: Java hat mit `java.time.Clock` seit Version 8 genau diese Abstraktion eingebaut, in Ruby macht Timecop das seit 2011, in JavaScript liefern Sinons Fake Timers dasselbe. Ich bevorzuge die injizierte Uhr gegenüber globalem Time-Freezing, weil sie ehrlicher ist: Sie macht die Zeitabhängigkeit im Code sichtbar, statt sie global zu überschreiben.\n\nDamit es nicht zu einfach klingt: Zeit-Determinismus ist mehr als die Uhr einzufrieren. Zeitzonen, DST-Übergänge, Locale-abhängige Formatierung und die Sortierreihenfolge von Maps oder Sets sind eigene Nicht-Determinismus-Quellen. Die eingefrorene Uhr löst den großen Brocken, nicht alles.\n\n## Achse 2: Daten\n\nDie zweite Achse ist unscheinbar und richtet stillen Schaden an: Zufallsdaten. Ein `faker.name()` oder eine ungeseedete Zufallszahl macht jeden Testlauf einzigartig. Meistens geht das gut – bis der eine Datensatz auftaucht, der die Sortierung, eine Längenbegrenzung oder einen Unicode-Sonderfall kippt. Dann ist der Build rot, beim nächsten Lauf grün, und niemand kann es reproduzieren.\n\nDie Regel ist simpel: deterministische Daten. Entweder feste Fixtures oder ein geseedeter Zufallsgenerator. Der geseedete Weg ist oft der bessere, weil er Streuung behält und trotzdem reproduzierbar bleibt.\n\n```typescript\nimport seedrandom from \"seedrandom\";\n\nfunction makeRng(seed = \"1337\") {\n  return seedrandom(seed);\n}\n\n// same seed -> same sequence -> reproducible failures\nfunction buildUser(rng: () => number): User {\n  return {\n    id: Math.floor(rng() * 1_000_000),\n    tier: rng() > 0.5 ? \"premium\" : \"free\",\n  };\n}\n```\n\nInteressant ist, dass genau diese Regel im Contract Testing explizit festgeschrieben ist. Pact – seit 2013 im Einsatz für consumer-driven contracts – formuliert die Vorgabe \"use deterministic data, avoid random data\" ohne Wenn und Aber. Der Grund ist aufschlussreich: Wenn ein Kontrakt zufällige Daten enthält, wird er bei jedem Lauf als \"verändert\" markiert, obwohl sich fachlich nichts geändert hat. Nicht-deterministische Daten erzeugen also nicht nur flaky Tests, sondern flaky Verträge.\n\nContract Testing verdient hier eine kurze Würdigung, weil es die Datenachse mit der Isolationsachse verbindet. Statt dass ein Service den anderen startet, um \"echt\" zu testen, prüft jedes Consumer-Provider-Paar in Isolation gegen einen geteilten, deterministischen Kontrakt. Kein Netz, keine fremde Laufzeit, kein Timing – die klassischen Flaky-Quellen sind per Konstruktion ausgeschlossen.\n\n## Achse 3: Isolation\n\nDie dritte Achse ist die grundlegendste und trägt den treffenden Namen Hermetik. Die Bazel-Dokumentation verwendet „hermeticity“ für Builds und Tests, deren Werkzeuge und Eingaben bekannt und kontrolliert sind. Diese Abschottung von Netz, geteilter Datenbank und Host-Zustand entfernt wichtige versteckte Einflüsse; sie garantiert allein noch keinen Determinismus, wenn etwa Rennen, Zufall oder Zeit im Test unkontrolliert bleiben. Ein nicht hermetischer Test kann reproduzierbar sein, ist aber stärker von seiner Umgebung abhängig.\n\nDie zwei praktischen Feinde der Isolation sind geteilter Zustand und Test-Reihenfolge-Abhängigkeit. Wenn Test B nur grün ist, weil Test A vorher eine Zeile in die Datenbank geschrieben hat, dann ist die Suite eine Kette, die bei jeder Umsortierung reißt. Das Gegenmittel heißt shared-nothing: Jeder Test baut seinen Zustand auf und räumt ihn wieder ab.\n\nFür echte Abhängigkeiten wie Datenbank oder Message-Broker haben sich seit 2015 Testcontainers durchgesetzt: eine ephemere, weggeworfene Instanz pro Test statt einer geteilten, langlebigen Umgebung. Das folgende Skelett zeigt die Isolationsdisziplin kompakt – und es kombiniert bewusst alle drei Achsen:\n\n```typescript\nlet db: EphemeralDatabase;\nlet clock: Clock;\nlet rng: () => number;\n\nbeforeEach(async () => {\n  rng = makeRng(\"1337\");                       // DATA: seeded\n  clock = new FixedClock(FIXED_INSTANT);       // TIME: frozen\n  db = await startEphemeralContainer();         // ISOLATION: hermetic\n  await db.beginTransaction();\n});\n\nafterEach(async () => {\n  await db.rollbackTransaction();               // state reset, shared-nothing\n  await db.stop();\n});\n```\n\nDer Transaktions-Rollback pro Test ist mein bevorzugter Reset-Mechanismus, wo er geht: schnell und vollständig. Wo Transaktionen nicht reichen, tut es Truncation zwischen den Tests. Entscheidend ist nur das Prinzip: Kein Test darf davon abhängen, was ein anderer hinterlassen hat.\n\nFowlers Ursachenliste fällt übrigens exakt auf diese drei Achsen zurück. Er nennt lack of isolation, time, asynchrony beziehungsweise remote services und resource leaks. Als Fishbone gezeichnet:\n\n```mermaid\nflowchart LR\n  ISO[lack of isolation] --> S[flaky build]\n  TIME[time / clock] --> S\n  ASYNC[async / remote services] --> S\n  LEAK[resource leaks] --> S\n```\n\nDie Asynchronität verdient einen eigenen Satz, weil sie der klassische Flaky-Generator ist. Der häufigste Fehler ist ein `sleep(500)`, in der Hoffnung, dass eine asynchrone Operation \"wohl fertig\" ist. Auf einem langsamen CI-Runner ist sie es manchmal nicht. Der Fix ist nie eine längere Wartezeit, sondern Polling gegen eine echte Bedingung – warten, bis der Zustand tatsächlich eingetreten ist, mit einem Timeout als Obergrenze. \"Warte, bis\" statt \"warte, wie lange\".\n\n## Der ehrliche Umgang mit Retries\n\nBleibt das Thema, um das in solchen Diskussionen alle herumreden: der automatische Retry. Fast jedes CI-System bietet an, fehlgeschlagene Tests einfach nochmal laufen zu lassen, und wenn der zweite Lauf grün ist, gilt der Build als grün. Das ist verführerisch, weil es die roten Builds sofort verschwinden lässt.\n\nDie Einordnung ist eindeutig: Retries verstecken Nicht-Determinismus, sie beseitigen ihn nicht. Als kurzfristiges Notpflaster, um ein Team nicht komplett auszubremsen, kann ein begrenzter Retry vertretbar sein. Als Dauerlösung schaltet er genau das Signal aus, das man zur Ursachenanalyse braucht. Eine zeitlich begrenzte Quarantäne kann ehrlicher sein: Der Test läuft in einem separaten Job weiter, bekommt einen benannten Verantwortlichen und eine Frist. Der Haupt-Build wird davon nicht rot; die fehlende Abdeckung im Merge-Gate bleibt bis zur Reparatur jedoch ein bewusst eingegangenes Risiko.\n\n```yaml\n# conceptual pipeline stages\nstages:\n  - name: test\n    fail_on_red: true          # the trustworthy merge gate\n  - name: contract-verify\n    run: pact can-i-deploy --to prod\n  - name: quarantine\n    tests: tagged(\"flaky\")\n    fail_on_red: false         # does not block merge\n    on_red: open_bug_ticket    # but never silently ignored\n```\n\nDer Unterschied zwischen Retry und Quarantäne ist eine Haltungsfrage. Retry sagt: „Ich hoffe, es war Zufall.“ Quarantäne sagt: „Ich kenne die Lücke, ihren Besitzer und ihre Frist.“ Ohne diese drei Angaben wird auch die Quarantäne nur zum dauerhaften Ignorieren.\n\n## Fazit\n\nDer rote Faden ist einfacher, als die Menge der Bibliotheken vermuten lässt. Flakiness hat eine Ursache, aber nicht jede Ursache liegt vollständig in der Testfunktion. Zeit einfrieren, Daten festlegen und Isolation herstellen räumt die häufigsten hausgemachten Quellen aus. Externe Abhängigkeiten werden simuliert, gezielt beobachtet oder in einen eigenen Integrationstest verschoben, dessen Schwankungsbreite bekannt ist.\n\nUnd der Ertrag ist eben nicht \"grünere Dashboards\". Der Ertrag ist ein Merge-Gate, dessen Aussage das Team einschätzen kann. Wer flaky Tests als \"Pech\" abtut, gibt seine Qualitätskontrolle an den Zufall ab. Wer die verborgene Eingabe sucht und verbleibende Unsicherheit offen ausweist, kann auf einen roten Build wieder sinnvoll reagieren.\n\n## Weiterführende Quellen\n\n- Martin Fowler: \"Eradicating Non-Determinism in Tests\" (2011) – https://martinfowler.com/articles/nonDeterminism.html\n- Google Testing Blog: \"Flaky Tests at Google and How We Mitigate Them\" (2016) – https://testing.googleblog.com/2016/05/flaky-tests-at-google-and-how-we.html\n- Google Testing Blog: \"Where do our flaky tests come from?\" (2017) – https://testing.googleblog.com/2017/04/where-do-our-flaky-tests-come-from.html\n- Bazel: Hermeticity – https://bazel.build/basics/hermeticity und Test Encyclopedia – https://bazel.build/reference/test-encyclopedia\n- Pact: Consumer-driven contracts, deterministic data – https://docs.pact.io/\n- Forsgren, Humble, Kim: \"Accelerate\" (2018), DORA-Metrics – https://www.atlassian.com/devops/frameworks/dora-metrics\n- Ford, Parsons, Kua: \"Building Evolutionary Architectures\" (1. Auflage, 2017) – https://nealford.com/books/buildingevolutionaryarchitectures.html\n- Trunk-Based Development – https://trunkbaseddevelopment.com"},{"slug":"adapter-design-framework-grenzen","title":"Adapter-Design: wo Frameworks enden","summary":"Was ein Framework-Adapter wirklich leistet: der Vertrag über den Build-Output, die Übersetzung von Request und Response und die stillen Umgebungsannahmen an der Grenze zwischen SvelteKit und AWS.","tags":["Software","AWS","Cloud"],"published_at":"2022-10-12T10:00:00+00:00","updated_at":"2026-07-14T17:20:29.175417+00:00","url":"https://www.mikebild.dev/de/blog/adapter-design-framework-grenzen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/48b44a6738d412b8fa945afc676e4106aebe361bd7c552284962604ccdc680f9/adapter-design-framework-grenzen-vorlesen.mp3","audio_duration_secs":664,"text":"# Adapter-Design: wo Frameworks enden\n\nEin Adapter im SvelteKit-Sinn ist kein Entwurfsmuster aus dem Lehrbuch und auch kein Deployment-Werkzeug. Er ist ein Stück Code, das nach dem Build läuft, den Output des Frameworks entgegennimmt und daraus etwas erzeugt, das eine konkrete Plattform ausführen kann: ein Node-Server, eine Lambda-Function, ein Worker am Netzwerkrand. Er startet keine Prozesse, er bedient keine Requests, er provisioniert im Kern keine Infrastruktur. Er übersetzt.\n\nDie Definition klingt harmlos. Interessant ist, was sie impliziert: Ein Adapter ist genau die Stelle, an der ein Framework aufhört, Verantwortung zu übernehmen – und die Plattform anfängt. Alles vor dieser Stelle gehört dem Framework: Routing, Rendering, Datenladen, die Semantik einer Seite. Alles danach gehört der Plattform: Prozessmodell, Netzwerk, Dateisystem, Limits, Preisschild. Wer einen Adapter schreibt, zieht diese Grenze nicht selbst, aber er muss sie präzise nachzeichnen. Und dabei fällt auf, wie viele Entscheidungen auf beiden Seiten stillschweigend getroffen wurden.\n\nSeit Anfang des Jahres pflege ich einen solchen Adapter: `sveltekit-adapter-aws` bringt SvelteKit-Anwendungen per CDK auf Lambda, S3 und CloudFront. [Das Projekt selbst habe ich bereits vorgestellt](https://www.mikebild.dev/de/blog/sveltekit-adapter-aws/) – hier geht es um das, was ich beim Bauen über die Schnittstelle gelernt habe. Denn die eigentliche Erkenntnis steckt nicht im AWS-Teil, sondern in der Frage, die jeder Adapter beantworten muss: Was gehört auf welche Seite?\n\n## Der Vertrag über den Build-Output\n\nSvelteKit gibt einem Adapter erstaunlich wenig – und genau das ist gut. Die Schnittstelle besteht aus einem Objekt mit einem Namen und einer `adapt`-Funktion, die ein `builder`-Objekt bekommt. Der Builder ist der komplette Vertrag: Er liefert die Client-Assets, die prerenderten Seiten und den Server-Code, dazu ein Manifest mit den Routen-Metadaten. Mehr nicht.\n\n```js\nimport { writeFileSync } from 'node:fs';\n\nexport function adapter(options = {}) {\n  return {\n    name: 'sveltekit-adapter-aws',\n    async adapt(builder) {\n      const out = 'build';\n      builder.rimraf(out);\n\n      builder.writeClient(`${out}/assets`);        // static files for the CDN\n      builder.writePrerendered(`${out}/prerendered`);\n      builder.writeServer(`${out}/server`);        // the SSR part\n\n      const manifest = builder.generateManifest({ relativePath: './' });\n      writeFileSync(\n        `${out}/server/manifest.js`,\n        `export const manifest = ${manifest};`\n      );\n    }\n  };\n}\n```\n\nDas Framework sagt damit: Es gibt drei Sorten von Artefakten, und ich schreibe sie dir dorthin, wo du sie haben willst. Wohin sie am Ende gehören, wie sie ausgeliefert werden, was ein „Server\" auf deiner Plattform überhaupt ist – dein Problem. Für AWS heißt das konkret: Client-Assets und prerenderte Seiten wandern in einen S3-Bucket hinter CloudFront, der Server-Code wird mit esbuild zu einem Lambda-Bundle gebaut, und die Routing-Regeln der CDN-Verteilung müssen entscheiden, welcher Request an S3 geht und welcher an die Funktion.\n\n```mermaid\nflowchart LR\n  subgraph FW[Framework-Seite]\n    SRC[Quellcode] --> VITE[Vite-Build]\n    VITE --> CL[Client-Assets]\n    VITE --> PR[Prerenderte<br/>Seiten]\n    VITE --> SV[Server-Code +<br/>Manifest]\n  end\n  subgraph AD[Adapter]\n    CL --> MAP[Zuordnung,<br/>Bundling]\n    PR --> MAP\n    SV --> MAP\n  end\n  subgraph PF[Plattform-Seite]\n    MAP --> S3[S3-Bucket]\n    MAP --> LB[Lambda-Bundle]\n    S3 --> CF[CloudFront]\n    LB --> CF\n  end\n```\n\nDie Zuordnung im mittleren Kasten ist der Teil, den niemand sieht und in dem die meiste Arbeit steckt. Welche Pfade sind statisch, welche dynamisch? Was passiert mit einer prerenderten Seite, die auch eine Server-Route hat? Wer setzt die Cache-Header? Das Framework hat dazu eine Meinung, aber es erzwingt sie nicht – es kann sie gar nicht erzwingen, weil es die Plattform nicht kennt. Der Adapter muss die Meinung des Frameworks in die Mechanik der Plattform übersetzen, ohne dabei eine eigene, dritte Meinung zu erfinden.\n\n## Request und Response als gemeinsame Sprache\n\nZur Laufzeit ist der Vertrag noch schmaler. Der SvelteKit-Server spricht die Web-Standards: Er nimmt ein `Request`-Objekt aus der Fetch-API entgegen und gibt ein `Response`-Objekt zurück. Das ist eine bewusste und, wie ich finde, kluge Entscheidung – dieselben Typen, die im Browser seit Jahren existieren und die Deno Deploy und Cloudflare Workers ohnehin als natives Format verwenden. Die frisch gegründete WinterCG versucht gerade, genau diesen Kern serverseitiger Web-APIs über Runtimes hinweg zu vereinheitlichen. SvelteKit hat sich schon vorher darauf festgelegt.\n\nFür den Adapter bedeutet das: Seine Laufzeit-Aufgabe ist eine Hin- und eine Rückübersetzung. AWS spricht nicht Fetch, AWS spricht API-Gateway-Events. Der Handler in meinem Lambda-Bundle tut deshalb im Kern nur das hier:\n\n```js\nimport { Server } from './index.js';\nimport { manifest } from './manifest.js';\n\nconst server = new Server(manifest);\nawait server.init({ env: process.env });\n\nexport async function handler(event) {\n  const request = toRequest(event); // API Gateway event -> fetch Request\n  const response = await server.respond(request, {\n    getClientAddress: () => event.requestContext.http.sourceIp\n  });\n  return toLambdaResult(response); // fetch Response -> Lambda result\n}\n```\n\nDie beiden unscheinbaren Funktionen `toRequest` und `toLambdaResult` sind der Ort, an dem die Plattform ihre Eigenheiten zeigt. Ein API-Gateway-Event trägt Query-Parameter bereits zerlegt, der `Request` will eine vollständige URL. Binäre Antworten müssen Base64-kodiert werden, und ob etwas „binär\" ist, entscheidet der Adapter anhand des Content-Types – eine Heuristik, die man irgendwann zum ersten Mal falsch hat. Und `Set-Cookie` ist der Klassiker: Mehrere Cookies in einer Antwort werden von der `Headers`-Implementierung zu einer Zeile zusammengezogen, während API Gateway sie als separates Array erwartet.\n\n```js\nasync function toLambdaResult(response) {\n  const contentType = response.headers.get('content-type') || '';\n  const isBinary = !/^(text\\/|application\\/(json|javascript))/.test(contentType);\n\n  return {\n    statusCode: response.status,\n    headers: Object.fromEntries(response.headers.entries()),\n    // API Gateway expects cookies as a separate array\n    cookies: splitSetCookie(response.headers.get('set-cookie')),\n    body: isBinary\n      ? Buffer.from(await response.arrayBuffer()).toString('base64')\n      : await response.text(),\n    isBase64Encoded: isBinary\n  };\n}\n```\n\nNichts davon ist schwierig. Aber alles davon ist Verantwortung, die das Framework ausdrücklich nicht übernimmt – und auch nicht übernehmen sollte. Würde SvelteKit anfangen, API-Gateway-Eigenheiten zu kennen, müsste es morgen auch die von Azure, Google und jedem Hoster kennen, der eine eigene Event-Struktur erfindet. Die schmale Fetch-API ist die Grenze, die beide Seiten unabhängig hält.\n\n```mermaid\nsequenceDiagram\n  participant C as Client\n  participant CF as CloudFront\n  participant L as Lambda-Handler\n  participant K as SvelteKit-Server\n  C->>CF: HTTP-Request\n  CF->>L: API-Gateway-Event\n  L->>K: fetch Request\n  K-->>L: fetch Response\n  L-->>CF: statusCode, headers,<br/>cookies, body\n  CF-->>C: HTTP-Response\n```\n\n## Die stillen Annahmen der Umgebung\n\nDer dritte Teil der Grenze ist der unangenehmste, weil er in keiner Schnittstellen-Signatur steht: die Annahmen über die Umgebung. Eine SvelteKit-Anwendung darf in ihren Server-Routen fast beliebigen Node-Code ausführen. Was davon auf der Zielplattform funktioniert, weiß nur der Adapter – und oft erst der Betrieb.\n\nAuf Lambda sind mir drei Punkte immer wieder begegnet:\n\n- Das Dateisystem ist schreibgeschützt, bis auf `/tmp`. Wer im Server-Code Dateien ablegt, Caches auf Platte schreibt oder eine SQLite-Datei neben dem Code erwartet, fällt beim ersten echten Request auf die Nase – lokal mit `vite dev` funktioniert alles.\n- Umgebungsvariablen kommen aus der Funktionskonfiguration, nicht aus einer `.env`-Datei. SvelteKit hat dafür inzwischen den Hook `server.init({ env })`, über den der Adapter die Variablen explizit hineinreicht. Genau solche kleinen Übergabepunkte machen die Grenze ehrlich: Die Umgebung ist ein Parameter, kein globaler Zustand.\n- Streaming gibt es nicht. Lambda hinter API Gateway puffert die komplette Antwort und liefert sie am Stück aus, mit harten Größenlimits. Eine `Response` mit einem langsamen Stream als Body ist auf dieser Plattform schlicht nicht abbildbar – der Adapter muss den Stream einsammeln und materialisieren.\n\nDer letzte Punkt zeigt, warum Adapter-Design mehr ist als Format-Konvertierung. Das Framework verspricht mit dem `Response`-Typ eine Fähigkeit – streamende Bodies –, die die Plattform nicht einlösen kann. Der Adapter kann das nicht reparieren, er kann es nur sichtbar machen: dokumentieren, im Zweifel beim Build warnen, und die Antwort korrekt puffern statt sie abzuschneiden. Wo Framework-Versprechen und Plattform-Fähigkeit auseinanderlaufen, ist der Adapter derjenige, der die Differenz erklären muss.\n\n## Wenn sich die Framework-Seite bewegt\n\nSvelteKit ist im Oktober 2022 immer noch pre-1.0, und das merkt man als Adapter-Autor deutlicher als als Anwendungsentwickler. Seit Anfang des Jahres habe ich die Adapter-Integration mehrfach anfassen müssen: Aus `app.render(request)` wurde `server.respond(request, options)`, das Manifest-Format und die Builder-Methoden haben sich verschoben, und die große Routing-Umstellung im Spätsommer hat verändert, was überhaupt im Server-Output landet. Mit dem Wechsel auf Vite 3 kam noch die Werkzeug-Ebene dazu.\n\nIch will das nicht als Klage verstanden wissen – ein Framework vor 1.0 darf seine Schnittstellen ändern, dafür ist die Phase da. Aufschlussreich war etwas anderes: Jede dieser Änderungen hat mir gezeigt, welche meiner Annahmen zum Vertrag gehörten und welche ich mir nur eingebildet hatte. Wer eine Schnittstelle dreimal nachziehen muss, versteht sie danach besser als jemand, der sie einmal gelesen hat.\n\nPraktisch habe ich daraus zwei Konsequenzen gezogen. Erstens: Die SvelteKit-Version wird im Adapter-Projekt exakt gepinnt, und jede Aktualisierung ist ein bewusster Schritt mit eigenem Commit statt eines beiläufigen `npm update`. Zweitens: Der wichtigste Test ist kein Unit-Test der Konvertierungsfunktionen, sondern ein Beispielprojekt, das bei jeder Änderung wirklich gebaut und deployt wird. Ein Adapter ist Konsument einer instabilen API; die einzige belastbare Prüfung ist die, die den ganzen Weg geht – vom `vite build` bis zur HTTP-Antwort aus CloudFront. Alles andere testet nur die eigene Vorstellung vom Vertrag, nicht den Vertrag.\n\n## Dieselbe Grenze, andere Frameworks\n\nDie Frage „was gehört auf welche Seite\" stellt sich überall, nur beantworten die Frameworks sie unterschiedlich – und die Antworten sind aufschlussreich.\n\nNext.js hat keine Adapter-Schnittstelle. Die Plattform ist implizit Vercel, und wer woanders deployt, arbeitet sich an einem Output ab, der dafür nie gedacht war. Bemerkenswert finde ich, dass Vercel dieses Jahr mit der Build-Output-API den Spieß umgedreht hat: Dort definiert die Plattform ein dokumentiertes Verzeichnisformat, und beliebige Frameworks können sich darauf hinbauen. Das ist dieselbe Grenze, nur von der anderen Seite gezeichnet – statt „Framework definiert, Adapter übersetzt\" gilt „Plattform definiert, Framework liefert an\". Wer den Vertrag besitzt, hat die Macht über die Grenze.\n\nRemix wiederum hat die Übersetzung von Anfang an eingebaut: `createRequestHandler` nimmt plattformspezifische Requests entgegen, und offizielle Adapter existieren für Express, Architect und Cloudflare Workers. Auch dort ist der Kern identisch mit dem, was SvelteKit macht – Web-Standard-`Request` rein, Web-Standard-`Response` raus, und drumherum eine dünne Schicht Plattform-Dialekt. Je konsequenter sich Frameworks auf diese Standards festlegen, desto dünner werden die Adapter. Im besten Fall schrumpft die Laufzeit-Übersetzung auf null, wie bei Deno Deploy und den Workers, die Fetch nativ sprechen, und es bleibt nur noch der Build-Output-Teil übrig.\n\n## Was ich daraus mitnehme\n\nEin Adapter ist die ehrlichste Stelle einer Framework-Architektur, weil sich dort nichts wegabstrahieren lässt. Auf der einen Seite steht, was das Framework wirklich garantiert: drei Sorten Artefakte, ein Manifest, eine Fetch-API. Auf der anderen Seite steht, was die Plattform wirklich verlangt: ihr Event-Format, ihr Dateisystem, ihre Limits. Der Adapter dazwischen sollte langweilig und dünn sein. Wird er dick, ist das ein Befund – dann garantiert das Framework zu wenig, oder die Plattform verlangt zu viel, und beides sollte man wissen, bevor man eine Anwendung darauf baut.\n\nDeshalb lohnt sich der Blick in den Adapter auch für alle, die nie einen schreiben werden. Wer versteht, was `adapter-node` oder ein AWS-Adapter im Detail tun, weiß genau, welche Annahmen die eigene Anwendung an ihre Umgebung stellt – und welche davon beim nächsten Plattformwechsel brechen. Die Grenze zwischen Framework und Plattform verschwindet nicht dadurch, dass man sie ignoriert. Sie wird nur unsichtbar. Ein guter Adapter macht sie wieder sichtbar, und das ist mehr wert als jedes Feature, das er sonst noch mitbringt.\n\n## Weiterführende Quellen\n\n- Mein Adapter-Projekt: https://github.com/MikeBild/sveltekit-adapter-aws\n- SvelteKit-Doku zu Adaptern: https://svelte.dev/docs/kit/adapters\n- SvelteKit-Doku „Writing adapters\": https://svelte.dev/docs/kit/writing-adapters\n- Remix Server Adapters: https://v2.remix.run/docs/other-api/adapter"},{"slug":"rxjs-in-react-datenfluss","title":"RxJS in React: Observables im Datenfluss der Oberfläche","summary":"RxJS glänzt in React nicht überall, sondern genau dort, wo asynchrone Events koordiniert werden müssen – entprellte Eingaben, abgebrochene Autocomplete-Requests, kontinuierliche Ströme. Der Beitrag zeigt die wenigen Stellen, an denen sich Observables wirklich lohnen, und wo useState völlig genügt.","tags":["Software","Frontend","React"],"published_at":"2022-10-05T10:00:00+00:00","updated_at":"2026-07-13T10:04:28.27524+00:00","url":"https://www.mikebild.dev/de/blog/rxjs-in-react-datenfluss/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/43d132efa1f894e5eabdffa63e9f074587a13970fae4002aeebe61d73aaa2a44/rxjs-in-react-datenfluss-vorlesen.mp3","audio_duration_secs":618,"text":"Ein Observable ist kein Zustandsspeicher. Es hält keinen Wert bereit, den eine Komponente auslesen könnte, und es ersetzt kein `useState`. Es beschreibt eine Ausführung, die Werte über die Zeit an ihre Abonnenten schiebt und die sich jederzeit abbrechen lässt. Wer diese Definition ernst nimmt, hat die Frage nach RxJS in React halb beantwortet: Wo es nur um den aktuellen Wert eines Feldes geht, trägt ein Observable nichts bei. Es zahlt sich erst aus, wo die Zeitachse selbst zum Problem wird.\n\n**Redaktionelle Einordnung (Juli 2026):** Die gezeigten component-lokalen Subscriptions mit `useEffect` und Cleanup bleiben sinnvoll. Wird ein RxJS-basierter State dagegen als externer Store von mehreren Komponenten gelesen, braucht React einen stabilen Snapshot-Vertrag, typischerweise über `useSyncExternalStore` oder eine darauf aufbauende Adapter-Bibliothek. Der Beitrag bleibt bewusst bei der Koordination von Events und macht RxJS nicht zur allgemeinen State-Lösung.\n\nAn dieser Unterscheidung hängt die ganze Diskussion, die in React-Teams meist verhakt läuft. Wer RxJS aus dem Angular-Umfeld kennt, verbindet damit oft Pipelines, die quer durch die Anwendung liefen und niemand mehr überblickte. Wer Streams nur von außen kennt, findet sie elegant und würde den kompletten Komponentenzustand in Observables gießen. Beide Positionen verfehlen den schmalen Bereich, um den es tatsächlich geht: Tastenanschläge, die schneller kommen, als eine API antwortet. Antworten, die in der falschen Reihenfolge eintreffen. Verbindungen, die einfach weitersenden. Diese drei Fälle nehme ich hier auseinander – und danach die Stellen, an denen die React-Bordmittel die bessere Wahl bleiben.\n\n## Kurz eingeordnet: Was ein Observable in diesem Kontext ist\n\nIch setze die Grundlagen aus [RxJS: Push und Pull](https://www.mikebild.dev/de/blog/rxjs-push-und-pull/) hier voraus – der Beitrag erklärt, warum ein Observable ein Push-Producer ist und wie er sich von Promises und Iteratoren unterscheidet. Für den React-Kontext sind drei Eigenschaften entscheidend.\n\nEin Observable ist erstens *lazy*: Der Code im `subscribe`-Callback läuft erst, wenn tatsächlich jemand `subscribe()` aufruft. Ein Observable, das niemand abonniert, tut nichts. Zweitens ist ein normales Observable per Default *cold* und *unicast* – jede Subscription startet ihre eigene Ausführung. Drittens gibt `subscribe()` eine `Subscription` zurück, und über deren `unsubscribe()` wird die Ausführung wieder beendet.\n\nDiese drei Punkte zusammengenommen erklären, warum RxJS und der `useEffect`-Lifecycle so gut zusammenpassen: Ein Effect hat genau die Struktur „aufsetzen beim Mount, aufräumen beim Unmount“, die ein Observable braucht.\n\n```ts\nimport { Observable } from 'rxjs';\n\n// A cold observable: the callback runs once per subscriber\nconst ticker$ = new Observable<number>((subscriber) => {\n  let count = 0;\n  const id = setInterval(() => subscriber.next(count++), 1000);\n\n  // teardown logic runs on unsubscribe\n  return () => clearInterval(id);\n});\n\nconst subscription = ticker$.subscribe((value) => console.log(value));\n// later, to stop and clean up:\nsubscription.unsubscribe();\n```\n\nWer den Aufbau dieses `subscribe`/`unsubscribe`-Musters verinnerlicht hat, erkennt sofort die Parallele zur Cleanup-Function von `useEffect`. Das ist kein Zufall, sondern der eigentliche Grund, warum die Integration so geradlinig ist.\n\n## Der erste sinnvolle Fall: Eingaben entprellen\n\nDas klassische Beispiel ist ein Suchfeld. Bei jedem Tastenanschlag einen HTTP-Request abzufeuern ist verschwenderisch – der Nutzer tippt „React“, und schon sind sechs Requests unterwegs, von denen fünf sofort veraltet sind. Die naive React-Lösung baut hier einen `setTimeout` mit `clearTimeout` im Effect zusammen. Das funktioniert, wird aber schnell unübersichtlich, sobald mehr als nur Entprellung dazukommt.\n\nMit RxJS wird die Entprellung zu einem einzigen deklarativen Operator. `debounceTime(300)` emittiert einen Wert erst, wenn 300 Millisekunden lang kein neuer Wert nachgekommen ist. Ich speise die Eingaben über ein `Subject` ein – ein Subject ist zugleich Observable und Observer, ich kann also im `onChange`-Handler `next()` aufrufen und am anderen Ende die Pipeline abonnieren.\n\n```tsx\nimport { useEffect, useRef, useState } from 'react';\nimport { Subject } from 'rxjs';\nimport { debounceTime, distinctUntilChanged } from 'rxjs/operators';\n\nfunction SearchField() {\n  const query$ = useRef(new Subject<string>()).current;\n  const [debounced, setDebounced] = useState('');\n\n  useEffect(() => {\n    const subscription = query$\n      .pipe(debounceTime(300), distinctUntilChanged())\n      .subscribe(setDebounced);\n\n    return () => subscription.unsubscribe();\n  }, [query$]);\n\n  return (\n    <input\n      type=\"search\"\n      onChange={(event) => query$.next(event.target.value)}\n      placeholder=\"Enter search term\"\n    />\n  );\n}\n```\n\nZwei Details gehen in Code-Reviews regelmäßig unter. Das `Subject` lege ich mit `useRef` an, damit es über Re-Renders hinweg stabil bleibt – würde ich es im Render-Body neu erzeugen, hätte jeder Render sein eigenes Subject, und die Verbindung zwischen Handler und Pipeline bräche. Und `distinctUntilChanged()` filtert identische aufeinanderfolgende Werte heraus, damit ein Fokuswechsel ohne Textänderung keinen neuen Durchlauf auslöst.\n\n## Der Fall, in dem RxJS wirklich glänzt: Autocomplete mit switchMap\n\nEntprellen alleine rechtfertigt RxJS noch nicht – das bekommt man auch mit einem Timeout hin. Was RxJS trägt, ist das Verwerfen veralteter Requests. Genau hier liegt das eigentliche Race-Condition-Problem eines Autocomplete: Ich tippe „re“, ein Request geht raus. Ich tippe „rea“, ein zweiter Request geht raus. Wenn nun die Antwort auf „re“ *nach* der Antwort auf „rea“ eintrifft, überschreibt das veraltete Ergebnis das aktuelle. Die Oberfläche zeigt Treffer zu einem Suchbegriff an, der gar nicht mehr im Feld steht.\n\n`switchMap` löst das per Konstruktion. Der Operator abonniert das innere Observable – hier den HTTP-Request – und cancelt die vorherige innere Subscription, sobald ein neuer Wert von außen kommt. Kombiniert mit `fromFetch` und dessen `AbortController` wird der veraltete Request nicht nur ignoriert, sondern real abgebrochen.\n\n```tsx\nimport { useEffect, useRef, useState } from 'react';\nimport { Subject } from 'rxjs';\nimport { fromFetch } from 'rxjs/fetch';\nimport {\n  debounceTime,\n  distinctUntilChanged,\n  filter,\n  switchMap,\n} from 'rxjs/operators';\n\ntype Hit = { id: string; label: string };\n\nfunction Autocomplete() {\n  const query$ = useRef(new Subject<string>()).current;\n  const [results, setResults] = useState<Hit[]>([]);\n\n  useEffect(() => {\n    const subscription = query$\n      .pipe(\n        debounceTime(300),\n        distinctUntilChanged(),\n        filter((term) => term.length > 1),\n        switchMap((term) =>\n          fromFetch(`/api/search?q=${encodeURIComponent(term)}`).pipe(\n            switchMap((response) => response.json() as Promise<Hit[]>),\n          ),\n        ),\n      )\n      .subscribe(setResults);\n\n    return () => subscription.unsubscribe();\n  }, [query$]);\n\n  return (\n    <div>\n      <input onChange={(event) => query$.next(event.target.value)} />\n      <ul>\n        {results.map((hit) => (\n          <li key={hit.id}>{hit.label}</li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n```\n\nDer entscheidende Unterschied hängt an einem einzigen Operatornamen. Wer hier aus Gewohnheit `mergeMap` schreibt, lässt alle Requests parallel weiterlaufen, und das oben beschriebene Race-Condition-Problem ist wieder da. Für „nur die letzte Eingabe zählt“ ist `switchMap` die richtige Wahl. Der Unterschied wird greifbar, wenn man die Pipeline einmal bewusst mit `mergeMap` baut und gegen eine künstlich verlangsamte API laufen lässt: Die veralteten Treffer blitzen dann sichtbar in der Liste auf, bevor das aktuelle Ergebnis sie ersetzt.\n\nDas folgende Diagramm zeigt den Datenfluss der Pipeline von der Eingabe bis zum Re-Render.\n\n```mermaid\nflowchart LR\n  A[\"Input-Event<br/>onChange\"] --> B[\"Subject.next(term)\"]\n  B --> C[\"debounceTime(300)<br/>Ruhephase abwarten\"]\n  C --> D[\"distinctUntilChanged<br/>Duplikate filtern\"]\n  D --> E[\"switchMap<br/>alten Request verwerfen\"]\n  E --> F[\"subscribe<br/>setResults\"]\n  F --> G[\"React Re-Render\"]\n```\n\nWichtig ist die Cancellation-Semantik von `switchMap`, weil sie der Kern des Ganzen ist. Das zweite Diagramm stellt sie als Sequenz dar: Die Eingabe „rea“ bricht den noch offenen inneren Stream von „re“ ab, sodass nur die Antwort zur letzten Eingabe im State landet.\n\n```mermaid\nsequenceDiagram\n  participant U as Nutzer\n  participant P as Pipeline (switchMap)\n  participant A as API\n  U->>P: Eingabe \"re\"\n  P->>A: Request \"re\"\n  U->>P: Eingabe \"rea\"\n  Note over P: switchMap cancelt<br/>inneren Stream \"re\"\n  P--xA: Request \"re\" abgebrochen\n  P->>A: Request \"rea\"\n  A-->>P: Antwort \"rea\"\n  P->>U: setResults (nur letzte Antwort)\n```\n\n## Kontinuierliche Ströme: WebSockets und Events\n\nDer dritte Fall, in dem sich RxJS auszahlt, sind Quellen, die nicht ein einzelnes Ergebnis liefern, sondern über die Zeit immer weiter emittieren. WebSockets sind das offensichtliche Beispiel. RxJS bringt mit `webSocket` aus `rxjs/webSocket` einen bidirektionalen Wrapper mit, der zugleich Observable und Observer ist – ich kann eintreffende Nachrichten abonnieren und über `next()` selbst welche senden. Das Detail, das hier Gold wert ist: `unsubscribe()` schließt die Verbindung sauber.\n\n```tsx\nimport { useEffect, useState } from 'react';\nimport { webSocket } from 'rxjs/webSocket';\n\ntype Message = { id: string; text: string };\n\nfunction LiveFeed() {\n  const [messages, setMessages] = useState<Message[]>([]);\n\n  useEffect(() => {\n    const socket$ = webSocket<Message>('wss://example.com/feed');\n\n    const subscription = socket$.subscribe((message) =>\n      setMessages((prev) => [...prev, message]),\n    );\n\n    // unsubscribe closes the underlying socket\n    return () => subscription.unsubscribe();\n  }, []);\n\n  return (\n    <ul>\n      {messages.map((message) => (\n        <li key={message.id}>{message.text}</li>\n      ))}\n    </ul>\n  );\n}\n```\n\nDie Grundlagen hinter solchen Message-Strömen beschreibe ich in [RxJS: Push und Pull](https://www.mikebild.dev/de/blog/rxjs-push-und-pull/). Bidirektionale Kanäle ändern an diesem Modell nichts: Senden und Empfangen bleiben zwei getrennte Ströme.\n\nEine Variante zu `fromEvent` gehört noch dazu, weil sie eine Stolperstelle offenlegt. `fromEvent` hängt sich an einen echten DOM-Knoten, nicht an Reacts synthetisches Event-System. Man braucht also eine `ref`, und die ist erst nach dem Mount gesetzt:\n\n```tsx\nimport { useEffect, useRef, useState } from 'react';\nimport { fromEvent } from 'rxjs';\nimport { map, debounceTime } from 'rxjs/operators';\n\nfunction ScrollTracker() {\n  const boxRef = useRef<HTMLDivElement>(null);\n  const [position, setPosition] = useState(0);\n\n  useEffect(() => {\n    const node = boxRef.current;\n    if (!node) return;\n\n    const subscription = fromEvent(node, 'scroll')\n      .pipe(\n        map((event) => (event.target as HTMLElement).scrollTop),\n        debounceTime(50),\n      )\n      .subscribe(setPosition);\n\n    return () => subscription.unsubscribe();\n  }, []);\n\n  return <div ref={boxRef}>{/* scrollable content */}</div>;\n}\n```\n\n## Der Fallstrick, der jeden einmal erwischt: fehlendes unsubscribe\n\nWenn eine RxJS-React-Komponente unerklärlich Speicher frisst oder eine Warnung „setState on an unmounted component“ wirft, ist die Ursache fast immer dieselbe: Die Cleanup-Function fehlt oder gibt die Subscription nicht frei. Ohne `unsubscribe()` läuft der Stream nach dem Unmount weiter. Der Event-Listener bleibt hängen, der Timer tickt weiter, der WebSocket bleibt offen – und jeder emittierte Wert ruft ein `setState` auf einer Komponente auf, die es nicht mehr gibt.\n\n```tsx\n// Wrong: no cleanup, the stream keeps running after unmount\nuseEffect(() => {\n  interval(1000).subscribe((n) => setTick(n)); // memory leak\n}, []);\n\n// Right: keep the subscription and release it in cleanup\nuseEffect(() => {\n  const subscription = interval(1000).subscribe((n) => setTick(n));\n  return () => subscription.unsubscribe();\n}, []);\n```\n\nDer zweite verwandte Fehler ist subtiler: die Pipeline im Render-Body statt im Effect aufzubauen. Ein `fromEvent(...).pipe(...)`, das bei jedem Render neu entsteht, produziert doppelte Listener und doppelte Subscriptions. Die Reihenfolge, die zuverlässig funktioniert: Die Quelle stabil halten (per `useRef` oder außerhalb der Komponente), die Subscription im Effect aufsetzen, im Cleanup abbauen, und das Dependency-Array sauber pflegen.\n\nAls Randnotiz für 2022: React 18 bringt mit `useSyncExternalStore` einen Mechanismus, um sich sauber an externe Stores – auch RxJS-basierte – zu binden. Für die hier gezeigten Fälle ist der `useEffect`-Weg aber völlig ausreichend, und ich würde `useSyncExternalStore` erst dann heranziehen, wenn ein Store wirklich außerhalb der Komponente liegt und von mehreren Stellen aus genutzt wird.\n\n## Wann RxJS sich lohnt – und wann nicht\n\nDie häufigste Fehlentscheidung ist, RxJS überall hineinzuziehen. Verhindern lässt sie sich mit zwei kurzen Listen.\n\nRxJS lohnt sich, wenn:\n\n- Eingaben zeitbasiert entprellt oder gedrosselt werden müssen, bevor ein teurer Effect läuft.\n- veraltete asynchrone Ergebnisse aktiv verworfen werden sollen (Autocomplete, konkurrierende Requests) – der Kernfall für `switchMap`.\n- kontinuierliche Ströme wie WebSockets, Server-Sent-Events oder Timer verarbeitet werden.\n- mehrere Event- und Zeitquellen kombiniert, gemischt oder zeitlich koordiniert werden müssen.\n\nRxJS lohnt sich nicht, wenn:\n\n- es um einfachen lokalen Komponentenzustand geht – dafür sind `useState` und `useReducer` da.\n- ein einzelner Request beim Mount geladen wird; ein `fetch` mit `AbortController` im Effect reicht, oder man greift zu einer Data-Fetching-Bibliothek.\n- das Team RxJS nicht beherrscht und die Anforderung keine echte asynchrone Koordination enthält – die Lernkurve zahlt sich dann nicht aus.\n\nDiese Trennung ist der eigentliche Kern des Beitrags. RxJS ist kein Ersatz für Reacts Zustandsmodell, sondern eine Ergänzung für einen schmalen, aber wichtigen Ausschnitt: die Koordination asynchroner Events über die Zeit. Wer Observables gezielt an diesen wenigen Stellen einsetzt und überall sonst bei den React-Bordmitteln bleibt, bekommt beides: deklarative Streams dort, wo sie echte Probleme lösen, und schlanke Komponenten im Rest der Anwendung. Und wer konsequent im Cleanup `unsubscribe()` aufruft, umgeht den einen Fehler, der sonst zuverlässig zurückkommt.\n\n## Weiterführende Quellen\n\n- [RxJS: Push und Pull](https://www.mikebild.dev/de/blog/rxjs-push-und-pull/) – die Grundlagen zu Observables als Push-Producer.\n- [Observable-Guide (rxjs.dev)](https://rxjs.dev/guide/observable)\n- [Operators-Guide / pipe (rxjs.dev)](https://rxjs.dev/guide/operators)\n- [Subject (rxjs.dev)](https://rxjs.dev/guide/subject)\n- [switchMap (rxjs.dev)](https://rxjs.dev/api/operators/switchMap)\n- [debounceTime (rxjs.dev)](https://rxjs.dev/api/operators/debounceTime)\n- [fromEvent (rxjs.dev)](https://rxjs.dev/api/index/function/fromEvent)\n- [Subscription / unsubscribe (rxjs.dev)](https://rxjs.dev/guide/subscription)\n- [React: Using the Effect Hook (reactjs.org)](https://reactjs.org/docs/hooks-effect.html)"},{"slug":"svelte-motion-transitions-animationen","title":"Motion, Transitions und Animationen in Svelte","summary":"Wie Svelte Bewegung deklarativ und ohne Extra-Bibliothek mitbringt: transition-Direktiven fürs Ein- und Ausblenden, svelte/motion für sanft interpolierte Werte und animate:flip fürs Umsortieren von Listen.","tags":["Software","Frontend","Svelte"],"published_at":"2022-09-27T10:00:00+00:00","updated_at":"2026-07-13T07:58:57.049025+00:00","url":"https://www.mikebild.dev/de/blog/svelte-motion-transitions-animationen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/bd1ee46132158debdb3578dfd1f55510154a8638e740677e443ad9fd1b88d297/svelte-motion-transitions-animationen-vorlesen.mp3","audio_duration_secs":647,"text":"Ein Hinweis über der Liste taucht auf, und er ist einfach da – hart, ohne Übergang. Ein Eintrag wird gelöscht, und alles darunter rutscht schlagartig eine Zeile nach oben. In React-Projekten landet spätestens an dieser Stelle `react-transition-group` oder eine Animations-Runtime im `package.json`. In Svelte sind es drei Importe aus der Standardausstattung, und die Abhängigkeitsliste bleibt, wie sie war.\n\nDie drei Module sind aufeinander abgestimmt und decken zusammen fast alles ab, was im Alltag anfällt. Wer schon einmal verstanden hat, warum [Svelte ohne Virtual DOM auskommt und stattdessen zur Compile-Zeit Code erzeugt](https://www.mikebild.dev/de/blog/svelte-reaktivitaet-compiler-ohne-virtual-dom/), ahnt bereits, worauf das hinausläuft: Auch die Bewegung ist eng mit dem Compiler verwoben – er übersetzt die Direktiven in Code, der die Animation nach Möglichkeit über CSS-Keyframes statt über JavaScript pro Frame laufen lässt.\n\n**Redaktionelle Einordnung (Juli 2026):** Die Beispiele sind im Svelte-3-Stil geschrieben. `svelte/transition`, `svelte/animate` und `svelte/motion` gibt es auch in Svelte 5; geändert hat sich vor allem das umgebende Reaktivitätsmodell. Die alten Tutorial-URLs wurden durch die heutigen API-Seiten ersetzt.\n\n## Drei Werkzeuge, drei Aufgaben\n\nBevor wir Code schreiben, lohnt sich eine saubere Trennung im Kopf. Die drei Module haben unterschiedliche Zuständigkeiten, und die meisten Missverständnisse, die mir begegnet sind, entstehen daraus, dass jemand das falsche Werkzeug für die falsche Aufgabe greift.\n\n- `transition:` (aus `svelte/transition`) beschreibt, wie ein einzelnes Element in den DOM hinein- und wieder herauskommt. Es geht um Erscheinen und Verschwinden.\n- `animate:` (aus `svelte/animate`) beschreibt, wie sich Elemente bewegen, die im DOM bleiben, aber ihre Position ändern – also beim Umsortieren einer Liste.\n- `svelte/motion` (mit `tweened` und `spring`) hat mit dem DOM erst einmal gar nichts zu tun. Hier geht es um Werte, die sich weich von einem State zum nächsten interpolieren – eine Zahl, eine Farbe, eine Koordinate.\n\nDiese Dreiteilung ist der rote Faden für alles Weitere. Sobald klar ist, ob man ein Element auftauchen lässt, es innerhalb einer Liste verschiebt oder einen Wert gleiten lässt, ergibt sich das passende Modul fast von selbst.\n\n## Transitions: Ein- und Ausblenden\n\nEine Transition hängt man in Svelte als Direktive an ein Element. Sie wird ausgelöst, sobald das Element durch einen Kontrollfluss – ein `{#if}`, ein `{#each}` – in den DOM eintritt oder ihn verlässt. Das ist der entscheidende Punkt: Nicht ich rufe eine Animation auf, sondern die State-Änderung tut es. Ich beschreibe nur, wie sie aussieht.\n\n```svelte\n<script>\n  import { fade, fly } from 'svelte/transition';\n  let visible = true;\n</script>\n\n<label>\n  <input type=\"checkbox\" bind:checked={visible} />\n  toggle\n</label>\n\n{#if visible}\n  <p transition:fade={{ duration: 300 }}>\n    fades in and out\n  </p>\n\n  <div transition:fly={{ y: 20, duration: 300 }}>\n    flies in from below, flies back down on exit\n  </div>\n{/if}\n```\n\n`transition:` gilt bidirektional – dieselbe Bewegung läuft beim Einblenden vorwärts und beim Ausblenden rückwärts. Das ist häufig genau richtig. Manchmal aber möchte man asymmetrisch sein: hereinfliegen, aber verblassend verschwinden. Dafür gibt es die getrennten Direktiven `in:` und `out:`.\n\n```svelte\n<script>\n  import { fly, fade } from 'svelte/transition';\n  let visible = true;\n</script>\n\n{#if visible}\n  <div in:fly={{ y: 20, duration: 300 }} out:fade={{ duration: 200 }}>\n    enters flying, leaves fading\n  </div>\n{/if}\n```\n\nDas eingebaute Sortiment ist überschaubar und gut aufeinander abgestimmt: `fade`, `blur`, `fly`, `slide`, `scale`, `draw` und `crossfade`. Für den Alltag reichen die ersten fünf fast immer. `fly` nimmt `x` und `y` als Zahlen in Pixeln entgegen, `slide` blendet vertikal auf und zu, `scale` skaliert von einem Startwert her.\n\nEin Detail, das mir selbst erst beim genauen Hinsehen klar geworden ist: Eine Out-Transition braucht Zeit, und das Element bleibt für diese Dauer im DOM. Svelte entfernt es erst, wenn die Bewegung abgeschlossen ist. Wer also glaubt, ein Element sei schon weg, während es noch ausblendet, wundert sich über kurzzeitig doppelte Einträge. Das ist kein Fehler, sondern die Voraussetzung dafür, dass man das Verschwinden überhaupt sehen kann.\n\n## Was der Compiler intern daraus macht\n\nDer Grund, warum diese Transitions ohne Performance-Sorgen laufen, liegt darin, wie sie erzeugt werden. Eine Transition-Function liefert im Kern eine `css`-Funktion zurück, die für einen Fortschrittswert `t` (von 0 bis 1) einen CSS-String erzeugt. Aus dieser Funktion baut Svelte echte CSS-Keyframes. Die Animation läuft damit über die CSS-Engine des Browsers und blockiert nicht den Main-Thread mit JavaScript pro Frame.\n\n```mermaid\nflowchart LR\n  A[\"State change<br/>{#if} / {#each}\"] --> B[\"Svelte compiler\"]\n  B --> C[\"transition: / animate:<br/>directive\"]\n  C --> D{\"css function<br/>available?\"}\n  D -->|yes| E[\"CSS keyframes<br/>off main thread\"]\n  D -->|no| F[\"tick per frame<br/>JS on main thread\"]\n  E --> G[\"DOM update\"]\n  F --> G\n```\n\nNur wenn eine Bewegung sich nicht rein über CSS ausdrücken lässt – etwa weil man pro Frame einen Textinhalt ändern will –, greift der zweite Weg über eine `tick`-Funktion, die tatsächlich pro Frame in JavaScript läuft. Der Standardfall aber ist der performante CSS-Pfad, und man bekommt ihn geschenkt, ohne selbst Keyframes zu schreiben.\n\n## animate:flip für Listen\n\nKommen wir zum zweiten Werkzeug. `transition:` kümmert sich um Elemente, die kommen und gehen. Aber was ist mit Elementen, die bleiben und nur ihren Platz tauschen? Wenn ich eine Liste sortiere oder mische, springen die Einträge ohne Zutun hart an ihre neue Position. Genau hier setzt `animate:flip` an.\n\nDer Name FLIP steht für „First, Last, Invert, Play“ – eine bekannte Technik, bei der die alte und die neue Position eines Elements verglichen und die Differenz weganimiert wird. Svelte erledigt diese Rechnung für uns. Wichtig ist nur eine Bedingung: `animate:` funktioniert ausschließlich in einem keyed `{#each}`-Block, also einem `{#each}` mit einem stabilen Schlüssel. Ohne diesen Schlüssel kann Svelte gar nicht wissen, welches Element vorher wo war.\n\nDas folgende Beispiel bringt beide Werkzeuge zusammen – `transition:fly` fürs Ein- und Ausblenden neuer beziehungsweise entfallender Einträge und `animate:flip` fürs weiche Umsortieren der bleibenden:\n\n```svelte\n<script>\n  import { fly } from 'svelte/transition';\n  import { flip } from 'svelte/animate';\n\n  let items = [\n    { id: 1, label: 'apples' },\n    { id: 2, label: 'oranges' },\n    { id: 3, label: 'bananas' },\n    { id: 4, label: 'pears' }\n  ];\n\n  function shuffle() {\n    items = items\n      .map((item) => ({ item, sort: Math.random() }))\n      .sort((a, b) => a.sort - b.sort)\n      .map(({ item }) => item);\n  }\n\n  function remove(id) {\n    items = items.filter((item) => item.id !== id);\n  }\n</script>\n\n<button on:click={shuffle}>shuffle</button>\n\n<ul>\n  {#each items as item (item.id)}\n    <li\n      transition:fly={{ y: 20, duration: 300 }}\n      animate:flip={{ duration: 300 }}\n    >\n      {item.label}\n      <button on:click={() => remove(item.id)}>x</button>\n    </li>\n  {/each}\n</ul>\n```\n\nDer Schlüssel `(item.id)` ist hier nicht dekorativ, sondern trägt die ganze Mechanik. Er erlaubt es Svelte, dieselbe DOM-`<li>` über das Umsortieren hinweg als dasselbe Element zu erkennen und die Positionsdifferenz zu animieren. Das animierte Element muss außerdem direktes Kind des `{#each}`-Blocks sein. Wer stattdessen einen Wrapper dazwischenschiebt und `animate:flip` an das falsche Element hängt, sieht schlicht keine Bewegung – ein Klassiker, über den ich in Reviews immer wieder stolpere.\n\nDas Zusammenspiel von `transition` und `animate` in derselben Liste ist übrigens Absicht und gut abgestimmt: Neue Einträge fliegen herein, entfernte fliegen heraus und bleiben dafür kurz im DOM, und alle übrigen rutschen sanft auf ihre neuen Plätze. Drei Bewegungen, ein deklarativer Block.\n\n## svelte/motion: Werte, die gleiten\n\nDas dritte Werkzeug lässt den DOM zunächst außen vor. `tweened` und `spring` sind Stores – schreibbare Stores, wie ich sie im Artikel über [State außerhalb der Komponente mit Svelte Stores](https://www.mikebild.dev/de/blog/svelte-stores-zustand-ausserhalb-der-komponente/) beschrieben habe, nur mit einer Besonderheit: Wenn man ihren Wert setzt, springen sie nicht sofort auf das Ziel, sondern interpolieren dorthin. Man liest sie im Markup wie jeden Store mit dem `$`-Präfix.\n\n`tweened` interpoliert über eine feste Dauer und eine Easing-Funktion – gut für alles, was einem klaren zeitlichen Verlauf folgen soll, etwa ein Fortschrittsbalken.\n\n```svelte\n<script>\n  import { tweened } from 'svelte/motion';\n  import { cubicOut } from 'svelte/easing';\n\n  const progress = tweened(0, {\n    duration: 400,\n    easing: cubicOut\n  });\n</script>\n\n<progress value={$progress}></progress>\n\n<button on:click={() => progress.set(0)}>0%</button>\n<button on:click={() => progress.set(0.5)}>50%</button>\n<button on:click={() => progress.set(1)}>100%</button>\n```\n\n`spring` dagegen interpoliert nicht über eine Dauer, sondern über eine Physik – Steifigkeit und Dämpfung. Das Ergebnis wirkt lebendiger, weil es leicht nachfedert, statt exakt zu einem Zeitpunkt anzukommen. Für Dinge, die sich „natürlich“ anfühlen sollen, etwa ein Element, das der Maus folgt oder auf Eingaben reagiert, ist `spring` meist die schönere Wahl.\n\n```svelte\n<script>\n  import { spring } from 'svelte/motion';\n\n  const size = spring(1, {\n    stiffness: 0.1,\n    damping: 0.4\n  });\n</script>\n\n<div class=\"box\" style=\"transform: scale({$size})\">\n  {$size.toFixed(2)}\n</div>\n\n<button on:click={() => size.set(0.5)}>small</button>\n<button on:click={() => size.set(1)}>normal</button>\n<button on:click={() => size.set(2)}>large</button>\n\n<style>\n  .box {\n    width: 80px;\n    height: 80px;\n    background: #ff3e00;\n    display: grid;\n    place-items: center;\n    color: white;\n  }\n</style>\n```\n\nZwei Details fallen erst beim Arbeiten damit auf. Erstens geben `.set()` und `.update()` ein Promise zurück, das erst auflöst, wenn die Interpolation abgeschlossen ist – wer also darauf warten will, dass ein Wert „angekommen“ ist, kann das `await`en. Zweitens interpoliert Svelte Zahlen, Arrays und Objekte aus Zahlen automatisch; für andere Werttypen, etwa Farben in einer bestimmten Notation, gibt man `tweened` über die Option `interpolate` eine eigene Interpolationsfunktion mit.\n\nDie Parameter `stiffness` und `damping` lassen sich sogar zur Laufzeit anpassen. Man kann sie an Slider binden und live erleben, wie sich das Verhalten der Feder ändert. Das ist der schnellste Weg, ein Gefühl dafür zu bekommen, was die beiden Zahlen tatsächlich bewirken – schneller jedenfalls, als die Werte im Code zu raten und neu zu laden.\n\n## Eigene Transitions schreiben\n\nDer vielleicht schönste Beweis dafür, dass hier keine Blackbox arbeitet: Eine Transition ist nichts weiter als eine Funktion. Sie bekommt den DOM-Knoten und ihre Parameter und gibt ein Objekt zurück, das unter anderem eine `css`-Funktion enthält. Diese Funktion erhält den Fortschritt `t` von 0 bis 1 sowie den Gegenwert `u`, der `1 - t` entspricht, und liefert einen CSS-String.\n\n```js\nfunction spin(node, { duration = 400 }) {\n  return {\n    duration,\n    css: (t, u) => `\n      transform: rotate(${u * 360}deg) scale(${t});\n      opacity: ${t};\n    `\n  };\n}\n```\n\nVerwenden lässt sich diese eigene Transition exakt wie die eingebauten:\n\n```svelte\n<script>\n  import { spin } from './spin.js';\n  let visible = true;\n</script>\n\n{#if visible}\n  <div in:spin={{ duration: 600 }}>\n    spins into view\n  </div>\n{/if}\n```\n\nWenn man das einmal geschrieben hat, entzaubert sich der Rest von selbst. `fade`, `fly` und `scale` sind genau solche Funktionen – nur eben in der Bibliothek. Es gibt keinen zweiten, verborgenen Mechanismus. Genau diese Durchgängigkeit ist es, die ich an Sveltes Bewegungsmodell so schätze: Vom fertigen Einzeiler bis zur eigenen Funktion ist es ein durchgehendes Konzept, kein Bruch.\n\nZur Einordnung noch einmal die drei Werkzeuge im direkten Vergleich, damit die Zuordnung dauerhaft sitzt:\n\n- `transition:` / `in:` / `out:` – für das Erscheinen und Verschwinden einzelner Elemente, ausgelöst durch `{#if}` oder `{#each}`.\n- `animate:flip` – für Elemente, die im DOM bleiben und in einem keyed `{#each}` nur ihre Position tauschen.\n- `tweened` / `spring` aus `svelte/motion` – für interpolierte Werte, die man als Store mit `$` liest und ins Markup einbindet.\n\n## Fazit\n\nBewegung ist in Svelte keine Zusatzentscheidung, die man später mit einer Bibliothek nachrüstet, sondern ein Teil der Sprache. Das verändert die Art, wie man an das Thema herangeht. Statt zu fragen „Welches Paket nehme ich?“, fragt man „Erscheint hier etwas, verschiebt sich etwas, oder gleitet ein Wert?“ – und die Antwort führt direkt zu `transition:`, `animate:flip` oder `svelte/motion`.\n\nDer gemeinsame Nenner ist der deklarative Ansatz: Ich beschreibe, wie eine Bewegung aussieht, und der Compiler kümmert sich darum, dass sie ausgelöst wird, wenn sich der State ändert, und dass sie möglichst über CSS-Keyframes performant läuft. Dass eigene Transitions dieselben schlichten Funktionen sind wie die eingebauten, macht das Ganze lernbar bis auf den Grund. Für den Projektalltag heißt das: weniger Abhängigkeiten, weniger Boilerplate und mehr Zeit für die Frage, welche Bewegung eine Oberfläche wirklich verständlicher macht – statt für die Frage, welches Framework sie überhaupt ermöglicht.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-svelte.html\n- Repository zur Artikelreihe mit den lauffähigen Svelte-Beispielen\n- [`svelte/transition` – offizielle Dokumentation](https://svelte.dev/docs/svelte/svelte-transition)\n- [`svelte/animate` – offizielle Dokumentation](https://svelte.dev/docs/svelte/svelte-animate)\n- [`svelte/motion` – offizielle Dokumentation](https://svelte.dev/docs/svelte/svelte-motion)"},{"slug":"nodejs-buffers-und-binaerdaten","title":"Buffers in Node.js: mit Binärdaten arbeiten","summary":"Warum JS-Strings für rohe Bytes nicht taugen und Buffer Nodes Werkzeug dafür ist: alloc gegen allocUnsafe, Encodings sauber lesen und schreiben, und die Speicherfalle hinter slice und subarray.","tags":["Software","Node.js","Backend"],"published_at":"2022-08-09T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/nodejs-buffers-und-binaerdaten/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/f79ce3a3f7b7ae540531c3cc02d79483ea907f28698d77a4b751c67810c56712/nodejs-buffers-und-binaerdaten-vorlesen.mp3","audio_duration_secs":667,"text":"Ein `Buffer` ist kein kaputter String. Wenn nach dem Einlesen einer Datei `<Buffer 89 50 4e 47 0d 0a 1a 0a ...>` in der Konsole steht, ist nichts schiefgegangen: Node zeigt genau das, was in der Datei liegt – eine Folge von Bytes, die keine Textbedeutung hat und auch keine bekommen soll.\n\nSolche Bytes kommen in Node aus vielen Richtungen: aus `fs`, aus offenen Netzwerkverbindungen, aus `crypto`. Der `Buffer` ist der Typ, in dem Node sie hält. Interessant an ihm sind vor allem die Stellen, an denen er sich anders verhält, als man es von Strings und Arrays gewohnt ist – beim Anlegen und beim Zerteilen.\n\n## Warum Strings hier nicht reichen\n\nEin JavaScript-String ist eine Folge von Unicode-Zeichen, keine Folge von Bytes. Das klingt nach Haarspalterei, ist aber der Kern des Problems. Sobald ich einen String habe, hat die Laufzeit bereits eine Interpretation vorgenommen: Diese Bytes sind Text, und zwar in einer bestimmten Kodierung. Rohe Binärdaten – ein PNG-Header, ein komprimierter Block, ein kryptografischer Schlüssel – haben aber gar keine Textbedeutung. Zwinge ich sie durch einen String, verliere oder verfälsche ich Bytes, weil ungültige Byte-Folgen durch Ersatzzeichen ausgetauscht werden. Das fällt oft erst spät auf, etwa wenn eine hochgeladene Datei am anderen Ende beschädigt ankommt.\n\nNode löst das mit `Buffer`: einem Bereich fester Byte-Länge, der genau die Bytes enthält, die man hineingelegt hat, ohne jede Umdeutung. Entscheidend ist dabei ein Detail, das vieles erklärt: Ein `Buffer` ist eine Unterklasse von `Uint8Array`, also selbst ein TypedArray. Alles, was man von `Uint8Array` kennt – Indexzugriff, `length`, `for`-Schleifen, die TypedArray-Methoden – funktioniert damit auch auf einem Buffer. `Buffer` fügt lediglich die für Node praktischen Extras hinzu, allen voran die Encodings.\n\n```js\nconst fs = require('node:fs');\n\nconst data = fs.readFileSync('image.png');\n\nconsole.log(Buffer.isBuffer(data));        // true\nconsole.log(data instanceof Uint8Array);   // true\nconsole.log(data.length);                  // number of bytes\nconsole.log(data.subarray(0, 8));          // <Buffer 89 50 4e 47 0d 0a 1a 0a>\n```\n\nDie ersten acht Bytes sind übrigens die feste Signatur einer PNG-Datei. Genau solche Header liest man in der Praxis, um Dateitypen zu erkennen, ohne sich auf die Endung zu verlassen.\n\n## Einen Buffer anlegen: alloc, allocUnsafe, from\n\nEs gibt drei Wege, an einen Buffer zu kommen, und die Unterschiede sind wichtig – auch aus Sicherheitsgründen.\n\n- `Buffer.alloc(size)` reserviert `size` Bytes und setzt sie alle auf `0`. Optional gibt man ein Füllbyte und ein Encoding mit. Das ist der sichere Standardweg, wenn man einen leeren Buffer bestimmter Größe braucht.\n- `Buffer.allocUnsafe(size)` reserviert ebenfalls `size` Bytes, initialisiert sie aber nicht. Der Inhalt ist alter Speicher – was vorher dort stand, steht weiter dort. Dafür ist das Anlegen messbar schneller, weil das Nullen entfällt und der Speicher gegebenenfalls aus einem internen Pool stammt.\n- `Buffer.from(...)` erzeugt einen Buffer aus vorhandenen Daten: aus einem String (mit Encoding), aus einem Array von Byte-Werten, aus einem anderen Buffer (kopiert) oder aus einem `ArrayBuffer`.\n\nDer Unterschied zwischen den ersten beiden ist kein Detail. `allocUnsafe` gibt Speicher zurück, der Reste früherer Allokationen enthalten kann – möglicherweise Daten aus ganz anderen Teilen des Programms.\n\n```js\nconst zeroed = Buffer.alloc(8);\nconsole.log(zeroed);        // <Buffer 00 00 00 00 00 00 00 00>\n\nconst uninitialized = Buffer.allocUnsafe(8);\nconsole.log(uninitialized); // <Buffer ?? ?? ?? ?? ?? ?? ?? ??> old memory\n```\n\n`alloc` ist deshalb die Voreinstellung; `allocUnsafe` lohnt sich nur dort, wo der Buffer unmittelbar danach ohnehin vollständig überschrieben wird. Wer `allocUnsafe` nutzt und den Speicher nur teilweise füllt, darf den Rest niemals ausgeben oder versenden, denn dort können sensible Daten stehen. Das ist die erste der beiden klassischen Fallen, und sie ist tückisch, weil sie im lokalen Test nie auffällt – auf einem frisch gestarteten Prozess ist der Speicher oft zufällig genullt. Erst unter Last, wenn der Pool wiederverwendet wird, tauchen die Datenreste auf.\n\nAm Rande, weil es in altem Code noch herumgeistert: Der frühere Aufruf `new Buffer(...)` ist seit Node 10 zur Laufzeit abgekündigt (DEP0005) und sollte nicht mehr verwendet werden. Und `Buffer.from(10)` erzeugt keinen Buffer der Größe 10, sondern wirft einen `TypeError` – eine Zahl ist für `Buffer.from` kein gültiges Argument. Wer eine Größe meint, nimmt `alloc` oder `allocUnsafe`.\n\n## Encodings: dieselben Bytes, andere Brille\n\nDer zweite große Nutzen von `Buffer` gegenüber einem nackten `Uint8Array` sind die Encodings. Ein Encoding ist keine Umwandlung der Bytes, sondern eine Vorschrift, wie man dieselben Bytes als Text liest oder wie man Text in Bytes zerlegt. Der Kreislauf zwischen beiden Seiten sieht so aus:\n\n```mermaid\ngraph LR\n  S[\"String<br/>(Unicode-Text)\"] -->|\"Buffer.from(str, enc)\"| B[\"Buffer<br/>(rohe Bytes)\"]\n  B -->|\"buf.toString(enc)\"| S\n  B -->|\"buf.toString('hex')\"| H[\"Hex-Text\"]\n  B -->|\"buf.toString('base64')\"| B64[\"Base64-Text\"]\n```\n\nBeim Hineinschreiben mit `Buffer.from(str, enc)` sage ich: Zerlege diesen Text nach dieser Regel in Bytes. Beim Herauslesen mit `buf.toString(enc)` sage ich: Deute diese Bytes nach dieser Regel als Text. Wichtig ist, dass beide Seiten dasselbe Encoding meinen, sonst kommt Unsinn heraus.\n\nDie gebräuchlichen Encodings lassen sich in zwei Gruppen teilen. Die einen beschreiben Text-zu-Bytes: `utf8` (der Standard), `utf16le`, `latin1` (auch `binary` genannt) und `ascii`. Die anderen sind Byte-zu-Text-Darstellungen, die beliebige Binärdaten druckbar machen: `hex`, `base64` und `base64url`. Gerade `hex` und `base64` braucht man ständig, etwa um einen Hash lesbar zu machen oder Binärdaten in JSON zu transportieren.\n\n```js\nconst buf = Buffer.from('Grüße', 'utf8');\n\nconsole.log(buf.length);            // 7 bytes, not 5\nconsole.log('Grüße'.length);        // 5 characters\n\nconsole.log(buf.toString('hex'));     // 4772c3bcc39f65 byte view as hex\nconsole.log(buf.toString('base64'));  // R3LDvMOfZQ== byte view as base64\n\nconst b64 = buf.toString('base64');\nconst back = Buffer.from(b64, 'base64').toString('utf8');\nconsole.log(back);                    // 'Grüße' again\n```\n\nDer interessante Punkt steht in den ersten beiden Zeilen: Der String hat fünf Zeichen, der Buffer aber sieben Bytes. Das ü und das ß belegen in UTF-8 jeweils zwei Bytes. Bytes und Zeichen sind eben nicht dasselbe, und wer `length` verwechselt, berechnet Offsets falsch. Genau daraus entsteht in der Praxis ein verwandtes Problem: Bei Streams kann ein Multibyte-Zeichen an der Stelle zerschnitten werden, an der ein Datenblock endet und der nächste beginnt. Für sauberes Dekodieren über solche Blockgrenzen hinweg gibt es den `string_decoder`, der halbe Zeichen zwischenpuffert – aber das ist ein Thema für sich, das der Beitrag [Node.js-Streams und Backpressure](https://www.mikebild.dev/de/blog/nodejs-streams-und-backpressure/) genauer ausführt.\n\n## Zahlen als Bytes: die Byte-Reihenfolge\n\nEncodings kümmern sich um Text. Sobald man aber ein Binärformat auseinandernimmt – einen Dateikopf, ein Netzwerkprotokoll, einen Frame – geht es um Zahlen, die über mehrere Bytes verteilt sind. Dafür bringt `Buffer` eine Reihe von Lese- und Schreibmethoden mit, die eine feste Anzahl Bytes an einem Offset als Ganzzahl deuten: `readUInt8`, `readUInt16BE`, `readUInt32LE`, dazu die vorzeichenbehafteten Varianten und für 64 Bit die `BigInt`-Fassungen wie `readBigUInt64BE`. Das Gegenstück zum Schreiben heißt `writeUInt32BE` und so fort.\n\nDas Kürzel am Ende ist der Punkt, an dem viele stolpern: `BE` steht für Big-Endian, `LE` für Little-Endian – also für die Frage, ob das höchstwertige Byte zuerst oder zuletzt steht. Wählt man beim Lesen die andere Reihenfolge als der Schreiber, bekommt man eine völlig andere Zahl heraus, ohne dass ein Fehler geworfen wird. Netzwerkprotokolle sind traditionell Big-Endian, viele Dateiformate legen ihre Reihenfolge selbst fest; man muss also im jeweiligen Format nachsehen und darf nicht raten.\n\n```js\nconst buf = Buffer.alloc(4);\n\nbuf.writeUInt32BE(0x89504e47, 0); // PNG signature bytes as one number\nconsole.log(buf);                 // <Buffer 89 50 4e 47>\n\nconsole.log(buf.readUInt32BE(0).toString(16)); // 89504e47\nconsole.log(buf.readUInt32LE(0).toString(16)); // 474e5089, reversed bytes\n```\n\nDieselben vier Bytes ergeben je nach gewählter Reihenfolge zwei verschiedene Zahlen. Der Buffer selbst weiß nicht, was richtig ist – die Bedeutung steckt im Format, nicht in den Bytes.\n\n## Die Speicherfalle: slice und subarray teilen den Speicher\n\nJetzt zur zweiten großen Falle, und die schnappt besonders oft zu, weil das Verhalten von Arrays ein anderes ist. Wenn man von einem gewöhnlichen JavaScript-Array `slice` aufruft, bekommt man eine Kopie. Bei einem Buffer ist das anders: `buf.subarray(start, end)` und das ältere `buf.slice(start, end)` erzeugen beide keine Kopie, sondern eine Sicht auf denselben zugrunde liegenden Speicher. Wer in die Sicht schreibt, verändert das Original mit.\n\n```mermaid\ngraph TD\n  M[\"Byte-Block im Speicher<br/>[1, 2, 3, 4, 5]\"]\n  O[\"original<br/>(ganzer Block)\"] --> M\n  V[\"view = original.subarray(1, 3)<br/>(Bytes 1 und 2)\"] --> M\n```\n\nBeide Namen, `original` und `view`, zeigen auf denselben Speicher. Ein Schreibzugriff über den einen ist über den anderen sichtbar.\n\n```js\nconst original = Buffer.from([1, 2, 3, 4, 5]);\n\nconst view = original.subarray(1, 3); // shares memory, no copy\nview[0] = 99;\n\nconsole.log(view);      // <Buffer 63 03>\nconsole.log(original);  // <Buffer 01 63 03 04 05> byte 1 changed too!\n```\n\nDas Byte an Position 1 im Original hat sich geändert, obwohl ich scheinbar nur in `view` geschrieben habe. In einer Funktion, die einen Teil-Buffer zurückgibt und der Aufrufer diesen dann verändert, führt das zu Fehlern, die schwer zu finden sind, weil die Ursache und die Wirkung an ganz verschiedenen Stellen des Codes stehen.\n\nWer eine unabhängige Kopie braucht, muss sie explizit anlegen – etwa mit `Buffer.from` über die Sicht oder mit `buf.copy` in einen frisch reservierten Ziel-Buffer.\n\n```js\nconst original = Buffer.from([1, 2, 3, 4, 5]);\n\nconst copy = Buffer.from(original.subarray(1, 3)); // independent copy\ncopy[0] = 99;\n\nconsole.log(copy);      // <Buffer 63 03>\nconsole.log(original);  // <Buffer 01 02 03 04 05> untouched\n```\n\nZur Namensfrage, weil sie oft aufkommt: Die Node-Dokumentation empfiehlt inzwischen `subarray`. Der Name `slice` existiert bei Buffern nur noch aus Kompatibilitätsgründen und weicht bewusst vom Verhalten von `TypedArray.prototype.slice` ab, das eine Kopie liefert. Diese Inkonsistenz ist historisch gewachsen und eine häufige Verwechslungsquelle. Ich rate dazu, bei Buffern konsequent `subarray` zu schreiben – dann weiß man beim Lesen sofort, dass hier Speicher geteilt wird und keine Kopie entsteht.\n\n## Wo Buffer im Alltag auftauchen\n\nMan legt Buffer selten von Hand an; meist bekommt man sie von der Plattform gereicht. Das Modul `fs` gibt beim Lesen ohne Encoding-Angabe einen Buffer zurück. Streams liefern ihre Daten in Buffer-Blöcken, wenn man sie nicht als Text konfiguriert. Das Netzwerkmodul `net` reicht empfangene Pakete als Buffer weiter. Und `crypto` arbeitet fast durchgängig mit Buffern – ein Hash etwa entsteht als Buffer, den man anschließend mit `toString('hex')` lesbar macht.\n\n```js\nconst crypto = require('node:crypto');\n\nconst hash = crypto.createHash('sha256')\n  .update('hello world')   // accepts string or Buffer\n  .digest();               // returns a Buffer\n\nconsole.log(Buffer.isBuffer(hash)); // true\nconsole.log(hash.toString('hex'));  // b94d27b9934d3e08a52e52d7da7dabfa...\n```\n\nHier sieht man die zwei Rollen des Buffers schön zusammen: `update` nimmt Text oder Bytes entgegen, `digest` gibt rohe Bytes zurück, und `toString('hex')` macht diese Bytes für Logs und Vergleiche lesbar. Wer verstehen will, in welchem Ausführungsmodell diese Ein- und Ausgaben laufen und warum das nicht blockiert, findet den Hintergrund im Beitrag [Node.js-Training](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/) zur Event-Loop-Architektur.\n\n## Was hängen bleibt\n\nDrei Dinge tragen weit. Erstens: Ein Buffer ist ein `Uint8Array` mit Node-Extras – wer das im Kopf hat, versteht den Indexzugriff und die Länge sofort und erwartet keine String-Magie. Zweitens: `alloc` ist der sichere Standard, `allocUnsafe` ein bewusster Kompromiss für Fälle, in denen man den Speicher ohnehin komplett überschreibt; alles andere riskiert, alte Daten preiszugeben. Drittens: `subarray` und `slice` teilen den Speicher, kopieren nicht – wer eine unabhängige Fassung braucht, kopiert von Hand.\n\nDie beiden Fallen – uninitialisierter Speicher aus `allocUnsafe` und die geteilte Sicht aus `subarray` – haben eines gemeinsam: Sie fallen im schnellen Test nicht auf und schlagen erst später oder unter Last zu. Genau deshalb lohnt es sich, das Verhalten einmal bewusst durchzuspielen, statt es dem Zufall zu überlassen. Dateizugriff, Netzwerk und Kryptografie laufen in Node über rohe Bytes; wer den Buffer verstanden hat, liest ihre Ausgabe in der Konsole als das, was sie ist: eine Darstellung der Daten ohne jede Umdeutung.\n\n## Weiterführende Quellen\n\n- [Node.js-Training: Event-Loop-Architektur](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/)\n- [Node.js-Streams und Backpressure](https://www.mikebild.dev/de/blog/nodejs-streams-und-backpressure/)\n- [Node.js-Dokumentation: Buffer](https://nodejs.org/api/buffer.html)\n- [Buffer.alloc(size, fill, encoding)](https://nodejs.org/api/buffer.html#static-method-bufferallocsize-fill-encoding)\n- [Buffer.allocUnsafe(size)](https://nodejs.org/api/buffer.html#static-method-bufferallocunsafesize)\n- [Buffers and character encodings](https://nodejs.org/api/buffer.html#buffers-and-character-encodings)\n- [buf.slice() – Legacy-Hinweis, subarray bevorzugen](https://nodejs.org/api/buffer.html#bufslicestart-end)\n- [MDN: Uint8Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array)"},{"slug":"svelte-stores-zustand-ausserhalb-der-komponente","title":"Svelte Stores: State außerhalb der Komponente","summary":"Svelte Stores sind ein minimales Modell für State, der nicht in eine einzelne Komponente passt: writable, readable, derived – plus die $-Kurzschreibweise, die abonniert und beim Unmount aufräumt.","tags":["Software","Frontend","Svelte"],"published_at":"2022-07-19T10:00:00+00:00","updated_at":"2026-07-13T13:25:28.073895+00:00","url":"https://www.mikebild.dev/de/blog/svelte-stores-zustand-ausserhalb-der-komponente/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/5778b78c43791f059972fe0f796b916d21e93dfa08bc5ec98d43e0cab5248f93/svelte-stores-zustand-ausserhalb-der-komponente-vorlesen.mp3","audio_duration_secs":831,"text":"Wer eine State-Management-Bibliothek installiert, bevor die Besitzfrage geklärt ist, hat den Preis schon bezahlt: eine zusätzliche Abhängigkeit, ein zweites mentales Modell und Boilerplate – für ein Problem, das das Projekt womöglich gar nicht hat. Der Auslöser ist meist harmlos. Zwei Komponenten, die nicht direkt verwandt sind, sollen sich einen Wert teilen; die Reaktivität innerhalb einer Komponente funktioniert wunderbar, aber über die Komponentengrenze hinweg fehlt der Mechanismus. Aus der React-Welt kennt man den Griff zur großen Bibliothek, und er passiert reflexartig, obwohl die eigentliche Frage keine API-Frage ist: Wem gehört dieser State, und wie viel Maschinerie darf er kosten?\n\nSvelte hat für diesen Fall bereits ein Werkzeug an Bord, das kaum eine Handvoll Funktionen umfasst – Stores. Sie beantworten die Besitzfrage mit dem kleinstmöglichen Mittel und lassen die Kosten erst dann steigen, wenn ein konkretes Problem es erzwingt. Der eigentliche Gewinn liegt dabei weniger in den Funktionen selbst als in der Disziplin, die sie erlauben: klein anfangen und die Grenze zu „mehr“ bewusst ziehen statt sie reflexartig zu überschreiten.\n\nEin Store ist State, der außerhalb der Komponente steht. Er gehört keiner einzelnen `.svelte`-Datei, sondern liegt in einem separaten Modul und kann von überall aus abonniert werden. Damit löst er ein Problem, das in Komponentenbäumen schnell lästig wird: das Durchreichen von Werten durch mehrere Ebenen, nur damit ein tief verschachteltes Kind an eine Information kommt. Wer meinen Artikel zur [Svelte-Reaktivität](https://www.mikebild.dev/de/blog/svelte-reaktivitaet-compiler-ohne-virtual-dom/) gelesen hat, kennt bereits das Fundament: Der Compiler baut die Reaktivität zur Bauzeit ein, ohne Virtual DOM. Stores sind die logische Fortsetzung dieses Gedankens für State, der die Komponentengrenze überschreiten muss.\n\n**Redaktionelle Einordnung (Juli 2026):** Der Text verwendet das Store-Modell von Svelte 3/4. Stores sind in Svelte 5 weiterhin unterstützt und besonders für asynchrone Streams oder manuell kontrollierte Subscriptions nützlich. Für geteilten State kann heute aber auch eine `.svelte.js`- oder `.svelte.ts`-Datei mit Runes genügen; Stores sind nicht mehr die einzige eingebaute Antwort außerhalb einer Komponente.\n\n## Die Besitzfrage steht vor der Werkzeugfrage\n\nBevor ich zu den konkreten Svelte-Funktionen komme, ein Prinzip, das mit Svelte selbst nichts zu tun hat und in jedem komponentenbasierten Frontend gilt – ob React, Vue oder Svelte. Ich habe es an anderer Stelle für React ausführlich hergeleitet, im [State-Management-Vergleich](https://www.mikebild.dev/de/blog/state-management-vergleich/), und der Kern trägt eins zu eins hierher: Die entscheidende Frage lautet nicht „welche State-Bibliothek“, sondern „wem gehört dieser State?“. Ein State gehört an die tiefste Stelle, an der ihn alle Beteiligten noch erreichen. Solange das eine einzelne Komponente ist, bleibt er dort – lokal, ohne jede Zeremonie. Erst wenn sich mehrere, nicht verwandte Komponenten denselben Wert teilen oder er von außerhalb des Baums kommt, verlässt er die Komponente. Ein globaler oder modulweiter Store ist damit die Ausnahme, die man begründet, nicht der Normalfall, in den man hineinfällt.\n\nWarum ich das so betone, ist ein Kostenargument. Jede Ebene, auf die ich State hebe, macht ihn teurer: mehr Stellen, die schreiben dürfen, schwerer nachvollziehbare Änderungen, eine größere Fläche für Fehler. In Projekten habe ich den umgekehrten Weg oft genug mitgemacht – erst wird die große Bibliothek gewählt, dann wird State hineingeschoben, bis alles global ist. Die Gegenprobe geht andersherum: Braucht dieser Wert wirklich mehrere Komponenten, und sind sie so entkoppelt, dass eine gemeinsame Elternkomponente sie nicht ohnehin verbinden würde? In meiner Erfahrung bleibt nach dieser Frage deutlich weniger globaler State übrig als anfangs vermutet.\n\nFür die Entscheidung, ob ein Wert die Komponente verlassen soll, nutze ich drei Kriterien. Die Reichweite: Teilen ihn mindestens zwei Komponenten, die nicht in einer sauberen Eltern-Kind-Beziehung stehen? Die Herkunft: Wird der Wert von außerhalb des Baums geschrieben – Timer, WebSocket, Sensor? Und die Lebensdauer: Muss er unabhängig davon existieren, ob eine bestimmte Komponente gerade gemountet ist? Trifft keines zu, bleibt der State lokal, und jede darüberliegende Lösung ist bezahlte Komplexität ohne Gegenwert. Trifft eines zu, ist ein schlanker, modulweiter Store die richtige Antwort – in Svelte buchstäblich eine Zeile Code. Die eigentliche Arbeit steckt in der Entscheidung davor, nicht in der API danach.\n\n## Der Contract, nicht die Klasse\n\nEin Store ist in Svelte kein spezieller Typ und keine Klasse, von der man erben muss. Ein Store ist jedes Objekt, das eine `.subscribe`-Methode nach einem festen Vertrag bereitstellt – und optional eine `.set`-Methode, wenn es schreibbar sein soll. `subscribe` muss den aktuellen Wert synchron an den Subscriber liefern und eine Funktion zum Abbestellen zurückgeben. Genau dieser Contract ist es, der zählt. Alles, was ihn erfüllt, funktioniert mit der `$`-Syntax.\n\nIn der Praxis schreibt man diese `.subscribe`-Methode fast nie selbst. Svelte liefert drei Fabrikfunktionen mit, die einem die Arbeit abnehmen. Sie stammen alle aus demselben Modul:\n\n```js\nimport { writable, readable, derived, get } from 'svelte/store';\n```\n\nDie drei Store-Typen decken drei unterschiedliche Rollen ab:\n\n- `writable` – les- und schreibbar. Der Standardfall für geteilten State, den irgendeine Komponente oder Funktion verändern darf. Bietet `.set(value)`, `.update(fn)` und `.subscribe(run)`.\n- `readable` – nur lesbar von außen. Der Wert wird intern über einen Start-Callback gesetzt, ideal für externe Quellen wie eine Uhr, einen WebSocket oder einen Event-Listener.\n- `derived` – abgeleitet. Berechnet seinen Wert reaktiv aus einem oder mehreren anderen Stores und aktualisiert sich automatisch, wenn sich eine seiner Quellen ändert.\n\n## Der writable Store und die $-Kurzschreibweise\n\nFangen wir mit dem häufigsten Fall an – einem gemeinsamen Zähler, den mehrere Komponenten teilen. Der Store liegt in einem eigenen Modul, damit ihn jede Komponente importieren kann:\n\n```js\n// stores.js\nimport { writable } from 'svelte/store';\n\nexport const count = writable(0);\n```\n\nDas ist der komplette Store. Ein Modul, eine Zeile, ein Anfangswert. Jetzt kann eine beliebige Komponente diesen Zähler lesen und schreiben – und hier kommt die eleganteste Idee von Svelte ins Spiel, die `$`-Kurzschreibweise:\n\n```svelte\n<script>\n  import { count } from './stores.js';\n</script>\n\n<button on:click={() => $count++}>\n  Clicked {$count} times\n</button>\n```\n\nDas `$count` im Template ist keine magische globale Variable, sondern eine Anweisung an den Compiler. Er erkennt das Präfix `$` vor einer Store-Referenz und generiert daraus zweierlei: erstens ein `count.subscribe(...)`, das den aktuellen Wert in eine lokale Variable schreibt und das Template bei jeder Änderung neu rendert; zweitens – und das ist der entscheidende Teil – ein automatisches `unsubscribe`, sobald die Komponente zerstört wird. Man muss sich um das Aufräumen also nicht kümmern, der Compiler nimmt es einem ab.\n\nAuch das Schreiben ist über die Kurzschreibweise abgedeckt. `$count++` übersetzt der Compiler in einen `count.set(...)`-Aufruf mit dem inkrementierten Wert. Wer es expliziter mag oder eine komplexere Änderung braucht, greift zu den Methoden direkt:\n\n```svelte\n<script>\n  import { count } from './stores.js';\n\n  function increment() {\n    count.update(n => n + 1);\n  }\n\n  function reset() {\n    count.set(0);\n  }\n</script>\n\n<button on:click={increment}>+1</button>\n<button on:click={reset}>Reset</button>\n<p>Current value: {$count}</p>\n```\n\nDer Unterschied zwischen `set` und `update` ist schnell erklärt. `set` überschreibt den Wert bedingungslos, `update` bekommt den aktuellen Wert als Argument und gibt den neuen zurück – praktisch immer dann, wenn der neue Wert vom alten abhängt.\n\nUnd jetzt der Kern des Ganzen: Sobald eine zweite Komponente denselben Store importiert und `$count` verwendet, teilen sich beide denselben State. Ändert die eine den Wert, sieht ihn die andere sofort. Kein Prop wird durch den Baum gereicht, keine gemeinsame Elternkomponente muss den State halten. Der Store ist die gemeinsame Quelle, an der alle Beteiligten hängen.\n\n```mermaid\ngraph TD\n  S[\"writable count<br/>(stores.js)\"]\n  A[\"Component A<br/>$count lesen + schreiben\"]\n  B[\"Component B<br/>$count anzeigen\"]\n  C[\"Component C<br/>$count anzeigen\"]\n  S --> A\n  S --> B\n  S --> C\n  A -. \"count.set()\" .-> S\n```\n\nMan sieht hier gut, was der Store leistet: Er entkoppelt die Komponenten voneinander. A, B und C müssen weder verwandt sein noch voneinander wissen. Sie kennen nur den Store.\n\n## Abgeleiteter State mit derived\n\nOft braucht man einen Wert, der sich aus einem anderen ergibt. Man könnte ihn in jeder Komponente neu ausrechnen – oder man leitet ihn einmal zentral ab und alle profitieren. Genau dafür ist `derived` da:\n\n```js\n// stores.js\nimport { writable, derived } from 'svelte/store';\n\nexport const count = writable(0);\n\nexport const doubled = derived(count, $count => $count * 2);\n```\n\n`doubled` ist selbst wieder ein Store, aber ein nur lesbarer. Er hat keine `.set`-Methode, denn sein Wert ergibt sich vollständig aus `count`. Ändert sich `count`, berechnet `derived` den Wert neu und benachrichtigt seine Abonnenten. In der Komponente sieht das dann so aus, als wäre es ein ganz normaler Store:\n\n```svelte\n<script>\n  import { count, doubled } from './stores.js';\n</script>\n\n<p>{$count} doubled is {$doubled}</p>\n<button on:click={() => $count++}>+1</button>\n```\n\n`derived` kann auch aus mehreren Quellen ableiten. Dann übergibt man ein Array von Stores und bekommt im Callback ein Array der Werte:\n\n```js\nimport { writable, derived } from 'svelte/store';\n\nexport const price = writable(100);\nexport const quantity = writable(2);\n\nexport const total = derived(\n  [price, quantity],\n  ([$price, $quantity]) => $price * $quantity\n);\n```\n\nNeben dieser synchronen Form gibt es eine zweite, die einen `set`-Callback bekommt. Damit lässt sich der abgeleitete Wert verzögert oder bedingt setzen – etwa nach einem Timeout oder erst, wenn eine bestimmte Bedingung erfüllt ist:\n\n```js\nimport { derived } from 'svelte/store';\n\n// Sets the value one second after each change of 'count'\nexport const delayed = derived(\n  count,\n  ($count, set) => {\n    const id = setTimeout(() => set($count), 1000);\n    return () => clearTimeout(id);\n  }\n);\n```\n\nDer Rückgabewert der Callback-Function ist hier eine Aufräumfunktion, die Svelte vor der nächsten Berechnung und beim letzten Unsubscribe ausführt. So bleibt kein Timeout hängen.\n\n## readable für externe Quellen\n\nNicht jeder State wird von der Anwendung selbst geschrieben. Manchmal kommt er von außen – eine Uhr, die tickt, ein WebSocket, der Nachrichten liefert, ein Sensor. Für solche Fälle gibt es `readable`. Von außen kann man ihn nur lesen; wer ihn füttert, ist ein Start-Callback, der die Quelle aufsetzt:\n\n```js\nimport { readable } from 'svelte/store';\n\nexport const time = readable(null, set => {\n  const id = setInterval(() => set(new Date()), 1000);\n\n  return () => clearInterval(id);\n});\n```\n\nHier lohnt sich ein genauer Blick auf das Timing, weil es ein zentrales Prinzip von Stores offenlegt. Der Start-Callback läuft nicht sofort beim Anlegen des Stores. Er läuft erst, wenn der erste Abonnent hinzukommt – wenn die Zahl der Subscriber von null auf eins springt. Und die zurückgegebene Funktion – der Stop-Teil – läuft, wenn der letzte Abonnent abspringt, also von eins auf null. Solange niemand zuhört, tickt bei diesem `readable` kein Intervall und öffnet sich kein Socket. Bei `writable` bleiben `set` und `update` auch ohne Subscriber wirksam; lazy ist nur eine optionale Start-/Stop-Arbeit. `derived` wiederum berechnet für seine Abonnenten und meldet sich ohne sie von den Quellen ab.\n\nIn der Komponente ist der Umgang wieder trivial, denn `$time` abonniert automatisch und räumt beim Unmount auf:\n\n```svelte\n<script>\n  import { time } from './stores.js';\n</script>\n\n<p>The time is {$time?.toLocaleTimeString()}</p>\n```\n\n## Der Fallstrick: subscribe ohne unsubscribe\n\nBisher lief alles über die `$`-Kurzschreibweise, und dort ist das Aufräumen kein Thema. Aber Stores sind nicht auf Komponenten beschränkt. Man kann sie genauso in einem reinen JavaScript-Modul verwenden – in einer Utility-Function, einem Service, irgendwo außerhalb von Svelte. Und genau da lauert der häufigste Fehler.\n\nAußerhalb einer Komponente gibt es kein `$` und keinen Compiler, der aufräumt. Man ruft `subscribe` von Hand auf, und die Methode gibt eine Unsubscribe-Function zurück. Diese Funktion **muss** man aufheben und aufrufen, sonst hat man ein Leak:\n\n```js\n// utils.js\nimport { count } from './stores.js';\n\n// WRONG: the callback runs forever.\ncount.subscribe(value => {\n  console.log('count is now', value);\n});\n```\n\nDer Rückgabewert von `subscribe` wird hier einfach ignoriert. Solange das Modul beziehungsweise dieser Nutzungskontext lebt, bleibt der Callback registriert. Der Store zählt weiterhin einen Abonnenten, der Stop-Callback von 1→0 wird nicht ausgelöst – bei einem `readable` mit Intervall liefe der Timer entsprechend weiter. Richtig ist es so:\n\n```js\n// utils.js\nimport { count } from './stores.js';\n\nconst unsubscribe = count.subscribe(value => {\n  console.log('count is now', value);\n});\n\n// ... later, when no longer needed:\nunsubscribe();\n```\n\nDieselbe Regel gilt sinngemäß auch innerhalb von Komponenten, falls man dort aus irgendeinem Grund manuell `subscribe` statt `$` verwendet: Dann muss man den Unsubscriber in `onDestroy` aufrufen. Aber ehrlich gesagt ist das fast immer ein Zeichen, dass man einfach die `$`-Syntax hätte nehmen sollen. Genau diese Buchhaltung nimmt sie einem ab.\n\nWenn man den Wert nur ein einziges Mal lesen will – ohne Abonnement, ohne Reaktivität – gibt es einen sauberen Weg, der das Leak-Problem gar nicht erst aufkommen lässt:\n\n```js\nimport { get } from 'svelte/store';\nimport { count } from './stores.js';\n\nconst current = get(count);\n```\n\n`get` abonniert intern kurz, liest den aktuellen Wert und meldet sich sofort wieder ab. Der Wert ist eine Momentaufnahme und aktualisiert sich nicht mehr. Genau deshalb passt `get` gut für einmaliges Auslesen in einem JS-Modul – und gerade nicht in einen heißen Pfad, der ständig durchlaufen wird.\n\n```mermaid\nsequenceDiagram\n  participant C as Erster Subscriber\n  participant S as Store\n  participant Q as Externe Quelle\n  C->>S: subscribe(run)\n  S->>Q: start-Callback (0 -> 1)\n  Q-->>S: Werte fließen\n  S-->>C: run(value)\n  C->>S: unsubscribe()\n  S->>Q: stop-Callback (1 -> 0)\n```\n\n## Wann Store, und wann nicht\n\nDer Store ist das richtige Werkzeug, sobald State nicht mehr sauber in eine einzelne Komponente passt – wenn mehrere, nicht direkt verwandte Komponenten sich denselben Wert teilen, oder wenn ein Wert von außerhalb des Komponentenbaums kommt. Das klassische Symptom, das nach einem Store ruft, ist Prop-Drilling: Man reicht einen Wert durch drei, vier Ebenen, obwohl ihn nur die unterste Komponente wirklich braucht. Ein Store schneidet diesen Durchreiche-Pfad ab. Die betroffenen Komponenten importieren den Store direkt, und die Zwischenebenen müssen von dem Wert gar nichts wissen.\n\nBleibt die Frage, ob es daneben noch eine große State-Management-Bibliothek braucht. Für die allermeisten Svelte-Anwendungen: nein. Die drei Store-Typen decken erstaunlich viel ab. `writable` für den State, den man verändert, `derived` für alles, was sich daraus ergibt, `readable` für externe Quellen. Das ist kein Kompromiss, kein „reicht erstmal“ – es ist für den Großteil realer Anwendungen schlicht ausreichend. Ein zentrales Store-Modul, das die einzelnen Stores exportiert, gibt der Anwendung eine klare, überschaubare Struktur, ohne dass man ein weiteres mentales Modell und eine weitere Abhängigkeit mitschleppen muss.\n\nWo liegt dann der Rand, ab dem man über mehr nachdenkt? Ich würde sagen: Erst wenn der State wirklich komplex wird – viele voneinander abhängige Übergänge, strikte Nachvollziehbarkeit jeder Änderung, ausgefeiltes Time-Travel-Debugging über eine große Domäne hinweg. Solche Anforderungen sind selten, und selbst dann baut man häufig eine dünne Schicht auf Stores auf, statt sie zu ersetzen. Solange die Frage aber nur lautet „Wie teilen sich meine Komponenten einen Wert?“, ist die Antwort ein Store – und mehr braucht es nicht.\n\nWas nach mehreren Projekten mit Svelte bleibt, ist weniger eine API-Empfehlung als eine Reihenfolge: erst den Besitzer des States bestimmen, dann das Werkzeug. Für das 2022 gezeigte Modell sind `writable`, `readable` und `derived` oft genug. Die `$`-Kurzschreibweise erledigt in Komponenten die Subscription-Buchhaltung; außerhalb bleibt die schlichte Regel, jedes manuelle `subscribe` mit einem `unsubscribe` zu paaren.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-svelte.html\n- Repository: [mikebild.dev](https://www.mikebild.dev) – dieser und weitere Artikel zu Svelte und Frontend-Architektur\n- [Svelte-Reaktivität: der Compiler ohne Virtual DOM](https://www.mikebild.dev/de/blog/svelte-reaktivitaet-compiler-ohne-virtual-dom/)\n- [Svelte-Docs – Stores und Runes](https://svelte.dev/docs/svelte/stores)"},{"slug":"nodejs-streams-und-backpressure","title":"Node.js-Streams: Backpressure verstehen und nutzen","summary":"Warum ein schneller Producer einen langsamen Consumer nicht überrennen darf – und wie pipe, pipeline, der Rückgabewert von write() und das drain-Event die Flusskontrolle in Node.js regeln.","tags":["Software","Node.js","Backend"],"published_at":"2022-06-21T10:00:00+00:00","updated_at":"2026-07-13T07:58:20.382471+00:00","url":"https://www.mikebild.dev/de/blog/nodejs-streams-und-backpressure/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/3e382a54f3c092a6f85e773ef76392af44fc3ece61a521d50f1d211acd968fb0/nodejs-streams-und-backpressure-vorlesen.mp3","audio_duration_secs":713,"text":"```\nFATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory\n```\n\nDieser Abbruch kommt nicht davon, dass zu viele Daten verarbeitet wurden. Er kommt davon, dass sie zu schnell hereinkamen. Eine Quelle liest ein Logfile von einer schnellen SSD, jeder Chunk geht per `write()` an ein langsames Ziel – und weil niemand hinsieht, was `write()` zurückgibt, wächst der interne Puffer, bis der Heap voll ist. Die Datenmenge ist dabei egal; entscheidend ist das Tempogefälle zwischen den beiden Enden.\n\nStreams gelten vielen als Dateikram rund um `createReadStream`. Sie sind aber das grundlegende Modell, wie Node.js Daten stückweise durch eine Pipeline schiebt, ohne dass jemals alles gleichzeitig im Speicher liegt. Der Teil davon, der in echten Systemen über \"läuft stabil\" und \"Prozess stirbt nachts um drei\" entscheidet, heißt Backpressure. Die Stream-API sieht harmlos aus, man verkettet ein paar `pipe`-Aufrufe, und es funktioniert – bis zu dem Tag, an dem die Quelle schneller liefert als das Ziel schlucken kann.\n\n## Warum Streams überhaupt\n\nDer klassische Weg, eine Datei zu verarbeiten, ist `fs.readFile`. Die Funktion liest die komplette Datei in einen Buffer und ruft dann den Callback auf. Bei einer 2-MB-Konfigurationsdatei völlig in Ordnung. Bei einem 4-GB-Logfile hat man ein Problem: Node.js versucht, vier Gigabyte in den Heap zu laden, und der Prozess kippt um, lange bevor überhaupt eine Zeile verarbeitet wurde.\n\nEin Stream macht es anders. Er liest die Datei in kleinen Häppchen – Chunks – und reicht jeden Chunk einzeln weiter. Zu jedem Zeitpunkt liegt nur ein kleiner Puffer im Speicher, unabhängig davon, ob die Datei zwei Megabyte oder zwei Terabyte groß ist. Der Speicherverbrauch bleibt konstant. Das ist der Kernnutzen, und er gilt nicht nur für Dateien: HTTP-Requests, TCP-Sockets, Kompression, Verschlüsselung – all das sind in Node.js Streams.\n\n## Die vier Typen\n\nBevor es um Flusskontrolle geht, lohnt sich der Überblick über die vier Stream-Typen. Alle vier erben von `EventEmitter`, kommunizieren also über Events – das ist der rote Faden, der sich durch die ganze asynchrone Seite von Node.js zieht, wie ich sie im [Node.js-Training: Vom Event Loop zur Architekturentscheidung](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/) ausführlicher beschreibe.\n\n- Ein Readable ist eine Quelle, aus der man liest. `fs.createReadStream()` liefert einen Readable, ebenso der eingehende Body eines HTTP-Requests.\n- Ein Writable ist ein Ziel, in das man schreibt. `fs.createWriteStream()` oder die HTTP-Response sind Writables.\n- Ein Duplex ist lesbar und schreibbar zugleich, aber mit getrennten Kanälen. Ein `net.Socket` ist das Paradebeispiel: rein und raus über dieselbe Verbindung.\n- Ein Transform ist ein Duplex, bei dem der Output eine Funktion des Inputs ist. `zlib.createGzip()` nimmt rohe Bytes rein und gibt komprimierte raus. Genau hier setzt man eigene Verarbeitungslogik an.\n\nDiese Typen kombiniert man zu einer Pipeline: eine Quelle, beliebig viele Transforms in der Mitte, ein Ziel am Ende.\n\n```mermaid\nflowchart LR\n    A[\"Readable<br/>fs.createReadStream\"] -->|chunks| B[\"Transform<br/>gzip / uppercase\"]\n    B -->|chunks| C[\"Writable<br/>fs.createWriteStream\"]\n    C -.->|drain-Signal| A\n    style A fill:#2b6cb0,color:#fff\n    style B fill:#2f855a,color:#fff\n    style C fill:#c05621,color:#fff\n```\n\nDie durchgezogenen Pfeile sind der Datenfluss vorwärts. Der gestrichelte Pfeil zurück ist das Entscheidende – er ist Backpressure. Dazu gleich mehr.\n\n## Der naive Ansatz und warum er kippt\n\nNehmen wir an, wir wollen eine große Datei in eine andere kopieren und dabei jeden Chunk verarbeiten. Der intuitive Code sieht so aus:\n\n```js\nconst fs = require('node:fs');\n\nconst source = fs.createReadStream('huge-input.log');\nconst target = fs.createWriteStream('huge-output.log');\n\nsource.on('data', (chunk) => {\n  target.write(chunk);\n});\n```\n\nDas läuft. Meistens. Und dann kommt der Fall, in dem `huge-input.log` von einer schnellen SSD kommt und `huge-output.log` auf ein langsames Netzlaufwerk geschrieben wird. Die Quelle feuert `data`-Events, so schnell sie kann, und `target.write()` nimmt jeden Chunk entgegen. Kann das Ziel nicht schnell genug wegschreiben, puffert Node.js die Chunks intern – und dieser Puffer kennt keine Obergrenze. Der Speicher füllt sich, `RSS` klettert, der Garbage Collector arbeitet gegen eine immer größere Menge, und irgendwann beendet das Betriebssystem den Prozess.\n\nDas ist der Fallstrick in einem Satz: **Wer den Rückgabewert von `write()` ignoriert und einfach weiterschreibt, produziert unbegrenztes Puffern.**\n\n## Was write() eigentlich sagt\n\n`writable.write(chunk)` gibt einen Boolean zurück, und dieser Boolean ist die halbe Miete:\n\n- `true` bedeutet: Der interne Puffer liegt noch unter `highWaterMark`, du darfst weiterschreiben.\n- `false` bedeutet: Der Puffer hat `highWaterMark` erreicht oder überschritten. Hör auf zu schreiben, sonst puffert Node.js ins Blaue.\n\n`highWaterMark` ist die Puffergrenze. Im hier behandelten Node 16/18 lag der allgemeine Default für Byte-Streams bei 16384 Bytes, also 16 KiB; seit Node 22 sind es 64 KiB. Im Object-Mode – dazu später – bleibt der Default bei 16 Objekten. Wichtig zu verstehen: Diese Grenze ist ein **Schwellwert, keine harte Mauer**. Node.js hört nicht auf zu puffern, wenn man `highWaterMark` überschreitet. Es sagt einem nur höflich per `false`-Rückgabe Bescheid, dass es genug ist. Wer die Warnung überhört, puffert trotzdem weiter – auf eigenes Risiko.\n\nWenn `write()` einmal `false` geliefert hat, sollte man pausieren und auf das `'drain'`-Event warten. Das Writable emittiert `'drain'`, sobald sein Puffer wieder geleert ist – das Signal, dass man weiterschreiben darf. Von Hand sieht der korrekte Zyklus so aus:\n\n```js\nsource.on('data', (chunk) => {\n  const ok = target.write(chunk);\n  if (!ok) {\n    source.pause();\n    target.once('drain', () => source.resume());\n  }\n});\n```\n\nWir schreiben, prüfen den Rückgabewert, pausieren die Quelle bei `false` und nehmen sie erst nach `'drain'` wieder auf. Genau das ist Flusskontrolle: Ein schneller Producer wird ausgebremst, damit er einen langsamen Consumer nicht überrennt.\n\nDen Zyklus kann man auch als State Machine zeichnen:\n\n```mermaid\nstateDiagram-v2\n    [*] --> Schreiben\n    Schreiben --> Schreiben: write() liefert true\n    Schreiben --> Pausiert: write() liefert false<br/>source.pause()\n    Pausiert --> Schreiben: 'drain'-Event<br/>source.resume()\n```\n\n## pipe erledigt das automatisch\n\nDie gute Nachricht: Diesen ganzen `pause`/`resume`-Tanz muss man in der Praxis selten von Hand schreiben. `pipe()` macht genau das im Hintergrund.\n\n```js\nconst fs = require('node:fs');\n\nfs.createReadStream('huge-input.log')\n  .pipe(fs.createWriteStream('huge-output.log'));\n```\n\n`pipe()` verbindet Quelle und Ziel miteinander, prüft intern den Rückgabewert von `write()`, pausiert den Readable bei Backpressure und resumt ihn nach `'drain'`. Backpressure ist also gelöst, ohne dass man eine Zeile Flusskontrolle selbst schreibt. `pipe()` gibt außerdem das Ziel zurück, sodass man verketten kann – praktisch, wenn ein Transform dazwischenhängt:\n\n```js\nconst fs = require('node:fs');\nconst zlib = require('node:zlib');\n\nfs.createReadStream('huge-input.log')\n  .pipe(zlib.createGzip())\n  .pipe(fs.createWriteStream('huge-input.log.gz'));\n```\n\nUnd trotzdem rate ich davon ab, produktiv nur `pipe()` zu verketten. Der Grund ist die Error Handling.\n\n## Warum pipeline die bessere Wahl ist\n\n`pipe()` hat eine tückische Schwäche: Es propagiert Fehler nicht. Wirft eine Stage mitten in der Kette einen Fehler – die Netzverbindung bricht weg, die Platte ist voll – dann bekommt das ein `pipe()`-Verbund nicht sauber mit. Die anderen Streams werden nicht zerstört, Dateideskriptoren bleiben offen, der Stream hängt. In einem langlaufenden Serverprozess sammeln sich solche Leaks an, bis irgendwann die Ressourcen ausgehen.\n\n`stream.pipeline()` löst das. Es verkettet die Stages genauso, aber es propagiert Fehler über alle Stufen hinweg und ruft bei Fehler oder regulärem Ende `destroy()` auf jedem beteiligten Stream auf. Nichts leakt. Es gibt die Callback-Variante:\n\n```js\nconst fs = require('node:fs');\nconst zlib = require('node:zlib');\nconst { pipeline } = require('node:stream');\n\npipeline(\n  fs.createReadStream('huge-input.log'),\n  zlib.createGzip(),\n  fs.createWriteStream('huge-input.log.gz'),\n  (err) => {\n    if (err) {\n      console.error('pipeline failed', err);\n      return;\n    }\n    console.log('pipeline succeeded');\n  }\n);\n```\n\nSeit Node.js 15 – in den 2022 aktuellen Release-Linien 16 und 18 also fest verankert – gibt es zusätzlich eine Promises-Variante aus `node:stream/promises`. Die liest sich in async-Functions deutlich sauberer, weil man Fehler mit try/catch fängt:\n\n```js\nconst fs = require('node:fs');\nconst zlib = require('node:zlib');\nconst { pipeline } = require('node:stream/promises');\n\nasync function compress() {\n  try {\n    await pipeline(\n      fs.createReadStream('huge-input.log'),\n      zlib.createGzip(),\n      fs.createWriteStream('huge-input.log.gz')\n    );\n    console.log('done');\n  } catch (err) {\n    console.error('pipeline failed', err);\n  }\n}\n\ncompress();\n```\n\nDie Promises-Variante nimmt auch Optionen, etwa ein `signal` für ein `AbortSignal`, mit dem man die Pipeline von außen abbrechen kann. Der Punkt, der für mich den Ausschlag gibt: Backpressure regelt `pipeline()` genauso automatisch wie `pipe()` – aber es räumt auch auf. Für Produktionscode ist `pipeline()` die Standardwahl, `pipe()` höchstens für kurze, isolierte Skripte.\n\n## Einen eigenen Transform schreiben\n\nDer Punkt, an dem Streams richtig nützlich werden, ist der eigene Transform in der Mitte. Ein Transform bekommt Chunks rein, verarbeitet sie und schiebt das Ergebnis raus. Das Skelett besteht aus einer `transform`-Funktion, die drei Dinge bekommt: den Chunk, das Encoding und einen Callback, den man am Ende aufrufen muss.\n\n```js\nconst { Transform } = require('node:stream');\n\nconst upperCase = new Transform({\n  transform(chunk, encoding, callback) {\n    const transformed = chunk.toString().toUpperCase();\n    this.push(transformed);\n    callback();\n  }\n});\n```\n\n`this.push()` reicht das Ergebnis an die nächste Stage weiter, `callback()` signalisiert, dass dieser Chunk fertig verarbeitet ist. Ruft man `callback()` mit einem Fehler auf – `callback(err)` –, propagiert `pipeline()` diesen Fehler sauber durch die ganze Kette. Eingebaut in eine Pipeline sieht das so aus:\n\n```js\nconst fs = require('node:fs');\nconst { pipeline } = require('node:stream/promises');\n\nawait pipeline(\n  fs.createReadStream('input.txt'),\n  upperCase,\n  fs.createWriteStream('output.txt')\n);\n```\n\nUnd weil der `upperCase`-Transform wie jeder Stream an der Flusskontrolle teilnimmt, greift Backpressure auch hier: Ist das Ziel langsam, bremst die Pipeline über den Transform hinweg bis zur Quelle zurück. Man muss dafür nichts extra tun.\n\n## Async-Iteration als moderne Alternative\n\nSeit Node.js 10 implementiert jeder Readable `Symbol.asyncIterator`, lässt sich also direkt mit `for await ... of` konsumieren. Das liest sich in async-Functions oft klarer als `on('data')` und behält die Flusskontrolle bei, ohne dass man `pause` und `resume` anfassen muss.\n\n```js\nconst fs = require('node:fs');\n\nasync function countLines(path) {\n  const stream = fs.createReadStream(path, { encoding: 'utf8' });\n  let lines = 0;\n  for await (const chunk of stream) {\n    lines += chunk.split('\\n').length - 1;\n  }\n  return lines;\n}\n```\n\nDer Backpressure entsteht hier implizit: Solange der Schleifenkörper mit einem Chunk beschäftigt ist – und ein `await` darin ihn zusätzlich aufhält –, fordert die Schleife keinen neuen Chunk an, und der Readable liefert von selbst erst dann den nächsten. Man bekommt also dieselbe Bremse wie bei `pipe()`, nur in gewöhnlichem async/await-Code. Wichtig bleibt die Regel, die gleich noch kommt: Wer so iteriert, sollte den Stream nicht parallel auch noch über `on('data')` konsumieren.\n\n## Object-Mode und ein paar Regeln\n\nStandardmäßig arbeiten Streams mit Buffers und Strings. Setzt man `objectMode: true`, dürfen die Chunks beliebige JavaScript-Objekte sein – nützlich, wenn man eine Pipeline aus geparsten Datensätzen baut statt aus rohen Bytes. Zu beachten ist dabei, dass `highWaterMark` im Object-Mode als Anzahl Objekte zählt, nicht als Bytes; der Default sind 16 Objekte. Und ein bestehender Stream lässt sich nicht nachträglich sicher auf Object-Mode umschalten – das entscheidet man bei der Konstruktion.\n\nEine Regel, an der es in Reviews immer wieder hakt: Konsummethoden nicht mischen. Pro Readable wählt man genau eine Art zu lesen – entweder `on('data')`, oder `on('readable')`, oder `pipe()`, oder async-Iteration. Wer zwei davon kombiniert, produziert Race Conditions, verlorene Chunks und schwer reproduzierbare Bugs. Ein Readable, eine Konsummethode.\n\n## Was hängenbleiben soll\n\nStreams sind das Werkzeug, um beliebig große Datenmengen mit konstantem Speicher zu verarbeiten – der Puffer bleibt klein, egal wie groß die Quelle ist. Backpressure ist der Mechanismus, der das trägt: Der Rückgabewert von `write()` und das `'drain'`-Event bilden die Flusskontrolle, die einen schnellen Producer davon abhält, einen langsamen Consumer zu überrennen. `highWaterMark` ist dabei der Schwellwert, an dem `write()` von `true` auf `false` kippt – eine Warnung, kein Zwang.\n\nVon Hand muss man diesen Zyklus selten schreiben, weil `pipe()` und vor allem `pipeline()` ihn automatisch regeln. Der entscheidende Unterschied liegt in der Error Handling: `pipeline()` propagiert Fehler und räumt alle Streams auf, `pipe()` tut das nicht. Mit diesem Unterschied im Hinterkopf entsteht Node.js-Verarbeitungscode, der auch bei vier Gigabyte und einem langsamen Ziel nicht umkippt – sondern einfach ein bisschen langsamer wird, so wie es sein soll.\n\n## Weiterführende Quellen\n\n- Repository zur Node.js-Einführung: [mikebild/introduction-nodejs](https://github.com/mikebild/introduction-nodejs) – Lehrmaterial zu Async-Patterns inklusive `10-streams.md`.\n- [Node.js Stream API (v18)](https://nodejs.org/docs/latest-v18.x/api/stream.html) – Readable, Writable, Duplex, Transform, `pipe`, `pipeline`, `write`, `drain`, `highWaterMark`.\n- [Aktuelle Node.js Stream API – heutige Defaults](https://nodejs.org/api/stream.html)\n- [stream.pipeline in der offiziellen Doku](https://nodejs.org/api/stream.html#streampipelinesource-transforms-destination-callback)\n- [Guide: Backpressuring in Streams](https://nodejs.org/learn/modules/backpressuring-in-streams)"},{"slug":"fitness-functions-messbare-architektur","title":"Fitness Functions: ausgewählte Architektureigenschaften prüfen","summary":"Fitness Functions machen ausgewählte Eigenschaften wie Kopplung, Abhängigkeiten und Latenz kontinuierlich prüfbar. Fachliche Qualität und Architekturabwägungen ersetzen sie nicht.","tags":["Software","Architektur"],"published_at":"2022-05-24T10:00:00+00:00","updated_at":"2026-07-19T18:46:18.505233+00:00","url":"https://www.mikebild.dev/de/blog/fitness-functions-messbare-architektur/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/c46ce971e174566c7367923c5edbfdfd366b5bbefe7b3a688f4cf59e34fd7326/fitness-functions-messbare-architektur-vorlesen.mp3","audio_duration_secs":761,"text":"Eine Fitness Function ist kein vollständiges Qualitätsmodell. Sie bewertet gezielt eine operationalisierbare Architektureigenschaft – etwa Zyklenfreiheit, Kopplung, Latenz oder Verträglichkeit zwischen zwei Services. In diesem Beitrag konzentriere ich mich auf automatisierte, möglichst eindeutige Prüfungen, die bei jedem Commit ein eng begrenztes Urteil fällen. Eigenschaften wie Verständlichkeit oder fachliche Angemessenheit brauchen weiterhin menschliche Bewertung.\n\nWie groß der Unterschied zur üblichen Praxis ist, zeigt ein Satz, der in Architektur-Reviews regelmäßig fällt: „Die Domäne darf natürlich nicht auf die Infrastruktur zugreifen.“ Alle nicken. Es wird ein Diagramm gezeichnet, vielleicht ein Confluence-Eintrag angelegt. Drei Sprints später zeigt eine Import-Anweisung von der Domain-Schicht direkt in den Datenbank-Adapter. Niemand hat das böswillig getan. Es war Freitagnachmittag, der Bugfix musste raus, und die Regel stand nun mal nicht im Code, sondern in einem Protokoll, das keiner mehr liest. Wo sich eine Architektureigenschaft eindeutig prüfen lässt, ist ein automatischer Check verlässlicher als die Erinnerung im Review.\n\nEine Fitness Function schließt den Weg von einer gewünschten Eigenschaft bis zu einer wiederholbaren Entscheidung.\n\n:::process{title=\"Von der Architekturaussage zum Gate\" role=\"primary\" preferredPattern=\"connected-process\"}\n1. Eigenschaft · eine fachlich relevante Architekturqualität benennen\n2. Beobachtung · ein reproduzierbares Signal dafür auswählen\n3. Schwelle · den noch akzeptablen Bereich ausdrücklich festlegen\n4. Prüfung · das Signal automatisch und wiederholbar auswerten\n5. Gate · eine Verletzung sichtbar machen oder die Änderung passieren lassen\n6. Anpassung · Eigenschaft und Schwelle bei veränderten Bedingungen erneut prüfen\n:::\n\nDie Reihenfolge bewahrt den fachlichen Grund der Messung. Ein vorhandenes Tool allein definiert noch keine sinnvolle Fitness Function.\n\n## Vom Wunsch zur mechanischen Prüfung\n\nDer Begriff stammt aus *Building Evolutionary Architectures* von Neal Ford, Rebecca Parsons und Patrick Kua (O'Reilly, 2017), entliehen aus dem Evolutionary Computing, wo eine Fitness-Function misst, wie nah eine Lösung am Ziel ist. Übertragen auf Software heißt das: Eine architectural fitness function ist ein objektiver, mechanischer Integritätstest für ein oder mehrere architectural characteristics – also für die Eigenschaften, die man früher etwas sperrig \"-ilities\" nannte: Performance, Skalierbarkeit, Kopplung, Sicherheit.\n\nDer Perspektivwechsel, der in der Praxis den Unterschied macht, ist folgender: Architektur wird nicht länger als Zustand behandelt, den man einmal beschließt, sondern als Eigenschaft, die man kontinuierlich einhält. Das klingt nach einer Nuance, ist aber ein Bruch im Arbeitsmodus. Ein Diagramm beschreibt, wie das System aussehen soll. Eine Fitness Function beschreibt, wie es aussehen muss – und bricht den Build, wenn es das nicht tut.\n\nDamit wandert ein Teil der Governance von der Sitzung in die Pipeline. Die automatisierbaren Regeln laufen bei jedem Commit mit; Reviews bleiben dort nötig, wo Zielkonflikte, Kontext und fachliche Angemessenheit zählen. Der Gewinn ist vor allem das frühe Feedback: Eine explizit vereinbarte Regel hängt nicht davon ab, ob sie im nächsten Review wieder auffällt. Wer schon einmal erlebt hat, wie ein einziger schleichender Kopplungsverstoß über Monate ein Refactoring blockiert, kennt den Preis der nachgelagerten Kontrolle.\n\n## Die erste Fitness Function ist ein Unit-Test\n\nDer Einstieg ist unspektakulär, und das ist die gute Nachricht. Die einfachste Kategorie sind statische, atomare Fitness Functions – Regeln über die Struktur des Codes, die sich als ganz normaler Test ausdrücken lassen. In der Java-Welt hat sich dafür ArchUnit von TNG etabliert, das über Bytecode-Analyse Paket- und Schichtabhängigkeiten prüft. Für .NET gibt es NetArchTest, im JavaScript-Umfeld den Dependency-Cruiser. Das Prinzip ist überall dasselbe.\n\nDie eingangs beschworene Regel – Domäne kennt keine Infrastruktur – sieht als Fitness Function so aus:\n\n```java\n@ArchTest\nstatic final ArchRule domain_must_not_depend_on_infrastructure =\n    noClasses().that().resideInAPackage(\"..domain..\")\n        .should().dependOnClassesThat().resideInAPackage(\"..infrastructure..\");\n\n@ArchTest\nstatic final ArchRule no_cyclic_dependencies =\n    slices().matching(\"..(*)..\").should().beFreeOfCycles();\n```\n\nDas Schöne daran: Diese Regel ist selbst dokumentierend. Sie steht im Repository, sie läuft in der CI, und sie ist nicht verhandelbar, ohne dass jemand sie sichtbar ändert. Der Confluence-Eintrag von oben beschreibt eine Absicht. Der Test macht dieselbe gewählte Regel ausführbar und ihre Verletzung sichtbar.\n\nDer zweite Block – `no_cyclic_dependencies` – adressiert einen Klassiker, der in Reviews notorisch schwer zu erkennen ist. Zyklische Abhängigkeiten entstehen selten in einem großen Schritt. Sie wachsen aus vielen kleinen, je für sich harmlosen Importen. Ein Mensch sieht den Zyklus im Diagramm nicht mehr, wenn er über sieben Module läuft. Die Maschine schon. Genau solche Eigenschaften – afferent/efferent coupling, Instabilität, Zyklenfreiheit – sind der Idealfall für statische Fitness Functions, weil sie deterministisch und billig zu prüfen sind.\n\n## Mehr als eine Werkzeugliste\n\nWer bei Fitness Functions nur an ArchUnit denkt, verkennt die Reichweite des Konzepts. Ford, Parsons und Kua schlagen eine Taxonomie vor, die sich gut als Sortierhilfe eignet, weil sie den richtigen Testtyp für die jeweilige Eigenschaft nahelegt. Zwei Achsen genügen für den Anfang:\n\n```mermaid\nquadrantChart\n    title Einordnung von Fitness Functions\n    x-axis \"atomic (ein Merkmal)\" --> \"holistic (Zusammenspiel)\"\n    y-axis \"triggered (bei Bedarf)\" --> \"continuous (laufend)\"\n    quadrant-1 \"Latenz-/Lasttests in Prod\"\n    quadrant-2 \"Chaos-/Resilienz-Checks\"\n    quadrant-3 \"ArchUnit-Dependency-Rules\"\n    quadrant-4 \"Security-Scan pro Commit\"\n```\n\n*Atomic* misst eine einzelne Eigenschaft isoliert, *holistic* prüft das Zusammenspiel mehrerer – etwa Sicherheit und Latenz gemeinsam unter Last. *Triggered* läuft auf Anstoß, etwa im Build, *continuous* läuft dauerhaft gegen das produktive System. Dazu kommen weitere Dimensionen aus dem Buch: static versus dynamic, automated versus manual, sowie temporal fitness functions, die bewusst nur für eine begrenzte Zeit gelten – etwa eine Erinnerung, eine Bibliotheksmigration bis zu einem Stichtag abzuschließen.\n\nDer praktische Wert dieser Einteilung liegt nicht in der Klassifikation um ihrer selbst willen. Er liegt darin, dass sie eine Frage erzwingt, der man sonst ausweicht: An welches konkrete architectural characteristic ist diese Prüfung eigentlich gekoppelt? Wer das nicht beantworten kann, baut vermutlich gerade eine Vanity-Metric.\n\n## Wenn die Eigenschaft dynamisch ist\n\nNicht alles lässt sich statisch prüfen. Latenz zum Beispiel ist keine Eigenschaft des Quelltexts, sondern des laufenden Systems. Hier kommen dynamische Fitness Functions ins Spiel, die gegen ein Deployment messen. Konzeptionell bleibt es dasselbe – ein Schwellwert, ein Vergleich, ein Urteil:\n\n```python\ndef latency_fitness(service):\n    p95 = measure_p95_ms(service, window=\"5m\")\n    assert p95 < LATENCY_BUDGET_MS, f\"p95 {p95} exceeds budget\"\n```\n\nSo sauber das aussieht, so gefährlich ist es, diesen Assert blind als hartes Gate in die CI zu hängen. Latenzwerte schwanken. Ein p95 über ein Fünf-Minuten-Fenster kann durch einen einzelnen Garbage-Collection-Peak oder einen langsamen Nachbarn im Cluster reißen, ohne dass sich an deiner Software irgendetwas geändert hätte. Wer das ignoriert, produziert flaky builds – und ein flaky gate wird binnen zwei Wochen weggeklickt oder auskommentiert. Dann hast du die Reibung der Prüfung, aber nicht mehr ihren Nutzen.\n\nDie pragmatische Antwort ist eine Trennung: Statische, deterministische Eigenschaften dürfen den Build blockieren. Dynamische, statistisch schwankende Eigenschaften laufen als beobachtende, getriggerte Checks mit statistischen Fenstern und alarmieren, statt hart zu brechen. Dieselbe Idee, zwei Betriebsmodi – je nachdem, wie deterministisch das gemessene Merkmal ist.\n\nFür resilienzbezogene Eigenschaften lohnt der Blick in Michael Nygards *Release It!*. Die dort beschriebenen Stability Patterns – Circuit Breaker, Bulkhead, Timeout – liefern nicht nur Bausteine für robuste Systeme, sondern auch prüfbare Erwartungen: Öffnet der Circuit Breaker unter simulierter Überlast wie vorgesehen? Auch das ist eine Fitness Function, nur eben eine dynamische.\n\n## Kopplung zwischen Services messen\n\nDie interessanteste Kopplung in verteilten Systemen verläuft nicht innerhalb eines Deployables, sondern zwischen ihnen. Zwei Teams, zwei Services, ein Vertrag – und die stille Annahme, dass der andere sein API nicht kaputtmacht. Consumer-Driven Contract Testing macht diese Annahme prüfbar. Mit Pact und einem Pact Broker beschreibt der Konsument seine Erwartung an den Provider, und der Provider verifiziert gegen genau diese Erwartung.\n\nAls Fitness Function landet das direkt als Schritt in der Deployment-Pipeline:\n\n```bash\npact-broker can-i-deploy \\\n    --pacticipant Checkout \\\n    --version \"$GIT_SHA\" \\\n    --to-environment production\n```\n\nDie Frage \"kann ich das deployen, ohne einen Konsumenten zu brechen?\" wird damit von einer Bauchentscheidung zu einer mechanischen Antwort. Aus der Annahme, kompatibel zu bleiben, wird ein Nachweis – Kopplung als Governance-as-Code. In der Praxis ist das oft der Moment, in dem Teams zum ersten Mal wirklich unabhängig deployen können, weil die Angst vor dem unbemerkten Bruch aus dem Prozess genommen ist.\n\n## Der Ort, an dem alles zusammenläuft\n\nEine lose Sammlung von Regeln entfaltet wenig Wirkung. Wirksam werden Fitness Functions erst als Gate in der Auslieferung. Die Deployment-Pipeline – konzeptuell seit Humble und Farley etabliert – ist der natürliche Ort dafür. Zwischen Build und Deploy liegt eine Barriere, die mehrere Kategorien parallel prüft:\n\n```mermaid\nflowchart LR\n    commit[\"commit\"] --> build[\"build\"]\n    build --> gate{\"Fitness<br/>Functions<br/>Gate\"}\n    gate -->|static / atomic| s[\"Dependency &amp; Cycle Rules\"]\n    gate -->|dynamic / holistic| d[\"Latency p95 &amp; Load\"]\n    gate -->|contract| c[\"Pact can-i-deploy\"]\n    s -->|fail = break build| commit\n    d -->|fail = break build| commit\n    c -->|fail = break build| commit\n    gate -->|all green| deploy[\"deploy\"]\n```\n\nDie roten Rückpfeile sind der Kern der Idee: Ein Verstoß führt nicht zu einem Ticket, das irgendwann priorisiert wird, sondern zu einem gebrochenen Build, den derjenige repariert, der ihn verursacht hat – sofort, mit vollem Kontext, bevor der Fehler in die Historie einsintert. Das ist derselbe Mechanismus, der Continuous Integration wirksam macht, angewandt auf Architektureigenschaften.\n\nUnd hier schließt sich der Kreis zur messbaren Auslieferungsqualität. *Accelerate* von Forsgren, Humble und Kim und die DORA Four Key Metrics – Deployment Frequency, Lead Time for Changes, Change Failure Rate, Time to Restore – liefern Metrics für die Delivery-Ebene. Fitness Functions können dazu passende technische Leitplanken prüfen. Eine verletzte Kopplungsregel fällt dann früh auf; ob dadurch Lead Time oder Fehlerrate sinken, muss das Team an den eigenen Daten überprüfen.\n\n## Wo es in der Praxis kippt\n\nSo überzeugend das Konzept ist, so zuverlässig scheitert seine naive Umsetzung an drei Stellen – und die habe ich in Projekten alle gesehen.\n\nErstens die Vanity-Metric. Eine grüne Fitness Function, die eine reine Test-Coverage-Schwelle prüft, fühlt sich nach Sicherheit an und misst doch nichts Architektonisches. Coverage sagt, wie viel Code ausgeführt wird, nicht, ob die Abhängigkeiten stimmen. Jede Function muss an ein konkretes architectural characteristic gekoppelt sein, sonst ist sie Theater.\n\nZweitens die Wartungslast. Fitness Functions sind Code und altern wie Code. Ich habe Codebasen gesehen, in denen jede zweite Regel ein `@ArchIgnore` trug, weil es unter Termindruck schneller ging, die Ausnahme einzutragen, als das Problem zu lösen. Ohne Ownership und regelmäßiges Aufräumen der Ausnahmeliste erodiert die Aussagekraft, bis nur noch grünes Rauschen bleibt. Diese Regeln brauchen dieselbe Pflege wie Produktionscode – sonst sind sie schlimmer als nichts, weil sie falsche Sicherheit erzeugen.\n\nDrittens der zu späte Einstieg. Wer Fitness Functions auf eine gewachsene Legacy-Codebasis loslässt, bekommt beim ersten Lauf Tausende Violations – und garantiert Frust. Der Ausweg ist eine Baseline: Man friert den Ist-Zustand ein, deklariert Fitness von jetzt an und lässt nur noch Verbesserung zu, keine Verschlechterung. \"Declare fitness from now on\" ist kein Betrug an der Reinheit. In gewachsenen Systemen ist es die einzige Art, wie so etwas überhaupt anläuft.\n\n## Fazit\n\nDer rote Faden dieser drei Bausteine – statische Regeln, dynamische Messungen, Vertragsprüfungen – ist ein einziger Gedanke: Architektur ist eine Eigenschaft, die man einhält, keine Entscheidung, die man einmal trifft. Fitness Functions verschieben die Kontrolle vom Review in die Pipeline und damit von der Hoffnung zur Gewissheit.\n\nDer Gewinn ist selten die einzelne Regel. Er liegt im veränderten Arbeitsmodus. Teams, die ihre wichtigsten Architektureigenschaften mechanisch absichern, diskutieren in Reviews nicht mehr über Importrichtungen, sondern über Fachlichkeit. Sie deployen öfter, weil die stille Angst vor dem unbemerkten Bruch aus dem Prozess genommen ist. Und sie refaktorieren mutiger, weil ein Netz aus grünen Checks sofort meldet, wenn eine Umstrukturierung eine Eigenschaft verletzt.\n\nDer Anfang darf klein sein: eine einzige atomare, statische Regel, die eine Eigenschaft absichert, an der dir wirklich etwas liegt. Eine Abhängigkeitsregel, die den Build bricht, ist mehr wert als zwanzig Regeln in einem Dokument, das keiner liest. Der Rest wächst von dort – als schlichter Code, der bei jedem Build mitläuft und niemandem einen zusätzlichen Prozess aufzwingt.\n\n## Weiterführende Quellen\n\n- Neal Ford, Rebecca Parsons, Patrick Kua: *Building Evolutionary Architectures* (O'Reilly, 2017) – [Kapitel zu Fitness Functions](https://www.oreilly.com/library/view/building-evolutionary-architectures/9781491986356/ch02.html)\n- [ArchUnit (TNG)](https://www.archunit.org/) – statische Architekturtests für Java\n- Nicole Forsgren, Jez Humble, Gene Kim: *Accelerate* (2018) und die DORA Four Key Metrics – [martinfowler.com: StateOfDevOpsReport](https://www.martinfowler.com/bliki/StateOfDevOpsReport.html)\n- Michael Nygard: *Release It!* (2. Auflage, 2018) – Stability Patterns für resilienzbezogene Fitness Functions\n- [Pact](https://docs.pact.io/) – Consumer-Driven Contract Testing mit `can-i-deploy`"},{"slug":"svelte-reaktivitaet-compiler-ohne-virtual-dom","title":"Svelte-Reaktivität: der Compiler statt Virtual DOM","summary":"Svelte kompiliert Komponenten zur Build-Zeit in gezielten DOM-Code, statt zur Laufzeit einen Virtual DOM zu diffen. Ich zeige das mentale Modell, die reaktive Zuweisung mit $: und die klassische push-Falle, die mir in Projekten immer wieder begegnet.","tags":["Software","Frontend","Svelte"],"published_at":"2022-04-26T10:00:00+00:00","updated_at":"2026-07-13T13:25:08.920456+00:00","url":"https://www.mikebild.dev/de/blog/svelte-reaktivitaet-compiler-ohne-virtual-dom/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/f0ef6dba8a07d70d3d2da6973b896daf952f6459e64a67c3601a3aa53ca0c987/svelte-reaktivitaet-compiler-ohne-virtual-dom-vorlesen.mp3","audio_duration_secs":727,"text":"Zwei Regeln tragen einen großen Teil des Legacy-Modells von Svelte 3. Erstens: Der Compiler erkennt Zuweisungen wie `count += 1`, `obj.key = value` oder `array[index] = value`; ein bloßer Methodenaufruf wie `array.push(...)` ist keine solche Zuweisung. Zweitens: Eine `$:`-Anweisung hängt an den Variablen, die der Compiler in ihrem Rumpf statisch erkennt. Daraus folgen die direkte Syntax und einige typische Überraschungen.\n\nBeide Regeln stammen aus derselben Quelle. Svelte entscheidet schon beim Übersetzen einer Komponente, welche Variable welchen DOM-Knoten berührt. Was im Browser ankommt, sind fertige Update-Anweisungen; ein Baumvergleich zur Laufzeit findet nicht statt. `count += 1` genügt deshalb tatsächlich – aber nur, solange man weiß, woran der Compiler eine Änderung erkennt.\n\nGrundlage ist der Stand von Svelte 3, im April 2022 die aktuelle Hauptversion. Alles Folgende beruht auf dem assignment-basierten Reaktivitätsmodell und den `$:`-Deklarationen. Vollständig ist das nicht; es ist der Ausschnitt, an dem sich das mentale Modell am schnellsten begreifen lässt.\n\n**Redaktionelle Einordnung (Juli 2026):** Svelte 5 arbeitet für neuen Code bevorzugt mit Runes wie `$state`, `$derived` und `$effect`. Das hier erklärte `$:`- und Zuweisungsmodell gehört zum weiterhin unterstützten Legacy-Modus. „Ohne Virtual DOM“ stimmt nach wie vor, beschreibt allein aber nicht mehr die heutige Reaktivitäts-API.\n\n## Der Denkfehler, den fast alle mitbringen\n\nWer aus React kommt, trägt ein bestimmtes Bild im Kopf: Eine Komponente ist eine Funktion, die bei jeder State-Änderung neu läuft und eine Beschreibung des gewünschten UI States zurückgibt – einen Virtual-DOM-Baum. Das Framework vergleicht diesen neuen Baum mit dem alten, findet die Unterschiede und überträgt nur die auf das echte DOM. Dieses Diffing ist clever, aber es ist Arbeit, die bei jedem Update im Browser des Nutzers stattfindet. Rich Harris hat das in seinem oft zitierten Text „Virtual DOM is pure overhead“ scharf formuliert: Der Virtual DOM ist kein Feature, für das man dankbar sein sollte, sondern ein Mittel zum Zweck – und ein Mittel mit Preis.\n\nSvelte stellt die Frage anders. Wenn schon zur Build-Zeit feststeht, welche Teile des Markups von welchen Variablen abhängen, warum sollte man diese Zuordnung dann bei jedem Update im Browser neu ausrechnen? Der Svelte-Compiler analysiert die Komponente statisch und erzeugt imperativen JavaScript-Code, der genau die betroffenen DOM-Knoten aktualisiert. Kein Diff-Baum, kein Vergleich zur Laufzeit. Der Compiler weiß schon, dass sich bei einer Änderung von `count` genau dieser eine Textknoten ändern muss, und schreibt exakt diese Anweisung hin.\n\nWichtig ist mir dabei eine Präzisierung, die in Blogposts gern verschwimmt: Svelte ist nicht „gar keine Runtime“. Der kompilierte Code bringt einen kleinen Kern an Hilfsfunktionen mit, in der Größenordnung von wenigen Kilobyte. Was fehlt, ist der Laufzeit-Diff-Algorithmus und ein schwergewichtiges Framework, das im Bundle mitreist. Der korrekte Satz lautet also: kein Virtual DOM, kein Laufzeit-Diffing – nicht: null Runtime.\n\n## Was der Compiler übernimmt\n\nBevor ich zum Code komme, lohnt sich der Blick darauf, welche Aufgaben in Svelte vom Compiler statt von einer Laufzeit-Bibliothek erledigt werden. Genau diese Verschiebung erklärt, warum das Ganze weniger Overhead im Browser bedeutet:\n\n- Die statische Analyse, welcher DOM-Knoten von welcher Zustandsvariablen abhängt – das Ergebnis landet als fester Code im Bundle, nicht als Berechnung zur Laufzeit.\n- Die Erzeugung gezielter Update-Anweisungen, die nur die tatsächlich betroffenen Knoten anfassen, statt einen kompletten Baum zu vergleichen.\n- Die Auflösung der `$:`-Abhängigkeiten: Der Compiler bestimmt aus dem Quelltext, welche Variablen eine reaktive Anweisung liest, und erzeugt daraus den Code für die Neuauswertung.\n\nDieser letzte Punkt ist der, an dem die meisten Fallen liegen, und ihm widme ich weiter unten den größten Teil.\n\n## Reaktivität über Zuweisung\n\nSchauen wir uns die einfachste reaktive Komponente an. Eine Datei mit der Endung `.svelte` bündelt Skript, Markup und Style. Eine ganz normale `let`-Variable auf der obersten Ebene des `<script>`-Blocks ist bereits Komponentenzustand.\n\n```svelte\n<script>\n  let count = 0;\n\n  function handleClick() {\n    count += 1;\n  }\n</script>\n\n<button on:click={handleClick}>\n  Clicked {count} times\n</button>\n```\n\nEs gibt hier keine spezielle State-API. `count` ist eine Variable, `count += 1` ist eine Zuweisung. Der Punkt, an dem für mich alles hängt: Nicht die Variable an sich ist reaktiv, sondern die Zuweisung an sie ist das auslösende Event. Der Compiler hat gesehen, dass `count` im Markup referenziert wird. Also erzeugt er um jede Zuweisung herum den Code, der die Komponente als „dirty“ markiert und den betroffenen Textknoten neu schreibt. Was aussieht wie eine gewöhnliche Wertänderung, ist im kompilierten Ergebnis von einem gezielten Update begleitet.\n\nDas erklärt auch, warum manche Änderungen ins Leere laufen. Wer `count` nur liest oder in einer Hilfsfunktion herumreicht, löst nichts aus. Erst der Zuweisungsoperator ist das Signal.\n\n## Abgeleitete Werte mit `$:`\n\nDer nächste Baustein sind Werte, die sich aus anderen ergeben. In React greift man dafür zu `useMemo` oder rechnet im Render-Körper. In Svelte 3 gibt es die reaktive Deklaration mit dem `$:`-Label.\n\n```svelte\n<script>\n  let count = 0;\n  $: doubled = count * 2;\n\n  function handleClick() {\n    count += 1;\n  }\n</script>\n\n<button on:click={handleClick}>\n  Clicked {count} times\n</button>\n\n<p>{count} * 2 = {doubled}</p>\n```\n\n`$: doubled = count * 2;` liest sich wie eine normale Zuweisung mit einem seltsamen Präfix, ist aber eine Anweisung an den Compiler: „Halte `doubled` aktuell, sobald sich eine seiner Abhängigkeiten ändert.“ Die Abhängigkeiten muss ich dabei nicht selbst angeben. Der Compiler bestimmt sie zur Compile-Zeit aus dem Quelltext – es sind alle Variablen, die in der Anweisung gelesen, aber nicht zugewiesen werden. Hier also `count`. Ändert sich `count` per Zuweisung, läuft die Deklaration erneut, `doubled` bekommt einen neuen Wert, und der abhängige Textknoten aktualisiert sich.\n\n`$:` kann mehr als abgeleitete Werte. Man kann damit auch Side Effects an State-Änderungen koppeln:\n\n```svelte\n<script>\n  let count = 0;\n\n  $: if (count >= 10) {\n    alert(\"count is dangerously high!\");\n  }\n\n  $: console.log(`count is ${count}`);\n</script>\n```\n\nBeide Formen laufen erneut, sobald sich `count` ändert. Der Mechanismus ist derselbe wie bei der Deklaration: gelesene Variablen werden zu Abhängigkeiten, Zuweisungen an sie lösen die Neuauswertung aus. Ein Detail für die Praxis: Diese Anweisungen laufen auch beim serverseitigen Rendern und vor dem ersten Client-Render einmal durch. Sie gehören zum Reaktivitätsfluss selbst und sind kein Ersatz für einen Mount-Hook.\n\nDas folgende Diagramm zeigt diesen Fluss von der Zuweisung bis zum DOM-Update – inklusive des Seitenpfads, der später zur Falle wird.\n\n```mermaid\nflowchart TD\n  A[Zuweisung: count += 1] --> B[Variable als dirty markiert]\n  B --> C[reaktive $:-Deklarationen<br/>neu ausgewertet]\n  C --> D[gezieltes DOM-Update<br/>nur betroffene Knoten]\n  E[array.push item] -.-> F[keine Zuweisung<br/>keine Referenzänderung]\n  F -.-> G[kein Update ausgelöst]\n```\n\n## Build-Zeit gegen Laufzeit\n\nDer Kontrast zu React wird deutlich, wenn man beide Spuren nebeneinanderlegt. Svelte bestimmt Abhängigkeiten und erzeugt Update-Code beim Build; der Browser führt diesen Code bei jeder Änderung weiterhin aus. React erzeugt beim Rendern einen neuen Elementbaum und gleicht ihn zur Laufzeit ab. Svelte verschiebt also die Analyse, nicht die gesamte Aktualisierungsarbeit, in den Build.\n\n```mermaid\nflowchart LR\n  subgraph Build[Build-Zeit – Svelte]\n    S1[.svelte-Komponente] --> S2[Svelte-Compiler]\n    S2 --> S3[gezielter Update-Code<br/>optimiertes Vanilla-JS]\n  end\n  subgraph Runtime[Laufzeit – React]\n    R1[Render-Funktion] --> R2[neuer Virtual-DOM-Baum]\n    R2 --> R3[Diff gegen alten Baum]\n    R3 --> R4[Patch auf echtes DOM]\n  end\n```\n\nDer Kern des Bildes ist nicht „Svelte gut, React schlecht“, sondern eine andere Aufteilung der Arbeit. React gleicht Elementbäume zur Laufzeit ab. Svelte kennt die statisch sichtbaren Abhängigkeiten bereits nach dem Build und liefert dafür gezielten Update-Code aus. Der Nutzer führt diesen Code weiterhin aus, nur keinen Virtual-DOM-Diff für diese Aktualisierung.\n\nBei den Bundle-Größen bleibe ich bewusst vorsichtig. Der Svelte-Kern liegt in der Größenordnung von wenigen Kilobyte, React plus ReactDOM deutlich darüber. Ich nenne das als Größenordnung, nicht als exakte Messung – die konkreten Zahlen hängen von Version, Bundler und dem, was man tatsächlich nutzt, ab, und wer eine harte Zahl braucht, misst am eigenen Projekt.\n\n## Die push-Falle\n\nJetzt zur Kehrseite desselben Modells – der Teil, um den es mir hier eigentlich geht. Weil Reaktivität an die Zuweisung gebunden ist, gibt es eine Klasse von Änderungen, die Svelte 3 schlicht nicht bemerkt: in-place-Mutationen ohne Zuweisung. Der Klassiker ist `Array.push`.\n\n```svelte\n<script>\n  let numbers = [1, 2, 3, 4];\n\n  function addBroken() {\n    numbers.push(numbers.length + 1);\n  }\n</script>\n\n<button on:click={addBroken}>Add number</button>\n\n<p>{numbers.join(\", \")}</p>\n```\n\nMan klickt, die Konsole bestätigt, dass das Array wächst – und im Browser tut sich nichts. Kein Fehler, kein Warnhinweis, einfach kein Update. Der Grund ist konsequent aus dem Modell abgeleitet: `numbers.push(...)` verändert das bestehende Array, aber es gibt keine Zuweisung an `numbers`. Ohne Zuweisung markiert Svelte die Variable nicht als dirty, und ohne dirty-Markierung läuft kein Update. Der Compiler kann nicht wissen, dass eine Methode das Innere des Arrays angefasst hat – er sieht nur Zuweisungen.\n\nDer Fix ist so einfach wie das Problem, sobald man das Modell verinnerlicht hat: Man muss eine Zuweisung erzwingen. Zwei Wege sind gängig.\n\n```svelte\n<script>\n  let numbers = [1, 2, 3, 4];\n\n  function addWorking() {\n    numbers = [...numbers, numbers.length + 1];\n  }\n\n  function addAlsoWorking() {\n    numbers.push(numbers.length + 1);\n    numbers = numbers;\n  }\n</script>\n\n<button on:click={addWorking}>Add number</button>\n\n<ul>\n  {#each numbers as n}\n    <li>{n}</li>\n  {/each}\n</ul>\n```\n\n`numbers = [...numbers, ...]` erzeugt ein neues Array und weist es zu – die saubere, an anderen Frameworks geschulte Variante. `numbers = numbers` sieht dagegen aus wie eine Nulloperation, ist aber genau das, was der Compiler braucht: eine Zuweisung an `numbers`, die die dirty-Markierung setzt. Damit Svelte eine Änderung überhaupt bemerkt, muss der Name der Variablen also auf der linken Seite eines `=` landen.\n\nBei Objekten ist die Lage anders als beim Methodenaufruf: Eine direkte Zuweisung wie `obj.key = value` erkennt Sveltes Legacy-Compiler und aktualisiert abhängige Stellen. Nicht sichtbar bleibt eine Mutation, die nur innerhalb einer aufgerufenen Funktion oder Methode geschieht. Dann braucht es anschließend eine für den Compiler sichtbare Zuweisung oder einen immutable Ersatz per Spread.\n\n## Die stille Schwester: indirekte Abhängigkeiten\n\nEs gibt eine zweite Falle, die aus demselben Prinzip folgt und in Reviews seltener sofort erkannt wird. Der Compiler bestimmt die Abhängigkeiten einer `$:`-Anweisung daraus, welche Variablen im Body direkt referenziert werden. Versteckt man eine Variable hinter einem Funktionsaufruf, taucht sie im Body nicht auf – und wird nicht als Abhängigkeit erkannt.\n\n```svelte\n<script>\n  let count = 0;\n\n  function getCount() {\n    return count;\n  }\n\n  // count is not read directly here,\n  // so it is not a detected dependency\n  $: label = `value is ${getCount()}`;\n</script>\n```\n\n`label` wird beim ersten Durchlauf berechnet und danach nie wieder aktualisiert, weil `count` im Body der reaktiven Deklaration nicht sichtbar ist – dort steht nur `getCount()`. Der Compiler hat keine Möglichkeit, die Abhängigkeit durch die Funktionsgrenze hindurch zu erkennen. Die Lösung ist, die Abhängigkeit sichtbar zu machen, also `count` direkt in der Anweisung zu referenzieren. Eine Schwäche ist das nicht; es ist die logische Konsequenz eines Modells, das statisch zur Compile-Zeit analysiert, statt dynamisch zur Laufzeit zu verfolgen.\n\n## Was das für die Praxis heißt\n\nWenn ich diese beiden Fallen zusammenziehe, ergibt sich ein erstaunlich kompaktes mentales Modell für Svelte 3, das sich auf zwei Punkte eindampfen lässt:\n\n- Reaktivität wird im Legacy-Modus durch statisch sichtbare Zuweisungen ausgelöst. Dazu zählen auch direkte Property- und Index-Zuweisungen, nicht aber beliebige Methodenaufrufe mit internen Mutationen.\n- Abhängigkeiten von `$:` müssen im Body direkt sichtbar sein. Was hinter einem Funktionsaufruf verschwindet, wird nicht getrackt.\n\nDiese zwei Regeln decken einen großen Teil der Überraschungen ab, die mir in Legacy-Svelte-Projekten begegnen. Beide folgen aus derselben Designentscheidung: Die Abhängigkeiten werden beim Build statisch analysiert. Das verlangt Wachsamkeit an Funktionsgrenzen und bei Methoden, die intern mutieren.\n\nIn einem kleinen Daten-Client tragen genau diese reaktiven Zuweisungen den Datenfluss: Eine Antwort ersetzt den bisherigen Wert, und die abhängigen Ausdrücke werden vom Compiler gezielt aktualisiert.\n\n## Fazit\n\nSvelte kehrt eine Grundannahme vieler Frontend-Frameworks um: Statt zur Laufzeit einen Virtual DOM zu vergleichen, lässt es den Compiler beim Build bestimmen, welcher State welchen DOM-Knoten berührt, und erzeugt daraus gezielten Update-Code. Damit entfällt diese Kategorie von Laufzeit-Diffing; wie groß der praktische Vorteil ist, hängt von Anwendung und Update-Pattern ab. Der Alltagscode bleibt angenehm direkt – `count += 1` genügt. Im Legacy-Modus hängt Reaktivität an statisch sichtbaren Zuweisungen. Wer das verinnerlicht, versteht sowohl die `push`-Falle als auch indirekte Abhängigkeiten.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-svelte.html\n- [Svelte Docs](https://svelte.dev/docs) – die `$:`-Syntax und die zur Compile-Zeit bestimmten Abhängigkeiten im Component-Format\n- [Svelte Tutorial](https://svelte.dev/tutorial) – die Kapitel zu Reactivity, Assignments und Updating arrays and objects\n- [Virtual DOM is pure overhead](https://svelte.dev/blog/virtual-dom-is-pure-overhead) – Rich Harris zur Motivation hinter dem Compiler-Ansatz\n- [Write less code](https://svelte.dev/blog/write-less-code) – das Leitprinzip von Svelte, weniger Laufzeit-Code auszuliefern"},{"slug":"nodejs-eventemitter-und-ereignismodell","title":"EventEmitter: das Event-Modell im Kern von Node.js","summary":"EventEmitter ist der gemeinsame Nenner unter Streams, HTTP-Server und Prozessen: Ich zeige on/once/off/emit, das Sonderverhalten von 'error' und wie man Listener-Lecks vermeidet.","tags":["Software","Node.js","Backend"],"published_at":"2022-04-19T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/nodejs-eventemitter-und-ereignismodell/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/ff5d94a15642346e127bcaafecd57bac715ec8a6fce53096c5852d47e47c4974/nodejs-eventemitter-und-ereignismodell-vorlesen.mp3","audio_duration_secs":702,"text":"Dass `.on('data', …)` an einem Stream, `.on('request', …)` an einem HTTP-Server und `.on('exit', …)` an einem Kindprozess gleich aussehen, ist keine Konvention, an die sich die Modulautoren freiwillig halten. Die Ähnlichkeit hat einen technischen Grund: Diese Objekte erben alle von derselben Klasse – dem `EventEmitter`. Wer diese eine Klasse durchdrungen hat, versteht damit einen großen Teil der Standardbibliothek.\n\nDieser Artikel nimmt den `EventEmitter` auseinander: erst die Idee dahinter, dann die konkrete API, dann die zwei, drei Stellen, an denen es in der Praxis wehtut. Wer vorher noch einmal nachlesen will, warum Node überhaupt ereignisgetrieben arbeitet, findet das im Beitrag zum [Node.js-Training rund um den Event Loop](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/). Hier geht es um die Schicht direkt darüber.\n\n## Der gemeinsame Nenner unter allem\n\nNode.js ist im Kern ereignisgetrieben. Ein HTTP-Server wartet nicht in einer Schleife auf die nächste Anfrage, sondern meldet an: \"Wenn eine Anfrage kommt, ruf diese Funktion.\" Ein Lesestrom sagt nicht \"gib mir die Daten\", sondern \"sag mir Bescheid, wenn ein Datenstück da ist\". Dieses Muster – Interesse anmelden, später benachrichtigt werden – zieht sich durch die gesamte Plattform.\n\nDamit dieses Muster nicht in jedem Modul neu erfunden wird, gibt es eine gemeinsame Basisklasse. `net.Server`, `http.Server`, die Stream-Klassen `Readable` und `Writable`, das globale `process`-Objekt, die Objekte aus `child_process` – sie alle sind Emitter oder erben von `EventEmitter`. Das erklärt, warum sich `.on()` überall gleich anfühlt: Es ist buchstäblich dieselbe Methode.\n\n```mermaid\nclassDiagram\n  class EventEmitter {\n    +on(event, listener)\n    +once(event, listener)\n    +off(event, listener)\n    +emit(event, ...args)\n  }\n  EventEmitter <|-- HttpServer\n  EventEmitter <|-- Readable\n  EventEmitter <|-- Writable\n  EventEmitter <|-- Process\n  EventEmitter <|-- MyEmitter\n  class HttpServer[\"http.Server\"]\n  class Readable[\"stream.Readable\"]\n  class Writable[\"stream.Writable\"]\n  class Process[\"process\"]\n  class MyEmitter[\"eigene Klasse\"]\n```\n\nDie Klasse selbst liegt im eingebauten Modul `events`. Man erreicht sie klassisch über `require('events')` oder – seit Node 16 möglich – über den Prefix `require('node:events')`. Beide Schreibweisen liefern dieselbe Klasse; der Prefix macht nur unmissverständlich klar, dass ein eingebautes Modul gemeint ist und kein Paket aus `node_modules`.\n\n## Die vier Methoden, um die sich alles dreht\n\nDie API des Emitters ist erstaunlich klein. Vier Methoden reichen für den Alltag. Drei davon – `on`, `once` und `off` – geben den Emitter selbst zurück und sind damit verkettbar; `emit` bildet die Ausnahme und liefert stattdessen einen `boolean`, wie wir gleich sehen werden.\n\n```js\nconst { EventEmitter } = require('node:events');\n\nconst emitter = new EventEmitter();\n\nfunction onTask(job) {\n  console.log('received task', job.id);\n}\n\nemitter.on('task', onTask);        // register a listener\nemitter.emit('task', { id: 1 });   // trigger it synchronously\nemitter.off('task', onTask);       // remove it again\n```\n\n`on(eventName, listener)` hängt einen Listener ans Ende der Liste für dieses Event. `emit(eventName, ...args)` ruft alle registrierten Listener auf und reicht die zusätzlichen Argumente durch. `off(eventName, listener)` – Alias `removeListener` – nimmt einen Listener wieder heraus. Und `once(eventName, listener)` registriert einen Listener, der nach dem ersten Auslösen automatisch verschwindet.\n\nZwei Eigenschaften von `emit` sind für das Verständnis entscheidend, und beide werden gern übersehen:\n\n- `emit` ruft die Listener **synchron** auf. Es gibt hier nichts Asynchrones, keinen Umweg über den Event Loop, keine Verzögerung. Wenn `emit` zurückkehrt, sind alle Listener durchgelaufen.\n- Die Listener laufen **in der Reihenfolge ihrer Registrierung**. Wer zuerst `on()` aufgerufen hat, dessen Funktion läuft zuerst.\n\n```mermaid\nsequenceDiagram\n  participant C as Aufrufer\n  participant E as EventEmitter\n  participant L1 as Listener 1\n  participant L2 as Listener 2\n  C->>E: emit('task', data)\n  E->>L1: listener(data)\n  L1-->>E: fertig\n  E->>L2: listener(data)\n  L2-->>E: fertig\n  E-->>C: return true\n```\n\nDer Rückgabewert von `emit` ist ein `boolean`: `true`, wenn es für dieses Event mindestens einen Listener gab, sonst `false`. Was die Listener selbst zurückgeben, interessiert den Emitter nicht – Rückgabewerte von Listenern werden schlicht ignoriert. Wer aus einem Listener heraus etwas nach draußen melden will, muss das über einen anderen Weg tun, etwa ein weiteres Event oder einen geteilten Zustand.\n\n## on, once und off im Zusammenspiel\n\nDer Unterschied zwischen `on` und `once` klingt harmlos, hat aber Konsequenzen. Ein mit `once` registrierter Listener wird **vor** seinem eigenen Aufruf aus der Liste entfernt und dann ausgeführt. Er feuert also garantiert genau einmal, egal wie oft das Event danach noch ausgelöst wird.\n\n```js\nconst { EventEmitter } = require('node:events');\nconst queue = new EventEmitter();\n\nlet handled = 0;\nqueue.on('task', () => { handled += 1; });\n\nlet drained = 0;\nqueue.once('drain', () => { drained += 1; });\n\nqueue.emit('task');   // handled = 1\nqueue.emit('task');   // handled = 2\nqueue.emit('drain');  // drained = 1\nqueue.emit('drain');  // drained stays 1 - the listener is gone\n```\n\nBeim Entfernen lohnt ein genauer Blick. `off` entfernt **höchstens eine** Instanz eines Listeners. Wenn man dieselbe Funktion versehentlich zweimal mit `on` registriert hat, braucht man auch zwei `off`-Aufrufe, um sie vollständig loszuwerden. Und man kann nur entfernen, was man auch benennen kann – eine anonyme Arrow-Function, die man direkt an `on` übergibt, hat man nachher nicht mehr in der Hand. Deshalb registriere ich Listener, die ich später wieder abmelden will, immer als benannte Funktion oder als Variable.\n\nHier die Semantik der vier auf einen Blick:\n\n- `on` – Listener bleibt registriert und läuft bei jedem `emit` erneut.\n- `once` – Listener läuft genau einmal und entfernt sich vorher selbst.\n- `off` – entfernt eine Instanz eines benannten Listeners; anonyme Funktionen bekommt man so nicht mehr weg.\n- `emit` – ruft alle Listener synchron in Registrierungsreihenfolge auf und liefert `true`/`false` zurück.\n\nNeben diesen vieren gibt es noch nützliche Randmethoden: `prependListener` hängt einen Listener an den Anfang statt ans Ende, `removeAllListeners` räumt komplett auf, `listeners(eventName)` gibt eine Kopie der aktuellen Listener-Liste zurück und `eventNames()` verrät, für welche Events überhaupt jemand registriert ist. Man braucht sie selten, aber wenn man sie braucht, sind sie Gold wert – etwa beim Debuggen der Frage \"wer hört hier eigentlich alles zu?\".\n\n## Das error-Event ist ein Sonderfall\n\nDiese Eigenheit hat in einem Projekt einmal einen laufenden Prozess gekostet: Das Event `'error'` verhält sich anders als alle anderen. Bei einem gewöhnlichen Event ohne Listener passiert nichts – `emit` liefert `false` und gut ist. Beim `'error'`-Event ist das nicht so.\n\nLöst ein Emitter `emit('error', ...)` aus und es ist **kein** Listener für `'error'` registriert, dann wird der Fehler geworfen. Und weil er innerhalb der Emitter-Maschinerie geworfen wird und niemand ihn auffängt, reißt er im Zweifel den ganzen Prozess ab. Aus einem \"ein Stream hat sich beschwert\" wird so ein kompletter Absturz des Node-Prozesses.\n\n```js\nconst { EventEmitter } = require('node:events');\nconst source = new EventEmitter();\n\n// Without this listener the next line would crash the process:\nsource.on('error', (err) => {\n  console.error('handled:', err.message);\n});\n\nsource.emit('error', new Error('boom'));  // handled cleanly\n```\n\nDas ist übrigens keine Schikane, sondern eine bewusste Entscheidung des Node-Teams. Ein Fehler, den niemand behandelt, soll laut sein und nicht still verschluckt werden. Daraus folgt eine einfache Regel: Für jeden Emitter, der Fehler melden kann – und das sind praktisch alle Streams, alle Netzwerkobjekte, alle Kindprozesse – registriere ich einen `'error'`-Listener, bevor die eigentliche Arbeit losgeht. Er ist das Auffangnetz, ohne das die ganze Anwendung an einer einzigen unbehandelten Meldung zerbricht.\n\nEs gibt noch zwei weitere Spezial-Events, die intern verwendet werden: `'newListener'` feuert, bevor ein neuer Listener hinzugefügt wird, und `'removeListener'` feuert, nachdem einer entfernt wurde. Beide sind eher für Werkzeuge und Diagnose gedacht als für den Alltag, aber es ist gut zu wissen, dass es sie gibt, wenn man einmal beobachten will, wer sich wann an- oder abmeldet.\n\n## Zu viele Listener: die Leck-Warnung\n\nNode führt für jeden Emitter Buch darüber, wie viele Listener pro Event registriert sind. Überschreitet diese Zahl einen Schwellenwert – standardmäßig 10 –, gibt Node eine Warnung auf stderr aus: die `MaxListenersExceededWarning`.\n\nDiese Warnung ist keine Fehlermeldung und stoppt nichts. Sie ist eine Heuristik gegen eine sehr häufige Fehlerklasse: das Listener-Leck. Der typische Fall sieht so aus, dass irgendwo pro Anfrage, pro Schleifendurchlauf oder pro Wiederholung ein `on()` aufgerufen wird, das dazugehörige `off()` aber fehlt. Die Listener stapeln sich, der Speicher wächst, und irgendwann meldet sich Node.\n\n```js\nconst { EventEmitter } = require('node:events');\nconst bus = new EventEmitter();\n\n// Optional: make the warning itself visible\nprocess.on('warning', (warning) => {\n  console.log(warning.name, '- count:', warning.count);\n});\n\n// Anti-pattern: a new, never-removed listener on every iteration\nfor (let i = 0; i < 12; i += 1) {\n  bus.on('data', (chunk) => processChunk(chunk));\n}\n// From the 11th listener on, the MaxListenersExceededWarning appears.\n```\n\nEntscheidend ist: Die Warnung ist das Symptom, nicht die Ursache. Die Ursache ist die nie entfernte Registrierung. Wer die Warnung mit `emitter.setMaxListeners(Infinity)` zum Schweigen bringt, hat nur das Warnlämpchen abgeklebt – das Leck läuft weiter, der Speicher wächst weiter. Richtig ist, die Listener wieder abzumelden, wenn man sie nicht mehr braucht, oder sie gleich als `once` zu registrieren, wenn sie ohnehin nur einmal feuern sollen.\n\n```js\nfunction onData(chunk) {\n  processChunk(chunk);\n}\n\nbus.on('data', onData);\n// ... later, when done:\nbus.off('data', onData);   // cleanly removed, no pile-up\n```\n\nDas heißt nicht, dass der Schwellenwert von 10 in Stein gemeißelt ist. Es gibt legitime Fälle, in denen ein Emitter bewusst mehr Listener hat. Dann justiert man das absichtlich: `emitter.setMaxListeners(n)` hebt das Limit für einen einzelnen Emitter an, `events.defaultMaxListeners = n` verschiebt den globalen Standardwert. Der Wert `0` beziehungsweise `Infinity` schaltet die Warnung ganz ab. Wichtig ist nur, dass diese Entscheidung eine bewusste ist und kein Reflex, um die Meldung im Log loszuwerden.\n\n## Eine eigene Klasse bauen\n\nDer `EventEmitter` ist nicht nur etwas, von dem die Standardbibliothek erbt – man kann ihn genauso gut selbst als Basis nehmen. Immer dann, wenn eine eigene Komponente ihre Umgebung über Dinge informieren soll, ohne die Empfänger fest zu kennen, ist ein Emitter die naheliegende Wahl.\n\nDas Muster ist `class MyThing extends EventEmitter` mit einem `super()`-Aufruf im Konstruktor. Hier eine kleine Aufgabenwarteschlange, die meldet, wenn eine Aufgabe hinzukommt, wenn sie abgearbeitet ist und wenn etwas schiefgeht:\n\n```js\nconst { EventEmitter } = require('node:events');\n\nclass TaskQueue extends EventEmitter {\n  constructor() {\n    super();\n    this.pending = [];\n  }\n\n  add(job) {\n    this.pending.push(job);\n    this.emit('task', job);\n  }\n\n  run() {\n    while (this.pending.length > 0) {\n      const job = this.pending.shift();\n      try {\n        job.handler();\n        this.emit('done', job);\n      } catch (cause) {\n        // Only emit 'error' when a listener exists -\n        // otherwise it crashes the process:\n        if (this.listenerCount('error') > 0) {\n          this.emit('error', cause);\n        } else {\n          throw cause;\n        }\n      }\n    }\n    this.emit('drain');\n  }\n}\n\nconst queue = new TaskQueue();\n\nqueue.on('task', (job) => console.log('queued', job.id));\nqueue.on('done', (job) => console.log('done', job.id));\nqueue.once('drain', () => console.log('all work finished'));\nqueue.on('error', (err) => console.error('failed:', err.message));\n\nqueue.add({ id: 1, handler: () => {} });\nqueue.add({ id: 2, handler: () => { throw new Error('bad job'); } });\nqueue.run();\n```\n\nAn diesem Beispiel sieht man mehrere Dinge zusammenwirken. Die Queue kennt ihre Listener nicht – sie ruft `emit` auf und wer will, hört zu. `once('drain', ...)` sorgt dafür, dass die Abschlussmeldung genau einmal kommt. Und beim Fehlerfall prüfe ich mit `listenerCount('error')`, ob überhaupt jemand zuhört, bevor ich `emit('error', ...)` aufrufe. So vermeide ich den Absturz durch ein unbehandeltes `'error'`-Event und lasse den Fehler stattdessen als gewöhnliche Exception weiterlaufen, wenn niemand ihn abfängt.\n\nEin kleiner, aber wiederkehrender Stolperstein bei eigenen Klassen betrifft `this`. In einem klassischen Function-Listener zeigt `this` auf den Emitter, der das Event ausgelöst hat – praktisch, wenn man von innen auf den Emitter zugreifen will. In einer Arrow-Function gilt das nicht; dort behält `this` seine Bedeutung aus dem umgebenden Kontext. Beides ist legitim, man muss sich nur bewusst sein, welche Variante man wählt.\n\n## Synchron heißt synchron\n\nZum Schluss noch eine Klarstellung, die im Alltag Fehler verhindert. Weil Node so stark mit Asynchronität verbunden ist, nehmen viele intuitiv an, `emit` würde die Listener irgendwann später aufrufen. Das tut es nicht. Der Aufruf ist synchron, und die Listener blockieren den aufrufenden Code, bis sie durch sind.\n\nDas ist meistens genau richtig, denn es macht den Ablauf vorhersehbar. Wer aber innerhalb eines Listeners bewusst etwas aus dem aktuellen Ablauf herauslösen will – damit lange Arbeit den `emit`-Aufrufer nicht aufhält –, muss das ausdrücklich tun, etwa mit `setImmediate` oder `process.nextTick`. Der Emitter selbst plant nichts um; er ruft nur der Reihe nach auf, was registriert ist.\n\n## Fazit\n\nDer `EventEmitter` ist eine kleine Klasse mit großer Reichweite. Vier Methoden – `on`, `once`, `off`, `emit` – tragen den Alltag, und weil ein großer Teil der Standardbibliothek von genau dieser Klasse erbt, überträgt sich das Verständnis unmittelbar auf Streams, Server und Prozesse. Zwei Dinge sollte man dabei nie aus den Augen verlieren: Das `'error'`-Event stürzt ohne Listener den Prozess ab, deshalb gehört überall dort, wo Fehler gemeldet werden können, ein `'error'`-Listener hin. Und die `MaxListenersExceededWarning` ist ein ehrliches Warnsignal für ein Leck, kein lästiger Hinweis, den man wegkonfiguriert. Mit diesen beiden Reflexen schreibt man ereignisgetriebenen Node-Code, der auch dann noch stabil läuft, wenn es ungemütlich wird.\n\n## Weiterführende Quellen\n\n- Node.js-Doku: [Events-API](https://nodejs.org/docs/latest-v16.x/api/events.html)\n- Node.js-Guide: [The Node.js Event emitter](https://nodejs.org/en/learn/asynchronous-work/the-nodejs-event-emitter)\n- Node.js-Doku: [Stream-API](https://nodejs.org/docs/latest-v16.x/api/stream.html) (erbt von `EventEmitter`)\n- Eigener Beitrag: [Node.js-Training rund um den Event Loop](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/)"},{"slug":"graphql-paginierung-und-connections","title":"GraphQL-Paginierung: Cursor, Connections und das Relay-Pattern","summary":"Warum offset/limit bei bewegten Listen Duplikate und Lücken produziert und wie cursor-basierte Paginierung mit dem Connections-Pattern stabil bleibt – inklusive Schema, Resolver-Skizze und den Fallstricken aus der Projektpraxis.","tags":["Software","GraphQL","API"],"published_at":"2022-03-08T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/graphql-paginierung-und-connections/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/e808d98c926de740f80ca7763d4ba6fcf557691e34fd1142243cf20286bc0aa0/graphql-paginierung-und-connections-vorlesen.mp3","audio_duration_secs":743,"text":"Wer eine Liste über Positionen durchblättert, verlässt sich stillschweigend darauf, dass in der Zwischenzeit niemand schreibt. Im Produktivbetrieb trägt diese Annahme selten. Eine Position ist kein Anker in den Daten, sie ist eine Zählung – und Zählungen verschieben sich, sobald vorne ein Datensatz dazukommt oder verschwindet. Die Folgeanfrage stellt dann nicht mehr die Frage, die der Client stellen wollte.\n\nPaginierung ist deshalb einer der Punkte, an denen sich zeigt, ob eine API robust ist oder nur im Demo-Fall funktioniert. Solange die Liste stillsteht, tut offset/limit tadellos seinen Dienst. Sobald sie sich unter dem blätternden Client verändert – und das ist der Normalfall, nicht die Ausnahme – entstehen Fehler, die schwer zu reproduzieren und noch schwerer zu erklären sind. Genau dafür hat die GraphQL-Community mit dem Connections-Pattern eine eigene Konvention etabliert. Darum geht es hier.\n\n## Warum offset/limit bei bewegten Listen versagt\n\nMachen wir das Problem konkret. Ich habe eine nach Erstelldatum absteigend sortierte Liste und blättere sie in Zweierschritten durch. Der Client fragt die erste Seite an: `offset: 0, limit: 2`. Er bekommt die Einträge an den Positionen 0 und 1. Für die nächste Seite fragt er `offset: 2, limit: 2`.\n\nDas Tückische: `offset` ist eine reine Positionszählung. Es sagt \"überspringe die ersten zwei Elemente\" – aber es hat keine Ahnung, *welche* zwei Elemente das beim ersten Aufruf waren. Wenn zwischen den beiden Anfragen vorne ein neuer Eintrag eingefügt wird, rutscht die ganze Liste um eine Position nach hinten. Das Element, das eben noch auf Position 1 lag, liegt jetzt auf Position 2 – und wird dem Client bei `offset: 2` ein zweites Mal ausgeliefert. Ein Duplikat.\n\nDer umgekehrte Fall ist genauso ärgerlich: Wird vorne ein Eintrag gelöscht, rückt alles eine Position nach vorne. Das Element, das auf Position 2 lag, springt auf Position 1 – und wird bei `offset: 2` übersprungen. Eine Lücke. Der Nutzer sieht einen Eintrag nie, obwohl er existiert.\n\n```mermaid\nflowchart TB\n  subgraph stabil[\"Stabil: cursor-basiert\"]\n    direction LR\n    a1[\"Anfrage 1<br/>first: 2\"] --> a2[\"liefert A, B<br/>endCursor = B\"]\n    a2 --> a3[\"Anfrage 2<br/>after: Cursor von B\"]\n    a3 --> a4[\"liefert C, D<br/>kein Duplikat\"]\n  end\n  subgraph kaputt[\"Verschoben: offset/limit\"]\n    direction LR\n    b1[\"Anfrage 1<br/>offset 0, limit 2\"] --> b2[\"liefert A, B\"]\n    b2 --> b3[\"neuer Eintrag X<br/>wird vorne eingefügt\"]\n    b3 --> b4[\"Anfrage 2<br/>offset 2, limit 2\"]\n    b4 --> b5[\"liefert B, C<br/>B doppelt\"]\n  end\n```\n\nDer Kern ist immer derselbe: Offset zählt Positionen, und Positionen sind kein stabiler Anker, sobald sich die Liste vorne verändert. Offset ist wie die Ansage im Zug \"steigen Sie am dritten Halt aus\" – wunderbar, solange niemand einen Zwischenhalt einlegt.\n\n## Der cursor-basierte Ausweg\n\nDie Lösung ist ein Wechsel der Frage. Statt \"gib mir die Elemente ab Position N\" fragt der Client \"gib mir die Elemente *nach diesem konkreten Element*\". Dieses \"diesem konkreten Element\" wird durch einen **Cursor** repräsentiert – einen opaken Zeiger, den der Server ausgibt und den der Client unverändert zurückreicht.\n\nDer entscheidende Unterschied: Ein Cursor ist an ein Element beziehungsweise dessen Sortierwert gebunden, nicht an eine Position. Wenn der Client sagt \"weiter nach dem Element mit Cursor X\", dann bleibt diese Aussage korrekt, egal wie viele Einträge davor eingefügt oder gelöscht werden. Das Fenster verschiebt sich nicht, weil es nicht an einer Zählung hängt, sondern an einem Anker in den Daten.\n\nEin Punkt, der in Code-Reviews regelmäßig auffällt: Der Cursor ist **opak**. Der Client darf keine Struktur annehmen und keine Arithmetik damit treiben. Er darf ihn nur wieder zurückgeben. Das ist bewusst so – der Server bestimmt allein, was ein Cursor bedeutet. Heute mag darin eine ID stecken, morgen eine Kombination aus Sortierfeld und ID, übermorgen etwas völlig anderes. Solange der Client den Cursor nur durchreicht, kann der Server seine Cursor-Implementierung wechseln, ohne dass ein einziger Client kaputtgeht. Üblich ist eine Base64-Kodierung – aber das ist Konvention, keine Vorschrift, und dient nur dazu, dass niemand in Versuchung gerät, den Inhalt zu interpretieren.\n\n## Das Connections-Pattern: edges, node, pageInfo\n\nDamit sind wir beim eigentlichen Thema. Die GraphQL-Community hat rund um cursor-basierte Paginierung eine Konvention formalisiert: die Relay Cursor Connections Specification. Man muss Relay als Framework nicht einsetzen, um von dem Muster zu profitieren – es hat sich als De-facto-Standard etabliert, weil es eine wichtige Frage sauber löst: Wo hänge ich eigentlich den Cursor hin?\n\nDie naive Antwort wäre, den Cursor direkt an den Datensatz zu kleben. Aber der Cursor ist keine Eigenschaft des Nutzers oder des Artikels – er ist eine Eigenschaft *dieser Position in dieser Liste*. Deshalb führt das Muster eine Zwischenschicht ein, die **Edge**. Eine Edge verbindet den eigentlichen Datensatz (`node`) mit seinem `cursor`. Der `node` bleibt sauber, die listenspezifische Information sitzt auf der Kante.\n\nDarüber liegt die **Connection**. Sie bündelt die Liste der Edges und ein Objekt namens `pageInfo`, das die Metainformationen fürs Weiterblättern trägt.\n\nWas liefern die einzelnen Bausteine konkret?\n\n- `edges` ist die Liste der Kanten. Jede Edge enthält `node` (den reinen Datensatz, ohne List-Wrapper) und `cursor` (den opaken Zeiger auf genau diese Edge).\n- `pageInfo.hasNextPage` sagt, ob es nach der aktuellen Seite noch weitere Elemente gibt. Das ist die maßgebliche Abbruchbedingung fürs Blättern.\n- `pageInfo.hasPreviousPage` ist das Gegenstück für die Rückwärtsrichtung.\n- `pageInfo.endCursor` ist der Cursor des letzten Elements der aktuellen Seite – genau der Wert, den der Client als `after` in die Folgeanfrage steckt.\n- `pageInfo.startCursor` ist das Pendant am Anfang der Seite, relevant fürs Rückwärtsblättern mit `before`.\n\nZur Namenskonvention: Der Connection-Typ endet auf `Connection`, der Edge-Typ auf `Edge`. `pageInfo` ist non-null, ebenso die beiden Booleans in `PageInfo`. Die beiden Cursor-Felder `startCursor` und `endCursor` dagegen sind nullable – bei einer leeren Seite gibt es schlicht keinen Cursor.\n\nEin oft gesehenes Feld ist `totalCount`, also die Gesamtzahl der Treffer. Das ist praktisch, hat aber einen Haken: `totalCount` ist **nicht** Teil der Connections-Spezifikation. Es ist eine verbreitete optionale Erweiterung, und je nach Datenquelle kann ein exaktes `COUNT` teuer sein. Wer es aufnimmt, sollte das bewusst tun und nicht in der Annahme, es gehöre zum Standard dazu.\n\n## Ein Beispiel-Schema\n\nGenug Prosa, schauen wir auf SDL. Eine Query, die Nutzer paginiert ausliefert, sieht im Connections-Pattern so aus:\n\n```graphql\ntype Query {\n  users(first: Int, after: String): UserConnection!\n}\n\ntype UserConnection {\n  edges: [UserEdge!]!\n  pageInfo: PageInfo!\n  totalCount: Int\n}\n\ntype UserEdge {\n  node: User!\n  cursor: String!\n}\n\ntype PageInfo {\n  hasNextPage: Boolean!\n  hasPreviousPage: Boolean!\n  startCursor: String\n  endCursor: String\n}\n\ntype User {\n  id: ID!\n  name: String!\n  createdAt: String!\n}\n```\n\nDie Argumente folgen der Spezifikation. Für Vorwärts-Paginierung gibt es `first: Int` (die gewünschte Seitengröße, nicht-negativ) und `after: String` (der Cursor, hinter dem es weitergehen soll). Für die Rückwärtsrichtung gibt es analog `last: Int` und `before: String`. `first` und `last` gleichzeitig zu senden ist laut Spezifikation ausdrücklich unerwünscht – das Ergebnis wäre mehrdeutig.\n\nEine Query vom Client liest sich dann so:\n\n```graphql\nquery FirstPage {\n  users(first: 2) {\n    edges {\n      cursor\n      node {\n        id\n        name\n      }\n    }\n    pageInfo {\n      hasNextPage\n      endCursor\n    }\n  }\n}\n```\n\nUnd die Folgeanfrage nimmt den `endCursor` der vorigen Antwort als `after`:\n\n```graphql\nquery NextPage {\n  users(first: 2, after: \"dXNlcjpjdXJzb3I6NDI=\") {\n    edges {\n      cursor\n      node {\n        id\n        name\n      }\n    }\n    pageInfo {\n      hasNextPage\n      endCursor\n    }\n  }\n}\n```\n\nViele Einführungsbeispiele – auch mein eigenes Grundlagenmaterial – zeigen vereinfachte Varianten, etwa `skip`/`take` oder ein `after: 99`, bei dem der Cursor verdächtig nach einer ID oder einem Offset aussieht. Zum Erklären taugt das, das echte Relay-Pattern ist es nicht. Ein produktiver Cursor ist opak und sieht eben *nicht* nach einer lesbaren Zahl aus. Diese Unterscheidung lohnt den zusätzlichen Satz, sonst rechnet später jemand auf dem Client mit dem Cursor herum.\n\n## Die Resolver-Skizze\n\nAuf der Serverseite muss der Resolver drei Dinge tun: den eingehenden Cursor dekodieren, die passende Datenmenge laden und daraus Edges plus `pageInfo` bauen. Der spannendste Trick steckt in der Ermittlung von `hasNextPage`.\n\nEin häufiger Anfängerfehler ist, `hasNextPage` daraus abzuleiten, ob das Ergebnis kleiner als `first` ist. Das ist unzuverlässig: Wenn zufällig genau `first` Elemente übrig sind, wüsste man nicht, ob danach noch etwas kommt. Der saubere Weg ist, ein Element mehr zu laden, als angefragt wurde – `first + 1`. Existiert dieses Extra-Element, gibt es eine nächste Seite. Anschließend kürzt man das Ergebnis wieder auf `first`.\n\n```js\nfunction encodeCursor(id) {\n  return Buffer.from(`cursor:${id}`).toString(\"base64\");\n}\n\nfunction decodeCursor(cursor) {\n  const decoded = Buffer.from(cursor, \"base64\").toString(\"utf8\");\n  return Number(decoded.split(\":\")[1]);\n}\n\nasync function usersResolver(_parent, args, context) {\n  const { first = 20, after } = args;\n  const limit = Math.min(first, 100); // enforce an upper bound\n  const afterId = after ? decodeCursor(after) : 0;\n\n  // load one extra row to detect a following page\n  const rows = await context.db.query(\n    `SELECT id, name, created_at\n       FROM users\n      WHERE id > $1\n      ORDER BY id ASC\n      LIMIT $2`,\n    [afterId, limit + 1]\n  );\n\n  const hasNextPage = rows.length > limit;\n  const pageRows = hasNextPage ? rows.slice(0, limit) : rows;\n\n  const edges = pageRows.map((row) => ({\n    node: row,\n    cursor: encodeCursor(row.id),\n  }));\n\n  return {\n    edges,\n    pageInfo: {\n      hasNextPage,\n      hasPreviousPage: Boolean(after),\n      startCursor: edges.length ? edges[0].cursor : null,\n      endCursor: edges.length ? edges[edges.length - 1].cursor : null,\n    },\n  };\n}\n```\n\nZwei Details lohnen den zweiten Blick. Erstens die Obergrenze: Ohne ein `Math.min` könnte ein Client `first: 1000000` senden und die Datenbank in die Knie zwingen. Eine erzwungene Obergrenze ist Pflicht, nicht Kür. Zweitens die Cursor-Helfer `encodeCursor`/`decodeCursor` – sie demonstrieren, dass hinter dem opaken String hier schlicht eine ID steckt, aber der Client das eben nicht wissen muss.\n\nDer Ablauf über mehrere Seiten sieht als Dialog zwischen Client und Server so aus:\n\n```mermaid\nsequenceDiagram\n  participant C as Client\n  participant S as Server\n  C->>S: users(first: 2)\n  S-->>C: edges A, B<br/>pageInfo endCursor=B, hasNextPage=true\n  C->>S: users(first: 2, after: Cursor B)\n  S-->>C: edges C, D<br/>pageInfo endCursor=D, hasNextPage=true\n  C->>S: users(first: 2, after: Cursor D)\n  S-->>C: edges E<br/>pageInfo endCursor=E, hasNextPage=false\n  Note over C,S: Schleife endet, sobald hasNextPage=false\n```\n\nDer Client blättert also in einer simplen Schleife: Solange `hasNextPage` wahr ist, schickt er die nächste Anfrage mit dem zuletzt erhaltenen `endCursor` als `after`. Kein Positionszählen, kein Nachrechnen.\n\n## Der Preis der Sortierung – und ein Datenbank-Bonus\n\nEin Punkt, an dem cursor-basierte Paginierung stillschweigend scheitert, ist eine mehrdeutige Sortierung. Der Cursor braucht eine **deterministische, eindeutige** Ordnung. Sortiere ich etwa nur nach `created_at`, und zwei Datensätze tragen denselben Zeitstempel, dann ist an dieser Gleichstands-Stelle nicht definiert, welcher zuerst kommt – und genau dort entstehen wieder Duplikate oder Auslassungen. Die Lösung ist ein Tie-Breaker: Man sortiert zusätzlich nach einer garantiert eindeutigen Spalte, in der Regel der ID. Aus `ORDER BY created_at` wird `ORDER BY created_at, id`.\n\nDamit fällt ein hübscher Side Effect ab. Cursor-Paginierung lässt sich in SQL als sogenannte Keyset-Pagination formulieren, und die skaliert deutlich besser als große Offsets:\n\n```sql\nSELECT id, created_at, name\n  FROM users\n WHERE (created_at, id) > (:cursor_created_at, :cursor_id)\n ORDER BY created_at, id\n LIMIT :page_size;\n```\n\nDer Grund: Ein großes `OFFSET n` zwingt die Datenbank, `n` Zeilen zu lesen und wegzuwerfen, bevor sie überhaupt anfängt zu liefern – bei tiefen Seiten wird das spürbar langsam. Die Keyset-Variante mit `WHERE (sort_key, id) > (...)` kann dagegen direkt über den Index an die richtige Stelle springen. Cursor-Paginierung ist also nicht nur korrekter bei bewegten Listen, sie ist bei tiefem Blättern oft auch schneller.\n\n## Wann offset trotzdem in Ordnung ist\n\nCursor-Paginierung ist kein Selbstzweck. Es gibt Fälle, in denen offset völlig ausreicht:\n\n- Die Liste ist im Wesentlichen stabil, etwa ein historisches Archiv oder ein abgeschlossener Report, in den nichts mehr eingefügt wird.\n- Der Nutzer soll gezielt zu Seite 7 springen können – nummerierte Seiten sind mit reinen Cursorn nicht ohne Weiteres darstellbar, weil man keine absolute Position kennt.\n- Die Datenmengen sind klein und die Seiten flach, sodass weder Konsistenz- noch Performance-Probleme auftreten.\n\nEs geht also nicht um Dogma, sondern um eine bewusste Wahl. Sobald eine Liste sich unter dem Nutzer verändern kann *und* er sie potenziell tief durchblättert – Feeds, Timelines, Aktivitätsströme – ist cursor-basierte Paginierung mit dem Connections-Pattern die richtige Antwort. Für ein stabiles, überschaubares Nachschlagewerk darf offset gern bleiben.\n\n## Fazit\n\nPaginierung wirkt banal, bis man sie unter Bewegung betrachtet. Offset zählt Positionen, und Positionen verschieben sich – das Ergebnis sind Duplikate und Lücken, die im ruhigen Demo-Betrieb nie auftauchen und im Produktivbetrieb schwer zu fassen sind. Der cursor-basierte Ansatz ersetzt die Frage \"ab welcher Position\" durch \"nach welchem Element\" und wird damit gegen Veränderungen an der Liste robust.\n\nDas Connections-Pattern gießt diese Idee in eine saubere Struktur: Edges tragen den listenspezifischen Cursor, Nodes bleiben reine Datensätze, und `pageInfo` liefert mit `hasNextPage` und `endCursor` genau die zwei Angaben, die ein Client zum korrekten Weiterblättern braucht. Dazu kommen die Details, die den Unterschied zwischen Demo und Produktion ausmachen – ein Extra-Element für `hasNextPage`, ein Tie-Breaker in der Sortierung, eine Obergrenze für die Seitengröße und die Disziplin, den Cursor wirklich opak zu behandeln. Wer diese vier Dinge beherzigt, hat eine Paginierung, die auch dann noch stimmt, wenn die Liste sich unter den Fingern des Nutzers bewegt.\n\n## Weiterführende Quellen\n\n- Grounding-Repository: [introduction-graphql](https://github.com/mikebild/introduction-graphql) (`introduction-graphql/paging.md`)\n- [GraphQL Pagination – offizielle Lernressource](https://graphql.org/learn/pagination/)\n- [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm)"},{"slug":"sveltekit-adapter-aws","title":"SvelteKit Adapter AWS: Framework trifft Cloud","summary":"Warum ich einen eigenen SvelteKit-Adapter für AWS gebaut habe und was die Übersetzung zwischen Framework-Build und Lambda, S3 und CloudFront über beide Seiten verrät.","tags":["Software","AWS","Cloud"],"published_at":"2022-01-12T10:00:00+00:00","updated_at":"2026-07-14T17:18:34.934208+00:00","url":"https://www.mikebild.dev/de/blog/sveltekit-adapter-aws/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/4e850c1423335c5bba5a826754dbb26d69a667201217af5d37c25bd776dc21ff/sveltekit-adapter-aws-vorlesen.mp3","audio_duration_secs":709,"text":"# SvelteKit Adapter AWS: Framework trifft Cloud\n\nWenn das Deployment-Modell eines Frameworks und die Zielplattform nicht zusammenpassen, entsteht in Projekten immer dasselbe Nebenprodukt: ein handgeschriebenes Deploy-Skript. Es kopiert Build-Ausgaben in einen S3-Bucket, zippt irgendwo einen Lambda-Handler, setzt Cache-Header von Hand und invalidiert am Schluss eine CloudFront-Verteilung. Das funktioniert – bis das Framework die Struktur seiner Build-Ausgabe ändert, bis ein Header vergessen wird oder bis die eine Person das Projekt verlässt, die das Skript noch erklären kann. Danach hat das Team ein Deployment, dem niemand mehr traut, und jedes Release wird zur Mutprobe.\n\nGenau an diesem Punkt stehen Anfang 2022 SvelteKit-Anwendungen, die auf AWS laufen sollen. SvelteKit selbst ist noch Beta – Version 1.0 ist angekündigt, aber nicht erschienen – und bringt für dieses Problem ein bemerkenswert klares Konzept mit: Adapter. Es gibt `adapter-node` für den eigenen Server, `adapter-static` für rein statische Seiten, `adapter-vercel` und `adapter-netlify` für die jeweiligen Plattformen. Einen offiziellen Adapter für AWS gibt es nicht. Also habe ich einen geschrieben: [sveltekit-adapter-aws](https://github.com/MikeBild/sveltekit-adapter-aws), ein Open-Source-Projekt, das eine SvelteKit-App per AWS CDK auf Lambda, S3 und CloudFront ausrollt. Beim Bauen ist mir einiges über beide Seiten aufgefallen – über das Framework und über die Cloud. Darum geht es hier.\n\n## Adapter sind eine Architekturgrenze\n\nEin SvelteKit-Adapter ist im Kern ein Node-Modul mit einer einzigen wesentlichen Funktion: `adapt()`. Nach dem eigentlichen Build ruft SvelteKit diese Funktion auf und übergibt ein `builder`-Objekt, über das der Adapter die Build-Ausgaben abholt: den Client-Code für den Browser, den Server-Code für das Rendering, die vorgerenderten Seiten. Was der Adapter daraus macht, ist seine Sache. `adapter-node` baut einen startbaren HTTP-Server, `adapter-static` eine Verzeichnisstruktur für beliebige Webserver, `adapter-vercel` genau das Format, das die Vercel-Plattform erwartet.\n\nDiese Grenze ist bewusst gezogen. Das Framework weiß, wie aus Routen, Komponenten und Endpoints eine Anwendung wird. Es weiß nicht – und will nicht wissen –, wo diese Anwendung später läuft. Alles Plattformspezifische wandert hinter die Adapter-Schnittstelle. Für den Anwender reduziert sich die Entscheidung auf eine Zeile in der `svelte.config.js`:\n\n```javascript\nimport { adapter } from 'sveltekit-adapter-aws';\nimport preprocess from 'svelte-preprocess';\n\nexport default {\n  preprocess: preprocess(),\n  kit: {\n    adapter: adapter({\n      autoDeploy: true,\n      FQDN: 'demo.example.com',\n      MEMORY_SIZE: 256,\n      LOG_RETENTION_DAYS: 7,\n    }),\n  },\n};\n```\n\nMit `autoDeploy: true` rollt der Build-Schritt die Anwendung direkt aus – `svelte-kit build` endet dann nicht mit einem Verzeichnis, sondern mit einem laufenden CloudFormation-Stack. Das ist für Prototypen und kleine Projekte angenehm; für alles andere lässt sich über `cdkProjectPath` eine eigene CDK-App einhängen, die den Adapter-Output als Baustein verwendet.\n\n## Warum es den AWS-Adapter nicht gab\n\nDass Vercel und Netlify eigene Adapter haben und AWS nicht, ist kein Zufall und kein Versäumnis. Vercel und Netlify sind Plattformen: Sie kontrollieren beide Seiten der Schnittstelle und können exakt definieren, welches Artefakt sie erwarten. AWS ist keine Plattform in diesem Sinn, sondern ein Baukasten. Es gibt keinen einzelnen Dienst namens „SvelteKit-Hosting\", sondern Dutzende Primitive, aus denen man sich eines von vielen möglichen Deployments zusammensetzt.\n\nEin AWS-Adapter muss deshalb etwas tun, was die Plattform-Adapter nicht tun müssen: eine Architektur festlegen. Welcher Dienst beantwortet SSR-Anfragen? Wo liegen die statischen Dateien? Wer verteilt den Traffic? Das sind Entscheidungen, die sonst ein Architekt in einem Design-Dokument trifft – hier stecken sie in einem npm-Paket. Genau das macht so einen Adapter zu einem interessanten Stück Software: Er ist geronnene Architektur, versioniert und wiederverwendbar.\n\n## Die Architektur: drei Dienste, eine Anwendung\n\nDie Aufteilung, für die ich mich entschieden habe, folgt dem Charakter der Build-Ausgaben. Eine gebaute SvelteKit-App zerfällt in zwei grundverschiedene Teile: statische Dateien (Client-Bundles, Assets, vorgerenderte Seiten), die sich beliebig cachen lassen, und einen Server-Teil, der pro Anfrage rechnen muss. Statisches gehört in S3, Dynamisches in eine Lambda-Function, und davor sitzt CloudFront als gemeinsamer Eingang, der anhand des Pfads entscheidet, wohin eine Anfrage geht.\n\n```mermaid\nflowchart LR\n  B[Browser] --> CF[CloudFront<br/>Verteilung]\n  CF -->|/_app/*, Assets,<br/>vorgerenderte Seiten| S3[S3 Bucket<br/>statische Dateien]\n  CF -->|alle übrigen<br/>Anfragen| GW[API Gateway<br/>HTTP API]\n  GW --> L[Lambda<br/>SvelteKit SSR]\n  R53[Route 53 + ACM<br/>eigene Domain] -.-> CF\n```\n\nProvisioniert wird das Ganze mit dem AWS CDK, und zwar in der Version 2, die erst im Dezember 2021 allgemein verfügbar geworden ist – das Projekt setzt also auf ziemlich frischem Fundament auf. CDK v2 räumt vor allem das Paket-Chaos der v1 auf: eine Bibliothek `aws-cdk-lib` statt Dutzender einzeln zu versionierender Module. Für einen Adapter, der als npm-Abhängigkeit in fremde Projekte wandert, ist das ein spürbarer Unterschied, denn Versionskonflikte zwischen CDK-Teilpaketen waren in v1 ein Dauerthema. Der Kern des Stacks, leicht gekürzt:\n\n```typescript\nimport { Stack, StackProps, Duration } from 'aws-cdk-lib';\nimport * as lambda from 'aws-cdk-lib/aws-lambda';\nimport { RetentionDays } from 'aws-cdk-lib/aws-logs';\n\nexport class AWSAdapterStack extends Stack {\n  constructor(scope: Construct, id: string, props: StackProps) {\n    super(scope, id, props);\n\n    const serverHandler = new lambda.Function(this, 'ServerFunction', {\n      runtime: lambda.Runtime.NODEJS_14_X,\n      handler: 'index.handler',\n      code: lambda.Code.fromAsset(process.env.SERVER_PATH!),\n      memorySize: parseInt(process.env.MEMORY_SIZE ?? '128'),\n      timeout: Duration.seconds(15),\n      logRetention: RetentionDays.ONE_WEEK,\n    });\n    // ... HTTP API, S3 bucket, CloudFront distribution, Route 53 records\n  }\n}\n```\n\nEin Detail, das leicht untergeht: Die Log-Aufbewahrung steht bewusst auf sieben Tagen. CloudWatch-Logs ohne Aufbewahrungsregel wachsen unbegrenzt, und bei einer SSR-Funktion, die jede Seitenanfrage protokolliert, summiert sich das schneller, als man denkt. Solche Betriebs-Voreinstellungen gehören meiner Erfahrung nach in den Adapter, nicht in eine Wiki-Seite mit Empfehlungen.\n\n## Vom Lambda-Event zur Framework-Antwort\n\nDie eigentliche Übersetzungsarbeit passiert an zwei Stellen. Die erste ist der Build: Der Server-Teil der SvelteKit-Ausgabe wird mit esbuild zu einer einzigen CommonJS-Datei gebündelt – Format `cjs`, Ziel Node 14, die eingebauten Node-Module bleiben extern. Lambda bekommt damit ein Artefakt ohne `node_modules`-Verzeichnis, was Paketgröße und Kaltstart gutgetan hat. Die zweite Stelle ist die Runtime selbst: Ein kleiner Handler-Shim nimmt das Event von API Gateway entgegen, formt daraus die Anfrage-Struktur, die der SvelteKit-Server erwartet, und übersetzt die Antwort zurück:\n\n```javascript\n// index.js – bridges API Gateway events to the SvelteKit server\nimport { init, render } from './app.js';\n\ninit();\n\nexport async function handler(event) {\n  const { rawPath, rawQueryString, headers, requestContext } = event;\n\n  const response = await render({\n    method: requestContext.http.method,\n    path: rawPath,\n    query: new URLSearchParams(rawQueryString),\n    headers,\n    rawBody: event.body\n      ? Buffer.from(event.body, event.isBase64Encoded ? 'base64' : 'utf8')\n      : undefined,\n  });\n\n  return {\n    statusCode: response.status,\n    headers: response.headers,\n    body: response.body,\n  };\n}\n```\n\nDieser Shim ist unspektakulär, aber er ist der Ort, an dem Framework-Welt und Cloud-Welt tatsächlich aufeinandertreffen. Hier entscheidet sich, ob Binärdaten korrekt durchgereicht werden, ob mehrfache `Set-Cookie`-Header überleben und ob Query-Parameter mit Sonderzeichen heil ankommen. Jede dieser Kleinigkeiten habe ich nicht am Schreibtisch entworfen, sondern nach einem konkreten Fehlverhalten nachgezogen. Der gesamte Ablauf vom Quellcode bis zum Stack sieht so aus:\n\n```mermaid\nflowchart LR\n  SRC[SvelteKit<br/>Quellcode] --> BUILD[svelte-kit build<br/>via Vite]\n  BUILD --> ADAPT[adapter adapt&#40;&#41;<br/>sortiert Ausgaben]\n  ADAPT --> ESB[esbuild<br/>SSR-Bundle, cjs]\n  ADAPT --> STATIC[statische Dateien<br/>+ Prerendered]\n  ESB --> CDK[AWS CDK v2<br/>synth + deploy]\n  STATIC --> CDK\n  CDK --> STACK[CloudFormation-Stack<br/>Lambda, S3, CloudFront]\n```\n\n## Gegen eine bewegliche API bauen\n\nDer unbequemste Teil des Projekts hat nichts mit AWS zu tun. SvelteKit ist Beta, und die Adapter-Schnittstelle gehört zu den Teilen, die sich am häufigsten bewegen. Methoden auf dem `builder`-Objekt wurden umbenannt, zusammengelegt und im Verhalten geändert; die Struktur der Server-Ausgabe hat sich ebenfalls mehr als einmal verschoben. Ein Adapter, der gegen SvelteKit von Oktober gebaut wurde, kompiliert im Dezember womöglich noch – und erzeugt trotzdem ein kaputtes Deployment.\n\nFür ein Open-Source-Projekt heißt das: Die Issues kommen nicht von eigenen Fehlern, sondern von der Drift zwischen der Framework-Version des Nutzers und der Version, gegen die der Adapter entwickelt wurde. Ein typischer Fehlerbericht beginnt mit „funktioniert bei mir nicht\" und endet nach etwas Grabarbeit bei einer SvelteKit-Version, die drei Wochen neuer ist als meine letzte Anpassung. Dagegen helfen nur unspektakuläre Maßnahmen: die getestete SvelteKit-Version im Projekt festnageln, Beispiel-Repositories aktuell halten, Änderungen am Framework zeitnah nachziehen. Das ist der reale Preis dafür, gegen eine Beta zu bauen – und ich halte ihn für angemessen. Wer wartet, bis eine Schnittstelle stabil ist, überlässt die Erfahrung aus der Zwischenzeit anderen. Nebenbei zwingt einen ein Adapter dazu, das Framework wirklich zu verstehen: Man arbeitet nicht mit der dokumentierten Oberfläche, sondern mit den Build-Ausgaben und ihren Annahmen.\n\nWas der Adapter beim Build konkret übernimmt, ist überschaubar und genau deshalb gut wartbar:\n\n- Client-Bundles, Assets und vorgerenderte Seiten in ein Artefakt-Verzeichnis sortieren\n- den Server-Teil mit esbuild zu einer Lambda-tauglichen Datei bündeln\n- den Handler-Shim für API Gateway daneben legen\n- einen CDK-Stack synthetisieren und auf Wunsch direkt ausrollen\n- Domain, Zertifikat und DNS-Einträge über Route 53 und ACM verdrahten, wenn ein `FQDN` gesetzt ist\n\n## Was Serverless nicht abnimmt\n\nAuf der anderen Seite der Grenze wartet die zweite Ernüchterung: Auch mit fertigem Adapter ist AWS keine Plattform, die einem den Betrieb abnimmt. Serverless verschiebt Arbeit, es beseitigt sie nicht. Ein paar Punkte sind mir beim Betreiben eigener SvelteKit-Deployments deutlich geworden.\n\nKaltstarts sind real, aber handhabbar. Eine per esbuild gebündelte SSR-Funktion startet in dieser Größenordnung schnell genug für die meisten Seiten – wer aber schwere Abhängigkeiten in den Server-Teil zieht, bezahlt das bei jeder kalten Anfrage. Die Speichergröße ist bei Lambda zugleich der CPU-Regler: Die 128-MB-Voreinstellung ist für Rendering oft zu knapp, und mehr Speicher kann Antwortzeiten so weit verbessern, dass es sich trotz höherem Preis pro Millisekunde rechnet. Messen statt raten.\n\nDazu kommt das Caching. CloudFront entlastet die Funktion nur, wenn die Cache-Schlüssel und Header stimmen – eine SSR-Antwort mit `Cache-Control` auf `private` trifft die Lambda bei jedem Aufruf. Für kleine Umschreibungen direkt an der Kante gibt es seit letztem Jahr CloudFront Functions, die deutlich leichtgewichtiger sind als Lambda@Edge; für das eigentliche Rendering sind beide der falsche Ort. Und CloudFormation macht Deployments nachvollziehbar und rückrollbar, aber nicht schnell: Eine Änderung an der CloudFront-Verteilung kann etliche Minuten dauern. Wer von Vercel kommt, muss sich an diese Taktung erst gewöhnen.\n\nÜbrig bleibt eine ehrliche Arbeitsteilung. Der Adapter beantwortet die Frage „wie kommt die App nach AWS\" – diese Fragen bleiben beim Team:\n\n- Beobachtbarkeit: welche Logs, Metrics und Alarme zeigen, dass das Rendering leidet?\n- Kosten: was kosten Anfragen, Datenverkehr und Logs bei realem Traffic?\n- Sicherheit: welche Rechte hat die SSR-Funktion, und welche braucht sie wirklich?\n- Lifecycle: wer zieht Framework- und Adapter-Updates nach, und wie wird ein Rollback geprobt?\n\n## Unterm Strich\n\nDas Projekt hat mir zwei Dinge gezeigt, die über SvelteKit und AWS hinausreichen. Erstens: Die Adapter-Schnittstelle ist ein gelungenes Beispiel dafür, wie ein Framework eine Grenze ziehen kann, ohne sich für eine Plattform zu entscheiden. Alles Plattformspezifische – von der Verzeichnisstruktur bis zur Architekturentscheidung – bekommt einen klaren Ort, und dieser Ort ist versionierbar, testbar und austauschbar. Dass eine Einzelperson in einem überschaubaren Repository einen kompletten AWS-Unterbau für ein Framework bereitstellen kann, spricht für den Schnitt dieser Schnittstelle.\n\nZweitens: Ein Deployment-Ziel ist nie nur Konfiguration. Der Weg von `svelte-kit build` zu einem laufenden CloudFront-Endpoint besteht aus lauter kleinen Entscheidungen – Bundling, Routing-Regeln, Cache-Verhalten, Speichergrößen, Log-Aufbewahrung –, und jede davon hat Konsequenzen im Betrieb. Ein handgeschriebenes Deploy-Skript trifft dieselben Entscheidungen, nur unsichtbar und unversioniert. Ein Adapter macht sie explizit und teilt sie mit allen, die dasselbe Problem haben. Genau deshalb lohnt es sich, so etwas als Open Source zu bauen, auch wenn das Framework noch Beta ist und die Schnittstelle unter den Händen wackelt: Die Alternative ist nicht „kein Adapter\", sondern hundert private Skripte, die alle dieselben Fehler noch einmal machen.\n\n## Weiterführende Quellen\n\n- Repository: https://github.com/MikeBild/sveltekit-adapter-aws\n- SvelteKit-Dokumentation zu Adaptern: https://svelte.dev/docs/kit/adapters\n- AWS CDK v2 Developer Guide: https://docs.aws.amazon.com/cdk/v2/guide/home.html\n- Amazon CloudFront Functions: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-functions.html"},{"slug":"preview-umgebungen-pro-pull-request","title":"Preview-Umgebungen pro Pull Request","summary":"Ephemere Umgebungen pro Pull Request verkürzen das Feedback und machen Reviews konkret: Man klickt die Änderung, statt sie sich aus dem Diff vorzustellen.","tags":["Software","Delivery"],"published_at":"2021-11-24T10:00:00+00:00","updated_at":"2026-07-13T08:41:54.963229+00:00","url":"https://www.mikebild.dev/de/blog/preview-umgebungen-pro-pull-request/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/91d363160d1227a5bf8dea88a779e35acfb7cd3c9344b2d022fea1b8e0ac3c12/preview-umgebungen-pro-pull-request-vorlesen.mp3","audio_duration_secs":728,"text":"# Preview-Umgebungen pro Pull Request\n\n„Ist Staging frei?“ – diese Frage im Team-Chat ist der eigentliche Flaschenhals. Solange eine Umgebung für alle reichen muss, entscheidet die Reihenfolge darüber, wann jemand eine Änderung überhaupt zu sehen bekommt. Bekommt dagegen jeder Pull Request seine eigene, wegwerfbare Instanz, dreht sich das Verhältnis um: Nicht das Review wartet auf die Umgebung, die Umgebung entsteht mit ihm.\n\n## Das Problem hinter dem Diff\n\nEin Review endet oft schon hier: Jemand öffnet einen Pull Request, ein Kollege liest den Diff, denkt sich das Verhalten dazu und schreibt „LGTM“. Das ist ein Ratespiel. Ein Diff zeigt, welche Zeilen sich geändert haben – nicht, wie sich die Anwendung danach anfühlt. Ob der neue Checkout-Flow auf dem Smartphone bedienbar ist, ob der Ladezustand flackert, ob ein Sonderfall in der Fachlichkeit stimmt: Das steht in keinem `git diff`.\n\nDie klassische Antwort war ein gemeinsames Staging. Ein Team, eine Umgebung, eine Queue. Wer testen will, deployt seinen Branch, überschreibt damit den vorigen und blockiert alle anderen. Bei zwei parallelen Features ist das eine Quelle für Konflikte, bei fünf ein permanenter Stau. Das eigentliche Ziel – schnelles, konkretes Feedback – geht in der Koordination unter.\n\nDer Stau ist dabei nur die sichtbare Seite. Die unsichtbare ist, dass ein geteiltes Staging nie einen definierten Zustand hat: Es trägt immer die Reste des letzten Deploys, halb migrierte Schemata, Testdaten aus einem fremden Feature. Wenn ein Reviewer dort etwas Merkwürdiges sieht, weiß niemand mit Sicherheit, ob das der aktuelle PR ist oder ein Artefakt von gestern. Genau dieses „liegt es an meiner Änderung oder an der Umgebung?“ frisst Zeit und Vertrauen. Eine frisch hochgezogene Instanz kennt dieses Problem nicht – sie beginnt bei jedem Lauf im selben, reproduzierbaren Nullzustand.\n\nDie These dieses Artikels ist einfach: Jeder Pull Request bekommt seine eigene, wegwerfbare Umgebung mit eigener URL. Reviewer klicken die laufende Änderung an, statt sie sich vorzustellen. Und weil die Umgebung ephemer ist, verschwindet sie beim Merge wieder – ohne Aufräum-Ritual, ohne wachsende Kostenrechnung.\n\n## Der Outcome, nicht die Mechanik\n\nBevor es um `app.json` und CI-Pipelines geht, lohnt der Blick auf das Warum. Der Nutzen liegt in der kürzeren Feedback-Schleife – und die zahlt direkt auf messbare Delivery-Leistung ein.\n\n*Accelerate* (Forsgren, Humble, Kim, 2018) hat das mit Daten unterlegt: Kleine Batches, kurze Lead Time for Changes und hohe Deployment Frequency sind die Treiber leistungsfähiger Softwareauslieferung. Eine Preview-Umgebung pro Pull Request drückt genau dort. Sie belohnt kleine Änderungen – ein winziger PR ist in Sekunden deploybar und in Minuten reviewt. Sie senkt die Hemmschwelle, überhaupt zu integrieren, weil Feedback nicht auf den nächsten Staging-Slot warten muss. Das passt bruchlos zu Trunk-Based Development und zu dem, was Humble und Farley schon 2010 in *Continuous Delivery* beschrieben haben: kurzlebige Branches, schnelle Integration, der PR als Integrationseinheit.\n\nDer vielleicht unterschätzteste Effekt ist sozial. Sobald es eine klickbare URL gibt, kann auch reviewen, wer keine Entwicklungsumgebung aufsetzt: QA, Produktmanagement, Design. Ein Designer, der die reale Umsetzung im Browser sieht, gibt in fünf Minuten Feedback, für das sonst ein Meeting und drei Screenshots nötig wären. Das ist der Punkt, an dem aus einem Technik-Feature ein Delivery-Vorteil wird.\n\nKonzeptionell ist das eng verwandt mit der Build-Measure-Learn-Schleife aus Ries' *Lean Startup* (2011). Eine Preview ist ein konkretes Artefakt, an dem man messen und lernen kann, bevor etwas in Produktion geht. Je kleiner der Batch, desto schneller kann die Schleife werden. Wirtschaftlich ist eine Umgebung pro PR aber nur, wenn Provisionierung, Daten, Laufzeit und Abbau beherrscht sind. Sonst verlagert die Preview die Reibung lediglich vom Review in den Betrieb.\n\n## Der Lifecycle einer ephemeren Umgebung\n\nDie Mechanik ist bei allen etablierten Produkten dieselbe – ob Heroku „Review Apps“, GitLab „Review Apps“, Netlify „Deploy Previews“ oder Vercels „Preview Deployments“. Der PR ist der Auslöser, der Merge das Ende.\n\n```mermaid\nsequenceDiagram\n    participant Dev as Developer\n    participant PR as Pull Request\n    participant CI as CI Pipeline\n    participant Env as Preview Environment\n    Dev->>PR: open / push commit\n    PR->>CI: trigger build (HEAD commit)\n    CI->>Env: deploy isolated instance (own URL + own DB)\n    Env-->>PR: post preview URL as commit status\n    Dev->>Env: reviewer clicks and tests\n    Dev->>PR: push new commit\n    PR->>CI: rebuild and redeploy\n    Dev->>PR: merge or close\n    PR->>Env: destroy environment\n```\n\nWichtig ist das Wort *isolated*. Statt eines Stagings für alle laufen n Previews parallel – jede mit eigener URL, im Idealfall mit eigener Datenschicht. Der Kontrast zum alten Modell ist der ganze Punkt:\n\n```mermaid\ngraph LR\n    subgraph Classic[\"One staging for all\"]\n        A[PR 1] --> S[Shared Staging]\n        B[PR 2] --> S\n        C[PR 3] --> S\n        S --> Q[Queue and conflicts]\n    end\n    subgraph Ephemeral[\"Preview per PR\"]\n        D[PR 1] --> P1[preview-1.example.com]\n        E[PR 2] --> P2[preview-2.example.com]\n        F[PR 3] --> P3[preview-3.example.com]\n    end\n```\n\n## Deklarativ statt Skript-Grab\n\nDer eleganteste Ansatz beschreibt die Umgebung deklarativ, statt sie in einer Kette von Shell-Schritten zusammenzusetzen. Heroku macht das über eine `app.json` vor: Was eine Review App braucht – Add-ons, Migrationen, Feature-Flags – steht an einer Stelle, versioniert im Repository.\n\n```json\n{\n  \"name\": \"my-app\",\n  \"environments\": {\n    \"review\": {\n      \"addons\": [\"heroku-postgresql:hobby-dev\"],\n      \"scripts\": {\n        \"postdeploy\": \"bundle exec rake db:migrate db:seed\"\n      },\n      \"env\": {\n        \"FEATURE_FLAGS\": \"preview\"\n      }\n    }\n  }\n}\n```\n\nDer Reiz liegt im `postdeploy`-Hook: Jede Preview bekommt eine frische Datenbank, migriert das Schema und spielt Seed-Daten ein. Die Umgebung ist damit reproduzierbar aus dem Nichts – und genau das macht sie wegwerfbar.\n\nWer nicht auf einer PaaS wie Heroku oder Netlify sitzt, baut dasselbe Muster mit GitHub Actions selbst. Die drei Schritte bleiben gleich: bauen, in eine isolierte Instanz deployen, die URL zurück in den PR schreiben. Wie das Deploy-Skript intern arbeitet – ob es einen Container-Host anspricht, eine App auf einer eigenen Plattform hochzieht oder ein statisches Bundle ausliefert – ist für dieses Gerüst zweitrangig; entscheidend ist der saubere Vertrag aus Name, Image und Host.\n\n```yaml\nname: preview\non:\n  pull_request:\n    types: [opened, synchronize, reopened]\n\njobs:\n  deploy-preview:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n\n      - name: Build and push image\n        run: |\n          docker build -t registry.example.com/app:${{ github.sha }} .\n          docker push registry.example.com/app:${{ github.sha }}\n\n      - name: Deploy preview instance\n        run: |\n          ./scripts/deploy-preview.sh \\\n            --name \"pr-${{ github.event.number }}\" \\\n            --image \"registry.example.com/app:${{ github.sha }}\" \\\n            --host \"pr-${{ github.event.number }}.preview.example.com\"\n\n      - name: Comment preview URL\n        run: |\n          gh pr comment ${{ github.event.number }} \\\n            --body \"Preview ready: https://pr-${{ github.event.number }}.preview.example.com\"\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n```\n\nGenauso wichtig wie das Deploy ist das Teardown. Eine Preview, die niemand abräumt, ist kein Feature, sondern eine Kostenstelle. Deshalb gehört ein zweiter Workflow dazu, der beim Schließen des PR aufräumt:\n\n```yaml\nname: preview-teardown\non:\n  pull_request:\n    types: [closed]\n\njobs:\n  destroy-preview:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Tear down environment\n        run: |\n          ./scripts/destroy-preview.sh --name \"pr-${{ github.event.number }}\"\n```\n\nOb das Deploy-Ziel nun ein Kubernetes-Namespace, eine Heroku-App oder ein statischer Netlify-Build ist, spielt konzeptionell keine Rolle. Das Muster ist portabel: PR öffnet, Instanz entsteht, URL landet im PR, Merge räumt auf.\n\n## Ephemere Umgebungen als Prüfstand\n\nEine isolierte, produktionsnahe Instanz ist mehr als eine Klick-Vorschau. Sie ist ein sauberer Ort, um automatisierte Checks gegen ein echtes Deployment laufen zu lassen – nicht nur gegen Mocks. Zwei Beispiele, die sich in der Praxis bewähren:\n\nContract-Tests mit Pact prüfen, ob ein Service die Erwartungen seiner Konsumenten noch erfüllt. Gegen eine echte, hochgefahrene PR-Instanz verifiziert, ist das aussagekräftiger als gegen einen Stub. Und architektonische Fitness Functions, wie sie Ford, Parsons und Kua in *Building Evolutionary Architecture* (2017) beschreiben, lassen sich gegen die laufende Preview scharf stellen: Antwortzeiten, Abhängigkeitsregeln, Bundle-Größen. So wird die Preview zum Gate, das nicht nur „sieht gut aus“, sondern „hält die Regeln ein“ beantwortet.\n\nFeature-Flags ergänzen das Bild. Eine Preview mit gesetztem `FEATURE_FLAGS=preview` zeigt den Zielzustand, während dieselbe Änderung in Produktion noch dunkel geschaltet bleibt – Review und Rollout werden entkoppelt. Wer die Flag-Idee ernst nimmt, kann eine Änderung mehrfach mergen, ohne sie freizuschalten, und sie später per Toggle live nehmen. Das reduziert lange Feature-Branches und passt zur Trunk-Based-Idee: integrieren, sichtbar machen, kontrolliert ausrollen.\n\nDer gedankliche Bogen dahinter ist derselbe wie bei blue-green oder canary: Man trennt „deployed“ von „für alle sichtbar“. Eine Preview ist in diesem Sinn ein canary für genau einen Reviewer – die kleinste denkbare Ausrollstufe. Wer diese Trennung einmal verinnerlicht hat, denkt Deployment und Freigabe nicht mehr als denselben Schritt, und das ist eine der wirkungsvollsten Entlastungen für ein Team, das oft und ruhig ausliefern will.\n\n## Wo es wirklich wehtut\n\nReibungslos ist das alles nicht. Der schwierige Teil ist nie das Deploy des Web-Prozesses – es ist alles darunter.\n\nAm härtesten ist die Datenschicht. Teilen sich alle Previews eine Datenbank, kontaminieren sie sich gegenseitig: Ein PR migriert ein Schema, ein anderer stolpert darüber. Eine Datenbank pro PR löst das, kostet aber Provisioning-Zeit und Geld – und jede Migration muss sauber von Null durchlaufen. Das ist übrigens ein unterschätzter Side Effect: Wer Previews ernst nimmt, testet damit bei jedem PR auch seinen Migrationspfad auf der grünen Wiese. Migrationen, die nur auf gewachsenen Datenbeständen funktionieren, fallen hier sofort auf, statt beim nächsten frischen Setup zu überraschen. Externe Dienste verschärfen das Bild noch: Zahlungen, E-Mail-Versand, Drittanbieter-APIs dürfen aus einer Preview niemals echt feuern. Dafür braucht es Sandbox-Modi oder Stubs, und die müssen konfiguriert sein, bevor der erste Reviewer klickt – sonst verschickt die Vorschau eines Marketing-Features versehentlich echte Mails an echte Adressen.\n\nDer zweite Dauerbrenner ist der Lifecycle. Vergessene Umgebungen kosten Geld und driften vom aktuellen Stand weg. Das Teardown bei Merge oder Close muss zuverlässig greifen, ergänzt um eine TTL oder einen Idle-Shutdown für PRs, die wochenlang offen liegen. Ein einzelner nicht gefeuerter Close-Event – etwa weil ein PR ins Leere gelöscht wurde – darf keine verwaiste Instanz hinterlassen, deshalb gehört neben dem ereignisgetriebenen Abbau ein regelmäßiger Aufräum-Job dazu, der nach Alter oder Inaktivität kehrt. Und der Feedback-Vorteil kippt, wenn der Build zehn Minuten dauert: Kalte Starts und lange Pipelines untergraben genau das, wofür man das Ganze baut. Ein schneller, aggressiv gecachter Build ist die Voraussetzung dafür, dass die kurze Schleife überhaupt kurz bleibt.\n\nDrittens Secrets und Zugriff. Preview-URLs sind oft öffentlich und erratbar – `pr-42.preview.example.com` ist keine Sicherheitsgrenze. Ungeschützt leaken solche Vorschauen unfertige Features oder echte Daten. Secrets gehören sicher injiziert, der Zugriff hinter Basic Auth, SSO oder Passwortschutz. Netlify bietet Password Protection für Deploy Previews genau aus diesem Grund.\n\nUnd schließlich der Konfig-Skew. Eine Preview nützt nur, wenn sie produktionsnah ist. Weicht sie zu stark ab, bekommt man das schlechteste aller Ergebnisse: grünes Review, roter Prod-Deploy. Dann hat die Umgebung Vertrauen erzeugt, das sie nicht decken konnte. Die Gegenmittel sind unspektakulär, aber wirksam: dasselbe Container-Image von der Preview bis in die Produktion durchreichen, Konfiguration konsequent über Umgebungsvariablen statt über abweichende Build-Pfade steuern und die wenigen bewusst gesetzten Unterschiede – Sandbox-Endpoints, kleinere Ressourcen, aktive Feature-Flags – an einer Stelle sichtbar halten. Je näher die Preview an Produktion liegt, desto mehr ist ein grünes Review tatsächlich wert.\n\n## Der rote Faden\n\nPreview-Umgebungen pro Pull Request sind 2021 gängige, produktisierte Praxis – von Heroku über GitLab und Netlify bis Vercel. Der Kern ist überall gleich und in wenigen Sätzen erzählt: Der PR erzeugt eine isolierte, wegwerfbare Instanz mit eigener URL, jeder neue Commit deployt neu, der Merge räumt auf.\n\nDer Gewinn ist die kürzere und ehrlichere Feedback-Schleife. Reviewer beurteilen laufendes Verhalten statt Zeilen im Diff. Nicht-Entwickler können mitreden. Kleine Batches werden belohnt, der Staging-Stau verschwindet. Das ist die *Accelerate*-Logik in Reinform: Reibung raus, Zyklen kürzer, Qualität früher sichtbar.\n\nDer Einstieg ist deshalb unspektakulär: Fang beim Outcome an, nicht bei der Plattform. Die schwierigen Fragen sind Datenbank, Lifecycle und Zugriffsschutz – nicht das Deploy des Web-Prozesses. Wer die sauber löst, bekommt Reviews, die man anklickt statt sich vorstellt. Und das ist der Unterschied zwischen „LGTM“ und „ich habe es gesehen“.\n\n## Weiterführende Quellen\n\n- [Heroku Review Apps](https://devcenter.heroku.com/articles/github-integration-review-apps)\n- [Netlify: Introducing Deploy Previews (2016)](https://www.netlify.com/blog/2016/07/20/introducing-deploy-previews-in-netlify/)\n- [GitLab Review Apps](https://docs.gitlab.com/ci/review_apps/)\n- [Vercel: Every push now receives a unique URL](https://vercel.com/changelog/every-push-now-receives-a-unique-url)\n- [Using the Four Keys to measure your DevOps performance (DORA)](https://cloud.google.com/blog/products/devops-sre/using-the-four-keys-to-measure-your-devops-performance)\n- Forsgren, Humble, Kim: *Accelerate* (2018)\n- Humble, Farley: *Continuous Delivery* (2010)\n- Ford, Parsons, Kua: *Building Evolutionary Architecture* (2017)"},{"slug":"jamstack-architektur","title":"Jamstack als Architekturstil für Webanwendungen","summary":"Jamstack beschreibt einen Architekturstil mit vorgerendertem Frontend und entkoppelten Diensten. Der Beitrag ordnet die ursprünglichen Bausteine JavaScript, APIs und Markup im Stand von 2021 ein.","tags":["Software","Architektur"],"published_at":"2021-11-15T10:00:00+00:00","updated_at":"2026-07-13T13:32:54.201633+00:00","url":"https://www.mikebild.dev/de/blog/jamstack-architektur/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/a6a2771076c4cfa5c4045be101cf50af68dd03408c0b82f37c72406b53f23e4a/jamstack-architektur-vorlesen.mp3","audio_duration_secs":792,"text":"Jamstack lässt sich nicht per `npm install` in ein Projekt holen, und kaufen kann man ihn auch nirgends. Es ist ein Architekturstil für Webanwendungen, der Vorrendering und die Entkopplung von Oberfläche und Diensten betont.\n\nDeshalb fällt die Entscheidung auch eine Ebene höher als die Wahl des nächsten Frameworks. Genau dort setzt Jamstack an.\n\nDer Name wurde ursprünglich aus **JavaScript, APIs und Markup** gebildet. Geprägt haben ihn Matt Biilmann und Chris Bach bei Netlify; Biilmann machte ihn 2015/2016 öffentlich bekannt. Schon 2021 wurde Jamstack breiter als das Akronym verwendet. Geblieben sind zwei Prinzipien: Vorrendering und Entkopplung. Statt bei jeder Anfrage frisch HTML auf dem Server zu erzeugen, wird Markup vorab gebaut und über ein CDN ausgeliefert; dynamische Teile greifen getrennt auf APIs zu.\n\n## Der Paradigmenwechsel: klassisches SSR gegen Jamstack\n\nUm zu verstehen, warum das relevant ist, lohnt sich der Blick auf das klassische Modell. Bei serverseitigem Rendering im traditionellen Sinn passiert bei jedem Seitenaufruf dasselbe: Der Browser schickt einen Request, ein Applikationsserver nimmt ihn entgegen, fragt eine Datenbank ab, baut daraus HTML zusammen und schickt es zurück. Jeder einzelne Besucher löst diese Kette erneut aus. Das ist bewährt, aber es bindet Serverkapazität pro Anfrage und macht den App-Server zum Nadelöhr und zur Angriffsfläche zugleich.\n\nDer Jamstack dreht diese Reihenfolge um. Die Arbeit passiert nicht mehr pro Request, sondern einmalig zur Build-Zeit. Ein Static-Site-Generator zieht Inhalte aus Datenquellen, rendert daraus fertige HTML-Seiten und legt sie als statische Dateien ab. Diese Dateien landen auf einem CDN und werden von dort global, nah am Nutzer, ausgeliefert. Was dynamisch sein muss – ein Warenkorb, ein Login, eine Suche – wird nachträglich clientseitig über APIs nachgeladen.\n\n```mermaid\nflowchart LR\n  subgraph Classic[\"Klassisches SSR\"]\n    B1[\"Browser\"] -->|\"Request\"| S1[\"App-Server\"]\n    S1 --> DB[(\"Datenbank\")]\n    DB --> S1\n    S1 -->|\"HTML pro Request\"| B1\n  end\n  subgraph Jam[\"Jamstack\"]\n    B2[\"Browser\"] -->|\"statische Seite\"| CDN[\"CDN<br/>(pre-rendered)\"]\n    B2 -->|\"Dynamik zur Laufzeit\"| API[\"APIs /<br/>Serverless\"]\n  end\n```\n\nEine Klarstellung, die in Diskussionen regelmäßig untergeht: Der Jamstack verbietet serverseitiges Rendering nicht. Er verlagert den Standardfall. Statt „jede Seite pro Request auf dem Server“ heißt es „jede Seite einmal im Build“ – und dort, wo es wirklich nötig ist, darf ich serverseitiges Rendering weiterhin gezielt einsetzen. Genau darum geht es im nächsten Abschnitt.\n\n## Static-Site-Generatoren und Hybrid-Rendering\n\nDer Motor des Jamstack ist der Static-Site-Generator. Im einfachsten Fall ist das ein Prozess, der Inhalte einliest und daraus HTML schreibt. Ein bewusst reduziertes Beispiel macht das Prinzip greifbar – Markdown rein, HTML raus:\n\n```jsx\nimport { loadMarkdown, renderToHtml, writeFile } from \"./ssg\";\n\nfor (const post of loadMarkdown(\"content/**/*.md\")) {\n  const html = renderToHtml(post);\n  writeFile(`dist/${post.slug}/index.html`, html);\n}\n```\n\nIn der Praxis übernehmen das ausgereifte Werkzeuge. Gatsby etwa ist ein React-basierter Generator mit einem GraphQL-Data-Layer und einem großen Plugin-Ökosystem. Next.js – aktuell in Version 12, erschienen Ende Oktober 2021 mit einem neuen, deutlich schnelleren Rust-Compiler auf SWC-Basis – bietet mehrere Rendering-Modi nebeneinander an. Und SvelteKit, das sich seit dem Frühjahr 2021 in einer öffentlichen Beta befindet, geht denselben Weg über ein Adapter-Konzept. SvelteKit ist bewusst noch als jung einzuordnen; es ist pre-1.0 und im Beta-Stadium, aber die Richtung ist bereits klar erkennbar.\n\nDer entscheidende Punkt moderner Generatoren ist, dass ich das Rendering nicht global festlege, sondern pro Seite oder Route entscheide. Am Beispiel von Next.js lässt sich das gut sortieren:\n\n- SSG (Static Site Generation) über `getStaticProps` und `getStaticPaths` rendert die Seite im Build; danach ist sie reines Markup vom CDN.\n- SSR (Server-Side Rendering) über `getServerSideProps` erzeugt die Seite pro Request auf dem Server, für echt dynamische Inhalte.\n- ISR (Incremental Static Regeneration), seit Version 9.5 aus dem Jahr 2020 verfügbar, baut eine statische Seite zeit- oder ereignisgesteuert im Hintergrund neu.\n- CSR (Client-Side Rendering) über Hooks und Datenbibliotheken wie SWR lädt die Daten erst im Browser nach.\n\nEin typisches SSG-Pattern mit einem Hauch ISR sieht so aus:\n\n```ts\nexport async function getStaticProps() {\n  const posts = await fetchFromCMS();\n\n  return {\n    props: { posts },\n    revalidate: 3600, // ISR: regenerate at most once per hour\n  };\n}\n```\n\nDas `revalidate` ist der Übergang vom rein Statischen ins Hybride: Die Seite bleibt statisch und CDN-schnell, altert aber nicht ein, weil sie sich nach der angegebenen Zeit selbst erneuert. Diese Mischung – statisch, wo möglich, serverseitig oder clientseitig, wo nötig – ist in meiner Erfahrung der realistische Normalfall, nicht die reine Lehre.\n\n## CDN und Performance\n\nWarum der ganze Aufwand? Weil vorgerenderte Assets vom CDN oft schneller beim Nutzer ankommen. Statt einer Rechenkette aus Server und Datenbank liefert ein naher Edge-Standort eine fertige Datei aus. Das kann die Time to First Byte (TTFB) deutlich verbessern. Für die Interaktivität reicht fertiges Markup allein jedoch nicht: Viel JavaScript und teure Hydration können den Vorteil wieder aufzehren. Deshalb messe ich Auslieferung und Interaktivität getrennt, statt beides dem Pre-Rendering gutzuschreiben.\n\nHinzu kommt der Effekt auf SEO. Suchmaschinen bekommen bei vorgerendertem Markup sofort vollständigen Inhalt zu sehen, ohne auf clientseitiges Nachladen angewiesen zu sein. Für inhaltsgetriebene Seiten – Blogs, Dokumentationen, Marketing – ist das ein handfester Vorteil, den ich nicht der reinen Ladegeschwindigkeit unterordnen würde.\n\nEin häufiges Missverständnis will ich hier ausräumen: Das CDN liefert die statischen Assets aus, nicht die APIs. Die dynamischen Daten kommen weiterhin über API-Requests – entweder direkt aus dem Frontend oder über eine vorgelagerte Frontend-API. Das CDN beschleunigt also die Auslieferung des Gerüsts, während die dynamischen Teile getrennt davon bedient werden.\n\n## Entkopplung und API-Orchestrierung\n\nDamit sind wir beim zweiten großen Prinzip: der Entkopplung. Im Jamstack sind Auslieferung des Frontends und dynamische Datenzugriffe getrennt. Das Backend kann weiterhin eine zusammenhängende Anwendung sein oder aus mehreren Diensten bestehen; entscheidend ist, dass das Frontend während Build oder Laufzeit über APIs darauf zugreift. Authentifizierung kann ein externer Dienst übernehmen, Inhalte können aus einem Headless-CMS kommen, und eigene Geschäftslogik kann in Serverless-Functions liegen. Das sind verbreitete Bausteine, keine Definition der Architektur.\n\nDie spannende Frage ist dann, wo diese vielen APIs zusammengeführt werden. Zwei Wege haben sich etabliert. Entweder orchestriere ich direkt im Frontend, indem die Komponenten die benötigten Endpoints selbst abfragen:\n\n```jsx\nimport useSWR from \"swr\";\n\nconst fetcher = (url) => fetch(url).then((res) => res.json());\n\nfunction Cart() {\n  const { data, error } = useSWR(\"/api/cart\", fetcher);\n  if (error) return <p>Failed to load</p>;\n  if (!data) return <p>Loading…</p>;\n  return <CartView items={data.items} />;\n}\n```\n\nOder ich lege eine Frontend-API davor – häufig über GraphQL, etwa mit AWS AppSync oder dem GraphQL-Endpoint eines Headless-CMS. Das Frontend stellt dann eine einzige, konsolidierte Anfrage, und die Orchestrierung der dahinterliegenden Dienste passiert eine Schicht tiefer. Für größere Anwendungen mit vielen Datenquellen bevorzuge ich diesen Weg, weil er das Frontend von der Detailkenntnis über jedes einzelne Backend entlastet.\n\nDie Serverless-Functions selbst sind bewusst schlank und zustandslos. Ein Handler im Netlify- beziehungsweise Lambda-Stil sieht typischerweise so aus:\n\n```js\nexports.handler = async (event, context) => {\n  const { name } = JSON.parse(event.body);\n\n  return {\n    statusCode: 200,\n    body: JSON.stringify({ message: `Hello ${name}` }),\n  };\n};\n```\n\nDer wirtschaftliche Reiz dahinter kann das Abrechnungsmodell sein: pay-per-execution. Je nach Plattform, Grundgebühr und Lastprofil zahlt man stärker für tatsächliche Aufrufe und Rechenzeit als für dauerhaft vorgehaltene Server. Auch die Skalierung übernimmt der Anbieter innerhalb seiner Limits weitgehend. Laufzeitgrenzen, verteilte Fehler, Quoten und die Kosten einzelner Aufrufe bleiben trotzdem Architekturfragen.\n\n## Build und CI: der Git-Push als Auslöser\n\nDie Vorrenderei muss natürlich irgendwann passieren. Häufig startet ein Git-Push eine CI-Pipeline – etwa über GitHub Actions –, die den Static-Site-Generator ausführt und das Ergebnis auf ein CDN bringt. Viele spezialisierte Plattformen veröffentlichen diese Artefakte atomar und versioniert, sodass eine neue Version geschlossen aktiv wird und sich zurückrollen lässt. Das ist eine Eigenschaft des gewählten Deployment-Systems, nicht automatisch jedes Jamstack-Builds.\n\n```mermaid\nflowchart LR\n  Dev[\"git push\"] --> CI[\"GitHub Actions<br/>(CI)\"]\n  CI --> SSG[\"Static Site Generator<br/>(Build)\"]\n  SSG --> Deploy[\"atomic deploy\"]\n  Deploy --> CDN[\"CDN<br/>(global edge)\"]\n  CDN --> User[\"User\"]\n  SSG -.->|\"großer Katalog\"| Shard[\"Sharding /<br/>parallele Builds\"]\n  Shard --> Deploy\n```\n\nEin einfacher Workflow, der genau das tut, ist schnell aufgeschrieben:\n\n```yaml\nname: build-and-deploy\non:\n  push:\n    branches: [main]\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n      - uses: actions/setup-node@v2\n        with:\n          node-version: \"16\"\n      - run: npm ci\n      - run: npm run build\n      - run: npx netlify deploy --prod --dir=dist\n```\n\nBei kleinen Seiten ist der Build in Sekunden durch. Bei großen Katalogen mit vielen tausend Seiten wird die Build-Zeit selbst zum Thema – hier hilft Sharding, also das Aufteilen des Builds in parallele Teilprozesse. Das ist gleichzeitig ein guter Übergang zu den Grenzen, die ich weiter unten anspreche.\n\n## Sicherheit und Betriebsvorteile\n\nEin Side Effect der Architektur, der leicht übersehen wird, betrifft die Sicherheit. Wenn zur Laufzeit gar kein Applikationsserver pro Request angesprochen wird, schrumpft die Angriffsfläche erheblich. Kein monolithischer Server, der bei jeder Anfrage Datenbankzugriffe fährt und dabei angreifbar wird. Ausgeliefert werden statische Dateien, und die dynamischen Teile stecken in klar abgegrenzten, einzeln absicherbaren APIs. In Summe ergibt sich daraus eine handliche Liste an Vorteilen:\n\n- Die TTFB kann sinken, weil vorgerendertes Markup direkt vom Edge kommt; die Interaktivität hängt zusätzlich am ausgelieferten JavaScript.\n- Für statische Seiten entfällt ein Applikationsserver pro Lesezugriff; die APIs bleiben eine eigene Angriffsfläche.\n- CDN und Plattform können einen großen Teil der Skalierung übernehmen, jeweils innerhalb ihrer Quoten und Laufzeitgrenzen.\n- Pay-per-execution kann bei passendem Lastprofil Kosten sparen, ist aber nicht grundsätzlich billiger.\n- Die Entwicklererfahrung profitiert von klarer Trennung, Git-basierten Deploys und schnellen Rollbacks.\n\n## Drei Fallstudien aus der Praxis\n\nDamit das nicht theoretisch bleibt, drei Szenarien, die ich in dieser Reihenfolge fast als Reifegrad lese.\n\nDas erste ist der statische Blog oder das Portfolio. Hier spielt der Jamstack seine Stärken aus: Inhalte werden im Build gerendert, es gibt kaum Dynamik, und das CDN kann sie ohne Anwendungsserver ausliefern. Für diese Klasse von Projekten ist reines SSG oft schon die vollständige Antwort.\n\nDas zweite Szenario ist B2C-E-Commerce. Sobald Sessions, Warenkörbe und Transaktionen ins Spiel kommen, reicht das rein Statische nicht mehr. Produktseiten lassen sich hervorragend statisch vorrendern und über das CDN ausliefern, während Warenkorb, Checkout und Nutzerkonto clientseitig über APIs bedient werden. Das ist der klassische Hybrid-Fall, bei dem ISR zusätzlich hilft, Produktdaten aktuell zu halten, ohne jede Seite pro Request neu zu bauen.\n\nDas dritte Szenario sind B2B-, SaaS- und IoT-Anwendungen. Hier dominieren Dashboards, personalisierte Ansichten und Echtzeitdaten. Ein statisches Gerüst kann zur schnellen Erstauslieferung beitragen; geschützte Bereiche lassen sich je nach Produkt clientseitig, serverseitig oder hybrid rendern. Auch eine GraphQL-Frontend-API kann sinnvoll sein, wenn tatsächlich mehrere Datenquellen für die Oberfläche gebündelt werden müssen.\n\n## Wo der Jamstack an Grenzen stößt\n\nSo überzeugend das Modell ist, ehrlich bleibe ich bei den Rändern. Reines SSG skaliert nicht beliebig. Bei einigen tausend Seiten wird die Build-Zeit langsam relevant, bei Millionen von Seiten wird das vollständige Vorrendern schlicht unpraktikabel. Genau an diesem Punkt kommen die bereits genannten Hilfsmittel ins Spiel: ISR rendert nur das nach, was sich wirklich ändert; Hybrid-Rendering erlaubt serverseitiges Erzeugen für die selten besuchten oder hochdynamischen Routen; und Sharding verteilt große Builds auf parallele Prozesse.\n\nAuch bei der Werkzeugauswahl lohnt Nüchternheit. SvelteKit ist Ende 2021 vielversprechend, aber noch Beta. Azure Static Web Apps ist erst seit Mai 2021 allgemein verfügbar und damit ebenfalls recht jung, integriert dafür Azure Functions als Backend direkt mit. Schnelle Build-Bausteine wie esbuild von Evan Wallace beschleunigen die Pipeline enorm, sind aber selbst noch vor der 1.0. Wer hier baut, wählt bewusst zwischen etabliert und aufstrebend.\n\n## Fazit\n\nDer Jamstack ist für mich weniger eine Technologie als eine Haltung zur Architektur. Er verschiebt geeignete Arbeit vom Request in den Build und trennt die Auslieferung des Frontends von dynamischen Datenzugriffen. Ob dahinter eine Enterprise-Anwendung, mehrere Dienste oder Serverless-Functions stehen, ist eine eigene Entscheidung. Schnelle Auslieferung über das CDN, eine kleinere Laufzeitfläche und einfache Deployments können daraus folgen, wenn Anwendung und Plattform dazu passen.\n\nEntscheidend ist, dass der Jamstack technologieoffen bleibt. Er schreibt mir kein Framework vor, sondern eine Denkweise, innerhalb derer ich Next.js, Gatsby oder SvelteKit ebenso einsetzen kann wie Netlify, Vercel oder Azure. Und er ist kein Dogma: Reines SSG für den Blog, Hybrid für den Shop, serverseitig plus API-Orchestrierung für die SaaS-Plattform. Mit dieser Abstufung fällt die Architekturentscheidung dort, wo sie hingehört – eine Ebene über dem Framework.\n\n## Weiterführende Quellen\n\n- Mike Bild: „JAMstack-Architektur“ – der ausführliche Originalartikel: https://www.developer-world.de/dwx-insights/architecture-patterns/jamstack-architektur\n- Offizielle Jamstack-Ressource: https://jamstack.org/\n- [Jamstack: Definition und Herkunft des Namens](https://jamstack.org/what-is-jamstack/)\n- Next.js 12 (Release vom 26.10.2021): https://nextjs.org/blog/next-12\n- SvelteKit Public Beta: https://svelte.dev/blog/sveltekit-beta\n- Azure Static Web Apps GA (Mai 2021): https://www.infoq.com/news/2021/05/azure-static-web-apps-ga/"},{"slug":"ses-email-hook-serverless","title":"Transaktionale E-Mails serverless: SES und Lambda","summary":"Wie SES zum ereignisgetriebenen E-Mail-Baustein wird: ausgehende Bestätigungsmails aus einer Lambda-Function und eingehende Receipt Rules, die aus einer Mail einen Handler auslösen.","tags":["Software","AWS","Cloud"],"published_at":"2021-11-09T10:00:00+00:00","updated_at":"2026-07-13T08:41:19.011377+00:00","url":"https://www.mikebild.dev/de/blog/ses-email-hook-serverless/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/76e901a3677cedd9ce69bf8a1ad1f51dd207024d8f2519d378217799ef4a273d/ses-email-hook-serverless-vorlesen.mp3","audio_duration_secs":949,"text":"Ein `SendEmail`-Aufruf trägt bis zu 50 Empfänger und 10 MB Message. Ein eingehendes SES-Event trägt null Byte Mailtext: Es liefert Header und Metadaten, der Body liegt in S3. Diese zwei Zahlen sagen mehr über den Dienst als jede Feature-Liste – die ausgehende Richtung ist in einer halben Stunde erledigt, die eingehende hat eigene Regeln, die man kennen muss.\n\nDahinter steckt eine Entscheidung, die im Team wie Konfiguration klingt: „Und wie schicken wir jetzt die Bestätigungsmail raus?“ Häufig steht da schon ein Signup-Flow, vielleicht mit einer User-Verwaltung wie in [Serverless-Auth mit Cognito User Pools](https://www.mikebild.dev/de/blog/cognito-userpool-auth-serverless/), und die Mail wirkt wie der letzte, triviale Baustein. Genau da liegt der Denkfehler. Die eigentliche Frage lautet: Behandle ich E-Mail als externen Side Effect, den ich ans Ende eines Flows klebe, oder als vollwertiges Domänen-Event – rein wie raus –, das dieselbe Behandlung verdient wie ein API-Aufruf oder eine Queue-Message?\n\nDiese Weichenstellung entscheidet, ob der E-Mail-Teil später sauber in die Architektur passt oder als Fremdkörper Reibung erzeugt. SES – der Simple Email Service – ist deshalb lehrreich, weil er beide Richtungen mit denselben Bausteinen bedient: Lambda auf der einen Seite, Receipt Rules auf der anderen. Der Hebel liegt nicht in den API-Feldern, sondern in dieser Symmetrie. Im Folgenden gehe ich beide Richtungen durch – zuerst die ausgehende Bestätigungsmail aus einer Funktion, danach den eingehenden Mail-Hook, der aus einer echten E-Mail zum Beispiel ein Support-Ticket macht. Der Code ist bewusst schlank gehalten, damit der rote Faden – Event rein, Event raus – sichtbar bleibt.\n\n## SES als Baustein in zwei Richtungen\n\nBevor wir Code schreiben, lohnt sich ein Blick auf das mentale Modell. SES sitzt an der Systemgrenze zwischen deiner Anwendung und der Außenwelt der E-Mail. Nach außen: Deine App löst ein Event aus – ein neuer Account, eine bezahlte Bestellung – und eine Lambda-Function ruft `SendEmail` auf. Nach innen: Jemand schickt eine Mail an eine deiner Adressen, und SES verarbeitet sie über sogenannte Receipt Rules weiter, etwa an S3, an SNS oder direkt an eine Lambda-Function.\n\nDiese Symmetrie ist der Kern. Sobald SES im Entwurf als ereignisgetriebener Baustein auftaucht, fügt er sich sauber in die übrige Serverless-Architektur ein. Bleibt er ein reiner „Mail-Versender“, hängt er hinterher außen dran. Die ausgehende Richtung ist dabei die häufigere und der einfachere Einstieg.\n\n- Ausgehend: `SendEmail` oder `SendTemplatedEmail`, ein verifizierter Absender, Sandbox und Quota im Blick, ausgelöst durch ein App-Event.\n- Eingehend: ein Receipt Rule Set mit Actions für S3, Lambda oder SNS, nur in bestimmten Regionen verfügbar, ausgelöst durch eine eintreffende Mail, Body über S3 nachgeladen.\n\nHalten wir diese beiden Zeilen im Kopf – sie strukturieren den Rest des Artikels.\n\n## E-Mail als Domänen-Event, nicht als SES-Konfiguration\n\nDer Unterschied klingt akademisch, hat aber handfeste Konsequenzen. Wer SES als Konfigurationsaufgabe begreift, hört bei „Absender verifiziert, `SendEmail` läuft“ auf. Wer E-Mail als Domänen-Event modelliert, fragt weiter: Welches fachliche Event löst diese Mail aus, und welches Event erzeugt eine eintreffende Mail in meinem System? Aus „ich versende eine Message“ wird „ein Signup ist passiert“, und aus „jemand hat gemailt“ wird „ein Support-Fall ist eingegangen“. Die Mail ist dann nur noch der Transport, nicht das Event selbst.\n\nDiese Perspektive kostet etwas und bringt etwas. Der Preis ist zusätzliche Sorgfalt an der Systemgrenze: verifizierte Identitäten, ein aktives Rule Set, korrekte Berechtigungen, das Wissen, dass eingehende Bodies in S3 liegen. Der Ertrag ist Anschlussfähigkeit. Ein Event lässt sich weiterreichen, orchestrieren, protokollieren und testen – eine „verschickte Mail“ dagegen ist eine Sackgasse.\n\nFür die Frage, ob SES der richtige Baustein ist, helfen ein paar Entscheidungskriterien:\n\n- Kopplung und Kontrolle: SES bleibt vollständig in AWS und IAM, ohne zweiten Anbieter und ohne zusätzlichen Vertrag. Dafür trägst du die regionale Einschränkung beim Empfang und das MIME-Parsing selbst.\n- Kosten: SES rechnet pro tausend Mails ab und verursacht im Leerlauf nichts. Ein spezialisierter Transaktions-Anbieter bringt bessere Zustellbarkeits-Werkzeuge und fertige Templates mit, aber eben auch eine eigene Rechnung und ein zweites System, das jemand betreiben muss.\n- Wirkung: Erst wenn die eingehende Mail ein echtes Event ist, lässt sich die Nachbearbeitung automatisieren – Klassifizierung, Zuweisung, Antwortentwurf. Genau das verkürzt die Zyklen, die sonst an manueller Triage hängen.\n\nSolange E-Mail eine Event Source und -senke innerhalb einer AWS-Architektur ist, ist SES die reibungsärmste Wahl. Sobald Marketing-Zustellbarkeit, Bounce-Analytics und Kampagnen-Tooling im Vordergrund stehen, ist ein dedizierter Anbieter ehrlicher. Für den transaktionalen Kern – Bestätigung raus, Ticket rein – gewinnt der ereignisgetriebene SES-Baustein.\n\n## Die ausgehende Bestätigungsmail\n\nFangen wir mit der ausgehenden Richtung an. Eine Lambda-Function wird durch ein Event ausgelöst – sagen wir, ein API-Gateway-Aufruf nach erfolgreicher Registrierung – und soll eine kurze Bestätigungsmail verschicken.\n\nDas folgende Diagramm zeigt den Weg des Events. Das Event kommt rein, die Lambda-Function übersetzt es in einen `SendEmail`-Aufruf, SES nimmt die Message an. Das ist noch keine Zustellgarantie: Bounces und Complaints laufen über eigene SES-Events. Wird das auslösende Event wiederholt, kann auch die Mail doppelt versendet werden. Für einen produktiven Flow brauche ich deshalb einen stabilen Ereignisschlüssel und einen gespeicherten Versandstatus oder eine Outbox.\n\n```mermaid\nflowchart LR\n  A[Event: Signup] --> B[Lambda: sendConfirmationEmail]\n  B --> C[ses:SendEmail]\n  C --> D[Recipient Inbox]\n```\n\nDer zugehörige Handler ist überschaubar. Ich nutze hier das AWS SDK für JavaScript in der Version 3, also die modularen `@aws-sdk/client-ses`-Pakete – die etablierte Version 2 mit `new AWS.SES()` funktioniert genauso, ist mir für neue Funktionen aber schlicht zu monolithisch im Import.\n\n```ts\nimport { SESClient, SendEmailCommand } from \"@aws-sdk/client-ses\";\n\nconst ses = new SESClient({ region: \"eu-west-1\" });\n\nexport async function sendConfirmationEmail(event: {\n  email: string;\n  name: string;\n}) {\n  const command = new SendEmailCommand({\n    Source: \"no-reply@example.com\",\n    Destination: {\n      ToAddresses: [event.email],\n    },\n    Message: {\n      Subject: {\n        Data: \"Willkommen bei uns\",\n        Charset: \"UTF-8\",\n      },\n      Body: {\n        Html: {\n          Data: `<p>Hallo ${event.name}, dein Konto ist aktiv.</p>`,\n          Charset: \"UTF-8\",\n        },\n      },\n    },\n  });\n\n  const result = await ses.send(command);\n  return { messageId: result.MessageId };\n}\n```\n\nDrei Dinge sind hier verpflichtend und deshalb einen genaueren Blick wert. `Source` ist die Absender-Adresse und muss eine 7-bit-ASCII-Adresse einer verifizierten Identität sein. `Destination` bündelt die Empfänger nach `ToAddresses`, `CcAddresses` und `BccAddresses`. `Message` trägt `Subject` und `Body`, wobei der Body Text und HTML enthalten kann, jeweils als Objekt aus `Data` und `Charset`. Zurück kommt eine `MessageId` – die einzige Quittung, die du für den erfolgreichen Aufruf bekommst. Optional lässt sich noch einiges ergänzen, etwa `ReplyToAddresses` oder ein `ConfigurationSetName`, aber für die Bestätigungsmail reicht das Gezeigte.\n\n### Verifizierte Absender, Sandbox und Quota\n\nDer Code ist selten die Stolperstelle. Es sind drei organisatorische Punkte, die man vorher klären muss.\n\nErstens die Absender-Identität. SES verschickt nur von verifizierten Absendern. Du kannst eine einzelne Adresse verifizieren oder – für die Produktion deutlich sinnvoller – eine ganze Domain über DKIM- und TXT-Einträge im DNS. Ist der Absender nicht verifiziert, quittiert SES den Aufruf mit „Email address not verified“, und die Mail geht nicht raus.\n\nZweitens die Sandbox. Jeder neue SES-Account startet in der Sandbox. Das bedeutet: Du darfst nur an verifizierte Empfänger senden beziehungsweise an den SES Mailbox Simulator, und die Sending-Quota ist reduziert. Die Bestätigungsmail an einen echten Kunden schlägt in der Sandbox also fehl, solange dessen Adresse nicht selbst verifiziert ist. Für den Produktionsbetrieb muss man den Produktionszugang per Support-Case beantragen.\n\nDrittens die Quota. Jeder Empfänger einer Message zählt gegen dein Sending-Kontingent. Ein `SendEmail`-Aufruf darf maximal 50 Empfänger über `To`, `Cc` und `Bcc` zusammen adressieren und die Message bis zu 10 MB groß sein.\n\nFür Demos und Tests ist der Mailbox Simulator Gold wert. Ein Aufruf an `success@simulator.amazonses.com` verhält sich wie eine erfolgreiche Zustellung, ohne dass du in der Sandbox anecken oder echte Postfächer belasten musst.\n\n### Templates statt String-Konkatenation\n\nSobald mehr als eine Mail-Art im Spiel ist, wird das Zusammenbauen von HTML im Code unübersichtlich. SES bietet dafür serverseitige Templates. Man legt einmalig ein Template mit `CreateTemplate` an – mit `SubjectPart`, `TextPart` und `HtmlPart`, in denen Platzhalter in doppelten geschweiften Klammern stehen – und verschickt dann mit `SendTemplatedEmail`.\n\n```ts\nimport { SESClient, SendTemplatedEmailCommand } from \"@aws-sdk/client-ses\";\n\nconst ses = new SESClient({ region: \"eu-west-1\" });\n\nexport async function sendWelcome(email: string, name: string) {\n  const command = new SendTemplatedEmailCommand({\n    Source: \"no-reply@example.com\",\n    Destination: { ToAddresses: [email] },\n    Template: \"WelcomeTemplate\",\n    TemplateData: JSON.stringify({ name }),\n  });\n\n  return ses.send(command);\n}\n```\n\n`TemplateData` ist ein JSON-String, dessen Schlüssel die Platzhalter im Template füllen. Der Vorteil: Der Text steht nicht mehr im Deployment-Artefakt, sondern liegt als Ressource bei SES und lässt sich unabhängig vom Code aktualisieren. Für viele Empfänger auf einmal gibt es außerdem `SendBulkTemplatedEmail` – im transaktionalen Kontext braucht man das aber selten, weil dort meist eine Mail an eine Person geht.\n\n## Der eingehende Mail-Hook\n\nJetzt drehen wir die Richtung um. Die zweite, oft übersehene Fähigkeit von SES: Es kann eintreffende E-Mails empfangen und daraus ein Event in deiner Architektur machen. Das ist die Grundlage für Muster wie „aus einer Mail an support@ wird automatisch ein Ticket“.\n\nVerwaltet wird das über ein Receipt Rule Set. Es gibt pro Account mehrere Rule Sets, aber immer nur eines ist aktiv. Innerhalb eines Rule Sets definierst du einzelne Receipt Rules, die auf bestimmte Empfänger-Adressen matchen und eine Liste von Actions abarbeiten. Als Actions stehen unter anderem zur Verfügung: die Ablage in einem S3-Bucket, das Publizieren an ein SNS-Topic, das Aufrufen einer Lambda-Function, das Hinzufügen eines Headers, ein Bounce oder ein Stop.\n\nDer übliche Aufbau für einen Ticket-Flow kombiniert zwei Actions: Erst wird die Roh-Mail nach S3 geschrieben, dann eine Lambda-Function ausgelöst, die sich um die Verarbeitung kümmert.\n\n```mermaid\nflowchart LR\n  A[Incoming Mail] --> B[SES Receipt Rule]\n  B --> C[S3: raw MIME]\n  B --> D[Lambda: handleInboundEmail]\n  D --> E[Ticket / DB]\n  B -.-> F[SNS Notification]\n```\n\n### Die Receipt Rule als Infrastruktur\n\nDie Receipt Rule selbst gehört in Infrastructure-as-Code, nicht in ein manuell geklicktes Konsolen-Setup – sonst ist sie nach dem nächsten Account-Wechsel weg. Als Skizze in CloudFormation-naher Form sieht eine Regel etwa so aus:\n\n```yaml\nResources:\n  SupportRule:\n    Type: AWS::SES::ReceiptRule\n    Properties:\n      RuleSetName: default-rule-set\n      Rule:\n        Name: support-inbound\n        Enabled: true\n        Recipients:\n          - support@example.com\n        Actions:\n          - S3Action:\n              BucketName: inbound-mail-bucket\n          - LambdaAction:\n              FunctionArn: !GetAtt InboundFn.Arn\n              InvocationType: Event\n```\n\nZwei Details sind hier wichtig. Die Reihenfolge der Actions zählt: Erst muss die Mail in S3 liegen, dann wird die Lambda-Function gerufen, die sie von dort liest. Und `InvocationType: Event` bedeutet asynchroner Aufruf – das ist die empfohlene Variante. Der synchrone `RequestResponse`-Aufruf existiert ebenfalls, hat aber ein hartes Timeout von 30 Sekunden und erlaubt der Lambda-Function, den weiteren Regelverlauf per Rückgabe zu steuern, etwa mit `{ disposition: \"STOP_RULE\" }`. Für einen Ticket-Handler, der ohnehin nichts am Mailfluss steuern will, ist `Event` die richtige Wahl.\n\n### Der Handler und der häufigste Irrtum\n\nKommen wir zum Handler – und zum Fallstrick, der in Projekten am zuverlässigsten zuschlägt. Die naheliegende Annahme ist, dass das Lambda-Event den Mailtext enthält. Das tut es nicht. Das Event, das SES an die Lambda-Function übergibt, trägt nur Metadaten und Header – Absender, Betreff, Message-ID –, aber nicht den Body. Der komplette MIME-Inhalt landet in S3, und von dort muss der Handler ihn nachladen.\n\nDas Event ist um ein `Records`-Array herum aufgebaut, mit `eventSource: \"aws:ses\"` und den Feldern `ses.receipt` sowie `ses.mail`. Letzteres enthält unter `commonHeaders` die aufbereiteten Header und unter `messageId` den Schlüssel, unter dem die Roh-Mail in S3 liegt.\n\n```ts\nimport { S3Client, GetObjectCommand } from \"@aws-sdk/client-s3\";\nimport { Readable } from \"stream\";\n\nconst s3 = new S3Client({ region: \"eu-west-1\" });\n\nasync function streamToString(stream: Readable): Promise<string> {\n  const chunks: Buffer[] = [];\n  for await (const chunk of stream) {\n    chunks.push(Buffer.from(chunk));\n  }\n  return Buffer.concat(chunks).toString(\"utf-8\");\n}\n\nexport async function handleInboundEmail(event: any) {\n  const record = event.Records[0];\n  const mail = record.ses.mail;\n  const messageId = mail.messageId;\n\n  const raw = await s3.send(\n    new GetObjectCommand({\n      Bucket: \"inbound-mail-bucket\",\n      Key: messageId,\n    })\n  );\n\n  const mime = await streamToString(raw.Body as Readable);\n\n  // parse MIME, then create a support ticket\n  await createTicket({\n    from: mail.commonHeaders.from?.[0],\n    subject: mail.commonHeaders.subject,\n    body: mime,\n  });\n\n  return { status: \"processed\" };\n}\n```\n\nDie eigentliche MIME-Zerlegung habe ich hier bewusst ausgespart – dafür nimmt man eine Bibliothek, denn multipart-Mails mit Anhängen von Hand zu parsen ist keine gute Idee. Der Punkt ist der Ablauf: Header aus dem Event, Body aus S3, dann die fachliche Aktion. Das S3-Objekt darf dabei bis zu 40 MB groß sein.\n\n### Berechtigungen und DNS, sonst passiert stumm nichts\n\nDer zweite Fallstrick beim Empfang ist unspektakulär und deshalb tückisch: Ohne die richtigen Berechtigungen verarbeitet SES eintreffende Mail einfach stillschweigend nicht. SES braucht eine Resource-Policy, die ihm erlaubt, in den S3-Bucket zu schreiben, an das SNS-Topic zu publizieren oder die Lambda-Function aufzurufen. Fehlt eine dieser Erlaubnisse, gibt es keine laute Fehlermeldung – die Mail versickert.\n\nDazu kommt DNS. Damit Mail überhaupt bei SES ankommt, müssen die MX-Einträge deiner Domain auf `inbound-smtp.<region>.amazonaws.com` zeigen. Und es gibt eine harte regionale Einschränkung: E-Mail-Empfang bietet SES nur in ausgewählten Regionen an – Stand heute us-east-1, us-west-2 und eu-west-1. Lambda und SNS für den eingehenden Flow müssen in derselben Region wie der SES-Endpoint liegen. Wer seine übrige Infrastruktur in einer anderen Region betreibt, muss diesen Teil bewusst dorthin ziehen.\n\n## Wo SES in die größere Architektur passt\n\nDie beiden Richtungen ergeben zusammen ein hübsches Bild: Lambda ist der gemeinsame Rechenkern, SES die Systemgrenze zur E-Mail-Welt. Nach außen übersetzt eine Funktion App-Events in Mails, nach innen übersetzt SES eintreffende Mails in Funktionsaufrufe.\n\nInteressant wird es, wenn der eingehende Hook nicht das Ende, sondern der Anfang einer längeren Verarbeitung ist. Eine Support-Mail, die geparst wird, kann eine Kette von Schritten anstoßen – Klassifizierung, Zuweisung, Antwortentwurf. Genau dafür lohnt sich der Blick auf eine Orchestrierung, wie ich sie in [Serverless-Workflows mit AWS Step Functions](https://www.mikebild.dev/de/blog/step-functions-serverless-workflows/) beschrieben habe: Die SES-Lambda legt das Ticket an und übergibt an eine State Machine, die den Rest koordiniert. So bleibt der Mail-Hook klein und macht genau eine Sache, während die Fachlogik ihren eigenen, nachvollziehbaren Ablauf bekommt.\n\n## Fazit\n\nDer Aha-Moment bei SES liegt in der eingehenden Richtung: wenn klar wird, dass eintreffende Mail kein Sonderfall ist, sondern ein ganz normaler Event-Trigger – genauso wie ein API-Aufruf oder eine Queue-Message. Ausgehend ist der Dienst schnell erklärt: verifizierter Absender, `SendEmail` oder ein Template, Sandbox und Quota im Kopf behalten. Eingehend braucht mehr Sorgfalt: ein aktives Rule Set, die richtigen Berechtigungen, MX-Einträge und das Wissen, dass der Body in S3 liegt und nicht im Event.\n\nDer Gewinn dieser Perspektive ist konkret. Kürzere Zyklen, weil ein eingehender Mail-Hook Nachbearbeitung anstößt, statt sie in einem Postfach zu parken. Weniger Reibung, weil E-Mail denselben ereignisgetriebenen Regeln folgt wie der Rest der Architektur und nicht als Fremdkörper danebensteht. Höhere Qualität, weil ein modelliertes Event testbar, protokollierbar und orchestrierbar ist, wo eine lose verschickte Mail eine Sackgasse bleibt. Und klare Grenzen: Sobald Zustellbarkeit im großen Stil, Bounce-Analytics oder Kampagnen im Vordergrund stehen, ist ein spezialisierter Anbieter die ehrlichere Wahl – SES glänzt im transaktionalen Kern. Wer diese beiden Muster einmal sauber gebaut hat, denkt nicht mehr „wie verschicke ich eine Mail“, sondern „E-Mail ist einfach eine weitere Event Source und -senke“ – rein wie raus.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-aws-serverless.html\n- Repository: `introduction-aws`, Kapitel `ses.md` und `examples/email-hook`\n- [SendEmail (SES API Reference)](https://docs.aws.amazon.com/ses/latest/APIReference/API_SendEmail.html)\n- [SendTemplatedEmail (SES API Reference)](https://docs.aws.amazon.com/ses/latest/APIReference/API_SendTemplatedEmail.html)\n- [Verifizierte Identitäten anlegen](https://docs.aws.amazon.com/ses/latest/dg/creating-identities.html)\n- [E-Mail-Empfang – Konzepte](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-concepts.html)\n- [Invoke-Lambda-Action](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-action-lambda.html)\n- [Beispiel für ein eingehendes Mail-Event](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-action-lambda-event.html)\n- [Deliver-to-S3-Action](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-action-s3.html)\n- [Berechtigungen für den E-Mail-Empfang](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-permissions.html)"},{"slug":"state-management-vergleich","title":"State-Management in React: Redux Toolkit, leichte Stores, MobX","summary":"Wenn ein State wirklich app-weit ist, stellt sich die Werkzeugfrage – Redux Toolkit, ein leichter Store wie Zustand oder Jotai, oder das reaktive Modell von MobX. Warum diese Wahl aber erst nach der Gegenprobe kommt und meistens kleiner ausfällt als gedacht.","tags":["Software","Frontend","React"],"published_at":"2021-10-26T10:00:00+00:00","updated_at":"2026-07-19T18:46:18.505233+00:00","url":"https://www.mikebild.dev/de/blog/state-management-vergleich/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/2a0fd3f8e39f217c6a1c4143b5fa1d07579216e5cf768bc7650a14d34cc26fef/state-management-vergleich-vorlesen.mp3","audio_duration_secs":732,"text":"# State-Management in React: Redux Toolkit, leichte Stores, MobX\n\nEin globaler Store gehört nicht zur Grundausstattung einer React-Anwendung. Er ist die Ausnahme, die man begründen muss. So steht es im React-Überblick, wo die Besitzfrage im Mittelpunkt stand – wo gehört ein State hin? Offen blieb dort, was man denn nimmt, wenn ein State die Begründung tatsächlich besteht, wenn er also wirklich app-weit ist. Den Vergleich hatte ich versprochen. Hier ist er.\n\n**Redaktionelle Einordnung (Juli 2026):** Der Werkzeugteil ist eine Momentaufnahme vom Oktober 2021: Redux Toolkit 1.x, Zustand 3.x, Jotai 1.x und MobX 6. Die wichtigere Trennung ist weniger vergänglich: URL State, Server-Cache und eigener Client State haben verschiedene Besitzer. Aktuelle Versionen können die Ergonomie ändern, nicht diese Zuständigkeiten.\n\nBevor wir über Werkzeuge reden, muss ich aber auf die Bremse treten. Die Frage „Redux, Zustand oder MobX?“ wird meistens viel zu früh gestellt – zu einem Zeitpunkt, an dem noch gar nicht feststeht, ob überhaupt ein Store gebraucht wird. Beim genauen Hinsehen entpuppt sich der vermeintlich globale State oft als gar keiner. Deshalb kommt zuerst die Gegenprobe, und erst danach der eigentliche Vergleich.\n\nNicht jede State-Art braucht denselben Speicher. Die Wahl beginnt mit Besitz, Lebensdauer und Synchronisationsbedarf.\n\n::::comparison{title=\"State nach Verantwortung einordnen\" role=\"primary\" preferredPattern=\"comparison-matrix\"}\n:::side{label=\"Lokaler UI State\"}\n- Besitzer · eine Komponente oder ein begrenzter Teilbaum\n- Lebensdauer · an die Darstellung gebunden\n- Beispiele · geöffneter Dialog und Eingabefokus\n:::\n:::side{label=\"Geteilter Client State\"}\n- Besitzer · mehrere Oberflächenbereiche\n- Lebensdauer · innerhalb der Anwendungssitzung\n- Beispiele · lokale Auswahl und noch nicht gespeicherter Arbeitsstand\n:::\n:::side{label=\"Server State\"}\n- Besitzer · Backend oder Fachsystem\n- Lebensdauer · unabhängig vom Browser\n- Beispiele · Datensätze, Berechtigungen und freigegebene Revisionen\n:::\n::::\n\nDie Matrix empfiehlt keine Bibliothek. Sie verhindert zunächst, dass Serverdaten wie lokaler State oder kurzlebige UI-Details wie globale Fachobjekte behandelt werden.\n\n## Die Gegenprobe zuerst\n\nIn einem Projekt, das „unbedingt einen globalen Store“ brauchte, haben wir zuerst aufgeschrieben, was da eigentlich alles hinein sollte – und dann angefangen zu streichen. Ein überraschend großer Teil davon war in Wahrheit URL State: Filter, Seite, Auswahl oder Tab, sofern sie teilbar, navigierbar oder mit einem Lesezeichen speicherbar sein sollten. Kurzlebige Darstellungsdetails dürfen lokal bleiben. Ein Store, der navigierbare Werte zusätzlich hält, baut dagegen leicht eine zweite Wahrheit neben der Adresse auf.\n\nDer nächste große Block ist noch tückischer, weil er wie State aussieht und sich wie State anfühlt: geladene Daten. Die Liste der Rechnungen, das Nutzerprofil, das Suchergebnis. Das sind Cache-Kopien von etwas, dessen Wahrheit dem Backend gehört. Dafür braucht es Server-Cache-Semantik wie Schlüssel, Frische, Invalidierung und Fehlerzustände – ob sie eine spezialisierte Bibliothek oder etwa RTK Query innerhalb des Redux-Stores bereitstellt. Problematisch ist nicht der physische Speicherort, sondern ein handverwalteter Slice, der diese Semantik nachbaut.\n\nNimmt man diese beiden Blöcke heraus – navigierbaren State in die Adresse, Serverdaten in einen Cache –, bleibt oft wenig übrig. Der echte app-weite Client State kann ein kleiner Kern sein: Theme, Sprache, ein lokaler Entwurf oder ein Warenkorb, sofern dessen Wahrheit tatsächlich im Client liegt. Identität und Rechte stammen dagegen meist vom Server; lokal liegt nur ihre Cache-Kopie. Für den verbleibenden Kern lohnt sich ein Store, und weil dieser Kern klein ist, darf auch das Werkzeug klein sein.\n\n```mermaid\nflowchart TB\n  Start[\"Ein Wert, der wie<br/>globaler State aussieht\"]\n  Start --> Global{\"Wirklich app-weit,<br/>oder nur lokal gebraucht?\"}\n  Global -->|nur eine Ecke| Lokal[\"useState / useReducer<br/>in der Komponente\"]\n  Global -->|app-weit| URL{\"Gehört er in die URL?<br/>Filter, Seite, Auswahl\"}\n  URL -->|ja| Adresse[\"Router / URL<br/>teilbar, überlebt Reload\"]\n  URL -->|nein| Server{\"Gehört die Wahrheit<br/>dem Backend?\"}\n  Server -->|ja| Cache[\"Server-Cache<br/>TanStack Query / SWR\"]\n  Server -->|nein| Store[\"Echter Kern: erst<br/>jetzt Store-Wahl\"]\n```\n\nAuf die Reihenfolge kommt alles an. Wer mit der Werkzeugfrage anfängt, füllt den Store mit Dingen, die dort nichts zu suchen haben, und wundert sich später über Synchronisationsbugs. Wer mit der Gegenprobe anfängt, kommt oft mit `useState` und der URL durch und braucht gar keinen Store. Erst wenn nach dem Streichen ein Kern übrig bleibt, lohnt der Rest dieses Beitrags.\n\n## Drei Familien für den echten Kern\n\nAngenommen, dieser Kern ist da. Dann gibt es grob drei Denkschulen, wie man app-weiten State hält. Ich zeige jede an einem winzigen Beispiel und sage dazu ehrlich, was sie kostet – denn keine ist umsonst.\n\n### Redux Toolkit: das klassische Modell, entschlackt\n\nRedux hat ein klares Modell: Es gibt genau einen Store, einen zentralen State-Baum. Man ändert ihn nie direkt, sondern schickt Actions – beschreibende Nachrichten wie „increment“ –, und Pure Functions namens Reducer berechnen aus altem State plus Action den neuen. Dieses Modell hat Redux jahrelang den Ruf eingebracht, umständlich zu sein: viel Zeremonie, Action-Typen als Konstanten, Boilerplate für jeden Handgriff.\n\nRedux Toolkit – kurz RTK, heute die offiziell empfohlene Art, Redux zu schreiben – nimmt genau diese Zeremonie ab. Ein `createSlice` bündelt State, Actions und Reducer in einem Stück. Bemerkenswert ist der Reducer-Code: Er sieht aus, als würde man den State direkt mutieren, bleibt aber intern immutabel, weil RTK die Bibliothek Immer benutzt, die aus den scheinbaren Mutationen eine saubere neue Kopie macht.\n\n```js\nimport { configureStore, createSlice } from \"@reduxjs/toolkit\";\n\nconst cartSlice = createSlice({\n  name: \"cart\",\n  initialState: { items: [] },\n  reducers: {\n    addItem(state, action) {\n      // looks mutable, stays immutable via Immer\n      state.items.push(action.payload);\n    },\n    clear(state) {\n      state.items = [];\n    },\n  },\n});\n\nexport const { addItem, clear } = cartSlice.actions;\nexport const store = configureStore({ reducer: { cart: cartSlice.reducer } });\n```\n\nDas `configureStore` setzt den Store mit sinnvollen Voreinstellungen auf – die DevTools sind schon angebunden, gängige Middleware ist dabei. In TypeScript legt man sich zusätzlich einmal typsichere Hooks an (`useAppSelector`, `useAppDispatch`), sodass State und Actions durchgehend typisiert sind.\n\nDie Stärke von RTK liegt in der Nachvollziehbarkeit. Jede State-Änderung ist eine benannte Action, die durch einen Reducer läuft – ein protokollierbarer, reproduzierbarer Übergang. Die DevTools zeigen jede Action in der Reihenfolge, erlauben Time-Travel durch die Zustandshistorie und machen aus „warum steht da plötzlich dieser Wert?“ eine Frage, die man beantworten kann, statt sie zu erraten. In großen Teams und bei komplexen Abläufen ist das Gold wert: Der Zustandsfluss ist eine Konvention, die alle teilen, und keine Frage des persönlichen Stils. Der Preis ist ebenso ehrlich zu nennen: mehr Struktur, mehr Dateien, ein zentraler Baum, durch den alles läuft. Für den kleinen Kern einer mittelgroßen Anwendung ist das oft mehr Apparat, als der Kern verdient.\n\n### Leichte Stores: Zustand und Jotai\n\nDie zweite Familie setzt genau dort an: minimale API, kein Provider-Zwang, so wenig Code wie möglich. Zwei Vertreter aus demselben Umfeld, die aber unterschiedlich denken.\n\nZustand (ja, die Bibliothek heißt so – ein deutsches Wort als Name) gibt einem eine Funktion `create`, die einen Hook zurückliefert. Aus diesem Hook liest man per Selektor genau die Scheibe des States, die eine Komponente braucht. Der Selektor ist der Clou: Eine Komponente, die nur `count` liest, rendert nicht neu, wenn sich ein anderes Feld im Store ändert. Die Re-Renders bleiben granular, ohne dass man Context-Provider verschachteln muss.\n\n```js\nimport { create } from \"zustand\";\n\nconst useCartStore = create((set) => ({\n  items: [],\n  addItem: (item) => set((state) => ({ items: [...state.items, item] })),\n  clear: () => set({ items: [] }),\n}));\n\n// in a component: read only what you need\nconst count = useCartStore((state) => state.items.length);\n```\n\nJotai geht von der anderen Seite heran. Statt eines Stores, aus dem man Scheiben liest, gibt es viele kleine, atomare State-Einheiten – Atoms –, die man frei kombiniert. Ein Atom hält einen Wert, abgeleitete Atoms rechnen aus anderen Atoms. Eine Komponente abonniert genau die Atoms, die sie liest, und mehr nicht.\n\n```js\nimport { atom, useAtom } from \"jotai\";\n\nconst itemsAtom = atom([]);\nconst countAtom = atom((get) => get(itemsAtom).length);\n\n// in a component\nconst [count] = useAtom(countAtom);\n```\n\nDie Stärke beider ist offensichtlich: sehr wenig Code, feingranulare Updates, ein kleines Bundle. Man ist in Minuten produktiv, es gibt kaum Konzepte zu lernen. Der Preis ist die Kehrseite derselben Medaille – weniger Konvention, weniger vorgegebene Struktur. Redux zwingt einen in ein Muster; ein leichter Store lässt einem alle Freiheiten, auch die, sich ein Chaos zu bauen. Die Disziplin, den Store klein und geordnet zu halten und nicht doch wieder Serverdaten hineinzukippen, liegt beim Team. Für den kleinen Kern, um den es hier geht, ist das oft genau die richtige Größe.\n\n### MobX: der reaktive Ansatz\n\nDie dritte Familie denkt grundsätzlich anders. Redux und die leichten Stores sind im Kern immutabel: State ändern heißt, einen neuen State herstellen. MobX dreht das um. Man beschreibt beobachtbaren State, mutiert ihn ganz direkt, und die Bibliothek verfolgt selbständig, wer was liest, und aktualisiert genau die Stellen, die es angeht.\n\nMan macht ein Objekt mit `makeAutoObservable` beobachtbar. Abgeleitete Werte deklariert man als `computed` – hier als Getter –, und sie aktualisieren sich von allein, sobald sich ihre Grundlage ändert. Komponenten, die man in `observer` einwickelt, reagieren gezielt auf genau die beobachteten Werte, die sie im Rendern gelesen haben.\n\n```jsx\nimport { makeAutoObservable } from \"mobx\";\nimport { observer } from \"mobx-react-lite\";\n\nclass CartStore {\n  items = [];\n  constructor() {\n    makeAutoObservable(this);\n  }\n  addItem(item) {\n    this.items.push(item); // direct mutation is fine here\n  }\n  get count() {\n    return this.items.length; // derived, recomputes automatically\n  }\n}\n\nconst cart = new CartStore();\nconst CartBadge = observer(() => <span>{cart.count}</span>);\n```\n\nMobX glänzt, wenn viel State voneinander abgeleitet ist. Wo man in Redux Selektoren schreiben und memoisieren müsste, ergibt sich der abgeleitete Wert bei MobX von selbst und wird nur dann neu berechnet, wenn seine Eingaben sich ändern. Es bleibt auffällig wenig Code übrig, der nur Werte miteinander verbindet. Der Preis ist die Implizitheit: Genau weil man nichts explizit dispatcht, ist die Frage „wo kommt diese Änderung her?“ schwerer zu beantworten – die Aktualisierung passiert im Hintergrund, ausgelöst durch eine Mutation, die irgendwo im Code steht. Und es ist ein anderes mentales Modell als der Rest des React-Ökosystems: gezielte Mutation statt Immutabilität. Wer aus der Redux-Welt kommt, muss umdenken; wer objektorientiert denkt, fühlt sich sofort zu Hause.\n\n## Woran man die Wahl festmacht\n\nEs gibt keine Rangliste, die für alle stimmt – die Wahl hängt an ein paar Eigenschaften des Projekts. Je mehr Leute an demselben State arbeiten, desto mehr kann eine feste Konvention helfen. Für protokollierbare Übergänge und das Zurückspulen der Zustandshistorie sind die Redux-DevTools eine starke, aber nicht exklusive Lösung. Bei viel abgeleitetem State spielt das reaktive Modell von MobX seine Stärke aus. Dazu kommen Bundle-Budget, vorhandene Tooling-Anforderungen und die Vertrautheit des Teams; harte Größenurteile sollte man am konkreten Build messen, nicht aus einer Tabelle übernehmen.\n\nDaraus lassen sich ein paar Faustregeln ableiten, ohne dass sie Gesetze wären. Ist der Kern wirklich klein – eine angemeldete Person, ein Theme, ein Warenkorb –, reichen [Context mit `useReducer`](https://www.mikebild.dev/de/blog/context-richtig-einsetzen/) oder ein leichter Store wie Zustand, und mehr wäre überzogen. Steht ein großes Team vor komplexen Abläufen mit dem Wunsch nach auditierbaren Übergängen, ist Redux Toolkit die naheliegende Wahl, weil es genau diese Nachvollziehbarkeit zur Konvention macht. Lebt die Anwendung von viel abgeleitetem State, der sich kaskadierend aktualisieren soll, ist MobX in seinem Element. Und wenn zwei Optionen gleich gut passen, gibt die Vertrautheit des Teams den Ausschlag.\n\n## Die Wahl ist nachrangig\n\nKein Werkzeug ist „das beste“. Diese Vergleiche werden mit einer Ernsthaftigkeit geführt, die der Sache nicht angemessen ist – als hinge der Projekterfolg an Redux gegen MobX. Tut er nicht. Die Wahl des Stores ist nachrangig gegenüber der Besitzfrage, die davor kommt. Wer die URL für das nutzt, was in die URL gehört, und Serverdaten als Cache behandelt statt als State, hält den echten Client State so klein, dass fast jede der drei Familien reicht – und die Entscheidung dazwischen zur Geschmacksfrage schrumpft.\n\nDer Fehler, den die Werkzeugdebatte verdeckt, ist der teurere: einen großen globalen Store zu bauen und ihn mit URL State und Serverdaten zu füllen, die dort nicht hingehören. Kein Redux Toolkit und kein MobX rettet einen vor den Bugs, die daraus entstehen. Umgekehrt kommt man mit sauber gezogenen Grenzen und dem simpelsten der leichten Stores erstaunlich weit. Deshalb steht die Gegenprobe am Anfang dieses Beitrags und nicht am Ende: Sie entscheidet mehr als die Tabelle mit den drei Spalten danach.\n\n## Weiterführende Quellen\n\n- Repository introduction-react (Kapitel react-hooks, state-management): https://github.com/mikebild/introduction-react\n- Redux Toolkit, offizielle Dokumentation: https://redux-toolkit.js.org/\n- Zustand: https://github.com/pmndrs/zustand\n- Jotai: https://jotai.org/\n- MobX: https://mobx.js.org/"},{"slug":"dynamodb-datenmodellierung-single-table","title":"DynamoDB-Datenmodellierung: Single-Table-Design","summary":"DynamoDB belohnt ein anderes Denken als eine relationale Datenbank: nicht vom Datenmodell her, sondern von den Access Patterns. Single-Table-Design fasst mehrere Entity Types über zusammengesetzte Keys in einer Tabelle zusammen.","tags":["Software","AWS","Cloud"],"published_at":"2021-09-21T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/dynamodb-datenmodellierung-single-table/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/d146f748faf791163106431a1acbcfd3ad2a7b0788826f275bd7a6cf24048946/dynamodb-datenmodellierung-single-table-vorlesen.mp3","audio_duration_secs":700,"text":"# DynamoDB-Datenmodellierung: Single-Table-Design\n\nWer in DynamoDB den Partition Key festlegt, hat damit bereits entschieden, welche Queries später billig sind und welche gar nicht gehen. Es gibt keinen Optimizer, der eine ungeschickte Wahl im Nachhinein rettet, und keinen server-seitigen Join, der zwei Tabellen zur Laufzeit verbindet. Die Frage „Wo trage ich die Fremdschlüssel ein?“ hat schlicht keine Antwort – die Beziehung muss im Key stehen, bevor das erste Item geschrieben wird.\n\nDamit dreht sich die gewohnte Reihenfolge um. Bei einer relationalen Datenbank entwerfe ich zuerst ein sauberes, normalisiertes Modell und überlege mir die Queries später; der Optimizer sorgt schon dafür, dass die meisten Queries irgendwie funktionieren. Bei DynamoDB schreibe ich zuerst auf, welche Fragen die Anwendung an die Daten stellen wird, und leite daraus das Key-Schema ab. Das Datenmodell ist das Ergebnis dieser Muster, nicht ihr Ausgangspunkt.\n\n## Die drei Bausteine, mit denen alles anfängt\n\nSingle-Table steht und fällt mit drei Grundbausteinen. Solange die nicht sitzen, hängt jede Beschreibung des Musters in der Luft.\n\nEine DynamoDB-Tabelle hat einen Primary Key in einer von zwei Formen. Entweder ein Simple Primary Key – nur ein Partition Key (PK, historisch „hash key“). Oder ein Composite Primary Key aus Partition Key plus Sort Key (SK, „range key“). Bei der zusammengesetzten Variante muss die Kombination aus PK und SK pro Item eindeutig sein, der PK allein darf sich wiederholen. Genau das ist der Hebel für alles Weitere.\n\nDer Partition Key bestimmt über einen internen Hash, auf welcher physischen Partition ein Item landet. Der Sort Key ordnet die Items innerhalb einer Partition und erlaubt Range-Bedingungen. Alle Items mit demselben Partition Key bilden eine Item Collection – und diese Item Collection ist der eigentliche Kern von Single-Table-Design.\n\nDie `Query`-API liest immer genau eine Item Collection, also genau einen PK-Wert. Optional schränkt eine Bedingung auf den Sort Key das Ergebnis ein, mit Operatoren wie `=`, `<`, `<=`, `>`, `>=`, `BETWEEN` oder `begins_with`. Ein Aufruf liefert bis zu 1 MB, danach wird über `LastEvaluatedKey` paginiert. Der Gegensatz dazu ist der `Scan`, der die ganze Tabelle durchgeht – das will ich im Produktivbetrieb praktisch nie.\n\nDamit die Rollen klar bleiben, die Aufgabenverteilung im Überblick:\n\n- PK bestimmt Partition und Verteilung. Jede Query braucht genau einen PK-Wert – er entscheidet, welche Item Collection überhaupt gelesen wird.\n- SK ordnet und filtert innerhalb der Collection, über Range-Bedingungen und `begins_with`. Damit modelliere ich 1:n-Beziehungen und Hierarchien.\n- GSI (Global Secondary Index) definiert eine neue PK/SK-Kombination für zusätzliche Access Patterns. Jederzeit hinzufügbar, nur eventually consistent, mit eigenem Durchsatz.\n\nDer LSI (Local Secondary Index) ist der Kontrastfall: gleicher PK wie die Basistabelle, nur ein alternativer SK. Er unterstützt strong consistency, muss aber schon bei der Tabellenerstellung feststehen und bindet die Item Collection je PK an eine 10-GB-Grenze. Im Zweifel nehme ich den GSI.\n\n## Warum es keinen Join gibt – und was stattdessen passiert\n\nDynamoDB kennt keine server-seitigen Joins. Das klingt zunächst nach einem Mangel, ist aber eine bewusste Entscheidung für vorhersagbare Latenz. Der Trick: Ich verbinde die Daten nicht beim Lesen, sondern beim Schreiben. Wenn verschiedene Entity Types denselben Partition Key tragen, liegen sie in derselben Item Collection und lassen sich mit einem einzigen Query zusammen lesen. Das ist Pre-Joining – die Beziehung ist schon im Key-Schema angelegt, bevor der erste Lesezugriff passiert.\n\nNehmen wir ein kleines CMS mit Nutzern, deren Beiträgen und den Kommentaren zu einem Beitrag. Das zentrale Access Patterns lautet: „Hole einen Nutzer und alle seine Beiträge in einem Aufruf.“ Genau daraus leite ich das Key-Design ab, nicht aus einem ER-Diagramm.\n\n```mermaid\nflowchart LR\n  A[\"Zugriffsmuster:<br/>Hole User + seine Posts\"] --> B[\"PK wählen:<br/>USER#id\"]\n  B --> C[\"SK-Prefix wählen:<br/>USER# und POST#\"]\n  C --> D[\"Query auf PK = USER#123<br/>liefert eine Item Collection\"]\n  D --> E[\"Ergebnis:<br/>User-Item + alle Post-Items\"]\n```\n\nDie Ableitungsrichtung ist wichtig: Ich starte links beim Muster und arbeite mich zum Key vor. Das ist die Denkbewegung, die relationale Entwickler am Anfang irritiert.\n\n## Ein Single-Table-Layout\n\nSingle-Table-Design heißt: mehrere Entity Types in einer einzigen Tabelle, zusammengehalten über generische, zusammengesetzte Keys. Statt sprechender Attributnamen wie `UserId` nutze ich bewusst generische Namen wie `PK` und `SK`, denn dieselbe Spalte trägt je nach Entity Type unterschiedliche Bedeutungen. Für das CMS sieht ein Layout so aus:\n\n```text\nPK              SK                    weitere Attribute\n--------------  --------------------  -----------------------------\nUSER#123        USER#123              name, email, createdAt\nUSER#123        POST#2021-09-01T...   title, body, status\nUSER#123        POST#2021-09-14T...   title, body, status\nPOST#987        COMMENT#2021-09-15..  author, text\nPOST#987        COMMENT#2021-09-16..  author, text\n```\n\nDer Nutzer und seine Beiträge teilen sich den Partition Key `USER#123` und bilden damit eine Item Collection. Das User-Item hat einen SK, der den PK wiederholt (`USER#123`), die Beiträge nutzen den Prefix `POST#` gefolgt von einem Zeitstempel. Der Zeitstempel im Sort Key sortiert die Beiträge nebenbei chronologisch – ganz ohne zusätzlichen Index.\n\nFür das Muster „Hole alle Kommentare zu einem Beitrag“ reicht der Primärindex nicht, weil Kommentare unter einem anderen PK sinnvoll gebündelt sind. Hier kommt ein GSI ins Spiel. Ich lege zusätzliche Attribute `GSI1PK` und `GSI1SK` an und projiziere darüber einen Index, der Post-zu-Comment rückwärts lesbar macht. Das ist die Idee des Inverted Index: ein GSI mit vertauschten Rollen, um eine Beziehung aus der Gegenrichtung zu lesen.\n\nDie Tabelle selbst definiere ich mit generischen Keys und einem GSI. Ein Ausschnitt der `CreateTable`-Parameter:\n\n```json\n{\n  \"TableName\": \"cms\",\n  \"BillingMode\": \"PAY_PER_REQUEST\",\n  \"AttributeDefinitions\": [\n    { \"AttributeName\": \"PK\", \"AttributeType\": \"S\" },\n    { \"AttributeName\": \"SK\", \"AttributeType\": \"S\" },\n    { \"AttributeName\": \"GSI1PK\", \"AttributeType\": \"S\" },\n    { \"AttributeName\": \"GSI1SK\", \"AttributeType\": \"S\" }\n  ],\n  \"KeySchema\": [\n    { \"AttributeName\": \"PK\", \"KeyType\": \"HASH\" },\n    { \"AttributeName\": \"SK\", \"KeyType\": \"RANGE\" }\n  ],\n  \"GlobalSecondaryIndexes\": [\n    {\n      \"IndexName\": \"GSI1\",\n      \"KeySchema\": [\n        { \"AttributeName\": \"GSI1PK\", \"KeyType\": \"HASH\" },\n        { \"AttributeName\": \"GSI1SK\", \"KeyType\": \"RANGE\" }\n      ],\n      \"Projection\": { \"ProjectionType\": \"ALL\" }\n    }\n  ]\n}\n```\n\n`BillingMode: PAY_PER_REQUEST` ist der On-Demand-Modus, der seit Ende 2018 verfügbar ist. Für Beispiele und schwankende Last nehme ich ihn gerne, weil ich mich nicht um vorab reservierte Kapazität kümmern muss. Bei einem GSI wähle ich über `ProjectionType` bewusst, welche Attribute mitkommen – `KEYS_ONLY`, `INCLUDE` oder `ALL`. `ALL` ist bequem, kostet aber mehr Speicher und Schreib-Durchsatz.\n\n## Der Query über eine Item Collection\n\nJetzt der Teil, der die ganze Modellierung rechtfertigt. Mit dem SDK v3 und dem `DynamoDBDocumentClient` aus `@aws-sdk/lib-dynamodb` hole ich alle Beiträge eines Nutzers mit genau einem Aufruf:\n\n```ts\nimport { DynamoDBClient } from \"@aws-sdk/client-dynamodb\";\nimport { DynamoDBDocumentClient, QueryCommand } from \"@aws-sdk/lib-dynamodb\";\n\nconst client = DynamoDBDocumentClient.from(new DynamoDBClient({}));\n\nasync function getPostsByUser(userId: string) {\n  const result = await client.send(\n    new QueryCommand({\n      TableName: \"cms\",\n      KeyConditionExpression: \"PK = :pk AND begins_with(SK, :prefix)\",\n      ExpressionAttributeValues: {\n        \":pk\": `USER#${userId}`,\n        \":prefix\": \"POST#\",\n      },\n    })\n  );\n\n  return result.Items ?? [];\n}\n```\n\nDie `KeyConditionExpression` adressiert genau eine Item Collection über `PK = :pk` und schneidet mit `begins_with(SK, :prefix)` die Beiträge heraus. DynamoDB liest nur die Items, die auf `POST#` passen – der Prefix wirkt direkt auf dem Sort Key, nicht als nachträglicher Filter. Will ich den Nutzer samt aller Beiträge, lasse ich die `begins_with`-Bedingung weg und bekomme die komplette Collection in einem Rutsch.\n\nFür das Rückwärtsmuster über den Inverted Index ändert sich nur wenig. Ich adressiere den GSI und nutze dessen Keys:\n\n```ts\nasync function getCommentsByPost(postId: string) {\n  const result = await client.send(\n    new QueryCommand({\n      TableName: \"cms\",\n      IndexName: \"GSI1\",\n      KeyConditionExpression: \"GSI1PK = :g AND begins_with(GSI1SK, :prefix)\",\n      ExpressionAttributeValues: {\n        \":g\": `POST#${postId}`,\n        \":prefix\": \"COMMENT#\",\n      },\n    })\n  );\n\n  return result.Items ?? [];\n}\n```\n\nDas ist im Kern dieselbe Query-Mechanik, nur auf einem anderen Index. Wer das Primärmuster verstanden hat, überträgt es ohne Umgewöhnung auf den GSI.\n\n## Der Fallstrick mit dem FilterExpression\n\nDie `FilterExpression` sieht aus wie eine günstige Abkürzung, ist aber keine. Anders als die `KeyConditionExpression` filtert sie erst, nachdem die Items gelesen wurden. Das heißt, ich bezahle die Read Capacity Units auch für die Items, die der Filter anschließend wegwirft. Schlimmer noch, in Kombination mit `Limit` kann ich leere Seiten zurückbekommen, obwohl es passende Daten gibt – weil das Limit vor dem Filter greift.\n\nDie Konsequenz ist klar: Selektivität gehört ins Key-Design, nicht in den Filter. Wenn ich häufig nach einem Kriterium filtere, sollte dieses Kriterium in den Sort Key oder in einen GSI wandern. `FilterExpression` nutze ich nur für seltene, unkritische Verfeinerungen am Rand.\n\n## Wann Single-Table lohnt – und wann es überzogen ist\n\nSo elegant das Muster ist, es ist kein Selbstzweck. Single-Table wird auch dort eingeführt, wo es vor allem im Konferenzvortrag beeindruckend aussah – und handelt der Anwendung dann Komplexität ein, die ihr niemand zurückzahlt. Deshalb lohnt der ehrliche Blick auf die Motivation.\n\nSingle-Table lohnt sich, wenn die Access Patterns stabil und bekannt sind und wenn es darauf ankommt, viele zusammengehörige Daten mit möglichst wenigen Roundtrips zu lesen. Der Gewinn ist Performance und Skalierung bei vorhersagbarer Latenz, nicht Speicherersparnis. Datenduplizierung und Denormalisierung nehme ich dabei bewusst in Kauf – das ist ein Feature, kein Versehen.\n\nÜberzogen ist der Ansatz in mehreren Situationen:\n\n- Die Access Patterns sind früh im Projekt noch unklar oder ändern sich häufig. Single-Table macht nachträgliche Änderungen der Muster teuer, weil das Key-Schema tief in den Daten verankert ist.\n- Die Anwendung braucht Ad-hoc-Auswertungen oder Analytics. Ohne SQL wird die Export- und Reporting-Story mühsam, und ein Single-Table-Layout mit vielen Entity Types macht das nicht einfacher.\n\nAlex DeBrie, dessen Texte zu DynamoDB ich gerne weiterreiche, nennt einen weiteren Fall: GraphQL. Ein GraphQL-Server löst Felder ohnehin einzeln über Resolver auf, sodass der große Vorteil des Ein-Query-Ladens verpufft. Dort gibt ein Multi-Table-Ansatz oft mehr Flexibilität. Wie GraphQL und DynamoDB auf AWS konkret zusammenspielen, habe ich in [GraphQL-APIs mit AppSync und DynamoDB](https://www.mikebild.dev/de/blog/appsync-dynamodb-graphql-api/) beschrieben.\n\nEin technisches Detail, das bei vielen Entity Types in einer Tabelle zum Problem wird, sind DynamoDB Streams: AWS empfiehlt, pro Shard nicht mehr als zwei gleichzeitige Consumer zu betreiben, sonst drohen Throttling-Effekte. Wenn unterschiedliche Entities unterschiedliche Verarbeitungslogik brauchen, drängen sich schnell mehr Consumer auf, als ein Stream verträgt.\n\n## Ein Wort zu den harten Grenzen\n\nDamit die Modellierung nicht an physischen Limits scheitert, halte ich ein paar Zahlen präsent, die 2021 stabil gelten. Ein Item darf maximal 400 KB groß sein. Ein Query liefert pro Aufruf höchstens 1 MB, danach wird paginiert. `BatchGetItem` holt bis zu 100 Items oder 16 MB. Pro Tabelle sind standardmäßig bis zu 20 GSIs und maximal 5 LSIs möglich. Diese Grenzen zwingen zu bewusstem Design – etwa dazu, sehr große oder unbegrenzt wachsende Item Collections gar nicht erst entstehen zu lassen.\n\n## Fazit\n\nDer entscheidende Umbau findet im Kopf statt, nicht im Code. Wer DynamoDB wie eine relationale Datenbank behandelt, kämpft gegen das System. Wer bei den Access Patterns beginnt und daraus Partition Key, Sort Key und passende GSIs ableitet, arbeitet mit ihm. Single-Table-Design folgt dann aus dem Wunsch, zusammengehörige Daten mit einem Query und ohne Join zu lesen – und nicht aus dem Reiz des Musters selbst.\n\nPragmatisch führt der Weg über drei Schritte: erst die Access Patterns aufschreiben, dann das Key-Schema entwerfen, dann prüfen, ob ein einziger Primärindex plus wenige GSIs die Muster abdeckt. Wenn die Muster noch wackeln oder Ad-hoc-Analysen zentral sind, ist ein einfacheres Multi-Table-Layout die ehrlichere Wahl. Das Ziel ist nie das eleganteste Diagramm, sondern die Anwendung, die unter Last vorhersagbar bleibt.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-aws-serverless.html\n- Repository und begleitender Artikel: [GraphQL-APIs mit AppSync und DynamoDB](https://www.mikebild.dev/de/blog/appsync-dynamodb-graphql-api/)\n- AWS Docs, Core Components (Tables, Items, Keys, Item Collections): https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html\n- AWS Docs, Query-API: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.html\n- AWS Docs, Global Secondary Indexes: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.html\n- AWS Docs, Local Secondary Indexes: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LSI.html\n- AWS Docs, Best Practices und relationale Modellierung: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-relational-modeling.html\n- Alex DeBrie, „The What, Why, and When of Single-Table Design“: https://www.alexdebrie.com/posts/dynamodb-single-table/\n- Alex DeBrie, „How to model one-to-many relationships“: https://www.alexdebrie.com/posts/dynamodb-one-to-many/\n- AWS Database Blog, „Single-table vs. multi-table design in Amazon DynamoDB“: https://aws.amazon.com/blogs/database/single-table-vs-multi-table-design-in-amazon-dynamodb/"},{"slug":"cdn-construct-infrastruktur-api","title":"CDN Construct: eine kleine Infrastruktur-API","summary":"Warum ein eigenes CDK-Construct für statische Websites mehr ist als gesparte Zeilen: eine Schnittstelle mit Meinung über S3, CloudFront, Zertifikate und DNS.","tags":["Software","AWS","Cloud"],"published_at":"2021-09-14T10:00:00+00:00","updated_at":"2026-07-14T17:16:42.516469+00:00","url":"https://www.mikebild.dev/de/blog/cdn-construct-infrastruktur-api/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/047c3b151f89b25871cd02994dec8d367ee77c7f4015e9ba69b9551a5f8853c7/cdn-construct-infrastruktur-api-vorlesen.mp3","audio_duration_secs":712,"text":"# CDN Construct: eine kleine Infrastruktur-API\n\nRund 280 Zeilen CloudFormation, verteilt auf zwei Stacks in zwei Regionen – das war zuletzt der Preis dafür, eine statische Website „richtig\" auf AWS auszurollen. Vierzehn Ressourcen für eine Handvoll HTML-Dateien: ein S3-Bucket, eine Bucket-Policy, eine Origin Access Identity, eine CloudFront-Distribution mit Cache- und Fehlerverhalten, ein ACM-Zertifikat samt DNS-Validierung – zwingend in us-east-1, deshalb der zweite Stack –, dazu A- und AAAA-Alias-Records in Route53. Nichts davon ist für sich genommen schwierig. Aber alles zusammen ist Verdrahtung, die bei jedem neuen Projekt wieder ansteht und jedes Mal ein kleines bisschen anders gerät.\n\nGenau diese Wiederholung hat mich dazu gebracht, die ganze Kette in ein eigenes, kleines AWS-CDK-Construct zu packen: `cdk-cdn-construct`. Statische Inhalte – eine Single-Page-Application oder eine klassische statische Website – landen in einem S3-Bucket und werden über CloudFront als CDN ausgeliefert. Der Aufruf im eigenen Stack schrumpft auf wenige Zeilen. Das ist der sichtbare Gewinn. Der interessantere Gewinn ist ein anderer, und um den geht es in diesem Artikel: Ein gutes Construct ist eine Schnittstelle mit Meinung. Es macht Entscheidungen explizit, die vorher in Copy-and-paste-Templates versteckt waren, und es lässt genau die Knöpfe frei, die von Projekt zu Projekt wirklich variieren.\n\n## Was da eigentlich verdrahtet wird\n\nBevor die Abstraktion trägt, lohnt der Blick auf das, was sie kapselt. Eine statische Website hinter CloudFront besteht aus mehr Teilen, als der Satz „S3-Bucket mit CDN davor\" vermuten lässt:\n\n- ein privater S3-Bucket, bei dem Block Public Access vollständig aktiv ist\n- eine Origin Access Identity (OAI), über die CloudFront – und nur CloudFront – lesend auf den Bucket zugreift\n- die passende Bucket-Policy, die genau dieser Identität `s3:GetObject` erlaubt\n- eine CloudFront-Distribution mit HTTPS-Zwang, Default Root Object und sinnvollem Fehlerverhalten für Client-seitiges Routing\n- ein ACM-Zertifikat in us-east-1 samt DNS-Validierung\n- A- und AAAA-Alias-Records in der Route53-Zone, die auf die Distribution zeigen\n- der eigentliche Upload des Build-Outputs, inklusive Cache-Invalidierung nach dem Deployment\n\nAls Diagramm sieht die Verdrahtung so aus:\n\n```mermaid\nflowchart LR\n  Browser[Browser] -->|HTTPS| CF[CloudFront-Distribution]\n  CF -->|nur via OAI| S3[S3-Bucket<br/>privat, Block Public Access]\n  ACM[ACM-Zertifikat<br/>us-east-1] -.->|TLS| CF\n  R53[Route53<br/>A/AAAA-Alias] -.->|löst Domain auf| CF\n  Build[Build-Output<br/>z. B. ./build] -->|Upload + Invalidierung| S3\n```\n\nJede dieser Kanten ist eine Entscheidung. Der Bucket könnte auch öffentlich sein und Website-Hosting direkt anbieten – dann bräuchte es keine OAI, aber der Bucket wäre am CDN vorbei erreichbar und HTTPS auf der eigenen Domain fiele weg. Das Zertifikat könnte importiert statt DNS-validiert sein. Die Fehlerseiten könnten auf `404.html` zeigen statt auf `index.html`. In handgeschriebenen Templates stehen diese Entscheidungen nirgends als Entscheidung. Sie stehen da als 280 Zeilen YAML, und ob eine Abweichung Absicht oder Versehen ist, sieht man ihnen nicht an.\n\n## Vom Template zur API\n\nDas AWS CDK bietet mit seinen Construct-Ebenen ein brauchbares Vokabular dafür. L1-Constructs sind generierte Eins-zu-eins-Abbilder der CloudFormation-Ressourcen. L2-Constructs sind handgeschriebene Klassen mit Defaults und Hilfsmethoden – `Bucket` statt `CfnBucket`. Und L3-Constructs, in der Dokumentation auch Patterns genannt, bündeln mehrere Ressourcen zu einem Muster mit Absicht. AWS selbst pflegt seit letztem Jahr mit den Solutions Constructs eine ganze Bibliothek solcher Muster.\n\nMein Construct ist so ein L3-Baustein, technisch als eigener Stack umgesetzt, damit er sich als Ganzes in eine bestehende CDK-App einhängen lässt. Die Benutzung sieht so aus:\n\n```typescript\n#!/usr/bin/env node\nimport { App } from '@aws-cdk/core';\nimport { CdkCdnConstructStack } from 'cdk-cdn-construct';\n\nconst app = new App();\n\n// ... your own stacks ...\n\nnew CdkCdnConstructStack(app, 'MyWebsite', {\n  namespace: 'my-app-namespace',\n  env: {\n    account: process.env.CDK_DEFAULT_ACCOUNT,\n    region: process.env.CDK_DEFAULT_REGION,\n  },\n});\n```\n\nWas von außen konfigurierbar bleibt, folgt bewusst einer schlichten Konvention über Umgebungsvariablen – Name, Build-Verzeichnis, Domain:\n\n```\nCDK_DEFAULT_REGION=eu-central-1\nCDK_DEFAULT_ACCOUNT=default\nCDK_CDN_NAME=your-host-and-bucket-name\nCDK_CDN_PATH=./build\nCDK_CDN_DOMAIN=example.com\n```\n\nDas ist der ganze öffentliche Umfang. Kein Schalter für die Preisklasse der Distribution, keiner für TTLs, keiner für die Frage, ob der Bucket vielleicht doch öffentlich sein soll. Das ist keine Faulheit, sondern der Kern der Sache. Eine Schnittstelle mit Meinung sagt: Diese Punkte sind entschieden, und zwar so. Wer eine andere Meinung braucht, soll sie nicht durch einen Parameter hindurchfädeln, sondern ein anderes Construct nehmen – oder ein eigenes schreiben.\n\nDas Projekt ist dabei ehrlicherweise Work in Progress, ein Baustein aus der eigenen Werkstatt und kein poliertes Produkt. Aber gerade deshalb taugt es als Anschauungsmaterial: Die Frage, welche Knöpfe eine Infrastruktur-API frei lässt, stellt sich bei einem kleinen Construct genauso wie bei einer großen Plattform – nur dass man sie hier noch in einer Kaffeepause diskutieren kann.\n\n## Ein Blick ins Innere\n\nIm Inneren passiert genau das, was vorher im Template stand – nur einmal, benannt und mit Defaults, die man im Code-Review lesen kann. Der Kern lässt sich mit den CDK-v1-Bausteinen so skizzieren:\n\n```typescript\nconst originAccessIdentity = new cloudfront.OriginAccessIdentity(this, 'WebsiteOAI');\n\nconst websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {\n  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,\n});\nwebsiteBucket.grantRead(originAccessIdentity);\n\nconst certificate = new acm.DnsValidatedCertificate(this, 'WebsiteCertificate', {\n  domainName: props.domainName,\n  hostedZone: props.hostedZone,\n  region: 'us-east-1', // CloudFront accepts ACM certificates from us-east-1 only\n});\n\nconst distribution = new cloudfront.Distribution(this, 'WebsiteDistribution', {\n  defaultBehavior: {\n    origin: new origins.S3Origin(websiteBucket, { originAccessIdentity }),\n    viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,\n  },\n  domainNames: [props.domainName],\n  certificate,\n  defaultRootObject: 'index.html',\n});\n\nnew route53.ARecord(this, 'WebsiteAliasRecord', {\n  zone: props.hostedZone,\n  recordName: props.domainName,\n  target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),\n});\n```\n\nDrei Details daran finde ich bemerkenswert, weil sie zeigen, was ein L2/L3-Zuschnitt gegenüber rohem CloudFormation tatsächlich leistet.\n\nErstens: `websiteBucket.grantRead(originAccessIdentity)` ist eine Zeile. Dahinter steht die komplette Bucket-Policy mit dem kanonischen Principal der OAI – ein Stück JSON, das ich in Template-Zeiten regelmäßig aus dem letzten Projekt kopiert habe, inklusive der Gelegenheit, dabei etwas zu verschleppen. Die Methode drückt die Absicht aus („diese Identität darf lesen\"), nicht den Mechanismus.\n\nZweitens: `DnsValidatedCertificate` mit explizitem `region: 'us-east-1'`. CloudFront akzeptiert nur Zertifikate aus dieser einen Region, egal wo der Rest des Stacks liegt. Wer das zum ersten Mal übersieht, bekommt beim Deployment eine Fehlermeldung, die auf das Zertifikat zeigt, aber nicht auf die Ursache – mir ist das seinerzeit mit einem Zertifikat in eu-central-1 passiert, und die Suche hat einen halben Nachmittag gekostet. Im Construct ist dieser Stolperstein jetzt eine kommentierte Zeile. Ein Kollege hat im Review genau darauf gezeigt und gefragt, warum die Region fest verdrahtet ist – und genau dieses Gespräch ist der Punkt: Die Regel steht jetzt an einer Stelle, an der man sie diskutieren kann, statt in drei Projekten implizit richtig oder falsch kopiert zu sein.\n\nDrittens: Der Alias-Record entsteht im selben Zug wie die Distribution. In der Template-Welt waren DNS-Einträge gern der manuelle Rest, „den machen wir nachher in der Konsole\" – und drei Monate später wusste niemand mehr, warum die Staging-Umgebung auf eine gelöschte Distribution zeigte. Wenn der Record Teil desselben Bausteins ist, gibt es diesen Rest nicht.\n\n## Defaults sind Meinung, nicht Magie\n\nDer übliche Einwand gegen solche Bausteine lautet: Abstraktion versteckt, was passiert, und in der Cloud heißt versteckt schnell teuer oder unsicher. Der Einwand trifft schlechte Constructs, aber er trifft nicht das Prinzip. Der Unterschied liegt darin, ob Defaults implizit oder explizit sind.\n\nEin kopiertes Template hat auch Defaults – sie heißen nur nicht so. Sie sind das, was der Autor des ersten Templates vor zwei Jahren für richtig hielt, verteilt über 280 Zeilen, ohne Namen und ohne Begründung. Ein Construct dagegen zwingt zur Entscheidung: Was ist fest? Was ist Parameter? Was ist bewusst nicht vorgesehen? Diese drei Fragen einmal sauber zu beantworten hat mich beim Bau des Constructs mehr Zeit gekostet als die eigentliche Verdrahtung – und es war die besser investierte Zeit.\n\nBeim CDN-Baustein sind die festen Meinungen zum Beispiel: Der Bucket ist privat, immer. Ausgeliefert wird ausschließlich über CloudFront, immer über HTTPS. Das Zertifikat wird per DNS validiert und liegt dort, wo CloudFront es verlangt. Frei bleiben Domain, Namensraum und der Pfad zum Build-Output – also genau die Dinge, die zwei Projekte tatsächlich unterscheiden. Alles andere zu öffnen hieße, die Wiederholung nur zu verschieben: Wer zwanzig Parameter durchreicht, hat am Ende wieder CloudFormation, nur mit anderen Klammern.\n\nEs gibt dabei eine Grenze, die ich anfangs unterschätzt habe: Ein Construct ist eine API, und Infrastruktur-APIs haben härtere Kompatibilitätsregeln als Bibliotheks-APIs. Wenn ich in einer Bibliothek eine Klasse umbenenne, brechen Builds. Wenn ich in einem Construct die Struktur ändere, ändern sich unter Umständen die logischen IDs der Ressourcen – und CloudFormation ersetzt dann Dinge, statt sie zu ändern. Bei einem S3-Bucket mit Inhalt ist das keine theoretische Sorge. Ein Construct zu versionieren heißt deshalb nicht nur, semantische Versionsnummern zu vergeben, sondern sich zu merken, welche Änderungen ein Replacement auslösen. Das steht in keinem Typsystem, und genau deshalb gehört es in die Meinung des Bausteins.\n\n## Wann sich ein eigenes Construct lohnt\n\nMan muss dieses Rad nicht selbst bauen. Die Solutions Constructs von AWS decken das Muster „CloudFront zu S3\" ab, und für viele Teams ist das die richtige Wahl. Ein eigenes Construct lohnt sich aus meiner Sicht dann, wenn die Meinung, die man kapseln will, spezifischer ist als die eines generischen Bausteins: eigene Namenskonventionen, eine feste Deploy-Konvention über Umgebungsvariablen, ein bestimmtes Fehlerverhalten für Single-Page-Applications, die hauseigene Tagging-Pflicht. Also immer dann, wenn man merkt, dass man den generischen Baustein in jedem Projekt mit denselben fünf Handgriffen nachjustiert – dann sind diese fünf Handgriffe das eigentliche Construct.\n\nDer Prozess dorthin folgt einem einfachen Ablauf, den man auch als Zyklus lesen kann:\n\n```mermaid\nflowchart TD\n  A[Verdrahtung im Projekt<br/>zum zweiten Mal getippt] --> B[Entscheidungen benennen:<br/>fest, Parameter, ausgeschlossen]\n  B --> C[Construct extrahieren<br/>mit minimaler Prop-Fläche]\n  C --> D[In zwei Projekten einsetzen]\n  D --> E{Nachjustiert?}\n  E -->|ja, immer gleich| B\n  E -->|nein| F[Meinung trägt]\n```\n\nWichtig ist die Reihenfolge: erst die Wiederholung erleben, dann extrahieren. Ein Construct, das vor dem zweiten Projekt entsteht, kapselt keine Erfahrung, sondern eine Vermutung – und Vermutungen mit zwanzig Parametern erkennt man daran, dass sie sich nicht trauen, etwas festzulegen.\n\nUmgekehrt gilt: Nicht jede Wiederholung verdient eine Abstraktion. Zwei ähnliche Stacks sind billiger als ein schlechtes gemeinsames Construct, das bei jeder Abweichung um einen Schalter wächst. Die ehrliche Frage ist, ob die Meinung stabil ist. Bei „statische Website hinter CloudFront\" bin ich mir da inzwischen sicher – das Muster hat sich über mehrere Projekte hinweg nicht bewegt, nur die Domains und Build-Verzeichnisse haben gewechselt. Das ist genau das Profil, bei dem sich die Extraktion trägt.\n\n## Unterm Strich\n\nAus 280 Zeilen in zwei Regionen werden ein Import, ein Konstruktoraufruf und fünf Umgebungsvariablen. Diese Rechnung ist real, aber sie ist nicht der eigentliche Ertrag. Der eigentliche Ertrag ist, dass die Infrastruktur-Entscheidung jetzt eine Adresse hat. Wenn jemand fragt, warum der Bucket privat ist, warum das Zertifikat in us-east-1 liegt oder warum es keinen TTL-Schalter gibt, dann zeigt die Antwort auf eine Stelle im Code – mit Namen, Kommentar und Git-Historie. Vorher zeigte sie auf ein Template, das irgendwann irgendwo kopiert wurde.\n\nIch habe lange Infrastruktur als etwas behandelt, das man beschreibt, und Anwendungscode als etwas, das man entwirft. Das CDK hebt diese Trennung auf, und ein kleines eigenes Construct ist der Punkt, an dem man das wirklich spürt: Auf einmal gelten die vertrauten Fragen des API-Designs – was ist öffentlich, was ist Default, was ist ausgeschlossen, was bricht bei einer Änderung – für Buckets und Distributionen genauso wie für Klassen und Module. Eine kleine Infrastruktur-API zu bauen ist deshalb weniger ein DevOps-Thema als eine ganz normale Entwurfsübung. Nur dass ein schlechter Default hier nicht in einer Exception endet, sondern in einer Rechnung oder einem öffentlichen Bucket.\n\n## Weiterführende Quellen\n\n- Projekt-Repository: [github.com/MikeBild/cdk-cdn-construct](https://github.com/MikeBild/cdk-cdn-construct)\n- AWS CDK v1: [Construct Library](https://docs.aws.amazon.com/cdk/api/v1/docs/aws-construct-library.html)\n- CloudFront: [Restricting access to S3 origins mit Origin Access Identity](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html)\n- ACM: [Unterstützte Regionen – Zertifikate für CloudFront in us-east-1](https://docs.aws.amazon.com/acm/latest/userguide/acm-regions.html)\n- AWS Solutions Constructs: [github.com/awslabs/aws-solutions-constructs](https://github.com/awslabs/aws-solutions-constructs)"},{"slug":"outcome-vor-output","title":"Outcome vor Output: warum Velocity die falsche Zahl ist","summary":"Erledigte Tickets sind Output, aber noch kein Beleg für Wirkung. Warum Velocity im Management-Reporting in die Irre führt und wie man Teams stattdessen an Outcomes ausrichtet.","tags":["Software","Produkt"],"published_at":"2021-08-26T10:00:00+00:00","updated_at":"2026-07-19T18:46:18.505233+00:00","url":"https://www.mikebild.dev/de/blog/outcome-vor-output/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/81a046fcdf437c7828cd32c32f177765c1c538c7371d4c0c9f59f58d5ea565df/outcome-vor-output-vorlesen.mp3","audio_duration_secs":749,"text":"```typescript\n// Sprint-Report, wie er im Statusdeck landet\nconst sprint42 = {\n  storyPointsCommitted: 34,\n  storyPointsCompleted: 38,\n  ticketsDone: 17,\n  velocityTrend: \"up\",\n};\n```\n\nAus diesem Objekt folgt genau eine Aussage: Das Team hat mehr Punkte abgeschlossen, als es zugesagt hatte. Ob ein Nutzer dadurch schneller ans Ziel kommt, ob ein System stabiler läuft, ob irgendwo Umsatz entsteht – dafür gibt es hier kein Feld, und es lässt sich auch nachträglich keines hineinrechnen. Trotzdem wandert genau diese Kurve Sprint für Sprint in Berichte, wird zwischen zwei Teams verglichen und landet gelegentlich neben dem Umsatz auf einer Board-Folie.\n\nDie Frage, die tatsächlich zählt, lautet nicht \"Wie viele Story Points hat das Team abgearbeitet?\", sondern \"Was hat sich für den Nutzer, für das System, für das Geschäft dadurch verändert?\". Das ist der Unterschied zwischen Output und Outcome. Und wer ihn verwischt, optimiert ein Team systematisch auf Beschäftigung statt auf Wirkung.\n\nDie Trennlinie verläuft zwischen einer beobachteten Aktivität und der belegbaren Veränderung, die daraus folgt.\n\n::::comparison{title=\"Aktivität und Wirkung beantworten andere Fragen\" role=\"primary\" preferredPattern=\"split-comparison\"}\n:::side{label=\"Output\"}\n- Beobachtung · Was wurde gebaut oder abgeschlossen?\n- Beispiele · Features, Tickets und Story Points\n- Nutzen · Planung und Nachverfolgung der Arbeit\n- Grenze · Belegt noch keine Veränderung beim Nutzer\n:::\n:::side{label=\"Outcome\"}\n- Beobachtung · Was hat sich im Verhalten oder Ergebnis verändert?\n- Beispiele · Aktivierung, Zeit bis zum ersten Wert und Fehlerquote\n- Nutzen · Prüft, ob eine Lösung auf das Ziel einzahlt\n- Grenze · Benötigt Kontext und eine belastbare Messung\n:::\n::::\n\nDamit bleibt Velocity als internes Planungsmaß brauchbar, ohne zur Aussage über Produktivität oder Geschäftswert zu werden.\n\n## Velocity misst Vorhersagbarkeit, nicht Produktivität\n\nFangen wir bei der Quelle an, denn Velocity ist zu Unrecht in Verruf geraten. Martin Fowler hat schon vor bald zwei Jahrzehnten präzise beschrieben, wofür die Zahl gedacht war: als Kalibrierungswerkzeug für Schätzungen. Das Prinzip heißt \"Yesterday's Weather\" – wir gehen davon aus, dass ein Team im nächsten Sprint ungefähr so viel schafft wie im letzten. Damit lassen sich Planungen realistisch halten, ohne dass jemand heldenhafte Zusagen macht.\n\nStory Points sind dabei eine teamspezifische Baseline. Sie bedeuten für Team A etwas anderes als für Team B, weil sie relativ zum jeweiligen Kontext geschätzt werden. Genau deshalb ist der Vergleich zweier Teams anhand ihrer Velocity nicht nur ungenau, sondern schlicht sinnlos – man vergleicht zwei verschiedene Maßeinheiten und tut so, als wären es dieselben.\n\nUnd dann passiert das Vorhersehbare. Sobald Velocity zum Management-Ziel wird, wird sie gespielt. Teams schätzen großzügiger, splitten Tickets feiner, deklarieren Nebensächliches als Story. Die Kurve steigt, die Lieferung bleibt gleich. Das ist keine Böswilligkeit, das ist Goodhart's Law in Reinform: \"When a measure becomes a target, it ceases to be a good measure.\" Jede Zahl, die man zum Erfolgsmaß erhebt, hört in demselben Moment auf, ehrlich zu sein.\n\nIch will Velocity also gar nicht verteufeln. Intern, als Planungs- und Vorhersagehilfe, ist sie völlig legitim und nützlich. Der Fehler liegt nicht in der Zahl, sondern in ihrer Beförderung zum Erfolgsmaß. Das ist eine wichtige Differenzierung, die im Eifer der \"Velocity ist böse\"-Debatten gern verschwindet.\n\n## Der wahre Output ist Wirkung – und die lässt sich nicht direkt messen\n\nFowlers eigentlich radikaler Punkt kommt danach: \"True output is business value.\" Der wahre Ertrag eines Teams ist nicht die Menge an Features, sondern der geschaffene Wert. Sein Beispiel bringt es auf den Punkt: Ein Team, das weniger Features liefert, aber zehn Millionen Gewinn erzeugt, ist produktiver als eines mit mehr Features und fünf Millionen Wert.\n\nDer Haken daran ist ernüchternd. Business Value hinkt der Investition zeitlich nach, oft um Jahre. Was ein Team heute baut, zahlt sich – wenn überhaupt – erst später aus, verzahnt mit Markt, Vertrieb, Timing und einem Dutzend anderer Faktoren. Eine sofortige, saubere Produktivitätsmessung ist damit schlicht unmöglich. Fowlers ehrliche Konsequenz: \"We have to admit our ignorance.\" Wir können Produktivität nicht direkt messen. Punkt.\n\nDas klingt entmutigend, ist aber befreiend. Denn wenn die perfekte Zahl nicht existiert, hören wir auf, sie zu suchen, und wenden uns dem zu, was wir tatsächlich beeinflussen und beobachten können: der Wirkung unserer Arbeit auf Nutzer und System.\n\n## Outcome statt Output: der Kern moderner Produktarbeit\n\nGenau hier setzt die Produktseite an. Teresa Torres unterscheidet in ihrer Arbeit zu Continuous Discovery sauber: Outputs sind das, was wir bauen – Features, Screens, Endpoints. Outcome ist die Veränderung im Verhalten von Nutzern, die einen Wert für Kunde und Geschäft erzeugt. Wer Outputs zum Ziel erklärt, produziert einen \"disconnect\" zwischen der Aktivität des Teams und dem gelieferten Wert. Das Team ist ausgelastet, das Board ist voll, und trotzdem bewegt sich beim Nutzer nichts.\n\nMarty Cagan formuliert dieselbe Beobachtung noch schärfer: \"Empowered teams get outcomes, feature teams get output.\" Ein Feature-Team bekommt eine Roadmap vorgesetzt und arbeitet sie ab – Output. Ein \"empowered\" Team bekommt ein Problem und ein Outcome-Ziel und darf selbst herausfinden, wie es dorthin kommt. Der Unterschied ist nicht kosmetisch. Er entscheidet darüber, ob Menschen mitdenken oder nur abarbeiten.\n\nDas Werkzeug, mit dem ich dabei die besten Erfahrungen gemacht habe, ist der Opportunity Solution Tree. An der Wurzel steht genau ein Outcome, zum Beispiel \"Aktivierungsrate um X Prozent erhöhen\". Darunter verzweigen sich Opportunities – konkrete Nutzerprobleme oder -bedürfnisse, die auf dieses Outcome einzahlen. Darunter wiederum Solutions, und ganz unten die Experimente, mit denen wir prüfen, ob eine Lösung tatsächlich wirkt. Der Baum zwingt zu der Frage, die die Velocity-Kurve nie stellt: Zahlt das, was wir gerade bauen, überhaupt auf die Wurzel ein?\n\n```mermaid\ngraph TD\n    subgraph output[\"Output-Sicht (Vanity)\"]\n        B[Backlog] --> S[Sprint]\n        S --> V[\"Velocity-Chart<br/>viele erledigte Tickets\"]\n        V --> U[\"up and to the right\"]\n    end\n    subgraph outcome[\"Outcome-Sicht\"]\n        O[\"Outcome<br/>activation rate +X%\"] --> OP1[Opportunity A]\n        O --> OP2[Opportunity B]\n        OP1 --> SOL[Solution]\n        SOL --> EXP[Experiment]\n    end\n    V -. \"bewegt der Output<br/>die Wurzel?\" .-> O\n```\n\nDas Diagramm zeigt die beiden Welten nebeneinander. Links die Output-Pipeline, die zuverlässig nach oben zeigt. Rechts der Baum, der die Wirkung nachvollziehbar macht. Der gestrichelte Pfeil dazwischen ist die eigentliche Managementfrage – und genau dieser Pfeil fehlt in den Berichten, die ich zu sehen bekomme, fast durchgängig.\n\n## Vanity Metrics: die Engineering-Variante der Selbsttäuschung\n\nEric Ries hat für diese Sorte Zahlen im Lean-Startup-Kontext den Begriff Vanity Metrics geprägt. Registrierungen, Pageviews, Downloads – Zahlen, die \"up and to the right\" gehen und sich gut anfühlen, ohne echten Fortschritt zu belegen. Velocity und erledigte Tickets sind die Engineering-Variante genau dieses Musters. Sie steigen zuverlässig, sie erzeugen ein gutes Gefühl, und sie sagen nichts darüber, ob wir dem Ziel näher gekommen sind.\n\nDem gegenüber stehen Actionable Metrics: Zahlen, die eine Ursache-Wirkungs-Beziehung zeigen und aus denen eine Entscheidung folgt. Die Fortschrittseinheit ist bei Ries nicht \"Feature ausgeliefert\", sondern \"validated learning\" – bestätigtes Lernen darüber, was beim Nutzer tatsächlich wirkt. Der Rahmen dafür heißt Innovation Accounting, der Zyklus Build-Measure-Learn.\n\nIn der Praxis heißt das: Man instrumentiert nicht, was man gebaut hat, sondern was der Nutzer daraufhin tut. Ein Event, das ein Outcome trackt, sieht anders aus als eines, das Output zählt.\n\n```typescript\n// Actionable: measures what actually moves the user\ntrack(\"activation_completed\", {\n  userId,\n  cohort,\n  timeToValueSeconds, // how fast does the user reach first value?\n});\n\n// Vanity: feels like progress, says nothing about impact\n// track(\"features_shipped\", { count });\n// track(\"story_points_done\", { total });\n```\n\nDer entscheidende Schritt kommt danach: Man rollt das Feature hinter einem Flag aus und vergleicht das Outcome-Delta zwischen zwei Cohorts. Erhöht die neue Lösung die Aktivierungsrate messbar, oder nicht? Das ist Build-Measure-Learn in einer Zeile Infrastruktur – und es verlagert die Erfolgsfrage vom \"haben wir es gebaut?\" zum \"hat es gewirkt?\".\n\n## Bessere Proxys: DORA und Fitness Functions\n\nNun wird an dieser Stelle gern eingeworfen: Es gibt doch DORA. Die vier Kennzahlen aus der Accelerate-Forschung von Forsgren, Humble und Kim – Deployment Frequency, Lead Time for Changes, Change Failure Rate und Time to Restore Service – sind ein großer Fortschritt. Sie messen bewusst Team-Outcomes statt individueller Outputs. Ausdrücklich kein Lines-of-Code, keine Story Points, keine Auslastung, weil das siloartige Beschäftigungszahlen sind. Und sie balancieren Geschwindigkeit gegen Stabilität, sodass man nicht eine Zahl auf Kosten des Gesamtsystems optimieren kann.\n\nDORA hilft, mit einer Einschränkung: Auch diese Metrics sind Proxys, keine echten Outcomes. Deployment Frequency ist nicht Nutzerwirkung. Ein Team kann fünfzigmal am Tag deployen und trotzdem am Bedarf vorbei. DORA misst die Fähigkeit, schnell und stabil zu liefern – ein exzellenter Indikator für Delivery-Gesundheit, aber eben nicht das Ziel selbst. Wer das verwechselt, hat nur eine Vanity-Zahl durch vier ersetzt.\n\nAuf der Architekturebene gibt es ein Instrument, das mir besonders am Herzen liegt, weil es Outcome ausführbar macht: Fitness Functions aus der Evolutionary Architecture von Ford, Parsons und Kua. Eine Fitness Function ist eine objektive, automatisierte Bewertung einer Architektureigenschaft. Statt in einem Ticket \"Performance verbessert\" abzuhaken, schreibt man einen Test, der die Systemwirkung selbst prüft:\n\n```python\ndef test_checkout_latency_stays_within_budget():\n    \"\"\"Architectural fitness function: checkout stays fast under load.\"\"\"\n    result = load_test(endpoint=\"/checkout\", concurrent_users=200)\n\n    assert result.p99_ms < 500       # latency budget, not a story point\n    assert result.error_rate < 0.01  # reliability is the real outcome\n```\n\nDer Charme daran: Erfolg ist hier keine Statusmeldung mehr, sondern eine messbare Systemeigenschaft, die entweder gehalten wird oder nicht. Der Test ist grün, weil das System die versprochene Wirkung liefert – nicht, weil jemand eine Story auf \"done\" geschoben hat. Ähnlich funktionieren Consumer-Driven Contracts, etwa mit Pact: Ein Test schlägt fehl, wenn ein Service ein Versprechen gegenüber seinem Konsumenten bricht. Auch das ist ein outcome-naher Test – er prüft die tatsächliche Wirkung an der Schnittstelle statt die bloße Existenz von Code.\n\n## Wie man Teams wirklich auf Outcome ausrichtet\n\nWenn ich das in einer Organisation aufsetze, folge ich einem einfachen Ablauf. Er ist bewusst schlank, weil der ganze Sinn der Übung ist, Prozess-Overhead durch Klarheit zu ersetzen, nicht neue Zeremonien zu schaffen.\n\n```mermaid\nflowchart LR\n    A[\"Outcome definieren<br/>ein Ziel, messbar\"] --> B[\"Opportunities<br/>Nutzerprobleme kartieren\"]\n    B --> C[\"Experimente<br/>hinter Feature-Flags\"]\n    C --> D[\"Wirkung messen<br/>Cohort-Delta\"]\n    D -->|\"wirkt\"| E[\"skalieren\"]\n    D -->|\"wirkt nicht\"| B\n```\n\nAm Anfang steht genau ein Outcome, formuliert als messbare Verhaltensänderung – nicht \"Checkout-Redesign ausliefern\", sondern \"Abbruchrate im Checkout senken\". Dazu passt der Gedanke der North Star Metric und, auf Zielebene, OKRs: Das Objective beschreibt die Richtung, die Key Results sind die Outcome-Signale, an denen wir Fortschritt erkennen.\n\nDer zweite Schritt sammelt die Opportunities: An welchen Stellen bricht der Nutzer tatsächlich ab, wo entsteht Reibung? Erst danach kommen Lösungen, und die werden nicht ausgerollt und abgehakt, sondern als Experiment hinter einem Flag getestet. Zeigt das Cohort-Delta eine Wirkung, skalieren wir. Zeigt es keine, gehen wir zurück zu den Opportunities – ohne schlechtes Gewissen, denn ein widerlegtes Experiment ist validated learning und damit selbst ein Ergebnis.\n\nEin wichtiger Vorbehalt gehört dazu, sonst tappt man in die nächste Falle. Goodhart gilt auch für Outcome-KPIs. Auch eine Aktivierungsrate lässt sich spielen, und der Zeitverzug aus Fowlers Beobachtung bleibt bestehen: Wirkung braucht Zeit, und die Attribution ist selten eindeutig. Wer ein Outcome-Ziel setzt, muss diese Unschärfe einkalkulieren, statt sie wegzudefinieren – sonst konstruiert man falsche Kausalitäten und ist keinen Schritt weiter als beim Velocity-Chart.\n\n## Fazit\n\nDer rote Faden durch all das ist einfach: Output ist notwendig, aber Wirkung ist das Ziel. Erledigte Tickets, Story Points und Velocity beschreiben Arbeit und Planung – nicht, ob sich für Nutzer oder Geschäft etwas verbessert hat. Als interne Planungshilfe sind sie legitim; als Erfolgsmaß fürs Management lenken sie in die falsche Richtung, weil eine Zielzahl optimiert wird und Beschäftigung noch keine Wirkung belegt.\n\nDer Ausweg ist keine bessere Einzelzahl, sondern eine Haltung. Wir richten Teams an einem messbaren Outcome aus, machen die Wirkung über Actionable Metrics, Fitness Functions und Experimente sichtbar, und behandeln DORA als das, was es ist – einen guten Proxy für Lieferfähigkeit, nicht als Ziel. Das Ergebnis ist in meiner Erfahrung kein schwererer, sondern ein leichterer Prozess: kürzere Zyklen, weil wir Irrwege früh abbrechen; weniger Reibung, weil Teams selbst entscheiden dürfen; und höhere Qualität, weil Erfolg an der Wirkung hängt und nicht an der Statusfarbe eines Tickets.\n\nFowlers nüchternes Eingeständnis bleibt der beste Ausgangspunkt: Wir können Produktivität nicht direkt messen. Aber wir können aufhören, die falsche Zahl zu feiern, und anfangen, die richtige Frage zu stellen – bewegt das, was wir bauen, überhaupt die Wurzel?\n\n## Weiterführende Quellen\n\n- Martin Fowler: [CannotMeasureProductivity](https://martinfowler.com/bliki/CannotMeasureProductivity.html) und [XpVelocity](https://martinfowler.com/bliki/XpVelocity.html)\n- Teresa Torres: [Continuous Discovery Habits](https://www.goodreads.com/book/show/58046715-continuous-discovery-habits) (2021)\n- Nicole Forsgren, Jez Humble, Gene Kim: *Accelerate* (2018); [Four Keys to measure DevOps performance](https://cloud.google.com/blog/products/devops-sre/using-the-four-keys-to-measure-your-devops-performance)\n- Eric Ries: [The Lean Startup – Principles](https://theleanstartup.com/principles) (2011)\n- Neal Ford, Rebecca Parsons, Patrick Kua: [Building Evolutionary Architectures – Fitness Functions](https://www.oreilly.com/library/view/building-evolutionary-architectures/9781491986356/ch02.html) (2017)"},{"slug":"datenanalyse-mit-pandas","title":"Datenanalyse mit NumPy, Pandas und Matplotlib","summary":"Von Rohdaten zur Erkenntnis: Wie NumPy, pandas und Matplotlib zusammenspielen – vektorisiertes Rechnen, groupby-Aggregation und ein schneller Plot in einer kleinen Pipeline.","tags":["Software","Python","Daten"],"published_at":"2021-08-24T10:00:00+00:00","updated_at":"2026-07-13T10:03:09.962873+00:00","url":"https://www.mikebild.dev/de/blog/datenanalyse-mit-pandas/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/0d0283b0c8a9113bdad599cce9a9111e4e793b3728fe981d70f7ccba34b4d8c9/datenanalyse-mit-pandas-vorlesen.mp3","audio_duration_secs":1025,"text":"Ein `DataFrame` ist keine Tabellenkalkulation und keine Datenbanktabelle. Er ist eine Sammlung typisierter Spalten im Arbeitsspeicher, jede davon ein NumPy-Array, alle über einen gemeinsamen Index verbunden. Diese Herkunft erklärt fast alles, was später überrascht: warum das Durchlaufen einzelner Zeilen so teuer ist, warum eine Auswahl mal eine Sicht auf die Originaldaten und mal eine Kopie liefert, und warum ein `groupby` in einem Ausdruck erledigt, wofür man sonst eine Schleife samt Cache bauen müsste.\n\nDer erste Reflex vor einer CSV-Datei mit ein paar hunderttausend Zeilen ist trotzdem die Python-Schleife. Öffnen, Zeile für Zeile durchgehen, Werte summieren, am Ende ausgeben. Das funktioniert – für hundert Zeilen. Bei einer Million Zeilen wartet man dann und fragt sich, warum das so lange dauert.\n\nDie Frage nach der Schleife ist in Wahrheit die kleinere. Die größere entscheidet sich weiter oben: Eine Analyse ist selten Selbstzweck. Am Ende hängt an ihr eine Entscheidung. Ein Preis wird angepasst, ein Budget umgeschichtet, eine Region ausgebaut oder zurückgefahren. Der Wert einer Analyse bemisst sich deshalb nicht daran, wie elegant sie rechnet, sondern daran, wie belastbar ihr Ergebnis eine Entscheidung trägt. Und genau da liegt das eigentliche Risiko: nicht in der Laufzeit, sondern in der Frage, ob die Zahl am Ende stimmt und ob ich morgen dieselbe Zahl noch einmal herstellen kann.\n\nDer Werkzeugkasten aus NumPy, pandas und Matplotlib ist die Antwort auf diese Anforderung, nicht nur auf das Geschwindigkeitsproblem. Die drei Bibliotheken bilden zusammen einen Weg von den Rohdaten bis zur fertigen Grafik, und jede übernimmt dabei eine klar umrissene Aufgabe. Entscheidend ist, dass dieser Weg wiederholbar wird: derselbe Code, dieselben Rohdaten, dasselbe Ergebnis, unabhängig davon, wer ihn ausführt. In diesem Artikel gehe ich den Weg einmal komplett durch, erst das schnelle Rechnen mit NumPy, dann strukturierte Tabellen mit pandas, schließlich die Visualisierung mit Matplotlib. Am Ende steht eine kleine Pipeline, die lädt, bereinigt, gruppiert und plottet. Ich lege dabei den Blick weniger auf einzelne Funktionsaufrufe als auf die Stellen, an denen eine Analyse ihre Verlässlichkeit gewinnt oder verliert. Wer sich vorher noch fragt, wie die Daten überhaupt ins Programm kommen, findet in [Daten einlesen in Python](https://www.mikebild.dev/de/blog/python-daten-einlesen/) die Grundlagen dazu.\n\n## Warum keine Python-Schleife? NumPy und das ndarray\n\nDer Kern von NumPy ist ein einziger Datentyp: das `ndarray`. Auf den ersten Blick sieht es aus wie eine Liste, aber der Unterschied steht im Speicher. Eine Python-Liste hält Verweise auf beliebige Objekte, die irgendwo verstreut im Speicher liegen. Ein `ndarray` ist homogen typisiert und zusammenhängend abgelegt – alle Elemente haben denselben Datentyp und stehen direkt hintereinander.\n\nDieser Aufbau ist die Voraussetzung für das, was NumPy schnell macht: vektorisierte Operationen. Statt in Python über einzelne Elemente zu iterieren, wird die Rechnung als Ganzes an kompilierten C-Code übergeben. Ein Beispiel macht den Unterschied deutlich.\n\n```python\nimport numpy as np\n\nvalues = np.arange(1_000_000)   # 0, 1, 2, ... 999999\nresult = values * 2 + 1         # vectorized, no Python loop\ntotal = result.mean()\n\nprint(result.shape)             # (1000000,)\nprint(result.dtype)             # int64\nprint(total)                    # 1000000.0\n```\n\nDie Zeile `values * 2 + 1` rechnet auf allen Millionen Elementen, ohne dass eine einzige Python-`for`-Schleife läuft. Der Ausdruck ist nicht nur schneller, er ist auch kürzer und liest sich näher an der mathematischen Absicht. Das ist der eigentliche Gewinn: Man beschreibt, *was* gerechnet werden soll, nicht *wie* über die Elemente iteriert wird.\n\nNeben den arithmetischen Operationen gibt es zwei Werkzeuge, die in praktisch jeder Auswertung vorkommen. Das eine sind Reduktionen wie `.sum()`, `.mean()`, `.std()` oder `.min()` und `.max()`, die aus einem Array eine Kennzahl machen. Das andere sind Boolean-Masken.\n\n```python\nimport numpy as np\n\ndata = np.array([-3, 7, -1, 4, 9, -8])\n\nmask = data > 0                 # array of True/False\npositives = data[mask]          # keep only positive values\nclipped = np.where(data < 0, 0, data)   # replace negatives with 0\n\nprint(positives)                # [7 4 9]\nprint(clipped)                  # [0 7 0 4 9 0]\n```\n\n`data > 0` erzeugt kein einzelnes `True` oder `False`, sondern ein ganzes Array aus Wahrheitswerten. Damit lässt sich dann filtern (`data[mask]`) oder mit `np.where` fallweise ersetzen. Auch hier keine Schleife, keine Bedingung im Python-Code – die Logik steckt komplett in der vektorisierten Operation.\n\nEng verwandt mit der Vektorisierung ist das Broadcasting. Damit lassen sich Arrays unterschiedlicher Form miteinander verrechnen, solange ihre Dimensionen zueinander passen. Ein Skalar wird dabei gedanklich auf die gesamte Form des Arrays ausgedehnt – deshalb funktioniert `values * 2 + 1`, ohne dass man die `2` und die `1` erst selbst auf eine Million Elemente aufblasen müsste. Bei zweidimensionalen Daten greift dieselbe Regel: Ein Zeilenvektor mit passender Spaltenzahl lässt sich auf jede Zeile einer Matrix anwenden, etwa um von jeder Spalte den Mittelwert abzuziehen. Broadcasting erspart damit viele explizite Schleifen und hält den Code nah an der mathematischen Notation. Wichtig ist nur, die Formen im Kopf zu behalten: Passen die Dimensionen nicht zusammen, quittiert NumPy das mit einem `ValueError`, statt stillschweigend etwas Falsches zu rechnen.\n\n## Pandas: Tabellen mit Beschriftung\n\nNumPy rechnet gut mit Zahlen, aber reale Daten haben Struktur. Spalten haben Namen, Zeilen haben eine Bedeutung, und oft mischen sich Zahlen mit Text und Datumsangaben. Hier kommt pandas ins Spiel, und pandas baut direkt auf NumPy auf. Unter den Tabellen von pandas stecken NumPy-Arrays.\n\nDie zwei zentralen Strukturen sind `Series` und `DataFrame`. Eine `Series` ist eine eindimensionale Spalte mit einem Index. Ein `DataFrame` ist eine zweidimensionale Tabelle mit beschrifteten Zeilen und Spalten – im Grunde eine Sammlung von `Series`, die sich denselben Index teilen.\n\n```python\nimport pandas as pd\n\ndf = pd.DataFrame({\n    \"region\": [\"north\", \"south\", \"north\", \"west\", \"south\"],\n    \"amount\": [120.0, 80.0, 45.0, 200.0, 95.0],\n    \"channel\": [\"online\", \"store\", \"online\", \"store\", \"online\"],\n})\n\nprint(df.shape)                 # (5, 3)\nprint(df[\"amount\"].mean())      # 108.0\n```\n\nDas Einlesen aus einer Datei läuft meist über `pd.read_csv`, das eine CSV-Datei in ein `DataFrame` verwandelt. Ab dann arbeitet man mit derselben Tabelle weiter, egal woher sie stammt.\n\n### Auswahl mit loc und iloc – der klassische Stolperstein\n\nSobald man Zeilen und Spalten gezielt herausgreifen will, tauchen zwei Zugriffsarten auf, die Anfänger zuverlässig durcheinanderbringen: `.loc` und `.iloc`. Der Unterschied ist eigentlich einfach, aber man muss ihn einmal bewusst gelernt haben.\n\n`.loc` ist label-basiert. Man greift über die Beschriftung von Zeilen und Spalten zu, und – das ist die Eigenheit – der Endpoint eines Bereichs ist **inklusiv**. `.iloc` ist positionsbasiert. Man greift über die ganzzahlige Position zu, und der Endpoint ist **exklusiv**, genau wie beim normalen Python-Slicing.\n\n```python\nimport pandas as pd\n\ndf = pd.DataFrame(\n    {\"amount\": [120, 80, 45, 200, 95]},\n    index=[\"a\", \"b\", \"c\", \"d\", \"e\"],\n)\n\n# label-based, endpoint INCLUDED -> rows a, b, c\nprint(df.loc[\"a\":\"c\", \"amount\"])\n\n# position-based, endpoint EXCLUDED -> rows a, b, c (positions 0, 1, 2)\nprint(df.iloc[0:3, 0])\n```\n\nBeide Ausdrücke liefern hier dieselben drei Zeilen, aber aus unterschiedlichen Gründen: einmal weil `\"c\"` als Label eingeschlossen wird, einmal weil Position `3` gerade nicht mehr mitgenommen wird. Wer das verwechselt, erntet entweder einen Off-by-one-Fehler oder einen `KeyError`. Labels also immer über `.loc`, Positionen immer über `.iloc`, und die beiden nie in einem Ausdruck mischen.\n\nZum Filtern nutzt man Boolean-Masken – genau wie in NumPy, nur dass die Bedingung sich auf eine Spalte bezieht.\n\n```python\nimport pandas as pd\n\ndf = pd.DataFrame({\n    \"region\": [\"north\", \"south\", \"north\", \"west\"],\n    \"amount\": [120, 80, 45, 200],\n})\n\nbig = df[df[\"amount\"] > 100]              # keep rows above 100\nbig_regions = df.loc[df[\"amount\"] > 100, \"region\"]   # filter + pick column\n```\n\n## Der häufigste Fehler: verkettetes Indizieren\n\nJetzt zu einem Fallstrick, der in Code-Reviews regelmäßig auftaucht und der zu einer der meistgesuchten Warnungen im pandas-Umfeld führt: dem `SettingWithCopyWarning`. Er entsteht durch verkettetes Indizieren – also zwei aufeinanderfolgende Zugriffe in einem Ausdruck.\n\n```python\n# problematic: chained indexing\ndf[df[\"amount\"] > 100][\"tier\"] = \"high\"     # may write into a copy!\n```\n\nDas Tückische daran: `df[df[\"amount\"] > 100]` liefert je nach Situation eine Sicht auf die Originaldaten oder eine Kopie davon – und es ist nicht sicher vorhersagbar, welches von beiden. Schreibt man dann in das Ergebnis, landet die Zuweisung womöglich in einer Kopie, die sofort wieder verworfen wird. Das Original bleibt unverändert, und pandas warnt genau davor. Die korrekte Form nutzt einen einzigen `.loc`-Zugriff, der Zeilenauswahl und Spalte in einem Schritt adressiert:\n\n```python\n# correct: single .loc access\ndf.loc[df[\"amount\"] > 100, \"tier\"] = \"high\"\n```\n\nUnd wenn man bewusst mit einer eigenständigen Kopie weiterarbeiten will, sagt man das ausdrücklich mit `df.copy()`. Dann ist klar, dass Änderungen das Original nicht berühren sollen. Diese eine Regel – Auswahl und Zuweisung in einem `.loc` – erspart erfahrungsgemäß die meisten dieser Warnungen.\n\nDer zweite Fehler in derselben Kategorie ist das Iterieren über Zeilen. Wer aus der klassischen Programmierung kommt, greift instinktiv zu `df.iterrows()` und geht Zeile für Zeile durch. Das ist langsam und speicherhungrig, weil pandas für jede Zeile ein neues Objekt bauen muss und der ganze Geschwindigkeitsvorteil des vektorisierten NumPy-Unterbaus verloren geht. Fast immer lässt sich dieselbe Absicht mit einer Spaltenoperation, mit `groupby` oder mit `np.where` ausdrücken – und läuft dann um Größenordnungen schneller.\n\n## Gruppieren und aggregieren mit groupby\n\nDie vielleicht wichtigste Operation für Analysen ist `groupby`. Sie folgt einem Muster, das unter dem Namen Split-Apply-Combine bekannt ist: Die Daten werden nach einem Schlüssel in Gruppen aufgeteilt (split), auf jede Gruppe wird eine Aggregatfunktion angewendet (apply), und die Teilergebnisse werden zu einer neuen Tabelle zusammengefügt (combine).\n\n```python\nimport pandas as pd\n\ndf = pd.DataFrame({\n    \"region\": [\"north\", \"south\", \"north\", \"west\", \"south\"],\n    \"amount\": [120.0, 80.0, 45.0, 200.0, 95.0],\n})\n\nsummary = (df.groupby(\"region\")\n             .agg(total=(\"amount\", \"sum\"),\n                  avg=(\"amount\", \"mean\"))\n             .reset_index())\n\nprint(summary)\n#   region  total    avg\n# 0  north  165.0   82.5\n# 1  south  175.0   87.5\n# 2   west  200.0  200.0\n```\n\nDie benannte Aggregation `total=(\"amount\", \"sum\")` ist meine bevorzugte Schreibweise: Sie sagt in einer Zeile, welche Spalte mit welcher Funktion aggregiert wird und wie die Ergebnisspalte heißen soll. Das Ergebnis ist eine kompakte Tabelle, die sich direkt weiterverarbeiten lässt. Das `.reset_index()` am Ende holt den Gruppierungsschlüssel aus dem Index zurück in eine normale Spalte – praktisch, wenn man gleich danach plotten will.\n\nOft liegen die Daten nicht in einer einzigen Tabelle, sondern verteilt. Verkaufszahlen hier, Regionsinformationen dort. Zum Zusammenführen dient `merge`, das nach dem Vorbild von Datenbank-Joins arbeitet:\n\n```python\nimport pandas as pd\n\nsales = pd.DataFrame({\n    \"region\": [\"north\", \"south\", \"west\"],\n    \"amount\": [165, 175, 200],\n})\nmanagers = pd.DataFrame({\n    \"region\": [\"north\", \"south\", \"east\"],\n    \"manager\": [\"Ada\", \"Ben\", \"Cleo\"],\n})\n\njoined = sales.merge(managers, how=\"inner\", on=\"region\")\nprint(joined)\n#   region  amount manager\n# 0  north     165     Ada\n# 1  south     175     Ben\n```\n\nHier lohnt ein wacher Blick auf den Parameter `how`. Mit `how=\"inner\"` bleiben nur Regionen, die in beiden Tabellen vorkommen – „west“ und „east“ fallen heraus. Ein `how=\"outer\"` würde alle Regionen behalten und die fehlenden Werte mit `NaN` auffüllen. Wer den falschen Modus wählt, verliert entweder unbemerkt Zeilen oder handelt sich unerwartete Lücken ein. Es lohnt sich, vor und nach einem `merge` einmal auf `.shape` zu schauen und zu prüfen, ob die Zeilenzahl zu den Erwartungen passt.\n\n## Die Pipeline: laden, bereinigen, gruppieren, plotten\n\nSetzen wir die Teile zusammen. Eine typische Analyse durchläuft immer dieselben Stationen, und es hilft, diesen Ablauf einmal als Bild vor Augen zu haben.\n\n```mermaid\nflowchart LR\n    A[Rohdaten<br/>CSV-Datei] --> B[Laden<br/>pandas.read_csv]\n    B --> C[Bereinigen<br/>dropna / fillna]\n    C --> D[Transformieren<br/>groupby / agg]\n    D --> E[Plotten<br/>matplotlib]\n```\n\nVor dem Gruppieren steht meist das Bereinigen. Reale Daten haben Lücken, und `groupby` oder Rechnungen darauf reagieren empfindlich auf fehlende Werte. Zwei Werkzeuge decken die meisten Fälle ab: `dropna` wirft Zeilen mit fehlenden Werten weg, `fillna` ersetzt sie durch einen Vorgabewert. Welches von beiden richtig ist, hängt von der Frage ab – manchmal ist eine Lücke ein echtes Nichts, manchmal eine getarnte Null.\n\n```python\nimport pandas as pd\n\ndf = pd.read_csv(\"sales.csv\")\n\n# clean: drop rows without an amount\ndf = df.dropna(subset=[\"amount\"])\n\n# transform: aggregate per region\nsummary = (df.groupby(\"region\")\n             .agg(total=(\"amount\", \"sum\"))\n             .reset_index())\n```\n\n## Datenqualität und Reproduzierbarkeit als Entscheidungsrisiko\n\nDer Schritt `df.dropna(subset=[\"amount\"])` sieht nach einer Formsache aus. Er ist keine. In dem Moment, in dem ich fehlende Werte wegwerfe statt sie zu füllen, entscheide ich darüber, welche Zahl am Ende beim Entscheider ankommt. `dropna` verkleinert die Grundmenge und kann eine Region verschwinden lassen, die schlicht schlecht erfasst war. `fillna(0)` macht aus einem „unbekannt“ eine harte Null und zieht damit jeden Mittelwert nach unten. Beides ist manchmal richtig und manchmal falsch, aber nie neutral. Die Frage ist immer dieselbe: Ist die Lücke ein echtes Nichts oder eine getarnte Null? Diese Entscheidung gehört dokumentiert, nicht stillschweigend getroffen.\n\nDer zweite, teurere Risikofaktor ist stiller Datenverlust. Ein `merge` mit dem falschen `how` lässt Zeilen ohne Warnung fallen, eine Bereinigung halbiert unbemerkt die Grundmenge, und das Balkendiagramm sieht trotzdem plausibel aus. Genau darin liegt die Gefahr: Eine falsche Analyse sieht einer richtigen zum Verwechseln ähnlich. Deshalb baue ich Prüfungen direkt in die Pipeline, statt auf den Augenschein zu vertrauen.\n\n```python\nbefore = len(df)\ndf = df.dropna(subset=[\"amount\"])\nassert len(df) >= before * 0.9, \"dropped more than 10% of rows\"\nassert df[\"amount\"].between(0, 10_000).all(), \"amount out of expected range\"\n```\n\nSolche Zusicherungen kosten Minuten in der Entwicklung. Eine Entscheidung, die auf einer um zehn Prozent geschrumpften Grundmenge fußt, kostet deutlich mehr, und der Fehler fällt oft erst auf, wenn er schon gewirkt hat. Das ist der eigentliche Trade-off: ein wenig Reibung beim Schreiben gegen eine belastbare Zahl beim Entscheiden.\n\nDer dritte Punkt ist Reproduzierbarkeit. Eine Analyse, die ich in einer Tabellenkalkulation zusammenklicke, ist nach zwei Wochen nicht mehr nachvollziehbar. Niemand weiß mehr, welche Zellen von Hand angefasst wurden. Eine Analyse als Code ist das Gegenteil: derselbe Code auf denselben Rohdaten liefert dasselbe Ergebnis, jederzeit, für jeden im Team. Die Rohdaten bleiben unangetastet, jede Transformation steht sichtbar im Skript, und das Ergebnis lässt sich bis zur Quelle zurückverfolgen. Damit wird aus einer einmaligen Zahl ein Vorgang, den ich prüfen, versionieren und verteidigen kann. Genau das hebt eine Analyse von einer netten Grafik zu einer Entscheidungsgrundlage.\n\n## Matplotlib: aus Zahlen wird ein Bild\n\nAm Ende der Pipeline steht die Visualisierung. Matplotlib ist die etablierte Bibliothek dafür, und ich empfehle von Anfang an den objektorientierten Stil: erst mit `plt.subplots()` eine Figur und eine Achse erzeugen, dann alles auf dieser Achse aufbauen. Das ist etwas mehr Tipparbeit als der schnelle `plt.plot()`-Einzeiler, aber es bleibt übersichtlich, sobald eine Grafik mehr als eine Kurve enthält.\n\n```python\nimport matplotlib.pyplot as plt\n\nfig, ax = plt.subplots()\nax.bar(summary[\"region\"], summary[\"total\"])\nax.set_xlabel(\"Region\")\nax.set_ylabel(\"Total sales\")\nax.set_title(\"Sales by region\")\nplt.show()\n```\n\nFür die drei häufigsten Diagrammtypen gibt es je eine passende Funktion auf der Achse. Ein Balkendiagramm mit `ax.bar(x, height)` vergleicht Kategorien – ideal für das `groupby`-Ergebnis von eben. Ein Liniendiagramm mit `ax.plot(x, y)` zeigt Verläufe, etwa eine Kennzahl über mehrere Monate hinweg. Und ein Streudiagramm mit `ax.scatter(x, y)` zeigt den Zusammenhang zweier Größen. Beschriftungen über `set_xlabel`, `set_ylabel` und `set_title` gehören für mich immer dazu; eine Grafik ohne Achsenbeschriftung ist nur eine halbe Aussage.\n\n## Wer macht was? Die Rollenteilung im Überblick\n\nDie Aufgabenteilung im Werkzeugkasten ist klar geschnitten, und wer sie kennt, greift für ein Problem nicht zur falschen Bibliothek:\n\n- NumPy bildet die numerische Basis: homogene Arrays, vektorisiertes Rechnen, mathematische Operationen. Alles darüber baut darauf auf.\n- pandas kümmert sich um strukturierte, beschriftete Tabellendaten: einlesen, bereinigen, auswählen, filtern, gruppieren, zusammenführen – der gesamte Umgang mit realen, unordentlichen Daten.\n- Matplotlib übernimmt die Visualisierung: Aus den fertig aufbereiteten Zahlen wird ein Diagramm, das eine Aussage transportiert.\n\nDiese Reihenfolge ist kein Zufall. Sie beschreibt genau den Weg, den die Daten nehmen: von rohen Zahlen über eine strukturierte Tabelle bis zum Bild.\n\n## Roter Faden\n\nDer eigentliche Denkwechsel bei der Datenanalyse mit Python ist der Abschied von der Schleife. Nicht mehr Element für Element durchgehen, sondern ganze Spalten und ganze Tabellen als Einheit behandeln. NumPy macht das über vektorisierte Operationen auf dem `ndarray`, pandas hebt dieselbe Idee auf beschriftete Tabellen, und `groupby` fasst nach dem Split-Apply-Combine-Pattern ganze Gruppen auf einmal zusammen. Der Gewinn ist aber nicht nur Tempo. Wer die zwei Anfängerfallen kennt, verkettetes Indizieren und das Iterieren über Zeilen, umgeht die häufigsten Reibungspunkte und schreibt Code, der bei einer Million Zeilen genauso ruhig bleibt wie bei hundert.\n\nWirkung entfaltet dieser Werkzeugkasten dort, wo Analyse auf Entscheidung trifft. Eine Pipeline aus Code verkürzt den Zyklus von der Frage zur Antwort: Ändern sich die Rohdaten, läuft dasselbe Skript erneut, und die neue Zahl steht in Minuten statt in einem halben Tag Handarbeit. Sie senkt die Reibung im Team, weil jeder dasselbe Ergebnis reproduzieren kann, statt eine fremde Tabelle zu entziffern. Und sie hebt die Qualität, weil Prüfungen und dokumentierte Bereinigungsentscheidungen dafür sorgen, dass eine falsche Zahl auffällt, bevor sie eine Entscheidung trägt. Die Grenze ist ebenso klar: Diese Werkzeuge arbeiten dort gut, wo die Daten in den Arbeitsspeicher passen und eine Analyse im Batch genügt. Bei Datenmengen jenseits einer einzelnen Maschine oder bei laufenden Datenströmen beginnt ein anderes Werkzeugfeld, und die Kunst besteht darin, diese Grenze rechtzeitig zu erkennen, statt pandas über seinen Einsatzbereich hinaus zu prügeln.\n\nDer Rest ist Übung: laden, bereinigen, gruppieren, plotten, und diesen Kreislauf so oft gehen, bis er in den Fingern sitzt. Das Werkzeug wird erst dann nützlich, wenn die Schritte reproduzierbar bleiben und eine zweite Person dieselbe Zahl erhält.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-python.html\n- Repository `introduction-python` mit lauffähigen Beispielen zu NumPy, pandas und Matplotlib\n- NumPy – „the absolute basics for beginners“: https://numpy.org/doc/stable/user/absolute_beginners.html\n- pandas – Indexing and Selecting Data (loc/iloc, chained indexing): https://pandas.pydata.org/docs/user_guide/indexing.html\n- pandas – Group by: split-apply-combine: https://pandas.pydata.org/docs/user_guide/groupby.html\n- pandas – Merge, join, concatenate: https://pandas.pydata.org/docs/user_guide/merging.html\n- Matplotlib – pyplot API: https://matplotlib.org/stable/api/pyplot_summary.html"},{"slug":"wann-cqrs-und-event-sourcing-zu-viel-ist","title":"Wann CQRS und Event Sourcing zu viel sind","summary":"Für die meisten Systeme reicht CRUD. CQRS und Event Sourcing lohnen sich nur unter klaren Bedingungen – dieser Beitrag liefert einen ehrlichen Entscheidungsleitfaden statt Muster als Selbstzweck.","tags":["Software","Architektur"],"published_at":"2021-07-13T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/wann-cqrs-und-event-sourcing-zu-viel-ist/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/759367c6923eba28889d4d50637b54fa67b84c7a39f3a8c83ebcdbec47eccdf8/wann-cqrs-und-event-sourcing-zu-viel-ist-vorlesen.mp3","audio_duration_secs":665,"text":"# Wann CQRS und Event Sourcing zu viel sind\n\nEine E-Mail-Adresse ändern kostet im CRUD-Handler drei Zeilen: laden, Feld setzen, speichern. Mit CQRS und Event Sourcing sind es fünf Teile – ein Command, ein Event, ein Aggregat mit Replay-Pfad, ein Append in den Event Store und eine Projection, die das Read Model nachzieht. Dazu kommt ein Zeitfenster, in dem eine Query noch die alte Adresse liefert. Fünf bewegliche Teile und ein Konsistenzproblem, das es vorher nicht gab.\n\nDiesen Preis kann man zahlen, und unter bestimmten Bedingungen zahlt er sich aus. Ich mag beide Muster und habe sie in anderen Beiträgen verteidigt. Für den Großteil der Systeme sind sie trotzdem zu viel.\n\nDas ist keine steile These. Martin Fowler formuliert es fast identisch: \"For most systems CQRS adds risky complexity.\" Und ungewöhnlich deutlich für ihn: In der Mehrzahl der Fälle, die ihm begegnet sind, war CQRS keine gute Wahl, sondern brachte ein erhebliches Risiko mit, das System in ernste Schwierigkeiten zu bringen. Wenn der Autor, der ein Muster bekannt gemacht hat, so warnt, lohnt sich ein ehrlicher Entscheidungsleitfaden.\n\n## Die Baseline heißt CRUD\n\nBevor ich über Alternativen rede, halte ich fest, was die Standardlösung eigentlich leistet. CRUD bedeutet: ein einziges Datenmodell für Lesen und Schreiben, direkte Persistenz des aktuellen Zustands, meist gegen eine Tabelle über ein ORM. Create, Read, Update, Delete. Fowler nennt das nüchtern: \"Many systems do fit a CRUD mental model, and so should be done in that style.\"\n\nDer entscheidende Punkt ist, dass CRUD nicht die primitive Vorstufe ist, die man später durch etwas Besseres ersetzt. Für ein Formular, das gespeichert und wieder angezeigt wird, ist CRUD nicht der Kompromiss, sondern die richtige Antwort. Ein illustrativer Handler dafür ist unspektakulär – und genau das ist die Stärke:\n\n```ts\nasync function updateCustomerEmail(id: string, email: string): Promise<void> {\n  const customer = await repo.findById(id);\n  customer.email = email;\n  await repo.save(customer); // read and write share the same model\n}\n```\n\nEin Modell, ein Zugriff, keine Latenz zwischen Schreiben und Lesen. Wer diesen Wert wegoptimiert, ohne ein Problem zu haben, tauscht Einfachheit gegen Komplexität ein und bekommt dafür nichts zurück.\n\n## Was CQRS und Event Sourcing wirklich sind\n\nDamit die Entscheidung sauber wird, trenne ich die beiden Begriffe, die gern in einem Atemzug genannt werden. CQRS – Command Query Responsibility Segregation – heißt, dass man ein anderes Modell zum Ändern von Daten benutzt als zum Lesen. Die Schreibseite nimmt Commands an, prüft Invarianten und gibt nichts zurück. Die Leseseite beantwortet Queries, liefert Daten und ändert nichts. Das ist mehr als das Command-Query-Separation-Prinzip von Bertrand Meyer: CQS trennt Methoden auf einem Objekt, CQRS trennt ganze Modelle.\n\nEvent Sourcing ist etwas anderes. Hier wird jede Zustandsänderung als Event festgehalten, und der aktuelle Zustand entsteht durch erneutes Abspielen der Events. Der Event Store ist die maßgebliche Quelle der Wahrheit, der Zustand ist rein abgeleitet. Fowlers Analogie dazu ist die Versionskontrolle: Das Commit-Log entspricht dem Event Store, der Arbeitsbaum dem aktuellen Systemzustand. Diesen Gedanken habe ich in [Event Sourcing: der Event-Store als Logbuch](https://www.mikebild.dev/de/blog/event-sourcing-der-event-store-als-logbuch/) ausführlicher entwickelt.\n\nWichtig für den heutigen Beitrag: Beide Muster sind unabhängig voneinander. Greg Young stellt das mehrfach klar – CQRS ist nicht Event Sourcing, braucht keinen Message Bus und ist auch nicht zwingend an DDD gekoppelt. Man kann CQRS ohne Event Sourcing betreiben, also mit getrennter Lese-Datenbank, aber klassischer Zustandspersistenz. Und man kann Event Sourcing mit nur einem Modell betreiben. Wer beide zusammen einführt, entscheidet sich also für zwei Kostenblöcke, nicht für einen.\n\n## Die Kosten, die man selten mitplant\n\nGetrennte Modelle erzwingen faktisch Eventual Consistency. Sobald die Schreibseite ein Event erzeugt und die Leseseite daraus erst eine Projection baut, existiert ein Zeitfenster, in dem eine Query den gerade geschriebenen Wert noch nicht sieht. Fowler nennt das offen: Getrennte Modelle erhöhen die Wahrscheinlichkeit, Eventual Consistency einsetzen zu müssen. Das ist keine Funktion, sondern eine Kostenposition.\n\nEin illustrativer Kontrast zum CRUD-Handler oben zeigt die zusätzlichen beweglichen Teile. Erst die Messages:\n\n```ts\ninterface ChangeEmail {\n  type: \"ChangeEmail\";\n  id: string;\n  email: string;\n}\n\ninterface EmailChanged {\n  type: \"EmailChanged\";\n  id: string;\n  email: string;\n  version: number;\n}\n```\n\nDann das Write Model, das aus seinen Events rehydriert wird und beim Ändern eine Invariante prüft, statt einfach ein Feld zu überschreiben:\n\n```ts\nclass Customer {\n  private constructor(\n    private readonly id: string,\n    private email: string,\n    private version: number,\n  ) {}\n\n  static empty(): Customer {\n    return new Customer(\"\", \"\", 0);\n  }\n\n  static from(events: EmailChanged[]): Customer {\n    // fold the event stream into current state (replay)\n    return events.reduce(\n      (state, event) => state.apply(event),\n      Customer.empty(),\n    );\n  }\n\n  private apply(event: EmailChanged): Customer {\n    return new Customer(event.id, event.email, event.version);\n  }\n\n  changeEmail(email: string): EmailChanged[] {\n    if (email === this.email) return []; // no change, no event\n    return [{ type: \"EmailChanged\", id: this.id, email, version: this.version + 1 }];\n  }\n}\n```\n\nUnd schließlich der Handler, der lädt, entscheidet, anhängt – und die Projection getrennt davon nachzieht:\n\n```ts\nasync function handle(command: ChangeEmail): Promise<void> {\n  const events = await store.load(command.id);\n  const customer = Customer.from(events);\n  const newEvents = customer.changeEmail(command.email);\n  await store.append(command.id, newEvents); // source of truth\n  // read model is updated asynchronously by a projection\n}\n```\n\nDie Leseseite ist ein eigenes, bewusst dummes Read Model, das aus denselben Events entsteht:\n\n```ts\nclass CustomerListView {\n  private rows = new Map<string, { id: string; email: string }>();\n\n  on(event: EmailChanged): void {\n    this.rows.set(event.id, { id: event.id, email: event.email });\n  }\n\n  // a query right after append may still return the previous email (stale read)\n  find(id: string) {\n    return this.rows.get(id);\n  }\n}\n```\n\nZwischen dem ersten CRUD-Beispiel und dieser Variante liegt kein Fortschritt, sondern eine Abwägung. Ich habe mehr Typen, mehr Indirektion, einen Replay-Pfad, eine Projection und ein Konsistenzfenster eingetauscht – gegen einen Nutzen, der nur unter bestimmten Bedingungen überhaupt entsteht.\n\nEvent Sourcing bringt darüber hinaus eigene, oft unterschätzte Kosten mit. Alte Events müssen praktisch für immer lesbar bleiben, was Event-Versionierung und Schema-Evolution zu einer Daueraufgabe macht. Projections und Read Models müssen aufgebaut und neu aufgebaut werden können. Externe Side Effects beim Replay wollen sorgfältig behandelt sein, sonst verschickt ein Neuaufbau versehentlich echte E-Mails. Und ein einfaches Ad-hoc-Query auf den aktuellen Zustand gibt es nicht mehr geschenkt. Diese Punkte kommen zusätzlich zu den CQRS-Kosten obendrauf.\n\n## Ein Entscheidungsfluss\n\nIch mache die Entscheidung an wenigen Fragen fest, in dieser Reihenfolge. Wichtig dabei: Das gilt pro Bounded Context, nicht global. Greg Young ist hier eindeutig – CQRS ist kein Top-Level-Pattern, es wird innerhalb einer Komponente angewandt, nicht über das ganze System gestülpt.\n\n```mermaid\nflowchart TD\n  Start([Neuer Context]) --> Inv{Echtes Domänenmodell<br/>mit kollidierenden Invarianten?}\n  Inv -->|nein| Crud[CRUD genügt]\n  Inv -->|ja| Asym{Starke Read/Write-Asymmetrie<br/>ODER Audit-/Zeitreise-Anforderung?}\n  Asym -->|nein| Lite[Weiter CRUD<br/>oder CQRS-lite]\n  Asym -->|ja| Team{Team trägt Eventual Consistency<br/>und Event-Versionierung?}\n  Team -->|nein| Kompromiss[CRUD oder Kompromiss]\n  Team -->|ja| Cqrs[CQRS – plus Event Sourcing,<br/>wenn Audit oder Temporal]\n```\n\nDer Nutzen von CQRS ist eng an zwei Bedingungen gebunden. Erstens eine komplexe Domäne, in der ein gemeinsames Modell für Lesen und Schreiben tatsächlich zu kompliziert wird. Zweitens eine ausgeprägte Lese-/Schreib-Asymmetrie – viele Lesezugriffe, wenige Schreibzugriffe –, sodass sich die unabhängige Skalierung von Lese- und Schreibseite auszahlt. Fehlen beide, führt Fowlers Beobachtung geradewegs in die Falle: CQRS auf einer Domäne, die nicht dazu passt, erhöht die Komplexität, senkt die Produktivität und steigert das Risiko. Das Muster vereinfacht nicht von sich aus, sondern nur dort, wo bereits echte Modell- oder Invarianten-Komplexität liegt.\n\n## Signale dafür und dagegen\n\nIm konkreten Projekt hängt die Antwort an ein paar Signalen, die man meist früher sieht, als man sie ernst nimmt.\n\nDafür spricht:\n\n- ein task-basiertes UI mit vielen nebenläufig Zusammenarbeitenden, deren Aktionen fachlich kollidieren können\n- harte Audit-, Compliance- oder Zeitreise-Anforderungen, die eine lückenlose Historie verlangen – hier zieht besonders Event Sourcing\n- eine ausgeprägte Read/Write-Asymmetrie mit getrennten Skalierungszielen\n- eine reichhaltige Domäne mit echten, konkurrierenden Invarianten und deutlich unterschiedlichen Lese- gegenüber Schreib-Formen\n\nDagegen spricht:\n\n- eine klassische CRUD- oder Formular-Anwendung ohne echte Invarianten-Komplexität\n- ein kleines Team, das mit Eventual Consistency noch keine Erfahrung hat\n- keine Audit- oder Historien-Anforderung, die den Aufwand rechtfertigt\n- der Wunsch nach einer globalen Einheitsarchitektur – oder schlicht \"weil es alle machen\"\n\nDer letzte Punkt führt zum eigentlichen Fallstrick.\n\n## Der Fallstrick: das Muster als Selbstzweck\n\nDas größte Risiko ist nicht die falsche Technik, sondern die falsche Motivation. Greg Young beschreibt den Kern präzise: Teams wollen CQRS und Event Sourcing überall implementieren, als Cookie-Cutter-Architektur – und genau das ist ein massives Risiko. Ein Muster wird eingeführt, weil es glänzt, nicht weil ein konkretes Problem danach verlangt. Fowler rät zur Vorsicht, weil das Muster schwer gut einzusetzen ist.\n\nDrei Symptome verraten den Cargo Cult. Es gibt Events, aber niemand kann eine fachliche Invariante nennen, die sie schützen. Es gibt eine Projection, aber die Leseform ist identisch zur Schreibform. Und es gibt einen Event Store, aber keine Anforderung an Audit oder Historie. Wenn diese drei Dinge zusammenkommen, ist das Muster dekorativ, nicht tragend.\n\nDazu kommen die üblichen Verwechslungen. CQRS ist nicht Event Sourcing – beide sind orthogonal und einzeln einsetzbar. CQRS ist keine Architektur und kein Top-Level-Pattern, sondern eine Entscheidung innerhalb eines Bounded Context. CQRS ist nicht CQS, weil es Modelle trennt und nicht Methoden. CQRS braucht weder Message Bus noch DDD. Und Event Sourcing ist nicht dasselbe wie ereignisgetriebene Integration über Pub/Sub – es ist ein interner Persistenz- und Source-of-Truth-Mechanismus, kein Kommunikationsstil zwischen Systemen. Fowler trennt diese Bedeutungen von \"Event-Driven\" bewusst, weil sonst vier verschiedene Dinge unter einem Wort verschwimmen.\n\n## Mehr bewegliche Teile, sichtbar gemacht\n\nEin direkter Kontrast der Pfade macht die Abwägung greifbar. Der CRUD-Pfad ist kurz, der CQRS-Pfad mit Event Sourcing hat mehr Stationen – und jede Station ist ein Ort, an dem etwas schiefgehen kann.\n\n```mermaid\nflowchart LR\n  subgraph CRUD\n    C1[Client] --> H1[Handler] --> DB[(Database)]\n  end\n  subgraph CQRS_ES\n    Cmd[Command] --> Agg[Aggregate] --> ES[(Event Store)]\n    ES --> Proj[Projection] --> RM[(Read Model)]\n    RM --> Q[Query]\n  end\n```\n\nDiese Grafik verkauft nichts. Sie macht nur ehrlich, dass die untere Variante mehr Komponenten, mehr Verträge und mehr Fehlerpfade bedeutet. Wer diesen Preis kennt und trotzdem zahlt, weil die Domäne ihn wert ist, trifft eine gute Entscheidung. Wer ihn zahlt, ohne den Gegenwert zu haben, baut Komplexität auf Vorrat.\n\n## Fazit\n\nCQRS und Event Sourcing sind keine besseren Standardarchitekturen, sondern gezielte Werkzeuge für gezielte Probleme. Die ehrliche Ausgangslage ist CRUD, und für den Großteil der Systeme bleibt sie die richtige Antwort. Die beiden Muster lohnen sich, wenn eine Domäne echte, kollidierende Invarianten hat, wenn Lese- und Schreiblast stark auseinanderlaufen oder wenn Audit- und Zeitreise-Anforderungen eine lückenlose Historie verlangen – und wenn das Team bereit ist, Eventual Consistency und Event-Versionierung zu tragen. Sie werden zum Overhead bei einfachen CRUD-Apps, in kleinen Teams, ohne echte Invarianten-Komplexität und immer dann, wenn das Muster aus Mode statt aus Not gewählt wird.\n\nDer beste Umgang damit ist eine bewusste, kontextlokale Entscheidung. Nicht global, nicht als Cookie-Cutter, sondern pro Bounded Context, mit einem klaren \"Wofür\". Dass CQRS diese fachliche Grenze braucht und kein Framework ist, habe ich in [CQRS ist eine fachliche Grenze](https://www.mikebild.dev/de/blog/cqrs-fachliche-grenze-kein-framework/) beschrieben. Die reifste Architekturqualität zeigt sich oft nicht darin, ein anspruchsvolles Muster einzusetzen – sondern darin, es begründet nicht einzusetzen.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html\n- Martin Fowler, CQRS: https://martinfowler.com/bliki/CQRS.html\n- Martin Fowler, Event Sourcing: https://martinfowler.com/eaaDev/EventSourcing.html\n- Martin Fowler, Command Query Separation: https://martinfowler.com/bliki/CommandQuerySeparation.html\n- Martin Fowler, What do you mean by Event-Driven?: https://martinfowler.com/articles/201701-event-driven.html\n- Greg Young, CQRS is not an Architecture (2012): https://gregfyoung.wordpress.com/2012/09/09/cqrs-is-not-an-architecture/\n- Greg Young, CQRS (2012): https://gregfyoung.wordpress.com/2012/03/02/cqrs/\n- Vaughn Vernon, Implementing Domain-Driven Design (Sample): https://ptgmedia.pearsoncmg.com/images/9780321834577/samplepages/0321834577.pdf\n- Eigenes Vortragsmaterial \"CQRS + Event Sourcing\" (SlideShare)\n\nDie Quellen sichern die Begriffe gegen Standards ab. Die eigentliche Aussage bleibt die kontextlokale Entscheidung, das Muster nur dort einzusetzen, wo die Domäne es verdient."},{"slug":"graphql-konzepte-schema-resolver-ausfuehrung","title":"GraphQL-Konzepte: Schema, Resolver, Ausführung","summary":"Die Ideen hinter GraphQL, die bleiben, egal welcher Server gerade modern ist – das Schema als typisierter Vertrag, die Query als Form, das Resolver-Modell und die Reihenfolge der Ausführung. Eine konzeptuelle Vertiefung mit Diagrammen.","tags":["Software","API","GraphQL"],"published_at":"2021-06-24T10:00:00+00:00","updated_at":"2026-07-13T13:20:27.930931+00:00","url":"https://www.mikebild.dev/de/blog/graphql-konzepte-schema-resolver-ausfuehrung/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/696afd56ecc7b30fef42c875c1aace24d4483f9442d400c45407ea3abff6ebb6/graphql-konzepte-schema-resolver-ausfuehrung-vorlesen.mp3","audio_duration_secs":498,"text":"# GraphQL-Konzepte: Schema, Resolver, Ausführung\n\nGraphQL ist keine Query Language für Datenbanken, und ein Resolver ist kein Controller. Beide Vergleiche tragen ein Stück weit und brechen dann – spätestens bei der Query, die harmlos aussieht und im Server dreißig einzelne Datenbankabrufe auslöst. Die Erklärung dafür steht nicht im Handbuch des jeweiligen Servers, sondern in der Spezifikation. Wer ihre Ausführungsregeln sauber verstanden hat, liest unterschiedliche GraphQL-Server mit demselben mentalen Modell.\n\nDer GraphQL-Überblick hat das Thema einmal in der Breite ausgelegt, vom Schema bis zur föderierten Architektur. Hier geht es eine Ebene tiefer, zu den Ideen hinter den Werkzeugen: Was ein Schema wirklich ist, warum eine Query aussieht wie ihre Antwort, und wie die Ausführung Feld für Feld durch den Baum wandert. Das klingt nach Grundlagen, aber gerade an ihnen entscheidet sich, ob man GraphQL beherrscht oder nur benutzt.\n\n## Das Schema ist ein typisierter Graph\n\nEin Schema wirkt beim ersten Lesen wie eine Sammlung von Datentypen. Das ist es auch, aber die entscheidende Eigenschaft steckt in den Beziehungen zwischen den Typen. Nehmen wir wieder ein Blog:\n\n```graphql\ntype Author {\n  id: ID!\n  name: String!\n  posts: [Post!]!\n}\n\ntype Post {\n  id: ID!\n  title: String!\n  author: Author!\n}\n```\n\n`Author` verweist auf `Post`, `Post` verweist zurück auf `Author`. Damit beschreibt das Schema keinen flachen Katalog, sondern einen Graphen: Die Typen sind Knoten, die Felder, die auf andere Typen zeigen, sind Kanten. Der Name ist wörtlich gemeint. Eine Query ist dann nichts anderes als ein Pfad durch diesen Graphen, den der Client selbst wählt.\n\nDie Modifizierer sind Teil des Vertrags, nicht Dekoration. `String!` heißt: Dieses Feld ist garantiert vorhanden. `[Post!]!` heißt: garantiert eine Liste, garantiert ohne Lücken darin. Ein Client kann sich darauf verlassen und muss nicht defensiv gegen `null` prüfen, wo das Schema `null` ausschließt. Umgekehrt zwingt ein nicht-nullbares Feld, das doch `null` liefert, einen Fehler herbei – die Garantie wird also durchgesetzt, nicht bloß versprochen.\n\nUnd dann gibt es die drei Wurzeltypen: `Query`, `Mutation` und `Subscription`. Sie sind die einzigen Türen in den Graphen. Alles, was ein Client tun kann, beginnt an einem Feld eines dieser drei Typen. Was dort nicht steht, existiert für den Client nicht. Das macht das Schema zu einem vollständigen, verbindlichen Vertrag – und zu einer der wenigen API-Beschreibungen, die zugleich Dokumentation, Validierungsregel und Typdefinition sind.\n\n## Eine Query ist eine Form\n\nDer zweite Gedanke ist einfach und trotzdem der, den Umsteiger am längsten überrascht: Eine GraphQL-Query hat dieselbe Gestalt wie ihre Antwort.\n\n```graphql\nquery {\n  author(id: \"1\") {\n    name\n    posts {\n      title\n    }\n  }\n}\n```\n\nDie Antwort darauf ist strukturgleich – ein `author` mit `name` und einer Liste `posts`, in der jeder Eintrag ein `title` hat. Man beschreibt nicht, wie Daten geholt werden, sondern welche Form man am Ende haben will. Das ist dieselbe deklarative Haltung, die auch anderswo in der modernen Frontend-Welt auftaucht: Man nennt das Ziel, nicht den Weg.\n\nDaraus folgt das bekannte Versprechen, Over- und Under-Fetching auf der Schnittstelle zu verringern. Der Client erhält nur die ausgewählten Felder und kann zusammenhängende Daten in einem Dokument anfordern. Das garantiert weder eine einzige Netzwerk-Runde unter jeder Transportarchitektur noch effiziente Zugriffe hinter den Resolvern. Der Server bekommt aber eine präzise Beschreibung des Bedarfs – eine gute Grundlage, um die Ausführung passend zu planen.\n\n## Das Resolver-Modell\n\nJetzt zur Ausführung. Für jedes Feld in der Query ruft der Server eine Funktion auf, den Resolver. Ein Resolver bekommt immer dieselben vier Argumente, in dieser Reihenfolge:\n\n```js\nfunction resolver(source, args, context, info) {\n  // source  – the parent object (the return value of the field above)\n  // args    – this field's arguments in the query\n  // context – a per-request object (e.g. the logged-in user, data access)\n  // info    – information about execution state (which field, which path)\n}\n```\n\nDas erste Argument ist der Schlüssel zum Verständnis der Kette. Der Rückgabewert eines Resolvers wird zum `source` für die Resolver der Felder darunter. Löst der Resolver für `author` einen Autor auf, bekommt der Resolver für dessen `posts` genau diesen Autor als `source` und kann darüber die Posts laden.\n\n```js\nconst resolvers = {\n  Query: {\n    author: (source, { id }, context) => context.db.loadAuthor(id),\n  },\n  Author: {\n    // source here is the author from the resolver above\n    posts: (author, args, context) => context.db.loadPostsForAuthor(author.id),\n  },\n  Post: {\n    // source here is a single post\n    author: (post, args, context) => context.db.loadAuthor(post.authorId),\n  },\n};\n```\n\nFür die meisten Felder schreibt man gar keinen Resolver. Wenn ein Feld `name` heißt und das Elternobjekt eine Eigenschaft `name` hat, greift der Standard-Resolver: Er liest die gleichnamige Eigenschaft aus dem `source`. Man schreibt Resolver also nur dort explizit, wo etwas zu tun ist – wo Daten geladen, berechnet oder umgeformt werden. Das hält die meisten Schemas erstaunlich schlank.\n\nDer `context` verdient einen eigenen Satz, weil an ihm viel hängt. Er wird einmal pro Anfrage gebaut und an jeden Resolver durchgereicht. Dort landet, wer die Anfrage stellt, und dort liegen die Zugänge zu den Datenquellen. Weil jeder Resolver denselben Kontext sieht, ist er der natürliche Ort für Authentifizierung und für geteilte Ressourcen – ohne dass man sie durch jede Funktionssignatur fädeln müsste.\n\n## Die Reihenfolge der Ausführung\n\nWie genau wandert die Ausführung durch den Baum? Sie folgt der Auswahl rekursiv von den Wurzelfeldern zu deren Unterfeldern. Ein Unterfeld kann erst aufgelöst werden, wenn der Wert seines Elternfelds vorliegt; unabhängige Geschwisterfelder dürfen dagegen parallel ausgewertet werden. Es ist also kein streng getakteter Breitendurchlauf Ebene für Ebene.\n\n```mermaid\nflowchart TB\n  Q[\"Query\"] --> A[\"author-Resolver<br/>liefert Autor\"]\n  A --> N[\"name<br/>(Standard-Resolver)\"]\n  A --> P[\"posts-Resolver<br/>liefert Liste\"]\n  P --> T1[\"title von Post 1\"]\n  P --> T2[\"title von Post 2\"]\n```\n\nZwei Feinheiten sind wichtig, weil sie im Alltag Konsequenzen haben. Erstens: Geschwisterfelder auf derselben Ebene löst der Server nebenläufig auf, nicht streng nacheinander. Gibt ein Resolver ein Promise zurück, wartet der Server es ab, aber er blockiert nicht die Geschwister. Deshalb dürfen Resolver keine Annahmen über ihre Ausführungsreihenfolge relativ zu Nachbarfeldern treffen – sie sollen unabhängig voneinander funktionieren.\n\nZweitens, und das ist die eine bewusste Ausnahme: Auf der obersten Ebene einer Mutation läuft die Ausführung seriell. Nennt eine Mutation mehrere Schreibfelder, führt der Server sie nacheinander in Dokumentreihenfolge aus. Das ersetzt weder Transaktionen noch Isolation. Bei Queries ist Freiheit von Side Effects eine wichtige Konvention, keine vom Ausführungsmodell erzwungene Garantie; deshalb darf der Server unabhängige Wurzelfelder parallel auswerten.\n\n## N+1 und warum Batching dazugehört\n\nAus dem Resolver-Modell folgt fast zwangsläufig ein Problem, das jeder GraphQL-Entwickler früher oder später trifft. Fragt jemand eine Liste von Autoren ab und zu jedem die Posts, läuft der `posts`-Resolver einmal pro Autor. Bei zehn Autoren sind das zehn Aufrufe – und wenn jeder eine eigene Datenbankabfrage macht, hat man zehn Queries plus die eine für die Autorenliste. Das ist das N+1-Problem: eine Query für die Liste, N weitere für die Kinder.\n\nDer Ausweg ist Batching. Statt sofort loszulaufen, sammelt man alle Anfragen, die innerhalb eines Ausführungstakts entstehen, und beantwortet sie mit einer einzigen Query. Die übliche Umsetzung ist die DataLoader-Bibliothek: Man fragt nicht mehr direkt die Datenbank, sondern einen Loader nach einer ID; der Loader wartet bis zum Ende des aktuellen Ticks, bündelt alle gesammelten IDs und löst sie in einem Rutsch auf. Nebenbei merkt er sich innerhalb der Anfrage, was er schon geladen hat, sodass dieselbe ID nicht zweimal geholt wird.\n\n```mermaid\nflowchart LR\n  R1[\"posts von Autor 1\"] --> L[\"DataLoader<br/>sammelt IDs im Tick\"]\n  R2[\"posts von Autor 2\"] --> L\n  R3[\"posts von Autor 3\"] --> L\n  L -->|eine gebündelte Abfrage| DB[(\"Datenbank\")]\n```\n\nBatching bügelt dabei keinen Designfehler aus. Es ist die konsequente Antwort auf die Natur des Resolver-Modells: Die feldweise Auflösung ist der Grund für die Flexibilität von GraphQL – und Batching ist der Preis und zugleich die saubere Lösung dafür. Ein Loader gehört in den `context`, damit er pro Anfrage frisch ist und sein Cache nicht über Anfragegrenzen hinweg bestehen bleibt.\n\n## Warum diese Konzepte bleiben\n\nMan kann GraphQL mit Apollo Server bauen, mit GraphQL Yoga, mit einer Bibliothek in C#, Go oder Python. Der Server wechselt, das Ökosystem verschiebt sich, die Spezifikation bekommt neue Fassungen. Aber das Schema bleibt ein typisierter Graph, die Query bleibt eine Form, die Resolver bleiben eine Kette mit denselben vier Argumenten, und die Ausführung bleibt ein Durchlauf von oben nach unten mit seriellen Mutationen und nebenläufigen Geschwistern.\n\nGenau deshalb lohnt es sich, diese Ebene zu verstehen und nicht nur die API eines bestimmten Servers. Wer weiß, was ein Resolver bekommt und wie die Ausführung durch den Baum läuft, kann N+1-Probleme gezielter eingrenzen, entwirft [Schemas, die mitwachsen](https://www.mikebild.dev/de/blog/graphql-schema-das-mitwaechst/), und findet sich in einem anderen Server schneller zurecht. Die Werkzeuge sind austauschbar. Die Konzepte sind es nicht.\n\n## Weiterführende Quellen\n\n- Repository introduction-graphql (Kapitel resolver, schema, subscriptions): https://github.com/mikebild/introduction-graphql\n- Folienübersicht zu diesem Themenkomplex: https://slides.mikebild.dev/introduction-graphql.html\n- GraphQL, Ausführung: https://graphql.org/learn/execution/\n- GraphQL, Schema und Typen: https://graphql.org/learn/schema/\n- GraphQL-Spezifikation, Execution: https://spec.graphql.org/\n- DataLoader (Batching und Caching pro Anfrage): https://github.com/graphql/dataloader"},{"slug":"appsync-dynamodb-graphql-api","title":"GraphQL-APIs mit AppSync und DynamoDB","summary":"AWS AppSync verschiebt den GraphQL-Server aus der eigenen Betriebsverantwortung in einen managed Dienst. Ich zeige am DynamoDB-Backend, wann diese Build-vs-Buy-Entscheidung trägt und wann managed zur Sackgasse wird, inklusive der VTL-Mapping-Templates, die 2021 der Preis dafür sind.","tags":["Software","AWS","Cloud"],"published_at":"2021-06-15T10:00:00+00:00","updated_at":"2026-07-13T10:02:55.07027+00:00","url":"https://www.mikebild.dev/de/blog/appsync-dynamodb-graphql-api/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/5714cf054303d183590a0d10cd1f87a4fdbe3b67369af1df0f9779cba71ffff8/appsync-dynamodb-graphql-api-vorlesen.mp3","audio_duration_secs":947,"text":"Wer ein GraphQL-Schema veröffentlicht, hat damit einen Serverbetrieb übernommen – auch wenn im Projektplan nur „Schema und Clients“ steht. Die Resolver müssen irgendwo laufen, die Verbindungen irgendwo enden, die Last irgendwo abgefangen werden. Diese Rechnung kommt nicht am Tag des Deployments, sie kommt jeden Monat danach.\n\nDie verbreiteten GraphQL-Einführungen enden vor dieser Rechnung. Typen definieren, Queries schreiben, Daten mit Apollo oder urql abholen: alles gut erklärt. Der Server mit den Resolvern, die Datenbank darunter, die Skalierung, die Real-time-Verbindungen – darüber schweigen sie. Wegdefinieren lässt sich diese Schicht trotzdem nicht. Jemand baut sie, deployt sie und hält sie im Betrieb.\n\nBleibt die Frage, wer dieses Jemand ist: das eigene Team oder ein managed Dienst. Ich mache die Antwort an einem konkreten Aufbau fest – AWS AppSync auf DynamoDB, so wie er Mitte 2021 tatsächlich aussieht, samt der VTL-Mapping-Templates, die beim ersten Kontakt gewöhnungsbedürftig sind. Der Code ist dabei nicht der Kern. Der Kern ist, was diese Entscheidung einbringt und was sie kostet.\n\n**Redaktionelle Einordnung (Juli 2026):** AppSync unterstützt heute neben VTL die JavaScript-basierte Resolver-Runtime `APPSYNC_JS`. VTL bleibt gültig, ist aber nicht mehr die einzige direkte Abbildung auf DynamoDB. Managed nimmt dem Team den GraphQL-Serverbetrieb ab; Schema, Resolver, Berechtigungen, Datenmodell, Deployment und Beobachtbarkeit bleiben eigene Arbeit.\n\n## Was AppSync eigentlich ist\n\nAppSync ist ein vollständig managed GraphQL-Layer. Der entscheidende Punkt: Man betreibt keinen eigenen GraphQL-Server. Es gibt keinen Node-Prozess mit Apollo Server, keinen Container, kein Hosting, keine Skalierungslogik, um die man sich kümmern müsste. Stattdessen lädt man ein Schema in der GraphQL Schema Definition Language (SDL) hoch und hängt an jedes Feld einen Resolver, der auf eine Data Source zeigt. AWS übernimmt den Rest.\n\nDiese Verschiebung ist der eigentliche Wert. In einem klassischen Setup ist der GraphQL-Server das Herzstück, das man selbst schreibt, deployt und betreibt. Bei AppSync liegt dieser Serverbetrieb bei AWS, während das Team die Abbildung zwischen Schema-Feld und Datenquelle sowie deren Infrastruktur beschreibt. Das ist der bekannte Serverless-Tausch: weniger eigener Betrieb gegen stärkere Bindung an den Dienst.\n\nEin Resolver verbindet dabei immer genau ein Schema-Feld mit einer Data Source. Und davon gibt es 2021 eine überschaubare, aber gut abgedeckte Auswahl:\n\n- DynamoDB-Tabellen als Key-Value- und Dokument-Store\n- AWS Lambda für beliebige Custom-Logik\n- HTTP-Endpoints, um bestehende REST-APIs anzubinden\n- Amazon Elasticsearch Service für Volltextsuche\n- relationale Datenbanken über RDS bzw. Aurora Serverless sowie „None“-Local-Resolver ohne externe Quelle\n\nFür diesen Beitrag konzentriere ich mich auf DynamoDB, weil das die häufigste Kombination in Serverless-Projekten ist und weil sich daran die VTL-Mechanik am besten zeigen lässt.\n\n## Der Request-Fluss\n\nBevor wir Code sehen, hilft ein Blick auf den Weg, den eine Anfrage nimmt. Ein Client schickt eine GraphQL-Query oder -Mutation an AppSync. AppSync prüft sie gegen das Schema und ruft für das angefragte Feld den passenden Resolver auf. Der Resolver hat zwei Teile: ein Request-Template, das die GraphQL-Anfrage in einen DynamoDB-Call übersetzt, und ein Response-Template, das das DynamoDB-Ergebnis zurück in GraphQL-Form bringt.\n\n```mermaid\nflowchart LR\n  Client[GraphQL Client] -->|Query / Mutation| AppSync[AppSync<br/>Schema + VTL Resolver]\n  AppSync -->|Request Template| DDB[(DynamoDB Table)]\n  DDB -->|Response Template| AppSync\n  AppSync -->|JSON Result| Client\n  AppSync -.->|other data sources| Lambda[AWS Lambda]\n  AppSync -.->|other data sources| HTTP[HTTP Endpoint]\n```\n\nDiese beiden Templates sind das Herzstück und zugleich der Teil, an dem sich 2021 die Geister scheiden. Sie sind nämlich nicht in JavaScript geschrieben, sondern in der Apache Velocity Template Language, kurz VTL.\n\n## VTL: die Sprache dazwischen\n\nVTL ist eine Template-Sprache, keine vollwertige Programmiersprache. Sie kann Werte einsetzen, einfache Bedingungen und Schleifen ausdrücken, aber sie ist bewusst schmal gehalten. Für die Übersetzung zwischen GraphQL-Argumenten und DynamoDB-Aufrufen reicht das in den meisten Fällen aus, und AWS liefert eine Reihe von Hilfsfunktionen mit, die die typischen Aufgaben abnehmen.\n\nNehmen wir ein kleines CMS als Beispiel. Wir wollen Blogposts anlegen, einzeln abrufen und bei neuen Posts in Echtzeit benachrichtigt werden. Das Schema dazu sieht so aus:\n\n```graphql\ntype Post {\n  id: ID!\n  title: String!\n  body: String!\n}\n\ntype Query {\n  getPost(id: ID!): Post\n}\n\ntype Mutation {\n  createPost(title: String!, body: String!): Post\n}\n\ntype Subscription {\n  onCreatePost: Post @aws_subscribe(mutations: [\"createPost\"])\n}\n```\n\nDas ist reines SDL, wie man es aus jeder GraphQL-Einführung kennt. Der einzige AppSync-spezifische Zusatz ist die Direktive `@aws_subscribe` auf dem Subscription-Feld, dazu später mehr. Zunächst zu den Resolvern für `createPost` und `getPost`.\n\n### Ein PutItem-Resolver\n\nWenn ein Client `createPost` aufruft, soll ein neues Item in der DynamoDB-Tabelle landen. Das Request-Template beschreibt genau diesen `PutItem`-Aufruf:\n\n```vtl\n{\n  \"version\": \"2017-02-28\",\n  \"operation\": \"PutItem\",\n  \"key\": {\n    \"id\": $util.dynamodb.toDynamoDBJson($util.autoId())\n  },\n  \"attributeValues\": $util.dynamodb.toMapValues($ctx.args)\n}\n```\n\nHier passiert einiges auf engem Raum. Der Versionsstring `\"2017-02-28\"` legt das Format des Templates fest. Das ist 2021 der Standardwert, den man ohne besonderen Grund nicht ändert. Das `operation`-Feld sagt DynamoDB, was zu tun ist. Der Key wird über `$util.autoId()` erzeugt, eine Hilfsfunktion, die eine UUID generiert, damit jeder Post einen eindeutigen Primärschlüssel bekommt.\n\nDer wichtigste Punkt versteckt sich in `$util.dynamodb.toDynamoDBJson(...)` und `$util.dynamodb.toMapValues(...)`. DynamoDB erwartet Werte nicht als nackte Strings oder Zahlen, sondern in einem typisierten Attribut-Format: ein String wird zu `{\"S\": \"...\"}`, eine Zahl zu `{\"N\": \"...\"}`. Diese Hilfsfunktionen übernehmen die Umwandlung automatisch. Wer sie vergisst und die Rohwerte direkt einsetzt, schreibt ein Item im falschen Format – der Fehler, über den beim Einstieg in AppSync fast alle einmal stolpern.\n\nDas Response-Template ist dagegen unspektakulär. Es gibt das geschriebene Item einfach als JSON zurück:\n\n```\n$util.toJson($ctx.result)\n```\n\n`$ctx`, auch als `$context` verfügbar, ist das Objekt, über das man an alles Relevante kommt: `$ctx.args` für die Argumente der GraphQL-Operation, `$ctx.identity` für Informationen zum aufrufenden Nutzer, `$ctx.result` für das Ergebnis der Data Source. Das Response-Template nutzt `$ctx.result`, um das eben geschriebene Item an den Client zurückzuspielen.\n\nNeben dem reinen Ergebnis liefert AppSync im Response-Template auch `$ctx.error`, falls die Data Source einen Fehler zurückgibt, etwa eine abgelehnte Bedingung bei einem `PutItem`. Wer das ignoriert, gibt im Fehlerfall stillschweigend `null` zurück, statt dem Client eine aussagekräftige Meldung zu geben. Mit `$util.error(...)` oder `$util.appendError(...)` lässt sich der Fehler kontrolliert an die GraphQL-Antwort durchreichen, sodass er im `errors`-Block der Response landet. Für die beiden schlichten Resolver hier ist das noch verzichtbar, aber sobald echte Bedingungen ins Spiel kommen, gehört diese Prüfung ins Template.\n\n### Ein GetItem-Resolver\n\nFür `getPost` ist die Sache noch schlichter. Das Request-Template beschreibt einen `GetItem`-Aufruf mit dem `id`-Argument als Key:\n\n```vtl\n{\n  \"version\": \"2017-02-28\",\n  \"operation\": \"GetItem\",\n  \"key\": {\n    \"id\": $util.dynamodb.toDynamoDBJson($ctx.args.id)\n  }\n}\n```\n\nAuch hier wandert der Wert aus `$ctx.args.id` durch `toDynamoDBJson`, damit er im richtigen Format bei DynamoDB ankommt. Das Response-Template ist identisch zum vorigen:\n\n```\n$util.toJson($ctx.result)\n```\n\nWenn man diese beiden Resolver nebeneinanderlegt, wird das Prinzip greifbar: Ein Resolver ist immer ein Paar aus Request- und Response-Template, das ein Schema-Feld mit einer konkreten DynamoDB-Operation verbindet. Mehr Magie steckt nicht dahinter.\n\nDie verfügbaren Operationen decken das übliche DynamoDB-Repertoire ab. Neben `GetItem` und `PutItem` stehen `UpdateItem` und `DeleteItem` bereit, dazu `Query` und `Scan` für Mehrfachtreffer sowie die Batch- und Transaktionsvarianten wie `BatchGetItem` oder `TransactWriteItems`. Für den Einstieg reichen die vier Einzeloperationen, und man wächst mit dem Datenmodell in die komplexeren Varianten hinein. Das Schöne daran: Das Muster bleibt immer gleich. Man ändert das `operation`-Feld, passt Key und Werte an, und der Aufbau des Templates verändert sich kaum. Diese Gleichförmigkeit ist es, die VTL trotz seiner Sperrigkeit im Alltag handhabbar macht.\n\n## Subscriptions ohne eigenen Aufwand\n\nDer Teil, der beim ersten Kontakt am meisten überrascht, sind die Real-time-Subscriptions. In einem selbstgebauten GraphQL-Server ist das der aufwendigste Teil überhaupt: WebSocket-Verbindungen verwalten, ein Pub/Sub-System betreiben, den Zustand über mehrere Serverinstanzen synchron halten. Bei AppSync ist davon nichts zu tun.\n\nErinnern wir uns an die Direktive aus dem Schema:\n\n```graphql\ntype Subscription {\n  onCreatePost: Post @aws_subscribe(mutations: [\"createPost\"])\n}\n```\n\nDiese eine Zeile genügt. `@aws_subscribe(mutations: [\"createPost\"])` bindet das Subscription-Feld `onCreatePost` an die Mutation `createPost`. Immer wenn diese Mutation erfolgreich durchläuft, schiebt AppSync das Ergebnis über eine WebSocket-Verbindung an alle Clients, die `onCreatePost` abonniert haben. Kein Polling, kein eigener Server, keine Verbindungsverwaltung.\n\n```mermaid\nsequenceDiagram\n  participant A as Client A\n  participant AS as AppSync\n  participant D as DynamoDB\n  participant B as Client B (subscribed)\n  A->>AS: mutation createPost\n  AS->>D: PutItem\n  D-->>AS: written item\n  AS-->>A: created Post\n  AS-->>B: onCreatePost (WebSocket push)\n```\n\nDie Verbindung läuft dabei über WebSockets, die AppSync von Haus aus bereitstellt. Der Client muss sich nur einmal auf `onCreatePost` subscriben und bekommt danach jeden neuen Post automatisch zugestellt. Für ein Live-Feed, ein Chat-Feature oder ein Dashboard ist das eine enorme Vereinfachung, weil der gesamte Real-time-Teil aus der eigenen Betriebsverantwortung verschwindet.\n\nEin Detail, das man dabei nicht übersehen darf: Ohne die `@aws_subscribe`-Direktive passiert schlicht nichts. Ich habe mehr als einmal erlebt, dass jemand eine Subscription definiert, den Client verbindet und sich dann wundert, warum keine Events ankommen. Die Antwort war jedes Mal dieselbe: die Direktive fehlte oder verwies auf die falsche Mutation. Das Subscription-Feld muss explizit an die auslösende Mutation gekoppelt sein.\n\n## Wo VTL an seine Grenzen kommt\n\nSo elegant der Ansatz ist, so klar sind seine Ränder. VTL ist bewusst keine Programmiersprache, und das merkt man, sobald die Logik komplexer wird. Verschachtelte Bedingungen, mehrstufige Transformationen, das Zusammenführen mehrerer Quellen: all das lässt sich in VTL zwar irgendwie ausdrücken, aber die Templates werden schnell unleserlich und schwer zu warten.\n\nDie Grenze verläuft dort, wo ein Template mehr tut, als Argumente umzusortieren und in einen DynamoDB-Call zu gießen. Im Stand von 2021 gehörte komplexere Logik in einen Lambda-Resolver. Heute ist vorher zu prüfen, ob ein `APPSYNC_JS`-Resolver die Abbildung lesbarer ausdrückt; für freie Laufzeitlogik und externe Bibliotheken bleibt Lambda die weitergehende Grenze. VTL eignet sich weiterhin für schlanke, direkte Abbildungen auf DynamoDB.\n\nDer zweite Rand betrifft nicht AppSync, sondern DynamoDB selbst. Die Frage, ob ein Zugriff eine `Query` oder ein `Scan` wird, entscheidet sich am Tabellendesign. Eine `Query` nutzt Partition- und Sort-Key oder einen Global Secondary Index und ist schnell und günstig. Ein `Scan` liest im Zweifel die ganze Tabelle und wird mit wachsendem Datenbestand teuer und langsam. Diese Access Patterns muss man vor dem ersten Schreiben durchdenken, denn DynamoDB verzeiht ein schlecht geplantes Key-Design nur ungern nachträglich. Das schöne AppSync-Frontend ändert nichts daran, dass darunter eine DynamoDB-Tabelle liegt, die ihre eigenen Regeln hat.\n\n## Managed oder selbstbetrieben: die eigentliche Entscheidung\n\nBis hierher ging es um Mechanik. Der Grund, warum ich AppSync in Projekten überhaupt aufrufe, liegt eine Ebene höher. Ein GraphQL-Server ist kein Feature, das man einmal schreibt und dann vergisst. Er ist eine dauerhafte Betriebsverpflichtung. Er muss laufen, skalieren, aktualisiert und überwacht werden, und bei Real-time-Verbindungen kommt der ganze Zustand über mehrere Instanzen hinweg dazu. Diese Kosten fallen nicht einmalig beim Bauen an, sondern jeden Monat im Betrieb.\n\nBuild-vs-Buy heißt an dieser Stelle konkret: Selbst betreiben bedeutet einen Node-Prozess mit Apollo Server, ein Hosting, eine Skalierungsstrategie, ein Pub/Sub-System für Subscriptions und die Bereitschaft, das alles im Ernstfall nachts zu reparieren. Managed bedeutet, dass ich Schema, Resolver und Data Sources deklariere und den Rest an AWS abgebe. Ich zahle dann nicht mehr in Betriebsstunden, sondern in Nutzung und in einem Stück Kontrolle, das ich aus der Hand gebe.\n\nWann trägt managed? Immer dann, wenn der GraphQL-Server für das Produkt kein Alleinstellungsmerkmal ist, sondern eine Notwendigkeit. Ein CRUD-Backend mit Real-time-Feed ist selten der Teil, mit dem sich ein Team vom Wettbewerb abhebt. Genau solche Standardschichten kauft man besser ein, weil jede selbstbetriebene Stunde dort fehlt, wo das Team tatsächlich Wert schafft. Kleine Teams ohne eigene Plattformmannschaft, Projekte mit schwankender Last, Prototypen, die schnell live müssen: In all diesen Fällen ist der Wegfall des Betriebs mehr wert als die abgegebene Kontrolle.\n\nWann wird managed zur Sackgasse? Sobald die Abbildung, die der Dienst anbietet, nicht mehr zum eigenen Problem passt. Bei AppSync 2021 ist VTL diese Abbildung. Solange ich Argumente in DynamoDB-Calls gieße, ist das ein guter Tausch. Sobald ich verschachtelte Logik, mehrstufige Transformationen oder die Zusammenführung mehrerer Quellen in Templates presse, zahle ich den managed Komfort mit Wartbarkeit zurück. Der zweite Sackgassen-Marker ist der Lock-in. Schema und Clients bleiben portabel, die VTL-Resolver und das DynamoDB-Key-Design sind es nicht. Wer damit rechnet, den Anbieter später zu wechseln, sollte diese Grenzschicht bewusst dünn halten.\n\nDamit lässt sich die Entscheidung auf wenige Kriterien eindampfen. Ist die Schicht Standard oder Differenzierung? Wie teuer ist der Betrieb im Verhältnis zur Teamgröße? Wie gut passt die angebotene Abbildung auf das eigene Problem, heute und in zwei Jahren? Und wie tief reicht der Lock-in, falls die Antwort einmal nicht mehr passt? Managed ist keine Glaubensfrage, sondern eine Rechnung, die man pro Schicht neu aufmacht. Bei AppSync fällt sie für einen großen Teil der Serverless-Projekte klar zugunsten von Buy aus, aber eben nicht für alle.\n\n## Was am Ende bleibt\n\nIch komme zum Anfang zurück, zur Frage nach dem Betrieb des Servers. Die verbreiteten GraphQL-Inhalte behandeln Schema-Design und Client-Seite. Das ist wichtig und bleibt wichtig, egal welches Backend darunter liegt. AppSync mit DynamoDB ist die andere Hälfte, das managed Backend, bei dem der selbstgeschriebene Server zu einer Konfiguration aus Schema, Resolvern und Data Sources zusammenschmilzt.\n\nDie Wirkung dieser Entscheidung ist konkret: Das Team deployt weiterhin Schema, Resolver und Infrastruktur, betreibt aber keinen eigenen GraphQL-Prozess und keine eigene WebSocket-Schicht. Dadurch bleibt mehr Zeit für Datenmodell und fachliche Logik. Ob die Rechnung aufgeht, hängt daran, wie gut die Resolver-Runtimes und Datenquellen zum Problem passen.\n\nDer Preis dafür sind die VTL-Templates und ein Stück Bindung an AWS. Beides ist überschaubar, solange man die Grenzen kennt: schlanke Abbildungen in VTL, komplexe Logik in Lambda, ein Key-Design, das die Access Patterns vorwegnimmt. Wo diese Grenzen halten, ist der Tausch hervorragend. Wo sie reißen, gehört die Schicht selbst gebaut. Diese Linie sauber zu ziehen ist die eigentliche Architekturarbeit, nicht das Schreiben der Templates.\n\nAppSync steht nicht für sich allein. Es greift mit Lambda, DynamoDB und den übrigen Managed Services ineinander, und gerade im Zusammenspiel entfaltet es seinen Wert.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-aws-serverless.html\n- Repository mit den Schema- und Resolver-Beispielen aus meinen Serverless-Workshops\n- [DynamoDB-Resolver-Referenz (AWS)](https://docs.aws.amazon.com/appsync/latest/devguide/resolver-mapping-template-reference-dynamodb.html)\n- [`APPSYNC_JS`-Resolver-Referenz (AWS)](https://docs.aws.amazon.com/appsync/latest/devguide/resolver-reference-js-version.html)\n- [Tutorial: Simple Post Application mit DynamoDB-Resolvern (AWS)](https://docs.aws.amazon.com/appsync/latest/devguide/tutorial-dynamodb-resolvers.html)\n- [Resolver-Mapping-Template-Overview (AWS)](https://docs.aws.amazon.com/appsync/latest/devguide/resolver-mapping-template-reference-overview.html)\n- [$util-Utility-Referenz (AWS)](https://docs.aws.amazon.com/appsync/latest/devguide/resolver-util-reference.html)\n- [AppSync FAQs zu Data Sources und Subscriptions (AWS)](https://aws.amazon.com/appsync/faqs/)"},{"slug":"context-richtig-einsetzen","title":"Context richtig einsetzen: Verteilweg statt heimlicher globaler State","summary":"Context löst das Verteilen von Daten durch den Baum – aber nicht die Frage, wem der State gehört. Wer beides verwechselt, baut einen globalen Store durch die Hintertür ein und erzeugt bei unbedacht wechselnden Provider-Werten unnötige Renderläufe.","tags":["Software","Frontend","React"],"published_at":"2021-05-26T10:00:00+00:00","updated_at":"2026-07-13T07:56:47.211196+00:00","url":"https://www.mikebild.dev/de/blog/context-richtig-einsetzen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/83ed4db87748b158c4d6913ed23044ba1c821c3a5d4172f534aafaa353ece9ee/context-richtig-einsetzen-vorlesen.mp3","audio_duration_secs":616,"text":"# Context richtig einsetzen: Verteilweg statt heimlicher globaler State\n\n```jsx\nconst AppContext = createContext(null);\n\nfunction AppProvider({ children }) {\n  const [user, setUser] = useState(null);\n  const [filter, setFilter] = useState(\"\");\n  const [items, setItems] = useState([]);\n\n  return (\n    <AppContext.Provider value={{ user, setUser, filter, setFilter, items, setItems }}>\n      {children}\n    </AppContext.Provider>\n  );\n}\n```\n\nDrei States hängen an einem einzigen Provider, und das Objekt hinter `value` entsteht bei jedem Rendern neu. Ein Zeichen im Filterfeld genügt, und alles, was diesen Context liest, rendert mit – auch der Header, der nur den Namen der angemeldeten Person anzeigt. Aus einer Abkürzung durch den Komponentenbaum ist ein Store geworden, ohne dass jemand diese Entscheidung bewusst getroffen hätte.\n\nContext kann genau eine Sache: einen Wert oben bereitstellen und weiter unten lesbar machen, ohne dass die Schichten dazwischen ihn durchreichen. Alles andere – wo der Wert entsteht, wer ihn fortschreibt, wie oft er wechselt – bleibt Handarbeit und muss vorher entschieden sein. Fangen wir bei dem Problem an, für das Context gebaut wurde.\n\n## Das Problem: prop drilling\n\nDaten fließen in React von oben nach unten. Eine Komponente reicht Werte über Props an ihre Kinder, die reichen sie an ihre Kinder weiter, und so fort. Solange die Strecke kurz ist, ist das genau richtig – der Datenfluss ist sichtbar, man liest an den Props ab, was eine Komponente braucht.\n\nUnangenehm wird es, wenn ein Wert weit oben entsteht und weit unten gebraucht wird, aber alles dazwischen ihn gar nicht braucht. Die angemeldete Person zum Beispiel: Sie wird einmal ganz oben ermittelt, gebraucht wird sie im Header, in einem Kommentarformular tief im Baum und in einem Menü ganz woanders. Alle Komponenten auf dem Weg dorthin müssen den Wert entgegennehmen und weiterreichen, obwohl sie ihn nur durchschleusen.\n\n```jsx\nfunction Page({ user }) {\n  return <Layout user={user} />;\n}\n\nfunction Layout({ user }) {\n  return <Sidebar user={user} />;\n}\n\nfunction Sidebar({ user }) {\n  return <Profile user={user} />;\n}\n\nfunction Profile({ user }) {\n  return <span>{user.name}</span>;\n}\n```\n\n`Layout` und `Sidebar` interessieren sich nicht für `user`. Sie tragen die Prop nur weiter, weil weiter unten jemand sie braucht. Genau das nennt man prop drilling – man bohrt einen Wert durch eine Schicht Komponenten, die mit ihm nichts zu tun haben. Es macht Zwischenkomponenten schwerer wiederverwendbar, weil sie an Daten gekoppelt sind, die sie nur durchreichen, und jede neue Zwischenschicht muss die Prop von Hand mitnehmen.\n\n```mermaid\nflowchart TB\n  subgraph drilling[\"prop drilling\"]\n    P1[\"Page (user)\"] --> L1[\"Layout<br/>reicht user nur weiter\"]\n    L1 --> S1[\"Sidebar<br/>reicht user nur weiter\"]\n    S1 --> Pr1[\"Profile<br/>braucht user\"]\n  end\n  subgraph context[\"Context\"]\n    P2[\"Provider (user)\"] -.-> Pr2[\"Profile<br/>liest per useContext\"]\n  end\n```\n\n## Wie Context funktioniert\n\nContext dreht dieses Durchreichen um. Statt einen Wert Schicht für Schicht weiterzugeben, stellt man ihn oben einmal bereit und liest ihn unten direkt aus – ohne die Zwischenschichten zu belästigen. Drei Teile gehören dazu: `createContext` erzeugt das Objekt, ein `Provider` stellt den Wert für einen Teilbaum bereit, und `useContext` liest ihn dort aus, wo er gebraucht wird.\n\n```jsx\nimport { createContext, useContext } from \"react\";\n\nconst CurrentUserContext = createContext(null);\n\nfunction Page({ user }) {\n  return (\n    <CurrentUserContext.Provider value={user}>\n      <Layout />\n    </CurrentUserContext.Provider>\n  );\n}\n\nfunction Profile() {\n  const user = useContext(CurrentUserContext);\n  return <span>{user.name}</span>;\n}\n```\n\n`Layout` und `Sidebar` sind aus dem Spiel – sie kennen `user` nicht mehr und müssen nichts weiterreichen. `Profile` greift sich den Wert direkt beim nächstgelegenen Provider über sich. Genau das ist die Aufgabe von Context, und für diese Aufgabe ist er das richtige Werkzeug: Er ist ein Verteilweg, eine Abkürzung durch den Baum.\n\nWichtig ist, was hier nicht passiert. Der Provider erzeugt den Wert nicht. Er bekommt ihn von oben hereingereicht und stellt ihn zur Verfügung. Wo `user` herkommt, wer ihn ändert, wo er liegt – all das ist an dieser Stelle noch völlig offen. Und genau hier fangen die Probleme an, wenn man nicht aufpasst.\n\n## Die zentrale Falle: Context als Ersatz für State-Besitz\n\nWeil Context Werte so bequem überall verfügbar macht, liegt eine Versuchung nahe: Man legt einfach alles hinein, was mehrere Komponenten brauchen. Die geladene Liste, den aktuellen Filter, halb geöffnete Dialoge, den gesamten Anwendungszustand – Context wird zum Ablageort für alles Geteilte. Das fühlt sich anfangs großartig an und rächt sich später zuverlässig.\n\nZwei Konsequenzen fallen einem auf die Füße. Die erste ist das Rendern. React vergleicht den alten und neuen Provider-Wert mit `Object.is`. Ist er verschieden, werden die Komponenten neu gerendert, die diesen Context lesen; bloße Nachfahren ohne `useContext` sind damit nicht automatisch gemeint. Bei einem großen Objekt als Context betrifft eine geänderte Referenz allerdings auch Consumer, die nur ein anderes Feld daraus verwenden. Aus einem kleinen Update kann so eine Renderwelle werden.\n\n```mermaid\nflowchart TB\n  Change[\"Context-Wert ändert sich\"] --> P[\"Provider\"]\n  P --> C1[\"Consumer A<br/>zeigt den Wert\"]\n  P --> C2[\"Consumer B<br/>liest ein anderes Feld –<br/>rendert trotzdem\"]\n  P --> C3[\"Consumer C<br/>liest ein anderes Feld –<br/>rendert trotzdem\"]\n```\n\nIn einem Projekt lag neben dem Theme beiläufig auch der Wert eines Suchfelds im selben Context. Bei jedem Tastendruck im Suchfeld flackerte die halbe Oberfläche – Komponenten, die mit der Suche nichts zu tun hatten, renderten mit, nur weil sie am selben Context hingen. Der Wert war im falschen Behälter gelandet.\n\nDie zweite Konsequenz ist die Kopplung. Alles, was aus einem Context liest, ist an diesen Provider gebunden. Man kann eine solche Komponente nicht mehr isoliert verwenden, testen oder an anderer Stelle einsetzen, ohne den passenden Provider drumherum aufzubauen. Je mehr in einem Context steckt, desto mehr Komponenten hängen an ihm, und desto zäher wird die Anwendung. Context macht die Herkunft eines Wertes unsichtbar – das ist beim Verteilen ein Vorteil und beim Besitz ein Problem, weil man nicht mehr sieht, wer eigentlich für einen Wert verantwortlich ist.\n\nDer Kern des Missverständnisses ist dieser: Context beantwortet die Frage „wie kommen die Daten dorthin, wo sie gebraucht werden“. Er beantwortet nicht die Frage „wem gehören die Daten und wer darf sie ändern“. Die zweite Frage – den State-Besitz – muss man weiterhin selbst beantworten, mit `useState` oder `useReducer` in einer klar benannten Komponente. Context ersetzt diese Entscheidung nicht, er transportiert nur ihr Ergebnis.\n\n## Regeln für den sauberen Einsatz\n\nAus dieser Trennung folgen drei Regeln, mit denen Context genau das bleibt, was er sein soll – ein Transportmittel.\n\nErstens: der Besitz liegt oben, Context reicht nur durch. Der State gehört in eine klar benannte Komponente – nennen wir sie `CurrentUserProvider` –, die ihn mit `useState` oder `useReducer` hält. Diese Komponente ist die eine Stelle, an der der Wert liegt und geändert wird. Context transportiert ihn von dort nach unten, mehr nicht. Wer den Provider aufmacht und `useState` sucht, findet den Besitzer sofort; wer ihn nicht findet, hat wahrscheinlich einen versteckten globalen State gebaut.\n\nZweitens: den Wert stabil halten. Hier steckt der häufigste konkrete Fehler. Schreibt man ein frisches Objekt direkt ins `value`, erzeugt jedes Rendern der Provider-Komponente eine neue Referenz – und weil sich die Referenz ändert, rendern alle Consumer neu, selbst wenn sich inhaltlich nichts geändert hat:\n\n```jsx\nfunction CurrentUserProvider({ children }) {\n  const [user, setUser] = useState(null);\n\n  // new object on every render → all consumers re-render\n  return (\n    <CurrentUserContext.Provider value={{ user, setUser }}>\n      {children}\n    </CurrentUserContext.Provider>\n  );\n}\n```\n\nDie Lösung ist, den Wert zu memoisieren, sodass die Referenz nur wechselt, wenn sich tatsächlich etwas geändert hat:\n\n```jsx\nfunction CurrentUserProvider({ children }) {\n  const [user, setUser] = useState(null);\n  const value = useMemo(() => ({ user, setUser }), [user]);\n\n  return (\n    <CurrentUserContext.Provider value={value}>\n      {children}\n    </CurrentUserContext.Provider>\n  );\n}\n```\n\nBei State aus `useReducer` lohnt oft ein zweiter Schritt: State und Dispatch in zwei getrennte Contexts legen. Der Dispatch ist stabil, er ändert sich nie. Trennt man ihn ab, rendern Komponenten, die nur Aktionen auslösen und den State gar nicht anzeigen, bei einer State-Änderung nicht mehr mit – sie hängen nur am immutable Dispatch-Context.\n\n```jsx\nconst StateContext = createContext(null);\nconst DispatchContext = createContext(null);\n\nfunction StoreProvider({ children }) {\n  const [state, dispatch] = useReducer(reducer, initialState);\n\n  return (\n    <StateContext.Provider value={state}>\n      <DispatchContext.Provider value={dispatch}>\n        {children}\n      </DispatchContext.Provider>\n    </StateContext.Provider>\n  );\n}\n```\n\nDrittens: nicht jeden veränderlichen Wert hineinlegen. Manches, was man reflexhaft in einen Context steckt, gehört anderswo hin. Serverdaten – geladene Listen, Detaildaten, Suchergebnisse – sind kein Anwendungszustand, sondern ein Cache, und der gehört in eine Data-Fetching-Bibliothek, nicht in Context. Diese Grenze habe ich in „Client State vs. Server-Cache“ ausführlich behandelt. Und die aktuelle Auswahl, der Filter, die Seite in einer Liste gehören in die URL – teilbar, mit Lesezeichen speicherbar, überlebt einen Reload. Zieht man diese beiden Kategorien ab, bleibt für Context erstaunlich wenig übrig, und genau dieser Rest ist sein eigentliches Zuhause.\n\n## Wofür Context wirklich gut ist\n\nContext spielt seine Stärke bei selten wechselnden, app-weiten Dingen aus – bei dem, was eher die Umgebung einer Anwendung beschreibt als ihre eigentlichen Daten. Das aktive Theme, die eingestellte Sprache, die angemeldete Person, eine app-weite Service- oder Dispatch-Funktion: Solche Werte ändern sich selten, werden aber an vielen Stellen gebraucht. Genau dafür ist ein Verteilweg gedacht, der die Zwischenschichten überspringt.\n\nDer gemeinsame Nenner dieser guten Fälle ist, dass sie „Umgebung“ sind und nicht „Anwendungsdaten“. Sie wechseln nicht im Sekundentakt, also fällt die Renderwelle kaum ins Gewicht. Und sie sind tatsächlich app-weit, also stört die Kopplung an einen Provider nicht – man will diese Komponenten ohnehin nur innerhalb der Anwendung verwenden. Vor jedem neuen Context lohnt darum eine Prüffrage: Beschreibt der Wert die Umgebung, in der eine Komponente läuft, oder die Daten, die sie anzeigt? Nur das Erste gehört hinein.\n\n## Context und Reducer als kleiner globaler Kern\n\nFür einen überschaubaren globalen Kern lassen sich Context und `useReducer` zu einem leichten Muster kombinieren. Der Reducer hält den State und die Logik an einer Stelle, zwei getrennte Contexts verteilen State und Dispatch, und die Consumer greifen über kleine Hooks zu:\n\n```jsx\nfunction useStore() {\n  return useContext(StateContext);\n}\n\nfunction useDispatch() {\n  return useContext(DispatchContext);\n}\n```\n\nDas reicht überraschend weit – für einen Warenkorb, ein paar app-weite Einstellungen, eine kleine Sammlung von Aktionen. Der Besitz ist klar (im `StoreProvider`), der Transport ist sauber (zwei Contexts), und die Trennung von State und Dispatch hält die Renderings im Zaum.\n\nMan sollte aber die Grenze dieses Musters kennen und offen benennen: Context ist keine State Management, er ist Transport. Der Reducer daneben ist die eigentliche Logik, und Context reicht nur dessen Ergebnis durch. Solange der Kern klein bleibt, trägt das Muster. Wächst die Logik – viele voneinander abhängige Teilzustände, selektives Lesen einzelner Felder ohne Renderwellen, Middleware, Persistenz, Zeitreise beim Debuggen –, stößt die Kombination an ihre Grenzen. Dann braucht es einen echten Store, der genau für diese Aufgaben gebaut ist. Das ist keine Niederlage von Context, sondern die richtige Arbeitsteilung: Context hat nie behauptet, ein Store zu sein.\n\n## Was bleiben soll\n\nContext beantwortet eine einzige Frage sehr gut: wie die Daten dorthin kommen, wo sie gebraucht werden. Er beantwortet die andere, wichtigere Frage nicht: wem sie gehören und wer sie ändert. Diese zweite Frage muss man selbst beantworten, in einer klar benannten Komponente mit `useState` oder `useReducer`, bevor man Context überhaupt anfasst.\n\nWer diese beiden Fragen sauber trennt, benutzt Context als das, was er ist – einen Verteilweg für wenige, selten wechselnde, app-weite Werte, mit stabilem `value` und, wo nötig, getrennten State- und Dispatch-Contexts. Wer sie vermischt, baut sich einen globalen State durch die Hintertür, samt Renderwellen und Kopplung. Der Unterschied kostet beim Schreiben eine einzige bewusste Entscheidung und erspart hinterher eine Menge Fehlersuche.\n\n## Weiterführende Quellen\n\n- Repository introduction-react: https://github.com/mikebild/introduction-react\n- react.dev, „Passing Data Deeply with Context“: https://react.dev/learn/passing-data-deeply-with-context\n- react.dev, „Scaling Up with Reducer and Context“: https://react.dev/learn/scaling-up-with-reducer-and-context\n- [react.dev: `useContext`, Wertvergleich und Re-Rendering](https://react.dev/reference/react/useContext)"},{"slug":"python-daten-einlesen","title":"Daten einlesen in Python: CSV, JSON, Excel und Datenbank","summary":"Ein Werkzeugkasten für jede Datenquelle: Wann die Standardbibliothek reicht, wann pandas lohnt, und wie man Encoding, Speicher und SQL-Injection nicht zum Verhängnis werden lässt.","tags":["Software","Python","Daten"],"published_at":"2021-04-27T10:00:00+00:00","updated_at":"2026-07-13T10:32:52.212694+00:00","url":"https://www.mikebild.dev/de/blog/python-daten-einlesen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/ebadcd9a409af7754516bb30cef1d50c01054977dd84d4c63f11d774f9393c81/python-daten-einlesen-vorlesen.mp3","audio_duration_secs":990,"text":"Ein Reader ist keine Transportfunktion. `csv.DictReader`, `json.load`, `pandas.read_excel` – sie alle machen aus fremden Bytes Python-Objekte, und in diesem Übergang entscheidet sich, ob ein `ä` ein `ä` bleibt, ob eine Pflichtspalte gefüllt ist und ob eine Eingabe später eine ganze Tabelle leert. Und die Quellen sind selten so aufgeräumt, wie man sie sich wünscht: eine CSV-Datei, die jemand aus einem Export gezogen hat. Eine JSON-Antwort von einer API. Eine Excel-Mappe mit drei Tabellenblättern und einer Kopfzeile, die erst in Zeile vier anfängt. Oder eine SQLite-Datei, die still im Projektordner liegt und darauf wartet, abgefragt zu werden.\n\nWelchen Reader ich dafür wähle, ist die kleinere Entscheidung. Die größere ist, an welcher Stelle Vertrauen entsteht: Stimmt das Encoding, sind Pflichtfelder gefüllt, passen die Typen? Ein falsch dekodierter Umlaut oder eine ungeprüfte Eingabe kostet beim Einlesen selten etwas – die Rechnung kommt später, im Report an den Kunden, in der Datenbank, im Modell, das auf Müll trainiert wurde. Das Einlesen ist damit die Grenze, an der sich Datenqualität entscheidet.\n\nDer Schritt ist unspektakulär, und trotzdem bleiben erstaunlich viele Projekte an ihm hängen. Schwer ist er nicht, nur unübersichtlich: Python hält für jedes Format einen eigenen Weg bereit, und welcher davon wann der richtige ist, verliert man schnell aus dem Blick. Dieser Artikel legt dafür einen kleinen Werkzeugkasten an: die Standardbibliothek für den direkten, zeilenweisen Zugriff, pandas für die tabellarische Analysearbeit, und eine parametrisierte Datenbankabfrage, die gar nicht erst in die häufigste Sicherheitsfalle führt. Die Werkzeuge sind schnell erklärt – interessanter ist die Haltung dahinter: Daten als Domäne zu behandeln, deren Qualität man aktiv verantwortet.\n\n## Daten sind eine Domäne, kein Nebenschauplatz\n\nBevor es um Reader geht, lohnt ein Schritt zurück. Wird das Einlesen als Transport verstanden – Bytes rein, Objekte raus, weiter im Text –, ist das eine teure Sicht, denn sie verschiebt das Vertrauen an die falsche Stelle. Prüfe ich erst spät – im Report, in der Auswertung, im produktiven Endpoint – dann prüfe ich Daten, die längst durch mein halbes System gewandert sind. Der Fehler ist dann nicht mehr eine Zeile in einer Datei, sondern ein Vorfall mit Namen: ein falscher Umsatz beim Kunden, ein Datensatz, der eine Auswertung kippt, eine Eingabe, die eine Tabelle leert.\n\nIch behandle das Einlesen deshalb als eigene kleine Domäne mit klarer Aufgabe: aus unbekannten Bytes vertrauenswürdige, typisierte Objekte machen – und alles zurückweisen, was das nicht erfüllt. Drei Fragen entscheiden dabei über das Produktrisiko, und alle drei fallen an der Systemgrenze an. Encoding: Ist ein Zeichen wirklich das gemeinte Zeichen? Ein `Ã¤` statt `ä` ist kein Schönheitsfehler. Es ist ein stiller Datenschaden, der sich durch jede spätere Suche, jeden Vergleich, jeden Export fortpflanzt. Vertrauen: Wem gehört die Quelle? Einen eigenen Export behandle ich anders als eine Nutzereingabe oder eine fremde API – bei allem von außen gilt die Annahme, dass es feindlich sein kann. Validierung: Sind die Felder da, und haben sie die erwartete Form?\n\nDie Abwägung liegt bei der Validierung, denn Prüfung kostet Zeit. Je weiter die Daten reisen und je mehr Menschen am Ende von ihnen abhängen, desto früher und strenger prüfe ich. Ein Wegwerf-Skript für eine einmalige Analyse darf großzügig sein. Ein Reader, der eine Kundendatenbank oder einen wiederkehrenden Report speist, prüft an der Grenze und weist Fehlerhaftes sichtbar zurück, statt es klaglos durchzureichen. Der Gewinn ist konkret: Ein Problem taucht dort auf, wo es entsteht, mit Datei und Zeile – statt drei Schichten später als rätselhaftes Fehlverhalten, das man rückwärts durch ein halbes System zurückverfolgen muss.\n\n## Die zwei Lager: Standardbibliothek oder pandas\n\nBevor wir Code schreiben, lohnt eine grobe Einordnung. Python bringt für die gängigsten Formate bereits alles mit, was du brauchst – `csv`, `json`, `xml.etree` und `sqlite3` sind Teil der Standardbibliothek, ohne dass du etwas installieren müsstest. Diese Module arbeiten nah an den Rohdaten: Du bekommst Listen, Dictionaries, einzelne Zeilen. Das ist ideal, wenn du eine Datei zeilenweise verarbeiten willst, wenig Speicher zur Verfügung hast oder schlicht keine externe Abhängigkeit in dein Projekt holen möchtest.\n\npandas gehört ins andere Lager. Es ist eine zusätzliche Bibliothek, und es lohnt sich immer dann, wenn deine Daten tabellarisch sind und du sie analysieren, filtern oder aggregieren willst. Statt für jedes Format ein eigenes mentales Modell zu pflegen, landest du in pandas immer im selben `DataFrame` – egal, ob die Quelle eine CSV, eine Excel-Mappe, ein JSON-Dokument oder eine SQL-Query war. `read_csv`, `read_excel`, `read_json`, `read_sql`: ein Reader je Format, ein gemeinsames Ziel. Dazu kommen Typinferenz und ein reichhaltiger Satz an Operationen.\n\nDer folgende Überblick zeigt den Weg von der Quelle zur Zielstruktur:\n\n```mermaid\nflowchart LR\n  CSV[CSV] --> STD[Standardbibliothek<br/>csv, json, etree, sqlite3]\n  JSON[JSON] --> STD\n  XML[XML] --> STD\n  DB[(SQLite/DB)] --> STD\n  CSV --> PD[pandas<br/>read_csv, read_excel, read_sql]\n  XLSX[Excel .xlsx] --> PD\n  DB --> PD\n  STD --> DICTS[dicts / Listen]\n  PD --> DF[DataFrame]\n```\n\nDie Einordnung, nach der ich entscheide, ist kurz: Zeilenweise, klein, ohne Zusatzabhängigkeit – Standardbibliothek. Tabellarisch, analytisch, mit Filtern und Aggregaten – pandas. Und Excel im modernen `.xlsx`-Format bekommst du ohnehin nur über pandas (mit einer Hilfsbibliothek) sinnvoll gelesen, denn die Standardbibliothek kann das nicht. Entscheidend ist nicht das bequemste API, sondern das Datenvolumen und die Arbeit, die danach mit den Daten passieren soll.\n\n## CSV mit der Standardbibliothek: der Klassiker\n\nCSV ist das Format, das dir am häufigsten begegnet, und gleichzeitig das mit den meisten kleinen Tücken. Die Standardbibliothek bietet zwei Wege: `csv.reader` liefert pro Zeile eine Liste, `csv.DictReader` liefert pro Zeile ein Dictionary und nutzt dafür die Kopfzeile automatisch als Schlüssel. In der Praxis greife ich fast immer zu `DictReader`, weil der Zugriff über sprechende Spaltennamen den Code lesbarer macht.\n\n```python\nimport csv\n\nwith open(\"customers.csv\", newline=\"\", encoding=\"utf-8\") as f:\n    reader = csv.DictReader(f)          # header row becomes dict keys\n    rows = [row for row in reader]      # each row is a dict\n\nfor row in rows[:3]:\n    print(row[\"name\"], row[\"email\"])\n```\n\nZwei Details an dieser `open`-Zeile sind wichtiger, als sie aussehen. Erstens `newline=\"\"`: Das `csv`-Modul kümmert sich selbst um Zeilenumbrüche innerhalb von Feldern, und wenn du die Datei nicht mit leerem `newline` öffnest, kann es unter bestimmten Bedingungen zu zerrissenen Zeilen kommen. Das steht so in der Dokumentation, und es ist eine dieser Regeln, die man einmal lernt und dann nie wieder vergisst – meist nach dem ersten kaputten Datensatz.\n\nZweitens `encoding=\"utf-8\"`. Dazu gleich mehr, denn hier steckt der Fallstrick, der in Projekten die meiste Verwirrung stiftet – kaum ein Datenfehler taucht so zuverlässig auf.\n\nEin schöner Side Effect von `DictReader`: Weil er die Datei als Iterator durchläuft, kannst du ihn auch für große Dateien nutzen, ohne alles auf einmal in den Speicher zu laden. Statt die Liste per Comprehension zu materialisieren, verarbeitest du dann Zeile für Zeile in der Schleife und behältst nur, was du wirklich brauchst.\n\n## JSON: von der Datei zum Python-Objekt\n\nJSON ist in Python angenehm unkompliziert, weil sich die Struktur fast eins zu eins auf Dictionaries und Listen abbildet. `json.load` liest aus einem geöffneten Datei-Objekt, `json.loads` aus einer Zeichenkette. Das Ergebnis ist ein ganz normales Python-Objekt, mit dem du sofort weiterarbeiten kannst.\n\n```python\nimport json\n\nwith open(\"config.json\", encoding=\"utf-8\") as f:\n    data = json.load(f)         # dict or list, depending on the document\n\nprint(data[\"version\"])\nfor item in data[\"items\"]:\n    print(item[\"id\"], item[\"title\"])\n```\n\nFür das Zurückschreiben gibt es die Gegenstücke `json.dump` und `json.dumps`. Beim Schreiben lohnt sich meist `ensure_ascii=False`, damit Umlaute als echte Zeichen und nicht als `\\u`-Escapes in der Datei stehen, sowie `indent=2` für eine lesbare Formatierung. Beim Lesen brauchst du diese Optionen nicht – da ist JSON erfreulich anspruchslos.\n\n## Excel: hier führt der Weg über pandas\n\nSobald echte Tabellen ins Spiel kommen – mehrere Blätter, Zahlenformate, Kopfzeilen – wird pandas zum Mittel der Wahl. `read_excel` liest ein `.xlsx` in einen DataFrame, und du kannst gezielt ein Tabellenblatt über `sheet_name` auswählen.\n\n```python\nimport pandas as pd\n\ndf = pd.read_excel(\"report.xlsx\", sheet_name=\"Sales\")  # openpyxl engine by default\nprint(df.head())\nprint(df[\"revenue\"].sum())\n```\n\nEin Punkt, über den viele beim ersten Mal stolpern: `read_excel` braucht für `.xlsx` eine zusätzliche Bibliothek namens `openpyxl`. Seit pandas 1.2.0 (Ende 2020) ist `openpyxl` der Standard-Engine für dieses Format – die ältere `xlrd`-Bibliothek unterstützt moderne `.xlsx`-Dateien nicht mehr und ist nur noch für die alten `.xls`-Dateien zuständig. Wenn du also eine Fehlermeldung über eine fehlende Engine bekommst, ist die Lösung fast immer ein `pip install openpyxl`.\n\nGibst du `sheet_name=None` an, liefert pandas dir ein Dictionary aller Tabellenblätter als DataFrames zurück – praktisch, wenn du eine Mappe komplett einlesen willst. Und wenn deine Kopfzeile nicht in der ersten Zeile steht, hilft der `header`-Parameter, die richtige Zeile als Spaltennamen zu setzen.\n\n## Datenbanken: sqlite3 und der wichtigste Sicherheitshinweis\n\nSQLite ist mein Standardwerkzeug für Datenbankzugriff in kleinen Projekten und Prototypen, weil es ohne Server auskommt und das Modul `sqlite3` bereits in der Standardbibliothek steckt. Der Ablauf ist immer gleich: Verbindung öffnen, einen Cursor holen, eine Query ausführen, das Ergebnis abholen, Verbindung schließen.\n\n```python\nimport sqlite3\n\nconn = sqlite3.connect(\"shop.db\")\ncur = conn.cursor()\n\ncategory = \"books\"\ncur.execute(\n    \"SELECT name, price FROM products WHERE category = ?\",\n    (category,),\n)\nresult = cur.fetchall()\n\nfor name, price in result:\n    print(name, price)\n\nconn.close()\n```\n\nDer entscheidende Teil steht in der `execute`-Zeile: das Fragezeichen als Platzhalter und der Wert als separates Tupel im zweiten Argument. Das ist keine Stilfrage, sondern eine Sicherheitsfrage. Wer stattdessen den Wert direkt in den SQL-String hineinbaut – per Konkatenation oder f-String – öffnet die Tür für SQL-Injection. Ein manipuliertes Eingabefeld kann dann die Query umschreiben, Daten auslesen oder ganze Tabellen löschen.\n\nDer Vergleich macht den Unterschied deutlich:\n\n```python\n# unsafe: never do this\ncur.execute(f\"SELECT name, price FROM products WHERE category = '{category}'\")\n\n# safe: the driver handles quoting and escaping\ncur.execute(\n    \"SELECT name, price FROM products WHERE category = ?\",\n    (category,),\n)\n```\n\nDer parametrisierte Weg hat noch einen zweiten Vorteil neben der Sicherheit: Der Datenbanktreiber übernimmt das Quoting und Escaping für dich. Du musst dir keine Gedanken um Anführungszeichen im Wert machen. Ein wichtiges Detail dabei: Der Platzhalter `?` steht **ohne** umschließende Anführungszeichen im SQL-Text – das Quoting macht der Treiber, nicht du.\n\nWenn du mehr Komfort willst oder zwischen verschiedenen Datenbanken wechseln möchtest, kommt SQLAlchemy ins Spiel. Mit `create_engine(\"sqlite:///shop.db\")` baust du eine Verbindung auf, und `pandas.read_sql(query, con)` liest das Ergebnis direkt in einen DataFrame. Damit schließt sich der Kreis zwischen Datenbank und Analyse: Die Query kommt aus SQL, das Ergebnis liegt sofort im vertrauten pandas-Modell.\n\n## Und XML? Nicht vergessen, aber selten gebraucht\n\nXML begegnet dir seltener als früher, taucht aber in Konfigurationen, Exporten und älteren Schnittstellen weiterhin auf. Die Standardbibliothek deckt das mit `xml.etree.ElementTree` ab. Du parst die Quelle zu einem Baum, holst dir die Wurzel und navigierst über `find`, `findall` und `iter` zu den Elementen, die dich interessieren.\n\n```python\nimport xml.etree.ElementTree as ET\n\ntree = ET.parse(\"catalog.xml\")\nroot = tree.getroot()\n\nfor product in root.findall(\"product\"):\n    name = product.find(\"name\").text\n    price = product.get(\"price\")        # read an attribute\n    print(name, price)\n```\n\nFür einfache Strukturen reicht das vollkommen. Bei komplexeren XML-Dokumenten mit Namespaces oder tiefer Verschachtelung wird es schnell fummelig – dann greife ich gern zu spezialisierten Bibliotheken. Für den Alltag deckt `ElementTree` aber die meisten Fälle ab.\n\n## Welcher Reader für welches Format?\n\nDamit du die Übersicht behältst, hier die Zuordnung als kompakte Liste:\n\n- Für CSV nimmst du `csv.DictReader` aus der Standardbibliothek zum zeilenweisen Lesen und `pandas.read_csv`, sobald es analytisch wird.\n- Für JSON reichen `json.load` und `json.loads` aus der Standardbibliothek; `pandas.read_json` hilft, wenn das JSON tabellarisch ist.\n- Für Excel im `.xlsx`-Format führt der Weg über `pandas.read_excel` mit installiertem `openpyxl` – die Standardbibliothek kann dieses Format nicht.\n- Für Datenbanken deckt `sqlite3` den direkten, parametrisierten Zugriff ab; `pandas.read_sql` oder SQLAlchemy kommen dazu, wenn Analyse oder Portabilität gefragt sind.\n- Für XML genügt `xml.etree.ElementTree` aus der Standardbibliothek bei überschaubaren Dokumenten.\n\n## Drei Fallstricke, die immer wieder zuschlagen\n\nIn der Praxis sind es selten die Reader selbst, die Probleme machen, sondern drei wiederkehrende Themen rund ums Einlesen.\n\n### Encoding\n\nWarum Encoding ein Produktrisiko ist, stand schon oben – hier der praktische Teil. Der Standard-Zeichensatz beim Öffnen einer Datei ist plattformabhängig, unter Windows oft `cp1252` statt UTF-8. Erscheinen Umlaute als `Ã¤` oder `Ã¶`, oder kommt ein `UnicodeDecodeError`, ist fast immer das Encoding schuld, und die Lösung ist, es explizit anzugeben: `encoding=\"utf-8\"`. Für Dateien, die aus Excel exportiert wurden, brauchst du oft `encoding=\"utf-8-sig\"`, weil Excel eine sogenannte Byte Order Mark an den Anfang setzt, die das schlichte `utf-8` als Sonderzeichen missdeutet.\n\n### Große Dateien komplett im Speicher\n\nEs ist verlockend, eine CSV einfach mit `pandas.read_csv(\"big.csv\")` komplett zu laden – bis die Datei mehrere Gigabyte hat und der Speicher überläuft. pandas bietet dafür `chunksize`: Statt eines DataFrames bekommst du dann einen Iterator, über den du die Datei in Blöcken abarbeitest.\n\n```python\nimport pandas as pd\n\ntotal = 0\nfor chunk in pd.read_csv(\"big.csv\", chunksize=100_000):\n    total += chunk[\"amount\"].sum()     # process one block at a time\n\nprint(total)\n```\n\nIn der Standardbibliothek erreichst du dasselbe, indem du über `DictReader` Zeile für Zeile iterierst, statt alles in eine Liste zu materialisieren. Ein Hinweis noch: `read_excel` kennt kein natives Chunking – bei sehr großen Tabellen lohnt es sich, die Daten vorher nach CSV oder in eine Datenbank zu überführen.\n\n### SQL-Injection\n\nWichtig genug für die Wiederholung: Baue niemals Benutzereingaben per String-Konkatenation oder f-String in eine SQL-Query ein. Nutze immer Platzhalter und übergib die Werte separat. Das kostet keine zusätzliche Zeile Code und erspart dir eine ganze Klasse von Sicherheitslücken.\n\nDer folgende Entscheidungsbaum fasst zusammen, wie ich in der Praxis den Weg wähle:\n\n```mermaid\nflowchart TD\n  A{Tabellarische<br/>Analyse nötig?} -->|ja| B[pandas]\n  A -->|nein| C{Zeilenweise,<br/>klein, ohne Abhängigkeit?}\n  C -->|ja| D[Standardbibliothek]\n  C -->|nein| B\n  B --> E{Sehr große Datei?}\n  E -->|ja| F[chunksize / Streaming]\n  E -->|nein| G[direkt einlesen]\n```\n\n## Fazit\n\nDie Werkzeugwahl ist schnell getroffen: Für zeilenweisen, direkten Zugriff ohne Zusatzabhängigkeit reicht die Standardbibliothek mit `csv`, `json`, `xml.etree` und `sqlite3`; sobald es tabellarisch und analytisch wird oder Excel im Spiel ist, zahlt sich pandas mit seinem einheitlichen DataFrame-Modell aus. Der Unterschied zwischen einem Skript, das läuft, und einem, dem man vertraut, entsteht nicht bei den Readern, sondern bei der Haltung: Encoding, Vertrauen und Validierung gehören an die Systemgrenze.\n\nWer diese Grenze bewusst zieht, verändert die Ökonomie des Projekts. Fehler tauchen früh und benannt auf, statt spät und rätselhaft – das verkürzt die Debugging-Zyklen und nimmt Reibung aus der Weiterarbeit. Die Daten, die durchkommen, sind die, denen man trauen kann, was die Qualität aller späteren Schritte anhebt. Und die drei Reflexe dazu kosten fast nichts: Encoding immer explizit setzen, damit Umlaute nicht zerfallen. Bei großen Dateien früh an `chunksize` oder zeilenweises Streamen denken, statt den Speicher vollzuladen. Datenbankabfragen ausnahmslos mit Platzhaltern, nie mit zusammengebautem SQL. Wichtig ist auch die klare Grenze nach oben: Nicht jedes Skript braucht volle Schema-Validierung, und wo Aufwand und Risiko nicht zusammenpassen, ist Großzügigkeit die richtige Wahl. Wer das entscheidet statt es dem Zufall zu überlassen, kommt schnell und sicher ans Ziel – und kann sich dem eigentlich Interessanten widmen: dem, was in den Daten steckt.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-python.html\n- Repository `introduction-python` – Beispiele zum Datenzugriff aus meinen Schulungen (`csv`, `json`, `sqlite3`, pandas).\n- csv – Python-Dokumentation: https://docs.python.org/3/library/csv.html\n- json – Python-Dokumentation: https://docs.python.org/3/library/json.html\n- sqlite3 – Python-Dokumentation: https://docs.python.org/3/library/sqlite3.html\n- xml.etree.ElementTree – Python-Dokumentation: https://docs.python.org/3/library/xml.etree.elementtree.html\n- pandas IO-Guide: https://pandas.pydata.org/docs/user_guide/io.html\n- pandas 1.2.0 – openpyxl als Default-Engine: https://pandas.pydata.org/docs/whatsnew/v1.2.0.html\n- SQLAlchemy-Dokumentation: https://docs.sqlalchemy.org/en/14/"},{"slug":"adr-entscheidungen-die-man-versteht","title":"Architecture Decision Records: Entscheidungen, die man in zwei Jahren noch versteht","summary":"Warum wir uns damals für PostgreSQL und gegen den Message-Bus entschieden haben? ADRs machen das Warum als versioniertes Markdown-Artefakt haltbar – statt es im Flurfunk versickern zu lassen.","tags":["Software","Architektur"],"published_at":"2021-03-26T10:00:00+00:00","updated_at":"2026-07-13T08:40:51.197927+00:00","url":"https://www.mikebild.dev/de/blog/adr-entscheidungen-die-man-versteht/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/8888fb4934ede6206b0c19ae9a3e2c5fb22820900259d449cc4f20153bf47f46/adr-entscheidungen-die-man-versteht-vorlesen.mp3","audio_duration_secs":703,"text":"Fünf Abschnitte reichen, um eine Architekturentscheidung haltbar zu machen: Title, Status, Context, Decision, Consequences. Michael Nygard hat dieses Format 2011 in seinem Blogpost „Documenting Architecture Decisions“ beschrieben und Architecture Decision Record genannt, kurz ADR – ein kleines, versioniertes Textartefakt, das genau eine Entscheidung samt ihrem Kräftefeld festhält. Eine Seite Markdown, mehr nicht.\n\nDer Anlass steht bei Nygard gleich im ersten Satz: „One of the hardest things to track during the life of a project is the motivation behind certain decisions.“ Das *Was* steht im Code. Das *Warum* steht nirgends – oder es steckt in einem Slack-Thread, einer Confluence-Seite, die keiner mehr findet, oder im Kopf von jemandem, der gerade Urlaub hat. Die teuerste Frage in einem Projekt ist deshalb selten „Wie bauen wir das?“, sondern „Warum haben wir das damals eigentlich so gebaut?“. Ein Team übernimmt ein System, stößt auf eine merkwürdige Entscheidung – eine ungewöhnliche Datenbank, ein selbstgebautes Caching, ein Verzicht auf den naheliegenden Message-Bus – und niemand kann sagen, ob das Absicht war oder ein Unfall. Die Leute, die es wussten, sind längst im nächsten Projekt oder in der nächsten Firma. Übrig bleibt Code, der eine Entscheidung ausführt, deren Begründung verdunstet ist.\n\n## Das Outcome zuerst: weniger Archäologie, schnellere Übergaben\n\nBevor ich über Format und Tooling rede, will ich sagen, was ADRs praktisch bringen – denn darum geht es, nicht um ein weiteres Dokumentationsritual. In den Projekten, in denen ich ADRs eingeführt habe, verkürzt sich vor allem eine Sache dramatisch: die Zeit, in der ein neues Teammitglied vom „Ich verstehe den Code, aber nicht die Logik dahinter“ zum „Ich kann hier mitentscheiden“ kommt. Onboarding wird von Ausgraben zu Nachlesen.\n\nDer zweite Effekt ist subtiler, aber wertvoller. Wenn eine Entscheidung aufgeschrieben werden *muss*, wird sie auch bewusster getroffen. Man merkt beim Schreiben, ob man wirklich Alternativen abgewogen hat oder nur den erstbesten Weg genommen hat. Das Artefakt zwingt nicht zu mehr Bürokratie – es zwingt zu Klarheit. Und Klarheit im Moment der Entscheidung ist billiger als jede spätere Rekonstruktion.\n\nWichtig ist mir dabei die Abgrenzung, die Nygard selbst macht: Agile Methoden sind nicht gegen Dokumentation, sie sind gegen *wertlose* Dokumentation. Das 80-seitige Architekturhandbuch, das schon beim ersten Commit veraltet, ist wertlos. Ein einseitiges ADR, das neben dem Code liegt und mit ihm durch die Git-History wandert, ist es nicht. ADRs sind der Leichtgewicht-Kompromiss zwischen „gar nichts aufschreiben“ und „alles totdokumentieren“.\n\n## Das Format: fünf Abschnitte, eine Entscheidung\n\nNygard beschreibt ADRs als „a format similar to an Alexandrian pattern“ – ein kurzer Text, der ein Kräftefeld beschreibt und *eine* Entscheidung als Antwort darauf festhält. Das Format ist bewusst minimal, und die Kürze ist kein Mangel, sondern das Feature: Kleine Dokumente werden eher aktuell gehalten als große.\n\nSo sieht ein ADR bei mir typischerweise aus. Es liegt als Markdown im selben Repository wie der Code, unter `docs/adr/`:\n\n```markdown\n# 7. Use PostgreSQL as primary datastore\n\nDate: 2021-03-12\n\n## Status\n\nAccepted   <!-- proposed | accepted | superseded by ADR-0012 -->\n\n## Context\n\nThe core service needs a transactional store. Expected load is\nmoderate (low thousands of writes per minute at peak). The team\nhas deep operational experience with PostgreSQL and none with\ndistributed NewSQL stores. Strong consistency is a hard requirement\nfor the billing path.\n\n## Decision\n\nWe will use PostgreSQL as the primary datastore for the core service.\n\n## Consequences\n\nPositive: strong consistency, mature tooling, no learning curve for the team.\nNegative: single-primary HA needs operational care; revisit this decision\nif sustained write throughput exceeds ~5k writes/second.\n```\n\nDer Context-Abschnitt ist der eigentliche Wert. Er beschreibt das, was Nygard „balancing of forces“ nennt – die Kräfte, die auf die Entscheidung wirken: erwartete Last, Team-Skills, Konsistenzgarantien, Kosten. Wer in zwei Jahren dieses ADR liest, sieht nicht nur, *dass* PostgreSQL gewählt wurde, sondern unter welchen Annahmen. Und – das ist entscheidend – der Consequences-Abschnitt nennt sogar die Bedingung, unter der man die Entscheidung neu bewerten sollte. Das ist kein Dogma, das ist ein Denkzustand, konserviert.\n\nEin Punkt daran wird oft übersehen: Der Context muss die Welt *zum Zeitpunkt der Entscheidung* beschreiben, nicht die Welt von heute. Ein ADR ist eine Momentaufnahme, kein Living Document. Das führt direkt zum wichtigsten Prinzip.\n\n## Immutability: ADRs werden nie überschrieben\n\nDer häufigste Fehler, den ich sehe, wenn Teams ADRs zum ersten Mal ausprobieren: Sie behandeln ein ADR wie eine Wiki-Seite und aktualisieren es, wenn sich die Entscheidung ändert. Das zerstört den ganzen Sinn. Ein ADR ist ein Log-Eintrag, kein Zustandsobjekt.\n\nDie Regel lautet: Ein einmal akzeptiertes ADR wird nie mehr inhaltlich verändert. Ändert sich die Entscheidung, schreibt man ein *neues* ADR, das das alte ablöst. Das alte bekommt den Status `superseded` und einen Verweis auf den Nachfolger. Die Nummerierung ist fortlaufend und wird nie wiederverwendet – 0001, 0002, 0003, auch wenn 0002 längst überholt ist. Was entsteht, ist ein Entscheidungs-*Log*: eine lückenlose Historie, die zeigt, welche Entscheidung wann galt und wodurch sie abgelöst wurde.\n\n```\ndocs/adr/          # sequential, zero-padded, never renumbered\n  0001-record-architecture-decisions.md\n  0002-use-markdown-for-adrs.md\n  0007-use-postgres-as-primary-datastore.md   # Status: superseded by 0012\n  0012-switch-to-cockroachdb.md               # Status: accepted\n```\n\nDiesen Lifecycle kann man gut als Zustandsdiagramm denken. Ein ADR wandert von `proposed` über `accepted` – und irgendwann, vielleicht, zu `superseded`, wobei der Zeiger auf das nachfolgende ADR verweist:\n\n```mermaid\nstateDiagram-v2\n    [*] --> proposed\n    proposed --> accepted: PR merged\n    proposed --> rejected: not adopted\n    accepted --> superseded: replaced by newer ADR\n    superseded --> [*]: points to successor ADR\n    rejected --> [*]\n```\n\nDer Charme daran ist, dass die Git-History und die ADR-History parallel laufen. Beide stecken im selben Repo, beide wandern durch dieselben Commits. Wenn ich einen alten Commit auschecke, sehe ich die ADRs, die zu diesem Stand gehörten. Das Warum reist mit dem Code durch die Zeit.\n\n## Flurfunk gegen Artefakt\n\nUm zu zeigen, was ADRs eigentlich verändern, hilft ein einfacher Kontrast. Auf der einen Seite steht der Normalzustand vieler Teams: Entscheidungen entstehen in Meetings, in Chat-Threads, im Vorbeigehen an der Kaffeemaschine. Sie sind real, sie prägen die Architektur – aber sie sind nirgends greifbar. Auf der anderen Seite steht das versionierte Markdown-Artefakt, das im Repo liegt und über den Pull-Request reviewt wird.\n\n```mermaid\nflowchart LR\n    subgraph Flurfunk\n        A[Slack thread] --> X[lost knowledge]\n        B[whiteboard photo] --> X\n        C[hallway talk] --> X\n        D[someone's memory] --> X\n    end\n    subgraph Artefakt\n        E[ADR in docs/adr/] --> G[reviewed via PR]\n        G --> H[versioned with the code]\n        H --> I[readable in two years]\n    end\n```\n\nDer Unterschied ist nicht, dass links keine Entscheidungen getroffen werden. Es werden dieselben Entscheidungen getroffen. Der Unterschied ist, ob die Begründung ein Artefakt wird oder verdunstet. Und das ist letztlich eine Frage der Reibung: Solange das Aufschreiben teurer ist als das Vergessen, wird vergessen. ADRs funktionieren, weil sie das Aufschreiben so billig machen – eine Markdown-Datei, ein Commit, fertig – dass es sich lohnt.\n\n## Tooling: gerade genug, um nicht im Weg zu stehen\n\nMan braucht für ADRs kein Werkzeug. Eine Textdatei und Git reichen. Trotzdem hilft ein bisschen Konvention, damit die Nummerierung und die Vorlage konsistent bleiben. Nat Pryce hat dafür `adr-tools` gebaut, ein schlankes CLI, das man sich per Homebrew installiert:\n\n```bash\n# create a new, auto-numbered ADR from the standard template\n$ adr new \"Use PostgreSQL as primary datastore\"\n# -> docs/adr/0007-use-postgres-as-primary-datastore.md\n\n# create an ADR that supersedes an earlier one\n$ adr new -s 7 \"Switch to CockroachDB\"\n# -> docs/adr/0012-switch-to-cockroachdb.md\n#    and sets ADR-0007 status to \"superseded by 0012\"\n```\n\nWer eine reichhaltigere Vorlage mag, findet mit MADR – „Markdown Any Decision Records“ – ein strukturierteres Template, das explizit Platz für abgewogene Optionen lässt. Für den Einstieg empfehle ich aber fast immer die nackten fünf Nygard-Abschnitte. Man kann später verfeinern; man kann schlecht nachträglich Disziplin einführen.\n\nDer wirkungsvollste Hebel ist ohnehin kein Tool, sondern eine Prozessgewohnheit: Eine architekturrelevante Änderung im Pull-Request wird nur gemergt, wenn das zugehörige ADR im selben PR liegt. Damit wird das ADR Teil des Reviews. Die Entscheidung wird nicht nur festgehalten, sondern im selben Moment von mindestens einer zweiten Person gegengelesen. Aus dem einsamen „Ich hab da mal was gebaut“ wird ein bewusster, geteilter Schritt.\n\nDass das Format tragfähig ist, ist übrigens keine Einzelmeinung. Der ThoughtWorks Technology Radar stufte „Lightweight Architecture Decision Records“ bereits im November 2017 auf *Adopt* – die höchste Empfehlungsstufe. Das ist keine Neuheit mehr, das ist etablierte Praxis.\n\n## Wo ADRs kippen – das ehrliche Kapitel\n\nADRs sind kein Selbstläufer. Sie scheitern, und zwar auf drei vorhersehbare Arten.\n\nDer erste Fehlerweg: ADRs veralten trotzdem. Wer sie nicht diszipliniert bei jeder relevanten Entscheidung schreibt, bekommt eine lückenhafte Historie. Und eine lückenhafte Historie ist gefährlicher als gar keine, weil sie Vollständigkeit vortäuscht. Wenn jemand liest „hier stehen unsere Entscheidungen“ und die entscheidende fehlt, zieht er falsche Schlüsse. Er verlässt sich auf ein Log, das lügt – nicht durch Falsches, sondern durch Weglassen. Disziplin schlägt Tooling, immer. Kein CLI und kein Template ersetzt die simple Gewohnheit, im Moment der Entscheidung kurz innezuhalten und sie festzuhalten.\n\nDer zweite Fehlerweg: zu groß, zu spät. Sobald ADRs zu Design-Dokumenten anschwellen, sinkt die Aktualisierungsquote, und Nygards ganzer Punkt – die Kürze – geht verloren. Besonders tückisch sind post-hoc geschriebene ADRs, also Begründungen, die man Wochen nach der Entscheidung nachreicht. Sie rationalisieren nur, statt das echte Kräftefeld einzufangen. Man schreibt dann nicht mehr auf, *warum* man sich entschieden hat, sondern warum die Entscheidung im Nachhinein gut aussieht. Das ist Selbstbetrug in Markdown – und er ist schwer zu entdecken, weil ein sauber formuliertes ADR überzeugend wirkt, ganz gleich, ob es die echten Kräfte einfängt oder nur eine hübsche Fassade davor baut.\n\nDer dritte Fehlerweg: falsche Granularität. Ohne definierten Auslöser passiert eins von zwei Dingen. Entweder wird jede Nichtigkeit ein ADR, dann ertrinkt das Signal im Rauschen. Oder niemand weiß, was „architekturrelevant“ heißt, dann wird gar nichts geschrieben. Eine grobe Schwelle, früh im Team vereinbart, hilft: Ein ADR entsteht, wenn eine Entscheidung schwer umkehrbar ist, mehrere Teams betrifft oder eine naheliegende Alternative bewusst verwirft. Alles andere ist ein Kommentar im Code, kein ADR. Diese Schwelle darf ruhig unscharf bleiben; wichtiger als die exakte Grenze ist, dass das Team überhaupt eine gemeinsame Vorstellung davon teilt.\n\nEin Gedanke noch, der ADRs über reine Dokumentation hinaushebt: Wer die Bedingung im Consequences-Abschnitt ernst nimmt – „revisit if write throughput exceeds 5k/s“ –, kann sie überprüfbar machen. In *Building Evolutionary Architecture* nennen Ford, Parsons und Kua das *fitness functions*: automatisierte Checks, die eine Architektureigenschaft messen. Ein ADR dokumentiert die Entscheidung, eine Fitness Function wacht darüber, dass ihre Annahme noch gilt. Beides zusammen macht aus einem toten Dokument einen lebenden Wächter.\n\n## Fazit\n\nADRs lösen kein technisches Problem. Sie lösen ein Gedächtnisproblem – und Gedächtnisprobleme sind auf lange Sicht teurer als technische, weil sie sich verzinsen. Jede undokumentierte Entscheidung wird mit der Zeit zu einer kleinen Schuld: Irgendwann muss jemand mit Mühe rekonstruieren, was man in fünf Minuten hätte aufschreiben können.\n\nDer Kern ist unspektakulär und genau deshalb tragfähig: In meiner Konvention bleibt ein angenommener Text unverändert; eine neue Entscheidung ersetzt ihn mit Verweis auf das alte ADR. So bleiben Kontext, Entscheidung und Konsequenzen versioniert neben dem Code und über den Pull-Request reviewbar. Dafür braucht es kein neues System, sondern die Disziplin, das Warum festzuhalten, solange es noch frisch ist.\n\nDer einfachste Anfang ist das erste ADR selbst – jenes, das festhält, dass ab jetzt ADRs geschrieben werden. `0001-record-architecture-decisions.md`. Alles Weitere ergibt sich, wenn die Gewohnheit einmal da ist. Und in zwei Jahren liest das jemand, den heute noch niemand im Team kennt.\n\n## Weiterführende Quellen\n\n- Michael Nygard, „Documenting Architecture Decisions“ (Cognitect, 2011): https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions.html\n- Martin Fowler, bliki „Architecture Decision Record“: https://martinfowler.com/bliki/ArchitectureDecisionRecord.html\n- ThoughtWorks Technology Radar, „Lightweight Architecture Decision Records“ (Adopt, Nov 2017): https://www.thoughtworks.com/radar/techniques/lightweight-architecture-decision-records\n- adr.github.io – Community-Sammlung zu ADRs und Tooling: https://adr.github.io/"},{"slug":"graphql-schema-das-mitwaechst","title":"GraphQL-Schema, das mitwächst: Evolution ohne Bruch","summary":"Wie man ein GraphQL-Schema additiv weiterentwickelt, statt es zu versionieren – sichere Änderungen, echte Breaking Changes, @deprecated als Werkzeug und eine Feld-Migration Schritt für Schritt.","tags":["Software","API","GraphQL"],"published_at":"2021-03-24T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/graphql-schema-das-mitwaechst/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/fa3b8abe81f20e17d76756b2e2154c95f81f15dd3f6ebf1441445cfc5bd2519f/graphql-schema-das-mitwaechst-vorlesen.mp3","audio_duration_secs":810,"text":"# GraphQL-Schema, das mitwächst: Evolution ohne Bruch\n\nEin Schema bricht nicht daran, dass es wächst, sondern daran, dass es etwas zurücknimmt. Ein Feld mehr stört keinen laufenden Client; ein Feld weniger, ein umbenanntes Argument, eine zurückgezogene Garantie legen ihn still. Auf dieser einen Unterscheidung – ergänzen oder zurücknehmen – ruht die gesamte Schema-Evolution. Im Alltag ist sie nur weniger eindeutig, als sie klingt: Manches sieht nach Rücknahme aus und ist völlig harmlos, manches sieht rein additiv aus und bricht trotzdem.\n\nDer typisierte Vertrag zwischen Server und Client, den ich im GraphQL-Überblick und in den GraphQL-Konzepten beschrieben habe, ist in dem Moment einfach, in dem er festgeschrieben wird. Schwierig wird er im laufenden Betrieb: Ein neues Feld muss rein, ein altes ist überflüssig geworden, eine Signatur passt nicht mehr. Draußen laufen dabei Clients, die man teils nicht einmal kennt – Web, Mobil, ein Partner-Backend, eine App-Version von vor achtzehn Monaten, die noch im Feld ist. Ein GraphQL-Schema zu entwerfen, bekommt fast jeder hin. Es über Jahre zu pflegen, ohne bestehende Clients zu brechen, ist die eigentliche Disziplin.\n\n## Kein /v2, sondern ein Schema\n\nWer von REST kommt, hat einen Reflex: Für eine größere Änderung baut man `/v2`. Die alte URL bleibt, die neue läuft daneben, irgendwann schaltet man `/v1` ab. Das funktioniert, aber es kostet – jede Version ist Code, den jemand baut, testet, betreibt und dokumentiert, oft jahrelang parallel.\n\nGraphQL geht einen anderen Weg. Es gibt keine Versionsnummer in der URL, sondern genau ein Schema, das sich additiv weiterentwickelt. Der Grund dafür steckt in einer Eigenschaft, die ich in den Konzepten schon beschrieben habe: Ein Client bekommt nur, was er in seiner Query nennt. Er fragt `name` und `title`, also bekommt er `name` und `title` – und sonst nichts. Kommt morgen ein Feld `subtitle` ins Schema, merkt dieser Client es nicht einmal. Er hat es nie genannt, also taucht es in seiner Antwort nicht auf, und seine Antwort behält exakt dieselbe Form wie gestern.\n\nDas ist der Hebel. Weil der Client die Form seiner Antwort selbst bestimmt, stören ihn zusätzliche Felder im Schema grundsätzlich nicht. Man kann das Schema also wachsen lassen, ohne dass jede Ergänzung ein Event ist. Die offizielle Doku bringt es auf den Punkt: GraphQL nimmt eine klare Haltung gegen Versionierung ein und stellt stattdessen Werkzeuge für kontinuierliche Weiterentwicklung bereit.\n\n## Sichere Änderungen: das, was man einfach hinzufügen darf\n\nEine ganze Klasse von Änderungen ist von Natur aus ungefährlich, weil sie nur ergänzt und nie etwas wegnimmt, worauf ein Client sich verlässt. Ein neues Feld an einem bestehenden Typ bemerkt niemand, der es nicht abfragt. Ein neuer Typ oder ein neuer Query- beziehungsweise Mutation-Einstieg ist reines Angebot, keine Pflicht. Ein neues optionales Argument an einem bestehenden Feld lässt jeden, der es weglässt, beim alten Verhalten – vorausgesetzt, der Default entspricht dem, was vorher galt. Und ein neuer Enum-Wert ist ebenfalls additiv, allerdings mit einer Einschränkung, auf die ich gleich zurückkomme, denn hier lauert eine Falle.\n\nDer gemeinsame Nenner: Bestehende Queries liefern nach der Änderung dasselbe Ergebnis wie vorher. Das ist die eigentliche Definition von „nicht brechend“ – nicht, dass sich nichts ändert, sondern dass keine gültige Query von gestern heute plötzlich ungültig wird oder eine andere Form zurückgibt.\n\n## Breaking Changes: das, was Clients wirklich zerreißt\n\nAuf der anderen Seite stehen die Änderungen, die eine gestern gültige Query heute brechen. Die wichtigsten, mit konkreten Beispielen:\n\nAm offensichtlichsten ist es, ein Feld zu entfernen oder umzubenennen. Fragt ein Client `user { fullName }` und ich benenne `fullName` in `displayName` um, ist die Query sofort ungültig. Umbenennen ist aus Schema-Sicht nichts anderes als Löschen plus Hinzufügen – der alte Name verschwindet, und jede Query, die ihn nennt, läuft in einen Validierungsfehler.\n\nSubtiler, und regelmäßig für Überraschung gut, ist die Nullability in die falsche Richtung. Ein nullbares Feld auf non-null zu ziehen ist *nicht* brechend: Wer bisher mit einem möglichen `null` gerechnet hat, kommt auch damit klar, dass jetzt immer ein Wert kommt. Umgekehrt aber – ein non-null-Feld nullbar machen – *ist* brechend. Ein Client, der sich auf die Garantie `String!` verlassen und nie gegen `null` geprüft hat, bekommt plötzlich ein `null`, mit dem er nicht rechnet. Dieselbe Logik gilt für Argumente, nur spiegelverkehrt: Ein Pflichtargument optional zu machen ist harmlos, ein optionales zur Pflicht zu erheben bricht jeden, der es bisher weggelassen hat.\n\nAuch ein geänderter Argument-Typ oder ein neues Pflichtargument bricht. Wird aus einem `String`-Argument ein `Int`, passen bestehende Queries nicht mehr. Und ein neues *nicht-optionales* Argument macht jede Query ungültig, die es noch nicht mitliefert – deshalb oben die Betonung auf *optional*.\n\nUnd einen Enum-Wert zu entfernen bricht ebenso: Verschwindet ein Wert, den ein Client als Argument sendet oder in einer Antwort erwartet, bricht die Interaktion.\n\n```mermaid\nflowchart LR\n  subgraph Additiv[\"Sicher – additiv\"]\n    A1[\"Feld hinzufügen\"]\n    A2[\"Typ hinzufügen\"]\n    A3[\"optionales Argument\"]\n    A4[\"nullable → non-null\"]\n  end\n  subgraph Breaking[\"Bricht Clients\"]\n    B1[\"Feld entfernen/umbenennen\"]\n    B2[\"non-null → nullable\"]\n    B3[\"Pflichtargument hinzufügen\"]\n    B4[\"Enum-Wert entfernen\"]\n  end\n```\n\nDer Trick beim Lesen dieser Liste: Es geht nie um „mehr“ oder „weniger“, sondern immer um dieselbe Frage – kann eine gestern gültige Query nach der Änderung noch gültig laufen und dieselbe Form zurückgeben? Wenn ja, ist die Änderung additiv. Wenn nein, ist sie brechend, egal wie klein sie aussieht.\n\n## @deprecated: veralten statt entfernen\n\nWeil das Entfernen bricht, braucht man einen Weg, ein Feld loszuwerden, *ohne* es sofort zu entfernen. Genau dafür gibt es in GraphQL eine eingebaute Direktive: `@deprecated`.\n\n```graphql\ntype User {\n  id: ID!\n  fullName: String @deprecated(reason: \"Use `displayName` instead. Removal planned for a future release.\")\n  displayName: String!\n}\n```\n\nDas Feld bleibt voll funktionsfähig – jede Query, die `fullName` nennt, bekommt weiterhin eine Antwort. Aber das Schema trägt jetzt eine sichtbare Markierung. Über Introspection landet dieser Hinweis in jedem Tooling: Der GraphiQL-Explorer streicht das Feld durch, Editor-Plugins warnen beim Tippen, Codegen und Linter melden die Nutzung. Der `reason`-Text ist dabei die eigentliche Botschaft an das Team am anderen Ende – idealerweise mit Nachfolger *und* Frist. Client-Teams sehen den Hinweis, migrieren auf `displayName`, und erst viel später, wenn niemand `fullName` mehr abfragt, entferne ich es.\n\nMan kann übrigens nicht nur Felder deprecaten, sondern auch einzelne Enum-Werte und Argumente. Das Muster ist überall dasselbe: erst markieren, Zeit geben, dann entfernen. Damit ist Deprecation das wichtigste Werkzeug der Schema-Evolution überhaupt.\n\n## Nullability ist eine Design-Entscheidung, kein Reflex\n\nEin Punkt verdient mehr als eine Zeile, weil er beim Entwurf oft falsch entschieden wird und sich später kaum korrigieren lässt: die Frage, ob ein Feld non-null sein soll.\n\n`String!` fühlt sich sauber an. Es sagt: Hier kommt immer ein Wert. Aber non-null ist eine Garantie, und Garantien kann man in einem laufenden Vertrag nicht mehr zurücknehmen – der Weg von non-null zurück zu nullable ist, wie gesehen, ein Breaking Change. Man legt sich also fest, und zwar dauerhaft.\n\nSchwerer wiegt, was zur Laufzeit passiert, wenn die Garantie einmal nicht hält. Fällt der Resolver eines non-null-Feldes aus – die Datenbank hakt, ein Upstream-Service liefert nichts – dann *muss* GraphQL ein `null` verhindern, wo `null` verboten ist. Es kann den Wert nicht einfach durchlassen. Also lässt es das `null` nach oben durchschlagen: Der Fehler steigt zum nächsten nullbaren Elternfeld auf und macht dort alles `null`. Ein einziger fehlgeschlagener non-null-Resolver kann so einen ganzen Antwortpfad mitreißen, bis hoch zu einer Stelle, die `null` verträgt. Aus einem lokalen Problem wird ein großflächiger Ausfall in der Antwort.\n\nIm Zweifel also nullable beginnen. Non-null nur dort, wo die Garantie wirklich trägt – bei einer `id`, bei einem Feld, das strukturell nie fehlen kann. Für alles, was von einem externen System, einer optionalen Relation oder einer Berechnung abhängt, die scheitern darf, ist nullable die ehrlichere und robustere Wahl. Man kann später jederzeit von nullable auf non-null verschärfen, wenn sich die Garantie als stabil erwiesen hat. Den umgekehrten Weg hat man nicht.\n\n## Input-Typen: Mutationen zukunftssicher schneiden\n\nAdditive Evolution beginnt schon beim Entwurf, nirgends deutlicher als bei Mutationen. Wer eine Mutation mit einer Handvoll Einzelargumenten baut, verbaut sich die Zukunft:\n\n```graphql\ntype Mutation {\n  createPost(title: String!, body: String!, authorId: ID!): Post!\n}\n```\n\nKommt später ein Feld dazu – eine Kategorie, ein Veröffentlichungsdatum – wächst die Argumentliste, und jede Ergänzung, die nicht optional ist, bricht bestehende Aufrufer. Die Signatur ist starr.\n\nDeshalb kapselt man die Eingabe in einen eigenen `input`-Typ:\n\n```graphql\ninput CreatePostInput {\n  title: String!\n  body: String!\n  authorId: ID!\n}\n\ntype Mutation {\n  createPost(input: CreatePostInput!): Post!\n}\n```\n\nDie Mutation nimmt jetzt genau ein Argument, `input`. Neue Eingabefelder ergänzt man additiv im `input`-Typ – als *optionale* Felder, damit bestehende Aufrufer, die sie nicht setzen, gültig bleiben. Die Signatur der Mutation selbst rührt sich nie wieder. Dasselbe Prinzip gilt auf der Ausgabeseite: Ein eigener Payload-Typ statt eines nackten `Post!` gibt Raum, später etwa ein Fehler- oder Metadatenfeld zu ergänzen, ohne die Rückgabe umzubauen. Ein bisschen mehr Struktur am Anfang, dafür jahrelange additive Bewegungsfreiheit.\n\n## Enums vorsichtig erweitern\n\nZurück zu der Falle, die ich bei den sicheren Änderungen offengelassen habe. Einen Enum-Wert *hinzuzufügen* ist auf der Serverseite additiv – das Schema wird nur reicher. Der Bruch passiert woanders, nämlich im Client-Code.\n\nViele Clients behandeln Enum-Werte in einem `switch` oder einer `if`-Kette und decken genau die Werte ab, die es beim Bau gab. Kommt ein neuer Wert über die Leitung, fällt so ein Client in kein Muster – im besten Fall zeigt er nichts an, im schlechteren wirft er einen Fehler. Aus einer sauber-additiven Schema-Änderung wird so ein realer Bruch beim Nutzer.\n\nDie Lösung liegt nicht im Schema, sondern in der Client-Disziplin: Jeder Verbraucher eines Enums sollte einen Default- oder Fallback-Zweig für unbekannte Werte haben. Genau darauf weisen die GraphQL Best Practices ausdrücklich hin – Clients sollen sich so verhalten, dass ein künftiger, heute noch unbekannter Enum-Wert nichts zum Absturz bringt. Wer das von Anfang an einplant, darf Enums später bedenkenlos erweitern.\n\n## Eine Feld-Migration Schritt für Schritt\n\nFühren wir alles an dem Beispiel zusammen, das oben schon anklang: `fullName` soll `displayName` weichen. So läuft die Migration sauber ab, ohne dass zu irgendeinem Zeitpunkt ein Client bricht.\n\n```mermaid\nflowchart TB\n  S1[\"1 – displayName additiv<br/>hinzufügen\"] --> S2[\"2 – fullName mit<br/>@deprecated markieren\"]\n  S2 --> S3[\"3 – Clients migrieren<br/>(Frist läuft)\"]\n  S3 --> S4[\"4 – fullName entfernen,<br/>wenn niemand mehr fragt\"]\n```\n\nSchritt eins, hinzufügen: Ich lege `displayName` als neues Feld an. Rein additiv, niemand ist betroffen. Bestehende Clients fragen weiter `fullName`, neue können ab sofort `displayName` nehmen.\n\nSchritt zwei, deprecaten: Ich markiere `fullName` mit einem `@deprecated` und einem `reason`, der auf `displayName` und die geplante Frist verweist. Ab jetzt sehen alle Client-Teams über ihr Tooling, dass das alte Feld ausläuft – aber es funktioniert weiter.\n\nEntscheidend ist die Übergangszeit: Der Resolver liefert *beide* Felder korrekt.\n\n```js\nconst resolvers = {\n  User: {\n    // legacy field – kept working until the deprecation window closes\n    fullName: (user) => user.displayName,\n    displayName: (user) => user.displayName,\n  },\n};\n```\n\nSchritt drei, migrieren: Die Client-Teams stellen in ihrem eigenen Takt auf `displayName` um. Wie lange das dauert, hängt vom langsamsten relevanten Client ab – bei einer Mobil-App oft Monate, weil alte Versionen im Feld bleiben. Eine Schema-Registry oder Traffic-Analysen zeigen, ob `fullName` überhaupt noch abgefragt wird.\n\nSchritt vier, entfernen: Erst wenn die Frist abgelaufen *und* nachweislich kein Traffic mehr auf `fullName` liegt, entferne ich Feld und Resolver. Jetzt, und keinen Moment früher, wird aus dem Breaking Change ein Nicht-Event, weil niemand mehr da ist, der bricht.\n\n## Ein Sicherheitsnetz in der CI\n\nAll diese Regeln im Kopf zu behalten, ist die eine Sache. Sie bei jedem Merge zuverlässig einzuhalten, während mehrere Leute am Schema arbeiten, die andere. Deshalb gehört die Prüfung nicht in den Kopf, sondern in die Pipeline.\n\nEin Werkzeug wie graphql-inspector vergleicht das Schema eines Pull Requests mit dem aktuell veröffentlichten und klassifiziert jede Differenz – additiv, gefährlich oder brechend. Findet es einen Breaking Change, kann es den Build rot färben, bevor irgendetwas gemerged wird. Ein entferntes Feld, ein non-null gewordenes Argument, ein verschwundener Enum-Wert fällt so schon im Review auf, nicht erst, wenn draußen ein Client kaputtgeht. Wer das Schema zusätzlich in einer Registry führt, kann die Prüfung sogar gegen den real beobachteten Traffic laufen lassen: Ein „Breaking Change“ an einem Feld, das seit Monaten niemand mehr abfragt, ist eben doch keiner – und die Migration von oben lässt sich exakt an dieser Datenlage festmachen.\n\n## Gepflegt, nicht neu geschnitten\n\nEin GraphQL-Schema entwickelt sich anders als eine REST-API. Man setzt nicht alle paar Jahre ein `/v2` daneben und migriert alles auf einmal, sondern man pflegt einen einzigen Vertrag über seine ganze Lebenszeit. Das Wachstum ist additiv: neue Felder, neue Typen, neue optionale Argumente kommen laufend dazu, ohne dass jemand es merkt. Und wo doch etwas verschwinden muss, ersetzt disziplinierte Deprecation den harten Schnitt – markieren, Frist geben, migrieren lassen, dann entfernen.\n\nDas ist weniger spektakulär als ein großer Versionssprung, und genau das ist der Punkt. Ein gutes Schema fühlt sich für seine Clients an wie etwas, das einfach immer da war und nie weh getan hat. Diese Ruhe ist kein Zufall – sie ist das Ergebnis von ein paar Regeln, konsequent angewandt, und einem CI-Check, der über ihre Einhaltung wacht.\n\n## Weiterführende Quellen\n\n- Repository introduction-graphql (Kapitel schema, resolver): https://github.com/mikebild/introduction-graphql\n- GraphQL, Schema und Typen: https://graphql.org/learn/schema/\n- GraphQL Best Practices (Versionierung, Nullability, Deprecation): https://graphql.org/learn/best-practices/\n- graphql-inspector (Schema-Checks in der CI): https://github.com/kamilkisiela/graphql-inspector"},{"slug":"step-functions-serverless-workflows","title":"Serverless-Workflows mit AWS Step Functions","summary":"Step Functions machen aus verschachtelten Lambda-Handlern eine deklarative State Machine: sichtbarer Ablauf, explizite Retry- und Catch-Pfade, Standard oder Express.","tags":["Software","AWS","Cloud"],"published_at":"2021-03-23T10:00:00+00:00","updated_at":"2026-07-19T18:46:18.505233+00:00","url":"https://www.mikebild.dev/de/blog/step-functions-serverless-workflows/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/0f9d70957a7d9361faca7f6e5e9adfc29070e001817695edc404c111e7ed16cb/step-functions-serverless-workflows-vorlesen.mp3","audio_duration_secs":845,"text":"Die erste Funktion validiert und ruft die zweite, die berechnet und ruft die dritte, die benachrichtigt. Auf dem Whiteboard ergibt das eine saubere Kette. Zur Laufzeit bleibt davon nur eine Folge von Aufrufen übrig, die niemand von außen anhalten, prüfen oder wieder aufnehmen kann. Wer die Lambda-Functions in den Handlern aneinanderhängt, hat den Ablauf dem Betrieb entzogen: Bleibt eine Bestellung hängen, beginnt die Suche in den Logs.\n\nAWS Step Functions dreht das Verhältnis um. Statt den Ablauf in verschachtelten Handlern zu verstecken, beschreibt man ihn deklarativ als State Machine – und der State wird sichtbar, statt in Aufruf-Ketten zu verschwinden.\n\nEine State Machine macht den Ablauf als Reihe benannter States und ausdrücklich behandelter Ergebnisse sichtbar.\n\n:::process{title=\"Ein expliziter Workflow\" role=\"primary\" preferredPattern=\"connected-process\"}\n1. Eingabe · fachlichen Auftrag und benötigten Kontext übernehmen\n2. Task · genau eine begrenzte Fähigkeit ausführen\n3. Choice · anhand eines Ergebnisses den nächsten zulässigen Pfad wählen\n4. Parallel · unabhängige Zweige nur bei echter Concurrency starten\n5. Catch · erwartbare Fehler in einen benannten Behandlungspfad führen\n6. Abschluss · Erfolg oder Scheitern als eindeutigen Endzustand festhalten\n:::\n\nDie Darstellung behauptet keinen linearen Happy Path. Verzweigung und Error Handling bleiben Teil des Modells, statt in verstreuten Funktionsaufrufen zu verschwinden.\n\n## Das Problem: Orchestrierung im Code\n\nSchauen wir uns zuerst an, wie die Reflex-Lösung typischerweise aussieht. Eine Lambda-Function, die eine Bestellung verarbeitet, könnte intern so orchestrieren:\n\n```js\nexports.handler = async (event) => {\n  const order = await validateOrder(event);\n  if (!order.valid) {\n    await notifyFailure(order);\n    return { status: \"rejected\" };\n  }\n\n  if (order.amount > 1000) {\n    await requestManualReview(order);\n  } else {\n    await autoApprove(order);\n  }\n\n  const [inventory, payment] = await Promise.all([\n    reserveInventory(order),\n    chargePayment(order),\n  ]);\n\n  return finalize(order, inventory, payment);\n};\n```\n\nAuf den ersten Blick ist das lesbar. Aber jeder dieser Aufrufe kann fehlschlagen, und sobald man Robustheit will, wächst der Code. Was passiert, wenn `chargePayment` einen transienten Fehler wirft? Dann braucht man einen Retry mit Backoff. Was, wenn `reserveInventory` klappt, aber `chargePayment` endgültig scheitert? Dann muss die Reservierung wieder zurückgerollt werden. Nach und nach füllt sich der Handler mit try/catch-Blöcken, Zählern und Timeout-Logik. Der eigentliche fachliche Ablauf – validieren, entscheiden, parallel ausführen, abschließen – verschwindet unter der Error Handling.\n\nUnd der unangenehmste Teil kommt im Betrieb. Wenn eine Bestellung stecken bleibt, gibt es keinen Ort, an dem man den State ablesen kann. Man hat CloudWatch-Logs und muss rekonstruieren, wie weit der Prozess gekommen ist. Bei einer einzigen Funktion geht das noch. Bei fünf Funktionen, die sich gegenseitig aufrufen, wird es zur Detektivarbeit. Die Koordination mehrerer Funktionen gehört zu den Grenzen, an die man mit reinem Code schnell stößt.\n\n## Die Idee: der Ablauf als State Machine\n\nStep Functions verlagert die Orchestrierung aus dem Code heraus in eine deklarative Beschreibung. Diese Beschreibung ist in der **Amazon States Language** (ASL) formuliert, einem JSON-Format. Man definiert States, deren Übergänge und die Fehlerpfade – die einzelnen Lambda-Functions bleiben klein und machen jeweils genau eine Sache.\n\nDer entscheidende Gewinn: Der Ablauf steht nicht mehr in einem Handler, sondern liegt als eigenständiges Artefakt vor, das die Konsole grafisch darstellt. Man sieht bei jeder Ausführung, in welchem State sie sich befindet, welcher Übergang genommen wurde und wo etwas fehlgeschlagen ist.\n\nEine ASL-Definition hat auf oberster Ebene wenige Pflichtfelder. `StartAt` benennt den Einstiegszustand, `States` enthält die States als Objekt. Jeder State hat einen `Type` und verweist mit `Next` auf den Folgezustand oder markiert sich mit `\"End\": true` als terminal.\n\nBevor wir in eine konkrete Definition einsteigen, hier die acht State-Typen, die 2021 zur Verfügung stehen – das ist der komplette Baukasten:\n\n- `Task` führt Arbeit aus, typischerweise eine Lambda-Function.\n- `Choice` verzweigt anhand von Bedingungen, das if/else der State Machine.\n- `Parallel` führt mehrere Zweige gleichzeitig aus und sammelt deren Ergebnisse.\n- `Map` wendet einen Ablauf auf jedes Element einer Liste an, 2021 nur als Inline-Variante.\n- `Pass` reicht Eingabe durch oder injiziert feste Daten, nützlich zum Testen.\n- `Wait` pausiert für eine Dauer oder bis zu einem Zeitpunkt.\n- `Succeed` beendet die Ausführung erfolgreich.\n- `Fail` beendet die Ausführung mit einem Fehler.\n\nMit diesen acht Bausteinen lassen sich erstaunlich viele Prozesse abbilden, ohne eine Zeile Orchestrierungscode zu schreiben.\n\n## Ein Task mit Retry und Catch\n\nFangen wir mit dem Kern an: einem Task-State, der eine Lambda-Function aufruft und Fehler deklarativ behandelt. Das ist der Teil, der im Handler-Ansatz am meisten Code kostet – und in ASL zu ein paar Feldern schrumpft.\n\n```json\n{\n  \"Comment\": \"Order processing workflow\",\n  \"StartAt\": \"ValidateOrder\",\n  \"States\": {\n    \"ValidateOrder\": {\n      \"Type\": \"Task\",\n      \"Resource\": \"arn:aws:lambda:eu-central-1:123456789012:function:ValidateOrder\",\n      \"Retry\": [\n        {\n          \"ErrorEquals\": [\"Lambda.ServiceException\", \"Lambda.SdkClientException\"],\n          \"IntervalSeconds\": 2,\n          \"MaxAttempts\": 3,\n          \"BackoffRate\": 2.0\n        }\n      ],\n      \"Catch\": [\n        {\n          \"ErrorEquals\": [\"States.ALL\"],\n          \"Next\": \"NotifyFailure\"\n        }\n      ],\n      \"Next\": \"CheckAmount\"\n    },\n    \"NotifyFailure\": {\n      \"Type\": \"Task\",\n      \"Resource\": \"arn:aws:lambda:eu-central-1:123456789012:function:NotifyFailure\",\n      \"End\": true\n    }\n  }\n}\n```\n\nHier steckt viel drin, deshalb der Reihe nach. Der `Retry`-Block ist ein Array von Retrier-Objekten. `ErrorEquals` benennt, auf welche Fehler dieser Retrier reagiert – hier zwei transiente Lambda-Service-Fehler, die man nicht dem fachlichen Code anlasten sollte. `IntervalSeconds` legt die erste Wartezeit fest, `BackoffRate` multipliziert sie bei jedem weiteren Versuch (Default 2.0), `MaxAttempts` deckelt die Wiederholungen (Default 3). Ohne explizite Angabe würde AWS diese Defaults verwenden.\n\nDer `Catch`-Block greift, wenn die Retries erschöpft sind oder ein Fehler auftritt, den kein Retrier abdeckt. Jeder Catcher braucht `ErrorEquals` und ein `Next`, das den Übergang beschreibt. `States.ALL` ist der Auffang-Fehler, der alles matcht – und genau deshalb gibt es zwei Regeln, die man kennen muss: `States.ALL` muss allein in seinem `ErrorEquals` stehen und als letzter Eintrag kommen. Steht danach noch ein weiterer Retrier oder Catcher, ist die Definition ungültig.\n\nEin wichtiges Detail bei der Reihenfolge: Erst werden die passenden Retrier abgearbeitet, und nur wenn keiner mehr greift, wird der Catcher-Übergang genommen. Retry vor Catch, immer in dieser Ordnung. Das ist genau das Verhalten, das man im Code mühsam von Hand nachbauen würde – hier ist es Konvention der Sprache.\n\nEin Punkt, der regelmäßig für Diskussion sorgt: Nicht jeder `States.`-Fehler wird von allem gefangen. `States.TaskFailed` etwa matcht so ziemlich alles außer `States.Timeout` – wer also nur auf `States.TaskFailed` retryt und eine Zeitüberschreitung erwartet, wird überrascht. Und `States.DataLimitExceeded`, der bei Überschreitung des Payload-Limits (2021 sind das 256 KB zwischen den States) auftritt, wird von `States.ALL` nicht gefangen. Solche Fehler muss man bewusst vermeiden, nicht abfangen.\n\n## Verzweigen mit Choice\n\nNach der Validierung soll der Prozess entscheiden. Kleine Bestellungen werden automatisch freigegeben, große gehen in die manuelle Prüfung. Im Handler war das ein `if`, in ASL ist es ein Choice-State.\n\n```json\n{\n  \"CheckAmount\": {\n    \"Type\": \"Choice\",\n    \"Choices\": [\n      {\n        \"Variable\": \"$.amount\",\n        \"NumericGreaterThan\": 1000,\n        \"Next\": \"ManualReview\"\n      }\n    ],\n    \"Default\": \"FanOut\"\n  },\n  \"ManualReview\": {\n    \"Type\": \"Task\",\n    \"Resource\": \"arn:aws:lambda:eu-central-1:123456789012:function:RequestManualReview\",\n    \"Next\": \"FanOut\"\n  }\n}\n```\n\nDer Choice-State enthält ein `Choices`-Array. Jede Regel besteht aus einer `Variable` (ein Pfad in die aktuelle Eingabe, hier `$.amount`), einem Vergleichsoperator wie `NumericGreaterThan`, `StringEquals` oder `BooleanEquals`, und einem `Next`. Man kann Bedingungen auch mit `And`, `Or` und `Not` verschachteln. Trifft keine Regel zu, greift `Default`.\n\nGenau dieses `Default` ist ein häufiger Stolperstein. Choice hat kein `End` und keinen impliziten Ausgang. Ohne `Default` und ohne passenden Treffer läuft die Ausführung in einen `States.NoChoiceMatched`-Fehler. Ich empfehle deshalb, `Default` immer zu setzen, selbst wenn man theoretisch alle Fälle abgedeckt glaubt.\n\n## Parallel: mehrere Zweige gleichzeitig\n\nFür Reservierung und Zahlung, die unabhängig voneinander laufen, gibt es den Parallel-State. Er führt mehrere `Branches` gleichzeitig aus – jeder Branch ist selbst eine kleine State Machine mit eigenem `StartAt` und `States`.\n\n```json\n{\n  \"FanOut\": {\n    \"Type\": \"Parallel\",\n    \"Branches\": [\n      {\n        \"StartAt\": \"ReserveInventory\",\n        \"States\": {\n          \"ReserveInventory\": {\n            \"Type\": \"Task\",\n            \"Resource\": \"arn:aws:lambda:eu-central-1:123456789012:function:ReserveInventory\",\n            \"End\": true\n          }\n        }\n      },\n      {\n        \"StartAt\": \"ChargePayment\",\n        \"States\": {\n          \"ChargePayment\": {\n            \"Type\": \"Task\",\n            \"Resource\": \"arn:aws:lambda:eu-central-1:123456789012:function:ChargePayment\",\n            \"End\": true\n          }\n        }\n      }\n    ],\n    \"Next\": \"Finalize\"\n  },\n  \"Finalize\": {\n    \"Type\": \"Task\",\n    \"Resource\": \"arn:aws:lambda:eu-central-1:123456789012:function:Finalize\",\n    \"End\": true\n  }\n}\n```\n\nDer Output eines Parallel-States ist ein geordnetes Array – ein Eintrag pro Branch, in der Reihenfolge der Definition. `Finalize` bekommt also beide Ergebnisse gebündelt übergeben. Auch Parallel unterstützt `Retry` und `Catch` auf Zustandsebene, ebenso der Map-State, wenn man eine Liste iterativ abarbeitet.\n\n## Wie Daten zwischen den States fließen\n\nSobald man das erste Mal ernsthaft mit ASL arbeitet, stolpert man über eine Frage, die im Handler-Code nie auftaucht: Was genau bekommt der nächste State als Eingabe? In einer JavaScript-Funktion reicht man Werte einfach als Argumente weiter. In einer State Machine ist die Weitergabe geregelt, und die Regeln muss man kennen, sonst gehen Daten unbemerkt verloren.\n\nJeder State bekommt ein JSON-Objekt als Eingabe und gibt ein JSON-Objekt als Ausgabe. Vier Felder steuern diesen Fluss. `InputPath` filtert, welcher Ausschnitt der Eingabe überhaupt beim State ankommt. `Parameters` baut daraus das Objekt, das die Lambda-Function tatsächlich erhält. `ResultPath` bestimmt, an welcher Stelle das Ergebnis in die Eingabe eingefügt wird. `OutputPath` filtert zuletzt, was an den Folgezustand weitergereicht wird.\n\nDer häufigste Stolperstein steckt in `ResultPath`. Ohne Angabe steht der Default auf `$`, und das heißt: Das Ergebnis des Tasks ersetzt die komplette Eingabe. Wer nach `ValidateOrder` im Choice-State noch auf `$.amount` zugreifen will, muss das Ergebnis mit `\"ResultPath\": \"$.validation\"` unter einen eigenen Schlüssel legen, sonst ist die ursprüngliche Bestellung weg. Genau dieser Default ist der Grund, warum ein Choice-State danach ins Leere greift: Der Schlüssel, den die Bedingung erwartet, existiert nach dem Task gar nicht mehr. Solche Fehler wirken wie eine falsche Bedingung, sind in Wahrheit aber ein Datenfluss-Problem.\n\n## Der Ablauf als Bild\n\nDas eigentliche Argument für Step Functions ist die Sichtbarkeit. Was ich oben in JSON beschrieben habe, stellt die Konsole als Graph dar. Und genau diese Struktur lässt sich als Diagramm nachzeichnen:\n\n```mermaid\nflowchart TD\n    Start([Start]) --> Validate[ValidateOrder<br/>Task]\n    Validate --> Check{CheckAmount<br/>Choice}\n    Check -->|amount > 1000| Review[ManualReview<br/>Task]\n    Check -->|else| FanOut[FanOut<br/>Parallel]\n    Review --> FanOut\n    FanOut --> Reserve[ReserveInventory]\n    FanOut --> Charge[ChargePayment]\n    Reserve --> Finalize[Finalize<br/>Task]\n    Charge --> Finalize\n    Finalize --> Done([Succeed])\n    Validate -. \"Catch States.ALL\" .-> Notify[NotifyFailure<br/>Task]\n```\n\nDie gestrichelte Kante ist der Fehlerpfad, den der Catcher beschreibt. Bei einer laufenden Ausführung färbt die Konsole die durchlaufenen States ein, sodass man auf einen Blick sieht, wo der Prozess steht oder wo er hängengeblieben ist. Diesen Blick habe ich im Handler-Ansatz nie – und das ist der Unterschied zwischen „wir müssen die Logs durchsuchen“ und „schau einfach in die Ausführung“.\n\n## Standard oder Express\n\nBei der Erstellung einer State Machine muss man sich für einen Typ entscheiden, und diese Entscheidung ist danach fix. Wer sich vertut, baut eine neue State Machine. Deshalb lohnt es sich, die beiden Typen früh zu verstehen.\n\n**Standard** ist der Typ für lang laufende, auditierbare Prozesse. Eine Ausführung darf bis zu ein Jahr dauern, die Ausführung ist exactly-once, und die vollständige Historie steht bis zu 90 Tage über die API bereit. Abgerechnet wird pro State Transition. Wichtig für die Kostenrechnung: Jeder Retry-Versuch ist ein eigener Übergang und zählt mit. Das visuelle Debugging in der Konsole gibt es nur bei Standard.\n\n**Express** ist für hochfrequente, kurze Abläufe gedacht. Eine Ausführung darf höchstens fünf Minuten laufen. Asynchron ausgeführt ist die Garantie at-least-once, synchron über `StartSyncExecution` (verfügbar seit November 2020) at-most-once. Abgerechnet wird nach Anzahl der Ausführungen sowie Dauer und Speicher. Eine Ausführungshistorie in der Konsole gibt es nicht – man ist auf CloudWatch Logs angewiesen, und dafür muss Logging aktiv sein.\n\nDie Trennlinie verläuft entlang der Fachlichkeit: Bestellprozesse, Freigabe-Workflows, alles mit menschlicher Beteiligung oder Prüfpflicht gehört zu Standard. Event-Verarbeitung mit hohem Durchsatz, kurze Transformationen, IoT-Ingestion – das ist Express-Territorium. Der idempotente Charakter kurzer Express-Abläufe passt zur at-least-once-Semantik, während man bei einem Bezahlvorgang die exactly-once-Garantie von Standard haben will.\n\n## Wann Step Functions – und wann nicht\n\nIch rate nicht dazu, jede Aneinanderreihung von zwei Funktionen zur State Machine zu machen. Wenn eine Funktion eine andere aufruft und das war es, ist ein direkter Aufruf oder ein Event völlig in Ordnung. Der Aufwand von Step Functions zahlt sich ab dem Punkt aus, an dem es echte Ablauflogik gibt: Verzweigungen, Parallelität, Retries mit unterschiedlichen Strategien, Kompensation bei Teilfehlern, oder wenn man den State für den Betrieb sichtbar braucht.\n\nEin zweites Kriterium ist die Error Handling. Sobald man merkt, dass der Orchestrierungs-Handler mehr try/catch als Fachlogik enthält, ist das ein starkes Signal. Diese Behandlung deklarativ auszulagern macht den Code der einzelnen Funktionen wieder klein und lesbar – jede Funktion konzentriert sich auf ihre eine Aufgabe, die Koordination liegt in der State Machine.\n\nUnd Step Functions ersetzt nicht die anderen Serverless-Bausteine, sondern koordiniert sie. Die Lambda-Functions bleiben, die Datenbank bleibt, die Authentifizierung – etwa mit [Cognito User Pools](https://www.mikebild.dev/de/blog/cognito-userpool-auth-serverless/) – bleibt an ihrem Platz. Step Functions ist die Schicht darüber, die den Ablauf beschreibt.\n\n## Fazit\n\nDer Weg von verschachtelten Handlern zu einer State Machine ist vor allem ein Wechsel der Perspektive. Statt den Ablauf im Code zu verstecken, macht man ihn zum Artefakt: `StartAt`, ein paar States, `Next`-Übergänge, und die Fehlerpfade als `Retry` und `Catch` direkt am State. Die einzelnen Funktionen werden dadurch kleiner, weil sie die Koordination nicht mehr tragen müssen, und der Betrieb wird ruhiger, weil der State jeder Ausführung sichtbar ist.\n\nDie acht State-Typen sind schnell gelernt, und mit Task, Choice und Parallel deckt man bereits die meisten realen Abläufe ab. Mit der Reihenfolge von Retry und Catch im Hinterkopf, einem gesetzten `Default` bei Choice und einer bewussten Wahl zwischen Standard und Express ist das Fundament beisammen. Überzeugt hat in den Projekten, an denen ich beteiligt war, ohnehin erst die eingefärbte Ausführung in der Konsole. Ab da liegt der Ablauf nicht mehr in Logs vergraben, sondern ist ein Bild, auf das alle schauen können.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-aws-serverless.html\n- Repository zur Serverless-Schulung mit den ASL-Beispielen dieses Artikels\n- [AWS Step Functions – States (States)](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-states.html)\n- [Amazon States Language](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-amazon-states-language.html)\n- [Standard vs. Express Workflows](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-standard-vs-express.html)\n- [Error Handling: Retry und Catch](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-error-handling.html)\n- [Task-State](https://docs.aws.amazon.com/step-functions/latest/dg/state-task.html)\n- [Choice-State](https://docs.aws.amazon.com/step-functions/latest/dg/state-choice.html)\n- [Tutorial: State Machine mit Lambda](https://docs.aws.amazon.com/step-functions/latest/dg/tutorial-creating-lambda-state-machine.html)"},{"slug":"event-sourcing-der-event-store-als-logbuch","title":"Event Sourcing: der Event-Store als Logbuch","summary":"Event Sourcing speichert nicht den aktuellen Zustand, sondern die Folge der Events als Wahrheitsquelle: der Event-Store als Logbuch, der Zustand als Faltungsergebnis. Nutzen, ehrliche Kosten und die Abgrenzung zu CQRS.","tags":["Software","Architektur"],"published_at":"2021-02-23T10:00:00+00:00","updated_at":"2026-07-13T08:40:37.248914+00:00","url":"https://www.mikebild.dev/de/blog/event-sourcing-der-event-store-als-logbuch/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/ec46ce169f74038ce2f0721ec4e361598d785016db0e8905b94180ef87efd053/event-sourcing-der-event-store-als-logbuch-vorlesen.mp3","audio_duration_secs":729,"text":"```sql\nUPDATE accounts SET balance = 300 WHERE id = 42;\n```\n\nNach diesem Statement steht in der Datenbank, dass das Konto 300 Euro hat. Der vorherige Wert ist überschrieben, und mit ihm die Antwort auf die Frage, wie die 300 zustande kamen: eine Einzahlung über 300, oder zwei über 200 plus eine Abhebung über 100? Die Zeile weiß es nicht mehr. Jede Buchung frisst ihren Vorgänger, und das Feld `balance` behält nur das Ergebnis.\n\nEvent Sourcing dreht diese Reihenfolge um. Nicht der aktuelle Zustand ist die Wahrheit, sondern die Folge der Events, die ihn erzeugt haben. Der Event-Store ist ein Logbuch – ein append-only Log, an das nur angehängt wird und in dem nichts geändert oder gelöscht wird. Der aktuelle Zustand ist dann kein gespeichertes Faktum mehr, sondern ein abgeleitetes Artefakt: Man faltet die Events und bekommt den Zustand heraus.\n\nGenannt wird Event Sourcing fast immer im selben Atemzug mit CQRS, und genau dort entstehen die hartnäckigsten Missverständnisse. Deshalb zuerst der Kern, danach die Grenzen – bewusst scharf gezogen.\n\n## Der Zustand ist ein Left Fold\n\nMartin Fowler beschreibt Event Sourcing als „Capture all changes to an application state as a sequence of events.“ Jede Zustandsänderung wird als Event festgehalten, und der Anwendungszustand ist damit vollständig aus dem Event-Log ableitbar. Man kann den gecachten Zustand komplett wegwerfen und ihn durch erneutes Abspielen aller Events auf einem leeren Anfangszustand rekonstruieren.\n\nGreg Young hat dafür die didaktisch schönste Formel geprägt: „Current state is a left fold of previous behaviours.“ Der Zustand ist keine Tabelle, sondern das Ergebnis einer Faltung über die Event-Folge. In Code, den jeder sofort liest:\n\n```ts\ntype Event =\n  | { type: \"AccountOpened\"; owner: string }\n  | { type: \"MoneyDeposited\"; amount: number }\n  | { type: \"MoneyWithdrawn\"; amount: number };\n\ntype Account = { owner: string; balance: number };\n\nconst apply = (state: Account, event: Event): Account => {\n  switch (event.type) {\n    case \"AccountOpened\":\n      return { owner: event.owner, balance: 0 };\n    case \"MoneyDeposited\":\n      return { ...state, balance: state.balance + event.amount };\n    case \"MoneyWithdrawn\":\n      return { ...state, balance: state.balance - event.amount };\n  }\n};\n\nconst fold = (events: Event[]): Account =>\n  events.reduce(apply, { owner: \"\", balance: 0 });\n```\n\nDas ist der ganze Trick, und es ist bewusst wenig. `apply` nimmt einen Zustand und ein Event und liefert den nächsten Zustand. `fold` läuft mit `reduce` über den Stream und ist nichts anderes als ein `left fold` mit `apply` als Faltungsfunktion. Der Code hier ist illustrativ – kein lauffähiges Repository, sondern die Idee auf das Wesentliche reduziert.\n\nEin paar Eigenschaften stecken schon in diesen Zeilen. Die Events sind in der Vergangenheitsform benannt – `AccountOpened`, `MoneyDeposited`. Sie sind Fakten, die bereits geschehen sind, und deshalb immutable: Ein Event wird nie geändert und nie gelöscht, es kommt nur ein neues hinzu. Und `apply` hat keine Side Effects; es ist eine Pure Function vom alten in den neuen Zustand. Das ist kein Zufall, sondern die Bedingung dafür, dass Replay überhaupt verlässlich funktioniert.\n\nDamit läuft alles auf dieselbe Trennlinie hinaus wie in [Commands, Events und Queries trennen](https://www.mikebild.dev/de/blog/commands-events-queries-trennen/): Ein Command ist eine Absicht, die scheitern darf; ein Event ist das, was daraus tatsächlich geworden ist und nicht mehr scheitern kann. Event Sourcing speichert konsequent die zweite Sorte.\n\n## Das Logbuch als Wahrheitsquelle\n\nSchaut man auf den Fluss, ergibt sich ein einfaches Bild. Ein Command trifft ein, das Aggregat entscheidet und erzeugt daraus ein oder mehrere Events, die an das Log angehängt werden. Der Zustand entsteht danach durch Falten.\n\n```mermaid\nflowchart LR\n  C[Command] --> A[Aggregate decides]\n  A --> E[New Event]\n  E --> L[(Event Store<br/>append-only)]\n  L --> F[fold / apply]\n  F --> S[Current State]\n```\n\nWichtig an diesem Bild ist die Richtung. Der Command produziert ein Event, das Log wächst nur nach hinten, und der Zustand hängt am Ende der Kette. Fällt der Zustand weg – Cache verloren, neuer Service, geänderte Berechnung – lässt er sich jederzeit aus dem Log wiederherstellen. Das Log ist maßgeblich, der Zustand ist ableitbar.\n\nDaraus folgen drei Vorteile, die Event Sourcing attraktiv machen und die alle an derselben Eigenschaft hängen: Das Log bleibt vollständig erhalten.\n\n- Ein vollständiger Audit-Trail entsteht gratis. Nicht als nachgelagertes Protokoll, sondern weil das Event-Log der Zustand ist. Es beschreibt nicht nur, was passiert ist – es *ist* das, was passiert ist.\n- Temporale Queries werden möglich. Weil jeder frühere Zustand ein Zwischenergebnis der Faltung ist, kann ich fragen: Wie sah das Konto am 3. Januar aus? Ich falte einfach bis zu dem Event, das an dem Tag geschrieben wurde.\n- Projections lassen sich neu aufbauen. Ein Read Model, das ich heute nicht habe, kann ich morgen einführen, indem ich die vorhandenen Events erneut durchlaufe. Historie, die bei zustandsbasierter Speicherung längst überschrieben wäre, steht hier vollständig zur Verfügung.\n\nDer dritte Punkt wiegt in der Praxis am schwersten: Beim Schreiben muss niemand vorhersehen, welche Sichten später gebraucht werden.\n\n## Projections: Read Models aus dem Stream\n\nWenn der Zustand ohnehin abgeleitet ist, dann sind es beliebig viele Sichten auf denselben Stream. Genau das sind Projections, und sie sind der natürliche Übergang zu den [Read Models sind Projections](https://www.mikebild.dev/de/blog/read-models-sind-projektionen/): abgeleitete, für Lesezwecke optimierte Modelle, die wegwerfbar und jederzeit neu aufbaubar sind.\n\n```ts\ntype BalanceView = Record<string, number>;\n\nconst project = (events: Event[]): BalanceView =>\n  events.reduce((view, event) => {\n    switch (event.type) {\n      case \"AccountOpened\":\n        return { ...view, [event.owner]: 0 };\n      case \"MoneyDeposited\":\n        // update the read model per event\n        return view;\n      case \"MoneyWithdrawn\":\n        return view;\n    }\n  }, {} as BalanceView);\n```\n\nAuch `project` ist ein Fold, nur mit einem anderen Zielmodell. Die eine Faltung ergibt das Aggregat für Entscheidungen, die andere eine flache Übersicht für die Leseseite. Aus demselben Log lassen sich eine Kontoübersicht, ein monatlicher Umsatzbericht und eine Betrugserkennung ableiten – drei Projections, drei Faltungen, ein Stream.\n\n```mermaid\nflowchart LR\n  L[(Event Store<br/>append-only)] -->|project| A[Read Model A<br/>balances]\n  L -->|project| B[Read Model B<br/>monthly report]\n  L -->|project| C[Read Model C<br/>fraud signals]\n```\n\nZwei Dinge muss man hier ehrlich benennen. Erstens sind diese Read Models nur eventual consistent – sie hinken dem Write-Log hinterher, weil die Projection nach dem Anhängen läuft. Wer sofortige Konsistenz zwischen Schreiben und Lesen braucht, bekommt sie hier nicht geschenkt. Zweitens dürfen Projections keine externen Side Effects auslösen, und das führt direkt zum ersten ernsten Fallstrick.\n\n## Replay hat keine Unschuld\n\nFowler weist auf ein Detail hin, das in Demos nie auffällt und in Produktion teuer wird: Externe Systeme unterscheiden nicht zwischen echtem Verarbeiten und Replay. Wenn ich ein Read Model neu aufbaue und dabei die Events noch einmal durchlaufe, sieht ein Gateway, das beim ersten Mal eine E-Mail verschickt oder eine Zahlung ausgelöst hat, keinen Unterschied. Es würde die E-Mail erneut senden.\n\nDeshalb gilt die Regel: `apply` und `project` sind reine Faltungen ohne Side Effects. Alles, was nach außen wirkt – Mails, Zahlungen, Benachrichtigungen –, gehört nicht in die Faltung, sondern hinter Gateways, die einen Replay-Modus kennen und ihre Effekte dann unterdrücken. Wer das nicht trennt, baut sich beim ersten Rebuild eine Katastrophe.\n\n## Snapshots: der gemerkte Fold\n\nBleibt die Frage nach der Performance. Ein Konto mit zwanzig Buchungen faltet sich in Mikrosekunden. Ein Aggregat mit zweihunderttausend Events tut das nicht mehr bei jedem Zugriff. Die Antwort heißt Snapshot, und Greg Young hat sie so präzise gefasst, dass sich seine Formulierung kaum verbessern lässt: „A snapshot is a memoization of your left fold.“\n\nEin Snapshot ist eine gespeicherte Momentaufnahme des Faltungsergebnisses bei Event N. Man faltet dann ab dem Snapshot weiter, statt bei Event 0 anzufangen.\n\n```ts\ntype Snapshot = { version: number; state: Account };\n\nconst rebuild = (snapshot: Snapshot, newerEvents: Event[]): Account =>\n  newerEvents.reduce(apply, snapshot.state);\n// snapshot = memorized left fold at version N\n```\n\nEntscheidend ist dabei: Ein Snapshot ist eine reine Performance-Optimierung, keine neue Wahrheitsquelle. Weil die Events immutable sind, kann ein Snapshot nie ungültig werden – die Vergangenheit, aus der er gefaltet wurde, ändert sich ja nicht. Fällt ein Snapshot weg oder wird die Faltungslogik geändert, wirft man ihn weg und faltet neu von vorn. Das Log bleibt maßgeblich; der Snapshot ist nur ein Cache des Folds.\n\n## Die Abgrenzung: Event Sourcing ist nicht CQRS\n\nEvent Sourcing und CQRS werden fast immer zusammen genannt, und viele halten sie für dasselbe. Sie sind es nicht.\n\n[CQRS ist eine fachliche Grenze](https://www.mikebild.dev/de/blog/cqrs-fachliche-grenze-kein-framework/) – die Trennung von Schreibmodell und Lesemodell. Event Sourcing ist eine Persistenzstrategie – Events statt Zustand als das, was gespeichert wird. Das sind zwei verschiedene Entscheidungen auf zwei verschiedenen Achsen. Man kann CQRS ohne Event Sourcing betreiben: getrennte Schreib- und Leseseite, beide klassisch mit Zustandstabellen. Und man kann Event Sourcing ohne strenges CQRS betreiben, auch wenn die Read-Model-Projections die Trennung dann fast von selbst nahelegen.\n\nSie werden oft kombiniert, weil Event Sourcing sehr natürlich Read-Model-Projections erzeugt und CQRS genau dafür den passenden Rahmen bietet. Aber keins erzwingt das andere. Wer das trennt, trifft beide Entscheidungen bewusst statt eine im Schlepptau der anderen.\n\nUnd noch eine Abgrenzung, die in verteilten Systemen wichtig wird: Event Sourcing ist nicht Event-Driven Architecture. Event Sourcing ist Speicherung und Wahrheitsquelle innerhalb eines Aggregats. Pub/Sub-Messaging zwischen Diensten ist Integration. Ein Domain Event, das als Integrationsnachricht über einen Bus geht, ist nicht automatisch ein gespeichertes Event-Sourcing-Event. Wo beide Ebenen verschwimmen, entstehen Systeme, die nach einem Jahr niemand mehr durchschaut.\n\n## Die ehrlichen Kosten\n\nSo attraktiv das Logbuch klingt – Event Sourcing ist teuer. Dem Nutzen steht ein realer Preis gegenüber, und beide Seiten gehören nebeneinander auf den Tisch.\n\nDer Nutzen ist ein vollständiger Audit-Trail, sind temporale Queries, nachträglich einführbare Projections und eine gute fachliche Semantik durch absichtsvolle Events. Die Kosten sind die Event-Schema-Versionierung über die gesamte Lebensdauer, Replay-Performance samt Snapshot-Verwaltung, die Eventual Consistency der Read Models und eine insgesamt höhere Betriebskomplexität.\n\nDer größte Kostenblock ist die Versionierung. Events leben ewig, und ein Replay muss auch die alten Event-Versionen von vor drei Jahren noch lesen können. Ändert sich das Schema – ein Feld kommt hinzu, eine Bedeutung verschiebt sich –, brauche ich eine Strategie zum Upcasting: alte Events beim Lesen in die aktuelle Form überführen. Wer das unterschätzt, merkt beim ersten großen Rebuild, dass sein Log Events in fünf Generationen enthält, die alle noch gelesen werden wollen. Diesen Aufwand plant man von Anfang an ein oder man plant ihn schmerzhaft nach.\n\nDazu kommt Eventual Consistency – ein dauerhafter Begleiter, den kein Ticket wegdebuggt. Und Snapshots sind kein Gratiswerkzeug: zusätzliche Infrastruktur, die man verwalten, invalidieren und beim Ändern der Faltungslogik neu erzeugen muss.\n\n## Zwei Anti-Pattern, die den Nutzen zerstören\n\nZwei Fehler sehe ich so oft, dass sie einen eigenen Absatz verdienen.\n\nDer erste ist „CRUD in Verkleidung“. Wenn die Events `EntityUpdated` oder generische `FieldChanged` heißen, hat man Event Sourcing mechanisch eingebaut, aber die eigentliche Absicht weggeworfen. Ein `MoneyWithdrawn` erzählt, was fachlich geschah; ein `BalanceFieldSetTo` erzählt nur, dass irgendein Setter lief. Der ganze Wert – Intent, Audit, Semantik – steckt in der fachlichen Benennung. Generische Setter-Events sind CRUD mit mehr Zeremonie und weniger Nutzen.\n\nDer zweite ist „Event Sourcing überall“. Es ist keine Default-Persistenz. Es lohnt sich dort, wo Historie, Audit und temporale Fragen echten fachlichen Wert haben – Buchhaltung, Handel, alles mit regulatorischer Nachvollziehbarkeit. Für einen Stammdaten-CRUD-Dienst, der nur den letzten Stand braucht, ist es teurer Overhead. Die Kunst liegt darin, die Grenze zu ziehen, an der der Kontext das Logbuch wirklich rechtfertigt, und sie nicht aus Begeisterung über das ganze System zu verschieben.\n\n## Fazit\n\nEvent Sourcing verschiebt eine einzige Annahme: Nicht der Zustand ist die Wahrheit, sondern die Folge der Events, die ihn erzeugt haben. Der Event-Store wird zum Logbuch – append-only, immutable, maßgeblich –, und der Zustand fällt als Left Fold über diesen Stream heraus. Daraus wachsen Audit-Trail, temporale Queries und beliebig viele neu aufbaubare Projections, fast wie von selbst.\n\nDer Preis dafür ist real: Event-Versionierung über Jahre, Replay-Performance mit Snapshots als gemerktem Fold, Eventual Consistency und die Disziplin, Side Effects aus den Faltungen herauszuhalten. Und die wichtigste gedankliche Trennung: Event Sourcing ist eine Persistenzstrategie, CQRS eine fachliche Grenze – oft gemeinsam, nie voneinander erzwungen.\n\nWer das nüchtern gegeneinander abwägt, setzt Event Sourcing dort ein, wo die Historie den Aufwand zurückzahlt, und speichert sonst weiter guten alten Zustand. Genau diese Entscheidung – bewusst, nicht aus Mode – ist der eigentliche Gewinn.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html\n- Martin Fowler, *Event Sourcing*: https://martinfowler.com/eaaDev/EventSourcing.html\n- Martin Fowler, *Retroactive Event* (zu Korrekturen durch neue Events): https://martinfowler.com/eaaDev/RetroactiveEvent.html\n- Greg Young, Vortrag *CQRS and Event Sourcing*, Code on the Beach 2014 (Left Fold, Snapshots als „memorized fold“)\n- Greg Young, *Functional Domain Models and Event Sourcing* (2012)\n- Vaughn Vernon, *Implementing Domain-Driven Design* (2013) – Kapitel zu Event Sourcing und CQRS\n- Mike Bild, Vortragsmaterial *CQRS + Event Sourcing* (SlideShare) – Autoren-Material, aus dem dieser Artikel schöpft"},{"slug":"flask-apps-testen","title":"Flask-Apps testen: der Test-Client und zuverlässige Endpoints","summary":"Wie ich Flask-Endpoints teste: mit app.test_client() ohne laufenden Server, pytest-Fixtures und geprüften JSON-Antworten.","tags":["Software","Python","Backend"],"published_at":"2021-02-16T10:00:00+00:00","updated_at":"2026-07-13T13:19:37.617788+00:00","url":"https://www.mikebild.dev/de/blog/flask-apps-testen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/e8a557c32517ead355faa68d08fc229829012a312f1c01f4a49c827f0afc79c1/flask-apps-testen-vorlesen.mp3","audio_duration_secs":692,"text":"`app.test_client()` ist kein HTTP-Client. Er öffnet keinen Socket, belegt keinen Port und braucht kein `app.run()`. Er ruft die WSGI-Anwendung direkt im selben Prozess auf und gibt zurück, was die View-Function produziert hat – ein Python-Objekt, keine Netzwerkantwort.\n\nDamit fällt die Begründung weg, Endpoints seien aufwendig abzusichern. Ein `curl` im Terminal zeigt, dass eine Route in diesem einen Augenblick das Richtige tut; nach dem nächsten Refactoring sagt es nichts mehr aus. Dieser Artikel baut auf [Web-APIs mit Flask](https://www.mikebild.dev/de/blog/web-apis-mit-flask/) auf und dreht die Perspektive um: nicht das Bauen der Endpoints, sondern das Absichern. Das Werkzeug dafür ist schlank – Flask bringt den Test-Client mit, pytest liefert die Struktur drumherum.\n\n## Ein Test ohne Server\n\nDer naheliegende Weg wäre, für einen Test die App zu starten und dann per HTTP-Bibliothek dagegen zu schießen. Das funktioniert, ist aber langsam, fehleranfällig und braucht einen freien Port. Flask bietet einen deutlich besseren Weg an. `app.test_client()` liefert einen Client, der die WSGI-Anwendung direkt im selben Prozess aufruft. Es gibt keinen Socket, keinen `app.run()`, keine echte Netzwerkverbindung – der Request wird als Python-Aufruf an die App durchgereicht und die Antwort kommt als Objekt zurück.\n\nDas ist der entscheidende Punkt: Der Test spricht mit deiner Anwendung, aber nicht über das Netz. Dadurch laufen die Tests schnell und funktionieren auch in einer CI-Umgebung ohne offene Ports. Die fehlende Netzwerk- und Serverstrecke entfernt einige Schwankungsquellen; deterministisch wird der Test aber erst, wenn auch Daten, Zeit und externe Abhängigkeiten kontrolliert sind.\n\n```mermaid\nflowchart LR\n    A[Test] -->|client.get / post| B[test_client]\n    B -->|WSGI in-process<br/>kein Socket, kein Server| C[Flask-App]\n    C --> D[View-Funktion]\n    D -->|Response<br/>status_code + body| B\n    B -->|Response-Objekt| A\n    A -->|assert| E[Status &amp; Body geprüft]\n```\n\nDer Client verhält sich wie ein kleiner Browser ohne Oberfläche. Ich rufe `client.get(\"/\")` oder `client.post(\"/json\", json={...})` auf und bekomme ein Response-Objekt zurück, das ich untersuchen kann. Die wichtigsten Attribute sind schnell aufgezählt: `response.status_code` als Ganzzahl, `response.data` als Rohbody in Bytes und `response.get_json()` für bereits deserialisierte JSON-Antworten.\n\n## Vom unittest-Stil zu pytest-Fixtures\n\nViele ältere Flask-Beispiele – auch das, mit dem ich früher in meinem Repository `introduction-python` gearbeitet habe – nutzen `unittest`. Der Aufbau sieht dann typischerweise so aus:\n\n```python\nimport unittest\nfrom examples.app import app\n\n\nclass HelloTestCase(unittest.TestCase):\n    def setUp(self):\n        app.testing = True\n        self.client = app.test_client()\n\n    def test_index(self):\n        result = self.client.get(\"/\")\n        self.assertEqual(result.status_code, 202)\n        self.assertEqual(result.data, b\"Hello World!\")\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n```\n\nDas funktioniert und ist ein guter Ausgangspunkt. Der Endpoint in meinem Beispiel gibt bewusst den ungewöhnlichen Status `202` zurück – genau deshalb eignet er sich zum Testen, denn ein falscher Status fällt sofort auf. Trotzdem modernisiere ich das inzwischen zu pytest, und zwar aus zwei Gründen: Die Tests werden kürzer und lesbarer, und die Fixtures machen das Setup wiederverwendbar, ohne dass ich es in jeder Testklasse wiederhole.\n\nDer zweite, subtilere Grund liegt im Detail des Codes oben. Die App wird als Modul-globales Objekt importiert und der Client teilt sich diese eine Instanz. Solange die App zustandslos ist, geht das gut. Sobald sie Daten hält, wird es heikel – dazu später mehr.\n\n## Fixtures in conftest.py\n\nBei pytest lege ich das Setup in eine Datei `tests/conftest.py`. Diese Datei wird von pytest automatisch gefunden, und die dort definierten Fixtures stehen allen Tests im Verzeichnis zur Verfügung. Ich definiere zwei Fixtures: eine für die konfigurierte App und eine für den Client.\n\n```python\nimport pytest\nfrom examples.app import app as flask_app\n\n\n@pytest.fixture\ndef app():\n    flask_app.config.update({\"TESTING\": True})\n    yield flask_app\n\n\n@pytest.fixture\ndef client(app):\n    return app.test_client()\n```\n\nZwei Dinge sind hier wichtig. Erstens die Konfiguration `TESTING = True`. Sie schaltet die Fehlerpropagierung ein: Wirft ein View-Fehler eine Exception, reicht Flask sie im Testmodus durch, statt sie in eine generische `500`-Antwort zu verpacken. Ohne dieses Flag debuggst du im Blindflug, weil du nur den Status `500` siehst und nicht den eigentlichen Stacktrace. Ich habe in einem Projekt eine halbe Stunde in einem Testlauf nach einer Ursache gesucht, die mit `TESTING=True` von Anfang an im Traceback gestanden hätte.\n\nZweitens die `client`-Fixture, die von der `app`-Fixture abhängt. pytest löst diese Abhängigkeit automatisch auf: Es baut erst die App, dann den Client. Jeder Test, der `client` als Argument deklariert, bekommt eine frische, sauber konfigurierte Instanz. Genau diese Frische ist der Schlüssel zu isolierten Tests.\n\n## Die AAA-Struktur\n\nWenn ich Tests schreibe, halte ich mich an das Muster AAA: Arrange, Act, Assert. Erst den Zustand vorbereiten, dann die eine Aktion ausführen, dann prüfen. Bei Flask-Tests fällt das Arrange größtenteils in die Fixtures, das Act ist der Request und das Assert die Prüfung der Antwort.\n\n```mermaid\nflowchart TD\n    A[\"Arrange<br/>Fixtures: App + Client<br/>TESTING = True\"] --> B[\"Act<br/>client.get / client.post\"]\n    B --> C[\"Assert<br/>status_code prüfen<br/>get_json / data prüfen\"]\n```\n\nEin erster, einfacher Test für den Index-Endpoint sieht damit so aus:\n\n```python\ndef test_index_returns_hello(client):\n    # Act\n    response = client.get(\"/\")\n\n    # Assert\n    assert response.status_code == 202\n    assert response.data == b\"Hello World!\"\n```\n\nKein `setUp`, keine Klasse, kein `self`. Die Fixture erledigt das Arrange, der Test bleibt bei der eigentlichen Aussage: Dieser Endpoint antwortet mit Status `202` und dem Body `Hello World!`.\n\n## Bytes sind nicht String\n\nÜber diese Stelle bin ich selbst oft genug gestolpert. Der naheliegende Vergleich\n\n```python\nassert response.data == \"Hello World!\"   # fails\n```\n\nschlägt fehl. `response.data` liefert Bytes, kein `str`. Der Vergleich zwischen `b\"Hello World!\"` und `\"Hello World!\"` ist in Python immer `False`. Es gibt zwei saubere Wege: Entweder ich vergleiche gegen ein Byte-Literal `b\"Hello World!\"`, oder ich hole den Body als Text.\n\n```python\ndef test_index_body_as_text(client):\n    response = client.get(\"/\")\n    assert response.get_data(as_text=True) == \"Hello World!\"\n```\n\n`get_data(as_text=True)` dekodiert den Body zu einem String. Beide Varianten sind korrekt – ich wähle je nachdem, ob ich Rohbytes oder lesbaren Text prüfen will. Was ich in diesem Kontext bewusst nicht nutze, ist `response.text`; diese String-Eigenschaft kam erst mit einer späteren Werkzeug-Version und steht bei Flask 1.1 noch nicht zur Verfügung.\n\n## JSON-Antworten prüfen\n\nDie meisten realen Endpoints liefern kein `Hello World!`, sondern JSON. Hier zeigt sich der zweite große Vorteil des Test-Clients. Statt den Rohbody selbst zu parsen, ruft man `response.get_json()` auf und bekommt bereits eine fertige Python-Struktur – ein `dict` oder eine `list`. Das trennt zwei Prüfebenen sauber voneinander: der Body als Bytes für den exakten Rohvergleich und die deserialisierte Struktur für die inhaltliche Prüfung.\n\nNehmen wir einen Endpoint, der ein JSON-Objekt entgegennimmt, es um Felder ergänzt und zurückgibt – ähnlich dem `/json`-Beispiel aus meinem Repository:\n\n```python\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\n\n@app.route(\"/json\", methods=[\"POST\"])\ndef json_endpoint():\n    payload = request.json\n    result = {\"username\": \"John Doe\", \"role\": payload.get(\"role\")}\n    return jsonify(result)\n```\n\nDer zugehörige Test schickt JSON hin und prüft die deserialisierte Antwort:\n\n```python\ndef test_json_endpoint(client):\n    # Act\n    response = client.post(\"/json\", json={\"role\": \"admin\"})\n\n    # Assert\n    assert response.status_code == 200\n    data = response.get_json()\n    assert data[\"username\"] == \"John Doe\"\n    assert data[\"role\"] == \"admin\"\n```\n\nEin Detail lohnt die Erwähnung: Der `json=`-Parameter beim Client setzt automatisch den Content-Type auf `application/json` und serialisiert das Dictionary. Man muss also weder Header von Hand setzen noch selbst `json.dumps` aufrufen. Auf der Antwortseite nimmt `get_json()` einem das Parsen ab. Der Test bleibt dadurch nah an der eigentlichen Aussage über das Verhalten des Endpoints.\n\n## Fehlerfälle gehören dazu\n\nEin häufiger Fehler, der mir in Code-Reviews begegnet, ist, nur den Happy Path zu testen. Ein Endpoint, der bei korrekter Eingabe funktioniert, aber bei falscher Eingabe still das Falsche tut, ist nicht zuverlässig. Deshalb gehört zu jedem ernstgemeinten Testsatz mindestens ein Fehlerfall. Der einfachste ist die unbekannte Route:\n\n```python\ndef test_unknown_route_returns_404(client):\n    response = client.get(\"/does-not-exist\")\n    assert response.status_code == 404\n```\n\nWas ich mindestens abdecke, lässt sich in eine kurze Liste fassen:\n\n- Der Status-Code – erwarte ich `200`, `202`, `201` oder einen bewussten Fehlercode?\n- Der Response-Body beziehungsweise die JSON-Struktur – stimmen die Felder und Werte?\n- Die Fehlerfälle – etwa `404` für unbekannte Routen und `400` für ungültige Eingaben.\n\nDiese drei Ebenen sind nicht viel Arbeit, verändern aber die Aussagekraft eines Testsatzes grundlegend. Ein Test, der nur den Status prüft, übersieht falsche Inhalte. Ein Test, der nur den Body prüft, übersieht einen falschen Status. Zusammen ergeben sie ein belastbares Bild.\n\n## Der Fallstrick: geteilter Zustand\n\nJetzt kommen wir zu dem Problem, das ich weiter oben angedeutet habe und das in der Praxis die meisten schwer auffindbaren Testfehler verursacht: geteilter Zustand zwischen Tests. Solange eine App zustandslos ist, teilen sich alle Tests gefahrlos dieselbe Instanz. Sobald die App aber Daten hält – eine Liste im Speicher, ein globales Dictionary, eine Datenbank –, wird die Reihenfolge der Tests plötzlich relevant.\n\nStell dir einen Endpoint vor, der Einträge in einer modul-globalen Liste sammelt. Ein Test legt einen Eintrag an, ein anderer zählt die Einträge. Läuft der Zähl-Test nach dem Anlege-Test, sieht er einen Eintrag mehr als erwartet. Läuft er davor, sieht er null. Solche Tests sind mal grün, mal rot, je nach Reihenfolge – und nichts ist frustrierender als ein Test, der ohne Codeänderung mal so und mal so ausgeht.\n\nDie Lösung liegt in den Fixtures. Statt eine globale App zu importieren, baue ich sie pro Test frisch auf. pytest-Fixtures haben standardmäßig den Geltungsbereich `function`, das heißt, sie werden für jeden Testaufruf neu ausgeführt. Wenn die Fixture bei jedem Aufruf eine App mit leerem Zustand liefert, kann kein Test den nächsten beeinflussen.\n\n## Datenbanken sauber isolieren\n\nDasselbe Problem eine Nummer größer ist die nicht isolierte Datenbank. Schreiben Tests in dieselbe Datenbank, ohne aufzuräumen, kommen sie sich genauso in die Quere wie bei der globalen Liste. Ein Test legt einen Nutzer an, der nächste findet ihn vor und rechnet nicht damit.\n\nDie saubere Antwort ist Setup und Teardown rund um das `yield` der Fixture. Alles vor dem `yield` richtet den Zustand ein, alles danach räumt auf. In der Praxis nutze ich für Tests gern eine frische temporäre SQLite-Datenbank pro Test oder ein Transaktions-Rollback am Ende. Das Muster sieht schematisch so aus:\n\n```python\nimport pytest\nfrom myapp import create_app, db\n\n\n@pytest.fixture\ndef app():\n    app = create_app({\"TESTING\": True, \"DATABASE\": \":memory:\"})\n\n    with app.app_context():\n        db.create_all()      # Setup: fresh schema\n        yield app\n        db.drop_all()        # Teardown: clean up\n\n\n@pytest.fixture\ndef client(app):\n    return app.test_client()\n```\n\nDer Kern ist das `yield` in der Mitte. Davor steht das Anlegen des Schemas, danach das Aufräumen. Jeder Test bekommt so eine leere Datenbank, arbeitet darin und hinterlässt keine Spuren für den nächsten. Das ist etwas mehr Aufwand als eine geteilte Datenbank, aber es ist der Unterschied zwischen Tests, denen ich vertraue, und Tests, die ich bei jedem roten Lauf erst einmal misstrauisch anschaue.\n\n## Was am Ende zählt\n\nDer Test-Client von Flask macht das Absichern von Endpoints zu einer Fleißaufgabe statt zu einem Infrastrukturthema. Es braucht keinen laufenden Server und keine Netzwerkbibliothek, nur `app.test_client()`, ein paar Fixtures und die Disziplin, Status, Body und Fehlerfälle gemeinsam zu prüfen.\n\nDie drei Ideen, die sich für mich bewährt haben, sind schlicht. Erstens: in-process testen, ohne Server – dann sind die Tests schnell und unabhängig von Port und Netzwerk. Zweitens: pytest-Fixtures nutzen, damit jeder Test eine frische, konfigurierte App bekommt – die beste Verteidigung gegen geteilten Zustand. Drittens: bei allem, was Daten hält, an Setup und Teardown denken, sonst hängt das Ergebnis an der Testreihenfolge. Damit lässt sich die WSGI-Anwendung verlässlich gegen ihre HTTP-Semantik prüfen. Den realen Weg über WSGI-Server, Reverse Proxy, TLS und Netzwerk deckt diese Testebene bewusst nicht ab.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-python.html\n- Repository `introduction-python`, `web/4-testing.md` sowie die Beispiele `examples/5-testing.py`, `examples/app.py` und `examples/3-json.py`\n- Flask, Testing Flask Applications: https://flask.palletsprojects.com/en/1.1.x/testing/\n- Flask, API-Referenz zu `Flask.test_client` und der `TESTING`-Config: https://flask.palletsprojects.com/en/1.1.x/api/\n- Werkzeug, Test-Utilities und Response-Objekt (`get_json`, `data`): https://werkzeug.palletsprojects.com/en/stable/test/\n- pytest, Fixtures: https://docs.pytest.org/en/6.2.x/fixture.html\n- Flask-Tutorial, Test Coverage: https://flask.palletsprojects.com/en/1.1.x/tutorial/tests/"},{"slug":"react-zustand-erhalten-und-zuruecksetzen","title":"State erhalten und zurücksetzen: React und die Position im Baum","summary":"React speichert State nicht in der Komponente, sondern an ihrer Position im Render-Baum – wer das versteht, steuert Erhalt und Reset absichtlich statt zufällig.","tags":["Software","Frontend","React"],"published_at":"2021-01-26T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/react-zustand-erhalten-und-zuruecksetzen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/23c1369a4d37d384192e8f5eb8776e0f7c167c1aa2be26b0e4e32c2f7ba0335f/react-zustand-erhalten-und-zuruecksetzen-vorlesen.mp3","audio_duration_secs":603,"text":"# State erhalten und zurücksetzen: React und die Position im Baum\n\nZwei Angaben entscheiden darüber, ob der State einer Komponente eine Aktualisierung übersteht: die Position im Render-Baum und der Komponententyp, der an dieser Position steht. Bleiben beide gleich, reicht React den gemerkten State weiter. Ändert sich eine von beiden, ist er verworfen. Weitere Kriterien gibt es nicht – auch keine Ausnahme für „aber es ist doch dieselbe Komponente“.\n\nRätselhaft wird das nur, weil sich ein anderes Bild aufdrängt: Der State stecke in der Komponente, so wie eine Variable in einem Objekt steckt. Nach diesem Bild müsste `useState` seinen Wert dort halten, wo die Funktion steht. Das tut React nicht. Beim Aktualisieren vergleicht es zwei Bäume – den alten und den neuen – und leitet aus den Unterschieden ab, was am DOM zu tun ist ([Wie React den Baum rendert](https://www.mikebild.dev/de/blog/wie-react-den-baum-rendert-und-aktualisiert/)). Genau dieser Vergleich entscheidet auch über den State. Aus dem falschen Bild wachsen zwei Sorten von Bugs: mal geht ein State verloren, den man behalten wollte, mal bleibt einer kleben, den man loswerden wollte.\n\n## Der State hängt an der Position, nicht an der Komponente\n\nDas richtige Bild ist dieses: React speichert den State nicht in der Komponente, sondern an ihrer Position im Render-Baum. Jede Stelle im Baum – erstes Kind eines Containers, zweites Kind, das Element hinter dieser Bedingung – ist eine Adresse. An dieser Adresse merkt sich React, welche Komponente dort zuletzt stand und welchen State sie hatte. Steht bei der nächsten Aktualisierung dieselbe Komponente wieder an derselben Adresse, reicht React ihr den gemerkten State erneut. Steht dort etwas anderes – oder gar nichts mehr –, wirft React den State dieser Adresse weg.\n\nDaraus folgt die ganze Regel in einem Satz: Gleiche Komponente an gleicher Position behält ihren State. Ändert sich an einer Position der Typ, oder verschwindet die Position, wird der State dort verworfen. Alles Weitere in diesem Beitrag ist nur diese eine Regel, angewendet auf Fälle, die einem sonst rätselhaft vorkommen.\n\n## Beispiel A: zwei Counter, zwei Positionen\n\nNehmen wir zwei gleiche Komponenten nebeneinander. Beide sind derselbe `Counter`, geschrieben aus derselben Funktion:\n\n```jsx\nfunction Counter({ label }) {\n  const [count, setCount] = useState(0);\n  return (\n    <button onClick={() => setCount(count + 1)}>\n      {label}: {count}\n    </button>\n  );\n}\n\nfunction App() {\n  return (\n    <div>\n      <Counter label=\"left\" />\n      <Counter label=\"right\" />\n    </div>\n  );\n}\n```\n\nKlickt man den linken hoch, bleibt der rechte stehen. Das irritiert zunächst, denn es ist doch dieselbe Komponente – sollte sie sich nicht denselben State teilen? Nein, und die Positionsregel erklärt sofort, warum. Der linke `Counter` sitzt an der Adresse „erstes Kind des `div`“, der rechte an „zweites Kind“. Das sind zwei Adressen, also zwei getrennte States. Dass beide aus derselben Funktion stammen, spielt keine Rolle – die Funktion ist nur die Vorlage, der State hängt an der Position.\n\nJetzt der Fall, der den Punkt wirklich schärft. Wir blenden den rechten Zähler bedingt aus und wieder ein:\n\n```jsx\nfunction App() {\n  const [showRight, setShowRight] = useState(true);\n  return (\n    <div>\n      <Counter label=\"left\" />\n      {showRight && <Counter label=\"right\" />}\n      <button onClick={() => setShowRight(!showRight)}>toggle right</button>\n    </div>\n  );\n}\n```\n\nMan zählt den rechten auf 7 hoch, schaltet ihn aus, schaltet ihn wieder ein – und er steht bei 0. Der State ist weg, unwiederbringlich. Solange `showRight` falsch ist, steht an der zweiten Position nichts. React sieht eine Position, die verschwunden ist, und räumt ihren State ab. Kommt der `Counter` zurück, ist es aus Sicht von React eine frisch besetzte Adresse, kein Wiedersehen. Der State war nie „im Zähler“ – er war an eine Position gebunden, die zwischenzeitlich nicht existierte.\n\n## Beispiel B: dieselbe Position, anderer Typ\n\nDer Gegenfall zeigt dieselbe Regel von der anderen Seite. Solange an einer Position derselbe Komponententyp steht, überlebt der State jede Änderung der Props. Man kann einem `Counter` bei jeder Aktualisierung ein anderes `label` geben, seine Farbe umstellen, ihn von aktiv auf inaktiv schalten – der Zählerstand bleibt, weil an der Position weiter derselbe Typ steht. Props formen die Komponente neu, sie setzen sie nicht zurück.\n\nKippt an derselben Stelle dagegen der Typ, ist der State weg. Genau hier greift die Typ-Regel der Reconciliation, die ich in [Wie React den Baum rendert](https://www.mikebild.dev/de/blog/wie-react-den-baum-rendert-und-aktualisiert/) beschrieben habe: Trifft React an einer Position auf einen anderen Elementtyp als vorher, baut es den alten Teilbaum ab und den neuen von Grund auf neu auf – samt State. Ein verbreiteter Auslöser sieht harmlos aus:\n\n```jsx\nfunction Panel({ editing }) {\n  if (editing) {\n    return <input defaultValue=\"draft\" />;\n  }\n  return <p>draft</p>;\n}\n```\n\nAn dieser Position wechselt der Typ zwischen `<input>` und `<p>`. Jeder Wechsel wirft den alten Knoten weg – der `<input>` verliert seinen getippten Text, weil beim nächsten Umschalten dort ein `<p>` steht und danach ein ganz neuer `<input>`. Dasselbe passiert, wenn an einer Stelle mal die eine, mal eine andere Komponente gerendert wird: verschiedene Typen an einer Adresse heißt für React verschiedene Dinge, und Verschiedenes teilt sich keinen State.\n\n```mermaid\nflowchart TB\n  subgraph erhalten[\"gleiche Position, gleicher Typ\"]\n    A1[\"Counter an Position 2<br/>count = 7\"] --> A2[\"neue Props<br/>Typ bleibt Counter\"]\n    A2 --> A3[\"count = 7<br/>bleibt erhalten\"]\n  end\n  subgraph reset[\"gleiche Position, anderer Typ\"]\n    B1[\"p an Position 2\"] --> B2[\"jetzt input<br/>an Position 2\"]\n    B2 --> B3[\"alter Teilbaum weg,<br/>Zustand verworfen\"]\n  end\n```\n\n## State absichtlich zurücksetzen\n\nBis hierher klingt das nach einer Falle. Man kann die Regel aber umdrehen und sie zum Werkzeug machen. Manchmal will man einen Reset ja ausdrücklich – und dann sagt einem die Positionsregel genau, wie man ihn bestellt.\n\nDer grobe Weg: die Komponente an einer anderen Position rendern. Zwei Zweige eines `if`, die scheinbar denselben `Counter` zeigen, sind aus Sicht von React zwei verschiedene Positionen, wenn sie an unterschiedlichen Stellen im Baum hängen; wer zwischen ihnen umschaltet, bekommt jedes Mal einen frischen State. Das funktioniert, ist aber grobschlächtig und leicht versehentlich ausgelöst.\n\nDer elegante Weg führt über den `key`. Ein `key` sagt React ausdrücklich: „Das hier ist ein bestimmtes Element, unterscheide es von seinen Nachbarn.“ Aus [JSX genau](https://www.mikebild.dev/de/blog/jsx-genau-die-regeln-hinter-dem-markup/) kennt man Keys als das, was Listen stabil macht, damit React beim Umsortieren die Elemente wiedererkennt. Genau dieselbe Fähigkeit taugt aber auch außerhalb von Listen zum gezielten Reset. Ändert sich der `key` eines Elements, behandelt React es als ein neues Element an dieser Position – der alte Teilbaum wird abgebaut, der State frisch angelegt.\n\nDer Klassiker dafür ist ein Formular, das pro ausgewähltem Datensatz frisch starten soll:\n\n```jsx\nfunction ContactEditor({ selectedId }) {\n  return <Form key={selectedId} contactId={selectedId} />;\n}\n```\n\nOhne den `key` bliebe `Form` an derselben Position stehen; wechselt `selectedId`, bekäme das Formular nur neue Props, und der halb getippte Text des vorigen Kontakts stünde weiter im Feld. Mit `key={selectedId}` wechselt bei jeder neuen Auswahl der Schlüssel, React sieht ein neues Element, und das Formular startet leer – ohne dass man ein einziges Feld von Hand zurücksetzt. Der `key` erledigt genau die Arbeit, die man sonst mit einem Effect und viel `setState` nachbauen würde.\n\n```mermaid\nflowchart LR\n  S1[\"selectedId = 1\"] --> F1[\"Form key=1<br/>frischer Zustand\"]\n  S2[\"selectedId = 2\"] --> F2[\"Form key=2<br/>neues Element,<br/>Formular leer\"]\n  S3[\"selectedId = 2<br/>Feld getippt\"] --> F3[\"Form key=2<br/>Zustand bleibt\"]\n```\n\n## State erhalten, obwohl sich die Struktur ändert\n\nDas Gegenstück ist der Fall, in dem man den State behalten will, die Umgebung sich aber ändert. Hier gilt es, Position und Typ stabil zu halten, auch wenn drumherum umgebaut wird. Wer eine Komponente je nach Bedingung mal in den einen, mal in den anderen Zweig schreibt, verschiebt sie damit an eine andere Position – und wundert sich über den Reset. Oft lässt sich das vermeiden, indem man die Komponente an einer festen Stelle rendert und nur ihre Props umschaltet, statt sie im Baum wandern zu lassen.\n\nUnd wenn ein State einen echten Umbau überleben muss, gehört er meist nicht dorthin, wo er gerade steht. Dann zieht man ihn nach oben, zu einem gemeinsamen Elternteil, der von der Umstrukturierung unberührt bleibt. Der State hängt dann an einer Position, die stabil ist, und die Kinder darunter dürfen kommen und gehen, ohne ihn mitzureißen. Das ist dieselbe Bewegung wie beim klassischen Hochziehen von State. Der Grund ist hier ein anderer: Es geht ums Überleben einer Strukturänderung, nicht ums Teilen zwischen Geschwistern.\n\n## Der fieseste Bug: eine Komponente in einer Komponente\n\nDen unangenehmsten Fall hebe ich mir für den Schluss auf, weil er die Positionsregel auf die Probe stellt und weil er in echten Codebasen erstaunlich oft steckt. Er sieht so aus:\n\n```jsx\nfunction Page() {\n  function Sidebar() {\n    const [open, setOpen] = useState(true);\n    return <button onClick={() => setOpen(!open)}>{open ? \"hide\" : \"show\"}</button>;\n  }\n\n  return (\n    <div>\n      <Sidebar />\n    </div>\n  );\n}\n```\n\n`Sidebar` ist innerhalb von `Page` definiert. Das wirkt ordentlich – die Komponente steht ja da, wo sie gebraucht wird. Der Effect ist trotzdem verheerend. Bei jedem Rendern von `Page` läuft die Zeile `function Sidebar()` erneut und erzeugt eine neue Funktion. Es ist bei jedem Durchlauf buchstäblich eine andere Funktion mit einer anderen Identität. Für React ist die Identität der Funktion aber der Typ. Ein anderer Typ an einer Position heißt: alter Teilbaum weg, neuer aufgebaut, State verworfen.\n\nIn der Praxis sieht das gespenstisch aus. Jede noch so kleine Aktualisierung von `Page` setzt `open` zurück, das Eingabefeld darin verliert seinen Text, und wer gerade in einem Feld tippt, verliert mitten im Wort den Fokus, weil das DOM-Element unter ihm ersetzt wurde. Man sucht den Fehler überall – im Effect, im Event-Handler, im State-Management – nur nicht an der Stelle, wo die Komponente definiert ist. Dabei ist es genau das: eine bei jedem Rendern neu geborene Funktionsidentität, die React zwingt, den ganzen Teilbaum wegzuwerfen.\n\nDie Lösung ist unspektakulär und deshalb leicht zu übersehen: Komponenten gehören auf die Modulebene, nicht in den Rumpf einer anderen Komponente. Dann existiert `Sidebar` genau einmal, behält über alle Aktualisierungen dieselbe Identität, steht als derselbe Typ an derselben Position – und ihr State bleibt. Braucht die innere Komponente Daten von außen, reicht man sie als Props durch, nicht über die verschachtelte Definition.\n\n## Ort und Zeit im Baum\n\nState in React ist an Ort und Zeit gebunden: an eine Position im Render-Baum und an die Dauer, die diese Position mit demselben Typ besetzt bleibt. Fast alles, was am Verhalten von State rätselhaft wirkt, klärt sich, sobald man aufhört zu fragen „in welcher Komponente steht das?“ und stattdessen fragt „an welcher Position im Baum, und steht dort noch derselbe Typ?“.\n\nWer das verinnerlicht, hat beide Richtungen in der Hand. Einen Reset bestellt man mit einem `key` oder einer neuen Position, statt Felder einzeln zurückzusetzen. Erhalt sichert man, indem man Position und Typ stabil hält und den State notfalls nach oben zieht. Und den fiesesten Bug vermeidet man, bevor er entsteht, indem Komponenten dort stehen, wo sie hingehören – auf der Modulebene. Aus etwas, das sich wie Magie anfühlte, wird so eine Entscheidung, die man absichtlich trifft statt zufällig zu erleiden.\n\n## Weiterführende Quellen\n\n- Repository introduction-react: https://github.com/mikebild/introduction-react\n- React, „Preserving and Resetting State“: https://react.dev/learn/preserving-and-resetting-state\n- React, „State as a Snapshot“: https://react.dev/learn/state-as-a-snapshot\n- React, „Render and Commit“: https://react.dev/learn/render-and-commit"},{"slug":"cognito-userpool-auth-serverless","title":"Serverless-Auth mit Cognito User Pools","summary":"Cognito User Pools als managed Identity-Backend: Sign-up, Confirm und Sign-in ohne eigenen Auth-Server, die drei Tokens richtig verstanden und ein API-Gateway-Endpoint, den ein Cognito-Authorizer absichert.","tags":["Software","AWS","Cloud"],"published_at":"2021-01-19T10:00:00+00:00","updated_at":"2026-07-13T10:01:52.666784+00:00","url":"https://www.mikebild.dev/de/blog/cognito-userpool-auth-serverless/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/a2b833d93b73891a6bfab0c63063011f4e70d077aa3ec3f49f6b06e4de6d41db/cognito-userpool-auth-serverless-vorlesen.mp3","audio_duration_secs":749,"text":"Cognito nimmt einem das Passwort-Handling ab – die Entscheidung darüber, wem die Identitäten der Nutzer gehören, nimmt es einem nicht ab. Diese beiden Dinge werden regelmäßig in einen Topf geworfen. Der Aufruf `signUp()` ist an einem Nachmittag integriert; die `sub`, die Cognito dabei vergibt, wandert danach als Fremdschlüssel durch jede Tabelle und bleibt Jahre. Die Integration ist billig, das Eigentum an den Identitäten teuer.\n\nPasswörter speichern, Bestätigungsmails verschicken, Tokens signieren und rotieren – gelöste Probleme, und selten das, was ein Produkt von der Konkurrenz unterscheidet. Ein selbstgebauter Auth-Server ist keine Differenzierung, sondern eine Fläche, die dauerhaft gepflegt und abgesichert werden will. AWS hat für diese Verantwortung einen managed Baustein: Cognito User Pools. Interessant ist daran weniger der API-Aufruf als der Trade-off dahinter: Welche Betriebsverantwortung gebe ich ab, und welchen Preis zahle ich bei einem späteren Wechsel? Genau den nehme ich hier auseinander – an einem durchgehenden Beispiel, das den Weg von der Registrierung bis zum abgesicherten Endpoint zeigt.\n\nServerless heißt für mich vor allem, Betriebsverantwortung dort abzugeben, wo sie keinen Wettbewerbsvorteil schafft. Ein eigener Auth-Server ist selten einer – er ist eine Angriffsfläche, die man pflegen muss.\n\n## Was ein User Pool eigentlich ist\n\nEin Cognito User Pool ist ein managed Verzeichnis für deine Endnutzer plus die komplette Maschinerie drumherum: Registrierung, E-Mail- oder SMS-Verifizierung, Anmeldung, Passwort-Reset und die Ausstellung von Tokens. Deine Anwendung speichert keine Passwörter mehr – sie redet nur noch mit Cognito. Das ist der entscheidende Perspektivwechsel: Nicht deine App entscheidet, ob ein Passwort stimmt, sondern der User Pool. Deine App bekommt am Ende nur ein signiertes Token in die Hand und muss diesem Token vertrauen können.\n\nZwei Objekte musst du dabei auseinanderhalten. Der **User Pool** selbst ist das Verzeichnis samt Policies – Passwortregeln, welche Attribute Pflicht sind, welche automatisch verifiziert werden. Der **App Client** ist die Anwendung, die sich beim Pool authentifiziert; er legt fest, welche Auth-Flows erlaubt sind und wie lange ein Refresh-Token gültig bleibt. Ein Pool kann mehrere App Clients haben – etwa einen fürs Web und einen für die native App.\n\nSo sieht das minimal in CDK aus. Ich bleibe bewusst bei den L1-Constructs (`Cfn...`), weil sie eins zu eins auf die CloudFormation-Ressourcen abbilden und man dadurch genau sieht, was entsteht:\n\n```js\nconst {\n  CfnUserPool,\n  CfnUserPoolClient,\n  CfnUserPoolGroup,\n} = require('@aws-cdk/aws-cognito');\n\nconst userPool = new CfnUserPool(this, 'UserPool', {\n  userPoolName: 'UserPool',\n  adminCreateUserConfig: {\n    allowAdminCreateUserOnly: false,\n  },\n  policies: {\n    passwordPolicy: {\n      minimumLength: 6,\n    },\n  },\n  schema: [\n    {\n      attributeDataType: 'String',\n      name: 'email',\n      required: true,\n    },\n  ],\n  autoVerifiedAttributes: ['email'],\n});\n\nconst userPoolClient = new CfnUserPoolClient(this, 'UserPoolClient', {\n  clientName: 'UserPoolClient',\n  explicitAuthFlows: ['ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],\n  refreshTokenValidity: 30,\n  userPoolId: userPool.ref,\n});\n```\n\n`autoVerifiedAttributes: ['email']` sorgt dafür, dass Cognito nach der Registrierung automatisch einen Bestätigungscode per E-Mail verschickt. `refreshTokenValidity: 30` setzt die Lebensdauer des Refresh-Tokens auf 30 Tage – dazu gleich mehr. Wer es lieber mit den höheren L2-Constructs (`UserPool`, `UserPoolClient`) baut, bekommt bequemere Defaults; für den Einstieg finde ich die explizite L1-Variante lehrreicher. Wenn du dich fragst, warum ich Infrastruktur überhaupt in einer Programmiersprache beschreibe statt in YAML: Das habe ich in [CDK: Infrastruktur als Programmiersprache](https://www.mikebild.dev/de/blog/cdk-infrastruktur-als-programmiersprache/) begründet.\n\n## Sign-up, Confirm, Sign-in\n\nDer Lebenszyklus eines Nutzers hat drei Stationen. Auf dem Client – oder wie in meinem Beispiel in einem dünnen Node-Backend – nutze ich dafür `amazon-cognito-identity-js`, das offizielle JavaScript-SDK für User Pools. Registrierung, Bestätigung, Anmeldung:\n\n```ts\nimport {\n  CognitoUserPool,\n  CognitoUser,\n  AuthenticationDetails,\n  CognitoUserAttribute,\n} from 'amazon-cognito-identity-js';\n\nconst userPool = new CognitoUserPool({\n  UserPoolId: process.env.USER_POOL_ID,\n  ClientId: process.env.USER_POOL_CLIENT_ID,\n});\n\n// 1) sign-up: create the user, Cognito emails a confirmation code\nuserPool.signUp(\n  username,\n  password,\n  [new CognitoUserAttribute({ Name: 'email', Value: username })],\n  [],\n  (err, result) => {\n    /* result.user is created but not yet confirmed */\n  }\n);\n\n// 2) confirm: user submits the code from the email\nconst cognitoUser = new CognitoUser({ Username: username, Pool: userPool });\ncognitoUser.confirmRegistration(code, true, (err, result) => {\n  /* result === 'SUCCESS' */\n});\n\n// 3) sign-in: authenticate and receive tokens\ncognitoUser.authenticateUser(\n  new AuthenticationDetails({ Username: username, Password: password }),\n  {\n    onSuccess(session) {\n      const idToken = session.getIdToken().getJwtToken();\n      const accessToken = session.getAccessToken().getJwtToken();\n      // refresh token is available too, but opaque\n    },\n    onFailure(err) {\n      /* wrong password, unconfirmed user, ... */\n    },\n  }\n);\n```\n\nDer wichtige Punkt: Zwischen Schritt eins und drei liegt der Confirm-Schritt. Ein frisch registrierter Nutzer ist zunächst `UNCONFIRMED` und kann sich nicht anmelden, bis er den per E-Mail zugestellten Code bestätigt hat. Diese E-Mail schickt Cognito selbst.\n\n## Die drei Tokens – und warum „drei JWT“ falsch ist\n\nNach erfolgreicher Anmeldung liefert Cognito genau drei Tokens zurück. Das klingt banal, aber hier sitzt ein hartnäckiger Denkfehler: Es heißt oft, man bekomme „drei JWT“. Das stimmt nicht. Zwei davon sind JWT, eines ist es ausdrücklich nicht.\n\nDas ID-Token ist ein JWT und trägt die Identität des Nutzers – Claims wie `sub`, `email` und `cognito:username`. Es beantwortet die Frage „wer ist dieser User?“ und ist das Standard-Token für den klassischen API-Gateway-Cognito-Authorizer. Das Access-Token ist ebenfalls ein JWT, trägt aber OAuth-Scopes und beantwortet „was darf dieser Client?“; es kommt ins Spiel, sobald du scope-basierte Autorisierung mit Resource Servern nutzt. Das Refresh-Token schließlich ist kein JWT, sondern ein verschlüsselter, nur für Cognito lesbarer String. Es ist langlebig – in meiner Konfiguration 30 Tage – und dient einzig dazu, ohne erneute Passworteingabe frische ID- und Access-Tokens einzutauschen.\n\nWarum ist das mehr als Begriffsklauberei? Weil man ein JWT lokal verifizieren kann – Signatur prüfen, Ablauf prüfen, fertig – ein opakes Token dagegen nicht. Und wer im Frontend das falsche Token an die API schickt, bekommt ein `401`, ohne zu verstehen warum. Für den REST-Cognito-Authorizer ohne konfigurierte Scopes gehört das ID-Token in den Header. Erst wenn du `authorizationScopes` konfigurierst, erwartet das Gateway das Access-Token. Genau diese Verwechslung ist eine der häufigsten Fehlerquellen.\n\nEin ID-Token, dekodiert, sieht im Kern so aus – man erkennt Issuer, Audience und den Verwendungszweck:\n\n```json\n{\n  \"sub\": \"a1b2c3d4-....\",\n  \"email\": \"user@example.com\",\n  \"cognito:username\": \"user@example.com\",\n  \"aud\": \"the-app-client-id\",\n  \"iss\": \"https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_ABC123\",\n  \"token_use\": \"id\",\n  \"exp\": 1611060000\n}\n```\n\n## Den Endpoint absichern – ohne eigenen Verifikationscode\n\nJetzt kommt der Teil, an dem sich managed Auth wirklich auszahlt. Ich könnte in jeder Lambda das Token selbst prüfen: das JWKS-Dokument des Pools laden, den passenden Schlüssel über die `kid` aus dem Token-Header finden, in ein PEM umwandeln und die Signatur verifizieren. In einem klassischen Express-Backend habe ich genau das gebaut, und es ist lehrreich, es einmal von Hand gesehen zu haben:\n\n```ts\nexport async function validateToken({ token, region, userPoolId }) {\n  const response = await fetch(\n    `https://cognito-idp.${region}.amazonaws.com/${userPoolId}/.well-known/jwks.json`\n  );\n  const { keys } = await response.json();\n\n  const decoded = jwt.decode(token, { complete: true });\n  if (!decoded) throw new Error('Invalid JWT');\n\n  const key = keys.find((k) => k.kid === decoded.header.kid);\n  if (!key) throw new Error('Invalid JWT');\n\n  const pem = jwkToPem({ kty: key.kty, n: key.n, e: key.e });\n  return new Promise((resolve, reject) => {\n    jwt.verify(token, pem, (err, payload) =>\n      err ? reject(new Error('Invalid JWT')) : resolve(payload)\n    );\n  });\n}\n```\n\nDas funktioniert – aber es ist Code, den ich schreiben, testen und pflegen muss, und der in jeder abgesicherten Route wieder auftaucht. Genau diese Arbeit nimmt einem der **API-Gateway-Cognito-Authorizer** ab. Man verweist das Gateway auf den User Pool, und es übernimmt die komplette Prüfung managed: Signatur gegen das Pool-JWKS, `exp`, `aud`, `iss`. Kommt ein gültiges Token, wird die Lambda aufgerufen; kommt ein ungültiges oder abgelaufenes, antwortet das Gateway mit `401`, bevor deine Funktion überhaupt startet. Kein eigener Verifikationscode.\n\nIn CDK verbindest du das über einen `CognitoUserPoolsAuthorizer` mit einer REST-API-Route:\n\n```ts\nimport {\n  RestApi,\n  CognitoUserPoolsAuthorizer,\n  AuthorizationType,\n  LambdaIntegration,\n} from '@aws-cdk/aws-apigateway';\n\nconst authorizer = new CognitoUserPoolsAuthorizer(this, 'Authorizer', {\n  cognitoUserPools: [userPool],\n});\n\nconst api = new RestApi(this, 'Api');\nconst items = api.root.addResource('items');\n\nitems.addMethod('GET', new LambdaIntegration(getItemsFn), {\n  authorizer,\n  authorizationType: AuthorizationType.COGNITO,\n});\n```\n\nWer direkt auf der API-Gateway-Ebene arbeitet – etwa in einer OpenAPI-Definition – erkennt dieselbe Konfiguration in der Rohform. Der Authorizer ist vom Typ `cognito_user_pools`, verweist per ARN auf den Pool und liest das Token aus dem `Authorization`-Header:\n\n```json\n{\n  \"type\": \"apiKey\",\n  \"name\": \"Authorization\",\n  \"in\": \"header\",\n  \"x-amazon-apigateway-authtype\": \"cognito_user_pools\",\n  \"x-amazon-apigateway-authorizer\": {\n    \"type\": \"cognito_user_pools\",\n    \"providerARNs\": [\n      \"arn:aws:cognito-idp:eu-central-1:123456789012:userpool/eu-central-1_ABC123\"\n    ],\n    \"identitySource\": \"method.request.header.Authorization\"\n  }\n}\n```\n\nDer Client schickt sein ID-Token also schlicht im `Authorization`-Header mit – in der Praxis meist als `Bearer <idToken>` – und muss sich um nichts weiter kümmern. Der gesamte Ablauf sieht so aus:\n\n```mermaid\nsequenceDiagram\n  participant C as Client\n  participant Cog as Cognito User Pool\n  participant GW as API Gateway\n  participant L as Lambda\n  C->>Cog: sign-in (username / password)\n  Cog-->>C: ID + Access + Refresh tokens\n  C->>GW: GET /items<br/>Authorization: Bearer idToken\n  GW->>Cog: verify JWT via JWKS (signature, exp, aud, iss)\n  GW->>L: invoke (token valid)\n  L-->>C: 200 OK\n```\n\n## Lambda-Trigger: eigene Logik in den Ablauf hängen\n\nManchmal reicht der Standardablauf nicht. Du willst zum Beispiel nur E-Mail-Adressen einer bestimmten Firmendomain zulassen oder beim Registrieren zusätzliche Daten in eine eigene Tabelle schreiben. Dafür bietet der User Pool **Lambda-Trigger** – Einsprungspunkte an definierten Stellen im Lifecycle. 2021 verfügbar sind unter anderem `PreSignUp`, `PostConfirmation`, `PreAuthentication`, `PostAuthentication`, `PreTokenGeneration` und `CustomMessage`.\n\nEin `PreSignUp`-Trigger läuft, bevor Cognito einen neuen Nutzer anlegt. Hier prüfe ich etwa die E-Mail-Domain:\n\n```js\nexports.handler = async (event) => {\n  const email = event.request.userAttributes.email || '';\n  if (!email.endsWith('@example.com')) {\n    throw new Error('Only example.com addresses are allowed');\n  }\n  // do NOT auto-confirm: keep the email verification step intact\n  event.response.autoConfirmUser = false;\n  return event;\n};\n```\n\nZwei Dinge sind hier entscheidend, und beide sind klassische Stolperfallen. Erstens: Der Trigger muss das `event`-Objekt am Ende unverändert – bis auf die gewollten Response-Felder – **zurückgeben**. Vergisst du das `return event`, bricht der Flow ab. Zweitens: `autoConfirmUser` und die verwandten Auto-Verify-Felder sind mächtig. Setzt du sie versehentlich auf `true`, umgeht ein Nutzer den E-Mail-Bestätigungsschritt komplett – und du wunderst dich später über unbestätigte Adressen im Pool.\n\nDas kleine Komponentenbild dazu:\n\n```mermaid\nflowchart LR\n  Pool[User Pool + App Client] -->|JWKS| GW[API Gateway<br/>Cognito Authorizer]\n  GW --> Fn[Lambda]\n  Trigger[PreSignUp trigger] -.-> Pool\n```\n\n## Der eigentliche Trade-off: managed Identity gegen Anbieterbindung\n\nAlles bisher Gezeigte ist die Habenseite: kein Passwort-Handling, keine selbstgebaute Token-Ausstellung, keine Verifikationslogik in jeder Route. Diese Verantwortung samt Angriffsfläche wandert zu AWS – meist der schwerere Posten. Die Sollseite verschweige ich trotzdem nicht: Bindung an den Anbieter.\n\nDiese Bindung ist konkreter, als sie zunächst wirkt. Deine Tokens sind gegen das JWKS dieses Pools signiert, dein Client redet über das Cognito-SDK, deine Trigger sind auf Cognitos Event-Struktur zugeschnitten – und am schwersten wiegend: Die `sub` jedes Nutzers ist eine von Cognito vergebene UUID, die als Fremdschlüssel quer durch deine Datenbanken wandert. Nicht der Login-Code klebt, sondern diese Identitäten.\n\nZwei Grenzfälle machen das greifbar. Cognito gibt keine Passwort-Hashes heraus: Wer wegzieht, kann seine Nutzer nicht still mitnehmen – entweder alle setzen ihr Passwort zurück, oder du migrierst sie lazy beim nächsten Login über einen `UserMigration`-Trigger auf dem Zielsystem. Und alles jenseits des Standards – Custom Attributes, SRP-Annahmen im Client, MFA-Konfiguration – ist Cognito-Vokabular, das ein anderer Anbieter nicht eins zu eins spricht. Das Migrationsrisiko sitzt also nicht im Auth-Flow, den man in Tagen neu baut, sondern in den Identitäten und ihren Referenzen, die über Jahre gewachsen sind.\n\nDaraus wird ein klares Entscheidungsraster. Für Standard-Anmeldung mit E-Mail und Passwort, kleine bis mittlere Teams ohne dediziertes Security-Personal und ohne Multi-Cloud-Zwang gewinnt managed Identity fast immer – der eingesparte Betrieb schlägt das Bindungsrisiko deutlich. Neu abwägen würde ich, sobald ein bestehender Identity-Provider im Haus steht, echte Anbieterportabilität gefordert ist oder Datenresidenz vorschreibt, wo Identitäten überhaupt liegen dürfen. Der Trade-off verschwindet nicht – aber man trifft ihn bewusst statt aus Reflex, und man trifft ihn am Anfang, wo er noch billig ist.\n\n## Wichtige Abgrenzung: Infrastruktur-Auth ist nicht Resolver-Auth\n\nZum Schluss die Unterscheidung, die in der Praxis am schnellsten verschwimmt. Was der Cognito-Authorizer am API Gateway macht, ist **Infrastruktur- oder Edge-Auth**. Er beantwortet eine einzige, grobe Frage: „Ist der Aufrufer überhaupt authentifiziert?“ Ist das Token gültig, kommt die Anfrage durch – ist es das nicht, fliegt sie mit `401` raus, noch bevor eigener Code läuft. Das ist ein Türsteher, kein Sachbearbeiter.\n\nWas der Authorizer nicht macht: entscheiden, ob *dieser konkrete Nutzer* *dieses konkrete Objekt* sehen oder verändern darf. Darf User A den Kommentar von User B löschen? Darf jemand nur die eigenen Bestellungen abfragen? Das ist feingranulare Autorisierung, und die gehört in deine Anwendungslogik – bei einer GraphQL-API zum Beispiel in den Resolver-Kontext, wo du die Identität aus dem Token gegen die tatsächlichen Datenobjekte prüfst. Der Authorizer liefert dir die verlässliche Identität; was du damit an Rechten durchsetzt, bleibt deine Aufgabe. Diese beiden Ebenen sauber zu trennen, erspart eine Menge Verwirrung – und verhindert den Trugschluss, mit einem Authorizer sei „die Autorisierung erledigt“.\n\nFür das hier gezeigte Setup – User Pool, Standard-Login und ein abgesicherter API-Endpoint – nimmt Cognito viel eigene Infrastruktur ab. Ein erster durchgängiger Pfad lässt sich damit überschaubar aufbauen; produktionsreif wird er erst mit geklärten Recovery-Flows, MFA, Monitoring und feingranularer Autorisierung. Der Authorizer liefert die verlässliche Identität, die Rechte auf konkrete Fachobjekte bleiben bewusst in deiner Anwendungslogik. Den Preis, die Anbieterbindung, zahlst du sehenden Auges – und du triffst die Entscheidung dort, wo sie hingehört: auf der Architekturebene, nicht beiläufig beim ersten API-Aufruf.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-aws-serverless.html\n- Repository mit dem vollständigen Beispiel: `introduction-aws/examples/userpool` (CDK-Stack, Sign-up/Confirm/Login und die manuelle Token-Prüfung als Kontrast zum Authorizer)\n- [Amazon Cognito – Using Tokens with User Pools](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-with-identity-providers.html)\n- [Using the ID Token](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-id-token.html) · [Using the Access Token](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-access-token.html) · [Using the Refresh Token](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-refresh-token.html)\n- [Control access to a REST API using Amazon Cognito user pools](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-integrate-with-cognito.html) · [Authorizer einrichten (ID- vs. Access-Token/Scopes)](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-enable-cognito-user-pool.html)\n- [JWT dekodieren und verifizieren (JWKS)](https://repost.aws/knowledge-center/decode-verify-cognito-json-token)\n- [Customizing user pool workflows with Lambda triggers](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools-working-with-aws-lambda-triggers.html)"},{"slug":"trunk-based-development-und-feature-flags","title":"Trunk-Based Development statt Branch-Friedhof","summary":"Kurze Integrationszyklen mit Feature Flags schlagen langlebige Branches: tägliche Integration ohne Merge-Hölle und ein Release, das zur Entscheidung wird statt zum Deploy-Event.","tags":["Software","Delivery"],"published_at":"2020-11-24T10:00:00+00:00","updated_at":"2026-07-13T08:39:51.028726+00:00","url":"https://www.mikebild.dev/de/blog/trunk-based-development-und-feature-flags/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/5e955e4d2b35e32f306b63c8298c2e37db086fbc74ec4954ba88da166724f4f1/trunk-based-development-und-feature-flags-vorlesen.mp3","audio_duration_secs":677,"text":"Wer Integration aufschiebt, hat den Konflikt nicht vermieden, sondern nur verzinst. Ein Branch namens `feature/redesign`, der sechs Wochen neben `master` herläuft, nimmt jeden Tag ein paar Änderungen auf, die niemand mehr gegen den aktuellen Stand prüft. Der Merge am Schluss ist dann kein Arbeitsschritt mehr, sondern eine Ausgrabung – und niemand kann vorher sagen, wie lange sie dauert. Repositories, in denen ein halbes Dutzend solcher Branches vor sich hin altert, nenne ich in Reviews den Branch-Friedhof.\n\nDer Ausweg führt nicht über das nächste Branching-Modell, sondern über weniger Abstand: täglich in einen gemeinsamen Stand integrieren, ihn jederzeit auslieferbar halten und das Einschalten eines Features vom Ausrollen des Codes trennen. Das klingt nach Git-Kosmetik, betrifft aber Durchlaufzeiten und Fehlerbehebung. Deploy und Release sind zwei verschiedene Dinge – an dieser Trennung hängt der Rest. Sie ist allerdings nicht kostenlos: Jeder Flag erzeugt zusätzliche Zustände, Testaufwand und eine Aufräumaufgabe.\n\n## Die eigentliche Idee: Integrationsdistanz klein halten\n\nFangen wir bei der Kernidee an, nicht beim Werkzeug. Trunk-Based Development – so wie Paul Hammant es auf trunkbaseddevelopment.com beschreibt – bedeutet im Wesentlichen: Alle Entwickler integrieren ihre Arbeit mindestens einmal täglich in einen einzigen gemeinsamen Branch. Nennen wir ihn `main` oder `trunk`. Dieser Branch bleibt jederzeit releasebar. Branches, falls es sie überhaupt gibt, sind kurzlebig – deutlich unter einem Tag.\n\nDer entscheidende Begriff dahinter ist Integrationsdistanz. Jeder Tag, den ein Branch abseits des Trunks lebt, vergrößert die Distanz zwischen dem, woran ich arbeite, und dem, was der Rest des Teams sieht. Diese Distanz ist keine lineare Größe. Sie wächst überproportional, weil sich Änderungen auf beiden Seiten anhäufen und miteinander in Konflikt geraten können. Ein Merge nach sechs Wochen ist nicht sechsmal so teuer wie einer nach einer Woche, sondern ein Vielfaches davon – und vor allem ist er unvorhersehbar.\n\nMartin Fowler bringt das in seinem Begriff der Continuous Integration auf den Punkt, und zwar wörtlicher, als viele es lesen. „Continuous Integration“ heißt tatsächlich fortlaufend integrieren. Ein langlebiger Feature-Branch ist per Definition das Gegenteil: aufgeschobene Integration, deferred integration. Man kann nicht gleichzeitig Continuous Integration betreiben und tagelang auf eigenen Branches sitzen. Das eine schließt das andere aus. In seinen Patterns for Managing Source Code Branches liefert Fowler die passende Taxonomie: Integrationsfrequenz, der Begriff des „healthy branch“, die Mainline. Wer diese Sprache einmal verinnerlicht hat, sieht den Branch-Friedhof sofort.\n\n## Was die Daten sagen – vorsichtig formuliert\n\nIch bin skeptisch gegenüber Methodik-Versprechen, die sich nur auf Bauchgefühl stützen. Deshalb schätze ich die Arbeit von Nicole Forsgren, Jez Humble und Gene Kim in *Accelerate* und den State-of-DevOps-Reports. Dort wird Trunk-Based Development nicht als Meinung behandelt, sondern als statistisch identifizierte Capability.\n\nIch formuliere das bewusst sauber, weil die Versuchung groß ist, hier zu übertreiben. Die Daten zeigen: Höhere Software-Delivery-Performance korreliert mit wenigen aktiven Branches im Repository – Größenordnung drei oder weniger – und mit Merges in den Trunk mindestens einmal pro Tag. Trunk-Based Development ist eine der Praktiken, die Continuous Delivery statistisch prädiziert. Nicht „verursacht garantiert“, sondern prädiziert und korreliert. Dieser Unterschied ist zu wichtig, um ihn beim Zitieren zu verwischen. Aber die Richtung ist eindeutig, und sie deckt sich mit dem, was ich in Teams beobachte: Wer die Integrationsdistanz klein hält, liefert schneller, seltener kaputt und mit weniger Drama.\n\n## Der Denkfehler: Deploy ist nicht Release\n\nJetzt kommt der Teil, an dem viele Diskussionen kippen. „Wenn alle ständig auf den Trunk mergen – wie soll dann jemals etwas Halbfertiges nicht in Produktion landen?“ Diese Frage ist berechtigt, und ihre Antwort ist der eigentliche Hebel dieses ganzen Ansatzes.\n\nDeploy und Release sind zwei verschiedene Events. Deploy heißt: Der Code liegt auf der Produktionsumgebung. Release heißt: Das Verhalten ist für Nutzer aktiv. Feature Toggles – von Fowler in der Bliki beschrieben und von Pete Hodgson in *Feature Toggles (aka Feature Flags)* ausführlich systematisiert – entkoppeln genau diese beiden Dinge. Der Code wird ausgerollt, aber das Feature ist ausgeschaltet. Das Aktivieren wird zu einer Konfigurations- und Business-Entscheidung, nicht zu einem technischen Deploy-Event mit Nachtschicht und Rollback-Plan.\n\n```mermaid\nflowchart LR\n    subgraph friedhof[\"Branch-Friedhof\"]\n        direction TB\n        M0[\"main\"] --> M1[\"main\"]\n        M0 --> B1[\"feature branch<br/>Tag 1\"]\n        B1 --> B2[\"... Tag 20 ...\"]\n        B2 --> B3[\"divergiert weit\"]\n        M1 --> MG((\"dicker<br/>Merge-Konflikt\"))\n        B3 --> MG\n    end\n    subgraph tbd[\"Trunk-Based\"]\n        direction TB\n        T0[\"trunk\"] --> T1[\"+ commit\"]\n        T1 --> T2[\"+ commit\"]\n        T2 --> T3[\"+ commit\"]\n        T3 --> T4[\"trunk bleibt grün\"]\n        T4 -. \"toggle on<br/>= Release\" .-> R((\"Release<br/>als Schalter\"))\n    end\n```\n\nDas Diagramm zeigt den Kern: Links divergiert ein Branch über zwanzig Tage und mündet in einen schmerzhaften Merge. Rechts fließt ein durchgehender Trunk mit vielen kleinen Commits, und die Aktivierung passiert auf einer eigenen Achse – zeitlich entkoppelt vom Merge und vom Deploy. Release wird zum Schalter.\n\nHodgson unterscheidet vier Kategorien von Toggles, und diese Unterscheidung ist praktisch wertvoll, weil jede einen anderen Lifecycle hat. Release Toggles verstecken unfertige Arbeit und sollen kurzlebig sein. Ops Toggles steuern das Betriebsverhalten, etwa um eine teure Funktion unter Last abzuschalten. Experiment Toggles treiben A/B-Tests und passen gut zum Build-Measure-Learn-Gedanken aus Eric Ries’ *Lean Startup*. Permission Toggles schalten Verhalten für bestimmte Nutzergruppen frei. Wer diese vier nicht auseinanderhält, behandelt am Ende jeden Flag gleich – und das rächt sich.\n\n## Wie unfertige Arbeit sicher auf dem Trunk lebt\n\nEin Release Toggle im einfachsten Fall sieht so aus. Wichtig ist, dass der Flag nicht als loses `if` im Fachcode verstreut liegt, sondern hinter einer sauberen Abstraktion:\n\n```typescript\n// The domain code does not know about flag plumbing.\n// It asks a small, well-named abstraction.\nif (features.isEnabled(\"new-checkout-flow\")) {\n    return newCheckoutService.process(cart);\n}\nreturn legacyCheckoutService.process(cart);\n```\n\nAuf der Konfigurationsseite entscheidet sich, ob ein Deploy zugleich ein Release ist. Ein deaktivierter Flag bedeutet: Der Code ist da, aber niemand sieht ihn. Das ist Deploy ohne Release:\n\n```yaml\nfeatures:\n  new-checkout-flow:\n    enabled: false        # deploy != release\n    strategy: canary      # gradual rollout instead of big-bang\n    percentage: 5         # start with 5% of traffic\n```\n\nFür große Umbauten reicht ein einzelner `if` allerdings nicht. Wenn ich ein zentrales Modul ersetze, hilft mir das Muster Branch by Abstraction, das Fowler beschreibt und Paul Hammant zugeschrieben wird. Die Idee: Ich ziehe ein gemeinsames Interface ein, unter dem alte und neue Implementierung gleichzeitig im Trunk stehen. Die Umschaltung erfolgt über eine Factory oder einen Toggle. Statt eines wochenlangen Branches integriere ich ein großes Refactoring in kleinen, täglich integrierten Schritten:\n\n```java\npublic interface PaymentGateway {\n    PaymentResult charge(Order order);\n}\n\n// Both live in the trunk at the same time.\nfinal class LegacyPaymentGateway implements PaymentGateway { /* ... */ }\nfinal class NewPaymentGateway    implements PaymentGateway { /* ... */ }\n\n// A factory selects the implementation behind a toggle.\nPaymentGateway gateway = features.isEnabled(\"new-payment-gateway\")\n        ? new NewPaymentGateway()\n        : new LegacyPaymentGateway();\n```\n\nErgänzend gibt es Dark Launching und das Keystone Interface: Man baut ein großes Feature Stück für Stück, aber setzt das sichtbare Element – den Schlussstein – erst ganz zuletzt ein. Bis dahin läuft der neue Code vielleicht schon mit, sammelt Daten oder wird gegen Produktionslast getestet, ohne dass ein Nutzer etwas bemerkt. Auch Canary Releases und Blue-Green Deployments spielen hier hinein: Ausrollen und Aktivieren werden zu graduellen, kontrollierbaren Vorgängen statt zum großen Knall.\n\n## Der Kontrast: GitFlow und der Reflex zum langen Branch\n\nIch will fair bleiben. GitFlow, das Vincent Driessen 2010 in „A successful Git branching model“ vorgestellt hat, war eine durchdachte Antwort auf ein reales Problem – für eine bestimmte Art von Software. Driessen selbst hat 2020 den Hinweis ergänzt, dass sein Modell nicht für Continuous-Delivery-Web-Apps gedacht war. Genau da liegt der Punkt. Wer ein Produkt mit versionierten Releases und langen Support-Zyklen baut, kann mit einem Branching-Modell wie GitFlow gut leben. Wer eine kontinuierlich ausgelieferte Anwendung betreibt, importiert sich damit den Branch-Friedhof frei Haus.\n\nAls leichteres Gegenmodell existiert seit 2011 GitHub Flow: ein kurzlebiger Branch, ein Pull Request, schneller Merge zurück in die Mainline. Das ist schon deutlich näher an Trunk-Based Development. Der eigentliche Unterschied liegt nicht im Tooling, sondern in der Frage, wie lange ein Team bereit ist, Integration aufzuschieben. Je länger diese Antwort ausfällt, desto teurer wird die Rechnung.\n\n## Ehrlich über die Fallstricke\n\nDer Ansatz löst nicht alles, und er bringt eigene Probleme mit. Drei Kanten sind es, an denen sich Teams zuverlässig schneiden.\n\nErstens: Toggle-Schulden, der Toggle-Friedhof. Der Witz an der Sache ist bitter. Man führt Feature Flags ein, um den Branch-Friedhof zu vermeiden, und produziert dabei einen neuen Friedhof aus nie entfernten Flags. Jeder Release Toggle, der nach dem Release im Code bleibt, ist technische Schuld. Er verdoppelt Pfade, verwirrt Leser und verrottet still. Hodgson ist hier deutlich: Release Toggles brauchen ein Ablaufdatum und müssen aktiv zurückgebaut werden. In den Teams, bei denen das funktioniert hat, gehörte das Entfernen des Flags fest zur Definition eines fertigen Features – fertig war es erst, wenn sein Release Toggle wieder aus dem Code verschwunden war.\n\n```mermaid\nflowchart LR\n    A[\"create toggle\"] --> B[\"deploy dark\"]\n    B --> C[\"gradual release\"]\n    C --> D[\"fully on\"]\n    D --> E[\"remove toggle\"]\n    E --> F[\"trunk stays clean\"]\n```\n\nZweitens: Trunk-Based Development ohne belastbare Testautomatisierung und ein CI-Gate ist gefährlich. Der ganze Ansatz steht und fällt damit, dass der Trunk grün und releasebar bleibt. Wenn Builds und Tests langsam oder unzuverlässig sind, importiert man Instabilität direkt in die Mainline, statt sie in Branches zu isolieren. Der lange Branch ist in schlecht getesteten Codebasen manchmal ein rationaler Selbstschutz. Die richtige Reihenfolge lautet deshalb: erst schnelle, verlässliche Tests und ein Gate, dann tägliche Integration. Nicht umgekehrt. Wer bei häufigen Merges zusätzlich Integrationsrisiken zwischen Services fürchtet, findet mit Consumer-Driven Contract Testing – etwa über Pact – ein wirksames Sicherheitsnetz.\n\nDrittens: die Testmatrix-Explosion. Jeder Flag verdoppelt kombinatorisch die möglichen Pfade durch den Code. Zwei Flags ergeben vier Zustände, drei ergeben acht. Ungetestete Flag-Kombinationen sind ein realer Risikoraum, kein theoretischer. Die Disziplin dagegen ist unspektakulär, aber wirksam: wenige gleichzeitig aktive Toggles, klare Ownership für jeden einzelnen, und ein bewusstes Auge auf die Kombinationen, die tatsächlich zusammen auftreten können.\n\n## Warum sich der Aufwand rechnet\n\nWenn ich all das zusammenziehe, lande ich nicht bei einer Tool-Empfehlung, sondern bei einer Haltung zur Auslieferung. Kleine Integrationsdistanz, ein jederzeit releasebarer Trunk, und die saubere Trennung von Deploy und Release ergeben zusammen etwas, das sich in Geschäftszahlen niederschlägt.\n\nDie Zyklen werden kürzer, weil niemand mehr Tage in Merge-Konflikten verliert. Die Reibung im Team sinkt, weil alle gegen denselben aktuellen Stand arbeiten und Konflikte klein und häufig statt groß und selten sind. Die Qualität steigt, weil ein Feature graduell und rückholbar ausgerollt wird, statt in einem einzigen riskanten Sprung. Und das Release verliert seinen Schrecken: Es ist kein nächtliches Deploy-Event mehr, sondern eine Entscheidung, die jemand mit einem Schalter trifft – zurücknehmbar, messbar, unaufgeregt.\n\nDas ist am Ende der Reiz des Ansatzes. Er ersetzt Prozess-Overhead durch etwas Einfaches und Elegantes. Statt eines aufwendigen Branching-Modells, das den Merge-Schmerz verwaltet, vermeidet man den Schmerz, indem man ihn gar nicht erst entstehen lässt. Der Branch-Friedhof ist kein Naturgesetz. Er ist eine Folge aufgeschobener Integration – und die kann man abschaffen.\n\n## Weiterführende Quellen\n\n- Paul Hammant u. a.: [trunkbaseddevelopment.com](https://trunkbaseddevelopment.com/)\n- Martin Fowler & Pete Hodgson: [Feature Toggles (aka Feature Flags)](https://martinfowler.com/articles/feature-toggles.html)\n- Martin Fowler: [Patterns for Managing Source Code Branches](https://martinfowler.com/articles/branching-patterns.html)\n- Martin Fowler: [Branch by Abstraction](https://martinfowler.com/bliki/BranchByAbstraction.html)\n- Forsgren, Humble & Kim: *Accelerate* (2018) und die State-of-DevOps-Reports\n- Humble & Farley: *Continuous Delivery* (2010)\n- Vincent Driessen: [A successful Git branching model](https://nvie.com/posts/a-successful-git-branching-model/) (als Kontrast)"},{"slug":"wie-react-den-baum-rendert-und-aktualisiert","title":"Wie React den Baum rendert und aktualisiert","summary":"Vom JSX-Element zum sichtbaren Pixel – was zwischen einer State-Änderung und dem neu gezeichneten Bildschirm genau passiert. Render und Commit als getrennte Phasen, der Abgleich zweier Bäume und warum React fast immer nur einen winzigen Teil des DOM anfasst.","tags":["Software","Frontend","React"],"published_at":"2020-10-24T10:00:00+00:00","updated_at":"2026-07-19T18:46:18.505233+00:00","url":"https://www.mikebild.dev/de/blog/wie-react-den-baum-rendert-und-aktualisiert/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/af0402076991b04ea8f2886840340192c80694feaa994a046bcd480a7e2ea7ab/wie-react-den-baum-rendert-und-aktualisiert-vorlesen.mp3","audio_duration_secs":550,"text":"# Wie React den Baum rendert und aktualisiert\n\n```js\nsetCount(count + 1);\n```\n\nNach dieser Zeile passiert im Browser erst einmal gar nichts. Kein Knoten wird ersetzt, kein Pixel neu gezeichnet. React ruft zunächst nur JavaScript-Funktionen auf – die Komponenten – und sammelt ein, was sie zurückgeben. Ob überhaupt ein DOM-Knoten angefasst werden muss, entscheidet sich erst danach, in einem eigenen Schritt. Dass eine Komponentenfunktion läuft und dass der Browser etwas zu tun bekommt, sind zwei verschiedene Vorgänge – an dieser Trennung lösen sich die meisten Missverständnisse über React-Performance auf.\n\nIm Beitrag [JSX genau: Die Regeln hinter dem Markup](https://www.mikebild.dev/de/blog/jsx-genau-die-regeln-hinter-dem-markup/) ging es darum, dass ein JSX-Element letztlich nur ein Objekt ist – eine Beschreibung dessen, was auf dem Bildschirm stehen soll. Hier geht der Weg weiter: Wie wird aus dieser Beschreibung ein echtes DOM, und was passiert damit, wenn sich der State ändert? Rendern ist dabei eine Berechnung in JavaScript, das Ändern des DOM ein Schreibvorgang im Browser. React hält beides bewusst auseinander.\n\nZwischen einer State-Änderung und einer sichtbaren DOM-Änderung liegen mehrere klar getrennte Stufen.\n\n:::process{title=\"Von der Aktualisierung zum DOM\" role=\"primary\" preferredPattern=\"vertical-journey\"}\n1. Auslösen · State, Props oder Kontext machen Arbeit notwendig\n2. Rendern · React ruft Komponenten auf und erzeugt die nächste Elementstruktur\n3. Abgleichen · alte und neue Struktur werden anhand von Typ und Schlüssel verglichen\n4. Commit · notwendige Änderungen werden auf DOM und Effects angewendet\n:::\n\nDie Trennung erklärt, warum ein erneuter Komponentenaufruf nicht automatisch einen vollständigen DOM-Neuaufbau bedeutet.\n\n## Drei Schritte: Auslösen, Rendern, Anwenden\n\nReact zeigt eine Oberfläche in drei Schritten an. Man kann sie sich wie eine Bestellung in einer Küche vorstellen: Jemand gibt die Bestellung auf, die Küche kocht, der Kellner bringt das Gericht an den Tisch.\n\n```mermaid\nflowchart LR\n  Trigger[\"1. Auslösen<br/>initial oder setState\"] --> Render[\"2. Rendern<br/>Komponenten aufrufen\"]\n  Render --> Commit[\"3. Anwenden<br/>minimale DOM-Änderung\"]\n  Commit --> Paint[\"Browser zeichnet neu\"]\n```\n\nDer erste Schritt ist das Auslösen. Es gibt genau zwei Anlässe. Beim Start der Anwendung passiert es einmalig, wenn man die Wurzel erzeugt und die oberste Komponente übergibt. Danach wird ein erneutes Rendern nur noch durch eine State-Änderung ausgelöst – immer über die Setter-Function aus `useState` oder einen entsprechenden Mechanismus. Ohne State-Änderung rendert React nichts neu. Das ist wichtig: Ein Klick allein bewirkt nichts; erst wenn der Klick den State ändert, kommt die Maschine in Gang.\n\n```jsx\nimport { createRoot } from \"react-dom/client\";\n\nconst root = createRoot(document.getElementById(\"root\"));\nroot.render(<App />);\n```\n\n## Rendern heißt: React ruft deine Funktionen auf\n\nDer zweite Schritt, das Rendern, ist der, der am meisten missverstanden wird. Rendern bedeutet bei React nicht, etwas auf den Bildschirm zu malen. Rendern bedeutet: React ruft deine Komponentenfunktionen auf, um herauszufinden, was angezeigt werden soll. Beim ersten Mal ruft es die Wurzelkomponente auf. Bei einem erneuten Rendern ruft es die Komponente auf, deren State sich geändert hat.\n\nUnd dieser Aufruf ist rekursiv. Gibt eine Komponente andere Komponenten zurück, ruft React auch die auf, und deren Kinder ebenfalls, bis es keine verschachtelten Komponenten mehr gibt. Am Ende dieses Durchlaufs steht kein DOM, sondern ein neuer Baum aus React-Elementen – denselben Beschreibungsobjekten aus dem JSX-Beitrag, nur eben der ganze Baum davon.\n\n```mermaid\nflowchart TB\n  App[\"App()\"] --> Liste[\"Rechnungsliste()\"]\n  App --> Kopf[\"Kopfzeile()\"]\n  Liste --> Z1[\"Zeile() #1\"]\n  Liste --> Z2[\"Zeile() #2\"]\n  Z1 --> Betrag1[\"Betrag()\"]\n  Z2 --> Betrag2[\"Betrag()\"]\n```\n\nDamit dieser Durchlauf verlässlich ist, verlangt React eine Eigenschaft, die ich in den früheren Beiträgen schon betont habe: Das Rendern muss eine reine Berechnung sein. Für dieselben Props und denselben State muss dieselbe Beschreibung herauskommen, und beim Rendern darf nichts verändert werden, was vorher schon existierte. Nur unter dieser Bedingung kann React das Rendern gefahrlos wiederholen, aufschieben oder mit einem späteren Ergebnis verwerfen – alles Dinge, die die moderne, nebenläufige Variante von React tatsächlich tut.\n\n## Der Abgleich: zwei Bäume vergleichen\n\nJetzt kommt der Kern, der erklärt, warum das Ganze schnell ist. Nach einem erneuten Rendern hat React zwei Bäume: den neuen, gerade berechneten, und den vorherigen aus dem letzten Durchlauf. Bevor irgendetwas ins DOM geschrieben wird, vergleicht React die beiden. Diesen Abgleich nennt man Reconciliation, und er folgt ein paar einfachen Regeln.\n\nAn derselben Stelle im Baum entscheidet der Typ des Elements. Ist der Typ gleich – vorher ein `<div>`, jetzt wieder ein `<div>`, oder vorher wie jetzt dieselbe Komponente –, dann behält React den bestehenden Knoten und aktualisiert nur die Eigenschaften, die sich geändert haben. Ist der Typ verschieden – vorher ein `<p>`, jetzt ein `<div>` –, dann gibt es keinen Abgleich im Detail: React reißt den alten Teilbaum ab, wirft dessen State weg und baut den neuen von Grund auf.\n\n```mermaid\nflowchart LR\n  subgraph Vorher\n    A1[\"div\"] --> A2[\"span: 'Hallo'\"]\n  end\n  subgraph Nachher\n    B1[\"div\"] --> B2[\"span: 'Welt'\"]\n  end\n  A1 -. \"gleicher Typ:<br/>Knoten bleibt\" .-> B1\n  A2 -. \"nur Textinhalt<br/>ändert sich\" .-> B2\n```\n\nDiese Regel hat eine praktische Folge, über die Anfänger oft stolpern. Weil ein Typwechsel den ganzen Teilbaum verwirft, verliert man dabei auch dessen State: Ein Eingabefeld, das vorher in einem `<div>` steckte und jetzt in einem `<section>`, wird als neues Element behandelt und ist wieder leer. Wer will, dass State erhalten bleibt, muss dafür sorgen, dass an derselben Stelle derselbe Typ steht.\n\n## Listen sind der Sonderfall – und hier kommen die Keys\n\nBei Kindern in einer Liste reicht die Position nicht aus, denn Listen ändern ihre Reihenfolge. Genau dafür gibt es den Key, über den ich im Beitrag [JSX genau](https://www.mikebild.dev/de/blog/jsx-genau-die-regeln-hinter-dem-markup/) schon gesprochen habe – und hier zeigt sich, warum er so wichtig ist. Beim Abgleich einer Liste vergleicht React nicht Position mit Position, sondern Key mit Key. Ein Element mit demselben Key gilt als dasselbe Element wie vorher, egal an welcher Stelle es jetzt steht. React verschiebt dann den bestehenden DOM-Knoten, statt ihn wegzuwerfen und neu zu bauen.\n\nDeshalb ist der Array-Index als Key so tückisch. Fügt man vorne ein Element ein, verschiebt sich jeder Index um eins. Für React sieht es dann so aus, als hätte sich der Inhalt jeder Zeile geändert – und der State, der eigentlich zur dritten Rechnung gehörte, klebt plötzlich an der vierten. Mit einer stabilen, fachlichen ID als Key erkennt React die Identität korrekt: Das eingefügte Element ist neu, alle anderen sind dieselben wie vorher und wandern einfach eine Position weiter.\n\n## Anwenden: React fasst nur das an, was sich geändert hat\n\nErst nach dem Abgleich kommt der dritte Schritt, der Commit. Jetzt schreibt React ins echte DOM – aber nur das, was der Abgleich als Unterschied ergeben hat. Beim allerersten Rendern muss alles neu erzeugt werden, dafür hängt React die erzeugten Knoten mit den üblichen DOM-Aufrufen in die Seite. Bei jedem weiteren Mal gilt der entscheidende Satz aus der Dokumentation: React verändert einen DOM-Knoten nur, wenn es zwischen zwei Renderdurchläufen einen Unterschied gibt.\n\nDas klingt selbstverständlich, hat aber sichtbare Konsequenzen. Wenn eine Elternkomponente neu rendert, ein Eingabefeld tief darin aber unverändert bleibt, dann fasst React diesen Knoten nicht an – und der Text, den man gerade getippt hat, bleibt stehen, obwohl der ganze Ast neu gerechnet wurde. Genau das ist der Beweis, dass Rendern und DOM-Änderung getrennt sind: Die Komponente wurde neu gerendert (die Funktion lief), aber das DOM wurde nicht angefasst (es gab keinen Unterschied).\n\n```mermaid\nsequenceDiagram\n  participant N as Nutzer\n  participant R as React\n  participant D as DOM\n  N->>R: setState (Zustand ändert sich)\n  R->>R: Komponenten erneut aufrufen (rendern)\n  R->>R: neuen Baum gegen alten abgleichen\n  R->>D: nur die Unterschiede schreiben\n  D->>N: Browser zeichnet das Ergebnis\n```\n\nNach dem Commit ist React fertig. Was danach passiert, gehört dem Browser: Er zeichnet den geänderten Teil der Seite neu. Dieses Neuzeichnen ist ein eigener, von React unabhängiger Schritt – ein weiterer Grund, warum die minimale DOM-Änderung so viel wert ist, denn je weniger sich im DOM ändert, desto weniger muss der Browser neu malen.\n\n## Warum ein erneutes Rendern nicht teuer sein muss\n\nMit diesem Bild löst sich ein verbreitetes Missverständnis auf. Viele glauben, ein erneutes Rendern sei automatisch langsam, und optimieren wild dagegen an. Aber Rendern heißt zunächst nur, dass eine JavaScript-Funktion noch einmal läuft und ein paar Objekte erzeugt. Das ist selten das Problem. Teuer wird erst das Schreiben ins DOM und das anschließende Neuzeichnen – und genau das hält React durch den Abgleich klein.\n\nDas heißt nicht, dass Rendern gratis ist. Rendert eine hoch angesiedelte Komponente neu, ruft React den ganzen Teilbaum darunter erneut auf, auch wenn am Ende kaum DOM-Änderungen herauskommen. Für die allermeisten Anwendungen ist das kein Thema. Wo es doch messbar wird, lässt sich ein Teilbaum von Hand memoisieren, etwa mit `React.memo`: React überspringt das erneute Rendern dann, solange sich dessen Props nicht geändert haben. Die Reihenfolge bleibt aber dieselbe: erst messen, dann prüfen, ob wirklich das Rendern das Problem ist – und meistens ist es das nicht.\n\n## Was man daraus mitnimmt\n\nDie ganze Mechanik lässt sich auf einen Satz eindampfen: React rechnet bei jeder State-Änderung eine neue Beschreibung der Oberfläche aus, vergleicht sie mit der alten und schreibt nur die Unterschiede ins DOM. Die Beschreibung ist billig, der Vergleich ist clever, und die teure DOM-Arbeit bleibt minimal. Der Key ist dabei kein Detail, sondern die Information, mit der der Vergleich Identität von Position unterscheidet – und der Grund, warum eine gute Wahl des Keys über korrekt erhaltenen State entscheidet.\n\nWer das im Kopf hat, trifft im Alltag bessere Entscheidungen: Man erschrickt nicht mehr vor einem erneuten Rendern, man weiß, warum ein Eingabefeld seinen Text behält oder verliert, und man versteht, wann ein Typwechsel oder ein wechselnder Key einen ganzen Teilbaum wegwirft. Zum tieferen Verhalten von State über Rendergrenzen hinweg – warum State an eine Position im Baum gebunden ist und wann er zurückgesetzt wird – gibt es den Folgebeitrag State erhalten und zurücksetzen.\n\n## Weiterführende Quellen\n\n- Repository introduction-react (Beispiele zum Nachvollziehen): https://github.com/mikebild/introduction-react\n- React, Render und Commit: https://react.dev/learn/render-and-commit\n- React, State als Momentaufnahme: https://react.dev/learn/state-as-a-snapshot\n- React, State erhalten und zurücksetzen: https://react.dev/learn/preserving-and-resetting-state\n- React, Listen rendern (Keys): https://react.dev/learn/rendering-lists"},{"slug":"team-automatisierung-github-actions","title":"Team-Automatisierung mit GitHub Actions","summary":"Warum ich ausgewählte, wiederkehrende Prüfungen als Workflows ausführe – und woran sich zeigt, ob die Automatisierung das Team tatsächlich entlastet.","tags":["Software","Architektur"],"published_at":"2020-10-12T10:00:00+00:00","updated_at":"2026-07-13T08:39:36.579783+00:00","url":"https://www.mikebild.dev/de/blog/team-automatisierung-github-actions/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/2ff98549f5b5e2054cf19f30ee1c7b8ed4671e23a84d96140f73c5576228c896/team-automatisierung-github-actions-vorlesen.mp3","audio_duration_secs":653,"text":"# Team-Automatisierung mit GitHub Actions\n\nEine Datei unter `.github/workflows` ist kein Build-Skript und kein besseres Makefile. Sie ist eine Regel mit Zähnen: etwas, das bei jedem Push und bei jedem Pull Request greift, ohne dass jemand daran erinnern muss. Der Unterschied klingt akademisch, entscheidet aber darüber, was so eine Datei im Alltag eines Teams überhaupt leisten kann.\n\nSichtbar wird er an den Fragen, die dort landen. Wird gemergt, bevor die Tests grün sind? Ist der Code formatiert? Hat die Änderung einen Changelog-Eintrag? Wer darf ein Release auslösen? Solange die Antworten im Kopf einer erfahrenen Person stehen, in einem Wiki oder in einem PR-Kommentar, den man schon dreimal geschrieben hat, halten sie – aber nur, solange alle wach und anwesend sind. Sie brechen an dem Tag, an dem jemand Neues dazukommt, jemand im Urlaub ist oder es einfach spät ist.\n\nIm Repository dagegen steht die Regel neben dem Code, im selben Verlauf, und wird angewendet statt verhandelt. Genau darin liegt der Wert und genau da fangen auch die interessanten Architekturfragen an.\n\n## Automatisierung als ausführbare Vereinbarung\n\nDer erste Denkfehler ist, Automatisierung als Zeitersparnis zu verkaufen. Zeit spart sie auch, aber das ist der kleinere Effekt. Der größere ist, dass eine Entscheidung aufhört, verhandelbar zu sein. Wenn „wir mergen nur mit grünen Tests“ eine Konvention ist, hält sie so lange, bis der Druck steigt. Wenn dieselbe Regel ein Required Check auf dem Branch ist, hält sie auch dann, wenn niemand hinschaut. Die Regel wandert vom sozialen in den technischen Raum, und dort ist sie robust gegen Müdigkeit und Hierarchie.\n\nDamit verschiebt sich meine Design-Frage. Ich frage nicht „welche Schritte kann ich automatisieren“, sondern „welche Entscheidung will ich verlässlich machen“. Das ist eine Architekturentscheidung, keine Skriptübung. Ein guter Workflow kodiert eine Absicht: Diese Klasse von Fehlern soll den Hauptzweig nie erreichen. Diese Nacharbeit soll niemand mehr von Hand machen. Dieser Schritt soll nachvollziehbar sein, auch in einem halben Jahr.\n\n## Wo die Grenze verläuft\n\nBevor ich eine Zeile YAML schreibe, ziehe ich eine Grenze, die viele Pipelines nicht sauber ziehen: die zwischen Prüfen und Verändern. Ein Gate liest nur. Es sagt ja oder nein und fasst nichts an – Lint, Tests, Typecheck, Format-Check. Ein verändernder Schritt greift ein: Er publiziert ein Artefakt, taggt ein Release, deployt, kommentiert, labelt. Diese beiden Klassen haben völlig unterschiedliche Anforderungen an Rechte, Wiederholbarkeit und Fehlerverhalten, und wenn man sie im selben Job vermischt, erbt der harmlose Test-Schritt plötzlich die Rechte des gefährlichen Deploy-Schritts.\n\nPrüfschritte laufen deshalb auf jedem Pull Request, auch auf denen aus Forks, und bekommen keine Geheimnisse. Verändernde Schritte laufen nur auf vertrauenswürdigen Auslösern – ein Merge auf den Hauptzweig, ein Tag, ein bewusst ausgelöster Lauf. Diese Trennung trägt die Sicherheitsarchitektur des ganzen Repositories. GitHub nimmt einen Teil davon ab: Bei einem Pull Request aus einem Fork ist das `GITHUB_TOKEN` read-only und die Secrets sind nicht sichtbar. Das ist die Antwort auf ein reales Angriffsszenario, und ich baue meine Workflows so, dass sie diese Grenze respektieren statt sie zu umgehen.\n\n## Ein konkretes Gate\n\nSo sieht ein Pull-Request-Gate aus, das ich in dieser Form immer wieder aufsetze. Es prüft und verändert nichts:\n\n```yaml\nname: pr-gate\n\non:\n  pull_request:\n    branches: [main]\n\njobs:\n  verify:\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        node-version: [12.x, 14.x]\n    steps:\n      - uses: actions/checkout@v2\n      - uses: actions/setup-node@v1\n        with:\n          node-version: ${{ matrix.node-version }}\n      - run: npm ci\n      - run: npm run lint\n      - run: npm run typecheck\n      - run: npm test -- --ci\n```\n\nEin paar Entscheidungen darin sind bewusst und tragen mehr Bedeutung, als sie aussehen. `npm ci` statt `npm install`, weil ich reproduzierbare Installationen aus dem Lockfile will und keine schleichende Drift der Abhängigkeiten. Die Matrix über zwei Node-Versionen, weil „läuft bei mir“ die häufigste Lüge in Teams ist und ich die Kompatibilität lieber vom Rechner in die Pipeline verlege. `fail-fast: false`, weil ich bei einem roten Lauf beide Versionen sehen will, nicht nur die erste, die abbricht – ein abgebrochener Report kostet eine ganze Feedback-Runde.\n\nDer eigentliche Hebel ist aber nicht das YAML. Er ist die Branch-Protection-Regel, die diesen Check zur Pflicht macht. Erst dadurch wird aus einer freundlichen Empfehlung eine Grenze, die der Hauptzweig verteidigt. Der Workflow liefert das Signal, die Protection-Regel gibt ihm Zähne.\n\n## Der teure Fehler ist der glückliche Pfad\n\nDer glückliche Pfad ist schnell gebaut: alles grün, Artefakt gebaut, Release raus. Interessant wird es genau da, wo es nicht gut geht, und dort entscheidet sich, ob die Automatisierung ein Werkzeug oder eine neue Fehlerquelle ist.\n\nDer erste Stolperstein ist Idempotenz. Ein verändernder Schritt muss so gebaut sein, dass ein zweiter Lauf keinen Schaden anrichtet. Läufe werden wiederholt – nach einem Netzwerkfehler, per Re-Run-Knopf, weil jemand denselben Tag neu setzt. Wenn mein Publish-Schritt beim zweiten Mal ein doppeltes Artefakt erzeugt oder mit einem harten Fehler abbricht, habe ich die manuelle Nacharbeit nicht abgeschafft, sondern nur verschoben. Ich baue solche Schritte deshalb so, dass sie prüfen, ob die Version schon existiert, und dann sauber nichts tun.\n\nDer zweite Stolperstein ist der Fork. Ein Pull Request von außen soll geprüft, aber nicht mit Vertrauen belohnt werden. Es gibt `pull_request_target`, das mit den Rechten des Zielrepositories läuft und Zugriff auf Secrets hat – gedacht für harmlose Aufgaben wie das Labeln oder Kommentieren eines PR. Wer damit fremden Code aus dem Fork auscheckt und ausführt, öffnet die Tür für genau die Geheimnisse, die er schützen wollte. Auf `pull_request_target` läuft bei mir deshalb nur Code aus dem Zielbranch und niemals der Inhalt des PR. Damit steht die Trennung von Prüfen und Vertrauen genau an der Stelle, an der sie wirklich wehtut.\n\nDer dritte Stolperstein ist der flaky Test. Ein Check, der zufällig rot wird, ist schlimmer als kein Check, weil er dem Team beibringt, das rote Kreuz zu ignorieren. In dem Moment, in dem „einfach nochmal laufen lassen“ zur Gewohnheit wird, ist der Wert des Gates weg. Ein instabiler Test ist für mich kein Randproblem, sondern ein Fehler an der Grenze selbst – er gehört repariert oder isoliert, nicht toleriert.\n\n## Der Weg von Prüfen zu Verändern\n\nDas Bild dahinter ist einfach, und ich zeichne es bewusst als Fluss, weil die Richtung die ganze Aussage trägt:\n\n```mermaid\nflowchart LR\n  PR[Pull Request] --> Gate{Gate<br/>lint, test, types}\n  Gate -- rot --> Stop[Merge blockiert]\n  Gate -- grün --> Merge[Merge auf main]\n  Merge --> Trusted[Vertrauenswürdiger Lauf]\n  Release --> Audit[Nachvollziehbar im Verlauf]\n  Trusted --> Release[Tag, Publish, Deploy]\n```\n\nLinks wird nur gelesen, rechts wird erst nach dem Merge verändert. Zwischen beiden liegt die Vertrauensgrenze. Die Sicherheits- und Betriebsfragen, die in echten Projekten auftauchen, lassen sich fast alle darauf zurückführen, ob ein Schritt auf der richtigen Seite dieses Strichs steht.\n\n## Der bewusste Auslöser\n\nNicht jeder verändernde Schritt soll automatisch feuern. Für ein Release will ich oft einen Menschen im Spiel, aber ohne dass jemand lokal Kommandos abtippt, die keiner mitliest. Genau dafür nutze ich `workflow_dispatch` – einen Auslöser, den man bewusst per Knopfdruck betätigt, mit einer kleinen Eingabemaske:\n\n```yaml\nname: release\n\non:\n  workflow_dispatch:\n    inputs:\n      level:\n        description: \"semver bump level\"\n        required: true\n        default: patch\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n      - uses: actions/setup-node@v1\n        with:\n          node-version: 14.x\n      - run: npm ci\n      - run: npm run build\n      - run: node ./scripts/release.js --level \"${{ github.event.inputs.level }}\"\n        env:\n          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n```\n\nDas ist die manuelle Entscheidung als Formular statt als mündliche Absprache. Die eigentliche Logik liegt bewusst in `release.js`, einem getesteten Skript, das den Workflow nur noch aufruft – das Secret ist auf diesen einen vertrauenswürdigen Pfad begrenzt und taucht im Prüf-Gate nie auf. Der Gewinn liegt in der Nachvollziehbarkeit: Jedes Release steht mit Auslöser, Zeitpunkt und Person im selben Verlauf wie der Code. Aus einem Vorgang, der früher in einem Terminal verschwand, wird ein Eintrag, den man ein halbes Jahr später noch lesen kann.\n\n## Was das messbar bringt\n\nIch mag keine Automatisierung, die sich nur gut anfühlt. Der Nutzen sollte an drei Stellen sichtbar werden, sonst habe ich Aufwand gegen ein Bauchgefühl getauscht.\n\nDer erste ist die Durchlaufzeit eines Pull Requests. Wenn das Gate die üblichen Rückfragen im Review – ist es formatiert, laufen die Tests, ist der Typecheck sauber – schon vor dem menschlichen Blick beantwortet, verkürzt sich die Zeit von „PR offen“ zu „gemergt“ spürbar. Das Review kann sich auf das konzentrieren, was eine Maschine nicht sieht: ob die Lösung die richtige ist.\n\nDer zweite ist die Reibung im Review selbst. Diskussionen über Formatierung und Stil sind teuer, nicht weil sie lange dauern, sondern weil sie den Ton vergiften. Eine Maschine, die Format-Fragen entscheidet, nimmt sie aus der zwischenmenschlichen Ebene heraus. Niemand streitet mehr mit einem Linter.\n\nDer dritte ist die Zeit bis zum ersten sinnvollen Beitrag einer neuen Person. Wenn die Regeln des Teams im Repository stehen und beim ersten PR sichtbar werden, muss niemand sie mündlich weitergeben. Onboarding wird von einer Bringschuld erfahrener Kollegen zu einer Eigenschaft des Projekts. Für mich ist das der unterschätzteste Effekt: Automatisierung ist auch eine Form von Dokumentation, die nie veraltet, weil sie ausgeführt wird.\n\n## Wann ich es bewusst nicht mache\n\nEin Werkzeug ist keine Architektur, und GitHub Actions ist keine Ausnahme. Es gibt Fälle, in denen ich abrate. Wenn eine Automatisierung eine echte Orchestrierung über viele Systeme mit Zustand, Wiederaufsetzpunkten und langen Laufzeiten wäre, ist ein Workflow-Runner das falsche Zuhause – das gehört in einen richtigen Job-Runner oder eine dafür gebaute Plattform, nicht in eine CI-Datei. Und wenn eine Regel noch im Fluss ist, weil das Team selbst nicht weiß, ob sie richtig ist, zementiere ich sie nicht in einem Required Check. Automatisierung ist gut darin, geklärte Entscheidungen durchzusetzen, und schlecht darin, ungeklärte zu ersetzen.\n\nDer teuerste Fehler ist, den Runner zur heimlichen Deployment-Plattform wachsen zu lassen, bis kritische Prozesse in einer YAML-Datei liegen, die niemand mehr versteht. Ein Workflow, der eine Seite füllt, ist ein Gewinn. Ein Workflow, der drei Bildschirme füllt und Sonderfälle mit `if`-Bedingungen abfängt, ist meist ein Programm am falschen Ort – dann gehört die Logik in ein getestetes Skript oder ein kleines CLI, das der Workflow nur aufruft.\n\n## Wie ich anfange\n\nIn der Praxis mache ich daraus keine große Initiative. Ich fange mit genau einem Gate an, das eine echte, wiederkehrende Reibung wegnimmt – meistens Tests plus Format-Check auf jedem PR – und mache es über die Branch-Protection zur Pflicht. Dann warte ich zwei Wochen und schaue, ob es das Team entlastet oder nur nervt. Erst wenn es trägt, kommt der nächste Schritt dazu, und der erste verändernde Schritt kommt bewusst später als der erste prüfende.\n\nDer Maßstab bleibt derselbe wie bei jeder Architektur: Eine Entscheidung ist erst gut, wenn ich ihren Fehlerpfad beschreiben kann. Was passiert bei einem Re-Run? Was, wenn der PR aus einem Fork kommt? Was, wenn ein Test flaky wird? Wer bemerkt es, wenn das Gate stumm ausfällt? Wenn ich diese Fragen beantworten kann, ist aus der Bequemlichkeit ein Stück verlässliche Teaminfrastruktur geworden. Dass dabei auch eine Maschine Arbeit abnimmt, ist der angenehme Side Effect. Der Punkt ist die Entscheidung, die endlich hält."},{"slug":"web-apis-mit-flask","title":"Web-APIs mit Flask: von der App zur JSON-API","summary":"Von Hello World zur JSON-API: wie Routing, Request-Verarbeitung und jsonify in Flask 1.1 zusammenspielen – und warum SSR und API dieselbe Mechanik sind.","tags":["Software","Python","Backend"],"published_at":"2020-09-22T10:00:00+00:00","updated_at":"2026-07-13T10:01:21.588381+00:00","url":"https://www.mikebild.dev/de/blog/web-apis-mit-flask/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/f6bb102177a044514e48f62a60d43d84465b31f9ce999c5e755f4e48d94f15bd/web-apis-mit-flask-vorlesen.mp3","audio_duration_secs":672,"text":"Drei Begriffe genügen, um alles zu beschreiben, was Flask tut: Route, Request, Response. Ein Dekorator bindet einen URL-Pfad an eine Python-Funktion, das Objekt `request` beschreibt die eingehende Anfrage, und der Rückgabewert der Funktion wird zur Antwort. Templates, Blueprints, JSON – alles Weitere hängt an diesen drei Punkten.\n\nDamit ist Flask ein guter Ausgangspunkt, wenn aus einem Python-Skript eine Schnittstelle werden soll, die ein anderes Programm über HTTP ansprechen kann. Es ist ein Mikro-Framework: Routing, Request-Objekt und Template-Engine sind dabei, fast alles andere bleibt offen. Es gibt kaum Magie, die man erst wieder auseinandernehmen müsste, um zu verstehen, was zwischen HTTP-Request und eigener Funktion tatsächlich passiert. Dieser Text bezieht sich durchgehend auf Flask 1.1 – die stabile Serie zum Zeitpunkt des Schreibens (1.1.2 erschien im April 2020). Wer schon einmal mit Python-Objekten, Dekoratoren und Dictionaries gearbeitet hat, findet sich schnell zurecht.\n\n**Redaktionelle Einordnung (Juli 2026):** Beim heutigen Flask 3.1 ruft `request.json` intern `get_json()` auf und antwortet bei einem ungeeigneten `Content-Type` standardmäßig mit 415. Nur `request.get_json(silent=True)` liefert in diesem Fall `None`. Diese Änderung betrifft die Error Handling, nicht das Routing-Modell des Artikels.\n\n## Hello World, und was dahinter steckt\n\nDer kleinste sinnvolle Flask-Server passt in sechs Zeilen. An ihm lässt sich das gesamte Modell ablesen.\n\n```python\nfrom flask import Flask\n\napp = Flask(__name__)\n\n@app.route('/')\ndef hello_world():\n    return 'Hello World!'\n\nif __name__ == '__main__':\n    app.run(debug=True, port=8080)\n```\n\n`Flask(__name__)` erzeugt die Anwendung. Der Name des Moduls dient Flask später als Ausgangspunkt, um etwa Templates und statische Dateien relativ zum Projekt zu finden. Der Dekorator `@app.route('/')` verknüpft einen URL-Pfad mit einer Funktion. Wird der Server unter `/` aufgerufen, ruft Flask `hello_world()` auf und schickt den Rückgabewert als Antwort zurück. `app.run()` startet den eingebauten Entwicklungsserver.\n\nDer zentrale Gedanke: Eine Funktion, die einen String zurückgibt, wird zu einer HTTP-Antwort mit `Content-Type: text/html`. Flask nimmt mir die gesamte Übersetzung zwischen HTTP und Python-Funktion ab. Und diese Übersetzung ist flexibler, als der erste Eindruck vermuten lässt. Ein View darf nicht nur einen String zurückgeben, sondern auch ein Tuple aus Rumpf und Statuscode:\n\n```python\nfrom flask import Flask\n\napp = Flask(__name__)\n\n@app.route('/')\ndef hello_world():\n    return 'Hello World!', 202\n```\n\nHier bekommt der Client denselben Text, aber mit dem Status 202 statt der Standard-200. Das Tuple kann sogar drei Elemente haben – Rumpf, Status und ein Dictionary mit zusätzlichen Headern. Diese Rückgabe-Konventionen sind der Kern von Flask, und sie erklären, warum sich dieselbe Route mal als HTML-Seite und mal als JSON-API verhält.\n\n## Routing, Request, Response\n\nBevor die eigentliche API dran ist, lohnen die drei Begriffe eine genauere Betrachtung, weil sie durchgängig wiederkehren:\n\n- Route: Der Dekorator `@app.route(pfad, methods=[...])` bindet einen URL-Pfad an eine Funktion. Pfad-Parameter wie `<int:item_id>` reichen Teile der URL direkt an die Funktion weiter, und `methods` legt fest, welche HTTP-Verben die Route bedient.\n- Request: Das importierte Objekt `request` beschreibt die eingehende Anfrage – `request.args` liest den Query-String, `request.form` die Formularfelder, `request.json` den JSON-Rumpf, `request.method` das verwendete Verb.\n- Response: Der Rückgabewert der Funktion. Ein String wird zu HTML, `jsonify(...)` zu JSON, ein Tuple `(rumpf, status)` steuert den Statuscode.\n\nDiese drei Achsen genügen, um erstaunlich viel zu bauen. Ein Pfad mit Parameter sieht etwa so aus:\n\n```python\n@app.route('/user/<username>')\ndef profile(username):\n    return f'Profile page of {username}'\n```\n\nDer Platzhalter `<username>` wird als Argument an die Funktion übergeben. Braucht man einen numerischen Wert, verlangt der Konverter `<int:item_id>` eine Ganzzahl und liefert sie bereits als `int` – passt der Pfad nicht auf dieses Muster, antwortet Flask von selbst mit 404, bevor meine Funktion überhaupt läuft.\n\nDer Request-Lifecycle lässt sich in einem einfachen Diagramm zusammenfassen. Zwischen der ankommenden Anfrage und der fertigen Antwort stehen nur wenige, klar benannte Schritte.\n\n```mermaid\ngraph LR\n    A[HTTP Request] --> B[\"@app.route<br/>Matching\"]\n    B --> C[View Handler]\n    C --> D[\"jsonify()\"]\n    D --> E[JSON Response]\n```\n\nDer Request trifft ein, Flask sucht die passende Route, ruft die zugehörige Funktion auf, diese baut mit `jsonify` eine Antwort, und die geht an den Client zurück. Kein Schritt davon ist versteckt – man kann an jeder Stelle einen `print` setzen und zusehen.\n\n## Von der Seite zur Schnittstelle\n\nJetzt zum eigentlichen Ziel: eine kleine, REST-artige Schnittstelle. Ich baue dafür einen einzigen Pfad, der zwei Verben bedient – ein `GET` liefert die Liste aller Einträge, ein `POST` legt einen neuen an. Dass eine URL mehrere Methoden bedienen kann, ist genau der Punkt, an dem REST-Denken sichtbar wird: Die Ressource ist dieselbe, nur die Aktion darauf unterscheidet sich.\n\n```python\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\nitems = []  # demo-only in-memory store\n\n@app.route('/items', methods=['GET', 'POST'])\ndef items_collection():\n    if request.method == 'POST':\n        payload = request.get_json()\n        item = {'id': len(items) + 1, 'name': payload['name']}\n        items.append(item)\n        return jsonify(item), 201\n    return jsonify(items)  # GET\n\n@app.route('/items/<int:item_id>')\ndef item_detail(item_id):\n    match = next((i for i in items if i['id'] == item_id), None)\n    return (jsonify(match), 200) if match else ('Not found', 404)\n```\n\nEin paar Dinge sind hier bewusst gesetzt. Beim `POST` lese ich den JSON-Rumpf mit `request.get_json()` und antworte mit Status 201, dem Code für „neu erstellt“. Beim `GET` gebe ich die gesamte Liste zurück. Und die zweite Route zeigt den Pfad-Parameter im Einsatz: `/items/3` sucht den Eintrag mit dieser ID und liefert entweder ihn oder eine 404-Antwort.\n\n`jsonify` ist dabei mehr als ein hübscheres `json.dumps`. Es serialisiert das Dictionary oder die Liste, setzt den `Content-Type` korrekt auf `application/json` und baut ein fertiges Response-Objekt. Der handgemachte Weg – `json.dumps(...)` in einen String verwandeln und den Header manuell setzen – funktioniert zwar, ist aber fehleranfälliger und dupliziert genau die Arbeit, die Flask ohnehin sauber erledigt.\n\n## Der historische Dreh: dict direkt zurückgeben\n\nHier lohnt eine Notiz, die in älteren Beispielen für Verwirrung sorgt. Seit Flask 1.1 darf ein View ein `dict` direkt zurückgeben – Flask ruft dann intern automatisch `jsonify()` darauf auf:\n\n```python\n@app.route('/status')\ndef status():\n    return {'ok': True, 'items': len(items)}  # Flask >= 1.1 -> JSON\n```\n\nDas ist bequem, aber es ist eine relativ junge Erleichterung. In Flask 1.0 und davor war genau diese Zeile ein Fehler: Ein Dictionary war kein gültiger Rückgabetyp, und man bekam einen `TypeError`. Wer damals JSON ausliefern wollte, musste `return jsonify({...})` explizit schreiben. In Tutorials und Stack-Overflow-Antworten stehen beide Varianten nebeneinander, ohne dass die Version dabeisteht – so geraten Version und Verhalten leicht durcheinander. Solange nicht sicher ist, welche Flask-Version läuft, schreibe ich `jsonify` explizit hin. Es funktioniert in jeder Version und macht die Absicht deutlich.\n\n## SSR und API sind dieselbe Mechanik\n\nServerseitiges Rendern (SSR) und eine JSON-API gelten oft als zwei verschiedene Welten. In Flask sind sie es nicht: derselbe Routing-Mechanismus, dieselbe Funktion, nur ein anderer Rückgabetyp. Statt `jsonify` verwende ich `render_template`, das eine HTML-Datei aus dem Ordner `templates/` mit Jinja2 füllt.\n\n```python\nfrom flask import Flask, request, render_template\n\napp = Flask(__name__)\n\n@app.route('/')\ndef index():\n    return render_template('index.html', items=items)\n\n@app.route('/forms', methods=['POST'])\ndef forms():\n    return 'You said: ' + request.form['text']\n```\n\nDas zugehörige Template ist gewöhnliches HTML mit Platzhaltern. Der übergebene Kontext – hier `items` – steht in der Vorlage als Variable zur Verfügung:\n\n```html\n<form action=\"/forms\" method=\"POST\">\n  <input name=\"text\">\n  <input type=\"submit\" value=\"Submit\">\n</form>\n\n<ul>\n  {% for item in items %}\n    <li>{{ item.name }}</li>\n  {% endfor %}\n</ul>\n```\n\nDer Unterschied zwischen der API und der gerenderten Seite ist allein die Repräsentation: einmal JSON für Maschinen, einmal HTML für den Browser. Das Framework darunter bleibt gleich. Als Diagramm ist die Verzweigung schnell erzählt.\n\n```mermaid\ngraph LR\n    A[View Handler] --> B{Repräsentation?}\n    B -->|render_template| C[HTML]\n    B -->|jsonify| D[JSON]\n```\n\nDamit lösen sich „API-Framework“ und „Web-Framework“ als getrennte Kategorien auf. Es ist eine Entscheidung pro Route, kein Architekturbruch.\n\n## Struktur mit Blueprints\n\nSobald mehr als eine Handvoll Routen zusammenkommt, wird die eine große `app.py` unübersichtlich. Flask bietet dafür Blueprints – eine Möglichkeit, Routen thematisch zu gruppieren und mit einem gemeinsamen Präfix an die App zu hängen.\n\n```python\nfrom flask import Blueprint, jsonify\n\napi = Blueprint('api', __name__, url_prefix='/api')\n\n@api.route('/items')\ndef list_items():\n    return jsonify(items)\n\n# in the main app:\n# app.register_blueprint(api)\n```\n\nAlle Routen dieses Blueprints liegen dann unter `/api` – die Route oben ist also unter `/api/items` erreichbar. Wichtig zu verstehen: Ein Blueprint ist ein reines Strukturmittel. Er ändert nichts am Laufzeitverhalten und bringt keine eigene Magie mit, sondern hilft nur, den Code in nachvollziehbare Einheiten zu schneiden. Ich trenne damit gern die JSON-API von den gerenderten Seiten – zwei Blueprints, ein Projekt.\n\n## Drei Fallstricke\n\nZum Schluss die Stellen, an denen es in der Praxis regelmäßig klemmt. Keiner dieser Punkte ist ein Bug in Flask – es sind Eigenheiten, die man kennen muss.\n\nDer erste betrifft `request.json`. Im hier verwendeten Flask 1.1 war der Wert ohne `Content-Type: application/json` schlicht `None`, und ein direkter Zugriff wie `payload['name']` lief in einen Fehler. Aktuelle Flask-Versionen antworten bei diesem Zugriff standardmäßig mit 415. Wer bewusst selbst behandeln will, nutzt `request.get_json(silent=True)` und prüft das mögliche `None`.\n\n```python\n@app.route('/echo', methods=['POST'])\ndef echo():\n    data = request.get_json(silent=True)\n    if not data:\n        return jsonify({'error': 'expected JSON body'}), 400\n    return jsonify(data)\n```\n\nDer zweite ist `debug=True`. Im Entwicklungsserver ist der Debug-Modus praktisch – er lädt bei Änderungen neu und zeigt bei Fehlern einen interaktiven Traceback. Genau dieser interaktive Traceback ist aber eine ausführbare Konsole im Browser. In einer öffentlich erreichbaren Umgebung ist das eine offene Tür für das Ausführen beliebigen Codes. `debug=True` gehört ausschließlich in die lokale Entwicklung. Und `app.run()` selbst ist ohnehin nur der Entwicklungsserver – für den produktiven Betrieb setzt man einen WSGI-Server wie gunicorn oder uWSGI davor.\n\nDer dritte betrifft den globalen Zustand. Meine `items = []`-Liste ist eine Demo-Abkürzung und keine Datenbank. Sie steht im Speicher des Prozesses, geht bei jedem Neustart verloren und verhält sich unzuverlässig, sobald mehrere Worker parallel laufen – dann sieht jeder Worker seine eigene Liste. Für ein Beispiel wie dieses ist das völlig in Ordnung, für echte Daten braucht es Persistenz, etwa eine Datenbank hinter der Route.\n\n## Fazit\n\nDer Weg von einem Python-Skript zu einer Web-Schnittstelle ist mit Flask kürzer, als viele erwarten, und – wichtiger – er ist nachvollziehbar. Eine Funktion wird über einen Dekorator an einen Pfad gebunden, das `request`-Objekt beschreibt die Anfrage, der Rückgabewert wird zur Antwort. Ob daraus eine HTML-Seite über `render_template` oder eine JSON-API über `jsonify` wird, ist eine Entscheidung pro Route und kein Systemwechsel. Blueprints geben dem Ganzen Struktur, sobald es wächst, ohne neue Konzepte einzuführen.\n\nWichtiger als die Liste der Funktionen ist mir dabei, dass sich jederzeit sagen lässt, was zwischen dem HTTP-Request und der eigenen Funktion geschieht. Flask hält diese Kette sichtbar. Wer sie einmal durchgegangen ist, liest auch komfortablere Frameworks gelassener – darunter steht überall dasselbe Modell aus Route, Request und Response.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-python.html\n- Repository `introduction-python`, Verzeichnis `web/examples/` – die lauffähigen Flask-Beispiele (`1-app.py`, `2-html.py`, `3-json.py`, `app.py`), auf denen dieser Artikel aufbaut.\n- Flask 1.1 Quickstart – Routing, Methoden und Pfad-Parameter: https://flask.palletsprojects.com/en/1.1.x/quickstart/\n- Flask 1.1 API-Referenz – Request-Objekt und `jsonify`: https://flask.palletsprojects.com/en/1.1.x/api/\n- Flask 1.1 Blueprints: https://flask.palletsprojects.com/en/1.1.x/blueprints/\n- Flask 1.1 Deployment – warum der Entwicklungsserver nicht in Produktion gehört: https://flask.palletsprojects.com/en/1.1.x/deploying/\n- [Flask 3.1: eingehende Request-Daten und `get_json()`](https://flask.palletsprojects.com/en/stable/api/#flask.Request.get_json)"},{"slug":"read-models-sind-projektionen","title":"Read Models aus Events: Projections statt zufälliger Caches","summary":"Ein aus Events aufgebautes Read Model ist eine bewusst gepflegte Projection für konkrete Queries. Herkunft und Rebuild unterscheiden es von einem zufällig gewachsenen Cache, auch wenn die technische Sicht cacheähnlich wirken kann.","tags":["Software","Architektur"],"published_at":"2020-09-15T10:00:00+00:00","updated_at":"2026-07-13T07:56:13.215191+00:00","url":"https://www.mikebild.dev/de/blog/read-models-sind-projektionen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/9d0ee765f965aeedcb5c67ddc831ab56b6c1883afb8741f00619c2bc01d77034/read-models-sind-projektionen-vorlesen.mp3","audio_duration_secs":706,"text":"# Read Models aus Events: Projections statt zufälliger Caches\n\nDieser Beitrag betrachtet den Event-Sourcing-Fall: Hinter der Lese-View liegt ein Event-Strom, aus dem sie sich erneut ableiten lässt. Das Read Model ist damit die für Queries vorgesehene Sicht und keine zufällige Beschleunigungsschicht. Technisch darf sie trotzdem cacheähnlich aussehen, etwa als denormalisierte Tabelle mit schneller Ersetzung. Entscheidend sind Herkunft und Rebuild, nicht das Speicherprodukt.\n\nDer Vergleich mit dem Cache liegt trotzdem nahe: eine denormalisierte, schnelle Sicht, die neben der eigentlichen Datenbank steht, sieht nun einmal aus wie einer. Teuer wird er in dem Moment, in dem das erste Schema auf der Leseseite wandern soll. Eine Leseseite, die als Cache gebaut wurde, lässt sich dann nicht mehr sauber wiederherstellen. Eine, die als Projection gebaut wurde, hat den Neuaufbau von Anfang an im Design – und dieser Unterschied zieht sich durch Betrieb, Migration und Fehlerbehebung.\n\n## Woher der Cache-Reflex kommt\n\nDer Reflex hat einen wahren Kern. Ein Read Model und ein Cache lösen oberflächlich dasselbe Problem: Queries sollen schnell sein, ohne das teure Schreibmodell bei jeder Anzeige zu befragen. Beide sind denormalisiert, beide liegen dem Nutzer näher als die Wahrheitsquelle, beide sind potenziell veraltet.\n\nDer Bruch liegt eine Ebene tiefer, bei der Frage: Wer ist die Wahrheitsquelle, und wie entsteht die schnelle Sicht daraus? Ein Cache hat eine autoritative Datenbank *hinter* sich. Fällt der Cache-Eintrag weg, zieht man den Datensatz erneut aus dieser Quelle und legt ihn wieder ab. Die Invalidierung ist ein Side Effect von TTL oder Verdrängung, der Kaltstart ist ein Betriebsdetail. Ein Read Model dagegen hat keine \"eigentliche\" Lese-Datenbank hinter sich, aus der man es bei Bedarf nachlädt. Es *ist* die autoritative Lese-View. Was dahinter liegt, ist der Event Store oder das Write Model – und daraus wird das Read Model nicht \"nachgeladen\", sondern *berechnet*.\n\nDiese Berechnung ist die Projection. Und sie ist der Grund, warum ich auf der Unterscheidung bestehe.\n\n## Was eine Projection ausmacht\n\nVor dem Code stehen drei Begriffe. Martin Fowler beschreibt CQRS als bewusste Trennung: Man nutzt ein anderes Modell zum Schreiben als zum Lesen. Das Read Model ist dabei kein Blick durch das Domänenmodell hindurch, sondern ein eigenständiges Modell, das ausschließlich Queries bedient. Greg Young und Vaughn Vernon liefern das Bindeglied: die Projection – der Prozess, der aus den Änderungen der Schreibseite, konkret aus den Domain Events, das Read Model ableitet und fortschreibt.\n\nDie entscheidende Eigenschaft der hier behandelten Projection ist ihre Herkunft aus einem immutable Event-Strom. Wenn Events, Reihenfolge, Projektionscode und sonstige Eingaben feststehen, entsteht beim Wiederholen dasselbe Read Model. Daraus folgt die zweite Eigenschaft: Man kann die Sicht wegwerfen und aus den Events vollständig neu berechnen. Der Rebuild ist kein Notfallplan, sondern ein reguläres Werkzeug.\n\nGenau hier trennt sich die Projection vom Cache. Ein Cache kann man auch neu füllen, aber der Neuaufbau ist ein Kaltstart-Side-Effect, dessen Ergebnis von der Quelle im *aktuellen* Moment abhängt. Ein Rebuild einer Projection ist deterministisch und zeitunabhängig, weil er auf der Historie fußt, nicht auf einem Momentanzustand.\n\nEin Vorbehalt gehört hierher: Dieser freie Rebuild setzt voraus, dass die Events dauerhaft und vollständig vorliegen – also einen Schreibweg mit Event Sourcing, bei dem der Event Store die Wahrheitsquelle ist. CQRS und Event Sourcing sind zwei getrennte Entscheidungen, die man oft zusammen trifft, aber nicht muss; Fowler beschreibt CQRS ausdrücklich unabhängig von der Persistenzform. Wer CQRS ohne persistente Event-Historie betreibt und die Leseseite nur aus flüchtigen Benachrichtigungen fortschreibt, hat eine Projection im Betrieb, aber nicht den deterministischen Neuaufbau aus der Historie. Die Beispiele hier setzen den Event Store voraus – und mit ihm die Eigenschaft, die das Read Model vom Cache trennt.\n\n## Die Projection als Faltung\n\nAm klarsten wird das im Code. Eine Projection ist im Kern eine Faltung – ein `reduce` über die Events, das mit einem leeren Zustand beginnt und jedes Event auf den bisherigen Zustand anwendet. Illustrativ, in TypeScript:\n\n```ts\ntype OrderPlaced = { type: \"OrderPlaced\"; orderId: string; total: number }\ntype OrderShipped = { type: \"OrderShipped\"; orderId: string }\ntype OrderCancelled = { type: \"OrderCancelled\"; orderId: string }\n\ntype OrderEvent = OrderPlaced | OrderShipped | OrderCancelled\n\ntype OrderSummary = {\n  orderId: string\n  total: number\n  status: \"placed\" | \"shipped\" | \"cancelled\"\n}\n\n// pure reducer: (state, event) => state\nfunction apply(state: OrderSummary, event: OrderEvent): OrderSummary {\n  switch (event.type) {\n    case \"OrderPlaced\":\n      return { orderId: event.orderId, total: event.total, status: \"placed\" }\n    case \"OrderShipped\":\n      return { ...state, status: \"shipped\" }\n    case \"OrderCancelled\":\n      return { ...state, status: \"cancelled\" }\n  }\n}\n\n// the projection is a fold over the event stream\nfunction project(events: OrderEvent[]): OrderSummary {\n  return events.reduce(apply, {} as OrderSummary)\n}\n```\n\n`apply` ist eine Pure Function `(state, event) => state`. Kein Zugriff auf die Uhr, kein Netzwerk, keine Zufälligkeit. Das ist keine stilistische Vorliebe. Ohne diese Reinheit bleibt der Rebuild nicht deterministisch. Sobald in `apply` ein `Date.now()` oder ein externer Lookup steckt, ist die schöne Eigenschaft \"gleiche Events, gleiches Read Model\" verloren – und man hat sich unbemerkt einen Cache gebaut, dessen Neuaufbau vom Zeitpunkt abhängt.\n\n## Ein Read Model pro Sicht\n\nDer zweite Kernpunkt: Es gibt nicht *das* eine Read Model. Es gibt eines pro Query, jeweils denormalisiert und exakt auf die Sicht zugeschnitten, die es bedient. Vernon weist darauf hin, dass Read Models besonders dort nützlich sind, wo die angezeigten Daten quer über mehrere Aggregates schneiden. Das Read Model zieht diese Daten vorberechnet zusammen, sodass die Query flach und billig bleibt.\n\nEine Sicht für eine Bestellliste zieht andere Felder als eine Detailsicht. Statt zur Laufzeit zu joinen und zu rechnen, legt die Projection die fertigen Werte ab:\n\n```ts\ntype CustomerOrderListItem = {\n  orderId: string\n  customerName: string   // denormalized from the customer aggregate\n  itemCount: number      // precomputed, no join at read time\n  totalAmount: number\n  statusLabel: string    // display-ready, e.g. \"Versandt\"\n}\n\n// this projection cuts across order and customer to build one flat view\nfunction toListItem(\n  order: OrderSummary,\n  customerName: string,\n  itemCount: number,\n): CustomerOrderListItem {\n  return {\n    orderId: order.orderId,\n    customerName,\n    itemCount,\n    totalAmount: order.total,\n    statusLabel: order.status === \"shipped\" ? \"Versandt\" : \"Offen\",\n  }\n}\n```\n\nWichtig ist die Denkrichtung: Das Read Model wird für die Query gebaut, nicht die Query für das Read Model. Das ist genau das, was ich in [CQRS ist eine fachliche Grenze](https://www.mikebild.dev/de/blog/cqrs-fachliche-grenze-kein-framework/) meine, wenn ich sage, dass die Trennung von Lesen und Schreiben eine Aussage über Verantwortlichkeit ist, nicht über zwei Datenbanken. Die Leseseite darf ihre eigene Form haben, weil sie eine eigene Aufgabe hat.\n\n## Der Rebuild gehört zum Design\n\nWeil die Projection deterministisch aus den Events entsteht, ist der Neuaufbau trivial zu formulieren – man leert die Read-Seite und spielt die Events erneut ein:\n\n```ts\ninterface ReadStore {\n  clear(): void\n  save(view: OrderSummary): void\n}\n\ninterface EventStore {\n  allEvents(): Iterable<OrderEvent>\n}\n\nfunction rebuild(events: EventStore, read: ReadStore): void {\n  read.clear()\n  const views = new Map<string, OrderSummary>()\n  for (const event of events.allEvents()) {\n    const current = views.get(event.orderId) ?? ({} as OrderSummary)\n    views.set(event.orderId, apply(current, event))\n  }\n  for (const view of views.values()) read.save(view)\n}\n```\n\nDieser `rebuild` ist der praktische Lackmustest. Wenn er sich sauber schreiben lässt, hat man eine Projection. Wenn er sich *nicht* sauber schreiben lässt – weil Zustand von außen einfließt, weil Events verworfen wurden, weil das `apply` nicht rein ist –, dann hat man einen Cache und sollte aufhören, ihn Read Model zu nennen.\n\nDer Rebuild ist auch die Antwort auf eine Frage, die in Projekten früher oder später kommt: Was passiert bei einer Schema-Änderung auf der Leseseite? Bei einem Cache ist das ein Migrationsproblem mit Datenübernahme. Bei einer Projection ist es ein neuer Projector, den man über die vorhandenen Events laufen lässt. Die neue Sicht entsteht aus derselben Quelle wie die alte. Das ist der Moment, in dem sich die anfängliche Disziplin auszahlt.\n\n## Der Datenfluss im Bild\n\nEine Quelle, mehrere sichtenspezifische Projections – so sieht der Fluss aus:\n\n```mermaid\nflowchart LR\n  C[Command] --> W[Write Model /<br/>Event Store]\n  W -->|Events| P[Projektor]\n  P -->|async · eventual| A[Read Model A<br/>Liste]\n  P -->|async · eventual| B[Read Model B<br/>Detail]\n  P -->|async · eventual| R[Read Model C<br/>Report]\n```\n\nDer Command läuft gegen das Write Model und erzeugt Events. Der Projector abonniert diese Events und schreibt daraus so viele Read Models fort, wie es Sichten gibt. Die Kanten zu den Read Models sind bewusst mit \"async · eventual\" beschriftet, denn hier steckt der dritte Kernpunkt: die Konsistenz.\n\n## Eventual Consistency ist ein bewusster Handel\n\nDie Projection läuft asynchron, nachdem der Schreibvorgang erfolgreich war. Fowler formuliert die Konsequenz nüchtern: Man kann nicht exakt garantieren, *wann* das Read Model konsistent ist. Kurz nach dem Schreiben ist es potenziell veraltet. Das ist kein Bug, kein Datenverlust und keine Korruption – es ist ein begrenzter, bekannter Verzug, den man in Kauf nimmt, um Lese- und Schreibseite zu entkoppeln.\n\nDer Verzug selbst macht selten Ärger. Der Umgang damit schon. Wer eine synchrone Read-after-write-Konsistenz erwartet, baut die UI so, dass sie sofort nach dem Command aus dem Read Model liest und sich wundert, dass der neue Datensatz \"fehlt\". Er ist nicht weg, die Projection ist nur noch nicht durch. Das gehört ins Design der Oberfläche, nicht in einen Bugreport.\n\nGefährlicher ist die zweite Variante: aus dem Read Model heraus Command-seitige Invarianten prüfen. Greg Young ist hier deutlich – das Read Model darf keine Geschäftsentscheidung tragen, die auf aktuellem, konsistentem Zustand beruhen muss. Weil es veraltet sein kann, würde man auf veralteten Daten entscheiden. Die Prüfung, ob ein Konto gedeckt ist oder eine Bestellung noch storniert werden darf, gehört auf die Schreibseite, gegen das Aggregate, nicht gegen die Leseansicht. Diese Arbeitsteilung habe ich in [Commands, Events und Queries trennen](https://www.mikebild.dev/de/blog/commands-events-queries-trennen/) genauer aufgeschrieben: Eine Query stellt eine Frage und ändert nichts; sie darf deshalb auch keine Entscheidung erzwingen.\n\n## Projection oder Cache – die Unterscheidung in fünf Punkten\n\nOb eine konkrete Leseseite eine Projection oder ein Cache ist, klärt sich an fünf Fragen:\n\n- Wer ist die Wahrheitsquelle? Bei der Projection ist der Event Store die Quelle, das Read Model ist die autoritative Lese-View. Beim Cache liegt die autoritative Quelle in der Datenbank dahinter.\n- Wie wird invalidiert? Die Projection wird eventgetrieben fortgeschrieben, der Cache über TTL oder Verdrängung.\n- Wie läuft der Neuaufbau? Die Projection ist by design deterministisch aus der Historie neu berechenbar; der Cache-Neuaufbau ist ein Kaltstart-Side-Effect, abhängig vom aktuellen Quellzustand.\n- Welche Form hat die Sicht? Die Projection ist pro Sicht denormalisiert und aggregatübergreifend zusammengezogen, der Cache meist eine Kopie des Quell-Datensatzes.\n- Wie steht es um die Konsistenz? Die Projection ist bewusst eventual consistent mit begrenztem Verzug, der Cache best-effort.\n\nEin oder zwei dieser Punkte kann man diskutieren. Aber wenn die Antworten überwiegend in der Cache-Spalte landen, dann sollte man das Ding auch so nennen und nicht die Erwartungen an ein Read Model daran hängen.\n\n## Die drei Fallstricke, konkret\n\nZum Schluss die Muster, die den Ärger verursachen, jeweils in einem Satz benannt und mit der Gegenmaßnahme.\n\nDer erste ist das Cache-Denken selbst: Das Read Model als optionalen Beschleuniger behandeln, den man bei einem Fehltreffer aus der \"eigentlichen\" Datenbank neu zieht. Es gibt diese eigentliche Datenbank nicht. Die Fortschreibung erfolgt eventgetrieben, und der Rebuild kommt aus den Events. Wer das ernst nimmt, hört auf, nach der Quelle *hinter* dem Read Model zu suchen.\n\nDer zweite ist das Read Model als Wahrheitsquelle: Aus einer potenziell veralteten Lese-View heraus über Command-Invarianten entscheiden. Die Konsequenz sind Entscheidungen auf altem Stand. Die Gegenmaßnahme ist Disziplin über die Seitengrenze – Entscheidungen auf die Schreibseite, gegen das Aggregate.\n\nDer dritte ist der vergessene Neuaufbau: Eine Projection bauen, die sich nicht replayen lässt, weil das `apply` nicht rein ist oder Events verworfen wurden. Dann wird die erste Schema-Migration zur Sackgasse. Die Gegenmaßnahme steht oben im Code – halte den Projector als reine Faltung über den Events, und schreibe den `rebuild` früh, nicht erst wenn er brennt.\n\n## Fazit\n\nEin Read Model verdient einen besseren Namen als \"Cache\", weil der Name die Erwartungen setzt. Nennt man es Cache, baut man einen optionalen Beschleuniger mit einer vermeintlichen Quelle dahinter, TTL-Invalidierung und einem Neuaufbau, der Side Effect bleibt. Nennt man es Projection, baut man eine reine Faltung über einem immutable Event-Strom, pro Sicht zugeschnitten, eventgetrieben fortgeschrieben und jederzeit deterministisch neu berechenbar. Die drei Eigenschaften, an denen ich das festmache, sind immer dieselben: deterministisch abgeleitet, jederzeit neu aufbaubar, bewusst eventual consistent. Wer diese drei ins Design zieht, hat eine Leseseite, die man betreiben und migrieren kann, ohne bei jeder Änderung ins Schwitzen zu geraten.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html\n- Martin Fowler, [CQRS](https://martinfowler.com/bliki/CQRS.html) – der konzeptuelle Modellsplit und die Warnung, ihn nur kontextlokal einzusetzen.\n- Martin Fowler, [Reporting Database](https://martinfowler.com/bliki/ReportingDatabase.html) – die verwandte, aber engere Idee einer separaten Lese-Datenbank.\n- Martin Fowler, [Event Sourcing](https://martinfowler.com/eaaDev/EventSourcing.html) – die Persistenzform, die Projections so natürlich macht.\n- Greg Young, [CQRS Documents (2010, PDF)](https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf) – die Grundlagen zu Read Models, Eventual Consistency und Rebuild.\n- Vaughn Vernon, *Implementing Domain-Driven Design* (2013), Kapitel zu Architektur und CQRS; Beispielcode in den [IDDD Samples](https://github.com/VaughnVernon/IDDD_Samples).\n- Mein eigenes Vortragsmaterial \"CQRS + Event Sourcing\" auf SlideShare – die Folien, aus denen ich in Schulungen den Kern herleite: Lesen und Schreiben sind unterschiedliche Verantwortlichkeiten."},{"slug":"cloud-guardrails","title":"Cloud Guardrails statt Cloud Gatekeeping","summary":"Warum zentrale Freigabeprozesse in der Cloud Schatten-Infrastruktur züchten und wie technisch durchgesetzte Leitplanken – SCPs, Permission Boundaries, Config Rules, Budgets – Teams Bewegungsfreiheit zurückgeben.","tags":["Software","Architektur"],"published_at":"2020-09-12T10:00:00+00:00","updated_at":"2026-07-14T17:13:13.975247+00:00","url":"https://www.mikebild.dev/de/blog/cloud-guardrails/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/5e6213d2b8014cf05d733ee7148f711e54bd87f328be608851b42877af4362e0/cloud-guardrails-vorlesen.mp3","audio_duration_secs":663,"text":"# Cloud Guardrails statt Cloud Gatekeeping\n\nEin Freigabeprozess mit drei Tagen Wartezeit erzeugt keine Kontrolle. Er erzeugt Schatten-Infrastruktur. Das Team, das auf die Freigabe für eine DynamoDB-Tabelle wartet, wartet nicht wirklich – es weicht aus: auf den privaten AWS-Account eines Kollegen, auf die Kreditkarte des Projektleiters, auf den „temporären\" Bucket im Sandbox-Account, der zwei Jahre später immer noch produktive Daten enthält. Der Prozess, der Risiken verhindern sollte, verschiebt sie genau dorthin, wo niemand mehr hinschaut: kein Review, kein zentrales Logging, keine Kostenkontrolle, keine Backups.\n\nIn einem Projekt im letzten Jahr habe ich das aus der Nähe gesehen. Ein zentrales Cloud-Team, ein Jira-Board, jede neue Ressource ein Ticket. Offizielle Durchlaufzeit: drei Tage. Tatsächliche Durchlaufzeit bei Rückfragen: eine Woche und mehr. Das Cloud-Team war weder böswillig noch faul – es war schlicht zu klein für zwölf Produktteams, und jedes Ticket brauchte Kontext, den nur das anfragende Team hatte. Mir ist dabei aufgefallen, dass die eigentliche Diskussion nie „erlauben oder verbieten\" war. Sie war fast immer: „In welcher Region? Mit welchem Tagging? Wer zahlt das?\" Also Fragen, die sich als Regel formulieren lassen. Und was sich als Regel formulieren lässt, kann eine Maschine durchsetzen – ohne Wartezeit.\n\n## Was der Ticket-Prozess eigentlich schützt\n\nBevor man den Prozess abschafft, lohnt der Blick darauf, welche berechtigten Sorgen er trägt. In dem Projekt waren es im Kern vier:\n\n- Kosten: Niemand soll versehentlich einen teuren Cluster in einer exotischen Instanzgröße starten.\n- Datenhoheit: Daten bleiben in europäischen Regionen, Punkt.\n- Sicherheitsgrundlinien: Kein öffentlicher S3-Bucket, kein abgeschaltetes CloudTrail, keine weit offenen Security Groups.\n- Rechte-Eskalation: Wer Rollen anlegen darf, darf sich sonst beliebig weitreichende Rechte selbst ausstellen.\n\nAlle vier Sorgen sind real. Aber keine davon braucht einen Menschen, der drei Tage später ein Ticket liest. Sie brauchen Grenzen, die zum Zeitpunkt des API-Aufrufs gelten – nicht zum Zeitpunkt der Freigabe. Der Ticket-Prozess prüft eine Absichtserklärung; was danach wirklich passiert, prüft er nie wieder. Die Leitplanke prüft jede einzelne Aktion, jedes Mal.\n\nAWS selbst hat für dieses Denken inzwischen einen Namen: Control Tower, seit Sommer 2019 allgemein verfügbar, nennt seine vorkonfigurierten Regeln wörtlich „Guardrails\". Der Begriff ist also kein Schlagwort von Konferenzfolien, sondern kommt aus der Produktpraxis der Plattform. Interessanter als der Name ist die Unterscheidung, die Control Tower dabei trifft: preventive Guardrails verhindern eine Aktion, detective Guardrails erkennen eine Abweichung. Diese Unterscheidung trägt die gesamte Architektur.\n\n```mermaid\nflowchart LR\n  A[Team ändert<br/>Infrastruktur] --> B{Präventiv:<br/>SCP / Boundary}\n  B -->|verstößt| C[API-Aufruf sofort<br/>abgelehnt]\n  B -->|erlaubt| D[Ressource<br/>entsteht]\n  D --> E{Detektivisch:<br/>AWS Config}\n  E -->|konform| F[Nichts passiert]\n  E -->|Abweichung| G[Alarm oder<br/>Auto-Korrektur]\n```\n\n## Harte Grenzen: Service Control Policies\n\nDie äußerste Leitplanke sind Service Control Policies in AWS Organizations. Eine SCP gilt für ganze Accounts oder Organizational Units und schlägt jede IAM-Policy im Account – selbst der Account-Administrator kommt nicht daran vorbei. Genau deshalb gehören hier nur die Regeln hinein, die wirklich nie verhandelbar sind. In dem Projekt waren das zwei: erlaubte Regionen und eine kurze Liste verbotener Services.\n\n```json\n{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Sid\": \"DenyOutsideAllowedRegions\",\n      \"Effect\": \"Deny\",\n      \"NotAction\": [\n        \"iam:*\",\n        \"organizations:*\",\n        \"route53:*\",\n        \"cloudfront:*\",\n        \"support:*\"\n      ],\n      \"Resource\": \"*\",\n      \"Condition\": {\n        \"StringNotEquals\": {\n          \"aws:RequestedRegion\": [\"eu-central-1\", \"eu-west-1\"]\n        }\n      }\n    },\n    {\n      \"Sid\": \"DenyUnapprovedServices\",\n      \"Effect\": \"Deny\",\n      \"Action\": [\"gamelift:*\", \"robomaker:*\", \"chime:*\"],\n      \"Resource\": \"*\"\n    }\n  ]\n}\n```\n\nZwei Details daran sind mir wichtig. Erstens die `NotAction`-Ausnahmen: IAM, CloudFront oder Route 53 sind globale Services, ihre API-Aufrufe laufen technisch über us-east-1 – wer sie in der Regionssperre vergisst, sperrt sich selbst aus. Das haben wir auf die harte Tour gelernt, zum Glück in einem Test-Account. Zweitens der Charakter der Liste verbotener Services: Sie ist kurz und begründet, keine Positivliste von hundert Einträgen. Eine SCP, die alles verbietet außer dem, was jemand einmal beantragt hat, ist nur das alte Gatekeeping in JSON-Form. Die Leitplanke markiert den Rand der Straße, nicht die einzige erlaubte Spur.\n\nWichtig für das Verständnis: Eine SCP vergibt selbst keine Rechte. Sie definiert die Obergrenze dessen, was IAM-Policies im Account überhaupt wirksam erlauben können. Innerhalb dieser Grenze bewegen sich die Teams frei – und genau das ist der Punkt.\n\n## Selbstbedienung bei IAM: Permission Boundaries\n\nDie unbequemste Frage kam in dem Projekt vom Sicherheitsverantwortlichen: „Wenn Entwickler eigene IAM-Rollen anlegen dürfen, können sie sich dann nicht beliebige Rechte geben?\" Ohne Gegenmaßnahme: ja. Wer `iam:CreateRole` und `iam:AttachRolePolicy` hat, kann eine Rolle mit Administratorrechten bauen und sie übernehmen. Deshalb war IAM historisch das letzte Revier des zentralen Teams – und der häufigste Grund für Wartezeit, denn jede Lambda-Function, jede Pipeline braucht eine Rolle.\n\nSeit 2018 gibt es dafür Permission Boundaries: eine Managed Policy, die als Obergrenze an eine Rolle geheftet wird. Die effektiven Rechte sind der Schnitt aus Boundary und angehefteten Policies. Der Trick liegt in der Selbstverstärkung – die Boundary erlaubt das Anlegen neuer Rollen nur dann, wenn die neue Rolle wieder dieselbe Boundary trägt:\n\n```json\n{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Sid\": \"AllowWorkloadServices\",\n      \"Effect\": \"Allow\",\n      \"Action\": [\n        \"s3:*\", \"dynamodb:*\", \"lambda:*\", \"sqs:*\",\n        \"logs:*\", \"cloudwatch:*\", \"apigateway:*\"\n      ],\n      \"Resource\": \"*\"\n    },\n    {\n      \"Sid\": \"DenyCreateWithoutBoundary\",\n      \"Effect\": \"Deny\",\n      \"Action\": [\"iam:CreateRole\", \"iam:CreateUser\"],\n      \"Resource\": \"*\",\n      \"Condition\": {\n        \"StringNotEquals\": {\n          \"iam:PermissionsBoundary\": \"arn:aws:iam::111122223333:policy/workload-boundary\"\n        }\n      }\n    },\n    {\n      \"Sid\": \"DenyBoundaryTampering\",\n      \"Effect\": \"Deny\",\n      \"Action\": [\n        \"iam:DeleteRolePermissionsBoundary\",\n        \"iam:DeletePolicy\",\n        \"iam:CreatePolicyVersion\"\n      ],\n      \"Resource\": \"arn:aws:iam::111122223333:policy/workload-boundary\"\n    }\n  ]\n}\n```\n\nDamit konnten die Teams Rollen für ihre Services anlegen, ohne dass jemand freigeben musste – aber keine Rolle konnte je mehr als die `workload-boundary` erlaubt. Im CDK, mit dem wir die Infrastruktur ohnehin beschrieben haben, ist das eine Zeile pro Rolle:\n\n```ts\nimport * as iam from '@aws-cdk/aws-iam';\n\nconst boundary = iam.ManagedPolicy.fromManagedPolicyName(\n  this,\n  'WorkloadBoundary',\n  'workload-boundary'\n);\n\nconst serviceRole = new iam.Role(this, 'OrderServiceRole', {\n  assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),\n  permissionsBoundary: boundary,\n});\n```\n\nDie drei Tage Wartezeit für „bitte eine Rolle für den Order-Service\" waren damit weg. Nicht, weil jemand schneller freigab, sondern weil es nichts mehr freizugeben gab: Die Grenze war schon gezogen, bevor die Anfrage entstand.\n\n## Erkennen statt nur verhindern: AWS Config\n\nPräventive Regeln haben eine Lücke: Sie können nur verbieten, was sich als IAM-Bedingung ausdrücken lässt. „Kein S3-Bucket ohne Verschlüsselung\" oder „keine Security Group mit 0.0.0.0/0 auf Port 22\" sind Eigenschaften einer Ressource, keine API-Aktionen – dafür braucht es die zweite Sorte Leitplanke. AWS Config zeichnet den Zustand aller Ressourcen auf und prüft ihn laufend gegen Regeln. Bei Abweichung: Alarm, und wo es gefahrlos möglich ist, automatische Korrektur.\n\n```ts\nimport * as config from '@aws-cdk/aws-config';\nimport * as budgets from '@aws-cdk/aws-budgets';\n\nnew config.ManagedRule(this, 'BucketsNotPublic', {\n  identifier: 'S3_BUCKET_PUBLIC_READ_PROHIBITED',\n});\n\nnew config.ManagedRule(this, 'VolumesEncrypted', {\n  identifier: 'ENCRYPTED_VOLUMES',\n});\n\nnew budgets.CfnBudget(this, 'TeamMonthlyBudget', {\n  budget: {\n    budgetType: 'COST',\n    timeUnit: 'MONTHLY',\n    budgetLimit: { amount: 500, unit: 'USD' },\n  },\n  notificationsWithSubscribers: [\n    {\n      notification: {\n        notificationType: 'ACTUAL',\n        comparisonOperator: 'GREATER_THAN',\n        threshold: 80,\n      },\n      subscribers: [\n        { subscriptionType: 'EMAIL', address: 'team-orders@example.com' },\n      ],\n    },\n  ],\n});\n```\n\nDas Budget gehört für mich in dieselbe Kategorie, auch wenn es technisch ein anderer Service ist. Die Kostenangst war der emotionale Kern des alten Freigabeprozesses – und ein Budget-Alarm bei 80 Prozent beantwortet sie besser als jedes Ticket, weil er auf tatsächliche Ausgaben reagiert statt auf geschätzte. In dem Projekt bekam jedes Team sein eigenes Budget mit eigener Benachrichtigung. Die Diskussionen über Kosten wurden dadurch nicht weniger, aber sie fanden mit Zahlen statt mit Vermutungen statt, und sie fanden im Team statt – nicht zwischen Team und Torwächter.\n\nDer zweite Gewinn von Config ist unscheinbarer: die Historie. Wenn eine Security Group heute anders aussieht als gestern, zeigt Config, wann sich was geändert hat – das hat uns bei mehr als einer Fehlersuche Stunden gespart, ganz unabhängig von den Regeln. Ein Freigabeprozess weiß nur, was beantragt wurde; Config weiß, was tatsächlich passiert ist.\n\nBei der Auto-Korrektur bin ich zurückhaltender geworden. Eine Regel, die eine öffentliche Bucket-Policy sofort wieder entfernt, ist ein Sicherheitsgewinn. Eine Regel, die nachts Ressourcen löscht, deren Tags nicht stimmen, hat uns einmal einen Morgen Fehlersuche gekostet, weil ein legitimer Prototyp verschwunden war. Erkennen und melden ist fast immer richtig; automatisch eingreifen nur dort, wo die Korrektur garantiert nichts zerstört.\n\n## Leitplanken sind Code, keine Klick-Konfiguration\n\nDer Punkt, an dem solche Setups in meiner Erfahrung kippen, ist nicht die Technik, sondern die Pflege. Eine SCP, die jemand vor einem Jahr in der Konsole zusammengeklickt hat, ist genauso undurchsichtig wie der alte Freigabeprozess – nur dass jetzt niemand mehr weiß, warum Port 22 gesperrt ist und wer das ändern darf. Leitplanken sind Infrastruktur, und für Infrastruktur gilt, was ich schon [beim Review von Infrastruktur-Code](https://www.mikebild.dev/de/blog/infrastructure-as-code-code-reviews/) beschrieben habe: Sie gehört versioniert, reviewt und nachvollziehbar geändert.\n\nKonkret heißt das bei uns: SCPs, Boundary-Policies und Config-Rules stehen als Code im Repository, Änderungen laufen als Pull Request, und im Review sitzt neben dem Plattform-Team auch jemand aus einem Produktteam. Der Pull Request ersetzt das Ticket – mit einem entscheidenden Unterschied: Diskutiert wird eine dauerhafte Regel für alle, nicht eine Einzelfreigabe für einen. Die Diskussion lohnt sich also, ihr Ergebnis wirkt weiter. Wie sich solche Regeln zusätzlich schon beim Synthetisieren der eigenen Stacks prüfen lassen, habe ich in [Policy as Code für CDK](https://www.mikebild.dev/de/blog/policy-as-code-fuer-cdk/) beschrieben – beides zusammen ergibt zwei Prüfpunkte: einen früh im Build, einen unumgehbar an der Konto-Grenze.\n\nNebenbei: Das Muster ist nicht AWS-spezifisch. Im Kubernetes-Umfeld passiert gerade dasselbe mit Open Policy Agent und Gatekeeper – Regeln als Code, durchgesetzt am Admission Controller statt im Meeting. Wer beide Welten betreibt, erkennt die Denkfigur sofort wieder.\n\n```mermaid\nflowchart TD\n  Root[Organization Root] --> Sec[OU Security<br/>Logging, Audit]\n  Root --> Work[OU Workloads]\n  Root --> Sand[OU Sandbox]\n  Work --> A1[Account Team Orders]\n  Work --> A2[Account Team Billing]\n  SCP1[SCP: EU-Regionen,<br/>Service-Sperrliste] -.gilt für.-> Work\n  SCP2[SCP: zusätzlich<br/>Kostengrenzen eng] -.gilt für.-> Sand\n```\n\nDie Organisationsstruktur selbst ist Teil der Leitplanken: Eine Sandbox-OU mit engen Grenzen und einer Aufräum-Regel nimmt dem Experimentieren das Risiko, eine Workload-OU trägt die verbindlichen Regeln. Wer heute mit einer Multi-Account-Struktur anfängt, bekommt mit Control Tower genau dieses Gerüst vorkonfiguriert – die Guardrails dahinter sind dieselben SCPs und Config-Rules, nur fertig verdrahtet.\n\n## Was am Ende anders ist\n\nNach einem halben Jahr mit diesem Setup war das Jira-Board des Cloud-Teams fast leer – nicht weil weniger passierte, sondern weil das meiste keine Freigabe mehr brauchte. Die Tickets, die blieben, waren die interessanten: Anträge, eine Leitplanke zu ändern. Genau da gehört menschliche Aufmerksamkeit hin. Und das Plattform-Team hatte plötzlich Zeit für die Arbeit, die vorher liegen blieb: bessere Vorlagen, schärfere Regeln, ein neuer Account in einer Stunde statt in einer Woche.\n\nEin paar Beobachtungen nehme ich mit. Der Freigabeprozess und die Leitplanke beantworten dieselbe Frage – „was darf hier passieren?\" – aber zu verschiedenen Zeitpunkten: der Prozess einmal vorher auf Basis einer Beschreibung, die Leitplanke bei jeder Aktion auf Basis der Wirklichkeit. Das macht Leitplanken nicht nur schneller, sondern ehrlicher. Zweitens: Die Trennung in präventiv und detektivisch ist keine Feinheit, sondern die zentrale Entwurfsentscheidung. Alles, was hart verboten ist, gehört in SCPs und Boundaries; alles, was Eigenschaften von Ressourcen betrifft, in Config-Rules mit Alarm. Wer versucht, alles präventiv zu erzwingen, baut das Gatekeeping in JSON nach. Und drittens: Schatten-Infrastruktur ist kein Disziplinproblem der Teams, sondern ein Symptom. Sie verschwindet nicht durch strengere Prozesse, sondern durch einen offiziellen Weg, der schneller ist als der Umweg.\n\n## Weiterführende Quellen\n\n- [Service Control Policies – AWS Organizations User Guide](https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps.html)\n- [Permissions Boundaries for IAM Entities – AWS IAM User Guide](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html)\n- [What Is AWS Config? – AWS Config Developer Guide](https://docs.aws.amazon.com/config/latest/developerguide/WhatIsConfig.html)\n- [AWS Control Tower – Set up & Govern a Multi-Account AWS Environment (AWS News Blog, 2019)](https://aws.amazon.com/blogs/aws/aws-control-tower-set-up-govern-a-multi-account-aws-environment/)"},{"slug":"cdk-constructs-abstraktion-mit-haftung","title":"CDK Constructs: Abstraktion mit Haftung","summary":"Wer ein CDK Construct veröffentlicht, verschickt keine Hilfsfunktion, sondern Betriebsentscheidungen – über Defaults, Upgrade-Pfade und das, was beim Konsumenten im CloudFormation-Template landet.","tags":["Software","AWS","Cloud"],"published_at":"2020-08-12T10:00:00+00:00","updated_at":"2026-07-14T17:10:58.285909+00:00","url":"https://www.mikebild.dev/de/blog/cdk-constructs-abstraktion-mit-haftung/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/0044b69bc7a126d6fb3d3fa51af4f11d0392452c29e9d70533846aed3203aa8a/cdk-constructs-abstraktion-mit-haftung-vorlesen.mp3","audio_duration_secs":709,"text":"# CDK Constructs: Abstraktion mit Haftung\n\nEine Abstraktion, die man importiert, ist Verantwortung, die man abgibt – so lautet die stillschweigende Annahme, wenn ein Team das erste fertige CDK Construct in sein Projekt zieht. Drei Zeilen Code, ein `cdk deploy`, und S3-Bucket, CloudFront-Distribution und Bucket-Policy stehen. Die Details hat ja jemand anderes durchdacht.\n\nGenau diese Annahme halte ich für falsch, und zwar in beide Richtungen. Wer ein Construct benutzt, gibt keine Verantwortung ab – er importiert fremde Entscheidungen in sein eigenes CloudFormation-Template, auf seine eigene AWS-Rechnung und in seine eigene Angriffsfläche. Und wer ein Construct veröffentlicht, hat nicht einfach Code geteilt – er haftet ab jetzt für Defaults, für Upgrade-Pfade und für alles, was bei fremden Leuten in Produktion entsteht. Ein Construct ist Abstraktion mit Haftung. Das unterscheidet es von fast allem, was ich sonst über npm beziehe.\n\n## Drei Ebenen, drei verschiedene Versprechen\n\nDas CDK sortiert seine Bausteine in drei Ebenen, und die Haftungsfrage stellt sich auf jeder anders. L1-Constructs (`CfnBucket`, `CfnDistribution`) sind generierte Eins-zu-eins-Abbildungen der CloudFormation-Ressourcen. Sie versprechen nichts außer Vollständigkeit: jede Property, kein Default, keine Meinung. L2-Constructs (`s3.Bucket`, `lambda.Function`) sind handgeschriebene Kapseln mit Meinung – sinnvolle Defaults, Komfortmethoden wie `grantRead`, automatisch verdrahtete IAM-Policies. L3-Constructs schließlich, im CDK-Sprachgebrauch auch Patterns, verdrahten mehrere Dienste zu einem fachlichen Baustein: „statische Website mit CDN\", „API mit Lambda und DynamoDB\".\n\nJe höher die Ebene, desto größer das Versprechen – und desto größer die Haftung. Ein L1-Construct kann mich kaum überraschen, weil es nichts entscheidet. Ein L3-Construct entscheidet fast alles, und ich sehe es dem Code nicht an. Genau deshalb sind L3-Constructs so verführerisch: Sie lesen sich wie Fachlichkeit („eine Website\", „eine API\") und verstecken, dass darunter ein Bündel sehr konkreter Betriebsentscheidungen liegt. Was dabei wirklich passiert, zeigt erst die Synthese:\n\n```mermaid\nflowchart LR\n  L3[L3: StaticSite<br/>eigenes Pattern] --> L2[L2: s3.Bucket<br/>CloudFrontWebDistribution]\n  L2 --> L1[L1: CfnBucket<br/>CfnDistribution]\n  L1 --> SYNTH[cdk synth]\n  SYNTH --> TPL[CloudFormation-Template<br/>mehrere hundert Zeilen]\n  TPL --> DEPLOY[cdk deploy]\n  DEPLOY --> RES[echte Ressourcen:<br/>Kosten, Rechte, Angriffsfläche]\n```\n\nDer Pfeil ganz rechts ist der Punkt, an dem sich ein Construct von einer Bibliotheksfunktion unterscheidet. Eine Funktion aus lodash erzeugt zur Laufzeit ein Array. Ein Construct erzeugt eine CloudFront-Distribution, die Geld kostet, einen Bucket, der öffentlich sein kann oder nicht, und IAM-Statements, die jemandem etwas erlauben. Wenn `map` einen Fehler hat, schlägt ein Test fehl. Wenn ein Construct einen Fehler hat, liegt ein Bucket offen im Internet oder eine Removal Policy löscht beim Stack-Abbau Produktionsdaten. Das ist kein Grund, auf Abstraktion zu verzichten – aber ein Grund, sie anders zu behandeln.\n\n## Ein eigenes Construct und seine unbequemen Entscheidungen\n\nMir wurde das richtig klar, als ich selbst ein kleines Construct gebaut habe: statische Website in S3, ausgeliefert über CloudFront. Der dritte oder vierte handgeschriebene Stack dieser Art im Umfeld, immer dieselben sechzig Zeilen, also lag die Extraktion nahe. Der erste Wurf war schnell fertig. Interessant wurde es bei den Fragen, die der erste Wurf offen ließ.\n\n```typescript\nimport { Construct, RemovalPolicy } from '@aws-cdk/core';\nimport * as s3 from '@aws-cdk/aws-s3';\nimport * as cloudfront from '@aws-cdk/aws-cloudfront';\nimport * as deployment from '@aws-cdk/aws-s3-deployment';\n\nexport interface StaticSiteProps {\n  readonly sourcePath: string;\n  readonly retainBucket?: boolean;\n}\n\nexport class StaticSite extends Construct {\n  public readonly siteBucket: s3.Bucket;\n  public readonly distribution: cloudfront.CloudFrontWebDistribution;\n\n  constructor(scope: Construct, id: string, props: StaticSiteProps) {\n    super(scope, id);\n\n    const originAccessIdentity = new cloudfront.OriginAccessIdentity(\n      this,\n      'OriginAccessIdentity'\n    );\n\n    this.siteBucket = new s3.Bucket(this, 'SiteBucket', {\n      encryption: s3.BucketEncryption.S3_MANAGED,\n      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,\n      removalPolicy: props.retainBucket\n        ? RemovalPolicy.RETAIN\n        : RemovalPolicy.DESTROY,\n    });\n    this.siteBucket.grantRead(originAccessIdentity);\n\n    this.distribution = new cloudfront.CloudFrontWebDistribution(this, 'Distribution', {\n      originConfigs: [\n        {\n          s3OriginSource: {\n            s3BucketSource: this.siteBucket,\n            originAccessIdentity,\n          },\n          behaviors: [{ isDefaultBehavior: true }],\n        },\n      ],\n    });\n\n    new deployment.BucketDeployment(this, 'DeploySite', {\n      sources: [deployment.Source.asset(props.sourcePath)],\n      destinationBucket: this.siteBucket,\n      distribution: this.distribution,\n    });\n  }\n}\n```\n\nJede dieser Zeilen ist eine Betriebsentscheidung, die ich für alle künftigen Nutzer treffe. Der Bucket ist nicht öffentlich, sondern nur über die Origin Access Identity erreichbar – das ist die richtige Entscheidung, aber sie ist nicht selbstverständlich: das nackte `s3.Bucket` aus dem CDK setzt bis heute keinen Public-Access-Block, wenn man nichts angibt. Die Verschlüsselung ist eingeschaltet, obwohl der Inhalt einer öffentlichen Website sie streng genommen nicht braucht – weil niemand später diskutieren soll, warum ausgerechnet dieser Bucket unverschlüsselt ist. Und die Removal Policy steht per Default auf `DESTROY`.\n\nÜber diesen letzten Default habe ich am längsten nachgedacht. Das CDK selbst behält Buckets beim Stack-Abbau (`RETAIN`), weil ein Bucket mit Inhalt nicht ohne Weiteres gelöscht werden kann und weil Datenverlust der teuerste aller Fehler ist. Für ein Website-Construct, dessen Bucket-Inhalt bei jedem Deployment neu aus dem Build entsteht, ist `RETAIN` aber der falsche Schutz: Er hinterlässt bei jedem abgerissenen Test-Stack einen Waisen-Bucket, und nach drei Monaten weiß niemand mehr, welche davon weg können. Also `DESTROY` als Default und `retainBucket` als Ausweg für die Fälle, in denen der Bucket doch mehr enthält als Build-Artefakte. Vielleicht ist das die falsche Abwägung – aber sie steht jetzt in einer Zeile Interface-Definition und ist damit diskutierbar. Vorher steckte sie unausgesprochen in sechzig Zeilen kopiertem Stack-Code.\n\nGenauso bewusst sind die beiden `public readonly`-Felder. Ein Construct, das seine inneren Ressourcen versteckt, zwingt seine Nutzer beim ersten Sonderfall zum Fork. Wer an die `CfnDistribution` heran muss, kommt über `this.distribution.node.defaultChild` dran und kann per `addPropertyOverride` alles übersteuern, was meine Abstraktion nicht vorgesehen hat:\n\n```typescript\nconst cfnDistribution = site.distribution.node\n  .defaultChild as cloudfront.CfnDistribution;\n\ncfnDistribution.addPropertyOverride(\n  'DistributionConfig.ViewerCertificate.MinimumProtocolVersion',\n  'TLSv1.2_2019'\n);\n```\n\nDiese Escape Hatches sind kein Schönheitsfehler der Abstraktion, sondern Teil des Vertrags. Eine Abstraktion ohne Ausweg ist in der Infrastruktur eine Sackgasse, und Sackgassen fallen immer erst auf, wenn man schon drinsteht.\n\n## cdk synth ist der Wahrheitstest\n\nFür die andere Seite der Haftung – die des Konsumenten – gibt es ein Werkzeug, das ich für wichtiger halte als jede Dokumentation: die Synthese. `cdk synth` schreibt das CloudFormation-Template heraus, das aus dem ganzen Construct-Baum entsteht. Das Template lügt nicht. Es enthält jede Ressource, jede Policy, jeden Default, den irgendein Construct irgendwo gesetzt hat.\n\n```bash\nnpm install @aws-cdk/aws-s3@1.57.0 @aws-cdk/aws-cloudfront@1.57.0\n\ncdk synth StaticSiteStack > synthesized.yaml\n\n# what did the abstraction actually decide for me?\ngrep -A 6 'PublicAccessBlockConfiguration' synthesized.yaml\ngrep -B 2 'DeletionPolicy' synthesized.yaml\n\n# and before every deployment: what changes against reality?\ncdk diff StaticSiteStack\n```\n\nBevor ich ein fremdes Construct einsetze, baue ich ein Minimalbeispiel, synthetisiere und lese. Nicht das ganze Template Zeile für Zeile – aber gezielt die Stellen, an denen fremde Defaults teuer oder gefährlich werden. Das kostet eine halbe Stunde und hat sich bisher jedes Mal bezahlt gemacht: Einmal fand ich auf diesem Weg eine CloudFront-Price-Class, die weltweit auslieferte, wo Europa gereicht hätte; ein anderes Mal eine Log-Konfiguration, die schlicht fehlte, was erst beim ersten Vorfall aufgefallen wäre. Bei einem Pattern aus drei Zeilen Anwendungscode kommen gern mehrere hundert Zeilen Template heraus, und irgendwo darin stehen die Antworten auf die Fragen, die das README nicht beantwortet:\n\n- Welche IAM-Statements entstehen, und stehen darin Wildcards, die ich nicht bestellt habe?\n- Welche Ressourcen kosten laufend Geld, auch wenn niemand das System benutzt?\n- Was ist öffentlich erreichbar, was ist verschlüsselt, was hat welche `DeletionPolicy`?\n- Welche Properties kann ich über das Interface ändern – und für welche brauche ich schon am ersten Tag den Escape Hatch?\n\nAWS hat in diesem Frühjahr mit den Solutions Constructs eine eigene Bibliothek solcher Patterns gestartet: geprüfte Zwei-Dienst-Kombinationen wie CloudFront vor API Gateway vor Lambda, mit Defaults entlang des Well-Architected Framework. Ich habe einige davon gelesen, teils benutzt, und finde vor allem den Quellcode lehrreich – dort sieht man, wie viele Zeilen Default-Logik nötig sind, damit „CloudFront plus S3\" wirklich vernünftig verdrahtet ist, inklusive der Details, die in handgeschriebenen Stacks regelmäßig fehlen. Genau das ist der Wert einer fremden L3-Abstraktion: nicht, dass ich nichts mehr wissen muss, sondern dass jemand die Entscheidungen schon einmal getroffen und aufgeschrieben hat, gegen die ich meine eigenen halten kann. Wie sich solche Prüfungen automatisieren lassen, statt an Disziplin zu hängen, habe ich mir in [Policy as Code für CDK](https://www.mikebild.dev/de/blog/policy-as-code-fuer-cdk/) schon einmal angesehen – Synthese und Regelprüfung gehören für mich inzwischen zusammen.\n\n## Was der Autor schuldet\n\nBleibt die Frage, was man sich einhandelt, wenn man selbst veröffentlicht. Nach einem halben Jahr mit dem eigenen Construct und etlichen gelesenen fremden habe ich dafür eine vorläufige Liste, und sie ist länger, als mir beim Extrahieren der ersten sechzig Zeilen klar war.\n\nDa sind zuerst die Defaults, über die oben schon alles steht – mit dem Zusatz, dass ein veröffentlichter Default praktisch nicht mehr zu ändern ist. Wer ihn verschärft, bricht bestehende Deployments; wer ihn lockert, macht bestehende Nutzer unbemerkt unsicherer. Ein Default ist ein Versprechen mit sehr langer Laufzeit.\n\nDann die Upgrade-Pfade. Das CDK erscheint derzeit fast wöchentlich in neuer Version, wir stehen bei 1.5x, und Module wie CloudFront tragen noch den Experimental-Stempel, dürfen sich also in Minor-Releases ändern. Ein Construct, das ich veröffentliche, spannt sich über diese Bewegung: Meine Nutzer heben ihre CDK-Version an und erwarten, dass mein Construct mitkommt. Peer Dependencies auf die richtigen `@aws-cdk`-Pakete, zeitnahe Releases nach Upstream-Änderungen, ein Changelog, das Template-Änderungen erwähnt – das ist keine Kür, das ist die Hälfte der Arbeit. Und weil das CDK über jsii auch Python-, Java- und .NET-Bindings erzeugt, entscheidet schon die API-Form (keine TypeScript-Spezialitäten, die jsii nicht abbilden kann) darüber, wen ich als Nutzer überhaupt erreichen kann.\n\nDazu kommen Tests, und zwar auf der richtigen Ebene. Unit-Tests gegen die eigene TypeScript-Logik prüfen nur die halbe Wahrheit; haftbar bin ich für das Template. Mit `@aws-cdk/assert` lässt sich beides festnageln – einzelne Zusicherungen (`expect(stack).to(haveResource('AWS::S3::Bucket', { PublicAccessBlockConfiguration: … }))`) für die Entscheidungen, die nie kippen dürfen, und ein Snapshot des ganzen synthetisierten Templates, damit jede Änderung der Ausgabe im Review sichtbar wird statt erst beim Nutzer. Seit ich das so halte, sind mir zwei Upstream-Änderungen aufgefallen, bevor sie ein Release erreicht haben – beide hätten das Template verändert, ohne dass sich eine Zeile meines Codes geändert hätte.\n\nUnd schließlich die Template-Ausgabe selbst. Logische IDs im Template entstehen aus dem Construct-Pfad; wer beim Refactoring seines Constructs Kinder umbenennt oder umhängt, ändert die IDs – und CloudFormation ersetzt daraufhin Ressourcen, statt sie zu ändern. Bei einer CloudFront-Distribution ist das eine halbe Stunde Wartezeit, bei einem Bucket mit Daten ein Zwischenfall. `cdk diff` gegen einen ausgerollten Beispiel-Stack gehört deshalb bei mir vor jedes Release des Constructs, aus demselben Grund, aus dem es vor jedes Deployment gehört. Seit Juli gibt es mit CDK Pipelines (Developer Preview) einen Baustein, der solche Prüfungen in eine selbst-mutierende Deployment-Pipeline zieht – noch frisch, aber die Richtung stimmt: Auch die Auslieferung eines Constructs ist Infrastruktur und will als solche behandelt werden.\n\n## Unterm Strich\n\nVor gut einem Jahr habe ich [darüber geschrieben, was sich ändert, wenn Infrastruktur zur Programmiersprache wird](https://www.mikebild.dev/de/blog/cdk-infrastruktur-als-programmiersprache/). Constructs sind die Fortsetzung davon: Wenn Infrastruktur Code ist, bekommt sie auch die Eigenschaften von Code – Wiederverwendung, Paketierung, Versionierung. Nur die Fehlerkosten bleiben die von Infrastruktur.\n\nDeshalb halte ich beide bequemen Haltungen für falsch. „Ich schreibe alles selbst, dann weiß ich, was drin ist\" skaliert nicht und reproduziert in jedem Stack dieselben vergessenen Details. „Ich nehme das fertige Pattern, dann muss ich es nicht verstehen\" verwechselt Abstraktion mit Absolution. Was für mich trägt, liegt dazwischen: L3-Abstraktionen ja, gern auch eigene – aber gelesen vor dem Einsatz, synthetisiert vor dem Vertrauen, und beim Veröffentlichen mit dem Bewusstsein, dass man ab jetzt für fremde Produktionsumgebungen mitentscheidet. Ein Construct ist kein Snippet. Es ist ein Vertrag über Defaults, Auswege und Upgrade-Pfade, und `cdk synth` ist das Dokument, in dem dieser Vertrag schwarz auf weiß steht.\n\n## Weiterführende Quellen\n\n- AWS Solutions Constructs auf GitHub: https://github.com/awslabs/aws-solutions-constructs\n- AWS CDK auf GitHub: https://github.com/aws/aws-cdk\n- Constructs im CDK Developer Guide: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html\n- Ankündigung der AWS Solutions Constructs (AWS-Blog): https://aws.amazon.com/blogs/aws/aws-solutions-constructs-a-library-of-architecture-patterns-for-the-aws-cdk/\n- CDK Pipelines Developer Preview (AWS-Blog): https://aws.amazon.com/blogs/developer/cdk-pipelines-continuous-delivery-for-aws-cdk-applications/"},{"slug":"bounded-contexts-grenze-vor-microservice","title":"Bounded Contexts vor dem Serviceschnitt","summary":"Bevor ich Services schneide, kläre ich fachliche Grenzen. Ein Bounded Context ist eine Sprachgrenze und ein möglicher, aber nicht zwingender Serviceschnitt.","tags":["Software","Architektur"],"published_at":"2020-07-24T10:00:00+00:00","updated_at":"2026-07-19T18:46:18.505233+00:00","url":"https://www.mikebild.dev/de/blog/bounded-contexts-grenze-vor-microservice/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/df651ec6cd30243a1c3aeef51a70ecebb5d29d9abe4aa9b8292292caa8b8cede/bounded-contexts-grenze-vor-microservice-vorlesen.mp3","audio_duration_secs":738,"text":"# Bounded Contexts vor dem Serviceschnitt\n\nWer die Zahl der Services festlegt, bevor die fachlichen Grenzen stehen, löst keine Kopplung auf – er verteilt sie übers Netzwerk. Der Schnitt trennt dann Code, aber keine Zuständigkeiten: Eine Änderung an einem einzigen Begriff läuft weiterhin durch drei Systeme, jetzt nur mit HTTP dazwischen und drei Teams, die sich abstimmen müssen.\n\nDie Grenze, die vorher fehlt, ist keine technische. Sie verläuft dort, wo ein Wort aufhört, dasselbe zu bedeuten.\n\nDie Architektur entsteht an den sprachlichen Grenzen und an den Übersetzungen zwischen ihnen.\n\n:::relationship{title=\"Fachliche Grenzen vor technischen Schnitten\" role=\"primary\" preferredPattern=\"architecture-map\"}\n- Subdomain · beschreibt einen fachlichen Problemraum\n- Bounded Context · gibt Begriffen und Regeln einen eindeutigen Geltungsbereich\n- Modell · implementiert die Sprache innerhalb dieser Grenze\n- Context Map · benennt Abhängigkeiten und Einfluss zwischen Kontexten\n- Anti-Corruption Layer · übersetzt ein fremdes Modell in die eigene Sprache\n- Service · kann eine Grenze ausführen, definiert sie aber nicht automatisch\n:::\n\nDamit werden Kästen nicht zum Selbstzweck. Entscheidend bleibt, wo Bedeutung wechselt und wer diese Übersetzung verantwortet.\n\n## Warum die Reihenfolge über das Ergebnis entscheidet\n\nDie Frage, mit der Monolithen-Migrationen üblicherweise beginnen, lautet: \"In wie viele Microservices zerlegen wir den Monolithen?\" Sie ist schon falsch gestellt. Die Zahl ist ein Ergebnis und keine Vorgabe. Zu klären ist, wo die fachlichen Grenzen liegen – und ob sich für jede Grenze eine eigene, in sich konsistente Sprache benennen lässt. Wie viele Services daraus werden, zeigt sich danach.\n\nDer Grund dafür ist ökonomisch. Ein Serviceschnitt ist billig zu zeichnen und teuer zu korrigieren, sobald Teams, Deployments und Datenbanken daran hängen. Eine fachliche Grenze dagegen lässt sich am Whiteboard verschieben, solange sie noch Modell ist und keine Infrastruktur. Deshalb investiere ich die erste Woche eines solchen Projekts in Sprache und noch nicht in Infrastruktur- und Deployment-Diagramme. Das Business-Ergebnis ist messbar: kürzere Änderungszyklen, weil eine Änderung in einem Kontext bleibt; weniger Reibung zwischen Teams, weil niemand über geteilte Tabellen stolpert; höhere Qualität, weil das Modell die Fachlichkeit abbildet statt einer technischen Zufallsstruktur.\n\n## Der Bounded Context ist eine Sprachgrenze\n\nEric Evans hat den Begriff 2003 in *Domain-Driven Design* geprägt, Martin Fowler hat ihn in seinem Bliki auf den Punkt gebracht: Ein Bounded Context ist der Bereich, in dem genau ein Modell und genau eine Ubiquitous Language gelten. Innerhalb dieser Grenze bedeutet jedes Wort exakt eine Sache. Über die Grenze hinweg darf dasselbe Wort etwas anderes heißen – darin liegt der eigentliche Gewinn.\n\nEin Beispiel aus dem Handel: das Wort \"Product\". Im Verkauf ist ein Product ein Ding mit Preis, Beschreibung und Verfügbarkeit. Im Lager ist dasselbe Product eine SKU mit Bestand, Lagerort und Nachbestellpunkt. In der Buchhaltung ist es eine Position mit Steuersatz und Erlöskonto. Drei Kontexte, drei legitime Bedeutungen. Der klassische Fehler ist, daraus ein einziges kanonisches `Product`-Objekt bauen zu wollen, das allen dreien gerecht wird. Das Resultat ist eine Klasse mit vierzig Feldern, von denen jeder Kontext zehn ignoriert – und jede Änderung betrifft alle drei Teams.\n\nFowler nennt als dominanten Faktor beim Ziehen dieser Grenzen nicht die Technik, sondern \"human culture\" – Sprache und Organisation. Hier greift Conway's Law: Kontextgrenzen und Teamgrenzen beeinflussen sich gegenseitig. Wenn ich eine saubere Grenze ziehe, an der aber zwei Teams gemeinsam arbeiten müssen, ziehe ich sie am falschen Ort.\n\nPraktisch teste ich eine vermutete Grenze mit zwei Fragen. Erstens: Kann ich in diesem Kontext einen Satz sagen, den ein Domänenexperte des Nachbarkontexts ohne Übersetzung nicht mehr eindeutig versteht? Wenn ja, sind es zwei Sprachen und damit zwei Kontexte. Zweitens: Gibt es eine Regel, die genau hier gilt und nebenan nicht – etwa \"ein Product ohne Bestand ist im Verkauf trotzdem sichtbar, im Lager aber inexistent\"? Solche Regelbrüche sind die verlässlichsten Grenzmarken, die ich kenne. Sie stehen im Fachgespräch, nicht im Klassendiagramm, und genau deshalb finde ich sie nur, wenn ich zuerst zuhöre statt zu modellieren.\n\n## Subdomains ordnen, Bounded Contexts implementieren\n\nBevor ich Grenzen ziehe, sortiere ich den Problemraum. Strategisches DDD trennt Problem Space und Solution Space. Im Problemraum liegen die Subdomains, und die kategorisiere ich in drei Klassen:\n\nDie Core Domain ist das, was ein Unternehmen besser kann als der Wettbewerb – dorthin gehören das beste Team und die meiste Modellierungsarbeit. Supporting Subdomains sind notwendig, aber kein Differenzierungsmerkmal; hier reicht solide Standardarbeit. Generic Subdomains sind gelöste Probleme wie Authentifizierung, Buchhaltung oder Benachrichtigung; die kaufe oder standardisiere ich, statt sie selbst zu bauen.\n\nDiese Einordnung ist eine Geschäftsentscheidung, keine technische. Sie beantwortet die Frage, wo Aufwand einen Return bringt. Bounded Contexts sind dann die Antwort im Lösungsraum: Idealerweise entspricht eine Subdomain genau einem Bounded Context. In der Realität ist das selten deckungsgleich – ein historisch gewachsenes System schneidet oft quer. Aber die Zuordnung Core/Supporting/Generic sagt mir, an welcher Grenze ich Präzision brauche und wo Pragmatismus genügt.\n\n## Die Kanten sind die Architektur, nicht die Kästen\n\nIsolierte Modelle sind noch kein System. Das System entsteht an den Beziehungen zwischen den Kontexten, und diese Beziehungen zu benennen ist der Kern der Arbeit – das Context Mapping. Ohne Context Map entsteht genau das, was Evans einen \"distributed Big Ball of Mud\" nennt: viele Services, die alle voneinander abhängen, ohne dass jemand die Übersetzung kontrolliert.\n\nEine Context Map zeichne ich als Kästen mit Kanten. Die Kästen sind die Kontexte, jeder mit seiner eigenen Sprache. Interessant sind die Kanten: Wer ist upstream (bestimmt das Modell), wer downstream (muss sich anpassen)? Wer übersetzt, wer publiziert?\n\n```mermaid\ngraph LR\n  subgraph U[\"Upstream\"]\n    INV[\"Inventory<br/>Product = SKU + stock\"]\n  end\n  subgraph D[\"Downstream\"]\n    SAL[\"Sales<br/>Product = price + description\"]\n    BIL[\"Billing<br/>Product = tax + revenue account\"]\n  end\n  INV -->|\"OHS / PL\"| SAL\n  SAL -->|\"ACL\"| BIL\n  INV -.->|\"SK shared kernel\"| SAL\n```\n\nDie Beschriftungen tragen die Aussage. Ein Open Host Service mit Published Language (OHS/PL) auf der Upstream-Seite heißt: Inventory bietet eine stabile, dokumentierte Schnittstelle an, an die sich viele andocken können. Ein Anti-Corruption Layer (ACL) auf der Downstream-Seite heißt: Billing schützt sein Modell aktiv vor fremden Begriffen. Ein Shared Kernel (SK) heißt: Zwei Kontexte teilen sich bewusst ein kleines gemeinsames Modell – und damit Kopplung.\n\nDer entscheidende Punkt: Der spätere Serviceschnitt legt sich *auf* diese Context Map. Erst existiert sie, dann entscheide ich, welcher Kontext ein Service wird, welcher zwei, welche zusammen deployt bleiben. Der Bounded Context ist die Modellgrenze, der Service die Deployment-Einheit. Vladik Khononov hat 2018 treffend formuliert: Bounded Contexts sind nicht Microservices. Ein Service kann Teil eines Kontexts sein, ein Kontext kann mehrere Services umfassen.\n\n## Das Muster, das die meiste Reibung spart: der Anti-Corruption Layer\n\nVon allen Context-Mapping-Patterns ist der Anti-Corruption Layer das, dessen Fehlen ich am häufigsten teuer bezahlt sehe. Sobald ein sauber modellierter Kontext an ein Legacy- oder Fremdsystem grenzt, braucht er eine Übersetzungsschicht. Ohne sie sickert das fremde Modell – oft ein schlechtes – in meinen Kern. Was als \"wir passen uns kurz an\" (Conformist) beginnt, wird stillschweigend zur Dauerlösung.\n\nKonkret: Mein Sales-Kontext kennt ein sauberes `Product`. Das Inventory-System spricht in `LegacyInventoryResponse` mit `sku` und `price_cents`. Ich lasse diese Fremd-DTOs nie in mein Domänenmodell. Ein Adapter übersetzt an genau einer Stelle:\n\n```java\n// Downstream context: Sales\ninterface CatalogPort {\n    Product findById(ProductId id);\n}\n\n// ACL: isolates the upstream \"Inventory\" model.\n// Only this class knows the foreign DTOs; the domain never does.\nclass InventoryAntiCorruptionLayer implements CatalogPort {\n\n    private final LegacyInventoryClient legacyClient;\n\n    @Override\n    public Product findById(ProductId id) {\n        LegacyInventoryResponse raw = legacyClient.get(id.value());\n        return new Product(\n            new ProductId(raw.sku),\n            Money.of(raw.price_cents) // translate cents into the domain type\n        );\n    }\n}\n```\n\nDer Gewinn ist nicht kosmetisch. Wenn das Inventory-Team sein Format ändert, ändert sich genau eine Klasse. Mein Kern bleibt stabil, meine Tests bleiben grün, und die Übersetzung ist dokumentiert statt über den Code verstreut. Das ist der Unterschied zwischen einer Grenze, die hält, und einer, die leckt.\n\nDie Kehrseite gilt für den Shared Kernel: Ich setze ihn nur ein, wenn zwei Teams in echter Partnership arbeiten und die Kopplung bewusst wollen. Geteiltes Modell heißt geteilte Änderung – jede Anpassung zwingt beide Teams zur Koordination. Als Default missbraucht wird der Shared Kernel zur Bremse. Im Zweifel gebe ich jedem Kontext sein eigenes Modell und verbinde sie über eine Published Language.\n\nZwischen Conformist und ACL liegt eine Kostenabwägung, und die gehört ausgesprochen. Conformist heißt: Wir übernehmen das fremde Modell eins zu eins und sparen uns die Übersetzung. Das ist legitim, wenn der Upstream stabil ist und sein Modell zum eigenen passt – etwa bei einer Generic Subdomain, die ohnehin nur eingebunden wird. Sobald aber der eigene Kern betroffen ist oder der Upstream ein schwaches Modell hat, wird die eingesparte Übersetzungsschicht zur gestundeten Schuld. Der ACL kostet einmal Aufbau und zahlt bei jeder Fremdänderung zurück. Diese Rechnung mache ich explizit, statt die Entscheidung dem Zufall des ersten Integrationssprints zu überlassen. In der Praxis notiere ich sie als kurze Entscheidung: Welches Fremdmodell koppeln wir, wie stabil ist es, was kostet uns ein Wechsel? Wer diese Frage einmal bewusst beantwortet, trifft sie nicht mehr versehentlich unter Zeitdruck.\n\n## Grenzen deklarativ festhalten\n\nDamit die Context Map nicht als Foto vom Whiteboard verrottet, halte ich sie versionierbar fest. Mit dem Context Mapper und seiner DSL formuliere ich die Beziehungen als Text, der im Repository neben dem Code liegt und bei jeder Änderung mitversioniert wird:\n\n```text\nContextMap ShopMap {\n    contains Sales, Inventory, Billing\n\n    Inventory -> Sales : Customer-Supplier {\n        implementationTechnology = \"REST\"\n        downstreamRights = INFLUENCER\n        exposedAggregates = Product\n    }\n\n    Sales -> Billing : Conformist {\n        implementationTechnology = \"Messaging\"\n    }\n}\n```\n\nDas ist kein Selbstzweck. Der Wert liegt darin, dass die Beziehung – Customer-Supplier, wer upstream ist, welches Aggregat exponiert wird – überprüfbar dokumentiert ist. Ein neuer Entwickler liest an einer Stelle ab, wie die Kontexte zusammenhängen, statt es aus Aufrufketten zu rekonstruieren. Diskussionen über Grenzen führe ich am Diff dieser Datei, nicht am nächsten abgewischten Whiteboard.\n\n## Wie ich in der Praxis vorgehe\n\nMein Ablauf ist bewusst unspektakulär und braucht keine Werkzeugkette. Ich hole die Domänenexperten und ein bis zwei Entwickler in einen Raum und lasse sie den fachlichen Ablauf erzählen – bei größeren Domänen nutze ich EventStorming als Discovery-Technik, um die Events an die Wand zu bringen. Dabei höre ich auf Sprachbrüche: Sobald dasselbe Wort zweimal Verschiedenes meint, habe ich vermutlich eine Kontextgrenze gefunden. Dann benenne ich die Kontexte, ordne sie in Core, Supporting und Generic ein und zeichne die Kanten mit ihren Mustern. Jede vermutete Grenze spiegele ich sofort an den Domänenexperten zurück: Ich erzähle den Ablauf in ihrer Sprache nach und beobachte, wo sie mich korrigieren. Diese Korrekturen sind Gold wert, denn sie zeigen mir die Stellen, an denen mein Modell und die gelebte Fachlichkeit noch auseinanderlaufen. Erst danach – und oft erst Wochen später – reden wir über Services, Repositories und Deployment.\n\nDieses Vorgehen fühlt sich für technikgetriebene Teams zunächst langsam an. Es ist das Gegenteil. Die Wochen, die ich vorne in Sprache investiere, spare ich hinten dreifach, weil ich Grenzen nicht mehr durch produktives System hindurch korrigieren muss. Einfach und elegant heißt hier: wenige, gut begründete Grenzen statt vieler zufälliger.\n\n## Fazit\n\nDer rote Faden ist eine Reihenfolge. Zuerst die Sprache: Wo gilt welches Modell, wo bricht ein Begriff? Das ist der Bounded Context. Dann die Beziehungen: Wer ist upstream, wer übersetzt, wo schütze ich meinen Kern mit einem Anti-Corruption Layer, wo koppele ich bewusst über einen Shared Kernel? Das ist die Context Map. Und erst ganz zuletzt die Deployment-Einheit: Welcher Kontext wird ein Microservice?\n\nWer diese Reihenfolge einhält, kann Services an fachlichen Grenzen ausrichten, statt sie nach technischen Schichten zu schneiden. Wer sie umdreht und Services zählt, bevor er die Sprache versteht, riskiert einen verteilten Big Ball of Mud mit mehr Netzwerk dazwischen. Erst kommt die Modellgrenze, dann die Entscheidung über den Betrieb. Nicht jeder Bounded Context muss ein eigener Service werden.\n\n## Weiterführende Quellen\n\n- [Martin Fowler – BoundedContext](https://martinfowler.com/bliki/BoundedContext.html)\n- [Martin Fowler – UbiquitousLanguage](https://martinfowler.com/bliki/UbiquitousLanguage.html)\n- [Martin Fowler – Conway's Law](https://martinfowler.com/bliki/ConwaysLaw.html)\n- [Vladik Khononov – Bounded Contexts are NOT Microservices](https://vladikk.com/2018/01/21/bounded-contexts-vs-microservices/)\n- [Context Mapper – Context Map](https://contextmapper.org/docs/context-map/)"},{"slug":"funktionale-muster-in-python","title":"Funktionale Muster in Python: Comprehensions, itertools und functools","summary":"Comprehensions, functools und itertools im Zusammenspiel: Wie ich in Projekten zeige, wann eine Comprehension die bessere Wahl ist als map/filter, wo lru_cache und groupby glänzen und welche Fallstricke lauern.","tags":["Software","Python","Backend"],"published_at":"2020-07-15T10:00:00+00:00","updated_at":"2026-07-13T13:19:52.630074+00:00","url":"https://www.mikebild.dev/de/blog/funktionale-muster-in-python/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/c173aa70bef9c91aa9f80aaa651103293c143bdc6bb7ef525b7b27c18be9f259/funktionale-muster-in-python-vorlesen.mp3","audio_duration_secs":828,"text":"```python\nfrom itertools import groupby\nfrom operator import itemgetter\n\nrecords = [\n    {\"team\": \"a\", \"pts\": 3},\n    {\"team\": \"b\", \"pts\": 5},\n    {\"team\": \"a\", \"pts\": 2},\n]\n\ntotals = {\n    team: sum(r[\"pts\"] for r in group)\n    for team, group in groupby(records, key=itemgetter(\"team\"))\n}\n# {\"a\": 2, \"b\": 5} – erwartet war {\"a\": 5, \"b\": 5}\n```\n\nKein Traceback, keine Warnung, nur ein falsches Ergebnis. `groupby` fasst ausschließlich *aufeinanderfolgende* gleiche Schlüssel zusammen. Ohne Vorsortierung zerfällt Team `a` in zwei Gruppen, und die zweite überschreibt im Dictionary die erste. Wer `GROUP BY` aus SQL kennt, erwartet an dieser Stelle das Gegenteil.\n\nGenau so verhalten sich die funktionalen Werkzeuge in Python insgesamt: Sie tragen vertraute Namen aus Java, C# oder SQL, folgen aber eigenen Regeln. Wer funktional in Python arbeiten will, greift seltener zu `map` und `filter` und häufiger zu Comprehensions, `functools` und `itertools`. Diese drei decken zusammen fast alles ab, was aus der funktionalen Programmierung vertraut ist – in einer Schreibweise, die für Python typisch ist.\n\nIch gehe der Reihe nach durch: erst Comprehensions als idiomatischer Ersatz für `map`/`filter`, dann die lazy Kombinatoren aus `itertools`, danach die Helfer aus `functools` – allen voran `lru_cache` – und zum Schluss die Neigung zur Immutability. An mehreren Stellen zeige ich nicht nur, wie es geht, sondern wo ich in der Praxis regelmäßig Fehler sehe. Wer sich vorher mit [Fortgeschrittenem Python](https://www.mikebild.dev/de/blog/python-advanced-decorators-generatoren/) beschäftigt hat, kennt Generatoren und Dekoratoren schon – hier bauen wir darauf auf.\n\n## Comprehensions statt map und filter\n\nFangen wir mit dem konkreten Beispiel an. Ich will die Quadrate aller geraden Zahlen von 0 bis 9. In der `map`/`filter`-Variante liest sich das so:\n\n```python\nnums = range(10)\n\n# map/filter with lambdas – works, but reads inside out\nsquares_even = list(map(lambda n: n * n, filter(lambda n: n % 2 == 0, nums)))\n```\n\nDas funktioniert, aber man liest es von innen nach außen: erst `filter`, dann `map`, und die beiden `lambda`-Ausdrücke stehen zwischen den Argumenten. Die Comprehension dreht das um und stellt die Transformation nach vorn, die Bedingung nach hinten:\n\n```python\nnums = range(10)\n\n# comprehension – transform first, condition last, one line\nsquares_even = [n * n for n in nums if n % 2 == 0]\n```\n\nDas ist die Form, die in Python-Code überwiegt, und der Grund ist schlicht Lesbarkeit. Idiomatischer Python-Code bevorzugt meist eine Comprehension gegenüber `map` oder `filter` mit `lambda`; PEP 8 schreibt diese konkrete Wahl allerdings nicht vor. Comprehensions gibt es in drei Ausprägungen – für Listen, Dictionaries und Sets:\n\n```python\nwords = [\"alpha\", \"beta\", \"gamma\"]\n\n# list, dict and set comprehensions\nlengths = [len(w) for w in words]\nby_length = {w: len(w) for w in words}\ninitials = {w[0] for w in words}\n```\n\nEin Detail geht dabei leicht unter: Seit Python 3 hat die Comprehension einen eigenen Namensraum. Die Laufvariable `w` bleibt innerhalb der Comprehension und leckt nicht mehr in den umgebenden Code – ein alter Stolperstein aus Python-2-Zeiten, den man heute nicht mehr fürchten muss.\n\nBedeutet das, dass `map` und `filter` überflüssig sind? Nein. Sie sind weiterhin idiomatisch, wenn bereits eine benannte Funktion existiert. `map(str.upper, words)` ist klarer als `[w.upper() for w in words]` – hier steht kein `lambda`, sondern eine fertige Funktion, und der Ausdruck bleibt knapp:\n\n```python\nwords = [\"alpha\", \"beta\", \"gamma\"]\n\n# map shines with an existing named function – no lambda noise\nshouted = list(map(str.upper, words))\n```\n\nDas `lambda` taugt damit als Signal: Steht eines in `map` oder `filter`, wäre eine Comprehension die bessere Wahl. Eine benannte Funktion in `map` dagegen ist völlig in Ordnung.\n\nEin verwandtes Detail sorgt regelmäßig für Verwirrung: der Unterschied zwischen dem Filter am Ende und einem bedingten Ausdruck vorn. Beide benutzen das Schlüsselwort `if`, tun aber Gegensätzliches:\n\n```python\nnums = range(10)\n\n# if at the end filters elements out – only evens survive\nevens = [n for n in nums if n % 2 == 0]\n\n# if ... else at the front transforms – all elements stay\nmasked = [n if n % 2 == 0 else 0 for n in nums]\n```\n\nDas `if` am Ende wählt aus und lässt die ungeraden Zahlen weg; das `if ... else` vorn behält jedes Element und ersetzt nur die ungeraden durch `0`. Zwei Konstrukte, die sich leicht verwechseln lassen und völlig unterschiedliche Ergebnisse liefern – im Code-Review stolpere ich regelmäßig darüber.\n\n## Lazy rechnen mit map, filter und Generator-Expressions\n\nEin Punkt wird oft übersehen: In Python 3 geben `map` und `filter` keine Listen mehr zurück, sondern Iteratoren. Sie rechnen bedarfsgesteuert – erst wenn man Werte anfordert, werden sie erzeugt. Genau deshalb steht in den Beispielen oben ein `list(...)` drumherum, sonst bekäme man nur das Iterator-Objekt.\n\nDie Comprehension dagegen materialisiert sofort: Sie baut die komplette Liste im Speicher auf. Für kleine Datenmengen ist das egal, für große kann es teuer werden. Hier kommt die Generator-Expression ins Spiel – syntaktisch fast identisch zur List-Comprehension, nur mit runden statt eckigen Klammern:\n\n```python\n# list comprehension: builds the whole list in memory\ntotal_eager = sum([n * n for n in range(1_000_000)])\n\n# generator expression: yields one value at a time, constant memory\ntotal_lazy = sum(n * n for n in range(1_000_000))\n```\n\nBeide liefern dasselbe Ergebnis, aber die zweite Variante materialisiert keine vollständige Ergebnisliste. Sie erzeugt ihre Werte nach Bedarf; vorgelagerte Datenquellen oder einzelne Operationen können natürlich trotzdem Speicher belegen. Wenn ich einen Ausdruck ohnehin nur einmal durchlaufe – etwa als Argument für `sum`, `any` oder `max` –, ist die Generator-Expression meist die passende Wahl.\n\nEin Wort der Vorsicht: Ein Generator ist nach einem Durchlauf erschöpft. Wer denselben Generator zweimal durchgehen will, bekommt beim zweiten Mal ein leeres Ergebnis. Für mehrfachen Zugriff braucht man eine materialisierte Liste – oder man denkt die Pipeline so, dass ein Durchlauf reicht.\n\nDamit haben wir drei Wege, eine Sequenz zu verarbeiten, und jeder hat seinen Platz:\n\n- Comprehension – lesbar, materialisiert sofort, eigener Namensraum, Filter und Transformation in einem Ausdruck.\n- `map`/`filter` – lazy, ideal in Kombination mit einer vorhandenen benannten Funktion, mit `lambda` weniger lesbar.\n- `itertools` – lazy Kombinatoren für lange oder unendliche Ströme, konstanter Speicher, gut komponierbar.\n\nDen dritten Punkt schauen wir uns jetzt genauer an.\n\n## itertools: lazy Kombinatoren für Ströme\n\n`itertools` ist eine Sammlung von Bausteinen, die alle lazy arbeiten. Sie erzeugen Iteratoren, keine Listen, und lassen sich zu Pipelines zusammenstecken, ohne dass Zwischenergebnisse im Speicher auflaufen. Ein paar, die ich fast täglich verwende:\n\n```python\nfrom itertools import chain, islice, count\n\n# count: infinite counter, here 1, 3, 5, 7, 9\nfirst_five_odds = list(islice(count(1, 2), 5))\n\n# chain.from_iterable: flatten a list of lists lazily\nflat = list(chain.from_iterable([[1, 2], [3, 4], [5, 6]]))\n```\n\nNeben `chain`, `islice` und `count` greife ich oft zu `takewhile` und `dropwhile`. Beide nehmen ein Prädikat und einen Strom: `takewhile` liefert Werte, solange die Bedingung hält, und bricht beim ersten `False` endgültig ab; `dropwhile` überspringt den führenden Block, für den das Prädikat wahr ist, und gibt ab da alles zurück. Auf einem sortierten Strom lässt sich damit ein Bereich herausschneiden, ohne den ganzen Rest noch zu prüfen.\n\n`count(1, 2)` ist ein unendlicher Zähler – ohne `islice` würde er nie aufhören. `islice` schneidet die ersten fünf Werte heraus und stoppt die Auswertung genau dort. Das ist das Muster hinter lazy Pipelines: Man beschreibt eine potenziell unendliche Quelle und begrenzt sie erst am Ende. `chain.from_iterable` wiederum flacht verschachtelte Sequenzen ab, ohne die Teillisten vorher zusammenzukopieren.\n\nDer Baustein, der die meisten Aha-Momente auslöst, ist `groupby`. Damit lassen sich Datensätze nach einem Schlüssel gruppieren und aggregieren:\n\n```python\nfrom itertools import groupby\nfrom operator import itemgetter\n\nrecords = [\n    {\"team\": \"a\", \"pts\": 3},\n    {\"team\": \"a\", \"pts\": 2},\n    {\"team\": \"b\", \"pts\": 5},\n]\n\n# IMPORTANT: sort by the same key before grouping\nrecords.sort(key=itemgetter(\"team\"))\n\ntotals = {\n    team: sum(r[\"pts\"] for r in group)\n    for team, group in groupby(records, key=itemgetter(\"team\"))\n}\n# {\"a\": 5, \"b\": 5}\n```\n\nDamit sind wir zurück beim Beispiel vom Anfang: Ist die Eingabe nicht nach demselben Schlüssel vorsortiert, entstehen mehrere Gruppen für denselben Schlüssel, und das Ergebnis ist stillschweigend falsch. Das `records.sort(...)` vor dem `groupby` ist keine Kür, sondern Pflicht – es ist die eine Zeile, die den Unterschied zwischen `{\"a\": 5, \"b\": 5}` und `{\"a\": 2, \"b\": 5}` ausmacht.\n\nEin zweiter Punkt: Die Gruppen, die `groupby` liefert, sind flüchtige Iteratoren. Sobald man in der äußeren Schleife weiterrückt, wird die vorige Gruppe ungültig. Wer eine Gruppe über die Iteration hinaus behalten will, muss sie mit `list(group)` materialisieren, bevor er weitergeht. Im Beispiel oben ist das kein Problem, weil `sum(...)` die Gruppe direkt im selben Schritt auswertet.\n\nBevor wir zu `functools` kommen, ein Blick auf das Gesamtbild. Die folgende Skizze zeigt, wie sich Rohdaten durch eine solche Pipeline bewegen – von der eager materialisierenden Comprehension über lazy Generator-Expressions und `itertools`-Kombinatoren bis zur finalen Faltung auf einen Wert.\n\n```mermaid\ngraph LR\n    A[Raw data] --> B[Comprehension<br/>eager, materialised]\n    B --> C[Generator expr<br/>lazy, constant memory]\n    C --> D[itertools<br/>chain / islice / groupby]\n    D --> E[reduce / sum / prod<br/>fold to a value]\n    E --> F[Result]\n```\n\n## functools: partial, reduce und lru_cache\n\n`functools` bündelt Werkzeuge, die auf Funktionen selbst wirken. Drei davon sind für funktionale Muster zentral.\n\n`partial` bindet Argumente einer Funktion vorab und gibt eine neue, aufrufbare Funktion zurück. Das ist praktisch, um allgemeine Funktionen zu spezialisieren:\n\n```python\nfrom functools import partial\n\ndef power(base, exponent):\n    return base ** exponent\n\n# fix the exponent, get a specialised function\nsquare = partial(power, exponent=2)\ncube = partial(power, exponent=3)\n\nprint(square(5))  # 25\nprint(cube(5))    # 125\n```\n\n`reduce` faltet eine Sequenz auf einen einzigen Wert – der Klassiker der funktionalen Programmierung. In Python 3 ist `reduce` bewusst kein Builtin mehr, sondern in `functools` verschoben. Das ist ein deutliches Signal: sparsam einsetzen.\n\n```python\nfrom functools import reduce\n\nflat = [1, 2, 3, 4]\n\n# reduce works, but a built-in is almost always clearer\ntotal = reduce(lambda acc, x: acc + x, flat, 0)  # 10\nbetter = sum(flat)                                # same result, obvious intent\n```\n\nEin Detail an `reduce` ist leicht zu übersehen: das dritte Argument. Es ist der Startwert der Faltung und entscheidet zugleich, was bei einer leeren Sequenz passiert. Mit Startwert `0` liefert `reduce` bei leerer Eingabe genau diese `0`; ohne Startwert wirft `reduce` bei leerer Sequenz stattdessen einen `TypeError`. Wer den Startwert weglässt, handelt sich also einen Sonderfall ein, den man in Tests leicht vergisst – und der dann ausgerechnet im Randfall zuschlägt.\n\nHier zeigt sich der häufigste `functools`-Fehler, den ich sehe: `reduce` wird überstrapaziert. Verschachtelte `reduce`-Lambdas sind schwer zu lesen, und für die meisten Fälle gibt es klarere Alternativen – `sum` für Summen, `math.prod` für Produkte (seit Python 3.8), eine Comprehension oder schlicht eine Schleife. `reduce` ist gerechtfertigt, wenn die Faltung wirklich nicht-trivial ist und keine spezialisierte Funktion existiert. In allen anderen Fällen gilt: Der ausdrücklichere Weg gewinnt.\n\nDer Star von `functools` ist für mich `lru_cache`. Der Dekorator memoisiert die Ergebnisse einer Funktion – gleiche Argumente liefern das gespeicherte Ergebnis, statt neu zu rechnen. Das klassische Lehrbeispiel ist die naive rekursive Fibonacci-Funktion, deren Laufzeit ohne Memoisierung exponentiell explodiert:\n\n```python\nfrom functools import lru_cache\n\n@lru_cache(maxsize=None)\ndef fib(n: int) -> int:\n    return n if n < 2 else fib(n - 1) + fib(n - 2)\n\nprint(fib(50))          # 12586269025 – instant instead of minutes\nprint(fib.cache_info())  # CacheInfo(hits=48, misses=51, maxsize=None, currsize=51)\n```\n\nMit `lru_cache` wird jeder Fibonacci-Wert genau einmal berechnet – die Komplexität sinkt von exponentiell auf linear. `cache_info()` gibt Einblick in Treffer und Fehlschläge, `cache_clear()` leert den Speicher. Ein Hinweis zur Schreibweise: Ich schreibe konsequent `@lru_cache(maxsize=None)` mit Klammern. Das ist über alle Versionen hinweg eindeutig und erspart Diskussionen darüber, ab welcher Python-Version der Aufruf ohne Klammern erlaubt ist.\n\nZwei Bedingungen muss man kennen. Erstens: Die Argumente müssen hashbar sein – Listen oder Dictionaries als Parameter funktionieren nicht, Tupel nur dann, wenn auch ihre Elemente hashbar sind. Zweitens: `lru_cache` passt, wenn das Ergebnis ausschließlich von diesen Argumenten abhängt und das zurückgegebene Objekt gefahrlos wiederverwendet werden kann. Bei Side Effects oder veränderlichem externem Zustand liefert der Cache leicht falsche Ergebnisse. Und `maxsize=None` bedeutet einen unbeschränkten Cache – bei sehr vielen verschiedenen Argumenten kann der Speicherverbrauch ungebremst wachsen. Für solche Fälle setzt man ein konkretes `maxsize`.\n\n`functools` hält noch mehr bereit, als in einen Artikel passt – etwa `singledispatch`, das eine Funktion je nach Typ ihres ersten Arguments unterschiedlich auflösen lässt, oder `cmp_to_key`, das eine alte Vergleichsfunktion in einen `key` für `sorted` übersetzt. Für funktionale Muster im engeren Sinn sind `partial`, `reduce` und `lru_cache` aber die drei, zu denen ich in der Praxis am häufigsten greife.\n\n## Der Hang zur Immutability\n\nFunktionales Arbeiten und Immutability gehören zusammen. Python bietet zwei immutable Container, die dabei helfen: `tuple` für geordnete Sequenzen und `frozenset` für Mengen. Beide sind hashbar, sofern ihre Elemente hashbar sind, und lassen sich dann als Dictionary-Schlüssel oder als Elemente eines Sets verwenden – etwas, das mit `list` oder `set` nicht geht:\n\n```python\n# immutable, hashable – usable as dict keys or set members\npoint = (3, 4)\nallowed = frozenset({\"read\", \"write\"})\n\npermissions = {\n    frozenset({\"read\"}): \"viewer\",\n    frozenset({\"read\", \"write\"}): \"editor\",\n}\nprint(permissions[allowed])  # editor\n```\n\nDer Vorteil liegt nicht nur in der Hashbarkeit. immutable Daten können sich nicht unbemerkt ändern, während man sie durch eine Pipeline schickt. Genau das ist die Grundlage seiteneffektfreien Arbeitens: Eine Funktion bekommt Daten herein, gibt neue Daten heraus und lässt die Eingabe unangetastet. Gerade in größeren Systemen macht diese Trennung Übergänge nachvollziehbar.\n\nDas führt direkt zum letzten Fallstrick, den ich häufig sehe: Side Effects in Comprehensions. Eine Comprehension soll Werte erzeugen, nicht `print` aufrufen, an eine Liste anhängen oder Zustand verändern:\n\n```python\n# don't do this: a comprehension used only for side effects\n[print(w) for w in words]  # builds a useless list of None values\n\n# do this instead\nfor w in words:\n    print(w)\n```\n\nDie erste Variante baut nebenbei eine Liste aus lauter `None`-Werten, die niemand braucht. Sie missbraucht die Comprehension für etwas, wofür die normale Schleife da ist. Comprehensions sind für Transformationen gedacht – wenn kein Wert entsteht, gehört der Code in eine Schleife.\n\n## Die passende Wahl treffen\n\nEntscheidend ist weniger, jedes Werkzeug zu kennen, als im richtigen Moment das passende zu greifen. Der folgende Entscheidungsbaum fasst zusammen, wie ich in der Praxis abwäge.\n\n```mermaid\ngraph TD\n    A[Sequence to process] --> B{Build a new container?}\n    B -->|Yes| C[Comprehension<br/>list / dict / set]\n    B -->|No| D{Transform with a<br/>named function?}\n    D -->|Yes| E[map]\n    D -->|No| F{Large or infinite,<br/>save memory?}\n    F -->|Yes| G[Generator expr<br/>or itertools]\n    F -->|No| H{Fold to one value?}\n    H -->|Yes| I[sum / prod / reduce]\n```\n\nDie Linie, die sich durch all das zieht: Python stellt die funktionalen Werkzeuge bereit, verlangt aber, dass man sie in seiner eigenen Ausdrucksform nutzt. Comprehensions kommen vor `map`/`filter` mit `lambda`, weil sie sich besser lesen. Generator-Expressions und `itertools` treten an, wenn Datenmengen groß werden oder Ströme unendlich sind – lazy und speicherschonend. `functools` steuert `partial` für Spezialisierung, `reduce` für die seltenen echten Faltungen und `lru_cache` für Memoisierung Pure Functions bei. Und die immutable Container `tuple` und `frozenset` halten das Ganze seiteneffektfrei zusammen.\n\nDie Fallstricke, die ich gezeigt habe, sind alle vom selben Typ: `reduce`, wo `sum` gereicht hätte; `groupby` ohne Vorsortierung; Side Effects in einer Comprehension. Sie entstehen nicht aus fehlendem Wissen, sondern aus der Gewohnheit, ein Werkzeug über seinen Zweck hinaus einzusetzen. Wer diese drei Werkzeuggruppen kennt und diszipliniert einsetzt, schreibt Python, das kompakt bleibt und trotzdem gut lesbar ist – und genau das ist der Anspruch, den die Sprache an funktionalen Code stellt.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-python.html\n- Repository `introduction-python` – die Beispiele aus diesem Artikel zum Ausprobieren.\n- [functools – Higher-order functions (Python 3.8)](https://docs.python.org/3.8/library/functools.html)\n- [itertools – Functions creating iterators (Python 3.8)](https://docs.python.org/3.8/library/itertools.html)\n- [List Comprehensions – Python Tutorial (3.8)](https://docs.python.org/3.8/tutorial/datastructures.html#list-comprehensions)\n- [map und filter – Built-in Functions (Python 3.8)](https://docs.python.org/3.8/library/functions.html#map)"},{"slug":"jsx-genau-die-regeln-hinter-dem-markup","title":"JSX genau: Die Regeln hinter dem Markup","summary":"Ein präziser Blick auf JSX – was der Compiler daraus macht, warum in geschweiften Klammern Ausdrücke statt Anweisungen stehen, wie Attribute, Kinder, Bedingungen und Listen funktionieren und an welchen Stellen Anfänger stolpern.","tags":["Software","Frontend","React"],"published_at":"2020-06-24T10:00:00+00:00","updated_at":"2026-07-13T07:28:41.146176+00:00","url":"https://www.mikebild.dev/de/blog/jsx-genau-die-regeln-hinter-dem-markup/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/614df915c5e8ef680648f25d6349bd1fafb83a79f3f25699ae792fab84ac2e49/jsx-genau-die-regeln-hinter-dem-markup-vorlesen.mp3","audio_duration_secs":645,"text":"# JSX genau: Die Regeln hinter dem Markup\n\nJSX ist keine Template-Sprache und keine HTML-Variante, die JavaScript irgendwie duldet. Es ist eine Schreibweise für Funktionsaufrufe: Jedes Element, das man notiert, wird vor der Ausführung in einen Aufruf übersetzt, und dieser Aufruf gibt ein gewöhnliches JavaScript-Objekt zurück.\n\n**Redaktionelle Einordnung (Juli 2026):** Der Haupttext erklärt den klassischen JSX-Transform, der im Juni 2020 der veröffentlichte Stand war. Seit React 17 kann der automatische Transform Funktionen aus `react/jsx-runtime` verwenden; am mentalen Modell ändert das nichts. Wie React die entstehenden Elemente später abgleicht, behandelt der nachträglich erschienene Beitrag [Wie React den Baum rendert und aktualisiert](https://www.mikebild.dev/de/blog/wie-react-den-baum-rendert-und-aktualisiert/).\n\nIm React-Überblick stand dazu ein einziger Satz – sieht aus wie HTML, ist aber JavaScript. Der Satz stimmt und verhindert trotzdem keinen der Fehler, die im Alltag entstehen: reservierte Attributnamen, Anweisungen in geschweiften Klammern, eine einsame `0` in der Oberfläche, Keys aus Array-Indizes. Also die Regeln der Reihe nach, jede mit einem winzigen Beispiel und, wo es zählt, mit dem Fehler, den man macht, wenn man die Regel nicht kennt.\n\n## Was der Compiler wirklich daraus macht\n\nJSX ist keine Sprachfunktion von JavaScript. Kein Browser versteht es. Bevor der Code läuft, übersetzt ein Build-Schritt wie Babel jedes JSX-Element in einen normalen Funktionsaufruf. Diese eine Tatsache erklärt fast alle Regeln, die danach kommen.\n\nStand Juni 2020 ist das Ziel dieser Übersetzung `React.createElement`. Aus dem Markup wird ein Aufruf, und aus dem Aufruf ein einfaches Objekt.\n\n```jsx\nconst el = <button className=\"primary\" onClick={handleClick}>Save</button>;\n```\n\nwird sinngemäß zu:\n\n```js\nconst el = React.createElement(\n  \"button\",\n  { className: \"primary\", onClick: handleClick },\n  \"Save\",\n);\n```\n\nDas zurückgegebene Objekt beschreibt nur, was gerendert werden soll – es ist kein DOM-Knoten, es passiert noch nichts im Browser. React nimmt dieses Objekt später und erzeugt oder aktualisiert daraus das echte DOM. Man nennt so ein Beschreibungsobjekt ein React-Element. Wenn man das im Kopf hat, wird verständlich, warum ein Tippfehler im JSX oft als ganz normaler JavaScript-Fehler erscheint: Es ist ja am Ende ein Funktionsaufruf.\n\n## Ausdruck, nicht Anweisung\n\nDie wichtigste Regel für den geschweiften Klammerinhalt lautet: Zwischen `{` und `}` steht ein **Ausdruck**, keine **Anweisung**. Ein Ausdruck ist etwas, das einen Wert ergibt – `user.name`, `a + b`, `price > 0 ? \"ok\" : \"no\"`, ein Funktionsaufruf. Eine Anweisung ist etwas, das etwas tut, aber keinen Wert liefert – `if`, `for`, `while`, `switch`. Anweisungen sind in den Klammern nicht erlaubt.\n\n```jsx\n// works – an expression\n<p>{user ? user.name : \"Guest\"}</p>\n\n// does not work – if is a statement\n<p>{if (user) { user.name }}</p>\n```\n\nDeshalb greift man in JSX so oft zum ternären Operator `? :` und zum logischen `&&`: Sie sind die Ausdrucks-Varianten von `if`. Wer eine echte Verzweigung mit mehreren Zweigen braucht, zieht sie vor das `return` und arbeitet dort mit ganz normalem `if`:\n\n```jsx\nfunction Status({ status }) {\n  let text;\n  if (status === \"open\") text = \"Open\";\n  else if (status === \"paid\") text = \"Paid\";\n  else text = \"Unknown\";\n\n  return <span>{text}</span>;\n}\n```\n\nDas ist kein Umweg. Das ist die saubere Trennung: Logik oben, Darstellung unten. Ein häufiger Anfängerreflex ist, immer komplexere Ternäroperatoren ineinander zu schachteln, bis niemand sie mehr liest. Sobald zwei Ebenen erreicht sind, gehört die Entscheidung nach oben.\n\n## Attribute sind Props, und sie heißen anders als in HTML\n\nJSX-Attribute sehen aus wie HTML-Attribute, sind aber die Eigenschaften des erzeugten Objekts – also Props. Weil die Zielsprache JavaScript ist und nicht HTML, folgen sie den Regeln von JavaScript, und an ein paar Stellen weicht das sichtbar von HTML ab.\n\nZwei Namen sind reserviert, weil `class` und `for` in JavaScript Schlüsselwörter sind. Deshalb heißt das Klassenattribut `className` und das Label-Attribut `htmlFor`:\n\n```jsx\n<label htmlFor=\"email\" className=\"field-label\">Email</label>\n<input id=\"email\" />\n```\n\nAlle mehrteiligen HTML-Attribute werden in camelCase geschrieben: `tabindex` wird zu `tabIndex`, `readonly` zu `readOnly`, `maxlength` zu `maxLength`. Event-Handler heißen `onClick`, `onChange`, `onSubmit` – großes Binnen-C – und bekommen eine Funktion, keinen String:\n\n```jsx\n// correct – a function reference\n<button onClick={handleSave}>Save</button>\n\n// wrong – calls handleSave IMMEDIATELY on render and passes the return value\n<button onClick={handleSave()}>Save</button>\n```\n\nDer zweite Fall ist einer der häufigsten Einsteigerfehler. `onClick={handleSave()}` führt `handleSave` beim Rendern aus und hängt dessen Ergebnis an den Handler. Wenn man Argumente übergeben will, wickelt man den Aufruf in eine Pfeilfunktion: `onClick={() => handleSave(id)}`.\n\nWerte gibt man in geschweiften Klammern, feste Strings in Anführungszeichen. Ein Attribut ohne Wert ist implizit `true`:\n\n```jsx\n<input type=\"text\" value={value} disabled />\n// disabled is equivalent to disabled={true}\n```\n\n## Wie Werte im Inhalt gerendert werden – und was verschwindet\n\nWas React aus einem Ausdruck im Inhalt macht, hängt vom Typ des Werts ab, und hier lauert eine ganze Reihe kleiner Überraschungen. Strings und Zahlen werden als Text ausgegeben. Arrays werden Element für Element gerendert. Aber `null`, `undefined`, `true` und `false` rendern **nichts** – sie verschwinden spurlos. Genau das macht bedingtes Rendern erst möglich.\n\n```jsx\n<div>\n  {message}          {/* string or number -> gets displayed */}\n  {null}             {/* nothing */}\n  {isAdmin && <Panel/>} {/* Panel only when isAdmin is true */}\n</div>\n```\n\nEin klassischer Fehler steckt im `&&`. Es rendert das rechte Element nur, wenn die linke Seite wahr ist – aber es gibt die linke Seite zurück, wenn sie falsch ist. Bei Booleans ist das harmlos, weil `false` verschwindet. Bei einer Zahl nicht:\n\n```jsx\n// pitfall: if count is 0, a 0 appears in the UI, not nothing\n{count && <List />}\n\n// correct: convert to a boolean first\n{count > 0 && <List />}\n```\n\nDie `0` ist eben keine `false`, sondern eine Zahl, und Zahlen werden gerendert. Wer schon einmal eine einsame `0` in seiner Oberfläche gesucht hat, kennt diesen Fehler. Die Regel dahinter ist einfach: Vor einem `&&` steht am besten ein echter Wahrheitswert, kein Wert, der zufällig falsy ist.\n\n## Ein Element, ein Wurzelknoten – und Fragmente\n\nWeil ein JSX-Ausdruck am Ende ein einzelnes Objekt ergibt, muss eine Komponente genau ein Wurzelelement zurückgeben. Zwei Elemente nebeneinander ohne Klammerung sind ein Syntaxfehler. Man könnte alles in ein `<div>` wickeln, aber das erzeugt einen zusätzlichen, oft unerwünschten Knoten im DOM. Dafür gibt es das Fragment – eine Klammer, die im DOM nichts hinterlässt:\n\n```jsx\nfunction Row() {\n  return (\n    <>\n      <td>Name</td>\n      <td>Amount</td>\n    </>\n  );\n}\n```\n\nDie Kurzschreibweise `<>...</>` reicht fast immer. Nur wenn das Fragment selbst ein `key` braucht – etwa als direktes Kind einer Liste – muss man die lange Form `<Fragment key={id}>...</Fragment>` verwenden, weil die Kurzform keine Attribute annimmt.\n\nDie runden Klammern um mehrzeiliges JSX nach `return` sind übrigens keine Kosmetik gegen Einrückung, sondern ein Schutz vor einer JavaScript-Falle: Steht das erste Element in einer neuen Zeile direkt nach `return`, fügt JavaScript automatisch ein Semikolon ein, und die Funktion gibt `undefined` zurück. Die Klammer verhindert das.\n\n## Listen und Keys: die am meisten unterschätzte Regel\n\nEine Liste rendert man, indem man ein Array mit `map` in ein Array von Elementen verwandelt. Jedes dieser Elemente braucht ein `key`-Attribut. Der Key ist kein Deko-Detail – er ist die Information, an der React beim nächsten Rendern erkennt, welches alte Element welchem neuen entspricht.\n\n```jsx\n<ul>\n  {invoices.map((i) => (\n    <li key={i.id}>{i.number}</li>\n  ))}\n</ul>\n```\n\nDer Key muss zwei Eigenschaften haben: Er muss unter den Geschwistern eindeutig sein, und er muss stabil sein, also bei denselben Daten immer gleich. Deshalb nimmt man eine fachliche ID, keinen zufälligen Wert und – das ist der wunde Punkt – möglichst nicht den Array-Index.\n\nDer Index als Key funktioniert scheinbar, solange sich die Liste nie ändert. Sobald man aber vorne etwas einfügt, löscht oder sortiert, verschieben sich die Indizes, und React ordnet den alten States die falschen Elemente zu. Das äußert sich in gespenstischen Fehlern: Ein Eingabefeld behält seinen Text, obwohl die Zeile eine ganz andere sein sollte; eine Checkbox bleibt angehakt, obwohl das zugehörige Element gelöscht wurde. Der Index beschreibt eine Position, der Key soll aber eine Identität beschreiben – und Position und Identität sind zwei verschiedene Dinge. Nur wenn eine Liste garantiert statisch ist und nie umsortiert wird, ist der Index als Key vertretbar. Wie React diesen Vergleich beim erneuten Rendern im Detail durchführt, ist ein eigenes Thema.\n\n## Spread, Kinder und die kleinen Fallstricke am Rand\n\nProps kann man einzeln setzen oder mit dem Spread-Operator aus einem Objekt durchreichen. Das ist bequem, verdient aber Vorsicht:\n\n```jsx\nconst props = { type: \"text\", value: value, onChange: handleChange };\n<input {...props} />\n```\n\nSauber ist das, wenn man genau weiß, was im Objekt steckt. Unsauber wird es, wenn man ein ganzes fremdes Objekt blind durchreicht und damit unbemerkt Attribute setzt, die man gar nicht wollte. Kommt nach dem Spread noch ein einzelnes Attribut, gewinnt das letzte – `<input {...props} value={otherValue} />` überschreibt das `value` aus dem Objekt.\n\nDer Inhalt zwischen öffnendem und schließendem Tag landet in der besonderen Prop `children`. Eine Komponente, die `children` rendert, weiß nichts über ihren Inhalt und ist gerade deshalb überall einsetzbar. Man kann `children` auch explizit als Attribut übergeben, was aber selten schöner ist als die natürliche Schachtelung.\n\nEin paar weitere Details, die immer wieder auffallen: Inline-Styles sind in JSX ein Objekt, keine CSS-Zeichenkette, und ihre Schlüssel stehen in camelCase – `<div style={{ backgroundColor: \"red\", fontSize: 14 }} />`, mit doppelten Klammern, weil das äußere Paar den Ausdruck einleitet und das innere das Objekt ist. Kommentare innerhalb von JSX schreibt man als Ausdruck: `{/* so */}`. Und rohes HTML aus einem String setzt man nur über `dangerouslySetInnerHTML` ein – der sperrige Name ist Absicht, denn genau hier entstehen Cross-Site-Scripting-Lücken, wenn der String aus einer fremden Quelle stammt. Normaler Text in JSX wird von React automatisch escaped und ist deshalb sicher; dieser eine Weg umgeht diesen Schutz bewusst.\n\n## Warum sich die Genauigkeit lohnt\n\nAll diese Regeln folgen aus einem einzigen Gedanken: JSX ist eine Schreibweise für Funktionsaufrufe, die ein Beschreibungsobjekt erzeugen. Die reservierten Namen, die Ausdrucksregel, das Verschwinden von `null` und `false`, die `0`-Falle, der Zwang zu einem Wurzelknoten, die Bedeutung des Keys – nichts davon ist Willkür, alles ergibt sich daraus, dass am Ende JavaScript läuft und nicht HTML.\n\nWer JSX so liest, hört auf, gegen die Sprache zu arbeiten. Man setzt `className` nicht mehr aus Versehen als `class`, man macht aus einer Zahl vor dem `&&` einen echten Vergleich, man vergibt Keys aus fachlichen IDs statt aus Indizes – nicht weil ein Linter meckert, sondern weil man versteht, was der Compiler daraus macht. Zu den einzelnen Bausteinen, die hier nur gestreift wurden – der Datenfluss über Props, die Frage nach dem State-Besitz, das Rendern auf dem Server –, gibt es eigene Beiträge. JSX ist der Anfang von allem, und genau deshalb lohnt es sich, hier genau zu sein.\n\n## Weiterführende Quellen\n\n- Repository introduction-react (JSX-Kapitel und Beispiele): https://github.com/mikebild/introduction-react\n- React, JSX-Markup schreiben: https://react.dev/learn/writing-markup-with-jsx\n- React, JavaScript in JSX mit geschweiften Klammern: https://react.dev/learn/javascript-in-jsx-with-curly-braces\n- React, Listen rendern und Keys: https://react.dev/learn/rendering-lists"},{"slug":"aws-deployment-pipelines-architektur","title":"AWS Deployment Pipelines sind Architektur","summary":"Deployment Pipelines sind Architektur, weil sie bestimmen, wie häufig, sicher und reversibel ein System verändert wird.","tags":["Software","AWS","Cloud"],"published_at":"2020-05-12T10:00:00+00:00","updated_at":"2026-07-13T08:38:49.427291+00:00","url":"https://www.mikebild.dev/de/blog/aws-deployment-pipelines-architektur/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/34350b2e9e16ceefa74bad2b01d9163467d4c18dc23fe7d64ea87b5039ff5a4f/aws-deployment-pipelines-architektur-vorlesen.mp3","audio_duration_secs":655,"text":"# AWS Deployment Pipelines sind Architektur\n\nDie Zeit von einem gemergten Commit bis zu dem Moment, in dem die Änderung in Produktion wirkt, liegt bei manchen Teams unter einer Viertelstunde und bei anderen bei einem Quartal – häufig bei vergleichbarem Anwendungscode. Diese Spanne steht in keinem Architekturdiagramm. Sie entsteht dort, wo eine Änderung ihren Weg nimmt: in den Schritten zwischen Commit und Runtime, in den Kontrollen, die sie passieren muss, und in dem, was übrig bleibt, wenn sie zurückgenommen werden soll. Der Weg dorthin gehört damit in dieselbe Entwurfsdiskussion wie die Wahl einer Datenbank – und braucht dieselbe Begründung.\n\nDer konkrete Anlass für diesen Text ist eine kleine GitHub Action, die nichts weiter tut, als ein AWS-CDK-Projekt zu deployen. Ein paar Zeilen YAML. Und trotzdem legt genau dieser Baustein fest, welcher Fehler früh auffällt, welcher erst in der Produktion – und wer eine Änderung an der Infrastruktur überhaupt auslösen darf.\n\n## Die Pipeline legt die Änderbarkeit fest\n\nArchitektur beantwortet die Frage, was sich unabhängig voneinander ändern lässt. Genau das ist auch die Kernfrage einer Pipeline. Ein System, das nur im Quartals-Release deploybar ist, hat eine andere Architektur als eines, das dieselbe Codebasis zwanzigmal am Tag ausrollt – selbst wenn beide identischen Anwendungscode enthalten. Die Änderbarkeit steckt nicht allein im Modul-Schnitt, sondern im Weg von einem Commit bis in die Runtime.\n\nAuf AWS wird dieser Weg besonders sichtbar, seit Infrastruktur selbst Code ist. Mit dem CDK beschreibe ich Lambda-Functions, API-Gateways, Tabellen und Rechte in TypeScript. `cdk synth` übersetzt das in ein CloudFormation-Template, `cdk deploy` bringt es in ein Konto. Damit verschiebt sich etwas Grundlegendes: Die Frage „wie sieht meine Infrastruktur aus?“ und die Frage „wie kommt eine Änderung dorthin?“ fallen zusammen. Beide liegen im selben Repository, durchlaufen denselben Review und dieselbe Pipeline. Das habe ich in [Infrastruktur als Programmiersprache](https://www.mikebild.dev/de/blog/cdk-infrastruktur-als-programmiersprache/) ausführlicher aufgeschrieben; hier interessiert mich der Schritt danach – was passiert, wenn dieser Code automatisch ausgerollt wird.\n\nEin minimaler Stack sieht so aus:\n\n```ts\nimport * as cdk from \"@aws-cdk/core\";\nimport * as lambda from \"@aws-cdk/aws-lambda\";\nimport * as apigw from \"@aws-cdk/aws-apigateway\";\n\nexport class ApiStack extends cdk.Stack {\n  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {\n    super(scope, id, props);\n\n    const handler = new lambda.Function(this, \"ApiHandler\", {\n      runtime: lambda.Runtime.NODEJS_12_X,\n      handler: \"index.handler\",\n      code: lambda.Code.fromAsset(\"dist/api\"),\n    });\n\n    new apigw.LambdaRestApi(this, \"Api\", { handler });\n  }\n}\n```\n\nDas Beispiel ist bewusst klein. Es soll keine vollständige Anwendung zeigen, sondern die Grenze markieren: Alles, was hier steht, ist ab jetzt Teil des deploybaren Artefakts. Wenn ich die Runtime ändere, ein Recht erweitere oder das API umbaue, geht das denselben Weg wie eine Codeänderung. Die spannende Entscheidung ist nicht, wie viele Constructs ich schreibe, sondern welche Kontrolle zwischen diesem Code und der Produktion steht.\n\n## Die „CDK Action“ als kleinster ehrlicher Baustein\n\nDer einfachste Weg, dieses Deployment zu automatisieren, ist eine GitHub Action, die auf jeden Push nach `main` reagiert:\n\n```yaml\nname: deploy\non:\n  push:\n    branches: [main]\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n      - uses: actions/setup-node@v1\n        with:\n          node-version: 12\n      - run: npm ci\n      - run: npm test\n      - run: npx cdk deploy --require-approval never\n        env:\n          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}\n          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n          AWS_DEFAULT_REGION: eu-central-1\n```\n\nDas sieht harmlos aus, trifft aber lauter Architekturentscheidungen. `on: push` heißt: Jeder Merge nach `main` ist ein Deployment – der Trunk ist die Auslieferung, nicht bloß ein Zwischenstand. Der Schritt `npm test` vor `cdk deploy` zieht eine Qualitätsgrenze: Was rot ist, geht nicht raus. `--require-approval never` verzichtet auf die interaktive Rückfrage des CDK bei sicherheitsrelevanten Änderungen – bequem, aber es verschiebt die Verantwortung für IAM-Änderungen vollständig in den Review davor. Und die zwei Secrets legen fest, mit welchen Rechten diese Pipeline im Konto agiert; in der Praxis ist das der Punkt, an dem die meiste unausgesprochene Macht steckt.\n\nGenau deshalb ist dieser kleine Baustein aufschlussreicher als jede große Referenzpipeline. Man kann ihn in fünf Minuten im Team durchgehen, und jede Zeile steht für eine bewusste oder unbewusste Entscheidung über Häufigkeit, Sicherheit und Zuständigkeit.\n\n## Der Review-Punkt liegt vor dem Deploy, nicht danach\n\nBei Anwendungscode reviewen wir den Diff. Bei Infrastruktur tun das viele nicht – und wundern sich, dass ein Merge nebenbei eine Security Group öffnet oder eine Tabelle ersetzt. Das CDK hat dafür ein gutes Werkzeug, das in einer Pipeline zum eigentlichen Review-Artefakt wird:\n\n```bash\n# what will actually change in the account\ncdk diff ApiStack\n```\n\n`cdk diff` zeigt nicht meinen TypeScript-Diff, sondern den Unterschied zwischen dem, was im Konto steht, und dem, was ich deployen will – inklusive der IAM-Statements, die CloudFormation daraus ableitet. In einer sauberen Pipeline läuft dieser Schritt automatisch und hängt das Ergebnis an den Pull Request. Damit entscheidet ein Mensch über die Wirkung, nicht über den Quelltext. Das ist ein methodischer Unterschied mit direkter Betriebswirkung: Ein Reviewer, der sieht „diese Änderung fügt `iam:PassRole` hinzu“, stoppt sie, bevor sie im Konto liegt. Ein Reviewer, der nur `stack.ts` liest, übersieht sie oft.\n\nSo sieht der Weg als Ganzes aus:\n\n```mermaid\nflowchart LR\n  Commit --> Build[\"Build & Test\"]\n  Build --> Synth[\"cdk synth<br/>CloudFormation-Template\"]\n  Synth --> Diff[\"cdk diff<br/>Review-Artefakt\"]\n  Diff --> Approve{\"Freigabe?\"}\n  Approve -->|ja| Deploy[\"cdk deploy\"]\n  Approve -->|nein| Stop[\"Halt\"]\n  Deploy --> Observe[\"Metriken &<br/>Alarme\"]\n  Observe -->|Fehler| Rollback[\"Rollback auf<br/>vorherige Revision\"]\n```\n\nJeder Knoten in dieser Skizze ist eine Stellschraube. Wo die Freigabe sitzt, entscheidet über Tempo gegen Kontrolle. Ob nach `Deploy` überhaupt beobachtet wird, entscheidet, ob ein schlechtes Release auffällt, bevor der Kunde es meldet. Und ob es einen echten `Rollback`-Pfad gibt, entscheidet darüber, ob eine Änderung reversibel ist – oder nur hoffentlich richtig.\n\n## Dasselbe Artefakt durch mehrere Umgebungen\n\nSobald mehr als eine Umgebung im Spiel ist – Staging und Produktion, oft in getrennten AWS-Konten – wird die Pipeline zur eigentlichen Definition dessen, was „promoten“ heißt. Tragfähig ist das nur unter einer Bedingung: Dasselbe geprüfte Artefakt wandert durch die Stufen, nicht ein zweiter Build mit leicht anderen Zutaten. Beim CDK bedeutet das, dieselbe App mit unterschiedlichem Kontext zu synthetisieren.\n\n```ts\nconst app = new cdk.App();\n\nnew ApiStack(app, \"ApiStack-staging\", {\n  env: { account: \"111111111111\", region: \"eu-central-1\" },\n});\n\nnew ApiStack(app, \"ApiStack-prod\", {\n  env: { account: \"222222222222\", region: \"eu-central-1\" },\n});\n```\n\nDamit steht die Umgebungs-Topologie im selben Repository wie der Code und durchläuft denselben Review. Der methodische Gewinn ist Vertrauen: Wenn Staging und Produktion aus einer Quelle synthetisiert werden, testet Staging tatsächlich das, was später in Produktion läuft – und nicht eine plausible Kopie, die an drei Stellen abweicht. Genau diese Abweichungen sind es, die sonst erst im teuersten Konto auffallen. Die Trennung in eigene Konten ist dabei kein Betriebsdetail, sondern eine Architekturgrenze: Ein Deploy nach Staging kann Produktion nicht versehentlich berühren, weil ihm schlicht die Rechte im anderen Konto fehlen.\n\n## Trade-offs, die man benennen muss\n\nDie interessante Arbeit an einer Pipeline besteht nicht darin, sie zum Laufen zu bringen, sondern darin, ihre Kompromisse offenzulegen. Drei davon tauchen regelmäßig auf:\n\n- Auto-Deploy auf `main` gegen manuelles Gate. Der automatische Weg verkürzt die Durchlaufzeit drastisch, verlangt aber ein belastbares Testnetz und gute Alarme. Das Gate gibt ein Sicherheitsgefühl, sammelt in der Praxis aber Änderungen zu großen, riskanten Batches an – der häufigste Grund für seltene, angstbesetzte Releases.\n- Ein Stack gegen viele Stacks. Ein großer Stack ist einfach zu deployen, aber jede Änderung berührt alles. Getrennte Stacks für API, Daten und statisches Frontend erlauben es, das Risiko klein zu halten und Teile unabhängig auszurollen – auf Kosten von mehr Koordination an den Schnittstellen.\n- Rollback per Redeploy gegen Vorwärts-Fix. CloudFormation kann bei einem fehlgeschlagenen Deploy automatisch zurückrollen. Das trägt aber nur, solange die Änderung nicht bereits Daten migriert oder ein Schema erweitert hat. Ab da ist „einfach zurück“ eine Illusion, und man braucht additive, rückwärtskompatible Schritte statt eines Undo-Knopfs.\n\nKeine dieser Entscheidungen ist universell richtig. Aber sie gehören in ein Architektur-Gespräch und in ein kurzes ADR, nicht in eine YAML-Datei, die jemand vor einem Jahr committet hat und die seither niemand mehr hinterfragt.\n\n## Warum das ein CTO interessieren sollte\n\nFür die technische Leitung übersetzt sich das alles in Zahlen, die seit „Accelerate“ ein etabliertes Vokabular haben. Vier Kennzahlen beschreiben die Leistungsfähigkeit einer Auslieferung ziemlich vollständig, und alle vier werden von der Pipeline bestimmt, nicht vom Anwendungscode:\n\n- Deployment-Häufigkeit – wie oft geht überhaupt etwas live. Ein Auto-Deploy auf `main` hebt sie um Größenordnungen gegenüber einem manuellen Quartals-Gate.\n- Lead Time – wie lange von „Code fertig“ bis „in Produktion“. `cdk diff` im PR plus automatischer Deploy drückt das von Tagen auf Minuten.\n- Change Failure Rate – welcher Anteil der Releases erzeugt einen Fehler. Der Test-Schritt und das Diff-Review vor dem Deploy senken sie, ohne die Häufigkeit zu opfern.\n- Time to Restore – wie schnell ist der Normalzustand wieder da. Ein echter, geübter Rollback-Pfad ist hier der Unterschied zwischen Minuten und einem verlorenen Nachmittag.\n\nDas Wichtige an diesen vier: Sie machen unterschiedliche Seiten der Lieferfähigkeit sichtbar. Kleine Änderungen begrenzen den Umfang einer Prüfung und eines möglichen Fehlers; automatisierte Checks verkürzen die Rückmeldung; ein geübter Wiederherstellungsweg senkt die Zeit bis zur Reparatur. Ob eine Pipeline diese Wirkung tatsächlich hat, zeigen erst Lead Time for Changes, Change Failure Rate und Time to Restore Service im eigenen System. Sie ist damit keine Zugabe für die Betriebsseite, sondern Teil der Architektur.\n\n## Woran ich eine Pipeline prüfe\n\nBelastbar ist ein Pipeline-Entwurf erst, wenn auch der Fehlerpfad beschrieben ist. Die Fragen, die dabei am meisten aufdecken, zielen genau dorthin. Was passiert, wenn `cdk deploy` mitten im Umbau abbricht – bleibt ein halber Zustand zurück? Ist der Deploy idempotent genug, dass ein zweiter Lauf ihn heilt? Wo liegt die Grenze, ab der ein Rollback nicht mehr genügt, weil bereits Daten migriert wurden? Und ganz banal: Wer darf diese Pipeline auslösen, und ist das nachvollziehbar protokolliert?\n\nAm häufigsten begegnet mir dabei die Überbewertung des glücklichen Pfades. Im Demo-Konto läuft alles sauber. Interessant wird es bei Teilfehlern, Rechten, die knapp nicht ausreichen, Ressourcen, die CloudFormation ersetzen statt ändern will, und bei dem einen Deploy, der um 17 Uhr am Freitag hängenbleibt. Genau dort zeigt sich, ob das Pipeline-Modell trägt – und ob „reversibel“ ein echtes Versprechen ist oder nur ein Wort in einer Präsentation.\n\n## Fazit\n\nDie Verlockung ist, eine Pipeline als Werkzeugfrage zu behandeln: welche Action, welcher Runner, welcher Provider. Der eigentliche Gehalt liegt woanders. Eine Pipeline kodiert, wie oft ein Team seine Meinung ändern darf, wie sicher es das tut und wie leicht es einen Fehler zurücknimmt. Das sind Architektureigenschaften, und sie entstehen nicht im Toolnamen, sondern in den paar bewussten Entscheidungen dahinter – Auto-Deploy oder Gate, ein Stack oder viele, Undo oder Vorwärts-Fix. Wer diese Entscheidungen benennt und ihre Wirkung misst, kann Engpässe gezielt verändern. Wer sie einer alten YAML-Datei überlässt, trifft sie trotzdem – nur unbewusst und meist zum ungünstigsten Zeitpunkt.\n\n## Weiterführende Quellen\n\n- AWS CDK Developer Guide: https://docs.aws.amazon.com/cdk/latest/guide/home.html\n- AWS CDK Constructs: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html\n- AWS CloudFormation – wie Deployments und Rollbacks intern ablaufen: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/Welcome.html\n- Nicole Forsgren, Jez Humble, Gene Kim: „Accelerate“ – die vier Delivery-Kennzahlen im Detail."},{"slug":"python-advanced-decorators-generatoren","title":"Fortgeschrittenes Python: Decorators, Magic Methods und Generatoren","summary":"Drei Bausteine, die pythonischen Code ausmachen: Decorators umschließen Funktionen, Magic Methods binden eigene Objekte an Pythons Protokolle, Generatoren erzeugen lazy Sequenzen.","tags":["Software","Python","Backend"],"published_at":"2020-04-08T10:00:00+00:00","updated_at":"2026-07-13T10:00:30.74463+00:00","url":"https://www.mikebild.dev/de/blog/python-advanced-decorators-generatoren/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/3137291c3f9908c8a2f11ba497f69558d1af30b33839b6be786f0ac02cbbd328/python-advanced-decorators-generatoren-vorlesen.mp3","audio_duration_secs":615,"text":"Python-Code, den man Zeile für Zeile genauso in Java oder C# hätte schreiben können, ist selten falsch – er nutzt die Sprache nur nicht. Drei Mechanismen machen den Unterschied, und keiner davon ist Zauberei: der `@`-Decorator, die Dunder-Methode und `yield`. Alle drei sind im Sprachkern dokumentiert, jeder ist in wenigen Zeilen selbst nachbaubar, und jeder löst ein Problem, das man sonst mit Copy-Paste oder mit zu viel Arbeitsspeicher löst.\n\nDer Stand hier ist Anfang 2020, also Python 3.8. Alle Beispiele laufen dort ohne Zusatzpakete – nur mit `functools`, `time` und der Standardbibliothek. Vorausgesetzt sind Python-Objekte, Funktionen und die üblichen eingebauten Datentypen.\n\n## Decorators: Funktionen, die Funktionen umschließen\n\nDer Einstieg in Decorators ist einfacher, als der `@`-Zucker vermuten lässt. In Python sind Funktionen ganz normale Objekte. Man kann sie einer Variablen zuweisen, als Argument übergeben und aus einer anderen Funktion zurückgeben. Ein Decorator ist nichts weiter als eine Funktion höherer Ordnung: Sie nimmt eine Funktion entgegen und gibt eine – meist umschließende – Funktion zurück.\n\n```python\nimport functools\nimport time\n\n\ndef timer(func):\n    @functools.wraps(func)\n    def wrapper(*args, **kwargs):\n        start = time.perf_counter()\n        result = func(*args, **kwargs)\n        elapsed = time.perf_counter() - start\n        print(f\"{func.__name__} took {elapsed:.4f}s\")\n        return result\n    return wrapper\n\n\n@timer\ndef slow_sum(n):\n    return sum(range(n))\n```\n\nDie Schreibweise `@timer` über `def slow_sum` ist exakt Syntaxzucker für `slow_sum = timer(slow_sum)`. Nichts anderes passiert. Nach dem Dekorieren zeigt der Name `slow_sum` nicht mehr auf die ursprüngliche Funktion, sondern auf `wrapper`. Bei jedem Aufruf startet zuerst die Zeitmessung, dann läuft die echte Funktion, danach wird die verstrichene Zeit gedruckt. Das `*args, **kwargs` sorgt dafür, dass der Wrapper beliebige Signaturen durchreicht, ohne selbst etwas über die Argumente wissen zu müssen.\n\nDer Ablauf beim Aufruf einer dekorierten Funktion, aufgezeichnet – der Wrapper klammert die eigentliche Arbeit ein:\n\n```mermaid\nflowchart LR\n    Call[\"call slow_sum()\"] --> W[\"wrapper()\"]\n    W -->|\"before:<br/>start timer\"| F[\"original func()\"]\n    F -->|\"return value\"| W\n    W -->|\"after:<br/>print elapsed\"| Ret[\"return result\"]\n```\n\n### functools.wraps ist keine Kür\n\nEin Detail fehlt in selbstgeschriebenen Decorators mit schöner Regelmäßigkeit: die Zeile `@functools.wraps(func)`. Ohne sie funktioniert der Decorator zwar, aber der Wrapper übernimmt nicht die Identität der ursprünglichen Funktion. `slow_sum.__name__` liefert dann `\"wrapper\"` statt `\"slow_sum\"`, `slow_sum.__doc__` ist der Docstring des Wrappers, und `help(slow_sum)` zeigt die falsche Signatur.\n\n```python\ndef timer_broken(func):\n    def wrapper(*args, **kwargs):\n        return func(*args, **kwargs)\n    return wrapper\n\n\n@timer_broken\ndef greet(name):\n    \"\"\"Say hello to someone.\"\"\"\n    return f\"Hello, {name}\"\n\n\nprint(greet.__name__)   # -> \"wrapper\"\nprint(greet.__doc__)    # -> None\n```\n\n`functools.wraps` ist ein Convenience-Wrapper um `functools.update_wrapper`. Er kopiert `__module__`, `__name__`, `__qualname__`, `__annotations__` und `__doc__` von der Originalfunktion auf den Wrapper und setzt zusätzlich `__wrapped__`, sodass man im Zweifel wieder an die undekorierte Funktion herankommt. Für Debugging, Introspektion und Werkzeuge, die auf Metadaten schauen, ist das der Unterschied zwischen brauchbar und verwirrend. Die Zeile gehört deshalb in jeden Decorator, den ich schreibe.\n\n### Decorators mit Argumenten\n\nHäufig will man einen Decorator konfigurieren, etwa: „Führe die Funktion dreimal aus.“ Dafür braucht man eine Ebene mehr. Ein Decorator mit Argumenten ist eine Funktion, die einen Decorator zurückgibt, der wiederum den Wrapper zurückgibt – drei ineinander geschachtelte Funktionen.\n\n```python\ndef repeat(times):\n    def decorator(func):\n        @functools.wraps(func)\n        def wrapper(*args, **kwargs):\n            result = None\n            for _ in range(times):\n                result = func(*args, **kwargs)\n            return result\n        return wrapper\n    return decorator\n\n\n@repeat(times=3)\ndef ping():\n    print(\"ping\")\n```\n\n`@repeat(times=3)` wird zuerst ausgewertet: Der Aufruf `repeat(times=3)` liefert `decorator` zurück, und dieses `decorator` wird dann auf `ping` angewandt. Wer den Aufbau einmal von innen nach außen gelesen hat, dem erschließt sich das Muster – und es taucht in vielen Bibliotheken genau so auf, etwa bei Routing-Decorators in Web-Frameworks.\n\n## Magic Methods: eigene Objekte, die sich wie eingebaute verhalten\n\nDer zweite Baustein greift tiefer in die Sprache hinein. Magic Methods – auch Dunder-Methoden genannt, wegen der doppelten Unterstriche – sind der Weg, über den eigene Klassen an Pythons Protokolle andocken. Man ruft diese Methoden nie direkt auf. Stattdessen ruft der Interpreter sie auf, wenn man ein Objekt in einen bekannten Kontext stellt: `len(obj)` löst `__len__` aus, `==` löst `__eq__` aus, ein `for`-Loop löst `__iter__` aus, ein `with`-Block löst `__enter__` und `__exit__` aus.\n\nEin paar der gängigsten Dunder-Methoden und was sie auslösen:\n\n- `__init__` – die Konstruktion beim Erzeugen einer Instanz\n- `__repr__` – `repr()` und die Anzeige im REPL, idealerweise eindeutig und im besten Fall `eval`-fähig\n- `__str__` – `str()` und `print()`; fehlt sie, springt `__repr__` ein\n- `__eq__` – der Vergleich mit `==`\n- `__len__` – der Aufruf `len(obj)`, der auch für die Wahrheitsprüfung herangezogen wird, wenn `__bool__` fehlt\n- `__iter__` und `__next__` – die Iteration in `for`-Loops und beim Entpacken\n- `__enter__` und `__exit__` – der Ein- und Ausstieg eines `with`-Blocks\n- `__call__` – der Aufruf einer Instanz wie eine Funktion, `obj()`\n\nDamit das konkret wird, baue ich eine Klasse, die Zeilen aus einer Datei bereitstellt. Sie soll ein ordentliches `__repr__` haben, sich mit `==` vergleichen lassen, als Context Manager funktionieren und iterierbar sein.\n\n```python\nclass LineStore:\n    def __init__(self, path):\n        self.path = path\n\n    def __repr__(self):\n        return f\"LineStore({self.path!r})\"\n\n    def __eq__(self, other):\n        return isinstance(other, LineStore) and self.path == other.path\n\n    def __enter__(self):\n        self._file = open(self.path, encoding=\"utf-8\")\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        self._file.close()\n        return False   # do not swallow exceptions\n\n    def __iter__(self):\n        return (line.rstrip(\"\\n\") for line in self._file)\n```\n\nEin `LineStore` verhält sich jetzt wie ein eingebautes Objekt. Im REPL zeigt er `LineStore('access.log')` statt einer nichtssagenden Speicheradresse. Zwei Instanzen mit gleichem Pfad sind gleich. Und der eigentliche Clou steckt im `with`-Block:\n\n```python\nwith LineStore(\"access.log\") as store:\n    for line in store:\n        if \"ERROR\" in line:\n            print(line)\n```\n\nDas `with` ruft `__enter__` auf, bindet den Rückgabewert an `store`, führt den Body aus und ruft danach – garantiert, auch bei einer Exception – `__exit__` auf. Dort wird die Datei geschlossen. Genau das ist der Sinn eines Context Managers: Aufräumen, das man nicht vergessen kann. Der Rückgabewert von `__exit__` entscheidet über den Umgang mit Fehlern. Gibt die Methode `True` zurück, wird eine im Block aufgetretene Exception unterdrückt; ein falsy Wert wie `False` reicht sie weiter. Ich gebe hier bewusst `False` zurück, weil ein Fehler beim Lesen der Datei nicht stillschweigend verschwinden soll.\n\nFür einfache Fälle muss man das Protokoll übrigens nicht von Hand implementieren. `contextlib.contextmanager` verwandelt eine Generatorfunktion in einen Context Manager, wobei das `yield` Setup und Teardown trennt. Das führt uns direkt zum dritten Baustein.\n\n## Generatoren: lazy Sequenzen mit yield\n\nDer `LineStore` schleppt eine Eigenheit mit sich: Sein `__iter__` gibt bereits eine Generator-Expression zurück. Generatoren sind der dritte Pfeiler, und für mich der praktisch wichtigste, sobald Daten größer werden als der Arbeitsspeicher.\n\nSobald in einer Funktion das Schlüsselwort `yield` auftaucht, ist sie kein normales Callable mehr, sondern eine Generatorfunktion. Ihr Aufruf führt den Rumpf nicht aus, sondern gibt sofort ein Generator-Objekt zurück. Der Code läuft erst, wenn man über das Objekt iteriert – und er hält an jedem `yield` an, merkt sich seinen Zustand und macht beim nächsten Schritt genau dort weiter.\n\n```python\ndef stream_lines(path):\n    with open(path, encoding=\"utf-8\") as fh:\n        for line in fh:          # a file object is itself an iterator\n            yield line.rstrip(\"\\n\")\n\n\nfor line in stream_lines(\"big.log\"):\n    if line.startswith(\"2020\"):\n        print(line)\n```\n\nHier steht immer nur eine Zeile gleichzeitig im Speicher. Egal, ob die Datei zehn Kilobyte oder zehn Gigabyte groß ist – der Speicherbedarf bleibt konstant. Genau das ist der Unterschied zu einer Liste. Würde ich `fh.readlines()` schreiben, hielte Python alle Zeilen auf einmal. Der Generator erzeugt sie einzeln, auf Abruf.\n\nDasselbe Prinzip gibt es kompakt als Generator-Expression – syntaktisch wie eine List Comprehension, nur mit runden statt eckigen Klammern:\n\n```python\n# list comprehension: builds the whole list in memory\nlengths_list = [len(line) for line in stream_lines(\"big.log\")]\n\n# generator expression: yields one length at a time\ntotal = sum(len(line) for line in stream_lines(\"big.log\"))\n```\n\nDie zweite Variante summiert die Zeilenlängen, ohne je eine vollständige Liste anzulegen. Für Aggregationen über große Datenmengen ist das der Standardweg. Und wenn man Generatoren verketten will, hilft `yield from`, um an einen anderen iterierbaren Wert zu delegieren, ohne selbst eine Schleife zu schreiben:\n\n```python\ndef chained(*paths):\n    for path in paths:\n        yield from stream_lines(path)\n```\n\n## Drei Fallstricke\n\nSo klar die drei Bausteine sind, so zuverlässig treten in der Praxis dieselben Stolperstellen auf. Diese drei kosten die meiste Zeit.\n\nDer Klassiker sind veränderliche Default-Argumente. Wer eine leere Liste als Default schreibt, bekommt nicht bei jedem Aufruf eine frische Liste, sondern immer dieselbe.\n\n```python\ndef append_item(item, acc=[]):   # trap: shared across calls\n    acc.append(item)\n    return acc\n\n\nprint(append_item(1))   # [1]\nprint(append_item(2))   # [1, 2]  – surprise\n```\n\nDer Default wird genau einmal ausgewertet, nämlich beim `def`. Danach teilen sich alle Aufrufe dasselbe Objekt. Die Lösung ist ein Wächter über `None`:\n\n```python\ndef append_item(item, acc=None):\n    if acc is None:\n        acc = []\n    acc.append(item)\n    return acc\n```\n\nDie zweite Stolperstelle ist das vergessene `functools.wraps`, der oben gezeigte Fall. Ein Decorator ohne diese Zeile funktioniert, aber er verschluckt Name, Docstring und Signatur der dekorierten Funktion. In kleinen Skripten fällt das nicht auf; sobald jemand die Funktion introspektiert oder ein Framework auf die Metadaten schaut, wird es zum Rätsel.\n\nDie dritte trifft Generatoren: Nach einem Durchlauf sind sie erschöpft, und das ist die Kehrseite der Lazy-Auswertung. Ein Generator merkt sich seinen Fortschritt und setzt ihn nicht zurück. Wer ihn zweimal durchläuft, bekommt beim zweiten Mal nichts.\n\n```python\ngen = stream_lines(\"big.log\")\nfirst = list(gen)    # reads all lines\nsecond = list(gen)   # empty – the generator is exhausted\n```\n\nEine Liste kann man beliebig oft durchlaufen, einen Generator nur einmal. Wer die Daten mehrfach braucht, materialisiert sie bewusst in eine Liste – oder ruft die Generatorfunktion erneut auf, um ein frisches Objekt zu erhalten. Verwandt dazu: `len()` funktioniert nicht auf einem Generator, weil dessen Länge ohne vollständigen Durchlauf gar nicht bekannt ist.\n\n## Fazit\n\nDecorators, Magic Methods und Generatoren wirken beim ersten Kontakt wie fortgeschrittene Kür, sind aber im Alltag Grundwerkzeug. Decorators trennen wiederkehrendes Verhalten – Zeitmessung, Logging, Wiederholung – sauber von der eigentlichen Logik, solange man `functools.wraps` nicht vergisst. Magic Methods schließen eigene Objekte an die Sprache an, sodass `len()`, `==`, `for` und `with` einfach funktionieren, ohne dass der aufrufende Code etwas über die Interna wissen muss. Und Generatoren machen aus einer Funktion eine lazy Sequenz, die große Datenmengen Element für Element liefert, statt alles gleichzeitig zu halten.\n\nWas diese drei Bausteine verbindet, ist ein gemeinsamer Gedanke: Python stellt klar definierte Protokolle bereit, und der eigene Code klinkt sich dort ein. Man schreibt nicht gegen die Sprache, sondern mit ihr. Wer das einmal verinnerlicht hat, erkennt die Muster überall in der Standardbibliothek wieder – und schreibt Code, der sich nicht nur ausführen, sondern lesen lässt.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-python.html\n- Repository `introduction-python` – Kapitel `13-decorators.md`, `14-magic-methods.md`, `15-iter.md` mit den zugehörigen Beispielen unter `examples/` (`decorators.py`, `classes.py`, `functional.py`)\n- Python Data Model / Special Methods: https://docs.python.org/3.8/reference/datamodel.html\n- Das `with`-Statement und das Context-Manager-Protokoll: https://docs.python.org/3.8/reference/compound_stmts.html#the-with-statement\n- `functools` mit `wraps` und `update_wrapper`: https://docs.python.org/3.8/library/functools.html\n- `contextlib.contextmanager`: https://docs.python.org/3.8/library/contextlib.html\n- Yield-Ausdrücke und Generatoren: https://docs.python.org/3.8/reference/expressions.html#yield-expressions\n- Tutorial zu Generators und Generator Expressions: https://docs.python.org/3.8/tutorial/classes.html#generators\n- PEP 318 – Decorators for Functions and Methods: https://www.python.org/dev/peps/pep-0318/"},{"slug":"policy-as-code-fuer-cdk","title":"Policy as Code für CDK","summary":"Policy as Code macht einen prüfbaren Teil der Architekturregeln ausführbar und versionierbar.","tags":["Software","AWS","Cloud"],"published_at":"2020-03-12T10:00:00+00:00","updated_at":"2026-07-13T08:38:35.328116+00:00","url":"https://www.mikebild.dev/de/blog/policy-as-code-fuer-cdk/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/57dd96d6e8acc21a0c10bf31614e5e3ada3c5951ce79b742b4327842cf5036c4/policy-as-code-fuer-cdk-vorlesen.mp3","audio_duration_secs":678,"text":"# Policy as Code für CDK\n\nWer eine Architekturregel nur aufschreibt, bekommt sie irgendwann als Vorfall zurück. Kein öffentlich lesbarer Bucket, jede Tabelle verschlüsselt, keine Instanz größer als nötig, alles getaggt: Diese Liste steht selten vollständig an einem Ort. Sie existiert in ein paar Köpfen, in einem Wiki, das niemand mehr öffnet, und in Review-Kommentaren, die davon abhängen, wer gerade schaut und wie viel Zeit er hat. Ein unauffälliger Default genügt, und aus dem guten Vorsatz wird ein offener Bucket in Produktion. Genau um dieses Problem geht es hier – und `cloudpatrol` ist mein Versuch, es zu lösen.\n\nIn meinem [Beitrag über die CDK-Anfänge](https://www.mikebild.dev/de/blog/cdk-infrastruktur-als-programmiersprache/) endet der Text mit einem Nebensatz: Für Regeln, die nicht verhandelbar sind, lohnt sich eine maschinelle Prüfung der synthetisierten Vorlage, eine Art Policy as Code für den eigenen CDK-Output. Diesen Nebensatz löse ich hier ein. Es geht dabei weniger um ein weiteres Werkzeug als darum, begründete Guardrails als automatisierte Checks auszuführen. Abwägungen bleiben Architekturarbeit.\n\n## Warum Regeln aus dem Review herausfallen\n\nEin Review ist ein Mensch mit begrenzter Aufmerksamkeit. Er sieht heute, dass ein Bucket keine Verschlüsselung hat, und übersieht es nächste Woche, weil der Pull Request groß ist, es Freitagnachmittag ist oder die interessante Diskussion woanders lief. Das ist kein Vorwurf. So funktioniert Aufmerksamkeit. Wissen, das nur im Review existiert, ist so verlässlich wie die Tagesform des Reviewers.\n\nDaraus folgt: Eine Regel, die man nicht ausführen kann, ist keine Regel. Sie ist eine Hoffnung. Solange „kein öffentlicher Bucket“ ein Satz im Onboarding-Dokument ist, wird er irgendwann gebrochen, und zwar nicht aus bösem Willen, sondern weil ein Default unauffällig durchrutscht. Der Punkt von Policy as Code ist deshalb weniger technisch als kulturell. Er verschiebt die Regel von einem Ort, an dem sie erinnert werden muss, an einen Ort, an dem sie geprüft wird.\n\nDas CDK macht diese Verschiebung erst möglich. Weil Infrastruktur hier Code ist, der zu einer CloudFormation-Vorlage synthetisiert wird, gibt es einen Zwischenschritt, den man inspizieren kann, bevor irgendetwas in der Cloud entsteht. Genau dieser Zwischenschritt ist der richtige Ort für eine Prüfung.\n\n## Der Prüfpunkt liegt zwischen Synthese und Deployment\n\nMan kann Cloud-Ressourcen an vielen Stellen prüfen. Zur Laufzeit über einen Dienst, der Konten scannt, findet Verstöße erst, wenn sie schon existieren. Ein Wochenreport ist besser als nichts, kommt aber immer zu spät für den, der den Fehler gemacht hat. Interessant wird es, wenn die Prüfung dorthin wandert, wo die Entscheidung fällt: in den Moment, in dem der Code die Vorlage erzeugt.\n\n```mermaid\nflowchart LR\n  Source[\"CDK-Code (TypeScript)\"] --> Synth[\"cdk synth\"]\n  Synth --> Policy[\"Policy-Check\"]\n  Policy -->|verstoss| Fail[\"Feedback im PR\"]\n  Policy -->|ok| Deploy[\"Deployment\"]\n```\n\n`cloudpatrol` setzt genau an dieser Stelle an. Es hängt sich in die Synthese ein und bewertet die Constructs, während sie entstehen. Das ist ein architektonisch anderer Ansatz als ein nachgelagerter Scanner. Die Prüfung sieht nicht die rohe JSON-Vorlage, sondern die typisierten Constructs mit ihren Eigenschaften. Damit lässt sich eine Regel gegen `s3.CfnBucket` schreiben, statt gegen einen String-Pfad in einem YAML-Baum zu matchen. Die Grenze, an der geprüft wird, ist dieselbe Grenze, an der auch der Entwickler denkt.\n\nDer Effekt auf den Zyklus ist konkret. Ein Verstoß, der früher als Incident sichtbar wurde – jemand meldet einen offenen Bucket, es gibt eine Untersuchung, ein Fix, ein Postmortem – wird jetzt als roter Testlauf sichtbar, bevor der Pull Request gemergt ist. Die Zeit zwischen Fehler und Erkennung schrumpft von Tagen auf Sekunden, und die Kosten der Korrektur sinken entsprechend.\n\n## Eine Policy ist Code, kein Config-Eintrag\n\nDer entscheidende Entwurf in `cloudpatrol` ist, dass eine Regel keine Zeile in einer YAML-Datei ist, sondern eine Klasse. Sie deklariert, für welchen Construct-Typ sie gilt, und sie enthält eine Validator-Methode, die den konkreten Knoten prüft und Befunde an einen Reporter meldet.\n\n```ts\nimport { Policy, PolicyInterface, Reportable } from \"cloudpatrol\";\nimport * as s3 from \"@aws-cdk/aws-s3\";\n\nexport class BucketEncryptionPolicy extends Policy implements PolicyInterface {\n  public policyName = \"Bucket Encryption\";\n  public description = \"S3 buckets must be encrypted at rest.\";\n  public scope = s3.CfnBucket;\n\n  public validator(node: s3.CfnBucket, reporter: Reportable): void {\n    if (!node.bucketEncryption) {\n      reporter.addError(node, this, \"Bucket has no server-side encryption configured.\");\n    }\n  }\n}\n```\n\nDer `scope` ist der Selektor: Der Validator läuft nur für Buckets, nicht für jeden Knoten im Baum. Innerhalb des Validators habe ich die volle Ausdruckskraft von TypeScript. Ich kann Bedingungen verschachteln, Ausnahmen für bestimmte Umgebungen zulassen, mehrere Eigenschaften zusammen bewerten. Eine deklarative Regelsprache stößt genau hier an ihre Grenze – die interessanten Regeln lauten selten „Feld X muss Y sein“, sondern „Feld X muss Y sein, außer wenn Z, und dann bitte mit dieser Begründung“.\n\nAngewandt wird das Ganze über eine Patrol, die einen Stack durchläuft:\n\n```ts\nconst patrol = new AwsCdkPatrol(awsDefaults);\npatrol.check(stack);\n```\n\n`cloudpatrol` bringt eine Handvoll eingebauter Policies mit – Verschlüsselung, Versionierung, Instanztypen –, aber die eingebauten sind nur der Startpunkt. Der eigentliche Wert entsteht, wenn ein Team seine eigene ungeschriebene Liste in solche Klassen übersetzt. Ab dann ist die Liste nicht mehr ungeschrieben. Sie liegt im Repository, sie ist versioniert, sie hat einen Autor und eine Historie, und man kann über sie in einem Pull Request diskutieren wie über jeden anderen Code.\n\n## warn oder block ist eine Entwurfsentscheidung\n\nNicht jede Regel hat dasselbe Gewicht. Ein fehlendes Kostenstellen-Tag ist ärgerlich, ein öffentlich lesbarer Bucket mit Kundendaten ist ein Vorfall. Deshalb ist die Schwere einer Regel kein Detail, sondern die zentrale Entwurfsentscheidung. Eine Warnung meldet und lässt durch. Ein Fehler blockiert. Wer alles blockiert, erzieht sein Team dazu, die Prüfung zu umgehen. Wer nichts blockiert, hat am Ende nur eine schön formatierte Log-Ausgabe.\n\nIch unterscheide dabei bewusst zwischen Guardrails und Architekturpolizei. Guardrails definieren die wenigen Leitplanken, die wirklich nicht verhandelbar sind: Verschlüsselung, minimale Rechte, keine offenen Netze, harte Kostenlimits. Innerhalb dieser Leitplanken bleibt das Team handlungsfähig und schnell. Architekturpolizei dagegen versucht, jede Entscheidung zu zentralisieren, und verlagert damit Verantwortung von den Teams weg. Das Erste beschleunigt sicheres Arbeiten, das Zweite bremst und frustriert. Der Unterschied liegt nicht im Werkzeug, sondern in der Frage, wie viele Regeln man auf `block` setzt und wie gut man diese wenigen begründet.\n\n```ts\ntype Severity = \"warn\" | \"block\";\n\ntype RuleMeta = {\n  policyName: string;\n  severity: Severity;\n  reason: string;\n  suggestion: string;\n};\n```\n\n## Jede Regel braucht einen Grund und einen Ausweg\n\nEine Prüfung, die nur „verboten“ sagt, ist eine Wand. Der Entwickler steht davor, versteht nicht, warum, und sucht den kürzesten Weg drumherum. Eine Prüfung, die den Grund und eine Alternative liefert, ist Lernfeedback. Sie erklärt die Entscheidung an genau der Stelle, an der sie relevant wird, und macht den nächsten Entwickler ein Stück selbständiger.\n\n```json\n{\n  \"policy\": \"lambda-timeout-max\",\n  \"severity\": \"block\",\n  \"reason\": \"A timeout above 30s usually hides a synchronous call that should be async.\",\n  \"suggestion\": \"Move the work behind a queue or a Step Function and return early.\"\n}\n```\n\nDiese drei Felder – Grund, Schwere, Ausweg – sind der Unterschied zwischen einer Regel, die Reibung erzeugt, und einer, die Reibung abbaut. Der Grund verhindert, dass die Regel als Willkür gelesen wird. Der Ausweg verwandelt das Blockieren in eine Empfehlung. Genau deshalb gehört zu jeder Policy in `cloudpatrol` ein `description`- und ein `link`-Feld: Die Regel trägt ihre eigene Begründung mit sich, statt sie in einem Wiki zu parken, das vom Code entkoppelt ist.\n\n## Der Pull Request ist der richtige Ort\n\nGuardrails wirken am besten vor dem Deployment, und der Pull Request ist der natürliche Ort dafür, weil dort Code, Kontext und Diskussion zusammenkommen. Praktisch heißt das: Der Policy-Check läuft in derselben Pipeline, die den Code baut und die Infrastruktur ausliefert. Ein roter Check ist dann kein Bürokratie-Schritt, sondern dieselbe Art von Signal wie ein fehlgeschlagener Unit-Test.\n\nDer Nutzen ist an dieser Stelle messbar, und zwar auf drei Ebenen. Erstens die Zykluszeit: Ein Verstoß wird beim Öffnen des Pull Requests sichtbar, nicht Tage später im Betrieb. Zweitens die Konsistenz: Die Prüfung ist immer gleich streng, unabhängig davon, wer reviewt und wie voll der Kalender ist. Drittens die Review-Last: Menschen müssen nicht mehr mechanisch nach denselben zehn Dingen suchen, sondern können sich auf die Fragen konzentrieren, die eine Maschine nicht beantwortet – ist der Zuschnitt des Stacks sinnvoll, passt die Abstraktion, stimmt das Kostenprofil.\n\nDas ist der eigentliche Produktivitätsgewinn: Die immer gleiche, langweilige Prüfarbeit übernimmt eine Maschine, und die Köpfe im Team werden für die interessante Arbeit frei.\n\n## Kosten sind ein Architektursignal\n\nEin Side Effect lohnt die Erwähnung, weil er oft übersehen wird. Cloud-Kosten sind nicht nur ein Thema für die Buchhaltung, sie sind ein Verhaltenssignal der Architektur. Eine Funktion, die zu oft startet, eine Ressource, die zu großzügig dimensioniert ist, ein Deployment, das unnötig Artefakte erzeugt – all das hinterlässt eine Kostenspur, bevor es als Architekturproblem benannt wird.\n\n```mermaid\nflowchart LR\n  Usage[\"Nutzung\"] --> Cost[\"Kostenspur\"]\n  Cost --> Signal[\"Signal\"]\n  Signal --> Rule[\"Policy als Leitplanke\"]\n```\n\nEine Policy, die einen zu großzügigen Instanztyp oder eine fehlende Lifecycle-Regel markiert, ist deshalb nicht nur eine technische Prüfung. Sie fängt eine Kostenentscheidung ab, bevor sie im nächsten Monat als Rechnung auftaucht. Kosten ersetzen kein Design, aber sie sind ein ehrlicher Prüfstein dafür, ob die Architektur sich so verhält, wie man es beim Entwerfen angenommen hat.\n\n## Wo Policy as Code an ihre Grenze kommt\n\nEs wäre ein Fehler, alles in eine Regel gießen zu wollen. Manche Entscheidungen sind keine Prüfung, sondern eine Abwägung, und die gehört zu einem Menschen. Ein Timeout über dreißig Sekunden kann falsch sein oder genau richtig, je nach Kontext. Solche Fälle gehören in ein Architekturgespräch oder einen kurzen ADR, nicht in einen stumpfen Blocker. Die Policy sagt dann nicht „verboten“, sondern „das braucht eine Begründung“.\n\nDamit hat Policy as Code eine klare Zuständigkeit. Sie eignet sich für die kleine Menge nicht verhandelbarer Leitplanken, deren Verletzung objektiv erkennbar ist. Sie eignet sich nicht für Geschmacksfragen und nicht für Abwägungen, bei denen die richtige Antwort vom Projekt abhängt. Wer diese Grenze respektiert, bekommt ein Werkzeug, das Vertrauen schafft. Wer sie ignoriert und jede Meinung zur blockierenden Regel macht, bekommt ein Werkzeug, das umgangen wird.\n\nEin zweiter Vorbehalt betrifft die Abstraktion. `cloudpatrol` prüft, was die Synthese erzeugt. Ein selbst geschriebenes Construct, das ein Dutzend Ressourcen kapselt, kann eine Regel unbeabsichtigt aushebeln, wenn es die geprüfte Eigenschaft anders setzt, als man denkt. Die Prüfung ist also nur so ehrlich wie die Sichtbarkeit der Constructs. Das ist kein Argument gegen Abstraktion, aber eine Erinnerung daran, dass eine Regel und die Bausteine, gegen die sie läuft, zusammen entworfen werden müssen.\n\n## Was ich nach ein paar Wochen mitnehme\n\n`cloudpatrol` ist Alpha, die API bewegt sich, und ich würde heute keine große Initiative darauf aufbauen. Aber die Richtung überzeugt mich, und sie ist unabhängig vom konkreten Tool. Sobald Infrastruktur Code ist, gehören ihre Regeln in denselben Kreislauf wie der Code: versioniert, geprüft, im Pull Request diskutierbar. Der Gewinn ist nicht, dass eine Maschine Fehler findet. Der Gewinn ist, dass eine Architekturentscheidung nicht mehr im Kopf einer einzelnen Person liegt, sondern als ausführbare, begründete Regel im Repository steht, an der ein anderer Entwickler morgen sinnvoll widersprechen kann.\n\nOb sich eine Regel lohnt, entscheidet sich an drei Punkten. Sie muss einen echten Fehlerfall verhindern, den ich benennen kann, sie muss ihren Grund mitliefern, und sie muss einen Ausweg zeigen. Erfüllt sie das, hängt die Regel nicht mehr an der Tagesform des Reviewers, sondern an einer Prüfung, die immer gleich streng ist. Alles andere bleibt Gesprächsstoff für Menschen – und das ist gut so.\n\n## Weiterführende Quellen\n\n- AWS CDK, Einführung und Konzepte: https://docs.aws.amazon.com/cdk/latest/guide/home.html\n- Constructs als Bausteine: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html\n- Policy as Code fürs CDK (cloudpatrol): https://github.com/MikeBild/cloudpatrol"},{"slug":"aggregat-als-konsistenzgrenze","title":"Das Aggregat als Konsistenzgrenze","summary":"Ein Aggregat ist keine ORM-Entity und keine Tabelle, sondern eine Konsistenz- und Transaktionsgrenze: ein Verbund von Objekten, den eine Aggregate Root als Einheit schützt. Warum eine Transaktion genau ein Aggregat ändert und andere Aggregate nur per ID referenziert werden.","tags":["Software","Architektur"],"published_at":"2020-02-19T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/aggregat-als-konsistenzgrenze/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/0af23d3aa0f673eb11cd6200616017ee64a2a16d67ab4f6941af6802d8a20f06/aggregat-als-konsistenzgrenze-vorlesen.mp3","audio_duration_secs":851,"text":"```typescript\n@Entity()\nclass Order {\n  @PrimaryGeneratedColumn() id: number;\n  @ManyToOne(() => Customer) customer: Customer;\n  @OneToMany(() => LineItem, (li) => li.order) lineItems: LineItem[];\n  @Column() status: string;\n}\n```\n\nDiese Klasse trägt vier Annotationen und keine einzige Regel. Nichts hindert Code an irgendeiner Stelle daran, `order.lineItems.push(...)` aufzurufen, obwohl `status` längst auf `'CONFIRMED'` steht. Und über `order.customer.updateAddress(...)` verändert derselbe Aufrufer in derselben Transaktion nebenbei ein zweites Modell. Was hier steht, ist ein Objektgraph: Datenklassen mit Gettern und Settern, die über Fremdschlüssel zusammenhängen. Trotzdem wird genau so eine Klasse regelmäßig als Aggregat bezeichnet.\n\nEin Aggregat entsteht früher. Es beginnt mit der Frage, welche Regeln im selben Augenblick gelten müssen, und wird erst danach zu Klassen und Tabellen. Wer den Begriff so liest, trifft andere Entscheidungen: über Transaktionen, über Referenzen zwischen Objekten, über die Größe seiner Modelle und darüber, wo eventual consistency anfängt. Das ist die Fortsetzung eines Gedankens, den ich schon an anderer Stelle verfolgt habe – dass [CQRS eine fachliche Grenze ist](https://www.mikebild.dev/de/blog/cqrs-fachliche-grenze-kein-framework/) und kein Framework. Das Aggregat ist die nächste, kleinere Grenze im selben Denkgebäude.\n\n## Was ein Aggregat wirklich ist\n\nMartin Fowler formuliert es knapp: Ein Aggregat ist „a cluster of domain objects that can be treated as a single unit“ – ein Verbund von Domänenobjekten, den man als eine Einheit behandelt. Das klassische Beispiel ist eine `Order` mit ihren `LineItems`. Das sind getrennte Objekte, aber sie werden nie einzeln geladen, geändert oder gespeichert. Sie kommen als Ganzes aus dem Repository und gehen als Ganzes zurück. Das Aggregat ist die Grundeinheit für den Speicher-Transfer.\n\nInnerhalb dieses Verbunds gibt es genau eine ausgezeichnete Komponente: die Aggregate Root. Sie ist die einzige Entity, auf die von außen referenziert werden darf. Niemand außerhalb des Aggregats hält jemals direkt ein `LineItem` in der Hand – man geht immer über die `Order`. Das klingt nach einer lästigen Kapselungsregel, hat aber einen tiefen Grund: Nur wenn aller Zugriff über die Root läuft, kann die Root garantieren, dass der Zustand des gesamten Aggregats stimmt.\n\nUnd damit sind wir beim Kern. Eric Evans, Vaughn Vernon und Fowler meinen alle dasselbe, wenn sie vom Aggregat sprechen: Es ist die Konsistenzgrenze. Die Invarianten – die Geschäftsregeln, die immer gelten müssen – sind nach jeder abgeschlossenen Änderung erfüllt. Nicht „irgendwann“, nicht „nach dem nächsten Batch-Lauf“, sondern unmittelbar, transaktional. Vernon nennt das treffend „must always be transactionally consistent“. Eine Invariante ist zum Beispiel: „Nach der Bestätigung dürfen keine Positionen mehr hinzukommen.“ Oder: „Die Summe der Positionen entspricht der Bestellsumme.“ Solche Regeln gehören nicht in einen Service irgendwo weiter oben, sie gehören in die Root selbst.\n\nDas ist der entscheidende Perspektivwechsel: Den Zuschnitt eines Aggregats bestimmen die Invarianten, die gemeinsam geschützt werden müssen. Welche Objekte fachlich zusammengehören, ergibt sich daraus – und nicht umgekehrt.\n\n## Die Root schützt ihre Invarianten\n\nSehen wir uns das im Code an. Das folgende Beispiel ist illustrativ – es geht mir um die Struktur, nicht um ein lauffähiges System. Wichtig ist, was fehlt: öffentliche Setter. Die `lineItems`-Liste ist privat, und der einzige Weg, sie zu verändern, führt über Methoden, die die Invarianten kennen.\n\n```typescript\ntype OrderStatus = 'DRAFT' | 'CONFIRMED';\n\nclass Order {\n  private status: OrderStatus = 'DRAFT';\n  private readonly lineItems: LineItem[] = [];\n\n  constructor(\n    readonly id: OrderId,\n    private readonly customerId: CustomerId,\n  ) {}\n\n  addLineItem(product: ProductId, quantity: number): void {\n    if (this.status === 'CONFIRMED') {\n      throw new Error('Cannot modify a confirmed order');\n    }\n    if (quantity <= 0) {\n      throw new Error('Quantity must be positive');\n    }\n    this.lineItems.push(new LineItem(product, quantity));\n  }\n\n  confirm(): void {\n    if (this.lineItems.length === 0) {\n      throw new Error('Cannot confirm an order without line items');\n    }\n    this.status = 'CONFIRMED';\n  }\n}\n```\n\nZwei Invarianten stehen hier: Eine bestätigte Bestellung nimmt keine Positionen mehr an, und eine leere Bestellung lässt sich nicht bestätigen. Beide sind an genau einer Stelle durchgesetzt, in der Root. Es gibt keinen Weg, an dieser Prüfung vorbei ein `LineItem` in die Liste zu schmuggeln – denn niemand außerhalb der `Order` kommt an die Liste heran. Genau das meint „die Root sichert die Integrität des gesamten Aggregats“. Der Zustand kann gar nicht erst ungültig werden.\n\nMan vergleiche das mit der annotierten Datenklasse vom Anfang, deren `lineItems` und `status` offen liegen. Dort kann jeder Code an jeder Stelle die Liste manipulieren oder den Status setzen. Die Regel „keine Positionen nach Bestätigung“ existiert dann bestenfalls als Konvention, verstreut über die Codebasis, und wird früher oder später verletzt. Das Aggregat verlegt sie an eine einzige, unumgehbare Stelle.\n\n## Eine Transaktion ändert genau ein Aggregat\n\nAus der Konsistenzgrenze folgt unmittelbar die Transaktionsgrenze. Evans und Vernon sind hier deutlich: Transaktionen dürfen Aggregatgrenzen nicht überschreiten. Die Faustregel lautet – eine Transaktion ändert genau eine Aggregat-Instanz. Was darüber hinausgeht, wird nicht im selben atomaren Commit erledigt, sondern in getrennten Transaktionen, verbunden über eventual consistency.\n\nDiese Regel stößt zuverlässig auf Widerstand, und der Einwand klingt immer ähnlich: „Wenn eine Bestellung bestätigt wird, muss doch auch der Lagerbestand reserviert werden – das gehört in eine Transaktion.“ Gehört es nicht. `Order` und `Inventory` sind zwei Aggregate mit je eigenen Invarianten. Sie in einen gemeinsamen Commit zu zwingen, koppelt sie technisch, erzeugt Lock-Contention und macht das System schwerer skalierbar. Stattdessen bestätigt die Transaktion T1 die Bestellung und schreibt ein `OrderConfirmed`-Event. Eine separate Transaktion T2 verarbeitet dieses Event und reserviert im `Inventory` den Bestand.\n\n```mermaid\ngraph LR\n  subgraph T1[\"Transaction T1\"]\n    O[\"Order<br/>confirm()\"]\n  end\n  subgraph T2[\"Transaction T2\"]\n    I[\"Inventory<br/>reserve()\"]\n  end\n  O -- \"OrderConfirmed (event)\" --> I\n```\n\nWichtig gegen ein verbreitetes Missverständnis: Eventual consistency heißt nicht „keine Konsistenz“. Innerhalb eines Aggregats ist alles strikt und transaktional konsistent – die `Order` ist nach T1 in jedem Fall ein gültiger Zustand. Zwischen den Aggregaten ist es eventual: Es vergeht eine kurze, aber endliche Zeit, bis das `Inventory` nachgezogen hat. Diese Grenze bewusst zu setzen, ist eine fachliche Entscheidung. Die Frage lautet immer: Muss diese Regel im selben Augenblick gelten, oder darf sie ein paar Millisekunden später gelten? Nur wenn die Antwort „im selben Augenblick“ ist, gehören die Daten ins selbe Aggregat.\n\nWer diese Trennung von schreibenden Absichten und den daraus folgenden Fakten sauber halten will, landet ohnehin schnell bei der Frage, wie man [Commands, Events und Queries trennt](https://www.mikebild.dev/de/blog/commands-events-queries-trennen/). Das Event zwischen T1 und T2 ist genau so ein Fakt: etwas, das passiert ist und andere Aggregate reagieren lässt.\n\n## Andere Aggregate nur per Identity referenzieren\n\nDamit die Transaktionsgrenze überhaupt hält, braucht es eine zweite Regel, und sie ist die, gegen die am häufigsten verstoßen wird: Ein Aggregat referenziert ein anderes Aggregat nur über dessen Identität, über die ID, niemals über einen direkten Objekt-Pointer. Vernon führt das als eigene Design-Regel: „Reference Other Aggregates by Identity“.\n\nIn der `Order` oben sieht man das bereits. Die Root hält `customerId: CustomerId`, nicht `customer: Customer`. Der Unterschied ist keine Kleinigkeit.\n\n```typescript\n// Wrong: the customer is pulled inside the order\nclass Order {\n  private customer: Customer; // pulls another aggregate inside the boundary\n}\n\n// Right: only the identity crosses the boundary\nclass Order {\n  private readonly customerId: CustomerId; // reference by identity\n}\n```\n\nSobald `Order` ein echtes `Customer`-Objekt hält, verschwimmt die Grenze. Der Entwickler, der die `Customer`-Referenz sieht, wird sie früher oder später benutzen – `order.customer.updateAddress(...)` – und schon werden in einer Transaktion zwei Aggregate geändert. Die Referenz per ID verhindert das strukturell: Man kann eine `CustomerId` nicht versehentlich mutieren. Braucht man wirklich das Kunden-Aggregat, lädt man es vorher über sein eigenes Repository und übergibt gezielt, was gebraucht wird – die ID-Referenz erzwingt also die bewusste Entscheidung.\n\nDer Side Effect ist ebenfalls wertvoll: Aggregate bleiben klein. Es gibt kein Eager-Loading ganzer Objektgraphen, kein „Bestellung laden zieht den Kunden zieht dessen Adressen zieht deren Länder“. Es wird geladen, was in der Grenze liegt, und sonst nichts.\n\nDamit die Regeln zusammen im Bild bleiben, hier die Konsistenzgrenze als Diagramm. Innerhalb des umrandeten Blocks liegt das `Order`-Aggregat mit Root und inneren Objekten; `Customer` liegt außerhalb, als eigenes Aggregat, und ist nur über die Identität verbunden.\n\n```mermaid\ngraph TB\n  ext[\"Client code\"]\n  subgraph agg[\"Aggregate: Order (consistency boundary)\"]\n    root[\"Order (Aggregate Root)\"]\n    li[\"LineItem\"]\n    money[\"Money (Value Object)\"]\n    root --> li\n    li --> money\n  end\n  cust[\"Customer<br/>(separate aggregate)\"]\n  ext -->|\"references the root only\"| root\n  root -. \"customerId (by identity)\" .-> cust\n```\n\nMan beachte die Pfeile: Der externe Zugriff zeigt ausschließlich auf die Root, nie auf `LineItem` oder `Money`. Und die Verbindung zum `Customer` ist gestrichelt und mit `customerId` beschriftet – eine Referenz, kein Enthaltensein.\n\n## Aggregate klein halten\n\nDas führt zu Vernons vielleicht praktischster Regel: „Design Small Aggregates“. Die Versuchung, Aggregate groß werden zu lassen, ist enorm, und sie hat sogar einen Namen – „object graph greed“. Aus Bequemlichkeit schließt man alles ein, was man beim Navigieren gern zur Hand hätte. Ein `Customer`, der alle seine `Orders` als Liste hält, ist das Standardbeispiel. Fachlich klingt das plausibel, technisch ist es ein Problem: Jede Änderung an einer einzelnen Bestellung müsste das gesamte Kunden-Aggregat mitsamt aller Bestellungen laden und sperren. Bei einem Kunden mit tausend Bestellungen wird jede kleine Operation teuer, und gleichzeitige Zugriffe blockieren sich gegenseitig.\n\nDie Heuristik dagegen ist einfach: Modelliert wird entlang echter Invarianten, nicht entlang von Navigationswünschen. Die Frage ist nicht „will ich von A zu B navigieren können“, sondern „muss A ungültig werden, wenn B sich ändert, und zwar sofort“. Nur wenn zwischen zwei Objekten eine echte, augenblickliche Konsistenzregel besteht, gehören sie ins selbe Aggregat. Für alles andere gibt es Referenzen per ID und, wenn man lesend navigieren will, ein Read Model. Das Aggregat ist ein Schreibmodell; wer Navigationskomfort für die Anzeige sucht, baut sich dafür eine Projection, statt das Aggregat aufzublähen.\n\nFassen wir die Regeln zusammen, an denen ich ein Aggregat im Entwurf prüfe:\n\n- Aller Zugriff von außen läuft über die Aggregate Root; innere Objekte sind gekapselt.\n- Die Root setzt die Invarianten durch, und zwar transaktional konsistent nach jeder Änderung.\n- Eine Transaktion ändert genau eine Aggregat-Instanz; alles Weitere läuft über eventual consistency und Domain Events.\n- Andere Aggregate werden nur über ihre Identität referenziert, nie als eingebettetes Objekt.\n- Aggregate bleiben klein und werden entlang echter Invarianten geschnitten, nicht entlang von Navigationswünschen.\n\n## Die drei Fallstricke\n\nDrei Verwechslungen begegnen mir dabei immer wieder, und es lohnt sich, sie beim Namen zu nennen.\n\nDer erste und häufigste: das Aggregat mit einer Datenbanktabelle oder einer ORM-Entity gleichzusetzen. Das ist falsch. Das Aggregat ist ein Modellierungs- und Konsistenzkonzept; wie es auf Tabellen abgebildet wird, ist eine völlig getrennte Frage. Ein Aggregat kann über mehrere Tabellen persistiert werden, und eine Tabelle kann mehrere Value Objects enthalten. Die Persistenz ist orthogonal zum Modell. Eine `@Entity`-annotierte Klasse mit lauter Gettern und Settern ist noch lange kein Aggregat – ihr fehlt das Entscheidende, die durchgesetzte Invariante.\n\nDer zweite: die Aggregate Root als „God Object“ misszuverstehen. Weil die Root der einzige Entry Point ist, landet bei unerfahrenen Teams gern alles in ihr. Das ist das Gegenteil der Absicht. Die Root ist klein und invariantengetrieben. Sie hält genau das, was sie zur Durchsetzung ihrer Regeln braucht – nicht mehr. Eine Root, die alles hält, ist meist ein Zeichen dafür, dass mehrere Aggregate fälschlich zu einem verschmolzen wurden.\n\nDer dritte: direkte Objekt-Referenzen zwischen Aggregaten, den Fehler vom Anfang. Er ist deshalb so tückisch, weil er sich harmlos anfühlt und weil ORMs ihn geradezu einladen – eine `@ManyToOne`-Beziehung ist schnell annotiert. Aber jede solche Referenz ist eine Einladung, in einer Transaktion mehrere Aggregate zu ändern und die Grenze aufzuweichen. Die Disziplin lautet: per ID referenzieren, und wenn man das fremde Aggregat wirklich braucht, es vorher über sein Repository laden – vor dem Aufruf der Aggregat-Methode, nicht mittendrin.\n\n## Eine kurze Abgrenzung\n\nWeil im DDD-Umfeld ständig durcheinandergerät, was zusammengehört und was nicht, eine kurze Klarstellung am Rande. CQRS und Event Sourcing sind nicht dasselbe und beide sind nicht das Aggregat. CQRS trennt Schreib- und Lesemodell, Command von Query. Event Sourcing persistiert den Zustand als Sequenz von Events statt als aktuellen Snapshot. Beide sind unabhängig voneinander einsetzbar – man kann jedes ohne das andere nutzen, wie Fowler und Greg Young betonen. Das Aggregat wiederum ist ein Baustein, der in all diesen Welten vorkommt: Es ist die Konsistenzgrenze, gegen die ein Command validiert und aus der Events entstehen. Wer diese Begriffe sauber auseinanderhält, spart sich viele fruchtlose Architekturdiskussionen.\n\nUnd noch eine feine, aber wichtige Unterscheidung: Die Konsistenzgrenze ist nicht die Transaktionsgrenze der Datenbank. Es ist umgekehrt. Das Aggregat definiert die logische Grenze, innerhalb derer alles konsistent sein muss – und die Datenbanktransaktion hat sich daran zu halten. Nicht die verfügbaren Transaktionsmechanismen bestimmen den Zuschnitt der Modelle, sondern die fachlichen Invarianten bestimmen, wie weit eine Transaktion reichen darf. Wer das umdreht und seine Aggregate an den technischen Möglichkeiten des ORM ausrichtet, hat die Grenze am falschen Ende gezogen.\n\n## Fazit\n\nDas Aggregat verdient seinen zentralen Platz in DDD, weil es eine der schwierigsten Fragen der Softwareentwicklung beantwortet: Wo endet Konsistenz und wo beginnt Koordination? Es ist kein Datencontainer und kein Persistenzartefakt, sondern eine bewusst gezogene Konsistenz- und Transaktionsgrenze mit einer Root, die ihre Invarianten unerbittlich durchsetzt. Aus dieser einen Idee folgt alles Weitere fast von selbst: eine Transaktion pro Aggregat, Referenzen zwischen Aggregaten nur über Identität, kleine Modelle entlang echter Regeln, und eventual consistency dort, wo augenblickliche Konsistenz weder nötig noch bezahlbar ist.\n\nDie erste Frage vor einem Domänenmodell lautet also nicht „welche Objekte gehören zusammen“, sondern „welche Regeln müssen im selben Augenblick gelten“. Die Antwort schneidet die Aggregate – und mit ihnen die halbe Architektur.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html\n- Martin Fowler: [DDD_Aggregate](https://martinfowler.com/bliki/DDD_Aggregate.html) – die kompakte Definition von Aggregat und Aggregate Root.\n- Vaughn Vernon: [Effective Aggregate Design](https://www.dddcommunity.org/library/vernon_2011/) (2011), Teil I–III – die Regeln „Design Small Aggregates“ und „Reference Other Aggregates by Identity“.\n- Eric Evans: *Domain-Driven Design* (2003), Kapitel 6 – die Primärquelle für Aggregate und Aggregate Roots.\n- Greg Young / Martin Fowler zur Abgrenzung von CQRS und Event Sourcing – nützlich, um das Aggregat sauber davon zu trennen.\n- Aus meinem eigenen Vortragsmaterial: das Deck „CQRS + Event Sourcing“ (SlideShare), in dem ich diese Grenzziehung an konkreten Beispielen durchspiele."},{"slug":"ssmenv-cli-kleine-clis","title":"`ssmenv-cli`: kleine CLIs für große Ordnung","summary":"Eine kleine CLI über AWS SSM zeigt, wie Konfiguration aus einer benannten, zugriffskontrollierten Quelle in lokale Prozesse und CI gelangt – einschließlich Versions- und Ausfallfragen.","tags":["Software","Daten","Tools"],"published_at":"2020-02-12T10:00:00+00:00","updated_at":"2026-07-20T13:27:22.94021+00:00","url":"https://www.mikebild.dev/de/blog/ssmenv-cli-kleine-clis/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/eadf9601e030708ec8293eb2cd52f9afe50e6650584a307334dc20fe68fd3123/ssmenv-cli-kleine-clis-vorlesen.mp3","audio_duration_secs":695,"text":"# `ssmenv-cli`: kleine CLIs für große Ordnung\n\nKonfiguration ist kein Dateiformat. Sie ist ein Datenbestand mit Zugriffsrechten, Versionen und einer Historie – die `.env`-Datei ist nur eine Kopie davon, und selten die einzige.\n\nUnd Kopien vermehren sich: ein `.env` auf dem Laptop, ein zweites im CI, ein drittes im Onboarding-Wiki, ein viertes als Screenshot in einem Chat, ein fünftes „nur bei mir lokal“. Welcher Wert führt, weiß irgendwann niemand mehr. Dort entstehen die Bugs, die sich nicht reproduzieren lassen, und die Onboarding-Tage, die man nicht zurückbekommt. `ssmenv-cli` zieht daraus eine Konsequenz: Der AWS Systems Manager Parameter Store führt die Werte, und das Werkzeug übersetzt sie nur noch in die Umgebung eines Prozesses.\n\n## Das Problem hinter dem Werkzeug\n\nDas klassische Muster ist der geteilte `.env`-Zettel. Er funktioniert am ersten Tag und zerfällt danach langsam. Die typischen Symptome kenne ich auswendig: Ein Secret liegt versehentlich im Git-Verlauf. Ein neuer Kollege bekommt eine veraltete Kopie und verliert einen halben Tag mit „bei mir läuft es nicht“. Ein Wert wird in Produktion geändert, aber nirgends nachvollziehbar dokumentiert. Und niemand kann auf Anhieb sagen, wer eigentlich das Recht hat, ein Produktions-Secret zu lesen.\n\nDas ist im Kern kein Tooling-Problem, sondern ein Grenzproblem. Es fehlt eine klare Antwort auf drei Fragen: Welche Daten sind führend? Wer darf sie lesen und ändern? Wo wird eine Änderung nachvollziehbar? Solange diese Grenzen unscharf bleiben, hilft auch das schönste Skript nicht. Die drei Fragen kommen also vor dem Werkzeug.\n\n## Die Entscheidung: SSM als einzige Quelle\n\nDie tragende Entscheidung lautet: Der AWS Systems Manager Parameter Store wird zur einzigen Quelle für Konfiguration und Secrets. Nicht Git, nicht der Laptop, nicht der Chat – ein hierarchischer Namensraum in AWS, der ohnehin schon da ist, wenn man dort deployt.\n\nDas ist eine bewusste Wahl gegen ein eigenes System und gegen ein großes zusätzliches Produkt. Der Parameter Store bringt genau das mit, was ich für diese Grenze brauche: eine Hierarchie über Pfade wie `/myapp/prod/DATABASE_URL`, Verschlüsselung sensibler Werte über KMS als `SecureString`, Zugriffskontrolle über IAM und eine lückenlose Aufzeichnung über CloudTrail. Damit sind die drei offenen Fragen von oben beantwortet, ohne dass ich eine einzige Zeile Infrastruktur selbst betreibe.\n\nWas jetzt noch fehlt, ist nur die letzte Meile: der Weg vom Parameter Store in die Umgebungsvariablen eines Prozesses. Genau das – und nichts mehr – ist die Aufgabe der CLI.\n\n## Architektur: ein dünner Adapter, keine Plattform\n\nDie wichtigste Architekturentscheidung an `ssmenv-cli` ist, was das Tool bewusst *nicht* tut. Es speichert nichts, es entscheidet über keine Rechte, es hält keinen Zustand. Es ist ein dünner Adapter zwischen einer führenden Datenquelle und dem 12-Factor-Prinzip „config in the environment“.\n\nDiese Trennung ist die eigentliche Grenze. Der Parameter Store ist der Wahrheitsspeicher. IAM ist die Berechtigungsgrenze. KMS ist die Vertraulichkeitsgrenze. Die CLI ist reine Übersetzung: Sie liest einen Pfad-Präfix, löst `SecureString`-Werte auf und stellt sie einem Prozess als Umgebung bereit. Weil das Tool selbst keine Autorität hat, kann ich es jederzeit austauschen, ohne dass sich am Sicherheits- oder Datenmodell irgendetwas ändert. Das ist der Wert einer sauber gezogenen Grenze: Sie macht das Werkzeug ersetzbar.\n\n## Kleines Beispiel\n\nDer praktische Kern passt in wenige Zeilen. Werte setzt man einmal – idealerweise über Automatisierung, hier zur Anschauung von Hand:\n\n```bash\n# Store a secret as an encrypted SecureString under a namespaced path\nssmenv set --path /myapp/prod DATABASE_URL \"postgres://db.internal:5432/app\"\n\n# List everything under a prefix (values masked by default)\nssmenv list --path /myapp/prod\n```\n\nDer eigentliche Gewinn zeigt sich beim Laden. Statt eine Datei zu verteilen, löst man die Umgebung zur Laufzeit auf:\n\n```bash\n# Run a process with the resolved environment, nothing written to disk\nssmenv exec --path /myapp/prod -- node server.js\n\n# Or export into the current shell for a quick local session\neval \"$(ssmenv export --path /myapp/staging)\"\n```\n\nUnd weil zwei Umgebungen selten identisch sind, ist der Vergleich ein vollwertiger Befehl und keine Nebensache:\n\n```bash\n# Show which keys differ between staging and prod\nssmenv diff --path /myapp/staging --path /myapp/prod\n```\n\nDas Beispiel ist bewusst klein. Es simuliert keine vollständige Plattform, es zeigt die Grenze: Die CLI berührt nur die letzte Meile zwischen Speicher und Prozess. Ein gutes Beispiel ist nicht das mit den meisten Optionen, sondern das, bei dem man die Entscheidung nach fünf Minuten diskutieren kann.\n\n## Skizze\n\n```mermaid\nflowchart LR\n  Store[\"SSM Parameter Store<br/>/myapp/prod/*\"] --> CLI[\"ssmenv-cli<br/>thin adapter\"]\n  IAM[\"IAM<br/>permission boundary\"] --> CLI\n  KMS[\"KMS<br/>SecureString decrypt\"] --> CLI\n  CLI --> Env[\"process env\"]\n  Env --> App[\"node server.js\"]\n```\n\nDie Skizze macht die Verantwortlichkeiten sichtbar: Der Store hält die Wahrheit, IAM und KMS ziehen die Grenzen, die CLI übersetzt. Kein Kasten in diesem Bild ist auf einen Toolnamen angewiesen – das ist Absicht.\n\n## Trade-offs: warum klein, warum nicht mehr\n\nDie naheliegende Gegenfrage lautet: Warum nicht gleich ein vollwertiges Secret-Management-Produkt? Das ist eine Abwägung, kein Dogma. Ein großes System bringt Rotation, dynamische Credentials und feingranulare Policies mit – und dazu Betrieb, Lernkurve und eine weitere Abhängigkeit. Für viele Teams, die ohnehin auf AWS deployen, ist das mehr Maschine, als das Problem verlangt.\n\nIch baue die kleinste Lösung, die die drei Grenzfragen sauber beantwortet, und lasse Raum für den Wechsel. Weil `ssmenv-cli` nur ein Adapter ist, ist dieser Wechsel billig. Wenn ein Team später Rotation im großen Stil braucht, tausche ich den Backend-Speicher aus, nicht das mentale Modell. Die teure Entscheidung – Konfiguration ist zentral, zugriffskontrolliert und auditierbar – bleibt dieselbe. Genau so sollte ein Trade-off aussehen: klein anfangen, ohne sich den Ausbau zu verbauen.\n\nDer zweite Trade-off betrifft Netzabhängigkeit. Werte zur Laufzeit aus dem Store zu lösen heißt, dass ein Prozessstart eine AWS-Erreichbarkeit voraussetzt. Das ist bewusst gewählt: Ich tausche einen kurzen Netzaufruf beim Start gegen die dauerhafte Gefahr veralteter lokaler Kopien. Für lokale Offline-Arbeit gibt es weiterhin einen expliziten Export – eine sichtbare Ausnahme, die jemand bewusst auslösen muss.\n\n## Betriebliche Konsequenz\n\nDie Konsequenz einer Architektur zeigt sich nicht in der Demo, sondern bei Fehlern, Last, Rollback und Review. Hier trägt das Modell, weil die schweren Aufgaben dort liegen, wo sie hingehören.\n\nZugriff ist eine IAM-Frage. Wer `/myapp/prod/*` lesen darf, steht in einer Policy und nicht in einem Chatverlauf – und lässt sich damit prüfen und entziehen. Vertraulichkeit ist eine KMS-Frage: Sensible Werte liegen als `SecureString` verschlüsselt, und die CLI entschlüsselt nur, wenn der Aufrufer das Recht dazu hat. Nachvollziehbarkeit ist eine CloudTrail-Frage: Jeder Lese- und Schreibzugriff ist aufgezeichnet. Wenn jemand fragt „wer hat dieses Secret wann gelesen?“, gibt es eine Antwort statt eines Schulterzuckens.\n\nRückbaubarkeit ist der stärkste Punkt. Weil der Parameter Store Versionen hält, ist ein fehlerhaft gesetzter Wert kein Notfall, sondern ein Rollback auf die vorige Version. Eine Änderung an Konfiguration wird damit so behandelbar wie eine Änderung an Code.\n\n## Fehlerfälle\n\nEine Idee ist erst belastbar, wenn der Fehlerpfad beschrieben ist. Drei Fälle gehören dazu.\n\nFehlende Berechtigung: Wenn der Aufrufer den Pfad nicht lesen darf, muss die CLI hart und deutlich abbrechen. Ein leerer Wert, der stillschweigend als leerer String durchrutscht, ist der gefährlichste Bug – die Anwendung startet mit halber Konfiguration und fällt erst tief im Betrieb um. Ein `exec` muss deshalb bei fehlenden oder unlesbaren Schlüsseln scheitern, bevor der Zielprozess überhaupt startet.\n\nTeilweise aufgelöste Umgebung: Wenn von zehn erwarteten Schlüsseln nur neun da sind, ist das kein „fast richtig“, sondern falsch. Ich behandle das als Fehler mit klarer Meldung, welcher Schlüssel fehlt.\n\nNetz- und Regionsfehler: Ein nicht erreichbarer Store oder eine falsche Region darf keine kryptische SDK-Ausnahme durchreichen, sondern eine Meldung, die den Pfad und die Ursache benennt. Der Unterschied zwischen Demo und Betrieb liegt fast immer in genau diesen unaufgeregten Fehlermeldungen.\n\n## Dieselbe Grenze im CI\n\nDer wahre Test einer Konfigurationsgrenze ist die Pipeline. Im CI läuft kein Mensch mit persönlichen Rechten, sondern eine Rolle – und genau das macht das Modell sauber. Statt Secrets als CI-Variablen zu duplizieren, bekommt der Build-Job eine IAM-Rolle, die exakt den Pfad lesen darf, den er braucht, und keinen anderen. Ein Staging-Deploy sieht `/myapp/staging/*`, ein Produktions-Deploy `/myapp/prod/*`, und die Trennung ist keine Konvention, sondern eine Berechtigung.\n\n```bash\n# In CI: the job's role grants read on /myapp/staging/* only\nssmenv exec --path /myapp/staging -- npm run deploy\n```\n\nDamit verschwindet die Klasse der „Secret liegt jetzt auch im CI“-Kopien. Es gibt keine zweite Quelle, die veralten kann, und das Prinzip der geringsten Rechte gilt für Maschinen genauso wie für Menschen. Wenn ein CI-Token kompromittiert wird, ist der Schaden auf genau den Pfad begrenzt, den dieser Job lesen durfte – nachlesbar in CloudTrail, entziehbar in einer Policy. Dass lokal, im CI und in Produktion buchstäblich derselbe Befehl mit einem anderen Pfad läuft, ist der Kern der Reproduzierbarkeit.\n\n## Business-Outcome: kürzere Zyklen, weniger Reibung\n\nDer Nutzen ist messbar, auch wenn das Werkzeug klein ist. Onboarding, das früher „hol dir das aktuelle `.env` von irgendwem“ hieß, wird zu einem einzigen Befehl gegen einen Pfad – aus einem halben Tag Reibung werden Minuten. Die ganze Klasse „bei mir läuft es, bei dir nicht“-Fehler verschwindet, weil dev, CI und Produktion dieselbe führende Quelle nutzen und sich nur im Pfad-Präfix unterscheiden. Und das Risiko, ein Secret im Git-Verlauf zu hinterlassen, sinkt strukturell, weil es keinen Grund mehr gibt, Secrets in Dateien abzulegen, die versioniert werden.\n\nDas sind die drei Hebel, die für technische Entscheider zählen: kürzere Zyklen beim Aufsetzen und Wechseln von Umgebungen, weniger Reibung im Alltag und höhere Qualität durch reproduzierbare Konfiguration. Der Gewinn steckt nicht im Toolnamen, sondern in der Entscheidung, Konfiguration wie ein prüfbares Datensystem zu behandeln.\n\n## Das Muster für kleine CLIs\n\n`ssmenv-cli` bleibt absichtlich ein Adapter. Ob der Parameter Store wirklich die führende Quelle sein kann, entscheiden Schreibrechte, der Umgang mit Versionen und das Verhalten bei Nichterreichbarkeit. Die CLI löst keine dieser Fragen; sie macht die getroffene Antwort nur für lokale Prozesse und CI nutzbar.\n\nDeshalb baue ich lieber viele kleine, scharf umrissene CLIs als ein großes, unscharfes Framework. Jede erledigt eine Aufgabe, hat eine sichtbare Grenze und mindestens einen ehrlichen Fehlerfall. Dieselbe Denkweise steckt in `ssmenv-cli`: klein im Umfang, klar in der Verantwortung, ersetzbar im Detail. Große Ordnung entsteht nicht aus großen Werkzeugen, sondern aus vielen kleinen mit klaren Zuständigkeiten.\n\n## Schluss\n\nWichtiger als die Funktionsweise von `ssmenv-cli` ist der Verlauf der Grenze: Konfiguration zentral und auditierbar, das Werkzeug bewusst dünn und austauschbar. Diese Entscheidung trägt auch dann noch, wenn das Tool längst durch ein anderes ersetzt ist – und genau deshalb lässt sie sich im nächsten Teamgespräch wiederverwenden.\n\n## Weiterführende Quellen\n\n- Repository ssmenv-cli: https://github.com/MikeBild/ssmenv-cli\n\nDie Quelle hilft beim Gegenprüfen der konkreten Umsetzung."},{"slug":"github-actions-mit-aws-cdk","title":"GitHub Actions mit AWS CDK","summary":"GitHub Actions und AWS CDK verbinden Codeänderung, Infrastruktur und Deployment zu einer reviewbaren Pipeline – und machen die Architekturentscheidung im Pull Request sichtbar.","tags":["Software","AWS","Cloud"],"published_at":"2020-01-12T10:00:00+00:00","updated_at":"2026-07-13T07:55:50.690283+00:00","url":"https://www.mikebild.dev/de/blog/github-actions-mit-aws-cdk/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/7d9d27403d8521f29de31772a17da923b333891bb39d9b2cf6a8bcbfdd227dff/github-actions-mit-aws-cdk-vorlesen.mp3","audio_duration_secs":686,"text":"# GitHub Actions mit AWS CDK\n\nZwei Befehle tragen diese Konstruktion: `cdk diff` im Pull Request und `cdk deploy` auf `main`. Der erste ändert nichts und zeigt alles – neue Ressourcen, geänderte Rechte, gelöschte Tabellen. Der zweite ändert alles und zeigt nichts, was nicht vorher schon im Review stand.\n\n**Redaktionelle Einordnung (Juli 2026):** Workflow und CDK-Code zeigen den Stand von Januar 2020: Actions v1, Node 12, CDK v1 und dauerhaft hinterlegte AWS-Schlüssel. Für einen heutigen Aufbau gehören aktuelle Action-Versionen, CDK v2 und kurzlebige AWS-Zugangsdaten per OpenID Connect in den Workflow. Der Review-Gedanke um `cdk diff` bleibt davon unberührt.\n\nIn dieser Reihenfolge steckt der Unterschied zwischen Automatisierung und Architektur. Wie man GitHub Actions und AWS CDK miteinander verbindet, ist in einer Stunde erledigt. Spannender ist, welche Entscheidung dadurch früher auf den Tisch kommt.\n\n## Warum diese Kombination mehr ist als Automatisierung\n\nGitHub Actions ist seit wenigen Wochen allgemein verfügbar, und die naheliegende Reaktion ist, damit den bekannten Ablauf nachzubauen: Test bauen, Artefakt schnüren, per Skript deployen. Das funktioniert, verschenkt aber den eigentlichen Hebel. Denn mit AWS CDK beschreibe ich meine Infrastruktur im selben TypeScript-Repository wie meine Anwendung – Lambda, API Gateway, IAM-Rollen, Tabellen. Damit wird Infrastruktur zu Code, der reviewt, versioniert und getestet werden kann wie jeder andere.\n\nDer Punkt, um den es mir geht, entsteht erst im Zusammenspiel. Wenn Anwendungscode und Infrastrukturcode im selben Pull Request liegen und dieselbe Pipeline durchlaufen, dann ist eine Änderung nicht mehr „neuer Handler“ plus „jemand klickt später eine Rolle in der Konsole zusammen“. Sie ist eine einzige, nachvollziehbare Einheit. Die Codeänderung und ihre Betriebsfolge stehen nebeneinander, bevor irgendetwas produktiv wird. Genau das ist der architektonische Gewinn – nicht die Ersparnis an Klicks.\n\nDiese Grenze wird häufig woanders gezogen. Infrastruktur steckt dann in einem Wiki, in Terminalbefehlen, in den Köpfen von zwei Leuten. Die Pipeline deployt den Code, aber niemand kann im Review sagen, welche Rechte, welche Ressourcen und welche Kosten eine Änderung nach sich zieht. CDK plus GitHub Actions macht diese Frage zu einem Artefakt, über das man diskutieren kann.\n\n## Vor dem Workflow steht die Freigabe\n\nBevor ich YAML schreibe, kläre ich drei konkrete Punkte: Wer darf ein Deployment auslösen? Welche Änderungen müssen im Pull Request sichtbar sein? Und was ist der Rückweg, wenn der neue Stack nicht trägt? GitHub Actions beantwortet keine dieser Fragen. Der Workflow setzt die Antworten nur durch. Fehlt etwa eine geschützte Umgebung mit Freigabe, wird aus `cdk deploy` auf `main` schnell ein Automatismus, der schneller ist als die Kontrolle dahinter.\n\n## Der `cdk diff` als Review-Artefakt\n\nDer methodische Kern dieser Kombination ist für mich ein einziger Befehl: `cdk diff`. Er vergleicht den synthetisierten CloudFormation-Stand mit dem, was in AWS tatsächlich läuft, und zeigt die Differenz – neue Ressourcen, geänderte Rechte, gelöschte Tabellen. Wenn ich diesen Befehl in den Pull-Request-Lauf ziehe, bekommt jeder Review eine ehrliche Vorschau auf die Betriebsfolge.\n\nDas ändert die Qualität eines Reviews spürbar. Ein Reviewer sieht nicht nur, dass ein Handler dazukommt, sondern dass dieser Handler eine neue IAM-Policy mit `dynamodb:*` mitbringt. Diese Zeile im Diff ist der Moment, in dem eine schlechte Entscheidung auffällt – vor dem Merge, nicht im nächsten Security-Audit. Ich verschiebe damit einen Fehler von „teuer und spät“ nach „billig und früh“, und das ist die konkrete Wirkung, um die es geht.\n\nDer Diff deckt außerdem eine Klasse von Problemen auf, die sonst unsichtbar bleibt: Drift. Wenn jemand in der Konsole eine Ressource von Hand ändert, weicht der reale Stand vom Code ab. Ohne Diff merkt das niemand, bis ein Deployment die manuelle Änderung stillschweigend überschreibt und irgendetwas kaputtgeht, das „gestern noch lief“. Mit dem Diff im Pull Request wird diese Abweichung zum sichtbaren Signal – und zur Gelegenheit, die eigentliche Frage zu stellen: Warum wurde an der Konsole gearbeitet, und wie bringe ich diese Änderung zurück in den Code, wo sie hingehört. Der Diff ist damit nicht nur ein Review-Werkzeug, sondern eine leise Disziplinierung des ganzen Teams in Richtung „der Code ist die Wahrheit“.\n\n## Ein kleiner Stack\n\nAls Grundlage dient ein bewusst kleiner Stack – eine Lambda hinter einem API Gateway. Er soll keine vollständige Anwendung simulieren, sondern die Grenze zeigen.\n\n```ts\nimport * as cdk from \"@aws-cdk/core\";\nimport * as lambda from \"@aws-cdk/aws-lambda\";\nimport * as apigw from \"@aws-cdk/aws-apigateway\";\n\nexport class ApiStack extends cdk.Stack {\n  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {\n    super(scope, id, props);\n\n    const handler = new lambda.Function(this, \"ApiHandler\", {\n      runtime: lambda.Runtime.NODEJS_12_X,\n      handler: \"index.handler\",\n      code: lambda.Code.fromAsset(\"dist/api\"),\n      timeout: cdk.Duration.seconds(10)\n    });\n\n    new apigw.LambdaRestApi(this, \"Api\", { handler });\n  }\n}\n```\n\nInteressant ist weniger, was hier steht, als was hier nicht steht. `LambdaRestApi` ist ein Construct, das im Hintergrund eine API, eine Integration und die passende Ausführungsrolle erzeugt. Das ist der Wert einer guten Abstraktion: Sie reduziert Wiederholung und kapselt vernünftige Defaults. Und das ist zugleich ihr Risiko – sie darf nicht verstecken, welche Ressourcen, Rechte und Kosten real entstehen. Der `cdk diff` ist genau die Kontrollinstanz, die das Versteckte wieder sichtbar macht.\n\nEin gutes Beispiel hat wenige Features und einen Punkt, über den man nach fünf Minuten streiten kann. Der `timeout` ist so ein Punkt: zehn Sekunden sind eine Betriebsentscheidung, kein Detail, und sie gehört in den Code, nicht in eine mündliche Absprache.\n\n## Die Pipeline\n\nDie Pipeline hat zwei Aufgaben, die ich sauber trenne. Im Pull Request zeigt sie den Diff, ohne etwas zu verändern. Auf `main` deployt sie. Diese Trennung ist keine Kosmetik – sie stellt sicher, dass die Vorschau folgenlos ist und das Deployment einen klaren Auslöser hat.\n\n```yaml\nname: deploy\n\non:\n  push:\n    branches: [main]\n  pull_request:\n\njobs:\n  diff:\n    if: github.event_name == 'pull_request'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v1\n      - uses: actions/setup-node@v1\n        with:\n          node-version: \"12\"\n      - run: npm ci\n      - run: npm run build\n      - run: npx cdk diff\n        env:\n          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}\n          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n          AWS_DEFAULT_REGION: eu-central-1\n\n  deploy:\n    if: github.ref == 'refs/heads/main'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v1\n      - uses: actions/setup-node@v1\n        with:\n          node-version: \"12\"\n      - run: npm ci\n      - run: npm run build\n      - run: npx cdk deploy --require-approval never\n        env:\n          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}\n          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n          AWS_DEFAULT_REGION: eu-central-1\n```\n\nDie Credentials liegen als GitHub Secrets im Repository und werden über Umgebungsvariablen an die CDK-CLI gereicht. Das ist der pragmatische Weg, den ich heute gehe – und zugleich die Stelle mit der größten Verantwortung, dazu gleich mehr.\n\nDer Ablauf als Skizze:\n\n```mermaid\nflowchart LR\n  PR[Pull Request] --> Diff[cdk diff<br/>im Review sichtbar]\n  Diff --> Merge[Merge nach main]\n  Merge --> Build[Build + synth]\n  Build --> Deploy[cdk deploy]\n  Deploy --> CFN[CloudFormation<br/>Change Set]\n  CFN --> Observe[Logs + Metriken]\n```\n\n## Eine Umgebung, die sich reproduzieren lässt\n\nSobald der erste Stack läuft, kommt die Frage nach Staging und Produktion. Und hier zeigt sich, warum Infrastruktur als Code mehr ist als ein bequemeres Deployment. Weil mein Stack eine TypeScript-Klasse ist, kann ich ihn parametrisieren und zweimal instanziieren – dieselbe Definition, unterschiedliche Namen, unterschiedliche Konten. Staging ist dann per Konstruktion eine exakte Kopie von Produktion, nicht eine Umgebung, die sich über Monate langsam auseinanderentwickelt, bis niemand mehr weiß, welcher Unterschied Absicht war und welcher Zufall.\n\nDas ist eine der methodischen Konsequenzen, die ich am höchsten schätze. Der klassische Bruch zwischen „funktioniert bei mir“ und „funktioniert in Produktion“ entsteht fast immer aus Umgebungsunterschieden, die niemand dokumentiert hat. Wenn die Umgebung selbst versioniert ist, verschwindet dieser Bruch nicht vollständig, aber er wird zu einer Frage, die ich im Repository beantworten kann, statt zu einem Rätsel, das ich unter Zeitdruck im Produktivsystem löse. Genau deshalb gehört CDK für mich zum Thema Reproduzierbarkeit. Die gesparten Deployment-Handgriffe sind nur der sichtbare Side Effect.\n\n## Rechte, die nicht mit jeder Bequemlichkeit mitwachsen\n\nDie verlockendste Abkürzung ist eine Deploy-Identität mit `AdministratorAccess`. Sie funktioniert immer, sie erklärt nie einen Fehler, und sie ist der Grund, warum ein kompromittiertes Repository zum kompromittierten Account wird. Der Trade-off ist real: Eine eng geschnittene Rolle bedeutet, dass ein Deployment gelegentlich an einer fehlenden Berechtigung scheitert und ich nachschärfen muss. Aber dieses Scheitern ist billig und lehrreich, während der stille Vollzugriff nur ein einziges Mal teuer wird.\n\nServerless verschiebt Betriebsarbeit, es entfernt sie nicht. Rechte, Logs, Kaltstarts, Timeouts, Kosten und Rollback-Fähigkeit müssen weiterhin explizit entworfen werden. Der Unterschied ist, dass ich sie mit CDK im selben Review entwerfe, in dem ich auch den Handler schreibe – statt sie auf „machen wir später“ zu vertagen. „Später“ ist in der Praxis der Name für „gar nicht“.\n\n## Rollback und der Irrtum des glücklichen Pfades\n\nBleibt die Überbewertung des glücklichen Pfades. Viele Pipelines sehen sauber aus, solange jedes Deployment gelingt. Interessant wird es bei Teilfehlern: Eine Ressource wird erstellt, die nächste scheitert, und der Stack steht auf halber Strecke.\n\nHier hilft, dass CDK auf CloudFormation aufsetzt. Ein fehlgeschlagenes Deployment rollt auf den letzten konsistenten Stand zurück, und dieses Verhalten ist Teil der Plattform, nicht meines Skripts. Das ist ein starker Grund, unter CDK zu bleiben, statt einzelne Ressourcen mit imperativen CLI-Aufrufen aneinanderzureihen: Ich bekomme eine definierte Transaktionsgrenze geschenkt. Trotzdem muss ich sie kennen. Ein Deployment, das Daten migriert, ist nicht automatisch rückbaubar, nur weil der Stack es ist – die Frage nach Idempotenz und Wiederholbarkeit bleibt meine.\n\n## Was sich dadurch messbar ändert\n\nWenn diese Teile zusammenspielen, ändert sich die Arbeit spürbar, und zwar in einer Richtung, die ich einem technischen Entscheider erklären kann. Der Zyklus von „Idee“ zu „läuft produktiv“ wird kürzer, weil kein manueller Übergabeschritt mehr zwischen Code und Infrastruktur liegt. Die Reibung im Team sinkt, weil niemand mehr Deployment-Wissen als persönliches Herrschaftswissen hält – es steht im Repository. Und die Qualität steigt, weil der `cdk diff` einen Fehler dorthin verschiebt, wo er billig ist: in den Pull Request.\n\nKonkret sieht das so aus: In einem Team ohne diese Konstruktion dauert eine Infrastrukturänderung so lange, wie die verfügbare Person mit AWS-Zugang braucht – plus die Zeit, bis jemand merkt, dass die Änderung in Staging und Produktion unterschiedlich ausgefallen ist. Mit der Pipeline ist dieselbe Änderung ein Pull Request, den zwei Kolleg:innen lesen können, ohne selbst Konsolenzugriff zu haben. Der Engpass „eine Person mit Rechten“ verschwindet, und mit ihm die stille Queue, die niemand je auf einem Board sieht. Das ist Developer Productivity im wörtlichen Sinn: nicht schneller tippen, sondern weniger Übergaben, weniger Rückfragen, weniger Zustände, in denen jemand auf jemand anderen wartet.\n\nDas ist der Bogen von der Mechanik zur Wirkung. GitHub Actions und CDK sind austauschbare Werkzeuge. Die wiederverwendbare Entscheidung dahinter – Infrastruktur als reviewbaren Teil jeder Änderung zu behandeln – ist es nicht.\n\n## Worauf ich im Review prüfe\n\nFür den Review dieser Konstruktion stelle ich drei Fragen. Zeigt der Pull Request die Betriebsfolge, also den Diff, oder nur den Anwendungscode? Ist die Deploy-Identität so eng geschnitten, dass ich einen Rechtefehler erklären kann, statt ihn wegzukonfigurieren? Und ist klar, was bei einem halb durchgelaufenen Deployment passiert?\n\nWenn eine dieser Fragen offen bleibt, fehlt der Pipeline noch etwas – ein sichtbarer Diff, eine schärfere Rolle oder ein geklärter Rollback-Pfad. Ein Construct sollte Wiederholung reduzieren und gute Defaults kapseln, aber nie verschleiern, welche Ressourcen, Rechte und Kosten entstehen. Genau an dieser Kante zwischen bequemer Abstraktion und ehrlicher Sichtbarkeit entscheidet sich, ob die Kombination trägt.\n\n## Weiterführende Quellen\n\n- AWS CDK Developer Guide: https://docs.aws.amazon.com/cdk/latest/guide/home.html\n- AWS CDK Constructs: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html\n- [GitHub Actions: OpenID Connect mit AWS](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws)\n- Repository aws-cdk-js-github-action: https://github.com/MikeBild/aws-cdk-js-github-action\n\nDie Quellen helfen beim Gegenprüfen von Plattformverhalten. Die eigentliche Aussage bleibt die Architekturentscheidung im konkreten Beispiel.\n\n## Schluss\n\nGitHub Actions und CDK zusammenzubringen, ist schnell erledigt. Der Ertrag liegt darin, dass die Grenze zwischen Anwendung und Plattform in den Pull Request wandert. Dort entsteht der Nutzen für Entwickler, Architekten und technische Entscheider. Er hängt an einer wiederverwendbaren Entscheidung: Infrastruktur so ernst zu nehmen wie den Code, der auf ihr läuft. Der Toolname ist austauschbar."},{"slug":"pouchdb-offline-first","title":"PouchDB und Offline-First","summary":"Wer offline Schreibzugriffe erlaubt, wechselt von Request/Response zur Replikation – und der Revisionsbaum von PouchDB und CouchDB macht Konflikte zur Aufgabe des Fachmodells.","tags":["Software","Architektur"],"published_at":"2019-11-12T10:00:00+00:00","updated_at":"2026-07-14T17:08:56.769898+00:00","url":"https://www.mikebild.dev/de/blog/pouchdb-offline-first/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/9c5035a7e2bec2a23cb2df548e43879133e5580807a1540324b9d41fd12efc44/pouchdb-offline-first-vorlesen.mp3","audio_duration_secs":721,"text":"# PouchDB und Offline-First\n\nOffline gilt als Sonderfall: erst die Anwendung fertig bauen, dann kurz vor dem Release einen Cache davorschalten, fehlgeschlagene Requests abfangen, ein Banner „Du bist offline\" anzeigen. Diese Annahme hält genau bis zum ersten Schreibzugriff ohne Netz. Lesen lässt sich zwischenspeichern – Schreiben nicht. Sobald ein Nutzer im Zug einen Datensatz ändern darf, lautet die Frage nicht mehr, wie man Antworten cached, sondern: Wo steht die Wahrheit, solange die Verbindung fehlt? Und was passiert, wenn zwei Geräte in der Zwischenzeit denselben Datensatz geändert haben?\n\nDamit ist Offline-First keine Feature-Checkbox, die man am Ende abhakt. Es ist eine Architekturentscheidung, die das Kommunikationsmodell der Anwendung austauscht: Replikation statt Request/Response. Die Offline-First-Bewegung sagt das seit Jahren – der Begriff stammt aus dem Umfeld von hood.ie und offlinefirst.org, lange bevor Progressive Web Apps das Thema in die Breite getragen haben. Trotzdem wird er im Alltag gern auf „Service Worker plus Cache\" verkürzt. Der Service Worker löst aber nur die halbe Aufgabe: Er liefert die Anwendung selbst ohne Netz aus – HTML, JavaScript, CSS. Die Daten sind das eigentliche Problem, und für die braucht es ein anderes Werkzeug.\n\n## Replikation statt Request/Response\n\nIm Request/Response-Modell ist der Server die einzige Quelle der Wahrheit. Der Client fragt, der Server antwortet, und ohne Verbindung gibt es schlicht keinen Zustand. Jede Offline-Funktion ist in diesem Modell ein Umweg: eine Queue für ausstehende POSTs, ein Cache für veraltete Antworten, Retry-Logik, lauter Spezialfälle, die quer zur eigentlichen Anwendung liegen.\n\nIm Replikationsmodell dreht sich das um. Die Anwendung liest und schreibt ausschließlich lokal, gegen eine Datenbank im Browser. Synchronisation ist ein eigenständiger Prozess, der im Hintergrund läuft, wenn eine Verbindung da ist – und der pausiert, wenn nicht. Die UI bekommt vom Netz zunächst gar nichts mit.\n\n```mermaid\nflowchart LR\n  subgraph rr[\"Request/Response\"]\n    UI1[\"UI\"] -->|\"HTTP-Request<br/>blockiert ohne Netz\"| API[\"API-Server\"]\n    API --> DB1[(\"zentrale<br/>Datenbank\")]\n  end\n  subgraph repl[\"Replikation\"]\n    UI2[\"UI\"] -->|\"lokal lesen<br/>und schreiben\"| LOCAL[(\"PouchDB<br/>IndexedDB\")]\n    LOCAL <-->|\"Sync, wenn<br/>Netz da ist\"| REMOTE[(\"CouchDB\")]\n  end\n```\n\nDer Unterschied zeigt sich zuerst in der gefühlten Geschwindigkeit: Lokale Lese- und Schreiboperationen liegen im Millisekundenbereich, unabhängig von Funkloch, Latenz oder Serverlast. Der eigentliche Gewinn und der eigentliche Preis stecken aber woanders. Das Replikationsmodell akzeptiert, dass es zeitweise mehrere gültige Sichten auf dieselben Daten gibt. Konflikte sind darin kein Betriebsunfall, sondern eine erwartbare Folge der Architektur – und genau deshalb gehören sie ins Fachmodell, nicht in einen Fehler-Handler.\n\n## PouchDB im Browser, CouchDB am anderen Ende\n\nPouchDB ist eine JavaScript-Datenbank, die im Browser auf IndexedDB aufsetzt und die HTTP-API von CouchDB nachbildet – inklusive des CouchDB-Replikationsprotokolls. Dieses Protokoll ist offen dokumentiert und beschreibt, wie zwei Peers ihre Änderungen austauschen: Changes-Feed lesen, fehlende Revisionen ermitteln, Dokumente übertragen, Checkpoint schreiben. Bricht die Verbindung ab, setzt der nächste Lauf am letzten Checkpoint auf, nicht am Anfang.\n\nDass sich damit bidirektionale Synchronisation in wenigen Zeilen aufsetzen lässt, wirkt beim ersten Kontakt fast verdächtig:\n\n```js\nconst localNotes = new PouchDB('notes');\nconst remoteNotes = new PouchDB('https://couch.example.com/notes');\n\nlocalNotes.sync(remoteNotes, { live: true, retry: true })\n  .on('change', info => renderChange(info))\n  .on('paused', () => setSyncStatus('idle'))\n  .on('active', () => setSyncStatus('syncing'))\n  .on('error', err => reportSyncError(err));\n```\n\n`sync` ist dabei nichts Magisches, sondern zwei einseitige Replikationen, eine in jede Richtung. `live` hält den Changes-Feed offen, `retry` überbrückt Verbindungsabbrüche mit Backoff. Die Events `paused` und `active` taugen als ehrliche Statusanzeige: `paused` heißt „alles übertragen oder gerade kein Netz\", `active` heißt „es fließen Änderungen\". Mehr Sync-Code habe ich in kleinen Anwendungen tatsächlich nie gebraucht – der Rest ist Konfliktbehandlung, dazu gleich.\n\nWichtig für die Einordnung, Stand heute mit PouchDB 7 und CouchDB 2.x: Das Replikationsprotokoll ist kein internes Detail, sondern eine Schnittstelle. Alles, was es spricht, kann teilnehmen – ein CouchDB-Cluster, ein zweiter Browser-Tab, ein Node-Prozess mit express-pouchdb davor. Die Synchronisationslogik wird damit zu Infrastruktur. Was in der Anwendung verbleibt, ist die fachliche Frage, die keine Bibliothek beantworten kann: Was bedeutet es, wenn zwei Stände auseinanderlaufen?\n\n## Der Revisionsbaum\n\nDer Kern des Modells ist die Versionierung jedes einzelnen Dokuments. Jede Änderung erzeugt eine neue Revision, und jede Schreiboperation muss die Revision nennen, die sie fortschreiben will. Passt die nicht, lehnt die Datenbank sofort ab:\n\n```js\nasync function saveNote(note) {\n  try {\n    await localNotes.put(note);\n  } catch (err) {\n    if (err.status !== 409) throw err;\n    // optimistic locking: fetch the current revision and retry once\n    const current = await localNotes.get(note._id);\n    await localNotes.put({ ...note, _rev: current._rev });\n  }\n}\n```\n\nDieser 409 ist der unmittelbare Konflikt: zwei Schreiber auf derselben Datenbank, klassisches Optimistic Locking, gut zu behandeln. Interessanter ist der zweite Fall. Zwei Geräte ändern offline dasselbe Dokument, beide Schreiboperationen sind lokal völlig gültig, und erst die Replikation führt die Stände zusammen. Dann entsteht im Dokument ein Revisionsbaum mit zwei Zweigen:\n\n```mermaid\ngraph TD\n  R1[\"Rev 1-a4f<br/>gemeinsamer Ausgangsstand\"] --> R2A[\"Rev 2-c91<br/>geändert auf Gerät A\"]\n  R1 --> R2B[\"Rev 2-b07<br/>geändert auf Gerät B\"]\n  R2A --> R3[\"Rev 3-e12<br/>Zusammenführung<br/>durch die Anwendung\"]\n  R2B -. \"Verlierer-Zweig<br/>bleibt erhalten\" .-> R3\n```\n\nCouchDB und PouchDB wählen in dieser Lage deterministisch einen Gewinner: der längere Revisionspfad gewinnt, bei Gleichstand der lexikografisch höhere Revisions-Hash. Deterministisch heißt, jeder Knoten kommt ohne Abstimmung zum selben Ergebnis – ein Cluster braucht dafür keinen Konsens-Algorithmus. Es heißt ausdrücklich nicht: fachlich richtig. Der Verlierer-Zweig wird auch nicht gelöscht, sondern bleibt als `_conflicts` am Dokument hängen. Die Datenbank hebt beide Stände auf und wartet darauf, dass jemand entscheidet. Dieses „jemand\" ist die Anwendung.\n\n## Konflikte sind der Normalfall\n\nAn dieser Stelle trennt sich die Cache-Denke endgültig vom Replikationsmodell. Wer Offline-Fähigkeit nachrüstet, behandelt Konflikte als Fehler, die möglichst nicht auftreten sollen. Wer repliziert, behandelt sie als gewöhnliches Event mit einer fachlichen Antwort. Die Datenbank kann diese Antwort nicht geben, weil sie die Bedeutung der Daten nicht kennt. Ob bei einer Notiz der längere Text gewinnt, ob sich zwei Einkaufslisten mengenweise vereinigen lassen oder ob ein Mensch entscheiden muss – das steht im Fachmodell, nirgendwo sonst.\n\nDie Auflösung selbst ist mechanisch unspektakulär: Konfliktrevisionen abholen, zusammenführen, Ergebnis schreiben, Verlierer entfernen.\n\n```js\nasync function resolveConflicts(id) {\n  const winner = await localNotes.get(id, { conflicts: true });\n  if (!winner._conflicts) return winner;\n\n  const losers = await Promise.all(\n    winner._conflicts.map(rev => localNotes.get(id, { rev }))\n  );\n\n  const merged = mergeNotes(winner, losers); // domain-specific merge\n  await localNotes.put(merged);\n  await Promise.all(\n    losers.map(loser => localNotes.remove(loser._id, loser._rev))\n  );\n  return merged;\n}\n```\n\nEntscheidend ist `mergeNotes` – und dass es diese Funktion überhaupt gibt. Mir ist in Code-Reviews aufgefallen, dass genau sie fast immer fehlt: Sync eingebaut, Demo läuft, und der Konfliktfall wird auf später verschoben, weil er in der Entwicklung schlicht nicht auftritt. Ein einzelner Entwickler mit einem Gerät und stabilem WLAN produziert keine Konflikte. Zwei Außendienstler mit Tablets im Funkloch produzieren sie am ersten Tag. Fehlt dann die Auflösung, gewinnt stillschweigend der deterministische Algorithmus, und die Änderungen des Verlierers sind aus Nutzersicht verschwunden – nicht gelöscht, aber unsichtbar. Das ist die teuerste Sorte Fehler: kein Absturz, kein Log-Eintrag, nur leiser Datenverlust aus Sicht des Menschen vor dem Bildschirm.\n\nDeshalb gehört zur Konfliktstrategie auch eine Sichtbarkeitsstrategie. Eine kleine Anzeige „drei Änderungen warten auf Sync\" kostet wenig und verändert das Vertrauen in die Anwendung spürbar. Nutzer verzeihen Wartezeiten, wenn sie sehen, woran sie sind.\n\n## Was das mit dem Datenmodell macht\n\nReplikation und Konflikte wirken auf den Dokumentenschnitt zurück. Ein Dokument ist die Einheit, in der repliziert und in der gestritten wird – also lohnt es sich, Dokumente so zu schneiden, dass Konflikte selten und, wenn sie auftreten, auflösbar sind. Ein paar Regeln haben sich bei mir bewährt:\n\n- Ein Dokument pro fachlicher Einheit, die ein Nutzer in einem Zug bearbeitet – kein Sammeldokument für den halben Anwendungszustand.\n- Sprechende IDs wie `note:2019-11-12` statt reiner Zufalls-UUIDs, wo die Fachlichkeit es hergibt; das macht Direktzugriffe planbar und erspart manchen Index.\n- Anfügen statt Überschreiben, wo es geht: Wer Events als neue Dokumente schreibt, statt ein bestehendes zu ändern, bekommt strukturell weniger Konflikte.\n- Löschungen als Teil des Modells begreifen: Gelöschte Dokumente bleiben als `_deleted`-Revisionen erhalten, damit die Löschung selbst repliziert werden kann.\n\nFür Queries jenseits des Schlüsselzugriffs gibt es Mango-Queries über pouchdb-find, inzwischen Teil des Standard-Builds:\n\n```js\nawait localNotes.createIndex({ index: { fields: ['updatedAt'] } });\n\nconst recent = await localNotes.find({\n  selector: { updatedAt: { $gte: '2019-11-01' } },\n  sort: [{ updatedAt: 'desc' }],\n  limit: 20\n});\n```\n\nDazu kommt eine Browser-Realität, die man kennen sollte: IndexedDB-Speicher ist nicht garantiert. Der Browser darf unter Druck aufräumen, und Speicherkontingente unterscheiden sich je nach Plattform deutlich. Über `navigator.storage.persist()` lässt sich beständiger Speicher anfragen, aber verlassen würde ich mich darauf nicht – die Kopie auf dem Server bleibt die Lebensversicherung, die lokale Datenbank ist der Arbeitsstand.\n\n## Zwei eigene Erfahrungen\n\n2016 habe ich mit graphql-pouch ausprobiert, wie weit sich die Idee treiben lässt: eine GraphQL-Runtime direkt auf PouchDB, Schemas in GraphQL-Shorthand-Notation, als eigenständiger Dienst mit CouchDB-Synchronisation und Master/Master-Replikation. Der Prototyp hat mir zwei Dinge gezeigt. Erstens, wie viel Backend das Replikationsprotokoll ersetzt: Sync-Endpoints, Retry-Logik, Delta-Berechnung – alles Code, den ich nie geschrieben habe, weil das Protokoll ihn mitbringt. Zweitens, dass einen die Konfliktfrage auch serverseitig einholt. Sobald zwei Instanzen gegeneinander replizieren, trägt der „Server\" dieselben Revisionsbäume wie der Browser. Die Architekturentscheidung verschwindet also nicht, wenn man sie auf die Serverseite schiebt; sie wechselt nur den Ort.\n\nNebenbei pflege ich seit einigen Jahren die Liste awesome-pouchdb, in der ich Werkzeuge, Plugins und Artikel rund um PouchDB sammle. Der Blick auf das Ökosystem ist selbst ein Datenpunkt: Auffällig viele Einträge drehen sich um genau die Stellen, an denen das Modell drückt – pouchdb-find für Queries, express-pouchdb für eigene Sync-Endpoints, pouchdb-authentication für die Anmeldung gegen CouchDB. Wo eine Community viele Werkzeuge baut, liegt selten Zufall vor, sondern ein wiederkehrendes Problem.\n\n## Wo ich es nicht einsetzen würde\n\nSo überzeugend das Modell für Notizen, Formulare und Außendienst-Szenarien ist – es hat klare Grenzen, und die sollte man vor der Entscheidung kennen, nicht danach:\n\n- Feingranulare Zugriffsrechte: CouchDB autorisiert pro Datenbank, nicht pro Dokument. Das übliche Gegenmittel ist eine Datenbank pro Nutzer, was Betrieb und serverseitige Auswertung merklich verkompliziert.\n- Große, zentral auszuwertende Datenmengen: Was der Browser hält, muss dorthin repliziert werden. Für Dashboards über Millionen Zeilen ist der Ansatz das falsche Werkzeug.\n- Streng konsistente Abläufe: Kontostände, Lagerbestände, Buchungen mit harten Invarianten brauchen zentrale Koordination. Eventual Consistency lässt sich nicht wegkonfigurieren.\n- Daten, die das Endgerät nicht verlassen dürfen es aber täten – oder umgekehrt gar nicht erst darauf liegen dürfen. Replikation verteilt Kopien, das ist ihr Zweck.\n\nGefilterte Replikation kann die Datenmenge pro Client eingrenzen, hat aber ihren eigenen Preis: Der Filter läuft über den kompletten Changes-Feed, und nachträgliche Filteränderungen sind mühsam. Wer nur einen Teilbestand synchronisieren will, sollte das früh im Dokumentenschnitt berücksichtigen, etwa über getrennte Datenbanken je Mandant oder Projekt.\n\n## Unterm Strich\n\nOffline-First entscheidet man am Anfang eines Projekts, nicht am Ende – weil Dokumentenschnitt, Konfliktverhalten und Berechtigungsmodell daran hängen. PouchDB und CouchDB nehmen einem die Mechanik vollständig ab: Replikationsprotokoll, Revisionsbäume, Checkpoints, Wiederaufnahme nach Abbruch. Was sie einem nicht abnehmen, ist die fachliche Antwort auf die eine Frage, an der alles hängt: Was soll passieren, wenn zwei Geräte dasselbe geändert haben? Wer darauf eine Antwort hat, bekommt mit erstaunlich wenig Code eine Anwendung, die im Funkloch genauso funktioniert wie im Büro. Wer keine hat, bekommt sie trotzdem – nur entscheidet dann ein Hash-Vergleich über die Daten seiner Nutzer.\n\n## Weiterführende Quellen\n\n- [awesome-pouchdb – kuratierte Liste von PouchDB-Werkzeugen und Plugins](https://github.com/MikeBild/awesome-pouchdb)\n- [graphql-pouch – GraphQL-Runtime auf PouchDB mit CouchDB-Sync](https://github.com/MikeBild/graphql-pouch)\n- [PouchDB Guide: Replication](https://pouchdb.com/guides/replication.html)\n- [PouchDB Guide: Conflicts](https://pouchdb.com/guides/conflicts.html)\n- [CouchDB Replication Protocol (offizielle Spezifikation)](https://docs.couchdb.org/en/stable/replication/protocol.html)\n- [Offline First – offlinefirst.org](http://offlinefirst.org/)"},{"slug":"commands-events-queries-trennen","title":"Commands, Events und Queries sauber trennen","summary":"Command, Event und Query sind drei Nachrichtenarten mit klarer Semantik: ein Wunsch, der abgelehnt werden kann, eine geschehene Tatsache und eine seiteneffektfreie Query. Wer sie sauber benennt und trennt, gewinnt Klarheit im Modell – ganz ohne großes CQRS-Setup.","tags":["Software","Architektur"],"published_at":"2019-10-08T10:00:00+00:00","updated_at":"2026-07-19T18:46:18.505233+00:00","url":"https://www.mikebild.dev/de/blog/commands-events-queries-trennen/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/dd741934ab970fc6cfeb6198dca95a8c3f4dc1bf2d9d4620d0a0e1a140087d21/commands-events-queries-trennen-vorlesen.mp3","audio_duration_secs":802,"text":"`updateOrder(...)` ist keine fachliche Message. Der Name sagt nicht, ob jemand etwas möchte oder ob bereits etwas passiert ist – er beschreibt nur, dass Felder anders werden. Solche Methodennamen sind der Grund, warum Modelle verwaschen: Bitte, Tatsache und Frage laufen unter derselben Bezeichnung durch dieselben Klassen, und niemand kann an der Signatur ablesen, welche davon gerade unterwegs ist.\n\nCommand, Event und Query räumen das auf. Die drei unterscheiden sich in drei Punkten, die man an jeder einzelnen Message prüfen kann: in der Zeitform, in der Frage, ob sie abgelehnt werden darf, und in der Zahl der Empfänger. Die folgenden Abschnitte gehen diese Punkte durch – erst begrifflich, dann in TypeScript, dann entlang der Fehler, die dabei regelmäßig entstehen.\n\nDie Form einer Message folgt ihrer Bedeutung. Gleich aussehende JSON-Objekte können dennoch völlig unterschiedliche Verträge tragen.\n\n::::comparison{title=\"Messages nach ihrer Aussage unterscheiden\" role=\"primary\" preferredPattern=\"comparison-matrix\"}\n:::side{label=\"Command\"}\n- Bedeutung · Bitte, eine Zustandsänderung zu versuchen\n- Zeitbezug · auf eine beabsichtigte Zukunft gerichtet\n- Ergebnis · kann abgelehnt werden oder scheitern\n:::\n:::side{label=\"Event\"}\n- Bedeutung · eine bereits eingetretene Tatsache\n- Zeitbezug · immutable Vergangenheit\n- Ergebnis · wird nicht nachträglich abgelehnt\n:::\n:::side{label=\"Query\"}\n- Bedeutung · Anfrage nach vorhandener Information\n- Zeitbezug · Sicht auf den aktuellen oder angegebenen Stand\n- Ergebnis · verändert den fachlichen Zustand nicht\n:::\n::::\n\nDiese Unterscheidung macht Namen, Fehlerpfade und Verantwortlichkeiten präziser, bevor ein Framework ins Spiel kommt.\n\n## Drei Messages, drei Bedeutungen\n\nFangen wir mit dem Kern an, denn hier entscheidet sich alles. Ein Command, ein Event und eine Query sind grundverschiedene Dinge, auch wenn sie im Code oberflächlich ähnlich aussehen – am Ende ist jedes davon ein Objekt mit ein paar Feldern. Der Unterschied steckt nicht in der Struktur, sondern in der Bedeutung.\n\nEin **Command** ist ein imperativer Wunsch. „Tu X.“ `CreateOrder`, `ShipProduct`, `CancelReservation`. Ein Command drückt eine Absicht aus, die noch nicht eingetreten ist. Und das ist entscheidend: Ein Command ist eine Bitte, die abgelehnt werden kann. Er durchläuft Validierung und Geschäftsregeln, und wenn etwas nicht stimmt, scheitert er. Genau ein Handler ist zuständig, ein Command ist gerichtet – er hat einen intendierten Empfänger.\n\nEin **Event** ist das genaue Gegenteil in der Zeitachse. Es ist eine Tatsache, die bereits eingetreten ist. `OrderCreated`, `ProductShipped`, `ItemOrdered` – benannt in der Vergangenheit. Ein Event ist immutable, denn man kann die Vergangenheit nicht mehr ändern. Und ein Event ist nicht ablehnbar: Man kann zu etwas Geschehenem nicht „nein“ sagen. Es ist das Ergebnis eines erfolgreich verarbeiteten Commands. Während ein Command genau einen Handler hat, kann ein Event keinen, einen oder viele Konsumenten haben – es ist eine Benachrichtigung an potenziell viele.\n\nEine **Query** schließlich fragt Daten ab, ohne etwas zu verändern. `GetOrderSummary`. Sie gibt ein Ergebnis zurück und lässt den beobachtbaren Zustand des Systems unangetastet – sie ist frei von Side Effects. Eine Query liest, idealerweise aus einer für das Lesen optimierten Sicht, und darf harmlos beliebig oft wiederholt werden.\n\nDiese semantische Trennung ist der ganze Punkt. Bertrand Meyer hat sie vor Jahrzehnten als Command-Query Separation formuliert: Jede Methode ist entweder ein Command, der den Zustand ändert, aber nichts zurückgibt, oder eine Query, die etwas zurückgibt, aber den Zustand nicht ändert – nie beides. Das Event kommt als dritte Art hinzu, wenn wir über Zustandsänderungen nicht nur befehlen, sondern auch berichten wollen.\n\n## Der Name transportiert die Semantik\n\nWenn ich in Reviews auf verwaschene Modelle stoße, liegt es fast immer an der Benennung. Der Name einer Message ist kein Beiwerk – er ist die Semantik, sichtbar gemacht. Ein Command im Imperativ, ein Event in der Vergangenheit. Diese Disziplin klingt banal, aber sie ist der billigste und wirksamste Hebel, den ich kenne.\n\nVergleichen wir die drei Arten direkt:\n\n- Ein Command steht im Imperativ und in der Gegenwart (`CreateOrder`): eine Absicht, gerichtet an genau einen Handler, die abgelehnt werden und scheitern kann.\n- Ein Event steht in der Vergangenheit (`OrderCreated`): eine Tatsache, immutable und nicht ablehnbar, gerichtet an null bis viele Konsumenten.\n- Eine Query beginnt meist mit `Get` oder `Find` (`GetOrderSummary`): eine Frage, seiteneffektfrei, sie liefert Daten und verändert nichts.\n\nSobald ein Team diese Namenskonvention verinnerlicht hat, liest sich der Code fast von selbst. Man sieht `CreateOrder` und weiß: Hier will jemand etwas, das schiefgehen kann. Man sieht `OrderCreated` und weiß: Das ist bereits geschehen, damit kann ich rechnen. Der Name ersetzt die halbe Dokumentation.\n\n## Der Fluss: vom Wunsch zur Tatsache zur Antwort\n\nWie hängen die drei nun zusammen? Der typische Fluss ist erstaunlich geradlinig. Ein Command trifft ein und wird von seinem Handler geprüft. Der Handler validiert und wendet Geschäftsregeln an. Wenn alles stimmt, emittiert er ein oder mehrere Events – die Tatsachen, die aus dem erfolgreichen Command folgen. Diese Events aktualisieren dann das Read Model, also die Lesesicht. Und eine Query liest schließlich aus genau diesem Read Model, ohne den Schreibpfad überhaupt zu berühren.\n\nMartin Fowler beschreibt das als zwei getrennte Wege: Der geänderte Zustand wird über das Command-Modell geroutet, die Anzeige kommt aus dem Query-Modell. Schreiben läuft über Command zu Event, Lesen geht direkt ans Read Model.\n\n```mermaid\nflowchart LR\n  C[Command: CreateOrder] --> H[Command Handler]\n  H -->|validate / business rules| E[Event: OrderCreated]\n  E --> RM[(Read Model)]\n  Q[Query: GetOrderSummary] --> RM\n```\n\nMan erkennt in diesem Bild die Asymmetrie, die den ganzen Ansatz ausmacht. Der Schreibpfad ist ein Prozess mit Prüfung: Command, Validierung, dann erst die Tatsache. Der Lesepfad ist eine direkte Query ohne Umweg. Beide teilen sich das Read Model, aber sie nähern sich ihm von entgegengesetzten Seiten – der eine schreibend über Events, der andere lesend über Queries.\n\n## In Code gegossen\n\nGenug Theorie – schauen wir uns an, wie das in TypeScript aussieht. Der folgende Code ist illustrativ, kein lauffähiges Repository; er soll die Semantik zeigen, nicht ein Framework ersetzen. Beginnen wir mit den Nachrichtentypen selbst, modelliert als discriminated unions:\n\n```ts\ntype LineItem = { sku: string; quantity: number };\n\ntype Command =\n  | { type: 'CreateOrder'; orderId: string; customerId: string; items: LineItem[] };\n\ntype DomainEvent =\n  | { type: 'OrderCreated'; orderId: string; customerId: string; occurredAt: Date };\n\ntype Query =\n  | { type: 'GetOrderSummary'; orderId: string };\n```\n\nSchon an den Typen lässt sich die Semantik ablesen. Der Command steht im Imperativ. Das Event steht in der Vergangenheit und trägt ein `occurredAt` – den Zeitpunkt, zu dem die Tatsache eingetreten ist, denn ein Event lebt von seiner zeitlichen Verortung. Die Query beginnt mit `Get` und liefert erkennbar nur eine Frage.\n\nJetzt der Command-Handler. Er ist das Herzstück des Schreibpfads: Er prüft, entscheidet und emittiert. Wichtig ist, dass ein Command abgelehnt werden kann – und dass die Rückgabe eine Liste von Events ist, denn ein Command kann durchaus mehrere Tatsachen nach sich ziehen:\n\n```ts\nclass RejectedCommand extends Error {}\n\nfunction handleCreateOrder(cmd: Extract<Command, { type: 'CreateOrder' }>): DomainEvent[] {\n  if (cmd.items.length === 0) {\n    throw new RejectedCommand('order needs at least one item');\n  }\n\n  return [\n    {\n      type: 'OrderCreated',\n      orderId: cmd.orderId,\n      customerId: cmd.customerId,\n      occurredAt: new Date(),\n    },\n  ];\n}\n```\n\nHier wird die Semantik konkret. Der Handler validiert, und wenn die Bitte nicht erfüllbar ist, lehnt er sie ab – der Command scheitert, bevor irgendeine Tatsache entsteht. Erst wenn die Prüfung durchgeht, wird das Event geboren. Kein Event ohne bestandene Validierung; das ist die Ordnung, die wir wollen.\n\nAuf der anderen Seite steht der Query-Handler. Er tut genau eine Sache: lesen. Keine Mutation, kein emittiertes Event, kein Side Effect:\n\n```ts\ninterface OrderReadModel {\n  findSummary(orderId: string): OrderSummary;\n}\n\ntype OrderSummary = { orderId: string; itemCount: number; status: string };\n\nfunction handleGetOrderSummary(\n  q: Extract<Query, { type: 'GetOrderSummary' }>,\n  readModel: OrderReadModel,\n): OrderSummary {\n  return readModel.findSummary(q.orderId); // read only, no mutation, no events\n}\n```\n\nUnd damit der Kreis sich schließt: Wie kommt das Read Model überhaupt zu seinen Daten? Über eine Projection, die auf Events reagiert. Die Projection nimmt ein `OrderCreated` entgegen und aktualisiert die Lesesicht – so fließt die Tatsache aus dem Schreibpfad in die Sicht, die später von Queries gelesen wird:\n\n```ts\nfunction applyOrderCreated(\n  event: Extract<DomainEvent, { type: 'OrderCreated' }>,\n  store: Map<string, OrderSummary>,\n): void {\n  store.set(event.orderId, {\n    orderId: event.orderId,\n    itemCount: 0,\n    status: 'created',\n  });\n}\n```\n\nWer diese vier Bausteine nebeneinanderlegt – Command-Typ, Command-Handler, Projection, Query-Handler –, sieht den gesamten Fluss auf einen Blick. Der Command ist der Wunsch, der Handler prüft ihn, das Event ist die Tatsache, die Projection trägt sie ins Read Model, und die Query liest von dort. Jede Message hat ihren Platz und ihre Bedeutung.\n\n## Die Fallstricke, die ich immer wieder sehe\n\nSo klar die Theorie ist, so hartnäckig sind die Fehler in der Praxis. Vier davon begegnen mir immer wieder.\n\nDer häufigste ist die **verdrehte Benennung**. Ein `OrderCreated`, das eigentlich ein Command ist, oder ein `CreateOrder`, das als Event durch die Gegend fliegt. Sobald das passiert, verliert das Team die Unterscheidung zwischen „Absicht, die scheitern kann“ und „Fakt, der bereits eingetreten ist“. Der Name lügt, und mit ihm das Modell. Wenn ich in einem Review ein Event im Imperativ finde, ist das für mich das erste Alarmsignal.\n\nDer zweite Fallstrick ist die **Query mit Side Effect**. Eine vermeintliche Query, die nebenbei schreibt, einen Zähler hochsetzt oder ein Event feuert. Das bricht die Freiheit von Side Effects, die Meyer für Queries fordert. Eine Query muss harmlos wiederholbar sein – wenn ich sie zweimal aufrufe, darf sich am System nichts ändern. Sobald eine Query Zustand mutiert, ist sie keine Query mehr, sondern ein verkleideter Command.\n\nDer dritte Fehler ist begrifflicher Natur: **CQRS mit Event Sourcing verwechseln**. Die Trennung von Command, Event und Query braucht kein Event Sourcing. Sie braucht auch nicht zwingend zwei Datenbanken oder einen asynchronen Message Bus. Man kann die drei Nachrichtenarten in einem ganz gewöhnlichen Service mit einer einzigen Datenbank sauber trennen. Greg Young bringt das Verhältnis auf einen Merksatz: Man kann CQRS ohne Event Sourcing nutzen; nutzt man Event Sourcing, braucht man praktisch CQRS. Nicht umkehren.\n\nDer vierte Fallstrick ist der **anämische Command als Setter**. Ein `UpdateOrder(status = 'shipped')` verrät nichts über die fachliche Absicht. Warum wird der Status geändert? Weil das Produkt versandt wurde. Dann heißt der Command `ShipProduct`, nicht `UpdateOrder`. Ein Command soll eine fachliche Absicht ausdrücken, keine generische Datenmanipulation. Der Unterschied zwischen `UpdateOrder` und `ShipProduct` ist der Unterschied zwischen einem Formular und einer Geschichte.\n\n## Ein kurzer Blick über den Tellerrand: CQS, CQRS und die Kosten\n\nDamit die Begriffe nicht durcheinandergeraten, lohnt eine saubere Abgrenzung. **CQS** – Command-Query Separation nach Meyer – ist ein Prinzip auf Methodenebene: Jede Methode ist entweder Command oder Query. **CQRS** – von Greg Young geprägt und von Fowler popularisiert – hebt dasselbe Prinzip auf die Objekt- beziehungsweise Serviceebene: ein getrenntes Modell zum Schreiben und ein getrenntes Modell zum Lesen. Fowler formuliert es so, dass man ein anderes Modell zum Aktualisieren von Informationen nutzen kann als das Modell zum Lesen.\n\nUnd dazu gehört ein Satz, der gern untergeht: CQRS ist kein Default. Fowler warnt ausdrücklich, dass CQRS riskante Komplexität hinzufügt und nur bei klarem Nutzen gerechtfertigt ist. Getrennte Modelle, eventuell getrennte Speicher, asynchrone Konsistenz – das will man nicht überall. Wer CQRS reflexartig überall einführt, handelt sich mehr Probleme ein, als er löst. Ich habe an anderer Stelle ausführlicher beschrieben, warum [CQRS eine fachliche Grenze ist, kein Framework](https://www.mikebild.dev/de/blog/cqrs-fachliche-grenze-kein-framework/) – die Entscheidung für getrennte Modelle gehört an die fachliche Konsistenzgrenze, nicht in jeden Service.\n\nDie gute Nachricht ist: Die reine semantische Trennung von Command, Event und Query kostet fast nichts und lohnt sich fast immer. Sie ist nicht CQRS. Sie ist die begriffliche Hygiene, auf der CQRS später aufsetzen kann, falls man es überhaupt braucht. Man kann Commands imperativ benennen, Events in der Vergangenheit, Queries seiteneffektfrei halten – und dabei in einem völlig konventionellen Service bleiben. Diese Klarheit im Kleinen ist der eigentliche Gewinn.\n\n## Roter Faden\n\nDie Frage vom Anfang – möchte jemand etwas, oder ist etwas passiert? – lässt sich an jeder Message beantworten, sobald sie einen ehrlichen Namen trägt. Ein Command ist der Wunsch, der geprüft wird und scheitern kann. Ein Event ist die Tatsache, die daraus folgt, immutable und nicht verhandelbar. Eine Query ist die Frage, die liest, ohne zu berühren. Drei Messages, drei Bedeutungen, drei Namen – und der Name trägt die Bedeutung nach außen.\n\nDiese Trennung ist kein großes Architekturprojekt. Sie beginnt mit Disziplin bei der Benennung und mit der Weigerung, eine Query heimlich schreiben zu lassen. Man muss weder Event Sourcing einführen noch zwei Datenbanken aufsetzen, um den Nutzen einzufahren. Wer im nächsten Modell konsequent zwischen `CreateOrder`, `OrderCreated` und `GetOrderSummary` unterscheidet, hat schon den größten Teil der Arbeit getan. Alles Weitere – CQRS, Projections, getrennte Speicher – ist eine Frage, die man später und nur bei echtem Bedarf stellt. Die semantische Trennung selbst kostet nichts außer Aufmerksamkeit, und genau die zahlt sich in jedem Modell aus.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html\n- Martin Fowler, [CQRS](https://martinfowler.com/bliki/CQRS.html) – die kompakte Definition samt Warnung vor der Komplexität.\n- Martin Fowler, [CommandQuerySeparation](https://martinfowler.com/bliki/CommandQuerySeparation.html) – Meyers ursprüngliches Prinzip auf Methodenebene.\n- Martin Fowler, [What do you mean by „Event-Driven“?](https://martinfowler.com/articles/201701-event-driven.html) – zur Einordnung von Events als Messages und Tatsachen.\n- Greg Young, [CQRS](https://gregfyoung.wordpress.com/2012/03/02/cqrs/) – der Ursprung der Objekt-/Serviceebene und das Verhältnis zu Event Sourcing.\n- Vaughn Vernon, *Implementing Domain-Driven Design* (2013), Kapitel zu Domain Events – zur Benennung und Bedeutung von Events in der Domäne.\n- Mein eigenes Vortragsmaterial „CQRS + Event Sourcing“ (SlideShare) – die Folien, auf denen ich diese Trennung ausführlicher durchgehe."},{"slug":"infrastructure-as-code-code-reviews","title":"Infrastructure as Code braucht Code-Reviews","summary":"Sobald Infrastruktur Code ist, entscheidet ein kleiner Diff über Sicherheit, Kosten und Ausfallrisiko – deshalb gehört sie in denselben Review-Prozess wie Anwendungscode.","tags":["Software","AWS","Cloud"],"published_at":"2019-09-24T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/infrastructure-as-code-code-reviews/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/34344369eee3450fdca8c99df6b92ee0deb82c64d7b4b91ac411f54459dc14d5/infrastructure-as-code-code-reviews-vorlesen.mp3","audio_duration_secs":661,"text":"# Infrastructure as Code braucht Code-Reviews\n\nWer eine Wildcard-Berechtigung durchwinkt, hat sie erteilt. Der Merge ist der Moment der Entscheidung, nicht das Audit ein Jahr später, in dem dieselbe Zeile rot markiert wird. Und dieser Moment sieht harmlos aus: ein paar geänderte Zeilen, ein grüner Build, ein Daumen hoch.\n\nAuslöser ist das AWS CDK, das diesen Sommer stabil wurde. Damit beschreibe ich Cloud-Infrastruktur nicht mehr in tausend Zeilen YAML, sondern in TypeScript – mit Typen, Funktionen und Abstraktionen. Das ist spürbar produktiver, und genau da liegt der Haken.\n\nDenn eine Änderung an der Infrastruktur sieht jetzt aus wie jede andere Pull Request. Was in diesem unauffälligen Diff steckt, ist aber kein Feature-Flag. Es ist eine IAM-Policy, eine Security-Group oder die Speicherzuweisung einer Funktion, die pro Aufruf abgerechnet wird. Die Reibung beim Ändern ist gesunken – das Risiko pro Änderung nicht. Diese Schere ist das eigentliche Thema.\n\n## Was sich mit Infrastructure as Code wirklich ändert\n\nVorher steckte Infrastruktur in Klick-Sessions in der Konsole und in den Köpfen von zwei, drei Leuten. Niemand konnte eine Änderung reviewen, weil es keinen Diff gab. Wer eine Regel in einer Security-Group aufmachte, hinterließ keine nachvollziehbare Spur. Ausfälle wurden zu archäologischen Ausgrabungen: Wer hat wann was verstellt, und warum?\n\nInfrastructure as Code dreht das um. Jede Änderung wird zu einem Commit, jeder Commit ist versioniert, jede Version ist überprüfbar. Der eigentliche Gewinn liegt dabei nicht in der schöneren Syntax. Er liegt darin, dass Infrastruktur endlich denselben Kontrollmechanismen unterliegt wie der Rest des Systems. Rollback wird ein `git revert`, Nachvollziehbarkeit wird ein `git blame`, Wissen wird ein lesbares Repository statt eines Stammtisch-Gesprächs.\n\nNur: Die Review-Disziplin ist mit dieser Verschiebung nicht automatisch mitgewachsen. Wir reviewen Anwendungscode seit Jahren mit einem klaren Blick für Nebenwirkungen, Grenzfälle und Sicherheit. Bei Infrastruktur-Code lesen viele Teams denselben Diff mit halber Aufmerksamkeit, weil er kurz aussieht und der Build grün ist. Ein grüner Build heißt bei IaC aber nur, dass die Synthese durchlief – nicht, dass die entstehende Infrastruktur sicher, bezahlbar und zurückbaubar ist.\n\n## Der Diff, den ich wirklich reviewen muss\n\nCDK arbeitet mit Constructs: Bausteinen, die eine oder mehrere CloudFormation-Ressourcen samt sinnvoller Defaults kapseln. Das ist die Stärke und zugleich die Falle. Ein High-Level-Construct kann aus drei Zeilen TypeScript ein Dutzend Ressourcen erzeugen – eine Funktion, eine Log-Gruppe, eine Execution-Rolle, ein paar Berechtigungen. Der TypeScript-Diff zeigt mir die Absicht. Was tatsächlich provisioniert wird, zeigt er nicht.\n\nDas eigentliche Review-Artefakt ist deshalb der `cdk diff`, nicht der Code allein: die Gegenüberstellung von aktuellem und geplantem CloudFormation-Zustand. Erst da wird sichtbar, dass eine unscheinbare Zeile eine Rolle mit einer Wildcard-Berechtigung anlegt oder eine Security-Group öffnet.\n\n```ts\nconst handler = new lambda.Function(this, \"ApiHandler\", {\n  runtime: lambda.Runtime.NODEJS_10_X,\n  handler: \"index.handler\",\n  code: lambda.Code.fromAsset(\"dist/api\")\n});\n```\n\nDiese vier Zeilen sind bewusst klein. Sie erzeugen aber mehr als eine Funktion: eine Log-Gruppe, eine Execution-Rolle mit Basisrechten und die Verknüpfungen dazwischen. Die Review-Frage lautet nicht „läuft der Handler?“, sondern „welche Rechte bekommt diese Rolle, wie lange bleiben die Logs liegen, und was kostet ein Aufruf?“. Ein gutes Construct reduziert Wiederholung und kapselt gute Defaults – es darf aber nicht verstecken, welche Ressourcen, Rechte und Kosten entstehen. Genau diese Grenze prüfe ich im Review.\n\n## Die teuerste Zeile ist oft eine Berechtigung\n\nDer klassische Fehler ist die bequeme Berechtigung. Es funktioniert sofort, wenn ich einer Funktion breiten Zugriff gebe, und niemand merkt im Demo, dass die Grenze zu weit gezogen ist.\n\n```ts\n// convenient, and wrong\nbucket.grantReadWrite(handler);\ntable.grant(handler, \"dynamodb:*\");\n```\n\nDas läuft. Es ist auch die Art von Zeile, die ein Jahr später in einem Security-Audit rot markiert wird. Least Privilege ist keine Fleißaufgabe für später. Die Entscheidung darüber fällt im Review, im Moment des Merges – danach ist die Rolle in Betrieb, und aus einem „mach ich noch enger“ wird selten etwas. Im Review frage ich deshalb konkret: Braucht diese Funktion wirklich Schreibrechte, oder nur Lesen? Braucht sie den ganzen Table oder eine Aktion? Jede Wildcard ist eine Behauptung, die belegt werden muss.\n\nDas Muster wiederholt sich bei öffentlichen Buckets, offenen Security-Groups und geteilten Secrets. Keiner dieser Fehler ist technisch schwierig. Alle sind billig zu verhindern, wenn jemand den Diff mit der richtigen Frage liest – und teuer, wenn niemand es tut.\n\n## Kosten gehören in den Review\n\nBei serverloser Infrastruktur ist die Rechnung ein direktes Ergebnis von Codezeilen. Die Speichergröße einer Funktion, provisionierte Concurrency, ein NAT-Gateway, ein zu groß gewählter Instanztyp, eine Retention-Einstellung, die Logs für immer aufhebt – all das sind einzeilige Änderungen mit einer laufenden monatlichen Konsequenz.\n\nIch habe erlebt, wie eine einzige geänderte Zeile die Cloud-Rechnung eines Teams verdoppelt hat, weil ein Default unbedacht übernommen wurde. Der Diff war unauffällig, der Build grün, der Review eine Formsache. Kostentransparenz im Review heißt nicht, jede Rechnung vorherzusagen. Es heißt, die Frage überhaupt zu stellen: Was ändert diese Zeile an den laufenden Kosten – einmalig, pro Aufruf, oder pro Monat? Serverless verschiebt Betriebsarbeit, es entfernt sie nicht. Rechte, Logs, Kaltstarts, Timeouts, Kosten und Rollback-Fähigkeit müssen weiterhin explizit entworfen werden.\n\n## Wonach ich im Infrastruktur-Review sehe\n\nÜber die einzelnen Beispiele hinweg prüfe ich immer dieselben Grenzen. Ist die Berechtigung so eng wie möglich, oder öffnet der Diff mehr, als die Aufgabe braucht? Wie groß ist der Blast Radius, wenn diese Ressource falsch konfiguriert ist – trifft es einen Service oder das ganze Konto? Ist die Änderung idempotent und rückbaubar, oder erzeugt ein zweites Deployment einen anderen Zustand als das erste? Was passiert bei einem fehlerhaften Deployment – gibt es einen sauberen Weg zurück, oder muss jemand nachts von Hand reparieren? Und produziert der Betrieb genug Signale – Logs, Metrics, Alarme –, um einen Fehler später überhaupt zu erklären?\n\nDiese Fragen wirken trocken. Sie sind aber der Unterschied zwischen einem System, das im Demo sauber aussieht, und einem, das unter Wiederholung, Teilausfällen, alten Daten und falschen Berechtigungen trägt. Der glückliche Pfad ist nie das Problem. Interessant wird Infrastruktur genau dort, wo etwas schiefgeht.\n\n## Der Trade-off der Abstraktion\n\nCDK bietet Constructs auf mehreren Ebenen: dünne Hüllen um einzelne CloudFormation-Ressourcen, komfortable Bausteine mit guten Defaults und ganze Muster aus mehreren Ressourcen. Je höher die Ebene, desto weniger Code schreibe ich – und desto mehr Entscheidungen delegiere ich an Defaults, die ich nicht sehe.\n\nDas ist kein Argument gegen Abstraktion. Es ist ein Argument dafür, die Abstraktionsebene bewusst zu wählen und im Review sichtbar zu machen. Ein High-Level-Construct ist ein guter Tausch, wenn seine Defaults zu meinen Anforderungen an Sicherheit und Kosten passen. Er ist ein schlechter Tausch, wenn ich seine Defaults nie geprüft habe und sie in Produktion überrascht kennenlerne. Die methodische Konsequenz ist einfach: Der `cdk diff` gehört in jede Pull Request, damit die Ebene, auf der ich delegiere, nicht implizit bleibt.\n\n## Wo der Review in die Pipeline gehört\n\n```mermaid\nflowchart LR\n  Source --> Synth\n  Synth --> Diff[\"cdk diff\"]\n  Diff --> Review[\"Review<br/>Rechte · Kosten · Rollback\"]\n  Review --> Deploy\n  Deploy --> Observe\n```\n\nDer Diff ist keine Formalie am Schluss. Er ist der Ort, an dem der Review greift: vor dem Deployment, als sichtbares Artefakt in der Pull Request. Das verschiebt den Fehler aus dem Betrieb in die Review-Phase – und das ist der ganze Sinn. Ein Konfigurationsfehler, der im Diff auffällt, kostet zehn Minuten Diskussion. Derselbe Fehler in Produktion kostet einen Incident, eine Nachtschicht und Vertrauen.\n\n## Was die Maschine prüft, was der Mensch prüft\n\nEin guter Review-Prozess verschwendet keine menschliche Aufmerksamkeit an Dinge, die eine Regel erledigen kann. Vieles am Infrastruktur-Review ist mechanisch und lässt sich automatisieren: offene Security-Groups, unverschlüsselte Buckets, Wildcard-Policies, fehlende Log-Retention. Statische Analyse auf dem synthetisierten CloudFormation-Template – etwa mit cfn_nag – fängt diese Klasse von Fehlern in der Pipeline ab, bevor überhaupt ein Mensch draufschaut.\n\nWeil die Infrastruktur jetzt Code ist, kann ich außerdem Annahmen als Test festschreiben. CDK erlaubt Assertions gegen das synthetisierte Template: „diese Rolle darf keine Wildcard-Action enthalten“, „dieser Bucket ist nicht öffentlich“. Ein solcher Test schlägt fehl, sobald jemand die Grenze aufweicht – nicht erst im Audit ein Jahr später. Das ist derselbe Reflex, den ich von Anwendungscode kenne: Was einmal richtig war und richtig bleiben muss, wird ein Test, kein Merksatz.\n\nDie Automatisierung nimmt dem menschlichen Review nicht die Arbeit ab, sie fokussiert sie. Wenn die Maschine die immer gleichen Regeln durchsetzt, kann der Mensch sich auf das konzentrieren, was keine Regel entscheiden kann: Ist diese Abstraktionsebene die richtige? Ist der Blast Radius vertretbar? Passt die Kostenkonsequenz zur Aufgabe? Genau diese Fragen sind der Grund, warum es den Review überhaupt gibt.\n\n## Der Fehler, das Werkzeug für die Lösung zu halten\n\nEin Werkzeug erzeugt keine gute Architektur, es kann sie nur ausdrücken. CDK ist kein Sicherheitskonzept. CloudFormation ist kein Berechtigungsmodell. Serverless ist keine Betriebsstrategie. Diese Werkzeuge machen es leichter, gute Grenzen zu ziehen – sie ziehen sie nicht von selbst. Wer glaubt, mit dem Umstieg auf Infrastructure as Code sei die Sicherheits- und Kostenfrage erledigt, hat nur das Medium gewechselt, in dem dieselben Entscheidungen jetzt schneller und unauffälliger getroffen werden.\n\nGenau deshalb braucht Infrastructure as Code den Review mehr, nicht weniger. Die niedrige Reibung, die den ganzen Ansatz so attraktiv macht, ist gleichzeitig ihr größtes Risiko. Ändern ist billig geworden – Verstehen muss es bleiben.\n\n## Was das dem Team bringt\n\nDer Nutzen ist messbar, auch wenn er sich als Abwesenheit zeigt. Fehler, die früher als Incident auftraten, tauchen jetzt als Kommentar in einer Pull Request auf. Die Cloud-Rechnung bleibt vorhersehbar, weil Kostenänderungen benannt statt entdeckt werden. Onboarding wird kürzer, weil neue Leute die Infrastruktur lesen statt raten. Und die Durchlaufzeit sinkt sogar: Es wird nicht weniger geprüft, es muss weniger repariert werden.\n\nEin Review kostet Minuten. Ein offener Bucket, eine Wildcard-Rolle oder ein verdoppeltes NAT-Gateway kostet Tage und im schlimmsten Fall Kundendaten. Infrastructure as Code hat die Infrastruktur endlich in ein Format gebracht, in dem sich dieser Handel lohnt. Es wäre schade, den Review als einzigen Schritt auszulassen, der aus einem Diff eine Entscheidung macht.\n\n## Weiterführende Quellen\n\n- AWS CDK Developer Guide: https://docs.aws.amazon.com/cdk/latest/guide/home.html\n- AWS CDK Constructs: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html\n\nDie Quellen helfen beim Gegenprüfen von Plattformverhalten und Defaults. Die eigentliche Entscheidung steht aber nicht in der Doku. Sie steht im konkreten Diff – und hängt daran, dass jemand ihn liest, bevor er live geht."},{"slug":"secrets-im-build","title":"Secrets im Build sind Produktentscheidungen","summary":"Secrets im Build sind ein Vertrauensproblem: Wer darf was wann sehen, speichern und deployen?","tags":["Software","DevOps","Architektur"],"published_at":"2019-09-12T10:00:00+00:00","updated_at":"2026-07-13T08:38:07.947838+00:00","url":"https://www.mikebild.dev/de/blog/secrets-im-build/","audio_url":"https://media.mikebild.dev/site/mikebild/audio/4aca0cc499b8c90f1d0138de2f9e562ee4c383bcdf2da5a8dc0e841367bfc9f8.mp3","audio_duration_secs":647,"text":"# Secrets im Build sind Produktentscheidungen\n\n```console\n$ docker history --no-trunc myapp:staging\nIMAGE      CREATED BY\n<missing>  /bin/sh -c #(nop) COPY file:3f2a… in /app/.env\n<missing>  /bin/sh -c #(nop) ENV DATABASE_URL=postgres://app:S3cr3t@db.prod.internal:5432/app\n```\n\nZwei Zeilen Layer-Metadaten, lesbar für jeden mit Pull-Rechten auf die Registry. In einem Projekt lag die halbe Konfiguration in einer `.env`, die per `COPY` ins Image wanderte. Das lief monatelang gut, bis jemand das Image aus der Registry zog, `docker history` aufrief und die Datenbank-URL samt Passwort im Klartext las. Kein Angriff, kein exotischer Bug – nur ein Artefakt, das mehr wusste, als es durfte.\n\nDer Fehler war nicht die vergessene Verschlüsselung. Der Fehler war eine unausgesprochene Grenze: Das Team hatte nie entschieden, ob ein Build-Artefakt Secrets kennen darf. Es hatte nur ein Tutorial kopiert. Und genau da hört die Sache auf, eine Betriebsfrage zu sein. Wo ein Token, ein Passwort oder ein Zertifikat entsteht, gespeichert und in ein Artefakt injiziert wird, legt fest, wer ohne Rücksprache liefern darf und wer warten muss. Das entscheidet über Tempo, Reibung und Fehlerrate eines Teams – lange bevor irgendein Tool ausgewählt ist.\n\n## Die eigentliche Frage ist Vertrauen, nicht Verschlüsselung\n\nVerschlüsselung löst ein enges Problem: Daten at rest und in transit schützen. Sie beantwortet nicht die Frage, die im Alltag teuer wird – wer im Prozess das Secret überhaupt in die Hand bekommt. Ein KMS-verschlüsselter Wert, den jeder CI-Job entschlüsseln darf, ist bequem und trotzdem eine offene Grenze.\n\nBevor ich ein Werkzeug wähle, kläre ich vier Fragen. Welche Komponente ist führend für den Wert? Wer darf ihn lesen, und in welcher Umgebung? Wann wird er gelesen – zur Build-Zeit oder zur Laufzeit? Und was muss später nachvollziehbar sein, wenn er kompromittiert ist? Diese Antworten entscheiden über Rotation und Schadensradius. Verschlüsselung allein beantwortet keine davon.\n\n## Build-Zeit und Laufzeit sind zwei verschiedene Vertrauenszonen\n\nDie wichtigste Grenze verläuft zwischen Build und Runtime. Ein Build erzeugt ein Artefakt, das durch mehrere Umgebungen wandert und in einer Registry landet, auf die oft mehr Menschen Zugriff haben, als man denkt. Alles, was in dieses Artefakt eingebacken wird, ist damit so vertraulich wie die Registry – und nicht vertraulicher.\n\nDas klassische Anti-Pattern sieht harmlos aus:\n\n```dockerfile\n# ANTI-PATTERN: the secret becomes part of a shared, promotable artifact\nCOPY .env /app/.env\n```\n\n```dockerfile\n# ANTI-PATTERN: build args stay recoverable via `docker history`\nARG NPM_TOKEN\nRUN echo \"//registry.npmjs.org/:_authToken=${NPM_TOKEN}\" > .npmrc \\\n  && npm ci \\\n  && rm .npmrc\n```\n\nDas `rm` täuscht Sicherheit vor. Der Token bleibt in den Layer-Metadaten sichtbar, und die `.env` reist als eigener Layer mit. Beides überlebt jedes spätere `docker run` in jeder Umgebung. Das eigentliche Problem ist nicht der Befehl, sondern die vermischte Vertrauenszone: Ein Artefakt, das in Staging und Production identisch sein soll, trägt plötzlich Production-Secrets.\n\nDie architektonische Antwort ist eine saubere Trennung. Der Build produziert ein umgebungsneutrales Artefakt ohne Secrets. Die Konfiguration kommt erst zur Laufzeit dazu, injiziert aus einer Quelle, die pro Umgebung getrennt ist. Ein Multi-Stage-Build macht diese Absicht im Code sichtbar:\n\n```dockerfile\n# build stage produces an artifact that knows nothing about any environment\nFROM node:12 AS build\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\n# runtime stage receives config from the environment, never from the image\nFROM node:12-slim\nWORKDIR /app\nCOPY --from=build /app/dist ./dist\nCMD [\"node\", \"dist/server.js\"]\n```\n\nDieselbe Denkweise gilt für Build-Secrets, die der Build wirklich braucht – etwa ein privater Registry-Token. Die kommen über einen Secret-Mount, der im finalen Image nicht persistiert, statt über einen `ARG`, der in der History bleibt. Der Grundsatz bleibt: Ein Secret, das nur der Build braucht, darf das Runtime-Artefakt nicht erreichen.\n\n## Parameter Store als Interface, nicht als Datenbank\n\nSobald die Trennung steht, wird Parameter Store interessant. Seinen Wert bezieht er aus der Struktur, die er erzwingt, und nicht daraus, dass er Werte aufbewahrt. Der hierarchische Pfad ist der eigentliche Hebel: Er kodiert die Umgebung, und die Umgebung ist die Grenze, an der Berechtigungen ansetzen.\n\n```bash\n# resolve runtime config just-in-time, scoped by environment path\naws ssm get-parameters-by-path \\\n  --path \"/myapp/production/\" \\\n  --with-decryption \\\n  --query \"Parameters[].{Name:Name,Value:Value}\"\n```\n\nDer Pfad `/myapp/production/` ist mehr als Kosmetik. Er erlaubt es, Least-Privilege sauber auszudrücken: Ein Deploy-Job für Staging bekommt eine IAM-Policy, die genau auf `staging/*` zeigt und Production nicht einmal lesen kann.\n\n```json\n{\n  \"Effect\": \"Allow\",\n  \"Action\": \"ssm:GetParametersByPath\",\n  \"Resource\": \"arn:aws:ssm:eu-central-1:123456789012:parameter/myapp/staging/*\"\n}\n```\n\nDamit ist die fachliche Absicht in der Infrastruktur verankert statt in einem Wiki-Satz. Der Wert wird zur Laufzeit aufgelöst und nie in Repo oder Image geschrieben:\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# inject secrets at container start, keep them out of the image and the repo\nexport DATABASE_URL=\"$(aws ssm get-parameter \\\n  --name \"/myapp/production/database-url\" \\\n  --with-decryption --query \"Parameter.Value\" --output text)\"\nexec node dist/server.js\n```\n\nOb dahinter Parameter Store, Secrets Manager oder ein Vault steht, ist die kleinere Frage. Die größere ist, dass der Zugriff pro Umgebung getrennt, auditierbar und rotierbar ist. Das Werkzeug drückt die Grenze aus – es erzeugt sie nicht.\n\n## Skizze\n\n```mermaid\nflowchart LR\n  Repo[Code<br/>ohne Secrets] --> Build[Build<br/>reproduzierbares Artefakt]\n  Build --> Registry[Artefakt<br/>je Umgebung gleich]\n  Store[Parameter Store<br/>je Umgebung getrennt] --> Runtime\n  Registry --> Runtime[Runtime<br/>injiziert Config just-in-time]\n```\n\nDie Skizze zeigt die eine Aussage, auf die es ankommt: Der linke Pfad ist überall identisch, der Secret-Pfad kommt getrennt und erst ganz rechts hinzu. Genau an dieser Kreuzung entscheidet sich, ob ein Team schnell und sicher liefern kann oder beides gegeneinander eintauscht.\n\n## Die Trade-offs, die man wirklich abwägt\n\nKein Ansatz ist umsonst. Ein umgebungsneutrales Artefakt plus Laufzeit-Injektion braucht eine funktionierende Secret-Quelle in jeder Umgebung und macht den Container-Start abhängig von einem weiteren Dienst. Fällt Parameter Store aus, startet der Service nicht – das ist ein bewusster Preis für die Grenze, kein Versehen. Man federt ihn mit Caching und klaren Fehlermeldungen ab, nicht damit, dass man Secrets doch wieder einbäckt.\n\nUmgekehrt ist die Bequemlichkeit der `.env`-im-Image real: ein Artefakt, keine Abhängigkeit zur Startzeit, lokal leicht zu reproduzieren. Der Preis dafür fällt nur später an – bei Rotation, bei einem Leak, bei der Frage, wer das Staging-Image mit Production-Zugängen in der Hand hatte. Architektur heißt hier, den Preis an die Stelle zu verschieben, an der man ihn kontrollieren kann, statt ihn in einen späteren Incident zu verlagern.\n\nDer zweite Trade-off betrifft Zentralisierung. Eine zentrale Secret-Quelle vereinfacht Rotation und Audit, wird aber selbst zum kritischen Pfad und zur begehrten Grenze. Deshalb ist die Pfad- und IAM-Struktur keine Formalie: Sie ist der Unterschied zwischen „ein Dienst kennt alles“ und „jeder Dienst kennt genau seins“. Zwischen beidem liegt die eigentliche Produktentscheidung.\n\n## Der Fehlerpfad entscheidet, nicht der Happy Path\n\nEine Secret-Architektur ist erst belastbar, wenn der Fehlerpfad beschrieben ist. Im Demo-Modus sieht jeder Ansatz sauber aus. Interessant wird es genau dort, wo Systeme normalerweise brechen.\n\nRotation ist der ehrlichste Test. Kann ich einen kompromittierten Wert ändern, ohne ein Image neu zu bauen und durch alle Umgebungen zu promoten? Bei Laufzeit-Injektion ist das ein Parameter-Update plus Neustart. Beim eingebackenen Secret ist es ein voller Release-Zyklus unter Zeitdruck – der schlechteste Moment für einen langen Weg.\n\nRollback ist der zweite Test. Ein zurückgerolltes Image bringt bei sauberer Trennung nur alten Code mit, nicht alte Secrets. Vermischt man beides, rollt man mit dem Code auch stillschweigend abgelaufene oder rotierte Zugangsdaten zurück. Und drittens der Audit: Wenn ein Wert leakt, will ich rekonstruieren, welcher Job ihn wann gelesen hat. Ein zentraler Store mit Zugriffs-Logs beantwortet das; eine Datei in einem Layer beantwortet es nicht. Diese Fragen wirken trocken, sind aber der Unterschied zwischen Demo und Betrieb.\n\n## Der Business-Outcome ist Tempo bei kontrolliertem Risiko\n\nDer Grund, warum ich Secrets als Produktentscheidung behandle, ist messbar. Wenn ein Team Secrets nicht mehr manuell durchreichen muss, verschwindet eine ganze Klasse von Wartezeit. Ein Entwickler deployt nach Staging, ohne dass jemand aus dem Ops-Team ein Passwort per Chat kopiert – die IAM-Policy erlaubt genau diesen Pfad und nichts darüber hinaus. Das verkürzt den Zyklus von Idee zu laufender Änderung und senkt gleichzeitig das Risiko, weil der Zugriff eng geschnitten ist. Tempo und Sicherheit stehen hier nicht im Widerspruch; die richtige Grenze liefert beides.\n\nWeniger Reibung entsteht auch dadurch, dass die Grenze im Code steht und nicht im Kopf einer Person. Neue Teammitglieder lesen aus Pfadstruktur und Policy ab, was erlaubt ist, statt es zu erfragen. Und die Qualität steigt, weil der teuerste Fehler – ein Secret im geteilten Artefakt – strukturell nicht mehr passieren kann, statt nur durch Disziplin verhindert zu werden. Genau das meine ich mit Developer Productivity: nicht schneller tippen, sondern seltener warten und seltener aufräumen.\n\nDer Effekt ist kein weiches Versprechen, sondern lässt sich beobachten. Ich messe ihn an drei Zahlen: an der Zeit von „Passwort muss getauscht werden“ bis „getauscht und im Betrieb“, an der Zahl der manuellen Übergaben pro Release und daran, wie viele Umgebungen ein einzelner Zugang lesen kann. Jede dieser Zahlen sinkt, wenn die Build-Runtime-Grenze steht – und jede ist im Zweifel überzeugender als die Behauptung, es sei jetzt sicherer.\n\n## Typische Fehler\n\nDer erste Fehler ist, das Werkzeug mit der Lösung zu verwechseln. Ein Secret-Store ist keine Architektur, KMS ist keine Zugriffsgrenze, und ein `--build-arg` ist keine Vertraulichkeit. Diese Werkzeuge können gute Grenzen ausdrücken, aber sie erzeugen sie nicht automatisch. Wer den Store einführt, ohne die Build-Runtime-Grenze zu ziehen, hat die Datei nur an einen anderen Ort verschoben.\n\nDer zweite Fehler ist, den Happy Path zu überschätzen. Viele Setups sehen im ersten Deploy sauber aus und zeigen ihre Schwäche erst bei der ersten dringenden Rotation. Der dritte ist, alles gleich vertraulich zu behandeln. Eine Feature-Flag-Konfiguration und ein Datenbank-Passwort brauchen unterschiedliche Grenzen; wer beides in denselben Topf wirft, macht den harmlosen Fall teuer und den kritischen Fall bequem.\n\n## Worauf ich prüfe\n\nDrei Checks reichen mir meistens. Erstens: Ist ein Build-Artefakt umgebungsneutral, oder kennt es Secrets? Ein `docker history` und ein Blick in die Layer beantworten das in einer Minute. Zweitens: Ist der Zugriff pro Umgebung getrennt, sodass ein Staging-Job Production nicht lesen kann? Drittens: Kann ich ein Secret rotieren, ohne neu zu bauen, und den Zugriff im Nachhinein nachvollziehen?\n\nWenn eine dieser Fragen mit Nein endet, fehlt noch ein Teil der Architektur – kein weiteres Tool, sondern eine bewusst gezogene Grenze. Ich würde daraus keine große Initiative machen, sondern mit einem kleinen, überprüfbaren Ergebnis beginnen: ein Multi-Stage-Dockerfile, eine IAM-Policy mit Pfad-Scope, ein Startskript, das den Wert just-in-time auflöst. Konkret genug, dass ein anderer Entwickler sinnvoll widersprechen kann. Zustimmung ist gut, aber Widerspruch zeigt oft die versteckte Annahme.\n\n## Schluss\n\nEin Secret sicher abzulegen ist die kleinere Hälfte der Arbeit. Die größere ist die Begründung, warum die Grenze zwischen Build und Runtime genau dort verläuft. Entscheidend sind das konkrete Risiko, die erlaubten Zugriffe und die Frage, ob sich ein kompromittierter Wert unabhängig vom Artefakt rotieren lässt. Der Name des Secret-Stores kommt danach."},{"slug":"product-discovery-vor-der-ersten-zeile","title":"Product Discovery vor der ersten Zeile Code","summary":"Discovery senkt das Risiko, effizient das Falsche zu bauen. Ich zeige eine kontinuierliche Spur, die Annahmen prüft, bevor sie teure Delivery bindet – nicht eine Phase vor der Entwicklung.","tags":["Software","Produkt"],"published_at":"2019-06-26T10:00:00+00:00","updated_at":"2026-07-14T20:59:37.10998+00:00","url":"https://www.mikebild.dev/de/blog/product-discovery-vor-der-ersten-zeile/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/4d60ac3c1a16425e7e0ae58c5bca777dc004e8352ef94796a728c77b3c003965/product-discovery-vor-der-ersten-zeile-vorlesen.mp3","audio_duration_secs":669,"text":"Die teuersten Zeilen Code, die ich in Projekten gesehen habe, waren technisch tadellos. Sauber getestet, elegant strukturiert, in einer Continuous-Delivery-Pipeline mehrmals am Tag ausgeliefert. Nur: Sie lösten ein Problem, das niemand hatte. Ein halbes Jahr Entwicklungskapazität, ein makelloses Feature, und beim ersten echten Nutzerkontakt kippte die Grundannahme. Sauberes Handwerk sagt eben nichts darüber aus, ob am Ende jemand etwas davon hat. Diese Frage stellt keine Pipeline.\n\nGenau hier setzt Product Discovery an. Und ich meine damit nicht eine vorgeschaltete Analysephase, in der sechs Wochen lang Dokumente entstehen, bevor irgendjemand Code anfassen darf. Ich meine eine parallele, kontinuierliche Spur. Eine Annahme wird mit dem billigsten sinnvollen Versuch konfrontiert, bevor das Team sie als Feature ausbaut. Darum geht es in diesem Artikel.\n\n## Discovery und Delivery sind zwei Spuren, keine Phasen\n\nDer häufigste Denkfehler ist, Discovery als Vorstufe zu begreifen. Erst denken, dann bauen. Das ist Wasserfall im neuen Gewand. Der Ansatz, den Marty Cagan und Jeff Patton unter dem Begriff Dual-Track Agile popularisiert haben, meint etwas anderes: zwei Spuren, die dauerhaft nebeneinanderlaufen. Die Discovery-Spur klärt, *was* gebaut werden sollte. Die Delivery-Spur baut es solide. Beide laufen in jedem Sprint, nicht nacheinander.\n\nDer Trick liegt in der Größe der Übergabe. Bevor ein Team ein großes oder schwer umkehrbares Vorhaben in Delivery bindet, sollte die riskanteste Annahme dahinter geprüft sein. Kleine technische Spikes und dünne, reversible Schnitte dürfen früher in Delivery laufen und selbst Evidenz erzeugen. Dual Track ist keine harte Schleuse zwischen Denken und Bauen.\n\n```mermaid\nflowchart LR\n  subgraph Discovery\n    A[Idea] --> B[Assumption]\n    B --> C[Experiment]\n    C --> D[Validated item]\n  end\n  subgraph Delivery\n    E[Build] --> F[Test]\n    F --> G[Release]\n  end\n  D -->|only validated work<br/>enters delivery| E\n```\n\nDer Pfeil in der Mitte ist die eigentliche Botschaft. Er ist ein Filter. Alles, was ihn passiert, hat eine Prüfung bestanden. Alles, was in der Discovery-Spur scheitert, hat nie ein Entwicklerteam belastet. Das ist der Hebel: Ein verworfenes Experiment kostet Stunden, ein verworfenes Feature kostet Monate.\n\nEbenso wichtig ist, wer Discovery betreibt. Es ist keine Aufgabe, die eine vorgelagerte Analyseabteilung erledigt und dann als fertige Spezifikation weiterreicht. Teams, die das gut machen, setzen Produktverantwortung, Design und Engineering gemeinsam an die Discovery-Spur – das Trio, das Marty Cagan beschreibt. Der Entwickler, der die Machbarkeit einschätzt, hört im selben Nutzergespräch, warum ein Feature überhaupt gewünscht ist. Die Designerin, die einen Prototyp baut, versteht die technischen Grenzen aus erster Hand. Diese geteilte Verantwortung ist der Grund, warum Discovery schnell bleibt: Entscheidungen fallen dort, wo das Wissen sitzt, ohne den Umweg über Übergabedokumente. Wer Discovery an eine einzelne Rolle delegiert, holt sich die Wasserfall-Dynamik zurück, die der Dual-Track-Ansatz gerade vermeiden will.\n\n## Die vier großen Risiken, die Discovery adressiert\n\nAuf die Frage, *warum* ein bestimmtes Feature gebaut werden soll, kommt in Diskussionen meist eine Antwort, die nur eine einzige Dimension abdeckt. Cagan hat in der zweiten Auflage von *INSPIRED* vier Risikoklassen benannt, die jede ernsthafte Discovery abklopfen muss:\n\nValue-Risiko fragt, ob Nutzer das überhaupt wollen und ob sie dafür wechseln oder zahlen. Usability-Risiko fragt, ob sie es bedienen können. Feasibility-Risiko fragt, ob wir es mit unserer Zeit, unseren Daten und unserer Technik bauen können. Und Viability-Risiko fragt, ob es zum Geschäft passt – rechtlich, wirtschaftlich, strategisch.\n\nFeasibility zieht die Aufmerksamkeit an, weil es die Frage ist, die Entwickler am liebsten beantworten. \"Können wir das bauen?\" ist fast immer mit \"ja\" zu beantworten. Die teuren Fehlschläge sitzen aber im Value- und im Viability-Risiko. Discovery zwingt dazu, alle vier zu betrachten, bevor die erste Story geschnitten wird.\n\n## Annahmen sichtbar und priorisierbar machen\n\nDer praktischste Schritt ist banal und wirkungsvoll zugleich: Annahmen aufschreiben. Jede Produktidee ist ein Stapel unausgesprochener Wetten. \"Nutzer wollen ihre Reports selbst konfigurieren.\" \"Die Bezahl-API hält unsere Latenz-Anforderung.\" \"Der Vertrieb kann das ohne Schulung verkaufen.\" Solange diese Sätze im Kopf bleiben, kann man sie nicht testen und nicht priorisieren.\n\nDavid J. Bland hat mit dem Assumptions Mapping ein einfaches Werkzeug etabliert: eine Matrix aus zwei Achsen. Die eine Achse misst, wie wichtig eine Annahme ist – fällt das ganze Vorhaben, wenn sie falsch ist? Die andere misst, wie viel Evidenz wir schon haben. Der Quadrant oben rechts, wichtig und kaum belegt, wird zuerst getestet. Das ist die riskanteste Annahme, und sie verdient den ersten Test.\n\n```mermaid\nquadrantChart\n  title Assumptions Map\n  x-axis Low evidence --> High evidence\n  y-axis Low importance --> High importance\n  quadrant-1 Test first\n  quadrant-2 Keep as leap of faith\n  quadrant-3 Ignore for now\n  quadrant-4 Already known\n  Users self-configure reports: [0.2, 0.9]\n  Payment API meets latency SLA: [0.35, 0.85]\n  Sales can sell without training: [0.6, 0.4]\n  Login flow is usable: [0.8, 0.3]\n```\n\nDiesen Zustand lege ich versioniert im Repository ab, statt ihn an ein Whiteboard zu heften. Ein Assumption-Register neben dem Code macht die Wetten Teil des Projekts und nicht Teil eines vergessenen Workshops:\n\n```yaml\n# assumptions.yaml — reviewed before any story enters delivery\nassumptions:\n  - id: A-1\n    statement: \"Users will self-configure their reports instead of asking support\"\n    type: desirability\n    importance: high\n    evidence: low\n    test: landing-page\n    kill_criterion: \"signup-to-config rate below 20% after 200 visitors\"\n  - id: A-2\n    statement: \"The payment API stays under our latency budget at peak load\"\n    type: feasibility\n    importance: high\n    evidence: low\n    test: spike\n    kill_criterion: \"p95 response time above 200ms in a load test\"\n  - id: A-3\n    statement: \"Sales can close the feature without extra training\"\n    type: viability\n    importance: medium\n    evidence: low\n    test: interview\n    kill_criterion: \"3 of 5 reps ask for a dedicated enablement session\"\n```\n\nFür einen bestätigenden Test ist `kill_criterion` das entscheidende Feld. Wer erst nach dem Test entscheidet, was ein gutes Ergebnis gewesen wäre, findet leicht eine Erklärung, warum die Lieblingsidee doch gewonnen hat. Explorative Discovery darf offener beginnen; sobald eine konkrete Hypothese entschieden werden soll, gehört das Kriterium vorher festgeschrieben.\n\n## Fortschritt heißt validiertes Lernen, nicht ausgelieferte Features\n\nEric Ries hat in *The Lean Startup* den Begriff geprägt, an dem ich mich bis heute ausrichte: validated learning. Fortschritt misst sich an Annahmen, die wir belastbar geklärt haben, und nicht an Features im Release. Der Build-Measure-Learn-Kreis dreht sich um genau diese Frage: Was ist der schnellste Weg, um eine Wette zu prüfen?\n\nHier lauert das größte Missverständnis rund um das MVP. Ein Minimum Viable Product ist kein billiges, halbfertiges Produkt. Es ist das Experiment mit dem größten Lernertrag pro Aufwand. Sehr oft enthält es gar keinen Code. Eine Landingpage, die ein noch nicht existierendes Feature bewirbt und Anmeldungen zählt. Ein Concierge-Ansatz, bei dem ein Mensch manuell tut, was später Software tun soll. Ein Demo-Video, das die Reaktion misst. Für die Annahme A-1 oben ist die Landingpage schneller und ehrlicher als jede Implementierung. Sie beantwortet die Value-Frage in Tagen statt in Monaten.\n\nWenn das Ergebnis eindeutig gegen die Annahme spricht, ist die richtige Reaktion nicht, trotzdem zu bauen. Es ist der Pivot – die Richtung ändern und eine andere Wette angehen. Das fällt Teams schwer, weil verworfene Ideen sich wie Scheitern anfühlen. Sie sind das Gegenteil. Jede früh gekippte Annahme ist eingespartes Kapital.\n\n## Auch technische Annahmen gehören getestet\n\nDiscovery ist kein reines Produkt- oder UX-Thema. Das Feasibility-Risiko ist oft die stillste und teuerste Wette. \"Die Bezahl-API hält unsere Latenz\" ist eine technische Annahme, die man greifbar machen kann, bevor ein Feature darauf aufbaut. Ich formuliere solche Annahmen als ausführbaren Test – eine Fitness Function im Sinne von *Building Evolutionary Architectures*. Sie macht ein Qualitätsziel messbar und wiederholbar:\n\n```typescript\n// fitness function — runs the riskiest technical assumption as a test\nimport { measureP95 } from \"./load-harness\";\n\nconst SLA_MS = 200;\n\ntest(\"checkout endpoint stays under the latency budget at peak load\", async () => {\n  const p95 = await measureP95({\n    endpoint: \"/api/checkout\",\n    concurrentUsers: 500,\n    durationSeconds: 60,\n  });\n\n  expect(p95).toBeLessThan(SLA_MS);\n});\n```\n\nDer Wert dieses Tests zeigt sich in dem Moment, in dem er rot wird, bevor ein Feature live geht. Dann wissen wir: Die technische Annahme trägt nicht. Besser jetzt als in der Produktion. Dieselbe Denkweise gilt für Ausfallverhalten. Wenn ein Feature von einem externen Dienst abhängt, ist die Annahme \"der Dienst ist verfügbar\" eine Wette, die man mit Stabilitätsmustern wie einem Circuit Breaker aus Michael Nygards *Release It!* entschärft – und mit Consumer-Driven Contract Tests, die prüfen, ob zwei Dienste sich noch verstehen, bevor der Bruch in Produktion auffällt.\n\n## Warum Effizienz allein nicht schützt\n\nDer naheliegende Einwand lautet: \"Wir liefern doch schon schnell und stabil aus.\" Und ja, das ist wertvoll. Das Buch *Accelerate* von Nicole Forsgren, Jez Humble und Gene Kim hat mit vier Kennzahlen gezeigt, wie sich Auslieferungsqualität messen lässt: Deployment Frequency, Lead Time for Changes, Change Failure Rate und Mean Time to Restore. Teams, die hier stark sind, liefern häufig, schnell und mit wenig Fehlern.\n\nNur misst keine dieser Kennzahlen, ob das Richtige gebaut wird. Sie messen, wie gut die Delivery-Spur läuft. Ein Team mit Bestwerten kann mit hoher Frequenz Features ausliefern, die niemand braucht. Tempo beim Bauen des Falschen ist immer noch Verschwendung – nur schnellere. Deshalb gehören beide Spuren zusammen: Discovery sorgt dafür, dass wir am Richtigen arbeiten, Delivery-Exzellenz sorgt dafür, dass wir es gut bauen. Wer nur die zweite Hälfte optimiert, baut sich am Ende die effizienteste Art, Geld zu verbrennen.\n\nGenau das ist der ökonomische Kern. Discovery ist keine zusätzliche Prozessschicht, die Zyklen verlangsamt. Sie verkürzt den Zyklus, der wirklich zählt: von der Idee bis zur validierten Erkenntnis. Ein Landingpage-Test dauert Tage. Ein fehlgeleitetes Feature dauert Monate und fällt trotzdem. Discovery tauscht die teure, späte Rückmeldung des Marktes gegen eine billige, frühe Rückmeldung im Experiment.\n\n## Wie das im Alltag konkret aussieht\n\nIch fasse die Methodik in wenige Gewohnheiten, die ohne schweres Framework auskommen. Vor jedem größeren Vorhaben werden die Annahmen aufgeschrieben und auf der Zwei-Achsen-Matrix eingeordnet. Die riskanteste Annahme bekommt einen passenden Versuch; ein bestätigender Test erhält ein vorab definiertes Entscheidungskriterium. Technische Wetten werden als Fitness Function oder Spike geprüft, nicht als Bauchgefühl abgenickt. Solange die größte Unsicherheit offen ist, bleibt die Delivery-Investition klein und umkehrbar.\n\nDas klingt nach mehr Arbeit vorne. In der Praxis kann es große Umwege verkürzen, garantiert sie aber nicht. Die Teams, mit denen ich so arbeite, streiten weniger über Meinungen und mehr über Evidenz. Eine Landingpage mit zweihundert Besuchern kann ein Signal liefern, wenn Zielgruppe, Traffic-Quelle und erwarteter Effekt dazu passen; sie ersetzt weder sauberes Stichprobendesign noch qualitative Rückfragen. Was dabei verschwindet, ist ein Teil der unproduktiven Reibung zwischen Meinungen ohne Daten. Die produktive Reibung am Problem bleibt.\n\n## Fazit\n\nDie erste Zeile Produktionscode erhöht die Investition, ist aber kein unumkehrbarer Grenzübertritt. Product Discovery hält die riskantesten Entscheidungen so lange klein, bis es genug Evidenz für den nächsten Einsatz gibt. Discovery und Delivery laufen dabei parallel: Die eine Spur klärt, was zu bauen ist, die andere liefert kleine, belastbare Schritte und kann selbst Teil des Experiments sein.\n\nDer rote Faden ist einfach: Man kann alles technisch richtig machen und trotzdem das Falsche liefern. Effizienz in der Auslieferung ist notwendig, aber sie ist kein Ersatz für die Frage, ob wir am Richtigen arbeiten. Wer Annahmen sichtbar macht, die riskanteste zuerst prüft und für bestätigende Tests ein Entscheidungskriterium festlegt, verbessert die Chance auf kürzere Lernzyklen und begründete Entscheidungen. Discovery bleibt Arbeit; ihr Wert liegt darin, teure Gewissheit nicht mit unbelegter Zuversicht zu verwechseln.\n\n## Weiterführende Quellen\n\n- Marty Cagan: *INSPIRED* (2. Auflage, 2017) und die \"four big risks\" auf svpg.com/four-big-risks\n- Jeff Patton und Marty Cagan zu Dual-Track Agile, jpattonassociates.com und svpg.com\n- Eric Ries: *The Lean Startup* (2011), Build-Measure-Learn und MVP, theleanstartup.com\n- David J. Bland: Assumptions Mapping, precoil.com und strategyzer.com\n- Nicole Forsgren, Jez Humble, Gene Kim: *Accelerate* (2018)\n- Neal Ford, Rebecca Parsons, Patrick Kua: *Building Evolutionary Architectures* (2017)\n- Michael Nygard: *Release It!* zu Circuit Breaker und Stability Patterns"},{"slug":"cqrs-fachliche-grenze-kein-framework","title":"CQRS ist eine fachliche Grenze, kein Framework","summary":"CQRS trennt das Modell zum Schreiben vom Modell zum Lesen – eine fachliche Entscheidung, kein installierbares Framework. Wann sich die Grenze lohnt, wie man klein anfängt und welche Verwechslungen teuer werden.","tags":["Software","Architektur"],"published_at":"2019-06-25T10:00:00+00:00","updated_at":"2026-07-13T08:37:17.581144+00:00","url":"https://www.mikebild.dev/de/blog/cqrs-fachliche-grenze-kein-framework/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/ae168475354eb8f05b2bddb88a78a0dbcba066b36d081702aea7bb81285337c4/cqrs-fachliche-grenze-kein-framework-vorlesen.mp3","audio_duration_secs":723,"text":"Zwei Fragen stellt jede Anwendung an ihre Daten: Was soll sich ändern? Und was soll angezeigt werden? Die meisten Systeme beantworten beide mit demselben Modell – denselben Entities, demselben Mapping, denselben Objekten. CQRS beantwortet sie mit zwei.\n\nDie häufigste Anschlussfrage an das Akronym – Command Query Responsibility Segregation – dreht sich trotzdem um Werkzeuge: Welche Library, welches Paket? Danach sucht man vergeblich. Die Grenze zwischen Schreib- und Lesemodell verläuft durch die Domäne, nicht durch den Dependency-Baum. Was CQRS ist, was es ausdrücklich nicht ist, wann sich die Trennung lohnt und wo die teuersten Fehler liegen – darum geht es hier. Das Ergebnis ist unspektakulärer, als das Akronym verspricht, und genau das ist die gute Nachricht.\n\n## Woher der Begriff kommt\n\nCQRS steht für Command Query Responsibility Segregation. Der Kern ist eine einzige Idee: Man verwendet ein anderes Modell zum Ändern von Informationen als das Modell zum Lesen. Was in klassischen Systemen ein einziges konzeptionelles Modell ist – dieselben Entities, mit denen man schreibt und liest –, wird bewusst in zwei getrennte Modelle gespalten. Eines für Commands, also für Absichten und Änderungen. Eines für Queries, also für Sichten und Queries.\n\nMartin Fowler bringt die Begründung auf den Punkt: Für viele Probleme, besonders in komplexeren Domänen, führt ein gemeinsames konzeptionelles Modell für Commands und Queries zu einem Modell, das beide Aufgaben schlechter erfüllt, als zwei spezialisierte Modelle es täten. Fowler selbst hörte den Begriff zuerst bei Greg Young, der CQRS um 2010 prägte und ausformulierte.\n\nWichtig ist die Abgrenzung zu einem älteren Prinzip, mit dem CQRS gern verwechselt wird: Command Query Separation, kurz CQS, von Bertrand Meyer. CQS trennt auf Methodenebene – eine Methode ist entweder ein Command, der Zustand verändert und nichts zurückgibt, oder eine Query, die einen Wert liefert, ohne Zustand zu ändern. CQRS hebt genau dieses Prinzip auf die Architekturebene: nicht mehr zwei Arten von Methoden, sondern zwei getrennte Objektmodelle. CQRS baut konzeptionell auf CQS auf, arbeitet aber viel gröber.\n\n## Die zwei Seiten haben eine unterschiedliche Form\n\nSchreib- und Leseseite haben nicht nur unterschiedliche Aufgaben, sie haben eine unterschiedliche Form. Das ist keine kosmetische Beobachtung, sondern der eigentliche Grund für die Trennung.\n\nDie Schreibseite arbeitet mit einem reichen Domänenmodell. Hier liegen die Invarianten, die fachlichen Regeln, die entscheiden, ob eine Änderung überhaupt erlaubt ist. Ein Command ist der Ausdruck einer fachlichen Absicht – imperativ formuliert, benennbar, ablehnbar. `DeactivateInventoryItem` ist kein Datensatz-Update, sondern eine Aussage: „Ich möchte diesen Artikel deaktivieren.“ Das passt hervorragend zu aufgabenorientierten Oberflächen, in denen der Nutzer Absichten äußert statt Formularfelder zu befüllen.\n\nDie Leseseite dagegen will nichts entscheiden. Sie will anzeigen. Ihre natürliche Form sind flache, auf die Darstellung zugeschnittene DTOs – oft eine direkte Projection dessen, was am Bildschirm erscheint. Regeln, Invarianten, Verhalten: All das braucht die Leseseite nicht. In ein reiches Domänenmodell zwingt man sie nur, weil es zufällig schon da ist.\n\nGenau hier setzt CQRS an. Statt beide Seiten in ein Modell zu pressen, das keine von beiden gut bedient, gibt man jeder Seite die Form, die sie verdient.\n\n```mermaid\nflowchart LR\n    Client -->|Command| CommandHandler\n    CommandHandler --> WriteModel\n    WriteModel --> Store[(Store)]\n    Client -->|Query| QueryService\n    QueryService --> ReadModel\n    ReadModel --> Store\n```\n\nMan beachte: Beide Pfade können denselben Store nutzen. Dazu gleich mehr – es ist wichtiger, als es aussieht.\n\n## Ein Blick auf die Schreibseite\n\nIllustrativ, in TypeScript. Die Schreibseite nimmt einen Command entgegen, lädt das reiche Write Model, lässt es die fachliche Regel durchsetzen und speichert.\n\n```ts\ninterface DeactivateInventoryItem {\n  itemId: string;\n  reason: string;\n}\n\nclass InventoryCommandHandler {\n  constructor(private readonly repo: InventoryItemRepository) {}\n\n  handle(cmd: DeactivateInventoryItem): void {\n    const item = this.repo.findById(cmd.itemId); // rich write model\n    item.deactivate(cmd.reason);                 // domain rule enforced here\n    this.repo.save(item);\n  }\n}\n```\n\nDas Interessante steht in der mittleren Zeile. `item.deactivate(cmd.reason)` ist der Ort, an dem die Fachlichkeit sitzt. Ob ein Artikel deaktiviert werden darf, ob ein Grund verpflichtend ist, ob ein bereits deaktivierter Artikel den Command ablehnt: Das entscheidet das Domänenobjekt, nicht der Handler. Der Command-Handler ist bewusst dünn. Er orchestriert, er entscheidet nicht.\n\n## Und ein Blick auf die Leseseite\n\nDie Leseseite ignoriert dieses reiche Modell vollständig. Sie fragt eine auf Anzeige optimierte Sicht ab und gibt ein flaches DTO zurück.\n\n```ts\ninterface InventoryListItemDto {\n  id: string;\n  name: string;\n  isActive: boolean;\n}\n\nclass InventoryQueryService {\n  constructor(private readonly readDb: ReadDatabase) {}\n\n  listActiveItems(): InventoryListItemDto[] {\n    return this.readDb.query(\n      \"SELECT id, name, is_active FROM inventory_view WHERE is_active = true\",\n    );\n  }\n}\n```\n\nKein Repository, kein Domänenobjekt, keine Invariante. Nur eine Query gegen eine Sicht, die genau das liefert, was die Liste braucht. Man könnte einwenden, dass hier rohes SQL direkt in einem Service steht – für die Leseseite ist das oft genau richtig. Es gibt nichts zu schützen, keine Regel zu wahren. Der Umweg über ein reiches Modell wäre reine Zeremonie.\n\nDer Punkt, auf den alles hinausläuft: Diese beiden Klassen kennen einander nicht. Sie teilen kein gemeinsames Modell. Sie sind zwei getrennte Antworten auf zwei getrennte Fragen. Das ist CQRS.\n\n## Klein anfangen – ein Store genügt\n\nUnd jetzt kommt der Teil, der die meisten überrascht. Nichts an den beiden Beispielen oben verlangt zwei Datenbanken. `InventoryItemRepository` auf der Schreibseite und `readDb` auf der Leseseite können auf dieselbe Datenbank zeigen. Fowler formuliert es unmissverständlich: Die Modelle können sich denselben Datenspeicher teilen – die Datenbank dient dann als Kommunikationskanal zwischen beiden Modellen.\n\nDas bedeutet: Man kann CQRS praktizieren, ohne einen einzigen zusätzlichen Store einzuführen. Ein reiches Schreibmodell, das gegen die Tabellen arbeitet. Eine Sicht oder ein View, den die Leseseite direkt abfragt. Beides in derselben Datenbank. Man hat die fachliche Trennung, ohne sich einen zweiten Datenspeicher und dessen Konsequenzen einzukaufen.\n\nIn einem Projekt hatten wir das Muster jahrelang aufgeschoben, weil es als unerschwinglich galt – zu viel Infrastruktur, zu viel Betrieb. Die Sorge beruhte auf einer Verwechslung: CQRS wurde mit einer bestimmten, aufwendigen Ausbaustufe von CQRS gleichgesetzt. Die Trennung der Modelle ist billig. Was Geld kostet, sind getrennte Stores, und die sind optional.\n\n## Was CQRS ist – und was nicht\n\nWeil sich rund um den Begriff so viele Halbwahrheiten sammeln, hier die klare Sortierung. CQRS ist:\n\n- die fachliche Trennung von Schreib- und Lesemodell,\n- ein Muster für ausgewählte Bounded Contexts,\n- kombinierbar mit Event Sourcing, wenn die Domäne es rechtfertigt,\n- von Anfang an mit einem einzigen Store praktizierbar.\n\nCQRS ist nicht:\n\n- ein Framework oder eine Library,\n- ein Synonym für Event Sourcing,\n- eine Pflicht zu zwei Datenbanken,\n- ein systemweiter Architekturstandard, den man überall ausrollt.\n\nAn dieser zweiten Liste hängt der meiste Ärger, den ich in Reviews sehe.\n\n## Die drei teuersten Verwechslungen\n\nDie erste ist der Versuch, CQRS überall einzusetzen. Es ist ein Minderheitsfall, kein Default. Fowler warnt ausdrücklich, dass CQRS für die meisten Systeme riskante Komplexität hinzufügt und man beim Einsatz sehr vorsichtig sein sollte. Die Trennung gehört in einen einzelnen Bounded Context, wo die Domäne sie rechtfertigt – nicht in jedes Modul, nicht in jede Entity. Wer CQRS systemweit ausrollt, zahlt überall die Kosten der Trennung und erntet den Nutzen nur an den wenigen Stellen, an denen die Modelle wirklich auseinanderdriften.\n\nDie zweite ist, CQRS mit Event Sourcing zu verwechseln. Das ist die häufigste Verwechslung überhaupt, und ich verstehe, woher sie kommt – die beiden treten in Vorträgen und Beispielen fast immer gemeinsam auf. Aber sie sind unabhängige Muster. Event Sourcing ist eine Entscheidung über Persistenz: Statt den aktuellen Zustand zu speichern, speichert man die Folge der Events, die zu ihm führten. CQRS ist eine Entscheidung über Modelle: Schreiben und Lesen bekommen getrennte Modelle. Man kann jedes ohne das andere haben. Vaughn Vernon zeigt in seinen IDDD-Beispielen zwar Kontexte, die Event Sourcing und CQRS kombinieren – aber auch dort bleibt es eine bewusste Kombination zweier Muster, keine Definition, in der eines das andere enthält. Wer CQRS sagt und Event Sourcing meint, verhandelt in Diskussionen ständig aneinander vorbei.\n\nDie dritte ist, zwei Datenbanken zu früh einzuführen. Getrennte Read- und Write-Stores sind eine optionale, spätere Optimierung – meist getrieben von asymmetrischer Last, wenn man Lese- und Schreibseite unabhängig skalieren will. Führt man sie zu früh ein, holt man sich zwei Kosten ins Haus, bevor irgendein Bedarf erwiesen ist: den Synchronisationsaufwand zwischen den Stores und, schwerwiegender, eventual consistency. Ab dem Moment, in dem der Read Store dem Write Store hinterherläuft, sieht der Nutzer möglicherweise Daten, die schon veraltet sind. Das ist ein fachliches Problem, kein technisches Detail – und man sollte es sich nur einhandeln, wenn man dafür einen konkreten Grund hat.\n\nSo sähe diese Ausbaustufe aus – die man bewusst erst dann geht, wenn die Last es verlangt:\n\n```mermaid\nflowchart LR\n    CommandHandler --> WriteModel\n    WriteModel --> WriteStore[(Write Store)]\n    WriteStore -.->|projection<br/>eventual consistency| ReadStore[(Read Store)]\n    QueryService --> ReadStore\n```\n\nDer gestrichelte Pfeil ist der Preis. Zwischen Write Store und Read Store liegt jetzt eine Projection, und mit ihr eine Verzögerung. Solange dieser Pfeil nicht nötig ist, sollte er nicht existieren.\n\n## Wann sich die Grenze wirklich lohnt\n\nNach all den Warnungen die konstruktive Seite: Es gibt Situationen, in denen CQRS die richtige Antwort ist. Ich sehe im Wesentlichen drei.\n\nDie erste sind komplexe Domänen, in denen ein gemeinsames Modell keine Seite gut bedient. Wenn das Schreibmodell vor lauter Invarianten und Verhalten kaum noch abfragbar ist und die Leseseite sich mühsam durch dieses Modell quält, um simple Listen zu erzeugen, dann kämpfen beide gegen dieselbe Struktur. Die Trennung befreit beide.\n\nDie zweite ist asymmetrische Last. Viele Systeme lesen um Größenordnungen häufiger, als sie schreiben. Getrennte Modelle – und, in der Ausbaustufe, getrennte Stores – erlauben es, die Leseseite unabhängig zu skalieren, ohne die Schreibseite anzufassen.\n\nDie dritte sind aufgabenorientierte Oberflächen. Wenn die UI ohnehin in Absichten denkt – „Artikel deaktivieren“, „Bestellung stornieren“ – statt in CRUD-Formularen, dann liegt die Command-Seite von CQRS ohnehin schon in der Luft. Man muss sie nur noch benennen.\n\nAlle drei Kriterien haben denselben Ursprung: Die Struktur folgt aus der Fachlichkeit, nicht aus dem Wunsch nach einem modernen Diagramm. CQRS ist kein Ziel. Es ist eine Antwort – und nur dort richtig, wo die Domäne die passende Frage stellt.\n\n## Fazit\n\nWer CQRS als Framework begreift, sucht an der falschen Stelle. Es gibt nichts zu installieren. Die einzige Entscheidung, die zählt, ist eine fachliche: Profitiert diese Domäne davon, dass Schreiben und Lesen unterschiedliche Modelle bekommen? Wenn ja, zieht man die Grenze – klein, mit einem einzigen Store, ohne Event Sourcing, in einem einzelnen Bounded Context. Wenn nein, lässt man es, und das ist der häufigere Fall.\n\nDie getrennten Datenbanken, die Projections, die eventual consistency – all das sind spätere, optionale Ausbaustufen, die man geht, wenn die Last es verlangt, und nicht früher. CQRS mit diesen Ausbaustufen gleichzusetzen ist der Fehler, der das Muster teuer und angsteinflößend macht. In seiner Essenz ist es das Gegenteil: eine unspektakuläre, billige, fachlich motivierte Trennung. Zwei Fragen, zwei Modelle. Mehr braucht es nicht, um anzufangen.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html\n- Martin Fowler, „CQRS“: [martinfowler.com/bliki/CQRS.html](https://martinfowler.com/bliki/CQRS.html)\n- Martin Fowler, „CommandQuerySeparation“: [martinfowler.com/bliki/CommandQuerySeparation.html](https://martinfowler.com/bliki/CommandQuerySeparation.html)\n- Greg Young, „CQRS Documents“ (PDF): [cqrs.wordpress.com](https://cqrs.wordpress.com/documents/)\n- Vaughn Vernon, „Implementing Domain-Driven Design“, Kapitel 4 (Architecture / CQRS), sowie die [IDDD_Samples](https://github.com/VaughnVernon/IDDD_Samples)\n- Mein eigenes Vortragsmaterial „CQRS + Event Sourcing“ (SlideShare), aus dem ein Teil der hier gezeigten Beispiele stammt"},{"slug":"nodejs-training-event-loop-architektur","title":"Node.js-Training: Vom Event Loop zur Architekturentscheidung","summary":"Node.js lässt sich nicht am Framework erklären, sondern am Event Loop, an Streams und an der Frage, welche Architekturentscheidung ein Team morgen noch versteht – genau das übe ich mit Teilnehmenden im Repo introduction-nodejs.","tags":["Software","Node.js","Backend"],"published_at":"2019-05-12T10:00:00+00:00","updated_at":"2026-07-13T08:37:03.3766+00:00","url":"https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/29735c31777bf26bfd87b2f5ba9736e54b77af7bb892d1e7d1ec8c4a818d2442/nodejs-training-event-loop-architektur-vorlesen.mp3","audio_duration_secs":1215,"text":"# Node.js-Training: Vom Event Loop zur Architekturentscheidung\n\nExpress beantwortet genau eine Frage: welche Funktion bei welcher URL läuft. Die Fragen, an denen ein Node-Backend im Betrieb tatsächlich scheitert, beantwortet es nicht – was passiert, wenn ein einzelner Aufruf den einzigen Thread blockiert, wenn eine Antwort größer wird als der freie Speicher, wenn eine Promise abgelehnt wird und niemand zuhört. Wer mit einem Framework anfängt, lernt eine API auswendig. Wer mit dem Event Loop anfängt, versteht, warum diese API so aussieht, wie sie aussieht, und was genau passiert, wenn man sie falsch benutzt.\n\nDie Frage \"Warum bauen wir das nicht einfach mit Express?\" kommt in Node-Trainings – ob direkt oder über Partner wie [Grossweber](https://grossweber.com/) – trotzdem sehr früh. Sie ist berechtigt und trotzdem verfrüht. Der Anfang liegt deshalb woanders: bei einer einzigen Zeile, die auf den ersten Blick nichts mit Architektur zu tun hat.\n\n## Der erste Codeschnipsel, der schon alles zeigt\n\n```js\nconst fs = require(\"fs\");\n\nconst data = fs.readFileSync(\"input.txt\");\nconsole.log(data.toString());\nconsole.log(\"Fertig\");\n```\n\nDas ist kein aufregender Code. Genau deshalb eignet er sich als Einstieg. `readFileSync` blockiert den Prozess, bis die Datei komplett gelesen ist. Erst dann läuft die nächste Zeile. Ich lasse Teilnehmer diesen Code laufen, dann verändere ich eine Zeile:\n\n```js\nconst fs = require(\"fs\");\n\nfs.readFile(\"input.txt\", (err, data) => {\n  if (err) return console.error(err);\n  console.log(data.toString());\n});\nconsole.log(\"Fertig\");\n```\n\nJetzt steht \"Fertig\" zuerst in der Konsole, obwohl die Zeile im Quelltext danach kommt. Für Einsteiger ist das oft der erste kleine Schreckmoment: Der Code läuft nicht von oben nach unten ab, jedenfalls nicht so, wie man es aus anderen Sprachen gewohnt ist. Genau an diesem winzigen Unterschied lässt sich der ganze Rest des Trainings aufhängen. Node.js ist im Kern kein Framework und keine Sammlung von Modulen, sondern eine Entscheidung: Auf einen langsamen Ein-/Ausgabevorgang reagiert ein Programm mit Weitermachen statt mit Warten.\n\n## Der Event Loop ist Architektur, keine Randnotiz\n\nNode läuft auf der V8-Engine aus Chrome und in einem einzigen JavaScript-Thread. Das überrascht Teilnehmer regelmäßig, weil der Server trotzdem Tausende gleichzeitiger Verbindungen bedienen kann. Der Widerspruch löst sich auf, sobald man zwischen nebenläufig und parallel unterscheidet. Node arbeitet zu einem Zeitpunkt immer nur ein Stück JavaScript ab, aber während dieses Stück auf eine Antwort von der Festplatte, dem Netzwerk oder einer Datenbank wartet, gibt es die Arbeit nach unten weiter, an libuv, den Kernel oder einen internen Thread-Pool. Der Event Loop ist die Instanz, die zurückgemeldete Ergebnisse wieder abholt und die passenden Callbacks aufruft, sobald der Hauptthread frei ist.\n\nDas ist der Punkt, an dem ich in der Schulung bewusst langsamer werde, weil hier die eigentliche Architekturaussage steckt: Concurrency in Node ist kein Implementierungsdetail, das man ignorieren kann, solange der Code funktioniert. Sie bestimmt, welche Arbeit sofort passieren darf und welche warten muss, und sie bestimmt, was ein einzelner blockierender Aufruf für alle anderen gleichzeitig laufenden Anfragen bedeutet. Eine CPU-lastige Schleife, ein synchrones JSON.parse über mehrere Megabyte, eine verschachtelte Regex mit katastrophalem Backtracking – all das legt nicht nur die eine Anfrage lahm, die es ausgelöst hat, sondern den kompletten Prozess, weil es keinen zweiten Thread gibt, der in der Zwischenzeit übernehmen könnte.\n\n```mermaid\nflowchart LR\n  subgraph Call[\"Call Stack (ein Thread)\"]\n    direction TB\n    JS[\"dein JavaScript\"]\n  end\n  JS -->|\"gibt I/O ab\"| LibUV[\"libuv / Kernel / Thread-Pool\"]\n  LibUV -->|\"Ergebnis liegt vor\"| Queue[\"Callback-Queue\"]\n  Queue -->|\"Call Stack ist leer\"| JS\n```\n\nFür Teilnehmer, die aus Java- oder .NET-Umgebungen kommen, ist das oft die größte Umstellung. Der Satz, der hängenbleiben soll, lautet nicht \"Node ist schnell\". Er lautet: Node ist fair, solange man ihm keine blockierende Arbeit unterschiebt. Das ist ein Betriebsversprechen und kein Marketingsatz, und es hat direkte Konsequenzen für das, was in einem Node-Service überhaupt landen darf.\n\n## Warum Callbacks, EventEmitter, Streams und Promises in genau dieser Reihenfolge drankommen\n\nIm Repo `introduction-nodejs`, das ich mit den Teilnehmern gemeinsam durcharbeite, folgen die Kapitel bewusst dieser Reihenfolge: erst Callbacks, dann EventEmitter, dann Streams, danach Promises und ganz am Ende RxJS. Das sieht aus wie eine willkürliche Liste von Async-Werkzeugen, ist aber tatsächlich eine Lernkurve mit einer klaren Logik dahinter, und die lohnt es sich einmal auszubuchstabieren.\n\nEin Callback liefert genau ein Ergebnis, genau einmal, und die Error Handling ist reine Konvention: Das erste Argument ist im Fehlerfall gesetzt, sonst nicht. Diese Konvention nennt sich error-first, und sie funktioniert nur, wenn jede Funktion sie diszipliniert einhält. Ein EventEmitter geht einen Schritt weiter und liefert beliebig viele Events über die Zeit, aber er bringt keinen eingebauten Fehlerkanal mit. Ein `error`-Event ist reine Verabredung, und wenn niemand darauf hört, wirft Node die Ausnahme ungefangen weiter nach oben. Ein Stream ist im Kern ein spezialisierter EventEmitter, der zusätzlich zwei Dinge mitbringt, die für den Alltag entscheidend sind: eine feste Rolle (Readable, Writable, Duplex oder Transform) und Backpressure, also eine eingebaute Bremse, wenn das Ziel langsamer ist als die Quelle. Eine Promise schließlich kehrt zurück zu genau einem Ergebnis, genau einmal, gewinnt dabei aber einen echten, komponierbaren Fehlerkanal über `.catch`, den weder der Callback noch der EventEmitter besitzen. RxJS setzt am Ende beide Eigenschaften zusammen: viele Werte über die Zeit, wie beim EventEmitter, aber mit demselben verlässlichen Fehler- und Abschlussvertrag, den Promises eingeführt haben.\n\nWenn Teilnehmer diesen Bogen einmal gesehen haben, hören sie auf, nach dem \"besten\" Async-Pattern zu fragen. Die Frage verschiebt sich zu etwas Brauchbarerem: Wie viele Werte erwarte ich, wie viele Male darf ein Fehler passieren, und wer trägt die Verantwortung dafür, ihn zu behandeln?\n\n## Streams und Backpressure am konkreten Beispiel\n\nKein Kapitel im Training bringt so viele Aha-Momente wie Streams, weil sie das erste Mal zeigen, dass Node-Architektur auch eine Frage von Speicher und Durchsatz ist, nicht nur von Syntax. Ein kleines, oft gezeigtes Beispiel reicht dafür völlig aus:\n\n```js\nconst fs = require(\"fs\");\nconst zlib = require(\"zlib\");\n\nfs.createReadStream(\"input.txt\")\n  .pipe(zlib.createGzip())\n  .pipe(fs.createWriteStream(\"input.txt.gz\"));\n```\n\nAuf den ersten Blick ist das nur eine bequeme Art, eine Datei zu komprimieren. Interessant wird die Zeile erst, wenn man sich fragt, was passiert, wenn `input.txt` zehn Gigabyte groß ist. Ohne Streams müsste man die komplette Datei in den Speicher laden, bevor überhaupt komprimiert werden kann. Mit `pipe` liest Node einen Chunk, komprimiert ihn, schreibt ihn, und erst dann kommt der nächste Chunk an die Reihe – und zwar automatisch nur so schnell, wie das Schreibziel mithalten kann. Das ist Backpressure: eine eingebaute Queue, die verhindert, dass eine schnelle Quelle ein langsames Ziel mit Daten überschwemmt, bis der Prozess an seiner Speichergrenze erstickt.\n\n```mermaid\nflowchart LR\n  Source[\"Readable<br/>(z. B. Datei)\"] --> Transform[\"Transform<br/>(z. B. Gzip)\"]\n  Transform --> Sink[\"Writable<br/>(z. B. Zieldatei)\"]\n  Sink -. \"Backpressure\" .-> Transform\n  Transform -. \"pause / resume\" .-> Source\n```\n\nFür die Architektur eines Backends ist das kein Nischenwissen für Dateikonvertierung. Jeder Datei-Upload, jeder CSV-Export, jeder Log-Tail und jede Weiterleitung eines HTTP-Response-Bodys profitiert von genau demselben Prinzip. Wer stattdessen den kompletten Request-Body erst in einen Buffer sammelt, bevor irgendetwas mit ihm passiert, hat eine Speichergrenze eingebaut, die im Test mit einer kleinen Testdatei nie auffällt und im Betrieb mit echten Nutzerdateien plötzlich sehr wohl.\n\n## Express ist kein Architekturmuster\n\nDamit sind wir endlich bei der Frage angekommen, mit der die Teilnehmer eigentlich starten wollten. Express ist ein hervorragendes, schlankes Routing-Werkzeug, aber es trifft keine einzige der Entscheidungen, die eine Web-Architektur eigentlich ausmachen. Diese Entscheidungen liegen woanders: Antwortet der Server mit vollständig gerendertem HTML, wie beim klassischen Server-Side-Rendering? Antwortet er mit strukturierten Daten auf einen RPC-artigen Aufruf, bei dem eine Funktion und ihre Parameter im Body stecken? Oder folgt er dem RESTful-Gedanken, bei dem Ressourcen über URIs adressiert werden und HTTP-Methoden ihre jeweilige Bedeutung tragen?\n\nFür Letzteres nutze ich im Training gern das Richardson Maturity Model als kleine Orientierungshilfe, weil es die Abstufungen greifbar macht. Auf Stufe 0 gibt es nur eine einzige URI, meist mit POST, und die eigentliche Aktion steckt im Body – im Grunde RPC über HTTP. Auf Stufe 1 kommen mehrere URIs für unterschiedliche Ressourcen dazu, aber noch ohne saubere Nutzung der HTTP-Methoden. Stufe 2 nutzt GET, POST, PUT, DELETE und Statuscodes so, wie sie gemeint sind: 200 für Erfolg, 201 für angelegt, 204 ohne Inhalt, 404 wenn die Ressource fehlt. Stufe 3 fügt Hypermedia hinzu, also Links in der Antwort, die dem Client sagen, welche nächsten Schritte möglich sind, statt dass der Client das vorher wissen muss. Die meisten Systeme, die ich in Projekten sehe, bleiben bei Stufe 2 stehen, und das ist meistens auch völlig in Ordnung. Wichtig ist nur, dass die Entscheidung bewusst getroffen wird und nicht einfach das Ergebnis davon ist, welche Express-Beispiele man zuerst kopiert hat.\n\nWas Express tatsächlich beisteuert, ist etwas anderes, und es hat einen erstaunlichen Bezug zu Streams: eine Middleware-Pipeline.\n\n```js\nconst express = require(\"express\");\nconst app = express();\n\napp.use(express.json());\napp.use((req, res, next) => {\n  console.log(`${req.method} ${req.url}`);\n  next();\n});\n\napp.get(\"/invoices/:id\", async (req, res, next) => {\n  try {\n    const invoice = await loadInvoice(req.params.id);\n    if (!invoice) return res.status(404).json({ error: \"not_found\" });\n    res.status(200).json(invoice);\n  } catch (err) {\n    next(err);\n  }\n});\n\napp.use((err, req, res, next) => {\n  console.error(err);\n  res.status(500).json({ error: \"internal\" });\n});\n\napp.listen(3000);\n```\n\nJede Middleware bekommt Request und Response, tut etwas und reicht mit `next()` weiter, oder sie bricht mit einem Fehler ab, der beim vier-Parameter-Error-Handler landet. Das ist strukturell dasselbe Prinzip wie bei den Streams weiter oben: eine Quelle, eine Kette von Transformationen, ein Ziel, und ein eigener Kanal für den Fehlerfall. Sobald Teilnehmer diese Verwandtschaft sehen, hört Express auf, eine Liste auswendig zu lernender Aufrufe zu sein. Es wird zu einer von mehreren Ausprägungen desselben Pipeline-Gedankens, der sich durch das ganze Training zieht.\n\n## Skalierung ohne Illusionen\n\nEin einzelner Node-Prozess nutzt genau einen Kern der CPU. Das überrascht Teilnehmer mit Servern, die acht oder sechzehn Kerne haben, regelmäßig, weil unter Last nur einer davon etwas zu tun bekommt, solange man nichts weiter unternimmt. Node bietet dafür `child_process` an, mit drei Werkzeugen, die unterschiedlich grobkörnig sind: `spawn` startet einen beliebigen Systembefehl und liefert seine Ausgabe als Stream, `exec` tut ähnliches, sammelt die Ausgabe aber komplett im Speicher, bevor sie zurückkommt, und `fork` startet gezielt ein weiteres Node-Skript als eigenen Prozess mit einem eingebauten Nachrichtenkanal. Für rechenintensive Arbeit, die den Event Loop sonst blockieren würde, ist das der saubere Weg: Arbeit auslagern, statt den einzigen Thread zu opfern.\n\nEin `fork` sieht im Repo zum Beispiel so aus:\n\n```js\nconst { fork } = require(\"child_process\");\n\nconst worker = fork(\"./heavy-computation.js\");\nworker.send({ numbers: [1, 2, 3, 4, 5] });\nworker.on(\"message\", (result) => {\n  console.log(\"Result from worker process:\", result);\n});\n```\n\nDer Hauptprozess bleibt frei, während `heavy-computation.js` in einem eigenen Prozess rechnet und das Ergebnis über den eingebauten Nachrichtenkanal zurückschickt. Das ist grobkörniger als ein Thread, weil jeder `child_process` seinen eigenen V8-Kontext und seinen eigenen Speicher mitbringt, aber genau diese Isolation ist in vielen Fällen ein Vorteil: Ein abgestürzter Worker reißt den Hauptprozess nicht mit.\n\nGerade jetzt, im Frühjahr 2019, wird dieses Bild interessanter. Node 10 ist die Long-Term-Support-Version, die aktuell in den meisten Projekten produktiv läuft, solide und ausgereift. Node 12 ist vor wenigen Wochen erschienen und bringt zwei Dinge mit, über die ich in der Schulung schon spreche, ohne sie im Produktivbetrieb zu empfehlen: Worker Threads, die jetzt ohne Kommandozeilen-Flag laufen, aber von den Node-Maintainern selbst noch nicht als vollständig ausgereift bezeichnet werden, und experimentelle Unterstützung für ECMAScript-Module hinter dem Flag `--experimental-modules`. Beides zeigt, wohin sich Node entwickelt, aber wer heute etwas ausliefert, bleibt bei `require`, bei CommonJS und bei der 10er-Linie, bis die 12er zur LTS-Version wird. Ich finde diese Unterscheidung wichtig genug, um sie explizit zu machen: Neugier auf das Neue und Verantwortung für das Laufende sind zweierlei, und ein gutes Training zeigt beides.\n\n## Fehler sind auch Architektur, nicht nur ein try/catch\n\nIm Express-Beispiel weiter oben landet ein Fehler beim vier-Parameter-Handler, weil ich ihn mit `next(err)` explizit dorthin weitergereicht habe. Das ist der saubere Fall. Interessant wird es bei den Fehlern, die niemand explizit weitergereicht hat. Dafür bietet Node zwei globale Netze, und ich zeige Teilnehmern bewusst, dass es Netze und keine Reparaturwerkzeuge sind.\n\n```js\nprocess.on(\"uncaughtException\", (err) => {\n  console.error(\"Unexpected error, process is in an undefined state:\", err);\n  // only synchronous cleanup, then exit\n  process.exit(1);\n});\n\nprocess.on(\"unhandledRejection\", (reason) => {\n  console.error(\"Promise rejected without .catch:\", reason);\n});\n```\n\nDie Node-Dokumentation ist an dieser Stelle ungewöhnlich deutlich: Nach einer `uncaughtException` befindet sich der Prozess in einem undefinierten Zustand. Der Satz ist wörtlich gemeint. Irgendein Modul kann mitten in einer Operation stehengeblieben sein, eine Datei halb geschrieben, eine Verbindung halb aufgebaut. Im Handler macht man deshalb nicht weiter. Man räumt so schnell und so synchron wie möglich auf und beendet den Prozess, damit ein Supervisor – `pm2`, systemd, Kubernetes, was auch immer im jeweiligen Projekt läuft – ihn frisch neu startet. `unhandledRejection` ist die Promise-Variante desselben Problems: eine Promise, die abgelehnt wurde, ohne dass irgendwo ein `.catch` darauf gewartet hat.\n\nFür Teilnehmer, die aus einer Welt mit zentralem Exception Handling kommen, ist das gewöhnungsbedürftig. Die eigentliche Lektion lautet: Ein globaler Handler ersetzt niemals das `try/catch` oder das `.catch` an der Stelle, an der der Fehler fachlich etwas bedeutet – dass eine Rechnung nicht gefunden wurde, dass eine Zahlung fehlgeschlagen ist, dass eine Datei nicht existiert. Er ist das letzte Sicherheitsnetz für das, was durch alle bewusst gesetzten Netze hindurchgefallen ist. Dazu passt auch, dass Node über `process.on(\"SIGINT\", ...)` und verwandte Signale einen kontrollierten Shutdown erlaubt: laufende Verbindungen zu Ende bedienen, Datenbankverbindungen sauber schließen, dann erst beenden. Dahinter steckt derselbe Gedanke wie bei Backpressure: Ein System sagt ehrlich, wann es fertig ist, statt einfach abgewürgt zu werden.\n\n## Testen, bevor es wehtut\n\nDer letzte Baustein, den ich im Training bewusst nicht auslasse, ist Testing, und zwar aus einem einfachen Grund: Alles, was bisher gezeigt wurde – Backpressure, Fehlerpfade, die Reihenfolge von Middleware – lässt sich im Handumdrehen unsichtbar kaputtmachen, wenn niemand eine Erwartung daran festgehalten hat. Ich arbeite mit Mocha, weil es 2019 ein bewährter, unaufgeregter Standard ist, und mit dem eingebauten `assert`-Modul, weil man dafür keine zusätzliche Bibliothek braucht, um die Idee zu verstehen.\n\n```js\nconst assert = require(\"assert\");\nconst { describe, it } = require(\"mocha\");\n\ndescribe(\"Array.reverse\", () => {\n  it(\"reverses the order of elements\", () => {\n    const sut = \"ABC\".split(\"\");\n    const actual = sut.reverse();\n    assert.deepEqual(actual, [\"C\", \"B\", \"A\"]);\n  });\n});\n```\n\nArrange, Act, Assert: Testdaten aufbauen, die Funktion ausführen, das Ergebnis prüfen. Diese drei Schritte klingen banal, aber die meisten schwer lesbaren Tests verletzen genau diese Trennung, indem Aufbau und Prüfung munter durcheinandergemischt werden. Bei asynchronem Code zahlt sich Mocha zusätzlich aus, weil ein Test entweder einen `done`-Callback entgegennimmt oder schlicht eine Promise zurückgibt:\n\n```js\nit(\"resolves with the expected value after the async call\", () => {\n  return computeAsync().then((actual) => {\n    assert.equal(actual, 2);\n  });\n});\n```\n\nGibt die Testfunktion eine Promise zurück, wartet Mocha automatisch darauf, bevor es das Ergebnis bewertet. Vergisst man das `return`, meldet der Test unter Umständen Erfolg, bevor die eigentliche Assertion überhaupt gelaufen ist – ein Fehler, der in Code-Reviews leicht übersehen wird, weil der Test ja grün ist. Für Integrationstests kommen `before`, `after`, `beforeEach` und `afterEach` dazu, um zum Beispiel eine Testdatenbank einmal aufzusetzen und nach jedem Test wieder in einen bekannten Zustand zu bringen. Die Testpyramide bleibt dabei die Leitplanke: viele schnelle Unit-Tests an der Basis, weniger, aber aussagekräftige Integrationstests in der Mitte, eine Handvoll Ende-zu-Ende-Tests an der Spitze. Wer diese Verteilung umdreht, merkt es meistens erst an der Laufzeit der Build-Pipeline, wenn es zu spät ist, um sie günstig zu ändern.\n\n## Das Repository ist selbst schon Architektur\n\nEin Punkt, den ich Teilnehmern gerne erst am Ende erkläre, weil er vorher zu abstrakt wäre: `introduction-nodejs` ist selbst als Yarn-Workspaces-Monorepo aufgebaut und eben nicht bloß ein Ordner voller Markdown-Dateien. Im Wurzelverzeichnis steht eine `package.json` mit `\"private\": true` und einem `workspaces`-Feld, das auf einen `packages`-Ordner zeigt, und jedes Beispiel liegt als eigenständiges Paket darunter: `ARCHITECTURE`, `BASICS`, `ECMA`, `EVENTEMITTER`, `EXPRESSJS`, `FUNCTIONALJS`, `HTTP2`, `RXJS`, `STREAMS`, `TESTING`, `TYPESCRIPT`. Jedes dieser Pakete hat seine eigenen Abhängigkeiten und lässt sich einzeln installieren und ausführen, ohne dass die Beispiele sich gegenseitig verunreinigen.\n\nDas ist kein Zufall und auch kein reines Ordnungsprinzip. Es ist dieselbe Lektion, die vorher schon bei Streams und bei Express auftauchte, nur eine Ebene höher: klare Grenzen zwischen Bausteinen, jeder mit seiner eigenen Verantwortung, verbunden über einen expliziten, sichtbaren Mechanismus statt über stille Abhängigkeiten. Ich zeige Teilnehmern bewusst diese Struktur des Repos selbst, weil ein Monorepo mit sauberen Workspace-Grenzen ziemlich genau dasselbe Architekturproblem löst wie ein Backend, das aus mehreren Services besteht: Wo darf etwas geteilt werden, und wo muss es getrennt bleiben, damit eine Änderung an einer Stelle nicht überraschend woanders etwas kaputt macht?\n\n## Wie ich die Schulung tatsächlich gebe\n\nIch zeige selten eine Folie, ohne direkt danach den Code aufzumachen. Die Folienübersicht zum Training gibt es zum Nachschlagen und für alle, die zwischen den Terminen noch einmal die Übersicht sehen wollen, hier: [Introduction to Node.js – Folien](https://slides.mikebild.dev/introduction-nodejs.html). Aber die Folie ist der Wegweiser, nicht der Weg selbst. Das eigentliche Lernen passiert im Repository, gemeinsam: Ich tippe, ein Teilnehmer tippt, wir bauen einen Fehler absichtlich ein und schauen uns gemeinsam an, wie die Fehlermeldung aussieht, dann reparieren wir ihn zusammen. Wer nur zuschaut, merkt sich die Syntax für den Moment. Wer selbst tippt und selbst den Callback vergisst zurückzugeben oder das `await` an der falschen Stelle setzt, merkt sich die Grenze – und Grenzen sind es, die ein Jahr später noch abrufbar sind, nicht Syntax.\n\nGetragen wird die Schulung deshalb vom Repo [introduction-nodejs](https://github.com/mikebild/introduction-nodejs) und nicht vom Foliensatz. Die 23 Kapitel sind als eigenständige Markdown-Dateien angelegt, die man in beliebiger Reihenfolge nachschlagen kann und die als Ganzes eine Lernkurve vom ersten Skript zur Betriebsreife ergeben – von der Einführung über Event Loop, Streams und Error Handling bis zu Express, Testing, Promises, RxJS und TypeScript. Die dazugehörigen `EXAMPLES` sind lauffähiger Code, kein Pseudocode – genau der Code, den wir im Raum gemeinsam verändern.\n\n## Was am Ende bleiben soll\n\nAm letzten Schulungstag frage ich Teilnehmer manchmal, was sie mitnehmen. Die Antwort, die mich am meisten freut, ist selten \"ich kenne jetzt Express\". Sie lautet eher so etwas wie: \"Ich weiß jetzt, wann ich zu einem Callback greife und wann zu einem Stream, und ich weiß, warum ein `await` trotzdem noch etwas ist, worüber ich nachdenken muss.\" Genau das ist der Unterschied zwischen einer Werkzeugliste und einem Architekturverständnis. Ein Framework kann man in einem Wochenende nachlernen. Ein Gefühl dafür, welche Entscheidung sich später rächt, braucht die Übung an echtem, manchmal absichtlich kaputtem Code.\n\nNode.js taugt als Trainingsobjekt aus einem Grund, der mit Aktualität oder Beliebtheit nichts zu tun hat: Seine zentrale Designentscheidung – nicht-blockierendes I/O in einem einzigen Thread – ist so kompromisslos, dass sie ihre eigenen Konsequenzen erzwingt. Man kann sie nicht ignorieren, ohne dass der Server sich irgendwann merkwürdig verhält. Und genau an diesen merkwürdigen Momenten lernt man am meisten.\n\nSelbst Ryan Dahl, der Node vor zehn Jahren erfunden hat, hat vor einiger Zeit in einem vielbeachteten Vortrag öffentlich einige seiner eigenen Designentscheidungen infrage gestellt und experimentiert seitdem an einem möglichen Nachfolger. Das finde ich, ganz unabhängig davon, wohin dieses Experiment führt, bemerkenswert gesund für ein Ökosystem: dass der Erfinder selbst bereit ist zu sagen, was er heute anders machen würde. Genau diese Haltung versuche ich auch in der Schulung zu vermitteln. Node.js ist kein fertiges, geschlossenes Weltbild, das man auswendig lernt. Es ist eine Reihe von Entscheidungen, jede mit ihrem Preis, jede diskutierbar. Wer das einmal verinnerlicht hat, liest auch die nächste Technologie, die in fünf Jahren angesagt sein wird, als eine weitere Menge von Entscheidungen, die man verstehen und gegebenenfalls widerlegen kann – und nicht als Wahrheit.\n\n## Weiterführende Quellen\n\n- Repository introduction-nodejs (Basis für die gemeinsamen Übungen): https://github.com/mikebild/introduction-nodejs\n- Folienübersicht zum Training: https://slides.mikebild.dev/introduction-nodejs.html\n- Node.js, Event Loop und Timers (offizielle Dokumentation): https://nodejs.org/learn/asynchronous-work/event-loop-timers-and-nexttick\n- Node.js Streams API: https://nodejs.org/api/stream.html\n- Node.js 12 Release-Ankündigung: https://nodejs.org/en/blog/release/v12.0.0/\n- Richardson Maturity Model, Übersicht: https://martinfowler.com/articles/richardsonMaturityModel.html"},{"slug":"cdk-infrastruktur-als-programmiersprache","title":"CDK-Anfänge: Infrastruktur mit einer Programmiersprache","summary":"Mit dem CDK rückt Infrastruktur in dieselbe Werkzeugkette wie Anwendungscode, wodurch Tests, Reviews und logische Identitäten plötzlich über den Bestand von Produktionsdaten entscheiden.","tags":["Software","AWS","Cloud"],"published_at":"2019-03-12T10:00:00+00:00","updated_at":"2026-07-13T13:43:01.671293+00:00","url":"https://www.mikebild.dev/de/blog/cdk-infrastruktur-als-programmiersprache/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/7c0b251ee92a5e66b14124aab19ab8ce9971d17c7a5a9edd548b2fe0e21108e6/cdk-infrastruktur-als-programmiersprache-vorlesen.mp3","audio_duration_secs":787,"text":"# CDK-Anfänge: Infrastruktur mit einer Programmiersprache\n\nSeit ein paar Wochen liegt das AWS Cloud Development Kit als Developer Preview vor, und es verändert spürbar, wie ich über Infrastruktur nachdenke. Statt YAML zu schreiben und zu hoffen, dass die Einrückung stimmt, schreibe ich TypeScript. Ich bekomme Typen, Autovervollständigung, Schleifen, Funktionen und Klassen. Das klingt nach einer reinen Komfortverbesserung, ist aber eine Verschiebung der Verantwortung. Wer Infrastruktur in einer echten Programmiersprache beschreibt, erbt alle Werkzeuge dieser Sprache. Er erbt aber auch ihre Fehlerklassen.\n\n**Redaktionelle Einordnung (Juli 2026):** Das CDK ist keine eigene Programmiersprache, sondern ein Framework, mit dem Infrastruktur in vorhandenen Sprachen definiert und über CloudFormation bereitgestellt wird. Der Code zeigt die Developer Preview und später CDK v1: getrennte `@aws-cdk/*`-Pakete und damalige Lambda-Runtimes. CDK v2 bündelt die stabile Construct Library in `aws-cdk-lib`; neue Projekte sollten die Beispiele deshalb nicht wörtlich übernehmen. Das gilt auch für die Deployment-Action weiter unten: Aktuelle GitHub-Actions-Workflows sollten AWS über OIDC und kurzlebige Rollen-Credentials anbinden, nicht über langlebige Access Keys in Repository-Secrets.\n\nIch nehme im Folgenden einen kleinen, plausiblen Stack als roten Faden: einen S3-Bucket, in den Nutzer Dateien laden, und eine Lambda-Function, die diese Dateien verarbeitet. An diesem Beispiel lässt sich die interessante Architekturfrage besser zeigen als an einer Aufzählung von Merkmalen.\n\n## Vom Template zum Programm\n\nCloudFormation kennt jeder, der länger mit AWS arbeitet. Man beschreibt den Zielzustand deklarativ, und der Dienst gleicht ab. Das Modell ist robust, aber das Format ist mühsam. Wiederholung lässt sich nur über Parameter und Mappings ausdrücken, und sobald eine Vorlage über tausend Zeilen wächst, verliert man den Überblick darüber, welche Ressource eigentlich von welcher abhängt.\n\nDas CDK dreht die Richtung um. Ich schreibe Code, und dieser Code erzeugt am Ende wieder eine CloudFormation-Vorlage. Dieser Schritt heißt Synthese. Wichtig ist, dass darunter weiterhin CloudFormation arbeitet. Das CDK ersetzt den Provisionierungsmechanismus nicht, es setzt eine Programmierschicht darüber. Der Vorteil ist offensichtlich: Abstraktion, Wiederverwendung, Typprüfung. Der Preis ist, dass zwischen meinem Quelltext und den realen Ressourcen jetzt ein Übersetzungsschritt liegt, dessen Ergebnis ich kennen sollte.\n\nIn der aktuellen Vorabversion ist die Bibliothek nach Diensten aufgeteilt. Es gibt ein Kernpaket und je ein Paket pro AWS-Dienst. Wer S3 und Lambda nutzt, installiert genau diese Pakete. Das ist ungewohnt granular, hat aber den Effekt, dass man die eigenen Abhängigkeiten klein hält.\n\n## Die drei Ebenen: App, Stack, Construct\n\nDas Modell hat drei Schichten, und es lohnt sich, sie sauber auseinanderzuhalten, weil jede eine andere Aufgabe hat.\n\nGanz unten stehen die Constructs. Ein Construct ist ein Baustein. Es kapselt eine oder mehrere CloudFormation-Ressourcen samt sinnvoller Voreinstellungen. Ein S3-Bucket ist ein Construct, eine Lambda-Function ist ein Construct, und ein selbst geschriebenes Modul, das beide zu einem Upload-Verarbeiter verbindet, ist ebenfalls eines. Constructs lassen sich verschachteln, und genau daraus entsteht die Wiederverwendbarkeit.\n\nEine Ebene darüber liegt der Stack. Er ist die Einheit, die deployt wird, und entspricht eins zu eins einem CloudFormation-Stack. Was in einem Stack liegt, wird gemeinsam ausgerollt und im Fehlerfall gemeinsam zurückgenommen.\n\nGanz oben sitzt die App. Sie ist der Entry Point und bündelt einen oder mehrere Stacks. Wenn die App durchläuft, wird für jeden Stack eine Vorlage erzeugt.\n\n```mermaid\nflowchart TD\n  App[\"App (Einstiegspunkt)\"] --> Stack[\"Stack (Deployment-Einheit)\"]\n  Stack --> C1[\"Construct: S3-Bucket\"]\n  Stack --> C2[\"Construct: Lambda-Funktion\"]\n  C1 --> Synth[\"Synthese\"]\n  C2 --> Synth\n  Synth --> CFN[\"CloudFormation-Vorlage\"]\n  CFN --> AWS[\"Ressourcen in der Cloud\"]\n```\n\nDiese Hierarchie ist kein Selbstzweck. Sie bestimmt, was unabhängig geändert werden kann und was nicht. Zwei Dinge im selben Stack teilen ihr Schicksal beim Rollback. Zwei Dinge in getrennten Stacks lassen sich getrennt aktualisieren, müssen dann aber ihre Verbindung explizit über Referenzen ausdrücken.\n\n## Der Stack als Code\n\nSo sieht der erwähnte Upload-Verarbeiter in der aktuellen Bibliothek aus. Bewusst klein gehalten, damit die Entscheidung sichtbar bleibt und nicht im Beiwerk verschwindet.\n\n```ts\nimport cdk = require(\"@aws-cdk/core\");\nimport s3 = require(\"@aws-cdk/aws-s3\");\nimport lambda = require(\"@aws-cdk/aws-lambda\");\n\nclass UploadStack extends cdk.Stack {\n  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {\n    super(scope, id, props);\n\n    // Storage for incoming files.\n    // RETAIN keeps the bucket even when the stack is deleted.\n    const uploads = new s3.Bucket(this, \"Uploads\", {\n      removalPolicy: cdk.RemovalPolicy.RETAIN\n    });\n\n    const handler = new lambda.Function(this, \"ProcessUpload\", {\n      runtime: lambda.Runtime.NODEJS_10_X,\n      handler: \"index.handler\",\n      code: lambda.Code.fromAsset(\"dist/handler\"),\n      environment: {\n        // The bucket name is resolved at runtime, not hard-coded.\n        BUCKET_NAME: uploads.bucketName\n      }\n    });\n\n    // Instead of writing an IAM policy by hand:\n    // the construct grants the necessary permissions and nothing more.\n    uploads.grantReadWrite(handler);\n  }\n}\n\nconst app = new cdk.App();\nnew UploadStack(app, \"UploadStack\");\napp.synth();\n```\n\nDrei Stellen verdienen einen zweiten Blick. Der Aufruf `uploads.grantReadWrite(handler)` schreibt keine Policy von Hand, sondern leitet aus der Beziehung der beiden Constructs ab, welche Aktionen die Funktion auf dem Bucket braucht. Das ist mehr als Bequemlichkeit, denn es macht das Prinzip der minimalen Rechte zum Standardweg statt zur Ausnahme. Die Zeile `BUCKET_NAME: uploads.bucketName` zeigt, dass `bucketName` zur Synthesezeit kein fertiger String ist, sondern ein Verweis, den CloudFormation später auflöst. Und `removalPolicy: RETAIN` ist die kleine, unscheinbare Zeile, von der gleich noch die Rede sein wird, weil ihr Fehlen teuer wird.\n\nWas hier auffällt: Es ist normaler Code. Ich kann ihn refaktorieren, in eine Funktion auslagern, über eine Liste von Umgebungen iterieren. Genau das ist der Reiz. Und genau hier beginnt die Verantwortung.\n\n## Was man sich mit der Programmiersprache einhandelt\n\nSolange Infrastruktur in YAML steht, ist sie statisch und langweilig, aber gut überschaubar. Sobald sie Code ist, gelten die Gesetze des Codes. Ein Tippfehler in einer Schleife erzeugt nicht eine falsche Ressource, sondern fünfzig. Eine geänderte Konstante wirkt überall dort, wo sie gelesen wird. Eine Abstraktion, die zu viel versteckt, verbirgt auch Kosten und Rechte vor demjenigen, der den Stack reviewt.\n\nDamit verschiebt sich, was Sorgfalt hier heißt: Wenn Infrastruktur programmierbar wird, muss sie so behandelt werden wie Anwendungscode. Sie braucht Tests, sie braucht Reviews, und sie braucht ein Verständnis dafür, was beim Ändern passiert. Das ist keine Bürde, die das CDK uns aufdrückt, sondern die ehrliche Konsequenz daraus, eine echte Sprache zu benutzen.\n\nTests sind dabei kein Beiwerk. Die Bibliothek bringt bereits ein Assertion-Modul mit, mit dem sich das synthetisierte Ergebnis prüfen lässt, ohne überhaupt zu deployen:\n\n```ts\nimport { expect, haveResource } from \"@aws-cdk/assert\";\n\ntest(\"Bucket survives deletion of the stack\", () => {\n  const app = new cdk.App();\n  const stack = new UploadStack(app, \"TestStack\");\n\n  expect(stack).to(haveResource(\"AWS::S3::Bucket\", {\n    DeletionPolicy: \"Retain\"\n  }, undefined, true));\n});\n```\n\nDieser Test deployt nichts. Er prüft die erzeugte Vorlage. Damit lässt sich eine Regel festschreiben, die sonst nur im Kopf eines erfahrenen Kollegen existiert: dieser Bucket darf nicht versehentlich verschwinden. Genau solche Prüfungen unterscheiden einen Stack, der eine Demo überlebt, von einem, der einen Personalwechsel überlebt.\n\n## Der Fehler, der wirklich passiert\n\nDer gefährlichste Fehler im CDK ist kein Syntaxfehler, denn den fängt der Compiler ab. Er hat mit der logischen Identität von Ressourcen zu tun, und er ist deshalb so tückisch, weil der Code danach völlig in Ordnung aussieht.\n\nCloudFormation identifiziert jede Ressource über eine logische ID. Das CDK erzeugt diese ID aus dem Pfad des Constructs im Baum, also im Wesentlichen aus dem zweiten Argument des Konstruktors, dem Namen. In meinem Beispiel heißt der Bucket `\"Uploads\"`. Angenommen, jemand findet diesen Namen unschön und benennt ihn aus reiner Kosmetik um:\n\n```ts\nconst uploads = new s3.Bucket(this, \"UploadBucket\", {\n  removalPolicy: cdk.RemovalPolicy.RETAIN\n});\n```\n\nFunktional ändert sich nichts. Der Code kompiliert, die Tests zum Verhalten der Funktion laufen weiter. Aber die logische ID hat sich geändert. Für CloudFormation bedeutet das nicht: dieselbe Ressource mit neuem Namen. Es bedeutet: die alte Ressource ist verschwunden, eine neue ist hinzugekommen. Beim nächsten Deployment wird also ein neuer, leerer Bucket angelegt, und die Anwendung zeigt anschließend auf diesen leeren Speicher. Für die alte Ressource greift die Lösch- oder Austausch-Policy.\n\nAn dieser Stelle rettet die unscheinbare Zeile von vorhin. Steht `removalPolicy: RETAIN` im Code, wird der alte Bucket nicht gelöscht, sondern aus dem Stack entlassen und bleibt als verwaiste Ressource bestehen. Die Daten überleben, auch wenn die Anwendung sie nicht mehr findet, bis jemand manuell aufräumt. Fehlt diese Policy, versucht CloudFormation die alte Ressource zu entfernen. Einen nicht leeren S3-Bucket leert CloudFormation dabei nicht stillschweigend; die Löschung scheitert, solange Objekte enthalten sind, sofern nicht zusätzlich ein Mechanismus wie `autoDeleteObjects` eingerichtet wurde. Das macht die Umbenennung nicht harmlos: Die Anwendung zeigt bereits auf einen neuen, leeren Bucket, und bei anderen löschbaren Ressourcen oder expliziter automatischer Leerung kann der Austausch tatsächlich Daten vernichten.\n\nDer entscheidende Punkt ist nicht die einzelne Policy. Der Punkt ist, dass eine harmlose Umbenennung im Code eine zerstörerische Operation in der Infrastruktur auslöst, und dass diese Folge im Quelltext nicht sichtbar ist. Sie wird erst im `cdk diff` sichtbar, das eine Ersetzung als solche markiert. Wer den Diff vor dem Deployment liest, sieht den Schaden kommen. Wer ihn überspringt, weil das letzte Deployment ja auch durchlief, sieht ihn danach.\n\n```text\n$ cdk diff\nResources\n[-] AWS::S3::Bucket Uploads      destroy\n[+] AWS::S3::Bucket UploadBucket  create\n```\n\nDaraus folgt eine konkrete Betriebsregel: Bei Stacks mit Zustand ist der Diff kein optionaler Zwischenschritt, sondern Teil des Reviews. Und zustandsbehaftete Ressourcen, also Buckets, Datenbanken, Tabellen, bekommen eine explizite Removal-Policy, damit eine Stack-Löschung oder eine Ersetzung niemals stillschweigend Daten mitnimmt.\n\n## Abstraktion ist eine Entscheidung, keine kostenlose Annehmlichkeit\n\nDer Aufruf `grantReadWrite` ist ein gutes Beispiel für eine gelungene Abstraktion, weil er Wiederholung entfernt und einen sicheren Default erzwingt. Es gibt aber die andere Sorte. Ein selbst geschriebenes Construct, das einen ganzen Microservice mit Datenbank, Queue, Funktion und Alarm kapselt, ist verlockend. Es macht den aufrufenden Code kurz. Es macht aber auch unsichtbar, welche zwölf Ressourcen und welche Rechte dabei entstehen.\n\nEine gute Abstraktion im CDK reduziert Wiederholung und kapselt vernünftige Voreinstellungen, ohne zu verschleiern, was sie kostet und welche Berechtigungen sie verteilt. Der Prüfstein für ein eigenes Construct ist deshalb nicht seine Eleganz. Er ist die Frage, ob jemand im Review noch versteht, was er sich damit einkauft. Wenn die Antwort nur lautet, dass es funktioniert, ist die Abstraktion noch nicht reif.\n\nHier hilft, das Ergebnis der Synthese gelegentlich anzusehen. Ein `cdk synth` schreibt die fertige Vorlage auf die Konsole. Bei einem unscheinbaren Stack sind das schnell ein paar Hundert Zeilen, und es lohnt sich, dieses Verhältnis zwischen kurzem Code und umfangreicher Vorlage einmal bewusst wahrzunehmen. Es ist der ehrlichste Indikator dafür, wie viel die Abstraktion gerade für einen tut.\n\n## Wie sich das in eine Pipeline einfügt\n\nInfrastruktur als Code entfaltet ihren Wert erst, wenn niemand mehr von Hand deployt. Ich hatte dafür 2019 eine kleine GitHub Action gebaut, die in einem Container das CDK installiert und `deploy` mit AWS-Zugangsdaten aus Repository-Secrets ausführt. Dieses Credential-Pattern ist historisch; für neue Workflows nehme ich OIDC und eine zeitlich begrenzte AWS-Rolle. Geblieben ist der Reiz, dass derselbe Mechanismus Anwendungscode und Infrastruktur ausliefert. Ein Pull Request ändert dann nicht nur den Dienst, sondern auch seinen Unterbau, und beides durchläuft denselben Review.\n\nGenau an dieser Stelle schließt sich der Kreis zur Verantwortung. Wenn ein Merge ausreicht, um Produktionsinfrastruktur zu verändern, dann ist der Review der letzte Ort, an dem ein Mensch eingreifen kann. Der Diff gehört in diesen Review. Die Tests laufen vor dem Deployment. Und für Regeln, die nicht verhandelbar sind, etwa dass kein Bucket öffentlich lesbar sein darf, lohnt sich eine maschinelle Prüfung der synthetisierten Vorlage, eine Art Policy as Code für den eigenen CDK-Output. So wandert Wissen, das sonst von der Tagesform des Reviewers abhängt, in eine Prüfung, die immer gleich streng ist.\n\n## Was ich nach den ersten Wochen mitnehme\n\nDas CDK ist noch jung, die API bewegt sich, und manches wird sich bis zur stabilen Version ändern. Trotzdem ist die Richtung klar und sie überzeugt mich. Infrastruktur wird testbar, wiederverwendbar und refaktorierbar. Das ist ein echter Gewinn gegenüber handgeschriebenem YAML.\n\nDer Gewinn ist aber an eine Haltung gebunden. Wer Infrastruktur programmiert, muss sie wie Software behandeln, mit allem, was dazugehört: kleine, prüfbare Bausteine, ein Test für jede Annahme, die teuer wäre, wenn sie bricht, ein gelesener Diff vor jedem Deployment und eine bewusste Entscheidung darüber, was eine Abstraktion verbergen darf und was nicht. Die logische ID und die Removal-Policy sind dabei keine Randnotizen, sondern die Stellen, an denen sich entscheidet, ob ein Refactoring harmlos bleibt oder Daten kostet.\n\nDer Werkzeugwechsel von YAML zu TypeScript ist also der kleinere Teil der Geschichte. Der größere Teil ist, dass Betrieb und Codequalität jetzt dasselbe Thema sind. Wer das annimmt, bekommt eine Infrastruktur, die man lesen, prüfen und mit Vertrauen ändern kann. Wer es ignoriert, hat am Ende nur die alten Fehler in einer neuen Sprache.\n\n## Weiterführende Quellen\n\n- [AWS CDK v2, Einführung](https://docs.aws.amazon.com/cdk/v2/guide/home.html)\n- [Constructs als Bausteine](https://docs.aws.amazon.com/cdk/v2/guide/constructs.html)\n- [CloudFormation `DeletionPolicy`](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-attribute-deletionpolicy.html) und [`UpdateReplacePolicy`](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-attribute-updatereplacepolicy.html)\n- [GitHub OIDC mit AWS](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws)\n- GitHub Action fürs CDK-Deployment: https://github.com/MikeBild/aws-cdk-js-github-action\n- Policy as Code fürs CDK (cloudpatrol): https://github.com/MikeBild/cloudpatrol"},{"slug":"aws-parameter-store-im-build","title":"AWS Parameter Store im Build","summary":"Parameter Store im Build macht Konfiguration explizit, wirft aber harte Fragen zu Secrets und Reproduzierbarkeit auf.","tags":["Software","Frontend","AWS"],"published_at":"2019-02-12T10:00:00+00:00","updated_at":"2026-07-13T08:36:48.631838+00:00","url":"https://www.mikebild.dev/de/blog/aws-parameter-store-im-build/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/082e5960c2292c7cfb00b9db69345c79198109fdee470a3573bc7518f22f1c55/aws-parameter-store-im-build-vorlesen.mp3","audio_duration_secs":732,"text":"# AWS Parameter Store im Build\n\n```bash\n$ gatsby build\nsuccess Building production JavaScript and CSS bundles - 42.108s\nDone in 71.4s\n\n$ grep -o \"undefined/graphql\" public/app-8f2c1a.js\nundefined/graphql\n```\n\n`GATSBY_API_URL` stand beim Build nicht in `process.env`. Der Build ist trotzdem grün durchgelaufen, das Artefakt ist deploybar, und der leere Wert ist in das ausgelieferte JavaScript eingebacken – auffallen wird er zuerst im Browser eines Nutzers. Kein Absturz, keine Fehlermeldung in der Pipeline, nur ein statisches Bundle, das gegen nichts spricht.\n\nDer konkrete Anlass, das aufzuschreiben, ist ein kleines Gatsby-Plugin, das ich gebaut habe: [gatsby-plugin-aws-parameter-store-environment](https://github.com/MikeBild/gatsby-plugin-aws-parameter-store-environment). Es zieht beim Build Konfigurationswerte aus dem AWS Systems Manager Parameter Store und stellt sie als Environment-Variablen bereit. Fünfzig Zeilen Code, ein sehr überschaubares Problem – und trotzdem eine gute Gelegenheit, eine Architekturfrage sichtbar zu machen, die deutlich größer ist als das Plugin selbst.\n\nDie Frage lautet nicht „Wie hole ich einen Wert aus einem AWS-Service?“, sondern: „Wo gehört die Konfiguration eines statischen Frontends hin, wer besitzt sie, und was kostet mich die Antwort im Betrieb?“ Genau an dieser Grenze entscheidet sich, ob ein Deployment reibungslos läuft oder ob jedes Release ein kleiner manueller Kraftakt bleibt. Ein winziges Plugin ist oft der ehrlichste Ort, um so eine Entscheidung durchzudenken, weil nichts von der eigentlichen Frage ablenkt.\n\n## Das Problem: Konfiguration in einem statischen Build\n\nGatsby erzeugt zur Build-Zeit statisches HTML, CSS und JavaScript. Das ist der Kern des JAMstack-Versprechens: schnelle, cache-freundliche Auslieferung ohne Server im Anfragepfad. Der Preis dafür ist eine Verschiebung, die viele unterschätzen – Konfiguration wird nicht zur Laufzeit gelesen, sondern zur Build-Zeit eingebacken. Was während `gatsby build` in `process.env` steht, landet im Ergebnis. Was fehlt, fehlt für immer, bis der nächste Build läuft.\n\nIn der Praxis wächst daraus schnell ein bekanntes Muster. Jede Umgebung – lokal, Staging, Produktion – bekommt ihre eigene `.env`-Datei. Manche Werte liegen im CI-System als Secret, andere im Git-Repository, ein paar nur im Kopf desjenigen, der zuletzt deployt hat. Es gibt keine einzelne Quelle, sondern eine Streuung. Und Streuung erzeugt Drift: Staging läuft gegen einen anderen API-Endpoint als gedacht, ein Feature-Flag ist in Produktion anders gesetzt als dokumentiert, ein rotierter Zugangsschlüssel wurde an drei von vier Stellen aktualisiert.\n\nDas ist kein exotisches Randproblem. Es ist die häufigste Reibungsquelle zwischen „Code ist fertig“ und „Änderung ist live“. Und es ist teuer, weil es nicht als Ausfall auftritt, sondern als schleichende Unsicherheit: Niemand weiß mehr mit Bestimmtheit, welche Konfiguration ein bestimmter Build wirklich gesehen hat.\n\n## Die Idee: eine einzige Quelle für Build-Konfiguration\n\nDer Parameter Store dreht diese Streuung um. Statt viele `.env`-Dateien zu pflegen, definiere ich die Konfiguration hierarchisch an einer Stelle in AWS, geordnet nach Anwendung und Umgebung:\n\n```bash\n# One source of truth, organized by app and environment\naws ssm put-parameter \\\n  --name \"/mikebild-dev/production/GATSBY_API_URL\" \\\n  --value \"https://api.mikebild.com/graphql\" \\\n  --type \"String\"\n\naws ssm put-parameter \\\n  --name \"/mikebild-dev/production/CONTENTFUL_DELIVERY_TOKEN\" \\\n  --value \"cda-xxxxxxxx\" \\\n  --type \"SecureString\" \\\n  --key-id \"alias/build-config\"\n```\n\nDer Build zieht dann den ganzen Teilbaum einer Umgebung und exportiert ihn als Environment-Variablen. Im `gatsby-config.js` sieht das bewusst unspektakulär aus:\n\n```js\nmodule.exports = {\n  plugins: [\n    {\n      resolve: \"gatsby-plugin-aws-parameter-store-environment\",\n      options: {\n        // Pull the whole subtree for this environment\n        path: `/mikebild-dev/${process.env.DEPLOY_ENV}/`,\n        withDecryption: true,\n      },\n    },\n  ],\n};\n```\n\nDie Hierarchie ist der eigentliche Gewinn, nicht der API-Aufruf. Ein Pfad wie `/app/environment/KEY` gibt der Konfiguration eine Struktur, die man per IAM-Policy schneiden kann: Der Produktions-Build darf `/mikebild-dev/production/*` lesen, sonst nichts. Werte vom Typ `SecureString` liegen mit KMS verschlüsselt, und jeder Zugriff hinterlässt in CloudTrail eine Spur. Aus „irgendwo liegt eine Datei“ wird „ein benannter, versionierter, zugriffskontrollierter Wert mit Audit-Trail“.\n\n## Architektur: Build-Zeit gegen Laufzeit\n\nBevor ich das Plugin einsetze, ziehe ich eine Linie. Sie ist die wichtigste Entscheidung des ganzen Themas, und das Plugin trifft sie nicht für mich.\n\n```mermaid\nflowchart LR\n  PS[\"Parameter Store<br/>/app/env/*\"] -->|build time| B[\"gatsby build\"]\n  B --> OUT[\"Static bundle<br/>HTML / JS / CSS\"]\n  OUT --> CDN[\"CDN / Browser\"]\n  RT[\"Runtime secrets<br/>never here\"] -.->|not in bundle| OUT\n```\n\nAlles, was zur Build-Zeit in das statische Bundle wandert, ist öffentlich. Punkt. Ein API-Endpoint, ein öffentlicher Content-Delivery-Key, ein Feature-Flag, eine Analytics-ID – das darf im ausgelieferten JavaScript stehen, weil es ohnehin für jeden Browser sichtbar ist. Gatsby macht das durch die `GATSBY_`-Präfixkonvention sogar explizit: Diese Variablen werden bewusst in das Client-Bundle eingebettet.\n\nAlles, was ein echtes Geheimnis ist – ein schreibender Datenbank-Zugang, ein privater API-Key, ein Signaturschlüssel – gehört niemals in einen Build-Time-Wert für ein Frontend. Nicht, weil der Parameter Store es nicht schützen könnte, sondern weil das Ziel ein statisches, öffentlich ausgeliefertes Artefakt ist. Der sicherste Speicher der Welt hilft nichts, wenn ich den entschlüsselten Wert danach in eine Datei schreibe, die auf einem CDN landet.\n\nDer Parameter Store verführt hier ein wenig, und das ist die harte Frage zu Secrets: Weil er `SecureString` und KMS anbietet, fühlt sich jeder Wert „sicher“ an. Aber die Sicherheit endet an der Grenze des Builds. Ich sortiere deshalb jeden Parameter vorab in genau eine von zwei Schubladen: „darf öffentlich sein“ oder „ist ein Laufzeit-Geheimnis“. Nur die erste Schublade darf das Plugin überhaupt anfassen. Echte Laufzeit-Secrets gehören in eine Serverless-Function oder ein Backend, das sie zur Laufzeit selbst aus dem Parameter Store liest und nie an den Client gibt.\n\n## Der zweite Trade-off: Reproduzierbarkeit\n\nDie zweite harte Frage ist subtiler und wird gern übersehen. In dem Moment, in dem der Build externe Konfiguration liest, ist er keine Pure Function des Repositories mehr. Derselbe Git-Commit erzeugt nicht mehr zwangsläufig dasselbe Ergebnis – er erzeugt das Ergebnis, das zum externen Zustand des Parameter Store zum Build-Zeitpunkt passt.\n\nDas ist ein realer Verlust. Ändert jemand einen Wert im Parameter Store und stößt kein Rebuild an, driften Deployment und deklarierte Konfiguration auseinander, ohne dass ein Commit das anzeigt. Ein Rollback auf einen alten Commit stellt womöglich nicht die alte Konfiguration wieder her, weil die Parameterwerte sich inzwischen verändert haben. „Funktioniert bei mir“ bekommt eine neue, unangenehme Dimension: bei mir mit dem Parameter-Stand von gestern.\n\nDer Parameter Store hat für dieses Problem ein Werkzeug, und ich halte es für den entscheidenden methodischen Hebel: Jeder Parameter ist versioniert. Ich kann eine konkrete Version referenzieren – `/app/prod/KEY:4` – statt „den jeweils aktuellen Wert“. Damit wird der Build wieder deterministischer: Ein Commit plus ein gepinnter Parameter-Stand ergibt ein reproduzierbares Artefakt. Der Preis ist eine bewusste Disziplin, denn jemand muss die Version anheben und damit die Konfigurationsänderung genauso behandeln wie eine Code-Änderung: sichtbar, nachvollziehbar, rollback-fähig. Genau das ist der Punkt, an dem Konfiguration aufhört, ein Nebenschauplatz zu sein, und Teil der Architektur wird.\n\n## Was das messbar besser macht\n\nDer Aufwand lohnt sich, weil er an drei Stellen konkrete Wirkung zeigt, nicht nur Eleganz.\n\nKürzere Zyklen: Eine neue Umgebung ist ein neuer Pfad im Parameter Store, kein neues Ritual aus `.env`-Dateien, Copy-Paste und Nachfragen im Chat. Ein Deploy braucht keine manuelle Vorbereitung mehr, weil die Konfiguration dort liegt, wo der Build sie ohnehin abholt. Der Weg von „Wert ändern“ zu „Wert ist live“ verkürzt sich auf ein Parameter-Update plus einen Build.\n\nWeniger Reibung: Der Zugriff ist über IAM an Umgebungen gebunden, nicht an Personen mit Dateizugriff. Ein Entwickler kann eine Staging-Konfiguration selbst setzen, ohne an ein produktives Geheimnis zu kommen. Self-Service innerhalb sauberer Grenzen ersetzt das Nadelöhr, an dem sonst eine einzelne Person sitzt, die „die `.env` kennt“.\n\nHöhere Qualität: Config-Drift wird strukturell unwahrscheinlicher, weil es nur noch eine Quelle gibt. Und wenn doch etwas schiefgeht, gibt es einen Audit-Trail. CloudTrail beantwortet die Frage „Wer hat wann welchen Wert geändert?“ – die Frage, die bei einer Streuung aus Dateien und CI-Secrets prinzipiell unbeantwortbar bleibt.\n\nEs gibt noch einen vierten Effekt, der leicht übersehen wird, aber im Alltag viel Reibung spart: Dieselbe Mechanik funktioniert lokal. Ein Entwickler, der die Staging-Werte lesen darf, startet seinen lokalen Build gegen exakt denselben Pfad im Parameter Store wie die Pipeline – kein manuelles Kopieren einer `.env`, kein veralteter lokaler Stand, keine Diskussion darüber, warum etwas lokal anders läuft als in Staging. Das schließt eine der ärgerlichsten Lücken im Entwicklungszyklus, nämlich die zwischen der Konfiguration auf dem Laptop und der Konfiguration im CI. Damit verliert das „Funktioniert bei mir“ von weiter oben einen seiner häufigsten Gründe, weil Laptop und Pipeline aus derselben Quelle lesen. Wichtig bleibt auch hier die Grenze: lokal darf nur der öffentliche, build-taugliche Teilbaum sichtbar sein, niemals ein produktives Laufzeit-Geheimnis.\n\n## Worauf ich vor dem Einsatz prüfe\n\nEine Idee ist erst belastbar, wenn ihr Fehlerpfad beschrieben ist. Bevor ich das Plugin in eine Pipeline hänge, gehe ich ein paar Fragen durch, die im Demo-Modus alle unsichtbar bleiben und im Betrieb entscheiden.\n\nWas passiert, wenn der Parameter Store beim Build nicht erreichbar ist oder ein erwarteter Wert fehlt? Ein Build, der stillschweigend mit einem leeren `process.env.GATSBY_API_URL` durchläuft, ist gefährlicher als einer, der laut abbricht – denn er produziert ein defektes, aber deploybares Artefakt. Ich will hier ein hartes Fail-Fast, kein `undefined`, das erst im Browser des Nutzers auffällt.\n\nIst der Zugriff eng genug geschnitten? Der Build-Runner sollte über eine IAM-Rolle genau den einen Umgebungspfad lesen dürfen und `withDecryption` nur für die Werte, die er wirklich braucht. Ein weit gefasster `ssm:GetParametersByPath`-Zugriff über die gesamte Hierarchie ist bequem und genau die Art von Bequemlichkeit, die ein Sicherheits-Review später teuer macht.\n\nUnd die entscheidende Frage: Liegt in dem Pfad, den der Build liest, versehentlich ein echtes Geheimnis? Das ist kein technischer, sondern ein disziplinärer Fehler – und der häufigste. Deshalb trenne ich die Hierarchie schon in der Benennung, sodass build-öffentliche Werte und Laufzeit-Secrets gar nicht erst im selben Teilbaum landen und ein zu weiter Zugriff nicht sofort ein Leak bedeutet.\n\n## Der typische Fehler\n\nDer verführerischste Fehler bei diesem Thema ist, das Werkzeug mit der Lösung zu verwechseln. Der Parameter Store ist keine Konfigurationsarchitektur, so wenig wie ein verschlüsselter Speicher automatisch eine Secrets-Strategie ist. Er ist ein sehr guter Ort, um eine Grenze auszudrücken – die Grenze zwischen build-öffentlicher Konfiguration und Laufzeit-Geheimnis, zwischen Umgebungen, zwischen dem, was ein Build lesen darf, und dem, was er nie sehen sollte. Aber diese Grenze erzeuge ich, nicht der Service.\n\nGenau darin liegt der eigentliche Wert dieses kleinen Plugins. Es zwingt mich, die Frage zu beantworten, die die `.env`-Streuung erfolgreich verdeckt hat: Was von der Konfiguration meines Frontends ist öffentlich, was ist geheim, und wer besitzt jeden dieser Werte über seinen Lifecycle hinweg? Sobald diese Antwort steht, ist der technische Rest – ein Pfad, ein Plugin, ein `GetParametersByPath` – fast trivial.\n\n## Schluss\n\nParameter Store im Build ist ein gutes Beispiel dafür, dass die interessante Entscheidung selten im Toolnamen steckt. Das Plugin holt Werte aus AWS – das ist die einfache Hälfte. Die tragende Hälfte ist die Methodik dahinter: Konfiguration explizit machen, sie an genau einer Stelle mit klaren Zugriffsgrenzen führen, die Trennung von öffentlich und geheim erzwingen und die Reproduzierbarkeit über gepinnte Versionen zurückholen.\n\nWer diese Linien zieht, gewinnt kürzere Deploy-Zyklen, weniger Reibung bei jeder Umgebung und weniger stille Fehler im Betrieb. Wer sie nicht zieht, hat am Ende einen sehr sicheren Speicher, aus dem trotzdem Geheimnisse in ein öffentliches Bundle sickern. Der Unterschied liegt nicht im Service, sondern in der Entscheidung, die er sichtbar macht."},{"slug":"docker-eigene-registry","title":"Eine eigene Docker Registry betreiben","summary":"Wie ich in Projekten eine private Docker Registry mit registry:2 aufsetze: Images taggen, pushen und pullen, und danach mit htpasswd und TLS absichern.","tags":["Software","Platform"],"published_at":"2018-11-27T10:00:00+00:00","updated_at":"2026-07-13T07:28:25.481931+00:00","url":"https://www.mikebild.dev/de/blog/docker-eigene-registry/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/65a01a1ed070b002a16c1404c84d171de988269e4c5f4fdfba82533fbd6f1315/docker-eigene-registry-vorlesen.mp3","audio_duration_secs":957,"text":"Eine Docker Registry ist kein Dateiserver, auf dem man Images ablegt. Sie ist der Namensraum, über den Docker Images überhaupt adressiert: Ein voll qualifizierter Image-Name hat die Form `host:port/name:tag`, und der Host-Teil darin entscheidet, wohin ein `push` geht und woher ein `pull` kommt. Fehlt er, ist Docker Hub gemeint – auch dann, wenn im Image der eigene Anwendungscode steckt.\n\n**Redaktionelle Einordnung (Juli 2026):** Das Tutorial beschreibt den Stand von 2018. Docker Content Trust basiert auf Notary v1 und wird von Docker abgekündigt; für neue Signatur- und Verifikationsabläufe nennt Docker Sigstore oder Notation. Auch Portus ist keine heutige Produktempfehlung mehr: Das Projekt wurde 2023 archiviert. Der grundlegende Registry-Betrieb mit `registry:2`, TLS, Authentifizierung und immutable Digests bleibt davon unberührt.\n\nWer diesen Host-Teil selbst setzt, entscheidet damit auch, wo die eigenen Images liegen, woher sie stammen und wer dafür geradesteht, dass in Produktion genau das läuft, was gebaut und geprüft wurde. Der Weg dahin ist kurz. Zuerst klären wir, warum eine private Registry überhaupt sinnvoll ist. Dann starten wir `registry:2` mit einem einzigen Kommando, taggen ein Image, pushen es und ziehen es auf einem anderen Host wieder herunter. Weil eine offene Registry ein echtes Problem ist, sichern wir sie mit Basic-Auth und TLS ab. Zum Schluss geht es über das Werkzeug hinaus: um Herkunft, Signatur und Betriebsverantwortung der eigenen Images. Wer Docker Compose schon kennt – etwa aus meinem Artikel zu [Node.js und Redis mit Docker Compose](https://www.mikebild.dev/de/blog/docker-compose-nodejs-redis/) – wird sich hier schnell zurechtfinden.\n\n## Warum überhaupt eine eigene Registry?\n\nDocker Hub ist bequem. Man pusht ein Image, es liegt in der Cloud, jeder kann es ziehen. Genau das ist aber in vielen Firmenkontexten das Problem: Der eigene Anwendungscode steckt im Image, und der soll nicht öffentlich einsehbar sein. Klar, es gibt private Repositories bei Docker Hub, aber dann hängt die gesamte Deployment-Kette an einem externen Dienst und an einer Internetverbindung, die durchaus mal langsam oder gestört sein kann.\n\nEine private Registry im eigenen Netz löst diese Punkte auf einen Schlag. Die Images bleiben intern, das Ziehen läuft über das lokale Netzwerk und ist damit spürbar schneller, und man behält die volle Kontrolle darüber, wo die Daten liegen und wie lange sie aufbewahrt werden. Gerade in einer CI/CD-Pipeline, die viele Builds am Tag durch die Gegend schiebt, macht sich das schnell bemerkbar.\n\nDamit die Unterschiede greifbar werden, stelle ich beides gern direkt gegenüber:\n\n- Die private Registry hoste ich selbst, im eigenen Netz, mit voller Kontrolle über Speicherort und Lifecycle – dafür betreibe und sichere ich sie auch selbst.\n- Docker Hub ist ein gehosteter Dienst mit Accounts, Organisationen, Web-Oberfläche und offiziellen Images – dafür internetabhängig und außerhalb der eigenen Infrastruktur.\n\nBeides hat seine Berechtigung. Offizielle Basis-Images wie `node`, `redis` oder eben `registry` selbst ziehe ich weiterhin von Docker Hub. Die eigenen Anwendungs-Images landen dagegen in der privaten Registry. Das eine schließt das andere nicht aus.\n\n## Die Registry ist selbst nur ein Container\n\nKommen wir zum eigentlichen Werkzeug. Die Open-Source-Registry, die hinter Docker Hub dieselbe Technik verwendet, heißt „Docker Distribution“ und wird als fertiges Image `registry:2` bereitgestellt. Kein separater Server, keine aufwendige Installation – ein `docker run`, und die Registry läuft. Standardmäßig hört sie auf Port 5000.\n\n```bash\ndocker run -d \\\n  -p 5000:5000 \\\n  --restart=always \\\n  --name registry \\\n  -v /mnt/registry:/var/lib/registry \\\n  registry:2\n```\n\nEin paar Dinge sind hier bewusst gesetzt. Das `-d` startet den Container im Hintergrund. `--restart=always` sorgt dafür, dass die Registry nach einem Neustart des Docker-Daemons oder des Hosts von allein wieder hochkommt – für einen Dienst, an dem die ganze Pipeline hängt, will man das. Und ganz wichtig: das Volume mit `-v`. Die Registry legt ihre Images per Default über den Filesystem-Storagetreiber unter `/var/lib/registry` ab. Ohne Volume liegen diese Daten nur im Container. Löscht man den Container, sind die Images weg. Mit dem gemounteten Verzeichnis `/mnt/registry` bleiben sie erhalten.\n\nDamit läuft die Registry. Sie tut jetzt aber noch nichts Sichtbares – sie wartet auf ihr erstes Image.\n\n## Taggen, pushen, pullen\n\nDer entscheidende Punkt beim Umgang mit einer eigenen Registry ist der Image-Name. Docker steuert nämlich nicht über einen separaten Konfigurationsschalter, in welche Registry ein Image wandert, sondern über den Namen selbst. Ein voll qualifizierter Image-Name hat die Form `host:port/name:tag`. Fehlt der Host-Teil, geht Docker automatisch von Docker Hub (`docker.io`) aus. Setze ich dagegen meinen eigenen Registry-Host vor den Namen, weiß Docker beim `push` und `pull` genau, wohin.\n\nNehmen wir an, ich habe lokal ein Image `app:1.0` gebaut. Dann tagge ich es für meine Registry um:\n\n```bash\ndocker tag app:1.0 registry.local:5000/app:1.0\n```\n\n`docker tag` kopiert dabei nichts und baut nichts neu – es hängt lediglich einen zweiten Namen an dasselbe Image. Anschließend schiebe ich es in die Registry:\n\n```bash\ndocker push registry.local:5000/app:1.0\n```\n\nDocker liest den Host-Teil `registry.local:5000` aus dem Namen und lädt die einzelnen Layer dorthin hoch. Auf einem anderen Rechner im selben Netz – oder auf dem CI-Runner, der später das Deployment übernimmt – hole ich das Image genauso wieder herunter:\n\n```bash\ndocker pull registry.local:5000/app:1.0\n```\n\nDas ist der komplette Kreislauf. Bauen, taggen, pushen, an anderer Stelle pullen. Genau diesen Bogen halte ich gern als Diagramm fest, weil er das Zusammenspiel auf einen Blick zeigt:\n\n```mermaid\nflowchart LR\n    A[docker build<br/>app:1.0] --> B[docker tag<br/>registry.local:5000/app:1.0]\n    B --> C[docker push]\n    C --> D[(Private Registry<br/>:5000)]\n    D --> E[docker pull<br/>anderer Host / CI]\n```\n\nAm Registry-Host hängt damit die ganze Mechanik. Das erklärt auch, warum das Umtaggen vor dem Push nötig ist: Ein Image, das nur `app:1.0` heißt, würde Docker in Richtung Docker Hub schicken.\n\n## Der wunde Punkt: Ab Werk steht alles offen\n\nDie Registry, die wir gerade gestartet haben, läuft ohne jede Absicherung. Kein Passwort, keine Verschlüsselung. Jeder, der `registry.local:5000` im Netz erreicht, kann pushen und pullen. Für einen kurzen lokalen Test auf dem eigenen Rechner ist das in Ordnung. Sobald die Registry aber für mehr als einen Rechner erreichbar ist, darf sie so nicht bleiben.\n\nAbsicherung besteht dabei aus zwei voneinander unabhängigen Bausteinen, die man nicht verwechseln sollte:\n\n- TLS verschlüsselt den Transport, damit niemand mitlesen kann, was über die Leitung geht.\n- Basic-Auth per htpasswd schützt den Zugriff, damit nur berechtigte Nutzer pushen und pullen.\n\nDas eine ersetzt das andere nicht. TLS ohne Auth verschlüsselt zwar, lässt aber jeden herein. Auth ohne TLS wiederum wird von der Registry gar nicht akzeptiert – die Passwörter dürften ja im Klartext übers Netz gehen, und das lässt `registry:2` bewusst nicht zu. Beides gehört also zusammen.\n\nSchauen wir uns zuerst die Auth an. Für Basic-Auth braucht die Registry eine `htpasswd`-Datei, und zwar zwingend mit bcrypt-Hashes. Die erzeuge ich mir am einfachsten mit dem `httpd`-Image, das das Werkzeug `htpasswd` mitbringt:\n\n```bash\ndocker run --rm --entrypoint htpasswd httpd:2 \\\n  -Bbn deploy s3cret > auth/htpasswd\n```\n\nDie Flags sind schnell erklärt: `-B` erzwingt bcrypt (das ist Pflicht für die Registry), `-b` übergibt das Passwort direkt in der Kommandozeile, und `-n` gibt das Ergebnis auf stdout aus, statt es in eine Datei zu schreiben – deshalb leite ich die Ausgabe selbst nach `auth/htpasswd` um. Herausgekommen ist ein Nutzer `deploy` mit dem Passwort `s3cret`.\n\nFür TLS brauche ich ein Zertifikat und den passenden Schlüssel. In einer echten Umgebung kommen die von einer internen CA oder von Let's Encrypt; im Testnetz tut es ein selbstsigniertes Paar. Die beiden Dateien lege ich als `domain.crt` und `domain.key` in ein Verzeichnis `certs`.\n\nJetzt starte ich die Registry neu – diesmal mit beiden Schutzschichten. Die Konfiguration übergebe ich über Umgebungsvariablen, die die Registry beim Start ausliest:\n\n```bash\ndocker run -d \\\n  -p 5000:5000 \\\n  --restart=always \\\n  --name registry \\\n  -v \"$(pwd)\"/auth:/auth \\\n  -v \"$(pwd)\"/certs:/certs \\\n  -v /mnt/registry:/var/lib/registry \\\n  -e REGISTRY_AUTH=htpasswd \\\n  -e \"REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm\" \\\n  -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \\\n  -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \\\n  -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \\\n  registry:2\n```\n\nDie `REGISTRY_AUTH_*`-Variablen schalten die htpasswd-Authentifizierung ein und zeigen auf die eben erzeugte Datei im gemounteten `/auth`-Verzeichnis. Die beiden `REGISTRY_HTTP_TLS_*`-Variablen aktivieren TLS mit Zertifikat und Schlüssel aus `/certs`. Von jetzt an muss man sich anmelden, bevor man etwas pushen darf:\n\n```bash\ndocker login registry.local:5000\n```\n\nNach der Anmeldung mit Nutzername und Passwort funktionieren `push` und `pull` wieder wie gewohnt – nur eben abgesichert. Docker merkt sich das Login lokal, sodass die Pipeline nach einmaligem `docker login` durchläuft.\n\nDer Vollständigkeit halber: Statt alles über Umgebungsvariablen zu setzen, kann man der Registry auch eine eigene `config.yml` mitgeben. Dort stehen dieselben Angaben strukturiert unter `auth.htpasswd`, `http.tls` und `storage.filesystem`. Man baut sich dann ein kleines Image `FROM registry:2`, kopiert die Konfiguration hinein und startet sie über den passenden Entrypoint. Beide Wege sind gängig; für den Einstieg finde ich die Variante mit Umgebungsvariablen übersichtlicher, weil man alles in einem Kommando sieht.\n\nWie die beiden Schutzschichten zusammenspielen und wo der unsichere Notausgang liegt, zeigt dieses Bild:\n\n```mermaid\nflowchart LR\n    C[Client] -->|TLS| R[Registry-Container :5000]\n    R -->|htpasswd-Auth| S[(Storage-Volume<br/>/var/lib/registry)]\n    C2[Client ohne TLS] -.HTTP.-> I[insecure-registries<br/>nur Testnetz]\n    I -.-> R\n```\n\n## Wenn kein TLS zur Hand ist\n\nBeim schnellen Ausprobieren kommt es vor, dass gerade kein Zertifikat greifbar ist, man aber trotzdem einen Push testen will. Dann läuft man in eine typische Fehlermeldung: `http: server gave HTTP response to HTTPS client`. Der Docker-Client erwartet nämlich standardmäßig HTTPS und verweigert die Kommunikation mit einer reinen HTTP-Registry.\n\nFür genau diesen Fall gibt es eine Ausnahme – die man aber wirklich nur im abgeschotteten Testnetz einsetzen sollte. Man trägt die Registry auf jedem Client als „insecure“ in die Datei `/etc/docker/daemon.json` ein:\n\n```json\n{\n  \"insecure-registries\": [\"registry.local:5000\"]\n}\n```\n\nDanach muss der Docker-Daemon neu gestartet werden, damit die Änderung greift. Wichtig ist das „auf jedem Client“: Der Eintrag gilt lokal pro Rechner, nicht zentral in der Registry. Wer die Registry auf fünf Hosts nutzt, pflegt den Eintrag fünfmal. Für eine dauerhaft betriebene Registry ist das kein Zustand – da führt an TLS kein Weg vorbei. Kommt ein selbstsigniertes Zertifikat zum Einsatz, muss man es übrigens als CA auf den Clients hinterlegen, sonst misstraut ihm der Docker-Client genauso.\n\n## Die Registry als Kontrollpunkt in der Lieferkette\n\nBis hierhin war die Registry vor allem ein Transportmittel: bauen, taggen, pushen, pullen. Der eigentliche Hebel liegt aber eine Ebene höher. Sobald alle eigenen Images durch eine Registry laufen, die ich selbst kontrolliere, wird sie zum zentralen Punkt, an dem sich die Herkunft dessen entscheidet, was in Produktion läuft. In vielen Firmenkontexten zählt genau das mehr als die paar Sekunden, die ein Pull im lokalen Netz spart.\n\nDer erste Punkt ist die Herkunft. Ein Tag wie `registry.local:5000/app:1.0` ist bequem, aber veränderlich. Nichts hindert jemanden daran, denselben Tag später mit einem anderen Inhalt zu überschreiben – der Name bleibt gleich, das Image dahinter ist ein anderes. Wer verbindlich wissen will, welches Image läuft, referenziert es über seinen Digest statt über den Tag:\n\n```bash\ndocker pull registry.local:5000/app@sha256:9b2a4c...\n```\n\nDer Digest ist der Hash über den Image-Inhalt und damit immutable. Zieht ein CI-Runner oder ein Produktionshost ein Image per Digest, bekommt er garantiert genau die Bits, die einmal gebaut und geprüft wurden – nicht das, was zufällig gerade unter dem Tag liegt. In der Pipeline heißt das: Der Build gibt den Digest aus, das Deployment pinnt genau diesen Digest, und der Tag bleibt nur ein lesbarer Name für Menschen, kein Vertrauensanker.\n\nDer zweite Punkt ist die Signatur. Die Herkunft über den Digest sagt „es ist dasselbe Image“, aber noch nicht „es kommt von dem, von dem es kommen soll“. Dafür gibt es Docker Content Trust. Setze ich `DOCKER_CONTENT_TRUST=1`, signiert Docker die Images beim Push und verlangt beim Pull eine gültige Signatur:\n\n```bash\nexport DOCKER_CONTENT_TRUST=1\ndocker push registry.local:5000/app:1.0\n```\n\nIm Hintergrund verwaltet ein Notary-Dienst die Signaturen und die zugehörigen Schlüssel. Der Gewinn ist, dass ein Client ein untergeschobenes oder manipuliertes Image erkennt und den Pull verweigert, statt es blind auszuführen. Das kostet Aufwand – Schlüssel wollen erzeugt, sicher verwahrt und rotiert werden – und ist für einen reinen Testaufbau überzogen. Sobald Images aber bis in die Produktion laufen, ist die Frage „wurde das von uns gebaut und seitdem nicht angefasst?“ keine akademische mehr.\n\nDer dritte Punkt ist die Betriebsverantwortung, die Kehrseite der ganzen Kontrolle. Wer die Registry selbst betreibt, entscheidet nicht nur, wer pushen und pullen darf. Er ist auch dafür verantwortlich, dass der Dienst läuft, gepatcht ist, seine Daten behält und im Zweifel wiederherstellbar bleibt. Diese Verantwortung verschwindet nicht, sie wechselt nur den Besitzer. Bei Docker Hub trägt sie der Anbieter, bei der eigenen Registry trage ich sie.\n\nDaraus ergab sich 2018 das eigentliche Entscheidungskriterium. `registry:2` reichte, solange Herkunft und Zugriff mit Digests, einer Handvoll htpasswd-Nutzern und Content Trust abgedeckt waren. Sobald aber viele Teams, feingranulare Rechte, nachvollziehbare Audit-Logs und eine belastbare Trennung zwischen „gebaut“ und „produktionsfreigegeben“ gefordert waren, kamen Produkte wie Portus, Sonatype Nexus oder JFrog Artifactory ins Spiel. Portus ist inzwischen archiviert; für eine heutige Auswahl muss der Wartungs- und Supportstatus neu geprüft werden.\n\n## Was man beim Betrieb im Blick behalten sollte\n\nZwei Dinge gehen im ersten Enthusiasmus gern unter. Das erste ist die schon erwähnte Persistenz: Ohne gemountetes Volume verliert man beim nächsten Container-Neustart alle Images. Das Volume ist also nicht optional, sondern Grundausstattung.\n\nDas zweite ist der Speicherplatz. Das Filesystem-Backend wächst mit jedem Push, und alte, längst nicht mehr benötigte Layer verschwinden nicht von allein. Mit der Zeit läuft die Platte voll. Die Registry bringt dafür eine Garbage Collection mit, die man regelmäßig laufen lassen sollte, um nicht mehr referenzierte Blobs aufzuräumen. Das ist im Betrieb schnell vergessen und rächt sich dann Monate später mit einer vollen Festplatte. Ein Backup des Storage-Verzeichnisses gehört ebenfalls dazu – bei einer selbst betriebenen Registry kümmert sich niemand sonst darum. Das sind die konkreten Handgriffe hinter dem Wort Betriebsverantwortung. Dass `registry:2` mit einem Kommando startet, nimmt sie einem nicht ab – sie liegen von da an bei mir.\n\n## Fazit\n\nEine eigene Docker Registry klingt nach Infrastrukturprojekt, ist technisch aber eine Sache von wenigen Minuten – solange man die entscheidenden Punkte verinnerlicht. Die Registry ist selbst nur ein Container, `registry:2`, und der Registry-Host wird über den Image-Namen `host:port/name:tag` angesteuert. Ab Werk steht alles offen, und Absicherung braucht immer beide Bausteine – TLS für den verschlüsselten Transport und htpasswd für den Zugriffsschutz. Der `insecure-registries`-Eintrag ist eine Testnetz-Krücke, kein Betriebszustand.\n\nDer eigentliche Gewinn zeigt sich erst im Betrieb. Die eigenen Images liegen im eigenen Netz, sind schneller verfügbar und verkürzen die Wege in der Pipeline. Über Digests und den damals üblichen Docker Content Trust wurde aus schnellem Ausliefern ein nachvollziehbares Ausliefern – man wusste, was lief und woher es kam. Genauso klar sind die Grenzen: Wo Governance über viele Teams, feingranulare Rechte und lückenlose Audits verlangt ist, endet `registry:2` und ein spezialisiertes Produkt beginnt. Für alles davor reichen ein Container, ein sauber gesetzter Host-Teil im Image-Namen und zwei Schutzschichten.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-docker.html\n- [Deploy a registry server – docs.docker.com](https://docs.docker.com/registry/deploying/)\n- [Run an externally-accessible registry (Basic-Auth + TLS)](https://docs.docker.com/registry/deploying/#run-an-externally-accessible-registry)\n- [Registry configuration reference (config.yml)](https://docs.docker.com/registry/configuration/)\n- [Test an insecure registry (daemon.json)](https://docs.docker.com/registry/insecure/)\n- [docker tag / docker push / docker pull / docker login – CLI-Referenz](https://docs.docker.com/engine/reference/commandline/tag/)\n- [Docker Content Trust und Notary v1](https://docs.docker.com/engine/security/trust/)\n- [Docker: abgekündigte Produkte und empfohlene Alternativen](https://docs.docker.com/retired/)\n- [Portus – archiviertes Repository](https://github.com/SUSE/Portus)\n- [Node.js und Redis mit Docker Compose](https://www.mikebild.dev/de/blog/docker-compose-nodejs-redis/)"},{"slug":"kubernetes-grundlagen-deployment-druck","title":"Kubernetes-Grundlagen durch Deployment-Druck","summary":"Unter dem Druck eines echten Deployments sortieren sich die Kubernetes-Konzepte von selbst nach Wichtigkeit – vom Pod über Deployment und Service bis zum Ingress.","tags":["Software","Platform"],"published_at":"2018-10-24T10:00:00+00:00","updated_at":"2026-07-14T17:06:45.428736+00:00","url":"https://www.mikebild.dev/de/blog/kubernetes-grundlagen-deployment-druck/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/54d0903061f2b963b97aaac292874baf616bf19efcf221a311c4e2ab9efe5bee/kubernetes-grundlagen-deployment-druck-vorlesen.mp3","audio_duration_secs":691,"text":"# Kubernetes-Grundlagen durch Deployment-Druck\n\nUm zwei Uhr nachts stirbt ein Container. Kein Angriff, kein Hardware-Defekt – der Node.js-Prozess darin ist über ein Speicherleck gestolpert, das Betriebssystem hat ihn beendet, und mit dem Prozess ist auch der Container weg. Ab diesem Moment entscheidet eine einzige Frage über den Rest der Nacht: Wer startet ihn neu, wenn niemand hinschaut? Auf einem klassisch betriebenen Server ist die Antwort immer eine Kette aus Menschen und Halbautomatik – ein Monitoring, das jemanden anruft, eine systemd-Unit mit Restart-Regel, ein Admin mit SSH-Zugang und wenig Schlaf. Die Konsequenz aus solchen Nächten ist der eigentliche Grund, warum es Kubernetes gibt: ein System, in dem der Neustart keine menschliche Reaktion ist, sondern der Normalbetrieb einer Maschine, die ununterbrochen Soll und Ist vergleicht.\n\n## Die Doku erklärt alles, nur nicht die Reihenfolge\n\nWer Kubernetes über die Konzept-Dokumentation lernen will, bekommt ein Vokabelheft: Pods, ReplicaSets, Deployments, Services, Endpoints, Ingress, ConfigMaps, Secrets, Volumes, Namespaces, DaemonSets, StatefulSets, Jobs, CronJobs. Alles korrekt beschrieben, alles gleichberechtigt nebeneinander. Genau das ist das Problem. Die Doku ist ein Nachschlagewerk, keine Lernreihenfolge – und ohne Reihenfolge fühlt sich Kubernetes an wie eine Fremdsprache, bei der man erst sprechen darf, wenn man das komplette Wörterbuch auswendig kann.\n\nMir ist das dieses Jahr in einem Projekt aufgefallen, in dem eine bestehende Node.js-Anwendung samt Datenbank in ein Cluster umziehen sollte – mit Termin, nicht irgendwann. Unter diesem Deployment-Druck passierte etwas Interessantes: Die Konzepte sortierten sich von selbst. Nicht nach der Gliederung der Dokumentation, sondern nach der Frage, ohne welches Objekt die Anwendung jetzt nicht läuft. Übrig blieb eine erstaunlich kurze Liste:\n\n- Pod – das, was wirklich läuft\n- Deployment – das, was Pods am Leben hält\n- Service – eine stabile Adresse vor unstabilen Pods\n- Ingress – die Tür nach draußen\n- ConfigMap und Secret – Konfiguration raus aus dem Image\n\nAlles andere – StatefulSets, DaemonSets, NetworkPolicies, die Feinheiten von RBAC – kam erst später dran, als die Anwendung schon lief und der Druck nachgelassen hatte. Die Beispiele, an denen ich diese Reihenfolge durchgespielt habe, liegen öffentlich im Repository [introduction-kubernetes](https://github.com/MikeBild/introduction-kubernetes): eine Materialsammlung rund um eine kleine Todo-Anwendung aus Frontend, Backend und Datenbank, lokal mit Minikube nachvollziehbar.\n\n## Zielzustand statt Kommando-Choreografie\n\nDie wichtigste Grundeinsicht steht in keinem YAML-Beispiel, sondern dahinter: Kubernetes ist deklarativ. Man sagt dem Cluster nicht, was er tun soll, sondern beschreibt, wie die Welt aussehen soll, wenn alles gut ist. Drei Kopien dieses Containers, erreichbar unter diesem Namen, mit dieser Konfiguration. Ein Controller-Loop vergleicht diesen Zielzustand pausenlos mit der Wirklichkeit und korrigiert die Differenz. Stirbt ein Pod, fehlt einer – also wird einer gestartet. Nicht weil jemand einen Befehl gibt, sondern weil Soll und Ist nicht mehr übereinstimmen.\n\n```mermaid\nflowchart LR\n  M[Manifest<br/>todo-api.yaml] -->|kubectl apply| A[API-Server]\n  A --> E[(etcd<br/>Zielzustand)]\n  C[Controller-Loop] -->|liest Soll| E\n  C -->|misst Ist| P[Pods im Cluster]\n  C -->|korrigiert Differenz| P\n```\n\nDas erklärt auch, warum `kubectl apply -f` das zentrale Kommando ist – und nicht eine Choreografie aus „starte dies, dann jenes, dann verdrahte beides\". Auf dem alten Server war ein Deployment ein Drehbuch: Shell-Skripte in der richtigen Reihenfolge, und wehe, Schritt vier schlägt fehl, während Schritt drei schon durch ist. In Kubernetes gibt es dieses Drehbuch nicht mehr. Es gibt nur eine Beschreibung des Zielzustands, und `apply` übergibt sie. Was danach passiert, ist Sache der Controller – auch nachts um zwei.\n\n## Der Pod: das, was wirklich läuft\n\nEin Pod ist die kleinste Einheit, die Kubernetes startet: ein oder mehrere Container, die sich Netzwerk und Lifecycle teilen. In der Praxis fast immer ein Container pro Pod. Die zweite Einsicht unter Druck: Pods sind Wegwerfware. Man legt sie selten direkt an, man pflegt sie nicht, man repariert sie nicht. Ein Pod, der stirbt, wird nicht geheilt, sondern ersetzt – mit neuem Namen und neuer IP-Adresse. Wer das akzeptiert, versteht den Rest des Systems fast von allein. Wer dagegen versucht, an einzelnen Pods zu hängen, per `kubectl exec` hineinzuklettern und Zustand zu retten, kämpft gegen die Plattform statt mit ihr.\n\n## Das Deployment: Selbstheilung als Kern-Idee\n\nWeil Pods sterblich sind, braucht es etwas, das sie am Leben hält. Das ist das Deployment – für mich das eigentliche Herzstück:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: todo-api\nspec:\n  replicas: 3\n  selector:\n    matchLabels:\n      app: todo-api\n  template:\n    metadata:\n      labels:\n        app: todo-api\n    spec:\n      containers:\n        - name: todo-api\n          image: mikebild/todo-api:1.4.2\n          ports:\n            - containerPort: 3000\n          readinessProbe:\n            httpGet:\n              path: /health\n              port: 3000\n            initialDelaySeconds: 5\n          livenessProbe:\n            httpGet:\n              path: /health\n              port: 3000\n            initialDelaySeconds: 15\n```\n\nSeit Kubernetes 1.9 ist `apps/v1` die stabile API-Version dafür, das Rätselraten der frühen Beta-Versionen ist vorbei. Das Deployment beschreibt drei Dinge: wie viele Kopien laufen sollen, wie ein Pod auszusehen hat und woran der Cluster erkennt, ob ein Pod gesund ist. Die Readiness-Probe entscheidet, ob ein Pod Traffic bekommt; die Liveness-Probe entscheidet, ob er neu gestartet wird. Diese Unterscheidung habe ich anfangs unterschätzt – bis ein Backend beim Start zwanzig Sekunden für den Datenbank-Verbindungsaufbau brauchte und ohne Readiness-Probe munter Anfragen in ein noch nicht bereites System liefen.\n\nDer Moment, in dem Kubernetes bei mir „klick\" gemacht hat, war ein bewusst herbeigeführter Ausfall:\n\n```bash\nkubectl apply -f todo-api.yaml\nkubectl get pods --watch\nkubectl delete pod todo-api-7d9f6c8b45-x2x7k   # simulate a crash\n# a replacement pod appears within seconds\nkubectl rollout status deployment todo-api\nkubectl rollout undo deployment todo-api        # roll back a bad release\n```\n\nMan löscht einen Pod, und bevor man die Ausgabe gelesen hat, steht der Ersatz schon da. Das ist die Antwort auf die Zwei-Uhr-nachts-Frage – nicht als Feature-Versprechen, sondern beobachtbar im Terminal. Und `rollout undo` macht aus einem misslungenen Release keinen Notfall mehr, sondern einen Schritt zurück zum letzten funktionierenden Zielzustand.\n\n## Der Service: eine stabile Adresse vor unstabilen Pods\n\nWenn Pods ständig kommen und gehen und dabei ihre IP-Adressen wechseln, kann sich niemand direkt mit ihnen verbinden. Der Service löst genau dieses Problem: eine stabile virtuelle Adresse und ein DNS-Name, dahinter eine wechselnde Menge von Pods, ausgewählt über Labels.\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: todo-api\nspec:\n  selector:\n    app: todo-api\n  ports:\n    - port: 80\n      targetPort: 3000\n```\n\nAb jetzt erreicht jeder Pod im Cluster das Backend unter `http://todo-api` – egal, welche drei Pods gerade dahinterstehen, egal, wie oft sie ersetzt wurden. Das Frontend der Todo-Anwendung kennt keine IP-Adressen mehr, nur noch diesen Namen. Für mich war das der Punkt, an dem sich die Umzugsarbeit zum ersten Mal gelohnt hat: Service-Discovery, die vorher aus gepflegten Konfigurationsdateien und Hoffnung bestand, ist im Cluster schlicht DNS.\n\n## Der Ingress: die Tür nach draußen\n\nServices sind zunächst nur innerhalb des Clusters erreichbar. Für den Weg nach draußen gibt es den Ingress – eine Regel, die Hostnamen und Pfade auf Services abbildet, ausgeführt von einem Ingress-Controller wie ingress-nginx:\n\n```yaml\napiVersion: extensions/v1beta1\nkind: Ingress\nmetadata:\n  name: todo-api\nspec:\n  rules:\n    - host: todo.example.com\n      http:\n        paths:\n          - path: /\n            backend:\n              serviceName: todo-api\n              servicePort: 80\n```\n\n```mermaid\nflowchart LR\n  B[Browser] --> I[Ingress<br/>todo.example.com]\n  I --> S[Service<br/>todo-api]\n  S --> P1[Pod]\n  S --> P2[Pod]\n  S --> P3[Pod]\n```\n\nEhrlicherweise ist der Ingress die unfertigste Ecke dieser Liste: Die Ressource steckt seit Jahren im Beta-Status (`extensions/v1beta1`), der Controller ist nicht Teil des Clusters, sondern muss separat installiert werden, und je nach Umgebung – Minikube, eigenes Cluster, GKE – verhält sich die Sache leicht unterschiedlich. Trotzdem gehört er in die Grundausstattung, denn ohne Tür nach draußen bleibt die schönste Anwendung ein Selbstgespräch.\n\n## ConfigMap und Secret: Konfiguration raus aus dem Image\n\nDer letzte Baustein der Druck-Liste klingt unspektakulär, hat aber die größte Wirkung auf den Alltag: Konfiguration gehört nicht in das Container-Image. Ein Image, das für Staging neu gebaut werden muss, weil eine Datenbank-Adresse anders lautet, ist kein Artefakt, sondern eine Bastelei. ConfigMaps und Secrets ziehen diese Werte aus dem Image heraus in den Cluster:\n\n```yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: todo-api-config\ndata:\n  DATABASE_HOST: \"postgres\"\n  LOG_LEVEL: \"info\"\n```\n\nIm Deployment wird daraus per `envFrom` ein Satz Umgebungsvariablen, und dasselbe Image läuft unverändert lokal in Minikube und im Cluster beim Cloud-Anbieter. Bei Secrets lohnt ein nüchterner Blick: Die Werte sind Base64-kodiert, nicht verschlüsselt. Das trennt sie sauber von der übrigen Konfiguration und hält sie aus Git heraus, ein Tresor ist es nicht.\n\n## Fehlersuche ohne SSH\n\nWas unter Druck ebenfalls sofort gelernt wird: Fehlersuche funktioniert anders als auf einem Server. Es gibt kein SSH auf „die Maschine\", weil unklar ist, welche Maschine überhaupt gemeint wäre. Stattdessen fragt man den Cluster, was er über den Zustand weiß:\n\n```bash\nkubectl describe pod todo-api-7d9f6c8b45-x2x7k\nkubectl logs todo-api-7d9f6c8b45-x2x7k\nkubectl logs todo-api-7d9f6c8b45-x2x7k --previous   # logs of the crashed container\nkubectl get events --sort-by=.metadata.creationTimestamp\n```\n\n`describe` zeigt die Events – dort stehen die Klassiker im Klartext: `ImagePullBackOff`, wenn das Image nicht gefunden wird, `CrashLoopBackOff`, wenn der Container beim Start stirbt. Und `logs --previous` zeigt die Ausgabe des Containers, der gerade abgestürzt ist – genau das, was man um zwei Uhr nachts wissen will. Einfacher als vorher ist das nicht unbedingt, aber es ist systematischer: Der Cluster protokolliert, was er entschieden hat, und man muss es nicht aus vier verschiedenen Logdateien zusammenraten.\n\n## Was der Druck nicht wegdiskutiert\n\nZur Ehrlichkeit gehört auch die andere Seite. Für eine einzige kleine Anwendung stehen jetzt fünf YAML-Dateien im Repository, zusammen deutlich über hundert Zeilen – für etwas, das mit Docker Compose in zwanzig Zeilen beschrieben wäre. Helm verspricht hier Linderung durch Paketierung, bringt aber in Version 2 mit Tiller eine eigene Server-Komponente mit weitreichenden Rechten ins Cluster – ein Tausch von YAML-Menge gegen Betriebs- und Sicherheitsfragen, den ich noch nicht in jedem Projekt eingehen würde. Und auch wenn Amazon seit Juni mit EKS als letzter der großen Anbieter ein verwaltetes Kubernetes anbietet und GKE wie AKS den Cluster-Aufbau abnehmen: Die Konzepte nehmen sie einem nicht ab, und ein selbst per kubeadm aufgesetztes Cluster ist noch einmal eine ganz eigene Betriebsaufgabe.\n\nDeshalb die nüchterne Gegenprobe, bevor der Umzug beginnt. Ein einzelner Host mit Docker Compose reicht schlicht aus, wenn ein paar Dinge zusammenkommen:\n\n- die Anwendung läuft auf einer Maschine, und kurze Ausfälle sind verkraftbar\n- eine Restart-Policy im Compose-File deckt den nächtlichen Prozess-Tod ausreichend ab\n- es gibt kein Team, das parallel deployt, und keine Last, die Skalierung erfordert\n\nDann kauft Kubernetes vor allem Komplexität ein und liefert wenig zurück. Der Punkt, an dem die Rechnung kippt, ist erstaunlich präzise benennbar: sobald die Frage „wer startet das neu, wenn niemand hinschaut?\" nicht mehr mit „die eine Maschine, hoffentlich\" beantwortet werden darf.\n\n## Was ich daraus mitnehme\n\nDeployment-Druck ist ein unangenehmer, aber ehrlicher Lehrer. Er hat mir die Konzept-Doku nicht ersetzt, aber er hat sie sortiert: erst der Pod als das, was wirklich läuft, dann das Deployment als Wächter darüber, dann der Service als stabile Adresse, der Ingress als Tür nach draußen, ConfigMap und Secret als ausgelagerte Konfiguration. Fünf Objekte, mit denen eine echte Anwendung produktiv läuft – der Rest des Wörterbuchs kann warten, bis er gebraucht wird.\n\nDie tiefere Einsicht ist die deklarative Denkweise. Der Wechsel vom Drehbuch („führe diese Schritte aus\") zum Zielzustand („so soll es aussehen\") verändert, wie man über Betrieb nachdenkt – Ausfälle sind keine Zwischenfälle mehr, sondern Differenzen, die ein Controller ausgleicht. Und die Zwei-Uhr-nachts-Frage vom Anfang bekommt damit ihre beste Antwort: Es startet ihn niemand neu. Als der Erste morgens auf das Dashboard schaut, ist der Neustart eine Zeile in den Events – passiert, protokolliert, erledigt.\n\n## Weiterführende Quellen\n\n- Repository mit den Beispielen: https://github.com/MikeBild/introduction-kubernetes\n- Kubernetes-Dokumentation zu Deployments: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/\n- Kubernetes-Dokumentation zu Services: https://kubernetes.io/docs/concepts/services-networking/service/\n- Kubernetes-Dokumentation zu Ingress: https://kubernetes.io/docs/concepts/services-networking/ingress/\n- ConfigMaps in Pods verwenden: https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/"},{"slug":"docker-logging-mit-elk","title":"Zentrales Logging mit dem ELK-Stack in Docker","summary":"Container-Logs verstreuen sich über viele Container und sind flüchtig. Dieser Artikel zeigt, wie ein ELK-Stack aus Elasticsearch, Logstash und Kibana im Compose-Setup Logs zentralisiert, indiziert und durchsuchbar macht.","tags":["Software","Platform"],"published_at":"2018-09-18T10:00:00+00:00","updated_at":"2026-07-13T10:00:10.602524+00:00","url":"https://www.mikebild.dev/de/blog/docker-logging-mit-elk/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/f3f09b2e69b9edea710a7faf454d612e680162ce453f44a9a603890cab7ca70f/docker-logging-mit-elk-vorlesen.mp3","audio_duration_secs":1010,"text":"`docker logs <container>` skaliert bis genau eins. Bei einem Frontend, einer API, einer Datenbank und einem Cache sind es vier Aufrufe in vier Terminals, bei drei Hosts und ein paar Replikaten schnell fünfzehn – und jeder einzelne zeigt nur seinen Ausschnitt, und das nur so lange, wie der Container lebt.\n\n**Redaktionelle Einordnung (Juli 2026):** Das Beispiel verwendet bewusst Docker Compose und ELK 6.4 aus dem Jahr 2018. Es ist kein aktuelles Produktions-Template. Unabhängig von der Version unterstützt der Docker-GELF-Treiber sowohl UDP als auch TCP; die frühere Aussage, er könne ausschließlich UDP, war bereits 2018 falsch.\n\nDie Entscheidung dahinter ist weniger eine technische als eine strategische. Sie lautet nicht „welchen Logging-Driver konfiguriere ich?“, sondern „wie viel Sichtbarkeit in mein System will ich haben, und was zahle ich dafür an Speicher, Rechenzeit und Betriebsaufwand?“ Zentrales Logging ist kein Feature, das man anschaltet, sondern eine Investition mit laufenden Kosten. Wer das ELK-Setup als reine API-Übung angeht, baut sich zuverlässig ein System, das entweder zu wenig zeigt oder die Platte volllaufen lässt. Deshalb steht die Einordnung der Logs hier vor dem ersten umkonfigurierten Container.\n\nGenau da setzt der ELK-Stack an. In diesem Artikel gehe ich den Weg durch, den ich auch in Projekten gehe: erst Logs in die Observability-Strategie einordnen, dann verstehen, wo Docker die Logs eigentlich hinschreibt und welche Logging-Driver es gibt, danach einen kompletten ELK-Stack per Docker Compose aufsetzen, die Logs eines App-Containers dorthin schicken und am Ende in Kibana durchsuchbar machen. Dazwischen sammle ich die Fallstricke ein, die mir in der Praxis am häufigsten begegnet sind.\n\n## Logs als eine von drei Säulen\n\nBevor ein einziger Driver umgestellt wird, gehört die Einordnung geklärt: Logs sind nur eine von drei Säulen, auf denen Sichtbarkeit in ein verteiltes System steht, und keine der drei ersetzt die andere. Metrics sind aggregierte Zahlen über die Zeit – Requests pro Sekunde, Fehlerrate, Speicherauslastung – und beantworten „ist gerade etwas kaputt, und seit wann?“. Logs sind einzelne Events mit Kontext und beantworten „was genau ist in diesem einen Fall passiert?“. Traces verfolgen einen Request quer über mehrere Services und beantworten „an welcher Stelle im Pfad hängt es?“. Der ELK-Stack deckt die mittlere Säule ab, und es hilft ungemein, das bewusst zu wissen: Wer mit Logs allein eine Fehlerrate über die Zeit beobachten will, zahlt dafür viel mehr, als eine simple Metric gekostet hätte.\n\nUnd damit sind wir beim Kern der Sache, den Kosten. Logs sind die verführerischste und zugleich teuerste der drei Säulen, weil jede einzelne Zeile gespeichert und in Elasticsearch standardmäßig indiziert wird. Der Preistreiber heißt Kardinalität. Ein Feld mit wenigen möglichen Werten – etwa ein `log_level` mit `info`, `warn`, `error` – ist billig zu indizieren. Ein Feld mit fast unbegrenzt vielen unterschiedlichen Werten – eine `request_id`, eine `user_id`, ein voller Query-String – treibt die Größe des Index und damit den Speicher- und RAM-Bedarf nach oben. Bei Metrics wird eine solche hohe Kardinalität schnell existenzbedrohend, bei Logs ist sie teuer, aber beherrschbar, solange man sie bewusst steuert.\n\nDiese Steuerung ist die eigentliche Entwurfsarbeit, und sie besteht aus wenigen Hebeln. Erstens: Was landet überhaupt im Log, und auf welchem Level? Ein `DEBUG`, das in Produktion mitläuft, ist reiner Kostentreiber ohne Gegenwert. Zweitens: Wie lange bewahre ich Logs auf? Der tagesbasierte Index, den ich weiter unten baue, ist genau dafür der Hebel – ganze Tage lassen sich in einem Rutsch löschen. Drittens: Muss wirklich jedes Event vollständig indiziert werden, oder reicht bei hohem Volumen eine Stichprobe? Die Regel dahinter ist schlicht und in der Umsetzung trotzdem lästig: Geloggt wird nicht alles, was möglich ist, sondern das, was man im Fehlerfall wirklich lesen will – für den Rest zahlt man nicht. Der Rest dieses Artikels zeigt die Mechanik; dass sie mit einem Kostenbewusstsein aufgesetzt wird, entscheidet darüber, ob das Setup nach drei Wochen noch trägt.\n\n## Wo Docker-Logs standardmäßig landen\n\nDocker hat ein Konzept, das viele im ersten Moment überrascht: Es kümmert sich nicht darum, was in eurer Anwendung passiert, sondern nur darum, was auf STDOUT und STDERR geschrieben wird. Alles, was der Prozess mit PID 1 im Container auf diese beiden Streams ausgibt, fängt Docker ab und reicht es an einen sogenannten Logging-Driver weiter.\n\nDer Default-Driver heißt `json-file`. Er schreibt jede Logzeile als JSON-Objekt in eine Datei auf dem Host, typischerweise unter `/var/lib/docker/containers/<id>/<id>-json.log`. Genau aus dieser Datei liest `docker logs`. Das ist auch der Grund für eine Eigenheit, die in der Praxis für Verwirrung sorgt: `docker logs` funktioniert nur mit `json-file` (und `journald`). Stellt man den Driver auf `gelf` oder `syslog` um, weil man die Logs woanders hinschicken will, dann ist der lokale `docker logs`-Befehl für diesen Container leer. Die Logs sind nicht verschwunden, sie werden nur nicht mehr lokal vorgehalten.\n\nDie verfügbaren Driver, Stand 2018, sind eine ganze Reihe:\n\n- `json-file` und `journald` – lokal, mit `docker logs` lesbar.\n- `syslog`, `gelf`, `fluentd`, `splunk` – Weiterleitung an einen zentralen Empfänger.\n- `awslogs`, `gcplogs`, `etwlogs` – plattformspezifische Ziele.\n- `none` – Logging komplett aus.\n\nFür die Zentralisierung sind vor allem `gelf` und `syslog` interessant. Sie schicken die Logzeilen über das Netzwerk an einen Endpoint weiter – und dieser Endpoint kann Logstash sein.\n\nEin Logging-Driver lässt sich pro Container beim `docker run` setzen:\n\n```bash\ndocker run \\\n  --log-driver=gelf \\\n  --log-opt gelf-address=udp://logstash:12201 \\\n  --log-opt tag=\"myapp\" \\\n  myorg/myapp:latest\n```\n\nIn Compose sieht das gleiche pro Service so aus:\n\n```yaml\nversion: \"3\"\nservices:\n  app:\n    image: myorg/myapp:latest\n    logging:\n      driver: \"gelf\"\n      options:\n        gelf-address: \"udp://logstash:12201\"\n        tag: \"myapp\"\n```\n\nEin Detail, das man kennen sollte: Der `gelf`-Driver unterstützt UDP und TCP. In diesem Beispiel ist UDP gewählt. Das ist schnell und schlank, bestätigt den Empfang aber nicht – geht eine Nachricht unter Last verloren, ist sie weg. TCP arbeitet verbindungsorientiert, macht aus einem Log-Transport jedoch noch keinen revisionssicheren Audit-Trail.\n\n## Die drei Rollen im ELK-Stack\n\nBevor wir Compose schreiben, lohnt sich der Blick auf die drei Buchstaben. ELK steht für Elasticsearch, Logstash und Kibana, und jede Komponente hat genau eine klar umrissene Aufgabe. Elasticsearch speichert und indiziert: Es ist die Suchmaschine im Zentrum, die jede Logzeile durchsuchbar macht und die Daten vorhält. Logstash nimmt an, parst und transformiert – es ist die Eingangstür, die Logs aus verschiedenen Quellen entgegennimmt, sie in Struktur bringt und an Elasticsearch weiterreicht; seine Pipeline besteht aus `input`, `filter` und `output`. Und Kibana visualisiert und durchsucht: die Weboberfläche, in der man Logs filtert, Dashboards baut und Muster sichtbar macht.\n\nDer Datenfluss ist eine kurze Kette:\n\n```mermaid\nflowchart LR\n    A[App Container] -->|stdout / gelf| B[Logstash]\n    B -->|index| C[Elasticsearch]\n    C -->|query| D[Kibana]\n    E[Browser] -->|http :5601| D\n```\n\nDie App schreibt nach STDOUT, der Docker-Driver leitet an Logstash weiter, Logstash indiziert in Elasticsearch, und Kibana macht das Ganze im Browser sichtbar. Wichtig ist: Die Anwendung selbst weiß von alldem nichts. Sie loggt nach STDOUT und STDERR – so, wie es das Twelve-Factor-Prinzip vorsieht – und der Transport ist Sache der Infrastruktur. Kein eigenes Logfile-Handling im Container, keine Rotationslogik in der App. Das hält die Anwendung sauber und macht den Log-Pfad austauschbar.\n\n## Der Compose-Stack\n\nJetzt bauen wir den Stack. Ich pinne die Images bewusst auf eine feste Version – in diesem Fall die 6.4er-Reihe – weil ein ungetaggtes `elasticsearch:latest` die zuverlässigste Quelle für „bei mir sieht es aber anders aus“ ist. Hier die `elk-compose.yml`:\n\n```yaml\nversion: \"3\"\nservices:\n  elasticsearch:\n    image: docker.elastic.co/elasticsearch/elasticsearch:6.4.0\n    environment:\n      - discovery.type=single-node\n      - \"ES_JAVA_OPTS=-Xms512m -Xmx512m\"\n    volumes:\n      - es-data:/usr/share/elasticsearch/data\n    ports:\n      - \"9200:9200\"\n      - \"9300:9300\"\n\n  logstash:\n    image: docker.elastic.co/logstash/logstash:6.4.0\n    volumes:\n      - ./pipeline:/usr/share/logstash/pipeline\n    ports:\n      - \"12201:12201/udp\"\n    depends_on:\n      - elasticsearch\n\n  kibana:\n    image: docker.elastic.co/kibana/kibana:6.4.0\n    environment:\n      - ELASTICSEARCH_URL=http://elasticsearch:9200\n    ports:\n      - \"5601:5601\"\n    depends_on:\n      - elasticsearch\n\nvolumes:\n  es-data:\n```\n\nEin paar Punkte, die in der Konfiguration nicht zufällig so stehen. Elasticsearch bekommt `discovery.type=single-node`, damit es sich nicht vergeblich nach Cluster-Kollegen umschaut. Der Heap wird mit `ES_JAVA_OPTS` fix auf 512 Megabyte gesetzt – zum Ausprobieren reicht das, in Produktion würde man großzügiger dimensionieren, aber immer mit gleichem `Xms` und `Xmx`. Und die Daten liegen auf einem Named Volume `es-data`, damit sie einen Neustart des Containers überleben. Das ist kein Detail: Vergisst man das Volume, sind bei jedem `docker-compose down` alle indizierten Logs verloren – ausgerechnet beim Log-System besonders ärgerlich.\n\nKibana bekommt mit `ELASTICSEARCH_URL` die Adresse von Elasticsearch. Der Servicename `elasticsearch` funktioniert als Hostname, weil Compose alle Services in dasselbe Netzwerk hängt und Namensauflösung mitbringt.\n\nJetzt fehlt noch die Logstash-Pipeline. Die liegt als `pipeline/logstash.conf` neben der Compose-Datei und definiert, wie Logstash Nachrichten annimmt und wohin es sie schreibt:\n\n```ruby\ninput {\n  gelf {\n    port => 12201\n  }\n}\n\nfilter {\n  # room for grok, mutate, date & co.\n}\n\noutput {\n  elasticsearch {\n    hosts => [\"elasticsearch:9200\"]\n    index => \"docker-logs-%{+YYYY.MM.dd}\"\n  }\n}\n```\n\nDer `gelf`-Input lauscht auf Port 12201 – genau der Port, an den unser App-Service seine Logs schickt. Der `filter`-Block ist hier leer, aber genau dort passiert in echten Setups die eigentliche Arbeit: Zeilen mit `grok` in Felder zerlegen, Zeitstempel mit `date` normalisieren, Felder mit `mutate` umbenennen. Der `output`-Block schreibt nach Elasticsearch, und zwar in einen tagesbasierten Index `docker-logs-2018.09.18`. Dieses Muster ist bewusst gewählt: Ein Index pro Tag macht das spätere Aufräumen alter Logs einfach, weil man ganze Indizes löschen kann, statt einzelne Dokumente zu suchen.\n\n## Die App anschließen\n\nFehlt noch die Anwendung, deren Logs wir sehen wollen. Ich hänge einen App-Service an denselben Stack und gebe ihm den `gelf`-Logging-Driver mit:\n\n```yaml\n  app:\n    image: myorg/myapp:latest\n    logging:\n      driver: \"gelf\"\n      options:\n        gelf-address: \"udp://localhost:12201\"\n        tag: \"myapp\"\n    depends_on:\n      - logstash\n```\n\nBeim `gelf`-Driver gibt es eine Stolperstelle, die beim ersten Aufsetzen fast garantiert auftaucht: Die `gelf-address` wird vom Docker-Daemon auf dem Host aufgelöst, nicht aus dem Container-Netzwerk heraus. Deshalb steht dort `udp://localhost:12201` und der gemappte Host-Port, nicht der Servicename `logstash`. Der Daemon kennt das Compose-interne DNS nicht – er sieht nur den Port, den Logstash auf den Host veröffentlicht.\n\nStartet man den Stack mit `docker-compose -f elk-compose.yml up`, fährt zuerst Elasticsearch hoch, dann Logstash und Kibana, dann die App. Ab jetzt fließt jede Logzeile der App als GELF-Nachricht an Logstash, wird indiziert und ist in Kibana sichtbar.\n\nEin Wort zur Reihenfolge: `depends_on` steuert nur, in welcher Reihenfolge Docker die Container startet – nicht, ob der jeweilige Dienst schon einsatzbereit ist. Elasticsearch braucht nach dem Start einige Sekunden, bis der Index grün ist, und in dieser Zeit meldet Kibana im Browser gerne „Kibana server is not ready yet“. Das ist kein Fehler, das ist die Realität asynchroner Startvorgänge. Einmal durchatmen, Seite neu laden.\n\n## In Kibana durchsuchen\n\nKibana läuft auf Port 5601. Beim ersten Aufruf will es einen Index Pattern wissen – man gibt `docker-logs-*` ein, wählt das Zeitfeld aus, und schon zeigt der Discover-Tab die eintrudelnden Logzeilen. Von hier aus filtert man nach dem `tag`-Feld (`myapp`), sucht nach Volltext in den Nachrichten, grenzt auf Zeiträume ein und baut bei Bedarf Visualisierungen. Der Aha-Moment kommt zuverlässig genau hier: Aus verstreuten `docker logs`-Aufrufen über mehrere Terminals wird eine einzige durchsuchbare Oberfläche.\n\n## Fallstricke aus der Praxis\n\nDrei Dinge gehen bei diesem Setup am häufigsten schief, und alle drei habe ich mehr als einmal live erlebt.\n\nDer Klassiker ist, dass Elasticsearch gar nicht erst startet und im Log `max virtual memory areas vm.max_map_count [65530] is too low` steht. Elasticsearch 6.x verlangt auf dem Host `vm.max_map_count=262144`. Das ist eine Kernel-Einstellung, kein Container-Parameter, und muss auf dem Docker-Host gesetzt werden:\n\n```bash\nsudo sysctl -w vm.max_map_count=262144\n```\n\nAuf einer Docker-Machine mit VirtualBox setzt man das entsprechend in der VM. Vergisst man es, hilft kein Neustart des Containers – Elasticsearch stirbt reproduzierbar beim Hochfahren.\n\nDer zweite Fallstrick ist das Log-Volumen. Der Default-Driver `json-file` schreibt unbegrenzt auf die Platte. Ein gesprächiger Container kann so eine Entwicklungsmaschine über Nacht volllaufen lassen. Deshalb gehört bei `json-file` immer eine Begrenzung dazu:\n\n```yaml\n    logging:\n      driver: \"json-file\"\n      options:\n        max-size: \"10m\"\n        max-file: \"3\"\n```\n\nUnd beim hier gewählten UDP-Transport von `gelf` gilt, was ich oben schon angerissen habe: Unter Last können Nachrichten verloren gehen, ohne dass es irgendwo knallt. Für kritische Logs kommen GELF über TCP oder ein gepufferter Collector infrage. Ob ein Log vollständig, unveränderbar und nachweisbar sein muss, bleibt darüber hinaus eine eigene fachliche Anforderung.\n\nDer dritte Punkt ist die schon erwähnte Startkopplung. Logstash und Kibana brauchen ein erreichbares Elasticsearch. `depends_on` garantiert nur den Start, nicht die Readiness. In Produktion löst man das mit Health-Checks und Retry-Logik; beim lokalen Ausprobieren reicht meist das Wissen, dass „not ready“ in den ersten Sekunden normal ist und kein Grund zur Panik.\n\n## Zwei Wege der Weiterleitung\n\nZum Abschluss noch eine Einordnung, die zeigt, dass der Logging-Driver nicht der einzige Weg ist. Grundsätzlich gibt es zwei Ansätze, Logs aus Containern zu Logstash zu bekommen. Der erste ist der, den wir hier gegangen sind: Der Docker-Logging-Driver am App-Service schickt direkt weiter. Der zweite ist ein Log-Collector als Sidecar – ein zusätzlicher Container wie logspout, der den Docker-Socket liest und die gesammelten Logs aller Container per syslog weiterleitet. Der Vorteil des Collector-Ansatzes ist, dass man an den App-Services nichts ändern muss und `docker logs` weiterhin funktioniert, weil die Container beim Default-Driver bleiben. Der Nachteil ist eine weitere bewegliche Komponente im Stack. Welcher Weg besser passt, hängt davon ab, wie viel Kontrolle man über die einzelnen Services hat – bei fremden Images ist der Collector oft die pragmatischere Wahl.\n\nWer den ELK-Stack in ein größeres Compose-Setup einbetten will, findet in [Node.js und Redis mit Docker Compose](https://www.mikebild.dev/de/blog/docker-compose-nodejs-redis/) die Grundlagen für mehrteilige Stacks. Mehrere parallele Projekte trennt Compose über eigene Projektnamen und die daraus abgeleiteten Netzwerke.\n\n## Fazit\n\nContainer-Logs sind per Default lokal und flüchtig – das ist bei einem Container kein Problem und bei zwanzig ein großes. Der ELK-Stack löst das mit einer klaren Arbeitsteilung: Logstash nimmt an und transformiert, Elasticsearch speichert und indiziert, Kibana macht durchsuchbar. Docker liefert mit seinen Logging-Drivern die Brücke, um die Logs eines Containers überhaupt erst dorthin zu bekommen, ohne die Anwendung anfassen zu müssen.\n\nDie Wirkung, auf die es mir dabei ankommt, ist eine Verschiebung im Arbeitsalltag. Der Weg von der Störung zur Ursache wird kürzer, weil man nicht mehr über mehrere Terminals hinweg `docker logs` zusammensucht, sondern in einer Oberfläche filtert. Die Reibung sinkt, weil die App sauber bleibt – sie loggt nach STDOUT und STDERR, der Transport bleibt Sache der Infrastruktur und damit austauschbar. Und die Qualität der Diagnosen steigt, weil ein durchsuchbarer Verlauf Muster sichtbar macht, die im flüchtigen Einzel-Log untergehen. Wer die drei großen Fallstricke kennt – `vm.max_map_count`, unbegrenztes Log-Volumen und die Readiness beim Start – hat diesen Weg in einem Nachmittag hinter sich.\n\nEbenso wichtig ist die Kenntnis der Grenzen. Logs sind eines von mehreren Telemetriesignalen, nicht die ganze Observability: Für Trends über die Zeit sind Metrics billiger, für den Weg eines Requests durch mehrere Services sind Traces das richtige Werkzeug. Die Kosten wachsen mit Volumen und Kardinalität, weshalb Aufbewahrung, Log-Level und die Frage, welche Felder indiziert werden, bewusste Entscheidungen bleiben müssen. Und das hier verwendete `gelf` über UDP eignet sich für unkritische Anwendungslogs, aber nicht für Audit-Trails oder Abrechnungsdaten, bei denen keine Nachricht verloren gehen darf. Innerhalb dieser Grenzen bleibt der Kern einfach: Die App loggt nach STDOUT und STDERR, alles andere ist Infrastruktur – und was diese Infrastruktur kostet, entscheidet man selbst.\n\n## Weiterführende Quellen\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-docker.html\n- [Docker GELF logging driver – TCP- und UDP-Konfiguration](https://docs.docker.com/engine/logging/drivers/gelf/)\n- [Docker 17.11 Release Notes – Einführung der GELF-TCP-Unterstützung](https://docs.docker.com/engine/release-notes/17.11/)\n- [Docker: Configure logging drivers](https://docs.docker.com/config/containers/logging/configure/)\n- [Docker: json-file logging driver](https://docs.docker.com/config/containers/logging/json-file/)\n- [Docker: gelf logging driver](https://docs.docker.com/config/containers/logging/gelf/)\n- [Docker: syslog logging driver](https://docs.docker.com/config/containers/logging/syslog/)\n- [Elastic: Getting started with the Elastic Stack and Docker Compose](https://www.elastic.co/blog/getting-started-with-the-elastic-stack-and-docker-compose)\n- [Elasticsearch 6.4: Install with Docker](https://www.elastic.co/guide/en/elasticsearch/reference/6.4/docker.html)\n- [Kibana 6.4: Running Kibana on Docker](https://www.elastic.co/guide/en/kibana/6.4/docker.html)\n- [Logstash 6.4: Running Logstash on Docker](https://www.elastic.co/guide/en/logstash/6.4/docker.html)\n- [Referenz-Stack: deviantony/docker-elk](https://github.com/deviantony/docker-elk)"},{"slug":"micro-frontends-teamschnitt-browser","title":"Micro-Frontends: Teamschnitt im Browser","summary":"Warum die eigentliche Arbeit bei Micro-Frontends nicht in der Integrationstechnik steckt, sondern im Teamschnitt – gezeigt an einer lauffähigen Beispiel-Landschaft aus Gateway, vier Anwendungen und einer Komponenten-Bibliothek.","tags":["Software","Frontend"],"published_at":"2018-08-21T10:00:00+00:00","updated_at":"2026-07-14T17:04:47.915864+00:00","url":"https://www.mikebild.dev/de/blog/micro-frontends-teamschnitt-browser/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/9b987ac70a9ef15b671bb56cc12c66a81cca417481151665466b9f6411d86871/micro-frontends-teamschnitt-browser-vorlesen.mp3","audio_duration_secs":729,"text":"# Micro-Frontends: Teamschnitt im Browser\n\nVier Teams, ein `index.html`, ein gemeinsames Deployment pro Woche. Die Rechnung dahinter sieht harmlos aus und ist es nicht: Jedes Release verlangt zwei Tage Code-Freeze, eine Regressionsrunde über die komplette Oberfläche und vier Freigaben. Ist ein Team nicht fertig, warten drei andere. Bei fünfzig Arbeitswochen im Jahr sind das im besten Fall fünfzig Gelegenheiten, etwas auszuliefern – und in der Praxis deutlich weniger, weil ein einziger fehlgeschlagener Smoke-Test das gesamte Release zurückhält. Dabei sind die Backends dieser Teams längst getrennte Services mit eigenen Pipelines. Im Browser aber laufen alle vier in einem einzigen JavaScript-Bundle wieder zusammen, und mit dem Bundle kehrt genau der Engpass zurück, den die Aufteilung in Services eigentlich beseitigen sollte.\n\n## Ein Organisationsproblem mit technischem Symptom\n\nMicro-Frontends werden gern als Frontend-Thema verhandelt: welches Framework, welche Integrationstechnik, wie klein ein Fragment sein darf. Mir ist in den letzten Monaten aufgefallen, dass diese Diskussion regelmäßig am Kern vorbeiläuft. Die eigentliche Frage lautet: Bildet die Struktur im Browser die Struktur der Organisation ab – oder arbeitet sie dagegen? Conways Beobachtung von 1968 gilt auch hier: Systeme spiegeln die Kommunikationswege der Organisation, die sie baut. Wenn vier Teams fachlich getrennt arbeiten, aber eine gemeinsame Single-Page-Anwendung pflegen, entsteht die Reibung genau an dieser Naht – als Merge-Konflikte in der Router-Konfiguration, als Abstimmungsrunden über CSS-Klassen, als Release-Kalender, der zum Dauerthema wird.\n\nNeu ist das Muster nicht. Der ThoughtWorks Technology Radar führt „Micro Frontends\" seit November 2016, seit Mai dieses Jahres im Ring „Trial\". Zalando betreibt mit dem Mosaic-Projekt und dem Layout-Service Tailor seit gut zwei Jahren eine solche Architektur in Produktion. Neu ist höchstens die Einsicht, die sich beim Nachbauen einstellt: Der technische Teil ist der einfache.\n\n## Eine lauffähige Beispiel-Landschaft\n\nUm das nicht nur zu behaupten, habe ich im Juli das Repository `introduction-micro-frontends` zusammengestellt – eine lauffähige Beispiel-Landschaft mit einem Gateway auf Port 7000, dahinter vier eigenständige Anwendungen: Home (7010), Docs (7020), Products (7030) und Contact (7040), dazu eine Catalog-API, eine GraphQL-API auf Port 9000 und eine gemeinsame Komponenten-Bibliothek auf Port 5000. Jede Anwendung startet für sich, hat ihr eigenes Build und könnte von einem eigenen Team betrieben werden. Das Gateway kennt die Anwendungen über eine schlichte Service-Discovery – wahlweise aus Dateien, aus Docker/Kubernetes oder aus der Google Cloud – und routet nach Pfad.\n\n```mermaid\nflowchart LR\n  B[Browser] --> G[Gateway<br/>:7000]\n  G -->|/| H[home-app<br/>:7010<br/>Team A]\n  G -->|/docs| D[docs-app<br/>:7020<br/>Team B]\n  G -->|/products| P[products-app<br/>:7030<br/>Team C]\n  G -->|/contact| C[contact-app<br/>:7040<br/>Team D]\n  P --> Q[graphql-api<br/>:9000]\n  H -. shared UI .-> L[components-library<br/>:5000]\n  P -. shared UI .-> L\n```\n\nDas Diagramm zeigt bereits die zentrale Entwurfsentscheidung: Die Schnittlinien verlaufen vertikal. Jedes Team besitzt einen fachlichen Ausschnitt von der Route bis zur API – nicht eine horizontale Schicht wie „alle Buttons\" oder „das gesamte CSS\". Genau dieser Schnitt macht unabhängige Deployments erst möglich; alles andere ist Handwerk.\n\n## Der einfache Teil: Integration\n\nDie Integrationstechniken sind schnell aufgezählt, und keine davon ist spektakulär.\n\nDie älteste ist der Hyperlink. Wenn `/products` und `/contact` getrennte Anwendungen sind, reicht ein `<a href=\"/contact\">` – der Browser lädt die nächste Seite, das nächste Team übernimmt. Kein geteilter Code, keine geteilte Runtime, dafür ein sichtbarer Seitenwechsel. Für viele Übergänge ist das völlig ausreichend, und es ist die einzige Integration, die garantiert nie bricht.\n\nSollen Fragmente verschiedener Teams auf einer Seite erscheinen, komponiert man serverseitig. Server-Side-Includes beherrscht jeder nginx von Haus aus:\n\n```nginx\n# gateway: compose one page from team fragments\nlocation / {\n  ssi on;\n  proxy_pass http://home-app:7010;\n}\n\nlocation /fragment/header {\n  proxy_pass http://components-library:5000/header;\n}\n```\n\n```html\n<!-- home-app: index.html -->\n<!--#include virtual=\"/fragment/header\" -->\n<main>\n  <!--#include virtual=\"/fragment/product-teaser\" -->\n</main>\n```\n\nZalandos Tailor treibt dasselbe Prinzip weiter und streamt Fragmente parallel, mit Timeout und Fallback pro Fragment. Edge-Side-Includes verlagern die Komposition in den Varnish- oder CDN-Layer. Allen Varianten gemeinsam: Die Seite entsteht aus unabhängig deploybaren Teilen, und ein langsames Fragment blockiert nicht das ganze Release, sondern höchstens seinen eigenen Platzhalter.\n\nClientseitig sind Custom Elements die sauberste Klammer – seit v1 in Chrome und Safari nativ, in Firefox und Edge im Moment noch per Polyfill:\n\n```js\n// products-app: expose a fragment as a custom element\nclass ProductTeaser extends HTMLElement {\n  connectedCallback() {\n    const sku = this.getAttribute('sku');\n    fetch(`/products/api/teaser/${sku}`)\n      .then((res) => res.text())\n      .then((html) => { this.innerHTML = html; });\n  }\n\n  disconnectedCallback() {\n    // team C cleans up its own listeners here\n  }\n}\n\ncustomElements.define('product-teaser', ProductTeaser);\n```\n\nDas aufnehmende Team schreibt `<product-teaser sku=\"42\"></product-teaser>` und muss nicht wissen, mit welchem Framework das Fragment intern gebaut ist. Die DOM-Schnittstelle – Attribute rein, Events raus – ist der Vertrag zwischen den Teams. Daneben gibt es iframes: verrufen, aber die einzige Technik mit vollständiger Isolation von CSS, JavaScript und globalem State. Für das Zahlungsformular eines Drittanbieters oder ein Altmodul, das niemand mehr anfassen will, nach wie vor eine legitime Wahl. Und schließlich orchestriert single-spa mehrere Framework-Anwendungen innerhalb einer Single-Page-Anwendung – das löst das Routing-Problem elegant, holt aber alle Teams zurück in eine gemeinsame Runtime mit gemeinsamem Fehlerraum.\n\nWelche Technik trägt, entscheidet sich an wenigen Fragen:\n\n- Wie viel Seitenwechsel verträgt der Nutzerfluss – reicht ein Link?\n- Muss die Seite ohne JavaScript ein brauchbares Erstbild liefern (SSI, Tailor), oder ist die Zielgruppe ohnehin angemeldet (Client-Komposition)?\n- Wie hart müssen Fragmente isoliert sein – genügt Namensdisziplin, oder braucht es die iframe-Grenze?\n- Wer betreibt den Kompositionspunkt: ein Plattform-Team das Gateway, oder jedes Team seinen eigenen Einstieg?\n\n## Sorgfaltspflichten: Routing, Abhängigkeiten, CSS\n\nDrei Dinge muss man trotzdem sauber regeln, sonst holt einen der Monolith durch die Hintertür wieder ein.\n\nErstens die Routing-Hoheit. Die URL ist die öffentliche Schnittstelle der gesamten Anwendung, also braucht sie einen Eigentümer. Im Beispiel-Repository besitzt das Gateway die oberste Ebene – `/products` gehört Team C, und zwar vollständig, inklusive aller Unterpfade. Sobald zwei Teams über einen Pfad verhandeln müssen, ist der Schnitt falsch gezogen. Deep-Links müssen dabei funktionieren wie auf jeder anständigen Website: Ein Reload auf `/products/42` landet beim richtigen Team, ohne dass ein zentraler Client-Router davon wissen muss. Das Gateway selbst bleibt bewusst dumm – es kennt Pfade und Ziele, aber keine Fachlichkeit. Jede Regel mehr an dieser Stelle macht es zum nächsten gemeinsamen Deployment, über das wieder alle abstimmen müssen.\n\nZweitens die geteilten Abhängigkeiten. Vier Anwendungen, die je ihr eigenes React bündeln, schicken das Framework viermal über die Leitung – bei einer Bibliothek von gut 100 KB ist das messbar, aber selten der Untergang. Die Alternative, eine zentral vorgegebene Framework-Version für alle, klingt sparsamer und kostet mehr: Jedes Framework-Update wird wieder ein Vier-Team-Projekt, und der gemeinsame Release-Takt ist zurück. Im Repository bündelt deshalb jede Anwendung selbst. Diesen Preis zahle ich bewusst, weil er in Kilobyte anfällt statt in Meetings.\n\nDrittens die CSS-Isolation. CSS hat einen globalen Namensraum, und der ist in einer komponierten Seite das Einfallstor für Fernwirkung: Team B ändert eine Klasse `.card`, und bei Team C verrutscht das Layout. Präfix-Konventionen pro Team helfen sofort und kosten nichts. Shadow DOM kapselt Styles vollständig, hängt aber am selben Browser-Support wie Custom Elements. Der zuverlässigste Schutz bleibt Disziplin: keine globalen Selektoren, keine Element-Selektoren, kein `!important` über Team-Grenzen hinweg.\n\n## Der schwere Teil: Wem gehört der Header?\n\nBis hierher war alles Technik, und Technik lässt sich ausprobieren. Die Fragen, an denen solche Vorhaben tatsächlich hängen bleiben, stehen in keiner nginx-Konfiguration.\n\nDer Header ist das beste Beispiel. Er erscheint auf jeder Seite, gehört fachlich niemandem und technisch allen. In einem Projekt haben wir drei Antworten durchgespielt. Ein eigenes „Header-Team\" schafft eine neue horizontale Schicht und damit einen neuen Engpass – jede fachliche Änderung wird zum Ticket bei einem anderen Team. Die zweite Antwort: Ein fachliches Team besitzt den Header, alle anderen binden ihn als Fragment ein. Das funktioniert, solange dieses Team die Wünsche der anderen ernst nimmt – der Header wird zum internen Produkt mit internen Kunden. Die dritte Antwort steht im Beispiel-Repository: Der Header liegt in der Komponenten-Bibliothek, versioniert, und jedes Team aktualisiert in seinem eigenen Takt. Der Preis ist Ehrlichkeit gegenüber der Realität – für ein paar Tage sind zwei Header-Versionen gleichzeitig live. Wer das nicht aushält, hat sich gegen unabhängige Deployments entschieden und sollte es dann auch so nennen.\n\nDieselbe Struktur hat die Design-Frage. Ein gemeinsames Erscheinungsbild entsteht nicht durch ein zentrales CSS, sondern durch einen Styleguide, der wie ein Produkt gepflegt wird: mit Versionen, Changelog und Ansprechpartnern. Atomic Design liefert dafür ein brauchbares Vokabular – kleine, dumme Bausteine werden geteilt, zusammengesetzte Views gehören den Teams. Je höher eine Komponente in dieser Hierarchie steht, desto teurer ist ihr Teilen.\n\n## Was geteilter State kostet\n\nDie härteste Währung ist geteilter State. Das Standardbeispiel: Team C legt auf der Produktseite einen Artikel in den Warenkorb, und der Mini-Warenkorb im Header – Besitz eines anderen Teams – soll die neue Anzahl anzeigen. Der naheliegende Reflex ist ein globaler Store, den alle Fragmente lesen und schreiben. Damit handelt man sich exakt die Kopplung wieder ein, die man gerade losgeworden ist: ein gemeinsames Zustandsschema, dessen Änderung alle Teams gleichzeitig betrifft, plus die Frage, wessen Version des Stores geladen wird.\n\nDie günstigere Antwort: Fragmente teilen keinen Speicher, sie teilen Events. Das DOM bringt den Mechanismus mit, ganz ohne zusätzliche Bibliothek:\n\n```js\n// products-app: announce the change, share no memory\nwindow.dispatchEvent(new CustomEvent('cart:item-added', {\n  detail: { sku: '42', quantity: 1 }\n}));\n\n// header fragment: react and re-fetch its own truth\nwindow.addEventListener('cart:item-added', () => {\n  fetch('/cart/api/summary')\n    .then((res) => res.json())\n    .then((summary) => updateBadge(summary.count));\n});\n```\n\nDer Header hält seinen eigenen State und holt sich die Wahrheit aus der Warenkorb-API; das Event ist nur der Anstoß. Der Vertrag zwischen den Teams schrumpft auf einen Event-Namen und ein kleines `detail`-Objekt – beides lässt sich dokumentieren und abwärtskompatibel erweitern.\n\nÄhnlich liegt der Fall beim Anmeldestatus, dem zweiten Dauerkandidaten für geteilten State. Kein Fragment braucht das Session-Objekt eines anderen Teams: Ein Cookie auf der gemeinsamen Domain, vom Gateway geprüft, genügt als geteilte Tatsache – jedes Fragment entscheidet selbst, was es einem abgemeldeten Besucher zeigt. Wo das nicht reicht, etwa für den Anzeigenamen im Header, liefert wieder eine kleine API die Antwort, statt dass ein globales Objekt durch alle Fragmente gereicht wird. Trotzdem gilt: Jedes solche Event ist eine Schnittstelle, und Schnittstellen brauchen Pflege. Wenn die Liste der teamübergreifenden Events zweistellig wird, ist das kein Tooling-Problem, sondern ein Hinweis, dass der fachliche Schnitt nicht stimmt – die Teams arbeiten dann längst am selben Gegenstand, nur eben gegeneinander.\n\n## Unterm Strich\n\nDer Merksatz, den ich aus dem Bau der Beispiel-Landschaft mitnehme: Micro-Frontends kauft man nicht als Technik, man kauft sie als Organisationsform – die Technik ist in zwei Wochen gelernt, der Teamschnitt nicht. Wer nur ein Frontend-Team hat, braucht das alles nicht; ein gut strukturierter Monolith mit sauberen Modulgrenzen ist dann die ehrlichere Architektur. Interessant wird der Umbau, wenn Freigaberunden, Code-Freeze und Release-Queues messbar Zeit kosten und mehrere Teams tatsächlich unabhängig liefern könnten, wenn man sie ließe. Dann lohnt es sich, zuerst die unbequemen Fragen zu klären – wem gehört der Header, wer pflegt den Styleguide, welche Events bilden den Vertrag – und erst danach zwischen SSI, Custom Elements und iframe zu wählen. Die Integrationstechnik ist am Ende austauschbar. Der Teamschnitt, den sie in den Browser verlängert, ist es nicht.\n\n## Weiterführende Quellen\n\n- Repository mit den lauffähigen Beispielen: https://github.com/MikeBild/introduction-micro-frontends\n- ThoughtWorks Technology Radar zu Micro Frontends: https://www.thoughtworks.com/radar/techniques/micro-frontends\n- Zalando Tailor, Layout-Service aus Project Mosaic: https://github.com/zalando/tailor\n- single-spa, Router für mehrere Framework-Anwendungen: https://github.com/single-spa/single-spa\n- MDN zu Custom Elements: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements"},{"slug":"frontend-schnittstellen-sind-apis","title":"Frontend-Schnittstellen sind APIs","summary":"Warum Props, URLs, Events, CSS-Klassen und localStorage dieselbe Versionierungs-Disziplin verdienen wie ein REST-Endpoint – spätestens, wenn mehrere Teams im selben Browser arbeiten.","tags":["Software","Frontend"],"published_at":"2018-08-12T10:00:00+00:00","updated_at":"2026-07-14T20:57:53.475496+00:00","url":"https://www.mikebild.dev/de/blog/frontend-schnittstellen-sind-apis/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/ea2c67d34b5781137f583e2546b713433ab870ddb57c44b513595c1b9a18596e/frontend-schnittstellen-sind-apis-vorlesen.mp3","audio_duration_secs":692,"text":"# Frontend-Schnittstellen sind APIs\n\n```diff\n UserCard.propTypes = {\n-  name: PropTypes.string.isRequired,\n+  fullName: PropTypes.string.isRequired,\n   avatarUrl: PropTypes.string,\n   onSelect: PropTypes.func\n };\n```\n\nEin Diff wie dieser geht im Review anstandslos durch. Eine Umbenennung, der neue Name ist sprechender, die eigene Anwendung läuft, die Tests sind grün, gemergt. Drei Wochen später zieht ein anderes Team die neue Version der gemeinsamen Komponenten-Bibliothek, und in deren Browser-Konsole steht:\n\n```\nWarning: Failed prop type: The prop `fullName` is marked as required\n  in `UserCard`, but its value is `undefined`.\n```\n\nDas ist noch der gute Fall, weil `propTypes` wenigstens laut wird. Der schlechtere Fall: Die Karte rendert einfach ohne Namen, niemand schaut in die Konsole, und der Fehler fällt erst auf, wenn ein Anwender ihn meldet – zwei Releases später, mit einem Diff, in dem nichts Verdächtiges mehr steht.\n\nHätte jemand dasselbe mit einem Feld in einer REST-Antwort gemacht, also `name` kommentarlos in `fullName` umbenannt, ohne Ankündigung und ohne Versionssprung, wäre der Ärger groß und berechtigt. Bei einer Komponenten-Prop passiert genau das ständig, und kaum jemand nennt es beim Namen. Dabei ist die Lage identisch: Es gibt einen Vertrag zwischen zwei Codebasen, die von unterschiedlichen Teams zu unterschiedlichen Zeitpunkten geändert und ausgeliefert werden. Das ist die Definition einer API. Dass der Vertrag aus JSX besteht statt aus HTTP, ändert daran nichts.\n\n## Mehr Oberflächen, als im Architekturdiagramm stehen\n\nIn einem Portal-Projekt, an dem ich länger mitgearbeitet habe, liefen Anwendungsteile von vier Teams im selben Browser-Tab: eine Shell, zwei Fachanwendungen, ein eingebettetes Widget. Als wir nach einem besonders zähen Integrationsfehler einmal aufgeschrieben haben, worüber diese Teile tatsächlich miteinander reden, kam eine Liste heraus, die deutlich länger war als alles, was je in einem Architekturdiagramm stand:\n\n- Props und Callbacks der geteilten Komponenten-Bibliothek\n- URLs mit Pfaden und Query-Parametern, auf die verlinkt wird\n- CustomEvents und `postMessage`-Nachrichten zwischen Seitenteilen\n- CSS-Klassen und CSS Custom Properties, die andere Teile gezielt verwenden\n- localStorage-Schlüssel, die mehr als ein Modul liest\n\nJede dieser Oberflächen hatte Konsumenten außerhalb des Teams, das sie pflegte. Keine davon war versioniert. Nur die erste war überhaupt dokumentiert. Die REST-APIs desselben Projekts hatten dagegen Versionspräfixe, Changelogs und eine Deprecation-Richtlinie – weil dort allen klar war, dass fremder Code von ihnen abhängt. Im Frontend war dieselbe Abhängigkeit da, nur unsichtbar.\n\n```mermaid\nflowchart LR\n  subgraph FE[Frontend eines Teams]\n    P[Props & Callbacks]\n    U[URLs & Parameter]\n    E[CustomEvents /<br/>postMessage]\n    C[CSS-Klassen &<br/>Custom Properties]\n    S[localStorage-<br/>Schlüssel]\n  end\n  A[Team B:<br/>Fachanwendung] --> P\n  B[Nutzer: Bookmarks,<br/>geteilte Links] --> U\n  M[Mails, Kampagnen,<br/>Monitoring-Checks] --> U\n  W[Widget im iframe] --> E\n  T[Team C:<br/>Theming] --> C\n  X[Altes Portal-Skript] --> S\n```\n\nDas Diagramm ist keine Übertreibung, sondern die aufgeräumte Version unserer damaligen Liste. Und es erklärt, warum „wir refactoren nur intern\" im Frontend so oft schiefgeht: Was intern aussieht, hat längst Konsumenten.\n\n## Props: der Vertrag zwischen Teams\n\nFür die Komponenten-Bibliothek haben wir daraus die naheliegende Konsequenz gezogen: Sie wird wie eine API behandelt. Konkret heißt das dreierlei. Erstens ist die Prop-Signatur der Vertrag, und `propTypes` machen ihn maschinenlesbar. Zweitens gilt Semantic Versioning ernsthaft – eine entfernte oder umbenannte Prop ist ein Breaking Change und damit eine Major-Version, egal wie klein der Diff aussieht. Drittens bekommt jede Änderung ein Deprecation-Fenster: Die alte Form funktioniert weiter, warnt aber im Entwicklungsmodus.\n\nFür das `UserCard`-Beispiel von oben sieht das so aus:\n\n```jsx\nimport React from 'react';\nimport PropTypes from 'prop-types';\n\n// v2.3.0: `name` is deprecated in favor of `fullName`.\n// Both work until v3.0.0, where `name` will be removed.\nexport function UserCard({ fullName, name, avatarUrl, onSelect }) {\n  const displayName = fullName || name;\n\n  if (name && process.env.NODE_ENV !== 'production') {\n    console.warn(\n      'UserCard: prop `name` is deprecated, use `fullName` instead. ' +\n      'Support for `name` will be removed in v3.0.0.'\n    );\n  }\n\n  return (\n    <div className=\"user-card\" onClick={onSelect}>\n      {avatarUrl && <img className=\"user-card__avatar\" src={avatarUrl} alt=\"\" />}\n      <span className=\"user-card__name\">{displayName}</span>\n    </div>\n  );\n}\n\nUserCard.propTypes = {\n  fullName: PropTypes.string,\n  name: PropTypes.string, // deprecated, see above\n  avatarUrl: PropTypes.string,\n  onSelect: PropTypes.func\n};\n```\n\nDas ist mehr Code als die reine Umbenennung, und genau dieser Mehraufwand ist der Punkt. Er zwingt zu der Frage, ob die Umbenennung den Preis wert ist. Bei einer internen Hilfsfunktion ist die Antwort fast immer ja, weil der Compiler oder die Suche alle Aufrufer findet. Bei einer veröffentlichten Prop findet man die Aufrufer nicht – sie sitzen in Repositories, auf die man keinen Zugriff hat.\n\nAls Dokumentation hat sich bei uns Storybook bewährt – als lebende Vertragsbeschreibung, nicht bloß als hübscher Komponenten-Katalog: Für jede Komponente gibt es Stories, die die unterstützten Prop-Kombinationen zeigen, inklusive einer Story für die deprecatete Form, solange sie existiert. Wer wissen will, was `UserCard` kann, schaut nicht in den Quellcode des Komponenten-Teams, sondern in die gebaute Storybook-Instanz der Version, die er einsetzt. Das Team, das die Bibliothek pflegt, merkt dadurch außerdem selbst früher, wenn eine Änderung den Vertrag berührt – eine kaputte Story ist ein deutliches Signal.\n\n## URLs: der Vertrag mit allen anderen\n\nDie zweite Oberfläche wird noch häufiger unterschätzt: die URL. Routen fühlen sich wie ein internes Implementierungsdetail des Routers an, sind aber das genaue Gegenteil – die öffentlichste Schnittstelle, die ein Frontend hat. Auf eine Route zeigen Bookmarks, per Mail geteilte Links, Kampagnen, Monitoring-Checks, die Dokumentation des Nachbarteams und gelegentlich ein PDF, das vor zwei Jahren gedruckt wurde. Keiner dieser Konsumenten bekommt ein Update, wenn jemand im Code `/clients` in `/customers` umbenennt.\n\nEine Route umzubenennen ist deshalb ein Breaking Change mit besonders langlebigen Konsumenten, und die Deprecation-Phase heißt hier Redirect. Mit React Router v4 ist das eine Zeile plus Disziplin:\n\n```jsx\nimport { Switch, Route, Redirect } from 'react-router-dom';\n\n<Switch>\n  <Route path=\"/customers/:customerId\" component={CustomerPage} />\n  {/* Deprecated 2018-08: keep this redirect for at least 12 months. */}\n  <Redirect from=\"/clients/:customerId\" to=\"/customers/:customerId\" />\n</Switch>\n```\n\nDie Disziplin steckt im Kommentar: Der Redirect bleibt, bis die alten Links realistisch aus der Welt sind, nicht bis zum nächsten Aufräum-Commit. In dem Portal-Projekt haben wir irgendwann Zugriffe auf alte Routen mitgezählt, bevor ein Redirect entfernt wurde – dieselbe Praxis, mit der man auch einen alten REST-Endpoint abschaltet. Die Zahlen waren regelmäßig ernüchternd: Links, die wir für tot hielten, wurden noch Monate später aufgerufen.\n\nDasselbe gilt für Query-Parameter. Wer `?tab=invoices` unterstützt, hat einen Vertrag geschlossen, sobald der erste Nutzer den Link teilt. Und die URL ist gleichzeitig persistierter State: Was in ihr steht, übersteht Reloads und wandert zwischen Browsern – ein Grund mehr, sie bewusst zu entwerfen statt sie dem Router-Zufall zu überlassen. Bewusst entwerfen heißt vor allem: fachliche Bezeichner statt interner IDs, keine Implementierungsdetails im Pfad, und ein Schema, das ein Kollege ohne Blick in den Code erraten kann. Eine URL, die man am Telefon vorlesen kann, überlebt erfahrungsgemäß auch das nächste Redesign.\n\n## Events und Messages: Verträge zur Laufzeit\n\nSobald mehrere Anwendungsteile im selben Browser laufen, reden sie irgendwann direkt miteinander – bei uns über CustomEvents auf `window` und über `postMessage` zum eingebetteten Widget. Diese Nachrichten sind die flüchtigste Schnittstellenform: kein Typ-Check, kein gemeinsames Repository, oft nicht einmal ein gemeinsames Deployment. Genau deshalb brauchen sie den explizitesten Vertrag.\n\nWir haben dafür jede Nachricht wie ein kleines Wire-Format behandelt – benannter Typ, dokumentiertes Payload-Schema, Versionsfeld:\n\n```js\n// Contract: 'cart:item-added' (version 1)\n// detail: { version: number, sku: string, quantity: number, source: string }\nwindow.dispatchEvent(new CustomEvent('cart:item-added', {\n  detail: { version: 1, sku: 'A-4711', quantity: 1, source: 'product-page' }\n}));\n\n// Consumer side: tolerate unknown versions instead of guessing.\nwindow.addEventListener('cart:item-added', (event) => {\n  if (event.detail.version !== 1) return;\n  updateCartBadge(event.detail.quantity);\n});\n```\n\nDas Versionsfeld wirkt übervorsichtig, bis zum ersten Mal zwei Seitenteile mit unterschiedlichem Release-Stand im selben Tab laufen. Dann ist es der Unterschied zwischen einem ignorierten Event und einem `undefined`, das drei Funktionen weiter explodiert. Bei `postMessage` kommt die Origin-Prüfung dazu, aber das Prinzip bleibt: Der Empfänger validiert, der Sender verspricht Abwärtskompatibilität innerhalb einer Version. Custom Elements v1 macht diese Denkweise übrigens offiziell – ein Element mit Attributen und Events ist nichts anderes als eine im Browser registrierte API.\n\n## CSS und localStorage: die Verträge, die niemand schließen wollte\n\nDie letzten beiden Oberflächen sind die unangenehmsten, weil sie Verträge sind, die nie jemand bewusst geschlossen hat. Wer BEM-Klassen wie `user-card__avatar` ins DOM schreibt, publiziert sie. Irgendwo stylt ein anderes Team gegen diese Klasse, ein Test selektiert sie, ein Analytics-Snippet hängt daran. Nach derselben Logik sind CSS Custom Properties, die ein Theme nach außen anbietet – `--brand-primary`, `--spacing-unit` –, eine echte, gewollte API und sollten auch so gepflegt werden: benannt, dokumentiert, stabil. Die ungewollte Variante lässt sich eindämmen, indem man klar trennt, welche Klassen Vertragsbestandteil sind und welche generiert oder als privat markiert sind. Aber die Grundregel bleibt: Was beobachtbar ist und lange genug stabil bleibt, wird benutzt werden – unabhängig davon, was die Dokumentation dazu sagt.\n\nlocalStorage ist derselbe Fall in schlimmer: global pro Origin, ohne Namensräume, ohne Schema. Ein Schlüssel wie `user-settings`, den zwei Module lesen, ist eine ungewollte Datenbank-API inklusive Migrationsproblem – ändert ein Modul das Format, parst das andere Müll. Dazu kommt eine Eigenschaft, die Storage von allen anderen Oberflächen unterscheidet: Die alten Werte sind schon draußen. Ein Deployment tauscht den Code aus, aber nicht die Daten in tausenden Browsern – jede Formatänderung trifft dort auf Werte, die eine ältere Version geschrieben hat. Seit dem Portal-Projekt bekommen solche Schlüssel bei mir deshalb ein Präfix mit Modulname und eine Versionsnummer im Wert, und der lesende Code behandelt ein unbekanntes Format wie einen fehlenden Wert. Nicht weil das elegant wäre, sondern weil die Alternative ein Produktionsfehler ist, den man nur mit „Storage im DevTools-Panel leeren\" diagnostizieren kann.\n\n## Versionierung ist Kommunikation, nicht Bürokratie\n\nDer Einwand liegt nahe: Das ist viel Prozess für ein Frontend. Aber der Aufwand ist gestaffelt, und der größte Teil ist schlicht Kommunikation. Für die Komponenten-Bibliothek in unserem Lerna-Monorepo sah das Fenster für einen Breaking Change so aus:\n\n- Änderung im Changelog ankündigen, mit altem und neuem Vertrag\n- eine Minor-Version, in der beides funktioniert und die alte Form im Entwicklungsmodus warnt\n- Storybook-Story für die alte Form als deprecated markieren\n- Entfernung erst in der nächsten Major-Version, mit Migrationshinweis\n\nDas kostet pro Änderung vielleicht eine Stunde. Der Prop-Rename ohne dieses Fenster hat uns damals, alle Beteiligten zusammengerechnet, mehrere Tage gekostet – Fehlersuche in einer fremden Codebasis, Hotfix, Sonderrelease, und das Vertrauen zwischen zwei Teams gleich mit. Über die schludrige Behandlung von Code, der zwischen den Zuständigkeiten liegt, habe ich unter anderem in [Build-Skripte als Produktcode](https://www.mikebild.dev/de/blog/build-skripte-als-produktcode/) schon einmal geschrieben; Frontend-Schnittstellen werden auf dieselbe Weise übersehen, nur hängen hier mehr Teams daran.\n\nMir ist dabei aufgefallen, dass die Teams gar nicht an der Disziplin scheitern, sondern an der Wahrnehmung. Niemand weigert sich, eine API sauber zu versionieren – man sieht nur nicht, dass man gerade eine hat. Der wirksamste Schritt war deshalb nicht das Tooling, sondern die Liste vom Anfang: einmal aufschreiben, welche Oberflächen das eigene Frontend tatsächlich anbietet und wer daran hängt. Alles, was auf dieser Liste steht und einen Konsumenten außerhalb des eigenen Teams hat, verdient die drei bekannten Zutaten – expliziter Vertrag, semantische Version, Deprecation-Fenster.\n\n## Unterm Strich\n\nEin Frontend ist kein Blatt am Ende des Abhängigkeitsbaums, sondern selbst ein Anbieter von Schnittstellen: Props, URLs, Events, CSS-Oberflächen, gespeicherte Schlüssel. Solange ein einziges Team alles davon besitzt und in einem Schritt ausliefert, darf man das ignorieren. Spätestens wenn mehrere Teams im selben Browser arbeiten – und die Design-System- und Micro-Frontend-Bewegung sorgt gerade dafür, dass das der Normalfall wird –, gelten die Regeln, die im Backend seit Jahren selbstverständlich sind. Breaking Change heißt Major-Version. Umbenennen heißt deprecaten. Und ein harmlos aussehender Diff ist genau so harmlos wie die Zahl der Konsumenten, die niemand gefragt hat.\n\n## Weiterführende Quellen\n\n- Semantic Versioning 2.0.0 (deutsche Fassung): https://semver.org/lang/de/\n- React-Dokumentation: Typechecking mit PropTypes: https://legacy.reactjs.org/docs/typechecking-with-proptypes.html\n- MDN: CustomEvent: https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent\n- MDN: Window.postMessage: https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage\n- Storybook: https://storybook.js.org/"},{"slug":"docker-compose-nodejs-redis","title":"Node.js und Redis mit Docker Compose","summary":"Wie ich in Projekten eine Zwei-Container-App aus Node.js und Redis mit Docker Compose beschreibe: eine YAML für den ganzen Stack, Service Discovery über den Servicenamen, Persistenz per Volume – und warum depends_on nicht auf Betriebsbereitschaft wartet.","tags":["Software","Platform"],"published_at":"2018-07-24T10:00:00+00:00","updated_at":"2026-07-13T09:59:53.348841+00:00","url":"https://www.mikebild.dev/de/blog/docker-compose-nodejs-redis/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/4b528de46688ed9b7f59bc3945d12106845d620a1869c914765add8b50eecee4/docker-compose-nodejs-redis-vorlesen.mp3","audio_duration_secs":934,"text":"Ein zweiter Container verdoppelt die Arbeit nicht, er verändert sie. Bei einem einzelnen `docker run` geht es um ein Image, einen Port, vielleicht zwei Umgebungsvariablen. Sobald ein Node-Prozess auf Redis angewiesen ist, kommen Fragen dazu, die sich mit Kommandozeilen-Akrobatik nur noch mühsam beantworten lassen: In welchem Netz liegen beide Container? Unter welcher Adresse findet der eine den anderen? Wer startet zuerst – und wo bleiben die Daten, wenn ein Container ersetzt wird?\n\nDocker Compose beantwortet diese Fragen an einer einzigen Stelle. Das Wissen, wie der Stack hochfährt, steht danach in einer versionierten Datei statt in Köpfen und einer Setup-Doku: `git clone`, `docker-compose up`, läuft. Ein Neuzugang im Team verliert damit keinen halben Tag an Node-Versionen, lokaler Redis-Installation und belegten Ports. Bezahlt wird das mit einer Datei, die gepflegt werden will – ein Handel, der sich schnell rechnet.\n\nDen technischen Weg gehe ich vollständig: von der `docker-compose.yml` über Service Discovery und Persistenz bis zu dem Fallstrick, der beim ersten Mal leicht eine halbe Stunde kostet. Stand ist Sommer 2018, also Compose file v3 und die Bindestrich-CLI `docker-compose`.\n\n**Redaktionelle Einordnung (Juli 2026):** Aktuelles Docker Compose kann bei der Langform von `depends_on` mit `condition: service_healthy` auf einen erfolgreichen Healthcheck warten. Die Anwendung sollte Verbindungsabbrüche und Neustarts trotzdem selbst verkraften; ein Healthcheck ersetzt keine Retry-Strategie. Der Haupttext belässt die Einschränkung des damaligen Compose-v3-Formats.\n\n## Warum überhaupt Compose\n\nEin einzelner Container ist schnell erklärt. Sobald aber zwei Container zusammenspielen sollen, wird die imperative Variante mühsam: Netzwerk anlegen, beide Container darin starten, Ports publizieren, Umgebungsvariablen setzen, Startreihenfolge beachten. Das sind viele Kommandos, die niemand fehlerfrei aus dem Kopf reproduziert – schon gar nicht der Kollege, der das Projekt morgen zum ersten Mal klont.\n\nCompose dreht das um. Statt einer Kommandofolge schreibe ich eine deklarative Beschreibung des Zielzustands. Die Datei sagt, welche Services es gibt, welche Images sie nutzen, welche Ports offen sind und wie sie zusammenhängen. Der Startbefehl ist danach immer derselbe. Wer schon einmal mit Docker gearbeitet hat, kennt das Prinzip aus dem Dockerfile – Compose hebt es nur eine Ebene höher, von einem Image auf eine ganze Anwendung.\n\nDas Ganze folgt einer einfachen Idee: reproduzierbare, gekapselte Umgebungen, die auf jedem Rechner gleich hochfahren.\n\n## Die docker-compose.yml\n\nHier ist die Datei, mit der ich starte. Zwei Services, minimal gehalten, damit jede Zeile eine erkennbare Aufgabe hat.\n\n```yaml\nversion: '3'\nservices:\n  web:\n    build: .\n    environment:\n      NODE_ENV: production\n      REDIS_HOST: 'redis'\n      REDIS_PORT: '6379'\n    ports:\n      - '8080:8080'\n    depends_on:\n      - 'redis'\n  redis:\n    image: redis:4.0.11\n```\n\nDie erste Zeile ist 2018 Pflicht: `version: '3'`. Ohne diesen Key weigert sich Compose, die Datei zu lesen. Darunter kommen die Top-Level-Keys, die uns hier interessieren – `services:` auf jeden Fall, `volumes:` und `networks:` gleich noch.\n\nZwei Zeilen lohnt es, gezielt anzuschauen. Beim `web`-Service steht `build: .` – Compose baut das Image lokal aus dem Dockerfile im aktuellen Verzeichnis. Beim `redis`-Service steht `image: redis:4.0.11` – hier wird ein fertiges Image aus der Registry gezogen. Das ist der zentrale Gegensatz:\n\n- `build:` nutze ich für meinen eigenen Code, den es als fertiges Image noch nicht gibt. Compose ruft im Hintergrund einen `docker build` auf.\n- `image:` nutze ich für alles, was ich unverändert übernehme – Datenbanken, Caches, Message-Broker. Redis pinne ich dabei auf eine konkrete Version (`4.0.11`) statt auf `latest`, damit die Umgebung reproduzierbar bleibt.\n\nFür den Fall, dass mein Webservice bereits gebaut und in eine Registry geschoben wurde, tausche ich `build: .` einfach gegen `image: mikebild/compose-nodejs-redis:1.0.0`. Dieselbe Datei, ein anderer Bezugsweg – im Deploy-Kontext genau das, was man will.\n\nAuffällig ist auch, was beim `redis`-Service fehlt: kein `ports`. Das ist Absicht. Nur `web` publiziert mit `'8080:8080'` einen Port auf den Host, weil nur der Webservice von außen erreichbar sein soll. Redis bleibt containerintern und braucht deshalb keine Host-Veröffentlichung. Auf diesen Isolationspunkt komme ich weiter unten zurück, er hat nämlich eine handfeste Sicherheitsseite.\n\n## Service Discovery: Redis heißt einfach \"redis\"\n\nBleibt die Frage, woher der Node-Prozess weiß, unter welcher Adresse er Redis findet. Es steht ja nirgends eine IP.\n\nCompose legt beim Start automatisch ein Default-Netzwerk an, benannt nach dem Projekt, etwa `<projekt>_default`. Jeder Service hängt in diesem Netz und ist darin unter seinem Servicenamen per eingebautem DNS erreichbar. Anders gesagt: Jeder Service erreicht jeden anderen Service unter dessen Servicenamen. Der Name `redis` aus der YAML wird zum Hostnamen im Netzwerk.\n\nDeshalb setze ich in der Datei `REDIS_HOST: 'redis'`. Der Node-Client verbindet sich zu `redis:6379` – keine hartkodierte IP, keine `--link`-Akrobatik. Wenn Compose morgen dem Container eine andere interne Adresse gibt, ist das egal, weil über den Namen aufgelöst wird.\n\n```mermaid\ngraph LR\n  Client[\"Client / Browser\"] -->|\"Host-Port 8080\"| Web\n  subgraph net[\"Compose default network\"]\n    Web[\"web-Container\"] -->|\"DNS: redis:6379\"| Redis[\"redis-Container\"]\n  end\n  Redis --> Vol[(\"volume<br/>redis-data (/data)\")]\n```\n\nVon außen führt genau ein Weg herein, über den publizierten Host-Port 8080 auf `web`. Intern spricht `web` mit `redis` über den Servicenamen. Und die Redis-Daten liegen in einem Volume, um das es gleich geht.\n\nWer das Default-Netz sichtbar machen will, kann es auch explizit hinschreiben. Ich mache das in einem neuen Projekt gern einmal, damit klar wird, dass hier kein Zauber im Spiel ist:\n\n```yaml\nversion: '3'\nservices:\n  web:\n    build: .\n    environment:\n      REDIS_HOST: 'redis'\n      REDIS_PORT: '6379'\n    ports:\n      - '8080:8080'\n    networks:\n      - app-net\n    depends_on:\n      - 'redis'\n  redis:\n    image: redis:4.0.11\n    networks:\n      - app-net\nnetworks:\n  app-net:\n```\n\nFunktional ändert das gegenüber der ersten Datei nichts – Compose hätte ohnehin ein Netz gebaut. Aber der Mechanismus steht damit sichtbar in der Datei, und das Netz ist kein impliziter Side Effect mehr.\n\n## Der Node-Client\n\nDamit die Verbindung nicht abstrakt bleibt, hier der relevante Ausschnitt des Servers. Es ist ein schlanker Express-Server, der `node_redis` in der 2018 gängigen Version `^2.8` nutzt – also die Callback-API, noch nicht die späteren Promises.\n\n```js\nconst SERVICE_PORT = process.env.SERVICE_PORT || 8080;\nconst REDIS_HOST = process.env.REDIS_HOST || 'localhost';\nconst REDIS_PORT = process.env.REDIS_PORT || '6379';\n\nconst express = require('express');\nconst redis = require('redis');\nconst app = express();\nconst client = redis.createClient(REDIS_PORT, REDIS_HOST);\n\nclient.on('error', err => {\n  console.error(`Redis connection error.\\n${err.message}`);\n  process.exit(1);\n});\n\napp.get('/redis', (req, res, next) => {\n  client.get('foo', (error, result) => {\n    if (error) return next(error);\n    res.send(result);\n  });\n});\n\napp.post('/redis', (req, res, next) => {\n  client.set('foo', 'bar', (error, result) => {\n    if (error) return next(error);\n    res.status(201).send('done');\n  });\n});\n\napp.listen(SERVICE_PORT, () => console.log(`Listen on ${SERVICE_PORT}`));\n```\n\nDie entscheidende Zeile ist `redis.createClient(REDIS_PORT, REDIS_HOST)`. Host und Port kommen aus der Umgebung, und die Umgebung füllt Compose über den `environment`-Block. So bleibt der Code frei von Adressen. Auf meinem Laptop läuft er gegen `localhost`, im Compose-Netz gegen `redis` – ohne dass eine Zeile Anwendungscode sich ändert.\n\nEin Detail behalte ich im Hinterkopf: Der `client.on('error', …)`-Handler beendet den Prozess mit `process.exit(1)`, sobald die Redis-Verbindung scheitert. Das ist bewusst hart und führt direkt zum wichtigsten Fallstrick.\n\n## Der Fallstrick: depends_on wartet nicht auf \"ready\"\n\n`depends_on: ['redis']` liest sich, als würde Compose warten, bis Redis einsatzbereit ist, bevor es `web` startet. Das tut es nicht. `depends_on` steuert ausschließlich die Start-Reihenfolge: Compose startet den Redis-Container vor dem Web-Container. Es wartet aber nicht, bis Redis Verbindungen annimmt – nur, bis der Container gestartet wurde.\n\nDer Unterschied klingt akademisch, bis der harte Error-Handler oben zuschlägt. Redis braucht einen Moment, bis der Server im Container tatsächlich lauscht. Startet `web` in diesem Fenster und versucht sofort zu verbinden, feuert `client.on('error')`, der Prozess beendet sich mit Exit-Code 1 – und der frisch gestartete Stack ist schon wieder halb tot. Auf langsameren Rechnern passiert das reproduzierbar.\n\nDie saubere Lösung liegt nicht in der YAML, sondern in der Anwendung. Sie muss Verbindungsabbrüche vertragen und einen Reconnect mit Retry versuchen, statt beim ersten Fehler auszusteigen. Wo ich den Anwendungscode nicht anfassen will, schiebe ich in 2018 ein kleines Wartescript vor den Start – `wait-for-it.sh`, `dockerize` oder `wait-for` prüft, ob `redis:6379` erreichbar ist, bevor der Node-Prozess startet.\n\nEine Sache gehört hier ausdrücklich verneint: Die Langform `depends_on: {redis: {condition: service_healthy}}`, die auf einen Healthcheck wartet, gibt es in Compose v3 im Jahr 2018 nicht. Wer sie in ein v3-File schreibt, bekommt sie schlicht nicht anerkannt. Die Wartelogik gehört also in Anwendung oder Wartescript, Punkt.\n\n## Persistenz: Redis braucht ein Volume\n\nDer zweite Fallstrick ist stiller, dafür ärgerlicher. In der Startdatei hat Redis kein Volume. Solange der Container läuft, ist alles gut – die Daten liegen im Container-Dateisystem unter `/data`. Doch sobald der Container ersetzt wird, etwa nach `docker-compose down` und einem erneuten `up`, ist der Cache leer. Der Container ist eben flüchtig, und mit ihm sein Dateisystem.\n\nWer Redis nicht nur als Wegwerf-Cache nutzt, mountet ein benanntes Volume auf `/data`. Das redis-Image legt seine RDB-Snapshots genau dort ab.\n\n```yaml\nversion: '3'\nservices:\n  web:\n    build: .\n    environment:\n      REDIS_HOST: 'redis'\n      REDIS_PORT: '6379'\n    ports:\n      - '8080:8080'\n    depends_on:\n      - 'redis'\n  redis:\n    image: redis:4.0.11\n    volumes:\n      - 'redis-data:/data'\nvolumes:\n  redis-data:\n```\n\nDas benannte Volume `redis-data` überlebt den Lifecycle des Containers. Ich kann `web` neu bauen, den Stack herunterfahren und wieder hochziehen – die Redis-Daten sind noch da. Nur ein explizites `docker-compose down -v` räumt auch die Volumes ab.\n\nDamit habe ich die vier Bausteine beisammen, die eine Compose-Datei ausmachen:\n\n- `services` sind die Container der Anwendung, hier `web` und `redis`.\n- `ports` legt fest, welche Container-Ports auf den Host veröffentlicht werden; nur `web` bekommt einen.\n- `volumes` hält Daten in benannten Volumes, die den Container überleben sollen, hier `redis-data`.\n- `networks` beschreibt das meist automatische Netz, das Service Discovery per Servicename ermöglicht.\n\n## Der Zyklus: up, logs, down\n\nIm Betrieb sind es nur eine Handvoll Kommandos, und die bleiben über Projekte hinweg gleich. Ich starte den Stack im Hintergrund, schaue in die Logs und fahre am Ende alles wieder herunter.\n\n```bash\ndocker-compose up -d\ndocker-compose logs web\n\ncurl -X POST http://localhost:8080/redis\ncurl http://localhost:8080/redis\n\ndocker-compose down\n```\n\nDas `up -d` startet beide Container detached. `logs web` zeigt gezielt die Ausgabe des Webservice, was beim Debuggen des Startfensters oben hilft. Die beiden `curl`-Aufrufe schreiben zuerst den Wert nach Redis und lesen ihn dann zurück – der Beweis, dass `web` seinen Nachbarn unter dem Namen `redis` tatsächlich erreicht. `down` stoppt und entfernt Container und das Default-Netz; die benannten Volumes bleiben, sofern ich nicht `-v` ergänze.\n\nWer Docker Toolbox nutzt statt Docker for Mac oder Windows, erreicht den Stack übrigens nicht unter `localhost`, sondern unter der IP der docker-machine, in der Standardkonfiguration `192.168.99.100`. Das ist 2018 auf vielen Rechnern noch die Realität und kostet beim ersten Mal ein paar ratlose Minuten.\n\n## Der eigentliche Gewinn: Zeit bis zum ersten Commit\n\nDie YAML ist dabei nur das Mittel. Das Ergebnis ist eine Umgebung, die jeder im Team mit denselben zwei Befehlen bekommt. Wer heute ins Projekt kommt, klont das Repository, tippt `docker-compose up` und hat wenig später einen laufenden Stack aus Node und Redis vor sich – ohne die richtige Node-Version manuell zu suchen, ohne Redis lokal zu installieren, ohne die stille Annahme, dass Port 6379 frei ist. Die Umgebung steht in der Datei, nicht in einer Wiki-Seite, die niemand pflegt.\n\nDas ist der Punkt, an dem sich Compose betriebswirtschaftlich rechnet. Onboarding ist teuer, und der teuerste Teil ist selten das Verstehen des Codes – es ist das Herstellen einer Umgebung, in der der Code überhaupt läuft. Genau diesen Teil verschiebt Compose von jedem einzelnen Entwickler in eine versionierte Datei. Die Zeit bis zum ersten Commit, die sonst zwischen einem halben und einem ganzen Tag liegen kann, schrumpft auf die Dauer eines Image-Pulls.\n\nKostenlos ist das nicht. Ich handele mir eine zusätzliche Abstraktion ein, die das Team verstehen muss, und eine Datei, die gepflegt werden will. Versions-Pinning ist Disziplin: Ein `redis:latest` untergräbt genau die Reproduzierbarkeit, für die ich Compose überhaupt einsetze. Und Compose deckt Entwicklung und lokale Integration ab, nicht den Produktionsbetrieb mit seinen Fragen nach Ausfallsicherheit, Skalierung und Secrets.\n\nDie Entscheidung fällt deshalb an einer klaren Grenze. Sobald mehr als ein Prozess zusammenspielt und mehr als eine Person das Projekt anfasst, trägt Compose seinen Aufwand schnell wieder herein – jede vermiedene Setup-Stunde pro Neuzugang ist die Datei bereits wert. Bei einem einzelnen Tool ohne Nachbarn bleibt `docker run` die ehrlichere Antwort. Ausschlaggebend ist dabei weniger die Technik als die Reibung, die sie erspart.\n\n## Ein Wort zur Sicherheit\n\nDer fehlende `ports`-Eintrag bei Redis ist Absicht und der eigentliche Kern der Isolation. `ports` publiziert auf allen Host-Interfaces. Würde ich Redis mit `'6379:6379'` nach außen legen, stünde eine offene, standardmäßig ungesicherte Redis-Instanz im Netz – und Redis ist genau dafür immer wieder negativ aufgefallen. Innerhalb des Compose-Netzes erreicht `web` seinen Cache ohnehin über den Servicenamen. Von außen soll ihn niemand erreichen. Also lasse ich Redis unpubliziert. Das Prinzip bleibt schlicht: Nur Dienste, die vom Host erreichbar sein müssen, bekommen einen veröffentlichten Port.\n\nAm Rande: Der `links`-Eintrag, der einem in älteren Beispielen begegnet, ist Legacy. In v3 macht ihn das Default-Netz mit seinem DNS überflüssig. Wer ihn noch stehen hat, kann ihn streichen.\n\n## Fazit\n\nCompose macht aus einer Handvoll `docker run`-Kommandos eine einzige, lesbare Datei, die den ganzen Stack beschreibt. `image` gegen `build` trennt übernommene von selbstgebauten Diensten. Service Discovery über den Servicenamen räumt hartkodierte IPs komplett aus dem Weg – `REDIS_HOST: 'redis'` genügt, und der Node-Client verbindet sich zu `redis:6379`. Ein benanntes Volume auf `/data` rettet die Redis-Daten über den Container-Lifecycle hinweg. Und `ports` publiziert nur, was wirklich nach außen soll.\n\nDer eine Punkt, der mir am wichtigsten ist: `depends_on` ordnet den Start, garantiert aber keine Betriebsbereitschaft. Wer das nicht weiß, jagt einem Race-Condition-Fehler hinterher, den keine YAML-Zeile behebt – die Wartelogik gehört in die Anwendung oder in ein kleines Wartescript. Wer es weiß, baut den Stack von Anfang an widerstandsfähig.\n\nWichtiger als die einzelne YAML-Zeile ist aber, was sie dem Team spart. Eine reproduzierbare Umgebung verkürzt die Schleife vom Klonen zum ersten Commit, nimmt die Reibung aus dem Onboarding und macht \"läuft bei mir\" zu einer überprüfbaren Aussage statt einer Hoffnung. Das hebt nebenbei die Qualität, weil alle gegen denselben Zustand entwickeln und testen. Die Grenze bleibt klar: Compose ist ein Werkzeug für Entwicklung und lokale Integration, kein Produktions-Orchestrator. Innerhalb dieser Grenze fühlt sich `docker-compose up` jedes Mal wie ein einziger, verlässlicher Handgriff an – und genau das ist der Gewinn.\n\n## Weiterführende Quellen\n\n- [Docker Compose: Startreihenfolge und `service_healthy`](https://docs.docker.com/compose/how-tos/startup-order/)\n\n- Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-docker.html\n- [Compose file v3 reference](https://docs.docker.com/compose/compose-file/)\n- [Networking in Compose (DNS, Default-Netz)](https://docs.docker.com/compose/networking/)\n- [Control startup order in Compose](https://docs.docker.com/compose/startup-order/)\n- [Compose CLI overview](https://docs.docker.com/compose/reference/overview/)\n- [redis Official Image, Tag 4.0.11](https://hub.docker.com/_/redis)"},{"slug":"containerisierung-falsche-sicherheit","title":"Containerisierung und die falsche Sicherheit","summary":"Warum „läuft im Container“ weder Isolation noch Reproduzierbarkeit noch geregelten Betrieb bedeutet – und welche Handgriffe die drei Lücken tatsächlich schließen.","tags":["Software","Platform"],"published_at":"2018-07-12T10:00:00+00:00","updated_at":"2026-07-14T17:00:42.669323+00:00","url":"https://www.mikebild.dev/de/blog/containerisierung-falsche-sicherheit/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/8228e884878f6d7aeb6791673dd0c57cd1a3a8316a88de0b902a19d677159559/containerisierung-falsche-sicherheit-vorlesen.mp3","audio_duration_secs":677,"text":"# Containerisierung und die falsche Sicherheit\n\n„Es läuft im Container“ hat sich in Projektgesprächen zu einer Art Gütesiegel entwickelt. Der Satz soll gleich drei Dinge auf einmal belegen: Die Anwendung sei isoliert, der Build sei reproduzierbar, und der Betrieb sei geregelt. Keine dieser drei Aussagen folgt aus der Verpackung. Ein Container ist zunächst nur ein gewöhnlicher Prozess mit eingeschränkter Sicht auf das System – ob er Sicherheit und Wiederholbarkeit mitbringt, entscheidet sich im Dockerfile und in der Startkonfiguration, nicht im Wort „Container“.\n\nMir fällt diese Verwechslung seit einigen Monaten in Reviews auf. Ein Team zeigt ein sauberes Deployment auf Kubernetes 1.10, alles grün, und auf die Frage nach dem Patch-Stand des Basis-Images kommt ein Achselzucken. Ein anderes Team baut seit einem Jahr auf `node:latest` und wundert sich, dass der CI-Build sich anders verhält als der lokale. Die drei falschen Sicherheiten lohnen einen genaueren Blick, weil sie drei verschiedene Gegenmittel brauchen.\n\n## Erste falsche Sicherheit: Isolation\n\nEin Container ist keine kleine virtuelle Maschine. Eine VM bringt einen eigenen Kernel mit und spricht mit dem Host über einen Hypervisor. Ein Container teilt sich den Kernel mit dem Host und mit allen anderen Containern auf derselben Maschine. Was ihn abgrenzt, sind Kernel-Namespaces für Prozesse, Netzwerk und Dateisystem sowie cgroups für Ressourcen – nützliche Mechanismen, aber eine deutlich dünnere Wand als ein Hypervisor.\n\n```mermaid\nflowchart TB\n  subgraph VM[\"Virtuelle Maschine\"]\n    A1[App] --> K1[Gast-Kernel]\n    K1 --> H1[Hypervisor]\n    H1 --> HW1[Host-Kernel<br/>+ Hardware]\n  end\n  subgraph CT[\"Container\"]\n    A2[App als Prozess] --> NS[Namespaces<br/>+ cgroups]\n    NS --> HW2[gemeinsamer<br/>Host-Kernel]\n  end\n```\n\nDie praktische Konsequenz: Wer im Container als root läuft, läuft auf dem Host als root. UID 0 ist UID 0. User Namespaces, die Container-root auf einen unprivilegierten Host-Benutzer abbilden, gibt es in Docker seit einer Weile – aber sie sind opt-in über `--userns-remap`, und in den Umgebungen, die ich dieses Jahr gesehen habe, war das Feature genau null Mal eingeschaltet. Solange das so ist, trennt einen root-Prozess im Container vom Host nur noch die Frage, ob er einen Weg nach draußen findet.\n\nEin `--privileged`-Container findet diesen Weg garantiert. Das Flag schaltet sämtliche Capabilities frei und gibt Gerätezugriff – damit lässt sich vom Container aus das Host-Dateisystem einhängen. Ich habe das Flag schon in Produktions-Setups gefunden, gesetzt vor Monaten, „weil es sonst nicht lief“, und nie wieder hinterfragt. Ein privilegierter Container ist keine Isolationsschicht mit einer Schwäche, er ist eine offene Tür mit einem Vorhang davor.\n\nDocker bringt seit Längerem ein seccomp-Standardprofil mit, das riskante Syscalls filtert, dazu AppArmor-Profile auf Ubuntu-Hosts. Das hilft, ändert aber nichts am Grundsatz: Die Grenze ist der Kernel, und der Kernel ist gemeinsam. Meltdown und Spectre haben im Januar zusätzlich gezeigt, dass selbst Prozessgrenzen auf CPU-Ebene wackeln können. Wer echte Mandantentrennung braucht, also fremden oder nicht vertrauenswürdigen Code fährt, nimmt weiterhin VMs – oder schaut sich gVisor an, das Google im Mai vorgestellt hat: ein Userspace-Kernel zwischen Container und Host. Spannender Ansatz, aber im Juli 2018 zu frisch, um eine Produktionsentscheidung darauf zu bauen.\n\nWas heute schon geht, ist die Angriffsfläche beim Start zu verkleinern:\n\n```bash\ndocker run -d \\\n  --read-only \\\n  --cap-drop ALL \\\n  --cap-add NET_BIND_SERVICE \\\n  --security-opt no-new-privileges \\\n  --memory 256m \\\n  order-service:1.4.2\n```\n\nAlles daran ist seit Jahren verfügbar, nichts davon ist Standard. Genau das ist der Punkt: Isolation ist beim Container kein Zustand, sondern eine Konfiguration, die jemand aktiv vornehmen muss.\n\n## Zweite falsche Sicherheit: Reproduzierbarkeit\n\nDie zweite Annahme lautet: Weil die Anwendung als Image ausgeliefert wird, ist der Build reproduzierbar. Das stimmt nur für das fertige Image – dieselbe Image-ID startet überall denselben Stand. Für den Weg dorthin stimmt es nicht. Ein Dockerfile wie dieses ist keine Beschreibung, sondern eine Momentaufnahme:\n\n```dockerfile\nFROM node:latest\nWORKDIR /app\nCOPY . .\nRUN apt-get update && apt-get install -y imagemagick\nRUN npm install\nENV API_SECRET=changeme-in-prod\nEXPOSE 3000\nCMD [\"node\", \"server.js\"]\n```\n\nHier ist fast jede Zeile ein Zeitzünder. `node:latest` ist keine Version, sondern eine Wette darauf, wann gebaut wird – letztes Jahr bekam man Node 8, seit ein paar Wochen Node 10, und niemand hat eine Entscheidung getroffen. `apt-get install` ohne Versionsangabe zieht, was der Spiegelserver heute hergibt. `npm install` ohne Lockfile würfelt die Abhängigkeitsversionen zum Build-Zeitpunkt aus; mit `package-lock.json` ist das seit npm 5 lösbar, aber nur, wenn die Datei auch eingecheckt und respektiert wird. Und das Secret in der `ENV`-Zeile liegt jetzt für immer in der Image-History – `docker history` zeigt es jedem, der das Image ziehen darf.\n\nDazu kommen Build-Args als stille Variable: Ein `ARG`, das auf dem CI-Server anders gesetzt ist als auf dem Entwicklerrechner, erzeugt zwei verschiedene Images aus identischem Quellstand – und weil das Argument nirgends im Repository steht, sieht man den Unterschied erst, wenn man beide Images nebeneinanderlegt. Zwei Builds desselben Dockerfiles im Abstand von sechs Wochen können sich also in Kernelnähe, Systempaketen und npm-Abhängigkeiten unterscheiden, ohne dass eine einzige Zeile im Repository geändert wurde. In einem Projekt im Frühjahr haben wir genau daran einen Nachmittag verloren: Der Hotfix-Build für einen sechs Wochen alten Stand verhielt sich anders als das laufende System, weil das Basis-Image unter demselben Tag inzwischen ein anderes war. Die ehrliche Formel ist: Ein Image ist ein Ergebnis. Reproduzierbar wird es erst, wenn alle Eingaben festgenagelt sind – Basis-Image per Tag oder besser per Digest (`node:8.11-alpine@sha256:...`), Systempakete mit Version, Anwendungsabhängigkeiten über das Lockfile.\n\n## Dritte falsche Sicherheit: Betrieb\n\nDie dritte Annahme ist die leiseste: Der Container läuft, also ist der Betrieb erledigt. Dabei friert ein Image nicht nur die Anwendung ein, sondern auch jede Bibliothek darunter – inklusive ihrer Lücken. Auf einem klassisch administrierten Server spielt `unattended-upgrades` nachts Sicherheitsupdates ein. Im Container gibt es diesen Weg nicht. Die einzige Patch-Route ist: neues Basis-Image, neuer Build, neues Deployment. Wer diese Kette nicht als Routine eingerichtet hat, betreibt eine Flotte, die mit jedem Tag stiller veraltet, während das Dashboard grün bleibt.\n\nVorgelagert kommt noch eine Frage, die überraschend oft unbeantwortet bleibt: Welche Images laufen überhaupt, und aus welchem Stand wurden sie gebaut? Auf einem Cluster mit ein paar Dutzend Deployments ist das ohne Inventar reine Archäologie. Als im Januar die ersten Meltdown-Patches kamen, habe ich in einem Projekt erlebt, wie lange allein die Bestandsaufnahme dauert, wenn Tags wie `latest` und `stable` im Umlauf sind – man weiß dann buchstäblich nicht, was man betreibt, nur dass es läuft.\n\n```mermaid\nflowchart LR\n  CVE[CVE im Basis-Image<br/>veröffentlicht] --> BASE[Basis-Image<br/>aktualisiert]\n  BASE --> BUILD[Rebuild<br/>aller Services]\n  BUILD --> SCAN[Image-Scan<br/>z. B. Clair]\n  SCAN --> DEPLOY[Rollout]\n  DEPLOY -. ohne Routine reißt<br/>die Kette hier ab .- BUILD\n```\n\nDass Betrieb die eigentliche Flanke ist, hat dieses Jahr ein prominenter Fall gezeigt: Im Februar wurde bekannt, dass die Kubernetes-Verwaltungskonsole von Tesla ohne Passwortschutz aus dem Internet erreichbar war. Die Angreifer haben keine Kernel-Lücke gebraucht und keinen Container ausgebrochen – sie haben sich über die offene Konsole eingerichtet, Krypto-Mining-Pods deployt, die CPU-Last gedrosselt und den Mining-Pool hinter Cloudflare versteckt, um nicht aufzufallen. Dieselbe Kategorie: Docker-Daemons, deren Remote-API unauthentifiziert auf Port 2375 lauscht und die reihenweise von Mining-Skripten abgegrast werden. In beiden Fällen war die Container-Technik völlig unschuldig. Verwundbar war das Betriebsmodell drumherum – exponierte Verwaltungsschnittstellen, fehlende Zugangskontrolle, niemand, der hinschaut.\n\nFür mich ist das die wichtigste Lektion aus dem Fall: Die Plattform verschiebt die Betriebsfragen, sie beantwortet sie nicht. Wer vorher wissen musste, welche Pakete auf dem Server liegen, muss jetzt wissen, welche Layer in den Images liegen. Wer vorher SSH abgesichert hat, muss jetzt Kubelet, API-Server und Dashboard absichern. Der Aufwand ist nicht verschwunden, er hat die Adresse gewechselt.\n\n## Was konkret hilft\n\nNichts davon ist ein Argument gegen Container – es ist ein Argument gegen das Gütesiegel. Die Gegenmittel sind unspektakulär und seit geraumer Zeit verfügbar. Am Dockerfile selbst sieht das so aus:\n\n```dockerfile\n# build stage\nFROM node:8.11-alpine AS build\nWORKDIR /app\nCOPY package.json package-lock.json ./\nRUN npm install\nCOPY . .\nRUN npm run build && npm prune --production\n\n# runtime stage\nFROM node:8.11-alpine\nWORKDIR /app\nRUN addgroup -S app && adduser -S app -G app\nCOPY --from=build /app/dist ./dist\nCOPY --from=build /app/node_modules ./node_modules\nUSER app\nEXPOSE 3000\nCMD [\"node\", \"dist/server.js\"]\n```\n\nMulti-Stage-Builds gibt es seit Docker 17.05, sie sind also seit über einem Jahr etabliert und kein Experiment mehr. Der Effekt: Compiler, Dev-Abhängigkeiten und Build-Werkzeuge bleiben in der ersten Stufe zurück, ausgeliefert wird nur, was zur Laufzeit gebraucht wird. Die `USER`-Direktive sorgt dafür, dass der Prozess gar nicht erst als root startet – eine Zeile, die einen Großteil des Isolationsproblems aus dem ersten Abschnitt entschärft, weil ein Ausbruch dann bei einem unprivilegierten Benutzer landet. Das gepinnte Basis-Image nimmt der Reproduzierbarkeitsfrage die Würfel aus der Hand. Ein angenehmer Side Effect der kleinen Laufzeitstufe: Der Image-Scanner hat weniger zu melden, weil schlicht weniger Pakete an Bord sind – ein Alpine-Image mit einer Handvoll Bibliotheken erzeugt Befunde, die man tatsächlich liest, statt einer Liste mit dreihundert Einträgen, die nach der zweiten Woche niemand mehr öffnet.\n\nDarüber hinaus haben sich bei mir ein paar Punkte als Checkliste für Reviews eingespielt:\n\n- Basis-Images klein wählen – `alpine` für den Alltag, für kompilierte Sprachen die distroless-Images, die Google letztes Jahr veröffentlicht hat und die außer der Anwendung und ihren Laufzeitabhängigkeiten nichts enthalten, nicht einmal eine Shell.\n- Tags pinnen, für Produktions-Deployments zusätzlich den Digest festhalten – `latest` hat in einem Deployment-Manifest nichts verloren.\n- Image-Scanning in die CI-Kette hängen: Clair oder Anchore gleichen die Layer gegen bekannte CVEs ab und machen aus „wir müssten mal patchen“ einen roten Build.\n- Secrets niemals ins Image – nicht als `ENV`, nicht als `ARG`, nicht als kopierte Datei. Beides landet in der History beziehungsweise im Build-Kontext. Secrets kommen zur Laufzeit von außen, etwa als Kubernetes Secret oder über den Orchestrator.\n- Capabilities beim Start beschneiden und `--privileged` wie einen offenen root-Zugang behandeln: Wer es setzen will, muss begründen, warum.\n\nKeiner dieser Punkte ist schwer. Das eigentliche Hindernis ist, dass sie unsichtbar sind, solange alles läuft – und genau deshalb gehören sie ins Review und nicht in die Rubrik „machen wir, wenn Zeit ist“.\n\n## Unterm Strich\n\nContainer haben unsere Auslieferung spürbar besser gemacht: gleiche Artefakte in allen Umgebungen, schnelle Starts, saubere Schnittstelle zwischen Build und Betrieb. Was sie nicht liefern, ist das, was der Satz „läuft im Container“ so gern behauptet. Isolation ist eine Konfiguration, keine Eigenschaft – ohne `USER`, ohne beschnittene Capabilities und mit gemeinsamem Kernel ist die Wand dünner, als das Wort klingt. Reproduzierbarkeit ist eine Disziplin, keine Eigenschaft – ungepinnte Tags und Pakete machen aus dem Dockerfile ein Foto statt einer Bauanleitung. Und Betrieb ist eine Routine, keine Eigenschaft – ein Image, das niemand neu baut, ist ein Server, den niemand patcht, nur ohne die nächtlichen Updates.\n\nWenn mir heute jemand sagt, eine Anwendung sei sicher, weil sie containerisiert ist, frage ich drei Dinge: Als welcher Benutzer läuft der Prozess? Woher weiß der nächste Build, dass er dasselbe baut wie der letzte? Und wer baut neu, wenn morgen eine Lücke in der Basis veröffentlicht wird? An den Antworten erkennt man ziemlich zuverlässig, ob das Team ein Betriebsmodell hat – oder nur ein Gütesiegel.\n\n## Weiterführende Quellen\n\n- Repository introduction-docker: https://github.com/MikeBild/introduction-docker\n- Docker-Dokumentation zu Multi-Stage-Builds: https://docs.docker.com/build/building/multi-stage/\n- Docker Engine Security Overview: https://docs.docker.com/engine/security/\n- Clair, statische Analyse von Container-Images: https://github.com/quay/clair\n- Distroless-Images von Google: https://github.com/GoogleContainerTools/distroless\n- Bericht zum Tesla-Vorfall (CNBC, Februar 2018): https://www.cnbc.com/2018/02/21/hackers-hijack-teslas-cloud-system-to-mine-cryptocurrency-redlock.html"},{"slug":"mvp-kleinstes-lernendes-produkt","title":"Meine Arbeitsdefinition für ein MVP: lernen statt halb fertig liefern","summary":"Ich benutze MVP für ein Experiment, das eine riskante Annahme ehrlich testet. Diese Arbeitsdefinition verhindert, dass ein bloß reduziertes Feature-Set schon als Lernen gilt.","tags":["Software","Produkt"],"published_at":"2018-05-24T10:00:00+00:00","updated_at":"2026-07-13T13:21:51.091079+00:00","url":"https://www.mikebild.dev/de/blog/mvp-kleinstes-lernendes-produkt/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/39aeb3ad58840a27f853bd7ef1fe2303174efd4f87a5d63f11ec907581b287ec/mvp-kleinstes-lernendes-produkt-vorlesen.mp3","audio_duration_secs":643,"text":"Wer die Hälfte weglässt und das Ergebnis MVP nennt, hat kein Risiko gesenkt, sondern nur den Tag verschoben, an dem der Irrtum auffällt. Bauzeit ist verbraucht, ausgeliefert wurde etwas Halbes, und die teuerste Frage steht unverändert im Raum: Woher wissen wir, dass wir überhaupt das Richtige bauen?\n\nTrotzdem hängt das Wort genau in dieser Bedeutung an Roadmaps, Ticket-Titeln und Budgetzeilen. Weniger Buttons, weniger Screens, weniger Backend – MVP ist zum höflichen Ausdruck für „das Produkt, aber schlechter“ geworden. Das reduzierte Feature-Set beantwortet keine einzige offene Frage. Es verwechselt Geschwindigkeit beim Bauen mit Geschwindigkeit beim Lernen.\n\nFür meine Arbeit halte ich mich an die Lesart aus Lean Startup: Ein MVP ist ein Experiment, die kleinste Version eines Produkts, mit der sich eine konkrete, riskante Annahme ehrlich testen lässt. Der Begriff wird in der Praxis auch anders verwendet; diese Arbeitsdefinition ist meine bewusste Entscheidung. Aus ihr folgt, was hineingehört, was nicht, und warum der scheinbare Umweg über das Lernen der schnellste Weg zum tragfähigen Produkt sein kann.\n\n## Die Definition, die alles trägt\n\nEric Ries hat den Begriff 2011 in „The Lean Startup“ so gefasst: ein MVP ist „that version of a new product which allows a team to collect the maximum amount of validated learning about customers with the least effort.“ Der Kern steckt in zwei Wörtern: *validated learning*. Der Zweck ist Lernen, das durch echte Daten bestätigt wird, und zwar über die *leap-of-faith assumptions* – jene Annahmen, auf denen die ganze Idee ruht und die bisher niemand belegt hat.\n\nDamit ist das MVP kein Auslieferungsschritt, sondern der Bau-Teil eines Experiments. Ries stellt es in den *Build-Measure-Learn*-Loop: Wir bauen etwas, messen die Reaktion, lernen daraus und entscheiden. Der Ausgang eines MVP ist immer eine Entscheidung, die Ries *pivot or persevere* nennt – Kurs halten oder Annahme verwerfen und drehen.\n\n```mermaid\nflowchart LR\n  A[Riskante Annahme] --> B[Build:<br/>MVP als Experiment]\n  B --> C[Measure:<br/>echte Nutzer, echte Metrik]\n  C --> D[Learn:<br/>validated learning]\n  D --> E{pivot or<br/>persevere?}\n  E -->|persevere| A\n  E -->|pivot| A\n```\n\nDaraus ergibt sich für diese Arbeitsdefinition ein hartes Kriterium, das im Alltag fehlt: Ein MVP ohne definierte Lernfrage und ohne Metric ist dann kein Experiment, sondern nur ein kleines Produkt. Der Unterschied ist nicht akademisch. Wenn niemand vorher aufgeschrieben hat, welche Annahme geprüft wird und woran man den Ausgang erkennt, kann das Ergebnis auch nichts widerlegen. Man baut, launcht, findet es „ganz okay“ und macht weiter – gelernt hat man nichts.\n\nSteve Blank formuliert die Gegenseite des Missverständnisses direkt. „A minimum viable product is not always a smaller/cheaper version of your final product.“ Sein Customer-Development-Ansatz kreist um einen einzigen Imperativ: *get out of the building*. Die Wahrheit über das Produkt steht nicht im Meetingraum, sie steht bei den Nutzern. Das MVP ist das Werkzeug, mit dem man rausgeht.\n\n## Vertikaler Schnitt statt horizontaler Amputation\n\nDer Unterschied lässt sich an zwei Schnitten durch denselben geplanten Funktionsumfang zeigen.\n\nDer falsche MVP ist ein horizontaler Schnitt. Man nimmt alle geplanten Features und baut jedes zu vierzig Prozent. Das Registrierungsformular steht halb, die Suche liefert Ergebnisse ohne Filter, der Checkout endet in einer Sackgasse. Nichts funktioniert end-to-end. Der Nutzer kommt an keiner Stelle vom Anfang bis zum Wert. Es gibt nichts zu messen, weil es keinen vollständigen Pfad gibt, den jemand durchlaufen könnte.\n\nDer MVP ist häufig ein vertikaler Schnitt: ein dünner, aber durchgehender Pfad vom Nutzer bis zum Wert, an dessen Ende eine Messung hängt. Bei Concierge-, Wizard-of-Oz- oder Landingpage-Experimenten kann die kleinste belastbare Prüfung jedoch anders aussehen. Entscheidend ist nicht die Form des Schnitts, sondern ob sie die riskante Annahme ehrlich prüft.\n\n```mermaid\nflowchart TB\n  subgraph L[Horizontal: halbfertig]\n    L1[Feature A – 40%]\n    L2[Feature B – 40%]\n    L3[Feature C – 40%]\n  end\n  subgraph R[Vertikal: kleinstes lernendes Produkt]\n    R1[Einstieg] --> R2[ein durchgehender Pfad] --> R3[Wert] --> R4[Metrik]\n  end\n```\n\nGenau hier liegt die Bedeutung von „viable“. Das Wort meint nicht die Qualität, es meint den Scope. Minimal ist der Umfang, nicht die Sorgfalt. Ein buggy zusammengeschusterter MVP verfälscht das Lernsignal, denn dann weiß man am Ende nicht, ob die Nutzer die Idee ablehnen oder nur die kaputte Umsetzung. „Viable“ heißt: gut genug, um ein ehrliches Signal zu erzeugen. Der schmale Pfad muss sauber sein, sonst misst man Rauschen.\n\n## Das MVP muss kein Code sein\n\nDer stärkste Beleg dafür, dass „viable“ nicht „fertig gebaut“ bedeutet, sind zwei MVP-Typen, die es lange vor 2018 gibt und die ganz ohne fertiges Produkt auskommen.\n\nBeim *Concierge-MVP* erbringt zunächst ein Mensch die Leistung von Hand, die später Software erbringen soll. Man tut nicht so, als gäbe es ein Produkt – man liefert den Wert direkt und persönlich an eine Handvoll echter Nutzer und beobachtet, ob sie ihn überhaupt wollen. Beim *Wizard-of-Oz-MVP* sieht der Nutzer eine echte Oberfläche, aber dahinter arbeitet kein System, sondern ein Mensch, der die Antworten von Hand einträgt. Der Nutzer glaubt an Software, gelernt wird über echtes Verhalten, gebaut wurde fast nichts.\n\nBeide testen eine Hypothese, bevor eine Zeile Produktionscode existiert. Sie beweisen die These auf die klarste Art: „viable“ heißt genug, um ehrlich zu lernen, nicht genug, um zu verkaufen.\n\nWichtig ist dabei die Trennung zum Prototyp, ohne daraus eine künstliche Mauer zu bauen. Ein Prototyp untersucht meist Machbarkeit oder Bedienbarkeit und kann dafür selbstverständlich mit echten Nutzern getestet werden. Ein MVP in meiner Arbeitsdefinition prüft dagegen eine Markt- oder Wert-Hypothese. Entscheidend sind Frage und Evidenz, nicht ob das Artefakt intern oder extern gezeigt wird.\n\n## Die Hypothese als Code\n\nWenn ein MVP ein Experiment ist, dann sollte das Experiment so präzise deklariert sein, dass man es widerlegen kann. In den Projekten, in denen das getragen hat, stand die Hypothese vor der ersten Zeile Code – strukturiert, versioniert, neben dem Code im Repository statt in einem vergessenen Slide.\n\n```yaml\n# experiment.yaml – declares the MVP before any code is written\nexperiment:\n  name: \"concierge-checkout\"\n  riskiest_assumption: \"users will connect a payment method to finish an order\"\n  hypothesis: \"at least 30% of activated users will add a payment method\"\n  target_metric: activation_rate      # added payment / reached checkout\n  success_threshold: 0.30\n  kill_criteria: \"activation_rate < 0.10 after 200 sessions\"\n  sample_size: 200\n  cohort: \"new signups, region DE\"\n```\n\nDer entscheidende Punkt sind die letzten Felder. Ohne `success_threshold` und `kill_criteria` ist es kein Experiment. Ein Experiment, das man nicht scheitern lassen kann, misst nichts. Am `kill_criteria` scheitert die Einigung am häufigsten, und genau darin liegt sein Wert: Es zwingt das Team, vorher zu vereinbaren, wann die Idee widerlegt ist – bevor die emotionale Bindung an die eigene Lösung das Urteil trübt. Ries nennt die Gegenspieler dieser ehrlichen Zahlen *vanity metrics*: Kennzahlen wie Gesamtregistrierungen, die immer nach oben zeigen und nie eine Entscheidung erzwingen. Der `target_metric` muss eine *actionable metric* sein, an der man eine Annahme kippen kann.\n\n## Den schmalen Pfad technisch sauber schneiden\n\nDer vertikale Schnitt hat auch eine technische Seite, und hier verbinden sich zwei Welten, die oft getrennt diskutiert werden: Produktdenken und Delivery.\n\nDer falsche MVP wächst wochenlang in einem langen Feature-Branch neben dem Hauptstrang und landet irgendwann in einem großen, riskanten Merge. Der bessere Weg ist, das Experiment auf dem Trunk zu bauen und über ein Feature-Flag nur für die Experiment-Kohorte einzuschalten. Alle anderen sehen den Status quo, der schmale neue Pfad ist für eine kleine Gruppe vollständig live.\n\n```javascript\n// the MVP path ships on trunk, gated by a flag – not hidden in a long branch\nfunction renderCheckout(user, cart) {\n  if (featureFlags.isEnabled(\"concierge_checkout\", user)) {\n    trackExperiment(\"concierge-checkout\", user);\n    return conciergeCheckout(cart); // narrow but complete path to value\n  }\n  return standardCheckout(cart);     // everyone else: unchanged status quo\n}\n```\n\nDas ist mehr als ein technischer Trick. Es macht sichtbar, was ein MVP ist: ein schmales, aber vollständig funktionierendes Segment – nicht ein überall halb sichtbares, nirgends fertiges Feature. Trunk-Based Development und kurzlebige Branches sorgen dafür, dass das Experiment jederzeit ausgeliefert und ebenso schnell wieder abgeschaltet werden kann. Techniken wie *Canary Release* und *Blue-Green Deployment* geben demselben Prinzip die Betriebsseite: eine kleine Kohorte bekommt das Neue, der Rest bleibt sicher.\n\nWenn das Experiment über einen Service hinausgeht, hilft ein weiterer Winkel beim ehrlichen Testen. Statt eine Vollintegration vorzutäuschen, erfüllt der MVP-Service einen minimalen, aber echten *Consumer-Driven Contract*. Der Konsument schreibt auf, was er wirklich braucht, und der MVP erfüllt genau das – nicht mehr.\n\n```groovy\n// consumer-driven contract: the MVP fulfils a real, minimal contract\n// instead of faking a full integration\nPact.provider(\"payment-service\").upon(\"checkout\")\n  .given(\"an activated user\")\n  .uponReceiving(\"a request to attach a payment method\")\n    .withRequest(method: \"POST\", path: \"/methods\")\n  .willRespondWith(status: 201, body: [status: \"attached\"])\n```\n\nDer Sinn dahinter: Der schmale Pfad ist echt, nicht Fassade. Er integriert wirklich gegen das, was er braucht, aber eben nur gegen das. So bleibt das Lernsignal sauber, ohne dass man das ganze System fertig bauen muss.\n\n## Warum das schneller ist, obwohl es aufwendiger klingt\n\nDer häufigste Einwand: Experimente, Metrics, Flags und Contracts aufzusetzen klingt nach mehr Arbeit, nicht nach weniger. Der Einwand verwechselt zwei Größen – Aufwand pro Auslieferung und Aufwand pro gelernter Erkenntnis.\n\n„Accelerate“ von Forsgren, Humble und Kim liefert dafür einen Teil des technischen Fundaments. Die vier damaligen DORA-Metrics – Deployment Frequency, Lead Time for Changes, Change Fail Rate und Time to Restore – stehen für Lieferfähigkeit, nicht für den Marktwert eines Experiments. Ein Team, das kleine Änderungen häufig und stabil ausliefern kann, senkt jedoch die technische Hürde für kurze Versuche und schnelle Rücknahmen.\n\nEin MVP, das nur ein reduziertes Feature-Set ist, spart einmalig Bauzeit und verschiebt das Risiko nach hinten. Ein MVP als Experiment kostet vorne etwas Disziplin und macht das Risiko früher sichtbar. Das ist keine Garantie für weniger Reibung oder höhere Qualität. Es trennt aber die Frage „Können wir es liefern?“ von der wichtigeren Frage „Welche Annahme prüfen wir damit?“.\n\nWer das MVP als Experiment ernst nimmt, denkt übrigens von Anfang an in Richtung tragfähiger Architektur. Ein Ansatz wie die *evolutionary architecture* mit *fitness functions* passt genau, weil er akzeptiert, dass sich das Produkt entlang dessen entwickelt, was man lernt – nicht entlang eines Plans, den niemand geprüft hat.\n\n## Fazit\n\nEin MVP ist kein amputiertes Produkt. In meiner Arbeitsdefinition ist es ein Experiment, das eine riskante Annahme ehrlich testet – die kleinste Version, mit der man eine Hypothese wirklich prüfen kann. Ein bestätigender Test braucht eine Lernfrage und ein vorab festgelegtes Entscheidungskriterium; explorative Discovery darf offener beginnen. Häufig ist das ein vertikaler Schnitt bis zum Wert, manchmal ein Concierge-Test oder eine Landingpage. „Minimal“ meint den nötigen Umfang, nicht Nachlässigkeit. Und es muss nicht einmal Code sein.\n\nWenn ein Team von „das Produkt, aber weniger“ auf „die kleinste Version, die eine Annahme ehrlich testet“ umschwenkt, ändert sich nicht der Werkzeugkasten, sondern die Leitfrage. Nicht mehr „Was können wir noch weglassen?“, sondern „Was müssen wir als Nächstes lernen, und wie sieht der kleinste ehrliche Test dafür aus?“. Diese Frage verhindert keine Fehlentscheidung, macht aber sichtbar, welches Lernen die nächste Investition rechtfertigen soll.\n\n## Weiterführende Quellen\n\n- Eric Ries, *The Lean Startup* (2011) – Definition des MVP, Build-Measure-Learn, validated learning, vanity vs. actionable metrics\n- Steve Blank, „A minimum viable product is not always…“ (Blog, 2013) und *The Four Steps to the Epiphany* (2005) – Customer Development\n- Melissa Perri, „Finding the Truth Behind MVPs“ (2016) – Concierge- und Wizard-of-Oz-MVP\n- Nicole Forsgren, Jez Humble, Gene Kim, *Accelerate* (2018) – die vier DORA-Metrics\n- Neal Ford, Rebecca Parsons, Patrick Kua, *Building Evolutionary Architectures* (O'Reilly, 2017) – fitness functions\n- Martin Fowler, „FeatureToggle“ (2017) und trunkbaseddevelopment.com – Feature-Flags und Trunk-Based Development\n- Consumer-Driven Contracts (Fowler/Robinson, 2006) sowie das Contract-Testing-Werkzeug Pact"},{"slug":"graphql-schema-in-der-fachsprache","title":"GraphQL-Schema in der Fachsprache","summary":"Ein GraphQL-Schema ist der beste Ort, den es je für die ubiquitäre Fachsprache gab – und wenn die Domäne deutsch ist, darf sie dort auch deutsch bleiben.","tags":["Software","GraphQL","API"],"published_at":"2018-04-24T10:00:00+00:00","updated_at":"2026-07-14T16:58:22.628285+00:00","url":"https://www.mikebild.dev/de/blog/graphql-schema-in-der-fachsprache/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/ae6c5e61c5ada1dd13a5ff8684e3ba3f096bbd99d0e5836f0c094a50ce9c890e/graphql-schema-in-der-fachsprache-vorlesen.mp3","audio_duration_secs":745,"text":"# GraphQL-Schema in der Fachsprache\n\n„Fachsprache\" hat zwei Lesarten, und die geläufigere ist hier ausdrücklich nicht gemeint. Es geht nicht um Jargon – nicht um das Vokabular, mit dem eine Branche Außenstehende auf Abstand hält. Gemeint ist das, was Eric Evans 2003 in „Domain-Driven Design\" als ubiquitous language beschrieben hat: die eine gemeinsame Sprache, die Fachseite und Entwicklung in einem Projekt tatsächlich sprechen. Dieselben Wörter im Meeting, im Ticket, im Modell, im Code. Ein Vokabular für alle – keine zwei Vokabulare mit einer Übersetzungstabelle dazwischen.\n\nDie Idee ist fünfzehn Jahre alt, und sie scheitert in der Praxis selten am Willen. Sie scheitert am Ort. Ein Glossar im Wiki veraltet ab dem Tag, an dem es angelegt wird, weil niemand es mitzieht, wenn ein Feld umbenannt wird. Der Code wäre der ehrlichere Ort, aber der Code gehört nur einer Seite: Die Fachabteilung liest keine Java-Klassen. Was all die Jahre fehlte, war ein Artefakt, das beide Seiten lesen können und das gleichzeitig maschinell geprüft wird.\n\nSeit ich mit GraphQL arbeite, bin ich überzeugt, dass es dieses Artefakt inzwischen gibt: das Schema. Und – das ist der unbequemere Teil der These – man darf die Fachsprache dort auch deutsch sprechen.\n\n## Zweimal übersetzen, zweimal verlieren\n\nIn Projekten mit deutscher Fachseite ist mir über die Jahre immer wieder dasselbe Muster begegnet. Die Fachabteilung sagt „Angebot\". In der Datenbank steht `OFFER`, im Service heißt die Klasse `Quote`, im Frontend taucht ein `Proposal` auf – drei Übersetzungen desselben Wortes, entstanden zu drei Zeitpunkten unter drei verschiedenen Zeitdrücken. Jedes Gespräch zwischen Fachseite und Entwicklung übersetzt seitdem zweimal: hin beim Zuhören, zurück beim Antworten.\n\nJede dieser Übersetzungen kostet nicht nur Zeit, sie verfälscht. Ob „Angebot\" nun offer, quote, bid oder proposal heißen soll, ist keine neutrale Wahl – jedes dieser Wörter trägt im Englischen eine eigene Bedeutung, und keine deckt sich genau mit dem, was die Fachseite meint. Die Entscheidung trifft am Ende ein Entwickler unter Termindruck, und sie steht dann für Jahre im System – unbeachtet, aber wirksam bei jedem einzelnen Gespräch über genau dieses Feld.\n\n```mermaid\nflowchart LR\n  F[\"Fachseite<br/>sagt „Angebot“\"] -->|übersetzen| E[\"Entwicklung<br/>Offer? Quote? Bid?\"]\n  E -->|übersetzen| S[\"System<br/>OFFER, Quote, Proposal\"]\n  S -->|rückübersetzen| E\n  E -->|rückübersetzen| F\n```\n\nDie teuersten Missverständnisse, die ich in Reviews erlebt habe, waren selten technischer Natur. Es waren Begriffe, die auf dem Weg durch diese Kette ihre Bedeutung gewechselt hatten – ein „storniertes Angebot\", das im System ein `cancelled quote` war, obwohl die Fachseite unter Stornierung etwas anderes verstand als die Entwicklung unter cancelled.\n\n## Das Experiment: uber-ng\n\nIm März habe ich dazu ein kleines Repository angelegt: [uber-ng](https://github.com/MikeBild/uber-ng), eine bewusst überschaubare Nachstellung einer Fahrtvermittlung, wie man sie von Uber kennt. Kein Produkt – ein Prototyp mit In-Memory-Daten, ein paar Mocha-Tests und einer einzigen Frage: Wie liest sich ein GraphQL-Schema, wenn die Fachbegriffe deutsch bleiben?\n\nDas Schema steht dort als eigene Datei in Schema-Notation – jener SDL, die gerade in die GraphQL-Spezifikation aufgenommen wird, nachdem sie jahrelang nur Konvention der Werkzeuge war. Der Kern sieht so aus:\n\n```graphql\nenum STATUS {\n  angefragt\n  warteAufBestaetigung\n  angenommen\n}\n\ntype Angebot implements Node {\n  id: ID!\n  fahrgast: Fahrgast\n  von: String!\n  nach: String!\n  fahrer: Fahrer\n  wartezeit: String\n  status: STATUS\n}\n\ntype Mutation {\n  fahrtAnfragen(input: AnfrageInput!): Angebot\n  angebotAnnehmen(input: AngebotAnnehmenInput!): Angebot\n  angebotAblehnen(input: AngebotAblehnenInput!): Angebot\n}\n\ntype Subscription {\n  onAngebotChanged: Angebot\n}\n```\n\nWer das liest, braucht kein Glossar daneben. Eine Fahrt wird angefragt; was dabei entsteht, ist ein Angebot; ein Angebot kann angenommen oder abgelehnt werden – und wer ablehnt, muss einen Grund nennen, denn `AngebotAblehnenInput` verlangt ein `grund: String!`. Die Mutationen sind Verben der Domäne, keine CRUD-Prosa. `fahrtAnfragen` erzwingt sogar eine fachliche Unterscheidung, die ein `createOffer` verschluckt hätte: Angefragt wird eine Fahrt – das Angebot ist die Antwort des Systems darauf, nicht das, was der Fahrgast selbst anlegt. Dieser Unterschied ist mir erst beim Formulieren des Schemas aufgefallen, nicht vorher.\n\nAuch die Anfragen der Clients sprechen anschließend diese Sprache:\n\n```graphql\nmutation {\n  fahrtAnfragen(input: {\n    fahrgastId: \"f1\"\n    von: \"Alexanderplatz\"\n    nach: \"Flughafen Tegel\"\n  }) {\n    id\n    status\n    wartezeit\n  }\n}\n```\n\nMan kann diese Mutation in einem Termin laut vorlesen, und die Fachseite versteht sie ohne Dolmetscher. Das ist keine Kleinigkeit. Es ist der Moment, in dem eine API aufhört, ein reines Entwickler-Artefakt zu sein.\n\nTechnisch ist der Aufbau bewusst gewöhnlich gehalten: Node, ein GraphQL-Server, Subscriptions über WebSockets, wie sie sich mit `subscriptions-transport-ws` inzwischen eingespielt haben. `onAngebotChanged` schiebt jede Zustandsänderung eines Angebots an die verbundenen Clients – der Fahrgast sieht, wenn aus `angefragt` ein `warteAufBestaetigung` wird, ohne nachfragen zu müssen. Für das Experiment ist das Beiwerk. Interessant ist nur, dass auch dieser Kanal die Fachsprache spricht: Was beim Client ankommt, heißt Angebot, nicht `OfferDTO`.\n\n## Was das Schema zum Glossar macht\n\nEin Glossar beschreibt eine Sprache. Das Schema erzwingt sie – und genau das hat es jedem Glossar voraus, das mir bisher untergekommen ist:\n\n- Jede Query wird gegen das Schema validiert. Wer einen Begriff erfindet oder falsch schreibt, bekommt einen Fehler statt eines stillen Missverständnisses.\n- Introspection macht das Vokabular abfragbar: GraphiQL zeigt Typen, Felder und Beschreibungen schon beim Tippen an – das Glossar liefert sich selbst aus.\n- `@deprecated` mustert Begriffe geordnet aus, mit Begründung und sichtbar für jeden Client. In uber-ng hängt genau so ein Vermerk an `fahrgastId`.\n- Das Schema steht in der Versionsverwaltung. Jede Begriffsänderung ist ein Diff, und ein Diff kann ein Review bekommen, an dem auch die Fachseite teilnimmt.\n\nDazu kommen die Beschreibungstexte: Die SDL erlaubt an jedem Typ, jedem Feld und jedem Enum-Wert eine Beschreibung, die Introspection mit ausliefert. Dort steht dann nicht nur, dass es ein `Angebot` gibt, sondern was die Fachseite darunter versteht – die Definition wandert mit dem Begriff mit, statt in einem anderen System zu altern.\n\nEin Wiki kann nichts davon. Ein UML-Modell auch nicht. Das GraphQL-Schema ist meines Wissens das erste Artefakt, das gleichzeitig für Menschen lesbar, für Maschinen prüfbar und für den Betrieb verbindlich ist. Das Schema ist das Glossar – nicht dessen Abschrift.\n\n## Zustände haben Namen\n\nDer unscheinbarste Teil des Schemas trägt dabei am meisten: das Enum. `STATUS` mit seinen drei Werten ist eine vollständige, prüfbare Beschreibung des Lebenszyklus eines Angebots – und die Werte heißen so, wie die Fachseite sie am Telefon nennen würde.\n\nDiesen Lifecycle habe ich in älteren Systemen selten irgendwo aufgeschrieben gefunden. Er verteilt sich dort auf Status-Strings in der Datenbank, if-Kaskaden im Service und das Gedächtnis der dienstältesten Kollegin. Hier ist er ein paar Zeilen lang, versioniert und für jeden Client sichtbar:\n\n```mermaid\nstateDiagram-v2\n  [*] --> angefragt: fahrtAnfragen\n  angefragt --> warteAufBestaetigung: Vermittlung läuft\n  warteAufBestaetigung --> angenommen: angebotAnnehmen\n  warteAufBestaetigung --> [*]: angebotAblehnen\n```\n\nHier zeigt sich allerdings auch die erste hässliche Stelle: `warteAufBestaetigung`. GraphQL-Namen erlauben nur ASCII – Buchstaben, Ziffern, Unterstrich. Das „ä\" muss draußen bleiben, die Fachsprache kommt nur gefiltert ins Schema. Ich schreibe das bewusst hin, statt es zu verstecken: Wer deutsche Bezeichner wählt, wählt auch `Bestaetigung`, `Strasse` und `ueberweisen`. Im Fließtext wäre das ein Rechtschreibfehler; im Schema ist es der Preis der Entscheidung, und man sollte ihn kennen, bevor man sie trifft.\n\n## Schema deutsch, Implementierung englisch\n\nDie zweite Frage, die das Experiment beantworten sollte: Wo verläuft die Grenze? Nach ein paar Wochen liegt sie für mich exakt an der Resolver-Map. Das Schema trägt die Fachsprache, alles dahinter bleibt englisch – Variablen, Hilfsfunktionen, Kommentare. Vereinfacht, hier mit `graphql-subscriptions` notiert, sieht das Muster so aus:\n\n```javascript\nconst uuid = require('uuid/v4');\nconst { PubSub } = require('graphql-subscriptions');\n\nconst pubsub = new PubSub();\nconst offers = {}; // in-memory store, resets on restart\n\nconst resolvers = {\n  Query: {\n    angebote: (root, { take = 10, skip = 0 }) =>\n      Object.values(offers).slice(skip, skip + take),\n  },\n  Mutation: {\n    fahrtAnfragen: (root, { input }) => {\n      // a ride request creates a new offer in its initial state\n      const offer = {\n        id: uuid(),\n        von: input.von,\n        nach: input.nach,\n        status: 'angefragt',\n      };\n      offers[offer.id] = offer;\n      pubsub.publish('offerChanged', { onAngebotChanged: offer });\n      return offer;\n    },\n    angebotAnnehmen: (root, { input }) => {\n      const offer = offers[input.angebotId];\n      // accepting is only valid while the offer awaits confirmation\n      if (offer.status !== 'warteAufBestaetigung') return offer;\n      offer.status = 'angenommen';\n      pubsub.publish('offerChanged', { onAngebotChanged: offer });\n      return offer;\n    },\n  },\n  Subscription: {\n    onAngebotChanged: {\n      subscribe: () => pubsub.asyncIterator('offerChanged'),\n    },\n  },\n};\n```\n\nDie deutschen Wörter tauchen genau dort auf, wo das Schema sie verlangt: als Schlüssel der Resolver-Map und als Enum-Werte. Der Rest ist gewöhnlicher englischer Code. Diese Trennung hat sich als erstaunlich stabil erwiesen, und sie entspricht der fachlichen Wahrheit: `offers` und `pubsub` sind Technik und dürfen englisch sein. `Angebot` ist ein Begriff mit fachlicher Bedeutung, und der gehört der Fachseite – nicht dem, der ihn gerade implementiert.\n\nEin Side Effect, mit dem ich nicht gerechnet hatte: Auch die Tests lesen sich plötzlich wie Fachtexte. Ein Mocha-Test, der `fahrtAnfragen` aufruft und danach prüft, ob der Status `angefragt` ist, beschreibt einen fachlichen Sachverhalt – man könnte ihn fast wörtlich als Abnahmekriterium ins Ticket kopieren. Aus Testabdeckung wird ein Stück dokumentierte Fachlichkeit, ohne dass dafür ein zusätzliches Werkzeug nötig wäre.\n\n## Was dagegen spricht\n\nEhrlich bleiben: Das Experiment hat auch die Widerstände sichtbar gemacht, und die sind nicht klein.\n\nDer Stilbruch zuerst. `type Angebot implements Node` mischt zwei Sprachen in einer Zeile. `onAngebotChanged` klebt ein englisches Präfix an ein deutsches Substantiv und hängt ein englisches Partizip dahinter – ein Bezeichner aus drei Sprachschichten. camelCase ist für englische Wortgrenzen gebaut; mit deutschen Komposita wird es holprig, `warteAufBestaetigung` liest niemand flüssig. Das stört beim Lesen, jeden Tag ein bisschen, und es hört nicht auf zu stören.\n\nDazu kommt das Umfeld. Jedes Tutorial, jede Stack-Overflow-Antwort, jedes Beispiel in der Apollo-Dokumentation ist englisch – wer Schnipsel überträgt, übersetzt jetzt in die Gegenrichtung. Und die härteste Grenze ist das Team selbst: Sobald eine Kollegin dazukommt, die kein Deutsch spricht, kippt die Rechnung vollständig. Dann ist das deutsche Schema keine Brücke zur Fachseite mehr, sondern eine Mauer mitten durch die Entwicklung.\n\nUnd ein praktischer Punkt, der im Alltag zählt: Suchbarkeit. Wer zu `angebotAnnehmen` nach einer Fehlermeldung sucht, findet nichts – wer zu `acceptOffer` sucht, findet wenigstens fremde Diskussionen, die halb passen. Deutsche Bezeichner koppeln ein Projekt ein Stück weit von der kollektiven Fehlersuche des Netzes ab. Für einen Prototyp ist das egal, für ein Team unter Termindruck nicht.\n\n## Wann die Fachsprache gewinnt\n\nDie Abwägung ist deshalb keine Geschmacksfrage, sondern eine Frage an die Domäne: In welcher Sprache ist sie selbst verfasst?\n\nEine Fahrtvermittlung ist ein dankbares Spielfeld, aber der eigentliche Anwendungsfall sind Domänen, die deutsch sind – nicht aus Gewohnheit, sondern von Rechts wegen. Verträge nach deutschem Recht. Tarifwerke. Abrechnungsregeln, deren Begriffe eine deutsche Verordnung wörtlich vorgibt. In einem Abrechnungsprojekt vor einigen Jahren waren Wörter wie „Abschlag\" und „Gutschrift\" juristisch belegt; ihre englischen Übersetzungen im Code haben regelmäßig Diskussionen ausgelöst, die es fachlich gar nicht gab – war der `discount` nun ein Abschlag oder doch ein Rabatt? Wenn das Gesetz deutsch spricht und die Fachseite deutsch denkt, ist eine englische API-Schicht die einzige Stelle im System, die übersetzt. Und damit die einzige Stelle, die unbemerkt lügen kann.\n\nUmgekehrt gilt das genauso. Ein internationales Team, ein Produkt für mehrere Märkte, eine öffentliche API – dort ist Englisch die Fachsprache, und dann gehört sie auch konsequent ins Schema. Einen brauchbaren Mittelweg bietet die SDL übrigens gleich mit: englische Bezeichner, aber deutsche Beschreibungstexte an Typen und Feldern. Dann trägt das Schema wenigstens die Definitionen der Begriffe, wenn schon nicht ihre Namen – und die Übersetzungstabelle steht dort, wo sie geprüft und versioniert wird, statt in einem Wiki zu verstauben.\n\n## Unterm Strich\n\nuber-ng wird kein Produkt, und das musste es nie werden. Das Experiment hat mir zwei Dinge gezeigt. Erstens: Der Ort stimmt. Ein GraphQL-Schema kann leisten, was Glossare, Wikis und Modell-Dokumente fünfzehn Jahre lang versprochen und nicht gehalten haben – eine gemeinsame Sprache, die geprüft wird, statt nur beschrieben zu werden. Zweitens: Die Sprache im Schema ist eine Architekturentscheidung, keine Stilfrage. Deutsch im Schema kostet – Stilbrüche, ASCII-Krücken, Reibung mit dem Ökosystem. Es zahlt sich genau dann aus, wenn die Domäne deutsch ist und jede Übersetzung eine zweite, ungeprüfte Wahrheit erzeugen würde.\n\nFür die Projekte dazwischen bleibt die Frage offen, und das ist in Ordnung. Wichtig ist mir nur, dass sie überhaupt gestellt wird. Bisher wurde sie in jedem Schema, das ich gesehen habe, stillschweigend mit „englisch\" beantwortet – und stillschweigende Antworten sind selten die durchdachten.\n\n## Weiterführende Quellen\n\n- [uber-ng – GraphQL-Demo mit deutschem Schema (GitHub)](https://github.com/MikeBild/uber-ng)\n- [GraphQL-Dokumentation: Schemas and Types](https://graphql.org/learn/schema/)\n- [GraphQL Specification](https://spec.graphql.org/)\n- [Martin Fowler: Ubiquitous Language](https://martinfowler.com/bliki/UbiquitousLanguage.html)\n- [Eric Evans: Domain-Driven Design Reference](https://www.domainlanguage.com/ddd/reference/)"},{"slug":"angular-react-uebergaenge","title":"Angular, React und der Wert von Übergängen","summary":"Ob ein Framework-Wechsel zum Umzug oder zum Neubau wird, entscheidet sich daran, wie billig zwei Welten monatelang nebeneinander laufen können.","tags":["Software","Frontend"],"published_at":"2018-03-12T10:00:00+00:00","updated_at":"2026-07-14T16:56:24.827181+00:00","url":"https://www.mikebild.dev/de/blog/angular-react-uebergaenge/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/389e739fff39f1ec5b6604c2853f54f3f9d6bc195e0c3768f09e6a1a5ab26891/angular-react-uebergaenge-vorlesen.mp3","audio_duration_secs":632,"text":"# Angular, React und der Wert von Übergängen\n\nEin Beschluss wie „wir schreiben das Frontend komplett neu\" richtet seinen Schaden nicht sofort an, sondern mit etwa neun Monaten Verzögerung. Dann existieren zwei halbe Produkte: ein altes AngularJS-Frontend, offiziell eingefroren, das trotzdem laufend Hotfixes bekommt, weil zahlende Nutzer damit arbeiten – und ein neues, das bei gefühlten sechzig Prozent steht und keinen Release-Termin hat, weil „erst alles fertig sein muss\". Jede Fachanforderung wird ab da doppelt verhandelt: einmal für die alte Welt, einmal für die, die noch nicht ausgeliefert ist. Das Team teilt sich still in Bewahrer und Neubauer, und beide Gruppen werden langsamer.\n\nIch habe diesen Verlauf inzwischen mehr als einmal aus der Nähe gesehen, zuletzt in einem Produktteam mit einer über Jahre gewachsenen AngularJS-Anwendung. Der Fehler lag nie im Ziel-Framework. Er lag in einem Wort, das im Beschluss fehlte: Übergang. Ein Framework-Wechsel ist in der Praxis kein Umschreiben, sondern ein Zustand, in dem zwei Welten monatelang parallel laufen. Und ob dieser Zustand billig oder ruinös wird, hat mit Angular oder React erstaunlich wenig zu tun.\n\n## Der Treiber: AngularJS bekommt ein Ablaufdatum\n\nSeit Ende Januar ist offiziell, was sich lange abgezeichnet hat: AngularJS 1.x bekommt mit Version 1.7 einen letzten Entwicklungszyklus und wechselt danach in eine dreijährige Long-Term-Support-Phase. Ab dann gibt es nur noch Sicherheitskorrekturen und Anpassungen an neue Browser- und jQuery-Versionen, keine Features mehr. Für Teams mit großen 1.x-Anwendungen ist das der Moment, in dem aus „irgendwann müssen wir da raus\" ein Termin wird.\n\nEntsprechend viele Migrationsentscheidungen fallen gerade. Ob das Ziel Angular 5 heißt (Angular 6 steht für die kommenden Monate an) oder React 16 – die Richtung finde ich zweitrangig. Interessant ist, wie die Entscheidung formuliert wird. „Wir schreiben neu\" ist ein Projekt mit Enddatum, und dieses Enddatum reißt fast immer. „Wir betreiben beide Welten parallel und verschieben Stück für Stück\" ist dagegen ein Zustand, den man steuern, messen und notfalls anhalten kann.\n\n## Umschreiben oder umziehen\n\nDamit bin ich bei der eigentlichen These: Die Qualität einer Frontend-Architektur zeigt sich nicht daran, wie modern ihr Framework ist, sondern daran, wie billig der Parallelbetrieb zweier Frameworks wäre. Wer Fachlogik framework-frei gehalten hat – reine Module, Services ohne DI-Magie, State außerhalb der Komponenten – zieht um. Wer Berechnungen, Validierungen und API-Aufrufe in Controller, Direktiven und Komponenten gegossen hat, schreibt neu, ob er will oder nicht. Das Framework war dann nie ein Rahmen, sondern der Träger des gesamten Gebäudes, und Träger tauscht man nicht im laufenden Betrieb.\n\nIn dem erwähnten Produktteam haben wir vor dem ersten Migrationsschritt zwei Wochen lang gar nicht migriert. Wir haben Fachlogik aus Direktiven herausgezogen und in schlichte ES-Module verschoben, mit den bestehenden AngularJS-Services als dünner Hülle darüber. Erst danach war überhaupt entscheidbar, welcher Teil ein Umzug wird und welcher ein Neubau bleiben muss. Die Unterscheidung war ernüchternd konkret: Was ohne `angular.module`-Import auskam, ließ sich verschieben wie ein Möbelstück. Alles andere musste einzeln bewertet werden.\n\n## Hybridbetrieb mit ngUpgrade\n\nFür die Richtung AngularJS nach Angular ist Parallelbetrieb kein Trick, sondern ein dokumentierter, offiziell unterstützter Modus. Das `UpgradeModule` startet die alte Anwendung innerhalb der neuen:\n\n```typescript\n// app/app.module.ts — Angular 5 hosting AngularJS 1.6\nimport { NgModule } from '@angular/core';\nimport { BrowserModule } from '@angular/platform-browser';\nimport { UpgradeModule } from '@angular/upgrade/static';\n\n@NgModule({\n  imports: [BrowserModule, UpgradeModule],\n})\nexport class AppModule {\n  constructor(private upgrade: UpgradeModule) {}\n\n  ngDoBootstrap() {\n    // AngularJS boots inside Angular, not the other way around\n    this.upgrade.bootstrap(document.documentElement, ['orderApp'], { strictDi: true });\n  }\n}\n```\n\nAb diesem Punkt laufen beide Frameworks im selben DOM-Baum. Neue Angular-Komponenten lassen sich in alte Templates hineinreichen:\n\n```typescript\n// expose a new Angular component to old AngularJS templates\nimport { downgradeComponent } from '@angular/upgrade/static';\nimport { InvoiceFooterComponent } from './invoice-footer.component';\n\nangular\n  .module('orderApp')\n  .directive('invoiceFooter', downgradeComponent({ component: InvoiceFooterComponent }));\n```\n\nUmgekehrt lassen sich alte Services in die neue Dependency Injection hochreichen. Das klingt bequem, und genau darin liegt die Gefahr: Wer die Brücke zu oft benutzt, verflechtet beide Welten enger, als sie es vor der Migration waren. Zwei Change-Detection-Mechanismen laufen gleichzeitig: der Digest-Zyklus von AngularJS und die Zone-basierte Erkennung von Angular. Jedes Event, das die Grenze quert, wird in beide Welten übersetzt, und Fehler an dieser Naht sind unangenehm zu suchen. Der Hybridbetrieb ist ein Werkzeug, um eine Übergangszeit zu überstehen – nicht, um sie zu verlängern. Er braucht ein Ende, das jemand ausspricht.\n\n## Route für Route: der Strangler-Ansatz\n\nDie Idee dahinter ist deutlich älter als jedes der beteiligten Frameworks. Martin Fowler hat schon 2004 beschrieben, wie neue Systeme alte umwachsen wie eine Würgefeige ihren Wirtsbaum – die Strangler Application. Auf ein Frontend übersetzt heißt das: Eine schmale Shell besitzt den Router und entscheidet pro Route, welche Welt rendert. Für genau dieses Muster gibt es mit single-spa inzwischen sogar ein eigenes kleines Framework, aber im Kern reicht Routing-Disziplin.\n\n```mermaid\nflowchart LR\n  B[Browser] --> S[Router-Shell]\n  S --> O[\"/orders<br/>AngularJS 1.6\"]\n  S --> I[\"/invoices<br/>Angular 5\"]\n  S --> R[\"/reports<br/>Angular 5\"]\n  O -. \"Route für Route<br/>wandert um\" .-> I\n```\n\nDer Schnitt entlang von Routen hat drei Eigenschaften, die ich an keinem anderen Migrationsschnitt in dieser Kombination gefunden habe:\n\n- Jeder Schritt ist einzeln zurücknehmbar – eine Route zurückzuschalten ist ein Router-Eintrag, kein Rollback-Drama.\n- Der Fortschritt ist messbar und für alle sichtbar: so viele Routen alt, so viele neu.\n- Die Grenze ist fachlich, nicht technisch. „Rechnungen sind umgezogen\" versteht auch der Produktverantwortliche; „der Direktiven-Layer ist portiert\" versteht niemand außerhalb des Teams.\n\nDie Big-Bang-Alternative hat keine dieser Eigenschaften. Sie hat nur einen Stichtag, und Stichtage verteidigen sich nicht selbst.\n\nNebenbei ändert der Routen-Schnitt auch die Teamdynamik. Statt einer Bewahrer- und einer Neubauer-Gruppe gibt es ein Team, das dieselbe Anwendung betreibt und dabei Route für Route umbaut. Jeder abgeschlossene Schritt landet in Produktion und wird von echten Nutzern getreten – die Rückmeldung kommt nach Wochen, nicht nach anderthalb Jahren. Das ist unspektakulär, aber genau darin liegt der Wert.\n\n## Fachlogik, die den Umzug überlebt\n\nDas billigste Umzugsgut ist Code, der von keinem Framework weiß. In der erwähnten Anwendung war die Rechnungssummen-Logik so ein Kandidat – vorher verteilt über eine Direktive und zwei Controller, nachher ein einziges Modul:\n\n```javascript\n// billing/invoice-totals.js — plain ES module, no framework imports\nexport function calculateInvoiceTotal(items, taxRate) {\n  const net = items.reduce((sum, item) => sum + item.price * item.quantity, 0);\n  const tax = net * taxRate;\n  return { net, tax, gross: net + tax };\n}\n```\n\nDie alte Welt nutzt das Modul über eine AngularJS-Komponente:\n\n```javascript\n// old world: AngularJS 1.6 component wrapping the plain module\nimport { calculateInvoiceTotal } from '../billing/invoice-totals';\n\nangular.module('orderApp').component('invoiceFooter', {\n  bindings: { items: '<', taxRate: '<' },\n  controller: function InvoiceFooterController() {\n    this.$onChanges = () => {\n      this.totals = calculateInvoiceTotal(this.items, this.taxRate);\n    };\n  },\n  template: '<strong>{{ $ctrl.totals.gross | currency }}</strong>',\n});\n```\n\nDie neue Welt – hier bewusst React 16, um zu zeigen, dass die Richtung egal ist – nutzt dasselbe Modul unverändert:\n\n```jsx\n// new world: React 16 component, same module untouched\nimport { calculateInvoiceTotal } from '../billing/invoice-totals';\n\nexport function InvoiceFooter({ items, taxRate }) {\n  const totals = calculateInvoiceTotal(items, taxRate);\n  return <strong>{totals.gross.toFixed(2)} €</strong>;\n}\n```\n\nDie Abhängigkeitsrichtung ist dabei die eigentliche Regel, nicht das Modulformat:\n\n```mermaid\nflowchart TD\n  OLD[\"AngularJS-Komponenten<br/>alte Welt\"] --> CORE[\"Fachlogik-Module<br/>ohne Framework-Import\"]\n  NEW[\"React- oder Angular-Komponenten<br/>neue Welt\"] --> CORE\n  CORE -. \"verboten:<br/>Kern kennt kein UI\" .-> OLD\n```\n\nFramework-frei halten lässt sich mehr, als der erste Blick vermuten lässt: Berechnungen und Validierungen als Pure Functions, der API-Zugriff hinter einem eigenen Modul statt `$http` oder `HttpClient` direkt in Komponenten, der Anwendungszustand in einem eigenen Store. Redux ist hier ein gutes Beispiel, weil es absichtlich framework-neutral gebaut ist – `react-redux` und `ng-redux` sind nur dünne Bindings. Ein Redux-Store samt Reducern zieht ohne eine geänderte Zeile von AngularJS nach React um. Was übrig bleibt, sind dünne Komponenten, die Daten annehmen und Events abgeben. Genau die sind es dann auch, die neu geschrieben werden – und das ist der Teil der Arbeit, der schnell geht und wenig Risiko trägt.\n\n## Custom Elements als neutrale Brücke\n\nWer nicht AngularJS nach Angular migriert, sondern etwa Angular und React im selben Produkt versöhnen muss, hat kein `UpgradeModule`. Die neutralste Brücke, die sich hier abzeichnet, ist die Web-Plattform selbst: Custom Elements. Eine Komponente, die als `<invoice-footer>` im DOM registriert ist, kann von jeder Welt gerendert werden – React schreibt das Tag einfach ins JSX, AngularJS in ein Template, ein serverseitig gerendertes CMS in seinen HTML-Strom.\n\nMit Angular Elements ist genau dafür ein offizielles Labs-Projekt angekündigt, das mit Angular 6 greifbar werden soll: Angular-Komponenten als Custom Elements verpacken, ohne Adapter je Ziel-Framework. Ich finde die Richtung bemerkenswert, weil sie die Brücke aus dem Framework-Streit herauszieht und auf einen Browser-Standard stellt. Ehrlich bleiben muss man bei der Browser-Lage: Custom Elements v1 laufen in Chrome und Safari nativ, anderswo braucht es aktuell noch Polyfills. Für interne Anwendungen ist das meist tragbar, für öffentliche Seiten will es abgewogen sein.\n\n## Der ehrliche Preis: zwei Bundles\n\nParallelbetrieb ist nicht gratis, und ich halte nichts davon, das kleinzureden. Wer AngularJS und Angular – oder Angular und React – gleichzeitig ausliefert, schickt zwei Framework-Laufzeiten über die Leitung. Je nach Kombination sind das schnell 150 Kilobyte zusätzliches komprimiertes JavaScript, bevor die erste eigene Zeile geladen ist. Dazu kommen zwei Rendering-Modelle im selben Browser-Tab, doppelte Datums- und Zahlformatierung, im schlechten Fall doppelte HTTP-Schichten mit getrennten Caches.\n\nDas frisch erschienene webpack 4 macht wenigstens die Bundle-Seite besser handhabbar: `splitChunks` ersetzt das alte CommonsChunkPlugin und zieht gemeinsam genutzte Module – etwa die framework-freien Fachlogik-Pakete – automatisch in geteilte Chunks. Aber Werkzeuge lindern den Preis nur, sie heben ihn nicht auf. Deshalb gehört zu einem ehrlichen Übergang ein Budget: eine gemessene Obergrenze für das Gesamtgewicht, ein Datum, ab dem das alte Bundle nicht mehr wachsen darf, und ein Zielquartal, in dem es verschwindet. Der Parallelbetrieb ist ein Kredit. Man darf ihn aufnehmen – man sollte nur den Zins kennen und den Tilgungsplan aufschreiben.\n\n## Was ich daraus mitnehme\n\nDie LTS-Ankündigung für AngularJS hat vielen Teams gerade eine Deadline verpasst, und Deadlines verleiten zu Heldenbeschlüssen. Der Big-Bang-Rewrite ist so ein Heldenbeschluss: Er fühlt sich am Tag der Entscheidung gut an und neun Monate später nach zwei halben Produkten. Der Übergang ist die unspektakuläre Alternative – Hybridbetrieb wo es ihn gibt, Strangler-Schnitt entlang der Routen, Custom Elements als Brücke, ein ehrlich beziffertes Ladegewicht.\n\nWas mich an dem Thema aber eigentlich beschäftigt, ist der Blick zurück auf die eigene Architektur. Der Parallelbetrieb ist ein Prüfstand: Er macht sichtbar, wie viel der Anwendung wirklich uns gehört und wie viel dem Framework. Fachlogik in reinen Modulen, State außerhalb der Komponenten, API-Zugriff hinter eigenen Grenzen – das zahlt sich nicht erst bei der Migration aus, aber bei der Migration wird es unbarmherzig abgerechnet. Und da nach diesem Wechsel irgendwann der nächste kommt, schreibe ich die neue Welt gleich so, dass sie den übernächsten Übergang billig macht. Das ist vielleicht die nützlichste Frage an jeden Frontend-Entwurf, ganz ohne Migrationsdruck: Wie teuer wäre es, neben dieser Anwendung eine zweite Welt zu starten?\n\n## Weiterführende Quellen\n\n- Angular-Dokumentation: Upgrading from AngularJS: https://angular.io/guide/upgrade\n- Martin Fowler: StranglerApplication (2004): https://martinfowler.com/bliki/StranglerApplication.html\n- Angular-Blog: Stable AngularJS and Long Term Support: https://blog.angular.io/stable-angularjs-and-long-term-support-7e077635ee9c\n- single-spa: https://single-spa.js.org/"},{"slug":"javascript-prototypen-modell-hinter-class","title":"Prototypen: das Objektmodell hinter JavaScript-Klassen","summary":"class in ES2015 ist im Kern Syntax-Zucker über Prototypen: Wer die [[Prototype]]-Kette, Object.create und den new-Ablauf versteht, debuggt this-Fehler und geteilte Methoden souveräner.","tags":["Software","Frontend"],"published_at":"2017-11-21T10:00:00+00:00","updated_at":"2026-07-13T13:21:34.260955+00:00","url":"https://www.mikebild.dev/de/blog/javascript-prototypen-modell-hinter-class/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/ad42ae2f0d282cfcfcad12b717b4a46d024f422bcfdb2b4cf715cb565fab7d51/javascript-prototypen-modell-hinter-class-vorlesen.mp3","audio_duration_secs":686,"text":"```js\nclass Animal {\n  constructor(name) {\n    this.name = name;\n  }\n  speak() {\n    return this.name + ' makes a sound';\n  }\n}\n\nconst minka = new Animal('Minka');\nconst rex = new Animal('Rex');\n\nminka.speak === rex.speak;                         // true\nminka.hasOwnProperty('speak');                     // false\nObject.getPrototypeOf(minka) === Animal.prototype; // true\n```\n\nDrei Zeilen Ausgabe, und in ihnen steckt der ganze Artikel. Die Methode `speak` existiert genau einmal, obwohl zwei Instanzen sie aufrufen. Keiner der beiden Instanzen gehört sie – `hasOwnProperty` sagt nein. Gefunden wird sie an einem dritten Objekt, `Animal.prototype`, auf das beide Instanzen mit einem internen Verweis zeigen.\n\nDieses dritte Objekt hat `class` nicht erfunden, es hat es nur aus dem Blickfeld geräumt. Der Verweis darauf trägt in JavaScript jede Methodensuche, jede Vererbung und jeden verlorenen `this`-Bezug. Deshalb baue ich das Modell hier von unten auf: erst reine Prototypen mit `Object.create`, dann Constructor-Functions und der `new`-Ablauf, dann `class` als die Fassade, die beides zusammenfasst.\n\n## Jedes Objekt hat einen Link nach oben\n\nDas Fundament ist erstaunlich schlicht. Jedes Objekt in JavaScript trägt einen internen Verweis, in der Spezifikation `[[Prototype]]` genannt. Dieser Verweis zeigt entweder auf ein anderes Objekt – den Prototyp – oder auf `null`. Wenn du auf eine Property zugreifst, die das Objekt selbst nicht besitzt, schaut die Sprache nicht auf und gibt `undefined` zurück. Sie folgt zunächst dem `[[Prototype]]`-Link und sucht dort weiter. Und findet sie dort nichts, geht sie einen Link höher. So entsteht die Prototyp-Kette, und sie endet bei `null`.\n\nAm klarsten sieht man das ohne jede `class` und ohne `new`, nur mit `Object.create`:\n\n```js\nconst animal = {\n  describe() {\n    return this.name + ' makes ' + this.sound;\n  }\n};\n\nconst dog = Object.create(animal);\ndog.name = 'Rex';\ndog.sound = 'woof';\n\ndog.describe(); // 'Rex makes woof'\n\nObject.getPrototypeOf(dog) === animal; // true\ndog.hasOwnProperty('describe');        // false\ndog.hasOwnProperty('name');            // true\n```\n\nSchau dir an, was hier geschieht. Das Objekt `dog` besitzt selbst nur `name` und `sound` – das bestätigt `hasOwnProperty`. Die Methode `describe` findet es nicht bei sich, sondern erst einen Schritt höher, bei `animal`. Beim Aufruf zählt aber, wer der Empfänger war: `this` ist `dog`, deshalb liest `describe` `Rex` und `woof`. Die Methode liegt am Prototyp, die Daten liegen an der Instanz, und der Lookup verbindet beides. Das ist der ganze Mechanismus – keine Klassen, keine Konstruktoren, nur ein Objekt, das auf ein anderes zeigt.\n\n`Object.create` macht diesen Link explizit sichtbar: Der erste Parameter wird direkt zum `[[Prototype]]` des neuen Objekts. Du kannst sogar `Object.create(null)` schreiben und bekommst ein Objekt ganz ohne Prototyp – ohne `toString`, ohne `hasOwnProperty`, ein blankes Wörterbuch. Nützlich, wenn man ein Objekt als reine Key-Value-Ablage will und keine geerbten Eigenschaften im Weg haben möchte.\n\n## Die Kette, visualisiert\n\nWenn `dog.describe()` aufgerufen wird, läuft der Lookup die Kette hoch. Das folgende Diagramm zeigt die Stationen bis zum Ende bei `null`:\n\n```mermaid\ngraph TD\n  A[\"dog<br/>own: name, sound\"] -->|\"[[Prototype]]\"| B[\"animal<br/>own: describe\"]\n  B -->|\"[[Prototype]]\"| C[\"Object.prototype<br/>own: hasOwnProperty, toString\"]\n  C -->|\"[[Prototype]]\"| D[\"null\"]\n```\n\nJeder Pfeil ist ein `[[Prototype]]`-Verweis, und der Lookup steigt an ihnen entlang auf. Sucht die Sprache `describe`, wird sie bei `animal` fündig. Suchte sie `toString`, ginge es eine Station weiter bis `Object.prototype`. Und suchte sie etwas, das es nirgends gibt, käme sie bei `null` an und lieferte `undefined`. Diese Kette ist kein Sonderfall für selbstgebaute Objekte – jedes Objekt, jedes Array, jede Funktion hängt an so einer Kette, deren Wurzel `Object.prototype` ist.\n\n## Constructor-Functions: der Weg vor `class`\n\nVor ES2015 baute man wiederverwendbare „Typen“ mit Constructor-Functions. Der Trick sitzt in zwei Teilen, die man auseinanderhalten muss. Erstens: Jede Funktion besitzt eine Property namens `prototype` – ein gewöhnliches Objekt, das als Vorlage dient. Zweitens: Der `new`-Operator verbindet ein frisches Objekt mit genau dieser Vorlage.\n\n```js\nfunction Animal(name) {\n  this.name = name;\n}\n\nAnimal.prototype.speak = function () {\n  return this.name + ' makes a sound';\n};\n\nconst cat = new Animal('Minka');\ncat.speak(); // 'Minka makes a sound'\n\nObject.getPrototypeOf(cat) === Animal.prototype; // true\ncat.hasOwnProperty('speak');                     // false\n```\n\nWas tut `new Animal('Minka')` konkret? Der Ablauf ist – vereinfacht – dreistufig. Zuerst wird ein neues, leeres Objekt erzeugt, dessen `[[Prototype]]` auf `Animal.prototype` gesetzt wird. Dann wird `Animal` aufgerufen, wobei `this` auf dieses neue Objekt zeigt – so landet `this.name = name` als eigene Property an der Instanz. Und schließlich: Gibt die Funktion selbst kein Objekt zurück, wird das neue Objekt das Ergebnis. Deshalb steht `speak` nicht an der Instanz, sondern an `Animal.prototype`, und wird von allen Katzen geteilt.\n\nVererbung war vor `class` Handarbeit – und genau diese Handarbeit zeigt, was `extends` später automatisiert:\n\n```js\nfunction Dog(name) {\n  Animal.call(this, name);       // Parent-Konstruktor auf eigenem this\n}\n\nDog.prototype = Object.create(Animal.prototype); // Kette einhängen\nDog.prototype.constructor = Dog;                 // constructor wieder setzen\n\nDog.prototype.speak = function () {\n  return this.name + ' makes woof';\n};\n\nconst rex = new Dog('Rex');\nrex.speak();                 // 'Rex makes woof'\nrex instanceof Dog;          // true\nrex instanceof Animal;       // true\n```\n\nDrei Zeilen tragen hier die Last. `Animal.call(this, name)` ruft den Eltern-Konstruktor auf dem eigenen `this` auf, damit `name` gesetzt wird. `Dog.prototype = Object.create(Animal.prototype)` hängt die Prototyp-Kette ein, sodass eine Dog-Instanz erst bei `Dog.prototype`, dann bei `Animal.prototype`, dann bei `Object.prototype` sucht. Und die dritte Zeile repariert etwas, das die zweite kaputtgemacht hat: Beim Überschreiben von `Dog.prototype` ging die `constructor`-Rückverweisung verloren, die wir manuell wiederherstellen. Genau solche Details sind es, die man in der Praxis vergisst – und die `class` einem abnimmt.\n\n## Dieselbe Sache als `class`\n\nJetzt derselbe Code in ES2015-Syntax, Seite an Seite gedacht. Das Ergebnis ist identisch im Objektmodell – dieselben Prototyp-Objekte, dieselbe Kette, dieselben geteilten Methoden:\n\n```js\nclass Animal {\n  constructor(name) {\n    this.name = name;\n  }\n  speak() {\n    return this.name + ' makes a sound';\n  }\n}\n\nclass Dog extends Animal {\n  constructor(name) {\n    super(name);\n  }\n  speak() {\n    return this.name + ' makes woof';\n  }\n}\n\nconst rex = new Dog('Rex');\nObject.getPrototypeOf(rex) === Dog.prototype;              // true\nObject.getPrototypeOf(Dog.prototype) === Animal.prototype; // true\n```\n\nVergleiche das mit der ES5-Fassung, und die Übersetzung wird direkt sichtbar. Die Methoden `speak` im Class-Body landen auf `Animal.prototype` bzw. `Dog.prototype` – nicht an der Instanz. `extends Animal` erledigt das Einhängen der Kette, für das wir eben `Object.create(Animal.prototype)` von Hand geschrieben haben. Und `super(name)` ersetzt das manuelle `Animal.call(this, name)`. Die letzte Zeile beweist es: `rex` zeigt auf `Dog.prototype`, und dieses zeigt auf `Animal.prototype`. Dieselbe Kette wie vorher, nur ohne den Boilerplate und ohne die vergessene `constructor`-Zeile.\n\nEhrlich bleiben muss man trotzdem: `class` ist Syntax-Zucker, aber nicht ausschließlich. Ein Class-Body läuft immer im Strict Mode. Eine Klasse lässt sich nicht ohne `new` aufrufen – der Versuch wirft einen Fehler, während eine Constructor-Function sich auch als normale Funktion missbrauchen ließe. Und `class`-Deklarationen werden nicht wie `function`-Deklarationen an den Anfang gezogen und initialisiert; sie liegen in einer temporalen toten Zone und sind vor ihrer Definitionszeile nicht benutzbar. Auch `super` in Methoden greift auf einen internen Mechanismus (`[[HomeObject]]`) zurück, den man mit reinem ES5 nicht 1:1 nachbaut. Die Fassade ist also bequem und an ein paar Kanten strenger als das Original – aber das Modell darunter bleibt Prototyp.\n\n## `prototype`, `__proto__` und `Object.create` sauber trennen\n\nDie häufigste Verwirrung, die mir in Code-Reviews begegnet, dreht sich um zwei ähnlich klingende Namen. `prototype` und `__proto__` sind nicht dasselbe, und die Verwechslung produziert Fehler, die man lange anstarrt. Diese Liste sortiert die drei Werkzeuge:\n\n- Eine konstruierbare Funktion besitzt üblicherweise eine eigene `prototype`-Property. `new` verwendet das dort referenzierte Objekt als Prototyp der Instanz. Gewöhnliche Instanzen haben keine eigene `prototype`-Property; Pfeilfunktionen und Methoden sind nicht konstruierbar und besitzen ebenfalls keine solche Vorlage.\n- `__proto__` (bzw. das interne `[[Prototype]]`) ist der tatsächliche Kettenlink, den jedes Objekt trägt. Der kanonische Zugriff läuft über `Object.getPrototypeOf(obj)`; `__proto__` selbst ist in ES2015 nur über Annex B standardisiert und gilt als Legacy.\n- `Object.create(proto)` erzeugt ein Objekt und setzt dessen `[[Prototype]]` direkt auf `proto` – ganz ohne Constructor-Function. Das ist der direkte Weg, einen Kettenlink zu legen.\n\nDer entscheidende Unterschied: `Fn.prototype` ist die Vorlage auf der Funktion, `instance.[[Prototype]]` ist der Link auf dem erzeugten Objekt. Beim Aufruf von `new Fn()` wird das eine zum anderen. Und um es endgültig zu entwirren – die Funktion selbst ist auch ein Objekt und hat ihren eigenen Link:\n\n```js\nfunction Fn() {}\nconst obj = new Fn();\n\nObject.getPrototypeOf(obj) === Fn.prototype; // true  (Instanz-Link)\nobj.prototype;                               // undefined (Instanz hat keine)\nObject.getPrototypeOf(Fn) === Function.prototype; // true (Fn ist selbst Objekt)\n```\n\nAchte auf die letzte Zeile. `Fn.__proto__` ist `Function.prototype`, nicht `Fn.prototype`. Wer diese beiden verwechselt, sucht Methoden an der falschen Stelle. Im Zweifel hilft immer `Object.getPrototypeOf`, um die Kette Schritt für Schritt abzugehen, statt zu raten.\n\n## Der teuerste Fallstrick: Methoden im Konstruktor\n\nJetzt zu dem Punkt, der in echten Codebasen Speicher kostet und in Reviews oft durchrutscht. Es ist ein handfester Unterschied, ob eine Methode am Prototyp liegt oder im Konstruktor erzeugt wird:\n\n```js\nfunction WidgetBad() {\n  this.render = function () { /* ... */ }; // pro Instanz eine neue Funktion\n}\n\nfunction WidgetGood() {}\nWidgetGood.prototype.render = function () { /* ... */ }; // geteilt\n\nconst a1 = new WidgetBad();\nconst a2 = new WidgetBad();\na1.render === a2.render; // false  -> zwei getrennte Funktionen\n\nconst b1 = new WidgetGood();\nconst b2 = new WidgetGood();\nb1.render === b2.render; // true   -> eine Funktion, geteilt\n```\n\nDer Beweis steht in den Vergleichen. Bei `WidgetBad` bekommt jede Instanz ihre eigene `render`-Funktion – bei tausend Widgets sind das tausend identische Funktionen im Speicher. Bei `WidgetGood` liegt `render` einmal an `WidgetGood.prototype`, und alle Instanzen teilen sie sich. Genau das tut `class` automatisch: Methoden im Class-Body landen am Prototyp, nicht an der Instanz. Man muss sich für den guten Fall in `class` also gar nicht anstrengen – man muss ihn nur nicht durch eine Zuweisung im `constructor` zerstören.\n\nDer zweite häufige Fallstrick ist der Verlust von `this`. Eine Prototyp-Methode kennt ihren Empfänger nur, wenn sie als `obj.method()` aufgerufen wird. Reißt man sie los, ist der Bezug weg:\n\n```js\nconst cat = new Animal('Minka');\n\nconst fn = cat.speak;\nfn(); // this ist nicht mehr cat -> Fehler oder falsches Ergebnis\n\nconst bound = cat.speak.bind(cat);\nbound(); // 'Minka makes a sound' -> this wieder gebunden\n```\n\nDas ist kein Bug, sondern eine direkte Folge des Modells: `this` wird beim Aufruf bestimmt, nicht bei der Definition. Löst man `cat.speak` in eine freie Variable, fehlt der Empfänger. Die Werkzeuge dagegen sind 2017 etabliert: `.bind(cat)` erzeugt eine fest gebundene Variante, oder man wickelt den Aufruf in eine Arrow-Function, die `this` aus dem umgebenden Bereich übernimmt. Ein Ausblick am Rande: An Klassen-Feldern mit Arrow-Functions wird gearbeitet, die genau dieses Binden eleganter machen sollen – das ist Ende 2017 aber noch ein TC39-Proposal und kein Standard. Wer es heute produktiv braucht, greift zu `bind` oder Arrow-Wrapper.\n\n## Warum sich das auszahlt\n\nMan kann JavaScript jahrelang mit `class` schreiben, ohne je „Prototyp“ zu sagen. Bis zu dem Tag, an dem `this` in einem Event-Handler `undefined` ist, an dem eine geerbte Methode nicht gefunden wird, oder an dem ein Profiler tausendfach dieselbe Funktion im Heap zeigt. Dann trennt sich, wer das Modell kennt, von dem, der rät. Die drei wiederkehrenden Symptome – verlorenes `this`, Methode an der falschen Stelle, Speicher pro Instanz – sind allesamt keine Marotten der Sprache, sondern logische Konsequenzen einer Kette aus Objekten, die aufeinander zeigen.\n\n`class` hat seine Berechtigung: lesbar, strikt, und es nimmt einem den fehleranfälligen Boilerplate der ES5-Vererbung ab. Aber es ist eine Fassade, und hinter der Fassade steht immer noch `Object.create`, `Fn.prototype` und ein `new`, das beides verbindet. Wenn du das nächste Mal `extends` schreibst, denk kurz an die drei Zeilen, die es ersetzt – die eingehängte Kette, den `super`-Aufruf, den `constructor`-Rückverweis. Dann ist `class` keine Magie mehr, sondern eine Abkürzung, deren Langform du jederzeit selbst hinschreiben könntest.\n\nDieses prototypische Denken ist derselbe rote Faden, der sich durch die [JavaScript-Patterns zieht, die geblieben sind](https://www.mikebild.dev/de/blog/javascript-patterns-module-hof-currying/) – Objekte, die auf Objekte zeigen, Funktionen als Bausteine. Wer dieses Fundament versteht, baut auf Frameworks ruhiger auf.\n\n## Weiterführende Quellen\n\n- MDN, Inheritance and the prototype chain: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Inheritance_and_the_prototype_chain\n- MDN, Object.create: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/create\n- MDN, Object.getPrototypeOf: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getPrototypeOf\n- MDN, Object.setPrototypeOf: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/setPrototypeOf\n- MDN, Object.prototype.\\_\\_proto\\_\\_: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/proto\n- MDN, Classes: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes\n- MDN, new operator: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/new\n- ECMAScript 2015 (ES6) Spec: https://www.ecma-international.org/ecma-262/6.0/\n- ECMAScript 2017 (ES8) Spec: https://www.ecma-international.org/ecma-262/8.0/"},{"slug":"redux-als-protokoll","title":"Redux als Protokoll für UI-Änderungen","summary":"Der Store ist der langweiligste Teil von Redux – wertvoll ist das Nachrichtenprotokoll aus benannten, serialisierbaren Actions, das UI-Änderungen nachvollziehbar, testbar und diskutierbar macht.","tags":["Software","Frontend"],"published_at":"2017-10-12T10:00:00+00:00","updated_at":"2026-07-14T17:09:15.018598+00:00","url":"https://www.mikebild.dev/de/blog/redux-als-protokoll/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/ebfb39d33ce697f0dd04fea471b08b6b5b3d5def1bda1d23e09b16726d2d3d2a/redux-als-protokoll-vorlesen.mp3","audio_duration_secs":725,"text":"# Redux als Protokoll für UI-Änderungen\n\nIn der Netzwerktechnik ist ein Protokoll keine Software, sondern eine Vereinbarung: Es legt fest, welche Nachrichten zwischen Parteien fließen dürfen, wie jede Nachricht aufgebaut ist und was sie bedeutet. TCP schreibt niemandem vor, welches Programm an den Endpoints läuft – es bestimmt nur die Form der Pakete und die Regeln des Austauschs. Genau dieser Begriff, auf eine Benutzeroberfläche übertragen, beschreibt Redux treffender als die übliche Kurzformel.\n\nDie lautet nämlich: Redux sei ein globaler State-Container für React-Anwendungen. Das ist nicht falsch, aber es zeigt auf den langweiligsten Teil. Ein Objekt, das Anwendungszustand hält, hat jede Anwendung – notfalls als `window.appState`, und damit ist noch gar nichts gewonnen. Interessant an Redux ist die Vereinbarung darüber, wie sich dieses Objekt ändern darf: ausschließlich über benannte, serialisierbare Nachrichten. Redux ist weniger ein Behälter als ein Protokoll für Änderungen – zwischen den Teilen der Oberfläche und, was gern übersehen wird, über die Zeit hinweg. Wer heute eine Action dispatcht, redet auch mit dem Kollegen, der in drei Monaten das Action-Log eines Fehlerberichts liest.\n\n## Drei Bausteine, sehr ungleich verteilt\n\nRedux besteht aus drei Teilen: dem Store, der den State hält, den Actions, die Änderungen beschreiben, und den Reducern, die aus altem State und Action den neuen State berechnen. Der Store selbst ist erstaunlich unspektakulär – ein paar Dutzend Zeilen um ein Objekt, eine `dispatch`-Funktion und eine Subscriber-Liste. Wer den Quelltext von `createStore` liest, ist nach einer Tasse Kaffee durch.\n\nDer Wert steckt in dem, was die beiden anderen Bausteine erzwingen. Eine Action ist ein einfaches Objekt mit einem `type` und Nutzdaten:\n\n```js\n// actions.js\nexport const ORDER_SUBMITTED = 'ORDER_SUBMITTED';\nexport const ORDER_ACCEPTED = 'ORDER_ACCEPTED';\nexport const ORDER_REJECTED = 'ORDER_REJECTED';\n\nexport const submitOrder = order => ({\n  type: ORDER_SUBMITTED,\n  payload: { orderId: order.id, items: order.items }\n});\n```\n\nKein Funktionszeiger, keine Klasseninstanz, kein Verweis auf eine Komponente. Nur Daten, die sich als JSON aufschreiben lassen. Das wirkt wie eine Einschränkung – und ist genau als solche gemeint. Eine Nachricht, die sich serialisieren lässt, lässt sich auch aufzeichnen, versenden, vergleichen und Jahre später noch lesen. Ein Methodenaufruf kann das alles nicht.\n\nDer Reducer ist die zweite Hälfte der Vereinbarung: eine Pure Function, die niemals selbst etwas auslöst, sondern nur beantwortet, was eine Nachricht für den State bedeutet.\n\n```js\n// ordersReducer.js\nimport { ORDER_SUBMITTED, ORDER_ACCEPTED } from './actions';\n\nconst initialState = { pending: [], accepted: [] };\n\nexport default function ordersReducer(state = initialState, action) {\n  switch (action.type) {\n    case ORDER_SUBMITTED:\n      return { ...state, pending: [...state.pending, action.payload.orderId] };\n    case ORDER_ACCEPTED:\n      return {\n        ...state,\n        pending: state.pending.filter(id => id !== action.payload.orderId),\n        accepted: [...state.accepted, action.payload.orderId]\n      };\n    default:\n      return state;\n  }\n}\n```\n\nAus dieser Trennung – Nachricht hier, Bedeutung dort – folgt fast alles, was Teams an Redux schätzen. Meist wird es unter „Vorhersagbarkeit\" verbucht. Mir hilft eine andere Sortierung:\n\n- Jede Änderung lässt sich aufzeichnen und wieder abspielen, weil sie als Datenobjekt vorliegt.\n- Jede Änderung lässt sich ohne Browser testen, weil der Reducer eine Pure Function ist.\n- Jede Änderung hat einen Namen, über den man im Code-Review und mit der Fachseite reden kann.\n- Jede Änderung lässt sich von außen beobachten, ohne die UI anzufassen.\n\nDas sind keine vier Features. Das ist viermal dieselbe Eigenschaft: Es gibt ein Protokoll.\n\n## Zeitreise ist keine Zauberei\n\nDie Redux DevTools führen das am eindrucksvollsten vor. Jede Action erscheint dort als Zeile in einem Log, und weil der State nur aus der Faltung aller bisherigen Actions über die Reducer entsteht, kann man im Log beliebig zurückspringen – die Oberfläche folgt. Beim ersten Vorführen wirkt das wie ein Trick. Tatsächlich ist es die banale Konsequenz aus dem Protokollgedanken: Wer alle Nachrichten kennt und eine deterministische Funktion darauf anwendet, kann jeden Zwischenstand rekonstruieren. Netzwerkleute kennen das seit Ewigkeiten – ein Paketmitschnitt plus State Machine ergibt die komplette Sitzung.\n\nIn einem Projekt in diesem Sommer hat uns genau das einen zähen Fehler erspart. Ein Anwender meldete einen State, den niemand nachstellen konnte: eine Bestellung, die gleichzeitig als offen und als bestätigt angezeigt wurde. Statt Vermutungen zu sammeln, haben wir uns den exportierten Action-Verlauf schicken lassen und ihn lokal wieder eingespielt. Die fehlerhafte Reihenfolge – `ORDER_ACCEPTED` traf vor `ORDER_SUBMITTED` ein, weil zwei Requests sich überholt hatten – stand nach fünf Minuten schwarz auf weiß im Log. Mit verteilten `setState`-Aufrufen wäre dieselbe Diagnose ein Nachmittag mit Debugger und Glück gewesen.\n\nAuch das Testen profitiert von der Nachrichtenform, und zwar ohne jede Test-Infrastruktur:\n\n```js\n// ordersReducer.test.js\nimport ordersReducer from './ordersReducer';\nimport { submitOrder } from './actions';\n\nit('moves a submitted order into pending', () => {\n  const state = ordersReducer(undefined, submitOrder({ id: '42', items: [] }));\n  expect(state.pending).toEqual(['42']);\n});\n```\n\nKein DOM, kein Mock eines Stores, kein Rendern. Eingabe, Ausgabe, fertig. Reducer-Tests gehören zu den wenigen Frontend-Tests, die ich noch nie flaky gesehen habe.\n\n## Events statt Befehle\n\nGenau an dieser Stelle geht der Wert allerdings auch am schnellsten verloren – nicht durch die Bibliothek, sondern durch den Zuschnitt der Nachrichten. In einem Code-Review im Frühjahr stand ein Reducer vor mir, dessen Action-Liste so aussah: `SET_LOADING_TRUE`, `SET_LOADING_FALSE`, `SET_ORDERS`, `SET_ERROR`, `RESET_ERROR`. Formal alles korrekt: benannte, serialisierbare Objekte, reine Reducer, DevTools funktionieren. Trotzdem war das Protokoll wertlos, denn die Nachrichten beschrieben nicht, was passiert ist, sondern welchen Feldwert der Absender gern hätte. Das ist kein Ereignisprotokoll, das sind entfernte Zuweisungen – `setState` mit Umweg und mehr Tipparbeit.\n\nDer Unterschied wird im direkten Vergleich deutlich:\n\n```js\n// commands: the sender prescribes how state must change\ndispatch({ type: 'SET_LOADING_TRUE' });\ndispatch({ type: 'SET_ORDERS', payload: orders });\ndispatch({ type: 'SET_LOADING_FALSE' });\n\n// events: the sender reports what happened in the domain\ndispatch({ type: 'ORDERS_REQUESTED' });\ndispatch({ type: 'ORDERS_LOADED', payload: { orders } });\ndispatch({ type: 'ORDERS_LOADING_FAILED', payload: { error } });\n```\n\nDie Ereignisvariante hat drei Eigenschaften, die der Befehlsvariante fehlen. Erstens trägt sie Fachsprache in den Code: `ORDER_SUBMITTED` ist ein Satz, den auch die Produktverantwortliche versteht, `SET_LOADING_TRUE` versteht nur der Autor der Komponente. Im Review reden wir dann über die Frage „Darf eine abgelehnte Bestellung erneut eingereicht werden?\" statt über Boolean-Choreografie. Zweitens ist ein Event vergangen und damit unstrittig – es wird berichtet, nicht angeordnet. Der Absender muss nicht wissen, wer zuhört. Drittens skaliert genau deshalb der Empfängerkreis: Wenn später ein zweiter Reducer die Zahl offener Bestellungen für einen Badge zählen soll, abonniert er `ORDER_SUBMITTED` einfach mit. Bei `SET_ORDERS` müsste der Absender umgebaut werden. Ein Befehl kennt seinen Empfänger, ein Event kennt nur seinen Anlass – im Protokollbild ist das der Unterschied zwischen einem RPC-Aufruf und einem Broadcast.\n\nSeitdem lese ich Action-Dateien wie ein Nachrichtenverzeichnis. Steht dort ein Vokabular der Fachdomäne in Vergangenheitsform, trägt das Protokoll. Steht dort eine Liste von Settern, hat das Team einen zweiten `setState`-Mechanismus gebaut, nur umständlicher.\n\n```mermaid\nsequenceDiagram\n  participant V as View\n  participant M as Middleware\n  participant R as Reducer\n  participant S as Store\n  V->>M: dispatch(ORDER_SUBMITTED)\n  Note over M: Abhörpunkt:<br/>Logging, Analytics, DevTools\n  M->>R: action (unverändert)\n  R->>S: neuer Zustand\n  S-->>V: notify + render\n```\n\n## Middleware: das Protokoll abhören\n\nWer Actions als Nachrichten ernst nimmt, bekommt eine zweite Fähigkeit geschenkt, die bei der Behälter-Sichtweise unsichtbar bleibt: Man kann den Nachrichtenstrom anzapfen. Genau das ist Middleware – eine Funktion, die zwischen `dispatch` und Reducer sitzt und jede Nachricht sieht, bevor sie Bedeutung bekommt.\n\n```js\n// analyticsMiddleware.js\nconst analyticsMiddleware = store => next => action => {\n  if (action.type.startsWith('ORDER_')) {\n    trackEvent(action.type, action.payload);\n  }\n  return next(action);\n};\n```\n\nDie dreifach gestaffelte Signatur ist gewöhnungsbedürftig, aber schlicht angewandtes Currying – wie solche Funktionsketten entstehen, habe ich vor ein paar Wochen in [JavaScript-Patterns: Module, Higher-Order-Functions, Currying](https://www.mikebild.dev/de/blog/javascript-patterns-module-hof-currying/) auseinandergenommen. Wichtiger als die Form ist die Konsequenz: Analytics braucht keinen einzigen Aufruf in einer Komponente. In dem Bestell-Projekt hängt die Produktauswertung komplett an dieser einen Datei. Als eine neue Kennzahl gewünscht wurde – wie oft eine Ablehnung auf eine Einreichung folgt – war das eine Änderung in der Middleware, nicht in dreißig Komponenten. Dasselbe gilt für Logging im Fehlerfall: Die letzten fünfzig Actions in einen Ringpuffer zu schreiben und an den Error-Report zu hängen, kostet zwanzig Zeilen.\n\nAuch die Async-Frage ist am Ende eine Protokollfrage. Ob `redux-thunk` für die einfachen Fälle oder `redux-saga` und `redux-observable`, wenn Abläufe orchestriert werden müssen – alle drei sind Middleware, und alle drei münden am Ende wieder in gewöhnliche Actions. Der asynchrone Teil ändert die Sprache nicht, er spricht sie nur zeitversetzt. Wie sich Push- und Pull-Datenflüsse dabei grundsätzlich unterscheiden, steht in [RxJS: Push und Pull](https://www.mikebild.dev/de/blog/rxjs-push-und-pull/) – Redux ist konsequent auf der Push-Seite unterwegs.\n\n## Die Grenze: Nicht jede Änderung verdient eine Nachricht\n\nSo nützlich das Protokoll ist – es hat einen Preis pro Nachricht: eine Konstante, ein Action-Creator, ein Reducer-Zweig, im Zweifel ein Test. Diesen Preis sollte nur zahlen, was ihn wert ist. Ob ein Dropdown gerade offen ist, welcher Tab aktiv ist, was in einem Eingabefeld steht, bevor es abgeschickt wird – das ist lokaler UI State, und der gehört in die Komponente, in ein schlichtes `setState`. Niemand will im Action-Log dreißig `INPUT_CHANGED` zwischen zwei fachlichen Events lesen; das Protokoll verrauscht, und die Zeitreise wird zur Diashow.\n\nDan Abramov selbst hat das im vergangenen Jahr in „You Might Not Need Redux\" deutlich aufgeschrieben, und der Text ist keine Distanzierung, sondern eine Preisliste: Die Vorteile von Redux entstehen genau aus seinen Einschränkungen, also lohnt es sich nur dort, wo man die Vorteile auch einlöst. Meine Prüffragen, bevor State in den Store wandert:\n\n- Lesen mehrere, im Baum weit voneinander entfernte Komponenten diesen State?\n- Hat die Änderung einen fachlichen Namen, der in einem Fehlerbericht etwas erklären würde?\n- Interessiert die Historie dieser Änderungen irgendwen – Support, Analyse, Debugging?\n\nDreimal Nein heißt: Komponente. React kann lokalen State seit jeher selbst, und daran hat auch das gerade erschienene React 16 nichts geändert – der neue Fiber-Kern rendert anders, aber `setState` bleibt `setState`. Die beiden Mechanismen konkurrieren nicht, sie bedienen verschiedene Fragen.\n\n```mermaid\nflowchart TD\n  A[Zustand entsteht] --> B{Lesen entfernte<br/>Komponenten mit?}\n  B -- nein --> L[Lokal:<br/>setState in der Komponente]\n  B -- ja --> C{Fachlicher Name?<br/>Historie relevant?}\n  C -- nein --> L\n  C -- ja --> S[Store:<br/>Action + Reducer]\n```\n\nIm Repository [introduction-react](https://github.com/MikeBild/introduction-react), in dem ich seit dem Frühjahr React-Themen für Vorträge und Beispiele sammle, ist der Redux-Teil aus genau diesem Grund bewusst klein: eine überschaubare Beispiel-App, in der das Nachrichtenvokabular auf eine Handvoll fachlicher Events begrenzt bleibt. Nicht, weil mehr nicht ginge – sondern weil der Unterschied zwischen Protokoll und Behälter an einem kleinen, sauberen Vokabular besser sichtbar wird als an einem großen, verrauschten.\n\n## Was ich daraus mitnehme\n\nMir ist mit der Zeit aufgefallen, dass die Teams, die mit Redux glücklich sind, und die, die darüber fluchen, gar nicht verschiedene Bibliotheken benutzen – sie benutzen verschiedene Protokolle. Die einen haben ein Vokabular fachlicher Events, das man laut vorlesen kann; ihr Action-Log liest sich wie ein Ablaufbericht der Anwendung. Die anderen haben Setter mit Großbuchstaben und wundern sich, dass der versprochene Nutzen ausbleibt: Die DevTools zeigen Rauschen, die Tests prüfen Zuweisungen, und im Review gibt es nichts zu diskutieren, weil `SET_VALUE` keine fachliche Aussage enthält, der man widersprechen könnte.\n\nDeshalb halte ich den Store inzwischen für ein Implementierungsdetail und das Protokoll für die eigentliche Architekturentscheidung. Die drei Fragen, die den Unterschied machen, haben mit der Bibliothek wenig zu tun: Welche Events gibt es in dieser Anwendung? Wie heißen sie in der Sprache der Fachdomäne? Und wer darf sie auslösen? Wer diese Fragen beantwortet hat, bekommt von Redux Nachvollziehbarkeit, Testbarkeit und eine gemeinsame Sprache fast geschenkt. Wer sie überspringt, bekommt einen globalen State-Container mit Zeremonie – und hätte dann tatsächlich kein Redux gebraucht.\n\nSollte Redux irgendwann von etwas anderem abgelöst werden, bleibt die Übung trotzdem wertvoll. Ein Protokoll benannter, serialisierbarer Änderungsnachrichten ist keine Erfindung dieser Bibliothek – die Netzwerktechnik lebt seit Jahrzehnten davon, und Event-getriebene Systeme auf dem Server ebenso. Redux hat diese Disziplin nur an eine Stelle getragen, an der sie vorher selten war: mitten in die UI.\n\n## Weiterführende Quellen\n\n- [MikeBild/introduction-react](https://github.com/MikeBild/introduction-react) – Repository mit React-Beispielen, inklusive Flux/Redux-Teil und Beispiel-App\n- [Redux-Dokumentation: Motivation](https://redux.js.org/understanding/thinking-in-redux/motivation) – warum Mutation plus Asynchronität das eigentliche Problem ist\n- [Dan Abramov: You Might Not Need Redux](https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367) – die Preisliste zu den Einschränkungen, vom Autor selbst\n- [React v16.0 Release-Ankündigung](https://legacy.reactjs.org/blog/2017/09/26/react-v16.0.html) – der frische Fiber-Kern, der am Datenflussmodell nichts ändert"},{"slug":"javascript-patterns-module-hof-currying","title":"JavaScript-Patterns, die geblieben sind: Module, Higher-Order Functions, Currying","summary":"Drei JavaScript-Pattern haben jeden Framework-Wechsel überlebt: das Module-Pattern für saubere Kapselung, Higher-Order Functions für Funktionen als Werte und Currying für spezialisierte Varianten. Warum sie das Fundament moderner Utility-Libraries bilden.","tags":["Software","Frontend"],"published_at":"2017-09-19T10:00:00+00:00","updated_at":"2026-07-13T13:21:18.680262+00:00","url":"https://www.mikebild.dev/de/blog/javascript-patterns-module-hof-currying/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/0e4eb7d89382a1d51aea496c4b34ade00bd698529190020be01153604288b344/javascript-patterns-module-hof-currying-vorlesen.mp3","audio_duration_secs":625,"text":"Ein Pattern ist keine Bibliothek und kein Framework-Feature. Es ist die Form, in der Code angeordnet wird – und die liegt eine Ebene unter dem, was gerade als Framework darüberliegt. Zwischen jQuery, Backbone, Angular 1, Angular 2 und React lagen bei uns wenige Jahre. Ausgetauscht wurden die Werkzeuge; die Anordnung darunter blieb erstaunlich konstant.\n\nDrei dieser Anordnungen greife ich hier heraus: das Module-Pattern, Higher-Order Functions und Currying beziehungsweise Partial Application. Sie bauen aufeinander auf, und wer sie einmal auseinandergenommen hat, liest modernen JavaScript-Code mit anderen Augen. Ich setze dabei ES2015 als Baseline voraus.\n\nDie drei Muster in einem Satz:\n\n- Module-Pattern kapselt State und gibt nur eine bewusst gewählte öffentliche API frei.\n- Higher-Order Functions behandeln Funktionen als Werte – sie nehmen Funktionen entgegen oder geben welche zurück.\n- Currying und Partial Application erzeugen aus einer Generic Function spezialisierte, vorkonfigurierte Varianten.\n\n## Module-Pattern: zwei Wege, ein Ziel\n\nDas älteste Problem in Browser-JavaScript ist der globale Namespace. Früher landete jede Variable, die man nicht bewusst kapselte, am `window`-Objekt, und zwei Skripte konnten sich gegenseitig überschreiben, ohne es zu merken. Die Lösung vor ES6 war eine Konvention, kein Sprachmittel: die Immediately Invoked Function Expression, kurz IIFE. Man packt Code in eine Funktion, ruft sie sofort auf und nutzt den dabei entstehenden Scope als privaten Raum. Was drinnen deklariert wird, bleibt drinnen – außer man gibt es bewusst als Objekt zurück. Diese Rückgabe des öffentlichen APIs nennt man auch Revealing Module Pattern.\n\n```js\nconst counterModule = (function () {\n  let count = 0; // private, not accessible from outside\n\n  return {\n    increment() {\n      return ++count;\n    },\n    get() {\n      return count;\n    }\n  };\n})();\n\ncounterModule.increment(); // 1\ncounterModule.increment(); // 2\ncounterModule.get();       // 2\ncounterModule.count;       // undefined – deliberately not exposed\n```\n\nDer Trick liegt in der Closure: Die zurückgegebenen Methoden behalten Zugriff auf `count`, obwohl die äußere Funktion längst durchgelaufen ist. Von außen gibt es keinen Weg, `count` direkt zu setzen. Genau das wollten wir – Kapselung ohne Sprachunterstützung, allein durch Scope und Closure.\n\nSeit ES2015 gibt es echte Module in der Sprache. Jede Datei ist ihr eigener Modul-Scope, nichts leakt automatisch nach global, und was nach außen sichtbar sein soll, markiert man mit `export`. Der Import erfolgt statisch mit `import`. Dasselbe Beispiel als ES-Modul:\n\n```js\n// counter.js\nlet count = 0; // module-private, only visible in this file\n\nexport function increment() {\n  return ++count;\n}\n\nexport function get() {\n  return count;\n}\n```\n\n```js\n// app.js\nimport { increment, get } from './counter.js';\n\nincrement(); // 1\nincrement(); // 2\nget();       // 2\n```\n\nBeide verfolgen dasselbe Ziel: privater State und eine klar definierte öffentliche API. Der Unterschied ist die Semantik. Beim IIFE ist die Kapselung eine Laufzeit-Konstruktion, die auf Funktionen und Closures beruht. Beim ES-Modul ist sie Teil des Modulsystems – Werkzeuge können die Imports schon vor der Ausführung analysieren und ungenutzten Code entfernen. Importierte Bindings sind außerdem live und können vom Importeur nicht neu zugewiesen werden. Module laufen implizit im Strict Mode.\n\n```mermaid\ngraph LR\n  A[IIFE + Closure] --> C[Kapselung<br/>privater Zustand<br/>klare öffentliche API]\n  B[ES6 import/export] --> C\n```\n\nEin ehrliches Wort zum Stand 2017: Node.js kennt native ES-Module noch nicht stabil. Dort schreibt man weiterhin CommonJS mit `require` und `module.exports`; das experimentelle `--experimental-modules`-Flag ist gerade erst mit Node 8.5.0 erschienen. In den Browsern ist die Unterstützung frisch – Chrome 61 und Safari 10.1 können `type=\"module\"` bereits, Firefox und Edge noch hinter einem Flag. In der Praxis bündeln wir 2017 also mit webpack oder Rollup und lassen Babel die `import/export`-Syntax übersetzen. Das ändert am Muster nichts: Wir denken in Modulen, auch wenn das Deployment noch über einen Bundler läuft.\n\n## Higher-Order Functions: Funktionen sind Werte\n\nDer zweite Baustein ist die eigentliche Stärke von JavaScript, und die meisten von uns haben sie benutzt, lange bevor sie einen Namen dafür hatten. In JavaScript sind Funktionen First-Class Values: Man kann sie in Variablen speichern, als Argument übergeben und aus anderen Funktionen zurückgeben. Eine Funktion, die eine Funktion als Argument entgegennimmt oder eine Funktion zurückgibt, heißt Higher-Order Function.\n\nDie bekanntesten Beispiele sind die Array-Methoden `map`, `filter` und `reduce`. Sie sind seit ES5 (2009) überall verfügbar und ersetzen imperative Schleifen durch deklarative Transformationen. Statt zu beschreiben, wie iteriert wird, beschreibt man, was mit jedem Element geschieht:\n\n```js\nconst numbers = [1, 2, 3, 4, 5];\n\nconst doubled = numbers.map(n => n * 2);        // [2, 4, 6, 8, 10]\nconst evens = numbers.filter(n => n % 2 === 0); // [2, 4]\nconst sum = numbers.reduce((acc, n) => acc + n, 0); // 15\n```\n\nJede dieser Methoden ist selbst eine Higher-Order Function, weil sie einen Callback entgegennimmt. `map` ruft den Callback für jedes Element auf und sammelt die Rückgaben in einem neuen Array. `filter` behält nur die Elemente, für die der Callback wahr liefert. `reduce` faltet das Array auf einen einzigen Wert zusammen, indem es einen Akkumulator durch alle Elemente trägt.\n\nDie andere Hälfte des Musters – Funktionen, die Funktionen zurückgeben – ist der Schlüssel zu allem, was danach kommt. Eine Funktion, die eine konfigurierte Funktion produziert, nennt man manchmal Funktionsfabrik:\n\n```js\nconst multiplyBy = factor => n => n * factor;\n\nconst double = multiplyBy(2);\nconst triple = multiplyBy(3);\n\ndouble(5); // 10\ntriple(5); // 15\n\n[1, 2, 3].map(double); // [2, 4, 6]\n```\n\n`multiplyBy(2)` gibt keine Zahl zurück, sondern eine neue Funktion, die sich `factor` per Closure gemerkt hat. `double` und `triple` sind spezialisierte Werkzeuge, gebaut aus einer einzigen Generic Function. Und weil sie ganz normale Funktionen sind, lassen sie sich direkt an `map` übergeben. Genau hier verzahnen sich Higher-Order Functions mit dem nächsten Muster.\n\n## Currying und Partial Application: aus generisch wird speziell\n\nCurrying bedeutet, eine Funktion mit mehreren Argumenten in eine Kette einstelliger Funktionen umzuwandeln. Aus `f(a, b, c)` wird `f(a)(b)(c)`. Jeder Aufruf nimmt genau ein Argument und gibt eine Funktion zurück, die auf das nächste wartet – bis alle Argumente da sind und das Ergebnis berechnet wird. Realisiert wird das wieder über Closures.\n\n```js\nconst add = (a, b) => a + b;          // ordinary, two arguments at once\nconst curriedAdd = a => b => a + b;   // curried, unary chain\n\ncurriedAdd(10)(5); // 15\n\nconst add10 = curriedAdd(10); // specialized function, waits for b\nadd10(5);  // 15\nadd10(90); // 100\n```\n\nEng verwandt, aber nicht dasselbe ist Partial Application. Dabei fixiert man einige Argumente einer Funktion und erhält eine neue Funktion mit weniger Parametern. Der Unterschied zum Currying: Partial Application zwingt nicht in einstellige Ketten, sondern legt einfach ein paar Argumente vorab fest. In JavaScript kann man das ganz ohne eigene Helfer mit `Function.prototype.bind` bekommen, das ebenfalls seit ES5 verfügbar ist:\n\n```js\nconst add = (a, b) => a + b;\n\n// bind fixes this (here null) and the leading arguments\nconst add10 = add.bind(null, 10);\n\nadd10(5);  // 15\nadd10(90); // 100\n```\n\nDer Nutzen zeigt sich, sobald man solche spezialisierten Funktionen komponiert. Aus einem kleinen Vorrat generischer Bausteine baut man passgenaue Varianten und steckt sie zusammen. Ein Beispiel, das zeigt, wie natürlich sich so ein Baustein in die Array-Methoden fügt – `add10` aus dem `bind`-Schritt wandert direkt in `map`:\n\n```js\nconst numbers = [1, 2, 3, 4];\n\nconst shifted = numbers.map(add10);                   // [11, 12, 13, 14]\nconst total = shifted.reduce((sum, n) => sum + n, 0); // 50\n```\n\nDer Bogen von der generischen zur spezialisierten Funktion lässt sich gut als Fluss lesen:\n\n```mermaid\ngraph LR\n  A[\"add(a, b)<br/>generisch, zwei Argumente\"] --> B[\"curriedAdd(10)<br/>bzw. add.bind(null, 10)\"]\n  B --> C[\"add10(n)<br/>spezialisiert, ein Argument\"]\n```\n\nUnd genau das ist der Grund, warum diese Muster über Jahre geblieben sind: Sie sind das Fundament moderner Utility-Libraries. Ramda (seit 2013) und `lodash/fp` liefern viele Funktionen curried und mit „data-last“-Signatur aus – die Daten kommen zuletzt, damit man die Konfiguration vorab fixieren und die entstehenden Bausteine bequem komponieren kann. Wer das Prinzip hinter `multiplyBy` und `curriedAdd` verstanden hat, versteht auch, warum diese Bibliotheken so aufgebaut sind.\n\n## Drei Fallstricke, die ich immer wieder sehe\n\nSo elegant die Muster sind, so zuverlässig stolpern Teams über dieselben Stellen. Drei davon will ich benennen, weil sie in echten Codebases teuer werden.\n\nDer erste betrifft die `this`-Bindung bei Higher-Order Functions. Übergibt man eine Objekt-Methode direkt als Callback, geht der Receiver verloren. Die Methode wird ohne ihr Objekt aufgerufen, und `this` ist dann undefined oder das globale Objekt:\n\n```js\nconst counter = {\n  count: 0,\n  increment() {\n    this.count += 1; // this depends on HOW it is called\n  }\n};\n\n[1, 2, 3].forEach(counter.increment);\n// TypeError, i.e. counter.count stays 0 – this is not counter\n```\n\nDie Lösung 2017 ist eindeutig. Man bindet `this` mit `bind` fest, umschließt den Aufruf mit einer Arrow Function, die ihr `this` lexikalisch aus der Umgebung bezieht, oder nutzt den `thisArg`-Parameter, den `map`, `filter` und `forEach` als zweites Argument anbieten:\n\n```js\n[1, 2, 3].forEach(counter.increment.bind(counter));\n[1, 2, 3].forEach(() => counter.increment());\n[1, 2, 3].forEach(counter.increment, counter); // thisArg\n```\n\nArrow Functions sind hier das schärfste Werkzeug, weil sie gar kein eigenes `this` mitbringen – sie erben es aus dem umgebenden Scope. Damit verschwindet die Fehlerklasse in vielen Fällen ganz.\n\nDer zweite Fallstrick ist die Überverwendung von Currying. Tief verschachtelte `f(a)(b)(c)(d)`-Ketten sehen im Blogpost schick aus, aber im Team-Code kosten sie Lesbarkeit. Sie sind schwerer zu debuggen, die Stacktraces werden unübersichtlich, und wer den Code sechs Monate später liest, muss die Kette gedanklich wieder auffalten. Currying zahlt sich dort aus, wo Spezialisierung oder Komposition einen konkreten Vorteil bringt. Wo es nur funktional aussieht, kostet es mehr, als es bringt.\n\nDer dritte betrifft `reduce` ohne Startwert. Lässt man das `initialValue`-Argument weg, nimmt `reduce` das erste Array-Element als Startwert – und wirft bei einem leeren Array einen TypeError. Außerdem ist die Semantik des Akkumulators dann an den Elementtyp gekoppelt, was schnell zu subtilen Fehlern führt:\n\n```js\n[].reduce((sum, n) => sum + n);    // TypeError: Reduce of empty array\n[].reduce((sum, n) => sum + n, 0); // 0 – correct, because initial value set\n```\n\nFür Summen, Listenaufbau und andere Faltungen mit einem neutralen Element setze ich den Startwert deshalb explizit. Wo es fachlich kein neutrales Element gibt, behandle ich den leeren Fall lieber bewusst, statt einen künstlichen Startwert zu erfinden.\n\n## Fazit\n\nWenn ich die drei Muster nebeneinanderlege, fällt eine gemeinsame Linie auf. Das Module-Pattern kapselt State, egal ob per IIFE und Closure oder per ES-Modul – das Ziel bleibt dasselbe, nur die Ebene wandert von der Konvention in die Sprache. Higher-Order Functions machen Funktionen zu Werten und ersetzen Schleifen durch Transformationen. Currying und Partial Application bauen aus Generic Functions spezialisierte Varianten, die sich komponieren lassen. Die Syntax hat sich zwischen ES5 und ES6 verschoben, das Prinzip dahinter nicht.\n\nGenau das ist es, was ich über die Jahre gelernt habe: Muster schlagen Syntax. Die Sprach-Features werden sich weiter ändern – zwischen der ersten IIFE und dem heutigen `import` liegen Jahre –, aber Kapselung, Funktionen als Werte und Spezialisierung durch Vorkonfiguration sind stabil geblieben. Wer diesen Kern beherrscht, muss beim nächsten Framework-Wechsel nicht von vorn anfangen. Er erkennt die alten Bekannten in neuem Gewand wieder, und das ist auf lange Sicht mehr wert als jede aktuelle Bibliotheksversion.\n\n## Weiterführende Quellen\n\n- [ES-Module Guide (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules)\n- [export (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export) und [import (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import)\n- [IIFE (MDN Glossary)](https://developer.mozilla.org/en-US/docs/Glossary/IIFE) und [Closures (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures)\n- [Array.prototype.map (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map), [filter (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter), [reduce (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce)\n- [Function.prototype.bind (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind) und [Arrow functions (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions)\n- [ECMAScript 2015 (ES6) Specification](https://www.ecma-international.org/ecma-262/6.0/)\n- [Eloquent JavaScript – Higher-Order Functions](https://eloquentjavascript.net/05_higher_order.html)"},{"slug":"websockets-nicht-immer-antwort","title":"WebSockets sind nicht immer die Antwort","summary":"Warum Server-Sent Events für die meisten Echtzeit-Anforderungen die einfachere Wahl sind und wann sich der Preis einer echten WebSocket-Verbindung tatsächlich lohnt.","tags":["Software","Node.js","Backend"],"published_at":"2017-09-12T10:00:00+00:00","updated_at":"2026-07-14T17:07:20.712151+00:00","url":"https://www.mikebild.dev/de/blog/websockets-nicht-immer-antwort/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/4956c7709bd3815b187c28deb84429743f9e504e9346e6cfc0efae16ccaa9875/websockets-nicht-immer-antwort-vorlesen.mp3","audio_duration_secs":638,"text":"# WebSockets sind nicht immer die Antwort\n\nEin Dashboard, das neue Bestellungen anzeigt. Eine Benachrichtigung, wenn der Build durchgelaufen ist. Ein Fortschrittsbalken für einen Import, der zwanzig Minuten braucht. Drei typische „Echtzeit“-Anforderungen – und in allen drei Fällen fließen die Daten ausschließlich in eine Richtung: vom Server zum Client. Trotzdem lautet die erste technische Antwort darauf erstaunlich oft: WebSockets, socket.io, eine persistente bidirektionale Verbindung.\n\nDer Reflex „Echtzeit gleich WebSocket“ sitzt tief, und er ist verständlich. WebSockets sind das prominenteste Werkzeug im Regal, jede zweite Tutorial-Serie baut damit einen Chat. Nur: Ein Chat ist bidirektional. Ein Bestell-Dashboard ist es nicht. Wer für reines Server-zu-Client-Push die schwere Maschinerie anwirft, bezahlt für Fähigkeiten, die er nie abruft – und der Preis ist nicht null.\n\n## Was „Echtzeit“ im Anforderungstext wirklich bedeutet\n\nNimmt man die Anforderung ernsthaft auseinander, steht dort selten „Client und Server tauschen fortlaufend Messages in beide Richtungen aus“. Dort steht: Der Nutzer soll etwas sehen, sobald es passiert, ohne F5 zu drücken. Das ist Benachrichtigung, nicht Konversation. Der Client redet weiterhin über gewöhnliche Requests mit dem Server – er klickt, er speichert, er lädt nach. Neu ist nur, dass der Server von sich aus etwas sagen darf.\n\nGanz nebenbei: Für manche dieser Fälle reicht sogar simples Polling. Ein Fortschrittsbalken, der alle zwei Sekunden einen GET absetzt, ist keine elegante Lösung, aber eine, die jeder im Team versteht, die jeder Cache und jeder Proxy kennt und die sich in einer Minute wegwerfen und ersetzen lässt. Die Frage ist also nicht nur „WebSocket oder nicht“, sondern wie viel Maschinerie die Anforderung überhaupt rechtfertigt.\n\nMir ist das in einem Shop-Projekt in diesem Frühjahr deutlich geworden. Das Team hatte für ein Auftrags-Dashboard socket.io eingebaut, und die Verbindung selbst war nie das Problem. Das Problem war alles drumherum: Nach jedem Deployment hingen Clients an toten Verbindungen, bis der selbstgebaute Reconnect griff. Der Load-Balancer brauchte Sonderbehandlung für das Protokoll-Upgrade. Und wenn ein Browser dreißig Sekunden offline war, fehlten genau die Bestellungen aus diesen dreißig Sekunden – das Wiederaufsetzen beim letzten bekannten Stand hatte schlicht niemand gebaut, weil das Protokoll es nicht mitbringt.\n\n## Der Preis einer WebSocket-Verbindung\n\nEin WebSocket beginnt als HTTP-Request, verlässt HTTP aber nach dem Upgrade-Handshake und wird zu einem eigenen Protokoll (RFC 6455) auf einer rohen TCP-Verbindung. Ab diesem Moment gilt nichts mehr von dem, was die HTTP-Welt an Infrastruktur und Semantik mitbringt. Konkret kauft man sich ein:\n\n- Verbindungs-State-Management auf dem Server: Wer ist verbunden, seit wann, mit welcher Session?\n- Eigene Heartbeats, denn tote TCP-Verbindungen fallen nicht von allein auf – Ping/Pong ist Handarbeit oder Bibliotheks-Feature, aber immer Teil der eigenen Verantwortung.\n- Reconnect-Logik im Client, inklusive Backoff und der Frage, was während der Funkstille verpasst wurde.\n- Sonderfälle in der Infrastruktur: Proxies, Load-Balancer und Firmen-Firewalls, die das Upgrade nicht kennen oder lange stille Verbindungen kappen.\n\nNichts davon ist unlösbar. Bibliotheken wie socket.io nehmen einem vieles ab, genau deshalb existieren sie. Aber es bleibt Komplexität, die betrieben, überwacht und im Fehlerfall verstanden werden will. Für einen Kanal, der nur in eine Richtung sendet, ist das ein schlechter Tausch.\n\n```javascript\nconst WebSocket = require('ws')\nconst server = new WebSocket.Server({ port: 8080 })\n\nserver.on('connection', socket => {\n  socket.isAlive = true\n  socket.on('pong', () => { socket.isAlive = true })\n})\n\n// dead connections are not detected for free – ping/pong is on us\nsetInterval(() => {\n  server.clients.forEach(socket => {\n    if (!socket.isAlive) return socket.terminate()\n    socket.isAlive = false\n    socket.ping()\n  })\n}, 30000)\n```\n\nDas ist nur der Heartbeat. Reconnect, Nachrichtenpuffer und das Wiederaufsetzen nach einer Unterbrechung stehen auf einem anderen Blatt – und auf dem steht in den meisten Projekten, die ich gesehen habe, erstaunlich wenig.\n\n## Server-Sent Events: Push über gewöhnliches HTTP\n\nServer-Sent Events sind das unspektakuläre Gegenstück: ein ganz normaler HTTP-GET-Request, dessen Response nie endet. Der Server hält die Verbindung offen und schreibt Events als Text hinein, der Browser konsumiert sie über die `EventSource`-API. Kein Protokollwechsel, kein Upgrade, kein neues Vokabular für die Infrastruktur. Ein SSE-Endpoint in Express ist ein paar Zeilen:\n\n```javascript\nconst express = require('express')\nconst app = express()\n\nlet nextEventId = 0\nconst eventLog = []\nconst clients = []\n\napp.get('/events', (req, res) => {\n  res.set({\n    'Content-Type': 'text/event-stream',\n    'Cache-Control': 'no-cache',\n    Connection: 'keep-alive'\n  })\n  res.flushHeaders()\n\n  // replay everything the client missed while it was offline\n  const lastEventId = Number(req.get('Last-Event-ID') || 0)\n  eventLog\n    .filter(event => event.id > lastEventId)\n    .forEach(event => res.write(toMessage(event)))\n\n  clients.push(res)\n  req.on('close', () => clients.splice(clients.indexOf(res), 1))\n})\n\nfunction broadcast(data) {\n  const event = { id: ++nextEventId, data }\n  eventLog.push(event)\n  clients.forEach(res => res.write(toMessage(event)))\n}\n\nfunction toMessage({ id, data }) {\n  return `id: ${id}\\ndata: ${JSON.stringify(data)}\\n\\n`\n}\n\napp.listen(3000)\n```\n\nDer Client ist noch kürzer – und das ist der eigentliche Punkt:\n\n```javascript\nconst source = new EventSource('/events')\n\nsource.onmessage = event => {\n  const order = JSON.parse(event.data)\n  renderOrder(order)\n}\n\n// no reconnect code here: EventSource reconnects on its own\n// and sends the Last-Event-ID header with every new attempt\n```\n\nDer Kommentar im Client-Code ist keine Behauptung, sondern Spezifikation. Reißt die Verbindung ab, verbindet sich `EventSource` selbstständig neu – die Wartezeit kann der Server sogar per `retry:`-Feld im Stream vorgeben. Und beim Wiederverbinden schickt der Browser automatisch den Header `Last-Event-ID` mit der ID des letzten empfangenen Events mit. Der Server kann damit exakt dort weitermachen, wo der Client stehengeblieben ist. Genau das Feature, das im Shop-Projekt so schmerzhaft fehlte, ist hier Teil des Protokolls.\n\n```mermaid\nsequenceDiagram\n    participant B as Browser (EventSource)\n    participant S as Server (Express)\n    B->>S: GET /events\n    S-->>B: Event 41 (Bestellung A-1041)\n    S-->>B: Event 42 (Bestellung A-1042)\n    Note over B,S: Verbindung bricht ab<br/>(Deployment, Funkloch, Proxy-Timeout)\n    B->>S: GET /events mit Header Last-Event-ID 42\n    S-->>B: Event 43 (alles seit 42 wird nachgeliefert)\n```\n\nDazu kommt ein Vorteil, den man erst im Betrieb zu schätzen lernt: Der gesamte Kanal ist gewöhnliches HTTP. Load-Balancer routen ihn wie jeden anderen Request, Authentifizierung läuft über dieselben Header und Cookies wie der Rest der Anwendung, und Debugging braucht kein Spezialwerkzeug:\n\n```bash\n$ curl -N http://localhost:3000/events\nid: 1\ndata: {\"orderId\":\"A-1041\",\"total\":89.9}\n\nid: 2\ndata: {\"orderId\":\"A-1042\",\"total\":24.5}\n```\n\nWer schon einmal versucht hat, einen WebSocket-Frame-Mitschnitt gemeinsam mit einem Kollegen aus dem Betrieb durchzugehen, weiß, was dieser eine curl-Aufruf wert ist. Der Stream ist lesbarer Text, jedes Event trägt seine ID, und dieselbe Ausgabe sieht auch der Browser.\n\n## Wo SSE ehrlich an Grenzen stößt\n\nDamit das keine Werbeveranstaltung wird: Server-Sent Events haben Grenzen, und die gehören auf den Tisch, bevor die Entscheidung fällt.\n\nDer Kanal ist strikt unidirektional. Will der Client etwas sagen, macht er das über einen normalen POST – für Formulare, Klicks und Kommandos ist das völlig in Ordnung und ohnehin der natürliche Weg, für einen hochfrequenten Rückkanal ist es keiner. Zweitens: Internet Explorer und Edge kennen `EventSource` bis heute nicht, dort braucht es ein Polyfill. Die verfügbaren Polyfills funktionieren ordentlich, aber es ist ein Baustein mehr, den man einplanen muss. Drittens teilt sich SSE unter HTTP/1.1 das Browser-Limit von ungefähr sechs Verbindungen pro Host – wer pro Tab mehrere Streams öffnet, rennt dagegen. Unter HTTP/2 entspannt sich das deutlich, weil alle Streams über eine einzige multiplexte Verbindung laufen. Und schließlich: Puffernde Proxies muss man gelegentlich per Konfiguration davon überzeugen, den Stream sofort durchzureichen statt ihn zu sammeln – das ist aber eine Zeile nginx-Konfiguration, kein Architekturproblem.\n\n## GraphQL-Subscriptions: derselbe Reflex, ein Stockwerk höher\n\nInteressant wird es, wenn der Reflex in Frameworks einzieht. Im Apollo-Ökosystem laufen GraphQL-Subscriptions standardmäßig über `subscriptions-transport-ws`, also über WebSockets. Dabei sind Subscriptions das Lehrbuchbeispiel für unidirektionalen Datenfluss: Der Client meldet einmal an, was ihn interessiert, und ab dann redet nur noch der Server. Queries und Mutations gehen ohnehin über HTTP. Die bidirektionale Fähigkeit des Transports wird für einen einzigen Handshake benutzt – danach liegt sie brach, während ihre Betriebskosten bleiben.\n\nDas hat mich in diesem Frühjahr genug beschäftigt, um es auszuprobieren: `subscriptions-transport-sse` ist meine API-kompatible SSE-Variante dieses Transports, entstanden in meiner GitHub-Organisation CodeCommission. Server-seitig hängt sich der `SubscriptionServer` an eine Express-App und den `SubscriptionManager` aus `graphql-subscriptions`, client-seitig wird der `SubscriptionClient` wie gewohnt an das Network-Interface von Apollo gehängt:\n\n```javascript\nimport { SubscriptionClient, addGraphQLSubscriptions } from 'subscriptions-transport-sse'\n\nconst httpClient = createNetworkInterface({ uri: 'https://example.com/graphql' })\nconst sseClient = new SubscriptionClient('https://example.com/subscriptions')\nconst apolloClient = new ApolloClient({\n  networkInterface: addGraphQLSubscriptions(httpClient, sseClient)\n})\n\napolloClient.subscribeToMore({\n  document: gql`\n    subscription onOrderCreated {\n      orderCreated {\n        id\n        total\n      }\n    }`,\n  updateQuery: (prev, { subscriptionData }) => {\n    // merge the new order into the store and return the new state\n  }\n})\n```\n\nAus Sicht der Anwendung ändert sich nichts – dieselbe Subscription, dasselbe `subscribeToMore`. Nur der Transport darunter ist jetzt gewöhnliches HTTP mit allem, was oben beschrieben ist: automatischer Reconnect, `Last-Event-ID`, curl-Debugging. Als Praxistest habe ich im April eine kleine Presence-List damit gebaut – wer ist gerade online, React und Apollo im Frontend, Node im Backend, Events über SSE. Beides sind bewusst schlanke Projekte, eher Beleg als Produkt. Es ging mir darum zu zeigen, dass „GraphQL-Subscriptions brauchen WebSockets“ eine Konvention ist, keine Notwendigkeit.\n\n## Wann WebSockets die richtige Antwort sind\n\nNach so viel Gegenrede die andere Seite, denn es gibt sie. WebSockets sind die richtige Wahl, wenn tatsächlich beide Seiten fortlaufend und mit niedriger Latenz senden:\n\n- Kollaboratives Editieren, bei dem Tastenanschläge und Operationen beider Seiten zusammenlaufen\n- Cursor-Positionen und Präsenz-Signale, die viele Male pro Sekunde in beide Richtungen fließen\n- Spiele und Simulationen, in denen jede Runde Client-Eingaben gegen Server-Zustand abgeglichen wird\n- Frontends, bei denen der Overhead eines eigenen POST pro Client-Message wirklich ins Gewicht fällt\n\nDer gemeinsame Nenner: Der Rückkanal ist kein gelegentliches Kommando, sondern ein Strom. Dann lohnt sich das Protokoll, und dann ist auch die Betriebskomplexität gerechtfertigt – man nutzt ja, was man bezahlt. Die Entscheidungsfrage ist deshalb nicht „Brauchen wir Echtzeit?“, sondern „Wohin fließen die Daten, und wie oft?“.\n\n```mermaid\nflowchart TD\n    A[Anforderung: Echtzeit] --> B{Fließen Daten fortlaufend<br/>in beide Richtungen?}\n    B -- nein --> C[Server-Sent Events<br/>plus normale HTTP-Requests]\n    B -- ja --> D{Ist der Rückkanal ein Strom<br/>mit Latenzanforderung?}\n    D -- nein --> C\n    D -- ja --> E[WebSockets –<br/>Betriebskosten einplanen]\n```\n\nWer diese zwei Fragen ehrlich beantwortet, landet nach meiner Erfahrung in der großen Mehrheit der Fälle links im Diagramm. Das Auftrags-Dashboard, der Build-Monitor, der Import-Fortschritt, die Benachrichtigungsglocke oben rechts in der Anwendung – alles Fälle für SSE. Der Editor mit den Live-Cursorn der Kollegen ist der seltene, echte Fall für die rechte Seite.\n\n## Unterm Strich\n\nAus dem Shop-Projekt und den beiden SSE-Experimenten nehme ich vor allem eine Beobachtung mit: Die Kosten einer Technologie stecken selten dort, wo ihre Stärken beworben werden – sie stecken im Betrieb, im Debugging und in den Fehlerfällen, die das Protokoll nicht von sich aus abdeckt. WebSockets können etwas, das SSE nicht kann, und wenn man dieses Etwas braucht, sind sie konkurrenzlos. Aber „Echtzeit“ im Anforderungstext ist noch kein Beleg dafür, dass man es braucht. Meistens will da nur ein Server etwas mitteilen, und dafür reicht ein HTTP-Request, der nie endet – mit eingebautem Reconnect, eingebautem Wiederaufsetzen und einem Debugging-Erlebnis, das aus einem einzigen curl-Aufruf besteht.\n\nDie langweiligere Technik gewinnt hier nicht trotz, sondern wegen ihrer Langeweile. Sie hat weniger bewegliche Teile – und jedes bewegliche Teil, das man gar nicht erst einbaut, kann nachts um drei nicht kaputtgehen.\n\n## Weiterführende Quellen\n\n- [subscriptions-transport-sse auf GitHub](https://github.com/CodeCommission/subscriptions-transport-sse) – GraphQL-Subscriptions über Server-Sent Events, API-kompatibel zu subscriptions-transport-ws\n- [graphql-subscriptions-sse-presence-list auf GitHub](https://github.com/MikeBild/graphql-subscriptions-sse-presence-list) – Presence-List-Beispiel mit React, Apollo und SSE\n- [Server-Sent Events in der HTML-Spezifikation (WHATWG)](https://html.spec.whatwg.org/multipage/server-sent-events.html)\n- [EventSource-API auf MDN](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)\n- [RFC 6455 – The WebSocket Protocol](https://tools.ietf.org/html/rfc6455)"},{"slug":"knockoutjs-observables-computed-vor-signals","title":"KnockoutJS: fein-granulare Reaktivität mit Observables und Computed","summary":"Wie KnockoutJS Reaktivität löst: Observables als get/set-Funktionen, Computed mit automatischem Dependency-Tracking und gezielte DOM-Updates statt globalem Baum-Vergleich.","tags":["Software","Frontend"],"published_at":"2017-08-22T10:00:00+00:00","updated_at":"2026-07-13T09:59:11.759377+00:00","url":"https://www.mikebild.dev/de/blog/knockoutjs-observables-computed-vor-signals/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/96fac8afe29d45c9c7651ba5fd19f30f1dd3ca2ff441e0b0301ab501196a3559/knockoutjs-observables-computed-vor-signals-vorlesen.mp3","audio_duration_secs":712,"text":"Drei Funktionen genügen, um in KnockoutJS eine ganze Oberfläche reaktiv zu halten: `ko.observable`, `ko.observableArray` und `ko.computed`. Keine davon nimmt eine Abhängigkeitsliste entgegen. Trotzdem weiß Knockout nach dem ersten Durchlauf, welcher Wert an welcher Stelle im DOM hängt. Es liest die Abhängigkeiten aus dem tatsächlichen Programmablauf ab.\n\n**Redaktionelle Einordnung (Juli 2026):** Dieser Beitrag erklärt KnockoutJS als historischen Vorläufer heutiger fein-granularer Reaktivität. Ich empfehle damit nicht, 2026 ein neues Frontend auf Knockout aufzubauen. Interessant geblieben ist die Mechanik: Abhängigkeiten beim Lesen erfassen und nur die betroffenen Stellen aktualisieren.\n\nKnockout koppelt das DOM über Bindings wie `text`, `value`, `foreach` und `click` deklarativ an ein ViewModel. Hier schaue ich eine Ebene tiefer: Wie merkt Knockout, dass sich etwas geändert hat, und wie findet es heraus, welche Stellen der Oberfläche davon betroffen sind? Der Kern dieser Mechanik heißt Observables und Computed.\n\n## Ein Observable ist eine Funktion – keine Eigenschaft\n\nDer erste gedankliche Sprung, den man bei Knockout machen muss, ist dieser: Ein Observable ist in KnockoutJS keine Eigenschaft, sondern eine Funktion. Genauer gesagt eine Funktion, die gleichzeitig Getter und Setter ist. Rufe ich sie ohne Argument auf, lese ich den Wert. Rufe ich sie mit einem Argument auf, schreibe ich den Wert – und Knockout benachrichtigt alle, die an diesem Wert interessiert sind.\n\n```js\nvar name = ko.observable('Bob');\n\nname();          // read  -> 'Bob'\nname('Mary');    // write -> sets the value and notifies subscribers\nname();          // read  -> 'Mary'\n```\n\nDas ist der ganze Trick. Kein `Object.defineProperty`, kein Proxy, keine Compiler-Magie. Nur eine Funktion, die sich merkt, wer sie gelesen hat und wer benachrichtigt werden will. Das Schreiben ist übrigens chainbar, weil der Setter das ViewModel zurückgibt:\n\n```js\nviewModel.name('Mary').age(50);\n```\n\nFür Listen gibt es die passende Variante. Ein `ko.observableArray` verhält sich beim Lesen wie ein Observable – `arr()` liefert das dahinterliegende Array – bringt aber zusätzlich Mutations-Methoden mit, die das Tracking auslösen:\n\n```js\nvar todos = ko.observableArray([]);\n\ntodos();                 // read -> the current array\ntodos.push({ title: 'Buy milk' });\ntodos.remove(someItem);\ntodos.removeAll();\n```\n\nHier lauert schon der erste Fallstrick, auf den ich später zurückkomme: Diese `push`-, `remove`- und `splice`-Methoden sind Knockout-eigene Wrapper. Sie sind es, die eine Benachrichtigung auslösen. Wer stattdessen `todos().push(x)` schreibt, greift am rohen Array vorbei und Knockout bekommt von der Änderung nichts mit.\n\n## Computed leitet ab – und verfolgt automatisch, was es liest\n\nJetzt kommt der eigentlich interessante Teil. Selten will man einen Wert nur speichern und wieder ausgeben. Meistens braucht man abgeleitete Werte: die Summe eines Warenkorbs, den vollständigen Namen aus Vor- und Nachname, die Anzahl offener Aufgaben. Dafür gibt es `ko.computed`.\n\n```js\nvar firstName = ko.observable('Mike');\nvar lastName  = ko.observable('Bild');\n\nvar fullName = ko.computed(function () {\n  return firstName() + ' ' + lastName();\n});\n\nfullName();            // 'Mike Bild'\nfirstName('Michael');\nfullName();            // 'Michael Bild'\n```\n\nUnd hier passiert etwas, das ich mir beim ersten Kontakt mit Knockout selbst zweimal angeschaut habe, weil es der Dreh- und Angelpunkt des ganzen Modells ist. Ich habe Knockout nirgends gesagt, dass `fullName` von `firstName` und `lastName` abhängt. Ich habe keine Abhängigkeitsliste deklariert. Knockout hat das selbst herausgefunden.\n\nWie? Beim ersten Ausführen der Evaluator-Funktion beobachtet Knockout, welche Observables tatsächlich gelesen werden. `firstName()` und `lastName()` melden sich in diesem Moment gewissermaßen an: „Dieses Computed liest gerade mich.“ Knockout registriert daraufhin eine Subscription. Der Dependency-Graph entsteht damit zur Laufzeit, allein durchs Ausführen – deklariert wird nichts. Das ist das fein-granulare, automatische Dependency-Tracking, das Knockout ausmacht.\n\nDer Evaluator läuft dabei nicht nur einmal. Er wird sofort bei der Erstellung einmal ausgeführt, und danach jedes Mal neu, wenn sich eine seiner gelesenen Abhängigkeiten ändert. Wichtig ist der zweite Teil: Bei jeder Neuauswertung ermittelt Knockout die Abhängigkeiten frisch. Das erlaubt dynamische Abhängigkeiten hinter Bedingungen:\n\n```js\nvar showFullName = ko.observable(true);\n\nvar displayName = ko.computed(function () {\n  if (showFullName()) {\n    return firstName() + ' ' + lastName();\n  }\n  return firstName();\n});\n```\n\nSolange `showFullName` auf `true` steht, hängt `displayName` von drei Observables ab. Steht es auf `false`, wird `lastName()` gar nicht mehr gelesen – und Knockout entfernt diese Abhängigkeit beim nächsten Lauf automatisch wieder. Eine Änderung an `lastName` löst dann keine Neuauswertung mehr aus. Genau das meint „dynamisch“: Der Graph passt sich dem tatsächlichen Kontrollfluss an.\n\nAls Graph gezeichnet sieht dieser Zusammenhang so aus:\n\n```mermaid\ngraph LR\n  A[firstName<br/>observable] --> C[fullName<br/>computed]\n  B[lastName<br/>observable] --> C\n  C --> D[text-binding<br/>DOM]\n```\n\nDie Pfeile bedeuten „wird gelesen von und benachrichtigt“. Ändert sich `firstName`, wandert die Benachrichtigung entlang der Kante zu `fullName`, das sich neu berechnet, und von dort weiter zum Text-Binding im DOM. Nur dieser eine Pfad wird aktiv. Nichts anderes im Baum wird angefasst.\n\nFür den Werkzeugkasten reicht fürs Erste dieses kleine Trio:\n\n- `ko.observable(value)` – ein einzelner reaktiver Wert, gelesen mit `obs()`, geschrieben mit `obs(newValue)`.\n- `ko.observableArray([...])` – eine reaktive Liste mit Mutations-Methoden wie `push`, `remove` und `splice`, die Bindings benachrichtigen.\n- `ko.computed(fn)` – ein abgeleiteter Wert, der automatisch verfolgt, welche Observables er liest, und sich bei deren Änderung neu berechnet.\n\n## Vom ViewModel ins DOM: die Todos-App\n\nDamit das nicht abstrakt bleibt, hier eine kleine Todos-Anwendung. Sie zeigt alle drei Bausteine im Zusammenspiel.\n\n```js\nfunction TodoViewModel() {\n  var self = this;\n\n  self.todos = ko.observableArray([]);\n  self.newTodo = ko.observable('');\n\n  self.remaining = ko.computed(function () {\n    return self.todos().filter(function (t) {\n      return !t.done();\n    }).length;\n  });\n\n  self.addTodo = function () {\n    self.todos.push({\n      title: self.newTodo(),\n      done: ko.observable(false)\n    });\n    self.newTodo('');\n  };\n}\n\nko.applyBindings(new TodoViewModel());\n```\n\nEin paar Dinge sind hier bewusst so gebaut. `self.todos` ist ein `observableArray`, `self.newTodo` ein einfaches Observable für das Eingabefeld. `self.remaining` ist ein Computed, das die noch offenen Aufgaben zählt. Es liest `self.todos()` und pro Eintrag `t.done()` – und damit hängt es sowohl an der Liste als auch an jedem einzelnen `done`-Observable. Fügt man ein Todo hinzu oder hakt eines ab, rechnet `remaining` von selbst neu.\n\nBeachtenswert ist, dass jedes einzelne Todo ein eigenes `done: ko.observable(false)` bekommt. Nur so ist der Erledigt-Status pro Eintrag reaktiv. Ein reines `done: false` wäre ein toter Wert, an dem sich nichts beobachten ließe.\n\nDie View koppelt das ViewModel deklarativ ans DOM. Hier der relevante Ausschnitt:\n\n```html\n<input data-bind=\"value: newTodo\" />\n<button data-bind=\"click: addTodo\">Add</button>\n\n<ul data-bind=\"foreach: todos\">\n  <li data-bind=\"text: title\"></li>\n</ul>\n\n<span data-bind=\"text: remaining\"></span> remaining\n```\n\n`ko.applyBindings` aktiviert diese Verknüpfungen einmalig beim Start. Ab da läuft alles über das Tracking: Tippt der Nutzer ins Feld, aktualisiert das `value`-Binding `newTodo`. Ein Klick auf den Button ruft `addTodo` auf, das per `push` die Liste verändert. Diese Änderung benachrichtigt das `foreach`-Binding (neuer Listeneintrag) und das Computed `remaining` (Neuberechnung), das wiederum das `text`-Binding im `<span>` aktualisiert. Jede dieser Aktualisierungen trifft gezielt genau die Stelle, die betroffen ist.\n\n## Warum gezieltes Tracking ein anderes Modell ist\n\nAn diesem Punkt lohnt sich die Abgrenzung, die dem Ganzen erst Kontur gibt. Es gibt einen zweiten, sehr verbreiteten Ansatz für dasselbe Problem: das Virtual-DOM-Diffing, wie man es etwa von React kennt. Dort baut man bei jeder Änderung einen neuen virtuellen Baum, vergleicht ihn mit dem alten und wendet die Differenz aufs echte DOM an. Das Framework weiß nicht von vornherein, welche Datenänderung welche DOM-Stelle betrifft – es rechnet den Unterschied nachträglich aus.\n\nKnockout geht den umgekehrten Weg. Durch das fein-granulare Tracking weiß es pro Observable exakt, welche Computed und welche Bindings davon abhängen. Es muss nichts vergleichen. Ändert sich ein Wert, folgt Knockout einfach den registrierten Kanten des Dependency-Graphen und aktualisiert nur die dort hängenden DOM-Stellen. Kein Baum-Vergleich, kein Neu-Rendern von Teilbäumen, die sich gar nicht geändert haben.\n\nDie Sequenz einer einzelnen Änderung sieht so aus:\n\n```mermaid\nsequenceDiagram\n  participant U as User\n  participant O as observable\n  participant C as computed\n  participant D as DOM-Binding\n  U->>O: obs(newValue)\n  O->>C: notify subscriber\n  C->>C: re-evaluate\n  C->>D: gezieltes text-Update\n```\n\nBeide Modelle haben ihre Berechtigung, und ich möchte keines zum Sieger erklären. Aber wer den Unterschied einmal an dieser Stelle verstanden hat, versteht auch, warum die beiden Welten sich so unterschiedlich anfühlen. Knockouts Ansatz ist unmittelbar nachvollziehbar, weil man den Graphen im Code fast sehen kann. Der Preis dafür ist die Disziplin: Man muss konsequent mit Observables arbeiten, sonst reißt der Graph an einer Stelle ab.\n\n## Fallstricke aus der Praxis\n\nGenau an dieser Disziplin reißt der Graph gern, und zwar an drei wiederkehrenden Stellen. Alle drei haben mich in eigenen Projekten schon Stunden gekostet, weil sie stillschweigend danebengehen.\n\nDer Klassiker sind vergessene Klammern. Ein Observable ist eine Funktion. Wer sie ohne Klammern verwendet, arbeitet mit der Funktion selbst statt mit ihrem Wert:\n\n```js\n// wrong: todo.done is the function itself, so always truthy\nif (todo.done) {\n  // this branch ALWAYS runs\n}\n\n// correct: todo.done() reads the value\nif (todo.done()) {\n  // ...\n}\n```\n\nDer Fehler ist tückisch, weil er keinen Fehler wirft. Eine Funktion ist truthy, also läuft die Bedingung schlicht immer ins gleiche Ergebnis. Man sucht dann lange an der falschen Stelle. Zum Lesen gehören immer Klammern, ohne Ausnahme.\n\nDer zweite Fallstrick sind die nativen Array-Methoden. Ich hatte es oben angedeutet:\n\n```js\n// wrong: mutates the raw array without notification\ntodos().push(newItem);\n\n// correct: KO wrapper, notifies dependent bindings\ntodos.push(newItem);\n```\n\n`todos()` liefert das darunterliegende JavaScript-Array. Dessen natives `push` verändert zwar den Inhalt, aber Knockout erfährt nichts davon – das `foreach`-Binding bleibt stehen. Der KO-Wrapper `todos.push` hingegen löst das Tracking aus. Der Unterschied liegt einzig in den Klammern vor dem `.push`, was den Fehler wieder unangenehm subtil macht.\n\nDer dritte betrifft den `this`-Kontext in Computed. Wenn der Evaluator auf das ViewModel zugreifen will, muss `this` auch wirklich aufs ViewModel zeigen:\n\n```js\nfunction ViewModel() {\n  this.price = ko.observable(10);\n  this.count = ko.observable(3);\n\n  // wrong: this does not reliably point to the ViewModel here\n  this.total = ko.computed(function () {\n    return this.price() * this.count();   // this.price is undefined\n  });\n}\n```\n\nDer Evaluator wird von Knockout in einem anderen Kontext aufgerufen, sodass `this` nicht auf die Instanz zeigt. Es gibt zwei etablierte Auswege. Entweder man reicht den Kontext als zweites Argument nach:\n\n```js\nthis.total = ko.computed(function () {\n  return this.price() * this.count();\n}, this);   // second argument binds this\n```\n\nOder man verwendet das `self`-Muster, das ich im Todos-Beispiel schon genutzt habe:\n\n```js\nfunction ViewModel() {\n  var self = this;\n  self.price = ko.observable(10);\n  self.count = ko.observable(3);\n  self.total = ko.computed(function () {\n    return self.price() * self.count();\n  });\n}\n```\n\nIch bevorzuge in meinen Projekten das `self`-Muster, weil es durchgängig funktioniert – auch in Event-Handlern und Callbacks, nicht nur in Computed. Ein Muster für alles ist leichter zu merken als eine Sonderregel pro Fall.\n\nAm Rande sei noch `ko.pureComputed` erwähnt, das seit KO 3.2 zur Verfügung steht. Es verhält sich wie ein normales Computed, „schläft“ aber, solange niemand es abonniert, und spart so Neuberechnungen bei reinen Ableitungen ohne Side Effects. Für den Einstieg reicht `ko.computed`; sobald man merkt, dass ein rein ableitendes Computed unnötig oft rechnet, ist `pureComputed` die naheliegende Verbesserung.\n\n## Fazit\n\nWer den Kern von KnockoutJS verstanden hat, hält im Grunde nur drei Bausteine in der Hand: Observables als get/set-Funktionen, observableArrays für reaktive Listen und Computed für abgeleitete Werte. Das eigentlich Elegante liegt zwischen diesen Bausteinen: im automatischen Dependency-Tracking. Knockout beobachtet zur Laufzeit, welche Observables ein Computed liest, baut daraus einen Dependency-Graphen und aktualisiert bei jeder Änderung gezielt nur die betroffenen Stellen – neu ermittelt bei jedem Lauf, damit auch dynamische Abhängigkeiten hinter Bedingungen sauber abgebildet werden.\n\nDieses Modell steht bewusst quer zum Virtual-DOM-Diffing: Statt Bäume zu vergleichen, folgt Knockout den Kanten, die es beim Lesen selbst registriert hat. Das macht das Verhalten unmittelbar nachvollziehbar, verlangt aber Disziplin – Klammern zum Lesen, KO-Wrapper zum Mutieren von Listen, den richtigen `this`-Kontext im Computed. Sind diese drei Reflexe verinnerlicht, wird das Arbeiten mit Knockout überraschend ruhig. Man beschreibt, wovon ein Wert abhängt, und das Framework kümmert sich um den Rest. Genau diese Klarheit ist der Grund, warum ich Knockouts Reaktivitätsmodell nach wie vor für einen der besten Einstiege halte, um zu begreifen, was hinter reaktiven Oberflächen wirklich passiert.\n\n## Weiterführende Quellen\n\n- [Observables – KnockoutJS Documentation](https://knockoutjs.com/documentation/observables.html)\n- [Computed Observables – KnockoutJS Documentation](https://knockoutjs.com/documentation/computedObservables.html)\n- [How Dependency Tracking Works – KnockoutJS Documentation](https://knockoutjs.com/documentation/computed-dependency-tracking.html)\n- [Observable Arrays – KnockoutJS Documentation](https://knockoutjs.com/documentation/observableArrays.html)\n- [Pure Computed Observables – KnockoutJS Documentation](https://knockoutjs.com/documentation/computed-pure.html)"},{"slug":"pwa-weather-offline-feature","title":"PWA Weather: offline ist ein Feature","summary":"Eine kleine Wetter-App als Progressive Web App zeigt, warum Offline-Verhalten eine Produktentscheidung ist und wie ehrlich eine Oberfläche mit veralteten Daten umgehen sollte.","tags":["Software","Architektur"],"published_at":"2017-08-12T10:00:00+00:00","updated_at":"2026-07-14T17:05:39.041102+00:00","url":"https://www.mikebild.dev/de/blog/pwa-weather-offline-feature/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/ee6e481ffd6af2d91ea7fba8a0294b6513d3a5114c8b59dde6ee9f9cad99ff8d/pwa-weather-offline-feature-vorlesen.mp3","audio_duration_secs":677,"text":"# PWA Weather: offline ist ein Feature\n\nU-Bahn zwischen zwei Stationen, kein Empfang. Die Wetter-App öffnet sich, zeigt kurz einen Ladekreisel und dann: nichts. Ein weißer Bildschirm. Dabei hätte die Wetterlage von heute Morgen völlig gereicht – die Frage war, ob der Schirm mit muss, nicht ob es in dieser Minute 21,3 oder 21,4 Grad hat. Die App hatte diese Daten sogar schon einmal geladen. Sie hat sie nur weggeworfen, weil niemand entschieden hat, dass sie ohne Netz etwas wert sind.\n\nDieser weiße Bildschirm ist kein technischer Defekt. Er ist das Ergebnis einer Nicht-Entscheidung. Irgendwo im Backlog stand vermutlich „Offline-Support“ als technisches Nice-to-have, hinter dem nächsten Feature und dem übernächsten Redesign. Genau das halte ich für den Denkfehler: Offline ist kein Add-on, das man später an eine fertige App schraubt. Offline ist ein Feature mit Produktfragen – was soll ohne Netz funktionieren, was ausdrücklich nicht, und wie ehrlich zeigt die Oberfläche den Zustand der Daten an.\n\n## Ein kleines Demo als Prüfstand\n\nUm das für mich selbst durchzuspielen, habe ich im April dieses Jahres ein kleines Repository angelegt: [pwa-weather-demo](https://github.com/MikeBild/pwa-weather-demo). Es ist bewusst kein Produkt, sondern eine Nachbau-Übung entlang von Googles Tutorial „Your First Progressive Web App“ – eine Wetterkarte pro Stadt, eine öffentliche Wetter-API dahinter, ein Service Worker davor. Lokal startet das Ganze mit einem simplen `http-server`, mehr Infrastruktur braucht es nicht. Der Wert liegt nicht im Funktionsumfang, sondern darin, dass man an einem überschaubaren Beispiel jede Entscheidung einzeln anfassen kann.\n\nUnd Entscheidungen gibt es mehr, als der Begriff „Caching“ vermuten lässt. Beim Durcharbeiten ist mir aufgefallen, dass die eigentliche Arbeit nicht im Service-Worker-Code steckt. Der ist nach einem Nachmittag geschrieben. Die Arbeit steckt in der Frage, welche Antwort die App einem Menschen ohne Netzverbindung schuldet.\n\n## Offline ist eine Produktfrage, keine Cache-Option\n\nFür die Wetter-App sieht meine Antwort so aus:\n\n- Die zuletzt geladene Wetterlage der gemerkten Städte wird angezeigt – immer, sofort, auch ohne Netz.\n- Die Oberfläche selbst (Layout, Icons, Schrift, Logik) lädt vollständig aus dem lokalen Cache.\n- Die Suche nach neuen Orten funktioniert offline nicht – und sagt das auch, statt einen Spinner drehen zu lassen.\n- Jede angezeigte Wetterkarte trägt einen Zeitstempel: „Stand: vor 2 Stunden“ statt stillschweigend alter Daten.\n\nDer dritte Punkt ist mir wichtig. Es wäre technisch möglich, auch Suchergebnisse zu cachen und offline irgendetwas anzuzeigen. Aber eine Ortssuche gegen einen zwei Tage alten Cache liefert keine Suche, sondern eine Attrappe. Nicht alles, was cachebar ist, gehört gecacht. Die Grenze verläuft entlang der Frage, ob eine veraltete Antwort noch eine ehrliche Antwort ist: Die Wetterlage von heute Morgen ist eine ehrliche, klar datierte Information. Ein Suchergebnis von vorgestern ist es nicht.\n\nDer vierte Punkt ist die zweite Hälfte derselben Entscheidung. Wer alte Daten anzeigt, ohne sie als alt zu kennzeichnen, lügt per Interface. Ein kleiner Zeitstempel verwandelt denselben Datensatz von einer potenziellen Falschinformation in eine brauchbare Auskunft. Das kostet eine Zeile UI und ein Datum – und es ist der Unterschied zwischen „die App zeigt irgendwas“ und „die App sagt mir, was sie weiß und wie sicher sie sich ist“.\n\n## Der Service Worker: ein Proxy mit Lifecycle\n\nTechnisch hängt das alles am Service Worker – einem Skript, das der Browser getrennt von der Seite ausführt und das als programmierbarer Proxy zwischen App und Netzwerk sitzt. Jede Anfrage der Seite läuft durch seinen `fetch`-Handler, und dort entscheidet Code statt Zufall, ob eine Antwort aus dem Netz, aus dem Cache oder aus einer Kombination von beidem kommt.\n\nWer zum ersten Mal damit arbeitet, stolpert weniger über die API als über den Lifecycle. Ein Service Worker wird registriert, installiert, wartet gegebenenfalls auf das Ende der alten Version und übernimmt erst dann die Kontrolle über die Seite:\n\n```mermaid\nflowchart LR\n  A[register] --> B[install<br/>App-Shell precachen]\n  B --> C[waiting<br/>alte Version läuft noch]\n  C --> D[activate<br/>alte Caches aufräumen]\n  D --> E[fetch<br/>Requests kontrollieren]\n```\n\nDie Registrierung selbst ist unspektakulär und gehört ans Ende des App-Starts:\n\n```js\n// app.js\nif ('serviceWorker' in navigator) {\n  navigator.serviceWorker\n    .register('/service-worker.js')\n    .then(function (registration) {\n      console.log('service worker registered, scope:', registration.scope);\n    })\n    .catch(function (error) {\n      console.error('service worker registration failed', error);\n    });\n}\n```\n\nDas `if` ist keine Formsache, sondern Teil der Architektur: In Browsern ohne Service Worker läuft die App einfach als normale Website weiter. Dazu später mehr, denn dieser Fall ist 2017 alles andere als exotisch.\n\nZwei Eigenheiten des Lifecycle haben mich im Demo tatsächlich Zeit gekostet. Erstens: Ein aktualisierter Service Worker übernimmt standardmäßig erst, wenn alle Tabs der alten Version geschlossen sind – wer beim Entwickeln nur neu lädt und sich wundert, warum der neue Code nicht greift, kämpft gegen `waiting`, nicht gegen einen Bug. Zweitens: Der `activate`-Schritt ist der richtige Ort, um alte Cache-Versionen zu löschen. Wer das auslässt, sammelt bei jedem Deployment einen weiteren Cache an und wundert sich Monate später über den Speicherverbrauch.\n\n## App-Shell: das Gerüst kommt immer aus dem Cache\n\nDie erste Cache-Entscheidung betrifft die App-Shell – also alles, was die Anwendung zum Anzeigen braucht, bevor auch nur ein Byte Wetterdaten da ist: HTML, CSS, JavaScript, Icons. Diese Dateien ändern sich nur mit einem Deployment, deshalb werden sie beim `install` einmal komplett in den Cache gelegt (Precache) und danach immer von dort ausgeliefert:\n\n```js\n// service-worker.js\nvar SHELL_CACHE = 'weather-shell-v1';\nvar DATA_CACHE = 'weather-data-v1';\n\nvar SHELL_FILES = [\n  '/',\n  '/index.html',\n  '/styles/app.css',\n  '/scripts/app.js',\n  '/images/icons/icon-192.png'\n];\n\nself.addEventListener('install', function (event) {\n  event.waitUntil(\n    caches.open(SHELL_CACHE).then(function (cache) {\n      return cache.addAll(SHELL_FILES);\n    })\n  );\n});\n\nself.addEventListener('activate', function (event) {\n  event.waitUntil(\n    caches.keys().then(function (keys) {\n      return Promise.all(\n        keys\n          .filter(function (key) {\n            return key !== SHELL_CACHE && key !== DATA_CACHE;\n          })\n          .map(function (key) {\n            return caches.delete(key);\n          })\n      );\n    })\n  );\n});\n```\n\nDie Versionsnummer im Cache-Namen ist das Deployment-Konzept in seiner einfachsten Form: neue Shell, neuer Name, alte Caches fliegen beim `activate` raus. Werkzeuge wie `sw-precache` generieren diesen Teil aus dem Build heraus, und mit Workbox ist gerade ein frischer Nachfolger erschienen, der die gängigen Strategien als Bibliothek mitbringt. Für das Demo habe ich es bewusst von Hand geschrieben – nicht aus Prinzip, sondern weil man eine Strategie erst dann guten Gewissens einem Generator überlassen sollte, wenn man sie einmal selbst formuliert hat.\n\n## Zwei Datenarten, zwei Strategien\n\nDie interessantere Entscheidung fällt im `fetch`-Handler, denn App-Shell und Wetterdaten brauchen entgegengesetzte Strategien. Die Shell soll so schnell wie möglich kommen und darf ruhig einen Deployment-Zyklus alt sein: Cache zuerst. Die Wetterdaten sollen so frisch wie möglich sein, und der Cache ist nur das Sicherheitsnetz für den U-Bahn-Moment: Netzwerk zuerst, mit der letzten bekannten Antwort als Rückfallebene.\n\n```mermaid\nflowchart TD\n  R[fetch-Event] --> Q{Pfad beginnt mit<br/>/api/weather?}\n  Q -- ja --> N[Netzwerk zuerst]\n  N -- Antwort ok --> C1[Kopie in weather-data-v1<br/>Antwort an die App]\n  N -- kein Netz --> C2[letzte bekannte Antwort<br/>aus dem Cache]\n  Q -- nein --> S[Cache zuerst:<br/>App-Shell]\n  S -- Treffer --> C3[sofort ausliefern]\n  S -- kein Treffer --> C4[ans Netzwerk durchreichen]\n```\n\nIm Code sieht diese Weiche so aus:\n\n```js\nself.addEventListener('fetch', function (event) {\n  var url = new URL(event.request.url);\n\n  if (url.pathname.indexOf('/api/weather') === 0) {\n    // network first, cached data as offline fallback\n    event.respondWith(\n      fetch(event.request)\n        .then(function (response) {\n          var copy = response.clone();\n          caches.open(DATA_CACHE).then(function (cache) {\n            cache.put(event.request, copy);\n          });\n          return response;\n        })\n        .catch(function () {\n          return caches.match(event.request);\n        })\n    );\n    return;\n  }\n\n  // app shell: cache first, network as fallback\n  event.respondWith(\n    caches.match(event.request).then(function (cached) {\n      return cached || fetch(event.request);\n    })\n  );\n});\n```\n\nEine Alternative für die Datenroute wäre Stale-while-revalidate: sofort die gecachte Antwort ausliefern und parallel im Hintergrund die frische holen, die dann beim nächsten Rendern bereitsteht. Für eine Wetter-App ist das eine legitime Wahl – die Anzeige erscheint augenblicklich, und die Aktualisierung trudelt nach. Ich habe mich im Demo trotzdem für Network-first entschieden, weil bei Wetterdaten die frische Antwort den Vorrang verdient, solange das Netz da ist. Der Punkt ist nicht, welche der beiden Strategien „richtig“ ist. Der Punkt ist, dass es eine Wahl mit sichtbaren Konsequenzen ist – und dass sie pro Route getroffen wird, nicht pauschal für die ganze App. Jake Archibalds Offline Cookbook buchstabiert diese Muster einzeln durch und ist dafür seit Jahren meine Referenz.\n\n## Der Datenstand gehört in die Oberfläche\n\nDamit aus „alte Daten anzeigen“ kein stilles Unterschieben wird, muss die App wissen, wie alt eine Antwort ist. Praktischerweise steckt diese Information bereits in der gecachten Response – der `Date`-Header der ursprünglichen Server-Antwort wird mitgespeichert:\n\n```js\n// show data age when rendering a cached response\ncaches.match('/api/weather?city=berlin').then(function (cached) {\n  if (!cached) {\n    return;\n  }\n  var fetchedAt = new Date(cached.headers.get('date'));\n  var ageInMinutes = Math.round((Date.now() - fetchedAt.getTime()) / 60000);\n  showLastUpdated(ageInMinutes); // \"Stand: vor 120 Minuten\"\n});\n```\n\nDas sind zehn Zeilen, und sie verändern den Charakter der App. Aus „warum zeigt die mir Sonnenschein, draußen regnet es“ wird „ah, das ist der Stand von heute Morgen“. In einem Projekt-Review würde ich diese zehn Zeilen höher gewichten als die gesamte Cache-Logik darunter – die Cache-Logik entscheidet, ob etwas angezeigt wird; diese Zeilen entscheiden, ob man dem Angezeigten trauen kann.\n\n## Manifest, HTTPS und der Home-Bildschirm\n\nZur PWA gehören neben dem Service Worker noch zwei Bausteine. Das Web App Manifest beschreibt, wie sich die App verhält, wenn sie auf dem Home-Bildschirm landet:\n\n```json\n{\n  \"name\": \"PWA Weather\",\n  \"short_name\": \"Weather\",\n  \"start_url\": \"/index.html\",\n  \"display\": \"standalone\",\n  \"background_color\": \"#3f51b5\",\n  \"theme_color\": \"#3f51b5\",\n  \"icons\": [\n    {\n      \"src\": \"images/icons/icon-192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\"\n    }\n  ]\n}\n```\n\nChrome auf Android bietet bei Manifest plus registriertem Service Worker plus wiederholter Nutzung von sich aus „Zum Startbildschirm hinzufügen“ an – die App startet dann ohne Browser-Chrome, mit eigenem Icon, und fühlt sich erstaunlich nach installierter Anwendung an. Ob eine Seite die Kriterien erfüllt, prüft Lighthouse; der PWA-Audit meckert zuverlässig über fehlende Icons, fehlendes Offline-Verhalten der Startroute oder fehlendes HTTPS.\n\nHTTPS ist dabei keine Empfehlung, sondern Bedingung: Service Worker registrieren Browser ausschließlich über sichere Verbindungen (mit `localhost` als Entwicklungs-Ausnahme). Das ist auch gut so – ein Skript, das sämtliche Requests einer Domain abfangen und beantworten darf, will man nicht über eine manipulierbare Verbindung ausliefern.\n\n## Die ehrliche Grenze: Safari\n\nJetzt der Teil, der in vielen PWA-Artikeln dieses Jahres auffällig leise vorkommt: Safari hat keinen Service Worker. Weder auf dem Mac noch – und das wiegt schwerer – auf iOS. Chrome, Firefox und Opera unterstützen die API, bei Microsoft Edge ist sie in Arbeit, und im WebKit-Statusboard ist der Eintrag kürzlich immerhin auf „In Development“ gewandert. Aber Stand heute, August 2017, ist eine PWA auf einem iPhone schlicht eine normale Website: kein Offline-Cache, kein Abfangen von Requests, kein Install-Banner.\n\nDas ist kein Grund, die Sache zu lassen – aber es gehört zwingend in die Produktentscheidung, nicht in die Fußnote. Für das Wetter-Demo heißt das konkret: Die App muss ohne Service Worker vollständig funktionieren, nur eben ohne Offline-Komfort. Der Service Worker ist eine Verbesserungsschicht (Progressive Enhancement im ursprünglichen Wortsinn), keine Voraussetzung. Wer seine App so baut, dass sie ohne Service Worker bricht, hat auf iOS gar nichts. Wer sie so baut, dass der Service Worker Verhalten hinzufügt statt trägt, hat auf iOS eine ordentliche Website und auf Android eine App, die den U-Bahn-Moment übersteht.\n\nJe nach Zielgruppe verschiebt das die Rechnung erheblich. Bei einem Publikum mit hohem iOS-Anteil kauft man sich mit dem PWA-Aufwand heute vor allem bessere Ladezeiten über den Shell-Cache in Chrome und Firefox – auch nicht nichts, aber ein anderes Versprechen als „funktioniert offline“. Diese Rechnung sollte im Team offen auf dem Tisch liegen, bevor jemand „offline-fähig“ auf eine Folie schreibt.\n\n## Unterm Strich\n\nDie Technik hinter dem Demo ist überschaubar: ein Lifecycle, zwei Cache-Strategien, ein Manifest, HTTPS. Was hängen bleibt, ist etwas anderes. Der weiße Bildschirm in der U-Bahn entsteht nicht, weil Caching schwer wäre – er entsteht, weil „offline“ nie als Feature behandelt wurde, mit denselben Fragen wie jedes andere Feature: Was genau soll es können? Was bewusst nicht? Woran erkennt der Mensch davor, was gerade passiert?\n\nMeine Antworten für die Wetter-App – letzte bekannte Lage ja, Ortssuche nein, Zeitstempel immer – sind nicht universell. Eine Banking-App zieht die Grenze anders, ein Nachrichtenangebot wieder anders. Universell ist nur, dass diese Grenze gezogen werden muss, bevor der erste `fetch`-Handler entsteht. Der Service Worker führt eine Produktentscheidung aus. Er ersetzt sie nicht.\n\n## Weiterführende Quellen\n\n- Repository: [MikeBild/pwa-weather-demo](https://github.com/MikeBild/pwa-weather-demo)\n- Jake Archibald: [The Offline Cookbook](https://jakearchibald.com/2014/offline-cookbook/)\n- MDN: [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)\n- MDN: [Web App Manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest)"},{"slug":"tic-tac-toe-minimax-react","title":"Tic-Tac-Toe mit Minimax in React","summary":"Ein unschlagbarer Tic-Tac-Toe-Gegner in React zeigt, warum Spiellogik als Pure Function nichts vom Rendering wissen darf – und warum immutable Züge zu Reacts Modell passen.","tags":["Software","Frontend"],"published_at":"2017-06-12T10:00:00+00:00","updated_at":"2026-10-23T14:29:51.128339+00:00","url":"https://www.mikebild.dev/de/blog/tic-tac-toe-minimax-react/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/274503e068b76603c53b31c7a4e3a3e2f49348bc217ccfa45a6320b755bc70b6/tic-tac-toe-minimax-react-vorlesen.mp3","audio_duration_secs":618,"text":"# Tic-Tac-Toe mit Minimax in React\n\n```js\nfunction score(board, depth) {\n  if (winnerOf(board) === COMPUTER) return 10 - depth;\n  if (winnerOf(board) === HUMAN) return depth - 10;\n  return 0;\n}\n```\n\nMehr Bewertung braucht ein unschlagbarer Tic-Tac-Toe-Gegner nicht. Ein Brett ist `10 - depth` wert, wenn der Rechner gewonnen hat, `depth - 10`, wenn der Mensch gewonnen hat, und null bei Remis. Die Suchtiefe im Wert sorgt nebenbei für Spielstil: Ein Sieg in zwei Zügen zählt mehr als einer in vier, eine Niederlage in vier Zügen ist weniger schlimm als eine in zwei. Der Gegner gewinnt also so früh wie möglich und verliert so spät wie möglich – falls er überhaupt verlieren kann, was bei fehlerfreiem Spiel nie eintritt.\n\nAuffälliger als das, was in der Funktion steht, ist das, was fehlt: kein React, kein DOM, kein Event-Handler, kein `this`, kein `setState`. Ein Brett geht hinein, eine Zahl kommt heraus. Genau darum geht es in diesem Artikel. Das Spiel selbst ist trivial, jedes Kind lernt es in fünf Minuten. Interessant ist die Schnittlinie: Der komplette Gegner ist ein Satz Pure Functions über einem Array, und React rendert am Ende nur noch State. Wer diese Linie nicht zieht, hat später weder testbare Logik noch eine austauschbare Oberfläche.\n\nDas zugehörige Repository `react-tic-tac-toe` habe ich im Oktober 2016 gebaut, angestoßen durch Carsten Königs Othello in Elm – ein Projekt, das mich damals beeindruckt hat, weil dort dieselbe Trennung ganz selbstverständlich war. In Elm gibt es schlicht keine andere Möglichkeit: Spiellogik ist eine Funktion, die View eine andere. In JavaScript muss man sich dafür entscheiden.\n\n## Das Brett ist ein Array, ein Zug ist ein neues Brett\n\nDie Datenstruktur ist so klein, wie sie klingt: ein Array mit neun Feldern, jedes Feld `'X'`, `'O'` oder `null`. Die Indizes 0 bis 8 laufen zeilenweise von links oben nach rechts unten. Gewinnbedingungen sind acht Linien über diesen Indizes – drei Zeilen, drei Spalten, zwei Diagonalen.\n\n```js\nconst HUMAN = 'X';\nconst COMPUTER = 'O';\n\nconst LINES = [\n  [0, 1, 2], [3, 4, 5], [6, 7, 8],\n  [0, 3, 6], [1, 4, 7], [2, 5, 8],\n  [0, 4, 8], [2, 4, 6],\n];\n\nfunction winnerOf(board) {\n  const line = LINES.find(([a, b, c]) =>\n    board[a] && board[a] === board[b] && board[a] === board[c]);\n  return line ? board[line[0]] : null;\n}\n\nfunction movesFor(board) {\n  return board.reduce((moves, field, index) =>\n    field === null ? moves.concat(index) : moves, []);\n}\n\nfunction applyMove(board, index, player) {\n  const next = board.slice();\n  next[index] = player;\n  return next;\n}\n```\n\nDie wichtigste Entscheidung steckt in `applyMove`: Ein Zug verändert das Brett nicht, er erzeugt ein neues. `board.slice()` kopiert das Array, dann wird ein Feld gesetzt, das Original bleibt unangetastet. Für neun Elemente kostet das nichts, und es zahlt sich gleich doppelt aus.\n\nErstens für die Suche. Minimax probiert Züge hypothetisch aus: Was wäre, wenn der Rechner auf Feld 4 setzt und der Mensch dann auf Feld 0? Mit Mutation müsste jeder probierte Zug nach der Bewertung wieder zurückgenommen werden – klassisches Do/Undo mit allen Gelegenheiten, den Undo-Teil zu vergessen. Mit immutable Brettern gibt es nichts zurückzunehmen. Jeder Ast des Spielbaums hält sein eigenes Brett, und wenn die Rekursion zurückkehrt, ist das alte Brett noch exakt das alte Brett.\n\nZweitens für React. `setState` mit einem frischen Array bedeutet: Wer wissen will, ob sich etwas geändert hat, vergleicht Referenzen statt Inhalte. Dass das offizielle React-Tutorial – ausgerechnet ebenfalls ein Tic-Tac-Toe – an derselben Stelle `slice()` empfiehlt, ist kein Zufall. immutable Daten sind die Währung, in der React denkt. Dass dieselbe Entscheidung gleichzeitig den Suchalgorithmus vereinfacht und das UI-Modell bedient, war für mich der eigentliche Aha-Moment an diesem kleinen Projekt.\n\n## Minimax, rekursiv erzählt\n\nDer Algorithmus ist älter als jedes Web-Framework und lässt sich in einem Satz fassen: Ich wähle den Zug, der mein Ergebnis maximiert, unter der Annahme, dass der Gegner danach den Zug wählt, der mein Ergebnis minimiert – und so weiter, bis das Spiel entschieden ist. Zwei Spieler, abwechselnd Maximum und Minimum, daher der Name.\n\n```js\nfunction minimax(board, player, depth) {\n  if (winnerOf(board) || movesFor(board).length === 0) {\n    return score(board, depth);\n  }\n\n  const values = movesFor(board).map(index =>\n    minimax(\n      applyMove(board, index, player),\n      player === COMPUTER ? HUMAN : COMPUTER,\n      depth + 1\n    ));\n\n  return player === COMPUTER ? Math.max(...values) : Math.min(...values);\n}\n\nfunction bestMove(board) {\n  const moves = movesFor(board);\n  const values = moves.map(index =>\n    minimax(applyMove(board, index, COMPUTER), HUMAN, 1));\n  return moves[values.indexOf(Math.max(...values))];\n}\n```\n\nDie Rekursion hat drei Bausteine, und alle drei stehen sichtbar im Code:\n\n- Der Abbruch: Hat jemand gewonnen oder ist kein Feld mehr frei, liefert `score` den Wert des Endzustands.\n- Der Schritt: Für jedes freie Feld entsteht per `applyMove` ein Folge-Brett, das mit vertauschtem Spieler und um eins erhöhter Tiefe bewertet wird.\n- Die Auswahl: Ist der Rechner am Zug, gewinnt das Maximum der Kindwerte, sonst das Minimum.\n\n`bestMove` ist dann nur noch die oberste Ebene dieser Suche, ausgeschrieben: alle eigenen Züge bewerten, den Index des besten zurückgeben. Ein kleiner Spielbaum zeigt, warum die Tiefe in der Bewertung den Unterschied zwischen einem korrekten und einem klugen Gegner ausmacht:\n\n```mermaid\ngraph TD\n  R[\"Brett, O am Zug<br/>nimmt das Maximum\"] --> A[\"Zug auf Feld 2 blockt<br/>X am Zug, nimmt das Minimum\"]\n  R --> B[\"Zug auf Feld 5<br/>O gewinnt sofort: Wert +9\"]\n  A --> A1[\"X erzwingt später den Sieg<br/>Wert -6\"]\n  A --> A2[\"Remis<br/>Wert 0\"]\n  A -.-> AV[\"Minimum: -6\"]\n  B -.-> BV[\"Blatt: +9\"]\n```\n\nDer Rechner steht hier vor der Wahl, eine gegnerische Reihe zu blocken oder selbst zu gewinnen. Ohne Tiefenbonus wären beide Siege gleich viel wert und die Wahl hinge von der Reihenfolge der Auswertung ab. Mit `10 - depth` ist der sofortige Sieg strikt besser – der Gegner spielt nicht nur fehlerfrei, sondern zielstrebig.\n\nZur Größenordnung: Von einem leeren Brett aus gibt es rund 255.000 mögliche Spielverläufe, mit jedem gesetzten Stein schrumpft der Baum drastisch. Ein Laptop bewertet das in Millisekunden, Optimierung ist rechnerisch unnötig. Im Repository steckt trotzdem eine Variante mit Alpha-Beta-Pruning, nicht-rekursiv formuliert – das war Neugier entlang der Elm-Vorlage, denn für Othello mit seinem 8×8-Brett ist Pruning keine Spielerei mehr, sondern Voraussetzung. Für den Artikel hier bleibe ich bewusst bei der rekursiven Grundform: Sie ist die Version, die man am Whiteboard herleiten kann, und [einfach schlägt elegant](https://www.mikebild.dev/de/blog/einfach-schlagen-elegant-statt-prozess/), solange die einfache Form messbar reicht.\n\n## React rendert nur noch State\n\nErst jetzt kommt React ins Spiel, und zwar mit einer betont langweiligen Rolle. Der gesamte Spielzustand ist `state.board`, und die einzige Stelle, an der sich dieser State ändert, ist der Klick-Handler. Der Handler orchestriert zwei Züge – den des Menschen, dann den Antwortzug des Rechners – und delegiert alles Inhaltliche an die Pure Functions von oben.\n\n```jsx\nclass Game extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = { board: Array(9).fill(null) };\n    this.handleClick = this.handleClick.bind(this);\n  }\n\n  handleClick(index) {\n    const afterHuman = applyMove(this.state.board, index, HUMAN);\n    const finished =\n      winnerOf(afterHuman) || movesFor(afterHuman).length === 0;\n    const nextBoard = finished\n      ? afterHuman\n      : applyMove(afterHuman, bestMove(afterHuman), COMPUTER);\n    this.setState({ board: nextBoard });\n  }\n\n  render() {\n    return <Board board={this.state.board} onSquareClick={this.handleClick} />;\n  }\n}\n\nconst Board = ({ board, onSquareClick }) => (\n  <div className=\"board\">\n    {board.map((field, index) => (\n      <button\n        key={index}\n        disabled={field !== null || winnerOf(board) !== null}\n        onClick={() => onSquareClick(index)}\n      >\n        {field}\n      </button>\n    ))}\n  </div>\n);\n```\n\n`Board` ist eine zustandslose Functional Component: Brett rein, neun Buttons raus. `Game` ist eine React-15-Klasse mit genau einem Zustandsfeld und genau einem Handler. Das Setup drumherum ist ein gewöhnliches create-react-app-Projekt – `npm install`, `npm start`, läuft auf Port 3000. Mehr Werkzeug braucht es 2017 für so etwas nicht mehr, und das ist eine angenehme Entwicklung.\n\nDer Kontrast zum offiziellen React-Tutorial ist lehrreich. Dort ist Tic-Tac-Toe das Vehikel, um React zu erklären: Komponenten zerlegen, State nach oben ziehen, Props nach unten reichen, am Ende eine Zeithistorie. Der Gegner ist ein zweiter Mensch, die Spiellogik beschränkt sich auf die Gewinnprüfung. Hier ist es genau andersherum: React ist das Vehikel, der Algorithmus das Zentrum. Beide Blickrichtungen sind berechtigt – aber nur die zweite zwingt einen, die Grenze zwischen Logik und Darstellung sauber zu ziehen, weil die Logik sonst sofort unhandlich wird.\n\n```mermaid\nflowchart LR\n  subgraph core[\"Reine Funktionen – kein React-Import\"]\n    score --> minimax\n    winnerOf --> minimax\n    movesFor --> minimax\n    applyMove --> minimax\n    minimax --> bestMove\n  end\n  subgraph ui[\"React 15\"]\n    state[\"state.board\"] --> render[\"render()\"]\n    render --> onclick[\"onClick(index)\"]\n  end\n  onclick -->|\"applyMove + bestMove\"| core\n  core -->|\"neues Brett\"| setState[\"setState\"]\n  setState --> state\n```\n\nDie Pfeile zwischen den beiden Kästen sind die ganze Kopplung: Der Handler ruft Funktionen auf und schreibt das Ergebnis zurück in den State. Kein Import läuft in die Gegenrichtung. Man könnte den rechten Kasten durch eine Konsolenanwendung ersetzen, durch jQuery oder eben durch Elm – der linke Kasten würde es nicht bemerken.\n\n## Tests ohne Browser\n\nDiese Trennung ist keine Geschmacksfrage, sie ist messbar – am direktesten in den Tests. Die Spiellogik läuft in Node, ohne DOM, ohne jsdom, ohne gemocktes Event-System. Jest, das bei create-react-app gleich dabei ist, führt so etwas in Millisekunden aus:\n\n```js\ntest('computer blocks an open line', () => {\n  const board = [\n    HUMAN,    HUMAN, null,\n    COMPUTER, null,  null,\n    null,     null,  null,\n  ];\n  expect(bestMove(board)).toBe(2);\n});\n\ntest('computer prefers the immediate win over the block', () => {\n  const board = [\n    HUMAN,    HUMAN,    null,\n    COMPUTER, COMPUTER, null,\n    null,     null,     null,\n  ];\n  expect(bestMove(board)).toBe(5);\n});\n```\n\nDas sind keine Unit-Tests im kleinteiligen Sinn, sondern Spielverstand als Spezifikation: Der Gegner muss eine offene Reihe blocken, und er muss den eigenen Sieg dem Block vorziehen. Solche Fälle lassen sich direkt aus Partien gewinnen – jedes Mal, wenn sich der Gegner beim Ausprobieren merkwürdig verhält, wird die Stellung zum Testfall. Bei mir war es anfangs genau so ein Fall: Ohne den Tiefenanteil in `score` schob die Suche einen sicheren Sieg vor sich her, weil ein Sieg in vier Zügen denselben Wert hatte wie einer in zwei. Die Stellung aus dem zweiten Test oben ist das Fossil dieses Fehlers.\n\nNun die Gegenprobe, und die habe ich in Reviews oft genug in freier Wildbahn gesehen: Die Zuglogik steckt im Klick-Handler, die Gewinnprüfung in `render`, der Rechnerzug in einem Lifecycle-Hook wie `componentDidUpdate`. Funktional identisch, aber jetzt gilt:\n\n- Jeder Logik-Test braucht eine gemountete Komponente und simulierte Klicks – aus einem Funktionsaufruf wird ein Browser-Ritual.\n- Die Stellung aus dem Fehlerfall lässt sich nicht direkt hinschreiben, sie muss durch eine Klickfolge erspielt werden.\n- Ein Wechsel der UI-Bibliothek oder auch nur ein größeres Umbauen des Komponentenbaums reißt die Spiellogik mit um.\n- Der Algorithmus ist nicht mehr am Stück lesbar, weil er über Handler, Render-Zweige und Lifecycle-Methoden verteilt ist.\n\nNichts davon ist bei drei mal drei Feldern dramatisch. Aber Tic-Tac-Toe ist hier ja nur das Versuchstier. Ersetzt man „Spiellogik\" durch „Preisberechnung\", „Validierung\" oder „Planungsregeln\", ist es dieselbe Entscheidung mit denselben Folgen – nur dass die verwobene Version dann nicht mehr an einem Nachmittag zu entflechten ist.\n\n## Was ich daraus mitnehme\n\nMinimax selbst wird kaum jemand im Projektalltag brauchen. Was bleibt, ist das Muster darunter. Der ganze Gegner ist eine Bewertungsfunktion plus eine Suche über immutable Werten – und weil das so ist, lässt er sich testen wie eine Preisformel und rendern mit was auch immer gerade da ist. React hat in diesem Projekt die Rolle, die ihm meiner View nach am besten steht: State entgegennehmen, Oberfläche daraus machen, Eingaben zurückmelden. Nicht mehr.\n\nBemerkenswert finde ich, wie sehr die Teile zusammenpassen, ohne füreinander gebaut zu sein. Minimax verlangt immutable Bretter, weil die Rekursion sonst Buchhaltung bräuchte. React belohnt immutable States, weil Vergleiche sonst teuer würden. Ein Algorithmus aus den Anfängen der Spieltheorie und eine UI-Bibliothek von 2013 treffen sich in derselben Disziplin: keine Mutation, Werte statt Änderungen. Das ist kein Zufall, sondern ein Hinweis darauf, dass diese Disziplin grundsätzlicher ist als jedes einzelne Werkzeug.\n\nWer das nachbauen will: ein Array mit neun Feldern, fünf Pure Functions, eine Klasse mit einem Handler. Ein Nachmittag – und danach ein präzises Gespür dafür, wo in den eigenen, deutlich größeren Anwendungen die Linie zwischen Logik und Darstellung verlaufen sollte. Und ein Spiel, gegen das man nie wieder gewinnt.\n\n## Weiterführende Quellen\n\n- Repository `react-tic-tac-toe` („Tic Tac Toe in React with Minimax-AI\"): https://github.com/MikeBild/react-tic-tac-toe\n- Carsten Königs Othello in Elm, die Inspirationsquelle: https://github.com/CarstenKoenig/ElmOthello\n- Das offizielle React-Tutorial (Tic-Tac-Toe, heutiger Stand): https://react.dev/learn/tutorial-tic-tac-toe\n- Minimax im Überblick: https://en.wikipedia.org/wiki/Minimax\n- Alpha-Beta-Pruning: https://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning"},{"slug":"einfach-schlagen-elegant-statt-prozess","title":"Einfacher Entwurf kann Prozess sparen","summary":"In vielen Projekten kompensiert Prozess-Overhead einen zu großen oder spekulativen Entwurf. Kleiner zu schneiden kann Koordination sparen; externe Vorgaben und echte Abhängigkeiten bleiben.","tags":["Software","Produkt"],"published_at":"2017-05-24T10:00:00+00:00","updated_at":"2026-07-13T08:35:56.109624+00:00","url":"https://www.mikebild.dev/de/blog/einfach-schlagen-elegant-statt-prozess/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/652dfbe1c347508153752ae47b8269f074b3251a6d4cccbc07851cd602483f7d/einfach-schlagen-elegant-statt-prozess-vorlesen.mp3","audio_duration_secs":627,"text":"Ein Change Board misst nicht, wie riskant eine Änderung ist. Es misst, wie groß sie geschnitten wurde. Für die Freigaberunde, das Design Review und das wöchentliche Integrations-Meeting gilt dasselbe: Sie reagieren auf die Form, die ein Entwurf der Arbeit gegeben hat, und nicht auf ihren fachlichen Wert.\n\nDie üblichen Vorschläge gegen zu viel Prozess setzen deshalb an der falschen Stelle an – noch ein Gremium, straffere Freigaben, ein umgebautes Ticket-Board. Damit wird Reibung verwaltet, statt sie loszuwerden. Die Frage davor ist ergiebiger: Warum braucht diese eine Änderung überhaupt drei Teams, zwei Reviews und ein Approval-Meeting? Sehr oft steckt die Antwort im Entwurf und nicht in der Organisation. Nicht immer, aber oft genug, dass es sich lohnt, dort zuerst nachzusehen.\n\n## Overhead ist teuer, bevor er sichtbar wird\n\nBevor ich zum Entwurf komme, lohnt ein Blick auf die Rechnung. Martin Fowler hat in seinem Text zu YAGNI (\"You Aren't Gonna Need It\") vorgezogene, spekulative Komplexität sauber in Kostenarten zerlegt: Es gibt build cost – das Bauen des Features, das man heute nicht braucht. Es gibt delay cost – das, was man in derselben Zeit nicht am eigentlichen Problem gearbeitet hat. Es gibt carry cost – jede Zeile spekulative Flexibilität muss ab sofort mitgetragen, verstanden, getestet und umgangen werden. Und es gibt repair cost, wenn die vorgebaute Annahme sich als falsch herausstellt.\n\nDer Punkt, der in Diskussionen fast immer untergeht: Ein großer Teil dieser Kosten ist Koordinationskosten. Ein flexibler, konfigurierbarer, \"für später\" ausgelegter Entwurf braucht ein Design Review. Er braucht ein Config-Ticket. Er kreuzt Teamgrenzen und braucht deshalb Abstimmung. YAGNI ist deshalb kein Faulheitsprinzip, sondern ein Designprinzip – und nebenbei ein Prozessspar-Prinzip. Weniger vorgebaute Komplexität heißt weniger, worüber man sich abstimmen muss.\n\nEin kleines Beispiel, das ich in Varianten immer wieder sehe:\n\n```python\n# Over-engineered: a pluggable strategy, a factory, and a config object\n# for ONE rule that exists today. This needs a design review, a config\n# ticket, and a feature-flag rollout before it ships.\nclass DiscountEngine:\n    def __init__(self, strategy: DiscountStrategy, config: DiscountConfig):\n        self.strategy = strategy\n        self.config = config\n\n    def apply(self, price: Money, customer: Customer) -> Money:\n        return self.strategy.compute(price, customer, self.config)\n\n\n# YAGNI: the one rule you actually have today. One PR, no coordination,\n# no ticket for a config value nobody has asked for yet.\ndef apply_discount(price: Money, is_returning_customer: bool) -> Money:\n    return price * 0.9 if is_returning_customer else price\n```\n\nDie untere Variante ist nicht \"schludrig\". Sie ist ehrlich. Sie beschreibt exakt die eine Regel, die es heute gibt. Und sie kostet in der Auslieferung fast nichts – keine Runde durchs Gremium, kein Flag, kein Meeting. Wenn morgen eine zweite Discount-Regel dazukommt, refactore ich – dann, mit echter Information, statt heute auf Verdacht.\n\n## Der eigentliche Hebel: Schnittgröße\n\nDer Discount ist die kleine Bühne. Die große ist die Frage, wie groß eine Änderung ist, bevor sie live geht. Und genau hier steckt der Zusammenhang, den ich in Reviews am liebsten aufmale.\n\n```mermaid\nflowchart TB\n    A[\"One large change<br/>crossing three teams\"] --> B[Design Review]\n    B --> C[Cross-team Ticket]\n    C --> D[Merge Conflict]\n    D --> E[Change Board]\n    E --> F[Coordination Meeting]\n    F --> G[Release]\n\n    A2[\"Same value, cut into three<br/>small independent changes\"] --> H[\"Context A<br/>own pipeline\"]\n    A2 --> I[\"Context B<br/>own pipeline\"]\n    A2 --> J[\"Context C<br/>own pipeline\"]\n    H --> K[Release]\n    I --> K\n    J --> K\n```\n\nDie Kernaussage der Grafik ist schlicht: Der Koordinationsaufwand hängt nicht am Feature-Wert. Er hängt an der Schnittgröße und daran, wie viele Teamgrenzen ein Schnitt kreuzt. Ein großer Change, der drei Teams berührt, erzeugt dicke Koordinationskanten – Reviews, Merge-Konflikte, Change Board, Abstimmungsrunden. Derselbe fachliche Wert, in drei kleine, unabhängige Änderungen zerlegt, erzeugt fast keine Querkanten. Jede Änderung bleibt in ihrem eigenen Bounded Context und geht durch ihre eigene Pipeline.\n\nDas ist keine Theorie. Die State-of-DevOps-Reports der letzten Jahre – die Arbeit rund um Forsgren, Humble und Kim – zeigen dasselbe empirisch. High Performer arbeiten mit wenigen aktiven Branches, integrieren mindestens einmal täglich in den Trunk und kennen keine langen Integrations- oder Freeze-Phasen. Sie ersetzen Merge- und Koordinations-Overhead durch kleine Batches. Trunk-Based Development ist genau das: den Schnitt so klein machen, dass die aufwendige Koordination gar nicht erst entsteht.\n\nUnd der Teil, der Skeptiker überrascht: Kleine Deployments sind nicht riskanter, sondern sicherer. Dieselben Daten zeigen, dass hohe Deployment-Frequenz und kurze Lead Time for Changes mit einer niedrigeren Change Fail Rate und kürzerer Wiederherstellungszeit korrelieren. Ein kleiner Schnitt ist leichter zu verstehen, leichter zu reviewen und im Fehlerfall leichter zurückzudrehen. Ein großer Schnitt braucht die schwere Approval-Zeremonie – weil er sie tatsächlich verdient hat. Man baut sich den Prozess, den man durch die Größe erzwingt.\n\n## Wenn Koordination bloß Symptom ist\n\nEin Muster, das sich in Projekten hartnäckig hält: Zwei Teams stimmen sich in einem wiederkehrenden Integrations-Meeting ab, weil Team A einen Endpoint bereitstellt, von dem Team B abhängt. Jede Änderung wird vorher besprochen, jede Integration gemeinsam getestet. Der Prozess ist real – aber er kompensiert nur, dass niemand die Erwartung zwischen den Teams explizit gemacht hat.\n\nConsumer-Driven Contracts drehen das um. Der Consumer beschreibt genau, was er braucht – nicht mehr. Der Provider verifiziert diesen Vertrag in seiner eigenen Pipeline, unabhängig. Das Abstimmungs-Meeting verschwindet, weil die Abstimmung im Code steht:\n\n```javascript\n// The consumer declares only what it actually needs. No cross-team\n// integration meeting, no shared end-to-end test suite to schedule.\npact\n  .given(\"customer 42 exists\")\n  .uponReceiving(\"a request for the customer name\")\n  .withRequest({ method: \"GET\", path: \"/customers/42\" })\n  .willRespondWith({\n    status: 200,\n    body: { name: like(\"Ada\") },\n  });\n\n// The provider replays this contract in ITS OWN pipeline and fails fast\n// if it ever breaks the expectation — independently, no meeting required.\n```\n\nWerkzeuge wie Pact machen das seit einigen Jahren praktikabel. Der Effekt ist derselbe wie beim kleinen Schnitt: Ein Stück Koordination, das vorher als Meeting existierte, wird zu ausführbarer Spezifikation – und der Prozess drumherum ist nicht mehr nötig.\n\n## Governance ohne Gremium\n\nDasselbe Muster gilt auf Architekturebene. Viele schwergewichtige Governance-Prozesse – das Architektur-Gremium, das jede Änderung absegnet – existieren, weil niemand eine leichtgewichtigere Art gefunden hat, Architekturregeln durchzusetzen. Neal Ford und Rebecca Parsons prägen für die Alternative einen schönen Begriff: fitness functions. Statt einmal groß vorab zu entwerfen und dann per Gremium zu bewachen, formuliert man die Architekturziele als automatisch prüfbare Regeln in der Deployment-Pipeline.\n\n```yaml\n# Architecture rules as executable guardrails in the pipeline,\n# not as a slide reviewed once a quarter by a committee.\nfitness_functions:\n  - name: modules_stay_decoupled\n    rule: \"no cyclic dependencies between packages\"\n  - name: p95_latency_budget\n    threshold_ms: 300\n  - name: public_api_is_stable\n    rule: \"no breaking change without a new version\"\n```\n\nIst die Regel verletzt, wird der Build rot – sofort, nachvollziehbar, für alle gleich. Das ersetzt keine Architekturarbeit; es ersetzt das Ritual, mit dem man sie sonst durchsetzt. Verwandt dazu sind Fowlers Ideen der \"Sacrificial Architecture\" und des \"MonolithFirst\": erst einfach und notfalls wegwerfbar bauen, Komplexität nur dann einziehen, wenn die Realität sie erzwingt. Evolutionäre Architektur ist die konsequente Weigerung, Prozess-Overhead für eine Zukunft zu bezahlen, die man noch nicht kennt.\n\n## Wo die These an ihre Grenze kommt\n\nDie Gegenrede ist berechtigt. Drei ehrliche Einschränkungen, damit aus einer guten Heuristik keine Ideologie wird.\n\nErstens: Einfach ist nicht dasselbe wie simplistisch. YAGNI gilt für spekulative Features und vorgezogene Flexibilität – nicht für innere Qualität. \"Wir lassen die Tests und das Design weg, dann sind wir schneller\" ist die Fehllesart, gegen die Fowler ausdrücklich anschreibt. Der Unterschied ist der zwischen accidental und essential complexity, den schon Brooks gezogen hat. Weniger spekulative Struktur: ja. Weniger Sorgfalt im Kern: nein. Die kleine Discount-Funktion von oben braucht ihren Test genauso wie die große Engine – nur eben genau einen, für die eine Regel, die es gibt.\n\nZweitens: Overhead ist nicht immer Design-Verschulden. Compliance-Anforderungen sind real. Regulatorische Gates sind real. Echte Abhängigkeiten zwischen Teams, die sich nicht wegschneiden lassen, sind real. Deshalb steht oben bewusst \"oft\" und nicht \"immer\". Es geht nicht darum, jeden Prozessschritt abzuschaffen. Es geht darum, ihn zu befragen: Kompensierst du gerade eine Anforderung von außen – oder eine Komplexität, die wir uns selbst gebaut haben?\n\nDrittens: Ein kleinerer Schnitt kann Komplexität auch nur verschieben. Aus einem Modul zwei Services zu machen tauscht Code-Kopplung gegen verteilte Kopplung und Netzwerk ein. Das ist manchmal richtig, aber es ist nie gratis. Genau darum sind Contract Tests der Ersatz für den vollen End-to-End-Integrationszirkus – und genau darum lohnt Fowlers \"MicroservicePremium\" als Merksatz: Verteilung muss man sich verdienen, sie ist kein Default. Wer klein schneidet, ohne den Schnitt entlang echter fachlicher Grenzen zu legen, hat am Ende mehr Koordination, nicht weniger.\n\n## Wie ich es konkret angehe\n\nDie Reihenfolge, die sich dabei bewährt hat, beginnt nicht beim Prozess.\n\n```mermaid\nflowchart LR\n    A[\"Spot the heavy<br/>process step\"] --> B[\"Ask: what complexity<br/>is it compensating?\"]\n    B --> C[\"Cut the change<br/>smaller\"]\n    C --> D[\"Make the expectation<br/>explicit as a contract\"]\n    D --> E[\"Turn the gate into a<br/>fitness function\"]\n    E --> F[\"Drop the step<br/>that is now redundant\"]\n```\n\nZuerst suche ich den teuersten Prozessschritt – das Meeting, das alle nervt, das Approval, das immer blockiert. Dann frage ich nicht \"wie machen wir es effizienter\", sondern \"welche Komplexität fängt es auf\". Meistens finden wir dahinter einen zu großen Schnitt, eine unausgesprochene Erwartung oder eine Governance, die niemand automatisiert hat. Dann schneiden wir kleiner, machen die Erwartung als Contract explizit, gießen das Gate in eine fitness function – und lassen den Schritt, der jetzt nichts mehr auffängt, ersatzlos weg.\n\nDas Ergebnis ist selten spektakulär und fast immer spürbar: kürzere Zyklen, weil weniger auf Freigaben wartet. Weniger Reibung, weil weniger über Teamgrenzen abgestimmt werden muss. Höhere Qualität, weil kleine Änderungen leichter zu prüfen und im Fehlerfall leichter zurückzudrehen sind. Einfach und elegant statt Prozess-Overhead – das fällt als Nebenprodukt eines besseren Entwurfs ab und ist keine Sparmaßnahme.\n\n## Fazit\n\nProzess ist teuer, und meistens ist er teuer aus gutem Grund: Er fängt Reibung auf, die real da ist. Aber die interessantere Frage ist fast nie, wie man den Prozess effizienter macht. Sie lautet, warum die Reibung überhaupt entsteht. Sehr oft ist die Antwort ein Entwurf, der zu groß, zu spekulativ oder zu implizit ist.\n\nKleiner schneiden schlägt mehr koordinieren. Explizite Verträge schlagen wiederkehrende Abstimmung. Automatisierte Guardrails schlagen Gremien. Und YAGNI schlägt jede Flexibilität, die man heute baut und morgen umgeht. Das ist keine Aufforderung zur Schludrigkeit – innere Qualität bleibt nicht verhandelbar. Es ist die Einladung, den Prozess-Overhead nicht als Naturgesetz zu nehmen, sondern als Symptom zu lesen. Wer das Problem an der richtigen Stelle klein macht, braucht die schwere Maschinerie drumherum gar nicht erst.\n\nWo der Prozess selbst gebaute Komplexität kompensiert, kann ein einfacherer Entwurf ihn tatsächlich überflüssig machen. Wo Regulierung, Betrieb oder echte Teamabhängigkeiten die Ursache sind, bleibt diese Rechnung offen. Genau diese Unterscheidung ist für mich wichtiger als die pauschale Forderung nach weniger Prozess.\n\n## Weiterführende Quellen\n\n- [Martin Fowler – Yagni](https://martinfowler.com/bliki/Yagni.html)\n- [Martin Fowler – Is Design Dead?](https://martinfowler.com/articles/designDead.html)\n- [Sacrificial Architecture (InfoQ, 2014)](https://www.infoq.com/news/2014/11/sacrificial-architecture/)\n- [Building Evolutionary Architectures / Fitness Functions (O'Reilly, 2017)](https://www.oreilly.com/library/view/building-evolutionary-architectures/9781491986356/ch02.html)\n- [Neal Ford – Building Evolutionary Architectures](https://nealford.com/books/buildingevolutionaryarchitectures.html)\n- [Pact – Dokumentation](https://docs.pact.io/)"},{"slug":"html5-css3-ecmascript-baseline","title":"HTML5, CSS3 und ECMAScript als Baseline","summary":"Im Frühjahr 2017 tragen CSS Grid, Flexbox, fetch und natives ES2015 so weit, dass die erste Architekturfrage im Frontend neu gestellt gehört: Wie weit komme ich ohne Framework?","tags":["Software","Architektur"],"published_at":"2017-05-12T10:00:00+00:00","updated_at":"2026-07-14T17:01:38.042661+00:00","url":"https://www.mikebild.dev/de/blog/html5-css3-ecmascript-baseline/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/381654c4b3bf48e47d9e5e1d6ba07216dbf82047b70635d780f0a4e57071ecb5/html5-css3-ecmascript-baseline-vorlesen.mp3","audio_duration_secs":694,"text":"# HTML5, CSS3 und ECMAScript als Baseline\n\nBaseline meint hier etwas Bestimmtes: die Menge an HTML-, CSS- und JavaScript-Fähigkeiten, die in allen Browsern eines Projekts ohne eine einzige Abhängigkeit läuft. Kein `npm install`, kein Framework, kein Plugin – nur das, was die Plattform von Haus aus mitbringt. Diese Menge ist keine feste Größe. Sie hängt davon ab, welche Browser ein Projekt tatsächlich bedienen muss, und sie verschiebt sich mit jedem Browser-Release. Wer sie kennt, kann eine Frage präzise beantworten, die sonst reflexhaft beantwortet wird: Wofür genau brauche ich eine Abhängigkeit – und wofür nicht?\n\nDiese Frage lohnt sich gerade jetzt besonders, denn die Baseline hat sich in den letzten Monaten so weit nach oben geschoben wie lange nicht.\n\n## Ein ungewöhnliches Frühjahr\n\nIm März sind Chrome 57, Firefox 52 und Safari 10.1 innerhalb weniger Wochen mit CSS Grid Layout erschienen. Dass ein großes Layout-Feature praktisch zeitgleich und ohne Präfixe in drei großen Browsern landet, habe ich in dieser Form noch nicht erlebt. Sonst zog sich so etwas über Jahre – man denke an die drei verschiedenen Flexbox-Syntaxen, die sich lange nebeneinander gehalten haben. Grid ist von Tag eins an in der finalen Syntax nutzbar.\n\nGrid ist aber nur der sichtbarste Teil. Wer die Zielbrowser auf aktuelle Versionen von Chrome, Firefox, Safari und Edge eingrenzen kann, hat im Mai 2017 ohne jede Abhängigkeit zur Verfügung:\n\n- Flexbox und CSS Grid für Layout, dazu `@supports` für saubere Feature-Queries im Stylesheet\n- CSS Custom Properties (seit letztem Jahr breit unterstützt, IE11 ausgenommen)\n- ES2015 nativ: Klassen, Arrow Functions, Destructuring, Template Literals, Promises\n- `async`/`await` aus ES2017 in den aktuellen Versionen der Evergreen-Browser\n- `fetch` als eingebauten HTTP-Client, seit Safari 10.1 auch dort\n- Custom Elements v1 und Shadow DOM v1 in Chrome und Safari\n- Service Worker in Chrome und Firefox – Safari und Edge fehlen hier noch, das gehört zur Ehrlichkeit dazu\n\nVor zwei, drei Jahren war jeder einzelne Punkt dieser Liste ein Grund, eine Bibliothek zu installieren. Genau deshalb ist mir die Frage wieder wichtig geworden. In einem Code-Review bin ich kürzlich durch ein Frontend gegangen, das jQuery, ein Grid-System, eine AJAX-Hilfsbibliothek und einen Klassen-Emulator mitschleppte – zusammen deutlich über 100 KB, bevor die erste eigene Zeile lief. Für jede dieser Abhängigkeiten gab es 2013 einen guten Grund. 2017 erledigt die Plattform alle vier Aufgaben selbst. Niemand hatte die Entscheidung je zurückgenommen, weil niemand sie je als Entscheidung betrachtet hatte.\n\n## Layout gehört jetzt der Plattform\n\nDas Beispiel, an dem ich das durchspiele, ist bewusst gewöhnlich: eine Produktübersicht, Karten in einem Raster, Daten von einer HTTP-Schnittstelle. Der Klassiker, für den man sonst Bootstrap samt jQuery-Abhängigkeit installiert.\n\nDas Raster zuerst. Mit Grid ist das Layout eine Handvoll Zeilen, und der Fallback für ältere Browser ist keine zweite Codebasis, sondern eine Schicht darunter:\n\n```css\n/* baseline for every browser: a simple flex wrap */\n.products {\n  display: flex;\n  flex-wrap: wrap;\n}\n\n.product-card {\n  flex: 1 1 240px;\n  margin: 8px;\n}\n\n/* enhancement for browsers with grid support */\n@supports (display: grid) {\n  .products {\n    display: grid;\n    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));\n    grid-gap: 16px;\n  }\n\n  .product-card {\n    margin: 0;\n  }\n}\n```\n\nZwei Dinge daran finde ich bemerkenswert. Erstens: `repeat(auto-fill, minmax(240px, 1fr))` ersetzt das, wofür Grid-Systeme jahrelang Klassen wie `col-md-4` und eine Media-Query-Kaskade gebraucht haben – ohne eine einzige Media Query. Zweitens: `@supports` macht die Browser-Matrix zu einer Design-Entscheidung statt zu einem Kompatibilitäts-Hack. IE11 und der aktuelle Edge bekommen das Flexbox-Raster, moderne Browser das Grid. Die Seiten sehen nicht pixelgleich aus, und das ist in Ordnung – die Vorstellung, jede Browser-Version müsse identisch rendern, hat uns jahrelang unnötig viel Code gekostet.\n\nEhrlich bleiben muss man bei Edge: Der unterstützt bislang nur die alte Grid-Syntax mit `-ms`-Präfix, die noch aus IE10-Zeiten stammt. Wer Edge im Zielkorb hat, behandelt ihn beim Grid vorerst wie IE11 und fährt den Fallback. Genau dafür ist die `@supports`-Query da.\n\n## Daten laden ohne Bibliothek\n\nDer zweite Baustein ist der Datenabruf. Wofür früher `$.ajax` oder eine eigene XHR-Kapselung nötig war, reicht heute `fetch` – und mit `async`/`await` liest sich der Kontrollfluss endlich wie synchroner Code. Wie mühsam dieselbe Aufgabe vor drei Jahren mit Callback-Ketten war, habe ich damals [in einem eigenen Beitrag](https://www.mikebild.dev/de/blog/control-flow-vor-async-await/) festgehalten; die Distanz zwischen diesen beiden Welten ist erstaunlich kurz.\n\n```js\nasync function loadProducts() {\n  const response = await fetch('/api/products', {\n    headers: { 'Accept': 'application/json' }\n  });\n\n  // fetch resolves on HTTP errors too, so check explicitly\n  if (!response.ok) {\n    throw new Error('Request failed with status ' + response.status);\n  }\n\n  return response.json();\n}\n```\n\nDer Kommentar im Code markiert die Stolperstelle, über die ich in Reviews am häufigsten stolpere: `fetch` lehnt das Promise bei einem 404 oder 500 nicht ab, sondern nur bei Netzwerkfehlern. Wer von jQuery kommt, erwartet das Gegenteil. Solche Eigenheiten muss man einmal lernen – aber man lernt dabei die Plattform, nicht die Meinung einer Bibliothek, und dieses Wissen veraltet deutlich langsamer.\n\nFür IE11 gilt: kein `fetch`, kein Promise, kein `async`/`await`. Wer ihn bedienen muss, kompiliert mit Babel und lädt zwei kleine Polyfills. Das ist ein Build-Schritt, den ich sonst gern vermeide – aber er ist überschaubar, gezielt und vor allem befristet. Der Polyfill fliegt raus, sobald IE11 aus der Matrix fällt. Eine Framework-Abhängigkeit fliegt erfahrungsgemäß nie raus.\n\n## Komponenten direkt im Browser\n\nDer dritte Baustein ist der interessanteste, weil er am dichtesten an dem liegt, wofür man sonst zu einem Framework greift: wiederverwendbare Komponenten. Custom Elements v1 sind in Chrome und Safari angekommen, und damit lässt sich die Produktliste als eigenes HTML-Element definieren:\n\n```js\nclass ProductList extends HTMLElement {\n  async connectedCallback() {\n    this.textContent = 'Loading products ...';\n\n    try {\n      const products = await loadProducts();\n      this.render(products);\n    } catch (error) {\n      this.textContent = 'Products are currently unavailable.';\n    }\n  }\n\n  render(products) {\n    this.innerHTML = '<div class=\"products\"></div>';\n    const container = this.querySelector('.products');\n\n    products.forEach(product => {\n      const card = document.createElement('article');\n      card.className = 'product-card';\n      card.textContent = product.name;\n      container.appendChild(card);\n    });\n  }\n}\n\ncustomElements.define('product-list', ProductList);\n```\n\nIm Markup steht danach schlicht `<product-list></product-list>`, und der Browser ruft die Lifecycle-Methoden selbst auf. Kein virtueller DOM, kein Data-Binding, keine Render-Schleife – für eine Komponente dieser Größe braucht es das alles nicht. Firefox und Edge benötigen dafür noch das Polyfill aus dem webcomponents.org-Projekt, und das ist nicht gratis: Es bringt spürbar Gewicht mit und muss in der Browser-Matrix mitgetestet werden. Wer heute produktiv auf Custom Elements setzt, sollte diese Kosten kennen und nicht nur die Chrome-Demo.\n\nAm Rand notiert: Native ES-Module im Browser stehen ebenfalls kurz bevor – Safari 10.1 kann `<script type=\"module\">` bereits, bei den anderen liegt es hinter Flags. Für den Moment bleibt das Bundling beim Build; aber auch diese Lücke schließt sich gerade.\n\n## Die Prüfreihenfolge\n\nAus diesen Bausteinen ergibt sich eine Reihenfolge, die ich inzwischen bei jeder Frontend-Anforderung durchgehe – nicht als Dogma, sondern als Prüfweg, der Abhängigkeiten zu begründeten Entscheidungen macht statt zu Reflexen:\n\n```mermaid\nflowchart TD\n  A[Anforderung im Frontend] --> B{Kann die Plattform das?<br/>caniuse gegen die Zielbrowser}\n  B -- ja, in allen Zielbrowsern --> C[Baseline nutzen<br/>keine Abhängigkeit]\n  B -- ja, bis auf Ausnahmen --> D{Fallback per @supports<br/>oder Polyfill vertretbar?}\n  D -- ja --> E[Gezielt nachrüsten<br/>Polyfill ist befristet]\n  D -- nein --> F[Bibliothek für genau<br/>diese eine Lücke]\n  B -- nein --> F\n  F --> G{Bilden mehrere Lücken<br/>zusammen ein Muster?}\n  G -- ja --> H[Framework – mit klarem Wofür]\n  G -- nein --> C\n```\n\nDer entscheidende Punkt ist der letzte Knoten. Ein Framework ist in diesem Bild nicht verboten – es steht nur am Ende der Prüfung statt am Anfang. Wer den Weg durchlaufen hat, kann das Wofür präzise benennen: „Wir brauchen React, weil wir zwanzig Ansichten mit geteiltem Zustand haben und das Data-Binding von Hand nicht mehr beherrschbar ist\" ist eine Architekturentscheidung. „Wir nehmen React, weil man das heute so macht\" ist keine.\n\n## Die ehrliche Gegenseite\n\nDamit das nicht wie ein Plädoyer für Frontend-Askese klingt: Es gibt gute Gründe, 2017 weiterhin Abhängigkeiten zu installieren, und die gehören auf den Tisch.\n\nDie Browser-Matrix ist der größte. Wer ein internes System für einen Konzern baut, in dem IE11 per Gruppenrichtlinie gesetzt ist, hat eine andere Baseline als jemand, der eine Website für ein technikaffines Publikum betreibt. Für die IE11-Welt bedeutet der Plattform-Ansatz: Babel, mehrere Polyfills, doppelte Layout-Pfade, doppeltes Testen. Ab einem gewissen Punkt ist eine Bibliothek, die diese Unterschiede wegabstrahiert, schlicht die billigere Lösung – und genau dafür ist jQuery auch in diesem Jahr noch legitim:\n\n- wenn IE-Versionen im Zielkorb sind, deren Eigenheiten jQuery seit Jahren zuverlässig glattzieht\n- wenn ein bestehendes Plugin-Ökosystem – Datepicker, Tabellen, Upload-Widgets – produktiv im Einsatz ist und ein Neubau keinen fachlichen Wert schafft\n- wenn ein Team eine große, gewachsene jQuery-Codebasis pflegt und ein Technologiewechsel mehr Risiko brächte als Nutzen\n\nAuch die Polyfill-Rechnung muss ehrlich sein. Ein `fetch`-Polyfill ist klein; das Web-Components-Polyfill ist es nicht. Wer fünf Plattform-Features nachrüstet, hat am Ende womöglich mehr Kilobytes geladen als mit einer kompakten Bibliothek, die alles mitbringt – und trägt die Testlast für jede Kombination selbst. Die Baseline-Frage lautet eben nicht „Plattform um jeden Preis\", sondern: Was läuft ohnehin überall, und was kostet der Rest wirklich?\n\nUnd schließlich: Für große, langlebige Anwendungen mit viel geteiltem Zustand löst die Plattform das Kernproblem nicht. HTML, CSS und ECMAScript sagen nichts darüber, wie Zustand und Darstellung konsistent bleiben, wenn dreißig Komponenten dieselben Daten zeigen. Dieses Problem ist real, und Frameworks adressieren es zu Recht. Nur ist es eben ein spezifisches Problem – nicht jede Website hat es, und eine Produktübersicht mit drei Ansichten hat es ziemlich sicher nicht.\n\n## Eine Materialsammlung als Nebenprodukt\n\nBeim Sortieren dieses Themas ist bei mir im April eine öffentliche Materialsammlung entstanden: [introduction-html5-css3-ecma](https://github.com/MikeBild/introduction-web-plattform) auf GitHub. Dort liegt der Plattform-Bestand in Abschnitten beieinander – HTML5-Markup und die APIs von Geolocation über Canvas, Web Worker und WebSockets bis Server-Sent Events, dazu CSS3-Selektoren und -Animationen, die JavaScript-Grundlagen um Prototypen und Closures und die ES2015+-Syntax bis zu Promises, `async`/`await` und Generatoren. Nichts davon ist exotisch; der Wert liegt für mich im Nebeneinander. Erst wenn der Bestand einmal aufgeschrieben vor einem liegt, sieht man, wie viel davon im eigenen Projekt gerade doppelt vorhanden ist – einmal nativ im Browser und einmal als Abhängigkeit im Bundle.\n\n## Unterm Strich\n\nDie Baseline aus HTML5, CSS3 und aktuellem ECMAScript ist 2017 kein Bastel-Kompromiss mehr, sondern ein tragfähiges Fundament: Grid und Flexbox für Layout, `fetch` und `async`/`await` für Daten, Custom Elements für Komponenten im Kleinen. Wer damit anfängt, senkt drei Kosten auf einmal – weniger Abhängigkeiten, die gepflegt und aktualisiert werden wollen, weniger Build-Komplexität, und eine niedrigere Einstiegshürde für jeden, der neu ins Projekt kommt und erst einmal nur Webstandards können muss.\n\nVor allem aber verändert die Baseline die Qualität der Framework-Entscheidung. Sie fällt nicht mehr am Projektanfang aus Gewohnheit, sondern später, gezielt, mit einem benennbaren Wofür. Manchmal lautet die Antwort dann trotzdem React oder Angular – und das ist völlig in Ordnung, weil jetzt klar ist, welches Problem damit gelöst wird und welches die Plattform längst selbst löst. Öfter, als ich noch vor zwei Jahren gedacht hätte, lautet die Antwort aber: Es braucht gar keins.\n\n## Weiterführende Quellen\n\n- [MikeBild/introduction-html5-css3-ecma](https://github.com/MikeBild/introduction-web-plattform) – die erwähnte Materialsammlung zu HTML5, CSS3 und ECMAScript\n- [MDN: CSS Grid Layout](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout) – Referenz und Einführung in das Grid-Modell\n- [MDN: Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) – Dokumentation zu `fetch`, Request und Response\n- [Grid by Example](https://gridbyexample.com/) – Rachel Andrews Beispielsammlung zu CSS Grid\n- [Can I use](https://caniuse.com/) – Browser-Support-Tabellen für die eigene Zielbrowser-Matrix"},{"slug":"rxjs-push-und-pull","title":"RxJS: Push und Pull endlich auseinanderhalten","summary":"Wer Producer und Consumer benennt, erkennt sofort, ob Daten gezogen oder geschoben werden, und wählt zwischen Funktion, Iterator, Promise und Observable ohne Raten.","tags":["Software","Reactive"],"published_at":"2017-04-12T10:00:00+00:00","updated_at":"2026-07-14T20:56:00.987422+00:00","url":"https://www.mikebild.dev/de/blog/rxjs-push-und-pull/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/57453e2c7e2175ce6b7e57162944164916b730b7aaf9493ad6f79b9f06abf7a0/rxjs-push-und-pull-vorlesen.mp3","audio_duration_secs":487,"text":"# RxJS: Push und Pull endlich auseinanderhalten\n\nWer ein kaltes Observable wie ein geteiltes Promise behandelt, schickt seine HTTP-Anfrage so oft los, wie jemand `subscribe` aufruft. Drei Komponenten am selben kalten Stream bedeuten dann drei Requests – ohne Fehlermeldung, ohne Warnung, exakt nach Definition.\n\nDer Grund dafür hat zunächst weniger mit Operatoren zu tun als mit einer Frage: Wer entscheidet, wann ein Wert fließt – der Producer oder der Consumer? Vor `map`, `switchMap` und `debounceTime` wird meist gefragt, welcher Operator der richtige ist. Diese Frage kommt zu früh. Push und Pull liefern das Grundmodell, zu dem später Cold, Hot und Multicasting kommen.\n\n## Die eine Frage, die alles ordnet\n\nJede Datenquelle in JavaScript lässt sich über ihre Richtung beschreiben. Es gibt nur zwei.\n\nBei einem Pull-System holt sich der Consumer die Werte aktiv. Er ruft etwas auf und bekommt eine Antwort. Eine Funktion ist genau das: Du rufst sie, sie liefert. Ein Iterator ist dasselbe in mehreren Schritten, denn `iterator.next()` zieht den nächsten Wert heraus. Der Consumer bestimmt das Tempo.\n\nBei einem Push-System dreht sich das um. Jetzt entscheidet der Producer, wann etwas geschickt wird, und der Consumer weiß vorher nicht, wann der nächste Wert kommt. Ein Klick-Event, eine WebSocket-Message, ein Timer: All das schiebt, sobald es so weit ist. Der Consumer reagiert nur noch.\n\nDas klingt akademisch, hat aber direkte Folgen für den Code. Bei synchronem Pull bestimmt der Consumer den nächsten Abruf und kann dabei blockieren; ein asynchroner Iterator muss das nicht tun. Bei Push entscheidet der Producer, wann er liefert, und der Consumer reagiert darauf. Genau hier scheitern viele Migrationen von „Daten abfragen“ zu „auf Daten reagieren“.\n\n## Vier Produzenten, zwei Richtungen\n\nDie offizielle RxJS-Dokumentation ordnet die vier wichtigen Produzenten in ein Raster ein. Ich male es in Architektur-Diskussionen als Erstes ans Whiteboard, weil danach die meisten Fragen schon beantwortet sind.\n\n| Producer | Anzahl Werte | Richtung | Auswertung |\n| --- | --- | --- | --- |\n| Function | genau ein Wert | Pull | lazy |\n| Iterator / Generator | null bis viele | Pull | lazy |\n| Promise | genau ein Wert | Push | eager |\n| Observable | null bis viele | Push | lazy |\n\nLies die Tabelle als zwei Achsen. Die eine Achse fragt: ein Wert oder mehrere? Die andere fragt: zieht der Consumer oder schiebt der Producer? Das Observable ist die einzige Quelle, die mehrere Werte schiebt und dabei trotzdem lazy bleibt. Diese Kombination macht es so mächtig und für Einsteiger so verwirrend, weil sie keine direkte Entsprechung im klassischen JavaScript hat.\n\nEin Promise ist der nächste Verwandte, deckt aber nur eine Zelle daneben ab: ein Wert, geschoben, sofort gestartet. Wer das verinnerlicht, hört auf, ein Observable wie ein Promise mit mehr Methoden zu behandeln.\n\n## Ein Observable ist eine Funktion mit mehreren Ausgängen\n\nAm schnellsten wird das im Code klar. Mein `introduction-rxjs`-Material beginnt bewusst beim `Observable`-Konstruktor, bevor es Operatoren zeigt. Er zwingt dich, Producer und Consumer selbst zu benennen.\n\n```ts\nimport { Observable } from 'rxjs';\n\n// The producer decides on its own when values flow.\nconst ticks$ = new Observable((observer) => {\n  let n = 0;\n  const id = setInterval(() => observer.next(n++), 1000);\n\n  // Cleanup function: runs on unsubscribe or on complete.\n  return () => clearInterval(id);\n});\n\n// The consumer only reacts, it does not actively pull.\nconst sub = ticks$.subscribe({\n  next: (v) => console.log('value', v),\n  error: (e) => console.error('error', e),\n  complete: () => console.log('done'),\n});\n```\n\nDer Block in `subscribe` ist der Observer mit seinen drei Kanälen: `next` für Werte, `error` für den Abbruch mit Fehler, `complete` für das saubere Ende. Das ist das vollständige Vokabular eines Push-Consumers. Mehr gibt es nicht, und mehr braucht man auch nicht.\n\nZum Vergleich dieselbe Idee als Pull-Quelle. Hier passiert nichts, solange niemand zieht.\n\n```ts\nfunction* naturals() {\n  let n = 0;\n  while (true) yield n++; // The value is produced only when pulled.\n}\n\nconst it = naturals();\nit.next().value; // 0  – the consumer pulls\nit.next().value; // 1  – and sets the pace\n```\n\nBeide Quellen liefern eine unendliche Folge natürlicher Zahlen. Der Unterschied liegt allein in der Kontrolle. Beim Generator entscheidest du mit jedem `next()`, wann die Eins nach der Null kommt. Beim Observable entscheidet der Timer, und du bekommst die Werte einfach zugestellt.\n\n## Lazy heißt: ohne subscribe passiert nichts\n\nDie folgenreichste Spalte der Tabelle ist „Auswertung“. Das hier konstruierte kalte Observable ist lazy. Es führt seinen Producer-Code erst aus, wenn `subscribe` aufgerufen wird, und für jedes `subscribe` von vorn. Heiße Observables und geteilte Quellen verhalten sich anders. Ein Promise ist dagegen eager: Es startet seine Arbeit in dem Moment, in dem es erzeugt wird, ganz ohne `then`.\n\nAn diesem Unterschied entstehen die meisten Fehler beim Umstieg.\n\n```ts\n// eager: the request goes out immediately, even without then.\nconst preise = fetch('/api/preise');\n\n// lazy: nothing happens until someone calls subscribe.\nconst preise$ = new Observable((observer) => {\n  fetch('/api/preise')\n    .then((res) => res.json())\n    .then((data) => {\n      observer.next(data);\n      observer.complete();\n    })\n    .catch((err) => observer.error(err));\n});\n```\n\nDamit lässt sich der Fall vom Anfang auflösen. Ein Team baut eine Suche um. Vorher gab es ein geteiltes Promise, das einmal lief und dessen Ergebnis sich mehrere Komponenten teilten. Nach der Umstellung auf ein Observable abonnieren drei Komponenten denselben Stream. Plötzlich gehen drei HTTP-Anfragen raus statt einer. Niemand hat einen Fehler gemacht im klassischen Sinn, das Observable verhält sich exakt nach Definition: lazy und kalt, also pro Subscription eine eigene Ausführung. Wer das Promise-Verhalten 2017 zurückhaben wollte, teilte den Stream mit `publishReplay` und `refCount` oder setzte ein `Subject` davor. `publishReplay` ist inzwischen veraltet; heute drückt `shareReplay({ bufferSize: 1, refCount: true })` dieselbe Absicht direkter aus. Bewusst entscheiden muss man das Multicasting weiterhin. Erst wenn man Push, Lazy und Cold getrennt benennt, sieht man, warum hier drei Requests entstehen.\n\n## Zeit sichtbar machen mit dem Marble-Diagramm\n\nBei Push-Systemen hängt alles am zeitlichen Verlauf, und genau das macht sie schwer zu lesen. Ein Marble-Diagramm zeichnet diese Zeit auf eine Linie. Jede Murmel ist ein gepushter Wert, das `|` ist `complete`, das `X` wäre ein `error`.\n\n```text\nsubscribe                         unsubscribe\n   |                                   |\n   v                                   v\nticks$: ---0----1----2----3----4----5--|\n              (the producer pushes every 1000 ms)\n```\n\nDie Linie macht zwei Dinge sofort sichtbar, die im Text untergehen. Erstens: Die Werte erscheinen nach der inneren Logik des Producers, hier dem Intervall, und eben nicht auf Abruf. Zweitens: Es gibt zwei Ränder, die Subscription und die Unsubscription. Zwischen diesen Rändern existiert der Stream, davor und danach nicht. Wer Marble-Diagramme liest, hört auf, über einzelne Werte nachzudenken, und beginnt über die Lebensdauer des Streams nachzudenken.\n\nFür die Richtung selbst genügt ein kleines Schema. Es zeigt, wer wen antreibt.\n\n```mermaid\nflowchart LR\n  subgraph Pull\n    C1[Consumer] -- ruft next ab --> P1[Producer]\n  end\n  subgraph Push\n    P2[Producer] -- schiebt next --> C2[Consumer]\n  end\n```\n\n## Der Fehler, den fast jeder einmal baut\n\nAus der Lazy-Eigenschaft folgt eine Pflicht, die im Material ein eigenes Kapitel hat, nämlich Disposable. Wenn ein `subscribe` einen Producer startet, dann muss irgendwer ihn auch wieder beenden. Beim `ticks$` oben ist das offensichtlich: Der `setInterval` läuft ewig weiter, solange niemand `unsubscribe` aufruft.\n\n```ts\nconst sub = ticks$.subscribe((v) => console.log(v));\n\n// Without this line the timer ticks forever,\n// even after the component is long gone.\nsub.unsubscribe();\n```\n\nIn einer Single-Page-Anwendung kann daraus ein echtes Leck werden. Eine Komponente abonniert einen Event-Stream, der Nutzer navigiert weg, die Komponente verschwindet aus dem DOM, aber ein nicht beendetes Abo hält Listener und referenzierte Objekte am Leben. Beim nächsten Öffnen kommt ein zweites Abo dazu, dann ein drittes. Die Symptome sind doppelt ausgeführte Side Effects und wachsender Speicherverbrauch. Die Subscription ist die Grenze, an der ein Stream Besitz und Lebensdauer bekommt; je nach Quelle endet sie durch `complete`, `error` oder ein bewusstes Unsubscribe.\n\n## Was bleibt\n\nPush und Pull ist kein theoretisches Detail, sondern das Raster, an dem du jede Entscheidung in RxJS aufhängst. Brauche ich einen oder mehrere Werte? Zieht mein Consumer oder schiebt mein Producer? Startet die Arbeit sofort oder erst beim Abonnieren? Und wer beendet den Stream wieder? Diese vier Fragen klären die meisten Operator-Diskussionen, bevor sie entstehen.\n\nDie Reihenfolge in meinem Begleitmaterial – das ich auch mit Partnern wie [Grossweber](https://grossweber.com/) einsetze – folgt genau dieser Logik. Erst Observable, Observer und Notification, also das Push-Vokabular. Dann Scheduler und Disposable, also Zeit und Lebensdauer. Operatoren kommen zuletzt, weil sie nur Werkzeuge auf diesem Fundament sind. Wer mit der Operatorliste anfängt, lernt Vokabeln ohne Grammatik.\n\n## Weiterführende Quellen\n\n- RxJS-Dokumentation, Kapitel Observable mit der Push/Pull-Tabelle: https://rxjs.dev/guide/observable\n- [RxJS: `publishReplay` und heutige Alternativen](https://rxjs.dev/api/operators/publishReplay)\n- [RxJS: `shareReplay`](https://rxjs.dev/api/index/function/shareReplay)\n- Begleitmaterial der Schulung, Repository `introduction-rxjs`: https://github.com/MikeBild/introduction-rxjs"},{"slug":"nodejs-async-io-architektur","title":"Node.js: Async I/O erklärt Architektur","summary":"Die Rechnung „ein Thread mal 200 Verbindungen“ zeigt, welche Systeme zu Node.js passen und welche Arbeit besser in eigene Prozesse gehört.","tags":["Software","Node.js","Backend"],"published_at":"2017-03-24T10:00:00+00:00","updated_at":"2026-07-14T20:54:32.790854+00:00","url":"https://www.mikebild.dev/de/blog/nodejs-async-io-architektur/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/c9eed85f57845819b6b32bfdc67178f86cfba4c2a12fff5387324699eb973f6a/nodejs-async-io-architektur-vorlesen.mp3","audio_duration_secs":712,"text":"# Node.js: Async I/O erklärt Architektur\n\nFünfzig Millisekunden sind keine auffällige Zahl. Ein `JSON.parse` über ein paar Megabyte, eine synchrone Template-Renderung, etwas Hashing – 50 ms CPU-Zeit fallen in keinem Profiler unangenehm auf. Jetzt die zweite Zahl: 200 gleichzeitige Verbindungen. In Node.js läuft sämtlicher JavaScript-Code auf einem einzigen Thread. Enthält jede dieser 200 Anfragen einen synchronen 50-ms-Block, wartet die letzte Antwort nicht 50 Millisekunden, sondern 200 × 50 ms = 10 Sekunden. Nicht weil die Maschine überlastet wäre – CPU und Netz langweilen sich –, sondern weil sich alle Anfragen denselben Thread teilen und der Reihe nach durch denselben Engpass müssen.\n\nDiese kleine Rechnung ist der komplette Architektur-Vortrag über Node.js in einem Absatz. Wer sie einmal wirklich verstanden hat, kann die meisten Systementscheidungen rund um Node ableiten: welche Dienste dorthin gehören, welche nicht, und warum die Skalierungsantwort „mehr Prozesse“ heißt. Wie sich das im Einzelnen auswirkt, zeigen die folgenden Abschnitte.\n\n## Ein Thread, viele Verbindungen\n\nNode beantwortet die Frage „Wie bediene ich viele gleichzeitige Verbindungen?\" anders als die klassischen Server. Apache mit PHP oder ein Java-Servlet-Container geben jeder Anfrage einen eigenen Thread oder Prozess; blockiert einer, stört das die anderen kaum. Node dreht das Modell um: Es gibt genau einen Thread für JavaScript, und der darf nie warten. Alles, was dauert – ein Netzwerkaufruf, ein Dateizugriff, ein DNS-Lookup –, wird an das Betriebssystem oder an einen kleinen internen Threadpool abgegeben. Der JavaScript-Thread registriert lediglich einen Callback und wendet sich der nächsten Aufgabe zu.\n\nOrganisiert wird das von libuv, der C-Bibliothek, auf der Node aufsetzt. Sie fragt den Kernel über epoll (Linux), kqueue (macOS) oder IOCP (Windows), welche der offenen Operationen fertig sind, und arbeitet die zugehörigen Callbacks in einer festen Reihenfolge ab – das ist der Event-Loop. Der Loop durchläuft Phasen: Timer, anstehende Callbacks, Polling auf I/O, `setImmediate`, Close-Callbacks. Dazwischen schiebt sich die Microtask-Verarbeitung mit `process.nextTick` und Promise-Auflösungen. Dateisystem, DNS und Teile von Krypto laufen nicht über den Kernel-Mechanismus, sondern über den erwähnten Threadpool – standardmäßig vier Threads, einstellbar über `UV_THREADPOOL_SIZE`.\n\n```mermaid\nflowchart LR\n  subgraph proc[Node-Prozess]\n    JS[JavaScript<br/>ein einziger Thread] --> Loop[Event-Loop<br/>libuv]\n  end\n  Loop --> Kernel[Kernel<br/>epoll / kqueue / IOCP]\n  Loop --> Pool[Threadpool<br/>fs, dns, crypto]\n  Kernel -. Callback bereit .-> JS\n  Pool -. Callback bereit .-> JS\n```\n\nFür den Alltag reichen zwei Merksätze. Erstens: I/O ist in Node praktisch gratis, weil das Warten woanders stattfindet – im Kernel oder im Threadpool. Zweitens: CPU-Zeit im JavaScript-Thread ist die teuerste Ressource des ganzen Systems, weil sie exklusiv ist. Jede Millisekunde, die eine Anfrage rechnet, ist eine Millisekunde, in der alle anderen Anfragen stillstehen.\n\n## Ein blockierender Aufruf blockiert alle\n\nMir ist das zum ersten Mal in einem Produktionsvorfall wirklich klar geworden, nicht beim Lesen der Dokumentation. Ein Reporting-Endpoint nahm JSON-Dokumente entgegen, im Normalfall wenige Kilobyte. Eines Tages begann ein Client, Exporte mit rund 30 MB zu schicken. Der Code sah harmlos aus:\n\n```js\nconst http = require('http');\n\nconst server = http.createServer((req, res) => {\n  let body = '';\n  req.on('data', chunk => { body += chunk; });\n  req.on('end', () => {\n    // synchronous: the event loop stops while parsing\n    const report = JSON.parse(body);\n    res.end(JSON.stringify({ items: report.items.length }));\n  });\n});\n\nserver.listen(3000);\n```\n\n`JSON.parse` über 30 MB braucht mehrere hundert Millisekunden. In dieser Zeit passiert im Prozess sonst nichts: keine neuen Verbindungen, keine laufenden Antworten, keine Timer. Auch der Health-Check-Endpoint antwortete nicht mehr, der Load-Balancer nahm die Instanz aus der Rotation, das Monitoring meldete den Dienst als tot – dabei lief der Prozess einwandfrei und hat schlicht gerechnet. Der Vorfall war nach einer Stunde verstanden, aber die Lektion war größer als der Vorfall: In Node gibt es keine „eine langsame Anfrage\". Es gibt nur langsame Anfragen, die alle anderen mitnehmen.\n\nDas Fehlermodell unterscheidet sich damit grundlegend von Thread-pro-Anfrage-Servern. Dort äußert sich ein CPU-Fresser als ein langsamer Request in der Statistik. In Node äußert er sich als Latenz-Spitze über das gesamte System – und zwar auch für Anfragen, die mit dem Verursacher nichts zu tun haben.\n\n## Welche Systeme dazu passen – und welche nicht\n\nNimmt man das Modell ernst, sortieren sich Architekturentscheidungen fast von selbst. Node ist stark, wenn der Prozess überwiegend wartet und nur kurz rechnet:\n\n- API-Gateways und Proxies, die Anfragen prüfen, anreichern und weiterleiten\n- REST-APIs vor Datenbanken, bei denen die eigentliche Arbeit im Storage stattfindet\n- Aggregatoren, die mehrere Backends parallel abfragen und Ergebnisse zusammensetzen\n- Push- und Realtime-Dienste mit vielen offenen, meist stillen Verbindungen\n\nAll diese Systeme haben denselben Charakter: Tausende Verbindungen, von denen im Moment fast alle auf etwas warten. Genau dafür ist ein Event-Loop gebaut – die wartenden Verbindungen kosten fast nichts, ein Thread genügt für alle.\n\nUmgekehrt gehört Arbeit mit ernsthafter CPU-Last nicht in den Request-Pfad eines Node-Prozesses: Bildverarbeitung, PDF-Erzeugung, das Verschlüsseln großer Dateien, aufwendige Berechnungen über Datensätzen. Nicht weil V8 langsam wäre – der JIT-Compiler ist erstaunlich schnell –, sondern weil jede dieser Millisekunden allen Anfragen gleichzeitig gehört.\n\nIn einem Projekt haben wir an dieser Linie ein Gateway geschnitten: Node übernahm Authentifizierung, Routing und das Zusammensetzen von Antworten aus drei Backends. Die Thumbnail-Erzeugung für hochgeladene Bilder, anfangs mitten im Upload-Handler, wanderte in einen separaten Prozess mit eigener Queue – per `child_process.fork` gestartet und über Messages angebunden. Die Upload-Latenz fiel, aber der eigentliche Gewinn war ein anderer: Die 99. Perzentile aller übrigen Endpoints beruhigte sich, weil kein Bild mehr den gemeinsamen Thread besetzte. Der Event-Loop hatte uns die Grenze diktiert, an der das System zu trennen war – und es war eine gute Grenze.\n\n## Backpressure: auch Warten will organisiert sein\n\nAsync I/O heißt nicht, dass Daten von allein in der richtigen Geschwindigkeit fließen. Wer eine 2-GB-Exportdatei mit `fs.readFile` lädt, um sie an den Client zu schicken, hält 2 GB im Speicher – pro Anfrage. Streams lösen das, indem sie Daten in Stücken durchreichen:\n\n```js\nconst fs = require('fs');\nconst zlib = require('zlib');\nconst http = require('http');\n\nhttp.createServer((req, res) => {\n  res.setHeader('Content-Encoding', 'gzip');\n  fs.createReadStream('./export.csv')\n    .pipe(zlib.createGzip())\n    .pipe(res);\n}).listen(3000);\n```\n\nDas Interessante an `pipe` ist nicht die kompakte Schreibweise, sondern die eingebaute Flusskontrolle. Kommt das Ziel nicht hinterher – liefert `write()` also `false` –, pausiert der lesende Stream, bis das `drain`-Event signalisiert, dass wieder Platz ist. Dieses Wechselspiel heißt Backpressure. Ohne diese Bremse liest die Platte mit ein paar hundert MB/s, während ein Mobilfunk-Client mit einem Bruchteil davon empfängt – die Differenz sammelt sich als Buffer im Heap, und der Prozess wächst, bis der Speicher endet.\n\nBackpressure ist damit die zweite Stelle, an der das Laufzeitmodell zur Architekturfrage wird: Wer Uploads, Exporte oder Eventströme über Node führt, muss Datenfluss als etwas Begrenzbares entwerfen – mit Streams als Standardwerkzeug und `pipe` als Verbindungsstück, nicht mit „alles in eine Variable und dann senden\".\n\n## Skalieren heißt: mehr Prozesse\n\nEin Node-Prozess nutzt für JavaScript einen Kern. Auf einer Maschine mit acht Kernen liegen also sieben brach, solange nur ein Prozess läuft. Die Antwort der Plattform ist das `cluster`-Modul: Ein Master-Prozess startet pro Kern einen Worker, alle teilen sich denselben Port, der Master verteilt eingehende Verbindungen reihum.\n\n```js\nconst cluster = require('cluster');\nconst os = require('os');\n\nif (cluster.isMaster) {\n  os.cpus().forEach(() => cluster.fork());\n  cluster.on('exit', worker => {\n    console.log(`worker ${worker.process.pid} died, forking a new one`);\n    cluster.fork();\n  });\n} else {\n  require('./server');\n}\n```\n\n```mermaid\nflowchart TB\n  In[eingehende Verbindungen<br/>Port 3000] --> M[Master-Prozess<br/>Round-Robin-Verteilung]\n  M --> W1[Worker 1<br/>eigener Event-Loop]\n  M --> W2[Worker 2<br/>eigener Event-Loop]\n  M --> W3[Worker N<br/>eigener Event-Loop]\n```\n\nJeder Worker hat seinen eigenen Event-Loop und seinen eigenen Heap. Das hat zwei angenehme Nebenwirkungen und eine unbequeme Konsequenz. Angenehm: Ein abgestürzter Worker reißt nur sich selbst mit, der Master startet ihn neu; und ein blockierter Worker blockiert nur noch ein Achtel des Systems statt alles. Unbequem: Es gibt keinen gemeinsamen Speicher. Sessions, Caches, Zähler – alles, was bisher bequem in einer Modul-Variablen lag, muss raus aus dem Prozess, etwa in Redis oder die Datenbank. Aus meiner Sicht ist das kein Verlust, sondern ein früher Zwang zu einem Zustand, den man für horizontale Skalierung über Maschinen hinweg ohnehin braucht. Wer mit `cluster` auf acht Worker kommt, kommt später fast ohne Änderung auf acht Maschinen.\n\nWichtig ist die Einordnung: `cluster` mildert das Blockier-Problem, es löst es nicht. Die 50-ms-Rechnung vom Anfang gilt in jedem Worker unverändert. Für echte CPU-Arbeit bleibt die saubere Antwort ein eigener Prozess mit Queue davor – `cluster` ist Lastverteilung, kein Freibrief für Rechenarbeit im Request-Pfad.\n\n## async/await: neue Syntax, dasselbe Modell\n\nSeit wenigen Wochen – Node 7.6, erschienen im Februar 2017 – laufen `async`/`await` ohne Flag. Auf Node 6 LTS, das viele Produktionssysteme fahren, braucht es dafür weiterhin Babel. Nach Jahren von Callback-Pyramiden und Promise-Ketten liest sich derselbe Ablauf plötzlich wie synchroner Code:\n\n```js\nasync function loadDashboard(userId) {\n  const user = await fetchUser(userId);\n  const orders = await fetchOrders(user.id);\n  return { user, orders };\n}\n\n// independent calls: run them concurrently instead\nasync function loadDashboardFast(userId) {\n  const [user, orders] = await Promise.all([\n    fetchUser(userId),\n    fetchOrders(userId)\n  ]);\n  return { user, orders };\n}\n```\n\nSynchron ist daran nichts. An jedem `await` gibt die Funktion den Thread frei; der Event-Loop bedient andere Callbacks und setzt die Funktion fort, sobald das Promise aufgelöst ist. Genau deshalb gehören zwei Dinge in denselben Gedanken. Erstens: Zwei nacheinander stehende `await`-Zeilen laufen sequenziell, auch wenn sie unabhängig sind – für Parallelität bleibt `Promise.all` zuständig, wie im zweiten Beispiel. Zweitens, und das halte ich für die eigentliche Falle der neuen Syntax: `await` macht Wartezeiten kooperativ, aber CPU-Arbeit zwischen zwei `await`-Punkten blockiert exakt wie vorher. Ein `JSON.parse` über 30 MB wird nicht höflicher, weil davor ein `await` steht. Wer vom Callback über das Promise zu `async`/`await` gewechselt ist, hat deutlich besseren Code – und ein unverändertes Laufzeitmodell. Die Syntax ist neu, die Rechnung bleibt.\n\nLauffähige Beispiele zu all dem – Event-Loop, Streams, Express, Async-Pattern vom Callback bis zum Stream – sammle ich in einem öffentlichen Repository: [MikeBild/introduction-nodejs](https://github.com/MikeBild/introduction-nodejs). Die Beispiele sind bewusst klein gehalten; jedes zeigt genau eine der hier beschriebenen Grenzen.\n\n## Unterm Strich\n\nDer Event-Loop ist ein brauchbarer Architektur-Test, und zwar ein billiger. Für jedes geplante Feature lässt sich fragen: Wartet dieser Code, oder rechnet er? Wartende Arbeit – Datenbank, HTTP, Dateien, offene Verbindungen – passt in den Node-Prozess, in fast beliebiger Menge. Rechnende Arbeit gehört in eigene Prozesse, hinter eine Queue, mit `cluster` und `child_process` als Bordmitteln. Streams mit funktionierendem Backpressure halten den Speicher flach, wo große Datenmengen fließen.\n\nBemerkenswert finde ich, wie viel Architektur aus einer einzigen Laufzeit-Eigenschaft folgt. Die Trennung von Gateway und Bildverarbeitung, zustandslose Prozesse, Queues vor CPU-Arbeit – nichts davon steht in einem Framework-Handbuch, alles davon steckt in der Rechnung 200 × 50 ms. Plattformen erziehen ihre Systeme, ob man das will oder nicht. Bei Node ist die Erziehung streng, aber sie zeigt in eine Richtung, die sich auch jenseits von Node auszahlt: kleine Prozesse, die überwiegend warten, nichts Wichtiges im lokalen Speicher halten und ihre Datenflüsse begrenzen. Wer die 10 Sekunden vom Anfang einmal in einem echten Dashboard gesehen hat, vergisst diese Richtung nicht mehr.\n\n## Weiterführende Quellen\n\n- Beispiel-Repository „Introduction to NodeJS\": https://github.com/MikeBild/introduction-nodejs\n- The Node.js Event Loop, Timers, and process.nextTick(): https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick\n- Backpressuring in Streams: https://nodejs.org/en/learn/modules/backpressuring-in-streams\n- libuv Design Overview: https://docs.libuv.org/en/v1.x/design.html\n- Cluster-Modul (Node.js-Dokumentation): https://nodejs.org/api/cluster.html"},{"slug":"babel-node-express-http","title":"Babel Node Express: moderne Syntax, altes HTTP","summary":"Babel übersetzt Syntax, kein Ausführungsmodell – wer das trennt, weiß, warum babel-node in die Entwicklungsschleife gehört und nicht in den Betrieb.","tags":["Software","Node.js","Backend"],"published_at":"2016-12-20T10:00:00+00:00","updated_at":"2026-07-14T17:17:26.234673+00:00","url":"https://www.mikebild.dev/de/blog/babel-node-express-http/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/00ec79caf10ee98fa18e0fa86cada992ddffdf45f4e9abaa02fb3c24069157f2/babel-node-express-http-vorlesen.mp3","audio_duration_secs":769,"text":"# Babel Node Express: moderne Syntax, altes HTTP\n\n```text\n$ node src/server.js\n/app/src/server.js:1\n(function (exports, require, module, __filename, __dirname) { import express from 'express';\n                                                               ^^^^^^\nSyntaxError: Unexpected token import\n    at Object.exports.runInThisContext (vm.js:76:16)\n    at Module._compile (module.js:542:28)\n```\n\nSo quittiert Node 6 eine Datei, die mit einem `import` beginnt. Die Meldung ist kurz, die Verwirrung dahinter verlässlich: Dieselbe Zeile steht in gefühlt jedem zweiten Tutorial dieses Jahres, sie läuft im Frontend-Build ohne Murren – nur die Runtime, die den Server am Ende tatsächlich ausführen soll, kennt das Schlüsselwort nicht. ES2015 ist seit anderthalb Jahren verabschiedet, V8 hat einen großen Teil davon eingebaut, und trotzdem scheitert der Start an der ersten Zeile der ersten Datei.\n\nDer Anlass für diesen Beitrag ist ein kleines Repository, das ich Anfang Dezember zusammengesteckt habe: [babel-node-expressjs](https://github.com/MikeBild/babel-node-expressjs), eine bewusst schlichte Express-App, durchgehend in ES2015+ geschrieben und über Babel lauffähig gemacht. Am selben Tag entstand ein zweites, [babel-node-rx-ping-monitor](https://github.com/MikeBild/babel-node-rx-ping-monitor), das RxJS durch dieselbe Toolchain schiebt – das hat aber ein eigenes Thema und verdient einen eigenen Text. Hier geht es um die Trennlinie, die beim Aufsetzen sichtbar wurde und die in Diskussionen erstaunlich oft verwischt: Babel verändert die Sprache, nicht das Ausführungsmodell. Die Syntax ist 2016 modern geworden. Das HTTP darunter ist dasselbe geblieben.\n\n## Was Node 6 und 7 schon von selbst können\n\nBevor Babel ins Spiel kommt, lohnt ein nüchterner Blick darauf, was die Plattform ohne jedes Werkzeug beherrscht. Node 6 ist seit Oktober die aktive LTS-Linie, Node 7 die aktuelle Entwicklungslinie, und beide bringen über V8 bereits einen erheblichen Teil von ES2015 nativ mit:\n\n- Klassen samt `extends` und `super`\n- `const` und `let` mit echtem Block-Scoping\n- Template-Literals, Arrow Functions, Default-Parameter\n- Destructuring für Objekte und Arrays\n- Promises und Generatoren\n\nWer nachsehen will, was die eigene Node-Version im Detail abdeckt, findet auf node.green eine Matrix pro Version und Feature. Die Liste ist länger, als viele annehmen – und genau deshalb ist die pauschale Ansage „für ES2015 brauchst du Babel“ inzwischen schief. Für den Großteil der Sprache braucht man es schlicht nicht mehr.\n\nZwei prominente Lücken bleiben. `async`/`await` existiert in Node 7 nur experimentell hinter der Flagge `--harmony-async-await` – für Code, der einen Server tragen soll, ist das kein Fundament, sondern ein Ausblick. Und `import`/`export` fehlt vollständig: Die Modul-Syntax ist zwar Teil der Spezifikation, aber wie ein Loader in Node aussehen soll und wie ES-Module mit dem gewachsenen CommonJS-Ökosystem zusammenspielen, ist ungeklärt. Ein natives ES-Modulsystem ist nicht in Sicht. Wer heute `import` im Backend schreibt, schreibt Syntax für eine Runtime, die es so noch gar nicht gibt – und braucht deshalb einen Übersetzer.\n\n## Die App: Express 4 in heutiger Schreibweise\n\nDer Server im Repository tut absichtlich wenig – eine JSON-Route, eine Route für URL-encodierte Formulardaten, beides im README vorgeführt:\n\n```js\nimport express from 'express';\nimport bodyParser from 'body-parser';\n\nconst app = express();\nconst port = process.env.PORT || 3000;\n\napp.use(bodyParser.json());\napp.use(bodyParser.urlencoded({ extended: false }));\n\napp.get('/', (req, res) => {\n  res.json({ message: 'hello' });\n});\n\napp.post('/', (req, res) => {\n  const { msg } = req.body;\n  res.json({ received: msg });\n});\n\napp.listen(port, () => {\n  console.log(`server listening on port ${port}`);\n});\n```\n\nInteressant ist nicht, was dieser Code tut, sondern wie er aussieht: `import` statt `require`, Arrow Functions statt `function`, `const` statt `var`, Destructuring am Request-Body, Template-Literal im Log. Das ist die Schreibweise, die sich dieses Jahr im Frontend endgültig durchgesetzt hat, und es gibt keinen guten Grund, im Backend eine ältere Sprachstufe zu pflegen als im Rest des Projekts. Einheitliche Syntax senkt die Reibung, wenn dieselben Leute vormittags React-Komponenten und nachmittags Express-Routen anfassen.\n\nAm Rand sieht man hier auch ein Stück Express-4-Geschichte: Das Body-Parsing ist keine eingebaute Funktion mehr, sondern kommt als eigenes Paket `body-parser` dazu und wird pro Format als Middleware eingehängt. Wer von Express 3 kommt, stolpert zuerst genau an dieser Stelle – und das hat mit Babel nichts zu tun, mit HTTP dafür umso mehr.\n\nAuf der Leitung ändert sich dadurch exakt nichts. Ein POST auf `http://localhost:3000` mit einem URL-encodierten Formularfeld – im README steht der passende Einzeiler – schickt dieselben Bytes wie gegen jede Express-App der letzten drei Jahre, und die Antwort ist derselbe HTTP-Response mit denselben Headern. Middleware-Kette, Body-Parsing, Statuscodes, Content-Negotiation – das komplette HTTP-Verhalten ist von der Syntaxfrage unberührt. Das klingt banal. Es ist aber der Kern des Themas, denn genau hier entstehen die Missverständnisse.\n\n## Was babel-node im Entwicklungsmodus tut\n\nDamit die Datei überhaupt startet, braucht es zwei Dinge: eine Übersetzungsvorschrift und ein Werkzeug, das sie anwendet. Die Vorschrift steht in der `.babelrc`:\n\n```json\n{\n  \"presets\": [\"es2015\"]\n}\n```\n\nDas Preset `es2015` ist der etablierte Weg mit Babel 6. Ganz frisch gibt es daneben `babel-preset-env`, das über ein `targets`-Feld nur noch das transpiliert, was die Ziel-Node-Version tatsächlich nicht kann – dazu gleich mehr. Das Werkzeug für die Entwicklung heißt `babel-node` und liegt der `babel-cli` bei. Es startet einen normalen Node-Prozess, hängt sich in den `require`-Mechanismus ein und übersetzt jede geladene Datei im Speicher, unmittelbar bevor V8 sie sieht. Kein `dist/`-Ordner, kein Zwischenschritt, zusammen mit nodemon ergibt das eine angenehm kurze Schleife:\n\n```json\n{\n  \"scripts\": {\n    \"dev\": \"nodemon --exec babel-node src/server.js\",\n    \"build\": \"babel src --out-dir dist --source-maps\",\n    \"start\": \"node dist/server.js\"\n  }\n}\n```\n\nDatei speichern, nodemon startet neu, babel-node übersetzt, der Server läuft. Für die Entwicklung ist das genau richtig, weil die Rückmeldung zählt und der Prozess ohnehin ständig neu startet. Wichtig ist nur, sich klarzumachen, was da passiert: Die Übersetzung ist in den Programmstart hineingezogen worden. Jeder Start bezahlt sie erneut.\n\nDerselbe Mechanismus trägt übrigens auch die Tests: `babel-register`, in mocha eingehängt, übersetzt die Testdateien nach demselben Prinzip beim Laden. Auch das ist für die Entwicklungsschleife gedacht und dort völlig in Ordnung – ein Testlauf ist ein kurzlebiger Prozess, bei dem ein paar hundert Millisekunden Anlauf nicht ins Gewicht fallen. Die Grenze verläuft also nicht zwischen „mit Babel“ und „ohne Babel“, sondern zwischen kurzlebigen Werkzeugprozessen und dem einen Prozess, der lange laufen und nach einem Absturz schnell wieder da sein muss.\n\n```mermaid\nflowchart LR\n  subgraph DEV[Entwicklung]\n    S1[src/server.js<br/>ES2015+] --> BN[babel-node<br/>übersetzt im Speicher] --> P1[Node-Prozess]\n  end\n  subgraph PROD[Produktion]\n    S2[src/server.js<br/>ES2015+] --> BC[npm run build<br/>babel-cli] --> D[dist/server.js<br/>ES5 + CommonJS] --> P2[node dist/server.js]\n  end\n```\n\n## Was der bequeme Weg in Produktion kostet\n\nDie Versuchung liegt nahe, `babel-node` einfach auch in Produktion zu verwenden – ein Skript weniger, ein Ordner weniger, dieselbe Kommandozeile überall. Die Babel-Dokumentation rät selbst ausdrücklich davon ab, und die Gründe sind handfest:\n\n- Der Prozessstart wird spürbar langsamer, weil jede Datei beim Hochfahren erst durch den Transpiler muss – bei jedem Deployment, bei jedem Neustart nach einem Absturz, bei jedem Prozess im Cluster erneut.\n- Der Speicherverbrauch steigt, weil Babel samt Parser, Presets und Cache dauerhaft im Serverprozess mitläuft, obwohl er nach dem Start nichts mehr beiträgt.\n- Stacktraces zeigen auf übersetzten Code, den nie jemand geschrieben hat; ohne sauber verdrahtete Sourcemaps zeigt die Zeilennummer im Fehlerreport ins Leere.\n- Der Debugger arbeitet gegen den erzeugten Code, Breakpoints landen neben der gemeinten Zeile, und das Verhalten hängt an der installierten Babel-Version auf der Zielmaschine.\n\nMir ist der Punkt in einem Projekt dieses Jahr konkret begegnet: Ein Kollege hatte einen kleinen internen Service mit `babel-node` deployt, und wochenlang fiel das niemandem auf. Aufgefallen ist es erst, als der Prozess unter Last neu startete und der Supervisor ihn mehrfach hintereinander hochzog – jeder Neustart mehrere Sekunden Transpilierung, in denen der Port zwar offen, der Service aber nicht ansprechbar war. Der Fehler stand in keiner Zeile Anwendungscode. Er steckte in der Entscheidung, ein Entwicklungswerkzeug in den Betrieb mitzunehmen.\n\n## Erst bauen, dann starten\n\nDie erwachsene Variante trennt die Übersetzung vom Start. `npm run build` ruft die `babel-cli` auf, übersetzt `src/` einmal nach `dist/` und legt Sourcemaps daneben. Was dort ankommt, ist unspektakuläres ES5 mit CommonJS – aus dem `import` am Dateianfang wird genau das, was Node seit Jahren versteht:\n\n```js\n'use strict';\n\nvar _express = require('express');\n\nvar _express2 = _interopRequireDefault(_express);\n\nfunction _interopRequireDefault(obj) {\n  return obj && obj.__esModule ? obj : { default: obj };\n}\n```\n\nMan muss diesen erzeugten Code nicht schön finden, aber man sollte ihn einmal gelesen haben. Er nimmt der Modul-Syntax jede Magie: `import` ist nach der Übersetzung ein synchrones `require` beim Programmstart, mit einer kleinen Verpackung für den Default-Export. Keine neue Ladelogik, kein anderes Auflösungsverhalten, kein Unterschied zur Laufzeit.\n\nDas Artefakt in `dist/` hat zwei angenehme Eigenschaften. Erstens braucht die Zielmaschine kein Babel mehr – `node dist/server.js` genügt, die devDependencies bleiben zu Hause. Zweitens ist der Startpfad wieder der langweilige, den Node seit Jahren geht: Prozess starten, Module laden, Port öffnen. Die Übersetzung passiert einmal pro Build statt einmal pro Start, und genau dort gehört sie hin. Dass zwischen „es läuft auf meiner Maschine per babel-node“ und „es läuft als gebautes Artefakt“ ein Unterschied besteht, merkt man sonst erst im unpassendsten Moment.\n\nPraktisch folgt daraus auch, wo gebaut wird: in der Pipeline, nicht auf der Zielmaschine. Gebaut und getestet wird einmal, das Ergebnis wird ausgeliefert, und eine Installation ohne devDependencies lässt Babel samt Presets auf dem Server komplett weg. Die Reihenfolge im README des Repos – build, test, dann erst run – ist insofern keine Formsache, sondern die Kurzfassung dieses Betriebsmodells.\n\n## Sprache oben, Ausführungsmodell unten\n\nDamit zur eigentlichen Trennlinie. Babel arbeitet ausschließlich auf der Ebene der Sprache: Es liest Syntax, die V8 noch nicht versteht, und schreibt sie in Syntax um, die V8 versteht. Alles darunter bleibt unangetastet – der Event Loop, das Modulsystem zur Laufzeit, Streams, Sockets, das komplette HTTP.\n\n```mermaid\nflowchart TB\n  B[Babel] -->|verändert nur diese Ebene| L1\n  L1[Sprache: import, Klassen,<br/>Arrow Functions, Destructuring] --> L2[Laufzeitumgebung: Event Loop,<br/>CommonJS-require, Streams]\n  L2 --> L3[HTTP: Verbindungen, Header,<br/>Request und Response]\n```\n\nDiese Trennung klingt selbstverständlich, wird aber regelmäßig verwischt, sobald die neue Syntax nach mehr aussieht, als sie ist. Drei Beispiele aus Gesprächen der letzten Monate. Ein `import` oben in der Datei wirkt deklarativ und statisch – nach der Übersetzung ist es ein gewöhnliches `require`, das beim Start synchron ausgeführt wird, in der Reihenfolge der Zeilen. Eine Arrow Function in der Route wirkt leichtgewichtig – sie läuft auf demselben einen Thread wie alles andere, und eine teure JSON-Serialisierung darin blockiert den Event Loop exakt so wie 2013. Und die `async`-Vorboten aus Node 7 wirken wie Concurrency – tatsächlich sind sie nur eine andere Schreibweise für Promise-Ketten, an Timeouts, Fehlerpfaden und Lastverhalten ändern sie nichts. Wer einen Express-Server mit moderner Syntax betreibt, betreibt denselben Server. Die Fragen, die im Betrieb zählen – wie lange darf ein Request dauern, was passiert bei einem abgebrochenen Body, wer beendet den Prozess sauber –, beantwortet kein Preset.\n\nUmgekehrt hilft die Trennung auch beim Sparen. Weil Node 6 den Großteil von ES2015 nativ ausführt, muss Babel gar nicht mehr die ganze Sprache übersetzen. Genau da setzt das frische `babel-preset-env` an: Man nennt ihm die Zielumgebung, und es wählt nur die Transformationen aus, die dort wirklich fehlen.\n\n```json\n{\n  \"presets\": [\n    [\"env\", { \"targets\": { \"node\": 6 } }]\n  ]\n}\n```\n\nFür Node 6 bleibt davon im Wesentlichen die Modul-Syntax übrig. Der erzeugte Code bleibt näher am Original, Klassen bleiben echte V8-Klassen statt ES5-Prototypen-Nachbauten, und der Build wird schneller. Je besser die Plattform wird, desto dünner darf die Übersetzungsschicht sein – bis sie eines Tages vielleicht ganz verschwindet. Darauf wetten würde ich beim Thema Module allerdings noch nicht.\n\n## Was ich daraus mitnehme\n\nDie beiden kleinen Repos waren an einem Nachmittag zusammengebaut, aber die Trennlinie, die sie zeigen, trägt weiter als der Code. Babel ist ein Übersetzer für Syntax, kein neues Ausführungsmodell – und beide Richtungen dieser Aussage sind nützlich. In die eine Richtung: Die moderne Schreibweise kostet im Betrieb nichts, wenn man sie im Build bezahlt statt beim Prozessstart; `babel-node` gehört in die Entwicklungsschleife, `node dist/` in die Produktion. In die andere Richtung: Die moderne Schreibweise löst auch nichts, was auf der HTTP-Ebene zu lösen wäre. Timeouts, Error Handling, Body-Grenzen und Neustartverhalten sehen in ES2015 exakt so aus wie in ES5, nur hübscher.\n\nWenn im nächsten Jahr `async`/`await` ohne Flagge ankommt, wird sich dieselbe Frage neu stellen, mit denselben Antworten. Die Syntax wird wieder ein Stück moderner. Das HTTP darunter bleibt das alte – und das ist keine schlechte Nachricht, denn es bedeutet, dass sich zwanzig Jahre Wissen über Server nicht mit jedem Preset erneuern müssen.\n\n## Weiterführende Quellen\n\n- Repository: https://github.com/MikeBild/babel-node-expressjs\n- Express-Dokumentation: https://expressjs.com/\n- babel-preset-env: https://github.com/babel/babel-preset-env\n- babel-node in der Babel-Dokumentation: https://babeljs.io/docs/babel-node"},{"slug":"api-denken-vor-framework-denken","title":"API-Denken vor Framework-Denken","summary":"Wer Operationen, Daten und Fehlerfälle vor der Framework-Wahl festlegt, kann das Frontend später fast beliebig tauschen – umgekehrt bekommt man eine API, die ein Framework nachbildet.","tags":["Software","GraphQL","API"],"published_at":"2016-12-12T10:00:00+00:00","updated_at":"2026-07-14T16:37:07.719359+00:00","url":"https://www.mikebild.dev/de/blog/api-denken-vor-framework-denken/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/0bacf706b5df62ef57f675eb7df31f0b18e3c014f935815ef531087b586bceb6/api-denken-vor-framework-denken-vorlesen.mp3","audio_duration_secs":752,"text":"# API-Denken vor Framework-Denken\n\n„Angular oder React?\" gilt in diesem Jahr als die Architekturfrage für neue Web-Projekte. Sie ist keine. Sie ist eine Werkzeugfrage – und sie wird regelmäßig gestellt, bevor jemand die Frage beantwortet hat, die tatsächlich Architektur ist: Was sind eigentlich unsere Operationen und unsere Daten? Diese Reihenfolge ist verkehrt herum, und sie ist gerade besonders verführerisch. 2016 ist das Jahr der Framework-Debatten: Angular 2 ist seit September final, Vue 2 erschien Ende September, React steht bei Version 15, und die Vergleichsartikel dazu füllen Konferenzprogramme, Blogs und Team-Meetings.\n\nDer Streit ist unterhaltsam, aber er verdeckt etwas Einfaches: Das Framework rendert nur, was eine API hergibt. Wer die API zuerst entwirft – Ressourcen oder Schema, Namen aus der Fachsprache, Fehlerfälle, Berechtigungen –, kann das Frontend danach fast frei wählen und später sogar wechseln. Wer das Framework zuerst wählt, bekommt mit hoher Wahrscheinlichkeit eine API, die den Datenfluss genau dieses Frameworks nachbildet. Und diese API bleibt, wenn das Framework längst abgelöst ist.\n\n## Was eine API festlegt – und was ein Framework\n\nEin Frontend-Framework entscheidet, wie Komponenten gerendert werden, wie Zustand im Client verwaltet wird und wie sich das Team organisiert, das die Oberfläche baut. Das ist nicht wenig, aber es ist austauschbar. Migrationsgeschichten von Backbone nach Angular oder von Angular 1 nach React gibt es genug – in diesem Jahr, mit dem harten Schnitt zwischen Angular 1 und Angular 2, mehr denn je. Ein Framework-Wechsel ist teuer, aber er ist ein lokales Event: Er betrifft den Client und sonst niemanden.\n\nEine API legt etwas anderes fest, und zwar für alle Beteiligten gleichzeitig:\n\n- welche Operationen es gibt und wie sie heißen,\n- welche Daten zusammengehören und welche bewusst getrennt bleiben,\n- welche Fehlerfälle fachlich erwartbar sind und wie sie aussehen,\n- wer welche Operation auslösen darf.\n\nDiese vier Punkte überleben jede Framework-Mode. Sie bilden den Vertrag zwischen Fachseite, Backend und sämtlichen Clients – dem Web-Frontend von heute, der Mobile-App vom nächsten Jahr, dem Partner-System von übermorgen. Ein Vertrag, der sich ändert, zieht Änderungen bei allen Vertragspartnern nach sich. Genau deshalb gehört er an den Anfang, nicht ans Ende.\n\nDer vierte Punkt verdient dabei einen zweiten Blick, weil er beim Framework-zuerst-Vorgehen am zuverlässigsten unter die Räder kommt. Berechtigungen wirken aus Sicht der Oberfläche wie eine Anzeige-Angelegenheit: Der Button wird ausgeblendet, das Menü gekürzt, fertig. Aus Sicht der API sind sie eine Zusicherung – die Aussage, dass eine Operation von einem bestimmten Aufrufer schlicht nicht ausgelöst werden kann, egal welcher Client anklopft. Diese Zusicherung lässt sich nur im Vertrag geben, nicht in der Komponente. Wer sie beim Entwurf ausspart, holt sie später unter Zeitdruck nach, meist nachdem der erste Aufruf aufgetaucht ist, den kein Button je ausgelöst hat.\n\n## Die falsche Reihenfolge im Projektalltag\n\nWie sich die verkehrte Reihenfolge anfühlt, habe ich in diesem Frühjahr in einem Projekt erlebt: Bestellverwaltung für einen Handelskunden, Web-Oberfläche zuerst, Mobile „irgendwann später\". Die ersten drei Wochen gingen in die Framework-Evaluation – Angular 2 war noch Release Candidate, React mit Redux die Alternative, dazu die üblichen Nebenkriegsschauplätze um TypeScript und JSX. Die API stand auf keiner Agenda. „Machen wir, wenn das Frontend steht\", hieß es, und das war ehrlich gemeint.\n\nAls das Frontend stand, gab es dann auch eine API. Sie hatte einen Endpoint `/api/app-state`, der beim Laden den kompletten Redux-Store befüllte. Sie hatte Feldnamen, die aus den Reducern stammten, nicht aus der Fachsprache. Fehler kamen als HTTP 500 mit einem Text, weil der Client ohnehin nur eine rote Meldung anzeigte. Berechtigungen wurden im Frontend geprüft, weil das dort gerade bequemer war. Nichts davon war böser Wille – jede einzelne Entscheidung war aus Sicht des Frontends vernünftig. In Summe war die API ein Abdruck des Redux-Datenflusses.\n\nEin halbes Jahr später kam das Mobile-Team. Es konnte mit `/api/app-state` nichts anfangen, brauchte kleinere Zuschnitte, andere Error Handling, echte Autorisierung auf dem Server. Das Backend bekam eine zweite API neben der ersten, und ab da wurden beide gepflegt. Die Framework-Frage war damals übrigens zugunsten von React entschieden worden – es hätte für das Ergebnis keinen Unterschied gemacht, wenn es Angular geworden wäre. Die teure Entscheidung war nicht das Framework. Es war die Reihenfolge.\n\n## Ressourcen zuerst: ein Beispiel\n\nWie sieht die andere Reihenfolge aus? Sie beginnt mit einem Gespräch, in dem kein Framework vorkommt. Mit der Fachseite am Tisch werden die Begriffe eingesammelt, die dort ohnehin täglich fallen: Bestellung, Kunde, Kreditlimit, Stornierung. Aus diesen Begriffen entsteht ein erster Ressourcen-Entwurf – hier als REST-Routen notiert:\n\n```http\nGET    /customers/{id}\nGET    /customers/{id}/orders\nPOST   /orders\nGET    /orders/{id}\nPOST   /orders/{id}/cancellation\n```\n\nZwei Dinge an diesem Entwurf sind wichtiger, als sie aussehen. Erstens: `POST /orders/{id}/cancellation` statt `DELETE /orders/{id}`. Eine Stornierung ist im Handel keine Löschung, sondern ein fachlicher Vorgang mit eigenen Regeln – sie kann abgelehnt werden, sie kann Gebühren auslösen, sie taucht in der Historie auf. Der Endpoint trägt den Namen aus der Fachsprache, nicht den aus dem HTTP-Lehrbuch. Zweitens: Der Fehlerfall ist Teil des Vertrags. Wenn eine Bestellung das Kreditlimit überschreitet, ist das kein Serverfehler, sondern eine erwartbare fachliche Antwort:\n\n```json\n{\n  \"order\": null,\n  \"failure\": {\n    \"code\": \"CREDIT_LIMIT_EXCEEDED\",\n    \"message\": \"Customer credit limit exceeded\",\n    \"creditLimit\": 5000,\n    \"openAmount\": 5240\n  }\n}\n```\n\nDieses JSON kann man ausdrucken und mit der Fachseite besprechen. „Stimmt der Fehlercode? Fehlt ein Feld? Darf der Außendienst das überhaupt sehen?\" – solche Fragen lassen sich an einem Vertragsdokument klären, lange bevor eine Zeile Frontend existiert. An einem Redux-Reducer oder einem Angular-Service lassen sie sich nicht klären, weil die Fachseite ihn nie zu Gesicht bekommt.\n\n## Dasselbe als Schema gedacht\n\nSeit anderthalb Jahren gibt es für diese Vertragsarbeit ein zweites Vokabular: GraphQL, im Sommer 2015 von Facebook als Spezifikation samt Referenzimplementierung veröffentlicht. Mit Relay gab es von Anfang an einen Client, seit Mitte dieses Jahres arbeitet das Meteor-Team mit Apollo an einer zweiten, bewusst framework-neutralen Client-Familie. Was mich an GraphQL für den Entwurfsschritt interessiert, ist weniger der Query-Mechanismus als die Schema-Kurznotation. Sie zwingt dazu, genau die Dinge aufzuschreiben, um die es beim API-Entwurf geht:\n\n```graphql\ntype Order {\n  id: ID!\n  customer: Customer!\n  items: [OrderItem!]!\n  status: OrderStatus!\n}\n\nenum OrderStatus {\n  PLACED\n  APPROVED\n  REJECTED\n  CANCELLED\n}\n\ntype OrderFailure {\n  code: String!\n  message: String!\n}\n\ntype PlaceOrderResult {\n  order: Order\n  failure: OrderFailure\n}\n\ntype Mutation {\n  placeOrder(input: PlaceOrderInput!): PlaceOrderResult\n  cancelOrder(id: ID!): CancelOrderResult\n}\n```\n\n`placeOrder` und `cancelOrder` heißen wie die Vorgänge, über die die Fachseite spricht. `PlaceOrderResult` macht sichtbar, dass eine Bestellung fachlich scheitern kann, ohne dass technisch etwas kaputt ist – dieselbe Unterscheidung wie im JSON-Beispiel oben, nur diesmal im Typsystem verankert. Ob am Ende REST-Ressourcen oder ein GraphQL-Schema den Vertrag bilden, ist für die Reihenfolge zweitrangig. Beides sind Notationen für dieselbe Denkarbeit: Operationen, Daten, Fehlerfälle, Berechtigungen – vor dem Framework.\n\n```mermaid\nflowchart TD\n  subgraph FF[\"Framework zuerst\"]\n    F1[Framework-Wahl] --> F2[Datenfluss des<br/>Frameworks] --> F3[API bildet den<br/>Datenfluss nach] --> F4[Jeder neue Client<br/>braucht eine neue API]\n  end\n  subgraph AF[\"API zuerst\"]\n    A1[Fachsprache:<br/>Operationen und Daten] --> A2[API-Vertrag:<br/>Ressourcen oder Schema] --> A3[Frontend frei wählbar:<br/>Angular, React, Vue]\n  end\n```\n\n## Woran man eine framework-geformte API erkennt\n\nDie verkehrte Reihenfolge hinterlässt Spuren, die sich in Code-Reviews zuverlässig wiederfinden. Ein paar Signale, auf die ich inzwischen achte:\n\n- Endpoints, die exakt einer Bildschirmseite entsprechen (`/api/dashboard`, `/api/app-state`)\n- Feldnamen aus dem Client-Store statt aus der Fachsprache\n- Fehler ausschließlich als HTTP 500 mit Freitext, weil der Client ohnehin nur eine Meldung einblendet\n- Berechtigungsprüfungen, die nur im Frontend existieren\n- Datenformate, die die interne Struktur einer bestimmten Client-Bibliothek voraussetzen\n\nJedes dieser Signale ist für sich genommen harmlos und im Moment seiner Entstehung sogar effizient. Zusammen bedeuten sie: Diese API hat genau einen legitimen Konsumenten, und der ist zufällig das Framework, das gerade im Projekt steckt. Der zweite Konsument – Mobile-App, Partner-Anbindung, Batch-Import – zahlt dann den vollen Preis.\n\n## Backend-for-Frontend als ehrlicher Kompromiss\n\nNun wäre es unehrlich, so zu tun, als hätten alle Clients dieselben Bedürfnisse. Ein Web-Frontend will oft breite, verschachtelte Antworten; eine Mobile-App über Mobilfunk will kleine, vorverdichtete Zuschnitte. Wer beiden dasselbe generische Format aufzwingt, macht beide langsam. Für dieses Spannungsfeld hat sich seit dem letzten Jahr ein Muster mit eigenem Namen etabliert: Backend for Frontend, kurz BFF. Sam Newman hat es beschrieben, SoundCloud hat es in der eigenen Architektur vorgemacht – pro Client-Typ eine dünne, dem jeweiligen Frontend gewidmete Schicht, dahinter die eine fachliche API.\n\n```mermaid\nflowchart LR\n  Web[Web-Client<br/>React oder Angular] --> BW[BFF Web]\n  Mob[Mobile-Client<br/>iOS und Android] --> BM[BFF Mobile]\n  BW --> API[Fachliche API:<br/>Orders, Customers]\n  BM --> API\n```\n\nDer Punkt, der das Muster für mich ehrlich macht: Die Kopplung an den Client wird nicht geleugnet, sondern benannt und eingezäunt. Das BFF darf client-spezifisch sein, darf Antworten zuschneiden, darf sogar dem Datenfluss eines Frameworks schmeicheln – es ist die einzige Stelle im System, an der das erlaubt ist. Die fachliche API dahinter bleibt sauber und framework-frei. Verwechselt man beide Ebenen, landet man wieder beim `/api/app-state`-Endpoint, nur diesmal mit gutem Gewissen.\n\nWichtig ist außerdem, wem so eine Schicht gehört. Bei SoundCloud pflegt das jeweilige Client-Team sein BFF selbst – das Web-Team seins, das Mobile-Team seins. Das passt zur Logik des Musters: Wer den Zuschnitt braucht, verantwortet ihn auch, und die fachliche API dahinter muss nicht jede Client-Laune mitverhandeln. Übernimmt stattdessen das Backend-Team alle BFFs, wird aus dem Kompromiss schnell wieder ein Flaschenhals, und der Druck wächst, die Zuschnitte doch in die fachliche API zu drücken.\n\n## Vertragsarbeit ist Arbeit mit der Fachseite\n\nOb Ressourcen oder Schema: Der Entwurf taugt nur so viel wie das Gespräch, aus dem er stammt. Für REST-APIs hat sich Swagger als Beschreibungsformat durchgesetzt – offiziell heißt die Spezifikation seit der Gründung der OpenAPI Initiative Ende letzten Jahres OpenAPI, im Alltag sagt weiterhin jeder Swagger. Der eigentliche Wert liegt nicht im Tooling, sondern darin, dass es ein Dokument gibt, das in einem Review neben den Fachkonzepten liegen kann. Ein GraphQL-Schema leistet dasselbe, in kompakterer Form.\n\nIch habe dieses Jahr selbst in diese Richtung experimentiert: graphql-pouch, ein kleines Open-Source-Projekt, das aus einer GraphQL-Schema-Notation eine lauffähige API über PouchDB erzeugt, inklusive CouchDB-Synchronisation. Der Ansatz dort ist bewusst „Frontend-First\" – ohne Backend-Migrationen schnell zu einem typisierten Datenmodell kommen. Interessanterweise bestätigt gerade dieses Experiment die These: Auch wenn die Runtime dem Frontend entgegenkommt, ist das erste Artefakt die Schema-Notation. Man schreibt Typen und Operationen auf, bevor irgendetwas rendert. Das Schema ist der Vertrag; alles andere ist Erzeugnis.\n\nDie Vertragsarbeit selbst bleibt dabei unbequem, und das ist gut so. Wenn die Fachseite beim Namen `cancelOrder` die Stirn runzelt, weil im Haus niemand von „Stornierung\", sondern alle von „Rückabwicklung\" sprechen, dann ist das genau die Diskussion, die man im Dezember führen will – nicht im Juni, wenn drei Clients den Namen bereits fest verdrahtet haben.\n\n## Unterm Strich\n\nDie Framework-Frage darf Spaß machen, und die Antworten dieses Jahres – Angular 2, React 15, Vue 2 – sind allesamt vertretbar. Aber sie ist die zweite Frage. Die erste lautet: Welche Operationen gibt es, wie heißen sie in der Fachsprache, welche Fehlerfälle sind erwartbar, wer darf was? Wer diese Frage in einem Vertragsdokument beantwortet – als Ressourcen-Entwurf oder als Schema –, macht die Framework-Wahl beinahe risikofrei und den späteren Wechsel bezahlbar. Wer sie überspringt, wählt das Framework trotzdem – nur dass es dann heimlich die API mitentwirft, und dieser Teil der Entscheidung lässt sich später nicht mehr per npm install korrigieren.\n\n## Weiterführende Quellen\n\n- [graphql-pouch auf GitHub](https://github.com/MikeBild/graphql-pouch)\n- [GraphQL – Introduction und Schema-Dokumentation](https://graphql.org/learn/)\n- [Sam Newman: Pattern – Backends For Frontends](https://samnewman.io/patterns/architectural/bff/)\n- [Phil Calçado: The Back-end for Front-end Pattern (BFF) bei SoundCloud](https://philcalcado.com/2015/09/18/the_back_end_for_front_end_pattern_bff.html)\n- [OpenAPI Initiative](https://www.openapis.org/)"},{"slug":"tyrian-rx-react-ui-datenfluss","title":"Tyrian Rx React: UI als Datenfluss","summary":"Ein Nachbau der Elm-Architektur mit RxJS und React – Actions als Strom, scan() faltet das Model, Side Effects bleiben am Rand – und was dieser Proof of Concept über gut alternde UI-Architektur verrät.","tags":["Software","Reactive"],"published_at":"2016-10-12T10:00:00+00:00","updated_at":"2026-07-14T16:35:20.035699+00:00","url":"https://www.mikebild.dev/de/blog/tyrian-rx-react-ui-datenfluss/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/8e7ccdd7685d30b639110526bce8b1d0ddb9a8d315e0a5635afba923104e0cdd/tyrian-rx-react-ui-datenfluss-vorlesen.mp3","audio_duration_secs":792,"text":"# Tyrian Rx React: UI als Datenfluss\n\nWenn in einer Oberfläche jede Komponente ihren eigenen State mutiert, verliert das System als Erstes die Zeitachse. Ein Klick hier, ein Callback dort, ein `setState` irgendwo dazwischen – und drei Wochen später kann niemand mehr sagen, **wann** der State gekippt ist, der den Fehler ausgelöst hat. Der Debugger zeigt das Ergebnis, aber nicht die Geschichte. Man sieht: `count` ist 7, obwohl er 3 sein müsste. Man sieht nicht: welche Folge von Events aus der 3 eine 7 gemacht hat, in welcher Reihenfolge zwei Antworten vom Server eintrafen, ob der Benutzer schneller geklickt hat als das Netzwerk geantwortet.\n\nGenau gegen diesen Verlust der Geschichte ist die Elm-Architektur gebaut. Und weil man eine Architektur erst versteht, wenn man sie einmal selbst nachgebaut hat, habe ich das in den letzten Wochen gemacht: die Elm-Architektur mit RxJS und React – als Proof of Concept, nicht als Framework. Das Repository liegt unter [github.com/MikeBild/reactish-elmish](https://github.com/MikeBild/reactish-elmish), und schon das README warnt in der zweiten Zeile: *Don't use in production!* Diese Warnung ist ernst gemeint, und ich komme am Ende darauf zurück. Der Wert des Experiments liegt woanders.\n\n## Die Elm-Architektur, auf das Skelett reduziert\n\nElm – seit Version 0.17 vom Mai dieses Jahres ohne Signals, dafür mit Subscriptions – zwingt jede Anwendung in dieselbe Form:\n\n- ein Model, das den gesamten State der Anwendung als einen Wert hält\n- eine `update`-Funktion, die aus altem Model und einer Message ein neues Model errechnet\n- eine `view`-Funktion, die aus dem Model die Darstellung erzeugt\n- Subscriptions für alles, was von außen hereinkommt: Timer, WebSockets, Browser-Events\n\nKein Teil davon ist optional, kein Teil kennt einen Seitenausgang. Der Compiler lässt schlicht nichts anderes zu. State entsteht nirgendwo nebenbei; er entsteht ausschließlich dadurch, dass `update` eine Message verarbeitet. Damit ist die Geschichte des States identisch mit der Liste der Messages – und die Frage „wann ist das passiert?\" hat wieder eine Antwort: bei Message Nummer 34, zwischen 33 und 35.\n\nDas Bemerkenswerte an dieser Form ist, wie wenig sie mit der Sprache Elm zu tun hat. Model, Update, View – das ist eine Faltung über eine Event-Folge plus eine Projection. Beides sind Konzepte, die es in jeder Sprache gibt. Die Frage, die mich nicht losließ: Wie viel von der Klarheit bleibt übrig, wenn man den Compiler weglässt und die Form freiwillig in JavaScript nachbaut?\n\n## Der Nachbau: Actions als Strom\n\nDer Kern des Nachbaus passt in wenige Zeilen. Jede Benutzerinteraktion wird zu einem Wert auf einem Strom, und eine einzige `update`-Funktion faltet diesen Strom zum Model. Im Repository steckt das hinter einer kleinen API aus `init`, `update`, `view` und `subscriptions` – hier die Rohform ohne Verpackung:\n\n```javascript\nimport Rx from 'rx'\n\n// every user interaction becomes a value on this stream\nconst actions$ = new Rx.Subject()\n\nfunction update (model, action) {\n  switch (action.type) {\n    case 'INCREMENT':\n      return { count: model.count + 1 }\n    case 'DECREMENT':\n      return { count: model.count - 1 }\n    case 'TICK':\n      return { count: model.count + action.step }\n    default:\n      return model\n  }\n}\n\n// fold the action history into the current model\nconst model$ = actions$\n  .startWith({ type: 'INIT' })\n  .scan(update, { count: 0 })\n```\n\nEine Randnotiz zur Wahl der Bibliothek: Der Prototyp benutzt das klassische `rx`-Paket, also Version 4 mit `onNext` und `dispose`. RxJS 5 steht als Release Candidate bereit und wird die Namen an die ES-Observable-Vorschläge angleichen (`next`, `unsubscribe`), aber für ein Experiment wollte ich die stabile Version, deren Operator-Dokumentation vollständig ist. Am Konzept ändert das nichts – Methodenketten auf Observables funktionieren in beiden Welten gleich.\n\nDer entscheidende Operator ist `scan`. Wer `reduce` über Arrays kennt, kennt `scan` fast: Es ist dieselbe Faltung, nur dass sie nicht auf ein fertiges Array wartet, sondern bei jedem neuen Element den Zwischenstand herausgibt. `reduce` faltet eine abgeschlossene Liste zu einem Endwert. `scan` faltet eine Liste, die nie endet – und genau das ist eine Benutzeroberfläche: eine Event-Folge ohne Schlusspunkt. Das Model zu einem Zeitpunkt ist nichts anderes als die Faltung aller Actions bis zu diesem Zeitpunkt. Diese eine Einsicht ist der halbe Artikel: **State ist der linke Fold über die Ereignisgeschichte.**\n\n## React rendert nur noch\n\nReact kommt in diesem Aufbau eine bewusst langweilige Rolle zu: Es projiziert das aktuelle Model in den DOM und meldet Interaktionen zurück auf den Action-Strom. Kein Fetch in `componentDidMount`, keine State-Logik in Handlern – die Komponente folgt dem `model$`-Strom und sonst nichts:\n\n```javascript\nimport React from 'react'\n\nclass CounterView extends React.Component {\n  componentDidMount () {\n    // the view follows the model stream, nothing else\n    this.subscription = model$.subscribe(model => this.setState({ model }))\n  }\n\n  componentWillUnmount () {\n    this.subscription.dispose()\n  }\n\n  render () {\n    if (!this.state) return null\n    const { count } = this.state.model\n    return (\n      <div>\n        <span>{count}</span>\n        <button onClick={() => actions$.onNext({ type: 'INCREMENT' })}>+</button>\n        <button onClick={() => actions$.onNext({ type: 'DECREMENT' })}>-</button>\n      </div>\n    )\n  }\n}\n```\n\nDass hier trotzdem `setState` auftaucht, ist kein Stilbruch, sondern der Übergabepunkt: Irgendwo muss der Strom in Reacts Render-Zyklus münden, und `setState` ist in React 15 nun einmal die Tür. Wichtig ist, dass es die **einzige** Stelle ist, an der `setState` vorkommt – als Brücke, nicht als State Management. Im Repository ist diese Brücke in zwei Varianten verpackt, einmal als Higher-Order-Component `withElmish` für die Komposition mit zustandslosen Functional Components, einmal als Basisklasse `ReactComponent`, von der man erbt und `init`, `update`, `view` überschreibt.\n\nAn dieser Stelle weicht der Nachbau übrigens bewusst von Elm und von Redux ab: Er hält den State pro Komponente statt in einem zentralen Store. Jede „smarte\" Komponente bekommt ihre eigene kleine Elm-Schleife, „dumme\" Komponenten bleiben zustandslose Funktionen. Ob das eine gute Idee ist, hängt vom Zuschnitt ab – für die Beispielsammlung im Repository (Counter, Formular-Validierung, optimistische UI-Updates, ein Bestellprozess als lang laufender Saga-Strom) war es angenehm, weil jedes Beispiel für sich lebt. Für eine große Anwendung würde ich die Frage neu stellen.\n\n## Side Effects als Ströme am Rand\n\nDer interessanteste Teil ist der, den Counter-Beispiele gern unterschlagen: Was passiert mit allem, das nicht aus einem Klick entsteht? Timer, HTTP-Antworten, Server-Push. In Elm sind das Subscriptions und Tasks. Im Nachbau sind es – natürlich – Observables. Die `subscriptions`-Funktion bekommt ein Kommando aus `init` oder `update` und antwortet mit einem Strom, dessen Werte wieder als Messages in `update` landen:\n\n```javascript\nconst stop$ = actions$.filter(action => action.type === 'INTERVAL_STOP')\n\nfunction subscriptions (cmd) {\n  switch (cmd) {\n    case 'INTERVAL_START':\n      return Rx.Observable\n        .interval(1000)\n        .map(() => ({ type: 'TICK', step: 1 }))\n        .takeUntil(stop$)\n  }\n}\n```\n\nDie Laufzeitschicht abonniert diesen Strom selbst; die Komponente ruft nirgendwo `subscribe` auf. Damit bleiben Side Effects dort, wo sie hingehören: am Rand, als eigene Ströme, die man mit denselben Operatoren komponiert wie alles andere. `takeUntil` beendet den Timer deklarativ, `merge` fasst mehrere Quellen zusammen, `filter` zerlegt einen Strom in Teilströme. Der Lifecycle einer Side Effect ist keine Sonderlogik mehr, sondern ein Datenfluss-Ausdruck.\n\nHier zeigt sich auch ein echter Unterschied zu Elm, kein nachgebauter: Elm-Subscriptions kennen nur *next* – es kommt die nächste Message, sonst nichts. Fehler und Abschluss laufen als gewöhnliche Message-Inhalte durch `update`, und für verkettete asynchrone Effects gibt es Tasks mit `andThen`. Ein Rx-Observable dagegen trägt drei Signale im Vertrag: *next*, *error*, *complete*. Ein Strom kann scheitern, und ein Strom kann fertig sein – beides ist Teil des Typs, nicht Konvention. Für Dinge wie AJAX-Long-Polling gegen ein CouchDB-Change-Feed oder EventSource-Push (beides als Beispiel im Repository) ist das die natürlichere Beschreibung.\n\nDer gesamte Kreislauf, als Bild:\n\n```mermaid\nflowchart LR\n  U[Benutzer-Ereignis] --> A[\"actions$ (Strom)\"]\n  SUB[\"subscriptions:<br/>Interval, HTTP, Push\"] --> A\n  A --> S[\"scan(update, initialModel)\"]\n  S --> M[\"model$ (Strom)\"]\n  M --> V[\"React-View<br/>rendert das Model\"]\n  V --> U\n```\n\nEine Richtung, ein Kreis. Alles, was passiert, passiert als Wert auf einem Strom – und ist damit beobachtbar, filterbar, protokollierbar. Ein `actions$.subscribe(console.log)` an der richtigen Stelle ersetzt eine Menge Debugger-Sitzungen.\n\n## Redux, Elm, RxJS – dreimal dieselbe Idee\n\nWer bis hierhin mitgelesen hat und Redux kennt, hat die Pointe längst gesehen: `update` ist ein Reducer. `(model, action) => model` und `(state, action) => state` sind dieselbe Signatur mit anderen Namen. Das ist kein Zufall – Dan Abramov hat nie einen Hehl daraus gemacht, dass Redux die Elm-Architektur für JavaScript destilliert. Der Unterschied liegt darin, was jede Variante der Idee erzwingt und was sie nur empfiehlt.\n\nRedux macht die Faltung mit weniger Mathematik und mehr Konvention: ein Store statt eines Model-Stroms, `dispatch` statt `onNext`, Middleware statt Operator-Ketten. Dass Reducer rein bleiben und Side Effects draußen, steht in der Dokumentation – erzwingen kann es niemand. Dafür bekommt man ein Ökosystem: DevTools mit Time-Travel, Bindings, Konventionen, die ein Team ohne Diskussion übernehmen kann. Interessanterweise wächst gerade mit redux-observable eine Middleware heran, die Rx-Ströme als Side-Effect-Schicht in Redux zurückholt – Actions rein, Actions raus, dazwischen Observable-Operatoren. Die beiden Welten nähern sich also ohnehin an.\n\nElm erzwingt die Form im Compiler. Es gibt keine Möglichkeit, State an `update` vorbeizuschleusen, keine vergessene Mutation, keine unbehandelte Message-Variante – der Typcheck findet sie. Das ist die härteste, aber auch die unnachgiebigste Variante: Man bekommt die Garantien nur zusammen mit der Sprache.\n\nUnd RxJS? RxJS macht etwas explizit, das bei beiden anderen implizit bleibt: die **Zeit**. Ein Observable ist eine Folge von Werten auf einer Zeitachse, und die Operatoren sprechen offen darüber – `debounce` verhandelt über Abstände, `takeUntil` über ein Ende, `scan` über Geschichte. In Redux ist Zeit ein Side Effect der Dispatch-Reihenfolge; in Elm versteckt sie die Runtime. Im Rx-Nachbau steht sie im Code. Wer einmal gesehen hat, dass „State\" nur der aktuelle Stand einer Faltung über der Zeitachse ist, liest danach auch Redux-Code anders. CycleJS treibt denselben Gedanken übrigens noch konsequenter: Dort ist die gesamte Anwendung eine Funktion von Strömen nach Strömen, React inklusive Rendering ersetzt. Und wer die entgegengesetzte Schule sehen will, findet sie in MobX, das Mutation nicht verbannt, sondern beobachtbar macht – ein anderer Vertrag, eine andere Diskussion.\n\n## Was der Eigenbau lehrt – und was nicht\n\nNach ein paar Wochen mit dem Prototyp und seinen sechzehn Beispielen ist meine Bilanz zweigeteilt. Gelernt habe ich mehr als aus jedem Blogartikel über Redux, und zwar Dinge, die unabhängig von der Bibliothek gelten:\n\n- State als Faltung zu begreifen macht Fehlerbilder erklärbar: Nicht „der State ist falsch\", sondern „diese Action-Folge erzeugt dieses Model\" – eine überprüfbare Aussage.\n- Eine einzige Richtung im Datenfluss ist keine Ästhetik, sondern der Grund, warum man das System noch lesen kann, wenn es gewachsen ist.\n- Side Effects sind dann beherrschbar, wenn sie einen expliziten Lifecycle haben – egal ob der Task heißt, Subscription oder Observable.\n- `update` als Pure Function ist trivial testbar; der Test für den Delayed-Counter im Repository braucht weder DOM noch Browser.\n\nWas der Eigenbau **nicht** ist: ein Produktionsersatz. Die Warnung im README steht dort aus Überzeugung. Es fehlt alles, was eine Architektur erst im Alltag trägt – durchdachte Error Handling über Komponentengrenzen, Werkzeuge, Dokumentation jenseits eines README, und vor allem: andere Leute, die den Code kennen. Ein Framework, das eine Person an ein paar Abenden gebaut hat, ist eine Lernmaschine, kein Fundament. Wer die Elm-Architektur in JavaScript produktiv will, nimmt Redux und bekommt Konventionen samt Community dazu; wer die Garantien will, nimmt Elm und bezahlt mit dem Sprachwechsel. Der Nachbau dazwischen hat genau einen Zweck: verstehen, warum beide funktionieren.\n\nDazu kommt eine Beobachtung aus dem Bauen selbst: Die Versuchung, aus dem Proof of Concept „noch schnell\" ein richtiges Framework zu machen, ist enorm. Jedes Beispiel legt eine weitere Abstraktion nahe, jede Abstraktion einen weiteren Konfigurationspunkt. Ich habe bewusst dagegen gehalten – minimal-invasiv, React-Rendering plus Rx-Komposition, keine Schicht mehr als nötig. Auch das ist eine Lektion: Der Abstand zwischen „lehrreicher Prototyp\" und „schlechtes Framework\" beträgt ungefähr zwei Wochenenden.\n\n## Unterm Strich\n\nWarum altert diese Form so gut? Weil sie fast keine Annahmen über ihre Umgebung trifft. Model, Update, View – das setzt weder eine Sprache voraus noch eine Bibliothek noch ein Rendering-Verfahren. React 15 kann morgen durch etwas anderes ersetzt werden, `rx` Version 4 durch Version 5, und die Architektur bleibt dieselbe, weil sie aus zwei Zutaten besteht, die älter sind als das Web: einer Faltung und einer Projection. Frameworks kommen und gehen in diesem Umfeld schnell genug, dass ich das nicht als Floskel meine, sondern als Auswahlkriterium.\n\nUnd die Frage vom Anfang – wann ist das passiert? – hat in dieser Form immer eine Antwort. Die Ereignisgeschichte ist kein Nebenprodukt, das man mit Logging rekonstruiert, sondern die primäre Datenstruktur der Anwendung. Alles andere, das Model eingeschlossen, ist eine Ableitung davon. Wenn ich in Reviews künftig vor einer UI sitze, deren State niemand mehr erklären kann, ist das mein erster Vorschlag: nicht mehr Werkzeuge, sondern eine Richtung.\n\n## Weiterführende Quellen\n\n- Das besprochene Repository: [github.com/MikeBild/reactish-elmish](https://github.com/MikeBild/reactish-elmish)\n- Die Elm-Architektur im offiziellen Guide: [guide.elm-lang.org/architecture](https://guide.elm-lang.org/architecture/)\n- Der `scan`-Operator interaktiv als Marble-Diagramm: [rxmarbles.com/#scan](https://rxmarbles.com/#scan)\n- Redux-Dokumentation: [redux.js.org](https://redux.js.org/)\n- redux-observable, Rx-Ströme als Redux-Middleware: [redux-observable.js.org](https://redux-observable.js.org/)"},{"slug":"graphql-pouch-lokale-daten","title":"GraphQL-Pouch: lokale Daten mit Schema","summary":"Ein Open-Source-Experiment aus dem Sommer 2016: eine GraphQL-Runtime, die aus einer Schema-Kurznotation eine komplette CRUD-API über PouchDB erzeugt – inklusive Synchronisation über das CouchDB-Replikationsprotokoll.","tags":["Software","GraphQL","API"],"published_at":"2016-07-26T10:00:00+00:00","updated_at":"2026-07-14T16:33:18.446465+00:00","url":"https://www.mikebild.dev/de/blog/graphql-pouch-lokale-daten/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/6811b4979ee5f1ff106d5596ccc25cacfe54f56e3b265bd6fff909ff5bb601c7/graphql-pouch-lokale-daten-vorlesen.mp3","audio_duration_secs":754,"text":"# GraphQL-Pouch: lokale Daten mit Schema\n\n```graphql\ntype Post implements Node {\n  id: ID!\n  rev: String\n  title: String\n  body: String\n  published: Boolean\n}\n```\n\nFünf Felder, ein Interface, keine einzige Zeile Resolver-Code. Diese Datei ist das vollständige Backend eines kleinen Blogsystems – Datenhaltung, HTTP-Endpoint und durchsuchbare API-Dokumentation inklusive. Man übergibt sie an `graphql-pouch`, und wenige Sekunden später antwortet unter `http://127.0.0.1:3000/graphql/blog` eine laufende GraphQL-API mit generierten Queries und Mutationen: `post`, `allPosts`, `upsertPost`, `deletePost`. Gespeichert wird nicht in einem entfernten Datenbankserver, sondern lokal in PouchDB – und auf Wunsch synchronisiert der Prozess seine Dokumente über das CouchDB-Replikationsprotokoll mit anderen Knoten.\n\nDas ist die Kurzfassung von GraphQL-Pouch, einem Open-Source-Projekt, das ich in diesem Monat auf GitHub und npm veröffentlicht habe. Es ist ausdrücklich ein Experiment. Ich wollte GraphQL nicht nur aus Blogposts kennen, sondern von innen – und die beste Methode, eine Technologie von innen kennenzulernen, ist bei mir seit Jahren dieselbe: etwas Kleines, Echtes damit bauen und es so lange benutzen, bis die unbequemen Stellen sichtbar werden.\n\n## Ein Jahr GraphQL, viele offene Fragen\n\nZur Einordnung: Die GraphQL-Spezifikation ist gerade ein Jahr öffentlich. Facebook hat sie im Juli 2015 zusammen mit `graphql-js` als Referenzimplementierung freigegeben, intern läuft die Technik dort schon seit 2012. Drumherum gibt es bislang eine überschaubare Werkzeuglandschaft: Relay als Client-Framework, GraphiQL als interaktive Oberfläche zum Erkunden einer API, `express-graphql` als HTTP-Anbindung für Node.js. Wer heute einen GraphQL-Server baut, schreibt Typdefinitionen und Resolver von Hand gegen `graphql-js` – für jedes Feld eine Funktion, die sagt, woher die Daten kommen.\n\nBeim dritten selbstgeschriebenen Resolver fiel mir auf, wie mechanisch diese Arbeit für einfache Fälle ist. Ein Typ `Post` mit fünf Feldern, dahinter eine Dokumentdatenbank: Lesen per Schlüssel, Liste aller Dokumente, Schreiben, Löschen. Das ist immer dasselbe Muster, und Muster, die immer gleich sind, kann eine Maschine erzeugen. Die eigentlich interessante Arbeit steckt im Schema – in der Frage, welche Typen, Felder und Beziehungen die Anwendung braucht. Daraus wurde die These des Experiments: Wenn das Schema die einzige Quelle ist, wie weit trägt eine Runtime, die den Rest generiert?\n\nDer zweite Strang kam aus einer anderen Richtung. In mehreren Frontend-Projekten habe ich erlebt, wie die Backend-Entwicklung zum Engpass wird: Das Team wartet auf Endpoints, auf Datenbankmigrationen, auf Deployments – und baut in der Zwischenzeit Attrappen, die später mühsam wieder ausgebaut werden. Ein Backend, das aus einer Schemadatei entsteht und ohne Infrastruktur lokal läuft, dreht diese Reihenfolge um. Das Frontend-Team definiert den Datenvertrag selbst, arbeitet sofort gegen eine echte API und verhandelt später, was davon ein „richtiges\" Backend übernehmen muss.\n\n## Von der Kurznotation zur laufenden API\n\nGraphQL-Pouch liest die Schema-Kurznotation – dieselbe knappe Schreibweise, in der die GraphQL-Dokumentation ihre Beispiele notiert – und baut daraus zur Laufzeit ein vollständiges, ausführbares Schema mit `graphql-js`. Für jeden Typ entstehen dabei generierte Wurzelfelder samt Resolvern gegen PouchDB. Der Parser dafür geht auf Matthew Muellers Projekt `graph.ql` zurück, dessen Vorarbeit mir viel Zeit gespart hat.\n\nStandardmäßig sind die Relay-Konventionen aktiv: ein `Node`-Interface mit globalen IDs, Connections mit `edges` und `node` für Listen, `clientMutationId` in den Mutations-Payloads. Das war eine bewusste Entscheidung, denn Relay ist im Moment der anspruchsvollste GraphQL-Client, und wer dessen Konventionen erfüllt, erfüllt die einfacheren Fälle nebenbei. Wer sie nicht will, schaltet sie mit `--no-relay` ab und bekommt schlichte Listen.\n\nEine Query gegen das generierte Blog-Schema sieht so aus:\n\n```graphql\nquery BlogFrontpage {\n  allPosts {\n    edges {\n      node {\n        id\n        rev\n        title\n        published\n      }\n    }\n  }\n}\n\nmutation PublishPost {\n  upsertPost(input: {\n    id: \"hello-graphql\",\n    title: \"GraphQL over local data\",\n    published: true,\n    clientMutationId: \"1\"\n  }) {\n    post {\n      id\n      rev\n    }\n  }\n}\n```\n\nIm Entwicklungsmodus (`graphql-pouch --development`) liefert der Server unter dem Endpoint automatisch GraphiQL aus. Das klingt nach einem Nebenschauplatz, war im Alltag aber der größte Einzelgewinn: Die API ist ab der ersten Minute interaktiv erkundbar, mit Autovervollständigung und eingebauter Dokumentation aus den Schema-Kommentaren. Kein Postman, kein cURL-Gefummel – Query eintippen, Ergebnis sehen.\n\nDarüber hinaus bringt die Runtime einige Dinge mit, die aus dem Experiment fast unbemerkt ein benutzbares Werkzeug gemacht haben:\n\n- mehrere Schemas parallel unter eigenen URL-Pfaden (`/graphql/blog`, `/graphql/cms`)\n- JWT-Authentifizierung per Startparameter, inklusive CLI-Kommando zum Signieren von Tokens\n- Ausliefern statischer Dateien, sodass eine React-Anwendung direkt daneben wohnen kann\n- Migration von Schemadateien, eigenen Funktionen und statischen Inhalten über die Kommandozeile\n\n## Warum PouchDB darunterliegt\n\nDie Wahl der Datenbank war die eigentliche Architekturentscheidung des Projekts. PouchDB ist eine JavaScript-Implementierung der CouchDB-Ideen: dokumentorientiert, revisionsbasiert, und – das ist der entscheidende Punkt – mit dem CouchDB-Replikationsprotokoll als eingebautem Synchronisationsmechanismus. Im Node.js-Prozess speichert PouchDB über LevelDB direkt ins Dateisystem. Es gibt keinen Datenbankserver, keinen Verbindungsstring, keine Migrationen. `npm install -g graphql-pouch`, starten, fertig – der Zustand liegt als Dateien neben dem Prozess.\n\nFür ein Werkzeug, das Frontend-Entwicklung vom Backend entkoppeln soll, ist diese Selbstgenügsamkeit kein Komfortmerkmal, sondern der Kern. Jede Infrastrukturabhängigkeit, die ein Entwickler erst installieren muss, ist eine Hürde vor der ersten Query.\n\nInteressant wird es, sobald mehr als ein Knoten im Spiel ist. Mit `graphql-pouch --remote http://user:pass@couch.example.com` repliziert die lokale PouchDB gegen eine entfernte CouchDB – in beide Richtungen. Mehrere Instanzen hinter einem Load Balancer teilen sich so denselben Datenbestand, ohne dass eine davon der „Master\" wäre. Der Preis ist bekannt und im CouchDB-Umfeld gut dokumentiert: Es gibt keine einzelne Quelle der Wahrheit, nur eventuelle Konsistenz. Jeder Knoten ist jederzeit verfügbar, aber nicht jeder Knoten sieht jederzeit denselben Stand.\n\n```mermaid\nflowchart LR\n  SDL[Schema-Datei<br/>Kurznotation] --> RT[graphql-pouch<br/>Laufzeitumgebung]\n  RT --> GEN[Generierte Felder<br/>allPosts, upsertPost, deletePost]\n  UI[GraphiQL /<br/>Relay-Client] --> GEN\n  GEN --> P[(PouchDB<br/>LevelDB im Dateisystem)]\n  P <-->|CouchDB-<br/>Replikationsprotokoll| C[(CouchDB<br/>entfernter Knoten)]\n```\n\nDass ich diese Eigenschaft nicht als Makel, sondern als Merkmal behandle, hat mit der Zielgruppe zu tun: Frontends. Eine Oberfläche, die auf Bestätigung eines entfernten Servers wartet, fühlt sich zäh an. Relay bringt mit seinen optimistischen Updates ohnehin ein Modell mit, das Änderungen sofort anzeigt und später mit der Serverantwort abgleicht. Eventuelle Konsistenz auf der Datenebene und optimistische Updates auf der Client-Ebene sind zwei Ausprägungen desselben Gedankens – die Architektur von GraphQL-Pouch nimmt diesen Gedanken ernst, statt ihn zu verstecken.\n\n## Die Revision steht im Schema – mit Absicht\n\nWer das `Post`-Beispiel oben aufmerksam liest, stolpert über das Feld `rev`. Das ist die Dokumentrevision aus der CouchDB-Welt, und man kann mit guten Gründen fragen, ob so ein Speicherdetail in einer API sichtbar sein darf. Ich habe mich dafür entschieden, und zwar nach einem konkreten Fehlversuch: In einer frühen Version habe ich die Revision intern behandelt und Schreibzugriffe einfach durchgereicht. Das funktioniert genau so lange, wie niemand gleichzeitig schreibt. Sobald zwei Clients dasselbe Dokument ändern – oder ein replizierter Knoten eine ältere Fassung zurückspielt –, überschreibt der Letzte stillschweigend den Ersten. Der verlorene Stand fällt erst auf, wenn ein Nutzer ihn vermisst, und dann ist er nicht mehr rekonstruierbar.\n\nMit sichtbarer Revision dreht sich das um. Ein `upsertPost` mit veralteter Revision scheitert kontrolliert, weil PouchDB den Konflikt erkennt:\n\n```mermaid\nsequenceDiagram\n  participant A as Client A\n  participant B as Client B\n  participant API as graphql-pouch\n  participant DB as PouchDB\n  A->>API: upsertPost(id, rev: \"1-abc\", ...)\n  API->>DB: put(document)\n  DB-->>API: ok, rev: \"2-def\"\n  B->>API: upsertPost(id, rev: \"1-abc\", ...)\n  API->>DB: put(document)\n  DB-->>API: 409 conflict\n  API-->>B: error instead of silent overwrite\n```\n\nDer Client muss dann neu lesen und entscheiden, was mit den konkurrierenden Änderungen passiert. Das ist unbequemer als ein Schema ohne `rev`, aber die Unbequemlichkeit ist ehrlich: Sie zeigt ein Problem an dem Ort, an dem es entsteht, statt es in die Zukunft zu verschieben. Bei lokalen, replizierten Daten ist der Schreibkonflikt kein Randfall, den man wegabstrahieren kann – er ist der Normalfall, der nur selten eintritt.\n\n## Wenn generiertes CRUD nicht mehr reicht\n\nGenerierte Endpoints tragen erstaunlich weit, aber nicht beliebig weit. Irgendwann braucht eine Anwendung eine Query mit Fachlogik, eine Validierung vor dem Schreiben oder Daten aus einer fremden Quelle. Für diese Fälle hat GraphQL-Pouch eine Fluchttür: eigene Funktionen in JavaScript, die sich unter dem Namen eines Schema-Felds registrieren und den generierten Resolver ersetzen. Zusammen mit der Nutzung als npm-Modul lässt sich das direkt in eine bestehende Express-Anwendung einbetten – das Begleit-Repository `graphql-pouch-as-library` zeigt genau diesen Aufbau:\n\n```javascript\nconst express = require('express');\nconst expressGraphQL = require('express-graphql');\nconst graphqlPouch = require('graphql-pouch');\n\nconst schemaDefinition = `\ntype Post implements Node {\n  id: ID!\n  rev: String\n  title: String\n  body: String\n  published: Boolean\n}\n\ntype Query {\n  postsByAuthor(author: String!): [Post]\n}\n`;\n\n// custom resolver replaces the generated CRUD implementation\nconst customFunctions = {\n  postsByAuthor: (ctx, input) => {\n    legacyBlogService\n      .fetchPostsByAuthor(input.author)\n      .then(posts => ctx.success(posts));\n  },\n};\n\nconst mySchema = graphqlPouch.schema('blog', schemaDefinition, true, customFunctions);\n\nconst app = express();\napp.use('/graphql', expressGraphQL({\n  schema: mySchema.schema,\n  graphiql: true,\n}));\napp.listen(3000);\n```\n\nDieses Muster hat sich im Experiment als der wichtigste Befund herausgestellt. Die Generierung deckt den mechanischen Teil ab, und die eigenen Funktionen übernehmen den fachlichen – ohne dass man das Fundament wechseln muss. GraphQL zeigt hier seine Stärke als Fassade: Hinter demselben Schema können ein generierter PouchDB-Zugriff, ein Aufruf an einen bestehenden REST-Dienst und eine handgeschriebene Berechnung nebeneinander existieren, und kein Client merkt den Unterschied.\n\n## Was das Experiment nicht kann\n\nZur Ehrlichkeit eines Experiments gehört die Liste der offenen Enden, und die ist bei GraphQL-Pouch nicht kurz:\n\n- Events fehlen. PouchDB liefert einen Änderungs-Feed, aus dem sich Benachrichtigungen an Clients speisen ließen – das steht auf der Roadmap, ist aber nicht gebaut.\n- Ein Batching- und Caching-Mechanismus für Resolver fehlt ebenfalls; verschachtelte Queries erzeugen mehr Datenbankzugriffe als nötig.\n- Die JWT-Unterstützung authentifiziert, autorisiert aber nicht – Rollen oder Feldrechte gibt es nicht.\n- Generiertes CRUD bleibt generisch: `upsertPost` sagt nichts darüber, ob ein Beitrag veröffentlicht, zurückgezogen oder redigiert wird. Eine fachlich gute API braucht benannte Operationen, und die muss weiterhin ein Mensch entwerfen.\n\nDer letzte Punkt ist der wichtigste. Die Generierung nimmt einem die Schreibarbeit ab, nicht die Entwurfsarbeit. Ein Schema, das nur aus Typen mit CRUD besteht, ist eine Datenbank mit hübscherem Protokoll – nützlich für Prototypen, Demos und die frühe Frontend-Phase, aber kein Ersatz für eine durchdachte Schnittstelle.\n\n## Was ich daraus mitnehme\n\nNach einigen Wochen mit dem eigenen Werkzeug bleiben drei Beobachtungen. Erstens: Der Ansatz, das Schema zuerst zu schreiben und alles Weitere daraus abzuleiten, verändert Gespräche. Über eine Schemadatei mit Kommentaren lässt sich mit Frontend-Kollegen konkret verhandeln, lange bevor irgendein Server steht – das Schema wird zum Vertrag, nicht zur Nachdokumentation. Zweitens: Lokale Daten mit Synchronisation verlagern die Komplexität, sie beseitigen sie nicht. Was an Infrastruktur wegfällt, kommt als Revisions- und Konfliktdenken im Datenmodell zurück, und es ist besser, wenn die API das offenlegt. Drittens: Ein Wochenend-Experiment, das man ernsthaft zu Ende baut – mit CLI, Tests, Dokumentation und Veröffentlichung auf npm –, lehrt mehr über eine Technologie als jede Einführung, weil es einen zwingt, auch die Ränder anzufassen: Fehlermeldungen, Authentifizierung, Betrieb mit mehreren Knoten.\n\nOb GraphQL sich durchsetzt, kann ich im Sommer 2016 nicht beurteilen. Aber die Idee, einen typisierten Datenvertrag zwischen Client und Server zu stellen und beide Seiten daran auszurichten, wirkt auf mich tragfähiger als vieles, was ich in zehn Jahren API-Entwicklung mit REST-Konventionen verhandelt habe. Das Experiment hat sich schon deshalb gelohnt.\n\n## Weiterführende Quellen\n\n- GraphQL-Pouch auf GitHub: https://github.com/MikeBild/graphql-pouch\n- Beispiel zur Einbettung als Bibliothek: https://github.com/MikeBild/graphql-pouch-as-library\n- Offizielle GraphQL-Einführung: https://graphql.org/learn/\n- PouchDB-Replikationsleitfaden: https://pouchdb.com/guides/replication.html\n- CouchDB-Replikation im Überblick: https://docs.couchdb.org/en/stable/replication/intro.html"},{"slug":"definition-of-done-als-qualitaets-gate","title":"Definition of Done als Qualitäts-Gate, nicht als Bürokratie","summary":"Der automatisierbare Kern einer Definition of Done gehört in ein grünes Gate aus Lint, Build und Test. Fachliche Abnahme und andere Urteile bleiben gemeinsame Qualitätsarbeit.","tags":["Software","Delivery"],"published_at":"2016-06-24T10:00:00+00:00","updated_at":"2026-07-13T08:35:40.529374+00:00","url":"https://www.mikebild.dev/de/blog/definition-of-done-als-qualitaets-gate/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/541e0bc46bdf2627beb07c9b4b5cbae7e1a2602f97dae5bc4e11018f42bd563d/definition-of-done-als-qualitaets-gate-vorlesen.mp3","audio_duration_secs":606,"text":"```console\n$ make verify\nlint    ✓  0 warnings\nbuild   ✓\ntest    ✗  2 failing, coverage 61 % (threshold 80 %)\n\nDefinition of Done: rot\n```\n\nDieses Rot lässt sich nicht verhandeln. Niemand kann es abhaken, niemand muss es im Review interpretieren, und jeder im Team reproduziert es in Sekunden auf der eigenen Maschine. Die ausgedruckte Zwölf-Punkte-Liste neben dem Board leistet das nicht. Steht dort \"alle Tests grün\" und setzt jemand ein Häkchen, dann ist damit nicht gesagt, dass die Tests grün sind – nur, dass jemand es glaubt oder behauptet.\n\nAn diesem Unterschied hängt alles Weitere. Was sich an einer Definition of Done als Kommando formulieren lässt, gehört in ein Gate, das den Merge blockiert, solange es rot ist. Was menschliches Urteil braucht, bleibt ehrlich als solches benannt, klein und sichtbar – und wird nicht so behandelt, als wäre es Code.\n\nDas klingt zunächst nach einer rein technischen Verschiebung. Tatsächlich schlägt sie bis ins Geschäftsergebnis durch: kürzere Zyklen, weniger Reibung im Team, höhere und vor allem verlässlichere Qualität. Wer Qualität an ein automatisiertes Gate koppelt, streitet nicht mehr im Review darüber, ob etwas fertig ist. Die Pipeline sagt es.\n\n## Warum das Ritual scheitert\n\nDie klassische Definition of Done als Checkliste hat ein strukturelles Problem: Sie prüft zum spätestmöglichen Zeitpunkt und mit dem unzuverlässigsten Prüfinstrument, das wir haben – einem müden Menschen am Sprintende. Wenn ein Punkt lautet \"alle Tests grün\", dann bedeutet das Abhaken dieses Punkts nicht, dass die Tests grün sind. Es bedeutet, dass jemand glaubt, dass sie es sind, oder es zumindest behauptet.\n\nGenau hier beginnt die Ticket-Schlacht. Weil die Checkliste nicht bindet, wird der Streit ins Soziale verlagert. Ist das jetzt \"done\" oder \"done done\"? Zählt das Refactoring noch dazu? Hat der Product Owner das schon abgenommen? Jede dieser Fragen kostet Zeit, erzeugt Reibung und – das ist das Schlimmste – vermittelt eine Sicherheit, die es gar nicht gibt. Ein abgehakter Punkt fühlt sich an wie eine Garantie, ist aber nur eine Absichtserklärung.\n\nMartin Fowler hat den technischen Gegenentwurf schon lange vorher beschrieben. Selbsttestender Code und Continuous Integration bilden ein Fundament, in dem jeder Commit einen automatischen Build- und Testlauf auslöst. Ein roter Build ist ein Stopp – \"stop the line\". Das ist kein Vorschlag, über den man diskutieren kann. Es ist ein Zustand. Genau diese Eigenschaft will ich für die Definition of Done: Sie soll ein Zustand sein, kein Konsens.\n\n## Die Definition of Done als Commit-Stage\n\nDer entscheidende gedankliche Schritt kam für mich über die Deployment Pipeline von Jez Humble und David Farley. Ihr Modell macht Qualität zu einem automatisierten Durchlauf. Die erste Stufe, die Commit-Stage, kompiliert den Code, führt Unit-Tests aus, lässt Linter und statische Analyse laufen. Erst wenn diese Stage grün ist, starten spätere Stufen überhaupt. Alles andere ist blockiert.\n\nWenn ich das ernst nehme, dann lässt sich eine Definition of Done fast eins zu eins auf diese Commit-Stage abbilden. Was in der Checkliste stand, wird zu Schritten in der Pipeline:\n\n```yaml\ncommit_stage:\n  fail_fast: true            # any red step blocks the merge\n  steps:\n    - name: compile\n    - name: lint             # static analysis, style rules\n    - name: unit_test\n    - name: coverage_check    # fail below an agreed threshold\n    - name: contract_test     # verify integration boundaries\ngate:\n  rule: merge_blocked_unless(commit_stage == \"green\")\n```\n\nDas Interessante an dieser Darstellung ist, was sie erzwingt. Ein Punkt, den ich nicht als Schritt formulieren kann, gehört nicht in den automatisierten Teil der Definition of Done. Ich kann \"code compiles\" ausführen. Ich kann \"no lint errors\" ausführen. Ich kann \"coverage above threshold\" ausführen. Ich kann \"der Code ist elegant\" nicht ausführen – das ist ein menschliches Urteil und bleibt im Review, wo es hingehört.\n\nDiese Trennschärfe ist der eigentliche Gewinn. Die Definition of Done zerfällt in zwei sauber getrennte Teile: ein hartes, ausführbares Gate und einen kleinen, ehrlich benannten Rest aus menschlichen Prüfungen. Kein Vermischen mehr, keine Scheingenauigkeit.\n\n## Ein Kommando, lokal und in der CI\n\nDamit das Gate im Alltag trägt, braucht es eine Eigenschaft, die oft übersehen wird: Der Entwickler muss es lokal genauso ausführen können wie die CI. Wenn die Pipeline andere Dinge prüft als das, was ich vor dem Push laufen lassen kann, dann verliere ich den schnellen Rückkopplungskreis und bekomme rote Builds erst nach dem Push. Das ist Reibung, und sie ist vermeidbar.\n\nDeshalb bündele ich den ausführbaren Kern der Definition of Done in genau einem Target. Der Name ist zweitrangig, die Idee zählt:\n\n```makefile\n# One command is the executable Definition of Done.\n# Developers run it before push; CI runs the exact same target.\nverify: lint build test\n\t@echo \"Definition of Done: green\"\n\nlint:\n\teslint . --max-warnings 0\n\nbuild:\n\tnpm run build\n\ntest:\n\tnpm test -- --coverage\n```\n\nDas Merge-Gate in der CI ruft kein eigenes Skript auf. Es ruft dasselbe `verify`. Damit gilt: Was lokal grün ist, ist auch in der CI grün, und umgekehrt. In einem Satz: Der automatisierbare Teil der Definition of Done ist ein Kommando; die vollständige Vereinbarung bleibt mehr als dieses Kommando.\n\nAls Git-Hook wird daraus ein leichtes Sicherheitsnetz, das den langsamen Weg über die CI gar nicht erst nötig macht:\n\n```bash\n#!/usr/bin/env sh\n# .git/hooks/pre-push\n# Run the executable DoD before anything leaves the machine.\nif ! make verify; then\n  echo \"Push blocked: Definition of Done is red.\"\n  exit 1\nfi\n```\n\nMan sieht sofort, warum das die Zyklen verkürzt. Der Entwickler bekommt das Rot in Sekunden auf der eigenen Maschine, nicht Minuten später aus der Pipeline und schon gar nicht Tage später im Sprint-Review. Fehler werden dort gefangen, wo sie am billigsten zu beheben sind: direkt nach ihrer Entstehung.\n\n## Der Weg durch das Gate\n\nNebeneinandergestellt springt der Unterschied ins Auge. Auf der einen Seite das Ritual, auf der anderen das Gate:\n\n```mermaid\nflowchart TD\n    A[Commit] --> B{Ritual oder Gate?}\n    B -->|Ritual| C[12-Punkte-Checkliste]\n    C --> D[manuelles Abhaken]\n    D --> E[Review-Meeting]\n    E --> F[Merge<br/>Hoffnung, kein Beweis]\n    B -->|Gate| G[Commit Stage:<br/>lint, build, test, contract]\n    G --> H{alles grün?}\n    H -->|ja| I[Merge]\n    H -->|nein| J[blockiert<br/>zurück zum Entwickler]\n```\n\nDer linke Pfad endet in Hoffnung. Der rechte endet in einem Beweis oder in einer klaren Blockade. Und die Blockade ist kein Vorwurf an eine Person, sondern eine sachliche Rückmeldung des Systems. Das entlastet auch die Teamdynamik enorm – nicht ein Kollege sagt \"das ist nicht fertig\", sondern das Gate sagt es, reproduzierbar und für alle gleich.\n\nEmpirisch stützt sich diese Haltung auf die State-of-DevOps-Reports von 2014 und 2015, an denen unter anderem Nicole Forsgren, Gene Kim und Jez Humble mitgearbeitet haben. Ihr Befund widerlegt den verbreiteten Reflex, Tempo gehe auf Kosten der Qualität. High-Performer koppeln hohe Deploy-Frequenz mit niedriger Change-Fail-Rate. Tempo und Qualität sind kein Trade-off – vorausgesetzt, die Prüfungen laufen automatisiert statt manuell. Genau das ist der Business-Case für das ausführbare Gate: Es macht schnell und sicher zugleich möglich.\n\n## Auch Grenzen lassen sich als Gate prüfen\n\nEin häufiger Einwand: Unit-Tests prüfen nur die eigene Einheit, aber die richtig teuren Fehler entstehen an den Integrationsgrenzen zwischen Services. Stimmt. Und auch dafür gibt es einen ausführbaren Weg statt eines Abnahme-Meetings. Consumer-Driven Contracts, wie Fowler und Ian Robinson sie beschrieben haben und wie Pact sie handhabbar macht, verwandeln die Frage \"passen unsere Schnittstellen zusammen?\" in ein Signal, das grün oder rot ist.\n\n```javascript\n// A consumer-driven contract turns an integration\n// boundary into an executable check.\nprovider\n  .given(\"an order with id 42 exists\")\n  .uponReceiving(\"a request for order 42\")\n  .withRequest({ method: \"GET\", path: \"/orders/42\" })\n  .willRespondWith({\n    status: 200,\n    body: { id: 42, status: \"shipped\" }\n  });\n```\n\nDer Contract-Test wird Teil derselben Commit-Stage. Damit wandert eine weitere Prüfung, die früher in Meetings und Abstimmungsketten stattfand, in die Pipeline. Weniger Termine, mehr Beweise. Der Hebel liegt überall dort, wo Abstimmung nur deshalb nötig ist, weil niemand die Übereinkunft ausführen kann – solche Koordination lässt sich durch ausführbare Übereinkünfte ersetzen.\n\n## Ehrlich bleiben: was das Gate nicht kann\n\nAn dieser Stelle muss ich gegen mein eigenes Argument arbeiten, sonst wird es unredlich. Ein grünes Gate ist nicht dasselbe wie Qualität. Es ist der Nachweis, dass die vereinbarten Prüfungen ausgeführt wurden und durchgelaufen sind – nicht der Nachweis, dass diese Prüfungen aussagekräftig sind. Eine hohe Coverage mit schwachen Assertions ist grün und trotzdem wertlos. Tests, die nichts wirklich behaupten, geben eine Scheinsicherheit, die gefährlicher ist als offen zugegebene Unsicherheit.\n\nDas Gate misst also die Ausführung von Prüfungen, nicht deren Güte. Die Güte der Prüfungen selbst bleibt eine menschliche, fachliche Verantwortung. Wer das verwechselt, ersetzt das alte Checklisten-Ritual nur durch ein Coverage-Ritual.\n\nEbenso ehrlich: Nicht jeder Punkt einer Definition of Done ist automatisierbar. \"Dokumentation aktualisiert\", \"vom Product Owner abgenommen\", \"Barrierefreiheit manuell geprüft\" – das bleibt menschlich. Der Fehler wäre, so zu tun, als würde alles zu Code. Der richtige Umgang ist Trennung: das automatisierbare Kern-Gate hart und blockierend machen, den manuellen Rest klein, sichtbar und bewusst als menschliche Entscheidung stehen lassen.\n\nUnd noch eine Abgrenzung, die in der Praxis viel Reibung spart: Die Definition of Done ist nicht dasselbe wie Akzeptanzkriterien. Die Definition of Done ist projektweit und stabil, ein Qualitätsstandard, der für jede Story gilt. Akzeptanzkriterien sind pro Story, sie beschreiben das fachlich Gewollte dieser einen Anforderung. Wer beides vermischt, bekommt genau die aufgeblähten, ständig neu verhandelten Listen zurück, gegen die dieser Artikel geschrieben ist.\n\n## Fazit\n\nDie Definition of Done ist eine gute Idee, die in vielen Teams an ihrer Form scheitert. Rein mechanisch als Checkliste am spätesten Punkt abgehakt, erzeugt sie Reibung und Scheinsicherheit statt Qualität. Ihr automatisierbarer Kern gehört deshalb in ein Gate, das lokal und in der CI dasselbe prüft. Gemeinsame Qualitätskriterien und fachliche Abnahme verschwinden dadurch nicht; sie werden nur nicht als automatischer Beweis ausgegeben.\n\nDer Weg dorthin ist konzeptionell einfach und genau deshalb elegant. Bilde den automatisierbaren Kern der Definition of Done auf die Commit-Stage einer Pipeline ab. Bündele ihn in einem `verify`-Kommando. Blockiere den Merge, solange es rot ist. Trenne diesen harten Kern sauber vom kleinen manuellen Rest und tu nicht so, als wäre alles Code. Und bleib ehrlich darüber, dass grün \"geprüft\" heißt, nicht \"gut\".\n\nDas Geschäftsergebnis dieser Verschiebung ist real und messbar. Kürzere Feedback-Zyklen, weniger Meetings über den Zustand des Codes, weniger soziale Reibung im Team und eine Qualität, die nicht auf Hoffnung beruht, sondern auf einem reproduzierbaren Zustand. Definition of Done wird vom Bürokratie-Ritual zum stillen, verlässlichen Türsteher. Und der beste Türsteher ist der, über den niemand mehr reden muss.\n\n## Weiterführende Quellen\n\n- Martin Fowler: *Continuous Integration* und *SelfTestingCode*, martinfowler.com\n- Jez Humble, David Farley: *Continuous Delivery*, 2010 – Deployment Pipeline und Commit-Stage\n- Puppet Labs: *State of DevOps Report* 2014 und 2015 (Forsgren, Kim, Humble)\n- Ken Schwaber, Jeff Sutherland: *The Scrum Guide* – Definition of Done\n- Martin Fowler, Ian Robinson: *Consumer-Driven Contracts*, martinfowler.com; Tool: Pact\n- Michael Nygard: *Release It!*, 2007 – Stability Patterns als nicht-funktionale Prüfziele"},{"slug":"react-komponenten-als-schnittstellen","title":"React: Komponenten als Schnittstellen","summary":"Warum Props der öffentliche Vertrag einer React-Komponente sind – und was sich ändert, wenn man sie wie eine API entwirft statt wie eine Parameterliste.","tags":["Software","Frontend"],"published_at":"2016-03-12T10:00:00+00:00","updated_at":"2026-08-17T11:32:14.2772+00:00","url":"https://www.mikebild.dev/de/blog/react-komponenten-als-schnittstellen/","audio_url":null,"audio_duration_secs":null,"text":"# React: Komponenten als Schnittstellen\n\nEine React-Komponente ist kein Template, dem jemand etwas Logik beigemischt hat. Diese Lesart ist verbreitet, weil JSX auf den ersten Blick wie eine Template-Sprache aussieht – und sie erzeugt genau die Komponenten, die ein halbes Jahr später niemand mehr anfassen will: Markup mit eingestreuten Bedingungen, fünfzehn Props ohne erkennbare Ordnung, und irgendwo tief innen ein Ajax-Aufruf. Eine Komponente ist eine Schnittstelle. Props sind ihr öffentlicher Vertrag, State ist ein privates Implementierungsdetail, und das Render-Ergebnis ist die Erfüllung des Vertrags. Wer Props so entwirft, wie man eine gute API entwirft – minimal, in der Sprache der Fachdomäne benannt, ohne durchgereichte Interna –, bekommt Komponenten, die Personalwechsel und Moden überleben.\n\nSeit React 0.14 ist das keine bloße Interpretation mehr, das Framework selbst spricht diese Sprache: Eine Komponente darf eine schlichte Funktion sein, die Props entgegennimmt und Elemente zurückgibt. Kein `createClass`, keine Klasse, kein Lifecycle – eine Funktionssignatur. Deutlicher lässt sich „Schnittstelle\" kaum ausdrücken.\n\n## Props sind der Vertrag, State ist ein Detail\n\nIn einem Abrechnungsprojekt, an dem ich gerade arbeite, rendern wir eine Liste offener Rechnungen. Die kleinste Einheit ist eine Zeile, und sie ist bewusst eine zustandslose Functional Component:\n\n```jsx\n// InvoiceRow.js - a stateless functional component (React 0.14)\nconst InvoiceRow = ({ invoice, isSelected, onSelect }) => (\n  <tr className={isSelected ? 'selected' : ''}\n      onClick={() => onSelect(invoice.id)}>\n    <td>{invoice.number}</td>\n    <td>{invoice.customer}</td>\n    <td className=\"amount\">{formatAmount(invoice.total)}</td>\n  </tr>\n);\n```\n\nAus Sicht des Aufrufers lautet der Vertrag: Gib mir eine Rechnung, sage mir, ob sie ausgewählt ist, und nenne mir einen Callback für die Auswahl. Mehr nicht. Die Komponente weiß nichts über REST-Endpoints, nichts über einen Store, nichts über die Session des angemeldeten Nutzers. Genau deshalb lässt sie sich in der Detailansicht, im Suchergebnis und im Monatsabschluss gleichermaßen verwenden – und genau deshalb kann ein Test sie ohne Server und ohne Browser-Verrenkungen aufrufen.\n\nDer Gegenspieler des Vertrags ist der State. In den offiziellen Docs steht dazu eine Prüfliste, die ich inzwischen fast wörtlich in Code-Reviews zitiere. Ein Wert gehört nur dann in den State, wenn alle drei Fragen mit Nein beantwortet werden:\n\n- Kommt der Wert über Props herein?\n- Bleibt er über die Zeit unverändert?\n- Lässt er sich aus anderen Props oder State-Werten ableiten?\n\nMir ist aufgefallen, dass die meisten React-Probleme, die ich in Reviews sehe, keine Render-Probleme sind, sondern Vertragsprobleme: Props werden beim Mounten in den State kopiert und laufen danach auseinander, abgeleitete Werte werden gespeichert statt berechnet, und plötzlich gibt es zwei Wahrheiten für dieselbe Rechnung. Wer State konsequent als privates Detail behandelt und alles Vertragliche in Props ausdrückt, bekommt diese Klasse von Fehlern gar nicht erst.\n\n## propTypes: der Vertrag steht im Code\n\nEin Vertrag, der nur im Kopf des Autors existiert, ist keiner. React bringt dafür `propTypes` mit – seit 0.14 direkt im `react`-Paket, egal ob man mit `React.createClass` oder mit ES2015-Klassen arbeitet:\n\n```jsx\nInvoiceRow.propTypes = {\n  invoice: React.PropTypes.shape({\n    id: React.PropTypes.string.isRequired,\n    number: React.PropTypes.string.isRequired,\n    customer: React.PropTypes.string.isRequired,\n    total: React.PropTypes.number.isRequired\n  }).isRequired,\n  isSelected: React.PropTypes.bool,\n  onSelect: React.PropTypes.func.isRequired\n};\n\nInvoiceRow.defaultProps = {\n  isSelected: false\n};\n```\n\nTechnisch sind das nur Warnungen in der Entwicklungsumgebung – kein Compiler, keine Garantie zur Laufzeit im Produktivbetrieb. Trotzdem halte ich `propTypes` für eines der unterschätztesten Werkzeuge im aktuellen React-Ökosystem, aus einem Grund, der mit Typprüfung wenig zu tun hat: Sie sind Dokumentation, die nicht unbemerkt veralten kann. Wenn eine Kollegin `InvoiceRow` zum ersten Mal öffnet, liest sie den Vertrag, bevor sie eine Zeile Implementierung sieht. Und wenn jemand den Vertrag bricht, meckert die Konsole beim nächsten Entwicklungslauf.\n\nNebenbei entschärfen `propTypes` auch ein Team-Problem, das gerade viele Codebasen betrifft: das Nebeneinander von `React.createClass` und ES2015-Klassen. Wir schreiben Neues mit Babel und Klassen beziehungsweise als zustandslose Funktionen, aber ältere Teile der Anwendung stecken noch im `createClass`-Stil – und das wird auch eine Weile so bleiben, denn niemand schreibt eine laufende Anwendung nur wegen einer Syntaxfrage um. Solange jede Komponente ihren Vertrag als `propTypes` deklariert, ist das erstaunlich egal. Der Aufrufer sieht in beiden Fällen dieselbe Schnittstelle, und die Migration einer einzelnen Komponente von einem Stil zum anderen bricht nichts, was der Vertrag nicht sofort melden würde.\n\nInteressant wird es, wenn man `propTypes` als Entwurfswerkzeug benutzt statt als Pflichtübung im Nachhinein. In dem Abrechnungsprojekt schreiben wir die `propTypes` inzwischen zuerst, bevor die Komponente Markup bekommt. Das fühlt sich zunächst umständlich an, zwingt aber zu der einen Frage, um die es geht: Was muss der Aufrufer wirklich liefern? Eine Komponente, deren `propTypes`-Block länger ist als ihr Render-Code, ist fast immer ein Hinweis darauf, dass hier zwei oder drei Komponenten in einer stecken.\n\n## Zerlegen wie in „Thinking in React\"\n\nDie offiziellen Docs enthalten mit „Thinking in React\" einen Text, der genau diese Entwurfshaltung als Methode beschreibt – und der meiner Meinung nach viel zu oft übersprungen wird, weil er nach Einsteigerlektüre klingt. Die Methode ist schlicht: Rechtecke über das UI-Mock zeichnen, jedem Rechteck eine einzige Verantwortung geben, zuerst eine statische Version ohne jeden State bauen, dann den minimalen State identifizieren und ihn im nächstgelegenen gemeinsamen Vorfahren ansiedeln.\n\nÜbersetzt in die Schnittstellen-Sicht heißt das: Jedes Rechteck bekommt einen Vertrag, und die statische Version ist der Beweis, dass der Vertrag trägt, bevor irgendetwas Dynamisches passiert. Der Schritt „minimalen State finden\" ist dann kein React-Trick, sondern das, was man beim Entwurf eines Datenmodells auch tut – jede Redundanz, die man jetzt zulässt, muss man später von Hand synchron halten.\n\nDas ist keine neue Idee. Wer schon einmal ein Modul-Interface geschnitten oder einen Service-Vertrag verhandelt hat, erkennt das Muster sofort wieder. Neu ist, dass React diese Disziplin bis in die kleinste UI-Einheit hinunter bezahlbar macht: Eine Funktion mit drei Props ist billig genug, um sie wegzuwerfen und neu zu schneiden, wenn der Schnitt nicht stimmt.\n\n## Container und Darstellung trennen\n\nIrgendwo müssen die Daten allerdings herkommen. Dan Abramov hat dafür im vergangenen Jahr die Unterscheidung zwischen Container- und Präsentationskomponenten aufgeschrieben, die sich bei uns im Projekt schnell durchgesetzt hat: Präsentationskomponenten kennen nur ihre Props, Container kennen die Datenquelle und reichen Ergebnisse als Props weiter.\n\n```jsx\n// InvoiceListContainer.js - knows where the data comes from\nclass InvoiceListContainer extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = { invoices: [], isLoading: true, selectedId: null };\n    this.handleSelect = this.handleSelect.bind(this);\n  }\n\n  componentDidMount() {\n    fetchInvoices().then(invoices =>\n      this.setState({ invoices, isLoading: false })\n    );\n  }\n\n  handleSelect(invoiceId) {\n    this.setState({ selectedId: invoiceId });\n  }\n\n  render() {\n    const { invoices, isLoading, selectedId } = this.state;\n    if (isLoading) return <LoadingHint />;\n    return (\n      <InvoiceList\n        invoices={invoices}\n        selectedId={selectedId}\n        onSelect={this.handleSelect}\n      />\n    );\n  }\n}\n```\n\nDer Container ist bewusst langweilig: Er lädt, er hält den Auswahlzustand, er reicht weiter. `InvoiceList` und `InvoiceRow` bleiben Pure Functions über ihren Props. Der Datenfluss ist damit an jeder Stelle ablesbar – Daten fließen als Props nach unten, Events laufen als Callbacks nach oben:\n\n```mermaid\nflowchart TD\n  C[InvoiceListContainer<br/>State: invoices, isLoading, selectedId] -->|Props: invoices, selectedId, onSelect| L[InvoiceList]\n  L -->|Props: invoice, isSelected, onSelect| R1[InvoiceRow]\n  L -->|Props: invoice, isSelected, onSelect| R2[InvoiceRow]\n  R1 -.->|onSelect mit invoice.id| C\n```\n\nWer Redux einsetzt – und der Aufstieg von Redux ist gerade in vollem Gange –, kennt dieses Muster bereits: `connect` erzeugt nichts anderes als Container. Der Punkt ist aber unabhängig von der Bibliothek. Die Trennung lohnt sich auch in einer Anwendung ganz ohne Store, weil sie die teuren Komponenten (die mit Datenzugriff) von den billigen (die mit reiner Darstellung) trennt. Die billigen sind die, die man wiederverwendet, im Styleguide zeigt und in fünf Minuten testet.\n\n## Komposition über children statt Vererbung\n\nDie zweite Stelle, an der das Schnittstellen-Denken konkret wird, ist Wiederverwendung von Rahmen und Layout. Der Reflex aus der objektorientierten Welt wäre Vererbung: eine `BasePanel`-Klasse, von der Spezialfälle erben. In React habe ich das noch in keinem Fall gebraucht. Stattdessen bekommt der Vertrag einen Platzhalter – `children`:\n\n```jsx\n// Panel.js - a layout component with a slot for arbitrary content\nconst Panel = ({ title, children }) => (\n  <div className=\"panel\">\n    <h2 className=\"panel-title\">{title}</h2>\n    <div className=\"panel-body\">{children}</div>\n  </div>\n);\n\n// usage\n<Panel title=\"Open invoices\">\n  <InvoiceList invoices={invoices} selectedId={selectedId} onSelect={handleSelect} />\n</Panel>\n```\n\n`Panel` weiß nichts über Rechnungen, `InvoiceList` weiß nichts über Panels. Beide Verträge bleiben minimal, und jede Kombination ist am Aufrufort sichtbar, statt in einer Vererbungshierarchie versteckt zu sein. Braucht ein Panel mehrere Einschubstellen – etwa eine Werkzeugleiste neben dem Titel –, ist auch das nur eine weitere Prop, die ein Element entgegennimmt. Elemente sind in React gewöhnliche Werte, und Verträge, die Werte austauschen, sind leichter zu verstehen als Verträge, die Ableitung verlangen.\n\n## Die ehrliche Grenze: Props über viele Ebenen\n\nSo weit die Stellen, an denen die Schnittstellen-Sicht trägt. Es gibt eine Stelle, an der sie im Moment wehtut, und die will ich nicht verschweigen: Props, die über viele Ebenen durchgereicht werden müssen.\n\nDas klassische Beispiel ist der angemeldete Nutzer. Das Nutzermenü ganz unten im Baum braucht ihn, also wandert er als Prop durch Komponenten, die ihn selbst gar nicht verwenden:\n\n```mermaid\nflowchart LR\n  App -->|currentUser| Page\n  Page -->|currentUser| Toolbar\n  Toolbar -->|currentUser| UserMenu\n```\n\n`Page` und `Toolbar` haben `currentUser` nicht in ihrem fachlichen Vertrag – sie transportieren ihn nur. Jede Zwischenebene, die eine fremde Prop durchreicht, verwässert genau die Minimalität, für die ich oben argumentiert habe. Und eine wirklich befriedigende Standard-Antwort darauf gibt es Anfang 2016 nicht. Es gibt Linderung:\n\n- den Baum umbauen und Komposition nutzen – `App` rendert das `UserMenu` selbst und reicht das fertige Element nach unten, statt der Daten\n- weitere Container tiefer im Baum ansetzen, die sich ihre Daten selbst besorgen, bei Redux etwa per `connect`\n- anwendungsweite Daten grundsätzlich in einen Store legen und nur noch lokale Verträge per Props führen\n\nReact hat zwar einen Context-Mechanismus, der genau dieses Problem adressieren soll – aber die Docs sagen dazu wörtlich, das Feature sei experimentell und die API voraussichtlich instabil. Auf so etwas baue ich keine Anwendung, die länger leben soll als die Warnung. Im Projekt tolerieren wir Durchreichen über zwei, drei Ebenen, bauen bei mehr den Baum um und setzen für wirklich globale Dinge auf den Store. Das ist ein Kompromiss, kein State, mit dem ich zufrieden bin. Hier ist das Ökosystem schlicht noch nicht fertig.\n\n## Was ich daraus mitnehme\n\nDie Qualität einer Komponente entscheidet sich nicht in ihrem Render-Code, sondern am Aufrufort. Deshalb stelle ich im Review inzwischen zuerst Fragen an die Props-Liste, nicht an die Implementierung: Verstehe ich den Vertrag, ohne die Datei zu öffnen? Sprechen die Namen die Sprache der Fachdomäne – `invoice`, `onSelect` – oder die Sprache der Technik dahinter? Steckt eine Prop darin, die nur existiert, weil ein tieferes Kind sie braucht? Könnte ein zweiter, ganz anderer Aufrufer diese Komponente verwenden, ohne dass sie es merkt?\n\nTemplates beantworten keine dieser Fragen, Schnittstellen schon. React gibt einem dafür erstaunlich wenig vor – eine Funktion, Props, `children`, dazu `propTypes` als lesbaren Vertrag – und genau diese Kargheit ist der Wert. Die Bibliothek unter der Komponente wird sich weiterdrehen; allein in den letzten anderthalb Jahren haben sich Build-Werkzeuge, Sprachstandard und State-Bibliotheken einmal komplett gedreht. Ein sauber geschnittener Vertrag aus drei Props hat gute Chancen, das alles zu überstehen. Ein Template mit etwas Logik hat sie nicht.\n\n## Weiterführende Quellen\n\n- Thinking in React (offizielle React-Dokumentation): [react.dev/learn/thinking-in-react](https://react.dev/learn/thinking-in-react)\n- React v0.14 – Release-Ankündigung mit stateless functional components: [legacy.reactjs.org/blog/2015/10/07/react-v0.14.html](https://legacy.reactjs.org/blog/2015/10/07/react-v0.14.html)\n- Dan Abramov: Presentational and Container Components (2015): [medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)"},{"slug":"graphql-als-frage-an-das-system","title":"GraphQL als Frage an das System","summary":"Ein halbes Jahr nach der Spezifikation sortiert dieser Beitrag, was an GraphQL wirklich neu ist – und welche Fragen Anfang 2016 noch niemand beantwortet hat.","tags":["Software","GraphQL","API"],"published_at":"2016-01-12T10:00:00+00:00","updated_at":"2026-07-14T16:06:16.162736+00:00","url":"https://www.mikebild.dev/de/blog/graphql-als-frage-an-das-system/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/6ee310cc30a6bcdec5a63285b0bd1042dd46c04d50d3caaea4246e5d515d0154/graphql-als-frage-an-das-system-vorlesen.mp3","audio_duration_secs":753,"text":"# GraphQL als Frage an das System\n\n„Eine Query Language für APIs\" – so beschreibt Facebook GraphQL in der Spezifikation, die seit Juli 2015 als Working Draft öffentlich ist. Der Begriff führt schnell in die Irre. Wer „Query Language\" hört, denkt an SQL und vermutet eine Query Language für Datenbanken, irgendwie ins Web verlängert: der Client schickt ein SELECT, der Server reicht es durch. Das trifft es nicht, und das Missverständnis ist gefährlich, weil es GraphQL entweder zu mächtig erscheinen lässt (beliebige Queries gegen meine Daten!) oder zu banal (noch ein ORM übers Netz).\n\nGraphQL fragt keine Tabellen ab, kennt keine Joins und hat keine Meinung dazu, wo Daten liegen. Abgefragt wird etwas anderes: ein Typsystem, das der Server veröffentlicht. Der Client formuliert seine Frage in den Begriffen dieses Typsystems, der Server beantwortet genau diese Frage – und woher die Daten kommen, ob aus einer Datenbank, aus drei internen Diensten oder aus einer Datei auf der Platte, bleibt vollständig Sache des Servers. Ein halbes Jahr nach der Veröffentlichung lohnt sich ein sortierender Blick: Was davon ist wirklich neu, und was ist nur neue Syntax für alte Ideen?\n\n## Wer stellt hier eigentlich die Frage?\n\nDer Kern lässt sich in einem Satz fassen: REST beantwortet Fragen, die sich der Server gestellt hat – GraphQL lässt den Client die Frage stellen.\n\nDas klingt polemischer, als es gemeint ist. Ein durchdachtes REST-Design ist eine Sammlung von Antworten: `GET /posts/42` liefert das, was der Entwickler des Endpoints für einen Artikel für relevant hielt. Als der Endpoint entworfen wurde, hat jemand geraten, welche Felder ein Client wohl brauchen wird – und im Zweifel lieber ein paar mehr hineingepackt. Diese Antwort ist eingefroren. Kommt später eine Ansicht dazu, die nur Titel und Autorennamen der letzten zehn Artikel braucht, hat sie zwei Möglichkeiten: den fetten Endpoint aufrufen und das meiste wegwerfen, oder einen neuen, maßgeschneiderten Endpoint beantragen. Beides passiert in echten Projekten ständig, und beides erzeugt Kosten – einmal im Netz, einmal in der Abstimmung zwischen Teams.\n\nGraphQL dreht die Richtung um. Der Server sagt nicht mehr „das sind meine Antworten\", sondern „das sind die Begriffe, in denen du fragen darfst\". Die konkrete Frage – welche Felder, wie tief verschachtelt, in welcher Kombination – formuliert der Client selbst, pro Ansicht, pro Release, ohne Rückfrage beim Server-Team. Das ist die eigentliche Verschiebung, und sie ist organisatorisch mindestens so interessant wie technisch.\n\n## Das Schema als Vertrag und Fachsprache\n\nDamit der Client fragen kann, muss der Server sein Vokabular offenlegen. Das geschieht im Schema, und hier liegt für mich die unterschätzte Qualität von GraphQL: Das Schema ist nicht nur ein Vertrag über Datenformen, es ist eine Fachsprache. Typen benennen Dinge der Domäne, nicht Zeilen einer Tabelle. In der kompakten Schema-Notation, die sich in Beispielen und Vorträgen eingebürgert hat, sieht das etwa so aus:\n\n```graphql\ntype Author {\n  id: ID!\n  name: String!\n  posts: [Post]\n}\n\ntype Post {\n  id: ID!\n  title: String!\n  body: String!\n  author: Author!\n  comments: [Comment]\n}\n\ntype Comment {\n  id: ID!\n  text: String!\n  author: Author!\n}\n\ntype Query {\n  post(id: ID!): Post\n  latestPosts(count: Int): [Post]\n}\n```\n\nMan kann dieses Schema einem Redakteur zeigen und darüber reden, ob ein Kommentar wirklich immer einen Autor hat. Das Ausrufezeichen ist keine Formalie, sondern eine fachliche Aussage: `author: Author!` behauptet, dass es keine verwaisten Kommentare gibt. Ob das stimmt, weiß nicht der Client-Entwickler, sondern derjenige, der die Altdaten kennt. Solche Diskussionen habe ich an Datenbankschemata selten geführt – am GraphQL-Schema entstehen sie fast von selbst, weil es genau an der Grenze zwischen den Teams liegt und beide Seiten es lesen können.\n\nDer Typ `Query` fällt aus der Reihe: Er beschreibt keine Sache, sondern die Entry Points – die Fragen, die das System überhaupt zulässt. Auch das ist eine Design-Entscheidung des Servers. GraphQL öffnet nicht wahllos alle Daten; es öffnet exakt die Wurzeln, die im Schema stehen, und von dort aus die Kanten, die die Typen definieren.\n\n## Die Query: die Form der Antwort ist die Form der Frage\n\nDie zweite Eigenschaft, die im täglichen Arbeiten sofort auffällt: Eine GraphQL-Query ist deklarativ, und die Antwort hat exakt die Gestalt der Frage.\n\n```graphql\n{\n  post(id: \"42\") {\n    title\n    author {\n      name\n    }\n    comments {\n      text\n      author {\n        name\n      }\n    }\n  }\n}\n```\n\nDie Antwort dazu:\n\n```json\n{\n  \"data\": {\n    \"post\": {\n      \"title\": \"GraphQL as a question\",\n      \"author\": { \"name\": \"Ada\" },\n      \"comments\": [\n        { \"text\": \"Nice read!\", \"author\": { \"name\": \"Grace\" } }\n      ]\n    }\n  }\n}\n```\n\nKein Feld zu viel, keines zu wenig, und die Verschachtelung der Antwort spiegelt die Verschachtelung der Frage. Das wirkt zunächst wie eine Annehmlichkeit, hat aber praktische Tiefe: Der Client-Code, der die Antwort verarbeitet, steht direkt neben der Query, die sie beschreibt. Wer die Ansicht ändert, ändert die Query, und die Datenform folgt. Facebooks Relay, seit August 2015 als Open Source verfügbar, treibt genau diesen Gedanken auf die Spitze und lässt jede Komponente ihre Datenanforderungen selbst deklarieren. Man muss Relay nicht einsetzen, um von der Idee zu profitieren – die Query allein erzwingt schon eine Klarheit, die bei „wir nehmen halt, was der Endpoint liefert\" nie entsteht.\n\n## Resolver: der Ort der Wahrheit\n\nBleibt die Frage, wie aus einer deklarativen Frage eine konkrete Antwort wird. Die Referenzimplementierung graphql-js beantwortet sie mit einem einfachen Prinzip: Jedes Feld hat eine Resolver-Function. Sie bekommt das Elternobjekt und die Argumente und liefert den Wert des Feldes – synchron oder als Promise.\n\n```js\nimport {\n  GraphQLObjectType,\n  GraphQLID,\n  GraphQLString,\n  GraphQLList,\n  GraphQLNonNull\n} from 'graphql';\n\nconst postType = new GraphQLObjectType({\n  name: 'Post',\n  fields: () => ({\n    id: { type: new GraphQLNonNull(GraphQLID) },\n    title: { type: new GraphQLNonNull(GraphQLString) },\n    author: {\n      type: authorType,\n      resolve: (post) => db.findAuthorById(post.authorId)\n    },\n    comments: {\n      type: new GraphQLList(commentType),\n      resolve: (post) => db.findCommentsByPostId(post.id)\n    }\n  })\n});\n\nconst queryType = new GraphQLObjectType({\n  name: 'Query',\n  fields: {\n    post: {\n      type: postType,\n      args: { id: { type: new GraphQLNonNull(GraphQLID) } },\n      resolve: (root, args) => db.findPostById(args.id)\n    }\n  }\n});\n```\n\nIn graphql-js baut man das Schema derzeit programmatisch aus solchen Typobjekten zusammen; die kompakte Schema-Notation von oben dient vor allem der Kommunikation. Wichtiger als die API-Details ist der Ort, an dem hier Wahrheit entsteht: im Resolver. Ob `author` aus derselben Datenbank kommt wie der Post, aus einem anderen Dienst oder aus einem Cache – das entscheidet diese eine Funktion, und nur sie. Das Schema verspricht die Form, der Resolver liefert den Inhalt. Diese Trennung macht GraphQL zur Fassade: Man kann dahinter aufräumen, Dienste zusammenlegen oder auseinanderschneiden, ohne dass ein Client es merkt, solange das Schema hält.\n\nIn einem Projekt Ende letzten Jahres habe ich einen kleinen Prototyp mit graphql-js vor zwei bestehende interne Dienste gesetzt – nichts Produktives, ein Experiment über die Feiertage. Die überraschende Erfahrung war nicht die Query-Sprache, sondern wie schnell die Diskussion im Team von Endpoints zu Begriffen wechselte. Statt „welcher Endpoint liefert das Feld\" hieß die Frage plötzlich „gehört das Feld an diesen Typ\". Das ist eine bessere Frage.\n\n## Overfetching und Underfetching: das eigentlich gelöste Problem\n\nWenn man nüchtern fragt, welches konkrete Problem GraphQL löst, lande ich immer wieder bei zweien, die dieselbe Wurzel haben. Overfetching: Der Endpoint liefert mehr, als die Ansicht braucht, und mobile Clients bezahlen das mit Datenvolumen und Akkulaufzeit. Underfetching: Der Endpoint liefert weniger, als die Ansicht braucht, und der Client klappert nacheinander weitere Endpoints ab – jede Runde eine volle Netzwerk-Latenz, im Mobilfunk gern dreistellige Millisekunden.\n\n```mermaid\nflowchart LR\n  subgraph REST[\"REST – drei Runden für eine Ansicht\"]\n    C1[Client] --> E1[\"GET /posts/42\"]\n    C1 --> E2[\"GET /posts/42/comments\"]\n    C1 --> E3[\"GET /authors/7\"]\n  end\n  subgraph GQL[\"GraphQL – eine Frage\"]\n    C2[Client] --> Q[\"POST /graphql<br/>eine Query, ein Roundtrip\"]\n    Q --> R[\"Resolver\"]\n    R --> S1[(\"Datenbank\")]\n    R --> S2[\"interner Dienst\"]\n  end\n```\n\nBeide Probleme verschwinden, weil die Frage vollständig beim Client liegt: Er fragt alles, was er braucht, und nichts darüber hinaus, in einem einzigen Roundtrip. Die Runden zwischen den Datenquellen finden weiterhin statt – aber serverseitig, im Rechenzentrum, wo Latenz billig ist. Genau deshalb hat Facebook GraphQL für seine mobilen Anwendungen gebaut, und genau deshalb ist der Nutzen dort am größten, wo Clients über schlechte Netze sprechen. Wer eine interne API zwischen zwei Servern im selben Rack baut, löst mit GraphQL ein Problem, das er womöglich gar nicht hat.\n\n## GraphiQL: das Fenster ins System\n\nEin Aspekt wird in den Diskussionen um Sprache und Typsystem oft unterschlagen: GraphQL-Server sind introspektierbar. Das Schema ist zur Laufzeit abfragbar – mit einer GraphQL-Query über das Schema selbst. Darauf baut GraphiQL auf, der browserbasierte Explorer, den Facebook zusammen mit der Referenzimplementierung veröffentlicht hat. Man richtet ihn auf einen Endpoint und bekommt:\n\n- Autovervollständigung für Felder und Argumente, direkt aus dem Schema\n- eingeblendete Dokumentation für jeden Typ und jedes Feld\n- Validierung der Query beim Tippen, vor dem ersten Request\n- ein Fenster zum Ausprobieren, ohne eine Zeile Client-Code\n\nDas verändert, wie man eine fremde API kennenlernt. Statt Dokumentations-Wikis zu lesen, die seit drei Versionen niemand gepflegt hat, exploriert man das System selbst – und die Auskunft kann nicht veralten, weil sie aus dem laufenden Server kommt. Mir ist beim Herumprobieren aufgefallen, dass GraphiQL nebenbei die Schema-Qualität diszipliniert: Wenn jeder Feldname sofort sichtbar und autovervollständigbar ist, tut schlechte Benennung sichtbar weh.\n\n## Was Anfang 2016 offen ist\n\nSo weit das Lob. Ehrlicherweise gehört in eine Bestandsaufnahme auch, was die Spezifikation und die junge Community noch nicht beantwortet haben. Vier Punkte beschäftigen mich derzeit:\n\n- Caching ohne URLs\n- das N+1-Problem in Resolvern\n- Berechtigungen pro Feld\n- Versionierung – das Versprechen der „versionless API\"\n\nBeim Caching verliert GraphQL zunächst etwas, das REST geschenkt bekommt. HTTP-Caching lebt von der URL als Cache-Schlüssel: `GET /posts/42` können Browser, Proxy und CDN cachen, ohne den Inhalt zu verstehen. Ein GraphQL-Request ist ein POST gegen einen einzigen Endpoint – für die HTTP-Infrastruktur sehen alle Anfragen gleich aus. Relay beantwortet das clientseitig mit einem normalisierten Cache über Objekt-IDs, was elegant ist, aber eben nur im Client hilft. Für die Zwischenschichten gibt es Stand heute keine überzeugende Antwort.\n\nDas N+1-Problem entsteht direkt aus dem Resolver-Modell. Die Query `latestPosts(count: 10)` mit dem Feld `author` ruft den Author-Resolver zehnmal auf – im naiven Fall zehn einzelne Datenbankabfragen nach einer Liste, die eine einzige Query liefern könnte:\n\n```mermaid\nflowchart TD\n  Q[\"latestPosts(count: 10)<br/>mit Feld author\"] --> P[\"Resolver latestPosts<br/>1 Abfrage\"]\n  P --> A1[\"Resolver author<br/>Post 1\"]\n  P --> A2[\"Resolver author<br/>Post 2\"]\n  P --> An[\"Resolver author<br/>Post 10\"]\n  A1 --> DB[(\"10 einzelne Abfragen<br/>statt einer\")]\n  A2 --> DB\n  An --> DB\n```\n\nFacebook hat dafür im Herbst DataLoader veröffentlicht, ein kleines, frisches Projekt, das Ladevorgänge pro Request sammelt, dedupliziert und gebündelt ausführt. Der Ansatz wirkt tragfähig, ist aber noch kein etabliertes Muster – man muss ihn kennen und bewusst einbauen, sonst produziert das eleganteste Schema miserable Datenbanklast.\n\nBerechtigungen sind die Frage, die mir am meisten Respekt einflößt. Bei REST autorisiert man pro Endpoint, und das Modell ist gut verstanden. In einem Graphen ist potenziell jeder Typ über Kanten von jedem anderen aus erreichbar: Wenn `Comment` ein Feld `author` hat und `Author` ein Feld `posts`, kommt man vom harmlosesten Kommentar zur kompletten Werkliste. Die Konsequenz ist unbequem: Autorisierung muss pro Feld gedacht werden, sinnvollerweise in den Resolvern – und ein verbreitetes, geprüftes Muster dafür existiert schlicht noch nicht.\n\nBleibt die Versionierung. Facebook bewirbt GraphQL als „versionless API\": Statt `/v2/` zu bauen, erweitert man das Schema, markiert alte Felder als veraltet – die Spezifikation sieht Deprecation ausdrücklich vor – und beobachtet, welche Clients sie noch abfragen. Weil jede Query ihre Felder explizit nennt, weiß der Server erstmals genau, was tatsächlich benutzt wird. Auf dem Papier überzeugt mich das; ob es die Disziplin über Jahre trägt, kann Anfang 2016 niemand belegen. Ich notiere es als Versprechen, nicht als Eigenschaft.\n\n## Unterm Strich\n\nWas an GraphQL wirklich neu ist, steckt nicht in der Syntax und nicht im Wort „Graph\". Neu ist die Verteilung der Verantwortung: Der Server definiert die Fachsprache und verantwortet die Wahrheit in den Resolvern, der Client formuliert seine Frage selbst – vollständig, typgeprüft, in einem Roundtrip beantwortbar. Overfetching und Underfetching, die beiden chronischen Reibungspunkte zwischen API-Teams und Client-Teams, verschwinden dabei fast als Side Effect.\n\nDie offenen Fragen sind real: Caching, N+1, Feld-Berechtigungen, das unbewiesene Versionierungs-Versprechen. Keine davon wirkt unlösbar, aber wer GraphQL heute produktiv einsetzt, beantwortet sie selbst und trägt das Risiko dafür. Für mich heißt das: weiter prototypisch arbeiten, das Schema als Gesprächsgrundlage mit Fachleuten ernst nehmen und die Server-Seite klein halten. Die Idee, dass ein Client dem System eine Frage stellt statt vorgefertigte Antworten abzuholen, halte ich unabhängig vom Werkzeug für richtig – und falls GraphQL sich nicht durchsetzt, wird die nächste Technik genau diese Idee wieder aufgreifen müssen.\n\n## Weiterführende Quellen\n\n- GraphQL-Spezifikation (Working Draft): https://spec.graphql.org/\n- GraphQL Learn – offizielle Einführung: https://graphql.org/learn/\n- graphql-js, die Referenzimplementierung: https://github.com/graphql/graphql-js\n- GraphiQL, der Schema-Explorer: https://github.com/graphql/graphiql\n- DataLoader für gebündelte Ladevorgänge: https://github.com/graphql/dataloader"},{"slug":"build-skripte-als-produktcode","title":"Build-Skripte als Produktcode","summary":"Build-Skripte verdienen die Sorgfalt von Produktcode, weil sie bestimmen, wie reproduzierbar ein System tatsächlich ist.","tags":["Software","Tooling"],"published_at":"2015-10-12T10:00:00+00:00","updated_at":"2026-07-13T08:35:25.355094+00:00","url":"https://www.mikebild.dev/de/blog/build-skripte-als-produktcode/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/067a012c887284d5aeed2ce2eb6492227a4c7000d66979611c23b0238dab8876/build-skripte-als-produktcode-vorlesen.mp3","audio_duration_secs":678,"text":"# Build-Skripte als Produktcode\n\nEin Build-Skript ist keine Sammlung bequemer Abkürzungen für die Kommandozeile. Es ist die Beschreibung sämtlicher Eingaben, aus denen ein auslieferbares Artefakt entsteht: Quellen, Abhängigkeiten in konkreten Versionen, Toolchain, Umgebung. Was dort nicht steht, wird stillschweigend von der Maschine geliehen, auf der gerade gebaut wird.\n\nTrotzdem landen diese Skripte gern in einer Grauzone zwischen Code und Infrastruktur: selten reviewt, fast nie getestet, meist von einer einzigen Person gepflegt. Genau diese Grauzone ist der Grund, warum Releases wehtun.\n\n## Die These\n\nEin Feature ist erst fertig, wenn ein zweiter Mensch es aus dem leeren Verzeichnis heraus bauen, testen und ausliefern kann – ohne dass ich danebensitze. Der Weg dorthin ist kein Nebenprodukt. Er ist das Produkt. Wenn `git clone` plus ein Kommando nicht ausreicht, um dieselbe Artefaktdatei wie auf meinem Rechner zu erzeugen, dann existiert das System eigentlich nur bei mir lokal – und ist damit nicht ausgeliefert, sondern nur vorgeführt.\n\nIch behandle das Build-Skript deshalb mit derselben Sorgfalt wie Produktcode: Es hat einen klaren Vertrag, es wird versioniert, es wird gelesen, und es hat einen definierten Fehlerpfad. Das macht es nicht in jeder Hinsicht so laufzeitkritisch wie den ausgelieferten Dienst. Gemeint ist das Verantwortungsniveau: Andere müssen sich auf sein Ergebnis verlassen können.\n\n## Warum das eine Architekturfrage ist\n\n„Build“ klingt nach Handwerk, nach Nebensache. Tatsächlich entscheidet der Build über zwei Eigenschaften, die sonst nur teuer nachrüstbar sind: Reproduzierbarkeit und Zykluszeit.\n\nReproduzierbarkeit heißt, dass gleiche Eingaben gleiche Ausgaben erzeugen – unabhängig von Maschine, Uhrzeit und Laune des Netzwerks. Ein Build, der auf meinem Laptop grün ist und auf dem CI-Server rot, hat kein Testproblem. Er hat ein Zustandsproblem: Irgendwo fließt undeklarierter Zustand ein – eine global installierte CLI, eine Umgebungsvariable, eine Version, die „latest“ auflöst.\n\nZykluszeit heißt, wie lange es dauert, von einer Änderung zu einer belastbaren Aussage zu kommen. Diese Zeit multipliziert sich mit jeder Person und jedem Tag im Team. Ein Build, der zehn Minuten braucht und alle zwei Läufe scheitert, ist kein Komfortthema. Er ist ein Produktivitätsleck, das man in Stunden pro Woche messen kann.\n\nBeides sind Architektureigenschaften, keine Tool-Details. Deshalb kommt die Werkzeugfrage zuletzt. Davor steht die Klärung: Welche Eingaben gehen in den Build? Welche davon sind deklariert und welche implizit? Was ist das erwartete Artefakt? Erst danach entscheidet sich, ob das ein `npm`-Script, ein Gulpfile oder ein Makefile wird.\n\n## Ein kleines, unspektakuläres Beispiel\n\nDer häufigste Fehler ist nicht das falsche Tool, sondern der undeklarierte Zustand. Ein typisches Frontend-Setup aus dieser Zeit sieht oft so aus:\n\n```json\n{\n  \"scripts\": {\n    \"start\": \"webpack-dev-server --hot\",\n    \"build\": \"webpack -p\"\n  }\n}\n```\n\nDas liest sich harmlos. Der versteckte Vertrag steht aber gar nicht im Skript: Es setzt voraus, dass `webpack` global installiert ist, dass die richtige Node-Version läuft, dass `NODE_ENV` gesetzt ist. Nichts davon ist Teil des Repositories. Der neue Kollege bekommt am ersten Tag einen anderen Build als ich – und niemand hat den Vertrag gebrochen, weil es nie einen gab.\n\nDie Korrektur ist billig und wirkt sofort: Abhängigkeiten lokal binden, den Einstieg auf ein Kommando reduzieren, die eine Umgebungsentscheidung explizit machen.\n\n```json\n{\n  \"scripts\": {\n    \"start\": \"webpack-dev-server --hot --config webpack.dev.js\",\n    \"build\": \"NODE_ENV=production webpack --config webpack.prod.js\",\n    \"test\": \"mocha --recursive\",\n    \"verify\": \"npm run test && npm run build\"\n  }\n}\n```\n\nDer Gewinn ist nicht Eleganz. Der Gewinn ist, dass `npm run verify` jetzt eine überprüfbare Aussage ist: Wenn es grün ist, existiert ein Artefakt, und die Tests, die es absegnen, sind mitgelaufen. Ich habe keinen Build-Server ersetzt. Ich habe nur den Vertrag ins Repository geholt, wo er reviewt werden kann.\n\n## Determinismus ist kein Luxus\n\nDer teuerste Bruch der Reproduzierbarkeit sind Versionen, die sich zur Laufzeit entscheiden. `^1.2.0` in der `package.json` bedeutet: Der Build von heute und der von nächster Woche installieren möglicherweise unterschiedlichen Code, ohne dass sich eine Zeile im Repository geändert hat. Für ein Experiment ist das egal. Für ein Produkt ist es eine offene Flanke.\n\n```bash\n# freeze the exact dependency tree, then commit the lockfile\nnpm shrinkwrap\ngit add npm-shrinkwrap.json\n```\n\nAb hier ist der Abhängigkeitsbaum ein Artefakt wie jedes andere: versioniert, diffbar, im Review sichtbar. Wenn ein Update etwas kaputt macht, sehe ich es als Änderung an einer Datei – und nicht als Geist, der nur auf dem CI-Server erscheint. Das ist der Kern des Ganzen: Zustand, der vorher unsichtbar durch die Ritzen floss, wird zu etwas, das man lesen und ablehnen kann.\n\nWo mir das Sprachökosystem nicht reicht, ziehe ich die Grenze eine Ebene tiefer. Ein schlankes Image, das genau die Toolchain enthält, macht die Umgebung selbst zum versionierten Artefakt.\n\n```makefile\nIMAGE = myapp-build:1.0\n\nbuild:\n\tdocker build -t $(IMAGE) .\n\tdocker run --rm -v $(PWD):/src $(IMAGE) npm run verify\n```\n\nIch benutze Docker hier nicht als Deployment-Strategie und schon gar nicht als Selbstzweck – das Thema Cluster und Orchestrierung lasse ich bewusst weg. Ich benutze es als Reproduzierbarkeits-Werkzeug: Die Frage „welche Node-Version, welches `make`, welche System-Bibliothek?“ hat jetzt eine Antwort, die im Repository steht.\n\n## Ein Kommando pro Absicht\n\nEin Build-Skript ist eine Schnittstelle, bevor es eine Implementierung ist. Deshalb interessiert mich zuerst das Vokabular, nicht der Inhalt. Wenn jedes Projekt im Haus dieselben vier Verben versteht – `install`, `verify`, `build`, `clean` –, dann muss niemand mehr das README lesen, um mitzuarbeiten. Die Absicht steht im Namen, die Mechanik dahinter darf sich pro Projekt unterscheiden.\n\nDas klingt nach Kosmetik, ist aber eine Architekturentscheidung mit Reichweite. Sobald das Vokabular stabil ist, kann ich Werkzeuge dahinter austauschen, ohne dass sich für das Team etwas ändert. Der Wechsel von Grunt auf Gulp, von Gulp auf pures `npm run`, von einem Test-Runner auf einen anderen – all das bleibt hinter `npm run verify` verborgen. Die Grenze, die ich ziehe, trennt Absicht von Mechanik. Genau diese Trennung macht den Build wartbar, während sich die Toolchain unter ihm bewegt.\n\nDer andere Effekt ist, dass das Skript zur Dokumentation wird. Ein Neuzugang muss nicht wissen, dass der Build intern Babel, ein Bundling und einen Uglify-Schritt hat. Er muss `npm run build` kennen. Das verschobene Wissen ist der Punkt: Es steht jetzt im Code und nicht mehr in den Köpfen – und geht damit nicht verloren, wenn jemand das Team verlässt.\n\n## Der Build gehört in die Pipeline, nicht auf meinen Laptop\n\nEin Vertrag, den nur ich einhalte, ist keiner. Deshalb ist die konsequente Fortsetzung, denselben Einstieg auf einem neutralen Server auszuführen – bei jedem Push, ohne Ausnahme.\n\n```yaml\nlanguage: node_js\nnode_js:\n  - \"4\"\ninstall:\n  - npm install\nscript:\n  - npm run verify\n```\n\nDas ist bewusst langweilig. Die CI ruft exakt dasselbe Kommando auf, das ich lokal benutze – nicht eine eigene, parallele Wahrheit. Damit ist der Server kein zweites Build-System, das auseinanderdriften kann, sondern ein unbestechlicher Zeuge für denselben Vertrag. Sobald ein Kollege eine undeklarierte Annahme einbaut, wird die Pipeline rot, bevor der Code in den Hauptzweig kommt. Der teure Fehler – Umgebungsdrift, die erst im Release auffällt – wird zu einem billigen Fehler, der im Pull Request auffällt.\n\nGenau hier werden die Kennzahlen aus der nächsten Schicht überhaupt erst messbar: Zeit bis grüner Build, Anteil roter Läufe, Dauer bis zum reproduzierten Artefakt. Ohne die Pipeline sind das Anekdoten. Mit ihr sind es Zahlen, an denen man eine Verbesserung belegen kann.\n\n## Der Fehlerpfad, nicht der glückliche Pfad\n\nEine Idee zum Build ist erst belastbar, wenn der Fehlerpfad beschrieben ist. Genau hier trennt sich Demo von Betrieb.\n\nDie erste Frage ist Idempotenz. Was passiert beim zweiten Lauf? Ein Build, der ein `dist/`-Verzeichnis füllt, ohne es vorher zu leeren, produziert nach ein paar Wochen Geisterdateien: alte Bundles, die niemand mehr referenziert, aber ausgeliefert werden. Ein sauberer Build beginnt mit `clean`, nicht aus Ordnungsliebe, sondern damit der zehnte Lauf dasselbe ergibt wie der erste.\n\nDie zweite Frage ist das Scheitern selbst. Ein Skript, das mehrere Schritte mit `&&` verkettet, muss beim ersten Fehler abbrechen – und mit einem Exit-Code ungleich null enden. Sonst meldet die CI grün, obwohl der Test-Schritt still weggelaufen ist. Ich habe mehr kaputte Releases auf verschluckte Exit-Codes zurückgeführt als auf falsche Logik.\n\nDie dritte Frage ist Rückbaubarkeit. Kann ich das Artefakt von letzter Woche exakt neu erzeugen, wenn ein Fehler in Produktion auftaucht? Wenn der Build nicht deterministisch ist, ist die Antwort nein – und dann ist ein Rollback keine Entscheidung mehr, sondern ein Ratespiel.\n\n## Die betriebliche Konsequenz\n\nOb die Arbeit wirkt, sehe ich an einfachen Dingen: Wie lange braucht ein neuer Kollege bis zum ersten lokalen Build? Wie oft scheitert die CI an einer Annahme, die nur auf einem Rechner galt? Lässt sich das Artefakt eines alten Releases aus den damaligen Eingaben erneut erzeugen? Ein deklarierter Build verbessert genau diese Fälle. Wie groß der Effekt ist, muss das Team an seinen eigenen Laufzeiten und Fehlern messen.\n\n## Typische Fehler\n\nDer erste Fehler ist, das Werkzeug mit der Lösung zu verwechseln. Gulp ist keine Reproduzierbarkeit. Ein Makefile ist keine Determinismus-Garantie. Diese Werkzeuge können einen sauberen Vertrag ausdrücken, aber sie erzeugen ihn nicht von allein. Ich habe Gulpfiles gesehen, die genauso viel verstecktes Wissen voraussetzten wie das kaputte `npm`-Script davor.\n\nDer zweite Fehler ist, den Build als fertig zu betrachten, sobald er einmal grün war. Ein Build ist lebender Code. Er verrottet mit jeder undeklarierten Annahme, die sich einschleicht – die eine Umgebungsvariable, die „nur bei uns“ gesetzt ist, das Tool, das „eh jeder installiert hat“. Deshalb gehört das Build-Skript in denselben Review wie das Feature, nicht in einen separaten, unbeaufsichtigten Ordner.\n\nDer dritte Fehler ist, Geschwindigkeit gegen Vertrauen zu tauschen. Ein Build, der Tests überspringt, um schneller grün zu sein, verkauft künftige Zykluszeit für heutige Bequemlichkeit. Der Kredit wird immer zurückgefordert, meist im ungünstigsten Moment.\n\n## Worauf ich im Review prüfe\n\nDrei Fragen reichen mir meistens, um zu beurteilen, ob ein Build den Namen Produktcode verdient. Kann ein Mensch, der das Projekt nicht kennt, aus dem leeren Verzeichnis mit einem Kommando ein Artefakt erzeugen? Ist jede Eingabe, die das Ergebnis beeinflusst, im Repository sichtbar – Versionen, Umgebung, Toolchain? Bricht der Build bei einem Fehler sichtbar ab, statt still grün zu melden?\n\nWenn eine dieser Fragen mit Nein beantwortet wird, fehlt kein Tool. Es fehlt ein Teil der Architektur. Und dieser Teil ist meist billiger zu schließen, als das Team glaubt – solange man den Build als das behandelt, was er ist: Produktcode, der bestimmt, ob ein System reproduzierbar existiert oder nur vorgeführt wurde.\n\n## Schluss\n\nAm Ende steht eine praktische Prüfung: Kann ein neuer Rechner aus den versionierten Eingaben dasselbe Artefakt bauen, bricht ein Fehler sichtbar ab und lässt sich ein Release zurücknehmen? Wenn ja, erfüllt das Build-Skript seine Verantwortung. Ob darunter Gulp, Grunt oder ein Shell-Skript läuft, ist zweitrangig."},{"slug":"es6-memory-ui-state","title":"ES6 Memory: UI-State begreifen","summary":"Ein Memory-Spiel in frischem ES2015, ohne Framework gebaut, macht sichtbar, welche States eine UI wirklich hat, wo sie liegen sollten und wer sie ändern darf.","tags":["Software","Frontend"],"published_at":"2015-09-24T10:00:00+00:00","updated_at":"2026-07-14T16:53:05.755017+00:00","url":"https://www.mikebild.dev/de/blog/es6-memory-ui-state/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/a79633d3968c180a5c7ce38e984d919228a2dc0ae278c6d423b5f111c66f3bb8/es6-memory-ui-state-vorlesen.mp3","audio_duration_secs":631,"text":"# ES6 Memory: UI-State begreifen\n\nEine einzelne Memory-Karte hat vier unterscheidbare States: verdeckt, aufgedeckt, gefunden – und gesperrt, solange zwei offene Karten gerade verglichen werden. Im DOM landen davon üblicherweise genau zwei, als CSS-Klassen wie `flipped` und `matched`. Der dritte State steckt implizit in einem laufenden `setTimeout`, der vierte ergibt sich aus der Abwesenheit der anderen. Zwei von vier States stehen also nirgends explizit – und genau aus diesen beiden stammen die Fehler, die man in kleinen UIs zuverlässig wiederfindet: die dritte Karte, die sich mitten im Vergleich aufdecken lässt, und das Paar, das nach einem hastigen Doppelklick dauerhaft offen liegen bleibt.\n\nDass mir dieses Zahlenverhältnis – vier States, zwei davon sichtbar notiert – erst richtig aufgefallen ist, als ich ein Spiel gebaut habe, sagt einiges darüber, wie beiläufig wir UI State sonst behandeln.\n\n## Ein Spiel als Prüfstand\n\nSeit Juni ist ECMAScript 2015 final. Klassen, Module, `const` und `let`, Arrow-Functions, Template-Literals – das steht jetzt im Standard, aber es fühlt sich noch ungewohnt an, wie eine fremde Sprache mit vertrauten Vokabeln. Ich wollte die neue Syntax nicht an Folien-Beispielen durchdeklinieren, sondern an etwas Echtem: klein genug für ein paar Abende, groß genug, dass echte Zustandsfragen auftauchen. Ein Memory-Spiel passt genau in diese Lücke. Sechzehn Karten, acht Paare, ein Zugzähler – mehr Fachlichkeit braucht es nicht.\n\nDas Ergebnis liegt als [es6-memory](https://github.com/MikeBild/es6-memory) auf GitHub. Bewusst ohne Framework: kein jQuery, kein React. Nicht aus Prinzipienreiterei, sondern weil ich wissen wollte, was von den Zustandsproblemen übrig bleibt, wenn kein Werkzeug sie versteckt oder abnimmt. Der Build ist trotzdem zeitgemäß: jspm löst die Abhängigkeiten auf, SystemJS lädt die ES2015-Module, Babel übersetzt die neue Syntax für Browser, die noch nicht so weit sind – und ein kleines `node build` bündelt alles zu einer `memory.min.js`, die sich mit einem einzigen `div` in jede bestehende Seite hängen lässt. Passend zur Woche, in der das Repository entstand: Anfang September ist Node 4.0 erschienen, die erste Version nach der Wiedervereinigung mit io.js, mit V8-Rückenwind für einen Teil der neuen Sprachfeatures. ES2015 ist gerade dabei, vom Papier in die Werkzeuge zu wandern, und genau in diesem Moment lohnt sich ein Projekt, das die Sprache pur benutzt.\n\n## Welche States es wirklich gibt\n\nDer erste Gewinn kam vor der ersten Zeile Code: States aufzählen. Für eine Karte sind es drei, nicht vier – das war die erste Korrektur an meinem eigenen Bild vom Spiel:\n\n- verdeckt: die Rückseite ist sichtbar, die Karte reagiert auf Klicks\n- aufgedeckt: das Motiv ist sichtbar, die Karte wartet auf den Vergleich\n- gefunden: das Motiv bleibt sichtbar, die Karte reagiert nie wieder\n\nUnd der vierte State aus dem Einstieg? „Gesperrt\" ist gar kein Kartenzustand. Es ist ein State des Spiels: Solange zwei aufgedeckte Karten verglichen werden – und beim Nicht-Treffer kurz sichtbar bleiben, damit man sie sich merken kann – darf keine weitere Karte umgedreht werden. Dazu kommen der Zugzähler und die abgeleitete Frage, ob das Spiel vorbei ist. Diese Trennung zwischen Karten- und Spielzustand fällt einem nicht ein, solange man in CSS-Klassen denkt. Sie fällt einem sofort ein, wenn man den State als Datenstruktur aufschreiben muss.\n\n```mermaid\nflowchart LR\n  H[hidden] -->|flipCard| R[revealed]\n  R -->|second card<br/>matches| M[matched]\n  R -->|no match<br/>after 800 ms| H\n  M --> E[game over<br/>when all matched]\n```\n\nIn ES2015 liest sich das Modell angenehm direkt. Eine Klasse für die Karte, eine Factory für das Spiel – und zum ersten Mal ohne das übliche Prototypen-Geflecht:\n\n```js\n// card.js\nexport class Card {\n  constructor(id, motif) {\n    this.id = id;\n    this.motif = motif;\n    this.state = 'hidden'; // 'hidden' | 'revealed' | 'matched'\n  }\n}\n\n// game.js\nimport { Card } from './card.js';\n\nexport function createGame(motifs) {\n  const pairs = motifs.concat(motifs);\n  const cards = shuffle(pairs).map((motif, id) => new Card(id, motif));\n  return { cards, locked: false, moves: 0 };\n}\n```\n\nDas ist der komplette State des Spiels: ein Array von Karten mit je einem `state`-Feld, ein `locked`-Flag, ein Zähler. Kein `data-`Attribut, keine Klassenliste, kein verstecktes Timeout, das nebenbei Wahrheit transportiert.\n\n## Wo der State liegt – und wo nicht\n\nDie naheliegende Alternative kenne ich aus genug jQuery-Codebasen der letzten Jahre, auch aus eigenen: Das DOM ist der Zustandsspeicher. Ob eine Karte aufgedeckt ist, entscheidet `classList.contains('flipped')`. Wie viele Karten offen sind, ermittelt ein Selektor. Das funktioniert erstaunlich lange – und kippt dann an immer derselben Stelle: Sobald Timing ins Spiel kommt, gibt es Momente, in denen die Darstellung der Wahrheit hinterherläuft. Die Animation ist noch nicht fertig, die Klasse noch nicht gesetzt, der Selektor zählt falsch. Der Fehler ist dann nicht deterministisch, sondern hängt davon ab, wie schnell jemand klickt.\n\nDer tiefere Punkt: Ein DOM-Knoten vermischt zwei Dinge, die getrennt gehören – was der Fall ist und wie es gerade aussieht. Solange beides identisch ist, merkt man den Unterschied nicht. Interessant wird es genau dann, wenn beides auseinanderfällt, und das tut es bei jedem `setTimeout`, bei jeder Transition, bei jedem Server-Roundtrip. Liegt der State dagegen im Modell, ist er zu jedem Zeitpunkt eindeutig, unabhängig davon, was der Browser gerade zeichnet. Und er ist ohne Browser testbar: `createGame`, zweimal `flipCard`, Assertion auf `state` – das läuft in Node in Millisekunden, ganz ohne DOM-Attrappe.\n\n## Eine Übergangsfunktion statt verstreuter Toggles\n\nDie zweite Entscheidung folgt aus der ersten: Wenn der State an einem Ort liegt, sollte er auch nur an einem Ort verändert werden. Nicht drei Event-Handler, die jeweils ein bisschen Klassenlogik machen, sondern eine einzige Funktion, die alle Spielregeln kennt:\n\n```js\n// game.js\nexport function flipCard(game, cardId, onSettled) {\n  const card = game.cards.find(c => c.id === cardId);\n  if (game.locked || !card || card.state !== 'hidden') return;\n\n  card.state = 'revealed';\n  const revealed = game.cards.filter(c => c.state === 'revealed');\n  if (revealed.length < 2) return;\n\n  game.locked = true;\n  game.moves += 1;\n  const [first, second] = revealed;\n  const isMatch = first.motif === second.motif;\n\n  setTimeout(() => {\n    first.state = second.state = isMatch ? 'matched' : 'hidden';\n    game.locked = false;\n    onSettled();\n  }, isMatch ? 300 : 800);\n}\n```\n\nDie erste Zeile nach dem `find` ist der eigentliche Ertrag des ganzen Umbaus. `game.locked || card.state !== 'hidden'` erledigt beide Fehler aus dem Einstieg in einem Ausdruck: Die dritte Karte während des Vergleichs prallt am `locked` ab, der Doppelklick auf dieselbe Karte am Kartenzustand. In der DOM-Variante waren das zwei getrennte Symptome an zwei Stellen im Code, jedes mit eigenem Workaround. Im Modell sind es zwei Facetten derselben Regel: Aufdecken ist nur erlaubt, wenn das Spiel offen und die Karte verdeckt ist.\n\nMir ist beim Schreiben dieser Funktion noch etwas aufgefallen: Sie ist die Stelle, an der man über das Spiel redet. Als eine Kollegin im Review fragte, ob ein aufgedecktes Paar sofort oder verzögert einrasten soll, konnte ich auf zwei Zeilen zeigen. Die Frage „Wer darf diesen State ändern?\" hat im Kleinen dieselbe Antwort verdient wie im Großen: möglichst genau eine Funktion, und die trägt einen Namen aus der Fachsprache.\n\n## Rendern in eine Richtung\n\nBleibt die dritte Frage: Wie kommt der State auf den Bildschirm? Die Antwort, die sich beim Bauen als tragfähig erwiesen hat, ist fast unangenehm simpel – nach jeder Änderung wird das komplette Brett neu gezeichnet:\n\n```js\n// render.js\nexport function render(game, root) {\n  root.innerHTML = `\n    <p class=\"moves\">Moves: ${game.moves}</p>\n    <ul class=\"board\">\n      ${game.cards.map(card => `\n        <li class=\"card ${card.state}\" data-id=\"${card.id}\">\n          ${card.state === 'hidden' ? '' : card.motif}\n        </li>`).join('')}\n    </ul>`;\n}\n```\n\nTemplate-Literals machen diese Art von Rendering zum ersten Mal angenehm. Vor ES2015 hieß das String-Konkatenation über zwanzig Zeilen oder eine Template-Bibliothek als Abhängigkeit; jetzt ist es ein mehrzeiliger Ausdruck mit Interpolation, direkt in der Sprache. Die Verdrahtung dazu ist ein einziger delegierter Listener:\n\n```js\n// main.js\nimport { createGame, flipCard } from './game.js';\nimport { render } from './render.js';\n\nconst root = document.querySelector('.memory');\nconst game = createGame(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']);\n\nconst update = () => render(game, root);\n\nroot.addEventListener('click', event => {\n  if (!event.target.classList.contains('card')) return;\n  flipCard(game, Number(event.target.dataset.id), update);\n  update();\n});\n\nupdate();\n```\n\nDamit fließt alles in eine Richtung: Ein Klick ist nur noch eine Meldung an die Übergangsfunktion, die Übergangsfunktion ändert das Modell, das Modell wird gerendert. Kein Handler fasst je das DOM an, kein Renderer ändert je den State.\n\n```mermaid\nflowchart LR\n  E[Klick-Event] --> T[flipCard<br/>Übergangsfunktion]\n  T --> S[(Spielzustand<br/>im Modell)]\n  S --> R[render]\n  R --> D[DOM]\n  D -. meldet nur Events,<br/>hält keinen Zustand .-> E\n```\n\nDer reflexhafte Einwand lautet: Alles neu rendern, ist das nicht verschwenderisch? Ich habe es gemessen, statt es zu glauben. Siebzehn Elemente per `innerHTML` zu ersetzen kostet in Chrome deutlich weniger als eine Millisekunde – bei einer Interaktion, die höchstens zweimal pro Sekunde stattfindet. Die Grenze existiert, aber sie liegt weit weg von sechzehn Karten: Bei Hunderten Knoten, Formularfokus oder Scroll-Positionen, die den Neuaufbau nicht überleben, trägt der Ansatz nicht mehr. Genau an dieser Grenze setzt React an, das mit Version 0.13 gerade viel Aufmerksamkeit bekommt: Es behält das Prinzip „State rein, Darstellung raus\" bei und macht nur den Schreibweg ins DOM schlau, indem es Unterschiede berechnet, statt alles zu ersetzen. Wer das Memory-Spiel von Hand gebaut hat, liest die React-Dokumentation anders – als Optimierung eines Modells, das man schon verstanden hat, statt als Magie.\n\n## Was ein Framework abnimmt – und was nicht\n\nDas ist für mich die eigentliche Erkenntnis aus dem Projekt, und sie hat mit ES2015 nur am Rande zu tun. Ein Framework nimmt einem eine Menge ab:\n\n- effizientes Aktualisieren des DOM, wenn das Neuzeichnen zu teuer wird\n- Zerlegung der Oberfläche in komponierbare, wiederverwendbare Teile\n- Verdrahtung von Events, Templates und Daten ohne Eigenbau-Konventionen\n\nWas kein Framework abnimmt, sind die drei Fragen, die dieses kleine Spiel erzwungen hat: Welche States gibt es überhaupt – und welche davon gehören zur Karte, welche zum Spiel? Wo liegen sie – im Modell oder verschmiert über Klassenlisten und Timeouts? Und wer darf sie ändern – eine benannte Übergangsfunktion oder jeder Handler ein bisschen? Wer diese Fragen nicht beantwortet, bekommt mit Framework dieselben Fehler wie ohne, nur besser verpackt. Duplizierter State zwischen Komponente und DOM sieht in JSX ordentlicher aus als in jQuery, bleibt aber duplizierter State. Am Rande: Aus der React-Gemeinde kommt seit dem Sommer mit Redux ein Bibliotheks-Experiment, das genau diese Übergangsfunktion zum zentralen Baustein erklärt – ein Hinweis darauf, dass die Idee nicht am Spielzeugbeispiel endet.\n\n## Was ich daraus mitnehme\n\nDas Spiel selbst ist ein Nachmittagsvergnügen, ein Prototyp mit acht Motiven und einem Zugzähler. Der Wert steckt in dem, was es erzwungen hat. Erstens: States aufzählen, bevor Code entsteht – schon die Inventur hat mein Bild korrigiert, weil „gesperrt\" auf der falschen Ebene vermutet war. Zweitens: Der State gehört ins Modell; das DOM ist eine Projection davon und taugt nicht als Speicher, sobald Timing ins Spiel kommt. Drittens: eine Übergangsfunktion, die alle Regeln kennt, statt Toggles, die über Handler verteilt sind – dort finden Reviews statt, dort steht die Fachsprache, dort verschwinden zwei Bugs in einer Guard-Zeile. Und die neue Sprache? ES2015 hat das Ganze nicht möglich, aber angenehm gemacht: Klassen für das Modell, Module für die Schnitte, Template-Literals für das Rendering. Babel und jspm sorgen dafür, dass es heute schon in echten Browsern läuft. Das nächste größere Frontend werde ich vermutlich nicht ohne Framework bauen – aber ich werde genauer wissen, welchen Teil der Arbeit das Framework wirklich übernimmt und welcher Teil vorher im eigenen Kopf passieren muss.\n\n## Weiterführende Quellen\n\n- Repository es6-memory: https://github.com/MikeBild/es6-memory\n- MDN: Classes (JavaScript-Referenz): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes\n- MDN: Template literals: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals\n- Babel, der JavaScript-Compiler für ES2015: https://babeljs.io/\n\nDie Links helfen beim Nachschlagen der Sprachfeatures – die Entscheidung, wo State liegt und wer ihn ändert, nehmen sie einem nicht ab."},{"slug":"web-messaging-mit-rxjs","title":"Web Messaging mit RxJS","summary":"Wer eingehende Messages hinter einem Observable versteckt, macht den Transport zwischen Browser und Server austauschbar – und merkt dabei, wie weit gewöhnliches HTTP trägt.","tags":["Software","Reactive"],"published_at":"2015-07-24T10:00:00+00:00","updated_at":"2026-07-14T16:51:17.40526+00:00","url":"https://www.mikebild.dev/de/blog/web-messaging-mit-rxjs/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/7f8b231172edbeddaaf2082dbde95bc7884051c166e7000c8fa34cb1b62379a4/web-messaging-mit-rxjs-vorlesen.mp3","audio_duration_secs":797,"text":"# Web Messaging mit RxJS\n\n```js\nvar incoming = Rx.Observable.create(function (observer) {\n  var source = new EventSource('/subscribe/green');\n\n  source.onmessage = function (event) {\n    observer.onNext(JSON.parse(event.data));\n  };\n  source.onerror = function (error) {\n    observer.onError(error);\n  };\n\n  return function () {\n    source.close();\n  };\n});\n\nincoming\n  .map(function (message) { return message.green; })\n  .distinctUntilChanged()\n  .subscribe(function (count) {\n    render(count);\n  });\n```\n\nDas Interessante an diesem Stück Client-Code ist der Teil, der fehlt. Die Konsumseite – die drei Zeilen ab `incoming.map(...)` – weiß nichts über `EventSource`, nichts über HTTP, nichts über Verbindungen. Sie abonniert einen Strom eingehender Messages und verarbeitet ihn mit denselben Operatoren, mit denen sie auch ein Array oder eine Folge von Mausklicks verarbeiten würde. Wie die Messages technisch ins Haus kommen, ist im `create`-Callback eingesperrt. Genau diese Trennung ist der Kern dessen, was ich in den letzten Wochen ausprobiert habe: Messages zwischen Browser und Server nicht als Verbindungsproblem denken, sondern als Strom – mit RxJS als gemeinsamer Sprache auf beiden Seiten.\n\n## Warum nicht gleich WebSockets?\n\nSobald in einem Projekt das Wort „Echtzeit\" fällt, liegt der Griff zu WebSockets nahe – meist in Form von socket.io, das die Verbindungsdetails freundlich wegkapselt. Das ist oft die richtige Wahl, aber sie hat einen Preis, der gern erst später auffällt. WebSockets sind ein eigenes Protokoll mit eigenem Handshake. Ältere Proxies verschlucken das Upgrade, manche Firmen-Firewalls kappen langlebige Verbindungen kommentarlos, Load Balancer brauchen Sonderbehandlung, und die gewohnte HTTP-Werkzeugkiste – Caching, Auth-Header, Access-Logs, curl zum Debuggen – greift nur noch teilweise. socket.io beantwortet einen Teil davon mit Fallbacks, holt sich dafür aber eine ganze Bibliotheksschicht auf Client und Server ins Projekt.\n\nDabei ist die fachliche Anforderung häufig bescheidener, als das Wort „Echtzeit\" klingt: Der Server soll den Browser über Events informieren, überwiegend in eine Richtung, und eine Latenz von Sekundenbruchteilen genügt völlig. Für dieses Profil hat HTTP seit Jahren Antworten, die ohne Protokollwechsel auskommen: Long-Polling, chunked Responses, Server-Sent Events. Alles davon läuft durch jede Infrastruktur, die HTTP kann – also durch praktisch alles.\n\nDie eigentliche Architekturfrage ist deshalb nicht „WebSockets oder nicht?\", sondern: Wie schreibe ich den Anwendungscode so, dass diese Entscheidung später revidierbar bleibt?\n\n## Ein Experiment: alles ist ein Strom\n\nVor knapp zwei Wochen habe ich dazu ein kleines Repository veröffentlicht, [web-messaging](https://github.com/MikeBild/web-messaging). Die README-Zeile beschreibt den Umfang ehrlich: „Message routing and Complex-Event-Streams via HTTP (Transfer-Encoding: chunked)\". Es ist ein Experiment, kein Produkt – eine einzelne `server.js`, eine `index.html`, kein Express, nicht einmal ein Routing-Framework, sondern ein handgeschriebener Mini-Router aus regulären Ausdrücken. Gerade diese Schlichtheit macht sichtbar, worum es geht.\n\nDer Server modelliert alles, was Messages erzeugt, als Observable. Eine simulierte Quelle tickt alle zwei Sekunden und würfelt eine Gruppe; ein `Subject` dient als Eingang für Messages von außen. Beides fließt in einen gemeinsamen Strom:\n\n```js\nvar rx = require('rx');\n\nvar publishChannel = new rx.Subject();\n\nvar simulated = rx.Observable.interval(2000)\n  .map(function (tick) {\n    return { sequence: tick, group: randomGroup() };\n  });\n\nvar events = simulated\n  .merge(publishChannel)\n  .publish()\n  .refCount();\n\nvar totals = events\n  .scan({}, function (acc, message) {\n    acc[message.group] = (acc[message.group] || 0) + 1;\n    return acc;\n  })\n  .publish()\n  .refCount();\n```\n\nZwei Dinge daran haben sich als tragend erwiesen. Erstens `merge`: Dass eine simulierte Zeitquelle und ein von HTTP-POSTs gefüttertes Subject sich zu einem Strom vereinigen lassen, ohne dass irgendwer davon erfährt, ist keine Spielerei – es heißt, dass „woher kommt die Message?\" eine lokale Entscheidung bleibt. Zweitens `publish().refCount()`: Ohne diese beiden Aufrufe wäre der Strom kalt, und jeder Abonnent bekäme seine eigene Simulation mit eigenem Zähler. So aber teilen sich alle Verbindungen dieselbe heiße Quelle, und `scan` führt genau einen Zählerstand über alle Gruppen – State als Strom, nicht als Variable, an der viele Stellen herumschreiben.\n\nDer `publishChannel` wird von der POST-Route gefüttert, und das ist die vielleicht hübscheste Zeile des Experiments: Ein gewöhnlicher HTTP-Request wird zu einem Event im Strom.\n\n```js\nroute.post(/\\/publish\\/([a-z]*)/, function (req, res) {\n  publishChannel.onNext({ group: req.params[0] });\n  res.end();\n});\n```\n\n## Die Antwort als Observer\n\nSpannender als die Erzeugerseite ist die Auslieferung. Wie kommt ein Server-Strom in einen Browser, ohne WebSockets? Die Antwort im Experiment: Die HTTP-Response bleibt einfach offen. Mit `Transfer-Encoding: chunked` darf ein Server beliebig lange häppchenweise schreiben – und damit wird `res` zu einem Observer, der Messages aus dem Strom entgegennimmt:\n\n```js\nroute.get(/\\/subscribe\\/([a-z]*)/, function (req, res) {\n  res.setHeader('Content-Type', 'application/json');\n  res.setHeader('Transfer-Encoding', 'chunked');\n\n  var subscription = totals\n    .map(function (data) {\n      var group = req.params[0];\n      if (!group) return data;\n      var result = {};\n      result[group] = data[group];\n      return result;\n    })\n    .subscribe(function (data) {\n      res.write(JSON.stringify(data) + '\\n\\n');\n    });\n\n  req.on('close', function () {\n    subscription.dispose();\n  });\n});\n```\n\nJede Verbindung ist eine Subscription. Das klingt banal, erledigt aber nebenbei das Problem, das in handgestrickten Publish-Subscribe-Servern regelmäßig zu Speicherlecks führt: die Lebensdauer. Wenn der Browser die Verbindung schließt – Tab zu, Netz weg, Seite neu geladen – feuert `req.on('close')`, die Subscription wird mit `dispose()` beendet, und weil `refCount` mitzählt, stellt die Quelle die Arbeit ein, sobald der letzte Abonnent weg ist. Aufräumen ist keine Sonderlogik mehr, sondern die natürliche Kehrseite des Abonnierens. In einem Projekt einige Jahre zuvor haben wir für genau dieses Aufräumen eine eigene Registry mit Timeout-Heuristiken gepflegt – hier sind es zwei Zeilen.\n\nEin Side Effect dieses Aufbaus, den ich nicht mehr hergeben möchte: Der Strom lässt sich mit Bordmitteln beobachten. Ein `curl http://localhost:8765/subscribe/green` im Terminal, und die Messages tropfen zeilenweise herein – kein Spezialwerkzeug, kein Browser-Plugin, keine Protokoll-Inspektion. Das Repository hat dafür sogar eine eigene Route `/log`, die den ungefilterten Rohstrom vor der Aggregation ausliefert. Beim Entwickeln hatte ich ständig zwei Terminals offen: eines auf `/log` für die Rohereignisse, eines auf `/subscribe/...` für das, was ein Client tatsächlich sieht. Bei einem WebSocket-Aufbau braucht dieselbe Sichtbarkeit bereits eigenen Werkzeugbau oder zumindest die Frame-View der Browser-Entwicklerwerkzeuge.\n\nDer Fluss im Überblick:\n\n```mermaid\nflowchart LR\n  POST[HTTP POST<br/>/publish/:group] --> SUBJ[Subject<br/>publishChannel]\n  SIM[Interval<br/>Simulation] --> MERGE[merge]\n  SUBJ --> MERGE\n  MERGE --> SCAN[scan<br/>Zählerstand je Gruppe]\n  SCAN --> SUBA[Subscription<br/>Verbindung A]\n  SCAN --> SUBB[Subscription<br/>Verbindung B]\n  SUBA --> RESA[chunked Response A<br/>res.write]\n  SUBB --> RESB[chunked Response B<br/>res.write]\n```\n\nAuf der Client-Seite bin ich im Repository übrigens weniger konsequent: Die `index.html` ist eine kleine AngularJS-1.4-Anwendung, die die Chunks noch von Hand aus einem `XMLHttpRequest` klaubt – bei `readyState === 3` den `responseText` an den doppelten Zeilenumbrüchen zerschneiden und die letzte vollständige Zeile parsen. Das funktioniert, ist aber genau die Sorte Transportdetail, die im Anwendungscode nichts verloren hat. Der `Rx.Observable.create`-Wrapper vom Anfang ist die Konsequenz daraus: einmal geschrieben, versteckt er dieses Gefummel, und alles dahinter spricht nur noch Observable. Wer statt roher Chunks Server-Sent Events verwendet – dasselbe Muster, nur als `text/event-stream` formalisiert – bekommt mit `EventSource` sogar Parsing und Wiederverbinden vom Browser geschenkt.\n\n## Reconnect und Backpressure als Streams-Probleme\n\nZwei Themen entscheiden darüber, ob so ein Aufbau ein Wochenend-Experiment bleibt oder Betrieb aushält. Beide lassen sich als Stream-Probleme formulieren, und das ist der zweite Grund, warum sich RxJS hier lohnt.\n\nErstens der Wiederaufbau der Verbindung. Netzverbindungen reißen – die Frage ist nicht ob, sondern wie oft. Mit `EventSource` verbindet der Browser von sich aus neu und schickt per `Last-Event-ID` sogar mit, wo er stehengeblieben ist, sofern der Server IDs vergibt. Beim rohen XHR-Ansatz baut man das selbst – und statt einer Schleife mit Zustandsvariablen wird daraus ein Operator auf dem Strom:\n\n```js\nvar resilient = incoming.retryWhen(function (errors) {\n  return errors.delay(3000);\n});\n```\n\nDer Fehlerfall ist damit kein Sonderpfad mit eigenem Code, sondern Teil derselben Deklaration wie der Erfolgsfall. Wer klügeres Verhalten will – wachsende Wartezeiten, Aufgeben nach n Versuchen – komponiert es aus denselben Bausteinen, statt eine State Machine zu pflegen.\n\nZweitens Backpressure. Der Server im Experiment schreibt so schnell in die Response, wie der Strom liefert. Was, wenn der Abnehmer nicht hinterherkommt – ein Mobilgerät im Funkloch, ein überlasteter Tab? `res.write` gibt in Node ehrlich `false` zurück, wenn der Kernel-Puffer voll ist; das Experiment ignoriert das großzügig, und genau da hört der Spaß auf. Die saubere Antwort ist wieder eine Stream-Antwort, denn RxJS bringt die Werkzeuge mit, die Rate vor dem Draht zu formen:\n\n- `sample` schickt in festen Abständen nur den jüngsten Stand – für Aggregationen wie den Zählerstand hier ideal, denn veraltete Zwischenstände interessieren niemanden.\n- `bufferWithTime` sammelt Messages und verschickt sie paketweise – wenn nichts verloren gehen darf, aber die Frequenz sinken soll.\n- `pausableBuffered` hält den Strom an, bis der Abnehmer wieder Luft hat – echtes Anhalten statt Verwerfen, etwa gekoppelt an das `drain`-Event der Response.\n\nWichtig ist weniger, welcher der drei Wege gewählt wird, als dass die Frage überhaupt eine Antwort im Modell hat. In der Callback-Welt ist „Erzeuger schneller als Verbraucher\" ein Querschnittsproblem, das sich durch den ganzen Code zieht. Im Strom ist es eine Zeile an der richtigen Stelle.\n\n## Der Transport wird austauschbar\n\nDamit zum eigentlichen Architekturgewinn. Wenn die Konsumseite nur ein Observable sieht, ist der Transport eine Implementierung dieses Interfaces – und Implementierungen kann man tauschen. Dieselbe Anwendung, morgen über WebSockets:\n\n```js\nvar incoming = Rx.Observable.create(function (observer) {\n  var socket = new WebSocket('ws://localhost:8765/subscribe/green');\n\n  socket.onmessage = function (event) {\n    observer.onNext(JSON.parse(event.data));\n  };\n  socket.onerror = function (error) {\n    observer.onError(error);\n  };\n\n  return function () {\n    socket.close();\n  };\n});\n```\n\nDie Zeilen ab `incoming.map(...)` bleiben unverändert, ebenso `retryWhen` und alles, was darauf aufbaut. Long-Polling passt in denselben Rahmen: ein Request, der beim Eintreffen einer Message antwortet, per `flatMap` und Wiederholung zu einem endlosen Strom verkettet. Der Wechsel des Transports ist dann ein lokaler Eingriff in einer Datei – keine Operation am offenen Herzen der Anwendung.\n\n```mermaid\nflowchart LR\n  LP[Long-Polling] --> OBS[Observable<br/>gemeinsame Schnittstelle]\n  SSE[Server-Sent Events] --> OBS\n  CH[chunked HTTP] --> OBS\n  WS[WebSocket] --> OBS\n  OBS --> APP[Anwendungscode<br/>map / filter / scan / retryWhen]\n```\n\nMir ist an diesem Punkt aufgefallen, dass die Diskussion „WebSockets vs. HTTP\" in Teams oft deshalb so zäh ist, weil sie als Einbahnstraße geführt wird – als müsste die Entscheidung für immer gelten. Sobald der Transport hinter einem Observable liegt, verliert sie diese Schwere. Man darf mit dem anspruchslosesten Transport anfangen, der die Anforderung erfüllt, und später umziehen, wenn Messungen es verlangen.\n\n## Wann schlichtes HTTP reicht\n\nBleibt die ehrliche Abwägung. Nach diesem Experiment würde ich gewöhnliches HTTP – chunked oder als Server-Sent Events – überall dort wählen, wo drei Bedingungen zusammenkommen:\n\n- Die Messages fließen ganz überwiegend vom Server zum Browser; die Gegenrichtung sind gelegentliche Kommandos, für die ein POST völlig genügt.\n- Latenzen von einigen hundert Millisekunden stören niemanden – Dashboards, Benachrichtigungen, Fortschrittsanzeigen, Feeds.\n- Die vorhandene HTTP-Infrastruktur – Proxies, Auth, Logging, Debugging mit curl – soll ohne Sonderwege weiterarbeiten.\n\nWebSockets verdienen ihren Platz, wo diese Bedingungen kippen: echter bidirektionaler Verkehr mit vielen kleinen Messages in beide Richtungen, kollaboratives Editieren, Spiele, alles mit spürbarer Latenzanforderung. Dann trägt ein POST je Message nicht mehr, und der Protokollwechsel zahlt sich aus. Ein Wermutstropfen der HTTP-Seite gehört ebenfalls auf den Tisch: Browser deckeln die Zahl gleichzeitiger Verbindungen pro Host, sechs sind üblich – wer viele Streams parallel öffnet, muss sie serverseitig zu einem multiplexen. HTTP/2 ist seit Mai als RFC verabschiedet und wird genau dieses Problem entschärfen, aber im Browser-Alltag ist es noch keine Größe, mit der man heute planen kann.\n\nUnd natürlich bleibt das Experiment ein Experiment: keine Authentifizierung, keine Persistenz, keine Messages-IDs für lückenloses Aufholen nach Reconnect, und ein Nachrichtenraum, der genau so lange existiert wie der eine Node-Prozess. Wer mehr braucht, hängt an das Subject eine echte Quelle – eine Queue, einen Log – und genau dafür ist die Stelle ja schon vorbereitet.\n\n## Unterm Strich\n\nDer Wert dieses kleinen Servers liegt nicht im Transport-Trick mit chunked Responses – den gab es lange vor RxJS. Er liegt darin, dass beide Seiten dieselbe Abstraktion sprechen. Auf dem Server sind Simulation, POST-Eingang und Aggregation Observables, die sich mit `merge` und `scan` komponieren lassen; im Browser ist der eingehende Nachrichtenstrom ein Observable, auf dem `map`, `distinctUntilChanged` und `retryWhen` arbeiten. Verbindungsabbruch, Wiederholung, Ratenbegrenzung, Aufräumen – all das, was in Callback-Code als Sonderfall über die Datei verstreut liegt, hat im Strom einen benannten Platz.\n\nES2015 ist seit ein paar Wochen verabschiedet, und vieles am Code oben wird mit Arrow Functions hübscher werden. Am Kern ändert das nichts: Die Entscheidung, eingehende Messages als Strom zu modellieren und den Transport dahinter zu verstecken, ist unabhängig von Syntax und übrigens auch von RxJS selbst – sie würde mit jeder Observable-Implementierung funktionieren. Was ich aus dem Experiment mitnehme, ist deshalb weniger eine Bibliotheksempfehlung als eine Reihenfolge: erst das Nachrichtenmodell als Strom festlegen, dann den anspruchslosesten Transport wählen, der die Anforderung erfüllt – und die Freiheit behalten, ihn zu wechseln, wenn die Wirklichkeit es verlangt.\n\n## Weiterführende Quellen\n\n- [MikeBild/web-messaging](https://github.com/MikeBild/web-messaging) – das besprochene Experiment: Message-Routing und Event-Streams über HTTP mit chunked Transfer-Encoding\n- [Reactive-Extensions/RxJS](https://github.com/Reactive-Extensions/RxJS) – die Reactive Extensions für JavaScript mit Dokumentation aller Operatoren\n- [Server-sent events (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) – `EventSource` und das `text/event-stream`-Format\n- [ReactiveX: Einführung](https://reactivex.io/intro.html) – das Observable-Modell und die Operator-Idee sprachübergreifend erklärt"},{"slug":"funktionale-stile-im-alltag","title":"Funktionale Stile im Alltag","summary":"Warum funktionaler Stil im Team-Alltag 2015 nicht Haskell bedeutet, sondern die Disziplin, Pure Functions von I/O zu trennen – und wie ES2015 das endlich bequem macht.","tags":["Software","Architektur"],"published_at":"2015-07-12T10:00:00+00:00","updated_at":"2026-07-14T16:49:16.61261+00:00","url":"https://www.mikebild.dev/de/blog/funktionale-stile-im-alltag/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/5770702d2e3369f179b1c77d169ed73f026dbd79f9f515bb7b55ce5f091b5359/funktionale-stile-im-alltag-vorlesen.mp3","audio_duration_secs":701,"text":"# Funktionale Stile im Alltag\n\nWenn Geschäftslogik und Side Effects ungetrennt im selben Funktionsrumpf stehen, hat das eine sehr konkrete Konsequenz: Jeder Test braucht eine Datenbank. Wer prüfen will, ob eine Rechnung als überfällig gilt, muss vorher Testdaten einspielen, einen Mailer durch eine Attrappe ersetzen und hinterher alles wieder aufräumen. Die Testsuite läuft Minuten statt Sekunden, also läuft sie seltener, also traut sich niemand mehr an genau die Stellen, die eine Änderung am dringendsten nötig hätten. Das ist kein Werkzeugproblem und kein Framework-Problem – es ist ein Stilproblem.\n\nFunktionale Programmierung gilt vielen Kollegen immer noch als akademische Veranstaltung: Haskell, Monaden, Typtheorie. Was ich im Projektalltag darunter verstehe, ist deutlich bescheidener und gleichzeitig wirksamer: Pure Functions als Standardbaustein, Datenfluss statt Zustandsmutation, `map`/`filter`/`reduce` statt Schleifenrümpfen mit Side Effects. Keine neue Sprache, keine neue Runtime – eine Disziplin in der Sprache, die ohnehin schon im Einsatz ist.\n\n## Reine Funktionen als Standardbaustein\n\nEine Pure Function ist schnell erklärt: gleiche Eingabe, gleiche Ausgabe, keine beobachtbare Side Effect. Sie liest nicht die Uhr, schreibt nicht in die Datenbank, verändert keine Variablen außerhalb ihres Rumpfs. Das klingt banal, ist im gewachsenen Code aber die Ausnahme. Typischer ist eine Funktion, die Daten lädt, filtert, umrechnet, speichert und nebenbei noch eine E-Mail verschickt – fünf Verantwortungen in dreißig Zeilen.\n\nAnfang des Jahres habe ich mir Crista Lopes' Sammlung [exercises-in-programming-style](https://github.com/MikeBild/exercises-in-programming-style) geforkt: dieselbe kleine Aufgabe – eine Worthäufigkeitsanalyse – in Dutzenden Programmierstilen, jeder Stil ein eigener Ordner mit Programm und Beschreibung. Beim Durcharbeiten ist mir etwas aufgefallen, das mir vorher so klar nicht war: Stil ist eine Entscheidung, kein Sprachmerkmal. Dieselbe Aufgabe lässt sich in Python monolithisch, objektorientiert oder als Pipeline Pure Functions schreiben. Die Sprache verhindert nichts davon und erzwingt nichts davon. Das gilt für JavaScript und C# genauso.\n\nDie Disziplin besteht darin, die Pure Function zum Normalfall zu machen. Nicht: „Wo könnte ich mal etwas Funktionales einstreuen?\", sondern umgekehrt: Jede Funktion ist rein, bis ein Grund dagegen spricht. Und die Gründe dagegen – I/O, Zeit, Zufall, DOM, Datenbank – sammeln sich dann an wenigen, benannten Stellen statt überall.\n\n## Was ES2015 daran ändert\n\nSeit Juni ist ECMAScript 2015 verabschiedet – die [Spezifikation](https://262.ecma-international.org/6.0/) ist final, und Babel 5 übersetzt den neuen Stand heute schon auf ES5 herunter. Node 0.12 und io.js beherrschen Teile davon nativ, für den Rest bleibt der Transpiler im Build. Für den funktionalen Stil ist das mehr als Kosmetik, denn drei Neuerungen senken genau die Schreibkosten, an denen dieser Stil bisher gescheitert ist:\n\n- Arrow Functions machen kleine Funktionen so billig, dass man sie tatsächlich schreibt – `x => x.amount` statt eines vierzeiligen `function`-Blocks mit `return`.\n- `const` dokumentiert im Code, dass eine Bindung nicht neu zugewiesen wird – die Leserichtung dreht sich: Mutation wird zur markierten Ausnahme.\n- Destrukturierung zerlegt Datenstrukturen ohne Hilfsvariablen, was Funktionen über schlichten Objekten erst angenehm macht.\n\nDer Transpiler ist dabei eine ehrliche Zusatzkosten-Position: Der Build wird um einen Schritt länger, Sourcemaps müssen stimmen, und wer im Browser debuggt, schaut gelegentlich auf generierten ES5-Code statt auf das, was er geschrieben hat. In unserem Fall war das den Tausch wert – aber es gehört auf den Tisch, bevor das Team zustimmt, nicht danach.\n\nVorher war funktionaler Stil in JavaScript möglich, aber syntaktisch teuer. Ein `function (invoice) { return !invoice.paid; }` an jeder Filterstelle ermüdet, und was ermüdet, unterbleibt. Mit Pfeilfunktionen kippt die Abwägung: Die kompakte Form ist jetzt auch die disziplinierte Form. Ich habe in einem laufenden Projekt erlebt, wie allein die Umstellung auf Babel die Zahl kleiner Hilfsfunktionen im Team vervielfacht hat – nicht weil jemand es angeordnet hätte, sondern weil es plötzlich der bequemste Weg war.\n\n## Vom Schleifenrumpf zur Datenleitung\n\nKonkret wird der Unterschied an einem Stück Code, wie es in jedem Backoffice-System steht: Aus einer Liste von Rechnungen sollen die überfälligen ermittelt, Mahnungen erzeugt und die offene Summe berechnet werden. Die verbreitete Form sieht so aus:\n\n```js\nfunction processInvoices(invoices, db, mailer) {\n  let total = 0;\n  for (let i = 0; i < invoices.length; i++) {\n    const invoice = invoices[i];\n    if (!invoice.paid && invoice.dueDate < Date.now()) {\n      total += invoice.amount;\n      db.markAsOverdue(invoice.id);\n      mailer.sendReminder(invoice.customerEmail, invoice.amount);\n    }\n  }\n  return total;\n}\n```\n\nDer Code funktioniert, und genau das macht ihn langlebig – niemand fasst eine Funktion an, die seit zwei Jahren unauffällig ihren Dienst tut. Trotzdem vermischt der Schleifenrumpf vier Dinge: die fachliche Regel (wann ist eine Rechnung überfällig?), die Aggregation (Summe), den Datenbankzugriff und den Mailversand. Dazu liest die Funktion mit `Date.now()` heimlich die Uhr – derselbe Aufruf liefert um Mitternacht ein anderes Ergebnis als um 23:59. Testbar ist das nur mit echter Datenbank, echtem Mailer-Ersatz und eingefrorener Zeit.\n\nDieselbe Logik als Datenfluss, mit ES2015:\n\n```js\nconst isOverdue = (today) => (invoice) =>\n  !invoice.paid && invoice.dueDate < today;\n\nconst toReminder = (invoice) => ({\n  invoiceId: invoice.id,\n  email: invoice.customerEmail,\n  amount: invoice.amount\n});\n\nconst totalAmount = (invoices) =>\n  invoices.reduce((sum, { amount }) => sum + amount, 0);\n\nconst overdueReminders = (today, invoices) => {\n  const overdue = invoices.filter(isOverdue(today));\n  return {\n    reminders: overdue.map(toReminder),\n    total: totalAmount(overdue)\n  };\n};\n```\n\nVier Pure Functions. Die Zeit kommt als Parameter herein statt aus der Uhr, die fachliche Regel steht isoliert in `isOverdue`, und `overdueReminders` beschreibt nur noch, was aus den Daten werden soll – nicht, wie eine Laufvariable hochzuzählen ist. Der Test dafür ist ein Array-Literal und eine Gleichheitsprüfung, keine Datenbank weit und breit. Und wer den Code in einem halben Jahr liest, sieht die Regel auf einen Blick, statt sie aus einem Schleifenrumpf herauszupräparieren.\n\n## Der unreine Rand\n\nDie ehrliche Grenze gehört dazu: `db.markAsOverdue` und `mailer.sendReminder` sind nicht verschwunden. I/O, DOM und Datenbank bleiben unrein – eine Anwendung, die nichts liest und nichts schreibt, ist nutzlos. Der Punkt ist nicht, Side Effects zu verbieten, sondern sie an den Rand zu schieben, wo sie sichtbar und dünn sind.\n\n```mermaid\nflowchart LR\n  DB[(Datenbank)] -->|Rechnungen laden| Edge[Unreiner Rand<br/>I/O, Uhr, Mailer]\n  Edge -->|Daten hinein| Core[Reiner Kern<br/>isOverdue, toReminder,<br/>totalAmount]\n  Core -->|Ergebnis heraus| Edge\n  Edge -->|Mahnungen senden| Mail[Mailserver]\n```\n\nAm Rand sieht der Code dann so aus – Promises sind mit ES2015 Teil der Sprache:\n\n```js\nconst today = Date.now();\n\ndb.loadInvoices()\n  .then((invoices) => overdueReminders(today, invoices))\n  .then(({ reminders, total }) => {\n    reminders.forEach((reminder) => mailer.sendReminder(reminder));\n    log.info(`sent ${reminders.length} reminders, open amount: ${total}`);\n  });\n```\n\nDieser Rand ist bewusst langweilig: laden, Pure Function aufrufen, Ergebnis wegschreiben. Er wird kaum je einzeln getestet, weil er kaum Logik enthält – die Logik steht im Kern, und der Kern kennt weder Datenbank noch Mailer. In einem Projekt haben wir genau nach diesem Schnitt die Integrationstests von einer halben Stunde auf eine Handvoll Randfälle reduziert; alles Fachliche lief als reiner Unit-Test in Sekunden.\n\n## Der Blick aus der .NET-Welt\n\nWem das als JavaScript-Mode erscheint, dem hilft ein Blick in die .NET-Welt, aus der ich ebenfalls komme. Dort heißt derselbe Gedanke seit Jahren LINQ und regt niemanden mehr auf:\n\n```csharp\nvar overdue = invoices\n    .Where(invoice => !invoice.Paid && invoice.DueDate < today)\n    .ToList();\n\nvar reminders = overdue.Select(invoice => new Reminder\n{\n    InvoiceId = invoice.Id,\n    Email = invoice.CustomerEmail,\n    Amount = invoice.Amount\n});\n\nvar total = overdue.Sum(invoice => invoice.Amount);\n```\n\n`Where`, `Select`, `Sum` – das sind `filter`, `map` und ein spezialisiertes `reduce`. In C#-Teams schreibt das seit Jahren jeder, ohne es funktionale Programmierung zu nennen; es ist schlicht die normale Art, mit Auflistungen zu arbeiten. Wer eine `foreach`-Schleife mit Laufsumme in ein Review bringt, bekommt dort die Rückfrage, warum es kein LINQ ist. Diese Beobachtung nimmt dem Thema die Ideologie: Es geht nicht um ein Lager, sondern um eine Ausdrucksform, die sich in einer sehr pragmatischen Umgebung längst durchgesetzt hat – JavaScript zieht mit ES2015 gerade nach.\n\n## Underscore, Lodash, Ramda\n\nFür die Lücken der Standardbibliothek gibt es Werkzeugkästen. Underscore war jahrelang gesetzt, Lodash hat es in Version 3 praktisch abgelöst – gleiche Idee, mehr Funktionen, und verkettete Aufrufe werden seit Lodash 3 lazy ausgewertet, sodass lange Ketten nicht bei jedem Schritt ein Zwischen-Array erzeugen müssen. [Ramda](https://ramdajs.com/) geht seit gut einem Jahr einen Schritt weiter: Alle Funktionen sind automatisch gecurryt, und die Daten kommen als letztes Argument, sodass sich Verarbeitungsschritte zu neuen Funktionen zusammenstecken lassen:\n\n```js\nconst openAmountOf = R.pipe(\n  R.filter(isOverdue(today)),\n  R.map(R.prop('amount')),\n  R.sum\n);\n\nconst total = openAmountOf(invoices);\n```\n\n`openAmountOf` ist eine benannte, wiederverwendbare Pipeline – kein Aufruf, sondern eine Definition. Das ist elegant, und genau hier beginnt die Reibung.\n\n## Wo es im Team reibt\n\nDer funktionale Stil hat im Team-Alltag echte Kosten, und wer sie verschweigt, verliert die Skeptiker zu Recht. Drei Punkte begegnen mir regelmäßig:\n\n- Debugging von Ketten ist mühsamer als Debugging einer Schleife. In einem `for`-Rumpf setzt man einen Haltepunkt und sieht alle lokalen Variablen; in einer `pipe`-Kette aus anonymen Funktionen zeigt der Stacktrace kryptische Namen, und für einen Blick auf Zwischenwerte muss man die Kette aufbrechen oder eine `tap`-Funktion einschieben.\n- Point-free-Schreibweise kippt schnell in Selbstzweck. `R.pipe(R.filter(...), R.map(R.prop('amount')), R.sum)` liest ein geübtes Auge flüssig – drei weitere Kompositionsebenen später liest es niemand mehr, auch der Autor nicht. Im Review hat sich bei uns eine einfache Frage bewährt: Kann der Kollege am Nachbartisch die Zeile ohne Erklärung laut vorlesen und sagen, was sie tut?\n- Das Performance-Argument kommt zuverlässig und ist meistens ein Mythos. Ja, `filter` und `map` erzeugen Zwischen-Arrays, und eine handgeschriebene Schleife ist auf einer Million Elementen schneller. Nur verarbeiten die wenigsten Codestellen eine Million Elemente – sie verarbeiten dreißig Rechnungen, und der eigentliche Engpass ist der Datenbankaufruf daneben. Erst messen, dann optimieren; die zwei heißen Pfade, die es wirklich betrifft, dürfen gern eine kommentierte Schleife bleiben.\n\nDazu kommt eine soziale Komponente: Ein Team, in dem zwei Leute Ramda lieben und fünf es nicht lesen können, hat mit dem Stil nichts gewonnen. Der gemeinsame Nenner – `map`, `filter`, `reduce`, Pure Functions, dünner Rand – trägt weiter als die vollendete Komposition. Die Bibliothek ist verhandelbar, die Disziplin nicht.\n\n## Ausblick: Ströme\n\nWer die Trennung von Kern und Rand einmal verinnerlicht hat, findet die nächste Stufe fast von selbst. [RxJS](https://github.com/Reactive-Extensions/RxJS) behandelt Events – Klicks, WebSocket-Messages, Timer – als Auflistungen über die Zeit, mit demselben Vokabular: `map`, `filter`, `reduce` beziehungsweise `scan` auf Strömen statt auf Arrays. Die Pure Functions aus dem Kern lassen sich unverändert einsetzen; nur der unreine Rand spricht dann eben Observables statt Promises. Für Oberflächen mit vielen gleichzeitigen Event Sources ist das ein spürbarer Gewinn – aber das ist ein eigener Artikel.\n\n## Unterm Strich\n\nFunktionale Programmierung im Alltag von 2015 heißt für mich nicht, die Sprache zu wechseln oder dem Team Kategorientheorie zuzumuten. Es heißt, drei Gewohnheiten zu pflegen: Funktionen sind rein, bis ein Grund dagegen spricht. Daten fließen durch `map`, `filter` und `reduce`, statt in Schleifenrümpfen mutiert zu werden. Und alles Unreine – Uhr, Datenbank, DOM, Netzwerk – steht gesammelt an einem dünnen, langweiligen Rand. ES2015 macht diese Gewohnheiten zum ersten Mal wirklich bequem, LINQ zeigt seit Jahren, dass sie mainstream-tauglich sind, und die Testsuite dankt es sofort: Der Teil des Systems, in dem die Fehler wehtun, braucht plötzlich keine Datenbank mehr. Dafür muss niemand Haskell lernen – nur konsequent bleiben, wenn die nächste Schleife mit Laufsumme im Review auftaucht.\n\n## Weiterführende Quellen\n\n- [exercises-in-programming-style](https://github.com/MikeBild/exercises-in-programming-style) – mein Fork von Crista Lopes' Stil-Sammlung: eine Aufgabe, Dutzende Stile\n- [ECMAScript 2015 Language Specification](https://262.ecma-international.org/6.0/) – der frisch verabschiedete Standard\n- [Array.prototype.reduce (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce) – die unterschätzteste der drei Kernfunktionen\n- [Ramda](https://ramdajs.com/) – funktionale Bibliothek mit Currying und Daten-zuletzt-Konvention\n- [RxJS](https://github.com/Reactive-Extensions/RxJS) – Reactive Extensions für JavaScript, derselbe Stil auf Event Streams"},{"slug":"container-sind-keine-architektur","title":"Container sind keine Architektur","summary":"Docker macht Systemgrenzen sichtbar und transportabel – gezogen werden müssen sie weiterhin von Hand, und ein Monolith bleibt auch im Container ein Monolith.","tags":["Software","Platform"],"published_at":"2015-06-12T10:00:00+00:00","updated_at":"2026-07-14T16:47:25.291379+00:00","url":"https://www.mikebild.dev/de/blog/container-sind-keine-architektur/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/0df37d999e79d0e8c0b0b274ecd32c888f754777b5859e21039f8424c85e582c/container-sind-keine-architektur-vorlesen.mp3","audio_duration_secs":659,"text":"# Container sind keine Architektur\n\nEin schlecht geschnittener Monolith wird nicht besser, wenn man ihn in einen Container steckt. Er bleibt derselbe Monolith – dieselben verfilzten Abhängigkeiten, dieselbe geteilte Datenbank, dasselbe Deployment, bei dem alles auf einmal rausgeht. Neu ist nur das Dockerfile obendrauf. Trotzdem ist gerade überall dieselbe Gleichsetzung zu hören, auf Konferenzen wie in Projektbesprechungen: Wir führen Docker ein, also machen wir jetzt Microservices. Das eine hat mit dem anderen zunächst nichts zu tun. Docker verpackt Software. Es entwirft sie nicht.\n\nDie Verwechslung ist kein Zufall, und sie ist auch nicht dumm. Sie hat einen Grund, und den lohnt es sich anzuschauen – genauso wie die Frage, was Container tatsächlich leisten. Denn das ist eine ganze Menge. Nur eben etwas anderes als Architektur.\n\n## Warum die Gleichsetzung so gut funktioniert\n\nBeide Themen sind zur selben Zeit groß geworden. Docker steht bei Version 1.6, die 1.7 ist in Arbeit, in anderthalb Wochen beginnt die DockerCon in San Francisco, und nach dem monatelangen öffentlichen Streit zwischen Docker und CoreOS über das „richtige\" Containerformat formiert sich gerade eine Initiative für einen offenen, herstellerneutralen Standard. Bei der Orchestrierung herrscht Wildwuchs: Mesos auf der einen Seite, Dockers eigenes Swarm auf der anderen, und Google arbeitet an Kubernetes, das noch vor der Version 1.0 steht. Parallel dazu ist der Text von James Lewis und Martin Fowler über Microservices seit gut einem Jahr der Referenzpunkt jeder Architekturdiskussion.\n\nBeide Wellen tragen dasselbe Vokabular vor sich her: kleine, unabhängige Einheiten, isoliert, einzeln deploybar. Wer die Texte aber nebeneinanderlegt, sieht die Lücke sofort. Lewis und Fowler reden über fachliche Schnitte, über Teams, über Datenhoheit. Die Docker-Dokumentation redet über Images, Layer und Prozesse. Dass ein Container eine „unabhängige Einheit\" ist, gilt für den Prozess und sein Dateisystem – nicht für die Fachlichkeit, die darin steckt. Ein Container weiß nicht, ob in ihm ein sauber geschnittener Dienst läuft oder ein Gebirge aus zwölf Jahren gewachsenem Code. Ihm ist es auch egal.\n\nDie Gleichsetzung funktioniert, weil sie bequem ist. Ein Dockerfile schreibt sich an einem Nachmittag. Einen Modulschnitt, über den zwei Teams nicht mehr streiten, bekommt man nicht an einem Nachmittag – der braucht Wochen an Gesprächen über Daten, Zuständigkeiten und Schnittstellen, und am Ende steht kein vorzeigbares Werkzeug, sondern nur ein System, das sich leichter ändern lässt. Wenn beides unter demselben Buzzword segelt, gewinnt der Nachmittag. Vorzeigen lässt sich schließlich nur einer von beiden.\n\n## Was ein Container wirklich leistet\n\nAnfang des Jahres habe ich eine kleine .NET-API in einen Container gepackt: Nancy als Web-Framework, selbst gehostet, LiteDB als Dateidatenbank, das Ganze auf Mono, damit es auf Linux läuft. Kein Kundenprojekt, ein Experiment – ich wollte wissen, ob der Weg von Windows-geprägtem .NET-Code zu einem Linux-Container ohne große Verrenkungen begehbar ist. Er ist es. Das Dockerfile war ernüchternd unspektakulär:\n\n```dockerfile\nFROM mono:3.12\n\nCOPY . /app\nWORKDIR /app\n\nRUN nuget restore shop-api.sln && \\\n    xbuild /p:Configuration=Release shop-api.sln\n\nEXPOSE 8080\nCMD [\"mono\", \"src/ShopApi/bin/Release/ShopApi.exe\"]\n```\n\nGenau diese Unspektakularität ist der Punkt. Alles, was der Dienst zum Laufen braucht, steht in dreizehn Zeilen: die Runtime samt Version, der Build-Weg, der Port, das eine Kommando, mit dem der Prozess startet. Vorher stand dieses Wissen verteilt in einer Wiki-Seite („Entwicklungsumgebung aufsetzen, 14 Schritte\"), in den Köpfen von zwei Kollegen und in der Konfiguration eines Build-Servers, den niemand mehr anfassen wollte. Jetzt steht es im Repository, wird mit dem Code versioniert und im Review gelesen.\n\nDas ist der ehrliche Nutzen von Containern, und er ist erheblich:\n\n- Dieselbe Umgebung auf dem Notebook, im CI und auf dem Server – „läuft bei mir\" verliert seinen Schrecken.\n- Ein expliziter Entry Point: genau ein Kommando startet den Prozess, nichts hängt an einem angemeldeten Benutzer oder einer Terminal-Session.\n- Abhängigkeiten stehen im Image statt im Wiki – wer wissen will, was der Dienst braucht, liest das Dockerfile, nicht die Erinnerung des dienstältesten Kollegen.\n- Ein Artefakt, das sich unverändert von Umgebung zu Umgebung schieben lässt, statt auf jeder Maschine neu zusammengebaut zu werden.\n\nMan beachte, was in dieser Liste nicht vorkommt: fachliche Grenzen, Datenhoheit, unabhängige Releases. Das Dockerfile oben verrät nicht, ob `ShopApi.exe` ein fokussierter Dienst ist oder ein Koloss. Es baut beides gleich klaglos.\n\n## Der Monolith zieht nur um\n\nWie wenig die Verpackung am Inhalt ändert, zeigt sich, sobald man einen echten Monolithen containerisiert. Nehmen wir einen Shop, wie er zigfach existiert: Bestellungen, Rechnungen und Katalog in einer Solution, eine gemeinsame Datenbank, ein Deployment. Mit Docker Compose ist die lokale Umgebung dafür schnell beschrieben:\n\n```yaml\nshop-api:\n  build: .\n  ports:\n    - \"8080:8080\"\n  links:\n    - shop-db\nshop-db:\n  image: postgres:9.4\n  environment:\n    POSTGRES_DB: shop\n```\n\nDiese Datei ist nützlich – jeder im Team startet den Stand mit einem Kommando. Aber sie ist vor allem eines: ehrlich. Sie dokumentiert schwarz auf weiß, dass es genau eine deploybare Einheit gibt und eine Datenbank, in die alle Teile hineinschreiben. Wer im Rechnungs-Code eine Zeile ändert, baut und deployt weiterhin den kompletten Shop. Wer das Bestell-Schema migriert, hält weiterhin den Atem an, weil auch der Katalog auf denselben Tabellen sitzt. Der Release-Takt, die Kopplung, die Angst vor der Migration – alles unverändert.\n\n```mermaid\nflowchart LR\n  subgraph before[\"Vorher\"]\n    M1[\"Monolith<br/>Orders + Billing + Catalog\"] --> D1[(\"eine Datenbank\")]\n  end\n  subgraph after[\"Nachher – mit Docker\"]\n    M2[\"Container<br/>Monolith<br/>Orders + Billing + Catalog\"] --> D2[(\"eine Datenbank\")]\n  end\n  before -. \"docker build\" .-> after\n```\n\nDas Diagramm ist absichtlich langweilig. Links und rechts steht dasselbe System. Der Container hat die Struktur sichtbar und transportabel gemacht – erzeugt hat er keine neue. Und Sichtbarkeit ist nicht wenig! Aber sie wird gern mit Veränderung verwechselt, so wie ein Umzugskarton mit Aufräumen verwechselt wird. Erst wenn man ihn packt, sieht man, was sich angesammelt hat. Weggeworfen ist damit noch nichts.\n\n## Grenzen sichtbar machen heißt nicht Grenzen ziehen\n\nDie eigentliche Arbeit heißt Modulschnitt, und sie findet vor jedem Dockerfile statt. Drei Fragen entscheiden, ob ein Schnitt trägt. Wem gehören welche Daten – wer darf die Bestelltabellen ändern, und wer bekommt sie nur über eine Schnittstelle zu sehen? Wie sieht der Vertrag zwischen den Teilen aus – welche Aufrufe sind erlaubt, und wie stabil bleiben sie, wenn sich innen etwas ändert? Und: Was lässt sich unabhängig ausliefern, ohne dass zwei Teams ihre Kalender abgleichen müssen?\n\nNichts davon braucht Docker. Ein sauberer Schnitt lässt sich mitten im Monolithen ziehen, in derselben Solution, im selben Prozess. Zwischen dem Bestell-Modul und dem Rechnungs-Modul steht dann ein bewusst schmaler Vertrag:\n\n```csharp\n// the only allowed path from Orders into Billing –\n// no shared tables, no reaching into internals\npublic interface IBillingService\n{\n    Invoice CreateInvoice(OrderId orderId, Money total);\n}\n```\n\nDazu gehört die unbequemere Hälfte: `Orders` schreibt ausschließlich in seine eigenen Tabellen, `Billing` in seine. Kein Join quer über die Modulgrenze, auch nicht „nur für den Report\". Wenn diese Disziplin eine Weile gehalten hat, ist der Rest Mechanik. Dann – und erst dann – wird aus dem Modul bei Bedarf ein eigener Container, und die Compose-Datei sieht plötzlich nach dem aus, was Lewis und Fowler beschreiben:\n\n```yaml\norders:\n  build: ./orders\n  ports:\n    - \"8080:8080\"\n  links:\n    - orders-db\n    - billing\norders-db:\n  image: postgres:9.4\nbilling:\n  build: ./billing\n  links:\n    - billing-db\nbilling-db:\n  image: postgres:9.4\n```\n\nDer springende Punkt: Diese Datei ist das Ergebnis der Entwurfsarbeit, nicht ihr Ersatz. Wer sie hinschreibt, ohne den Schnitt vorher im Code und in den Daten vollzogen zu haben, bekommt zwei Container, die sich gegenseitig in die Datenbank greifen – denselben Monolithen, jetzt mit Netzwerklatenz dazwischen. Das ist die teuerste Variante eines Monolithen, die ich kenne: verteilt, aber nicht entkoppelt. Jedes Orchestrierungswerkzeug, ob Mesos, Swarm oder demnächst Kubernetes, multipliziert diesen Zustand nur. Es verteilt, was man ihm gibt. Gibt man ihm einen verfilzten Schnitt, verteilt es Verfilzung.\n\n```mermaid\nflowchart LR\n  subgraph om[\"Modul Orders\"]\n    O[\"orders\"] --> ODB[(\"orders-db\")]\n  end\n  subgraph bm[\"Modul Billing\"]\n    B[\"billing\"] --> BDB[(\"billing-db\")]\n  end\n  O -- \"Vertrag:<br/>CreateInvoice(orderId, total)\" --> B\n```\n\nDieses Bild ist der Unterschied. Nicht die Kästen sind interessant, sondern die Pfeile, die fehlen: kein Pfeil von `orders` auf `billing-db`, kein Rückkanal an der Schnittstelle vorbei. Solche fehlenden Pfeile zeichnet einem kein Werkzeug. Die muss man sich erarbeiten, gegen den Widerstand des Tagesgeschäfts, das immer einen guten Grund für die eine kleine Abkürzung hat.\n\n## Der Container als Prüfstein\n\nWenn Container beim Entwurf überhaupt helfen, dann als Prüfstein. Bei dem Mono-Experiment ist mir aufgefallen, dass die unangenehmste Frage nicht „Wie baue ich das Image?\" war, sondern „Was gehört hinein?\". Der Dienst zog Konfiguration aus drei Quellen, erwartete ein Netzlaufwerk und schrieb Logs an einen fest verdrahteten Pfad. Nichts davon war vorher ein Problem gewesen, weil die Maschine, auf der er lief, all das stillschweigend bereitstellte. Der Container stellt nichts stillschweigend bereit. Er zwingt dazu, jede Annahme entweder ins Image zu schreiben oder als Umgebungsvariable an die Oberfläche zu holen. Jede dieser Annahmen war im Grunde eine unsichtbare Kopplung – an eine Maschine, an eine Abteilung, an ein Stück Betriebsfolklore, das nirgends aufgeschrieben war.\n\nDas ist dieselbe Sorte Zwang, die ein guter Modulschnitt ausübt, nur eine Ebene tiefer. Wer für einen Dienst nicht sagen kann, was in sein Image gehört und was von außen kommt, kennt die Grenzen dieses Dienstes nicht – und sollte über Microservices noch gar nicht reden. Insofern taugt die Containerisierung des Monolithen als erster Schritt durchaus: nicht weil sie Architektur wäre, sondern weil sie die Inventur erzwingt, ohne die kein späterer Schnitt gelingt. Man lernt dabei mehr über sein System, als einem lieb ist.\n\nNur sollte man den Schritt dann auch als das behandeln, was er ist. Ein containerisierter Monolith ist ein völlig legitimer Zustand – reproduzierbar gebaut, überall gleich startbar, mit dokumentierten Abhängigkeiten. Für viele Systeme ist das auf Jahre hinaus genug. Verkauft man ihn dagegen als „Microservices-Migration, Phase eins\", hat man ein Versprechen in die Welt gesetzt, das die Verpackung nicht einlösen kann.\n\n## Was ich daraus mitnehme\n\nFür mich hat sich eine Reihenfolge herausgeschält, die sich in Diskussionen bewährt: erst den Monolithen containerisieren und die betrieblichen Vorteile mitnehmen – gleiche Umgebung überall, expliziter Prozess-Einstieg, Abhängigkeiten im Image. Parallel dazu die Grenzen im Code ziehen, wo sie nichts kosten: Module, eigene Tabellen, schmale Verträge wie das `IBillingService`-Beispiel oben. Und erst wenn eine Grenze über mehrere Releases stabil geblieben ist und ein konkreter Grund existiert – anderer Release-Takt, andere Last, anderes Team – wird aus dem Modul ein eigener Dienst mit eigenem Container.\n\nDocker macht auf diesem Weg zwei Dinge hervorragend: Es transportiert die Einheiten, die man geschnitten hat, und es deckt schonungslos auf, wo man noch gar nicht geschnitten hat. Beides ist wertvoll. Keines davon ist Entwurf. Die Standardisierung, die sich gerade um das Containerformat abzeichnet, wird die Verpackung noch selbstverständlicher machen – umso wichtiger wird es, sie nicht mit dem Inhalt zu verwechseln. Ein Dockerfile beantwortet die Frage, wie ein Prozess startet. Wer die Frage beantworten will, wo ein System endet und das nächste beginnt, sitzt weiterhin vor einem Whiteboard, nicht vor einer YAML-Datei.\n\n## Weiterführende Quellen\n\n- [MikeBild/dotnet-docker auf GitHub](https://github.com/MikeBild/dotnet-docker) – das erwähnte Experiment: Nancy, Self-Hosting und LiteDB auf Mono im Container\n- [Microservices – James Lewis und Martin Fowler](https://martinfowler.com/articles/microservices.html) – der Referenztext zu fachlichem Schnitt, Datenhoheit und unabhängiger Auslieferung\n- [Dockerfile-Referenz](https://docs.docker.com/reference/dockerfile/) – die offizielle Beschreibung aller Build-Anweisungen\n- [Docker Compose Dokumentation](https://docs.docker.com/compose/) – Grundlagen zur Beschreibung mehrerer Dienste in einer Datei"},{"slug":"programming-styles-architekturvergleich","title":"Programming Styles als Architekturvergleich","summary":"Dieselbe kleine Aufgabe in Dutzenden Programmierstilen gelöst ist der kompakteste Architekturvergleich, den ich kenne – weil jeder Stil eine bewusst gewählte Constraint-Menge ist.","tags":["Software","Architektur"],"published_at":"2015-05-24T10:00:00+00:00","updated_at":"2026-07-14T16:44:07.688862+00:00","url":"https://www.mikebild.dev/de/blog/programming-styles-architekturvergleich/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/3e0c4b9e253f4ddd81cda72221d56929596347f36027544f1df3570028a2557c/programming-styles-architekturvergleich-vorlesen.mp3","audio_duration_secs":673,"text":"# Programming Styles als Architekturvergleich\n\nEin Programmierstil ist keine Geschmacksfrage. Zumindest nicht in der Bedeutung, die Cristina Videira Lopes dem Wort in ihrem Buch „Exercises in Programming Style\" (2014) gibt: Ein Stil ist dort eine Menge von Regeln, die festlegen, welche Mittel erlaubt und welche verboten sind. Keine globalen Variablen. Nur Funktionen, die ihre Eingabe unverändert lassen. Kein direkter Aufruf zwischen Komponenten. Wer in einem Stil schreibt, unterwirft sich einer Constraint-Menge – und genau diese Constraints erzeugen die Eigenschaften des Programms, nicht die persönliche Vorliebe für Einrückung oder Namensgebung.\n\nDas Buch treibt diese Idee mit einem Kunstgriff auf die Spitze, den Lopes von Raymond Queneau geliehen hat. Queneau erzählt in „Exercices de style\" (1947) dieselbe belanglose Alltagsszene auf 99 verschiedene Arten. Lopes macht dasselbe mit einem Programm: Eine einzige, bewusst banale Aufgabe wird in der ersten Auflage in 33 Stilen gelöst. Die Aufgabe ist Wortfrequenzzählung – Term Frequency:\n\n- eine Textdatei lesen (im Repo: „Pride and Prejudice\")\n- den Text normalisieren, also Kleinschreibung und Satzzeichen entfernen\n- Stopwörter wie „the\" und „of\" herausfiltern\n- die Häufigkeit jedes Wortes zählen\n- die 25 häufigsten Wörter ausgeben\n\nJede Lösung ist ein kurzes Python-Skript, alle liefern dieselbe Ausgabe, alle laufen mit demselben Aufruf. Ich habe das begleitende Repository Anfang Mai geforkt und mich Stil für Stil durchgearbeitet – nicht gelesen, sondern getippt, verändert, kaputt gemacht und repariert. Dabei ist mir etwas aufgefallen, das über das Buch hinausgeht: Das ist kein Kuriositätenkabinett. Es ist der kompakteste Architekturvergleich, der mir je begegnet ist. Weil die Aufgabe trivial und konstant ist, bleibt als einzige Variable die Struktur. Man sieht Architekturentscheidungen in Reinform, auf jeweils einer Bildschirmseite.\n\nEin paar der Stile will ich herausgreifen und gegeneinanderstellen.\n\n## Good Old Times: die Abwesenheit von Struktur\n\nDer erste Stil im Buch simuliert die Bedingungen früher Rechner: kaum Speicher, keine benannten Abstraktionen, keine Funktionen – das Programm ist eine einzige Folge von Anweisungen, die sich Zwischenstände auf einer simulierten „Festplatte\" merken muss. Das Ergebnis ist mühsam zu lesen und noch mühsamer zu ändern. Der verwandte monolithische Stil lockert die Speichergrenze, verbietet aber weiterhin jede Abstraktion: eine lange Prozedur, geschachtelte Schleifen, Flags.\n\nInteressant ist daran nicht die Nostalgie, sondern die Ehrlichkeit. Der Monolith ist der Nullpunkt: So sieht Code aus, wenn keine Constraint ihn formt. Und er ist keineswegs ausgestorben – ich habe 2014 in einem Projekt ein gewachsenes Import-Skript übernommen, das exakt diesem Stil folgte, nur eben mit 1800 statt 80 Zeilen. Es funktionierte. Es war nur unmöglich zu testen, weil es keine Stelle gab, an der man es hätte anfassen können, ohne alles anzufassen.\n\n## Pipeline: Datenfluss statt Ablauf\n\nDer Pipeline-Stil erlaubt ausschließlich Funktionen, die ihre Eingabe entgegennehmen und ein Ergebnis zurückgeben. Kein geteilter Zustand, keine Side Effects zwischen den Schritten. Mein Nachbau sieht so aus (Python 2.7, wie im Buch):\n\n```python\nimport re\nimport operator\n\ndef read_text(path):\n    with open(path) as f:\n        return f.read()\n\ndef normalize(text):\n    return re.sub('[^a-z]', ' ', text.lower())\n\ndef tokenize(text):\n    return text.split()\n\ndef remove_stop_words(words):\n    with open('stop_words.txt') as f:\n        stop_words = set(f.read().split(','))\n    return [w for w in words if w not in stop_words and len(w) > 1]\n\ndef count_frequencies(words):\n    counts = {}\n    for w in words:\n        counts[w] = counts.get(w, 0) + 1\n    return counts\n\ndef top_25(counts):\n    ordered = sorted(counts.items(), key=operator.itemgetter(1), reverse=True)\n    return ordered[:25]\n\nfor word, count in top_25(count_frequencies(remove_stop_words(\n        tokenize(normalize(read_text('pride-and-prejudice.txt')))))):\n    print word, '-', count\n```\n\nDie letzte Zeile liest sich wie eine mathematische Formel: Das Programm ist die Komposition seiner Schritte. Jede Funktion ist für sich testbar, jede lässt sich austauschen, solange die Datenform stimmt. Das ist die Architektur jeder Batch- und ETL-Strecke und jeder Unix-Pipe – nur dass dort die Funktionen Prozesse heißen und die Rückgabewerte Dateien oder Streams sind. Die Constraint „kein geteilter Zustand\" kauft Testbarkeit und Parallelisierbarkeit und bezahlt mit dem Verbot, sich zwischendurch etwas zu merken.\n\n## Things: Zustand hinter Türen\n\nDer Stil „Things\" ist die objektorientierte Antwort: Das Problem wird in Dinge zerlegt – ein `DataStorageManager`, ein `StopWordManager`, ein `WordFrequencyManager` – und jedes Ding kapselt seinen Zustand. Von außen sind nur Methoden sichtbar; niemand greift in die Daten eines anderen. Die Constraint lautet: Zustand existiert, aber nur hinter einer Tür, und die Tür hat einen Namen.\n\nAuf 60 Zeilen wirkt das fast übertrieben feierlich. Aber die Analogie auf Systemebene ist offensichtlich: Es ist die Grundidee modularer Systeme und sauberer Serviceschnitte. Die Frage „wem gehört dieser Zustand, und wer darf ihn ändern?\" ist in jedem Architektur-Review, das ich erlebt habe, die ergiebigste Frage überhaupt – und dieser Stil zwingt einen, sie für jedes Datum zu beantworten.\n\n## Hollywood: Don't call us, we'll call you\n\nAb hier dreht sich die Kontrollrichtung um. Im Hollywood-Stil ruft nicht mehr das Programm seine Bausteine – ein Rahmen ruft zurück, was sich vorher registriert hat:\n\n```python\nclass WordFrequencyFramework:\n    def __init__(self):\n        self._load_handlers = []\n        self._work_handlers = []\n        self._end_handlers = []\n\n    def register_for_load(self, handler):\n        self._load_handlers.append(handler)\n\n    def register_for_work(self, handler):\n        self._work_handlers.append(handler)\n\n    def register_for_end(self, handler):\n        self._end_handlers.append(handler)\n\n    def run(self, path):\n        for handler in self._load_handlers:\n            handler(path)\n        for handler in self._work_handlers:\n            handler()\n        for handler in self._end_handlers:\n            handler()\n```\n\nDie fachlichen Teile – Einlesen, Filtern, Zählen – melden sich beim Framework an und warten. Der Ablauf gehört dem Rahmen, nicht mehr der Anwendung. Das ist Inversion of Control in ihrer nacktesten Form, und es ist das Bauprinzip praktisch jedes Web-Frameworks und jeder Plugin-Architektur: Man schreibt keine Hauptschleife mehr, man füllt Lücken, die ein anderer vorgesehen hat.\n\nWer viel JavaScript schreibt, kennt dieses Gefühl aus jedem Node-Programm – dort ist der Callback nicht Stilübung, sondern Alltag, mit allen Folgen für die Lesbarkeit des Kontrollflusses. Ich habe dazu schon einmal aufgeschrieben, [wie sich Kontrollfluss vor async/await bändigen lässt](https://www.mikebild.dev/de/blog/control-flow-vor-async-await/). Die Stilübung macht sichtbar, was man dabei eintauscht: Flexibilität für den Rahmen, Nachvollziehbarkeit für den Leser. Wer wissen will, wann `count_frequencies` läuft, muss den Rahmen verstehen, nicht die Fachlogik.\n\n## Bulletin Board: das schwarze Brett\n\nDer Bulletin-Board-Stil entkoppelt noch eine Stufe weiter. Komponenten rufen einander gar nicht mehr, auch nicht über Registrierung beim Rahmen – sie veröffentlichen Events an ein schwarzes Brett, und wer sich für einen Event-Typ eingetragen hat, wird benachrichtigt:\n\n```python\nclass EventManager:\n    def __init__(self):\n        self._subscriptions = {}\n\n    def subscribe(self, event_type, handler):\n        self._subscriptions.setdefault(event_type, []).append(handler)\n\n    def publish(self, event):\n        event_type = event[0]\n        for handler in self._subscriptions.get(event_type, []):\n            handler(event)\n```\n\nDer Zähler abonniert `word`, der Filter abonniert `valid_word`, die Ausgabe abonniert `print` – und niemand kennt niemanden. Das ist Publish/Subscribe auf 15 Zeilen, das Muster hinter jedem Message-Broker und jeder ereignisgetriebenen Integration. Die Constraint „keine direkten Aufrufe\" kauft maximale Entkopplung: Man kann Teilnehmer hinzufügen, ohne bestehende zu ändern. Bezahlt wird mit dem Verlust des sichtbaren Ablaufs. Beim Durcharbeiten habe ich für diesen Stil zum ersten Mal den Debugger gebraucht – bei 80 Zeilen. Genau dieselbe Erfahrung macht man später im Großen, wenn man in einem ereignisgetriebenen System einer Message hinterherläuft.\n\n## The One: der Wert im Gefäß\n\nDer Stil mit dem schönsten Namen ist eine Monade light: Ein Wert wird in ein Gefäß gelegt, und das Gefäß weiß, wie es Funktionen der Reihe nach auf seinen Inhalt anwendet.\n\n```python\nclass TheOne:\n    def __init__(self, value):\n        self._value = value\n\n    def bind(self, func):\n        self._value = func(self._value)\n        return self\n\nTheOne('pride-and-prejudice.txt') \\\n    .bind(read_text) \\\n    .bind(normalize) \\\n    .bind(tokenize) \\\n    .bind(remove_stop_words) \\\n    .bind(count_frequencies) \\\n    .bind(top_25) \\\n    .bind(print_all)\n```\n\nInhaltlich ist das dieselbe Kette wie im Pipeline-Stil – aber die Verkettung selbst ist jetzt ein Baustein, den man erweitern kann: um Logging bei jedem Schritt, um Error Handling, um verzögerte Ausführung. Wer in ES5-JavaScript schon mit Promise-Bibliotheken gearbeitet hat, erkennt das Muster sofort wieder: `then` ist ein `bind`, das nebenbei Asynchronität wegkapselt. Die Constraint „alles fließt durch ein Gefäß\" kauft einen Ort, an dem Querschnittsbelange wohnen dürfen.\n\n## Sechs Stile, sechs Architekturen\n\nNebeneinandergelegt ergibt sich aus den Übungen eine Zuordnung, die ich seitdem im Kopf behalte:\n\n```mermaid\nflowchart LR\n    A[Monolith<br/>keine Abstraktion erlaubt] --> A2[gewachsene Skripte,<br/>Legacy-Prozeduren]\n    B[Pipeline<br/>nur reine Funktionen] --> B2[Batch, ETL,<br/>Unix-Pipes]\n    C[Things<br/>Zustand nur hinter Methoden] --> C2[Module,<br/>Serviceschnitte]\n    D[Hollywood<br/>Rahmen ruft zurück] --> D2[Frameworks,<br/>Plugin-Systeme]\n    E[Bulletin Board<br/>nur Ereignisse, keine Aufrufe] --> E2[Message-Broker,<br/>Integration]\n    F[The One<br/>alles durch ein Gefäß] --> F2[Fluent APIs,<br/>Promise-Ketten]\n```\n\nJede dieser Architekturen im Großen ist ein Programmierstil im Kleinen – dieselbe Constraint-Menge, nur auf Prozess- statt Funktionsebene. Und in beiden Größenordnungen gilt: Die Eigenschaften, die man sich wünscht, kommen aus dem, was man sich verbietet. Testbarkeit kommt aus dem Verbot geteilten Zustands. Erweiterbarkeit kommt aus dem Verbot direkter Aufrufe. Nachvollziehbarkeit kommt aus dem Verbot versteckter Kontrollflüsse. Nichts davon gibt es geschenkt; jede Constraint hat ihren Preis, und die Übungen machen den Preis auf einer Bildschirmseite fühlbar, statt ihn erst nach zwei Jahren Betrieb zu präsentieren.\n\n## Der unbewusste Stilmix\n\nDie für mich wichtigste Erkenntnis kam allerdings nicht aus einem der Stile, sondern aus dem Kontrast zu meinem eigenen Alltagscode. Der ist – wie vermutlich der meiste Code da draußen – ein Stilmix: ein bisschen objektorientiert, weil die Sprache es nahelegt; ein paar Pure Functions, wo es sich anbot; ein globaler Cache, weil es schnell gehen musste; Callbacks, weil das Framework es verlangt. Nichts davon ist für sich falsch. Das Problem ist, dass der Mix unbewusst entsteht. Niemand hat entschieden, dass dieses Modul dem Pipeline-Gedanken folgt und jenes dem schwarzen Brett – es ist einfach passiert, Commit für Commit.\n\nGenau da liegt für mich der Unterschied zwischen Code, der Struktur hat, und Code, der nur strukturiert aussieht. Architektur ist die Kunst, Constraints bewusst zu wählen – und sie dann auch dort durchzuhalten, wo sie gerade unbequem sind. Eine Constraint, die man aufgibt, sobald sie zwickt, war nie eine; sie war eine Gewohnheit. Seit ich die Übungen durchgearbeitet habe, stelle ich in Code-Reviews gelegentlich eine neue Frage: In welchem Stil sind wir hier eigentlich? Nicht als Fangfrage, sondern weil die Antwort sofort zeigt, ob eine Regel verletzt wurde, die sich das Team gegeben hat – oder ob es die Regel nie gab und wir sie jetzt nachholen sollten.\n\nHilfreich ist der Blick auch in der Gegenrichtung. Wenn ein Modul schwer testbar ist, lautet die Frage nicht „welches Test-Werkzeug fehlt?\", sondern „welche Constraint fehlt?\". Meist ist es dieselbe, die der Pipeline-Stil erzwingt und der gewachsene Code ignoriert.\n\n## Was ich daraus mitnehme\n\nWortfrequenzen zählen ist die langweiligste Aufgabe der Welt, und genau das macht sie zum idealen Prüfstand: Wenn die Aufgabe konstant bleibt, ist alles, was sich zwischen zwei Lösungen unterscheidet, Struktur – und damit vergleichbar. Man kann Architekturstile diskutieren, indem man Systeme mit jahrelanger Geschichte seziert. Oder man vergleicht 33 Skripte à 80 Zeilen, die alle dasselbe tun, und sieht dieselben Kräfte am Werk, nur ohne Nebel.\n\nEin paar Dinge nehme ich konkret mit:\n\n- Stile nicht nach Sympathie beurteilen, sondern nach der Frage, welche Eigenschaft ihre Constraints kaufen und was sie kosten.\n- Beim Lesen fremden Codes zuerst den Stil identifizieren – danach erklärt sich vieles, was vorher wie Willkür aussah.\n- Den eigenen Stilmix gelegentlich explizit machen: Welche Regeln gelten in diesem Modul, und stehen sie irgendwo?\n\nWer das Buch nicht zur Hand hat: Das Repository allein trägt schon weit. Forken, einen Stil pro Abend nachbauen, dabei die Constraints absichtlich verletzen und beobachten, was kaputtgeht. Ich habe aus diesen Abenden mehr über Architektur gelernt als aus manchem dicken Entwurfsmuster-Wälzer – gerade weil kein einziges UML-Diagramm vorkam, sondern nur die Frage: Was verbietest du dir, und was bekommst du dafür?\n\n## Weiterführende Quellen\n\n- Mein Fork mit den durchgearbeiteten Übungen: https://github.com/MikeBild/exercises-in-programming-style\n- Das Original-Repository von Cristina Videira Lopes: https://github.com/crista/exercises-in-programming-style\n- Raymond Queneaus literarische Vorlage „Exercises in Style\": https://en.wikipedia.org/wiki/Exercises_in_Style\n- Python-2-Dokumentation zu `collections` (u. a. `Counter` für die Frequenzzählung): https://docs.python.org/2/library/collections.html"},{"slug":"docker-fuer-dotnet","title":"Docker für .NET, bevor es normal war","summary":"Eine kleine Nancy-API unter Mono in einen Docker-Container zu packen war im Frühjahr 2015 ein Umweg über Linux – und hat mir mehr über .NET-Deployment beigebracht als jedes IIS-Handbuch.","tags":["Software","Platform"],"published_at":"2015-04-24T10:00:00+00:00","updated_at":"2026-07-14T17:05:06.200176+00:00","url":"https://www.mikebild.dev/de/blog/docker-fuer-dotnet/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/9e9092136ee3b807580f4ba10266965c814d180ec55e2485dd4bdccd4c7fb40d/docker-fuer-dotnet-vorlesen.mp3","audio_duration_secs":686,"text":"# Docker für .NET, bevor es normal war\n\nGut 400 Megabyte. Das meldet `docker images` für ein Image, dessen eigentliche Anwendung eine einzige kompilierte Datei von wenigen Kilobyte ist – eine kleine HTTP-API, geschrieben in C#. Die Zahl wirkt absurd, und sie ist es auch. Aber die interessantere Messung ist eine andere: `docker start demo1` braucht unter einer Sekunde, und danach antwortet der Dienst. Ein vergleichbares Deployment auf einem Windows-Server mit IIS hat mich in Projekten regelmäßig einen halben Nachmittag gekostet – Framework-Version prüfen, App-Pool anlegen, Rechte setzen, Dateien kopieren, beten.\n\nDiese beiden Zahlen nebeneinander – 400 Megabyte Platz gegen eine Sekunde Start – beschreiben ziemlich genau, worauf man sich einlässt, wenn man im Frühjahr 2015 .NET-Code in einen Docker-Container steckt. Man tauscht Plattenplatz gegen Wiederholbarkeit. Ich halte das für einen guten Tausch, und ich will erzählen, warum.\n\n## Frühjahr 2015: .NET heißt offiziell Windows\n\nWer heute „.NET\" sagt, meint praktisch immer Windows, IIS und den Microsoft-Stack von oben bis unten. Docker dagegen ist ein Linux-Thema: Docker 1.6 ist gerade erschienen, auf dem Mac läuft der Docker-Daemon in einer kleinen VirtualBox-VM namens boot2docker, und Compose ist eben erst aus Fig hervorgegangen. Die beiden Welten berühren sich kaum.\n\nGanz stimmt das Bild allerdings nicht mehr. Im November hat Microsoft angekündigt, .NET Core als Open Source zu entwickeln, seit Februar liegt die CoreCLR auf GitHub, und das kommende ASP.NET 5 soll ausdrücklich auch auf Linux und OS X laufen. Das ist eine bemerkenswerte Wende – aber im April 2015 ist es eine Ankündigung mit frühen Betas, kein Fundament. Wer heute .NET-Code in einem Linux-Container ausführen will, landet bei Mono, der freien Runtime, die es seit über zehn Jahren gibt und die inzwischen erstaunlich viel vom .NET Framework abdeckt.\n\nGenau das wollte ich ausprobieren. Nicht, weil ein Projekt es verlangt hätte, sondern weil mich die Frage nicht losließ: Wie viel vom üblichen .NET-Deployment-Ritual ist eigentlich nötig – und wie viel davon ist nur Gewohnheit, die am IIS hängt? Ein Wochenende, ein Repository, ein bewusst kleiner Dienst. Wenn das Experiment scheitert, ist wenig verloren; wenn es trägt, weiß ich etwas, das sich mit keinem Blogartikel von anderen ersetzen lässt.\n\n## Der Versuch: eine Nancy-API im Container\n\nDas Ergebnis liegt als Repository unter `dotnet-docker` auf GitHub: eine kleine HTTP-API mit dem Nancy-Framework, selbst gehostet über `Nancy.Hosting.Self`, mit LiteDB als eingebetteter Datenbank. Kein IIS, kein Windows-Dienst, keine Installation – ein Prozess, der einen Port öffnet. Das README nennt es einen „Docker + DotNet Microservice\", und so klein wie das klingt, ist es auch: ein Modul, zwei ernsthafte Endpoints, eine Datei Datenbank.\n\nDas Herzstück ist das Dockerfile. Es beschreibt vollständig, was diese Anwendung zum Laufen braucht – und zwar so, dass keine stille Annahme übrig bleibt:\n\n```dockerfile\nFROM mono:3.10\n\n# Update SSL certs\nRUN mozroots --import --sync\n\n# Copy source files\nADD src ./src\nWORKDIR /src\n\n# Install dependencies via NuGet\nRUN mono /src/NuGet.exe install Nancy\nRUN cp /src/Nancy.1.1/lib/net40/Nancy.dll /src\n\nRUN mono /src/NuGet.exe install Nancy.Hosting.Self\nRUN cp /src/Nancy.Hosting.Self.1.1/lib/net40/Nancy.Hosting.Self.dll /src\n\nRUN mono /src/NuGet.exe install LiteDB\nRUN cp /src/LiteDB.1.0.0/lib/net40/LiteDB.dll /src\n\n# Compile the web service\nRUN mcs nancy.cs modules/webhooks.cs \\\n    -reference:Nancy.dll \\\n    -reference:Nancy.Hosting.Self.dll \\\n    -reference:LiteDB.dll\nEXPOSE 8080\n\n# Run the web service\nCMD [\"mono\", \"/src/nancy.exe\"]\n```\n\nMan sieht dem Dockerfile die Epoche an, und das ist Absicht. Es gibt kein offizielles Microsoft-Image, also startet alles auf `mono:3.10`. Es gibt keine eingebaute Paketverwaltung im Build, also liegt `NuGet.exe` mit im Repository und wird per `mono` aufgerufen. Und weil NuGet die Pakete in Versionsordner legt, werden die DLLs anschließend von Hand an die Stelle kopiert, an der der Compiler sie erwartet. Kompiliert wird mit `mcs`, dem Mono-C#-Compiler, direkt im Image – kein Visual Studio, keine MSBuild-Projektdatei, nur Quelldateien und Referenzen.\n\nDas ist unbequem. Aber jede dieser Zeilen ersetzt eine Annahme, die sonst irgendwo unsichtbar auf einem Server läge: welche Runtime, welche Paketversionen, welche Zertifikate, welcher Port. Mir ist beim Schreiben aufgefallen, wie viele dieser Annahmen ich in Windows-Projekten nie aufgeschrieben, sondern immer nur gewusst habe.\n\n```mermaid\nflowchart LR\n  A[mono:3.10<br/>Basis-Image] --> B[mozroots<br/>SSL-Zertifikate]\n  B --> C[NuGet.exe install<br/>Nancy + LiteDB]\n  C --> D[mcs<br/>kompiliert nancy.exe]\n  D --> E[Image dotnet<br/>ca. 400 MB]\n  E --> F[docker run demo1<br/>mono nancy.exe]\n```\n\n## Der Code: ein Prozess, der einen Port öffnet\n\nDie Anwendung selbst ist bewusst schlicht. Der Entry Point startet einen `NancyHost` und hält den Prozess am Leben:\n\n```csharp\nusing System;\nusing Nancy;\nusing Nancy.Hosting.Self;\n\nclass Program\n{\n    static void Main()\n    {\n        using (var host = new NancyHost(new Uri(\"http://localhost:8080\")))\n        {\n            host.Start();\n            Console.WriteLine(\"Nancy host (http://localhost:8080) started.\");\n            Console.ReadLine();\n        }\n    }\n}\n```\n\nDazu ein Nancy-Modul, das Webhooks in LiteDB ablegt – eine Datenbank, die als einzelne Datei neben der Anwendung liegt und ohne Server auskommt:\n\n```csharp\npublic class WebHookModule : NancyModule\n{\n    public WebHookModule() : base(\"/webhooks\")\n    {\n        Get[\"/\"] = _ =>\n        {\n            using (var db = new LiteDatabase(@\"MyData.db\"))\n            {\n                return db.GetCollection<WebHook>(\"webhooks\")\n                    .FindAll()\n                    .ToArray();\n            }\n        };\n\n        Post[\"/\"] = _ =>\n        {\n            using (var db = new LiteDatabase(@\"MyData.db\"))\n            {\n                db.GetCollection<WebHook>(\"webhooks\").Insert(new WebHook());\n                return HttpStatusCode.Created;\n            }\n        };\n    }\n}\n```\n\nGebaut und gestartet wird mit drei Kommandos, so stehen sie auch im README:\n\n```bash\ndocker build -t dotnet .\ndocker run --name demo1 -p 8080:8080 -i -t dotnet\ndocker start demo1\n```\n\n## Wo es hakte\n\nGlatt lief das nicht, und die Stolpersteine sind lehrreicher als der glückliche Pfad. Der erste Start endete mit einem Container, der sich sofort wieder beendete. Der Grund steht oben im Code: `Console.ReadLine()` hält den Prozess nur am Leben, solange es ein Stdin gibt. Ohne die Flags `-i -t` liefert Docker keins, `ReadLine` kehrt sofort zurück, `Main` läuft aus, der Container ist tot. Deshalb stehen die beiden Flags im README – nicht aus Sorgfalt, sondern als Narbe. Die eigentliche Lektion: Ein Container lebt genau so lange wie sein Prozess. Es gibt keinen IIS, der die Anwendung im Hintergrund festhält; die Anwendung ist selbst dafür zuständig, nicht fertig zu werden.\n\nDer zweite Stolperstein war die Erreichbarkeit. Auf dem Mac läuft der Docker-Daemon in der boot2docker-VM, also antwortet der Dienst nicht auf `localhost`, sondern auf der IP der VM – `boot2docker ip` verrät sie. Und dass die Bindung an `http://localhost:8080` im Container überhaupt von außen erreichbar ist, verdankt der Code einer Eigenheit von Nancys Self-Hosting: Die `HostConfiguration` hat eine Option `RewriteLocalhost`, die standardmäßig aktiv ist und aus `localhost` eine Wildcard-Bindung macht. Das habe ich erst verstanden, als ich im Nancy-Quelltext nachgelesen habe. Wer strikt an `127.0.0.1` bindet, sieht von außen: nichts.\n\nDer Rest war Kleinkram, der sich summiert:\n\n- Frische Mono-Images kennen keine SSL-Stammzertifikate; ohne `mozroots --import --sync` scheitert schon der NuGet-Zugriff auf HTTPS-Quellen.\n- Die Pakete müssen als net40-Assemblies vorliegen, damit Mono 3.10 sie sauber lädt – bei Nancy 1.1 und LiteDB 1.0 kein Problem, bei exotischeren Paketen durchaus.\n- Jedes `RUN` erzeugt eine Image-Schicht; wer beim Experimentieren oft baut, lernt den Build-Cache zu lieben und die Reihenfolge der Zeilen zu respektieren.\n- Die 400 Megabyte bleiben. Ein Basis-Image mit kompletter Mono-Installation ist kein schlankes Fundament, und ein schlankeres gibt es für .NET-Code derzeit nicht.\n\n## Was der Umweg lehrte: Prozess statt IIS\n\nDer eigentliche Gewinn des Experiments liegt nicht im Container, sondern in dem, was der Container erzwingt. Eine .NET-Anwendung, die unter IIS läuft, ist keine Anwendung, sondern ein Gast: Der Webserver besitzt den Prozess, das Konfigurationssystem, den Lifecycle. Die XML-Konfigurationsdatei des IIS beschreibt weniger die Anwendung als ihr Verhältnis zum Wirt. Im Container dreht sich das um. Die Anwendung ist ein gewöhnlicher Prozess, der einen Port öffnet, auf die Standardausgabe schreibt und beendet werden kann, ohne dass etwas aufzuräumen wäre.\n\nDamit ändert sich auch, wo Konfiguration hingehört. Ein Image, das für jede Umgebung neu gebaut werden muss, weil die Einstellungen einkompiliert sind, verfehlt den Zweck. Die Konfiguration muss von außen kommen, und der natürliche Weg dafür sind Umgebungsvariablen – `docker run -e` reicht dann sowohl das Können als auch die Disziplin. In der nächsten Iteration des Entry Point sah das so aus:\n\n```csharp\nstatic void Main()\n{\n    var port = Environment.GetEnvironmentVariable(\"PORT\") ?? \"8080\";\n    var uri = new Uri(\"http://localhost:\" + port);\n\n    using (var host = new NancyHost(uri))\n    {\n        host.Start();\n        Console.WriteLine(\"Nancy host ({0}) started.\", uri);\n        Console.ReadLine();\n    }\n}\n```\n\nDas wirkt banal, ist aber ein Bruch mit Jahren von IIS-Konfigurations-Transformationen und Registry-Einträgen. Interessanterweise beschreibt die Twelve-Factor-Liste von Heroku – seit 2011 öffentlich – genau diese Denkweise: Konfiguration in der Umgebung, Prozesse zustandslos, Logs als Event Stream auf die Standardausgabe, Abhängigkeiten explizit deklariert. Ich hatte das Manifest gelesen und für ein Heroku-Werbedokument gehalten. Erst der Container hat mir gezeigt, dass es eine Beschreibung dessen ist, was übrig bleibt, wenn man einer Anwendung den Wirt wegnimmt.\n\n```mermaid\nflowchart TD\n  subgraph W[Gewohnter Windows-Pfad]\n    W1[Server mit IIS<br/>vorbereiten] --> W2[Framework + App-Pool<br/>konfigurieren]\n    W2 --> W3[XML-Konfiguration<br/>transformieren]\n    W3 --> W4[Dateien kopieren<br/>und hoffen]\n  end\n  subgraph C[Container-Pfad]\n    C1[Dockerfile<br/>beschreibt alles] --> C2[docker build<br/>reproduzierbar]\n    C2 --> C3[docker run -e PORT=8080<br/>Konfiguration von außen]\n  end\n```\n\nDazu kommt die Wiederholbarkeit. Das Image, das auf meinem Rechner entsteht, ist bit für bit das, was auf jedem anderen Docker-Host läuft. „Bei mir geht's\" hört auf, ein Argument zu sein, weil „bei mir\" jetzt eine Datei ist, die man weitergeben kann. Für jemanden, der .NET-Deployments bisher als Abfolge manueller Schritte auf gepflegten Servern kannte, ist das der eigentliche Kulturschock – nicht die Technik, sondern der Wegfall der Ausreden.\n\n## Die ehrliche Einordnung\n\nMan muss trotzdem nüchtern bleiben. Mono ist nicht das .NET Framework. Für Nancy, LiteDB und eine überschaubare API trägt es problemlos; wer WCF-Altlasten, WPF oder tiefe Windows-Abhängigkeiten mitbringt, kommt nicht weit. Wer im Betrieb auf Windows-Servern ausliefert, testet mit einem Mono-Container außerdem eine andere Runtime als die produktive – das darf man wissen und muss es einpreisen. Und die Werkzeugkette ist spürbar zweite Reihe: kein Debugging im Container, Paketverwaltung von Hand, Fehlermeldungen, die Linux-Grundwissen voraussetzen.\n\nIn ein Kundenprojekt würde ich dieses Setup heute deshalb nicht tragen. Dafür ist zu viel Handarbeit im Spiel, und ein Team, das seit Jahren Windows-Server betreibt, wechselt nicht wegen eines gelungenen Prototyps das Betriebsmodell. Als Lernvehikel dagegen ist es kaum zu schlagen: Jeder Handgriff, den das Dockerfile sichtbar macht, ist ein Handgriff, den der IIS bisher versteckt hat.\n\nDie Richtung stimmt trotzdem. Microsoft meint die Öffnung offenbar ernst: CoreCLR und der Compiler liegen öffentlich auf GitHub, ASP.NET 5 wird ausdrücklich für Linux entwickelt, und die frühen Betas laufen heute schon auf Mono. Wenn das hält, was es verspricht, ist der Mono-Umweg ein Übergangspfad – aber eben ein gangbarer, heute, nicht irgendwann. Ich vermute, dass sich die Fertigkeiten aus diesem Experiment eins zu eins übertragen werden: Dockerfiles schreiben, Prozesse statt Wirte denken, Konfiguration über die Umgebung reichen. Die Runtime darunter ist austauschbar; die Denkweise nicht.\n\n## Was hängen geblieben ist\n\nDas Repository ist ein Wegwerf-Experiment, und als solches behandle ich es auch. Trotzdem hat es meine Sicht auf .NET-Deployment dauerhaft verschoben, und zwar an drei Stellen:\n\n- Ein Dockerfile ist die vollständige, ausführbare Liste aller Laufzeitannahmen einer Anwendung – was dort nicht steht, wird stillschweigend vom Host geliehen.\n- Eine Anwendung, die als schlichter Prozess laufen kann, ist die flexiblere Anwendung – jeder Wirt, vom IIS bis zum Container, lässt sich darüberstülpen, umgekehrt gilt das nicht.\n- Konfiguration gehört in die Umgebung, nicht ins Artefakt – ein Build, viele Deployments.\n\nNichts davon setzt Docker voraus. Aber Docker hat mich gezwungen, es aufzuschreiben statt zu wissen, und aufgeschriebene Annahmen kann man diskutieren, prüfen und weitergeben. Die 400 Megabyte zahle ich dafür gern – Plattenplatz ist billig, verlorene Nachmittage vor dem IIS-Manager sind es nicht.\n\n## Weiterführende Quellen\n\n- Das Repository zum Artikel: [MikeBild/dotnet-docker](https://github.com/MikeBild/dotnet-docker)\n- Microsofts Ankündigung: [.NET Core is Open Source](https://devblogs.microsoft.com/dotnet/net-core-is-open-source/)\n- Das offizielle [Mono-Image auf dem Docker Hub](https://hub.docker.com/_/mono)\n- Das [Nancy-Framework auf GitHub](https://github.com/NancyFx/Nancy)\n- [The Twelve-Factor App](https://12factor.net/) – die Denkweise hinter Prozess, Umgebung und Logs"},{"slug":"tests-bevor-produkt","title":"Tests, bevor es ein Produkt gibt","summary":"Frühe Tests helfen, ein Problem zu begreifen, auch wenn der Code später komplett ersetzt wird.","tags":["Software","Testing"],"published_at":"2014-12-12T10:00:00+00:00","updated_at":"2026-07-13T06:06:03.52876+00:00","url":"https://www.mikebild.dev/de/blog/tests-bevor-produkt/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/6d3ddeee68c181864eba3e7f74b702a8ab456bc38ef66c10043458f43e067b95/tests-bevor-produkt-vorlesen.mp3","audio_duration_secs":621,"text":"# Tests, bevor es ein Produkt gibt\n\nEin Testfall braucht zwei Angaben: eine Eingabe und die Erwartung, was dabei herauskommen soll. Mehr ist nicht nötig, um ein Problem festzunageln, für das noch keine einzige Zeile Code existiert.\n\nDer Reflex bei etwas Neuem geht in eine andere Richtung: sofort bauen. Ein Endpoint, eine Maske, ein Datenmodell – irgendetwas Sichtbares, das man vorzeigen kann. Wer an dieser Stelle einen Schritt langsamer wird und zuerst den Test schreibt, sichert damit keinen Code ab – den gibt es ja noch gar nicht. Der Test zwingt dazu, das Problem in einem einzigen, überprüfbaren Satz zu benennen. Und dieser Satz überlebt fast immer die Implementierung, die ich danach schreibe und ein paar Tage später wieder wegwerfe. Qualitätssicherung wird der Test erst später; am Anfang ist er ein Denkwerkzeug.\n\n## Katas: üben, ohne dass ein Produkt entsteht\n\nDer konkrete Anlass für diesen Text sind Katas. Eine Kata ist eine kleine, klar umrissene Programmieraufgabe, die man nicht löst, um sie loszuwerden, sondern um an ihr zu üben – die String Calculator Kata, die Bowling-Game-Kata, römische Zahlen, FizzBuzz. Man kennt die Lösung oft schon. Trotzdem macht man sie wieder, im Coding Dojo mit dem Team oder allein an einem ruhigen Vormittag, weil es nicht um das Ergebnis geht, sondern um den Weg dorthin.\n\nDas Interessante an einer Kata ist, dass am Ende bewusst kein Produkt steht. Es gibt keinen Kunden, keine Deadline, kein Deployment. Was bleibt, ist die Erfahrung, wie sich ein Problem anfühlt, wenn man es Testschritt für Testschritt aufdröselt. Genau diese Haltung nehme ich mit in echte Projekte. In der frühen Phase eines Features weiß ich oft noch nicht, wie die Lösung aussieht. Ich weiß nur, welches Verhalten am Ende beobachtbar sein muss. Und das lässt sich als Test formulieren, bevor auch nur eine Zeile Produktivcode existiert.\n\nNehmen wir die String Calculator Kata. Die Aufgabe klingt trivial: eine Funktion, die eine Zeichenkette mit Zahlen entgegennimmt und ihre Summe liefert. Der erste Test ist maximal langweilig – und trotzdem die wichtigste Zeile des Tages.\n\n```js\ndescribe(\"String Calculator\", () => {\n  it(\"returns 0 for an empty string\", () => {\n    expect(add(\"\")).to.equal(0);\n  });\n\n  it(\"sums two comma-separated numbers\", () => {\n    expect(add(\"1,2\")).to.equal(3);\n  });\n});\n```\n\nBevor ich `add` schreibe, habe ich zwei Entscheidungen getroffen, die vorher unausgesprochen waren: Der leere Fall ist nicht undefiniert, er liefert die Zahl 0. Und das Trennzeichen ist zunächst das Komma. Zwei kleine Beispiele – und schon ist eine Diskussion möglich, die sonst erst im Code-Review oder, schlimmer, im Betrieb aufgetaucht wäre.\n\n## Der Test als Denkwerkzeug\n\nKent Beck hat das in *Test-Driven Development by Example* auf eine einfache Schleife gebracht: einen Test schreiben, der fehlschlägt, ihn mit dem kleinstmöglichen Schritt grün bekommen, dann aufräumen. Rot, grün, refactor.\n\n```mermaid\nflowchart LR\n  Red[Test schreiben,<br/>er schlägt fehl] --> Green[kleinster Schritt<br/>bis grün]\n  Green --> Refactor[aufräumen,<br/>ohne Verhalten zu ändern]\n  Refactor --> Red\n```\n\nDer entscheidende Effekt ist nicht die Testabdeckung. Es ist das Feedback über das Design. Wenn ich für einen einzigen Testfall drei Objekte aufsetzen, vier Abhängigkeiten stellen und eine halbe Datenbank mocken muss, dann sagt mir der Test etwas über meinen Entwurf, nicht über meinen Testfleiß: Die Grenze sitzt an der falschen Stelle. Ein Test, der schwer zu schreiben ist, ist fast immer ein Hinweis auf eine Kopplung, die ich noch nicht bezahlt, aber schon eingegangen bin.\n\nDeshalb schreibe ich den Test zuerst, auch wenn ich weiß, dass der Code darunter ein Wegwerfstück ist. Der Test ist die Frage, der Code ist nur der erste Antwortversuch. Die Frage ist das Wertvolle. Wenn ich den ersten Antwortversuch nach zwei Tagen komplett ersetze – weil ich das Problem inzwischen besser verstehe –, bleibt die Frage stehen und prüft die neue Antwort genauso streng wie die alte.\n\n## Specification by Example\n\nDer zweite Baustein, der für mich untrennbar dazugehört, ist Specification by Example, so wie Gojko Adzic es beschreibt. Die Idee: Statt eine Anforderung abstrakt zu formulieren („der Rabatt gilt für Neukunden“), schreibt man sie als konkretes Beispiel auf, das gleichzeitig Fachsprache, Erwartung und automatisierbare Prüfung ist.\n\n```gherkin\nScenario: A new customer gets the welcome discount\n  Given a customer with no previous orders\n  When the customer places an order of 100 EUR\n  Then the invoice total is 90 EUR\n```\n\nDieses kleine Szenario leistet mehr, als es aussieht. Es ist in einer Sprache geschrieben, die eine Fachexpertin lesen und korrigieren kann. Es legt die Erwartung auf eine Zahl fest, über die man streiten kann – sind es wirklich 90, oder greift der Rabatt vor oder nach der Mehrwertsteuer? Und es lässt sich an die Ausführung binden, sodass die Beschreibung nicht neben dem Code veraltet, sondern mit ihm mitläuft.\n\nIn der Praxis ist das Beispiel oft der Moment, in dem ein Missverständnis auffliegt. „Neukunde“ hieß für die Entwicklung „noch keine Bestellung“, für den Vertrieb aber „seit zwölf Monaten keine Bestellung“. Solange die Anforderung abstrakt blieb, waren sich alle einig. Erst das konkrete Beispiel mit den 100 EUR hat die beiden Deutungen sichtbar gemacht. Genau dafür schreibe ich Beispiele früh: nicht, um Code zu testen, sondern um Annahmen zu testen, die noch niemand ausgesprochen hat.\n\n## Die Grenze ist die eigentliche Entscheidung\n\nWenn ich einen Test formuliere, treffe ich implizit eine Architekturentscheidung: Ich lege fest, an welcher Grenze ich prüfe. Und diese Grenze bestimmt, was ich später frei ändern kann und was nicht.\n\nPrüfe ich zu tief – auf einzelne Methodenaufrufe, interne Reihenfolgen, private Hilfsstrukturen –, dann klebt der Test an der Implementierung. Jede Umgestaltung, die das Verhalten gar nicht ändert, lässt ihn rot werden. Solche Tests bestrafen Refactoring, statt es abzusichern, und das Team lernt schnell, sie zu meiden oder blind anzupassen. Prüfe ich dagegen an einer sinnvollen Grenze – am beobachtbaren Verhalten der Einheit, an dem, was ein anderer Teil des Systems tatsächlich von ihr sieht –, dann darf sich innen alles ändern, solange außen das Versprechen hält.\n\nDas ist der Punkt, an dem Testen aufhört, eine reine Handwerksfrage zu sein, und zur Entwurfsfrage wird. Die Grenze, an der ich teste, ist dieselbe Grenze, an der ich das System später aufschneiden, austauschen oder ersetzen kann. Ein gut gesetzter Test beschreibt eine Naht im System. Ein schlecht gesetzter Test zementiert ein Detail, das niemanden von außen etwas angeht. Deshalb lohnt sich vor der Tool-Frage – Mocha oder Jasmine, Sinon oder handgeschriebene Stubs – die inhaltliche Frage: Welches Verhalten will ich festhalten, und welches will ich bewusst offen lassen?\n\n## Wann sich das lohnt – und wann nicht\n\nIch will nicht behaupten, dass jede Zeile mit einem Test beginnen muss. Ein Spike, mit dem ich nur herausfinde, ob eine Bibliothek überhaupt taugt, braucht keinen Test – er ist selbst das Experiment und wird danach gelöscht. Ein reiner Durchstich, um ein Layout zu sehen, auch nicht.\n\nDie Abwägung hängt an einer einzigen Frage: Wie gut verstehe ich das Problem schon? Wenn die Aufgabe klar ist und nur noch getippt werden muss, kostet Test-First mehr, als es bringt. Wenn ich dagegen nicht sicher bin, was „richtig“ überhaupt heißt – und das ist in der frühen Produktphase der Normalfall –, dann ist der Test das billigste verfügbare Werkzeug, um Klarheit zu erzwingen. Gerade weil ich weiß, dass der Code Wegwerfware ist, will ich das Wenige, das ich lerne, in einer Form festhalten, die den Code überlebt.\n\nSo gesehen ist Test-First in der Erkundungsphase kein Widerspruch zum Wegwerfen. Es ist die Methode, mit der ich aus einem Wegwerf-Prototyp trotzdem dauerhaftes Wissen herausziehe: die eine Zeile, die das erwartete Verhalten festhält, bleibt, auch wenn alles andere geht.\n\n## Der Fehlerpfad entscheidet über Releasefähigkeit\n\nEs gibt einen Sprung zwischen „läuft in der Demo“ und „läuft im Betrieb“, und genau diesen Sprung machen frühe Tests sichtbar. Im Demo-Modus sieht fast jedes System sauber aus, weil man den Ablauf zeigt, in dem alles gut geht: gültige Eingabe, erreichbarer Dienst, genau ein Durchlauf. Interessant wird es daneben.\n\nWenn ich einen Import baue, ist die spannende Frage nicht, ob er eine korrekte Datei einliest, sondern was bei der halb kaputten passiert. Wird der Lauf wiederholt, entstehen dann Duplikate, oder ist er idempotent? Was passiert bei einem Timeout mitten in der Verarbeitung – bricht er sauber ab oder bleibt das System in einem halben Zustand? Welche Logzeile hilft mir nachts um drei, den Fehler zu verstehen, statt ihn nur zu bemerken? Diese Fragen wirken trocken, aber sie sind der eigentliche Unterschied zwischen einem Prototyp und etwas, das man einem Kunden gibt.\n\nIch schreibe deshalb früh mindestens einen Test für den unangenehmen Fall, nicht nur für den schönen. Ein Test, der prüft, dass eine doppelte Verarbeitung eben nicht zwei Rechnungen erzeugt, sagt mehr über die Tragfähigkeit des Entwurfs aus als zehn Tests für das erwartbare Ergebnis. Und er sagt es zu einem Zeitpunkt, an dem die Änderung noch billig ist.\n\n## Was das messbar bringt\n\nDas alles wäre Folklore, wenn es nur ein gutes Gefühl erzeugte. Der Nutzen ist konkreter. Missverständnisse werden im Beispiel sichtbar, nicht erst in der Abnahme – das verkürzt den teuersten aller Zyklen, den zwischen „falsch verstanden“ und „gemerkt, dass falsch verstanden“. Refactoring wird billiger, weil Tests an der richtigen Grenze das Verhalten absichern, statt die Implementierung einzufrieren; das Team traut sich, aufzuräumen, statt Code zu umgehen. Und die Übergabe wird leichter, weil ein Beispiel in Fachsprache eine bessere Dokumentation ist als jeder Fließtext, der neben dem Code veraltet.\n\nWenn ich einen Effekt herausgreifen müsste, dann diesen: kürzere Rückkopplung. Ein Beispiel, das eine Annahme prüft, verwandelt eine Diskussion, die sonst Wochen später im Betrieb geführt würde, in eine Diskussion von fünf Minuten am Whiteboard. Das ist keine weiche Verbesserung. Es ist der Unterschied zwischen einem Team, das an der Sache arbeitet, und einem, das an seinen Missverständnissen arbeitet.\n\n## Was bleibt, wenn der Code geht\n\nZurück zum Anfang. Der Grund, warum ich Tests schreibe, bevor es ein Produkt gibt, ist nicht Disziplin und auch nicht Vollständigkeit. Es ist, dass der Test die eine Sache festhält, die ich behalten will, während alles andere noch im Fluss ist: das erwartete Verhalten, benannt an einer Grenze, über die man streiten kann.\n\nDer Code der ersten Woche ist fast immer falsch, und das ist in Ordnung – er war nie als Antwort gemeint, sondern als Frage in ausführbarer Form. Wenn ich ihn wegwerfe und neu schreibe, prüft derselbe Test die neue Fassung genauso streng. Genau deshalb üben wir an Katas, ohne je ein Produkt zu bauen: nicht das Ergebnis trägt, sondern das Urteil darüber, welches Verhalten zählt. Und dieses Urteil ist das Einzige, was ein Team wirklich mitnehmen kann, wenn der erste Wurf längst gelöscht ist."},{"slug":"flood-it-architektur-lupe","title":"Flood-It: ein Spiel als Architektur-Lupe","summary":"Ein Brett aus Zahlen, ein Zug als Pure Function und ein Greedy-Löser zeigen im Kleinen, was in großen Systemen verschwimmt: die Trennung von Kernlogik, Strategie und Darstellung.","tags":["Software","Lernen"],"published_at":"2014-10-24T10:00:00+00:00","updated_at":"2026-07-14T16:38:17.99207+00:00","url":"https://www.mikebild.dev/de/blog/flood-it-architektur-lupe/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/47e2dfa2e2a83c2347aa229f4601fc40333bbac752466758f49c293bbdd0f41e/flood-it-architektur-lupe-vorlesen.mp3","audio_duration_secs":607,"text":"# Flood-It: ein Spiel als Architektur-Lupe\n\n```js\nvar board = [\n  [2, 1, 1, 3],\n  [1, 2, 3, 0],\n  [0, 1, 2, 2],\n  [3, 3, 0, 1]\n];\n\nfunction flood(board, color) {\n  // returns a new board where the top-left region\n  // has captured all adjacent cells of the picked color\n}\n```\n\nMehr Modell braucht Flood-It nicht. Das Spiel ist schnell erklärt: ein Brett aus Farbfeldern, oben links sitzt eine Region, die anfangs aus einem einzigen Feld besteht. Pro Zug wählt man eine Farbe. Die Region nimmt diese Farbe an und schluckt dabei alle orthogonal angrenzenden Felder, die bereits so gefärbt sind. Ziel ist ein einfarbiges Brett – in möglichst wenigen Zügen.\n\nVor gut zwei Wochen habe ich dafür das Repository `kata-flood-it` angelegt, eine frische Kata in JavaScript. Das README besteht bisher aus einer einzigen Zeile, und mehr verspreche ich hier auch nicht: kein fertiges Produkt, sondern ein kleines Experiment. Der Reiz liegt ohnehin nicht im Spiel selbst – Flood-It gibt es längst auf jedem Smartphone. Der Reiz liegt darin, dass so ein winziges Programm wie eine Lupe wirkt. Es gibt keine Datenbank, kein Framework, keinen Netzwerk-Layer, hinter dem sich schlechte Schnitte verstecken könnten. Was übrig bleibt, sind genau drei Dinge: ein Modell, eine Strategie und eine Darstellung. Und die Frage, wo die Grenzen zwischen ihnen verlaufen.\n\n## Das Brett ist eine Entscheidung\n\nDie erste Entscheidung fällt, bevor eine Zeile Spiellogik existiert: Was ist das Brett? Oben steht meine Antwort – ein zweidimensionales Array aus Zahlen. Das wirkt banal, ist es aber nicht. Die naheliegende Alternative sieht man in vielen kleinen Browser-Spielen: Das Brett existiert nur als Sammlung von `div`-Elementen, und die Wahrheit über den Spielzustand steckt in CSS-Klassennamen. Wer wissen will, welche Farbe ein Feld hat, fragt das DOM. Damit ist die Darstellung zum Modell geworden, und jede Logik muss durch sie hindurch.\n\nDie zweite, kleinere Entscheidung: Zahlen statt Farbnamen. Im Modell gibt es kein `\"red\"` und kein `\"#ff0000\"`, nur `0` bis `5`. Welche Zahl welchem Farbton entspricht, ist eine Frage der Darstellung – auf der Konsole vielleicht ein Buchstabe, im Browser eine CSS-Klasse, auf einem Canvas ein Füllstil. Das Modell weiß davon nichts, und genau das macht es tragfähig.\n\nMir ist beim Schreiben aufgefallen, wie viel Diskussionsstoff allein in diesen zwei Zeilen steckt. In einem Review über ein großes System reden wir über dieselben Fragen – wo liegt der führende Zustand, wer darf ihn lesen, wer ihn ändern –, nur verteilt über Dutzende Dateien. Hier passt die ganze Frage auf einen Bildschirm.\n\n## Der Zug als Funktion\n\nDer Kern des Spiels ist ein Flood-Fill: Von oben links aus werden alle zusammenhängenden Felder der alten Farbe eingesammelt und umgefärbt. Klassisch löst man das rekursiv (Tiefensuche) oder mit einer Queue (Breitensuche). Ich habe die Breitensuche gewählt, weil sie ohne tiefe Aufrufstapel auskommt – bei großen Brettern kippt die naive Rekursion in JavaScript irgendwann mit einem Stack Overflow um.\n\n```js\nfunction flood(board, color) {\n  var next = board.map(function (row) { return row.slice(); });\n  var target = next[0][0];\n  if (target === color) return next;\n\n  var queue = [[0, 0]];\n  while (queue.length > 0) {\n    var cell = queue.shift();\n    var row = cell[0];\n    var col = cell[1];\n    if (row < 0 || col < 0 || row >= next.length || col >= next[0].length) continue;\n    if (next[row][col] !== target) continue;\n\n    next[row][col] = color;\n    queue.push([row - 1, col], [row + 1, col], [row, col - 1], [row, col + 1]);\n  }\n  return next;\n}\n```\n\nDie wichtigste Eigenschaft steht in der ersten Zeile: `flood` verändert das übergebene Brett nicht, sondern liefert ein neues. Das kostet ein paar Kopien, und `queue.shift()` ist auf langen Arrays auch nicht die schnellste Operation der Welt – für ein 14×14-Brett ist beides völlig egal. Was ich dafür bekomme, ist erheblich: Ein Zug ist ein Wert. Die Spielhistorie ist schlicht ein Array von Brettern, Undo ist ein Index, der um eins zurückspringt, und ein Replay ist eine Schleife. Nichts davon musste ich extra bauen.\n\nDazu kommt eine zweite Pure Function, die das Spielende feststellt:\n\n```js\nfunction isSolved(board) {\n  return board.every(function (row) {\n    return row.every(function (cell) {\n      return cell === board[0][0];\n    });\n  });\n}\n```\n\nBeide Funktionen kennen weder Züge-Limits noch Buttons noch Punktestände. Sie beantworten genau eine Frage über das Brett. Alles andere – wie viele Züge erlaubt sind, ob das Spiel verloren ist, was danach passiert – gehört woanders hin.\n\n## Tests gegen die Logik, nicht gegen das DOM\n\nBrowser-Oberflächen zu testen ist 2014 mühsam: Man braucht einen echten oder kopflosen Browser, die Läufe sind langsam, und die Tests brechen, sobald sich eine CSS-Klasse ändert. Die Kata zeigt den Ausweg im Kleinen: Wenn die Kernlogik aus Pure Functions in CommonJS-Modulen besteht, laufen die Tests mit Mocha direkt auf Node – in Millisekunden, ohne Browser. Für die Auslieferung bündelt Browserify dieselben Module dann für den Browser. Ein Modulformat, zwei Runtimes.\n\n```js\nvar assert = require('assert');\nvar flood = require('./flood');\n\ndescribe('flood', function () {\n  it('captures adjacent cells of the picked color', function () {\n    var board = [\n      [0, 1],\n      [1, 1]\n    ];\n    assert.deepEqual(flood(board, 1), [\n      [1, 1],\n      [1, 1]\n    ]);\n  });\n\n  it('does not mutate the given board', function () {\n    var board = [\n      [0, 1],\n      [1, 1]\n    ];\n    flood(board, 1);\n    assert.deepEqual(board, [\n      [0, 1],\n      [1, 1]\n    ]);\n  });\n});\n```\n\nDas zweite Beispiel ist mir wichtiger als das erste. Ob eine Funktion ihre Eingabe verändert, ist eine Vertragsfrage, und Verträge gehören in Tests – nicht in Kommentare. In einem größeren Projekt habe ich einmal zwei Tage einem Fehler hinterhergesucht, der genau daher kam: eine Hilfsfunktion, die ein übergebenes Array sortierte und damit still den Zustand des Aufrufers umbaute. Der Test oben kostet sechs Zeilen und schließt diese ganze Fehlerklasse aus.\n\nAuffällig ist auch, was in den Tests nicht vorkommt: kein `document`, kein Selektor, kein simulierter Klick. Wenn die Tests für die Spielregeln einen Browser brauchen, ist das kein Test-Problem, sondern ein Architektur-Befund – dann steckt Logik in der Darstellung.\n\n## Die Strategie ist eine eigene Schicht\n\nFlood-It hat neben den Regeln noch eine zweite fachliche Ebene: Wie spielt man gut? Die naheliegende Heuristik ist gierig – probiere jede Farbe, zähle, wie groß die Region danach wäre, und nimm die Farbe mit dem größten Zuwachs.\n\n```js\nfunction countCaptured(board) {\n  var marked = flood(board, -1);\n  var count = 0;\n  marked.forEach(function (row) {\n    row.forEach(function (cell) {\n      if (cell === -1) count += 1;\n    });\n  });\n  return count;\n}\n\nfunction bestMove(board, colors) {\n  var best = { color: null, captured: -1 };\n  colors.forEach(function (color) {\n    if (color === board[0][0]) return;\n    var captured = countCaptured(flood(board, color));\n    if (captured > best.captured) {\n      best = { color: color, captured: captured };\n    }\n  });\n  return best.color;\n}\n```\n\nZwei Beobachtungen. Erstens: `countCaptured` erfindet keine eigene Traversierung, sondern missbraucht `flood` mit einer Farbe, die im Spiel nicht vorkommt, und zählt die Markierungen. Das geht nur, weil `flood` rein ist – ich kann es gefahrlos „auf Probe\" aufrufen, so oft ich will. Genau dieser Probelauf ist in Systemen mit verstreutem, veränderlichem Zustand unbezahlbar teuer: Wer dort eine Was-wäre-wenn-Frage stellen will, braucht Mocks, Transaktions-Rollbacks oder eine Staging-Umgebung.\n\nZweitens: Die gierige Strategie ist messbar nicht optimal, und das ist keine Schludrigkeit meinerseits. Clifford, Jalsenius, Montanaro und Sach haben 2010 gezeigt, dass Flood-It ab drei Farben NP-schwer ist – eine effiziente optimale Strategie ist also nicht zu erwarten. Für die Architektur heißt das: Die Strategie wird sich ändern. Heute gierig, morgen vielleicht mit zwei Zügen Vorausschau. Deshalb ist sie eine eigene Schicht, die nur über `flood`, `countCaptured` und `isSolved` mit dem Kern spricht. Auch der Zugzähler und das Zug-Limit gehören hierher, nicht in die Regeln und nicht in die Oberfläche – sie sind Spielmodus, nicht Spielgesetz.\n\n## Die Darstellung als austauschbare Haut\n\nGanz außen sitzt die Darstellung. Sie liest das Brett und zeichnet es – mehr nicht:\n\n```js\nfunction render(board, element) {\n  element.innerHTML = '';\n  board.forEach(function (row) {\n    var line = document.createElement('div');\n    row.forEach(function (cell) {\n      var field = document.createElement('span');\n      field.className = 'cell color-' + cell;\n      line.appendChild(field);\n    });\n    element.appendChild(line);\n  });\n}\n```\n\nDer Klick-Handler auf den Farbknöpfen übersetzt ein DOM-Event in einen Aufruf der Kernlogik und rendert das Ergebnis neu. Die Darstellung schreibt nie selbst am Brett herum; sie ist eine Haut, die man abziehen und ersetzen kann. Ein Canvas statt der `div`-Suppe? Nur `render` ändert sich. Eine Konsolen-Version mit Buchstaben statt Farben, um die Kata in Node durchzuspielen? Wieder nur `render`. jQuery habe ich gar nicht erst hereingeholt – für zwanzig Zeilen DOM-Arbeit trägt es nichts bei, und die Versuchung, Zustand in `data`-Attributen zu parken, bleibt draußen.\n\n```mermaid\nflowchart TB\n  subgraph ui[\"Darstellung\"]\n    H[Click-Handler] --> R[render]\n  end\n  subgraph strategy[\"Strategie / Spielmodus\"]\n    G[bestMove] --> Z[Zugzähler +<br/>Zug-Limit]\n  end\n  subgraph core[\"Kernlogik (Pure Functions)\"]\n    F[flood] --> I[isSolved]\n    F --> C[countCaptured]\n  end\n  ui --> strategy\n  strategy --> core\n  ui --> core\n```\n\nDie Pfeile zeigen alle nach innen. Die Kernlogik kennt niemanden, die Strategie kennt nur den Kern, die Darstellung darf beide benutzen. Rückwärts gibt es keinen Weg – `flood` kann gar nicht wissen, ob es gerade von einem Test, vom Greedy-Löser oder von einem Klick aufgerufen wurde.\n\n## Was im Großen verschwimmt\n\nNichts an dieser Aufteilung ist neu. Schichten, reine Kernlogik, dünne Ränder – das steht seit Jahren in jedem Architektur-Buch. Interessant ist, warum es im Großen trotzdem so oft verwischt und im Kleinen so klar bleibt. Meine Beobachtung: In großen Systemen gibt es immer einen bequemen Grund für den kleinen Grenzverstoß. Die Validierung landet im Controller, weil dort das Request-Objekt schon herumliegt. Der Rabatt wird im Template ausgerechnet, weil die Zahl nur dort gebraucht wird. Jede einzelne Abkürzung ist verständlich, und in Summe kann nach zwei Jahren niemand mehr sagen, wo die Geschäftsregeln eigentlich stehen.\n\nDie Kata hat diese Ausreden nicht. Wenn ich in `flood` einen Zugzähler einbaue, sehe ich den Fremdkörper sofort, weil die Funktion vorher sieben Zeilen hatte. Genau das meine ich mit Lupe: Das kleine Programm vergrößert nicht die Lösung, sondern den Verstoß.\n\nEin paar Fragen nehme ich aus dem Spiel direkt mit in die Projektarbeit:\n\n- Was ist unser „Brett\" – die eine Datenstruktur, die den führenden Zustand hält – und wie viele Stellen dürfen sie verändern?\n- Welche Operation ist unser „Zug\", und ist sie als Wert greifbar – nachvollziehbar, wiederholbar, rückgängig zu machen?\n- Können wir eine Entscheidung „auf Probe\" durchrechnen, ohne echten Zustand anzufassen?\n- Laufen die Tests für die Regeln ohne Browser, ohne Datenbank, ohne Netz?\n\nWer diese Fragen für ein großes System nicht beantworten kann, hat kein Tooling-Problem. Es fehlt die Grenze, die in der Kata vier Funktionsnamen groß ist.\n\n## Unterm Strich\n\nDie Kata ist bewusst klein geblieben, und das Repository wird auch keine Bibliothek werden – es ist eine Fingerübung mit Befund. Der Befund: Die Trennung von Kernlogik, Strategie und Darstellung ist keine Frage der Systemgröße und keine Frage des Frameworks. Sie ist in achtzig Zeilen ES5 genauso vorhanden oder abwesend wie in einer Anwendung mit achtzigtausend Zeilen. Der Unterschied ist nur, dass man sie in achtzig Zeilen nicht übersehen kann.\n\nDeshalb lohnt sich so ein Spiel gerade dann, wenn man es eigentlich nicht nötig hat. Nicht als Algorithmus-Training – der Flood-Fill ist an einem Abend geschrieben. Sondern als Prüfstand für die eigenen Gewohnheiten: Wo lege ich Zustand ab, wenn mir niemand eine Struktur vorgibt? Schreibe ich zuerst die Oberfläche oder zuerst den Vertrag? Die Antworten, die man sich bei einem Farbflut-Spiel gibt, sind dieselben, die man später unter Termindruck gibt – nur ehrlicher.\n\n## Weiterführende Quellen\n\n- Repository zur Kata: https://github.com/MikeBild/kata-flood-it\n- Flood Fill (Algorithmus-Überblick): https://en.wikipedia.org/wiki/Flood_fill\n- Clifford, Jalsenius, Montanaro, Sach: „The Complexity of Flood Filling Games\" (2010): https://arxiv.org/abs/1001.4420\n- Mocha (Test-Framework): https://mochajs.org/\n- Browserify: http://browserify.org/"},{"slug":"mathparser-parser-als-denkwerkzeug","title":"MathParser: Parser als Denkwerkzeug","summary":"Ein Rückblick auf ein eigenes Parser-Demo von 2010 zeigt, warum ein handgeschriebener Ausdrucks-Parser auf wenigen Zeilen mehr über Schichten, Struktur und die Grenze zwischen Syntax und Semantik lehrt als manches große Projekt.","tags":["Software","Architektur"],"published_at":"2014-10-12T10:00:00+00:00","updated_at":"2026-07-14T16:36:34.264841+00:00","url":"https://www.mikebild.dev/de/blog/mathparser-parser-als-denkwerkzeug/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/53b6d4b53816ae7bd654936776c86f1058c99e59c8cb22930ae8b6833f1f01a6/mathparser-parser-als-denkwerkzeug-vorlesen.mp3","audio_duration_secs":695,"text":"# MathParser: Parser als Denkwerkzeug\n\nRund 200 Zeilen C#. Mehr braucht ein vollständiger Parser für Rechenausdrücke nicht – Tokenizer, rekursiver Abstieg, Auswertung, Klammern, Punkt-vor-Strich, brauchbare Fehlermeldungen mit Positionsangabe. Ein durchschnittlicher Controller in einem Geschäftssystem erreicht dieselbe Länge und lehrt dabei fast nichts über Softwareentwurf. Die 200 Parser-Zeilen dagegen enthalten drei sauber getrennte Schichten, eine Grammatik als explizites Strukturmodell und eine Grenze zwischen Form und Bedeutung, an der man sich beim Schreiben mindestens einmal die Finger klemmt. Gemessen an Erkenntnis pro Zeile kenne ich wenig, das dichter ist – und es passt in einen Nachmittag.\n\n## Ein vier Jahre altes Demo-Projekt, neu gelesen\n\nDer Anlass für diesen Text ist ein eigenes Repository: [MathParser](https://github.com/MikeBild/MathParser), ein C#-Demo-Projekt von 2010. Eine Visual-Studio-Solution, darin ein Parser-Projekt, eine zweite Variante namens `ClassicMathParser` und ein Testprojekt. Kein Produkt, keine Bibliothek für andere – ein Demo, geschrieben, um die Sache einmal selbst durchdrungen zu haben.\n\nVier Jahre später habe ich es wieder aufgemacht, eigentlich nur, um aufzuräumen. Hängen geblieben bin ich an etwas anderem: Der Code stellt Fragen, die ich 2010 gar nicht gesehen habe. Warum liegen da zwei Varianten nebeneinander? Warum wertet der Parser sofort aus, statt erst eine Struktur zu bauen? Warum steckt die Behandlung des Minuszeichens genau dort, wo sie steckt? Alte eigene Projekte sind darin unbequem ehrlich: Man diskutiert mit einem früheren Ich, das sich nicht mehr herausreden kann.\n\nGenau deshalb lohnt sich der Rückblick als Artikel. Nicht weil das Repository besonders wäre – sondern weil das Thema als Denkwerkzeug besser altert als die meisten Frameworks jener Zeit.\n\n## Warum ein regulärer Ausdruck hier nicht reicht\n\nDer erste Reflex bei „Rechenausdruck parsen“ ist in Code-Reviews immer wieder derselbe: ein regulärer Ausdruck, vielleicht zwei, dazu ein `Split` auf Operatoren. Für `2+3*4` sieht das kurz sogar gut aus. Für `(1+2)*(3-(4/2))` bricht es zusammen, und zwar nicht wegen handwerklicher Fehler, sondern grundsätzlich: Reguläre Ausdrücke können nicht zählen. Verschachtelte Klammern verlangen ein Gedächtnis über die Tiefe der Verschachtelung, und genau das gibt ein regulärer Ausdruck vom Modell her nicht her. Man kann das theoretisch begründen – reguläre gegen kontextfreie Sprachen –, aber praktisch reicht ein Blick auf die Klammern: Wer `(((` liest, muss sich merken, dass noch dreimal `)))` folgen muss.\n\nMir ist dabei 2010 zum ersten Mal aufgefallen, dass die Wahl des Werkzeugs eine Aussage über die Struktur des Problems ist. Ein regulärer Ausdruck behauptet: „Diese Eingabe ist flach.“ Ein Parser mit Rekursion behauptet: „Diese Eingabe ist ein Baum.“ Rechenausdrücke sind Bäume – `1+2*3` hat eine innere Gliederung, die man der Zeichenkette nicht ansieht. Wer das Falsche behauptet, bezahlt später mit Sonderfällen.\n\n## Drei Schichten, drei Verträge\n\nEin Ausdrucks-Parser zerfällt fast von selbst in drei Schichten, und jede hat einen klaren Vertrag:\n\n- Der Tokenizer macht aus einer Zeichenkette einen Strom von Symbolen: Zahlen, Operatoren, Klammern. Er kennt Zeichen, aber keine Struktur.\n- Der Parser prüft, ob die Symbolfolge der Grammatik entspricht, und baut daraus die Struktur. Er kennt Struktur, aber keine Bedeutung.\n- Die Auswertung gibt der Struktur eine Bedeutung: Sie rechnet. Erst hier existieren Begriffe wie „Ergebnis“ oder „Division durch null“.\n\n```mermaid\nflowchart LR\n  A[\"Eingabe<br/>(1+2)*3\"] --> B[\"Tokenizer<br/>Zeichen zu Symbolen\"]\n  B --> C[\"Token-Strom<br/>( 1 + 2 ) * 3\"]\n  C --> D[\"Parser<br/>rekursiver Abstieg\"]\n  D --> E[\"Struktur<br/>Baum oder direkter Wert\"]\n  E --> F[\"Auswertung<br/>Bedeutung, Ergebnis\"]\n```\n\nDas klingt nach Lehrbuch, hat aber eine sehr praktische Pointe: Jede Schicht kann man einzeln testen, einzeln ersetzen und einzeln erklären. Genau dafür ist in der Solution ein eigenes Testprojekt vorgesehen, und die Tests für den Tokenizer wissen nichts von Grammatik – sie prüfen nur, dass aus `\"12.5+\"` die Symbole `12.5` und `+` werden. Diese Art von Trennung predigt man in großen Systemen ständig; im Parser ist sie kein Vorsatz, sondern die einzig bequeme Bauweise. Das ist der erste Grund, warum ich das Thema ein Denkwerkzeug nenne: Es macht ein Architekturprinzip erlebbar, statt es zu behaupten.\n\n## Die Grammatik zwingt zum Nachdenken über Struktur\n\nBevor der eigentliche Parser entsteht, steht die Grammatik – bei mir damals als EBNF-Skizze auf einem Zettel, in dieser Form:\n\n```text\nExpression := Term   { (\"+\" | \"-\") Term }\nTerm       := Factor { (\"*\" | \"/\") Factor }\nFactor     := Number | \"(\" Expression \")\" | \"-\" Factor\n```\n\nDrei Zeilen, und die interessanteste Eigenschaft steht nirgends explizit: die Operator-Rangfolge. Punkt-vor-Strich ist keine Regel, die irgendwo per `if` geprüft wird – sie steckt in der Schichtung der Grammatik. `Expression` besteht aus `Term`-Bausteinen, `Term` aus `Factor`-Bausteinen. Weil `*` und `/` eine Ebene tiefer gebunden werden als `+` und `-`, binden sie automatisch stärker. Die Klammer wiederum springt von ganz unten zurück nach ganz oben: Ein `Factor` darf eine komplette `Expression` enthalten. Das ist die Rekursion, an der der reguläre Ausdruck vorhin gescheitert ist, hier in einer einzigen Zeile.\n\nBeim Wiederlesen 2014 finde ich das immer noch bemerkenswert: Rangfolge als Struktur statt als Bedingung. Terence Parr behandelt genau diese Denkweise in „Language Implementation Patterns“ von 2010 ausführlich, und Douglas Crockford zeigt in seinem Essay „Top Down Operator Precedence“ von 2007 einen alternativen Weg, bei dem die Rangfolge als Bindungsstärke an den Operatoren selbst hängt. Beide Wege führen zum Ziel; wer beide einmal nachgebaut hat, versteht danach besser, was „Struktur ausdrücken“ überhaupt heißt.\n\n## Rekursiver Abstieg in C#\n\nDie Grammatik übersetzt sich fast mechanisch in Code – pro Regel eine Methode. Zuerst der Tokenizer, hier bewusst schlicht gehalten:\n\n```csharp\npublic enum TokenType { Number, Plus, Minus, Star, Slash, LeftParen, RightParen, End }\n\npublic sealed class Tokenizer\n{\n    private readonly string _input;\n    private int _position;\n\n    public Tokenizer(string input) { _input = input; }\n\n    public Token Next()\n    {\n        while (_position < _input.Length && char.IsWhiteSpace(_input[_position]))\n            _position++;\n\n        if (_position >= _input.Length)\n            return new Token(TokenType.End, 0, _position);\n\n        char current = _input[_position];\n\n        if (char.IsDigit(current))\n        {\n            int start = _position;\n            while (_position < _input.Length &&\n                   (char.IsDigit(_input[_position]) || _input[_position] == '.'))\n                _position++;\n\n            double value = double.Parse(\n                _input.Substring(start, _position - start),\n                CultureInfo.InvariantCulture);\n\n            return new Token(TokenType.Number, value, start);\n        }\n\n        _position++;\n        switch (current)\n        {\n            case '+': return new Token(TokenType.Plus, 0, _position - 1);\n            case '-': return new Token(TokenType.Minus, 0, _position - 1);\n            case '*': return new Token(TokenType.Star, 0, _position - 1);\n            case '/': return new Token(TokenType.Slash, 0, _position - 1);\n            case '(': return new Token(TokenType.LeftParen, 0, _position - 1);\n            case ')': return new Token(TokenType.RightParen, 0, _position - 1);\n        }\n\n        throw new FormatException(\n            string.Format(\"Unexpected character '{0}' at position {1}.\", current, _position - 1));\n    }\n}\n```\n\nZwei Kleinigkeiten mit großer Wirkung: `CultureInfo.InvariantCulture`, weil `double.Parse` auf einer deutschen Maschine sonst das Komma erwartet und `1.5` still zu `15` macht – ein Fehler, den ich nicht im Parser-Demo, aber in echten Systemen mehr als einmal gejagt habe. Und die Positionsangabe im Token, denn ohne sie kann später keine Schicht eine Fehlermeldung erzeugen, die dem Anwender die Stelle zeigt.\n\nDer Parser selbst folgt der Grammatik Zeile für Zeile:\n\n```csharp\npublic sealed class Parser\n{\n    private readonly Tokenizer _tokenizer;\n    private Token _current;\n\n    public Parser(string input)\n    {\n        _tokenizer = new Tokenizer(input);\n        _current = _tokenizer.Next();\n    }\n\n    public double Parse()\n    {\n        double result = ParseExpression();\n        Expect(TokenType.End);\n        return result;\n    }\n\n    // Expression := Term { (\"+\" | \"-\") Term }\n    private double ParseExpression()\n    {\n        double left = ParseTerm();\n        while (_current.Type == TokenType.Plus || _current.Type == TokenType.Minus)\n        {\n            TokenType op = _current.Type;\n            Advance();\n            double right = ParseTerm();\n            left = op == TokenType.Plus ? left + right : left - right;\n        }\n        return left;\n    }\n\n    // Term := Factor { (\"*\" | \"/\") Factor }\n    private double ParseTerm()\n    {\n        double left = ParseFactor();\n        while (_current.Type == TokenType.Star || _current.Type == TokenType.Slash)\n        {\n            TokenType op = _current.Type;\n            Advance();\n            double right = ParseFactor();\n            left = op == TokenType.Star ? left * right : left / right;\n        }\n        return left;\n    }\n\n    // Factor := Number | \"(\" Expression \")\" | \"-\" Factor\n    private double ParseFactor()\n    {\n        if (_current.Type == TokenType.Number)\n        {\n            double value = _current.Value;\n            Advance();\n            return value;\n        }\n\n        if (_current.Type == TokenType.LeftParen)\n        {\n            Advance();\n            double inner = ParseExpression();\n            Expect(TokenType.RightParen);\n            return inner;\n        }\n\n        if (_current.Type == TokenType.Minus)\n        {\n            Advance();\n            return -ParseFactor();\n        }\n\n        throw new FormatException(\n            string.Format(\"Unexpected token at position {0}.\", _current.Position));\n    }\n\n    private void Advance() { _current = _tokenizer.Next(); }\n\n    private void Expect(TokenType type)\n    {\n        if (_current.Type != type)\n            throw new FormatException(\n                string.Format(\"Expected {0} at position {1}.\", type, _current.Position));\n        Advance();\n    }\n}\n```\n\nDas ist rekursiver Abstieg in Reinform: `ParseExpression` ruft `ParseTerm`, `ParseTerm` ruft `ParseFactor`, und `ParseFactor` ruft bei einer Klammer wieder `ParseExpression`. Der Aufruf-Stack der Runtime übernimmt das Klammern-Zählen, das dem regulären Ausdruck gefehlt hat. Man kann Werkzeuge wie ANTLR solche Methoden aus einer Grammatik generieren lassen – für ernsthafte Sprachen würde ich das auch tun. Für das Verstehen ist die Handarbeit durch nichts zu ersetzen.\n\n## AST oder direkte Auswertung – die Frage, die ich 2010 übersprungen habe\n\nDer gezeigte Parser rechnet sofort: `ParseTerm` liefert ein `double`, nicht einen Knoten. Das ist die kürzeste Variante, und für ein Demo völlig in Ordnung. Beim Wiederlesen fällt mir aber auf, was diese Abkürzung kostet: Parser und Auswertung, eben noch zwei Schichten mit eigenen Verträgen, sind hier zu einer verschmolzen. Wer denselben Ausdruck zweimal mit anderen Variablenwerten rechnen will, muss zweimal parsen. Wer den Ausdruck hübsch formatiert ausgeben, vereinfachen oder in eine andere Sprache übersetzen will, kann es gar nicht – die Struktur wurde ja nie festgehalten, sie existierte nur flüchtig im Aufruf-Stack.\n\nDie Alternative ist ein abstrakter Syntaxbaum: `ParseTerm` liefert dann einen `Node`, etwa `BinaryNode(Star, NumberNode(2), NumberNode(3))`, und eine getrennte `Evaluate`-Methode läuft über den Baum. Mehr Code, mehr Allokationen – aber die Struktur wird zum eigenständigen Wert, mit dem sich weiterarbeiten lässt. In meiner Solution liegen bis heute zwei Projektvarianten nebeneinander, und ich vermute stark, dass der Unterschied genau um diese Frage kreiste; sicher sagen kann ich es nach vier Jahren nicht mehr, und das ist selbst eine Lektion über Demo-Projekte ohne Notizen.\n\nDie allgemeine Form der Frage begegnet mir seither ständig: Werte ich sofort aus oder baue ich erst ein Modell? Ein Import, der Zeile für Zeile direkt in die Datenbank schreibt, ist die direkte Auswertung. Ein Import, der erst ein geprüftes Zwischenmodell baut und es dann anwendet, ist der Baum. Beides ist legitim – aber es sollte eine Entscheidung sein, kein Zufall.\n\n## Dasselbe in ES5, zur Probe\n\nUm zu prüfen, ob die Struktur trägt oder nur ein C#-Idiom ist, habe ich den Kern in JavaScript nachgezogen – gleiches Muster, gleiche Namen:\n\n```javascript\n// Expression := Term { (\"+\" | \"-\") Term }\nfunction parseExpression(state) {\n  var left = parseTerm(state);\n  while (state.current.type === 'Plus' || state.current.type === 'Minus') {\n    var op = state.current.type;\n    advance(state);\n    var right = parseTerm(state);\n    left = op === 'Plus' ? left + right : left - right;\n  }\n  return left;\n}\n```\n\nKein Typsystem, keine Klassen, und trotzdem ist es erkennbar derselbe Parser. Das ist kein Zufall: Die Grammatik ist das Entwurfsdokument, der Code nur ihre Übersetzung. Crockfords Essay treibt das in JavaScript noch weiter und baut damit einen Parser für JavaScript selbst – wer wissen will, was `JSON.parse` oder ein Ausdrucks-Feld in einem Formular innen tun, findet dort die Antwort in erstaunlich wenig Code.\n\n## Die Grenze zwischen Syntax und Semantik wird körperlich\n\nDer Moment, der mir vom Nachmittag 2010 am deutlichsten geblieben ist, hängt am Minuszeichen. `3-2` und `-2` enthalten dasselbe Zeichen, aber zwei verschiedene Dinge: einmal einen binären Operator, einmal ein Vorzeichen. Der Tokenizer kann den Unterschied nicht kennen – er sieht nur ein Zeichen. Entschieden wird im Parser, und zwar über den Kontext: Ein Minus an einer Stelle, an der ein `Factor` erwartet wird, ist ein Vorzeichen; ein Minus nach einem fertigen `Term` ist eine Subtraktion. Die Bedeutung „negieren“ kommt dann noch einmal später, in der Auswertung.\n\nDasselbe gilt für Fehler. `2+*3` scheitert im Parser – ein Syntaxfehler, die Eingabe ist schon als Form ungültig. `2/0` dagegen passiert den Parser anstandslos und scheitert erst in der Auswertung – ein semantischer Fehler, die Form war korrekt, die Bedeutung ist es nicht. Wer beide Fehler in derselben Schicht behandelt, hat die Grenze verwischt; wer sie trennt, bekommt zwei präzise Fehlermeldungen fast geschenkt. Diese Unterscheidung – was ist wohlgeformt, was ist gültig, was ist sinnvoll – trage ich seitdem durch jede Schnittstellen-Diskussion, von der Validierung eines XML-Dokuments bis zur Frage, ob ein Web-Service eine 400 oder eine 422 zurückgeben sollte.\n\n## Was ich daraus mitnehme\n\nUnterm Strich hat mir das Wiederlesen des alten Demos drei Dinge klargemacht. Erstens: Kleine, abgeschlossene Probleme sind die besseren Lehrstücke, weil man sie vollständig durchdringen kann – bei einem Parser gibt es keine Stelle, an der man auf Magie verweisen müsste. Zweitens: Die drei Schichten und die Grammatik-Schichtung sind kein Parser-Spezialwissen, sondern Architektur im Kleinen; die Fragen „wer darf was wissen“ und „wo steckt die Struktur“ sind exakt dieselben wie in jedem größeren System. Drittens: Alte eigene Projekte mit vier Jahren Abstand zu lesen ist eine unterschätzte Übung – die offenen Fragen von damals sind heute sichtbar, und die Antworten von heute wären ohne den damaligen Nachmittag nicht da.\n\nWer noch nie einen Parser geschrieben hat: Ein Nachmittag, ein leeres Projekt, die drei EBNF-Zeilen von oben. Kein Generator, keine Bibliothek, kein Blick in fremden Code. Die 200 Zeilen, die dabei herauskommen, sind vermutlich das dichteste Stück Softwareentwurf, das sich in dieser Zeit lernen lässt.\n\n## Weiterführende Quellen\n\n- Mein Demo-Repository: [github.com/MikeBild/MathParser](https://github.com/MikeBild/MathParser)\n- Douglas Crockford: [Top Down Operator Precedence](https://crockford.com/javascript/tdop/tdop.html) (2007)\n- Terence Parr: [Language Implementation Patterns](https://pragprog.com/titles/tpdsl/language-implementation-patterns/) (Pragmatic Bookshelf, 2010)\n- [ANTLR](http://www.antlr.org/) – Parser-Generator, falls es doch eine echte Grammatik werden soll"},{"slug":"state-mutation-auf-streams","title":"State Mutation auf Streams","summary":"Wer State als Faltung über einen Strom von Events berechnet statt ihn direkt zu ändern, bekommt Undo, Replay und Nachvollziehbarkeit fast geschenkt.","tags":["Software","Reactive"],"published_at":"2014-07-12T10:00:00+00:00","updated_at":"2026-07-14T16:34:37.713652+00:00","url":"https://www.mikebild.dev/de/blog/state-mutation-auf-streams/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/10228cff5ded199a1c8a218c7f42eb40effd27666bc1bdf43b2d1cff809224c9/state-mutation-auf-streams-vorlesen.mp3","audio_duration_secs":629,"text":"# State Mutation auf Streams\n\n```javascript\nvar events = [\n  { type: 'ItemAdded', sku: 'A-100', qty: 1 },\n  { type: 'ItemAdded', sku: 'B-200', qty: 2 },\n  { type: 'QuantityChanged', sku: 'B-200', qty: 3 },\n  { type: 'ItemRemoved', sku: 'A-100' }\n];\n\nfunction applyEvent(state, event) {\n  var items = {};\n  Object.keys(state.items).forEach(function (sku) {\n    items[sku] = state.items[sku];\n  });\n\n  if (event.type === 'ItemAdded') { items[event.sku] = event.qty; }\n  if (event.type === 'QuantityChanged') { items[event.sku] = event.qty; }\n  if (event.type === 'ItemRemoved') { delete items[event.sku]; }\n\n  return { items: items };\n}\n\nvar cart = events.reduce(applyEvent, { items: {} });\n// -> { items: { 'B-200': 3 } }\n```\n\nVier Events, eine Funktion, ein Aufruf von `Array.prototype.reduce` – und am Ende steht der aktuelle State eines Warenkorbs. Bemerkenswert ist, was in diesem Code alles fehlt: kein `cart.addItem()`, kein Setter, keine Stelle, an der jemand ein Objekt anfasst und verändert. Der Warenkorb wird nicht gepflegt, er wird berechnet. Die Liste der Events ist die eigentliche Wahrheit, der State nur ihre Zusammenfassung.\n\nDas klingt zunächst wie ein Taschenspielertrick, denn natürlich mutiert irgendwo Speicher. Aber die Blickrichtung dreht sich um, und genau diese Drehung finde ich seit einer Weile spannender als jedes neue Framework: State ist hier kein Ding mit Lebenslauf, sondern das Ergebnis einer Faltung über alles, was bisher passiert ist.\n\n## Eine Faltung, kein Setter\n\nIn der funktionalen Welt heißt dieses Muster `foldl` – eine Linksfaltung. Man nimmt einen Startwert, läuft von links nach rechts über eine Sequenz und verrechnet in jedem Schritt den bisherigen Zwischenstand mit dem nächsten Element. `reduce` in JavaScript ist exakt das, seit ES5 im Sprachstandard und 2014 in jedem aktuellen Browser vorhanden; nur für die verbliebenen IE8-Installationen braucht es noch einen es5-shim.\n\nDie Signatur der Schrittfunktion ist dabei der interessante Teil:\n\n```javascript\n// (state, event) -> state\nfunction applyEvent(state, event) { /* ... */ }\n```\n\nZwei Eigenschaften machen diese Funktion wertvoll. Erstens ist sie deterministisch: Dieselbe Event-Folge und derselbe Startwert ergeben immer denselben State. Zweitens ist sie vollständig: Es gibt keinen zweiten Weg, auf dem sich der State ändern könnte, keine Methode, die an der Faltung vorbei ins Objekt greift. Wer wissen will, warum der Warenkorb aussieht, wie er aussieht, liest die Events. Punkt.\n\nOb `applyEvent` intern ein frisches Objekt baut oder den übergebenen State direkt beschreibt, ist dagegen fast schon Geschmackssache. Im Beispiel oben kopiere ich die Items, weil ich Zwischenzustände später noch brauche. In einer engen Schleife über hunderttausend Events darf die Funktion auch mutieren – solange die Mutation in der Faltung eingesperrt bleibt und nach außen nur das Ergebnis sichtbar wird. Die Disziplin steckt nicht in der Immutability der Objekte, sondern in der einen, einzigen Tür, durch die Änderungen gehen.\n\n```mermaid\nflowchart LR\n  S0[Startzustand<br/>leerer Warenkorb] --> F1((applyEvent))\n  E1[ItemAdded<br/>A-100] --> F1\n  F1 --> F2((applyEvent))\n  E2[ItemAdded<br/>B-200] --> F2\n  F2 --> F3((applyEvent))\n  E3[QuantityChanged<br/>B-200] --> F3\n  F3 --> F4((applyEvent))\n  E4[ItemRemoved<br/>A-100] --> F4\n  F4 --> S[aktueller Zustand<br/>als Projektion]\n```\n\n## Ein kleines Experiment von 2012\n\nAuf die Idee bin ich nicht am Reißbrett gekommen. Im Oktober 2012 habe ich ein winziges Repository namens [statemutatejs](https://github.com/MikeBild/statemutatejs) angelegt. Das README besteht aus einer einzigen Zeile: „simple projection functions, alias foldl, that mutate state on a stream of elements\". Mehr Anspruch hatte das Ding nie – ein Experiment, entstanden aus der Frage, wie sich Projektionsfunktionen anfühlen, wenn man sie konsequent als Faltung über einen Strom schreibt statt als Methoden an einem Objekt.\n\nIch erwähne das nicht, weil daraus eine Bibliothek geworden wäre, die irgendjemand einsetzen sollte. Sondern weil mir beim Herumprobieren etwas aufgefallen ist, das ich damals nur halb verstanden habe: Sobald die Schrittfunktion vom Container getrennt ist, wird austauschbar, worüber gefaltet wird. Ein Array, ein Node-Stream, eine Observable-Sequenz – die Projection-Function bleibt wörtlich dieselbe. Das eine Wort „foldl\" im README war im Rückblick der ganze Punkt des Experiments.\n\nZwei Jahre später sehe ich dasselbe Muster an immer mehr Stellen wieder, und es lohnt sich, die Varianten einmal nebeneinanderzulegen.\n\n## Dasselbe Muster auf Node-Streams\n\nSeit Node 0.10 gibt es die überarbeitete Stream-Schnittstelle – streams2 – und mit ihr den `objectMode`, in dem ein Stream keine Buffer, sondern beliebige Objekte transportiert. Damit löst sich die Faltung vom Array: Die Events müssen nicht mehr vollständig im Speicher liegen, sie dürfen eintreffen, wann immer sie wollen.\n\n```javascript\nvar stream = require('stream');\nvar util = require('util');\n\nfunction StateFold(applyFn, initialState) {\n  stream.Writable.call(this, { objectMode: true });\n  this.state = initialState;\n  this.applyFn = applyFn;\n}\nutil.inherits(StateFold, stream.Writable);\n\nStateFold.prototype._write = function (event, encoding, done) {\n  this.state = this.applyFn(this.state, event);\n  done();\n};\n\n// same projection function as before, no changes needed\nvar fold = new StateFold(applyEvent, { items: {} });\neventSource.pipe(fold);\n\nfold.on('finish', function () {\n  console.log(fold.state); // -> { items: { 'B-200': 3 } }\n});\n```\n\nDas ist bewusst unspektakulär: ein `Writable` im Objektmodus, das pro Event einmal die Schrittfunktion aufruft und den Zwischenstand behält. `applyEvent` ist unverändert die Funktion aus dem ersten Beispiel. Ob `eventSource` eine Datei mit zeilenweise serialisierten JSON-Events ist, ein TCP-Socket oder ein Parser, der Logzeilen in Objekte übersetzt, spielt für die Faltung keine Rolle. Die Backpressure-Mechanik von streams2 sorgt nebenbei dafür, dass ein langsamer Konsument die Quelle bremst, statt Events im Speicher zu stapeln.\n\nIn einem Projekt Anfang des Jahres haben wir genau so eine Auswertung über mehrere Gigabyte an Protokolldaten gebaut: Events aus Dateien lesen, durch eine Handvoll Projektionsfunktionen falten, fertig. Kein Cache, keine Datenbank für die Auswertung selbst – nur Streams und Faltungen. Der Code war so klein, dass die Diskussion im Review länger dauerte als das Schreiben.\n\n## scan() macht die Zwischenzustände sichtbar\n\n`reduce` liefert am Ende genau einen Wert. Auf einem endlosen Strom – Benutzereingaben, Sensordaten, Kursdaten – gibt es aber kein Ende und damit auch kein einzelnes Ergebnis. Die Reactive Extensions für JavaScript, aktuell in Version 2.x, haben dafür den Operator `scan`: dieselbe Faltung, nur dass jeder Zwischenzustand als Element eines neuen Stroms nach außen gereicht wird.\n\n```javascript\nvar cartStates = cartEvents\n  .scan({ items: {} }, applyEvent);\n\ncartStates.subscribe(function (state) {\n  renderCart(state); // every intermediate state, as it happens\n});\n```\n\nWieder dieselbe Projection-Function, kein Zeichen geändert. Bacon.js bietet mit `scan` denselben Operator an, falls einem der Rx-Stil nicht liegt. Interessant wird das im Browser: Wenn jede Benutzerinteraktion ein Event ist und der Anwendungszustand ein `scan` darüber, dann ist die Oberfläche zu jedem Zeitpunkt eine Funktion des bisherigen Event Stream. Ich habe das in einem Prototyp für ein Dashboard ausprobiert, und die ungewohnteste Erfahrung war dabei gar nicht der Code. Es war das Debugging: Statt im Debugger nach dem Objekt zu suchen, das gerade kaputtging, habe ich die letzten zwanzig Events ausgegeben und den Fehler in der Projection-Function gefunden. Der kaputte State war kein Rätsel mehr, sondern das nachvollziehbare Ergebnis einer konkreten Event-Folge.\n\n## Undo, Replay, Nachvollziehbarkeit – Nebenprodukte\n\nUnd damit zum eigentlichen Grund, warum mich dieses Muster nicht mehr loslässt. In klassisch mutierendem Code sind bestimmte Anforderungen teuer, weil der alte State beim Ändern zerstört wird. Wer Undo will, muss Schnappschüsse anlegen oder inverse Operationen pflegen. Wer wissen will, wie es zu einem State kam, braucht Logging, die jemand vorher eingebaut haben muss. In der Faltungswelt fallen diese Anforderungen einfach ab:\n\n```javascript\nfunction stateAt(events, count) {\n  return events.slice(0, count).reduce(applyEvent, { items: {} });\n}\n\nvar current = stateAt(events, events.length);     // state now\nvar undone = stateAt(events, events.length - 1);  // state before the last event\nvar initial = stateAt(events, 0);                 // start over\n```\n\nUndo ist eine Faltung über alle Events bis auf das letzte. Replay ist dieselbe Faltung noch einmal, gern auch schrittweise als Zeitraffer. Und die Nachvollziehbarkeit steckt in der Event-Liste selbst – sie ist das Protokoll, das man sonst mühsam nachrüstet.\n\n```mermaid\nflowchart TB\n  L[Ereignisliste<br/>e1, e2, e3, e4] --> A[Faltung über alle<br/>= aktueller Zustand]\n  L --> B[Faltung über e1 bis e3<br/>= Undo]\n  L --> C[Faltung Schritt für Schritt<br/>= Replay / Zeitreise]\n  L --> D[Liste selbst lesen<br/>= Audit-Protokoll]\n```\n\nEin paar Dinge bekommt man auf diesem Weg praktisch geschenkt:\n\n- Undo und Redo, ohne inverse Operationen zu schreiben\n- Reproduktion von Fehlern, indem man die aufgezeichnete Event-Folge erneut faltet\n- neue Sichten auf alte Daten durch eine zweite Projection-Function über dieselben Events\n- Tests, die nur aus einer Event-Liste und einem erwarteten Endzustand bestehen\n\nDer letzte Punkt ist im Alltag der unterschätzteste. Ein Test für `applyEvent` braucht keinen Mock, keine Datenbank, kein Setup – Eingabeliste rein, Endzustand vergleichen, fertig.\n\n## Vom Spielzeug zur Architekturfrage\n\nNeu ist die Idee natürlich nicht. Martin Fowler hat das Muster schon 2005 unter dem Namen Event Sourcing beschrieben: Der Anwendungszustand wird nicht direkt gespeichert, sondern aus einer Folge von Events rekonstruiert. Greg Young hat es in seinen Arbeiten zu CQRS weitergedacht – die Trennung von Schreibseite, die Events erzeugt, und Leseseite, die Projections darüber baut. Was mir mein kleines JavaScript-Experiment gezeigt hat, ist vor allem, wie kurz der Weg vom `reduce`-Einzeiler zu dieser Architekturfrage ist: Sobald man Events als die Wahrheit behandelt und State als Projection, hat man Event Sourcing im Kleinen bereits gebaut – nur eben im Speicher statt in einer Datenbank.\n\nIch will die Idee dabei nicht größer reden, als sie ist. Wer die Faltung zur Systemarchitektur macht, kauft sich echte Probleme ein. Die Event-Liste wächst unbegrenzt, also braucht es irgendwann Schnappschüsse, damit nicht jeder Start Millionen Events durchfaltet. Die Struktur der Events ändert sich mit der Anwendung, alte Events müssen aber lesbar bleiben – diese Schema-Evolution begleitet einen dauerhaft und ist alles andere als eine Fußnote. Und für einen simplen Verwaltungsdialog, dessen Historie niemanden interessiert, ist ein direktes Update schlicht das ehrlichere Werkzeug. Events aufzuheben lohnt sich dort, wo die Geschichte fachlich etwas bedeutet: Bestellungen, Konten, Verträge, Messreihen.\n\nDeshalb halte ich es im Moment so: im Kleinen konsequent, im Großen vorsichtig. Innerhalb eines Moduls Events zu falten kostet fast nichts und zwingt zu einer sauberen Trennung von „was ist passiert\" und „was folgt daraus\". Ob daraus ein persistenter Ereignisspeicher fürs ganze System werden soll, ist eine deutlich größere Entscheidung, die man nicht nebenbei im Refactoring trifft.\n\n## Was ich daraus mitnehme\n\nDas README von statemutatejs war eine Zeile lang, und trotzdem hat mich das Experiment mehr gelehrt als manches Framework-Handbuch. Die Lehre ist unscheinbar: Wenn Änderungen als Werte vorliegen – als Events, die man aufheben, ansehen und erneut abspielen kann –, dann ist der aktuelle State nur noch eine von vielen möglichen Auswertungen. `reduce` über ein Array, ein `Writable` im Objektmodus, `scan` in RxJS oder Bacon.js: viermal dieselbe Faltung, viermal dieselbe Projection-Function.\n\nSeitdem stelle ich in Code-Reviews eine Frage öfter als früher: Wo entsteht dieser State, und könnten wir die Änderungen stattdessen als Events festhalten? Meistens lautet die Antwort „lohnt hier nicht\", und das ist in Ordnung. Aber in den Fällen, in denen jemand gerade eine Undo-Funktion, ein Audit-Log oder den Abgleich zweier Datenstände von Hand baut, zeigt die Frage fast immer denselben Ausweg: Die gewünschten Eigenschaften stecken schon in den Events. Man muss sie nur falten.\n\n## Weiterführende Quellen\n\n- [MikeBild/statemutatejs](https://github.com/MikeBild/statemutatejs) – das erwähnte Experiment von Oktober 2012\n- [Martin Fowler: Event Sourcing](https://martinfowler.com/eaaDev/EventSourcing.html) – die Musterbeschreibung von 2005\n- [Node.js 0.10 Stream-API](https://nodejs.org/docs/latest-v0.10.x/api/stream.html) – streams2 inklusive objectMode\n- [MDN: Array.prototype.reduce](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce) – die Faltung im Sprachstandard\n- [Reactive-Extensions/RxJS](https://github.com/Reactive-Extensions/RxJS) – RxJS mit dem scan-Operator"},{"slug":"http-apis-klein-stabil-lesbar","title":"HTTP-APIs: klein, stabil, lesbar","summary":"Was der Heroku HTTP API Design Guide taugt, wenn man ihn an eigenen APIs spiegelt – und warum wenige Ressourcen, ein Versionsversprechen und selbsterklärende Fehler mehr bewirken als jedes Framework.","tags":["Software","Architektur"],"published_at":"2014-06-24T10:00:00+00:00","updated_at":"2026-07-14T16:07:55.065953+00:00","url":"https://www.mikebild.dev/de/blog/http-apis-klein-stabil-lesbar/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/0eb071f38aed43b3253c1500f4a27456b2bfa7e8a827019aaf301018da1235cc/http-apis-klein-stabil-lesbar-vorlesen.mp3","audio_duration_secs":676,"text":"# HTTP-APIs: klein, stabil, lesbar\n\nEine HTTP-API, die alle paar Wochen ihre Form ändert, erzieht ihre Clients – nur leider in die falsche Richtung. Nach dem zweiten Bruch baut jedes Client-Team eine Abwehrschicht: einen Mapper, der Felder umbenennt, bevor der Rest des Codes sie zu sehen bekommt. Ein Try/Catch um jeden Datumsparser, weil das Zeitformat schon zweimal gewechselt hat. Prüfungen auf fehlende Felder, auf drei verschiedene Fehlerformate, auf Statuscodes, die mal so und mal so gemeint sind. Nach einem halben Jahr besteht der Client zur Hälfte aus Code, der keine Fachlichkeit abbildet, sondern Misstrauen. Und weil jedes Team diese Schicht für sich erfindet, gibt es sie am Ende viermal, in vier Varianten, mit vier verschiedenen Fehlern.\n\nDie Kosten entstehen also nicht dort, wo die API gebaut wird, sondern verteilt über alle, die sie benutzen. Das macht sie so leicht zu übersehen.\n\nIm Mai hat Heroku seinen HTTP API Design Guide veröffentlicht – ein schmales Dokument auf GitHub, destilliert aus der Arbeit an der Heroku Platform API. Ich habe das Repository Ende Mai geforkt und seitdem Punkt für Punkt an APIs gespiegelt, die ich selbst betreue oder im Review sehe. Nicht alles davon würde ich unterschreiben. Aber die Grundhaltung trifft ziemlich genau das, was ich in eigenen Projekten als tragfähig erlebt habe: Eine gute HTTP-API ist klein, stabil und lesbar. Alles andere ist Zubehör.\n\n## Klein: eine Ressource muss sich ihren Platz verdienen\n\nDer Guide sagt es nicht wörtlich, aber es steht zwischen allen Zeilen: wenige Ressourcen, dafür konsequente Konventionen. Ressourcennamen im Plural (`/orders`, nicht `/order` und schon gar nicht `/getOrders`), möglichst flache Pfade, IDs als kleingeschriebene UUIDs, auf jeder Ressource `created_at` und `updated_at` – ohne dass jemand danach fragen muss.\n\nDas klingt banal, ist es aber nicht. In einem Projekt Anfang des Jahres haben wir eine API übernommen, die über sechzig Endpoints hatte – für eine Fachlichkeit, die mit etwa zehn Ressourcen vollständig beschrieben war. Der Rest waren Bequemlichkeits-Endpoints: `/orders/search`, `/orders/latest`, `/ordersWithCustomer`, jeweils entstanden, weil ein Client-Team eine bestimmte Sicht brauchte und der schnellste Weg ein neuer Endpoint war. Jeder dieser Endpoints hatte sein eigenes Antwortformat, seine eigene Vorstellung von Paginierung und seine eigene Lebensdauer. Keiner ließ sich entfernen, weil niemand wusste, wer ihn noch aufrief.\n\nSeitdem gilt bei mir: Ein Endpoint ist eine Verbindlichkeit, kein Feature. Jede Ressource, die ich veröffentliche, muss ich Jahre tragen. Deshalb frage ich vor jedem neuen Pfad zuerst, ob sich der Anwendungsfall nicht über eine bestehende Ressource plus Query-Parameter ausdrücken lässt. Meistens lautet die Antwort ja – und die API bleibt klein genug, dass ein neuer Entwickler sie an einem Vormittag versteht.\n\nSo sieht der Normalfall aus, auf den sich alles andere beziehen sollte:\n\n```http\nGET /orders/01234567-89ab-cdef-0123-456789abcdef HTTP/1.1\nHost: api.example.com\nAccept: application/json\n\nHTTP/1.1 200 OK\nContent-Type: application/json; charset=utf-8\n\n{\n  \"id\": \"01234567-89ab-cdef-0123-456789abcdef\",\n  \"state\": \"confirmed\",\n  \"total\": 4990,\n  \"currency\": \"EUR\",\n  \"created_at\": \"2014-06-20T08:30:00Z\",\n  \"updated_at\": \"2014-06-21T10:15:00Z\"\n}\n```\n\nNichts daran ist spektakulär. Genau das ist der Punkt: Wer diese eine Antwort gesehen hat, kann die Form jeder anderen Ressource der API erraten. Diese Vorhersagbarkeit ist das eigentliche Produkt.\n\n## Lesbar: der Fehler erklärt sich selbst\n\nDer Teil des Guides, den ich sofort übernommen habe, ist das Fehlerformat. Heroku schlägt für jeden Fehler denselben Body vor: eine maschinenlesbare `id`, eine menschenlesbare `message` und eine `url`, die auf weiterführende Dokumentation zeigt.\n\n```json\n{\n  \"id\": \"rate_limit\",\n  \"message\": \"Account reached its API rate limit.\",\n  \"url\": \"https://docs.example.com/rate-limits\"\n}\n```\n\nWarum mich das überzeugt: Es bedient beide Leser gleichzeitig. Der Client-Code verzweigt über die `id`, ohne Strings zu parsen. Der Mensch, der nachts um zwei im Log liest, versteht die `message`, ohne die Doku zu öffnen. Und die `url` beendet die Raterei, wenn er sie doch öffnen muss.\n\nDer Kontrast zur Praxis ist deutlich. Mir ist in Reviews immer wieder dieselbe Sammlung begegnet: mal ein HTML-Fehlertext vom Proxy, mal `{\"error\": true}`, mal ein Stacktrace im Klartext, mal Status 200 mit einer Fehlermeldung im Body. Jede dieser Varianten zwingt den Client, einen weiteren Sonderfall in seine Abwehrschicht zu schrauben – womit wir wieder beim Anfang wären.\n\nMit Express 4, das seit April draußen ist, lässt sich ein einheitliches Fehlerformat mit sehr wenig Code erzwingen. Der Kern ist eine kleine Fabrik für Fehlerobjekte und genau ein zentraler Error-Handler, der als letzte Middleware registriert wird:\n\n```javascript\n// errors.js - one error shape for the whole API\nfunction apiError(status, id, message, url) {\n  var err = new Error(message);\n  err.status = status;\n  err.id = id;\n  err.url = url;\n  return err;\n}\n\napp.get('/orders/:id', function (req, res, next) {\n  store.findOrder(req.params.id, function (err, order) {\n    if (err) { return next(err); }\n    if (!order) {\n      return next(apiError(404, 'order_not_found',\n        'No order exists with the given id.',\n        'https://docs.example.com/errors#order_not_found'));\n    }\n    res.json(order);\n  });\n});\n\n// registered last, catches everything\napp.use(function (err, req, res, next) {\n  res.status(err.status || 500).json({\n    id: err.id || 'internal_error',\n    message: err.message || 'An unexpected error occurred.',\n    url: err.url || 'https://docs.example.com/errors'\n  });\n});\n```\n\nDer Side Effect, den ich nicht erwartet hatte: Die Liste der Fehler-IDs wird zu einem Katalog der Fachlichkeit. Wenn dort `order_not_found`, `order_already_shipped` und `payment_declined` stehen, liest sich das wie eine Kurzfassung der Domäne. Eine API, deren Fehler sich so lesen, braucht deutlich weniger Prosa-Dokumentation – die URL erzählt die Fachlichkeit im Guten, die Fehler-ID erzählt sie im Schlechten.\n\n## Stabil: Versionierung ist ein Versprechen, kein Header\n\nBeim Thema Versionierung bin ich mit dem Guide nur halb einverstanden. Heroku empfiehlt, die Version über den Accept-Header zu verlangen – `Accept: application/vnd.heroku+json; version=3` – und Anfragen ohne explizite Version abzulehnen. Das ist die sauberste Form: Die URL bezeichnet die Ressource, der Header verhandelt die Repräsentation. So war HTTP gedacht.\n\nNur: In den Projekten, die ich kenne, gewinnt fast immer die Version in der URL, also `/v1/orders`. Und zwar nicht aus Unwissenheit, sondern aus handfesten Gründen. Ein curl-Aufruf zum Nachstellen eines Fehlers braucht keinen zusätzlichen Header. Der Access-Log zeigt auf einen Blick, welche Version ein Client benutzt – wichtig, wenn man wissen will, ob `/v1` endlich abgeschaltet werden kann. Proxies und Router können nach Pfad-Präfix trennen, ohne Header zu inspizieren. Und im Browser lässt sich eine GET-Antwort ohne Werkzeug ansehen. Der Accept-Header ist eleganter; die URL ist ehrlicher gegenüber dem Alltag von Betrieb und Fehlersuche. Ich habe beides im Einsatz und würde heute keinem Team die Header-Variante aufdrängen, das seine Logs liebt.\n\nWichtiger als der Transportweg der Versionsnummer ist ohnehin etwas anderes: was die Version verspricht. Eine Versionsnummer ist die Zusage, dass innerhalb dieser Version nichts bricht. Konkret heißt das für mich:\n\n- Neue Felder und neue Ressourcen dürfen jederzeit dazukommen – Clients müssen Unbekanntes ignorieren.\n- Bestehende Felder ändern weder Namen noch Typ noch Bedeutung.\n- Fehler-IDs und Statuscodes für bestehende Fälle bleiben, wie sie sind.\n- Wird eines davon verletzt, gibt es eine neue Version – und die alte läuft weiter, bis nachweislich niemand sie mehr aufruft.\n\nDiese Entscheidungslogik ist klein genug für ein Diagramm, und ich hänge sie inzwischen tatsächlich an die Wand, wenn ein Team über eine Änderung diskutiert:\n\n```mermaid\nflowchart TD\n  A[Änderung an der API] --> B{Nur additiv?<br/>Neue Felder, neue Ressourcen}\n  B -- ja --> C[Gleiche Version<br/>Clients laufen unverändert weiter]\n  B -- nein --> D{Ändert sich Name, Typ oder<br/>Bedeutung von Bestehendem?}\n  D -- nein --> C\n  D -- ja --> E[Neue Version<br/>alte Version läuft weiter,<br/>bis Logs sie für tot erklären]\n```\n\nDer unbequeme Teil steht im letzten Kasten. Eine neue Version auszurufen ist billig; die alte am Leben zu halten ist die eigentliche Arbeit. Wer das Versprechen nicht halten will, sollte lieber gar keine Versionsnummer vergeben – eine `/v1`, die trotzdem alle zwei Monate bricht, ist schlimmer als gar keine, weil sie Verlässlichkeit signalisiert, die es nicht gibt.\n\n## Zeitstempel und Paginierung – die unterschätzten Kleinigkeiten\n\nZwei Guide-Punkte wirken auf den ersten Blick wie Fußnoten, haben mir aber mehr Debugging-Stunden erspart als manche Architekturentscheidung.\n\nErstens: Zeiten ausschließlich als ISO 8601 in UTC, also `2014-06-20T08:30:00Z`. Ich habe in einem Altsystem einmal drei Zeitformate in derselben API gehabt – Unix-Timestamps in Sekunden, Unix-Timestamps in Millisekunden und ein deutsches Datumsformat mit Punkt. Der Fehler, der daraus entstand, war ein Klassiker: Ein Client interpretierte Sekunden als Millisekunden, und plötzlich lagen alle Bestellungen im Januar 1970. Seit alles konsequent ISO 8601 mit `Z` am Ende ist, ist diese ganze Fehlerklasse verschwunden. Das Format ist sortierbar, eindeutig, und jeder Parser dieser Welt kennt es.\n\nZweitens: Paginierung. Der Guide verweist auf Herokus Range-Header – der Client fragt mit `Range` einen Ausschnitt an, der Server antwortet mit `206 Partial Content` und nennt im `Next-Range`-Header den Schlüssel für die nächste Seite:\n\n```bash\ncurl -i https://api.example.com/orders \\\n  -H \"Range: id 01234567-89ab-cdef-0123-456789abcdef..; max=200\"\n\n# HTTP/1.1 206 Partial Content\n# Content-Range: id 01234567-...;\n# Next-Range: id 89abcdef-...; max=200\n```\n\nTechnisch gefällt mir daran vor allem eines: Es ist Cursor-Paginierung. Der Client blättert ab einem Schlüssel weiter, nicht ab einem Offset – und bekommt deshalb keine Duplikate oder Lücken, wenn zwischen zwei Seiten neue Datensätze entstehen. Das klassische `?page=3` hat genau dieses Problem, und es fällt erst in Produktion auf, wenn die Daten sich schnell genug ändern.\n\nTrotzdem bin ich beim Transportweg wieder pragmatischer als der Guide. Range-Header für etwas anderes als Bytes sind ungewohnt, viele HTTP-Bibliotheken machen den Zugriff auf Antwort-Header umständlicher als nötig, und im Team muss man die Konvention jedem neu erklären. In eigenen APIs bin ich bei Cursor-Paginierung über Query-Parameter gelandet – `?limit=200&after=<id>` – mit einem `next`-Verweis im Body. Dieselbe Eigenschaft, gewöhnlichere Mechanik. Die Idee des Guides übernehme ich, seine Syntax nicht.\n\nGenau das ist übrigens mein Verhältnis zu dem Dokument insgesamt. Es ist eine Sammlung von Entscheidungen, die ein Team für seine Plattform getroffen und öffentlich begründet hat – kein Standard, kein Gesetz. Der Wert liegt darin, dass man beim Lesen für jede eigene API gezwungen ist, Farbe zu bekennen: übernehmen, abweichen, aber nie wieder „haben wir uns nie überlegt“.\n\n## Was ich daraus mitnehme\n\nWer 2014 eine HTTP-API baut, hat das Formatproblem im Grunde gelöst – JSON hat gewonnen, die Verben stehen im RFC, und mit HAL und JSON Schema gibt es lebhafte Diskussionen darüber, wie viel Hypermedia eine API vertragen sollte. Das eigentliche Problem ist sozialer Natur: Eine API ist ein Versprechen an Leute, deren Code man nie sehen wird. Klein, stabil und lesbar sind die drei Eigenschaften, die dieses Versprechen einlösbar machen. Klein, damit man es überblicken kann. Stabil, damit man ihm trauen kann. Lesbar, damit man es ohne Dolmetscher versteht.\n\nDer Heroku-Guide ist dafür die beste Checkliste, die mir bisher begegnet ist – gerade weil er so kurz ist und weil hinter jedem Punkt erkennbar eine echte Plattform steht. Mein Fork liegt seit Ende Mai in meinem GitHub-Konto, und die nützlichste Übung war nicht das Lesen, sondern das Spiegeln: jede eigene API daneben legen und Punkt für Punkt notieren, wo sie abweicht und ob es dafür einen Grund gibt. Bei mir blieben zwei bewusste Abweichungen übrig – Version in der URL, Paginierung per Query-Parameter – und eine unangenehm lange Liste von Stellen, an denen die Abweichung schlicht Nachlässigkeit war. Die arbeite ich gerade ab. Die Clients werden es nicht bemerken, und genau daran erkennt man, dass es die richtige Arbeit ist.\n\n## Weiterführende Quellen\n\n- HTTP API Design Guide (Original von interagent/Heroku): https://github.com/interagent/http-api-design\n- Mein Fork des Guides: https://github.com/MikeBild/http-api-design\n- Heroku Platform API Reference (die API, aus der der Guide entstand): https://devcenter.heroku.com/articles/platform-api-reference\n- RFC 2616 – Hypertext Transfer Protocol HTTP/1.1: https://datatracker.ietf.org/doc/html/rfc2616\n- Express: http://expressjs.com/"},{"slug":"was-ich-an-elixir-gesucht-habe","title":"Was ich an Elixir gesucht habe","summary":"Nicht die Syntax hat mich zu Elixir gebracht, sondern das Ausführungsmodell der Erlang-VM – Prozesse, Messages und Supervisors, betrachtet mit JavaScript- und C#-Augen.","tags":["Software","Architektur"],"published_at":"2014-05-12T10:00:00+00:00","updated_at":"2026-07-14T16:06:01.869581+00:00","url":"https://www.mikebild.dev/de/blog/was-ich-an-elixir-gesucht-habe/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/72ad512e84aa2d8c31db90f71fc8b1a3ecc2ebbfdf97e63afc9cf6b26735c52e/was-ich-an-elixir-gesucht-habe-vorlesen.mp3","audio_duration_secs":713,"text":"# Was ich an Elixir gesucht habe\n\nEine neue Programmiersprache probiert man aus, weil einem die Syntax gefällt – so lautet die übliche Begründung. Kürzere Schreibweisen, elegantere Lambdas, weniger Zeremonie. Diese Begründung halte ich für falsch herum. An Syntax gewöhnt man sich in zwei Wochen, sie ist der austauschbarste Teil einer Sprache. Interessant wird es dort, wo eine Sprache anders *ausführt*: wie sie Concurrency organisiert, wem Zustand gehört und was passiert, wenn etwas kaputtgeht.\n\nGenau deshalb liegt seit letztem Herbst ein kleines Elixir-Repository auf meinem GitHub-Konto. Nicht, weil mir das Ruby-artige Gewand der Sprache gefallen hätte – das war mir anfangs beinahe egal –, sondern weil unter diesem Gewand die Erlang-VM arbeitet. Und die beantwortet die drei Fragen von oben radikal anders, als ich es aus meinem Alltag mit JavaScript und C# kenne: Prozesse statt Threads, Messages statt geteiltem Zustand, „let it crash\" statt defensivem try/catch an jeder Ecke.\n\n## Ein Blick über den Zaun, mit Zeitkolorit\n\nDer Zeitpunkt für diesen Blick über den Zaun ist günstig. Im Februar hat Facebook WhatsApp für eine Summe gekauft, über die noch immer alle reden – und plötzlich interessiert sich auch die Management-Etage dafür, dass ein erstaunlich kleines Team dort mehrere hundert Millionen Nutzer bedient. Das Backend: Erlang. Die Zahlen, die seit Jahren aus dieser Ecke kursieren – Millionen gleichzeitiger Verbindungen auf einer einzelnen Maschine –, klangen für mich lange wie Folklore. Jetzt schaut man genauer hin, ich auch.\n\nElixir setzt auf genau dieser Maschine auf. José Valim, vielen aus dem Rails-Umfeld bekannt, baut damit eine Sprache, die auf der Erlang-VM läuft, Erlang-Module direkt aufrufen kann und die Werkzeuge der Plattform erbt. Die Sprache ist noch jung: aktuell Version 0.13, auf dem Weg zu 1.0, mit gelegentlichen Brüchen zwischen den Releases. Dave Thomas schreibt bei den Pragmatic Programmers an „Programming Elixir\", bislang als Beta zu haben. Wer heute Elixir anfasst, fasst also bewusst etwas Unfertiges an – die Plattform darunter ist dagegen seit über zwanzig Jahren im Ernstfall erprobt.\n\n## Ein Repository voller Fragen\n\nMein Einstieg war kein Projekt, sondern eine Sammlung kleiner Versuche: [github.com/MikeBild/tryelixir](https://github.com/MikeBild/tryelixir), begonnen im Oktober, gewachsen in Abendstunden. Kein README, keine Architektur, nur Ordner – jeder Ordner eine Frage, die ich der Sprache stellen wollte:\n\n- `actors` – wie fühlen sich `spawn` und `receive` an, das Handwerkszeug der Prozesse?\n- `bankAccount` – kann ein Prozess allein den Zustand eines Kontos verwalten, ganz ohne Lock?\n- `cowboy` – wie spricht man HTTP auf der Erlang-VM, mit dem Webserver Cowboy?\n- `kataTennis` und `kataTennisFSM` – die bekannte Tennis-Kata, einmal klassisch, einmal als endlicher Automat\n- `leveldb` – wie bindet man eine Bibliothek wie LevelDB an?\n\nMehr behaupte ich über das Repository nicht, denn mehr ist es nicht: ein Experimentierkasten. Aber die Konten-Übung darin taugt gut, um zu zeigen, was mich am Ausführungsmodell gepackt hat.\n\n## Prozesse statt Threads\n\nZunächst der Ausgangspunkt. Ein Bankkonto mit nebenläufigen Zugriffen sieht in meinem C#-Alltag ungefähr so aus – geteilter Zustand, geschützt durch ein Lock:\n\n```csharp\npublic class BankAccount\n{\n    private readonly object _sync = new object();\n    private decimal _balance;\n\n    public void Withdraw(decimal amount)\n    {\n        lock (_sync)\n        {\n            if (_balance < amount)\n                throw new InvalidOperationException(\"insufficient funds\");\n            _balance -= amount;\n        }\n    }\n}\n```\n\nDas funktioniert – solange jeder Zugriff durch dasselbe Lock geht, niemand zwei Locks in unterschiedlicher Reihenfolge nimmt und keine Exception den Zustand halb verändert zurücklässt. In Code-Reviews sind das genau die Stellen, an denen ich am längsten hängen bleibe, und die Fehler, die dort durchrutschen, zeigen sich erst unter Last. In JavaScript stellt sich die Frage anders, aber nicht besser: Ein einzelner Thread mit Callbacks schützt zwar vor Race Conditions im Speicher, dafür blockiert eine einzige teure Berechnung alles andere, und ein nicht abgefangener Fehler reißt den ganzen Node-Prozess mit.\n\nDie Erlang-VM organisiert das Problem grundlegend anders. Ihre Prozesse sind keine Betriebssystem-Threads, sondern winzige, von der VM verwaltete Einheiten: wenige Kilobyte beim Start, jeder mit eigenem Heap und eigener Speicherbereinigung, präemptiv geplant. Hunderttausende davon auf einer Maschine sind normal, nicht heldenhaft. Vor allem aber: Es gibt keinen geteilten Speicher zwischen ihnen. Was ein Prozess weiß, weiß nur er.\n\nDasselbe Konto in Elixir ist ein Prozess, der seinen Kontostand als Argument einer Endlosschleife mit sich trägt:\n\n```elixir\ndefmodule BankAccount do\n  def start(initial_balance) do\n    spawn(fn -> loop(initial_balance) end)\n  end\n\n  defp loop(balance) do\n    receive do\n      {:deposit, amount} ->\n        loop(balance + amount)\n      {:withdraw, amount, caller} when amount <= balance ->\n        send(caller, {:ok, balance - amount})\n        loop(balance - amount)\n      {:withdraw, _amount, caller} ->\n        send(caller, {:error, :insufficient_funds})\n        loop(balance)\n      {:balance, caller} ->\n        send(caller, {:balance, balance})\n        loop(balance)\n    end\n  end\nend\n```\n\nKein Lock, kein `synchronized`, keine `volatile`-Grübelei. Der Kontostand kann gar nicht von zwei Seiten gleichzeitig verändert werden, weil ihn nur ein einziger Prozess besitzt und die Messages aus seiner Mailbox der Reihe nach abarbeitet. Concurrency entsteht nicht durch mehr Threads am selben Speicher, sondern durch mehr Prozesse mit je eigenem Zustand – ein Prozess pro Konto, nicht ein Lock pro Konto.\n\n```mermaid\ngraph LR\n  subgraph SHARED[\"Threads und geteilter Zustand\"]\n    T1[\"Thread 1\"] --> L[\"Lock\"]\n    T2[\"Thread 2\"] --> L\n    L --> MEM[\"gemeinsamer Speicher<br/>balance\"]\n  end\n  subgraph MSG[\"Prozesse und Nachrichten\"]\n    P1[\"Prozess A\"] -- \"{:withdraw, 30}\" --> MB[\"Mailbox\"]\n    P2[\"Prozess B\"] -- \"{:deposit, 50}\" --> MB\n    MB --> ACC[\"BankAccount-Prozess<br/>eigener Zustand: balance\"]\n  end\n```\n\n## Messages statt Aufrufe\n\nVon außen betrachtet ist so ein Konto kein Objekt mit Methoden, sondern eine Adresse, an die man Messages schickt:\n\n```elixir\naccount = BankAccount.start(100)\n\nsend(account, {:deposit, 50})\nsend(account, {:withdraw, 200, self()})\n\nreceive do\n  {:ok, new_balance} ->\n    IO.puts \"withdrawal ok, new balance: #{new_balance}\"\n  {:error, reason} ->\n    IO.puts \"withdrawal failed: #{reason}\"\nend\n```\n\nDer Unterschied zu einem Methodenaufruf ist größer, als er aussieht. Ein Aufruf ist synchron, kostenlos im Denken und teuer in den Folgen: Der Aufrufer hängt am Gelingen des Aufgerufenen, teilt sich mit ihm Stack und Schicksal. Eine Message ist asynchron und entkoppelt – ob und wann eine Antwort kommt, ist eine eigene Entscheidung, die im Code sichtbar wird. Beim Umstieg fühlt sich das zunächst umständlich an. Mir ist dabei aber aufgefallen, wie viele Entwurfsentscheidungen ein Methodenaufruf stillschweigend trifft, über die ich vorher nie nachgedacht habe.\n\nWer nicht jedes Protokoll von Hand strickt, greift zur OTP-Bibliothek der Plattform: `gen_server` gießt genau dieses Muster – Zustand in einer Schleife, synchrone und asynchrone Messages, geordnete Start- und Stopp-Semantik – in eine wiederverwendbare Form mit Callbacks. Die Konten-Übung von Hand zu schreiben war trotzdem der richtige Weg: Erst wenn man die `receive`-Schleife einmal selbst gebaut hat, versteht man, was OTP einem abnimmt.\n\n## „Let it crash\" – und wer räumt dann auf?\n\nDer Teil, der mein Denken am stärksten herausfordert, ist der Umgang mit Fehlern. Mein gelernter Reflex aus Jahren mit C# und JavaScript: Fehler dürfen nicht passieren, also verteidige ich mich – null-Prüfungen, try/catch um jede Schicht, Fallback-Werte, Logging und weiterwursteln. Das Ergebnis kenne ich aus etlichen Codebasen, auch eigenen: Der Fehler wird nicht behoben, sondern verschmiert. Irgendwo fängt ein catch-Block zu viel, das Programm läuft mit halb kaputtem Zustand weiter, und der eigentliche Knall kommt Stunden später an einer Stelle, die mit der Ursache nichts mehr zu tun hat.\n\nDie Erlang-Welt zieht daraus einen Schluss, der zunächst fahrlässig klingt: Lass den Prozess abstürzen. Kein defensives Abfangen im Prozess selbst – wenn eine Message nicht zum erwarteten Muster passt oder eine Berechnung scheitert, stirbt der Prozess. Möglich ist das nur wegen der Isolation: Ein sterbender Prozess reißt keinen geteilten Speicher mit, es gibt keinen halb veränderten Zustand, den er hinterlassen könnte. Aufgeräumt wird an anderer Stelle – von einem Supervisor, einem Prozess, dessen einzige Aufgabe darin besteht, andere Prozesse zu beobachten und nach einem Absturz mit frischem, definiertem Zustand neu zu starten. Supervisors überwachen auch andere Supervisors, daraus entstehen Bäume:\n\n```mermaid\ngraph TD\n  APP[\"Application\"] --> SUP[\"Supervisor<br/>Strategie: one_for_one\"]\n  SUP --> A1[\"BankAccount-Prozess<br/>Konto 1001\"]\n  SUP --> A2[\"BankAccount-Prozess<br/>Konto 1002\"]\n  SUP --> A3[\"BankAccount-Prozess<br/>Konto 1003\"]\n  A2 -. \"stürzt ab\" .-> SUP\n  SUP -. \"startet neu,<br/>frischer Zustand\" .-> A2\n```\n\nError Handling wird damit von einer Codestil-Frage zu einer Architektur-Frage. Nicht mehr: Wo fange ich die Exception? Sondern: Welche Einheit darf sterben, und wer startet sie in welchem Zustand neu? Die Antwort steht nicht verstreut in tausend catch-Blöcken, sondern an einer Stelle im Supervision-Baum. Joe Armstrong hat diese Idee in seiner Dissertation ausformuliert – zuverlässige Systeme *trotz* fehlerhafter Software, nicht durch fehlerfreie Software. Beim Lesen musste ich mehrfach an Produktionsvorfälle denken, bei denen uns genau das gefehlt hat: nicht weniger Fehler, sondern ein definierter Weg zurück in einen bekannten Zustand.\n\n## Die Syntax als Zugabe\n\nUnd dann gefällt mir die Syntax am Ende doch – aber als Zugabe, nicht als Grund. Drei Dinge stechen heraus.\n\nPattern Matching ist oben schon zu sehen: Die `receive`-Zweige sind keine if-Kaskade über einen Nachrichtentyp, sondern Muster mit Bedingungen, und die Struktur der Daten dokumentiert sich genau an der Stelle, an der sie verarbeitet wird. Dass `=` in Elixir kein Zuweisungs-, sondern ein Match-Operator ist, hat mich anfangs stolpern lassen und dann überzeugt.\n\nDaten sind immutable. Es gibt kein `balance -= amount`, es gibt nur einen neuen Wert, der in den nächsten Schleifendurchlauf wandert. Was in C# eine Disziplinfrage bleibt – bitte keine geteilten Objekte verändern –, ist hier schlicht nicht ausdrückbar. Ganze Fehlerklassen aus meinen Reviews existieren in dieser Sprache nicht.\n\nDer Pipe-Operator `|>` schließlich macht Transformationsketten lesbar, ohne Zwischenvariablen und ohne von innen nach außen geschachtelte Aufrufe:\n\n```elixir\n\"transactions.csv\"\n|> File.read!\n|> String.split(\"\\n\")\n|> Enum.map(&String.strip/1)\n|> Enum.reject(&(&1 == \"\"))\n|> Enum.map(&String.split(&1, \";\"))\n```\n\nJede Zeile eine Stufe, die Daten fließen von oben nach unten. Die ES5-Entsprechung mit `for`-Schleife und Hilfs-Arrays schreibe ich seit Jahren, ohne darüber nachzudenken – erst die Gegenüberstellung hat mir gezeigt, wie viel Rauschen darin steckt.\n\n## Was in meinen Alltag zurückwandert\n\nElixir wird auf absehbare Zeit nicht meine Produktionssprache. Die Projekte, in denen ich stecke, sind .NET- und Node-Landschaften, und eine Pre-1.0-Sprache schleppe ich dort nicht hinein. Trotzdem war das Experiment kein Selbstzweck, denn einige Denkmuster nehme ich mit über den Zaun zurück:\n\n- Kleine, absturzbereite Einheiten. Ein Node-Dienst, der in Sekunden startet und den ein Prozess-Wächter nach dem Absturz einfach neu hochzieht, ist dem großen Prozess überlegen, der jeden Fehler überleben muss. Schneller Neustart mit frischem Zustand schlägt heroisches Weiterlaufen.\n- Messages statt Aufrufe an den Grenzen. Wo zwei Teile eines Systems unabhängig voneinander leben und sterben sollen, gehört eine Queue dazwischen, kein direkter synchroner Aufruf – die Entkopplung, die Erlang-Prozessen von Haus aus mitgegeben ist, muss ich mir dort mit Infrastruktur erkaufen.\n- Zustand hat einen Besitzer. Auch ohne Erlang-Prozesse kann ein Modul der einzige Ort sein, an dem ein Stück Zustand verändert wird, während alle anderen nur über definierte Messages oder Kommandos mit ihm reden.\n- immutable Daten, wo die Sprache es hergibt – und sei es nur als Konvention im Team.\n\nGenauso wichtig ist, was *nicht* mitkommt. Einen Supervisor in C# oder JavaScript nachzubauen ist verlockend und führt in die Irre: Ohne isolierte Prozesse mit eigenem Heap ist „let it crash\" keine Strategie, sondern Leichtsinn – der abstürzende Thread hinterlässt eben doch geteilten Zustand, und der Neustart beginnt nicht bei null. Das Modell trägt als Ganzes oder gar nicht. OTP in einer anderen Runtime zu imitieren hieße, die Fassade ohne das Fundament zu kopieren.\n\n## Unterm Strich\n\nGesucht habe ich an Elixir keine schönere Sprache, sondern ein anderes Verhältnis zu den zwei Dingen, die mir in Projekten die meiste Arbeit machen: Zustand und Fehler. Gefunden habe ich ein Ausführungsmodell, das beide Probleme nicht besser löst, sondern anders stellt – Zustand hat genau einen Besitzer, Fehler haben einen definierten Bereich, in dem sie sterben dürfen. Dass darüber eine angenehme, junge Sprache liegt, macht das Experimentieren leichter; getragen wird alles von der Erlang-VM darunter.\n\nDas kleine Repository bleibt liegen und wächst weiter, wenn die nächste Frage auftaucht. Und Elixir selbst beobachte ich auf seinem Weg zu 1.0 – nicht, weil ich morgen ein Konto-System darauf bauen werde, sondern weil mir der Blick über diesen Zaun mehr über meine eigenen Werkzeuge beigebracht hat als so manches Framework-Release diesseits davon.\n\n## Weiterführende Quellen\n\n- [github.com/MikeBild/tryelixir](https://github.com/MikeBild/tryelixir) – meine Elixir-Experimente\n- [elixir-lang.org: Getting Started](https://elixir-lang.org/getting-started/introduction.html) – offizieller Einstieg in die Sprache\n- [Erlang/OTP Design Principles](https://www.erlang.org/doc/design_principles/des_princ.html) – Supervision Trees und Behaviours in der offiziellen Erlang-Dokumentation\n- [Joe Armstrong: Making reliable distributed systems in the presence of software errors](https://erlang.org/download/armstrong_thesis_2003.pdf) – die Dissertation hinter „let it crash\""},{"slug":"control-flow-vor-async-await","title":"Control Flow vor `async/await`","summary":"Ein Blick auf die winzige Bibliothek cflow zeigt, wie Node-Entwickler asynchrone Schritte von Hand zu einer Kette verdrahteten und warum der fehlende Fehlerkanal die eigentliche Lektion ist.","tags":["Software","Node.js","Backend"],"published_at":"2014-03-12T10:00:00+00:00","updated_at":"2026-07-13T07:27:27.832717+00:00","url":"https://www.mikebild.dev/de/blog/control-flow-vor-async-await/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/b2520fa45eab43ada9b8eec0c353f6cc6e7139704ebb265c7b81a34ffef94328/control-flow-vor-async-await-vorlesen.mp3","audio_duration_secs":494,"text":"# Control Flow vor `async/await`\n\nNode 0.10 kennt Funktionen, Callbacks und einen Event Loop. Was es nicht kennt, ist ein Befehl, der sagt: „Mach das, warte auf das Ergebnis, mach dann das Nächste.“ Diese Reihenfolge muss man selbst herstellen, Schritt für Schritt, von Hand. Und sobald drei oder vier Schritte aufeinander aufbauen, wird sichtbar, dass „warten“ in einer Single-Thread-Umgebung kein Sprachfeature ist, sondern eine Entwurfsaufgabe.\n\nIch habe mir dafür eine kleine Bibliothek gebaut, `cflow`. Sie ist so klein, dass man sie in einem Rutsch liest, und genau deshalb legt sie den Unterschied offen: zwischen einer verschachtelten Callback-Kette und einer Pipeline, deren Übergänge man einmal modelliert hat.\n\n## Das Problem: Reihenfolge aus Callbacks bauen\n\nEine asynchrone Operation in Node folgt einer Konvention. Sie nimmt als letztes Argument einen Callback, und dieser Callback bekommt als erstes Argument einen möglichen Fehler. Das nennt man error-first oder node-style callback:\n\n```js\nfs.readFile(\"config.json\", function (err, data) {\n  if (err) return handle(err);\n  // continue with data\n});\n```\n\nDas ist für einen Schritt unauffällig. Es wird unangenehm, sobald der nächste Schritt das Ergebnis des vorherigen braucht. Dann landet der zweite Aufruf im Callback des ersten, der dritte im Callback des zweiten, und so weiter:\n\n```js\nloadUser(id, function (err, user) {\n  if (err) return done(err);\n  loadAccount(user.accountId, function (err, account) {\n    if (err) return done(err);\n    loadInvoices(account.id, function (err, invoices) {\n      if (err) return done(err);\n      done(null, summarize(user, account, invoices));\n    });\n  });\n});\n```\n\nDiese Form hat einen festen Namen bekommen: Pyramid of Doom, manchmal auch Callback-Hölle. Der Code wandert nach rechts, die schließenden Klammern stapeln sich, und die eigentliche Absicht – drei Dinge nacheinander laden und zusammenfassen – verschwindet hinter der Mechanik. Das ist nicht nur ein optisches Ärgernis. Jede Ebene wiederholt dieselbe Error Handling, und genau dort schleichen sich Fehler ein.\n\n## Der Fehlerfall, den man fast immer einmal hat\n\nDie error-first-Konvention verlangt eine Disziplin, die das Sprachmittel selbst nicht erzwingt: nach `if (err)` muss ein `return` stehen. Fehlt es, läuft die Funktion weiter:\n\n```js\nloadUser(id, function (err, user) {\n  if (err) done(err);            // Careful: no return\n  loadAccount(user.accountId, function (err, account) {\n    // ...\n  });\n});\n```\n\nIm Fehlerfall passiert hier zweierlei gleichzeitig. `done(err)` wird aufgerufen, der Aufrufer hält das also für erledigt und meldet einen Fehler. Im selben Atemzug greift `user.accountId` auf ein `user` zu, das `undefined` ist, weil ja ein Fehler vorlag. Das wirft eine zweite, ganz andere Ausnahme, oder `done` wird ein zweites Mal aufgerufen – diesmal mit einem irreführenden Ergebnis. Ein Callback, der doppelt feuert, ist besonders gemein, weil die Reihenfolge der Meldungen nicht mehr garantiert ist. Mal kommt erst der echte Fehler, mal erst der Folgefehler. Solche Bugs sind im Log schwer zu lesen, weil die Ursache und ihre Auswirkung zeitlich auseinanderfallen.\n\nDaraus folgt nicht, dass Callbacks schlecht wären. Der Befund ist ein anderer: Wenn jeder Schritt seine Fehlerprüfung und seinen Übergang zum nächsten Schritt selbst von Hand schreibt, dann ist jede dieser Stellen eine Gelegenheit, es falsch zu machen. Was fehlt, ist eine Stelle, die den Kontrollfluss einmal modelliert, statt ihn an jeder Verschachtelung neu zu erfinden.\n\n## Bibliotheken, die den Fluss übernehmen\n\nDamit ist man im Jahr 2014 nicht allein. Caolan McMahons `async` ist der etablierte Werkzeugkasten und bietet genau für diese Muster benannte Funktionen an. `async.series` führt Aufgaben nacheinander aus, `async.parallel` gleichzeitig, und `async.waterfall` reicht das Ergebnis jedes Schritts an den nächsten weiter:\n\n```js\nasync.waterfall([\n  function (next) { loadUser(id, next); },\n  function (user, next) { loadAccount(user.accountId, next); },\n  function (account, next) { loadInvoices(account.id, next); }\n], function (err, invoices) {\n  if (err) return handle(err);\n  // done\n});\n```\n\nDas ist flach statt verschachtelt, und es gibt nur noch einen Fehlerausgang. Parallel dazu reift ein zweiter Ansatz heran: Promises. Die Promises/A+-Spezifikation hat sich durchgesetzt, Bibliotheken wie Q, when.js und bluebird sind verbreitet, und man kann Ketten mit `.then(...)` schreiben. Ein eingebautes `Promise` gibt es in der stabilen Node-Version 0.10 allerdings noch nicht zuverlässig; das kommt erst mit der V8-Aktualisierung der 0.11er-Linie. Wer Promises will, bindet sie heute als Abhängigkeit ein.\n\n## cflow: der Kontrollfluss auf das Nötigste reduziert\n\n`cflow` geht den waterfall-Weg, aber radikal klein. Die gesamte Bibliothek ist eine einzige Funktion:\n\n```js\nvar Flow = function () {\n  this.args = Array.prototype.slice.call(arguments);\n  var next = function () {\n    if (this.args.length > 0) {\n      this.args.shift();\n      this.args[0].apply(next, Array.prototype.slice.call(arguments));\n    }\n  }.bind(this);\n  this.args[0].apply(next);\n};\n```\n\nDer Trick steckt in zwei Zeilen. `Flow` bekommt eine Liste von Funktionen. Jede dieser Funktionen wird mit `this` aufgerufen, das auf `next` gebunden ist – also auf die Fortsetzung. Ein Schritt ruft am Ende seiner Arbeit einfach `this(...)` auf. Das verwirft den aktuellen Schritt (`shift`) und ruft den nächsten mit genau den Argumenten auf, die man `this` übergeben hat. Die Werte fließen so von Schritt zu Schritt weiter.\n\nIn der Anwendung sieht das aus wie eine gerade Liste von Phasen statt wie ein Pyramidenbau:\n\n```js\nvar Flow = require(\"cflow\");\n\nnew Flow(\n  function () {\n    slow(\"A\", this);                 // this is the continuation\n  },\n  function (data) {\n    slow2(data + \"B\", \"!!!\", this);  // data is \"A\"\n  },\n  function (data, data2) {\n    slow(data + \"C\" + data2, this);  // data is \"AB\", data2 is \"!!!\"\n  },\n  function (data) {\n    console.log(data);               // \"ABC!!!\"\n  }\n);\n\nfunction slow(value, callback) {\n  setTimeout(function () { callback(value); }, 200);\n}\nfunction slow2(value, value2, callback) {\n  setTimeout(function () { callback(value, value2); }, 300);\n}\n```\n\nJeder Schritt übergibt `this` als Callback an seine asynchrone Operation. Sobald die fertig ist, wird der nächste Schritt aktiv, und das Zwischenergebnis liegt direkt als Argument vor. Bemerkenswert ist, dass ein Schritt auch mehrere Werte weiterreichen kann – `slow2` gibt zwei zurück, und der folgende Schritt nimmt zwei entgegen. Das ist exakt das Waterfall-Pattern, nur ohne Array, ohne Optionen, ohne Abhängigkeiten. Die `package.json` listet unter `dependencies` schlicht nichts.\n\n## Kette gegen Pipeline\n\nDer Unterschied zwischen den beiden Schreibweisen lässt sich gut nebeneinanderstellen. Die Callback-Kette schachtelt Zustand und Error Handling mit jeder Ebene tiefer ineinander. Die Pipeline legt die Schritte nebeneinander und macht die Übergänge zu einem eigenen, benannten Mechanismus.\n\n```mermaid\nflowchart TB\n  subgraph Kette[\"Callback-Kette (verschachtelt)\"]\n    direction TB\n    a1[\"Schritt 1\"] --> a2[\"  Schritt 2\"]\n    a2 --> a3[\"    Schritt 3\"]\n    a3 --> a4[\"      Ergebnis\"]\n  end\n  subgraph Pipeline[\"Pipeline (modelliert)\"]\n    direction LR\n    b1[\"Schritt 1\"] --> b2[\"Schritt 2\"]\n    b2 --> b3[\"Schritt 3\"]\n    b3 --> b4[\"Ergebnis\"]\n  end\n```\n\nOptisch ist die Pipeline ruhiger. Wichtiger ist aber, was sich strukturell ändert. In der Pipeline existiert der Übergang von Schritt zu Schritt nur an einer einzigen Stelle, nämlich in der Bibliothek. Wenn man dort etwas verbessern will – Logging, ein Timeout, eine zentrale Fehlerweiche – dann hat man genau einen Ort dafür. In der verschachtelten Kette müsste man dieselbe Änderung in jeder Ebene wiederholen.\n\n## Wo `cflow` an seine Grenze stößt\n\nUnd hier liegt die ehrlichste Lektion dieses kleinen Stücks Code. `cflow` modelliert die Reihenfolge sauber, aber es modelliert keinen Fehler. Es gibt keinen error-first-Kanal, keinen Abbruch, kein `try`. Wirft ein Schritt eine Ausnahme oder ruft `this` mit einem Fehlerobjekt auf, dann behandelt `Flow` das nicht – der Fehler wird einfach als nächstes Argument weitergereicht oder fällt durch. Genau das, was die Callback-Konvention mit ihrem `err`-Argument zumindest vorsieht, fehlt hier ganz.\n\nDas ist Absicht, die Konsequenz aus „so einfach wie möglich“. Für eine Demonstration des Kontrollflusses reicht es. Für einen Service, der unter Last läuft, reicht es nicht. Damit benennt die Bibliothek selbst die Grenze, an der man weiterdenken muss: Ein Kontrollfluss ist erst dann vollständig, wenn auch der Fehlerpfad Teil des Modells ist. Wer eine eigene Flow-Funktion baut, sollte den nächsten Schritt deshalb so anlegen, dass ein Fehler die Kette abbricht und an einen einzigen Endpoint geleitet wird, statt blind weiterzulaufen. `async.waterfall` macht genau das vor, indem es bei einem Fehler im ersten Argument sofort zum Abschluss-Callback springt.\n\n## Rückblick aus späterer Zeit\n\n**Redaktionelle Einordnung (Juli 2026):** Der folgende Rückblick wurde nachträglich ergänzt. Der Hauptteil des Artikels beschreibt den Stand von 2014; die Hinweise auf ES2015 und ES2017 gehören nicht zur ursprünglichen zeitlichen Perspektive.\n\nWer diesen Text Jahre später liest, kennt die Auflösung. Mit ES2015 kamen Generatoren, die zusammen mit Bibliotheken wie `co` synchron aussehenden asynchronen Code erlaubten. Und mit ES2017 wurde `async/await` Teil der Sprache. Damit verschwindet die Frage, die diesen Artikel trägt, fast vollständig aus dem Alltag: Die Reihenfolge schreibt man wieder von oben nach unten, der Fehlerpfad ist ein normales `try/catch`, und das Warten ist ein Schlüsselwort.\n\nDas macht die alte Übung aber nicht wertlos. Im Gegenteil. Ein `await` sieht harmlos und synchron aus, doch dahinter steht weiterhin ein nicht-blockierender Aufruf, der Ressourcen halten kann. Die Dinge, die man sich 2014 von Hand klarmachen musste – wo die Reihenfolge erzwungen wird, wo ein Fehler die Kette abbricht, wo ein Timeout fehlt, was bei doppelt gefeuerten Fortsetzungen passiert – sind alle noch da. Sie sind nur unter einer bequemen Syntax verborgen. Wer den expliziten Kontrollfluss einmal selbst gebaut hat, erkennt diese Grenzen auch dann noch, wenn die Sprache sie nicht mehr vor die Augen hält.\n\n## Weiterführende Quellen\n\n- Repository cflow: https://github.com/MikeBild/cflow\n- caolan/async, Dokumentation der Control-Flow-Funktionen: https://github.com/caolan/async\n- Promises/A+ Spezifikation: https://promisesaplus.com/"},{"slug":"warum-katas-mehr-sind","title":"Warum Katas mehr sind als Fingerübungen","summary":"Eine Kata ist ein Architektur-Labor im Kleinen – weil die Aufgabe konstant bleibt, wird sichtbar, was sonst nur der Entwurf weiß.","tags":["Software","Testing"],"published_at":"2014-01-12T10:00:00+00:00","updated_at":"2026-07-14T16:02:12.984451+00:00","url":"https://www.mikebild.dev/de/blog/warum-katas-mehr-sind/","audio_url":"https://media.mikebild.dev/site/mikebild/assets/1e997fcddd264fe043757155e40937bac1d0bd63218ab692922cd1c30b8173fc/warum-katas-mehr-sind-vorlesen.mp3","audio_duration_secs":643,"text":"# Warum Katas mehr sind als Fingerübungen\n\nEine Kata ist in der Kampfkunst eine festgelegte Abfolge von Bewegungen, die man so oft wiederholt, bis sie ohne Nachdenken abläuft. Dave Thomas hat den Begriff vor gut zehn Jahren in die Programmierung geholt: kleine, scharf umrissene Aufgaben, die man nicht einmal löst, sondern immer wieder – FizzBuzz, der Bowling-Score, Roy Osheroves String Calculator. Auf codekata.com steht eine ganze Sammlung davon.\n\nDer Vergleich mit der Kampfkunst trifft einen Punkt sehr genau und geht an einem anderen deutlich vorbei. Er trifft: Eine Kata findet ohne Ernstfall statt. Kein Kunde wartet, kein Termin drückt, kein Altsystem redet dazwischen. Man kann sich eine einzige Sache vornehmen und ihr die volle Aufmerksamkeit geben. Er hinkt: In der Kampfkunst ist das Ziel der Wiederholung, dass die Bewegung immer gleicher wird. Beim Programmieren wäre das sinnlos. Tippen ist nicht der Engpass, und wer den Bowling-Score zum fünften Mal identisch herunterschreibt, hat beim fünften Mal nichts gelernt.\n\nDie Wiederholung lohnt sich aus einem anderen Grund. Weil die Aufgabe konstant bleibt, kann der Entwurf variieren – und genau dadurch werden Entwurfsentscheidungen sichtbar, die im Projektalltag unsichtbar bleiben. Eine Kata ist kein Tipp-Training. Sie ist ein Architektur-Labor im Kleinen.\n\n## Ein Labor braucht eine Konstante\n\nWer zwei Entwürfe ernsthaft vergleichen will, muss alles andere festhalten. Das ist im Projekt praktisch nie möglich. Dort ändert sich zwischen zwei Lösungsansätzen immer auch das Problem: neue Anforderungen, andere Kollegen, verschobene Prioritäten. Wenn die zweite Lösung besser aussieht als die erste, weiß niemand, ob der Entwurf besser war oder nur die Umstände günstiger. Aus einem Projekt lässt sich deshalb selten sauber ablesen, was eine einzelne Entwurfsentscheidung wirklich gekostet oder gebracht hat.\n\nDie Kata dreht das um. Das Problem ist klein, vollständig bekannt und bleibt exakt gleich. Die Anforderungen stehen fest, bevor die erste Zeile entsteht. Was zwischen zwei Durchläufen variiert, ist ausschließlich der Entwurf: Wo liegt der Zustand? Was ist eine Funktion, was ein Objekt? Welche Grenze trennt Logik von Ein- und Ausgabe? Wenn sich Durchlauf drei leichter testen lässt als Durchlauf eins, dann liegt das nicht an der Aufgabe. Es liegt an einer Entscheidung, die ich getroffen habe – und die ich jetzt benennen kann.\n\nDas klingt banal, ist es aber nicht. Mir ist erst über solche Wiederholungen aufgefallen, wie viele meiner Entwurfsgewohnheiten schlicht Gewohnheiten waren: Dinge, die ich tat, weil ich sie immer so tat, nicht weil sie der Aufgabe dienten. Im Projekt fällt so etwas nicht auf, weil nie jemand dieselbe Aufgabe ein zweites Mal anders löst. In der Kata fällt es sofort auf.\n\n## Zwei Regale voller Versuche\n\nAuf meinem GitHub-Konto liegen zwei solche Sammlungen aus den letzten Jahren. Das ältere Repository heißt schlicht Katas, ist von 2010 und enthält C#-Übungen: eine Tennis-Kata und eine FizzBuzz-Variante, die ich damals mit den Reactive Extensions gebaut habe – nicht weil FizzBuzz das braucht, sondern weil ich wissen wollte, wie sich dieselbe triviale Aufgabe anfühlt, wenn man sie als Strom von Events statt als Schleife denkt. Ein README gibt es nicht, das Repository ist ein persönliches Übungsarchiv und will auch nichts anderes sein.\n\nDas jüngere heißt AppKatas, ist von 2013 und in JavaScript geschrieben. Dort liegen unter anderem eine Calculator- und eine TicTacToe-Kata, beide bewusst als pures JavaScript mit Event-Sourcing umgesetzt, die Module laufen im Browser und unter Node.js, die Tests laufen über Travis. TicTacToe gibt es dort in zwei Ausbaustufen: als reine Browser-Anwendung und als Client-Server-Variante mit einem kleinen Node-Server.\n\nDer Wert dieser Repositories liegt nicht im Code. Den würde ich heute an vielen Stellen anders schreiben. Der Wert liegt darin, dass dieselben Aufgaben mehrfach und unterschiedlich gelöst dort nebeneinanderstehen. Man kann die Entwürfe vergleichen, weil die Aufgabe konstant geblieben ist. Genau das meine ich mit Labor.\n\n## Testbarkeit ist Entwurfsdruck\n\nDie Tennis-Kata sieht harmlos aus: Zähle die Punkte eines Tennisspiels und gib den Spielstand aus – Love, Fifteen, Thirty, Forty, Deuce, Advantage. Beim ersten Durchlauf 2010 habe ich das als eine Klasse mit zwei Zählern und einer großen Methode gebaut, die den Spielstand als Text zusammensetzt. Der erste NUnit-Test dazu sah ungefähr so aus:\n\n```csharp\n[TestFixture]\npublic class TennisGameTests\n{\n    [Test]\n    public void Score_AfterDeuce_ReturnsAdvantage()\n    {\n        var game = new TennisGame();\n        for (var i = 0; i < 3; i++)\n        {\n            game.WinPoint(Player.One);\n            game.WinPoint(Player.Two);\n        }\n        game.WinPoint(Player.One);\n\n        Assert.AreEqual(\"Advantage Player One\", game.Score());\n    }\n}\n```\n\nDer Test funktioniert, aber er verrät etwas: Um einen einzigen Spielstand zu prüfen, muss ich sieben Punkte in der richtigen Reihenfolge einspielen. Der Zustand ist im Objekt eingesperrt, und der einzige Weg hinein führt über die Historie. Bei Deuce ist das noch erträglich. Bei einer Aufgabe mit mehr Zustand wird genau dieses Muster zur Qual – jeder Test beginnt mit einem langen Aufbau, der mit der eigentlichen Behauptung nichts zu tun hat.\n\nDas ist der Moment, in dem Testbarkeit zu Entwurfsdruck wird. Der schwer zu schreibende Test ist kein Test-Problem, er ist ein Symptom. Er zeigt, dass der Entwurf keinen direkten Zugang zu dem Zustand erlaubt, um den es fachlich geht. Im zweiten Durchlauf habe ich den Spielstand als Wert modelliert, den man direkt konstruieren kann, und die Übergangslogik als Funktion von Zustand zu Zustand. Die Tests schrumpften auf drei Zeilen, und nebenbei war die große Textmethode in kleine, benennbare Fälle zerfallen.\n\nIm Projekt hätte ich diesen zweiten Durchlauf nie gemacht. Die erste Version funktionierte ja. Niemand baut ein funktionierendes Stück Software noch einmal, nur um zu sehen, ob der Entwurf auch anders ginge. In der Kata ist genau das der Zweck der Veranstaltung.\n\n## Dieselbe Aufgabe, zwei Entwürfe\n\nAm deutlichsten habe ich den Labor-Effekt bei TicTacToe in AppKatas gesehen. Die naheliegende erste Version in JavaScript sieht so aus, wie vermutlich die meisten sie schreiben würden – ein Spielobjekt, das ein Brett hält und es bei jedem Zug verändert:\n\n```javascript\nfunction Game() {\n  this.board = [null, null, null, null, null, null, null, null, null];\n  this.currentPlayer = \"X\";\n}\n\nGame.prototype.move = function (field) {\n  if (this.board[field] !== null) {\n    throw new Error(\"field already taken\");\n  }\n  this.board[field] = this.currentPlayer;\n  this.currentPlayer = this.currentPlayer === \"X\" ? \"O\" : \"X\";\n};\n```\n\nDas ist völlig in Ordnung und trägt ein kleines Spiel problemlos. Interessant wird es beim zweiten Durchlauf mit einer anderen Grundentscheidung: Das Spiel speichert kein Brett, sondern nur die Liste der Züge. Das Brett wird bei Bedarf aus den Events abgeleitet:\n\n```javascript\nfunction replay(events) {\n  var board = [null, null, null, null, null, null, null, null, null];\n  events.forEach(function (event) {\n    board[event.field] = event.player;\n  });\n  return board;\n}\n\nfunction move(events, field) {\n  var board = replay(events);\n  if (board[field] !== null) {\n    throw new Error(\"field already taken\");\n  }\n  var player = events.length % 2 === 0 ? \"X\" : \"O\";\n  return events.concat([{ player: player, field: field }]);\n}\n```\n\nKein `this`, keine Mutation, kein Konstruktor. `move` nimmt eine Liste und gibt eine neue Liste zurück. Die Konsequenzen dieser einen Entscheidung fallen einem regelrecht in den Schoß: Ein Test ist jetzt eine Liste rein, eine Liste raus – jeder Spielstand lässt sich direkt hinschreiben, ohne Züge einspielen zu müssen. Rückgängig machen ist das Weglassen des letzten Elements. Und die Client-Server-Variante wurde beinahe trivial, weil eine Zugliste sich als JSON verschicken lässt, während ein Objekt mit Methoden und innerem Zustand an der Prozessgrenze auseinanderfällt.\n\n```mermaid\nflowchart TD\n  A[Gleiche Aufgabe:<br/>TicTacToe] --> B[Entwurf 1:<br/>Zustand wird mutiert]\n  A --> C[Entwurf 2:<br/>Zugliste, Brett abgeleitet]\n  B --> D[Test braucht<br/>Zug-Historie als Aufbau]\n  C --> E[Test ist<br/>Liste rein, Liste raus]\n  D --> F[Unterschied ist dem Entwurf<br/>zurechenbar, weil das<br/>Problem konstant blieb]\n  E --> F\n```\n\nDer Punkt ist nicht, dass Ereignislisten besser wären als veränderlicher Zustand. Für ein TicTacToe im Browser ist die erste Version schlicht angemessen, und `replay` bei jedem Zug komplett laufen zu lassen, skaliert erkennbar nicht für große Ereignismengen. Der Punkt ist, dass ich beide Entwürfe an derselben Aufgabe nebeneinander halten kann. Erst dadurch sehe ich, was die Entscheidung kostet und was sie kauft. Im Projekt bekomme ich immer nur einen der beiden Zweige zu sehen – den, für den wir uns entschieden haben. Der andere bleibt Spekulation.\n\nNebenbei zwingt die zweite Version zu einer sauberen Grenze, die in der ersten gern verwischt: Die Spiellogik weiß nichts vom DOM. `replay` und `move` laufen unverändert unter Node und im Browser, die Darstellung ist ein dünner Rand, der Events entgegennimmt und ein Brett zeichnet. Diese Grenze habe ich nicht gezogen, weil ein Prinzip es verlangt, sondern weil der Entwurf sie mir aufgedrängt hat. Solche aufgedrängten Grenzen merkt man sich besser als gepredigte.\n\n## Der Luxus des Wegwerfens\n\nZur Kata gehört ein Schritt, der im Projekt undenkbar ist: Am Ende wird der Code gelöscht. Nicht archiviert, nicht als Basis für den nächsten Durchlauf behalten – gelöscht. Beim nächsten Mal beginnt man leer.\n\nAnfangs kam mir das verschwenderisch vor. Inzwischen halte ich es für den wichtigsten Teil. Solange Code weiterlebt, verteidigt man ihn. Man baut auf ihm auf, umgeht seine Schwächen, gewöhnt sich an seine Form. Erst wenn feststeht, dass er verschwindet, wird man ehrlich zu ihm. Dann probiert man die Entscheidung aus, die man im Projekt nie riskieren würde, weil dort jede Entscheidung sofort Bestandsschutz bekommt.\n\nGenau darin liegt der Unterschied zwischen Üben und Arbeiten. Im Projekt ist Code ein Investitionsgut, und jede Alternative konkurriert mit dem, was schon da ist. In der Kata ist Code Versuchsmaterial. Man darf eine Idee zu Ende denken, feststellen, dass sie nicht trägt, und sie ohne Kosten begraben. Die Erkenntnis überlebt, der Code nicht. Das ist die richtige Reihenfolge.\n\n## Was eine Runde für mich ausmacht\n\nMit der Zeit hat sich bei mir eine einfache Form eingespielt, ganz ohne Rahmenprogramm – eine Stunde, ein Editor, eine Aufgabe:\n\n- Eine bekannte Aufgabe wählen, keine neue. Die Neugier soll dem Entwurf gelten, nicht dem Problem.\n- Vor dem ersten Test eine einzige Entwurfsentscheidung festlegen, die diesmal anders ausfällt als beim letzten Mal.\n- Tests zuerst schreiben und jeden zähen Test als Hinweis auf den Entwurf lesen, nicht als Test-Problem.\n- Am Ende zwei, drei Sätze notieren: Was war leichter, was schwerer, und woran lag es?\n- Den Code löschen.\n\nDie Notizen sind unscheinbar, aber sie sind das eigentliche Ergebnis. Nach ein paar Runden mit derselben Aufgabe steht dort eine kleine, selbst erarbeitete Karteikartensammlung über Entwurfsentscheidungen – belegt an Code, den man selbst geschrieben hat, statt geglaubt aus einem Buch.\n\n## Was hängen bleibt\n\nFingerübung ist das falsche Wort, weil es das Ziel falsch beschreibt. Geübt wird nicht die Hand, geübt wird das Urteil. Eine Kata hält das Problem fest, damit der Entwurf variieren kann, und macht dadurch sichtbar, was eine Entwurfsentscheidung wirklich bewirkt: welche Tests sie leicht oder zäh macht, welche Grenzen sie erzwingt, was sie an einer Prozessgrenze kostet. Das sind genau die Fragen, die in großen Systemen über Jahre nachwirken – nur dass man sie dort nie isoliert beobachten kann.\n\nDeshalb halte ich die halbe Stunde mit FizzBuzz, Tennis oder TicTacToe nicht für vertane Zeit, obwohl dabei nichts entsteht, was jemand benutzt. Es entsteht etwas anderes: die Erfahrung, dieselbe Aufgabe mit zwei Entwürfen gesehen zu haben. Wer die einmal hat, argumentiert in der nächsten Architektur-Diskussion nicht mehr mit Vorlieben, sondern mit Beobachtungen. Das ist mehr, als eine Fingerübung je liefern könnte.\n\n## Weiterführende Quellen\n\n- https://github.com/MikeBild/AppKatas – meine JavaScript-Katas (Calculator, TicTacToe mit Event-Sourcing)\n- https://github.com/MikeBild/Katas – meine älteren C#-Katas (Tennis, FizzBuzz mit Rx)\n- http://codekata.com/ – Dave Thomas' Kata-Sammlung, die den Begriff geprägt hat\n- https://osherove.com/tdd-kata-1 – Roy Osheroves String-Calculator-Kata"}],"decks":[{"id":"introduction-javascript-typescript","title":"JavaScript & TypeScript: Von Werten zu Verträgen","summary":"Von Werten und Funktionen über ESM und Async bis zu Generics, Runtime Validation und Library Design.","topics":["JavaScript","TypeScript"],"url":"https://slides.mikebild.dev/introduction-javascript-typescript.html","pdf_url":"https://slides.mikebild.dev/introduction-javascript-typescript.pdf","repository":"https://github.com/MikeBild/introduction-javascript","articles":[],"slide_count":109,"slides":[{"no":1,"title":"JavaScript & TypeScript: Von Werten zu Verträgen"},{"no":2,"title":"Agenda"},{"no":3,"title":"Was sagt der Code, bevor er läuft?"},{"no":4,"title":"Was gilt im Jahr 2026?"},{"no":5,"title":"Werte, Funktionen, Objekte, Arrays","section":true},{"no":6,"title":"Warum überrascht JavaScript manchmal?"},{"no":7,"title":"Primitives vergleichen nach Wert, Objects nach Reference"},{"no":8,"title":"Strict Equality statt Loose Equality"},{"no":9,"title":"Fehlende Werte gezielt behandeln"},{"no":10,"title":"Funktionen sind Werte"},{"no":11,"title":"Closures merken sich ihren Scope"},{"no":12,"title":"Higher-Order Functions bauen Funktionen"},{"no":13,"title":"Wo stolpern Closures über Loops und this?"},{"no":14,"title":"Geteilte References erzeugen Fernwirkung"},{"no":15,"title":"Spread und Destructuring erzeugen neue Werte"},{"no":16,"title":"Spread kopiert nur eine Ebene"},{"no":17,"title":"Copying Array Methods statt Mutation"},{"no":18,"title":"Loops mischen das Was mit dem Wie"},{"no":19,"title":"filter, map, reduce drücken Absicht aus"},{"no":20,"title":"Suchen, prüfen und gruppieren ohne Loop"},{"no":21,"title":"Wo beißen Array-Methoden zurück?"},{"no":22,"title":"ESM, Async und erste Types","section":true},{"no":23,"title":"Warum Modules?"},{"no":24,"title":"Named Exports als Standard"},{"no":25,"title":"Node-Projekte laufen auf ESM"},{"no":26,"title":"Wann lohnt sich Dynamic Import?"},{"no":27,"title":"Warten, ohne zu blockieren"},{"no":28,"title":"In welcher Reihenfolge läuft das?"},{"no":29,"title":"async und await lesen sich synchron"},{"no":30,"title":"Parallel statt nacheinander"},{"no":31,"title":"forEach wartet nicht"},{"no":32,"title":"Warum TypeScript?"},{"no":33,"title":"Grenzen annotieren, den Rest ableiten"},{"no":34,"title":"unknown statt any"},{"no":35,"title":"Object Types beschreiben Daten"},{"no":36,"title":"Union Types ersetzen Magic Strings"},{"no":37,"title":"Es zählt die Form, nicht der Name"},{"no":38,"title":"Narrowing, Generics, Utility Types","section":true},{"no":39,"title":"Unions brauchen Type Checks"},{"no":40,"title":"Discriminated Unions verhindern unmögliche Zustände"},{"no":41,"title":"never meldet vergessene Fälle"},{"no":42,"title":"Type Guards glauben dir ungeprüft"},{"no":43,"title":"any verliert Information"},{"no":44,"title":"Type Parameter verbinden Ein- und Ausgabe"},{"no":45,"title":"extends und keyof setzen Grenzen"},{"no":46,"title":"Wann ist ein Generic zu viel?"},{"no":47,"title":"Kopierte Types driften auseinander"},{"no":48,"title":"Partial, Pick und Omit leiten Types ab"},{"no":49,"title":"Record und Union-Filter ersetzen Handarbeit"},{"no":50,"title":"Types lassen sich aus Funktionen ableiten"},{"no":51,"title":"Wo Utility Types still versagen"},{"no":52,"title":"Pure Functions haben keine versteckten Abhängigkeiten"},{"no":53,"title":"Kleine Schritte mit pipe verbinden"},{"no":54,"title":"Functional Core, Imperative Shell"},{"no":55,"title":"Wann wird Functional zu viel?"},{"no":56,"title":"Error Handling, Iterators, Tooling, Testing","section":true},{"no":57,"title":"Fehler sind in der Signatur unsichtbar"},{"no":58,"title":"Exceptions tragen Error-Objekte mit Ursache"},{"no":59,"title":"Result Types machen Fehler sichtbar"},{"no":60,"title":"Result oder Exception?"},{"no":61,"title":"fetch wirft bei HTTP-Fehlern nicht"},{"no":62,"title":"Das Iterator Protocol steckt hinter for...of"},{"no":63,"title":"Generators pausieren bei yield"},{"no":64,"title":"Iterator Helpers bauen Lazy Pipelines"},{"no":65,"title":"Async Iteration für Daten, die nach und nach kommen"},{"no":66,"title":"Vier Checks, ein Befehl"},{"no":67,"title":"strict ist die Untergrenze"},{"no":68,"title":"Node 26 führt TypeScript direkt aus"},{"no":69,"title":"ESLint findet, was Types nicht sehen"},{"no":70,"title":"Warum lohnen sich Tests?"},{"no":71,"title":"Ein Test beschreibt ein Behavior"},{"no":72,"title":"Dependencies übergeben statt Modules mocken"},{"no":73,"title":"Table Tests und Fake Timers sparen Zeit"},{"no":74,"title":"Advanced Types, Validation, Async","section":true},{"no":75,"title":"Types lassen sich aus Types berechnen"},{"no":76,"title":"Conditional Types entscheiden auf Type-Ebene"},{"no":77,"title":"Mapped Types laufen über Keys"},{"no":78,"title":"Template Literal Types bauen Strings"},{"no":79,"title":"Wo hören clevere Types auf zu helfen?"},{"no":80,"title":"Types verschwinden zur Runtime"},{"no":81,"title":"Schema und Type aus einer Quelle"},{"no":82,"title":"safeParse liefert ein Result"},{"no":83,"title":"Konfiguration beim Start prüfen"},{"no":84,"title":"Alles ist ein string"},{"no":85,"title":"Ein Brand macht Types unterscheidbar"},{"no":86,"title":"Branded Types entstehen nur mit Prüfung"},{"no":87,"title":"Brands lohnen sich auch für Einheiten"},{"no":88,"title":"Was geht in Produktion schief?"},{"no":89,"title":"AbortSignal bricht ab"},{"no":90,"title":"Ein Pool begrenzt die Concurrency"},{"no":91,"title":"Große Daten fließen als Stream"},{"no":92,"title":"Wo endet eine Cancellation auf halbem Weg?"},{"no":93,"title":"Performance, Library Design, Runtimes","section":true},{"no":94,"title":"Erst messen, dann optimieren"},{"no":95,"title":"Womit misst du Zeit und Memory?"},{"no":96,"title":"Memory Leaks hängen an vergessenen References"},{"no":97,"title":"Immutability hat in Hot Loops Kosten"},{"no":98,"title":"Die Public API ist das Produkt"},{"no":99,"title":"Ungültige Aufrufe unmöglich machen"},{"no":100,"title":"Aufrufer schreiben keine Type Arguments"},{"no":101,"title":"Types testen wie Behavior"},{"no":102,"title":"Was bricht Nutzer einer Library?"},{"no":103,"title":"Viele Runtimes, ein Language Core"},{"no":104,"title":"Web Standards laufen überall"},{"no":105,"title":"Core mit Standards, Adapter am Rand"},{"no":106,"title":"Was läuft nicht überall?"},{"no":107,"title":"Was bleiben soll"},{"no":108,"title":"Quellen"},{"no":109,"title":"Danke"}]},{"id":"introduction-python","title":"Python für Entwickler mit uv, pytest und Flask","summary":"Daten einlesen und analysieren, Web-APIs mit Flask und ihre Tests, funktionale Muster und fortgeschrittenes Python.","topics":["Python","Flask","Pandas"],"url":"https://slides.mikebild.dev/introduction-python.html","pdf_url":"https://slides.mikebild.dev/introduction-python.pdf","repository":"https://github.com/MikeBild/introduction-python","articles":["https://www.mikebild.dev/de/blog/datenanalyse-mit-pandas/","https://www.mikebild.dev/de/blog/python-daten-einlesen/","https://www.mikebild.dev/de/blog/flask-apps-testen/","https://www.mikebild.dev/de/blog/web-apis-mit-flask/","https://www.mikebild.dev/de/blog/funktionale-muster-in-python/","https://www.mikebild.dev/de/blog/python-advanced-decorators-generatoren/"],"slide_count":158,"slides":[{"no":1,"title":"Python für Entwickler mit uv, pytest und Flask"},{"no":2,"title":"Worum es geht"},{"no":3,"title":"Grundlagen","section":true},{"no":4,"title":"Namen zeigen auf Werte"},{"no":5,"title":"Geld rechnest du in Cent"},{"no":6,"title":"Gleich oder dasselbe?"},{"no":7,"title":"Funktionen mit klarer Signatur"},{"no":8,"title":"Veränderliche Default-Argumente"},{"no":9,"title":"Die Lösung: Wächter über None"},{"no":10,"title":"Vier Collections, vier Zugriffe"},{"no":11,"title":"Comprehensions statt append"},{"no":12,"title":"Projekt mit uv","section":true},{"no":13,"title":"Ein Tool für alles"},{"no":14,"title":"pyproject.toml ist der Vertrag"},{"no":15,"title":"Module und Pakete"},{"no":16,"title":"Dataclasses und Fehler","section":true},{"no":17,"title":"Die Dataclass schreibt den Rest"},{"no":18,"title":"Regeln gehören ins Objekt"},{"no":19,"title":"Eigene Fehler mit Bedeutung"},{"no":20,"title":"Gezielt fangen, Ursache behalten"},{"no":21,"title":"Daten einlesen","section":true},{"no":22,"title":"Zwei Lager: Standardbibliothek oder pandas"},{"no":23,"title":"Der Weg von Quelle zu Ziel"},{"no":24,"title":"CSV mit der Standardbibliothek"},{"no":25,"title":"Zwei wichtige open-Details"},{"no":26,"title":"JSON: von der Datei zum Objekt"},{"no":27,"title":"JSON zurückschreiben"},{"no":28,"title":"Excel: der Weg über pandas"},{"no":29,"title":"Excel: nützliche Parameter"},{"no":30,"title":"Datenbanken: sqlite3"},{"no":31,"title":"Der wichtigste Sicherheitshinweis"},{"no":32,"title":"Platzhalter: ohne Anführungszeichen"},{"no":33,"title":"XML: selten, aber nicht vergessen"},{"no":34,"title":"Welcher Reader für welches Format?"},{"no":35,"title":"Fallstrick: Encoding"},{"no":36,"title":"Fallstrick: große Dateien"},{"no":37,"title":"Entscheidungsbaum"},{"no":38,"title":"Type Hints mit mypy","section":true},{"no":39,"title":"Hinweise für Tools, nicht für Python"},{"no":40,"title":"Typen, die die Domäne beschreiben"},{"no":41,"title":"Testen mit pytest","section":true},{"no":42,"title":"Ein Test ist eine Funktion"},{"no":43,"title":"Viele Fälle, ein Test"},{"no":44,"title":"Web-APIs mit Flask","section":true},{"no":45,"title":"Flask ist ein Mikro-Framework"},{"no":46,"title":"Hello World in sechs Zeilen"},{"no":47,"title":"Was dahintersteckt"},{"no":48,"title":"Rückgabe als Tuple"},{"no":49,"title":"Route, Request, Response"},{"no":50,"title":"Pfad-Parameter"},{"no":51,"title":"Der Request-Lifecycle"},{"no":52,"title":"Eine REST-artige API"},{"no":53,"title":"Detail-Route mit Parameter"},{"no":54,"title":"Warum jsonify statt json.dumps"},{"no":55,"title":"dict und list direkt zurückgeben"},{"no":56,"title":"SSR und API sind dieselbe Mechanik"},{"no":57,"title":"Das Jinja2-Template"},{"no":58,"title":"Eine Entscheidung pro Route"},{"no":59,"title":"Struktur mit Blueprints"},{"no":60,"title":"Fallstrick: request.json ohne JSON-Header"},{"no":61,"title":"Fallstrick: debug=True"},{"no":62,"title":"Fallstrick: globaler Zustand"},{"no":63,"title":"App Factory statt globalem Zustand"},{"no":64,"title":"Fehler zentral übersetzen"},{"no":65,"title":"Flask-Apps testen","section":true},{"no":66,"title":"Ein Test ohne Server"},{"no":67,"title":"Der Client im Bild"},{"no":68,"title":"Das Response-Objekt"},{"no":69,"title":"Von unittest zu pytest"},{"no":70,"title":"Fixtures in conftest.py"},{"no":71,"title":"Warum TESTING = True"},{"no":72,"title":"Die AAA-Struktur"},{"no":73,"title":"Ein erster Test"},{"no":74,"title":"Bytes sind nicht String"},{"no":75,"title":"JSON-Antworten prüfen"},{"no":76,"title":"Der zugehörige Test"},{"no":77,"title":"Fehlerfälle gehören dazu"},{"no":78,"title":"Fallstrick: geteilter Zustand"},{"no":79,"title":"Datenbanken sauber isolieren"},{"no":80,"title":"Datenanalyse","section":true},{"no":81,"title":"Warum keine Python-Schleife?"},{"no":82,"title":"Das ndarray"},{"no":83,"title":"Reduktionen und Boolean-Masken"},{"no":84,"title":"Broadcasting"},{"no":85,"title":"pandas: Series und DataFrame"},{"no":86,"title":"loc und iloc"},{"no":87,"title":"Filtern mit Boolean-Masken"},{"no":88,"title":"Der häufigste Fehler: verkettetes Indizieren"},{"no":89,"title":"Die korrekte Form"},{"no":90,"title":"Zweiter Fehler: über Zeilen iterieren"},{"no":91,"title":"groupby: Split-Apply-Combine"},{"no":92,"title":"Benannte Aggregation"},{"no":93,"title":"Tabellen zusammenführen mit merge"},{"no":94,"title":"Der Parameter how"},{"no":95,"title":"Die Pipeline"},{"no":96,"title":"Bereinigen vor dem Gruppieren"},{"no":97,"title":"Matplotlib: der objektorientierte Stil"},{"no":98,"title":"Drei Diagrammtypen"},{"no":99,"title":"Die Rollenteilung"},{"no":100,"title":"Der Denkwechsel"},{"no":101,"title":"Jupyter: erkunden im Notebook"},{"no":102,"title":"Logik ins Modul, Ergebnisse ins Notebook"},{"no":103,"title":"Concurrency mit asyncio","section":true},{"no":104,"title":"Gemeinsam starten, gemeinsam enden"},{"no":105,"title":"Grenzen setzen"},{"no":106,"title":"Pakete bauen","section":true},{"no":107,"title":"Bauen, prüfen, veröffentlichen"},{"no":108,"title":"Fortgeschrittenes Python","section":true},{"no":109,"title":"Was pythonischen Code ausmacht"},{"no":110,"title":"Funktionen sind Objekte"},{"no":111,"title":"Ein erster Decorator: timer"},{"no":112,"title":"Der @-Zucker"},{"no":113,"title":"Ablauf beim Aufruf"},{"no":114,"title":"functools.wraps ist keine Kür"},{"no":115,"title":"Die Folgen"},{"no":116,"title":"Decorators mit Argumenten"},{"no":117,"title":"Anwendung von repeat"},{"no":118,"title":"Magic Methods","section":true},{"no":119,"title":"Dunder-Methoden"},{"no":120,"title":"Die gängigsten Protokolle"},{"no":121,"title":"Eine Klasse, die andockt"},{"no":122,"title":"LineStore als Context Manager"},{"no":123,"title":"Nutzung wie ein eingebautes Objekt"},{"no":124,"title":"Der Rückgabewert von `__exit__`"},{"no":125,"title":"Generatoren","section":true},{"no":126,"title":"yield macht eine Funktion lazy"},{"no":127,"title":"Konstanter Speicher"},{"no":128,"title":"Generatoren verketten"},{"no":129,"title":"Generator ist nach einem Durchlauf erschöpft"},{"no":130,"title":"Funktionale Muster","section":true},{"no":131,"title":"Wo ist map und filter?"},{"no":132,"title":"Die Comprehension dreht das um"},{"no":133,"title":"Drei Ausprägungen"},{"no":134,"title":"map bleibt idiomatisch"},{"no":135,"title":"Filter vs. bedingter Ausdruck"},{"no":136,"title":"Lazy vs. eager"},{"no":137,"title":"Drei Wege, eine Sequenz zu verarbeiten"},{"no":138,"title":"itertools: lazy Kombinatoren"},{"no":139,"title":"takewhile und dropwhile"},{"no":140,"title":"groupby: gruppieren und aggregieren"},{"no":141,"title":"groupby: die Aggregation"},{"no":142,"title":"groupby: flüchtige Gruppen"},{"no":143,"title":"Die Pipeline im Bild"},{"no":144,"title":"functools: partial"},{"no":145,"title":"functools: reduce"},{"no":146,"title":"reduce: der Startwert"},{"no":147,"title":"functools: lru\\_cache"},{"no":148,"title":"lru\\_cache: zwei Bedingungen"},{"no":149,"title":"Der Hang zur Immutability"},{"no":150,"title":"Fallstrick: Side Effects in Comprehensions"},{"no":151,"title":"Die passende Wahl treffen"},{"no":152,"title":"Zusammenfassung","section":true},{"no":153,"title":"Der rote Faden"},{"no":154,"title":"Die wiederkehrenden Fallstricke"},{"no":155,"title":"Fallstricke in Flask, Tests und Daten"},{"no":156,"title":"Quellen: Artikel und Repository"},{"no":157,"title":"Quellen: Dokumentation"},{"no":158,"title":"Danke"}]},{"id":"introduction-web-plattform","title":"Moderne Web-Plattform ohne Framework","summary":"Semantisches HTML, Accessibility, modernes CSS, Formulare, ES Modules ohne Build, fetch, Web Components, View Transitions und Offline.","topics":["HTML","CSS","Web-APIs"],"url":"https://slides.mikebild.dev/introduction-web-plattform.html","pdf_url":"https://slides.mikebild.dev/introduction-web-plattform.pdf","repository":"https://github.com/MikeBild/introduction-web-plattform","articles":[],"slide_count":102,"slides":[{"no":1,"title":"Moderne Web-Plattform ohne Framework"},{"no":2,"title":"Worum es geht"},{"no":3,"title":"HTML, Accessibility und Formulare","section":true},{"no":4,"title":"Was sieht ein Screenreader von deiner Seite?"},{"no":5,"title":"Landmarks geben der Seite eine Karte"},{"no":6,"title":"Die Produktliste ist eine Liste"},{"no":7,"title":"Ein button kann mehr als ein div"},{"no":8,"title":"Erst HTML, dann ARIA"},{"no":9,"title":"Wer bedient deinen Shop ohne Maus?"},{"no":10,"title":"Der Focus ist immer sichtbar"},{"no":11,"title":"dialog nimmt dir den Focus ab"},{"no":12,"title":"Änderungen ansagen, ohne den Focus zu stehlen"},{"no":13,"title":"Tools finden viel, aber nicht alles"},{"no":14,"title":"Was kann ein Formular ohne JavaScript?"},{"no":15,"title":"Attribute beschreiben die Regeln"},{"no":16,"title":"Fachregeln meldest du mit setCustomValidity"},{"no":17,"title":"FormData liest das ganze Formular"},{"no":18,"title":"Der Server prüft trotzdem"},{"no":19,"title":"Jetzt du"},{"no":20,"title":"Moderne CSS","section":true},{"no":21,"title":"Warum gewinnt die falsche Regel?"},{"no":22,"title":"Layer entscheiden vor der Specificity"},{"no":23,"title":"Custom Properties sind Variablen zur Laufzeit"},{"no":24,"title":"Nesting hält eine Komponente zusammen"},{"no":25,"title":"Fremd-CSS kommt in einen eigenen Layer"},{"no":26,"title":"Eine Richtung oder zwei?"},{"no":27,"title":"Ein Raster ohne Media Queries"},{"no":28,"title":"Die Warenkorbzeile ist eine Flexbox"},{"no":29,"title":"Subgrid lässt Karten fluchten"},{"no":30,"title":"Logical Properties denken in Schreibrichtung"},{"no":31,"title":"Der Viewport sagt nichts über die Karte"},{"no":32,"title":"Erst den Container, dann die Query"},{"no":33,"title":":has() schaut nach unten"},{"no":34,"title":"Formular-Feedback erst nach der Eingabe"},{"no":35,"title":"Wann bleibt die Media Query?"},{"no":36,"title":"Jetzt du"},{"no":37,"title":"Module und Daten aus dem Netz","section":true},{"no":38,"title":"Braucht jede Seite einen Bundler?"},{"no":39,"title":"Ein Modul-Skript startet den Graphen"},{"no":40,"title":"Eine Import Map ersetzt Pfad-Aliase"},{"no":41,"title":"modulepreload verkürzt den Wasserfall"},{"no":42,"title":"Wann lohnt sich doch ein Build?"},{"no":43,"title":"fetch wirft seltener, als du denkst"},{"no":44,"title":"Ohne Timeout wartet fetch beliebig lange"},{"no":45,"title":"Die neue Suche bricht die alte ab"},{"no":46,"title":"Ein Abbruch ist kein Fehler"},{"no":47,"title":"Warten, bis alles da ist, kostet Zeit"},{"no":48,"title":"NDJSON ist JSON pro Zeile"},{"no":49,"title":"Ein Zeilenpuffer setzt die Chunks zusammen"},{"no":50,"title":"for await rendert jedes Produkt sofort"},{"no":51,"title":"Jetzt du"},{"no":52,"title":"Web Components und View Transitions","section":true},{"no":53,"title":"Eigene HTML-Elemente für den Shop"},{"no":54,"title":"Attribute sind die Eingänge"},{"no":55,"title":"Events sind die Ausgänge"},{"no":56,"title":"Aufräumen gehört zum Lifecycle"},{"no":57,"title":"Wann lohnt sich ein Custom Element?"},{"no":58,"title":"Warum zerlegt das Seiten-CSS meine Karte?"},{"no":59,"title":"template und Shadow Root"},{"no":60,"title":"Slots holen den Inhalt herein"},{"no":61,"title":"Parts und Custom Properties sind die Design-API"},{"no":62,"title":"Declarative Shadow DOM rendert ohne JavaScript"},{"no":63,"title":"Ein Sprung verwirrt, eine Bewegung erklärt"},{"no":64,"title":"startViewTransition macht Vorher und Nachher"},{"no":65,"title":"view-transition-name verbindet zwei Elemente"},{"no":66,"title":"Zwischen Seiten nur als Zusatz"},{"no":67,"title":"Jetzt du"},{"no":68,"title":"Offline und Performance","section":true},{"no":69,"title":"Welcher Speicher für welche Daten?"},{"no":70,"title":"Gespeicherte Daten können kaputt sein"},{"no":71,"title":"Ein Service Worker steht zwischen Seite und Netz"},{"no":72,"title":"Zwei Strategien decken den Shop ab"},{"no":73,"title":"Registrieren, aktualisieren, aufräumen"},{"no":74,"title":"Drei Zahlen für das Gefühl von Tempo"},{"no":75,"title":"Gut heißt: 75 Prozent der Besuche"},{"no":76,"title":"Messen im Feld mit PerformanceObserver"},{"no":77,"title":"Bilder entscheiden über LCP und CLS"},{"no":78,"title":"Schnelle Antworten brauchen kurze Tasks"},{"no":79,"title":"Jetzt du"},{"no":80,"title":"Zusatzkapitel · Progressive Enhancement","section":true},{"no":81,"title":"JavaScript fällt öfter aus, als du denkst"},{"no":82,"title":"Die Basis ist ein echtes Formular"},{"no":83,"title":"Das Skript verbessert, statt zu ersetzen"},{"no":84,"title":"Ein Endpoint, zwei Antworten"},{"no":85,"title":"Jetzt du"},{"no":86,"title":"Zusatzkapitel · Web Components integrieren","section":true},{"no":87,"title":"Eine Komponente, viele Frameworks"},{"no":88,"title":"Properties rein, Events raus"},{"no":89,"title":"React 19 kann Custom Elements"},{"no":90,"title":"Knockout und ein Store verbinden alles"},{"no":91,"title":"Jetzt du"},{"no":92,"title":"Sicherheit","section":true},{"no":93,"title":"Was macht eingeschleustes Skript harmlos?"},{"no":94,"title":"Nonces statt Domain-Listen"},{"no":95,"title":"Die Policy entsteht im Server"},{"no":96,"title":"Erst beobachten, dann blockieren"},{"no":97,"title":"Jeder HTML-String ist ein Risiko"},{"no":98,"title":"Text ist kein HTML"},{"no":99,"title":"Trusted Types sperren alle Sinks"},{"no":100,"title":"Eine Policy ist die einzige Tür"},{"no":101,"title":"Jetzt du"},{"no":102,"title":"Was nimmst du mit?"}]},{"id":"introduction-nodejs","title":"Node.js: Eine moderne Einführung","summary":"Runtime und Event Loop, asynchrone Muster, Webentwicklung mit Express, Testing und Skalierung mit Node.js 26.","topics":["Node.js"],"url":"https://slides.mikebild.dev/introduction-nodejs.html","pdf_url":"https://slides.mikebild.dev/introduction-nodejs.pdf","repository":"https://github.com/MikeBild/introduction-nodejs","articles":["https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/"],"slide_count":71,"slides":[{"no":1,"title":"Node.js: Eine moderne Einführung"},{"no":2,"title":"Agenda"},{"no":3,"title":"Grundlagen","section":true},{"no":4,"title":"Was ist Node.js?"},{"no":5,"title":"Wann Node.js passt – I/O-bound"},{"no":6,"title":"Wann Node.js nicht passt – CPU-bound"},{"no":7,"title":"Node.js-Releases heute"},{"no":8,"title":"Setup & erste Schritte"},{"no":9,"title":"Dein erster HTTP-Server"},{"no":10,"title":"Die REPL"},{"no":11,"title":"Modernes JavaScript","section":true},{"no":12,"title":"JavaScript-Grundlagen – Werte & Deklarationen"},{"no":13,"title":"JavaScript-Grundlagen – `this`, `bind`, `call`, `apply`"},{"no":14,"title":"Modern ES – nativ, ohne Build-Step"},{"no":15,"title":"Modern ES – Features für jeden Tag"},{"no":16,"title":"Funktionales JavaScript – das Kern-Trio"},{"no":17,"title":"Funktionale Patterns – Komposition & Currying"},{"no":18,"title":"Module, Runtime & Tooling","section":true},{"no":19,"title":"Module – ESM (ECMAScript Modules) ist der Standard"},{"no":20,"title":"Globals & die `process`-API"},{"no":21,"title":"Error Handling"},{"no":22,"title":"Der Event Loop"},{"no":23,"title":"Blocking vs. Non-Blocking"},{"no":24,"title":"Concurrency ≠ Parallelism"},{"no":25,"title":"npm & `package.json`"},{"no":26,"title":"Monorepos mit npm Workspaces"},{"no":27,"title":"Eingebautes Developer-Tooling"},{"no":28,"title":"TypeScript heute"},{"no":29,"title":"Code-Qualität – ESLint & Prettier"},{"no":30,"title":"Asynchrone Patterns","section":true},{"no":31,"title":"Callbacks – wo alles begann"},{"no":32,"title":"Promises"},{"no":33,"title":"async / await"},{"no":34,"title":"Promise-Kombinatoren"},{"no":35,"title":"EventEmitter – Pub/Sub im Kern"},{"no":36,"title":"Streams – das Konzept"},{"no":37,"title":"Vier Stream-Typen, zwei Richtungen"},{"no":38,"title":"Streams konsumieren"},{"no":39,"title":"Streams implementieren"},{"no":40,"title":"Backpressure"},{"no":41,"title":"Buffer – Binärdaten"},{"no":42,"title":"Dateisystem – `node:fs/promises`"},{"no":43,"title":"RxJS – Reactive Streams (Ausblick)"},{"no":44,"title":"Webentwicklung","section":true},{"no":45,"title":"Web-Architekturstile"},{"no":46,"title":"REST & das Richardson Maturity Model"},{"no":47,"title":"HTTP-Verben, Idempotenz, Statuscodes"},{"no":48,"title":"Express 5 – Setup"},{"no":49,"title":"Express – Middleware"},{"no":50,"title":"Express – Routing"},{"no":51,"title":"Express 5 – Error Handling"},{"no":52,"title":"Railway-Oriented Error Handling"},{"no":53,"title":"Echtzeit – Server-Sent Events"},{"no":54,"title":"Echtzeit – WebSockets"},{"no":55,"title":"HTTP/2 und mehr"},{"no":56,"title":"Testing","section":true},{"no":57,"title":"Teststrategie – die Pyramide"},{"no":58,"title":"`node:test` – Testing ohne Dependencies"},{"no":59,"title":"Asynchrone & Negativ-Tests"},{"no":60,"title":"Integrationstests – echtes HTTP"},{"no":61,"title":"Mocking & Test Doubles"},{"no":62,"title":"Skalierung","section":true},{"no":63,"title":"child\\_process – andere Programme ausführen"},{"no":64,"title":"worker\\_threads – CPU-Arbeit vom Main Thread verlagern"},{"no":65,"title":"Horizontale Skalierung"},{"no":66,"title":"Zusammenfassung","section":true},{"no":67,"title":"Der Weg in die Produktion"},{"no":68,"title":"Kernaussagen: Runtime und Module"},{"no":69,"title":"Kernaussagen: asynchron und mit Bordmitteln"},{"no":70,"title":"Kernaussagen: Web und Skalierung"},{"no":71,"title":"Vielen Dank"}]},{"id":"introduction-react","title":"React: Von der Komponente zur Architektur","summary":"Von JSX über Zustand, Daten und Routing bis zur Komponentenarchitektur mit React 19.","topics":["React","Frontend"],"url":"https://slides.mikebild.dev/introduction-react.html","pdf_url":"https://slides.mikebild.dev/introduction-react.pdf","repository":"https://github.com/MikeBild/introduction-react","articles":["https://www.mikebild.dev/de/blog/react-ueberblick-von-jsx-zur-architektur/"],"slide_count":108,"slides":[{"no":1,"title":"React: Von der Komponente zur Architektur"},{"no":2,"title":"Agenda"},{"no":3,"title":"Die Leitfrage"},{"no":4,"title":"Grundlagen & JSX","section":true},{"no":5,"title":"Das mentale Modell"},{"no":6,"title":"Deklarativ statt imperativ"},{"no":7,"title":"Virtual DOM"},{"no":8,"title":"Unidirektionaler Datenfluss"},{"no":9,"title":"JSX ist ein Funktionsaufruf"},{"no":10,"title":"Ausdruck, nicht Anweisung"},{"no":11,"title":"Attribute heißen anders"},{"no":12,"title":"Bedingtes Rendern"},{"no":13,"title":"Listen & Keys"},{"no":14,"title":"Fragmente"},{"no":15,"title":"Spread, Styles & rohes HTML"},{"no":16,"title":"Vite"},{"no":17,"title":"Komponenten","section":true},{"no":18,"title":"Props sind read-only"},{"no":19,"title":"Reine Komponenten"},{"no":20,"title":"Functional Component als Standard"},{"no":21,"title":"children & Komposition"},{"no":22,"title":"Komposition statt Vererbung"},{"no":23,"title":"Klassen & Lifecycle (historisch)"},{"no":24,"title":"Komponenten klein halten"},{"no":25,"title":"State & Hooks","section":true},{"no":26,"title":"useState"},{"no":27,"title":"State als Snapshot"},{"no":28,"title":"State ist eine Entscheidung"},{"no":29,"title":"State nach oben ziehen"},{"no":30,"title":"useRef"},{"no":31,"title":"ref als Prop"},{"no":32,"title":"useReducer"},{"no":33,"title":"useContext"},{"no":34,"title":"Reducer + Context"},{"no":35,"title":"Globale Stores"},{"no":36,"title":"Die Regeln der Hooks"},{"no":37,"title":"Rendering","section":true},{"no":38,"title":"Rendern und Anwenden"},{"no":39,"title":"Reconciliation"},{"no":40,"title":"Keys: Identität, nicht Position"},{"no":41,"title":"Warum ein Re-Render nicht teuer ist"},{"no":42,"title":"Automatic Batching"},{"no":43,"title":"Concurrent Rendering"},{"no":44,"title":"useDeferredValue"},{"no":45,"title":"Effects & eigene Hooks","section":true},{"no":46,"title":"useEffect"},{"no":47,"title":"Das Abhängigkeits-Array"},{"no":48,"title":"Aufräumen & Wettläufe"},{"no":49,"title":"Wann KEIN Effect"},{"no":50,"title":"useId & useLayoutEffect"},{"no":51,"title":"Custom Hooks"},{"no":52,"title":"Daten laden","section":true},{"no":53,"title":"Wem gehört der State?"},{"no":54,"title":"Server-State ist kein Client-State"},{"no":55,"title":"Data Fetching: die Falle im Effect"},{"no":56,"title":"use()"},{"no":57,"title":"Suspense"},{"no":58,"title":"fetch + use() + Suspense"},{"no":59,"title":"Server-State-Bibliotheken"},{"no":60,"title":"Routing","section":true},{"no":61,"title":"React Router"},{"no":62,"title":"Verschachtelte Routen & Layouts"},{"no":63,"title":"Data Router: Loader & Action"},{"no":64,"title":"URL als State"},{"no":65,"title":"Formulare & Actions","section":true},{"no":66,"title":"Formulare"},{"no":67,"title":"Actions & useActionState"},{"no":68,"title":"useFormStatus"},{"no":69,"title":"useOptimistic"},{"no":70,"title":"Styling","section":true},{"no":71,"title":"CSS Modules"},{"no":72,"title":"Utility-first (Tailwind)"},{"no":73,"title":"CSS-in-JS"},{"no":74,"title":"Design-Tokens & Konsistenz"},{"no":75,"title":"Performance","section":true},{"no":76,"title":"Erst messen, dann optimieren"},{"no":77,"title":"React DevTools & Profiler"},{"no":78,"title":"memo, useMemo, useCallback"},{"no":79,"title":"Der React Compiler"},{"no":80,"title":"Code-Splitting & lazy"},{"no":81,"title":"Listen-Virtualisierung"},{"no":82,"title":"Metadaten & Ressourcen"},{"no":83,"title":"Assets vorladen"},{"no":84,"title":"Testen","section":true},{"no":85,"title":"Testen, was Nutzer sehen"},{"no":86,"title":"Vitest + Testing Library"},{"no":87,"title":"userEvent"},{"no":88,"title":"Playwright (E2E)"},{"no":89,"title":"Werkzeugkette","section":true},{"no":90,"title":"TypeScript als Vertrag"},{"no":91,"title":"ESLint & Prettier"},{"no":92,"title":"Setup ist eine Architekturentscheidung"},{"no":93,"title":"Architektur & Patterns","section":true},{"no":94,"title":"Feature-basierte Struktur"},{"no":95,"title":"Custom Hooks statt HOC & Render Props"},{"no":96,"title":"Compound Components"},{"no":97,"title":"Error Boundaries"},{"no":98,"title":"Suspense-Architektur"},{"no":99,"title":"Barrierefreiheit"},{"no":100,"title":"Ausblick: Server Components","section":true},{"no":101,"title":"Server Components"},{"no":102,"title":"Server Functions"},{"no":103,"title":"Zusammenfassung","section":true},{"no":104,"title":"React 19: was neu ist"},{"no":105,"title":"Anti-Patterns"},{"no":106,"title":"Best Practices"},{"no":107,"title":"Was bleiben soll"},{"no":108,"title":"Quellen"}]},{"id":"introduction-svelte","title":"Svelte 5 und SvelteKit mit TypeScript","summary":"Der Compiler statt Virtual DOM, Runes, Snippets, geteilter Zustand und Context, Motion und Transitions bis zur App mit SvelteKit.","topics":["Svelte","Frontend"],"url":"https://slides.mikebild.dev/introduction-svelte.html","pdf_url":"https://slides.mikebild.dev/introduction-svelte.pdf","repository":"https://github.com/MikeBild/introduction-svelte","articles":["https://www.mikebild.dev/de/blog/svelte-slots-context-und-web-components/","https://www.mikebild.dev/de/blog/svelte-motion-transitions-animationen/","https://www.mikebild.dev/de/blog/svelte-stores-zustand-ausserhalb-der-komponente/","https://www.mikebild.dev/de/blog/svelte-reaktivitaet-compiler-ohne-virtual-dom/"],"slide_count":75,"slides":[{"no":1,"title":"Svelte 5 und SvelteKit mit TypeScript"},{"no":2,"title":"Worum es geht"},{"no":3,"title":"Das durchgehende Beispiel: Werkstatt-Shop"},{"no":4,"title":"Die Typen im Shop"},{"no":5,"title":"Der Compiler","section":true},{"no":6,"title":"Die Grundidee"},{"no":7,"title":"Vom Quelltext zum DOM"},{"no":8,"title":"Eine erste Komponente"},{"no":9,"title":"Was das für die Praxis heißt"},{"no":10,"title":"Runes: State","section":true},{"no":11,"title":"Was Runes sind"},{"no":12,"title":"`$state`: reaktiver State"},{"no":13,"title":"Deep State im Warenkorb"},{"no":14,"title":"`$state.raw`: nur ersetzen, nicht verändern"},{"no":15,"title":"`$state.snapshot`: eine einfache Kopie"},{"no":16,"title":"Ableiten und Effects","section":true},{"no":17,"title":"`$derived`: Werte aus Werten"},{"no":18,"title":"Summe und Anzahl im Warenkorb"},{"no":19,"title":"`$derived.by`: wenn ein Ausdruck nicht reicht"},{"no":20,"title":"`$effect`: Side Effects"},{"no":21,"title":"Ein einfacher Effect"},{"no":22,"title":"Effects räumen auf"},{"no":23,"title":"Der häufigste Fehler mit `$effect`"},{"no":24,"title":"Der reaktive Fluss"},{"no":25,"title":"Faustregel"},{"no":26,"title":"Props und Events","section":true},{"no":27,"title":"`$props`: Daten von außen"},{"no":28,"title":"Eine Produktkarte"},{"no":29,"title":"Events sind jetzt Callbacks"},{"no":30,"title":"Die Karte im Einsatz"},{"no":31,"title":"`$bindable`: Two-Way-Binding"},{"no":32,"title":"Bindings an Formularfelder"},{"no":33,"title":"Snippets","section":true},{"no":34,"title":"Snippets statt Slots"},{"no":35,"title":"`children` rendern"},{"no":36,"title":"Snippets mit Parametern"},{"no":37,"title":"Der Aufrufer liefert das Markup"},{"no":38,"title":"Wann Snippets passen"},{"no":39,"title":"Geteilter State","section":true},{"no":40,"title":"Das Problem"},{"no":41,"title":"Der Warenkorb als Klasse"},{"no":42,"title":"Eine Regel für `.svelte.ts`-Module"},{"no":43,"title":"Context: einmal setzen, überall lesen"},{"no":44,"title":"Den Warenkorb bereitstellen"},{"no":45,"title":"Warum Context und kein globales Modul"},{"no":46,"title":"Wo Stores noch passen"},{"no":47,"title":"Ein Store für eine externe Quelle"},{"no":48,"title":"Wann was"},{"no":49,"title":"Bewegung","section":true},{"no":50,"title":"Drei Werkzeuge, drei Aufgaben"},{"no":51,"title":"Positionen ein- und ausblenden"},{"no":52,"title":"Was dabei passiert"},{"no":53,"title":"Eine Summe, die gleitet"},{"no":54,"title":"Bewegung mit Rücksicht"},{"no":55,"title":"SvelteKit","section":true},{"no":56,"title":"Was SvelteKit ergänzt"},{"no":57,"title":"Routen sind Dateien"},{"no":58,"title":"Die Konfiguration in SvelteKit 3"},{"no":59,"title":"Load Functions"},{"no":60,"title":"Produkte laden"},{"no":61,"title":"Wenn es die Bestellung nicht gibt"},{"no":62,"title":"Seitenzustand mit `$app/state`"},{"no":63,"title":"Form Actions"},{"no":64,"title":"Das gemeinsame Schema"},{"no":65,"title":"Die Action „bestellen“"},{"no":66,"title":"Das Formular"},{"no":67,"title":"Der Weg einer Bestellung"},{"no":68,"title":"Praxis-Regeln für Daten"},{"no":69,"title":"Ausblick: Remote Functions"},{"no":70,"title":"Was sich gegenüber Svelte 4 geändert hat"},{"no":71,"title":"Zusammenfassung","section":true},{"no":72,"title":"Ein durchgehender Gedanke"},{"no":73,"title":"Die Regeln, die bleiben"},{"no":74,"title":"Quellen"},{"no":75,"title":"Danke"}]},{"id":"introduction-knockoutjs","title":"KnockoutJS modernisieren und schrittweise migrieren","summary":"MVVM und Observables mit Knockout 3.5, modern betrieben mit TypeScript, Vite und Vitest. Dazu schrittweise Migration über Web Components und Sicherheit.","topics":["KnockoutJS","Frontend"],"url":"https://slides.mikebild.dev/introduction-knockoutjs.html","pdf_url":"https://slides.mikebild.dev/introduction-knockoutjs.pdf","repository":"https://github.com/MikeBild/introduction-knockoutjs","articles":[],"slide_count":24,"slides":[{"no":1,"title":"KnockoutJS modernisieren und schrittweise migrieren"},{"no":2,"title":"Worum es geht"},{"no":3,"title":"Grundlagen","section":true},{"no":4,"title":"Warum läuft Knockout 2026 noch?"},{"no":5,"title":"Wer macht was im ViewModel?"},{"no":6,"title":"Wie merkt Knockout, dass sich etwas ändert?"},{"no":7,"title":"Summen ableiten statt nachführen"},{"no":8,"title":"observableArray kennt die Liste, nicht die Einträge"},{"no":9,"title":"Welche Bindings brauchst du wirklich?"},{"no":10,"title":"Werkzeuge","section":true},{"no":11,"title":"Components statt verschachtelter Templates"},{"no":12,"title":"Wie kommt Knockout in ein modernes Projekt?"},{"no":13,"title":"Custom Bindings kapseln DOM-Code"},{"no":14,"title":"Teste ViewModel und Template zusammen"},{"no":15,"title":"Wartung und Migration","section":true},{"no":16,"title":"Wann rechnet Knockout zu oft?"},{"no":17,"title":"Schrittweise migrieren statt neu schreiben"},{"no":18,"title":"Knockout als Web Component"},{"no":19,"title":"Wie teilen alte und neue Welt den Warenkorb?"},{"no":20,"title":"Sicherheit","section":true},{"no":21,"title":"Kein html-Binding mit Nutzereingaben"},{"no":22,"title":"Wann wird Nutzerinhalt selbst zum Binding?"},{"no":23,"title":"Welche CSP verträgt Knockout 3.5?"},{"no":24,"title":"Was nimmst du mit?"}]},{"id":"introduction-graphql","title":"GraphQL: Vom Type System zum Produktionsbetrieb","summary":"Schema, Abfragen und Resolver, Echtzeit, Caching und Sicherheit bis zur föderierten API.","topics":["GraphQL","API"],"url":"https://slides.mikebild.dev/introduction-graphql.html","pdf_url":"https://slides.mikebild.dev/introduction-graphql.pdf","repository":"https://github.com/MikeBild/introduction-graphql","articles":["https://www.mikebild.dev/de/blog/graphql-ueberblick-vom-schema-zur-foederierten-api/","https://www.mikebild.dev/de/blog/graphql-konzepte-schema-resolver-ausfuehrung/"],"slide_count":138,"slides":[{"no":1,"title":"GraphQL: Vom Type System zum Produktionsbetrieb"},{"no":2,"title":"Agenda"},{"no":3,"title":"Grundidee","section":true},{"no":4,"title":"Die Leitfrage"},{"no":5,"title":"Was ist GraphQL?"},{"no":6,"title":"GraphQL vs. REST"},{"no":7,"title":"Ein Endpoint, viele Quellen"},{"no":8,"title":"Schema & Type System","section":true},{"no":9,"title":"Das Schema als Vertrag"},{"no":10,"title":"SDL – die Schemasprache"},{"no":11,"title":"Object Types & Fields"},{"no":12,"title":"Eingebaute Skalare"},{"no":13,"title":"Custom Scalars"},{"no":14,"title":"Enums"},{"no":15,"title":"Listen & Non-Null"},{"no":16,"title":"Nullability als Designentscheidung"},{"no":17,"title":"Interfaces"},{"no":18,"title":"Unions"},{"no":19,"title":"Input-Types"},{"no":20,"title":"Die drei Wurzeltypen"},{"no":21,"title":"extend – Schema aufteilen"},{"no":22,"title":"Eingebaute Directives"},{"no":23,"title":"Custom Directives"},{"no":24,"title":"Dokumentation im Schema"},{"no":25,"title":"Queries & Introspection","section":true},{"no":26,"title":"Eine Query ist eine Form"},{"no":27,"title":"Fields & Argumente"},{"no":28,"title":"Aliase"},{"no":29,"title":"Variablen"},{"no":30,"title":"Default-Werte & Variablentypen"},{"no":31,"title":"Fragments"},{"no":32,"title":"Inline Fragments & \\_\\_typename"},{"no":33,"title":"Directives in Queries"},{"no":34,"title":"Introspection"},{"no":35,"title":"Introspection-Queries"},{"no":36,"title":"Mutations","section":true},{"no":37,"title":"Mutations"},{"no":38,"title":"Input-Payload-Pattern"},{"no":39,"title":"Fehler als Daten"},{"no":40,"title":"Mutation-Design"},{"no":41,"title":"Ausführung & Resolver","section":true},{"no":42,"title":"Lifecycle eines Requests"},{"no":43,"title":"Validierung gegen das Schema"},{"no":44,"title":"Das Resolver-Modell"},{"no":45,"title":"Die Resolver-Kette"},{"no":46,"title":"Der Default-Resolver"},{"no":47,"title":"Ausführungsreihenfolge"},{"no":48,"title":"Rückgabewerte"},{"no":49,"title":"Server mit Apollo Server","section":true},{"no":50,"title":"Server-Optionen"},{"no":51,"title":"Code-first vs. Schema-first"},{"no":52,"title":"Apollo Server 5: Standalone"},{"no":53,"title":"Apollo Server 5 mit Express"},{"no":54,"title":"Schema + Resolver verdrahten"},{"no":55,"title":"Der context"},{"no":56,"title":"Das N+1-Problem"},{"no":57,"title":"DataLoader: Batching"},{"no":58,"title":"DataLoader im Server"},{"no":59,"title":"DataLoader: Patterns & Fallstricke"},{"no":60,"title":"Testen im Server"},{"no":61,"title":"Client mit Apollo Client","section":true},{"no":62,"title":"Client-Optionen"},{"no":63,"title":"Apollo Client: Setup"},{"no":64,"title":"useQuery"},{"no":65,"title":"Lade- & Fehlerzustände"},{"no":66,"title":"Suspense & useSuspenseQuery"},{"no":67,"title":"useMutation"},{"no":68,"title":"Cache-Updates nach Mutations"},{"no":69,"title":"Optimistic UI"},{"no":70,"title":"Fragment-Colocation"},{"no":71,"title":"Codegen & Typsicherheit"},{"no":72,"title":"React Server Components & GraphQL"},{"no":73,"title":"Testen mit MockedProvider"},{"no":74,"title":"React + GraphQL: Best Practices"},{"no":75,"title":"Subscriptions","section":true},{"no":76,"title":"Subscriptions"},{"no":77,"title":"Transport: WebSockets"},{"no":78,"title":"Transport: SSE"},{"no":79,"title":"PubSub & Event-Quellen"},{"no":80,"title":"Subscriptions im Server"},{"no":81,"title":"useSubscription"},{"no":82,"title":"Subscriptions vs. Alternativen"},{"no":83,"title":"Paginierung","section":true},{"no":84,"title":"Warum nicht offset/limit"},{"no":85,"title":"Cursor-basierte Paginierung"},{"no":86,"title":"Relay Connections"},{"no":87,"title":"Connection – SDL & Query"},{"no":88,"title":"Global Object Identification"},{"no":89,"title":"Relay: drei Serveranforderungen"},{"no":90,"title":"Paginierung mit fetchMore"},{"no":91,"title":"Caching","section":true},{"no":92,"title":"Die Caching-Herausforderung"},{"no":93,"title":"Normalisierter Client-Cache"},{"no":94,"title":"Cache-Updates & Redirects"},{"no":95,"title":"Serverseitiges Response-Caching"},{"no":96,"title":"Automatic Persisted Queries"},{"no":97,"title":"HTTP- & CDN-Caching"},{"no":98,"title":"Fehler","section":true},{"no":99,"title":"Das Fehlermodell"},{"no":100,"title":"Error-Codes & extensions"},{"no":101,"title":"Fehler-Propagation & Nullability"},{"no":102,"title":"Error Handling"},{"no":103,"title":"Sicherheit & Härtung","section":true},{"no":104,"title":"Angriffsfläche"},{"no":105,"title":"Query-Tiefe begrenzen"},{"no":106,"title":"Query-Komplexität / Cost Analysis"},{"no":107,"title":"Rate Limiting"},{"no":108,"title":"Persisted Queries als Härtung"},{"no":109,"title":"Introspection in Produktion"},{"no":110,"title":"Datei-Uploads"},{"no":111,"title":"Konfiguration & Umgebungen"},{"no":112,"title":"Weitere Härtung"},{"no":113,"title":"Authentifizierung & Autorisierung","section":true},{"no":114,"title":"AuthN vs. AuthZ"},{"no":115,"title":"Autorisierung auf Field-Ebene"},{"no":116,"title":"Observability & Betrieb","section":true},{"no":117,"title":"Tracing"},{"no":118,"title":"Metrics & Schema-Nutzung"},{"no":119,"title":"Performance & Observability"},{"no":120,"title":"Deployment"},{"no":121,"title":"Node + GraphQL: Best Practices"},{"no":122,"title":"Federation & Architektur","section":true},{"no":123,"title":"GraphQL als BFF"},{"no":124,"title":"Schema-Stitching"},{"no":125,"title":"Federation"},{"no":126,"title":"Federation: Entities"},{"no":127,"title":"Federation 2"},{"no":128,"title":"Schema-Registry & Composition"},{"no":129,"title":"Schema-Checks in CI"},{"no":130,"title":"Schema-Evolution & Praxis","section":true},{"no":131,"title":"Evolvieren statt Versionieren"},{"no":132,"title":"Deprecation-Workflow"},{"no":133,"title":"Schema-Design-Prinzipien"},{"no":134,"title":"Anti-Patterns"},{"no":135,"title":"Ökosystem-Werkzeuge"},{"no":136,"title":"Zusammenfassung","section":true},{"no":137,"title":"Was bleiben soll"},{"no":138,"title":"Quellen"}]},{"id":"introduction-reactive-programming","title":"Reactive Programming mit RxJS","summary":"Streams für Suche, Live-Status und Benutzereingaben, mit Operatoren, Error Handling und Tests auf virtueller Zeit.","topics":["RxJS","Reactive"],"url":"https://slides.mikebild.dev/introduction-reactive-programming.html","pdf_url":"https://slides.mikebild.dev/introduction-reactive-programming.pdf","repository":"https://github.com/MikeBild/Reactive-Programming","articles":[],"slide_count":64,"slides":[{"no":1,"title":"Reactive Programming mit RxJS"},{"no":2,"title":"Worum es geht"},{"no":3,"title":"Das durchgehende Beispiel: Werkstatt-Shop"},{"no":4,"title":"Die Übungen"},{"no":5,"title":"Push, Observable, Creation","section":true},{"no":6,"title":"Werte kommen über die Zeit"},{"no":7,"title":"Callbacks und Flags werden schnell unlesbar"},{"no":8,"title":"Dieselbe Produktsuche als Stream"},{"no":9,"title":"Wann du kein RxJS brauchst"},{"no":10,"title":"Wer stößt den nächsten Wert an?"},{"no":11,"title":"Vier Arten, Werte zu liefern"},{"no":12,"title":"Gleiche Operatoren, anderes Modell"},{"no":13,"title":"Observable, Observer, Subscription"},{"no":14,"title":"Der Vertrag ist streng"},{"no":15,"title":"Ein Observable selbst schreiben"},{"no":16,"title":"Fast alles wird zum Observable"},{"no":17,"title":"Callback-APIs einbinden"},{"no":18,"title":"Operatoren, Lebensdauer, Kombinieren","section":true},{"no":19,"title":"Ein Operator macht aus einem Stream einen neuen"},{"no":20,"title":"Marble-Diagramme lesen"},{"no":21,"title":"State über die Zeit mit scan"},{"no":22,"title":"Eigene Operatoren für den Bestellstatus"},{"no":23,"title":"Jede Subscription braucht ein Ende"},{"no":24,"title":"Drei Wege, eine Subscription zu beenden"},{"no":25,"title":"Ein Signal für alle Subscriptions"},{"no":26,"title":"Vier Arten, Streams zu verbinden"},{"no":27,"title":"Filter für die Produktsuche mit combineLatest"},{"no":28,"title":"Ein Trigger, viel Kontext"},{"no":29,"title":"Cold und Hot, Async, Flattening","section":true},{"no":30,"title":"Film auf Abruf oder Live-Sendung"},{"no":31,"title":"Zwei Subscriber, zwei Requests"},{"no":32,"title":"Einen Producer teilen"},{"no":33,"title":"Promises und Observables arbeiten zusammen"},{"no":34,"title":"Async-Funktionen abbrechbar einbinden"},{"no":35,"title":"Ein Wert startet einen neuen Stream"},{"no":36,"title":"Vier Strategien für den laufenden Stream"},{"no":37,"title":"Der Use Case entscheidet"},{"no":38,"title":"Fehler, Subjects, Zeit, Tests","section":true},{"no":39,"title":"Ein error ist das Ende"},{"no":40,"title":"catchError gehört in den inneren Stream"},{"no":41,"title":"Wiederholen mit Backoff"},{"no":42,"title":"Subjects: Observable und Observer zugleich"},{"no":43,"title":"Der Letzte macht das Licht aus"},{"no":44,"title":"Cachen mit shareReplay"},{"no":45,"title":"Push kennt keine Bremse"},{"no":46,"title":"Vier Strategien gegen die Flut"},{"no":47,"title":"Scheduler steuern, wann Arbeit läuft"},{"no":48,"title":"Warum echte Zeit Tests kaputt macht"},{"no":49,"title":"Marble-Syntax"},{"no":50,"title":"Die Produktsuche testen, ohne zu warten"},{"no":51,"title":"Vor- und Nachteile","section":true},{"no":52,"title":"Was du gewinnst"},{"no":53,"title":"Was es kostet"},{"no":54,"title":"Zusatzkapitel","section":true},{"no":55,"title":"UI State als Stream von Actions"},{"no":56,"title":"Actions rein, State raus"},{"no":57,"title":"Abgeleiteter State statt Flags"},{"no":58,"title":"Signals oder Observables?"},{"no":59,"title":"Dieselben Ideen in Rx.NET"},{"no":60,"title":"Zusammenfassung","section":true},{"no":61,"title":"Die Regeln, die bleiben"},{"no":62,"title":"Fehler begrenzen, Producer teilen, Zeit simulieren"},{"no":63,"title":"Quellen"},{"no":64,"title":"Danke"}]},{"id":"introduction-docker","title":"Docker in der Praxis","summary":"Container im Alltag: Node.js und Redis mit Compose, eine eigene Registry und zentrales Logging mit dem ELK-Stack.","topics":["Docker","DevOps"],"url":"https://slides.mikebild.dev/introduction-docker.html","pdf_url":"https://slides.mikebild.dev/introduction-docker.pdf","repository":"https://github.com/MikeBild/introduction-docker","articles":["https://www.mikebild.dev/de/blog/docker-eigene-registry/","https://www.mikebild.dev/de/blog/docker-logging-mit-elk/","https://www.mikebild.dev/de/blog/docker-compose-nodejs-redis/"],"slide_count":63,"slides":[{"no":1,"title":"Docker in der Praxis"},{"no":2,"title":"Der rote Faden"},{"no":3,"title":"Wiederkehrende Prinzipien"},{"no":4,"title":"Container und Images","section":true},{"no":5,"title":"Ein Container ist ein Prozess"},{"no":6,"title":"Das naive Dockerfile"},{"no":7,"title":"Multi-Stage-Build mit node:26-slim"},{"no":8,"title":"Was jede Zeile bringt"},{"no":9,"title":".dockerignore"},{"no":10,"title":"Werkstatt-Shop mit Docker Compose","section":true},{"no":11,"title":"Zwei Container werden mühsam"},{"no":12,"title":"Compose dreht das um"},{"no":13,"title":"Die vier Teile einer Compose-Datei"},{"no":14,"title":"Die compose.yaml des Werkstatt-Shops"},{"no":15,"title":"version: braucht es nicht mehr"},{"no":16,"title":"build gegen image"},{"no":17,"title":"Service Discovery über den Namen"},{"no":18,"title":"Das Netz als Diagramm"},{"no":19,"title":"Postgres bleibt intern"},{"no":20,"title":"Daten im Named Volume"},{"no":21,"title":"init.sql beim ersten Start"},{"no":22,"title":"Healthchecks","section":true},{"no":23,"title":"Fallstrick: depends_on wartet nicht"},{"no":24,"title":"Postgres meldet sich mit pg_isready"},{"no":25,"title":"Die API wartet auf healthy"},{"no":26,"title":"Starten, bis alles bereit ist"},{"no":27,"title":"Logging mit Docker-Bordmitteln","section":true},{"no":28,"title":"Die Leitfrage zu Logs"},{"no":29,"title":"Twelve-Factor: Logs sind Infrastruktur"},{"no":30,"title":"Wo Docker-Logs landen"},{"no":31,"title":"Der Datenfluss"},{"no":32,"title":"Alle Services auf einen Blick"},{"no":33,"title":"Strukturiert loggen: eine JSON-Zeile pro Ereignis"},{"no":34,"title":"Filtern statt Suchen"},{"no":35,"title":"Rotation in Compose setzen"},{"no":36,"title":"Default für den ganzen Host"},{"no":37,"title":"Die Logging-Driver"},{"no":38,"title":"Wann es mehr braucht"},{"no":39,"title":"Eine eigene Docker-Registry betreiben","section":true},{"no":40,"title":"Die Leitfrage zur Registry"},{"no":41,"title":"Warum eine eigene Registry?"},{"no":42,"title":"Die Registry ist selbst nur ein Container"},{"no":43,"title":"Der Image-Name steuert alles"},{"no":44,"title":"Taggen, pushen, pullen"},{"no":45,"title":"Der komplette Kreislauf"},{"no":46,"title":"Betrieb: Persistenz und Speicherplatz"},{"no":47,"title":"Sicherheit","section":true},{"no":48,"title":"Ein kleines Image hat weniger Lücken"},{"no":49,"title":"Scannen mit Trivy"},{"no":50,"title":"Distroless als letzte Stage"},{"no":51,"title":"Den Container härten"},{"no":52,"title":"Registry: ab Werk offen"},{"no":53,"title":"Die htpasswd-Datei erzeugen"},{"no":54,"title":"Registry mit beiden Schutzschichten"},{"no":55,"title":"Der insecure-Notausgang"},{"no":56,"title":"Zusammenfassung und Quellen","section":true},{"no":57,"title":"Images und Compose in einem Satz"},{"no":58,"title":"Der eine Compose-Merksatz"},{"no":59,"title":"Logs und Registry in einem Satz"},{"no":60,"title":"Sicherheit in einem Satz"},{"no":61,"title":"Quellen – Images und Compose"},{"no":62,"title":"Quellen – Logs, Registry, Sicherheit"},{"no":63,"title":"Danke"}]},{"id":"introduction-kubernetes","title":"Kubernetes in der Praxis","summary":"Vom Container zum Deployment im Cluster: Pods, Services, Probes, Rolling Updates und die Gateway API, lokal mit kind.","topics":["Kubernetes","DevOps"],"url":"https://slides.mikebild.dev/introduction-kubernetes.html","pdf_url":"https://slides.mikebild.dev/introduction-kubernetes.pdf","repository":"https://github.com/MikeBild/introduction-kubernetes","articles":[],"slide_count":69,"slides":[{"no":1,"title":"Kubernetes in der Praxis"},{"no":2,"title":"Der rote Faden"},{"no":3,"title":"Das durchgehende Beispiel"},{"no":4,"title":"Soll-Zustand statt Kommandos","section":true},{"no":5,"title":"Kubernetes gleicht ab"},{"no":6,"title":"Die Reconcile-Schleife"},{"no":7,"title":"Control Plane und Nodes"},{"no":8,"title":"kubectl spricht nur mit dem API Server"},{"no":9,"title":"Ein Cluster auf dem Laptop","section":true},{"no":10,"title":"Warum kind"},{"no":11,"title":"Ein Cluster mit drei Nodes"},{"no":12,"title":"Das eigene Image in den Cluster"},{"no":13,"title":"Pods und Deployments","section":true},{"no":14,"title":"Der Pod ist die kleinste Einheit"},{"no":15,"title":"Ein Deployment hält die Anzahl"},{"no":16,"title":"Deployment, ReplicaSet, Pod"},{"no":17,"title":"Ausprobieren"},{"no":18,"title":"Services","section":true},{"no":19,"title":"Pods kommen und gehen, der Service bleibt"},{"no":20,"title":"Ein Service vom Typ ClusterIP"},{"no":21,"title":"Den Service erreichen"},{"no":22,"title":"Postgres braucht eine feste Identität"},{"no":23,"title":"Konfiguration und Secrets","section":true},{"no":24,"title":"Konfiguration gehört nicht ins Image"},{"no":25,"title":"ConfigMap und Secret anlegen"},{"no":26,"title":"In den Container bringen"},{"no":27,"title":"Änderungen greifen erst nach dem Neustart"},{"no":28,"title":"Probes","section":true},{"no":29,"title":"Drei Fragen an den Pod"},{"no":30,"title":"Probes per HTTP"},{"no":31,"title":"Fallstrick: Abhängigkeiten in der Liveness"},{"no":32,"title":"Ressourcen","section":true},{"no":33,"title":"Requests und Limits"},{"no":34,"title":"Werte für die API"},{"no":35,"title":"Node.js kennt das Limit"},{"no":36,"title":"Rolling Updates","section":true},{"no":37,"title":"Ein Update ist ein Rollout"},{"no":38,"title":"Ohne Lücke ausrollen"},{"no":39,"title":"Warum preStop mit sleep"},{"no":40,"title":"Ausrollen, beobachten, zurück"},{"no":41,"title":"Gateway API statt Ingress","section":true},{"no":42,"title":"Warum nicht mehr Ingress"},{"no":43,"title":"Drei Ressourcen, drei Rollen"},{"no":44,"title":"Envoy Gateway installieren"},{"no":45,"title":"GatewayClass und Gateway"},{"no":46,"title":"Die HTTPRoute der API"},{"no":47,"title":"Von außen erreichen"},{"no":48,"title":"Zusatzkapitel · Kustomize","section":true},{"no":49,"title":"Eine Basis, mehrere Umgebungen"},{"no":50,"title":"Warum Kustomize statt Helm"},{"no":51,"title":"Ein Overlay für Produktion"},{"no":52,"title":"Erst ansehen, dann anwenden"},{"no":53,"title":"Zusatzkapitel · Observability","section":true},{"no":54,"title":"Was ist gerade los?"},{"no":55,"title":"Logs aller Pods"},{"no":56,"title":"Metrics mit metrics-server"},{"no":57,"title":"Sicherheit","section":true},{"no":58,"title":"Drei Schichten"},{"no":59,"title":"RBAC: Rechte für die Continuous Integration"},{"no":60,"title":"Rechte prüfen"},{"no":61,"title":"NetworkPolicy: erst alles zu"},{"no":62,"title":"Dann gezielt öffnen"},{"no":63,"title":"Pod Security: restricted"},{"no":64,"title":"Den Namespace absichern"},{"no":65,"title":"Zusammenfassung","section":true},{"no":66,"title":"Der Weg in einem Bild"},{"no":67,"title":"Merksätze"},{"no":68,"title":"Quellen"},{"no":69,"title":"Danke"}]},{"id":"introduction-aws-serverless","title":"AWS Serverless mit Lambda, CDK und DynamoDB","summary":"Lambda, DynamoDB, AppSync, Cognito, Step Functions und SES: serverlose Dienste auf AWS, vom Datenmodell bis zum Workflow.","topics":["AWS","Lambda","DynamoDB"],"url":"https://slides.mikebild.dev/introduction-aws-serverless.html","pdf_url":"https://slides.mikebild.dev/introduction-aws-serverless.pdf","repository":"https://github.com/MikeBild/introduction-aws","articles":["https://www.mikebild.dev/de/blog/lambda-performance-und-cold-starts/","https://www.mikebild.dev/de/blog/ses-email-hook-serverless/","https://www.mikebild.dev/de/blog/dynamodb-datenmodellierung-single-table/","https://www.mikebild.dev/de/blog/appsync-dynamodb-graphql-api/","https://www.mikebild.dev/de/blog/step-functions-serverless-workflows/","https://www.mikebild.dev/de/blog/cognito-userpool-auth-serverless/"],"slide_count":106,"slides":[{"no":1,"title":"AWS Serverless mit Lambda, CDK und DynamoDB"},{"no":2,"title":"Serverless-Grundhaltung","section":true},{"no":3,"title":"Was Serverless für uns bedeutet"},{"no":4,"title":"Das wiederkehrende Muster"},{"no":5,"title":"Die Dienste im Zusammenspiel"},{"no":6,"title":"Wo Serverless an Grenzen stößt"},{"no":7,"title":"Daten: DynamoDB Single-Table-Design","section":true},{"no":8,"title":"Die umgedrehte Reihenfolge"},{"no":9,"title":"Die drei Teile"},{"no":10,"title":"Was PK und SK tun"},{"no":11,"title":"Query vs. Scan"},{"no":12,"title":"Global vs. Local Secondary Index (GSI/LSI)"},{"no":13,"title":"Warum es keinen Join gibt"},{"no":14,"title":"Vom Access Pattern zum Key"},{"no":15,"title":"Ein Single-Table-Layout"},{"no":16,"title":"Der Inverted Index"},{"no":17,"title":"Die Tabelle als CreateTable"},{"no":18,"title":"Der Query über eine Item Collection"},{"no":19,"title":"Der Query über den GSI"},{"no":20,"title":"Der Fallstrick FilterExpression"},{"no":21,"title":"Wann Single-Table lohnt"},{"no":22,"title":"Wann es überzogen ist"},{"no":23,"title":"Harte Grenzen"},{"no":24,"title":"GraphQL-API mit AppSync","section":true},{"no":25,"title":"Der weiße Fleck"},{"no":26,"title":"Was AppSync ist"},{"no":27,"title":"Die Data Sources"},{"no":28,"title":"Der Request-Fluss"},{"no":29,"title":"Das Schema (SDL)"},{"no":30,"title":"JavaScript-Resolver statt VTL"},{"no":31,"title":"Ein PutItem-Resolver"},{"no":32,"title":"Der Kontext `ctx`"},{"no":33,"title":"Ein GetItem-Resolver"},{"no":34,"title":"Resolver in CDK anhängen"},{"no":35,"title":"Subscriptions ohne eigenen Aufwand"},{"no":36,"title":"Subscription-Fluss"},{"no":37,"title":"Wo JS-Resolver an Grenzen kommen"},{"no":38,"title":"Orchestrierung mit Step Functions","section":true},{"no":39,"title":"Das Problem: Orchestrierung im Code"},{"no":40,"title":"Warum das eskaliert"},{"no":41,"title":"Die Idee: Ablauf als State Machine"},{"no":42,"title":"Die acht State-Typen"},{"no":43,"title":"ASL-Grundgerüst"},{"no":44,"title":"Ein Task mit Retry und Catch"},{"no":45,"title":"Retry im Detail"},{"no":46,"title":"Catch und die States.ALL-Regeln"},{"no":47,"title":"Nicht jeder Fehler wird gefangen"},{"no":48,"title":"Verzweigen mit Choice"},{"no":49,"title":"Choice: Default nicht vergessen"},{"no":50,"title":"Parallel: mehrere Zweige"},{"no":51,"title":"Wie Daten zwischen Zuständen fließen"},{"no":52,"title":"Der Stolperstein Output"},{"no":53,"title":"Der Ablauf als Bild"},{"no":54,"title":"Standard oder Express"},{"no":55,"title":"Welcher Typ wofür"},{"no":56,"title":"Wann Step Functions – und wann nicht"},{"no":57,"title":"Transaktionale E-Mails mit SES","section":true},{"no":58,"title":"SES in zwei Richtungen"},{"no":59,"title":"Die ausgehende Bestätigungsmail"},{"no":60,"title":"Der SendEmail-Handler"},{"no":61,"title":"Die Pflichtfelder"},{"no":62,"title":"Drei organisatorische Stolperstellen"},{"no":63,"title":"Templates statt String-Konkatenation"},{"no":64,"title":"Der eingehende Mail-Hook"},{"no":65,"title":"Die Receipt Rule als Infrastruktur"},{"no":66,"title":"Reihenfolge und InvocationType"},{"no":67,"title":"Der häufigste Irrtum"},{"no":68,"title":"Der Inbound-Handler"},{"no":69,"title":"Berechtigungen und DNS"},{"no":70,"title":"Performance & Cold Starts","section":true},{"no":71,"title":"Was ein Cold Start ist"},{"no":72,"title":"Warm Invoke"},{"no":73,"title":"Der Lifecycle als Bild"},{"no":74,"title":"Hebel: Bundle klein halten"},{"no":75,"title":"Hebel: Init-Code richtig platzieren"},{"no":76,"title":"Keep-Alive: der stille Gewinn"},{"no":77,"title":"Lazy-Init für Seltenes"},{"no":78,"title":"Hebel: Memory ist auch CPU"},{"no":79,"title":"Der Memory-Fehler"},{"no":80,"title":"Hebel: Provisioned Concurrency"},{"no":81,"title":"SnapStart-Randnotiz"},{"no":82,"title":"Messen, nicht vermuten"},{"no":83,"title":"Die Hebel als Checkliste"},{"no":84,"title":"Auth mit Cognito User Pools","section":true},{"no":85,"title":"Was ein User Pool ist"},{"no":86,"title":"User Pool vs. App Client"},{"no":87,"title":"Der User Pool in CDK"},{"no":88,"title":"Der App Client in CDK"},{"no":89,"title":"Sign-up und Confirm"},{"no":90,"title":"Sign-in und Tokens"},{"no":91,"title":"Die drei Tokens"},{"no":92,"title":"Warum die Unterscheidung zählt"},{"no":93,"title":"Ein ID-Token, dekodiert"},{"no":94,"title":"Der harte Weg: Token selbst prüfen"},{"no":95,"title":"Der einfache Weg: Cognito-Authorizer"},{"no":96,"title":"Der Ablauf als Bild"},{"no":97,"title":"Lambda-Trigger: eigene Logik einhängen"},{"no":98,"title":"Zwei Stolperfallen bei Triggern"},{"no":99,"title":"Edge-Auth ist nicht Resolver-Auth"},{"no":100,"title":"Zusammenfassung und Quellen","section":true},{"no":101,"title":"Der rote Faden"},{"no":102,"title":"Wiederkehrende Stolperfallen"},{"no":103,"title":"Quellen · Cognito & Step Functions"},{"no":104,"title":"Quellen · AppSync & DynamoDB"},{"no":105,"title":"Quellen · SES & Lambda"},{"no":106,"title":"Danke"}]},{"id":"introduction-microservices","title":"Microservices mit Node.js, Express 5 und Docker Compose","summary":"Vom Modulith zu Services mit Express 5: Verträge, Docker Compose, Events mit Outbox, Sagas, Resilienz und Contract Tests.","topics":["Microservices","Architektur"],"url":"https://slides.mikebild.dev/introduction-microservices.html","pdf_url":"https://slides.mikebild.dev/introduction-microservices.pdf","repository":"https://github.com/MikeBild/introduction-microservices","articles":[],"slide_count":110,"slides":[{"no":1,"title":"Microservices mit Node.js, Express 5 und Docker Compose"},{"no":2,"title":"Warum, wann und wo schneiden","section":true},{"no":3,"title":"Was ist ein Microservice?"},{"no":4,"title":"Was du gewinnst, was du bezahlst"},{"no":5,"title":"Conway’s Law gilt in beide Richtungen"},{"no":6,"title":"Modulith zuerst"},{"no":7,"title":"Miss, was sich gemeinsam ändert"},{"no":8,"title":"Übung"},{"no":9,"title":"Ein Wort, drei Bedeutungen"},{"no":10,"title":"Grenzen findest du im Gespräch"},{"no":11,"title":"Die Context Map zeigt die Kanten"},{"no":12,"title":"Der Anti-Corruption Layer übersetzt an einer Stelle"},{"no":13,"title":"Kontext ist nicht gleich Service"},{"no":14,"title":"Übung"},{"no":15,"title":"Ein Deployment, mehrere Module"},{"no":16,"title":"Öffentlich ist nur index.ts"},{"no":17,"title":"Eine Grenze braucht einen Test"},{"no":18,"title":"Vom Modul zum Service"},{"no":19,"title":"Übung"},{"no":20,"title":"Der erste Service und seine Verträge","section":true},{"no":21,"title":"Der Service catalog"},{"no":22,"title":"App und Server trennen"},{"no":23,"title":"Konfiguration kommt aus der Umgebung"},{"no":24,"title":"Testen ohne Netzwerk-Setup"},{"no":25,"title":"Übung"},{"no":26,"title":"Der Vertrag ist die einzige Kopplung"},{"no":27,"title":"Eine Quelle: zod-Schemas"},{"no":28,"title":"OpenAPI 3.1 aus dem Schema"},{"no":29,"title":"Am Eingang prüfen, mit Problem Details antworten"},{"no":30,"title":"Verträge ändern, ohne Clients zu brechen"},{"no":31,"title":"Übung"},{"no":32,"title":"orders fragt catalog nach Preisen"},{"no":33,"title":"Verfügbarkeit multipliziert sich"},{"no":34,"title":"Jeder Aufruf hat einen Timeout"},{"no":35,"title":"Was tust du, wenn catalog fehlt?"},{"no":36,"title":"Übung"},{"no":37,"title":"Ein Image pro Service"},{"no":38,"title":"compose.yaml beschreibt das System"},{"no":39,"title":"Live und ready sind zwei Fragen"},{"no":40,"title":"Start nach Bereitschaft, Stopp ohne Abbruch"},{"no":41,"title":"Übung"},{"no":42,"title":"Daten, Events und Konsistenz","section":true},{"no":43,"title":"Die geteilte Datenbank koppelt heimlich"},{"no":44,"title":"Eine Datenbank pro Service"},{"no":45,"title":"Fremde Daten: fragen oder kopieren"},{"no":46,"title":"Die Kopie übernimmt nur Neueres"},{"no":47,"title":"Übung"},{"no":48,"title":"Ein Event meldet eine Tatsache"},{"no":49,"title":"Events entkoppeln Zeit und Verfügbarkeit"},{"no":50,"title":"Ein Event hat einen Umschlag"},{"no":51,"title":"Die Inbox macht doppelte Zustellung harmlos"},{"no":52,"title":"HTTP reicht, ein Broker skaliert"},{"no":53,"title":"Übung"},{"no":54,"title":"Zwei Schreibvorgänge, ein Absturz"},{"no":55,"title":"Das Event wohnt erst in der eigenen Datenbank"},{"no":56,"title":"Bestellung und Event in einer Transaktion"},{"no":57,"title":"Das Relay liefert at least once"},{"no":58,"title":"Varianten in Produktion"},{"no":59,"title":"Übung"},{"no":60,"title":"Kein Rollback über Service-Grenzen"},{"no":61,"title":"Jeder Schritt hat eine Kompensation"},{"no":62,"title":"Die Saga als Pure Function"},{"no":63,"title":"Duplikate und Nachzügler"},{"no":64,"title":"Choreografie oder Orchestrierung?"},{"no":65,"title":"Übung"},{"no":66,"title":"Resilienz, Observability und Tests","section":true},{"no":67,"title":"Nicht jeder Fehler ist vorübergehend"},{"no":68,"title":"Backoff mit Jitter"},{"no":69,"title":"Ein Retry darf nicht doppelt bestellen"},{"no":70,"title":"Idempotency-Key als Middleware"},{"no":71,"title":"Wenn ein Service lange ausfällt"},{"no":72,"title":"Übung"},{"no":73,"title":"Drei Logs, ein Fehler"},{"no":74,"title":"W3C Trace Context"},{"no":75,"title":"Den Kontext weitergeben"},{"no":76,"title":"Logs als JSON mit Trace-ID"},{"no":77,"title":"Metrics und OpenTelemetry"},{"no":78,"title":"Übung"},{"no":79,"title":"Der End-to-End-Zoo wird langsam und rot"},{"no":80,"title":"Der Consumer schreibt auf, was er braucht"},{"no":81,"title":"Ein Vertrag, zwei Tests"},{"no":82,"title":"Der Provider prüft jeden Vertrag"},{"no":83,"title":"Pact und Broker für viele Teams"},{"no":84,"title":"Übung"},{"no":85,"title":"Zusatzkapitel · Kubernetes","section":true},{"no":86,"title":"Gewünschter Zustand statt Startbefehl"},{"no":87,"title":"Deployment und Service"},{"no":88,"title":"Health Checks werden zu Probes"},{"no":89,"title":"Was im Cluster anders ist"},{"no":90,"title":"Übung"},{"no":91,"title":"Zusatzkapitel · Micro-Frontends","section":true},{"no":92,"title":"Der Engpass zieht in den Browser"},{"no":93,"title":"Vertikal schneiden"},{"no":94,"title":"Komposition mit Timeout und Fallback"},{"no":95,"title":"Clientseitig und mit Bedacht"},{"no":96,"title":"Übung"},{"no":97,"title":"Zusatzkapitel · Event Sourcing","section":true},{"no":98,"title":"Ein Update vergisst, wie es dazu kam"},{"no":99,"title":"Der Zustand ist ein Left Fold"},{"no":100,"title":"Das Aggregat entscheidet, der Store hängt an"},{"no":101,"title":"Projections sind wegwerfbar"},{"no":102,"title":"Was Event Sourcing kostet"},{"no":103,"title":"Übung"},{"no":104,"title":"Zusatzkapitel · Auth zwischen Services","section":true},{"no":105,"title":"Das interne Netz ist kein Schutz"},{"no":106,"title":"Ein kurzlebiges, signiertes Token"},{"no":107,"title":"Signatur prüfen mit node:crypto"},{"no":108,"title":"In Produktion: Client Credentials oder mTLS"},{"no":109,"title":"Übung"},{"no":110,"title":"Was nimmst du mit?"}]},{"id":"introduction-design-patterns","title":"Design Patterns als Funktionen","summary":"Adapter, Chain of Responsibility, Strategy, Provider und Observer als Funktionen in TypeScript, wiedererkannt in Express, React und Apollo.","topics":["Design Patterns","TypeScript"],"url":"https://slides.mikebild.dev/introduction-design-patterns.html","pdf_url":"https://slides.mikebild.dev/introduction-design-patterns.pdf","repository":"https://github.com/MikeBild/introduction-design-patterns","articles":[],"slide_count":83,"slides":[{"no":1,"title":"Design Patterns als Funktionen"},{"no":2,"title":"Das durchgehende Beispiel: der Werkstatt-Shop"},{"no":3,"title":"Der Lernweg"},{"no":4,"title":"Erst das Problem, dann das Pattern","section":true},{"no":5,"title":"Was ist ein Pattern?"},{"no":6,"title":"Warum Funktionen statt Klassen?"},{"no":7,"title":"Zehn Probleme, zehn Patterns"},{"no":8,"title":"Was bewusst fehlt"},{"no":9,"title":"Du nutzt Patterns längst"},{"no":10,"title":"Jetzt du"},{"no":11,"title":"Ports and Adapters ohne Klassen","section":true},{"no":12,"title":"Wenn alles von allem abhängt"},{"no":13,"title":"Ports and Adapters"},{"no":14,"title":"Funktional umgesetzt"},{"no":15,"title":"Verdrahten in main"},{"no":16,"title":"Modules und Trade-offs"},{"no":17,"title":"Jetzt du"},{"no":18,"title":"Facade vereinfacht, Proxy kontrolliert","section":true},{"no":19,"title":"Zwei Aufrufer, und einer vergisst den Bestand"},{"no":20,"title":"Eine Facade steht vor drei Services"},{"no":21,"title":"Ein Proxy behält den Vertrag und cacht davor"},{"no":22,"title":"Ein Proxy darf auch verweigern"},{"no":23,"title":"Facade, Proxy oder Adapter?"},{"no":24,"title":"Wo du beide schon nutzt"},{"no":25,"title":"Jetzt du"},{"no":26,"title":"Jeder Schritt entscheidet, ob es weitergeht","section":true},{"no":27,"title":"Ein Handler, der nicht aufhören kann"},{"no":28,"title":"Weiter oder Schluss"},{"no":29,"title":"Eine Chain in wenigen Zeilen"},{"no":30,"title":"Express-Middleware ist dieselbe Chain"},{"no":31,"title":"ApolloLink nutzt forward statt next"},{"no":32,"title":"Chain oder Pipeline?"},{"no":33,"title":"Jetzt du"},{"no":34,"title":"Verhalten anreichern, ohne es anzufassen","section":true},{"no":35,"title":"Eine Funktion mit drei Aufgaben"},{"no":36,"title":"Gleiche Signatur hinein und heraus"},{"no":37,"title":"Schichten von außen nach innen"},{"no":38,"title":"Jeder Decorator hat dieselbe Form"},{"no":39,"title":"Decorator oder Proxy?"},{"no":40,"title":"Handler-Wrapper in Express"},{"no":41,"title":"Jetzt du"},{"no":42,"title":"Verhalten ist ein Argument","section":true},{"no":43,"title":"Warum wächst dieser switch immer weiter?"},{"no":44,"title":"Eine Strategy ist nur eine Funktion"},{"no":45,"title":"Eine Tabelle ersetzt den switch"},{"no":46,"title":"Ein Command macht aus einem Aufruf einen Wert"},{"no":47,"title":"Was kann ein Aufruf als Wert mehr?"},{"no":48,"title":"Ein GoF-Command ist kein CQRS-Command"},{"no":49,"title":"Jetzt du"},{"no":50,"title":"Eine Funktion baut, der Rest nutzt","section":true},{"no":51,"title":"Was stört an let auf Modulebene?"},{"no":52,"title":"createX bekommt Abhängigkeiten und liefert Verhalten"},{"no":53,"title":"Jede Instanz hat ihren eigenen State"},{"no":54,"title":"Abhängigkeiten kommen als Argument"},{"no":55,"title":"Drei Factories aus deinem Stack"},{"no":56,"title":"Wo Factories schiefgehen"},{"no":57,"title":"Jetzt du"},{"no":58,"title":"Ein Wert für den ganzen Baum oder Request","section":true},{"no":59,"title":"Dasselbe Pattern an drei Stellen"},{"no":60,"title":"Erst Props, dann children, dann Context"},{"no":61,"title":"In React 19 ist der Context selbst der Provider"},{"no":62,"title":"In GraphQL ist der Request der Bereich"},{"no":63,"title":"In Node trägt AsyncLocalStorage den Wert"},{"no":64,"title":"Wo endet die Reichweite?"},{"no":65,"title":"Jetzt du"},{"no":66,"title":"Wer sich anmeldet, wird benachrichtigt","section":true},{"no":67,"title":"Warum kennt placeOrder alle Empfänger?"},{"no":68,"title":"Ein Observer ist eine Menge von Funktionen"},{"no":69,"title":"subscribe liefert die Teardown-Function"},{"no":70,"title":"EventEmitter und Rx sind auch Observer"},{"no":71,"title":"Welche Übergänge sind überhaupt erlaubt?"},{"no":72,"title":"Die Tabelle entscheidet, der Observer meldet"},{"no":73,"title":"Jetzt du"},{"no":74,"title":"Wann ist ein Pattern zu viel?","section":true},{"no":75,"title":"Was kostet ein Pattern?"},{"no":76,"title":"Die Dreimal-Regel bremst die Abstraktion"},{"no":77,"title":"Eine Implementierung braucht keine Strategy"},{"no":78,"title":"Ein Modul ist schon ein Singleton"},{"no":79,"title":"Der Service Locator versteckt Abhängigkeiten"},{"no":80,"title":"Zurückbauen in kleinen Schritten"},{"no":81,"title":"Jetzt du"},{"no":82,"title":"Quellen"},{"no":83,"title":"Danke"}]},{"id":"introduction-cqrs-event-sourcing","title":"Architektur: DDD, CQRS und Event Sourcing","summary":"Commands, Events und Queries trennen, das Aggregat als Konsistenzgrenze, Read Models als Projections und ein Event-Store auf AWS.","topics":["CQRS","Event Sourcing","DDD"],"url":"https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html","pdf_url":"https://slides.mikebild.dev/introduction-cqrs-event-sourcing.pdf","repository":"https://github.com/MikeBild/introduction-nodejs/tree/master/EXAMPLES/ARCHITECTURE","articles":["https://www.mikebild.dev/de/blog/event-sourcing-auf-aws-dynamodb-streams/","https://www.mikebild.dev/de/blog/wann-cqrs-und-event-sourcing-zu-viel-ist/","https://www.mikebild.dev/de/blog/event-sourcing-der-event-store-als-logbuch/","https://www.mikebild.dev/de/blog/read-models-sind-projektionen/","https://www.mikebild.dev/de/blog/aggregat-als-konsistenzgrenze/","https://www.mikebild.dev/de/blog/commands-events-queries-trennen/","https://www.mikebild.dev/de/blog/cqrs-fachliche-grenze-kein-framework/"],"slide_count":95,"slides":[{"no":1,"title":"Architektur: DDD, CQRS und Event Sourcing"},{"no":2,"title":"Die häufigste Einstiegsfrage"},{"no":3,"title":"Das durchgehende Beispiel: der Werkstatt-Shop"},{"no":4,"title":"Der Lernweg"},{"no":5,"title":"Fachsprache und Event Storming","section":true},{"no":6,"title":"Warum heißt im Code alles update?"},{"no":7,"title":"Ubiquitous Language: ein Wort, eine Bedeutung"},{"no":8,"title":"Event Storming: erst, was passiert ist"},{"no":9,"title":"Commands, Actors und Policies"},{"no":10,"title":"Hotspots sind die wertvollsten Haftnotizen"},{"no":11,"title":"Das Board als Code"},{"no":12,"title":"Bounded Contexts","section":true},{"no":13,"title":"Ein Produkt, drei Bedeutungen"},{"no":14,"title":"Ein Bounded Context ist eine Sprachgrenze"},{"no":15,"title":"Core, Supporting oder Generic?"},{"no":16,"title":"Die Kanten sind die Architektur"},{"no":17,"title":"Ein Anti-Corruption Layer übersetzt an einer Stelle"},{"no":18,"title":"Erst die Grenze, dann der Service"},{"no":19,"title":"Entities und Value Objects","section":true},{"no":20,"title":"Was rutscht durch, wenn alles string ist?"},{"no":21,"title":"Identität oder nur ein Wert?"},{"no":22,"title":"Smart Constructors prüfen einmal"},{"no":23,"title":"Positionen sind eingefrorene Werte"},{"no":24,"title":"Das Aggregat als Konsistenzgrenze","section":true},{"no":25,"title":"Wer schützt die Regeln der Bestellung?"},{"no":26,"title":"Was ein Aggregat ist"},{"no":27,"title":"Die Root ist der einzige Eingang"},{"no":28,"title":"Andere Aggregate nur per ID"},{"no":29,"title":"Eine Transaktion ändert genau ein Aggregat"},{"no":30,"title":"Mit erwarteter Version speichern"},{"no":31,"title":"Drei Fallstricke"},{"no":32,"title":"Commands, Events und Queries","section":true},{"no":33,"title":"Was bedeutet updateOrder?"},{"no":34,"title":"Drei Messages, drei Bedeutungen"},{"no":35,"title":"decide prüft, evolve wendet an"},{"no":36,"title":"Wunsch, Tatsache, Antwort"},{"no":37,"title":"Fallstricke beim Benennen"},{"no":38,"title":"CQRS als fachliche Grenze","section":true},{"no":39,"title":"Ein Modell für alles wird zu groß"},{"no":40,"title":"CQRS trennt Modelle, nicht Datenbanken"},{"no":41,"title":"Die Schreibseite prüft und schweigt"},{"no":42,"title":"Die Leseseite liefert genau die Zeile"},{"no":43,"title":"Ein Store genügt für den Anfang"},{"no":44,"title":"Die spätere Ausbaustufe"},{"no":45,"title":"Event Sourcing – der Event Store als Logbuch","section":true},{"no":46,"title":"Ein Update vergisst, wie es dazu kam"},{"no":47,"title":"Der Zustand ist ein Left Fold"},{"no":48,"title":"Laden, falten, entscheiden, anhängen"},{"no":49,"title":"Optimistic Concurrency"},{"no":50,"title":"Event Sourcing ist nicht CQRS"},{"no":51,"title":"Was Event Sourcing kostet"},{"no":52,"title":"Read Models sind Projections","section":true},{"no":53,"title":"Projection oder Cache?"},{"no":54,"title":"Ein Fold mit Checkpoint"},{"no":55,"title":"Neuaufbau ist dieselbe Funktion ab null"},{"no":56,"title":"Eine Quelle, viele Views"},{"no":57,"title":"Side Effects gehören in einen Reactor"},{"no":58,"title":"Snapshots sind gemerkte Folds"},{"no":59,"title":"Events versionieren","section":true},{"no":60,"title":"Das Schema ändert sich, das Logbuch nicht"},{"no":61,"title":"Jedes Event steckt in einem Umschlag"},{"no":62,"title":"Ein Upcaster pro Version"},{"no":63,"title":"Nicht jede Änderung braucht eine Version"},{"no":64,"title":"Konsistenz, Idempotenz und Outbox","section":true},{"no":65,"title":"Zwei Systeme, kein gemeinsamer Commit"},{"no":66,"title":"Der Event Store ist schon deine Outbox"},{"no":67,"title":"Consumer erkennen Duplikate"},{"no":68,"title":"Eventual Consistency ist ein bewusster Handel"},{"no":69,"title":"Architekturentscheidungen festhalten","section":true},{"no":70,"title":"Warum steht das so im Code?"},{"no":71,"title":"Ein ADR für Event Sourcing"},{"no":72,"title":"Wann CQRS und Event Sourcing zu viel sind","section":true},{"no":73,"title":"Die Baseline heißt CRUD"},{"no":74,"title":"Was die Patterns wirklich kosten"},{"no":75,"title":"Ein Entscheidungsfluss pro Context"},{"no":76,"title":"Event Sourcing braucht Team und Fachlichkeit"},{"no":77,"title":"Der Fluss als Code"},{"no":78,"title":"Das Pattern als Selbstzweck"},{"no":79,"title":"Zusatzkapitel: Sagas und Process Manager","section":true},{"no":80,"title":"Kein Commit über drei Contexts"},{"no":81,"title":"Kompensation statt Rollback"},{"no":82,"title":"Choreografie oder Orchestrierung?"},{"no":83,"title":"Zusatzkapitel: Event Sourcing auf AWS","section":true},{"no":84,"title":"Drei Teile, kein eigener Server"},{"no":85,"title":"pk ist der Stream, sk die Version"},{"no":86,"title":"Der Projector prüft die Version selbst"},{"no":87,"title":"AWS – die Kernpunkte"},{"no":88,"title":"Zusammenfassung","section":true},{"no":89,"title":"Die Grenzen im Modell"},{"no":90,"title":"Die Grenzen beim Lesen und Speichern"},{"no":91,"title":"Sauber getrennt halten"},{"no":92,"title":"Der rote Faden"},{"no":93,"title":"Quellen: DDD, CQRS und Event Sourcing"},{"no":94,"title":"Quellen: AWS"},{"no":95,"title":"Danke"}]}]}