# Mike Bild > Software-Architekt & Engineer in Berlin. Über 30 Jahre Software, seit gut 15 Jahren mit Schwerpunkt auf verteilten Systemen, APIs und digitalen Produkten. Heute auch Agentic Engineering. ## Navigation - [Profil](https://www.mikebild.dev/de/profil/) - [Blog](https://www.mikebild.dev/de/blog/) - [Projekte](https://www.mikebild.dev/de/projects/) - [Kontakt](https://www.mikebild.dev/de/contact/): mike.bild@gmail.com - [GitHub](https://github.com/MikeBild) - [LinkedIn](https://www.linkedin.com/in/mikebild) - [Mike Bild · Blog](https://www.mikebild.dev/de/feed.xml) - [Mike Bild · Blogcast](https://www.mikebild.dev/de/blogcast.xml): Ausgewählte Blog-Beiträge von Mike Bild als Hörfassung – Softwarearchitektur, Engineering und Methodik. - [MCP-Wissensserver](https://worker.mikebild.dev/workshop-online-api/knowledge/mcp): Artikel, Folien und Workshop-Katalog für KI-Assistenten, nur lesend, ohne Anmeldung. Anleitung: https://www.mikebild.dev/de/mcp/ ## Aktuelle Projekte - [SubKit](https://subkit.mikebild.dev/de/): Einmal mit der KI gebaut, per Freigabe fürs Team: Workflows mit Rechten und Verlauf. (Beta auf Einladung) - [Harmoniqs](https://harmoniqs.mikebild.dev/de/): Songideen mit Akkorden, Looper und BRAIN entwickeln. (Beta) - TackTics: Ein Segeltrainer, der Daten von Bord auswerten soll. (In Arbeit) ## Training - [JavaScript & TypeScript: Von Werten zu Verträgen](https://slides.mikebild.dev/introduction-javascript-typescript.html): Von Werten und Funktionen über ESM und Async bis zu Generics, Runtime Validation und Library Design. – Repository: https://github.com/MikeBild/introduction-javascript – PDF: https://slides.mikebild.dev/introduction-javascript-typescript.pdf - [Python für Entwickler mit uv, pytest und Flask](https://slides.mikebild.dev/introduction-python.html): Daten einlesen und analysieren, Web-APIs mit Flask und ihre Tests, funktionale Muster und fortgeschrittenes Python. – Repository: https://github.com/MikeBild/introduction-python – PDF: https://slides.mikebild.dev/introduction-python.pdf - [Moderne Web-Plattform ohne Framework](https://slides.mikebild.dev/introduction-web-plattform.html): Semantisches HTML, Accessibility, modernes CSS, Formulare, ES Modules ohne Build, fetch, Web Components, View Transitions und Offline. – Repository: https://github.com/MikeBild/introduction-web-plattform – PDF: https://slides.mikebild.dev/introduction-web-plattform.pdf - [Node.js: Eine moderne Einführung](https://slides.mikebild.dev/introduction-nodejs.html): Runtime und Event Loop, asynchrone Muster, Webentwicklung mit Express, Testing und Skalierung mit Node.js 26. – Repository: https://github.com/MikeBild/introduction-nodejs – PDF: https://slides.mikebild.dev/introduction-nodejs.pdf - [React: Von der Komponente zur Architektur](https://slides.mikebild.dev/introduction-react.html): Von JSX über Zustand, Daten und Routing bis zur Komponentenarchitektur mit React 19. – Repository: https://github.com/MikeBild/introduction-react – PDF: https://slides.mikebild.dev/introduction-react.pdf - [Svelte 5 und SvelteKit mit TypeScript](https://slides.mikebild.dev/introduction-svelte.html): Der Compiler statt Virtual DOM, Runes, Snippets, geteilter Zustand und Context, Motion und Transitions bis zur App mit SvelteKit. – Repository: https://github.com/MikeBild/introduction-svelte – PDF: https://slides.mikebild.dev/introduction-svelte.pdf - [KnockoutJS modernisieren und schrittweise migrieren](https://slides.mikebild.dev/introduction-knockoutjs.html): MVVM und Observables mit Knockout 3.5, modern betrieben mit TypeScript, Vite und Vitest. Dazu schrittweise Migration über Web Components und Sicherheit. – Repository: https://github.com/MikeBild/introduction-knockoutjs – PDF: https://slides.mikebild.dev/introduction-knockoutjs.pdf - [GraphQL: Vom Type System zum Produktionsbetrieb](https://slides.mikebild.dev/introduction-graphql.html): Schema, Abfragen und Resolver, Echtzeit, Caching und Sicherheit bis zur föderierten API. – Repository: https://github.com/MikeBild/introduction-graphql – PDF: https://slides.mikebild.dev/introduction-graphql.pdf - [Reactive Programming mit RxJS](https://slides.mikebild.dev/introduction-reactive-programming.html): Streams für Suche, Live-Status und Benutzereingaben, mit Operatoren, Error Handling und Tests auf virtueller Zeit. – Repository: https://github.com/MikeBild/Reactive-Programming – PDF: https://slides.mikebild.dev/introduction-reactive-programming.pdf - [Docker in der Praxis](https://slides.mikebild.dev/introduction-docker.html): Container im Alltag: Node.js und Redis mit Compose, eine eigene Registry und zentrales Logging mit dem ELK-Stack. – Repository: https://github.com/MikeBild/introduction-docker – PDF: https://slides.mikebild.dev/introduction-docker.pdf - [Kubernetes in der Praxis](https://slides.mikebild.dev/introduction-kubernetes.html): Vom Container zum Deployment im Cluster: Pods, Services, Probes, Rolling Updates und die Gateway API, lokal mit kind. – Repository: https://github.com/MikeBild/introduction-kubernetes – PDF: https://slides.mikebild.dev/introduction-kubernetes.pdf - [AWS Serverless mit Lambda, CDK und DynamoDB](https://slides.mikebild.dev/introduction-aws-serverless.html): Lambda, DynamoDB, AppSync, Cognito, Step Functions und SES: serverlose Dienste auf AWS, vom Datenmodell bis zum Workflow. – Repository: https://github.com/MikeBild/introduction-aws – PDF: https://slides.mikebild.dev/introduction-aws-serverless.pdf - [Microservices mit Node.js, Express 5 und Docker Compose](https://slides.mikebild.dev/introduction-microservices.html): Vom Modulith zu Services mit Express 5: Verträge, Docker Compose, Events mit Outbox, Sagas, Resilienz und Contract Tests. – Repository: https://github.com/MikeBild/introduction-microservices – PDF: https://slides.mikebild.dev/introduction-microservices.pdf - [Design Patterns als Funktionen](https://slides.mikebild.dev/introduction-design-patterns.html): Adapter, Chain of Responsibility, Strategy, Provider und Observer als Funktionen in TypeScript, wiedererkannt in Express, React und Apollo. – Repository: https://github.com/MikeBild/introduction-design-patterns – PDF: https://slides.mikebild.dev/introduction-design-patterns.pdf - [Architektur: DDD, CQRS und Event Sourcing](https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html): Commands, Events und Queries trennen, das Aggregat als Konsistenzgrenze, Read Models als Projections und ein Event-Store auf AWS. – Repository: https://github.com/MikeBild/introduction-nodejs/tree/master/EXAMPLES/ARCHITECTURE – PDF: https://slides.mikebild.dev/introduction-cqrs-event-sourcing.pdf ## Blog - [Releases mit getrennten Prüfgrenzen](https://www.mikebild.dev/de/blog/vier-releases-ohne-blindflug/): 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. - [Entscheidungen mit Geschichte](https://www.mikebild.dev/de/blog/entscheidungen-mit-geschichte/): Entscheidungen lassen sich als eigene, reviewte Wissensobjekte mit Kontext, Entscheidung, Begründung und Alternativen führen. Claims sagen, was gilt; Entscheidungen erklären warum. - [Finanzen bewusst abtrennen](https://www.mikebild.dev/de/blog/finanzen-bewusst-abtrennen/): Finanzreports können denselben technischen Ablauf verwenden, ohne Daten, Rechte, Speicher oder Wissen mit allgemeinen Produktreports zu vermischen. Die Trennung folgt der Fachlichkeit. - [Suchen ohne Antworten zu erfinden](https://www.mikebild.dev/de/blog/suchen-ohne-antworten-zu-erfinden/): Suche, vollständiges Lesen, Herkunft und optionale Q&A bleiben getrennte Schritte. Treffer bleiben rohe Belege; ohne Treffer entsteht keine erfundene Antwort. - [Zwischenfazit und Systemblick: eine Bilanz der Serie](https://www.mikebild.dev/de/blog/von-der-session-zum-system-zwischenfazit/): 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. - [MCP, HTTP, Webhooks und A2A einfach erklärt](https://www.mikebild.dev/de/blog/mcp-http-webhooks-a2a-einfach-erklaert/): MCP, HTTP mit OpenAPI, Webhooks und A2A verbinden Systeme auf unterschiedlichen Ebenen. Entscheidend ist, welche Verantwortung an der jeweiligen Grenze liegt. - [Widersprüche dürfen bleiben](https://www.mikebild.dev/de/blog/widersprueche-duerfen-bleiben/): 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. - [A2A: Wenn Agenten zusammenarbeiten](https://www.mikebild.dev/de/blog/a2a-agenten-zusammenarbeit/): 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. - [Vom JSON zum privaten Report](https://www.mikebild.dev/de/blog/vom-json-zum-privaten-report/): 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. - [Stündlich ist nicht Echtzeit](https://www.mikebild.dev/de/blog/stuendlich-ist-nicht-echtzeit/): Ein abgeschlossener Stundenreport beantwortet andere Fragen als laufende Betriebsbeobachtung – und wird einfacher, sobald Zeitfenster, Verspätung und Messlücken ausdrücklich modelliert sind. - [Claims: klein, belegt, prüfbar](https://www.mikebild.dev/de/blog/claims-klein-belegt-pruefbar/): 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. - [Produkte berichten über ihre API](https://www.mikebild.dev/de/blog/produkte-berichten-ueber-ihre-api/): Produktstatistiken bleiben verständlich, wenn jedes System seine Daten selbst verdichtet und über einen kleinen, zeitlich einheitlichen HTTP-Vertrag anbietet. - [Vom Organigramm zur Produktverantwortung](https://www.mikebild.dev/de/blog/vom-organigramm-zum-arbeitsfluss/): KI macht den Weg vom Kundenproblem bis zur Wirkung durchgängiger. - [llms.txt: Doku für Maschinen](https://www.mikebild.dev/de/blog/llms-txt-und-llms-full-txt/): 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. - [Hilfsmittel, die tragen: Konzepte, Methoden, Prozesse](https://www.mikebild.dev/de/blog/hilfsmittel-konzepte-methoden-prozesse/): 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. - [KI-Agenten pro Fachdomäne: Regeln, Freigaben und Nachweise](https://www.mikebild.dev/de/blog/eigener-agent-pro-fachdomaene/): Wie ein KI-Agent fachliche Regeln über mehrere Tool-Aufrufe hinweg durchsetzt: mit Vorgangszustand, geprüften Plänen, Freigaben und nachvollziehbarer Ausführung. - [MCP-Sicherheit: Tools, Rechte und Freigaben für KI-Agenten](https://www.mikebild.dev/de/blog/mcp-softwarefaehigkeiten-agenten/): Wie du MCP-Tools sicher in Anwendungen einbindest: serverseitige Rechteprüfung, menschliche Freigaben, Schutz vor Prompt Injection und nachvollziehbare Aufrufe. - [Agent, Modell, Fachdomäne](https://www.mikebild.dev/de/blog/agent-modell-fachdomaene/): 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. - [Wissen, das die Session überlebt](https://www.mikebild.dev/de/blog/wissen-das-die-session-ueberlebt/): 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. - [Sauberes Grounding II: Antworten erden](https://www.mikebild.dev/de/blog/sauberes-grounding-antworten-erden/): Mein Arbeitsmodell für Grounding unterscheidet Konventionen, Dokumente und API-Beschreibungen. Was im knappen Kontext bleibt, priorisiere ich nach Aufgabe und Wiederbeschaffungskosten. - [KI im Enterprise-Umfeld: Schnellere Entwicklung braucht andere Prozesse](https://www.mikebild.dev/de/blog/ki-im-konzern-schnellere-entwicklung-andere-prozesse/): 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. - [Sauberes Grounding I: Warum die KI so überzeugend rät](https://www.mikebild.dev/de/blog/sauberes-grounding-warum-ki-raet/): Warum Sprachmodelle plausible Dinge erfinden, weshalb mehr Training das Problem nicht zuverlässig beseitigt und was Grounding damit zu tun hat. - [Vergebliche Versuche: Skills, AGENTS.md und die Grenze der Empfehlung](https://www.mikebild.dev/de/blog/vergebliche-versuche-instruktionsdateien/): 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. - [Mein Erfolg lässt sich nicht kopieren](https://www.mikebild.dev/de/blog/mein-erfolg-laesst-sich-nicht-kopieren/): 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. - [Was ich kritisch sehe](https://www.mikebild.dev/de/blog/was-ich-kritisch-sehe/): 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. - [Delegieren statt Diktieren: der Harness macht den Unterschied](https://www.mikebild.dev/de/blog/harness-macht-den-unterschied/): 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. - [Gut genug: das Modell ist selten der Engpass](https://www.mikebild.dev/de/blog/gut-genug-modelle/): 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. - [Vom Werkzeug zur Arbeitsweise](https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/): 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. - [React-Überblick: Von JSX zur Komponentenarchitektur](https://www.mikebild.dev/de/blog/react-ueberblick-von-jsx-zur-architektur/): 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. - [Formulare in React 19: Actions, useActionState und useOptimistic](https://www.mikebild.dev/de/blog/formulare-in-react-19-mit-actions/): 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. - [React Server Components und Server Actions: Die neue Grenze](https://www.mikebild.dev/de/blog/react-server-components-und-server-actions/): 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. - [React Router Data Router: Die URL als State-Ort](https://www.mikebild.dev/de/blog/react-router-data-router/): 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. - [React Transitions: nicht dringende Updates mit useTransition](https://www.mikebild.dev/de/blog/react-transitions-mit-usetransition/): 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. - [React Testing ohne Theater](https://www.mikebild.dev/de/blog/react-testing-ohne-theater/): Tests auf sichtbares Nutzerverhalten koppeln weniger an DOM-Struktur, Komponentengrenzen und internen State. Dadurch überstehen sie bestimmte Refactorings ohne Anpassung. - [Suspense und der use-Hook: rendern, als wären die Daten schon da](https://www.mikebild.dev/de/blog/react-suspense-und-use/): 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. - [Client State vs. Server-Cache: die folgenreichste Grenze im Frontend](https://www.mikebild.dev/de/blog/client-zustand-und-server-cache/): 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. - [Scheduler und virtuelle Zeit testen](https://www.mikebild.dev/de/blog/scheduler-und-virtuelle-zeit-testen/): Injizierbare Zeit macht zeitabhängige Regeln schnell und deterministisch testbar. Daten, Isolation und externe Systeme bleiben davon unberührt. - [GraphQL-Überblick: Vom Schema zur föderierten API](https://www.mikebild.dev/de/blog/graphql-ueberblick-vom-schema-zur-foederierten-api/): 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. - [HTTP/2 und Server-Sent Events in Node.js](https://www.mikebild.dev/de/blog/nodejs-http2-und-server-sent-events/): 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. - [Markdown Reports statt Dashboard-Zwang](https://www.mikebild.dev/de/blog/markdown-reports-statt-dashboard/): Warum ein diffbarer Markdown Report für Nachvollziehbarkeit, Review und Betrieb oft mehr leistet als ein Dashboard – und wie ich das architektonisch aufziehe. - [GraphQL Federation: ein Schema aus vielen Subgraphs](https://www.mikebild.dev/de/blog/graphql-federation-und-subgraphs/): 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. - [Fuzzy Matching mit expliziten Grenzen](https://www.mikebild.dev/de/blog/fuzzy-matching-mit-grenzen/): 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. - [Event Sourcing auf AWS: DynamoDB Streams und Lambda](https://www.mikebild.dev/de/blog/event-sourcing-auf-aws-dynamodb-streams/): Event Sourcing serverless umgesetzt: DynamoDB als append-only Event-Store, DynamoDB Streams als Change-Feed und Lambda als idempotenter Projector in CQRS-Read-Models. - [RxJS testen mit Marble-Diagrammen](https://www.mikebild.dev/de/blog/rxjs-testen-mit-marble-diagrammen/): 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. - [Rechnungsdaten als Domäne](https://www.mikebild.dev/de/blog/rechnungsdaten-als-domaene/): 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. - [Node.js testen: die Testpyramide mit Mocha](https://www.mikebild.dev/de/blog/nodejs-testing-mit-mocha/): Wie sich eine Node.js-Testsuite sinnvoll auf verschiedene Testebenen verteilt und wie Mocha, node:assert, sinon und supertest dabei zusammenspielen. - [Deterministische CLIs](https://www.mikebild.dev/de/blog/deterministische-clis/): Determinismus ist eine Architekturentscheidung: gleiche Eingaben, gleicher Report, reviewbar per Diff. Er lohnt sich dort, wo ein Artefakt wiederholt geprüft wird. - [Observability mit OpenTelemetry: Logs, Traces und Metrics](https://www.mikebild.dev/de/blog/observability-mit-opentelemetry/): Logs, Traces und Metrics mit OpenTelemetry einheitlich instrumentieren, über Trace-IDs verbinden und per OTLP an geeignete Backends ausgeben. - [Der GraphQL-Client: Apollo Link und Fragments](https://www.mikebild.dev/de/blog/graphql-client-apollo-link-und-fragments/): 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. - [OCR mit Cache und Misstrauen](https://www.mikebild.dev/de/blog/ocr-mit-cache-und-misstrauen/): 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. - [Die vier Subject-Typen in RxJS](https://www.mikebild.dev/de/blog/rxjs-die-vier-subject-typen/): Subject, BehaviorSubject, ReplaySubject und AsyncSubject unterscheiden sich darin, was späte Subscriber sehen und wie Abschluss oder Fehler ihr gespeichertes Verhalten beenden. - [Apollo Client: der normalisierte Cache und optimistische Updates](https://www.mikebild.dev/de/blog/graphql-client-caching-mit-apollo/): 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. - [GraphQL absichern: Query-Tiefe, Komplexität und Rate-Limiting](https://www.mikebild.dev/de/blog/graphql-absichern-depth-complexity-rate-limiting/): 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. - [TypeScript in Node.js: Setup, Typen und Tests](https://www.mikebild.dev/de/blog/typescript-in-node/): 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. - [TypeScript in React: Props, Hooks und Events typisieren](https://www.mikebild.dev/de/blog/typescript-in-react/): 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. - [Resilience-Pattern: Timeout, Retry, Circuit Breaker, Idempotenz](https://www.mikebild.dev/de/blog/resilience-muster-timeout-retry-circuit-breaker/): Warum Resilienz kein Zufall ist: Timeout, Retry mit Backoff und Jitter, Circuit Breaker und Idempotenz als bewusst entworfene Muster gegen die Ausfallmodi verteilter Systeme. - [GraphQL-Subscriptions über SSE: Echtzeit ohne WebSocket-Zwang](https://www.mikebild.dev/de/blog/graphql-subscriptions-ueber-sse/): 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. - [Contract Testing statt E2E-Marathon](https://www.mikebild.dev/de/blog/contract-testing-statt-e2e-marathon/): 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. - [Fehler als Architektur in Node.js: Netze gegen den Absturz](https://www.mikebild.dev/de/blog/nodejs-fehler-als-architektur/): 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. - [DORA-Metrics: was Entwicklungszyklen wirklich verkürzt](https://www.mikebild.dev/de/blog/dora-metriken-was-zyklen-verkuerzt/): 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. - [child_process in Node.js: Skalierung ohne Illusionen](https://www.mikebild.dev/de/blog/nodejs-child-process-und-skalierung/): 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. - [Lambda-Performance und Cold Starts verstehen](https://www.mikebild.dev/de/blog/lambda-performance-und-cold-starts/): 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. - [RxJS in Node.js: Event Streams reaktiv verarbeiten](https://www.mikebild.dev/de/blog/rxjs-in-node-ereignisstroeme/): 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. - [Evolutionary Architecture: Systeme, die sich ändern dürfen](https://www.mikebild.dev/de/blog/evolutionary-architecture-wandel-einplanen/): 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. - [Von HOCs und Render Props zu Custom Hooks](https://www.mikebild.dev/de/blog/von-hocs-und-render-props-zu-custom-hooks/): Wie teile ich Logik zwischen Komponenten ohne Copy-Paste? Custom Hooks vermeiden dafür meist verschachtelte Wrapper; HOCs und Render Props behalten einzelne Kompositionsaufgaben. - [Async-Kontext in Node.js: AsyncLocalStorage, async_hooks und diagnostics_channel](https://www.mikebild.dev/de/blog/nodejs-async-context-mit-asynclocalstorage/): 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. - [GraphQL-Auth: Identität im Context, Autorisierung in der Fachlogik](https://www.mikebild.dev/de/blog/graphql-auth-im-context/): 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. - [Node.js lokal debuggen und profilieren](https://www.mikebild.dev/de/blog/nodejs-debuggen-und-beobachten/): Vom console.log-Reflex zum eingebauten Inspector: node --inspect, das debug-Modul, nodemon sowie erste CPU- und Speicherprofile für die lokale Diagnose. - [Developer Productivity: verkürzte Zyklen bei hoher Qualität](https://www.mikebild.dev/de/blog/developer-productivity-verkuerzte-zyklen/): Kurze Feedbackschleifen bei verlässlicher Qualität sind mein Denkmodell für Developer Productivity. Wo tatsächlich gewartet wird, muss die eigene Durchlaufzeit zeigen. - [Slots, Context und Web Components in Svelte](https://www.mikebild.dev/de/blog/svelte-slots-context-und-web-components/): 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. - [Build vs. Runtime in Webprojekten](https://www.mikebild.dev/de/blog/build-vs-runtime-webprojekte/): Der Rendering-Zeitpunkt wird pro Route entschieden. Statische, dynamische und inkrementelle Modelle lassen sich nach Datenfrist, Kosten und Fehlerverhalten kombinieren. - [Streams im Objektmodus: Datenpipelines jenseits von Bytes](https://www.mikebild.dev/de/blog/nodejs-streams-im-objektmodus/): 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. - [Reproduzierbare Testpipelines: kein Flaky, kein Zufall](https://www.mikebild.dev/de/blog/reproduzierbare-testpipelines-kein-flaky/): 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. - [Adapter-Design: wo Frameworks enden](https://www.mikebild.dev/de/blog/adapter-design-framework-grenzen/): 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. - [RxJS in React: Observables im Datenfluss der Oberfläche](https://www.mikebild.dev/de/blog/rxjs-in-react-datenfluss/): 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. - [Motion, Transitions und Animationen in Svelte](https://www.mikebild.dev/de/blog/svelte-motion-transitions-animationen/): 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. - [Buffers in Node.js: mit Binärdaten arbeiten](https://www.mikebild.dev/de/blog/nodejs-buffers-und-binaerdaten/): 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. - [Svelte Stores: State außerhalb der Komponente](https://www.mikebild.dev/de/blog/svelte-stores-zustand-ausserhalb-der-komponente/): 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. - [Node.js-Streams: Backpressure verstehen und nutzen](https://www.mikebild.dev/de/blog/nodejs-streams-und-backpressure/): 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. - [Fitness Functions: ausgewählte Architektureigenschaften prüfen](https://www.mikebild.dev/de/blog/fitness-functions-messbare-architektur/): Fitness Functions machen ausgewählte Eigenschaften wie Kopplung, Abhängigkeiten und Latenz kontinuierlich prüfbar. Fachliche Qualität und Architekturabwägungen ersetzen sie nicht. - [Svelte-Reaktivität: der Compiler statt Virtual DOM](https://www.mikebild.dev/de/blog/svelte-reaktivitaet-compiler-ohne-virtual-dom/): 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. - [EventEmitter: das Event-Modell im Kern von Node.js](https://www.mikebild.dev/de/blog/nodejs-eventemitter-und-ereignismodell/): 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. - [GraphQL-Paginierung: Cursor, Connections und das Relay-Pattern](https://www.mikebild.dev/de/blog/graphql-paginierung-und-connections/): 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. - [SvelteKit Adapter AWS: Framework trifft Cloud](https://www.mikebild.dev/de/blog/sveltekit-adapter-aws/): 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. - [Preview-Umgebungen pro Pull Request](https://www.mikebild.dev/de/blog/preview-umgebungen-pro-pull-request/): Ephemere Umgebungen pro Pull Request verkürzen das Feedback und machen Reviews konkret: Man klickt die Änderung, statt sie sich aus dem Diff vorzustellen. - [Jamstack als Architekturstil für Webanwendungen](https://www.mikebild.dev/de/blog/jamstack-architektur/): 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. - [Transaktionale E-Mails serverless: SES und Lambda](https://www.mikebild.dev/de/blog/ses-email-hook-serverless/): 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. - [State-Management in React: Redux Toolkit, leichte Stores, MobX](https://www.mikebild.dev/de/blog/state-management-vergleich/): 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. - [DynamoDB-Datenmodellierung: Single-Table-Design](https://www.mikebild.dev/de/blog/dynamodb-datenmodellierung-single-table/): 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. - [CDN Construct: eine kleine Infrastruktur-API](https://www.mikebild.dev/de/blog/cdn-construct-infrastruktur-api/): Warum ein eigenes CDK-Construct für statische Websites mehr ist als gesparte Zeilen: eine Schnittstelle mit Meinung über S3, CloudFront, Zertifikate und DNS. - [Outcome vor Output: warum Velocity die falsche Zahl ist](https://www.mikebild.dev/de/blog/outcome-vor-output/): 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. - [Datenanalyse mit NumPy, Pandas und Matplotlib](https://www.mikebild.dev/de/blog/datenanalyse-mit-pandas/): Von Rohdaten zur Erkenntnis: Wie NumPy, pandas und Matplotlib zusammenspielen – vektorisiertes Rechnen, groupby-Aggregation und ein schneller Plot in einer kleinen Pipeline. - [Wann CQRS und Event Sourcing zu viel sind](https://www.mikebild.dev/de/blog/wann-cqrs-und-event-sourcing-zu-viel-ist/): 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. - [GraphQL-Konzepte: Schema, Resolver, Ausführung](https://www.mikebild.dev/de/blog/graphql-konzepte-schema-resolver-ausfuehrung/): 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. - [GraphQL-APIs mit AppSync und DynamoDB](https://www.mikebild.dev/de/blog/appsync-dynamodb-graphql-api/): 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. - [Context richtig einsetzen: Verteilweg statt heimlicher globaler State](https://www.mikebild.dev/de/blog/context-richtig-einsetzen/): 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. - [Daten einlesen in Python: CSV, JSON, Excel und Datenbank](https://www.mikebild.dev/de/blog/python-daten-einlesen/): 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. - [Architecture Decision Records: Entscheidungen, die man in zwei Jahren noch versteht](https://www.mikebild.dev/de/blog/adr-entscheidungen-die-man-versteht/): 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. - [GraphQL-Schema, das mitwächst: Evolution ohne Bruch](https://www.mikebild.dev/de/blog/graphql-schema-das-mitwaechst/): 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. - [Serverless-Workflows mit AWS Step Functions](https://www.mikebild.dev/de/blog/step-functions-serverless-workflows/): Step Functions machen aus verschachtelten Lambda-Handlern eine deklarative State Machine: sichtbarer Ablauf, explizite Retry- und Catch-Pfade, Standard oder Express. - [Event Sourcing: der Event-Store als Logbuch](https://www.mikebild.dev/de/blog/event-sourcing-der-event-store-als-logbuch/): 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. - [Flask-Apps testen: der Test-Client und zuverlässige Endpoints](https://www.mikebild.dev/de/blog/flask-apps-testen/): Wie ich Flask-Endpoints teste: mit app.test_client() ohne laufenden Server, pytest-Fixtures und geprüften JSON-Antworten. - [State erhalten und zurücksetzen: React und die Position im Baum](https://www.mikebild.dev/de/blog/react-zustand-erhalten-und-zuruecksetzen/): 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. - [Serverless-Auth mit Cognito User Pools](https://www.mikebild.dev/de/blog/cognito-userpool-auth-serverless/): 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. - [Trunk-Based Development statt Branch-Friedhof](https://www.mikebild.dev/de/blog/trunk-based-development-und-feature-flags/): 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. - [Wie React den Baum rendert und aktualisiert](https://www.mikebild.dev/de/blog/wie-react-den-baum-rendert-und-aktualisiert/): 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. - [Team-Automatisierung mit GitHub Actions](https://www.mikebild.dev/de/blog/team-automatisierung-github-actions/): Warum ich ausgewählte, wiederkehrende Prüfungen als Workflows ausführe – und woran sich zeigt, ob die Automatisierung das Team tatsächlich entlastet. - [Web-APIs mit Flask: von der App zur JSON-API](https://www.mikebild.dev/de/blog/web-apis-mit-flask/): 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. - [Read Models aus Events: Projections statt zufälliger Caches](https://www.mikebild.dev/de/blog/read-models-sind-projektionen/): 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. - [Cloud Guardrails statt Cloud Gatekeeping](https://www.mikebild.dev/de/blog/cloud-guardrails/): 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. - [CDK Constructs: Abstraktion mit Haftung](https://www.mikebild.dev/de/blog/cdk-constructs-abstraktion-mit-haftung/): Wer ein CDK Construct veröffentlicht, verschickt keine Hilfsfunktion, sondern Betriebsentscheidungen – über Defaults, Upgrade-Pfade und das, was beim Konsumenten im CloudFormation-Template landet. - [Bounded Contexts vor dem Serviceschnitt](https://www.mikebild.dev/de/blog/bounded-contexts-grenze-vor-microservice/): Bevor ich Services schneide, kläre ich fachliche Grenzen. Ein Bounded Context ist eine Sprachgrenze und ein möglicher, aber nicht zwingender Serviceschnitt. - [Funktionale Muster in Python: Comprehensions, itertools und functools](https://www.mikebild.dev/de/blog/funktionale-muster-in-python/): 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. - [JSX genau: Die Regeln hinter dem Markup](https://www.mikebild.dev/de/blog/jsx-genau-die-regeln-hinter-dem-markup/): 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. - [AWS Deployment Pipelines sind Architektur](https://www.mikebild.dev/de/blog/aws-deployment-pipelines-architektur/): Deployment Pipelines sind Architektur, weil sie bestimmen, wie häufig, sicher und reversibel ein System verändert wird. - [Fortgeschrittenes Python: Decorators, Magic Methods und Generatoren](https://www.mikebild.dev/de/blog/python-advanced-decorators-generatoren/): Drei Bausteine, die pythonischen Code ausmachen: Decorators umschließen Funktionen, Magic Methods binden eigene Objekte an Pythons Protokolle, Generatoren erzeugen lazy Sequenzen. - [Policy as Code für CDK](https://www.mikebild.dev/de/blog/policy-as-code-fuer-cdk/): Policy as Code macht einen prüfbaren Teil der Architekturregeln ausführbar und versionierbar. - [Das Aggregat als Konsistenzgrenze](https://www.mikebild.dev/de/blog/aggregat-als-konsistenzgrenze/): 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. - [`ssmenv-cli`: kleine CLIs für große Ordnung](https://www.mikebild.dev/de/blog/ssmenv-cli-kleine-clis/): 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. - [GitHub Actions mit AWS CDK](https://www.mikebild.dev/de/blog/github-actions-mit-aws-cdk/): GitHub Actions und AWS CDK verbinden Codeänderung, Infrastruktur und Deployment zu einer reviewbaren Pipeline – und machen die Architekturentscheidung im Pull Request sichtbar. - [PouchDB und Offline-First](https://www.mikebild.dev/de/blog/pouchdb-offline-first/): Wer offline Schreibzugriffe erlaubt, wechselt von Request/Response zur Replikation – und der Revisionsbaum von PouchDB und CouchDB macht Konflikte zur Aufgabe des Fachmodells. - [Commands, Events und Queries sauber trennen](https://www.mikebild.dev/de/blog/commands-events-queries-trennen/): 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. - [Infrastructure as Code braucht Code-Reviews](https://www.mikebild.dev/de/blog/infrastructure-as-code-code-reviews/): Sobald Infrastruktur Code ist, entscheidet ein kleiner Diff über Sicherheit, Kosten und Ausfallrisiko – deshalb gehört sie in denselben Review-Prozess wie Anwendungscode. - [Secrets im Build sind Produktentscheidungen](https://www.mikebild.dev/de/blog/secrets-im-build/): Secrets im Build sind ein Vertrauensproblem: Wer darf was wann sehen, speichern und deployen? - [Product Discovery vor der ersten Zeile Code](https://www.mikebild.dev/de/blog/product-discovery-vor-der-ersten-zeile/): 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. - [CQRS ist eine fachliche Grenze, kein Framework](https://www.mikebild.dev/de/blog/cqrs-fachliche-grenze-kein-framework/): 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. - [Node.js-Training: Vom Event Loop zur Architekturentscheidung](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/): 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. - [CDK-Anfänge: Infrastruktur mit einer Programmiersprache](https://www.mikebild.dev/de/blog/cdk-infrastruktur-als-programmiersprache/): 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. - [AWS Parameter Store im Build](https://www.mikebild.dev/de/blog/aws-parameter-store-im-build/): Parameter Store im Build macht Konfiguration explizit, wirft aber harte Fragen zu Secrets und Reproduzierbarkeit auf. - [Eine eigene Docker Registry betreiben](https://www.mikebild.dev/de/blog/docker-eigene-registry/): Wie ich in Projekten eine private Docker Registry mit registry:2 aufsetze: Images taggen, pushen und pullen, und danach mit htpasswd und TLS absichern. - [Kubernetes-Grundlagen durch Deployment-Druck](https://www.mikebild.dev/de/blog/kubernetes-grundlagen-deployment-druck/): Unter dem Druck eines echten Deployments sortieren sich die Kubernetes-Konzepte von selbst nach Wichtigkeit – vom Pod über Deployment und Service bis zum Ingress. - [Zentrales Logging mit dem ELK-Stack in Docker](https://www.mikebild.dev/de/blog/docker-logging-mit-elk/): 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. - [Micro-Frontends: Teamschnitt im Browser](https://www.mikebild.dev/de/blog/micro-frontends-teamschnitt-browser/): 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. - [Frontend-Schnittstellen sind APIs](https://www.mikebild.dev/de/blog/frontend-schnittstellen-sind-apis/): 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. - [Node.js und Redis mit Docker Compose](https://www.mikebild.dev/de/blog/docker-compose-nodejs-redis/): 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. - [Containerisierung und die falsche Sicherheit](https://www.mikebild.dev/de/blog/containerisierung-falsche-sicherheit/): Warum „läuft im Container“ weder Isolation noch Reproduzierbarkeit noch geregelten Betrieb bedeutet – und welche Handgriffe die drei Lücken tatsächlich schließen. - [Meine Arbeitsdefinition für ein MVP: lernen statt halb fertig liefern](https://www.mikebild.dev/de/blog/mvp-kleinstes-lernendes-produkt/): 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. - [GraphQL-Schema in der Fachsprache](https://www.mikebild.dev/de/blog/graphql-schema-in-der-fachsprache/): 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. - [Angular, React und der Wert von Übergängen](https://www.mikebild.dev/de/blog/angular-react-uebergaenge/): Ob ein Framework-Wechsel zum Umzug oder zum Neubau wird, entscheidet sich daran, wie billig zwei Welten monatelang nebeneinander laufen können. - [Prototypen: das Objektmodell hinter JavaScript-Klassen](https://www.mikebild.dev/de/blog/javascript-prototypen-modell-hinter-class/): 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. - [Redux als Protokoll für UI-Änderungen](https://www.mikebild.dev/de/blog/redux-als-protokoll/): Der Store ist der langweiligste Teil von Redux – wertvoll ist das Nachrichtenprotokoll aus benannten, serialisierbaren Actions, das UI-Änderungen nachvollziehbar, testbar und diskutierbar macht. - [JavaScript-Patterns, die geblieben sind: Module, Higher-Order Functions, Currying](https://www.mikebild.dev/de/blog/javascript-patterns-module-hof-currying/): 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. - [WebSockets sind nicht immer die Antwort](https://www.mikebild.dev/de/blog/websockets-nicht-immer-antwort/): 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. - [KnockoutJS: fein-granulare Reaktivität mit Observables und Computed](https://www.mikebild.dev/de/blog/knockoutjs-observables-computed-vor-signals/): Wie KnockoutJS Reaktivität löst: Observables als get/set-Funktionen, Computed mit automatischem Dependency-Tracking und gezielte DOM-Updates statt globalem Baum-Vergleich. - [PWA Weather: offline ist ein Feature](https://www.mikebild.dev/de/blog/pwa-weather-offline-feature/): 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. - [Tic-Tac-Toe mit Minimax in React](https://www.mikebild.dev/de/blog/tic-tac-toe-minimax-react/): 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. - [Einfacher Entwurf kann Prozess sparen](https://www.mikebild.dev/de/blog/einfach-schlagen-elegant-statt-prozess/): 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. - [HTML5, CSS3 und ECMAScript als Baseline](https://www.mikebild.dev/de/blog/html5-css3-ecmascript-baseline/): 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? - [RxJS: Push und Pull endlich auseinanderhalten](https://www.mikebild.dev/de/blog/rxjs-push-und-pull/): Wer Producer und Consumer benennt, erkennt sofort, ob Daten gezogen oder geschoben werden, und wählt zwischen Funktion, Iterator, Promise und Observable ohne Raten. - [Node.js: Async I/O erklärt Architektur](https://www.mikebild.dev/de/blog/nodejs-async-io-architektur/): Die Rechnung „ein Thread mal 200 Verbindungen“ zeigt, welche Systeme zu Node.js passen und welche Arbeit besser in eigene Prozesse gehört. - [Babel Node Express: moderne Syntax, altes HTTP](https://www.mikebild.dev/de/blog/babel-node-express-http/): Babel übersetzt Syntax, kein Ausführungsmodell – wer das trennt, weiß, warum babel-node in die Entwicklungsschleife gehört und nicht in den Betrieb. - [API-Denken vor Framework-Denken](https://www.mikebild.dev/de/blog/api-denken-vor-framework-denken/): 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. - [Tyrian Rx React: UI als Datenfluss](https://www.mikebild.dev/de/blog/tyrian-rx-react-ui-datenfluss/): 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. - [GraphQL-Pouch: lokale Daten mit Schema](https://www.mikebild.dev/de/blog/graphql-pouch-lokale-daten/): 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. - [Definition of Done als Qualitäts-Gate, nicht als Bürokratie](https://www.mikebild.dev/de/blog/definition-of-done-als-qualitaets-gate/): 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. - [React: Komponenten als Schnittstellen](https://www.mikebild.dev/de/blog/react-komponenten-als-schnittstellen/): Warum Props der öffentliche Vertrag einer React-Komponente sind – und was sich ändert, wenn man sie wie eine API entwirft statt wie eine Parameterliste. - [GraphQL als Frage an das System](https://www.mikebild.dev/de/blog/graphql-als-frage-an-das-system/): Ein halbes Jahr nach der Spezifikation sortiert dieser Beitrag, was an GraphQL wirklich neu ist – und welche Fragen Anfang 2016 noch niemand beantwortet hat. - [Build-Skripte als Produktcode](https://www.mikebild.dev/de/blog/build-skripte-als-produktcode/): Build-Skripte verdienen die Sorgfalt von Produktcode, weil sie bestimmen, wie reproduzierbar ein System tatsächlich ist. - [ES6 Memory: UI-State begreifen](https://www.mikebild.dev/de/blog/es6-memory-ui-state/): 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. - [Web Messaging mit RxJS](https://www.mikebild.dev/de/blog/web-messaging-mit-rxjs/): 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. - [Funktionale Stile im Alltag](https://www.mikebild.dev/de/blog/funktionale-stile-im-alltag/): 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. - [Container sind keine Architektur](https://www.mikebild.dev/de/blog/container-sind-keine-architektur/): Docker macht Systemgrenzen sichtbar und transportabel – gezogen werden müssen sie weiterhin von Hand, und ein Monolith bleibt auch im Container ein Monolith. - [Programming Styles als Architekturvergleich](https://www.mikebild.dev/de/blog/programming-styles-architekturvergleich/): 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. - [Docker für .NET, bevor es normal war](https://www.mikebild.dev/de/blog/docker-fuer-dotnet/): 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. - [Tests, bevor es ein Produkt gibt](https://www.mikebild.dev/de/blog/tests-bevor-produkt/): Frühe Tests helfen, ein Problem zu begreifen, auch wenn der Code später komplett ersetzt wird. - [Flood-It: ein Spiel als Architektur-Lupe](https://www.mikebild.dev/de/blog/flood-it-architektur-lupe/): 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. - [MathParser: Parser als Denkwerkzeug](https://www.mikebild.dev/de/blog/mathparser-parser-als-denkwerkzeug/): 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. - [State Mutation auf Streams](https://www.mikebild.dev/de/blog/state-mutation-auf-streams/): Wer State als Faltung über einen Strom von Events berechnet statt ihn direkt zu ändern, bekommt Undo, Replay und Nachvollziehbarkeit fast geschenkt. - [HTTP-APIs: klein, stabil, lesbar](https://www.mikebild.dev/de/blog/http-apis-klein-stabil-lesbar/): 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. - [Was ich an Elixir gesucht habe](https://www.mikebild.dev/de/blog/was-ich-an-elixir-gesucht-habe/): 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. - [Control Flow vor `async/await`](https://www.mikebild.dev/de/blog/control-flow-vor-async-await/): 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. - [Warum Katas mehr sind als Fingerübungen](https://www.mikebild.dev/de/blog/warum-katas-mehr-sind/): Eine Kata ist ein Architektur-Labor im Kleinen – weil die Aufgabe konstant bleibt, wird sichtbar, was sonst nur der Entwurf weiß. --- # Releases mit getrennten Prüfgrenzen URL: https://www.mikebild.dev/de/blog/vier-releases-ohne-blindflug/ Datum: 2026-08-19 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. Jede 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. ## Fünf Prüfgrenzen, fünf Fragen ```mermaid flowchart LR V["Repository-Vertrag
API und Doku im Produkt"] --> A["Release-Artefakt
benannt und identifizierbar"] A --> D["Deployment-Gate
Health, Readiness und Runtime-Bindung"] D --> O["Produktionsschnittstellen
reale Übergaben in PROD"] O --> S["Soak
geplante Prüfungen über Zeit"] ``` Der 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. Ein 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. ## Der Vertrag beginnt im Repository Jedes 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. Vertragstests 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. Ein 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. ## Das Release-Artefakt ist der Prüfgegenstand Ein 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. Der Vergleich zwischen dem Hash des veröffentlichten Downloads und dem Hash des installierten Binaries ist ein mit Standardwerkzeugen automatisierbarer Vergleich: ```bash sha256sum ssh 'sha256sum ' ``` Dieser 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. Lokale Ä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. Zum 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. Semantic 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. ## Deployment und Readiness sind eigene Beweise Ein 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. Nach 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. Der 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. ## Die Reihenfolge folgt den Abhängigkeiten Produktreleases 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. Eine 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. Anschließ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. ## Produktion prüft reale Übergaben Nach 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. Diese 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. Autorisierte Ü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. Menschliche 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. ## Ein grüner Smoke deckt keinen späteren Lauf ab Zeitabhä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. Ein 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. Vor 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. ## Rollback ist pro Schicht verschieden Getrennte 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. Fü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. Ein 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? ## Getrennte Beweise bestimmen die Freigabe Die 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. Der 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. ## Weiterführende Quellen - [Contract Testing statt E2E-Marathon](https://www.mikebild.dev/de/blog/contract-testing-statt-e2e-marathon/) - [Deterministische CLIs](https://www.mikebild.dev/de/blog/deterministische-clis/) - [Semantic Versioning](https://semver.org/) - [SLSA v1.2: Spezifikation](https://slsa.dev/spec/v1.2/) --- # Entscheidungen mit Geschichte URL: https://www.mikebild.dev/de/blog/entscheidungen-mit-geschichte/ Datum: 2026-08-18 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. Eine 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. ## Eine Entscheidung beantwortet eine andere Frage Im 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. ```json { "slug": "use-postgresql-as-primary-store", "title": "Use PostgreSQL as the primary store", "context": "Proposal review requires atomic visibility and searchable revisions.", "decision": "Use PostgreSQL as the production source of truth.", "rationale": "Transactions, full-text search, and advisory locks cover the required behavior.", "alternatives": [ "Markdown files as the primary store", "SQLite per process" ] } ``` In 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. Ein separater Claim ist sinnvoll, wenn neben der Begründung auch ein aktueller, separat belegbarer Sachverhalt festgehalten werden soll. ```mermaid flowchart LR S["Quelle und Herkunft"] --> P["Änderungsvorschlag"] P --> R{"menschliche Prüfung"} R -->|freigegeben| D["Entscheidung
Kontext · Wahl · Begründung · Alternativen"] D -.-> C["im Beispiel optionaler Claim
aktueller Stand"] D --> H["Revision und Historie"] ``` ## Kontext begrenzt die Aussage Eine 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. Der Kontext trennt die Situation und die Anforderungen von der anschließend dokumentierten Begründung: ```text Context: The service needs atomic proposal activation, revision history, full-text search, and multiple concurrent reviewers. Decision: Use PostgreSQL as the production source of truth. ``` Die 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. Der 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. ## Alternativen dokumentieren die geprüften Optionen Eine 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. Ein Eintrag zu einer verworfenen Alternative könnte so aussehen: ```json { "alternative": "Markdown files as the primary store", "reason_rejected": "Concurrent atomic proposal activation would require additional coordination." } ``` Grü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. Eine 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. ## Freigegebene Entscheidungen behalten ihre Historie Eine 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. ```mermaid flowchart LR S1["Quelle für frühere Fassung"] --> R1["freigegebene Revision"] R1 --> H["Historie"] S2["neue Quelle"] --> P["neuer Änderungsvorschlag"] R1 --> P P --> R2["später freigegebene Revision"] R2 --> H ``` Die 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. Ein 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. ## Entscheidungen über REST und MCP lesen Eine Wissensbasis kann getrennte REST- und MCP-Lesewege für Entscheidungen anbieten. Anwendungen und Agenten können damit freigegebenes Entscheidungswissen lesen. Die menschliche Freigabe bleibt davon getrennt und ist eine eigene, auditierbare Handlung. Eine Entscheidung wird erst durch diese Freigabe verbindlich. Diese 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. ## Die Prüfung konzentriert sich auf Entscheidungstreue Bei der Prüfung einer Entscheidung stellen sich andere Fragen als bei einem normalen Claim: - Wurde tatsächlich entschieden oder nur eine Möglichkeit diskutiert? - Deckt der Kontext den beabsichtigten Geltungsbereich ab? - Ist die Begründung durch die angegebenen Quellen gedeckt? - Sind die genannten Alternativen dort tatsächlich dokumentiert? - Gibt es zugehörige Claims, und widersprechen sie der Entscheidung? Eine Review-Notiz kann eine notwendige Präzisierung benennen. Das folgende Fragment ist ein redaktionelles Beispiel: ```json { "decision": "approve", "note": "Geltungsbereich anhand der angegebenen Quelle auf den Produktionsdienst begrenzt." } ``` Die 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. ## Ein Report dokumentiert noch keine Entscheidung Ein Report hält eine zeitgebundene Auswertung fest. Ein Finding beschreibt eine Beobachtung. Beides belegt noch nicht, dass eine verbindliche Wahl getroffen wurde. Die 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. Soll 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. ## Hypothetisches Modellierungsbeispiel: öffentliche MCP-Clients Angenommen, 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. Der Inhalt einer vorgeschlagenen Entscheidung könnte so aussehen: ```text source: architecture meeting notes proposed decision: public MCP clients use OAuth possible related claim: service-public-mcp / authentication / OAuth 2.1 rejected alternatives: long-lived API key in the client anonymous read-only access ``` Dieses 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. Erst 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. Unterstü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. ## Claims und Entscheidungen müssen zusammenpassen Ein 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. Wo beide Objekte zusammengehören, sollten Reviewer ihre Zuordnung prüfen. Das folgende Fragment veranschaulicht die fachliche Beziehung: ```json { "decision": "use-oauth-for-public-mcp", "related_claim": { "subject": "service-public-mcp", "predicate": "authentication", "object": "OAuth 2.1" } } ``` Bei 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. Für das Review sollten die vorgeschlagene Entscheidung und betroffene Claim-Änderungen gemeinsam betrachtet werden, sofern beides zum selben fachlichen Änderungsvorhaben gehört. ## Vom ADR zum Decision Log Der 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. Ein 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. Damit verschiebt sich nicht die fachliche Verantwortung. Ein solches System strukturiert und versioniert das Entscheidungswissen; es entscheidet nicht selbst, welche Architekturwahl verbindlich sein soll. ## Der Entscheidungstext muss eine spätere Prüfung tragen Lange 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. Fü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? Wenn 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. ## Weiterführende Quellen - [ADR: Entscheidungen, die man versteht](https://www.mikebild.dev/de/blog/adr-entscheidungen-die-man-versteht/) - [Documenting Architecture Decisions – Michael Nygard](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions) - [PostgreSQL Transaction Isolation](https://www.postgresql.org/docs/current/transaction-iso.html) --- # Finanzen bewusst abtrennen URL: https://www.mikebild.dev/de/blog/finanzen-bewusst-abtrennen/ Datum: 2026-08-18 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. Der 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. ## Finanzen als eigene fachliche Domäne Finanzen 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. Ein solcher Vertrag könnte so aussehen: ```http GET /v1/stats/finance?from=2026-07-01T00:00:00Z&to=2026-08-01T00:00:00Z&bucket=month Authorization: Bearer Accept: application/json ``` Die 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: ```json { "window": { "from": "2026-07-01T00:00:00Z", "to": "2026-08-01T00:00:00Z", "bucket": "month" }, "currency": "EUR", "values": { "invoiced_total": 42000, "open_invoice_count": 3 }, "coverage": { "complete": true, "missing_sources": [] } } ``` Die 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. ## Trennung der Ressourcen Ein 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. ```mermaid flowchart LR A["Finance API
begrenzter Scope"] --> C["HTTP Connector
eigenes Credential"] C --> W["Finance Workflow
eigene Ausführungsidentität"] W --> S["Storage
getrennter Bucket oder Prefix"] S --> R["Publishing-System
eigene Gruppe und Pfadregel"] W --> K["Wissensdienst
eigener Space und Schlüssel-Scope"] ``` Der 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. Ein 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. Welche 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. Mehr 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. ## Least Privilege beginnt bei der Quelle Ein 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. Im Referenzentwurf begrenzt der Scope zusätzlich Fenster und Aggregationsstufen: ```json { "name": "finance-report-reader", "scopes": [ "finance:stats:read" ], "constraints": { "max_window_days": 366, "allowed_buckets": ["day", "month", "year"] } } ``` Der 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. Ausgehende 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. Bindet 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. Diese 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. ## Drei modellierte Fehlerbilder Ein 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. Ein 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. Das 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. ```text failure: broad source credential control: connector-specific read scope failure: public storage object control: private storage policy plus anonymous test failure: automation approves its own proposal control: separate submission and approval rights ``` Diese Szenarien benennen jeweils ein unerwünschtes Ergebnis und eine prüfbare Gegenmaßnahme. Daraus lassen sich konkrete Anforderungen und Tests ableiten. ## Verschlüsselung ersetzt keine Trennung – und umgekehrt Wenn 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. Zeitfenstergebundene Pfade erleichtern die Zuordnung: ```text finance/v1/windows/2026-07-01T00:00:00Z/ sources/finance-api.json facts.json analysis.json manifest.json finance/v1/latest/monthly.json ``` Der Pfad allein verhindert kein Überschreiben. Eine immutable Historie erfordert einen vom gewählten Speicher dokumentierten Schutz gegen Überschreiben und Löschen. In 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. ## Das Publishing-System filtert geschützte Ausgaben Das 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. ```mermaid flowchart TD C["Releasegebundene Zugriffskonfiguration"] --> G["Leser und Gruppen"] C --> P["Exakte oder präfixbasierte Pfadregeln"] G --> A["Freigegebene Seiten und Medien"] P --> A A --> F["Gefilterte Navigation, Suche
und maschinenlesbare Ausgaben"] ``` Damit 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. Ein `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. ## Der Wissensdienst begrenzt Finanzwissen über Space und Schlüssel Wiederkehrende 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. Bindet 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. Fü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. ## Mit logischen Grenzen beginnen Die 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. ```yaml domain: finance source_connector: finance-stats-prod workflow: finance-monthly-report storage_prefix: finance/v1/ content_path: reports/finance/ reader_group: finance-readers knowledge_space: finance ``` Diese 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. Vor 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. Im 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. ## Abgrenzung zu den verlinkten Beiträgen Die 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. Allgemeine 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. Fü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. ## Gleiches Muster, getrennte Verantwortung Eine 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. Spä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. Die 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. Wiederverwendbar bleibt der technische Ablauf. Berechtigungen an Quelle, Connector, Speicher, Veröffentlichung und Wissen müssen dagegen einzeln sichtbar, begründet und prüfbar bleiben. ## Weiterführende Quellen - [Secrets im Build sind Produktentscheidungen](https://www.mikebild.dev/de/blog/secrets-im-build/) - [Markdown Reports statt Dashboard-Zwang](https://www.mikebild.dev/de/blog/markdown-reports-statt-dashboard/) - [NIST: Least Privilege](https://csrc.nist.gov/glossary/term/least_privilege) - [OWASP Secrets Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html) - [Supabase Storage Access Control](https://supabase.com/docs/guides/storage/security/access-control) --- # Suchen ohne Antworten zu erfinden URL: https://www.mikebild.dev/de/blog/suchen-ohne-antworten-zu-erfinden/ Datum: 2026-08-18 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. Ein 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. ## Die Suche liefert Treffer, keine Synthese Die Suche liefert Treffer aus dem Wissensbestand. Sie formuliert daraus keine verbindenden Sätze und ergänzt keine Erklärung, die im Bestand nicht vorkommt. Die 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. ```mermaid flowchart LR Q["Frage"] --> S["Suche
rohe Treffer"] S --> R["Lesen
vollständiges Konzept"] R --> O["Quellen und Historie
Herkunft prüfen"] O --> A{"genug belegt?"} A -->|ja| N["Antwort oder Entscheidung"] A -->|nein| X["Nichtwissen benennen"] ``` ## Der Leseweg holt das vollständige Konzept Ein Suchtreffer ist ein Ausgangspunkt. Wer den vollständigen Stand braucht, liest das zugehörige Konzept über den dafür vorgesehenen REST- oder MCP-Weg. Im 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. Ein 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. ## Herkunft und Geschichte sind getrennte Prüfschritte Quellen 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. ## Umstrittene Claims bleiben ein Reviewfall Ein Konflikt im Wissensbestand darf nicht durch eine glatt formulierte Antwort verschwinden. Der Reviewzustand der Wissensbasis bleibt von der optionalen natürlichsprachigen Query getrennt. Fü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. ## Die natürlichsprachige Query ist optional Natürlichsprachige Queries bilden einen eigenen, optionalen Zugang. Die Wissensbasis, ihre Quellen und ihr Reviewzustand bleiben davon getrennt. Die 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. Dieser 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. ## Begriffe brauchen Kontext Bei einer begriffsgebundenen Suche kann ein vorhandenes Konzept fehlen, wenn die gesuchten Begriffe nicht im durchsuchten Text vorkommen. Ein 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. ```md # Retry policy The internal HTTP client uses one shared retry budget across all attempts. Teams sometimes refer to this as the request deadline or attempt budget. ``` Zu 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. ## Suche getrennt testen Bei festgehaltener Suchkonfiguration und festem Datenstand lässt sich ein wiederholbarer Testaufbau herstellen. Geprüft werden dokumentierte Eigenschaften, nicht eine stabile Rangfolge. Als 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: ```ts const hits = await testClient.search({ space: "engineering", query: "retry budget" }); expect(hits.map(hit => hit.slug)).toContain("retry-policy"); ``` Fü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. Grenzen 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“. ## Eine Antwort bis zur Quelle verfolgen Angenommen, 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. Eine Testfixture kann diese Zuordnung ausdrücklich darstellen: ```text answer sentence -> expected claim identifier -> supporting citation -> source -> revision history ``` Fehlt 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.“ Für wichtige Entscheidungen liest ein Mensch die Quelle selbst. Die natürlichsprachige Query verkürzt den Weg, hebt die Prüfpflicht aber nicht auf. ## Suche darf keine Zugriffskontrolle umgehen Wenn 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. Auch 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. ## Suchqualität wächst aus echten Fragen Ein 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. ```json { "question": "Why do retries share one budget?", "expected_slugs": ["retry-policy", "shared-retry-budget"], "expected_state": "answerable" } ``` Bei Ä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. Scheitert 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. ## MCP hält die Wege getrennt Ein 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. Die 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. ## Vom Grounding-Ansatz zu getrennten Such- und Lesewegen [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. Getrennte 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. [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. ## Ein Suchtest beginnt mit Nichtwissen Fü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. Ein 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. Die 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. ## Weiterführende Quellen - [Sauberes Grounding II](https://www.mikebild.dev/de/blog/sauberes-grounding-antworten-erden/) - [Sauberes Grounding I](https://www.mikebild.dev/de/blog/sauberes-grounding-warum-ki-raet/) --- # Zwischenfazit und Systemblick: eine Bilanz der Serie URL: https://www.mikebild.dev/de/blog/von-der-session-zum-system-zwischenfazit/ Datum: 2026-08-17 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? *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/).* ## Die Begeisterung hat die Prüfung überstanden – knapp Das 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. Trotzdem 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. Deshalb 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. ## Was eingetreten ist Der Auftakt hielt drei Beobachtungen fest. Alle drei haben die Monate seit dem Auftakt überlebt, aber keine ist geblieben, wie sie war. Die 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. Die 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. Die 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. ## Was ich falsch erwartet hatte Eine Bilanz, die nur Treffer aufzählt, ist Werbung. Zwei Erwartungen haben sich nicht gehalten. Die 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. Die 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. ## Was offen bleibt Drei Fragen bleiben in diesem Zwischenfazit unbeantwortet. Die 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. Die 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. Das 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. ## Der Bogen, im Rückblick Der 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. ```mermaid flowchart LR subgraph AKT1["Akt 1 · Ich"] S["Session
Erzeugen wird billig"] --> P["Arbeitsweise
Prüfen wird der Engpass"] end subgraph AKT2["Akt 2 · Wir"] P --> V["Team-Varianz
Disziplin skaliert nicht"] --> D["Instruktionsdateien
Empfehlung statt Regel"] end subgraph AKT3["Akt 3 · System"] D --> W["Kuratiertes Wissen
mit Besitzern"] --> R["Regeln im Pfad
Agent pro Fachdomäne"] --> B["Systemarchitektur
Oberfläche, Verträge, Freigaben"] end ``` Der Satz, der diesen Bogen zusammenhält, fiel in der Folge über kuratiertes Wissen: Nicht das Modell wird schlauer – die Organisation kuratiert ihr Wissen. ## Der heutige Systemblick Im 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. Damit 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. ## Position statt Prognose Ü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. Was 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. Und 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. ## Zwischenfazit, kein Schlusswort Diese 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. ## Weiterführende Quellen - [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 - [The Anthropic Economic Index](https://www.anthropic.com/economic-index) – laufende Datenerhebung dazu, wofür KI in der Wirtschaft tatsächlich eingesetzt wird - [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 --- # MCP, HTTP, Webhooks und A2A einfach erklärt URL: https://www.mikebild.dev/de/blog/mcp-http-webhooks-a2a-einfach-erklaert/ Datum: 2026-08-17 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. MCP, 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. ## Vier Fragen statt einer Rangliste Eine 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. | Technik | Leitfrage | Typische Aufgabe | | --- | --- | --- | | MCP | Welche Fähigkeiten kann eine Agentenanwendung entdecken und verwenden? | Werkzeuge und Kontext für eine offene Aufgabe bereitstellen | | HTTP mit OpenAPI | Welchen bekannten Maschinenbefehl kann ein Client ausführen? | Einen stabilen Dienstvertrag aufrufen und prüfen | | Webhook | Welches eingetretene Event soll ein anderes System erfahren? | Einen nachgelagerten Ablauf zeitnah anstoßen | | A2A | Welchen begrenzten Auftrag kann ein eigenständiger Agent übernehmen? | Zustandsbehaftete Arbeit an ein anderes Agentensystem delegieren | Die 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. ## MCP stellt Fähigkeiten für Agentenanwendungen bereit MCP 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. Ein Server kann lokal als Prozess oder entfernt als Webdienst laufen. Das Wort „Server“ beschreibt seine Rolle im Protokoll, nicht seinen Standort. ```json { "mcpServers": { "knowledge": { "type": "http", "url": "https://knowledge.example.com/mcp" } } } ``` Nach 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. MCP 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. ## HTTP und OpenAPI bilden einen ausdrücklichen Maschinenvertrag HTTP 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. Der folgende Aufruf ist ein schematisches Beispiel: ```http POST /v1/sites/demo/previews HTTP/1.1 Host: content.example.com Authorization: Bearer Content-Type: application/json { "revisionIds": ["rev-123", "rev-456"] } ``` Der 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. In 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. ## Ein Webhook meldet ein Event Bei 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. ```http POST /webhooks/knowledge HTTP/1.1 Host: orchestration.example.com webhook-id: evt-789 webhook-timestamp: 1785585600 webhook-signature: v1, Content-Type: application/json { "type": "proposal.approved", "data": { "space": "product-docs", "concepts": ["integration-options"] } } ``` Der 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. Mehrfache 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. ## A2A delegiert an ein eigenständiges Agentensystem A2A 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. Eine 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. Das 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. ## Weboberflächen und Maschinenoberflächen erfüllen verschiedene Aufgaben Eine 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. Jede 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. Aus dieser Trennung folgen zwei Grenzen: - Eine Weboberfläche wird nicht zum zweiten fachlichen System mit abweichenden Regeln. - Ein Maschinenprotokoll wird nicht zur unbeabsichtigten Umgehung der menschlichen Entscheidung. ## Kein Protokoll erteilt von selbst Berechtigungen Authentifizierung 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. Ein 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. Zuverlä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. Für schreibende oder freigabepflichtige Integrationen müssen drei Verträge zusammenpassen: - Der technische Vertrag beschreibt Eingaben, Ergebnisse und Fehler. - Der fachliche Vertrag legt fest, welches System Wissen, Freigabe und Veröffentlichung besitzt. - Der betriebliche Vertrag regelt Zeitlimits, Wiederholungen, Logging und den Umgang mit Ausfällen. ## Ein Ablauf mit allen vier Grenzen ```mermaid flowchart LR U["Mensch
formuliert ein Ziel"] --> A["Agentenanwendung
bearbeitet offene Aufgabe"] A -->|"MCP
suchen und lesen"| K["Wissensdienst
geprüftes Wissen"] A -->|"MCP oder HTTP / OpenAPI
Revision und Vorschau"| C["Publishing-System
Publishing-Domäne"] K -->|"signierter Webhook
Freigabeereignis"| S["Workflow-Engine
versionierter Workflow"] S -->|"HTTP / OpenAPI
festgelegte Schritte"| C A -.->|"A2A
begrenzten Auftrag delegieren"| R["Agentensystem
eigenständiger Task"] H["Mensch in der jeweiligen Weboberfläche"] -.-> K H -.-> S H -.-> C H -.-> R ``` Der 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. Sobald 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. A2A 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. ## Die Entscheidung liegt vor dem Protokoll Die 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. Zuerst werden der Besitzer von Zustand und Freigabe festgelegt. Danach folgt das Protokoll. ## Weiterführende Quellen - [Model Context Protocol: Architektur](https://modelcontextprotocol.io/docs/learn/architecture) - [Model Context Protocol: Einführung](https://modelcontextprotocol.io/docs/getting-started/intro) - [OpenAPI Specification](https://spec.openapis.org/oas/) - [Standard Webhooks](https://www.standardwebhooks.com/) - [A2A: zentrale Konzepte](https://a2a-protocol.org/latest/topics/key-concepts/) - [A2A und MCP im Vergleich](https://a2a-protocol.org/latest/topics/a2a-and-mcp/) --- # Widersprüche dürfen bleiben URL: https://www.mikebild.dev/de/blog/widersprueche-duerfen-bleiben/ Datum: 2026-07-22 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. Eine 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. ## Wann eine Wissensbasis einen Widerspruch erkennen kann Claims 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: ```json { "settings": { "functional_predicates": [ "primary_database", "current_owner", "default_retention_days" ] } } ``` Nur 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. ```mermaid flowchart TD A["service
primary_database
PostgreSQL"] --> C{"gleiches Subjekt und Prädikat
anderes Objekt?"} B["service
primary_database
SQLite"] --> C C -->|funktionales Prädikat| D["beide Claims disputed
contradicts-Beziehung"] C -->|mehrwertiges Prädikat| V["kein Konflikt
beide Claims bleiben bestehen"] ``` ## `disputed` und `contradicts` erhalten den Konflikt Trifft 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. ```json { "subject": "service-runtime", "predicate": "primary_database", "object": "PostgreSQL", "status": "disputed", "relations": [ { "kind": "contradicts", "to_claim_id": "claim_sqlite" } ] } ``` Ein 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. Stilles Ü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. ## Der Geltungsbereich löst viele Scheinkonflikte Im 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`. ```json [ { "subject": "service-standard-deployment", "predicate": "primary_database", "object": "PostgreSQL" }, { "subject": "service-edge-deployment", "predicate": "primary_database", "object": "SQLite" } ] ``` Der 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. Andere 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. Ein 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. ## Review durch Menschen Die 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. Ein praktischer Prüfpfad: - Subjekt und Prädikat auf zu groben Geltungsbereich prüfen. - Quellenzeitpunkt und betroffene Produktversion vergleichen. - Autorität der Quellen einordnen, ohne ältere Belege zu entfernen. - Entscheidung mit kurzer Begründung festhalten und in einer neuen Revision freigeben – oder den Konflikt bewusst `disputed` lassen. Die 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. ## Suche und Antworten sollten den Status respektieren Ein 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: ```json { "answer": "The knowledge base contains conflicting primary-database claims for the runtime.", "citations": ["claim_postgresql", "claim_sqlite"], "status": "conflict" } ``` Findet 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. ## Drei Revisionen zeigen den Unterschied Bei `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. Fü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. ```text revision 1: aktueller Wert: 30 Tage revision 2: aktueller Wert: 90 Tage alte Aussage bleibt als Geschichte erhalten revision 3 (Vorschlag): Runbook nennt 30 Tage als aktuellen Default bei atomarer Freigabe: -> beide aktuellen Werte werden disputed -> contradicts verbindet die kollidierenden Claims ``` Diese 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. ## Veraltet ist nicht automatisch widersprüchlich Ein 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. Als 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. ## Einordnung gegenüber früheren Beiträgen Zwei 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. ## Sichtbare Konflikte machen die Wissensbasis prüfbar Eine 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. Fü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. Fü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. ## Weiterführende Quellen - [ADR: Entscheidungen, die man versteht](https://www.mikebild.dev/de/blog/adr-entscheidungen-die-man-versteht/) - [Wissen, das die Session überlebt](https://www.mikebild.dev/de/blog/wissen-das-die-session-ueberlebt/) - [W3C PROV-O](https://www.w3.org/TR/prov-o/) - [PostgreSQL Full Text Search](https://www.postgresql.org/docs/current/textsearch.html) --- # A2A: Wenn Agenten zusammenarbeiten URL: https://www.mikebild.dev/de/blog/a2a-agenten-zusammenarbeit/ Datum: 2026-07-22 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. A2A 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. ## Eigenständig ist das entscheidende Wort Nicht 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. Ein 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. Im 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. ```mermaid flowchart LR U["Benutzer
gibt ein Ziel vor"] --> C["Aufrufender Agent
A2A-Client"] C -->|"Agent Card lesen"| S["Entfernter Agent
A2A-Server"] C -->|"Message
Auftrag und Kontext"| S S -->|"Task
Zustand und Rückfragen"| C S -->|"Artifacts
Ergebnisse"| C S --> M["Interne Modelle, Tools
und Datenquellen"] C -.->|"kein automatischer Zugriff"| M ``` ## Die Agent Card ist die Beschreibung, nicht die Berechtigung Bevor 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. Das folgende fiktive Publishing-Beispiel ist auf die hier benötigten Felder reduziert und beansprucht keine vollständige A2A-1.0-Agent-Card. ```json { "name": "Publishing Agent", "description": "Builds previews and prepares approved releases", "supportedInterfaces": [ { "url": "https://publishing.example.com/a2a", "protocolBinding": "HTTP+JSON", "protocolVersion": "1.0" } ], "version": "1.0.0", "capabilities": { "streaming": true, "pushNotifications": true, "extendedAgentCard": true }, "defaultInputModes": ["text/plain", "application/json"], "defaultOutputModes": ["text/markdown", "application/json"], "skills": [ { "id": "prepare-publication", "name": "Prepare publication", "description": "Creates a preview from approved knowledge", "tags": ["publishing", "preview", "content"] } ] } ``` Die 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. ## Message, Part, Task und Artifact Message, Part, Task und Artifact bezeichnen unterschiedliche Bausteine eines Auftrags. | Begriff | Bedeutung | Beispiel bei delegierter Arbeit | | --- | --- | --- | | Message | Ein Gesprächsbeitrag des Benutzers oder Agenten | „Analysiere diese Abhängigkeit und schlage eine Änderung vor.“ | | Part | Ein Bestandteil einer Message oder eines Artifacts | Anweisung als Text, eine Datei oder strukturierte Daten | | Task | Eine zustandsbehaftete Arbeitseinheit mit eindeutiger ID | Der Auftrag wartet auf eine Rückfrage oder arbeitet weiter | | Artifact | Ein vom Agenten erzeugtes Ergebnis | Analysebericht, vorbereitete Änderung, Vorschau-URL | Eine 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. Der 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: ```http POST /message:send HTTP/1.1 Host: agents.example.com Authorization: Bearer Content-Type: application/a2a+json A2A-Version: 1.0 { "message": { "role": "ROLE_USER", "messageId": "msg-123", "parts": [ { "text": "Prepare a limited change proposal. Do not merge it." }, { "data": { "repository": "product-service", "scope": ["dependency-upgrade"] }, "mediaType": "application/json" } ] } } ``` Die 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. Artifacts 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: ```json { "task": { "id": "task-456", "contextId": "context-789", "status": { "state": "TASK_STATE_WORKING", "timestamp": "2026-08-07T10:05:00Z" }, "artifacts": [] } } ``` ## Der Lifecycle macht längere Arbeit sichtbar A2A 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. ```mermaid stateDiagram-v2 [*] --> SUBMITTED SUBMITTED --> WORKING WORKING --> INPUT_REQUIRED: Rückfrage INPUT_REQUIRED --> WORKING: weitere Message WORKING --> AUTH_REQUIRED: zusätzliche Freigabe AUTH_REQUIRED --> WORKING: Berechtigung erteilt WORKING --> COMPLETED WORKING --> FAILED SUBMITTED --> REJECTED WORKING --> CANCELED COMPLETED --> [*] FAILED --> [*] REJECTED --> [*] CANCELED --> [*] ``` Das 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. Polling, 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. ## Delegation erzeugt keine neue Berechtigung A2A 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. Der 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. Ein 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. ## MCP, A2A und Weboberfläche sind verschiedene Zugänge, keine Etiketten MCP 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. | Frage | MCP | A2A | | --- | --- | --- | | Gegenüber | Fähigkeitsserver | Eigenständiges Agentensystem | | Auffindbare Beschreibung | Werkzeuge und Kontextquellen | Agent Card mit Skills und Schnittstellen | | Typische Arbeit | Werkzeug auswählen und aufrufen | Ziel oder Teilaufgabe delegieren | | Länger laufender Zustand | Durch die Anwendung modelliert | Task-Lifecycle ist Protokollkonzept | | Ergebnis | Tool-Ergebnis oder Resource | Message oder ein oder mehrere Artifacts | | Interne Umsetzung | Fähigkeit mit definiertem Schema | Entfernter Agent bleibt intern eigenständig | Dazu 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. Ein 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. ## Wann A2A sinnvoll ist A2A 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. Fü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. Die 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. ## Weiterführende Quellen - [A2A Protocol Specification 1.0](https://a2a-protocol.org/latest/specification/) - [A2A: Core Concepts](https://a2a-protocol.org/latest/topics/key-concepts/) - [A2A: Life of a Task](https://a2a-protocol.org/latest/topics/life-of-a-task/) - [A2A und MCP im Vergleich](https://a2a-protocol.org/latest/topics/a2a-and-mcp/) - [Model Context Protocol: Architektur](https://modelcontextprotocol.io/docs/learn/architecture) - [MCP, HTTP, Webhooks und A2A einfach erklärt](https://www.mikebild.dev/de/blog/mcp-http-webhooks-a2a-einfach-erklaert/) --- # Vom JSON zum privaten Report URL: https://www.mikebild.dev/de/blog/vom-json-zum-privaten-report/ Datum: 2026-07-21 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. Eine 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. ## Fünf Artefaktarten statt einer großen Datei Gezä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. ```mermaid flowchart LR S["Source Snapshots"] --> F["Fact Set"] F --> A["Analysis"] S --> M["Manifest
paths · bytes · hashes"] F --> M A --> M M --> P["Latest Pointer"] M --> C["Publishing-Revision"] ``` Diese 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. ## Was sich seit damals geändert hat Der ä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. Diese 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. Auch 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: ```text closed time window -> immutable JSON artifacts -> validated Markdown revision -> authenticated release -> readable report with source trail ``` Neu 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?“ ## Das Manifest macht eine Stunde prüfbar Ein 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. Die 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. Die 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. ## Das Publishing-System veröffentlicht, speichert aber nicht die Fakten neu In 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. ```md --- kind: page layout: composition composition: format: report intent: status question: Did the latest closed hour complete, and does an exception require action? thesis: The closed hour is complete and no selected failure counter is above zero. conclusion: No technical intervention is required for this window. action: Review the next closed hour. reportCadence: hourly title: Hourly Report 2026-07-18 14:00 UTC locale: en slug: report-2026-07-18-14 --- ::metric{label="Workflow runs" value="11" period="closed hour" tone="neutral"} ``` Der 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. Die 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. ## Zugriff gilt auf jeder Route, nicht nur auf der HTML-Seite Eine 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. In 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. ```mermaid flowchart TD Q["Request"] --> T{"Welche Route?"} T -->|geschütztes Objekt| G{"Zugriffsregel erfüllt?"} G -->|nein| L["Login oder abgelehnt"] G -->|ja| O["HTML, Markdown oder Medien"] T -->|öffentliche Discovery| S["Antwort ohne geschützte Einträge"] ``` Genauso 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. Der 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. ## Wie man einen Report liest Eine 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. Erstens 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. Zweitens 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. Drittens 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. Viertens 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. Kurze 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. ## Navigation ist Teil der Verständlichkeit Eine 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. ## Löschen und Aufbewahren bleiben getrennt Stundenartefakte 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“. So 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. ## Weiterführende Quellen - [Markdown Reports statt Dashboard-Zwang](https://www.mikebild.dev/de/blog/markdown-reports-statt-dashboard/) - [JSON Schema Specification](https://json-schema.org/specification) - [Supabase Storage S3 Compatibility](https://supabase.com/docs/guides/storage/s3/compatibility) --- # Stündlich ist nicht Echtzeit URL: https://www.mikebild.dev/de/blog/stuendlich-ist-nicht-echtzeit/ Datum: 2026-07-20 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. Diese 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. Der 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. ## Zwei Zeitachsen, zwei Aufgaben Monitoring 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. ```mermaid flowchart LR E["Ereignis"] --> M["Live-Metrik
Sekunden bis Minuten"] M --> A["Alarm oder Untersuchung"] E --> W["geschlossenes Stundenfenster"] W --> R["unveränderlicher Report"] R --> V["Vergleich und Einordnung"] ``` Beide 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. Eine 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. ## Die Grenzen des Zeitfensters Die Sammlung verwendet halboffene UTC-Intervalle. Anfang eingeschlossen, Ende ausgeschlossen. Diese Schreibweise verhindert, dass ein Event an der Grenze in zwei Reports landet. ```ts type ClosedWindow = { from: string; // inclusive RFC 3339 timestamp to: string; // exclusive RFC 3339 timestamp bucket: "hour"; }; const window: ClosedWindow = { from: "2026-07-18T13:00:00.000Z", to: "2026-07-18T14:00:00.000Z", bucket: "hour" }; ``` UTC 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. Zwischen 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. ## Verspätete Daten und fehlende Läufe getrennt halten Ein 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. Jeder 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. Erst 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. ## Was aus einer fehlenden Beobachtung folgt Ein 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. Fü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. ## Wann Prometheus und Grafana fehlen würden Werkzeuge 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. Eine 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. ## Vier Fragen vor der Werkzeugwahl „Dashboard oder Report?“ ist zu grob. Vier Fragen trennen die Anforderungen: Reaktionszeit, Veränderlichkeit, Untersuchung und Auflösung. Die 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. Die 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. Die 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. Die 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. ```yaml decision: reaction_time: next_work_cycle immutable_history: required questions_known_in_advance: true per_request_drilldown: false result: scheduled_report ``` Sobald 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. ## Der Scheduler ist Teil der Aussage Ein 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. Auch 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. ## Was ein Stundenreport erklären muss Ohne 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. Eine 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. ```md ## Data quality The source window is complete for four product APIs. External acquisition data is not connected yet and is shown as unavailable. No value is inferred from missing input. ``` Auch 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. ## Verdichtung über größere Zeiträume Tages-, 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. ```mermaid flowchart TD H["24 Stundenartefakte"] --> D["Tagesreport"] D --> W["Wochenreport"] W --> M["Monatsreport"] M --> Y["Jahresreport"] H --> Q["Abdeckung und fehlende Fenster"] Q --> D ``` Ein 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. Eine 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. Auch 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. ## Einfache Technik braucht eine klare Grenze Der 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. Diese 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. Stü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. ## Weiterführende Quellen - [Prometheus Overview](https://prometheus.io/docs/introduction/overview/) - [Grafana Dashboards](https://grafana.com/docs/grafana/latest/visualizations/dashboards/) - [W3C Trace Context](https://www.w3.org/TR/trace-context/) --- # Claims: klein, belegt, prüfbar URL: https://www.mikebild.dev/de/blog/claims-klein-belegt-pruefbar/ Datum: 2026-07-20 „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. Eine 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. ## Ein Claim sagt genau eine Sache Das 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. ```json { "subject": "content-release", "predicate": "rollback_mechanism", "object": "switch the active pointer to an immutable revision" } ``` Die 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. Zu 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. ```mermaid flowchart TD A["Aussage aus Quelle"] --> Q{"eine fachliche
Behauptung?"} Q -->|mehrere| S["in mehrere Claims teilen"] Q -->|eine| C{"ohne Absatz
verständlich?"} C -->|nein| E["Objekt präzisieren"] C -->|ja| V["Zitat und Status ergänzen"] ``` ## Das Zitat trägt die Aussage Jeder 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. ```json { "subject": "content-release", "predicate": "rollback_mechanism", "object": "switch the active pointer to an immutable revision", "confidence": 0.94, "citations": [ { "source_id": "source_release_docs", "quote": "Rollback changes the active release pointer to a previous immutable revision.", "locator": "Rollback" } ] } ``` Ein 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. Mehrere 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. ## Confidence ist keine Wahrheitsskala `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. ```ts type ClaimStatus = "proposed" | "verified" | "disputed" | "deprecated"; type Claim = { subject: string; predicate: string; object: string; confidence: number; status: ClaimStatus; }; ``` Ein 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. Diese 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. ## Status bildet den Lifecycle ab `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. Der 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. ```mermaid stateDiagram-v2 [*] --> proposed proposed --> verified: human approval proposed --> [*]: rejection verified --> disputed: conflicting evidence disputed --> verified: conflict resolved verified --> deprecated: superseded disputed --> deprecated: superseded ``` Der Ü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. ## Funktionale Prädikate machen Konflikte exakt Manche 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`. Ein 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. ```json { "settings": { "functional_predicates": [ "primary_database", "current_owner", "default_retention_days" ] } } ``` Diese 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. Zu 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. ## Einen Absatz in Claims zerlegen Ein 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. ```json [ { "subject": "service-runtime", "predicate": "distribution", "object": "single self-contained binary" }, { "subject": "service-runtime", "predicate": "database_migration", "object": "runs automatically on startup" }, { "subject": "service-runtime", "predicate": "exposes_probe", "object": "/health and /ready" } ] ``` Wenn 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. Die 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. Auch 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. Ein 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. ## Ein Konzept ist mehr als seine Claims Claims 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. Die Claims erfüllen eine andere Aufgabe. Sie erlauben: - eine einzelne Aussage bis zum Originalzitat zurückzuverfolgen; - einen Widerspruch ohne Ablehnung der ganzen Seite zu markieren; - bei einer neuen Revision nur die tatsächlich veränderten Behauptungen zu prüfen; - Suchtreffer und Agentenantworten auf sichtbare, freigegebene Einheiten zu begrenzen. Seitentext 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. ## Beziehungen verbinden, Claims belegen Neben 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. ```json { "from_slug": "content-release", "kind": "depends_on", "to_slug": "immutable-revision" } ``` Die 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. `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. ## Revisionen beantworten die Zeitfrage Ein 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. Diese 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. Die 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. Fü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. ## Ein Schritt über „Sauberes Grounding II“ hinaus [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. Hinzu 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“. Der ä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. ## Claims im Review prüfen Bei 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? Wenn 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. Strukturiert 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. ## Weiterführende Quellen - [W3C RDF 1.1 Concepts](https://www.w3.org/TR/rdf11-concepts/) - [W3C PROV-O](https://www.w3.org/TR/prov-o/) - [Sauberes Grounding II](https://www.mikebild.dev/de/blog/sauberes-grounding-antworten-erden/) - [Fuzzy Matching mit expliziten Grenzen](https://www.mikebild.dev/de/blog/fuzzy-matching-mit-grenzen/) --- # Produkte berichten über ihre API URL: https://www.mikebild.dev/de/blog/produkte-berichten-ueber-ihre-api/ Datum: 2026-07-19 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. Ü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. Diese 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. ## Datenbesitz ist mehr als Speicherort Wer 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. Direktes 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. Mit einer Produkt-API liegt die fachliche Verdichtung dort, wo auch die Zustandsänderung implementiert ist: :::process{title="Fachliche Verdichtung bleibt im Produkt" role="primary" preferredPattern="connected-process"} 1. Produktaktion · erzeugt einen fachlich definierten Zustandsübergang 2. Eigener Datenspeicher · bewahrt den fachlich maßgeblichen Zustand des Produkts 3. Aggregation · übersetzt interne Zustände in begrenzte fachliche Kennzahlen 4. Statistik-API · liefert authentifizierte Werte mit Zeitraum und Bedeutung 5. Collector · führt kompatible Antworten zusammen, ohne sie neu zu definieren 6. Report · erklärt den eingefrorenen gemeinsamen Stand ::: Der 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. ## Einheitlich ist die Zeit, nicht die Fachlichkeit Ein 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: - `bucket` bestimmt die Auflösung: Stunde, Tag, Monat oder Jahr. - `from` ist der eingeschlossene Anfang des Fensters. - `to` ist das ausgeschlossene Ende. - `tz` macht die Zeitzone ausdrücklich, hier durchgehend UTC. - `buckets` enthält eine dichte Reihe einschließlich leerer Intervalle. - `totals` fasst genau das angefragte Fenster zusammen. Die 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. ```json { "bucket": "hour", "tz": "UTC", "from": "2026-07-18T10:00:00.000Z", "to": "2026-07-18T12:00:00.000Z", "buckets": [ { "ts": "2026-07-18T10:00:00.000Z", "items_created": 2 }, { "ts": "2026-07-18T11:00:00.000Z", "items_created": 0 } ], "totals": { "items_created": 2 } } ``` Die 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. ## Gemessene Null, fehlende Beobachtung, unvollständiges Fenster Eine 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. Davon 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. Ein 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: ```json { "metric": "engagement_rate", "value_kind": "ratio", "value_state": "missing" } ``` Fü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. ## Datenschutz beginnt bei der Antwortform Aggregate 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. Eine 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. Auch 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. ```http GET /v1/sites/example/stats/releases?bucket=hour&from=2026-07-18T10:00:00Z&to=2026-07-18T11:00:00Z Authorization: Bearer traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 ``` ## Zero-config heißt: vorhandene Begriffe verwenden Vollstä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. Die 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. OpenTelemetry 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. Die 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. ## Begrenzte Queries statt versteckter Last Eine 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. Diese 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. Dasselbe 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. ## Bestehende Schlüssel statt einer Hintertür Ein 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. Die 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. Fü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`. ## PostgreSQL ist intern, nicht verboten Die 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. Wichtig 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. ## Dokumentation gehört zum Vertrag Ein 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. Darum 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. ## APIs machen Austausch möglich Der 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. Ein 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. Das 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. ## Was sich seit damals geändert hat Der 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. Neu 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. ## Weiterführende Quellen - [W3C Trace Context](https://www.w3.org/TR/trace-context/) - [OpenTelemetry Service Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/resource/service/) - [OpenTelemetry Deployment Attributes](https://opentelemetry.io/docs/specs/semconv/registry/attributes/deployment/) - [HTTP-APIs: klein, stabil, lesbar](https://www.mikebild.dev/de/blog/http-apis-klein-stabil-lesbar/) --- # Vom Organigramm zur Produktverantwortung URL: https://www.mikebild.dev/de/blog/vom-organigramm-zum-arbeitsfluss/ Datum: 2026-07-18 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. KI 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. ## Der Arbeitsfluss vom Kundenproblem bis zur Wirkung Mit 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. ```mermaid flowchart LR S[Kundensignal] --> H[Hypothese und Plan] H --> E[Entwurf und Umsetzung] E --> Q[Review und Abnahme] Q --> R[Release] R --> W[Wirkung beobachten] W --> S A[KI: Kontext, Varianten,
Tests und Dokumentation] --> H A --> E A --> Q ``` Die 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. KI 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. ## Übergaben verkürzen sich nicht automatisch Meine 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. Der [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. Zusammengefü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. Das 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. ## Vertikale Schnitte als überprüfbare Hypothese Vertikale 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. Bereits 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. ```mermaid flowchart TD P[Produktproblem] --> V[Vertikaler Schnitt] V --> U[Oberfläche] V --> F[Fachlogik] V --> D[Daten und Verträge] V --> B[Betrieb und Beobachtung] U --> X[Ein überprüfbares Ergebnis] F --> X D --> X B --> X K[KI: Abhängigkeiten und
Vorschläge sichtbar machen] --> V ``` Kleinere 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. Ein 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. ## Technologie wird wieder zur Option Viele 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. KI 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. Eine brauchbare Untersuchung hält dabei mehrere Möglichkeiten offen: - Was ist die kleinste Änderung, mit der wir etwas über das Kundenproblem lernen? - Welche vorhandenen Systeme, Daten und Verträge müssen wir wirklich verstehen? - Welche Annahme übernimmt die KI, und wie lässt sie sich prüfen? - Welche Wirkung erwarten wir nach dem Release, und woran merken wir das Gegenteil? Die 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. ## Erstellen wird billiger, Review und Abnahme werden wichtiger Das 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. Review 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. ```yaml review_focus: product_question: "Does this solve the observed customer problem?" domain_question: "Which business rule could make this wrong?" technical_question: "Which contract or dependency can break?" quality_signal: "What evidence would change our decision?" release_check: "Can we observe the result and recover safely?" ``` Dieses 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. Die 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. ## Verantwortung technisch verankern Wenn 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. Eine 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. Eine 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. Die Ü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. ## Ein vollständiger Schnitt als Test Die 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. ```yaml vertical_slice: customer_problem: "Describe the observed problem" smallest_change: "Deliver one useful outcome" ai_support: - "Find relevant context" - "Prepare tests and documentation" human_review: - "Validate domain assumptions" - "Accept quality and release risk" outcome_signal: "Measure customer use and failures" ``` Danach 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. KI 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. ## Weiterführende Quellen - [NBER: Generative AI at Work](https://www.nber.org/papers/w31161) - [KI im Enterprise-Umfeld: Schnellere Entwicklung braucht andere Prozesse](https://www.mikebild.dev/de/blog/ki-im-konzern-schnellere-entwicklung-andere-prozesse/) --- # llms.txt: Doku für Maschinen URL: https://www.mikebild.dev/de/blog/llms-txt-und-llms-full-txt/ Datum: 2026-07-15 `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. Es 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. ## Zwei Dateien, ein Problem Den 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. Die Antwort besteht aus zwei Bausteinen, die man sauber auseinanderhalten sollte. `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: ```markdown # Orderflow API > Orderflow is a REST API for creating, tracking, and refunding orders. > These docs are written for LLMs and coding agents. Every write endpoint accepts an `Idempotency-Key` header, so retries stay safe. ## Core Documentation - [Quick Start](https://docs.example.com/quickstart.md): Authenticate and create your first order - [API Reference](https://docs.example.com/api.md): Endpoints, parameters, and error codes - [Webhooks](https://docs.example.com/webhooks.md): Event types and signature verification ## Optional - [Changelog](https://docs.example.com/changelog.md): Version history and breaking changes ``` `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. Ergä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. Wichtig 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. ```mermaid flowchart TD A[Coding-Agent] -->|kennt llms.txt| B[llms.txt
kuratierter Index] B --> C{Welche Seite
brauche ich?} C -->|gezielt| D[seite.md
saubere Markdown-Fassung] C -->|alles auf einmal| E[llms-full.txt
gesamter Inhalt] A -.->|Alternative ohne llms.txt| F[HTML-Seite crawlen
Navigation, Werbung, JS] F -.->|viel Rauschen| G[Kontextfenster voll,
Inhalt verwässert] ``` ## Warum das etwas bringt Beim 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. `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. Der 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. ## Wo es sich lohnt Fü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. Die 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. Fü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. ## Aus der Quelle erzeugt, nicht von Hand gepflegt Ein 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. Deshalb 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. Das 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. ## Was ein MCP-Server daraus macht Bis 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. `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. Ein 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. Erstens 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“. Zweitens 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. Drittens 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. Kommt 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. ```mermaid flowchart LR H[Agent-Host
Cursor, Claude Code] --> M[MCP Docs-Server
z. B. mcpdoc] M -->|Quellen auflisten| L[llms.txt
registrierte Indizes] M -->|Seite nachladen| P[nur die benötigte
.md-Seite] P --> H ``` Besonders `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. ## Wo es hakt `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. Die 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. Und 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. ## Fazit `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. Daraus 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. ## Weiterführende Quellen - Der ursprüngliche Vorschlag und die Formatbeschreibung: [llmstxt.org](https://llmstxt.org/) - Repository zum Vorschlag: [AnswerDotAI/llms-txt](https://github.com/answerdotai/llms-txt) - MCP-Doku-Server, der `llms.txt` als Quelle liest: [langchain-ai/mcpdoc](https://github.com/langchain-ai/mcpdoc) - Model Context Protocol: [modelcontextprotocol.io](https://modelcontextprotocol.io/) --- # Hilfsmittel, die tragen: Konzepte, Methoden, Prozesse URL: https://www.mikebild.dev/de/blog/hilfsmittel-konzepte-methoden-prozesse/ Datum: 2026-07-15 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. *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/).* Die [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. ## Konzepte: nichts davon ist neu Wer 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. Neu 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. Dass 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. Fü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. ## Methode: erst die Regeln inventarisieren Die 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? Die 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: ```yaml # rule inventory, one entry per rule - rule: "Credit notes above 500 EUR require team lead approval" source: wiki, AGENTS.md violation_impact: money leaves the company violated_recently: twice in Q2 decision: enforce # goes into the path - rule: "Use the customer number, not the invoice number, in replies" source: onboarding notes violation_impact: confusion, no damage violated_recently: unknown decision: prose # stays a convention ``` Fü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. Die 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. ## Methode: die erste Domäne klein schneiden Die 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. Woran man einen tauglichen ersten Kandidaten erkennt: - Der Vorgang hat einen klaren Anfang und ein klares Ende – kein Dauerzustand, kein Sammelbegriff. - Eine Handvoll Verben genügt: Spätestens wenn die Tool-Liste beim Modellieren zweistellig wird, würde ich den Schnitt erneut prüfen. - Es gibt heute schon eine fachlich zuständige Person – die Rolle, die auch ohne KI freigibt und entscheidet. - Der Schmerz ist dokumentiert: Verstöße sind in der Vergangenheit tatsächlich passiert und haben nachweislich geärgert. - Das Schadenspotenzial rechtfertigt den Aufwand, bleibt aber überschaubar – Gutschriften ja, Zahlungsverkehr mit dem Ausland vielleicht erst im zweiten Jahr. Der 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. Zum 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. Der 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. ## Prozesse: Besitzer, Änderungsweg, Rhythmus Bleibt 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. Erstens: 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. Zweitens: 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. Drittens, 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. ```mermaid flowchart LR I["Inventur
Regeln sammeln und bewerten"] --> K["Einordnung
Pfad oder Prosa"] K --> B["Bauen
Verben · Prüfung · Freigabe"] B --> O["Betrieb
Ausführungen und Ablehnungen"] O --> S["Sichtung im Rhythmus
aus den Neins lernen"] S --> I ``` Zusammen 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. ## Moden, die man auslassen kann Und die Gegenliste? Vier Muster halte ich für Aufmerksamkeitsfresser, die weder Prüfung noch Freigabe oder Nachweis verbessern – das ist ein Urteil, kein Messergebnis. Das 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. Die 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. ## Zwischenstand Damit 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. Vor 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? ## Weiterführende Quellen - [Domain-Driven Design Reference (Eric Evans)](https://www.domainlanguage.com/ddd/reference/) – ubiquitous language und Bounded Context als Entwurfsarbeit - [John Gall: Systemantics](https://en.wikipedia.org/wiki/John_Gall_%28author%29) – warum funktionierende komplexe Systeme aus einfachen funktionierenden Systemen entstehen - [Martin Fowler: Strangler Fig Application](https://martinfowler.com/bliki/StranglerFigApplication.html) – das Muster für Übergänge ohne großen Umstellungstag - [Building Effective AI Agents (Anthropic)](https://www.anthropic.com/engineering/building-effective-agents) – der Rat, mit dem einfachsten funktionierenden Aufbau zu beginnen --- # KI-Agenten pro Fachdomäne: Regeln, Freigaben und Nachweise URL: https://www.mikebild.dev/de/blog/eigener-agent-pro-fachdomaene/ Datum: 2026-07-14 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. Ein 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. *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/).* Das 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. Diese 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. ## Ein Tool sieht den Aufruf, nicht den Vorgang Zur 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. In 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. Der 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. Die 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. Das 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. Was 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. ## Vom Vorschlag zum Nachweis: fünf Stationen Wie 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. Auf Agenten übertragen umfasst der Weg von der Idee zur Wirkung fünf Stationen. Am 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. ```mermaid flowchart LR M["Modellvorschlag
textuell oder strukturiert, unverbindlich"] --> P["Plan
alle geplanten Operationen"] P --> R{"fachliche Autorisierung
Regeln der Fachdomäne"} R -- "verletzt" --> N["Ablehnung
begründet und protokolliert"] R -- "freigabepflichtig" --> H["menschliche Freigabe"] R -- "zulässig" --> D{"gebundener Zustand
noch aktuell?"} H -- "bestätigt" --> D H -- "abgelehnt" --> A D -- "ja" --> E["Ausführung"] D -- "nein" --> R E --> A["Entscheidungs- oder Ausführungsnachweis
wer · was · wann · Regelversion"] N --> A ``` Im 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. ## Ein Nein, das ein Nein bleibt Die 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. Dafü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. Skizziert 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. ```ts const issueCreditNote = defineTool({ name: "issue_credit_note", input: z.object({ invoiceId: z.string(), amount: z.number().positive(), reason: z.enum(["complaint", "goodwill", "pricing_error"]), }), approval: { required: ({ input, history }) => input.amount + history.totalCreditedFor(input.invoiceId) > 500, approvers: ["billing_team_lead"], }, audit: { record: ["user", "input", "approval", "result"] }, }); ``` Die 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. ## Nachweis statt Erinnerung Die 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. Ein 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. ## Warum pro Fachdomäne Bis 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? Weil 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. Dazu 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. Und 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. An 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. ## Was der eigene Agent können muss Damit lässt sich die Frage aus der letzten Folge kompakt beantworten. Ein Agent, der eine Fachdomäne verbindlich vertreten soll, braucht: - Tools in der Fachsprache der Domäne – typisiert, validiert, autorisiert - die Trennung von Modellvorschlag und Wirkung, mit Prüfung des ganzen Plans statt einzelner Aufrufe - Regeln über Vorgänge: Summen, Häufigkeiten, Zusammenhänge über Aufrufe und Sessions hinweg - Freigaben im Pfad, außerhalb des Gesprächs, durch Rollen der Fachdomäne - Nachweis jeder Ausführung und jeder Ablehnung – mit Planrevision, Regelrevision, Freigabeentscheidung, Aufrufer, Ergebnis und Fehlerstatus - die Rechte des jeweiligen Aufrufers statt eigener Allmacht – eng geschnitten, domänenbezogen - kuratierte Wissensquellen der Domäne, mit Besitzern und Pflege Auf 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. Zu 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. ## Zwischenstand Der 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. Was 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. ## Was SubKit dazu beiträgt [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. Das 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/). ## Weiterführende Quellen - [Terraform: plan und apply](https://developer.hashicorp.com/terraform/cli/commands/plan) – die Trennung von Vorschlag und Wirkung im Infrastruktur-Alltag - [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 - [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 - [Domain-Driven Design Reference (Eric Evans)](https://www.domainlanguage.com/ddd/reference/) – Fachsprache und Domänenschnitt als Entwurfsarbeit --- # MCP-Sicherheit: Tools, Rechte und Freigaben für KI-Agenten URL: https://www.mikebild.dev/de/blog/mcp-softwarefaehigkeiten-agenten/ Datum: 2026-07-13 # MCP-Sicherheit: Tools, Rechte und Freigaben für KI-Agenten Ein 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? Das 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. Zur Einordnung der Schnittstellen findest du hier die [Unterschiede zwischen MCP, HTTP, Webhooks und A2A](/de/blog/mcp-http-webhooks-a2a-einfach-erklaert/). ## Nicht die App, sondern die Fähigkeit Das 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. Das 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. Der klassische Bedienpfad sieht so aus: ```mermaid flowchart LR Human[Mensch] --> UI[Benutzeroberfläche] UI --> App[Anwendung] App --> Result[Ergebnis] ``` Der 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. Mit einem Agenten kommt ein zweiter Pfad hinzu: ```mermaid flowchart LR Human[Mensch] --> Agent[Agent] Agent --> Validate[validate_markdown] Agent --> Preview[create_preview] Agent --> Publish[publish_revision] Agent --> Rollback[rollback_release] Agent --> Search[rebuild_search_index] ``` Die 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. Das 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. ## Ein Publishing-System als Beispiel Das 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. In 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. Als MCP-Fähigkeit sähe derselbe Arbeitsschritt anders aus: ```ts const publishRevision = defineTool({ name: "publish_revision", description: "Publish an approved content revision", inputSchema: { type: "object", properties: { site: { type: "string" }, revisionId: { type: "string" }, reason: { type: "string" } }, required: ["site", "revisionId", "reason"] } }); ``` Der 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. Sobald 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. ## Drei Zugänge, eine Domäne Ein 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. Entscheidend 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. Das 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. Diese 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. ## Tool-Zugriff ist eine Berechtigung Ein 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. Die 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. In einem produktiven System reicht deshalb eine Tool-Definition nicht aus. Daneben gehört eine Policy: ```ts type ToolPolicy = { tool: string; allowedRoles: string[]; requiresApproval: boolean; requiresIdempotencyKey: boolean; maxRuntimeMs: number; }; type ToolAuditEntry = { tool: string; inputHash: string; idempotencyKey?: string; decision: "allowed" | "blocked" | "approved"; outcome: "succeeded" | "failed" | "unknown"; actorId: string; runId: string; }; ``` Damit 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. MCP 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. ## Prompt Injection: Inhalte dürfen keine Rechte vergeben Ein 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. Auch 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. Fü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). Für das Publishing-Beispiel würde ich vor dem Betrieb mindestens folgende Fälle prüfen: - Ein Nutzer ohne Veröffentlichungsrecht ruft das Tool direkt auf: Der Server lehnt ab, auch wenn der Tool-Name bekannt ist. - Eine Freigabe gilt für Revision A, der Aufruf nennt Revision B: Der Server lehnt ab. - Ein Dokument verlangt den Versand an eine fremde Adresse: Die konfigurierte Grenze bleibt wirksam. - Ein Aufruf läuft in einen Timeout: Vor einem erneuten Schreibversuch wird sein Ergebnis geklärt oder die Wiederholung wirksam dedupliziert. - Eine fehlgeschlagene oder abgelehnte Operation erscheint im Verlauf mit ihrem tatsächlichen Ergebnis. Das ist eine Prüfliste für dieses Architekturbeispiel, kein vollständiger Sicherheitsnachweis. ## Der falsche und der richtige Schnitt Ich 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. Eine 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. Ein 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. ## Was mit Bedienoberflächen passiert Die stärkste Version der Zukunftsthese lautet: Wenn Agenten Software bedienen, werden GUIs zweitrangig. Ich halte das für falsch. Routinepfade 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. Aber 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. Kontrolloberflä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. ## Was ich daraus mitnehme MCP 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. Das 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. Nicht 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. ## Wo SubKit dazu passt Bei [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. MCP 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. Das 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/). :::note **Einordnung und Quellenstand** Das 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. ::: ## Weiterführende Quellen - [Model Context Protocol: Spezifikation 2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18) - [Model Context Protocol: Security Best Practices](https://modelcontextprotocol.io/docs/2025-11-25/tutorials/security/security_best_practices) - [Model Context Protocol: Einführung](https://modelcontextprotocol.io/docs/getting-started/intro) - [SubKit: Workflows, Freigaben und Betrieb](https://subkit.mikebild.dev/de/) --- # Agent, Modell, Fachdomäne URL: https://www.mikebild.dev/de/blog/agent-modell-fachdomaene/ Datum: 2026-07-13 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. *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/).* Die [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. ## Ein Agent ist Software Was 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. Da 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. Jedes 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. ## Was man mietet und was man kontrollieren kann Diese Arbeitsteilung lohnt einen genauen Blick, weil sie ökonomisch zwei völlig verschiedene Dinge beschreibt. Ein 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. Der 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. ```mermaid flowchart TB subgraph rented["gemietet – austauschbar"] M["Modell
Vorschläge · Klassifikation · Sprache"] end subgraph owned["gestaltbar – unter eigener Kontrolle"] A["Agent
Schleife · Kontext · Zustand · Rechte"] W["Tools und Schnittstellen
typisierte Operationen"] F["Fachdomäne
Begriffe · Regeln · Abläufe"] A --> W W --> F end A -- "fragt an" --> M M -- "schlägt Schritte vor" --> A ``` Wenn 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? ## Variante A: die Fachdomäne als Prosa Der 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.“ Ich 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. Aber 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. Prosa skaliert mit der Menge des Wissens. Sie skaliert nicht mit dessen Verbindlichkeit. ## Variante B: die Fachdomäne als Schnittstelle Die 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: ```ts const issueCreditNote = defineTool({ name: "issue_credit_note", description: "Issue a credit note for an existing invoice", input: z.object({ invoiceId: z.string(), amount: z.number().positive(), reason: z.enum(["complaint", "goodwill", "pricing_error"]), }), authorize: ({ user, input }) => input.amount <= 500 || user.hasRole("team_lead"), execute: (input, ctx) => billing.issueCreditNote(input, ctx.audit), }); ``` Der 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. Das 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. Die 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. ## Die Gegenrechnung So weit die Chance. Zur selben Rechnung gehören Lock-in, Pflegeaufwand, Verantwortung und Übersteuerung. Da 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. Dann 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. Drittens 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. Und 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. ## Wann generische Tools reichen – und wann nicht Fü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: - Die Arbeit ist explorativ: analysieren, prototypisieren, recherchieren, migrieren – Aufgaben, deren Ergebnis ohnehin ein Mensch begutachtet. - Es arbeitet eine Einzelperson, die das Ergebnis selbst verantwortet und selbst beurteilen kann. - Das Schadenspotenzial ist klein: alles reversibel, nichts verlässt das Haus, kein Geld bewegt sich. - Das Fachwissen ist noch im Fluss – es wäre schlicht zu früh, es in Schnittstellen zu gießen. Umgekehrt gehört die Fachdomäne in den Agenten, sobald sich das Bild dreht: - Ein Ablauf wiederholt sich im Team: dieselbe Operation, viele Personen, unterschiedliche Sorgfalt und Erfahrung. - Regeln sind verbindlich, nicht empfohlen – sie müssen auch am Freitagnachmittag um 17 Uhr gelten. - Es gibt Audit- oder Nachweispflichten: Wer hat wann was ausgelöst, mit welcher Begründung? - Das Schadenspotenzial ist hoch: Geld, personenbezogene Daten, Wirkung nach außen. Dazwischen 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. ## Zwischenstand Fü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. Offen 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. ## Weiterführende Quellen - [Building Effective AI Agents](https://www.anthropic.com/engineering/building-effective-agents) – Anthropics Unterscheidung von Workflows und Agenten - [Writing effective tools for AI agents](https://www.anthropic.com/engineering/writing-tools-for-agents) – Tool-Design für Agenten aus der Praxis - [Designing agentic loops](https://simonwillison.net/2025/Sep/30/designing-agentic-loops/) – Simon Willison über Schleifen, Tools und Stoppbedingungen - [Model Context Protocol](https://modelcontextprotocol.io/) – Spezifikation und Dokumentation des Tool-Protokolls - [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 --- # Wissen, das die Session überlebt URL: https://www.mikebild.dev/de/blog/wissen-das-die-session-ueberlebt/ Datum: 2026-06-23 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. *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/).* Das 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. Denn 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. Die 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? ## Zwei Sorten von Wissen Um darüber vernünftig zu reden, hilft mir eine Unterscheidung, die ich lange nur implizit mit mir herumgetragen habe: kuratiertes Wissen und emergentes Wissen. Kuratiertes 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. Emergentes 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. Das 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. ## Wissen wie Code behandeln Das 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. Das 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: - 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. - 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. - 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?“. - 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. Der 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? ## Der Kreislauf Wenn 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. Erstens: 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. Zweitens: 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. Drittens: 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. Viertens: 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. ```mermaid flowchart LR A["Echte Läufe
und Erfahrungen"] --> B["Beobachten und
Rohmaterial sammeln"] B --> C["Mensch kuratiert,
Review gibt frei"] C --> D["Destillat im Grounding
der nächsten Session"] D --> A ``` Das 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. ## Nicht das Modell wird schlauer Die 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. Am 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. Wer 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. ## Die Anti-Pattern Was ich an Versuchen gesehen habe, Session-Wissen festzuhalten, ist überwiegend gescheitert. Und zwar auf drei wiedererkennbare Arten. Die 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. Die 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. Das 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. Allen drei Mustern fehlt dasselbe: der Rückweg von der echten Arbeit ins Wissen. Sie behandeln Aufschreiben als Event statt als Kreislauf. ## Die Chance, die Arbeit kostet Und 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. Das ä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. Ich 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. Was 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. ## Weiterführende Quellen - [AGENTS.md – das offene Format für Agenten-Instruktionen](https://agents.md/) - [Anthropic: Memory Tool – Wissen über Sessions hinweg](https://platform.claude.com/docs/en/agents-and-tools/tool-use/memory-tool) - [Claude Code: How Claude remembers your project](https://code.claude.com/docs/en/memory) - [Ikujiro Nonaka: The Knowledge-Creating Company (Harvard Business Review)](https://hbr.org/2007/07/the-knowledge-creating-company) - [Write the Docs: Docs as Code](https://www.writethedocs.org/guide/docs-as-code/) --- # Sauberes Grounding II: Antworten erden URL: https://www.mikebild.dev/de/blog/sauberes-grounding-antworten-erden/ Datum: 2026-06-03 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. *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/).* In 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? ## Mein Arbeitsmodell: drei Wissensschichten Wenn 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. ```mermaid flowchart TB subgraph Schichten["Drei Wissensschichten"] K["Konventionen
wie wir arbeiten
präskriptiv, kuratiert"] D["Dokumente
Runbooks, Specs, Entscheidungen
deskriptiv, alternd"] S["Schnittstellenbeschreibungen
OpenAPI, GraphQL-Introspection, Schemas
maschinell prüfbar"] end K --> E["Erdung beim Bauen
und Vorbereiten"] D --> E S --> E E --> A["Geprüftes Arbeitsergebnis
Dokument, Plan, Code, konfigurierter Ablauf"] ``` Die 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. Die 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. Die 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. Die drei Schichten beantworten unterschiedliche Fragen, und sie ersetzen einander nicht: - Konventionen sagen, **wie** etwas gebaut werden soll – Stil, Schnitt, Verantwortlichkeiten. - Dokumente sagen, **warum** die Dinge so sind, wie sie sind – Kontext und Geschichte. - Die jeweilige API-Beschreibung nennt Felder, Typen oder Endpoints. Ob die Implementierung diesem Stand entspricht und funktioniert, muss separat geprüft werden. Wer 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. ## Nachschlagen statt Raten Das 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. Wenn 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. Ein 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. Erkundende 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. ## Grounding wiederholbarer Abläufe gehört ans Bauen Das 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. Die Grafik oben zeigt diesen Fall für ein vorab prüfbares Artefakt. An 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. Schief 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. Deshalb 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. Das 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. ## Kontext-Ökonomie: was zuletzt fliegt Das 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. Fü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. Deshalb 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. ## Was Grounding nicht leistet Zur 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. Prü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. Eine 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. ## Weiterführende Quellen - [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 - [Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks](https://arxiv.org/abs/2005.11401) – das RAG-Originalpapier von Lewis et al. (2020) - [GraphQL Introspection](https://graphql.org/learn/introspection/) – wie ein GraphQL-Schema über sich selbst Auskunft gibt - [OpenAPI Specification](https://spec.openapis.org/oas/latest.html) – der Standard für maschinenlesbare HTTP-API-Beschreibungen - [Model Context Protocol](https://modelcontextprotocol.io/) – offener Standard, um KI-Werkzeugen echte Datenquellen und Systeme zugänglich zu machen --- # KI im Enterprise-Umfeld: Schnellere Entwicklung braucht andere Prozesse URL: https://www.mikebild.dev/de/blog/ki-im-konzern-schnellere-entwicklung-andere-prozesse/ Datum: 2026-05-19 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. Genau 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. Das 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. ## KI verstärkt den vorhandenen Arbeitsfluss Die 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. Die 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. In 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. ## Der Ticketbaum ist nicht das Produkt In 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. Daraus 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. KI 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. ```yaml change: id: customer-address-v2 outcome: reduce failed deliveries constraints: - preserve existing API clients - keep customer consent auditable owners: product: customer-experience technical: customer-platform evidence: - contract-tests - migration-report - rollback-plan risk: medium ``` Das 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. Der 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. ## Integration braucht Verträge statt mehr Abstimmung Komplexe 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. Beim Beispiel der geänderten Kundenadresse könnte das relevante Event als Schema vorliegen: ```json { "$id": "CustomerAddressChanged.v2", "type": "object", "required": ["customerId", "address", "consentId"], "properties": { "customerId": { "type": "string" }, "address": { "$ref": "Address.v2.json" }, "consentId": { "type": "string" } } } ``` Die 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. Entscheiden 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. ## Dokumentation wird abgeleitet, nicht abgeschafft Confluence 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. KI 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. ```mermaid flowchart LR Idea[Produktidee] --> Ticket[Ticket-Hierarchie] Ticket --> Docs[Separate Dokumentation] Docs --> Meetings[Abstimmungsrunden] Meetings --> Build[Große Umsetzung] Build --> Test[Gemeinsames Testfenster] Test --> Approval[Externe Freigabe] Intent[Versionierte Absicht] --> SmallChange[Kleine Änderung] SmallChange --> Evidence[Tests, Vertrag und Diff] Evidence --> Review[Peer Review] Review --> Release[Release] Release --> Feedback[Nutzer- und Betriebssignal] Feedback --> Intent ``` ## Agil wird man durch die Kosten einer Änderung Agilitä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. Ob 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. Die 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. ## Governance ohne pauschale Queue Kontrolle 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. ```yaml approval_policy: - risk: low requires: [tests, policy-checks, peer-review] decision: peer-approved - risk: medium requires: [tests, contract-checks, owner-review, rollback-plan] decision: peer-approved - risk: high requires: [security-review, compliance-review, business-owner, audit-record] decision: manually-approved ``` Auch 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. ```mermaid flowchart TD Change[Änderung mit Evidenz] --> Risk{Risikoklasse} Risk -->|niedrig| Guardrails[Automatische Guardrails] Guardrails --> Peer[Peer Review] Peer --> Release[Release] Risk -->|mittel| Owner[Fachlicher und technischer Owner] Owner --> Release Risk -->|hoch| Specialist[Security, Compliance und Business] Specialist --> Approval{Freigabe} Approval -->|ja| Release Approval -->|nein| Rework[Überarbeiten oder verwerfen] ``` Der 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. ## Die Organisation muss dem Arbeitsfluss folgen Ein 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: - langlebige, funktionsübergreifende Produktteams, die eine Wirkung von der Idee bis zum Betrieb verantworten und kleine Änderungen selbst freigeben können - eine interne Plattform, die sicheren Zugriff auf Code, Dokumentation, Testumgebungen und freigegebene KI-Modelle als Standardweg anbietet - Governance-, Security- und Architekturgruppen, die Regeln, Referenzlösungen und Ausnahmen gestalten, statt jede normale Änderung einzeln durchzuwinken Das 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. Für die beteiligten Rollen verschiebt sich der Schwerpunkt: | Rolle | Neuer Schwerpunkt | | --- | --- | | Entwickler | Aufgaben schneiden, Kontext bereitstellen, generierte Änderungen prüfen, Tests und Betriebssignale entwerfen | | Architekten | Systemverträge, automatisierte Architekturregeln, Rückbaubarkeit und sichere Standardwege gestalten | | Product Owner | Outcomes, Akzeptanzbeispiele und Nutzerfeedback schärfen, statt den Ticketbestand zu verwalten | | Projektmanagement | Abhängigkeiten, Risiken, Wartezeiten und Work in Progress verringern, statt Tätigkeiten im Detail vorzuplanen | Keine 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. ## Klein anfangen und den Gesamtzyklus messen Ein 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? Für einen ersten Versuch reichen wenige Schritte: - ein versioniertes Änderungsartefakt und die führenden Quellen festlegen - der KI nur lesenden Zugriff geben und ihren Kontext nachvollziehbar protokollieren - Tests, Contract Checks und Dokumentations-Diffs automatisch als Evidenz erzeugen - zunächst ausschließlich kleine, reversible Änderungen durch den neuen Pfad schicken - Lead Time, Wartezeit, Zahl der Übergaben, Nacharbeit, Change Fail Rate und Zeit bis zum Nutzerfeedback vergleichen Nicht 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. ## Fazit Die 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. Der 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. So 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. ## Weiterführende Quellen - [DORA – State of AI-assisted Software Development 2025](https://dora.dev/research/2025/dora-report/) - [DORA – Capability Catalog](https://dora.dev/capabilities/) - [DORA – Streamlining Change Approval](https://dora.dev/capabilities/streamlining-change-approval/) - [DORA – Working in Small Batches](https://dora.dev/capabilities/working-in-small-batches/) - [DORA – Repository](https://github.com/dora-team/dora.dev) - [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/) - [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/) - [METR – We Are Changing Our Developer Productivity Experiment Design](https://metr.org/blog/2026-02-24-uplift-update/) - [Atlassian – State of Developer Experience Report 2025](https://www.atlassian.com/teams/software-development/state-of-developer-experience-2025) - [Manifesto for Agile Software Development](https://agilemanifesto.org/) - [NIST – AI Risk Management Framework](https://airc.nist.gov/airmf-resources/airmf/5-sec-core/) - [Europäische Kommission – AI Literacy: Questions & Answers](https://digital-strategy.ec.europa.eu/en/faqs/ai-literacy-questions-answers) - [Verordnung (EU) 2024/1689 – Artificial Intelligence Act](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A32024R1689) --- # Sauberes Grounding I: Warum die KI so überzeugend rät URL: https://www.mikebild.dev/de/blog/sauberes-grounding-warum-ki-raet/ Datum: 2026-05-06 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. Die 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. *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/).* Wer 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. ## Das Modell kennt seine Wissensgrenze nicht zuverlässig Um 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. Ein 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. Deshalb 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. Zwei 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. ## Symbole, die auf Symbole verweisen Es 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. Ich 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. Man 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. ## Warum größere Modelle das Problem mildern, aber nicht lösen Der 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. Aber „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. ## Ohne interne Quellen bleiben organisationsspezifische Fakten Vermutung Und 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: - die internen Bibliotheken und Services mit ihren tatsächlichen Signaturen und Eigenheiten - die hauseigenen Konventionen – wie Fehler behandelt werden, wie Module geschnitten sind, was ein Deployment durchlaufen muss - die Begriffe der Fachdomäne, die im Unternehmen etwas Präzises bedeuten und außerhalb etwas anderes oder gar nichts Wie 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. Das 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. ## Grounding: interne Fakten im Arbeitskontext bereitstellen Fü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. Hier 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. Damit 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. ## Weiterführende Quellen - [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 - [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 - [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) - [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 --- # Vergebliche Versuche: Skills, AGENTS.md und die Grenze der Empfehlung URL: https://www.mikebild.dev/de/blog/vergebliche-versuche-instruktionsdateien/ Datum: 2026-04-15 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. *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/).* Das 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. Es 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. ## Erster Anlauf: eine Datei im Repo Der 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. Unsere erste Fassung sah ungefähr so aus, wie sie in vielen Repositories aussieht: ```markdown # AGENTS.md ## Build & Test - Install dependencies with `pnpm install` - Run `pnpm test` for unit tests, `pnpm test:e2e` for integration - Run `pnpm lint` before every commit ## Conventions - TypeScript strict mode, no `any` in new code - New endpoints follow the router structure in `src/api/` - Tests follow Arrange/Act/Assert, one behavior per test ## Hard rules - NEVER edit files in `migrations/` by hand - NEVER modify the production schema without a migration - Do not commit directly to `main` ``` Und 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. Die 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. ## Die weiteren Anläufe: lauter, modularer, prominenter Danach 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. Als 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. Zuletzt 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. ## Der Befund: Empfehlungen, keine Regeln Was 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. Das lässt sich an vier Mechanismen festmachen, die ich alle in echten Projekten beobachtet habe: - 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. - 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. - 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. - 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. Damit 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. ## Was die Dateien trotzdem leisten Instruktionsdateien 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. Entscheidend 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. Ich 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. ## Empfehlung und Regel sind zwei Kategorien Damit 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. Vorher 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. ## Weiterführende Quellen - [AGENTS.md – offizielle Seite der Konvention](https://agents.md/) - [InfoQ: AGENTS.md Emerges as Open Standard for AI Coding Agents (August 2025)](https://www.infoq.com/news/2025/08/agents-md/) - [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) - [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) - [Anthropic Engineering: Claude Code Best Practices (u. a. zu CLAUDE.md)](https://www.anthropic.com/engineering/claude-code-best-practices) - [Anthropic Engineering: Effective context engineering for AI agents](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents) --- # Mein Erfolg lässt sich nicht kopieren URL: https://www.mikebild.dev/de/blog/mein-erfolg-laesst-sich-nicht-kopieren/ Datum: 2026-03-04 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. *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/).* Zwei 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?“ Die 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. ## Erste Ebene: dieselbe Aufgabe, zwei Sessions, zwei Ergebnisse Die erste Ebene hat noch gar nichts mit meinem Kollegen zu tun. Ich kann sie allein an meinem eigenen Rechner vorführen. Ich 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. In 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. Fü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. ## Zweite Ebene: das Wissen, das in keiner Datei steht Die 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. Als 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: - 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. - 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. - 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. Aufgabenzuschnitt, 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. Die [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. ## Dritte Ebene: es gibt keinen identischen Durchlauf Die dritte Ebene wird gern übersehen, weil sie so banal klingt: Mein Kollege arbeitet an anderen Aufgaben als ich. Nicht 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. Das 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. ## Was Organisationen daraus regelmäßig falsch schließen Auf Organisationsebene können diese Unterschiede zu falschen Produktivitätsannahmen führen. Denn aus der Einzelbeobachtung „bei Mike funktioniert das“ liegen drei Fehlschlüsse nahe. Der 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. Der 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. Der 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. Ich 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. ## Die Varianz ist auch eine Lernquelle Das klingt jetzt düsterer, als ich es meine. Denn dieselbe Varianz, die das Kopieren verhindert, ist eine brauchbare Diagnosequelle für ein Team. Als 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. Meine 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. Mein 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. Und 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. ## Weiterführende Quellen - [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/) - [Google Cloud: Announcing the 2025 DORA Report](https://cloud.google.com/blog/products/ai-machine-learning/announcing-the-2025-dora-report) - [Stack Overflow Developer Survey 2025: AI](https://survey.stackoverflow.co/2025/ai/) --- # Was ich kritisch sehe URL: https://www.mikebild.dev/de/blog/was-ich-kritisch-sehe/ Datum: 2026-02-10 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. *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/).* In 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. ## Wer nur noch prüft, verlernt das Bauen Wenn 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. Die 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. Fü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. ## Plausibel ist nicht richtig Die 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. Ob Review und Vier-Augen-Prinzip gegen solche plausiblen Ausgaben systematisch schlechter greifen als gegen erkennbar schlampigen Code, kann ich nicht belegen. Passend 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. ## „Das hat die KI so gemacht“ ist keine Antwort Im 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. In 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. ## Schulden, die keiner aufgenommen hat Technische 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. Wenn 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. ## Wenn alle dasselbe Modell fragen, können Entwürfe ähnlicher werden Die 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. Man 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. ## Kritik heißt nicht Ablehnung An 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. Ich habe für mich ein paar Regeln aufgestellt. Keine Weisheiten, eher Hausregeln: - 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. - 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. - 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. - 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. Nichts 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. Genau 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. ## Weiterführende Quellen - [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/) - [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) - [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) --- # Delegieren statt Diktieren: der Harness macht den Unterschied URL: https://www.mikebild.dev/de/blog/harness-macht-den-unterschied/ Datum: 2026-01-21 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. *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/).* An 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. Als 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. Der 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. ## Die praktische Folgerung aus der These vom Dezember Im 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. Für dieses Drumherum hat sich im Englischen ein Wort etabliert, das ich seither ständig benutze: **Harness**. ## Was ein Harness ist Das 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. Technisch 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. Erstens **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. Zweitens **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. Drittens **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. Viertens – 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. Als Bild sieht die Schleife so aus: ```mermaid flowchart TD A[Aufgabe
Ziel, Kontext, Abnahmekriterien] --> B[Plan
Aufgabe in Schritte zerlegen] B --> C[Tool ausführen
lesen, ändern, suchen, testen] C --> D{Ergebnis prüfen
Tests grün? Fehler? Plan noch gültig?} D -->|Fehler oder Abweichung| E[Nachbessern
Fehlermeldung lesen, Schritt anpassen] E --> B D -->|Kriterien erfüllt| F[Fertig
Diff und Zusammenfassung an den Menschen] ``` Neu 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. ## Diktieren und Delegieren sind zwei verschiedene Tätigkeiten Im 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. Die 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. Das 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. Mit 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. ## Wo Delegation trägt Nach einigen Wochen mit dieser Arbeitsweise zeichnet sich für mich klar ab, welche Aufgaben sich gut delegieren lassen: - 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. - 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. - 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. - Routinearbeit mit klarer Vorlage: Boilerplate, Migrationsskripte, das Nachziehen eines Musters, das im Repository schon dreimal existiert, kleine Bugfixes mit reproduzierbarem Fehlerbild. Diese 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. ## Wo sie bricht In denselben Wochen sind mehrere delegierte Aufgaben gescheitert. Die Ursachen ähnelten sich. Lange 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. Gravierender 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. Und 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. ## Das Gehäuse verändert die Arbeit – und stellt die nächste Frage Die 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. Deshalb lautet meine Regel: nur Aufgaben mit klaren, mit vertretbarem Aufwand prüfbaren Abnahmekriterien delegieren und jeden Diff vor dem Merge prüfen. Je 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. ## Weiterführende Quellen - [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 - [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 - [OpenAI Codex CLI](https://github.com/openai/codex) – der quelloffene Terminal-Coding-Agent von OpenAI - [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 - [Simon Willison über Building Effective Agents](https://simonwillison.net/2024/Dec/20/building-effective-agents/) – lesenswerte Einordnung des Agenten-Begriffs --- # Gut genug: das Modell ist selten der Engpass URL: https://www.mikebild.dev/de/blog/gut-genug-modelle/ Datum: 2025-12-09 *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/).* Ende 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. Ich 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.“ ## Die Konvergenz, über die niemand gern spricht Nü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. Natü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. Das 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. ## Das Rauschen im Alltag ist größer als der Unterschied zwischen Modellen Wenn 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. Ein 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. In 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. ## Wer auf das beste Modell wettet, wettet auf eine Commodity Damit 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. Die 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. Was 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: - der Zuschnitt der Aufgaben, also die Fähigkeit, Arbeit so zu zerlegen, dass ein Stück davon delegierbar wird - der Kontext, den eine Session am Anfang bekommt, statt dass das Modell meine Realität erraten muss - das Wissen des Teams in abrufbarer Form, von Konventionen bis zu den Gründen hinter alten Entscheidungen - der Prozess, der regelt, wann die KI arbeitet, wann ein Mensch prüft und was in den Main-Branch darf - die Prüfung selbst, also verlässliche Wege festzustellen, ob ein Ergebnis stimmt, bevor es etwas bewirkt Diese 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. Es 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. ## Wo die Modellwahl doch zählt Ehrlichkeit 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. Erstens 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. Zweitens 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. Drittens 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. Aber – 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. ## Die erste Frage Zurü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. Er 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. ## Weiterführende Quellen - [Anthropic: Introducing Claude Opus 4.5](https://www.anthropic.com/news/claude-opus-4-5) - [Google: Gemini 3 – das bislang leistungsfähigste Modell von Google](https://blog.google/products/gemini/gemini-3/) - [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/) - [Anthropic Engineering: Effective context engineering for AI agents](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents) --- # Vom Werkzeug zur Arbeitsweise URL: https://www.mikebild.dev/de/blog/von-der-session-zum-system-auftakt/ Datum: 2025-11-18 *Auftakt der Serie „Von der Session zum System“.* Vor 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. Das 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. Dazu 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. In 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. ## Die ersten Tage Ich 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. Und dann passierte etwas, womit ich nicht gerechnet hatte: Ich war binnen Tagen sehr angetan. Nicht milde interessiert – angetan. Drei Dinge haben mich erwischt: - 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. - Die Produktivität über den ganzen Tag. Nicht ein einzelner beeindruckender Moment, sondern die Summe: Es blieb abends messbar mehr fertig liegen als vorher. - 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. Aus 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. Ein 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. ## Beobachtung eins: Erzeugen wird billig, Prüfen wird der Engpass Die 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. Im 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. ## Beobachtung zwei: Die Schubladen gehen wieder auf Die 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. Vielleicht 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. Ich 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. ## Beobachtung drei: Der Ton stimmt immer Die 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. ## Was diese Serie werden soll Ich 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. Die 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. ## Weiterführende Quellen - [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) - [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 - [Introducing upgrades to Codex](https://openai.com/index/introducing-upgrades-to-codex/) – OpenAIs Codex-Update mit GPT-5-Codex (September 2025) - [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) --- # React-Überblick: Von JSX zur Komponentenarchitektur URL: https://www.mikebild.dev/de/blog/react-ueberblick-von-jsx-zur-architektur/ Datum: 2024-12-30 # React-Überblick: Von JSX zur Komponentenarchitektur React 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. Dieser 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. Ich 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. ## Das mentale Modell: die Oberfläche ist eine Funktion des States Bevor 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. Man 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/). ```mermaid flowchart LR State["Zustand (state)"] --> Render["Komponente rendert"] Render --> View["beschriebene Oberfläche"] View --> DOM["React gleicht ab und schreibt ins DOM"] User["Nutzerinteraktion"] --> Event["Event / setState"] Event --> State ``` Der 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. ## JSX und Komponenten: die kleinste Einheit ist eine Funktion JSX sieht aus wie HTML im JavaScript, ist aber nur eine bequeme Schreibweise für Funktionsaufrufe. Ein `; } ``` Die 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. Interessant 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: ```mermaid flowchart TB Frage["Wem gehört dieser Zustand?"] Frage --> Lokal["Komponente
(useState) – UI-Detail"] Frage --> URL["URL
Filter, Seite, ausgewähltes Objekt"] Frage --> Server["Server-Cache
geladene Daten, die dem Backend gehören"] Frage --> Global["globaler Store
app-weite, geteilte Entscheidungen"] ``` Vieles, 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/). ## Effects sind der Rand, nicht die Mitte `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. ```jsx import { useState, useEffect } from "react"; function useInvoice(id) { const [data, setData] = useState(null); const [error, setError] = useState(null); useEffect(() => { let active = true; fetch(`/api/invoices/${id}`) .then((r) => r.json()) .then((d) => active && setData(d)) .catch((e) => active && setError(e.message)); return () => { active = false; }; }, [id]); return { data, error }; } ``` Zwei 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. Ich 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. Der 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. Fü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 ``-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/). ## Von HOCs und Render Props zu Custom Hooks Die 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. Frü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. [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: ```jsx function InvoiceView({ id }) { const { data, error } = useInvoice(id); if (error) return ; if (!data) return ; return ; } ``` Dieselbe 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/). ## Wenn geteilter State wirklich global ist: Redux und die Alternativen Manchmal 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. ```js function invoicesReducer(state = [], action) { switch (action.type) { case "invoice/paid": return state.map((i) => i.id === action.id ? { ...i, paid: true } : i ); default: return state; } } ``` Der 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. Heute 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/). Ich 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. ## Router, Server und die Ränder der Anwendung Zwei 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: ```jsx import { BrowserRouter, Routes, Route } from "react-router-dom"; function App() { return ( } /> } /> } /> ); } ``` Der 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. Der 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. ## Betrieb: Tests, Werkzeugkette, Typen Ein Ü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. Ich 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. ```jsx import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Counter } from "./Counter"; describe("Counter", () => { it("counts up on click", async () => { render(); const button = screen.getByRole("button", { name: "0" }); await userEvent.click(button); expect(screen.getByRole("button", { name: "1" })).toBeInTheDocument(); }); }); ``` Dahinter 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. Auch 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. ## Das Repository ist selbst schon Architektur Ein 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. Dass 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. ```mermaid flowchart TB Repo["React-Monorepo"] Repo --> Grund["Grundlagen
JSX, Komponenten, Hooks, Router"] Repo --> Fort["Fortgeschritten
async, funktional, Struktur"] Repo --> Geteilt["geteilte Bausteine
UI-Komponenten, Testhilfen"] Grund --> Geteilt Fort --> Geteilt ``` ## Was am Ende bleiben soll Von 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. Deshalb 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. ## Weiterführende Quellen - Repository introduction-react (Grundlagen): https://github.com/mikebild/introduction-react - Repository advanced-react (fortgeschrittene Muster): https://github.com/mikebild/advanced-react - Folienübersicht zu diesem Überblick: https://slides.mikebild.dev/introduction-react.html - React, offizielle Dokumentation (Thinking in React): https://react.dev/learn/thinking-in-react - React 19, Ankündigung der stabilen Version: https://react.dev/blog/2024/12/05/react-19 - React, State verwalten und teilen: https://react.dev/learn/managing-state --- # Formulare in React 19: Actions, useActionState und useOptimistic URL: https://www.mikebild.dev/de/blog/formulare-in-react-19-mit-actions/ Datum: 2024-12-23 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. Alles 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 `
`-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. ## Von onSubmit zu action Der Ausgangspunkt ist eine kleine, aber folgenreiche Änderung am ``-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. So sieht das im Vergleich aus. Vorher: ```jsx function NameForm() { const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); async function handleSubmit(event) { event.preventDefault(); setIsSaving(true); setError(null); const formData = new FormData(event.currentTarget); const result = await save(formData.get("name")); setIsSaving(false); if (result.error) setError(result.error); } return ( {error &&

{error}

}
); } ``` Und nachher, mit einer Client Action: ```jsx function NameForm() { async function updateName(formData) { await save(formData.get("name")); } return (
); } ``` Die 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. ## Wer übernimmt was Die drei Hooks klingen auf den ersten Blick ähnlich, haben aber sehr unterschiedliche Aufgaben. Deshalb vor dem Code die Zuständigkeiten: - `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. - `useFormStatus` liest den Pending State des nächstgelegenen umgebenden `
` – gedacht für Kindkomponenten wie einen Submit-Button, die nichts über die Action selbst wissen müssen, aber wissen wollen, ob gerade gesendet wird. - `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. Man 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. ## useActionState: Ergebnis und Pending an einer Stelle `useActionState` importiert man aus `react` – nicht aus `react-dom`, was eine häufige erste Fehlerquelle ist. Die Signatur: ```jsx const [state, formAction, isPending] = useActionState(action, initialState, permalink); ``` Der 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: ```jsx import { useActionState } from "react"; async function updateName(previousState, formData) { const name = formData.get("name"); const error = await save(name); if (error) { return { error }; } return { ok: true }; } function NameForm() { const [state, formAction, isPending] = useActionState(updateName, {}); return ( {state.error &&

{state.error}

} {state.ok &&

Saved.

} ); } ``` Was 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. Wenn 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. Ein 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. ## useFormStatus: Pending im Kind lesen Jetzt zum Submit-Button, den ich oben schon als `` 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. ```jsx import { useFormStatus } from "react-dom"; function SubmitButton() { const { pending } = useFormStatus(); return ( ); } ``` Der 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-`
`** und muss deshalb in einer Kindkomponente stehen, nicht in derselben Komponente, die das `` rendert. Steht 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. ## useOptimistic: den erwarteten State sofort zeigen Der 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: ```jsx import { useOptimistic, useActionState } from "react"; function Thread({ messages, sendMessage }) { const [optimisticMessages, addOptimistic] = useOptimistic( messages, (currentMessages, newText) => [ ...currentMessages, { text: newText, sending: true }, ] ); const [state, formAction] = useActionState( async (previousState, formData) => { const text = formData.get("message"); addOptimistic(text); const error = await sendMessage(text); if (error) { return { error }; } return { ok: true }; }, {} ); return (
    {optimisticMessages.map((message, index) => (
  • {message.text} {message.sending && (sending…)}
  • ))}
{state.error &&

{state.error}

}
); } ``` `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. Das 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. ## Der Fallstrick: optimistisch ohne Fehlerpfad Die 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. Stell 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. Der 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: - `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. - 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. Bei 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. Im Code oben passiert genau das: Die Action gibt im Fehlerfall `{ error }` zurück, und `{state.error &&

…

}` 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. ## Das Zusammenspiel im Bild Wenn man die vier Beteiligten – das Formular, die Action, den Pending State und den optimistischen Wert – zeitlich anordnet, wird das Muster deutlich: ```mermaid flowchart LR A["form action(fn)
Submit ausgelöst"] --> B["isPending = true
Button disabled
via useFormStatus"] B --> C["addOptimistic()
erwarteter Zustand
sofort sichtbar"] C --> D["await save()"] D -->|Erfolg| E["realer State
state.ok
Formular-Reset"] D -->|Fehler| F["optimistisch revertiert
state.error sichtbar"] ``` Der 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. Das zweite Diagramm ordnet noch einmal die Zuständigkeiten, weil die Verwechslungsgefahr zwischen den dreien real ist: ```mermaid flowchart TB subgraph Form["React 19 Formular"] UAS["useActionState
Aufruf + Ergebnis + Pending"] UFS["useFormStatus
liest Pending des Eltern-Forms
im Kind"] UO["useOptimistic
sofortiger Zwischenzustand
mit Auto-Konvergenz"] end UAS -->|reicht isPending
und state| UI["Fehler- und Erfolgs-Anzeige"] UFS -->|pending| Button["SubmitButton"] UO -->|optimisticState| Liste["Live-Liste"] ``` ## Ein Wort zu Server Functions Bisher 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: ```js "use server"; export async function updateName(previousState, formData) { const name = formData.get("name"); await db.users.update({ name }); return { ok: true }; } ``` Diese Funktion lässt sich exakt wie eine Client Action an `useActionState` oder direkt an `
` ü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. Ein 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. ## Was bleibt hängen Unterm 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. Die 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. ## Weiterführende Quellen - [React 19 Release-Blogpost (Actions-Überblick)](https://react.dev/blog/2024/12/05/react-19) - [`useActionState` – react.dev](https://react.dev/reference/react/useActionState) - [`useFormStatus` – react.dev](https://react.dev/reference/react-dom/hooks/useFormStatus) - [`useOptimistic` – react.dev](https://react.dev/reference/react/useOptimistic) - [`` – react.dev](https://react.dev/reference/react-dom/components/form) - [`"use server"` / Server Functions – react.dev](https://react.dev/reference/rsc/use-server) - [`useTransition` (Hintergrund zu Actions/Transitions) – react.dev](https://react.dev/reference/react/useTransition) --- # React Server Components und Server Actions: Die neue Grenze URL: https://www.mikebild.dev/de/blog/react-server-components-und-server-actions/ Datum: 2024-12-19 # React Server Components und Server Actions: Die neue Grenze ```text Error: You're importing a component that needs `useState`. This React Hook only works in a Client Component. To fix, mark the file (or its parent) with the "use client" directive. ``` Diese 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. Denn 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. **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. Daran 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. ## Was eine Server Component wirklich ist Eine 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. Das 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. ## Der Unterschied zu SSR, den fast alle verwechseln Ein 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. Klassisches 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. Eine 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. ```mermaid flowchart TB subgraph Server SC["Server Component
liest Daten direkt"] end subgraph Browser UI["fertiges Ergebnis
wird angezeigt"] Island["Client-Insel
(Hooks, Events)"] end DB[("Datenbank / Service")] SC -->|await| DB SC -->|nur Ergebnis,
kein Code| UI SC -.->|bettet ein| Island ``` ## Die Grenze heißt `"use client"` Wenn 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`. Wichtig 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. ```mermaid flowchart TB Page["Server Component (Seite)
lädt Daten, zeigt an"] Page --> List["Server Component
Rechnungsliste"] Page --> Button["Client-Insel
"use client": Button mit onClick"] Button -->|darf als children| Slot["Server-Inhalt
als Slot"] ``` ## Server Components konkret Weil 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. ```jsx // invoices-page.jsx — a Server Component, no "use client" here import { db } from "@/lib/db"; import { PayButton } from "./pay-button"; export default async function InvoicesPage() { const invoices = await db.invoice.findMany({ where: { open: true } }); return (

Open invoices: {invoices.length}

    {invoices.map((invoice) => (
  • {invoice.title} — {invoice.amount} €
  • ))}
); } ``` Zwei 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"`. ```jsx // pay-button.jsx "use client"; import { useState } from "react"; export function PayButton({ invoiceId }) { const [busy, setBusy] = useState(false); return ( ); } ``` Was 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. ## Server Actions: das Schreiben zurück auf den Server Server 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 ``. 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. ```jsx // invoice-actions.js "use server"; import { db } from "@/lib/db"; import { revalidatePath } from "next/cache"; export async function payInvoice(formData) { const id = formData.get("invoiceId"); await db.invoice.update({ where: { id }, data: { open: false } }); revalidatePath("/invoices"); } ``` Aufgerufen 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. ```jsx // pay-form.jsx "use client"; import { useActionState } from "react"; import { useFormStatus } from "react-dom"; import { payInvoice } from "./invoice-actions"; function SubmitButton() { const { pending } = useFormStatus(); return ( ); } export function PayForm({ invoiceId }) { const [state, formAction] = useActionState(payInvoice, null); return ( ); } ``` Der 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. ```mermaid flowchart LR Form["Formular im Browser
action = payInvoice"] Form -->|Absenden| Action["Server Action
"use server""] Action -->|schreibt| DB[("Datenbank")] Action -->|revalidatePath| Fresh["Server rendert
betroffene Ansicht neu"] Fresh -->|neues Ergebnis| Form ``` ## Was das für die Serie bedeutet Diese 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. Das 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. ## Was die verschobene Grenze mit Test, Betrieb und Team macht Bis 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. Fangen 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. Beim 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. Und 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. Daraus 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“. ## Ehrlich eingeordnet: neu und mit Reibung Ich 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. Und 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. ## Die eigentliche Entscheidung Wenn 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. Wer 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. ## Weiterführende Quellen - Repository introduction-react (Kapitel server-components, server-actions; App-Router-Beispiele): https://github.com/mikebild/introduction-react - React, „Server Components“: https://react.dev/reference/rsc/server-components - React, „Server Functions“: https://react.dev/reference/rsc/server-functions - Next.js, App Router: https://nextjs.org/docs/app --- # React Router Data Router: Die URL als State-Ort URL: https://www.mikebild.dev/de/blog/react-router-data-router/ Datum: 2024-12-16 # React Router Data Router: Die URL als State-Ort Eine 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. Wie 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. **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. ## Die Kernidee: Die Route lädt, bevor sie rendert Der 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. Damit 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. ## `createBrowserRouter` und die Route-Objekte Man 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. ```jsx import { createBrowserRouter } from "react-router"; import { RouterProvider } from "react-router/dom"; const router = createBrowserRouter([ { path: "/invoices", element: , loader: invoiceListLoader, errorElement: , children: [ { path: ":invoiceId", element: , loader: invoiceDetailLoader, action: invoiceDetailAction, }, ], }, ]); function App() { return ; } ``` Statt `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. ## Der `loader`: Daten holen, bevor gerendert wird Ein `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. ```jsx import { useLoaderData } from "react-router"; async function invoiceDetailLoader({ params }) { const response = await fetch(`/api/invoices/${params.invoiceId}`); if (!response.ok) { throw new Response("Not found", { status: 404 }); } return response.json(); } function InvoiceDetail() { const invoice = useLoaderData(); return

Invoice {invoice.number}

; } ``` Man 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. Der 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. ```mermaid flowchart TB Nav["Navigation zu /invoices/42"] Nav --> Match["Router ermittelt betroffene Routen"] Match --> L1["loader Liste"] Match --> L2["loader Detail"] L1 --> Ready["alle loader fertig"] L2 --> Ready Ready --> Render["Komponenten rendern
mit useLoaderData"] ``` Hier 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. ## Die `action`: Schreiben über `
` Für schreibende Vorgänge hält React Router eine eigene Komponente bereit. `` 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. ```jsx import { Form, redirect } from "react-router"; async function invoiceDetailAction({ request, params }) { const formData = await request.formData(); await fetch(`/api/invoices/${params.invoiceId}/pay`, { method: "POST", body: formData, }); return redirect(`/invoices/${params.invoiceId}`); } function InvoiceDetail() { const invoice = useLoaderData(); return (
); } ``` Das 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. ```mermaid flowchart LR F["Form method=post"] --> A["action schreibt
zum Server"] A --> R["loader werden
revalidiert"] R --> U["Ansicht zeigt
frischen Stand"] ``` Wer 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. ## Verschachtelte Routen und `` Die 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 ``. ```jsx import { Outlet, NavLink } from "react-router"; function InvoiceList() { const invoices = useLoaderData(); return (
); } ``` Navigiert 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 ``. 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. ## Die URL als State: Suchparameter Bis 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. ```jsx import { useSearchParams } from "react-router"; function InvoiceFilter() { const [searchParams, setSearchParams] = useSearchParams(); const status = searchParams.get("status") ?? "all"; return ( ); } ``` Der 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. ## Fehler und langsame Teildaten Weil 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. ```jsx import { useRouteError } from "react-router"; function RouteError() { const error = useRouteError(); return

Something went wrong: {error.status}

; } ``` Das 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. Ein 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 `` innerhalb einer ``-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. ## Ein Wort zu den drei Modi Was 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. ## Wohin die Daten gehören Der 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. Damit 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. ## Weiterführende Quellen - Repository introduction-react (Routing-Kapitel, Ordner recipes zum Data Fetching): https://github.com/mikebild/introduction-react - React Router, offizielle Dokumentation: https://reactrouter.com/ - React Router, „Picking a Mode“: https://reactrouter.com/start/modes - React Router, „Data Loading“: https://reactrouter.com/start/data/data-loading - React Router, „Actions“: https://reactrouter.com/start/data/actions --- # React Transitions: nicht dringende Updates mit useTransition URL: https://www.mikebild.dev/de/blog/react-transitions-mit-usetransition/ Datum: 2024-12-13 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. Am 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`. ## Dringend oder nicht dringend – die einzige Unterscheidung, die zählt Die 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. Auf 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. Welche 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. ## Die Signatur ist bewusst schlicht ```jsx import { useTransition } from 'react'; function TabContainer() { const [isPending, startTransition] = useTransition(); const [tab, setTab] = useState('overview'); function selectTab(nextTab) { startTransition(() => { setTab(nextTab); }); } return ( <> selectTab('overview')}> Overview selectTab('reports')}> Reports
); } ``` Der 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. Der 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`. Das ist die konkrete Einlösung des Suspense-Versprechens: Ein Update innerhalb von `startTransition` verhindert, dass bereits sichtbarer Content in den Fallback zurückspringt. ## Was im Hintergrund wirklich passiert „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. ```mermaid flowchart TD A[Nutzer klickt Tab] --> B[setTab läuft in
startTransition] B --> C[React rendert neuen Tab
im Hintergrund] C --> D[Alte UI bleibt sichtbar
isPending = true] D --> E{Neue Daten /
Render fertig?} E -->|ja| F[Wechsel zur neuen UI
isPending = false] E -->|dringendes Update
kommt rein| G[Hintergrund-Render wird
verworfen und neu gestartet] G --> C ``` Der 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“. ## startTransition ohne Komponente Nicht 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: ```jsx import { startTransition } from 'react'; function navigate(url) { startTransition(() => { setCurrentUrl(url); }); } ``` Diese 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. Seit React 19, stabil seit Dezember 2024, akzeptiert `startTransition` übrigens auch async-Functions; darauf bauen die neuen Actions und `
` intern auf. Das ist ein eigenes Thema – hier zählt nur: Der Transition-Mechanismus selbst ist die Grundlage darunter. ## Der Klassiker unter den Fehlern: das Eingabefeld einfrieren In 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? ```jsx // Do NOT do this function onChange(e) { startTransition(() => { setText(e.target.value); }); } ``` Das 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. ## useDeferredValue: wenn ein Teil der Anzeige nachlaufen darf `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. ```jsx import { useDeferredValue, useState } from 'react'; function SearchPage() { const [query, setQuery] = useState(''); const deferredQuery = useDeferredValue(query); const isStale = query !== deferredQuery; return ( <> setQuery(e.target.value)} />
); } ``` Hier 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. Die 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. Ein 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. ## Der zweite Klassiker: der verlorene Kontext nach await Sobald async-Functions ins Spiel kommen, tappt fast jeder in dieselbe Falle: ```jsx // The transition context is lost after the await startTransition(async () => { const data = await fetchPage(url); setPage(data); // no longer part of the transition }); ``` Das `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. ```jsx startTransition(async () => { const data = await fetchPage(url); startTransition(() => { setPage(data); }); }); ``` Verwandt 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. ## Wann welches Werkzeug – der Entscheidungsbaum Ob `useTransition` oder `useDeferredValue` greift, hängt daran, was verzögert werden darf: der State-Übergang selbst oder nur ein nachgelagerter Teil der Anzeige. ```mermaid flowchart TD A[Teures Update soll
Interaktion nicht blockieren] --> B{Darf der Übergang
selbst warten?} B -->|ja| C[useTransition /
startTransition] B -->|nein, nur Verbraucher| D[useDeferredValue] C --> E[Update wird markiert
isPending verfügbar] D --> F[Aktueller Wert bleibt direkt
Verbraucher läuft nach] E --> G[Beide: alte UI bleibt stehen
statt Fallback] F --> G ``` Die Trennlinie ist also nicht „welches ist moderner“, sondern schlicht die Frage der Kontrolle. Zur Verdichtung die beiden Werkzeuge nebeneinander: - `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. - `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. Gemeinsam 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`. ## Was ich in der Praxis empfehle Ein paar Leitplanken, die sich in Projekten bewährt haben: - 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. - 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. - 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. - `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. - 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. Wer 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. ## Weiterführende Quellen - Repository: die Beispiele zu Tab-Wechsel mit `useTransition`, dem `useDeferredValue`-Suchfeld und dem Standalone-`startTransition` liegen als lauffähiges Repo bereit. - [react.dev – useTransition](https://react.dev/reference/react/useTransition) - [react.dev – startTransition](https://react.dev/reference/react/startTransition) - [react.dev – useDeferredValue](https://react.dev/reference/react/useDeferredValue) - [react.dev – Suspense](https://react.dev/reference/react/Suspense) - [React 19 Release (Actions und Transition-Kontext)](https://react.dev/blog/2024/12/05/react-19) --- # React Testing ohne Theater URL: https://www.mikebild.dev/de/blog/react-testing-ohne-theater/ Datum: 2024-12-12 # React Testing ohne Theater Ein 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. Der 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. ## Die These: Verhalten schlägt Implementierung Ein 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. Der Unterschied wird an zwei Varianten desselben Tests sichtbar. Zuerst die brüchige Variante, die an die Innerei koppelt: ```tsx // Brittle: couples the test to internal state and structure test("increments the count state", () => { const { result } = renderHook(() => useCounter()); act(() => result.current.increment()); expect(result.current.count).toBe(1); }); ``` Dieser 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. Jetzt dieselbe Zusicherung über das beobachtbare Verhalten: ```tsx // Robust: asserts what the user actually sees test("shows the new count after a click", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: /increment/i })); expect(screen.getByRole("status")).toHaveTextContent("Count: 1"); }); ``` Dieser 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. ## Wo die Grenze physisch verläuft Diese 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. ```tsx // Prefer accessible queries – they double as an a11y assertion screen.getByRole("searchbox"); screen.getByRole("button", { name: /search/i }); // testid is an escape hatch, not the default screen.getByTestId("product-list"); ``` Die praktische Konsequenz: Ich schreibe Komponenten so, dass sie über Rollen und Labels ansprechbar sind – semantische Elemente, `aria-label` wo nötig, echte `; } ``` Das `?` 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. In 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: - `interface` unterstützt Declaration Merging, was für öffentliche Bibliotheks-APIs praktisch ist, und liest sich bei einfachen Objektformen etwas natürlicher. - `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. Für 90 Prozent der Komponenten-Props ist die Wahl reine Geschmackssache. Wichtig ist Konsistenz im Team, nicht die Grundsatzdebatte. ## children ist kein Automatismus mehr Sobald 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. ```tsx interface CardProps { title: string; children?: React.ReactNode; } function Card({ title, children }: CardProps) { return (

{title}

{children}
); } ``` `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. Dass `children` nicht mehr magisch erscheint, ist einer der Gründe, warum `React.FC` an Bedeutung verloren hat. Dazu gleich mehr. ## State typisieren: wann Inferenz reicht und wann nicht `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: ```tsx const [count, setCount] = useState(0); // number const [name, setName] = useState(''); // string const [open, setOpen] = useState(false); // boolean ``` Hier 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: ```tsx // Wrong: type is inferred as `null` const [user, setUser] = useState(null); // later setUser({ id: 1, name: 'Mike' }) -> compile error // Correct: specify the generic explicitly const [user, setUser] = useState(null); ``` Startest 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. ```tsx const [items, setItems] = useState([]); const [selected, setSelected] = useState(null); ``` ## Events typisieren: Schluss mit `any` Der 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. ```tsx function LoginForm() { const [email, setEmail] = useState(''); const handleChange = (e: React.ChangeEvent) => { setEmail(e.target.value); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); console.log('submitting', email); }; return ( ); } ``` Zwei Dinge sind hier wichtig. Erstens muss das Generic zum Element passen: Ein `` 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: - `React.ChangeEvent` für Eingaben, analog `HTMLSelectElement` und `HTMLTextAreaElement`. - `React.FormEvent` für das Absenden eines Formulars. - `React.MouseEvent` für Klicks. Wenn 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` und `React.FormEventHandler` – praktisch, wenn du einen Callback als Prop durchreichst. ## useRef: DOM-Referenz mit Null-Check `useRef` verwirrt am Anfang, weil es zwei Rollen spielt. Die häufigste ist die DOM-Referenz, um etwa ein Feld beim Mounten zu fokussieren. ```tsx function SearchBox() { const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, []); return ; } ``` Drei Details zählen. Das Generic `` 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. Die 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: ```tsx const timerRef = useRef(null); const start = () => { timerRef.current = window.setTimeout(() => { console.log('done'); }, 1000); }; ``` Hier 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. ## Utility-Types: Props ableiten statt kopieren Wenn 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`: ```ts interface User { id: string; name: string; email: string; role: 'admin' | 'member'; } ``` Ein Formular, das nur Name und E-Mail bearbeitet, braucht nicht den ganzen `User`. `Pick` schneidet die relevanten Felder heraus: ```ts type EditUserProps = Pick; ``` Umgekehrt entfernt `Omit`, was du nicht willst – etwa die serverseitig vergebene `id`: ```ts type NewUserProps = Omit; ``` `Partial` macht alle Felder optional, was für Patch-Operationen oder Formular-Zwischenstände passt: ```ts type UserDraft = Partial; ``` Der 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` und `Readonly` zum Standardrepertoire. Ein 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`: ```tsx type IconButtonProps = React.ComponentPropsWithoutRef<'button'> & { icon: string; }; function IconButton({ icon, ...rest }: IconButtonProps) { return ( ); } ``` So bekommst du `onClick`, `disabled`, `type` und alle anderen Button-Attribute geschenkt und typgeprüft, ohne sie einzeln aufzuzählen. ## Die Alltags-Typen im Überblick Diese Übersicht zeigt die fünf Ecken, an denen man in einer typisierten React-Komponente am häufigsten mit Typen zu tun hat. ```mermaid graph TD K[Typen im React-Alltag] --> P[Props
interface / type] K --> S[State
useState<T>] K --> E[Events
ChangeEvent / FormEvent] K --> R[Refs
useRef<HTMLInputElement>] K --> U[Ableitung
Pick / Omit / Partial] P --> C[children:
ReactNode] ``` Diese fünf decken den größten Teil der täglichen Arbeit ab. Alles andere baut darauf auf. ## Und was ist mit React.FC? Die Frage kommt garantiert, oft mit einem Codebeispiel aus einem älteren Tutorial: `const Button: React.FC = (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. Der 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: ```tsx interface ListProps { items: T[]; render: (item: T) => React.ReactNode; } function List({ items, render }: ListProps) { return
    {items.map((item, i) =>
  • {render(item)}
  • )}
; } ``` So 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. ## Der Bogen zurück zu Hooks Wer 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. ## Fazit Der 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. Der 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. ## Weiterführende Quellen - 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) - react.dev – „Using TypeScript“: [react.dev/learn/typescript](https://react.dev/learn/typescript) - react.dev – Hooks-Referenz: [useState](https://react.dev/reference/react/useState) und [useRef](https://react.dev/reference/react/useRef) - TypeScript Handbook – Utility Types: [typescriptlang.org/docs/handbook/utility-types.html](https://www.typescriptlang.org/docs/handbook/utility-types.html) - DefinitelyTyped – `@types/react`: [github.com/DefinitelyTyped/DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react) --- # Resilience-Pattern: Timeout, Retry, Circuit Breaker, Idempotenz URL: https://www.mikebild.dev/de/blog/resilience-muster-timeout-retry-circuit-breaker/ Datum: 2024-01-30 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. Verteilte 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. Resilienz 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. Die 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. ::::comparison{title="Resilience-Pattern nach Fehlerfrage" role="primary" preferredPattern="comparison-matrix"} :::side{label="Timeout"} - Frage · Wie lange darf ein einzelner Versuch dauern? - Wirkung · begrenzt blockierte Ressourcen ::: :::side{label="Retry"} - Frage · Ist der Fehler vorübergehend und der Versuch sicher wiederholbar? - Wirkung · wiederholt mit begrenztem Backoff und Jitter ::: :::side{label="Circuit Breaker"} - Frage · Soll ein erkennbar gestörtes Ziel weiter belastet werden? - Wirkung · unterbricht Aufrufe und prüft später kontrolliert erneut ::: :::side{label="Bulkhead"} - Frage · Wie bleibt ein Ausfall auf einen Teil des Systems begrenzt? - Wirkung · trennt Ressourcen und Konkurrenzbereiche ::: :::side{label="Dead Letter Queue"} - Frage · Was geschieht mit dauerhaft nicht verarbeitbaren Messages? - Wirkung · bewahrt Fehlerfälle für Prüfung und gezielte Wiederaufnahme ::: :::: Erst die Fehlerfrage macht sichtbar, welche Muster sich ergänzen und welche nur zusätzlichen Verkehr oder versteckte Queues erzeugen würden. ## Die Muster im Überblick Bevor ich in den Code gehe, ein kurzer Überblick. Vier Muster, jedes mit einer klaren Zuständigkeit: - 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. - Ein Retry wiederholt einen fehlgeschlagenen Aufruf – aber nur bei transienten Fehlern, nur mit Exponential Backoff und Jitter, und nur mit begrenzter Versuchszahl. - Ein Circuit Breaker stoppt weitere Aufrufe an eine erkennbar ausgefallene Dependency, damit man sie nicht endlos weiter hämmert. - Idempotenz macht Retries überhaupt erst sicher, indem ein mehrfach gesendeter Aufruf denselben Effekt hat wie ein einziger. Diese 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. ## Timeout: nie unendlich warten Der 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. Wichtig 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. In 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: ```ts export async function withTimeout( fn: (signal: AbortSignal) => Promise, timeoutMs: number, ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fn(controller.signal); } finally { clearTimeout(timer); } } // usage: the signal is forwarded to fetch so the socket is released const result = await withTimeout( (signal) => fetch("https://payments.internal/charge", { signal }), 2_000, ); ``` Der `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. Eine 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. ## Retry: Backoff und Jitter, aber mit Verstand Retry 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. Drei 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. Fü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: ```ts type RetryOptions = { maxAttempts: number; baseDelayMs: number; capMs: number; isRetryable: (error: unknown) => boolean; }; export async function retryWithBackoff( fn: () => Promise, opts: RetryOptions, ): Promise { let lastError: unknown; for (let attempt = 0; attempt < opts.maxAttempts; attempt++) { try { return await fn(); } catch (error) { lastError = error; if (!opts.isRetryable(error) || attempt === opts.maxAttempts - 1) { throw error; } // full jitter: random point in the exponentially growing window const window = Math.min(opts.capMs, opts.baseDelayMs * 2 ** attempt); const delay = Math.random() * window; await new Promise((resolve) => setTimeout(resolve, delay)); } } throw lastError; } ``` Der `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. Die 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. ## Der Retry-Storm Retries 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. Das 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. ## Circuit Breaker: aufhören zu hämmern Retry 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. Die 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: ```mermaid stateDiagram-v2 [*] --> CLOSED CLOSED --> OPEN: Fehler >= Schwelle OPEN --> HALF_OPEN: Cooldown abgelaufen HALF_OPEN --> CLOSED: Probe erfolgreich HALF_OPEN --> OPEN: Probe scheitert ``` Im 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. Als kleine State Machine ist das überschaubar: ```ts type BreakerState = "CLOSED" | "OPEN" | "HALF_OPEN"; export class CircuitBreaker { private state: BreakerState = "CLOSED"; private failureCount = 0; private openedAt = 0; constructor( private readonly failureThreshold: number, private readonly cooldownMs: number, ) {} async execute(fn: () => Promise): Promise { if (this.state === "OPEN") { if (Date.now() - this.openedAt < this.cooldownMs) { throw new Error("circuit is open"); } this.state = "HALF_OPEN"; } try { const result = await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } } private onSuccess(): void { this.failureCount = 0; this.state = "CLOSED"; } private onFailure(): void { this.failureCount++; if (this.state === "HALF_OPEN" || this.failureCount >= this.failureThreshold) { this.state = "OPEN"; this.openedAt = Date.now(); } } } ``` AWS 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. In 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. ## Idempotenz: was Retries erst sicher macht Jetzt 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. Die 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. Das 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: ```ts type Result = { status: string; id: string }; interface IdempotencyStore { get(key: string): Promise; set(key: string, value: Result): Promise; } export async function handleRequest( key: string, op: () => Promise, store: IdempotencyStore, ): Promise { const existing = await store.get(key); if (existing) { // key already processed: return the stored result, no second side effect return existing; } const result = await op(); await store.set(key, result); return result; } ``` Zwei 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. ## Bulkhead und DLQ, kurz Zwei 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!". Und 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. ## Fazit Drei 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. Alle 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. ## Weiterführende Quellen - [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) - [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) - [AWS Well-Architected, REL05-BP02 – Throttle requests (Token Bucket)](https://docs.aws.amazon.com/wellarchitected/latest/framework/rel_mitigate_interaction_failure_throttle_requests.html) - [AWS Builders' Library – Timeouts, retries, and backoff with jitter (Marc Brooker)](https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/) - [AWS Architecture Blog – Exponential Backoff And Jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) - [Michael Nygard – Release It! (2. Auflage, 2018)](https://pragprog.com/titles/mnee2/release-it-second-edition/) --- # GraphQL-Subscriptions über SSE: Echtzeit ohne WebSocket-Zwang URL: https://www.mikebild.dev/de/blog/graphql-subscriptions-ueber-sse/ Datum: 2024-01-12 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. Hinter „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. Dabei 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. ## Query und Mutation bleiben, wie sie sind Bevor 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. Genau 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. ```mermaid flowchart LR subgraph Client UI[Browser-App] end subgraph Server GQL[GraphQL-Server] end UI -- "HTTP POST /graphql
Query & Mutation" --> GQL GQL -- "HTTP Antwort (JSON)" --> UI GQL -- "text/event-stream
Events via EventSource" --> UI ``` Das 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. ## Der Serverteil: ein AsyncIterable aus PubSub Auf 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. ```ts import { PubSub, withFilter } from 'graphql-subscriptions'; const pubsub = new PubSub(); const ORDER_STATUS_CHANGED = 'ORDER_STATUS_CHANGED'; const resolvers = { Subscription: { orderStatusChanged: { subscribe: withFilter( () => pubsub.asyncIterator(ORDER_STATUS_CHANGED), (payload, variables) => payload.orderStatusChanged.customerId === variables.customerId, ), }, }, Mutation: { advanceOrder: (_root, { id }) => { const orderStatusChanged = advanceOrderInStore(id); pubsub.publish(ORDER_STATUS_CHANGED, { orderStatusChanged }); return orderStatusChanged; }, }, }; ``` Zwei 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. Ein 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. ## Der SSE-Endpoint: der Strom nach draußen Damit 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: ```ts app.get('/graphql/stream', (req, res) => { res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); res.flushHeaders(); const iterator = pubsub.asyncIterator(ORDER_STATUS_CHANGED); (async () => { for await (const event of iterator) { res.write(`event: next\n`); res.write(`data: ${JSON.stringify(event)}\n\n`); } })(); req.on('close', () => { if (typeof iterator.return === 'function') iterator.return(); }); }); ``` Das 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. Der `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. ## Der Client: EventSource, mehr nicht Auf der Clientseite ist die Arbeit erstaunlich gering, weil der Browser den größten Teil übernimmt: ```js const es = new EventSource('/graphql/stream', { withCredentials: true }); es.addEventListener('next', (e) => { const payload = JSON.parse(e.data); render(payload.orderStatusChanged); }); es.onerror = () => { // The browser reconnects on its own; the retry: field controls the interval. }; ``` Kein 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. ```mermaid sequenceDiagram participant B as Browser (EventSource) participant S as GraphQL-Server participant P as PubSub B->>S: HTTP POST advanceOrder (Mutation) S->>P: pubsub.publish(topic, payload) P-->>S: asyncIterator liefert Event S-->>B: data:-Frame (event: next) Note over B,S: Verbindung bricht ab B->>S: Reconnect mit Last-Event-ID S-->>B: Ereignisse ab letzter ID ``` ## Das Schema ist ein Fachmodell, kein Ereignisrohr Der 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. Dieselbe 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. ## Die falsche Abstraktion vermeiden Damit 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. - 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. - 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. - 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. Wer 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. ## Entscheidungsrahmen: SSE, WebSocket oder Polling Die 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. Polling 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. SSE 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. WebSocket 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. Das 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. ## Woher die SSE-Idee kommt – und was heute gilt Die 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: ```js // HISTORICAL — Apollo Client 1.x, not today's API. import { SubscriptionClient, addGraphQLSubscriptions, } from 'subscriptions-transport-sse'; const sseClient = new SubscriptionClient('/subscriptions'); const client = new ApolloClient({ networkInterface: addGraphQLSubscriptions(httpInterface, sseClient), }); ``` Das 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. Fü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. ## Was ich aus der Praxis mitgebe Wenn 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. Der 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. ## Weiterführende Quellen - MDN – Using server-sent events: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events - MDN – EventSource: https://developer.mozilla.org/en-US/docs/Web/API/EventSource - graphql-subscriptions (README/API): https://github.com/apollographql/graphql-subscriptions - graphql-subscriptions (npm): https://www.npmjs.com/package/graphql-subscriptions - Apollo Server – Subscriptions: https://www.apollographql.com/docs/apollo-server/data/subscriptions - subscriptions-transport-ws (Legacy/archiviert): https://github.com/apollographql/subscriptions-transport-ws - subscriptions-transport-sse (historischer Anlass): https://github.com/CodeCommission/subscriptions-transport-sse - GraphQL Spec: https://spec.graphql.org/ --- # Contract Testing statt E2E-Marathon URL: https://www.mikebild.dev/de/blog/contract-testing-statt-e2e-marathon/ Datum: 2023-12-05 ```text FAIL e2e/checkout.spec.ts ✕ Bestellung abschließen (30014 ms) TimeoutError: waiting for selector "#order-confirmation" failed: timeout 30000ms exceeded Tests: 1 failed, 213 passed Dauer: 46m 08s ``` Dieser 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. Der 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. Der wesentliche Unterschied liegt nicht in der Anzahl der Tests, sondern im Ort, an dem Kompatibilität geprüft wird. ::::comparison{title="Kompatibilität an der richtigen Grenze prüfen" role="primary" preferredPattern="before-after"} :::side{label="Gemeinsame E2E-Umgebung"} - Aufbau · alle beteiligten Systeme gleichzeitig starten - Signal · ein kompletter Pfad ist gerade erfolgreich durchgelaufen - Schwäche · Fehlerursache, Timing und verantwortliche Grenze bleiben oft unklar - Kopplung · Releases warten auf denselben integrierten Zustand ::: :::side{label="Consumer-Driven Contract"} - Aufbau · Erwartungen an einer Servicegrenze aufzeichnen - Signal · Consumer und Provider erfüllen denselben Vertrag unabhängig - Stärke · schneller, deterministischer und einer Grenze zuordenbar - Grenze · ersetzt keine kleine Auswahl echter End-to-End-Prüfungen ::: :::: Der Vergleich streicht E2E-Tests nicht. Er verschiebt die häufige Kompatibilitätsprüfung an die Stelle, an der der Vertrag tatsächlich entsteht. ## Warum der E2E-Marathon strukturell scheitert Was 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. Martin 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. Dazu 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. Die 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. ## Die Grenze prüfen, nicht das ganze System Die 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. Fowlers 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. Das 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. In 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. ```mermaid flowchart TB subgraph Pyramid["Test Pyramid"] direction TB E2E["E2E
slow / flaky
few"] CT["Integration / Contract
fast / isolated
focus"] UNIT["Unit
fast / deterministic
many"] UNIT --> CT --> E2E end ``` Die 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. ## Wie ein Contract entsteht Konkret 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. ```js const provider = new Pact({ consumer: 'WebApp', provider: 'OrderService' }); provider .given('an order with id 42 exists') // provider state .uponReceiving('a request for order 42') .withRequest({ method: 'GET', path: '/orders/42' }) .willRespondWith({ status: 200, body: { id: like(42), // type matcher status: term({ matcher: 'PAID|OPEN', generate: 'PAID' }), total: like(19.99), }, }); // the consumer runs against the Pact mock, then publishes the pact file to the broker ``` Zwei 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. Zweitens 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. Auf 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. ```java @Provider("OrderService") @PactBroker class OrderServiceContractTest { @State("an order with id 42 exists") void orderExists() { // set up the provider state so the recorded interaction is reproducible orderRepository.save(new Order(42, "PAID", 19.99)); } } ``` Beide 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. ## Das Deploy-Gate: eine deterministische Ja/Nein-Frage Jetzt 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? ```bash # provider side: verify the recorded interactions and publish results to the broker pact-provider-verifier --provider OrderService --publish-verification-results # both sides gate their release on a deterministic check pact-broker can-i-deploy \ --pacticipant WebApp --version $GIT_SHA \ --to-environment production ``` `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. Der ganze Ablauf ist eine Prüfung an der Grenze, kein hochgefahrenes Gesamtsystem: ```mermaid sequenceDiagram participant C as Consumer (WebApp) participant B as Pact Broker participant P as Provider (OrderService) C->>B: publish pact (from mock test) P->>B: verify pact, publish results C->>B: can-i-deploy? (WebApp @ sha) B-->>C: yes -> deploy P->>B: can-i-deploy? (OrderService @ sha) B-->>P: yes -> deploy ``` Der 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. ## Wo Contract Testing aufhört – die ehrliche Abgrenzung Consumer-Driven Contracts ersetzen den E2E-*Marathon* – nicht jeden E2E-Test. Wer sie als Wundermittel verkauft, verspricht zu viel. Der 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. Der 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. Und 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. Ein 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. ## Fazit Der 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. Der 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. ## Weiterführende Quellen - [Consumer-Driven Contracts: A Service Evolution Pattern (Ian Robinson, martinfowler.com)](https://martinfowler.com/articles/consumerDrivenContracts.html) - [The Practical Test Pyramid (Ham Vocke, martinfowler.com)](https://martinfowler.com/articles/practical-test-pyramid.html) - [TestPyramid (Martin Fowler, Bliki)](https://martinfowler.com/bliki/TestPyramid.html) - [DORA: Loosely coupled architecture](https://dora.dev/capabilities/loosely-coupled-teams/) - [DORA: Continuous delivery](https://dora.dev/capabilities/continuous-delivery/) - [pact-jvm (pact-foundation, GitHub)](https://github.com/pact-foundation/pact-jvm) - Forsgren, Humble, Kim: *Accelerate* (2018) --- # Fehler als Architektur in Node.js: Netze gegen den Absturz URL: https://www.mikebild.dev/de/blog/nodejs-fehler-als-architektur/ Datum: 2023-11-07 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. Damit 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. Wo 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. Dabei 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. ## Die vier Ebenen, auf denen Fehler entstehen Bevor 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: - 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. - 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. - 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`. - 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. Diese 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. ## Ebene für Ebene: wo der Fehler wirklich ankommt Nehmen wir die Ebenen einzeln durch. Synchron ist einfach – das kennt jeder: ```js function parseConfig(raw) { try { return JSON.parse(raw); } catch (err) { // synchronous exception, caught right here return { ok: false, reason: err.message }; } } ``` Der 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: ```js const fs = require('node:fs'); // This try/catch catches NOTHING from the callback below. try { fs.readFile('/etc/config.json', (err, data) => { if (err) return handle(err); // the error arrives HERE, as first argument process.stdout.write(data); }); } catch (err) { // never reached for the async error } ``` Der 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. Promises drehen das wieder zurück in Richtung `try/catch` – aber nur um `await` herum: ```js async function loadUser(id) { try { const user = await db.users.findById(id); if (!user) throw new Error(`user ${id} not found`); return user; } catch (err) { // rejection AND thrown error both land here logger.warn({ err, id }, 'loadUser failed'); throw err; // re-throw: let the caller decide } } ``` Wichtig 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. Die 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: ```js const fs = require('node:fs'); const stream = fs.createReadStream('/does/not/exist'); // WITHOUT this line, the emitted 'error' is thrown and crashes the process. stream.on('error', (err) => { logger.error({ err }, 'stream failed, handled gracefully'); }); stream.on('data', (chunk) => process.stdout.write(chunk)); ``` Das 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“. ## Das letzte Netz – und wofür es nicht da ist Was passiert, wenn auf keiner der vier Ebenen jemand zuständig war? Dann greift das letzte Netz: `uncaughtException` und `unhandledRejection`. ```mermaid flowchart TD E["Fehler entsteht"] --> D{"Welche Ebene
fängt ihn?"} D -->|"try/catch"| H["behandeln &
weitermachen"] D -->|"error-first callback"| H D -->|"promise .catch"| H D -->|"emitter 'error'"| H D -->|"niemand zuständig"| ESC["eskaliert zum
letzten Netz"] ESC --> LOG["loggen
uncaughtException /
unhandledRejection"] LOG --> GS["Graceful Shutdown
starten"] GS --> CLOSE["server.close()"] CLOSE --> EXIT["process.exit(1)"] EXIT --> SUP["Supervisor
pm2 / systemd / K8s
startet frisch"] ``` Hier 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.“ Das 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: ```js // ANTI-PATTERN: swallow and keep running. process.on('uncaughtException', (err) => { logger.error({ err }, 'oops'); // logged, but then... nothing // no process.exit — the process stays alive in a corrupt state }); ``` Nach 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. Richtig 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: ```js process.on('unhandledRejection', (reason) => { logger.error({ reason }, 'unhandled rejection, escalating'); // escalate to uncaughtException so we exit through one path throw reason; }); process.on('uncaughtException', (err, origin) => { logger.fatal({ err, origin }, 'uncaught exception, shutting down'); process.exit(1); }); ``` Es 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: ```js // Log the crash without changing the crash behavior. process.on('uncaughtExceptionMonitor', (err, origin) => { logger.fatal({ err, origin }, 'about to crash'); }); ``` ## Graceful Shutdown: kontrolliert sterben, nicht reparieren Der 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. ```mermaid sequenceDiagram participant O as Orchestrator participant A as App O->>A: SIGTERM A->>A: stop accepting new requests A->>A: drain in-flight requests A->>A: close DB / pools A-->>O: exit 0 O->>O: restart fresh ``` Der 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. ```js const http = require('node:http'); const server = http.createServer(handler); server.listen(3000); let isShuttingDown = false; function shutdown(signal) { if (isShuttingDown) return; // guard against double invocation isShuttingDown = true; logger.info({ signal }, 'graceful shutdown started'); // stop accepting new connections, finish in-flight ones server.close((err) => { if (err) { logger.error({ err }, 'error during server.close'); return process.exit(1); } logger.info('all connections drained, exiting'); process.exit(0); }); // fallback: never hang forever if a connection refuses to close setTimeout(() => { logger.error('shutdown timed out, forcing exit'); process.exit(1); }, 10_000).unref(); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); ``` Zwei 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. Diese 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. ## Warum das eine Architekturentscheidung ist Die 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. Das 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. Wer 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. Behandle 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. ## Weiterführende Quellen - Repository mit Beispielen: [MikeBild/introduction-nodejs](https://github.com/MikeBild/introduction-nodejs) - [process – Event: 'uncaughtException'](https://nodejs.org/api/process.html#event-uncaughtexception) - [process – Event: 'unhandledRejection'](https://nodejs.org/api/process.html#event-unhandledrejection) - [process – Signal-Events](https://nodejs.org/api/process.html#signal-events) - [events – 'error'-Events des EventEmitter](https://nodejs.org/api/events.html#error-events) - [CLI – --unhandled-rejections=mode](https://nodejs.org/api/cli.html#--unhandled-rejectionsmode) - [Node.js Process API](https://nodejs.org/api/process.html) --- # DORA-Metrics: was Entwicklungszyklen wirklich verkürzt URL: https://www.mikebild.dev/de/blog/dora-metriken-was-zyklen-verkuerzt/ Datum: 2023-10-24 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“. **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. Eine 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. Diese 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. Die 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. ::::comparison{title="Delivery-Leistung hat zwei Achsen" role="primary" preferredPattern="comparison-matrix"} :::side{label="Throughput"} - Deployment Frequency · Wie oft erreicht eine Änderung Produktion? - Lead Time for Changes · Wie lange dauert der Weg vom Commit bis zum Deploy? ::: :::side{label="Instability"} - Change Failure Rate · Welcher Anteil der Änderungen verursacht eine Störung? - Failed Deployment Recovery Time · Wie lange dauert die Erholung nach einem fehlgeschlagenen Deploy? - Deployment Rework Rate · Welcher Anteil erzeugt ungeplante Nacharbeit? ::: :::: Die Einteilung ist keine Rangliste. Erst gemeinsam zeigen beide Seiten, ob ein Team schnell liefern kann, ohne die Kosten fehlgeschlagener Änderungen auszublenden. ## Zwei Achsen statt einer Zahl Der erste Denkfehler ist, Delivery-Performance als eine einzige Größe zu behandeln. Die vier Keys spannen bewusst zwei Achsen auf. Auf 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. Auf 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. Die 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. ```mermaid quadrantChart title Throughput vs. Stability x-axis Low Throughput --> High Throughput y-axis Low Stability --> High Stability quadrant-1 Elite quadrant-2 Slow but Safe quadrant-3 Low Performer quadrant-4 Fragile Speed Elite Team: [0.85, 0.85] Big-Batch Team: [0.2, 0.6] Move-Fast-Break-Things: [0.8, 0.25] Stuck Team: [0.2, 0.2] ``` Die 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. ## Wo der Zyklus klemmt Der 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. ```mermaid flowchart LR A[Commit] --> B[CI Build] B --> C[Test] C --> D[Deploy Gate] D --> E[Production] E -->|Incident| F[Restore] F --> E A -. Lead Time for Changes .-> E D -. Deployment Frequency .-> E E -. Change Failure Rate .-> F F -. Failed Deployment Recovery Time .-> E style D fill:#e8543f,color:#fff ``` Ein 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. Lead 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. ## Change Failure Rate und Recovery-Zeit sind gekoppelt Ein 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. Deshalb 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. Im Code sieht die Kopplung von umkehrbarem Rollout und automatischem Recovery zum Beispiel so aus: ```typescript // Canary rollout gated by the live error rate. // Ties Change Failure Rate and recovery time together in one control loop. interface RolloutConfig { canaryPercentage: number; maxErrorRate: number; // e.g. 0.02 -> roll back above 2% observationWindowMs: number; } async function progressiveRollout( release: Release, config: RolloutConfig, ): Promise { await featureFlags.enableForCohort(release.id, config.canaryPercentage); const errorRate = await metrics.observeErrorRate( release.id, config.observationWindowMs, ); if (errorRate > config.maxErrorRate) { await featureFlags.disable(release.id); // instant, no redeploy return { status: "rolled_back", observedErrorRate: errorRate }; } await featureFlags.enableForCohort(release.id, 100); return { status: "promoted", observedErrorRate: errorRate }; } ``` Es 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. ## Die Metrics messen, ohne sie zu erfinden Damit 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. ```typescript // Lead Time for Changes, derived from VCS + deploy events. // leadTime = deployedAt(prodDeployment) - firstCommitTime(changeSet) function leadTimeForChange(deployment: ProdDeployment): number { const firstCommit = deployment.changeSet .map((commit) => commit.authoredAt.getTime()) .reduce((min, t) => Math.min(min, t), Infinity); return deployment.deployedAt.getTime() - firstCommit; } // Aggregate as a median over a rolling window -- means get skewed by outliers. function medianLeadTime(deployments: ProdDeployment[]): number { const values = deployments.map(leadTimeForChange).sort((a, b) => a - b); const mid = Math.floor(values.length / 2); return values.length % 2 === 0 ? (values[mid - 1] + values[mid]) / 2 : values[mid]; } ``` Zwei 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. Noch 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: ```python # Executable fitness function: assert a DORA metric as a CI gate. def assert_change_failure_rate(window_days: int = 30, threshold: float = 0.15): deployments = metrics_db.query_deployments(window_days) failed = [d for d in deployments if d.caused_incident or d.rolled_back] if not deployments: return # nothing to assert yet change_failure_rate = len(failed) / len(deployments) assert change_failure_rate < threshold, ( f"Change Failure Rate {change_failure_rate:.0%} " f"exceeds threshold {threshold:.0%} over {window_days}d" ) ``` So 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. ## Der Fallstrick: Metrics als Ziel Damit 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. Zwei 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. ## Fazit Der 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. Praktisch 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. Und 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. ## Weiterführende Quellen - [DORA Metrics – dora.dev](https://dora.dev/guides/dora-metrics/) - [Accelerate State of DevOps Report 2022 – dora.dev](https://dora.dev/research/2022/dora-report/) - [DORA 2022 Report – Google Cloud Blog](https://cloud.google.com/blog/products/devops-sre/dora-2022-accelerate-state-of-devops-report-now-out) - [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) - [A history of DORA's metrics – dora.dev](https://dora.dev/insights/dora-metrics-history/) --- # child_process in Node.js: Skalierung ohne Illusionen URL: https://www.mikebild.dev/de/blog/nodejs-child-process-und-skalierung/ Datum: 2023-09-19 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. Der 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. ## Ein Event Loop, ein Thread, ein Nadelöhr Fangen 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. Das 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. Der folgende Code sieht harmlos aus und rutscht in Code-Reviews trotzdem regelmäßig durch: ```js const http = require('node:http'); function fibonacci(n) { if (n < 2) return n; return fibonacci(n - 1) + fibonacci(n - 2); } http .createServer((req, res) => { if (req.url === '/compute') { const result = fibonacci(45); // blocks the event loop for seconds res.end(`result: ${result}`); } else { res.end('ok'); } }) .listen(3000); ``` Solange `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“. ## Vier Werkzeuge, vier Aufgaben Node.js bietet für dieses Problem mehrere Werkzeuge an, und die werden regelmäßig verwechselt. Es lohnt sich, sie sauber auseinanderzuhalten: - `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. - `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. - `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. - Die normalen asynchronen APIs sind für alles, was I/O ist – und das braucht keine der drei oberen Optionen. Diesen letzten Punkt betone ich bewusst, weil dort der häufigste Denkfehler lauert. Aber der Reihe nach. ## child_process: spawn, exec, execFile, fork Das Modul `node:child_process` bietet vier Wege, einen Kindprozess zu starten, und die Unterschiede sind praxisrelevant. `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. `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. `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. ```js const { execFile } = require('node:child_process'); execFile('git', ['rev-parse', 'HEAD'], (err, stdout) => { if (err) throw err; console.log('current commit:', stdout.trim()); }); ``` Die 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. Der 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. ## Das Hauptbeispiel: schwere Berechnung in einem fork Nehmen 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. Zuerst der Worker in `worker.js`: ```js // worker.js function fibonacci(n) { if (n < 2) return n; return fibonacci(n - 1) + fibonacci(n - 2); } process.on('message', ({ n }) => { const result = fibonacci(n); process.send({ n, result }); }); ``` Und der Elternprozess, der ihn startet und beauftragt: ```js // main.js const { fork } = require('node:child_process'); const http = require('node:http'); const child = fork('./worker.js'); const pending = new Map(); child.on('message', ({ n, result }) => { const res = pending.get(n); if (res) { res.end(`fibonacci(${n}) = ${result}`); pending.delete(n); } }); http .createServer((req, res) => { if (req.url === '/compute') { pending.set(45, res); child.send({ n: 45 }); // heavy work happens elsewhere } else { res.end('ok'); // stays responsive during the computation } }) .listen(3000); ``` Der 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. Genau 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. Der folgende Ablauf zeigt das Prinzip als Sequenz: ```mermaid sequenceDiagram participant Client participant Main as Main Prozess
(Event Loop frei) participant Child as Forked Prozess
(eigener V8-Heap) Client->>Main: GET /compute Main->>Child: send({ n: 45 }) Note over Main: Event Loop bleibt frei
für andere Requests Child->>Child: heavy computation Child-->>Main: message({ result }) Main-->>Client: HTTP 200 result ``` ## worker_threads: dasselbe Ziel, geteilter Speicher Ein `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. Ein Worker läuft im selben Prozess, aber in einem eigenen Thread mit eigenem V8-Isolate und eigenem Event Loop. Dieselbe Rechnung sieht so aus: ```js // task.js const { parentPort, workerData } = require('node:worker_threads'); function fibonacci(n) { if (n < 2) return n; return fibonacci(n - 1) + fibonacci(n - 2); } parentPort.postMessage(fibonacci(workerData.n)); ``` ```js // main-threads.js const { Worker } = require('node:worker_threads'); function computeFib(n) { return new Promise((resolve, reject) => { const worker = new Worker('./task.js', { workerData: { n } }); worker.on('message', resolve); worker.on('error', reject); }); } computeFib(45).then((result) => { console.log('fibonacci(45) =', result); }); ``` Der 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. ## Der Fallstrick, der am meisten Schaden anrichtet Der 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. Die 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. Die Regel ist also einfach und trotzdem wird ständig gegen sie verstoßen: - 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. - Ist die Arbeit CPU-bound – Hashing, Bildverarbeitung, Kompression, Krypto, große synchrone Transformationen? Erst dann sind `worker_threads` oder `child_process` das richtige Werkzeug. Ein 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. ## cluster: Durchsatz, nicht Entlastung Bleibt `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. ```js const cluster = require('node:cluster'); const http = require('node:http'); const os = require('node:os'); if (cluster.isPrimary) { for (let i = 0; i < os.cpus().length; i++) { cluster.fork(); } cluster.on('exit', (worker) => { console.log(`worker ${worker.process.pid} died, restarting`); cluster.fork(); }); } else { http .createServer((req, res) => res.end(`handled by pid ${process.pid}`)) .listen(3000); } ``` Damit 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. ## Wann was: die Entscheidung auf einen Blick Die Entscheidung lässt sich auf wenige Fragen eindampfen: ```mermaid flowchart TD A[Arbeit einordnen] --> B{I/O-bound?} B -->|Ja| C[Normale async APIs
keine Worker nötig] B -->|Nein, CPU-bound| D{Geteilter Speicher
oder leichtgewichtig?} D -->|Ja| E[worker_threads
SharedArrayBuffer möglich] D -->|Nein, Isolation
oder externes Skript| F[child_process / fork
eigener Prozess + IPC] A --> G{Nur mehr Durchsatz
auf allen Kernen?} G -->|Ja| H[cluster
mehrere Instanzen, ein Port] ``` Die drei Werkzeuge beantworten verschiedene Fragen – sie konkurrieren nicht um dieselbe: - `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. - `cluster` gibt dir mehrere Node-Instanzen hinter einem Port für mehr Request-Durchsatz – es skaliert Instanzen, nicht einzelne Berechnungen. - `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. ## Fazit Die 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. Der 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. ## Weiterführende Quellen - Repository mit den Codebeispielen: [github.com/mikebild/introduction-nodejs](https://github.com/mikebild/introduction-nodejs) - [child_process – offizielle Node.js-Doku](https://nodejs.org/api/child_process.html) - [worker_threads – offizielle Node.js-Doku](https://nodejs.org/api/worker_threads.html) - [cluster – offizielle Node.js-Doku](https://nodejs.org/api/cluster.html) - [Don't Block the Event Loop – Node.js Learn](https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop) --- # Lambda-Performance und Cold Starts verstehen URL: https://www.mikebild.dev/de/blog/lambda-performance-und-cold-starts/ Datum: 2023-08-08 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. Dieser 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. ## Was bei einem Cold Start wirklich passiert Lambda 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. Ein Cold Start ist nichts anderes als die Init-Phase einer frisch erzeugten Environment. Dabei passiert einiges, bevor deine eigentliche Logik überhaupt anläuft: - Lambda lädt deinen Code herunter – aus einem internen Speicher beziehungsweise bei Container-Images aus der Registry. - Die Runtime wird initialisiert, also zum Beispiel der Node.js-Prozess hochgefahren. - 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. Erst 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. Nach 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. ```mermaid flowchart TD Req[Eingehender Invoke] --> Q{Warme Environment
verfügbar?} Q -->|Nein: Cold Start| C1[Code herunterladen] C1 --> C2[Runtime initialisieren] C2 --> C3[Init-Code außerhalb
des Handlers ausführen] C3 --> H[Handler ausführen] Q -->|Ja: Warm Invoke| H H --> F[Environment einfrieren
für spätere Invokes] F -.wiederverwenden.-> Q ``` Das 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. ## Der erste Hebel: das Bundle klein halten Der 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. Die 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: ```bash esbuild src/handler.ts \ --bundle \ --minify \ --platform=node \ --target=node18 \ --external:@aws-sdk/* \ --outfile=dist/handler.js ``` Zwei 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. Wer 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. ## Der zweite Hebel: Init-Code an die richtige Stelle Jetzt 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? Alles, 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. ```ts // module scope — runs once per execution environment (Init phase) import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { GetItemCommand } from "@aws-sdk/client-dynamodb"; // created once, reused across every warm invoke const client = new DynamoDBClient({}); export const handler = async (event: { id: string }) => { const result = await client.send( new GetItemCommand({ TableName: "orders", Key: { id: { S: event.id } }, }) ); return { statusCode: 200, body: JSON.stringify(result.Item) }; }; ``` Der `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. Ein 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. ### Lazy-Init für das, was nicht jeder Invoke braucht Nicht 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. ```ts import { createPool, type Pool } from "./db"; let dbPromise: Promise | undefined; // created on first use, then cached for the environment's lifetime const getDb = (): Promise => (dbPromise ??= createPool()); export const handler = async (event: { needsDb: boolean }) => { if (!event.needsDb) { return { statusCode: 204 }; } const db = await getDb(); const rows = await db.query("SELECT 1"); return { statusCode: 200, body: JSON.stringify(rows) }; }; ``` Der `??=`-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. ## Der dritte Hebel: Memory ist auch CPU Das 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. Das 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. Der 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. Die 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. ## Der vierte Hebel: Provisioned Concurrency für planbare Latenz Manchmal 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. Dafü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. Das 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. Eine 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. ## Messen, nicht vermuten Der 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: ```json { "Duration": "42.11 ms", "BilledDuration": "43 ms", "MaxMemoryUsed": "98 MB", "InitDuration": "312.44 ms" } ``` Das 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. Wer 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. ## Die Hebel im Überblick Wenn ich die konkreten Stellschrauben zusammenfassen soll, ergibt sich eine kompakte Liste, die ich in Reviews gerne als Checkliste verwende: - Bundle klein halten mit esbuild und Tree-Shaking, das in der Runtime vorhandene AWS SDK als `external` markieren. - Wenige Dependencies einsetzen und den Import-Graph bewusst schlank halten. - Wiederverwendbare Clients und Verbindungen außerhalb des Handlers anlegen und über Warm Invokes wiederverwenden. - Lazy-Init für teure, selten genutzte Ressourcen, damit die Init-Phase schlank bleibt. - Memory als CPU-Regler verstehen und empirisch tunen – mit Lambda Power Tuning statt Bauchgefühl. - Provisioned Concurrency gezielt für die wenigen latenzkritischen, planbaren Funktionen einsetzen. - Init Duration in CloudWatch und X-Ray messen, statt zu vermuten. ## Fazit Cold 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. In 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. Im 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-aws-serverless.html - [Lambda execution environment lifecycle](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtime-environment.html) - [Configure function memory (Memory = CPU)](https://docs.aws.amazon.com/lambda/latest/dg/configuration-memory.html) - [Understanding and remediating cold starts (AWS Compute Blog)](https://aws.amazon.com/blogs/compute/understanding-and-remediating-cold-starts-an-aws-lambda-perspective/) - [Operating Lambda: Performance optimization – Part 1](https://aws.amazon.com/blogs/compute/operating-lambda-performance-optimization-part-1/) - [Reuse connections with keep-alive (AWS SDK v3)](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/node-reusing-connections.html) - [Reuse connections with keep-alive (AWS SDK v2)](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/node-reusing-connections.html) - [Lambda function scaling and Provisioned Concurrency](https://docs.aws.amazon.com/lambda/latest/dg/lambda-concurrency.html) --- # RxJS in Node.js: Event Streams reaktiv verarbeiten URL: https://www.mikebild.dev/de/blog/rxjs-in-node-ereignisstroeme/ Datum: 2023-07-25 ```js let buffer = []; let timer = null; emitter.on('data', line => { const entry = JSON.parse(line); if (entry.level !== 'error') return; buffer.push(entry); if (!timer) { timer = setTimeout(() => { saveErrors(buffer); buffer = []; timer = null; }, 1000); } }); ``` Drei 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. RxJS 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. ## Von on/emit zu deklarativen Strömen Der `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. RxJS 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. Die Brücke zwischen beiden Welten ist `fromEvent`. Der Operator nimmt einen Node-`EventEmitter` und einen Event-Namen und liefert ein Observable: ```js const { EventEmitter } = require('events'); const { fromEvent } = require('rxjs'); const emitter = new EventEmitter(); const data$ = fromEvent(emitter, 'data'); const subscription = data$.subscribe({ next: value => console.log('received', value), }); emitter.emit('data', { level: 'info', message: 'server started' }); ``` `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/). Ein 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. ## Die Pipeline: Operatoren als Pure Functions Der 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. Ein 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: ```js const { fromEvent } = require('rxjs'); const { map, filter, bufferTime } = require('rxjs'); const logStream$ = fromEvent(logEmitter, 'line').pipe( map(line => JSON.parse(line)), filter(entry => entry.level === 'error'), bufferTime(1000), ); const subscription = logStream$.subscribe({ next: batch => saveErrors(batch), error: err => console.error('stream failed', err), }); ``` Was 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. Jeder 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. Ein 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: ```js const { fromEvent } = require('rxjs'); const { throttleTime, scan } = require('rxjs'); const average$ = fromEvent(sensor, 'reading').pipe( map(event => event.value), throttleTime(500), scan((sum, value, index) => (sum * index + value) / (index + 1), 0), ); average$.subscribe({ next: avg => console.log('running average', avg) }); ``` `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. Wenn 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: ```js const { mergeMap } = require('rxjs'); const details$ = ids$.pipe( mergeMap(id => fetchDetails(id), 4), ); ``` Mit `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. Der grobe Fluss durch so eine Pipeline sieht immer gleich aus: ```mermaid flowchart LR E[EventEmitter] -->|emit data| F["fromEvent
(Observable)"] F --> M[map] M --> FI[filter] FI --> B[bufferTime] B --> S[Subscriber] ``` ## Ein Subject: aus einem Strom viele Konsumenten machen Bis 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. ```js const { fromEvent, Subject } = require('rxjs'); const bus = new Subject(); // one source feeds the bus fromEvent(emitter, 'data').subscribe(bus); // multiple consumers share the same stream const loggerSub = bus.subscribe({ next: v => logger.write(v) }); const metricsSub = bus.subscribe({ next: v => metrics.record(v) }); // clean up later loggerSub.unsubscribe(); metricsSub.unsubscribe(); ``` Der 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. Genau das ist der Kern der Unterscheidung zwischen Unicast und Multicast: ```mermaid flowchart LR P[Producer] --> SUB[Subject] SUB --> C1[Logger-Subscriber] SUB --> C2[Metrics-Subscriber] SUB --> C3[Audit-Subscriber] ``` Neben dem einfachen `Subject` gibt es Varianten, die je nach Anwendungsfall passen: - `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. - `ReplaySubject(bufferSize)` puffert die letzten n Werte und spielt sie neuen Subscribern vor. - `AsyncSubject` gibt nur den allerletzten Wert aus, und das erst beim `complete`. Im 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. ## Hot und cold: der Unterschied, der Ergebnisse verfälscht Ein Punkt, an dem in Teams zuverlässig Verwirrung entsteht, ist die Frage, wann die Quelle eines Observables entsteht. Ein 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. Ein 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. Diese 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`. ## Die wichtige Abgrenzung: RxJS hat keine echte Backpressure Dieser 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. RxJS 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. Was RxJS stattdessen anbietet, sind zeitbasierte und puffernde Operatoren: - `throttleTime(ms)` lässt einen Wert durch und verwirft dann für eine Weile alle weiteren. - `debounceTime(ms)` wartet, bis eine Ruhephase eintritt, und emittiert dann den letzten Wert. - `sampleTime(ms)` greift in festen Abständen den jeweils aktuellen Wert ab. - `bufferTime(ms)` sammelt über ein Fenster und liefert Bündel. - das `concurrent`-Limit von `mergeMap` begrenzt die Zahl paralleler innerer Verarbeitungen. Der 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. Man kann beide Welten verbinden. Das Repository zeigt unter `node.js` die Brücke über `rxjs-stream`: ```js const { spawn } = require('child_process'); const { streamToRx } = require('rxjs-stream'); const { map } = require('rxjs'); streamToRx(spawn('ls', ['-lha']).stdout) .pipe(map(chunk => chunk.toString())) .subscribe({ next: text => console.log(text) }); ``` `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/). ## Fallstricke, die mir immer wieder begegnen Zwei Fehler tauchen so regelmäßig auf, dass ich sie hier ausdrücklich nenne. Der 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: ```js const { fromEvent, timer } = require('rxjs'); const { takeUntil } = require('rxjs'); const stop$ = timer(60000); fromEvent(emitter, 'data') .pipe(takeUntil(stop$)) .subscribe({ next: v => process(v) }); ``` Hier 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. Der 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`. Und 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: - Statt `subscribe(next, error, complete)` mit einzelnen Argumenten den Objekt-Observer `subscribe({ next, error, complete })` verwenden – die positionsbasierte Form ist seit 6.4 überholt. - Statt `tap(fn, errFn)` mit mehreren Callbacks ebenfalls den Objekt-Observer nutzen. - Operatoren aus `'rxjs'` importieren statt aus `'rxjs/operators'`. Diese Anpassungen sind mechanisch, aber wichtig, damit der Code nicht auf einem Muster aufsetzt, das perspektivisch verschwindet. ## Wann RxJS statt rohem EventEmitter? Nicht 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: - Ich muss mehrere Verarbeitungsschritte verketten – transformieren, filtern, aggregieren – und will das nicht über verschachtelte Callbacks und geteilten Zustand lösen. - Ich brauche zeitbasierte Steuerung: drosseln, entprellen, in Fenstern bündeln. - Denselben Event Stream sollen mehrere Konsumenten unabhängig verarbeiten (Multicast über ein `Subject`). - Der Strom soll sich sauber und deklarativ beenden lassen, etwa über `takeUntil`. - Der Verarbeitungscode soll in kleine, für sich testbare Bausteine zerfallen. Umgekehrt 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. ## Fazit RxJS 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. Zwei 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. ## Weiterführende Quellen - Repository-Beispiele: `/Users/mikebild/Documents/introduction-nodejs/EXAMPLES/RXJS/operators.js`, `.../subject.js`, `.../node.js` - [fromEvent](https://rxjs.dev/api/index/function/fromEvent) - [Subject-Guide](https://rxjs.dev/guide/subject) - [Observable-Guide](https://rxjs.dev/guide/observable) - [Operatoren-Guide](https://rxjs.dev/guide/operators) - [mergeMap](https://rxjs.dev/api/operators/mergeMap) - [throttleTime](https://rxjs.dev/api/index/function/throttleTime) - [bufferTime](https://rxjs.dev/api/operators/bufferTime) - [scan](https://rxjs.dev/api/operators/scan) - [subscribe-Argumente (Deprecation)](https://rxjs.dev/deprecations/subscribe-arguments) - [Glossar und Semantik: hot/cold, unicast/multicast](https://rxjs.dev/guide/glossary-and-semantics) - [Node.js events](https://nodejs.org/docs/latest-v18.x/api/events.html) --- # Evolutionary Architecture: Systeme, die sich ändern dürfen URL: https://www.mikebild.dev/de/blog/evolutionary-architecture-wandel-einplanen/ Datum: 2023-06-26 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. Der 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. Evolutionary 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. ## Warum das Endzustands-Denken teuer wird Der 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. Der 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. Der 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. ## Der Perspektivwechsel: geführte Evolution statt fixiertes Ziel Der 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. ```mermaid flowchart LR A[Change
small increment] --> B[Deployment Pipeline] B --> C{Fitness Functions
guardrails} C -->|pass| D[Learn & Adjust] C -->|fail| E[Stop & Fix] E --> A D --> A ``` Der 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. Wichtig 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. ## Fitness Functions: Architektur, die man messen kann Der 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. Das 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". Das einfachste und bekannteste Beispiel sind strukturelle Regeln. Mit einer Bibliothek wie ArchUnit lässt sich eine Architekturvorgabe als ganz normaler Unit-Test formulieren: ```java @ArchTest static final ArchRule domain_must_not_depend_on_infrastructure = noClasses().that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("..infrastructure.."); ``` Diese 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. Und 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. Die 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. Ein Performance-Budget etwa ist eher holistisch und lässt sich als Gate in der Pipeline formulieren: ```yaml fitness_functions: - name: p95_latency_budget metric: http_request_duration_p95_ms threshold: 300 on_breach: fail_build - name: max_module_coupling metric: afferent_efferent_coupling_ratio threshold: 0.7 on_breach: warn ``` Das 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. ## Kopplung ist die eigentliche Bremse Wenn 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. Deshalb 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. Auf 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. Ein 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. ## Der Beweis, dass es sich rechnet An 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? Die 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. Die vier klassischen DORA-Metrics lesen sich selbst wie systemweite Fitness Functions: ```text Deployment Frequency - wie oft deployen wir? Lead Time for Changes - wie lange von Commit bis Produktion? Change Failure Rate - wie viele Deployments verursachen einen Fehler? Time to Restore Service - wie schnell sind wir wieder online? ``` Sie 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. ```mermaid flowchart TB subgraph Old[Architecture as end-state] O1[Big Design Up Front] --> O2[freeze] --> O3[drift & erosion] end subgraph New[Architecture as guarded evolution] N1[named characteristics] --> N2[fitness functions] --> N3[small guarded change] --> N1 end ``` Der 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. ## Fazit Architektur 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. Der 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. Der 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. ## Weiterführende Quellen - Neal Ford, Rebecca Parsons, Patrick Kua: *Building Evolutionary Architectures*, O'Reilly, 1. Aufl. 2017 / 2. Aufl. 2022 (mit Pramod Sadalage) - Martin Fowler: [Foreword to Building Evolutionary Architectures](https://martinfowler.com/articles/evo-arch-forward.html) - [DORA – Four Key Metrics](https://dora.dev/guides/dora-metrics-four-keys/) - Nicole Forsgren, Jez Humble, Gene Kim: *Accelerate*, IT Revolution, 2018 - [ArchUnit – Unit-Tests für Architekturregeln](https://www.archunit.org/) - [InfoQ – Fitness Functions for Your Architecture](https://www.infoq.com/articles/fitness-functions-architecture/) - Michael Nygard: *Release It!*, Pragmatic Bookshelf, 2. Aufl. 2018 --- # Von HOCs und Render Props zu Custom Hooks URL: https://www.mikebild.dev/de/blog/von-hocs-und-render-props-zu-custom-hooks/ Datum: 2023-06-20 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. Dieser 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. ## Die immer gleiche Frage Ein 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. Die 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. Historisch sah der Weg dorthin so aus: ```mermaid graph LR A[Copy-Paste] --> B[Mixins
deprecated] B --> C[HOCs &
Render Props] C --> D[Custom Hooks
React 16.8 / 2019] ``` Mixins 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. ## Antwort 1: Higher-Order Components Ein 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: ```js // a function that takes a component and returns a new component const withSomething = (Component) => EnhancedComponent; ``` Die Namenskonvention ist `withXxx`. Nehmen wir das Klassikerbeispiel `withLoading`, das einen Ladezustand kapselt: ```jsx import { Fragment } from "react"; function withLoading(Wrapped) { return function WithLoading(props) { if (props.loading) { return ; } return ; }; } ``` Angewendet wird das Ganze auf Modulebene, nicht im Render: ```jsx const UserPageWithLoading = withLoading(UserPage); ``` Das liest sich sauber. Und es komponiert: Man kann mehrere HOCs stapeln, `withRouter(withLoading(withData(Page)))`. Genau da hat das Ganze aber seinen Preis. Dass 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. Dazu kommen zwei weitere Stolpersteine, die man kennen muss: - Statische Methoden der Original-Komponente verschwinden, weil der Wrapper sie nicht automatisch mitkopiert – man braucht etwas wie `hoist-non-react-statics`. - 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). Und 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. ## Antwort 2: Render Props Das 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. ```jsx class OnlineStatus extends React.Component { state = { isOnline: false }; handleOnline = () => this.setState({ isOnline: true }); handleOffline = () => this.setState({ isOnline: false }); componentDidMount() { window.addEventListener("online", this.handleOnline); window.addEventListener("offline", this.handleOffline); } componentWillUnmount() { window.removeEventListener("online", this.handleOnline); window.removeEventListener("offline", this.handleOffline); } render() { return this.props.children(this.state.isOnline); } } ``` Aufgerufen wird das dann so: ```jsx {(isOnline) => ( {isOnline ? "Online" : "Offline"} )} ``` Das 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. ## Das gemeinsame Problem: Wrapper Hell Beide 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“. Genau dieser Baum ist der entscheidende Punkt. Schauen wir ihn uns an: ```mermaid graph TD subgraph HOC["HOC / Render Props: verschachtelt"] R1[withRouter] --> L1[withLoading] L1 --> D1[withData] D1 --> P1[Page] end ``` Jede 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. ## Antwort 3: Custom Hooks Custom 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. Dasselbe `withLoading`-Problem sieht als Hook so aus, dass der Hook den State liefert und die Komponente selbst entscheidet, was sie damit rendert: ```jsx import { useState, useEffect } from "react"; function useOnlineStatus() { const [isOnline, setIsOnline] = useState(true); useEffect(() => { const handleOnline = () => setIsOnline(true); const handleOffline = () => setIsOnline(false); window.addEventListener("online", handleOnline); window.addEventListener("offline", handleOffline); return () => { window.removeEventListener("online", handleOnline); window.removeEventListener("offline", handleOffline); }; }, []); return isOnline; } ``` Und die Nutzung ist so unspektakulär, wie sie sein sollte: ```jsx function StatusBar() { const isOnline = useOnlineStatus(); return {isOnline ? "Online" : "Offline"}; } ``` Vergleicht 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: ```mermaid graph TD Page2[Page] -.ruft auf.-> H1[useData] Page2 -.ruft auf.-> H2[useLoading] Page2 -.ruft auf.-> H3[useRouter] ``` Die Hooks hängen seitlich an der Komponente – es sind Funktionsaufrufe, keine Baumebenen. Das ist der ganze Trick, und es ist ein großer. Zwei Eigenschaften von Custom Hooks sind es wert, explizit ausgesprochen zu werden, weil sie oft missverstanden werden: - 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. - 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. ## Regeln, die auch für Custom Hooks gelten Das `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: Nur 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. Eine 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. ## Wann HOCs heute noch sinnvoll sind Nach 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. Das 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. Was 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: ```js // recompose - historical, deprecated since 2018 import { compose, withState } from "recompose"; const enhance = compose( withState("count", "setCount", 0) ); ``` Der 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. ## Der rote Faden Drei 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. Fü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. ## Weiterführende Quellen - [Reusing Logic with Custom Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) – das aktuelle Kapitel zu Custom Hooks - [Higher-Order Components](https://legacy.reactjs.org/docs/higher-order-components.html) – die historische HOC-Dokumentation - [Render Props](https://legacy.reactjs.org/docs/render-props.html) – das Render-Props-Pattern im Original - [Hooks – Custom Hooks (Legacy)](https://legacy.reactjs.org/docs/hooks-custom.html) – das historische Custom-Hooks-Kapitel - [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 --- # Async-Kontext in Node.js: AsyncLocalStorage, async_hooks und diagnostics_channel URL: https://www.mikebild.dev/de/blog/nodejs-async-context-mit-asynclocalstorage/ Datum: 2023-05-16 `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. Wozu 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. Das 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. ## Das Problem: Kontext durch die Tiefe fädeln Schauen 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. ```js function handler(req, res) { const requestId = randomUUID(); serviceA(req.body, requestId); } function serviceA(input, requestId) { // serviceA doesn't need requestId itself return repository(input, requestId); } function repository(input, requestId) { // repository doesn't either log('writing record', requestId); } function log(message, requestId) { console.log(`[${requestId}] ${message}`); } ``` Das 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. Man 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. Genau für dieses „context threading“-Problem gibt es in Node.js eine saubere Antwort. ## AsyncLocalStorage: der praktische Weg `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. Vorweg 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. ```js import { AsyncLocalStorage } from 'node:async_hooks'; import { randomUUID } from 'node:crypto'; const als = new AsyncLocalStorage(); function handler(req, res) { const store = { requestId: randomUUID(), user: req.user }; als.run(store, () => { serviceA(req.body); }); } function serviceA(input) { return repository(input); } function repository(input) { log('writing record'); } function log(message) { const { requestId } = als.getStore() ?? {}; console.log(`[${requestId}] ${message}`); } ``` Der 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. Zwei 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. Als 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. Diese Ebene zeigt das folgende Diagramm. Der Store wird oben gesetzt, die tiefen Aufrufe lesen ihn, ohne dass ein Parameter je weitergereicht wird. ```mermaid flowchart TD Req["HTTP Request"] --> Run["als.run(store)"] Run --> A["serviceA()"] A --> R["repository()"] R --> L["log()"] Store[("Store
requestId, user")] Run -.-> Store L -.->|"reads store"| Store ``` ### enterWith – die riskante Abkürzung Neben `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. `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. ## async_hooks: das Fundament darunter Woher 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. ```js import { createHook, executionAsyncId } from 'node:async_hooks'; import { writeSync } from 'node:fs'; const hook = createHook({ init(asyncId, type, triggerAsyncId) { writeSync(1, `init ${type} id=${asyncId} trigger=${triggerAsyncId}\n`); }, destroy(asyncId) { writeSync(1, `destroy id=${asyncId}\n`); }, }); hook.enable(); ``` Warum `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. Und 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. Die 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. ## diagnostics_channel: entkoppelte Instrumentierung Der 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. `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. ```js import diagnostics_channel from 'node:diagnostics_channel'; const channel = diagnostics_channel.channel('app:request:start'); function handleRequest(req) { if (channel.hasSubscribers) { channel.publish({ requestId: req.id, route: req.url, startedAt: Date.now(), }); } // ... actual work } ``` Auf der anderen Seite, in einem völlig getrennten Stück Code – vielleicht in der Observability-Schicht, vielleicht in einem Plugin – abonniert man denselben Kanal: ```js import diagnostics_channel from 'node:diagnostics_channel'; diagnostics_channel.subscribe('app:request:start', (message, name) => { console.log(`[${name}] request ${message.requestId} on ${message.route}`); }); ``` Der `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. Ein 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)`. ## Drei Ebenen, drei Aufgaben Die drei Module klingen ähnlich und tun Grundverschiedenes – genau hier entsteht die Verwirrung. Die Rollenverteilung: - `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. - `async_hooks` ist das Fundament: experimentell und ausdrücklich nicht zum direkten Gebrauch empfohlen. Es liefert den Ressourcen-Lifecycle, auf dem `AsyncLocalStorage` aufsetzt. - `diagnostics_channel` meldet Events entkoppelt: stabil seit v18.13.0. Produzenten und Konsumenten kennen sich nicht – ideal für Instrumentierung und Tracing. Das folgende Diagramm ordnet die drei zueinander an: die Kontext-Schicht auf ihrem Fundament, daneben der eigenständige Event-Bus. ```mermaid flowchart TB subgraph Context["Kontext tragen"] ALS["AsyncLocalStorage
Stable"] AH["async_hooks
Experimental"] ALS --> AH end subgraph Bus["Ereignisse entkoppelt melden"] Pub["Publisher"] --> Ch["diagnostics_channel"] Ch --> S1["Subscriber: Logger"] Ch --> S2["Subscriber: Tracer"] end ``` ## Request-Korrelation als messbare Betriebsqualität Die 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. Diese 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. Der 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. Die 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. ## Fallstricke aus der Praxis Der 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. Beim `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. Und 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. Als 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. ## Fazit Der 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. Die 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. Wenn 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. ## Weiterführende Quellen - [AsyncLocalStorage und Async-Kontext (Node.js v18.x)](https://nodejs.org/docs/latest-v18.x/api/async_context.html) - [async_hooks (Node.js v18.x)](https://nodejs.org/docs/latest-v18.x/api/async_hooks.html) - [diagnostics_channel (Node.js v18.x)](https://nodejs.org/docs/latest-v18.x/api/diagnostics_channel.html) --- # GraphQL-Auth: Identität im Context, Autorisierung in der Fachlogik URL: https://www.mikebild.dev/de/blog/graphql-auth-im-context/ Datum: 2023-04-18 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. Die 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. ## Zwei Fragen, nicht eine Authentifizierung 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. Das 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. Wenn diese Trennung sitzt, kommt die eigentliche Architekturfrage: Wo gehört die Identität hin, und wie kommt sie zu jedem Resolver, der sie braucht? ## Warum der context der richtige Ort ist In 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. Der 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. In 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. ```ts import { ApolloServer } from '@apollo/server'; import { startStandaloneServer } from '@apollo/server/standalone'; const server = new ApolloServer({ typeDefs, resolvers }); const { url } = await startStandaloneServer(server, { context: async ({ req }) => { const raw = req.headers.authorization || ''; const token = raw.replace('Bearer ', ''); const user = await getUserFromToken(token); return { user }; }, }); ``` Zwei 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". Der Ablauf sieht damit für jeden Request gleich aus: ```mermaid flowchart LR C[Client] -- "Authorization:
Bearer token" --> S[GraphQL-Server] S --> CTX["context()
Token zu user"] CTX --> R[Resolver] R --> BL[Business-Logik
prueft user + Rolle] BL -- erlaubt --> DATA[Daten] BL -- verweigert --> ERR["GraphQLError
403 FORBIDDEN"] ``` Man 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. ## Was in den context gehört – und was nicht Weil der context so verlockend praktisch ist, wird er gern zur Rumpelkammer. Es lohnt sich, den Inhalt bewusst kurz zu halten. Hinein gehört: - die Identität – `user` oder `viewer`, aus dem Token ermittelt - die Berechtigungen dieses Nutzers – `roles` oder `scopes` - request-gebundene Infrastruktur – DataLoader-Instanzen, damit Batching und Deduplizierung pro Request funktionieren - Zugänge zur Datenschicht – `db`-Handle oder Repositories - request-spezifische Werte wie Locale oder ein durchzureichendes Auth-Token für nachgelagerte Services Und ebenso klar, was **nicht** hineingehört: - globaler State, der über Requests hinweg lebt - Cross-Request-Caches (ein DataLoader ist genau deshalb pro Request, nicht global) - Secrets im Klartext, die dort nichts verloren haben Die 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. ## Autorisierung im Resolver Jetzt 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. ```ts import { GraphQLError } from 'graphql'; const resolvers = { Query: { me: (parent, args, { user }) => { if (!user) { throw new GraphQLError('Not authenticated', { extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } }, }); } return user; }, adminReport: (parent, args, { user }) => { if (!user?.roles.includes('ADMIN')) { throw new GraphQLError('Forbidden', { extensions: { code: 'FORBIDDEN', http: { status: 403 } }, }); } return buildAdminReport(); }, }, }; ``` Wichtig 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. Man 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. ## Feld-Ebene versus Query-Ebene Autorisierung 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. Weil 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. Im Grounding-Repo taucht dafür ein deklarativer Ansatz auf: eine `@auth`-Directive mit einem Rollen-Enum. ```graphql enum Role { ADMIN OWNER USER } directive @auth(requires: Role!) on FIELD_DEFINITION type User { id: ID! name: String! role: String @auth(requires: ADMIN) } type Query { me: User @auth(requires: USER) } ``` Hier 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. ```ts import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils'; import { defaultFieldResolver, GraphQLError } from 'graphql'; function authDirectiveTransformer(schema) { return mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig) => { const directive = getDirective(schema, fieldConfig, 'auth')?.[0]; if (!directive) return fieldConfig; const { requires } = directive; const { resolve = defaultFieldResolver } = fieldConfig; fieldConfig.resolve = (source, args, context, info) => { if (!context.user) { throw new GraphQLError('Not authenticated', { extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } }, }); } if (!context.user.roles.includes(requires)) { throw new GraphQLError('Forbidden', { extensions: { code: 'FORBIDDEN', http: { status: 403 } }, }); } return resolve(source, args, context, info); }; return fieldConfig; }, }); } ``` Ein 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. So elegant die Directive aussieht: Sie ist nur die halbe Wahrheit. Und genau hier fangen im Team die interessanten Diskussionen an. ## Warum Auth nicht ins Schema gehört Die `@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. Der 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. Und 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. Die saubere Variante ist deshalb: Der Resolver entscheidet nicht selbst, er **delegiert** an eine Business-Logik-Funktion, die den context bekommt und die Entscheidung trifft. ```ts const resolvers = { Query: { posts: (parent, args, context) => postRepository.getPosts(context), }, Mutation: { updatePost: (parent, { id, input }, context) => postRepository.updatePost(id, input, context), }, }; // Business logic layer – authorization lives here const postRepository = { async getPosts(context) { if (!context.user) { throw new GraphQLError('Not authenticated', { extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } }, }); } // Owner rule: only the user's own posts return db.posts.findMany({ where: { authorId: context.user.id } }); }, }; ``` Der 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. Ich stelle mir das gern als drei Schichten vor, mit dem Schema bewusst in der Mitte und ohne Entscheidungsgewalt: ```mermaid flowchart TB subgraph Transport["Transport-Schicht"] A["Authentifizierung
Token zu user im context"] end subgraph Schema["Schema-Schicht"] B["nur deklarativ
optional @auth als Hinweis"] end subgraph Domain["Business-Logik-Schicht"] D["Autorisierung
darf user diese Ressource?"] end Transport --> Schema --> Domain ``` Die Botschaft dieses Bildes: Identität wird oben ermittelt, die Berechtigungsentscheidung fällt unten. Das Schema in der Mitte beschreibt nur, es entscheidet nicht. ## Der Fallstrick, der am teuersten wird Es 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. Das 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. Damit 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. In 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. ## Weiterführende Quellen - [Grounding-Repository MikeBild/introduction-graphql](https://github.com/MikeBild/introduction-graphql) – `introduction-graphql/auth.md` und `recipes/auth/` - [graphql.org – Authorization](https://graphql.org/learn/authorization/) – warum Autorisierung in die Business-Logik-Schicht gehört - [Apollo Server – Authentication and authorization](https://www.apollographql.com/docs/apollo-server/security/authentication) – GraphQLError-Codes, `UNAUTHENTICATED` vs. `FORBIDDEN` - [Apollo Server – The context object](https://www.apollographql.com/docs/apollo-server/data/context) – context und contextValue, einmal pro Request --- # Node.js lokal debuggen und profilieren URL: https://www.mikebild.dev/de/blog/nodejs-debuggen-und-beobachten/ Datum: 2023-02-14 ```js console.log(x); console.log('HIER'); console.log('HIER 2'); ``` Diese 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. Sortieren 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. ## Warum console.log an seine Grenzen kommt `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. Wer 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. ## Der eingebaute Inspector: --inspect und --inspect-brk Das 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. Gestartet wird das mit einem Flag: ```bash node --inspect ./src/server.js ``` Auf stderr erscheint dann eine Zeile wie diese: ```text Debugger listening on ws://127.0.0.1:9229/8f2c...-uuid For help, see: https://nodejs.org/en/docs/inspector ``` Der Prozess bindet standardmäßig an `127.0.0.1:9229`. Wichtig ist der Unterschied zwischen den beiden zentralen Flags: - `--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. - `--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. ```bash node --inspect-brk ./src/server.js ``` Fü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. Wer 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. ## Vom Flag zum Client Der offene Inspector-Port ist nur die halbe Miete – ich brauche noch einen Client, der sich verbindet. Zwei Wege nutze ich regelmäßig. In 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. In VS Code lege ich mir eine Attach-Konfiguration in der `.vscode/launch.json` an: ```json { "version": "0.2.0", "configurations": [ { "type": "node", "request": "attach", "name": "Attach to Node", "port": 9229 } ] } ``` Damit 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. Der folgende Ablauf fasst zusammen, wie Flag, Agent und Client zusammenspielen: ```mermaid flowchart LR A["node --inspect
oder --inspect-brk"] --> B["Inspector-Agent
CDP über ws://127.0.0.1:9229"] B --> C["Chrome DevTools
chrome://inspect"] B --> D["VS Code
request: attach"] C --> E["Breakpoints
CPU-Profil
Heap-Snapshot"] D --> E ``` Beide 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. ## Das debug-Modul: schaltbares Logging ohne Codeänderung Nicht 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. Der Kern ist eine Factory, die man mit einem Namespace aufruft: ```js const debug = require('debug')('app:http'); function handleRequest(req, res) { debug('incoming request %s %s', req.method, req.url); // ... handle the request ... } ``` Der 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: ```bash DEBUG=app:* node server.js ``` Das `*` 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: ```bash DEBUG=app:*,-app:db node server.js ``` Damit 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`. Ein 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. ## nodemon: Auto-Reload in der Entwicklung Ein 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: ```bash nodemon --watch ./ --ignore ./node_modules ./bin/www ``` Schöner ist eine `nodemon.json` im Projektwurzelverzeichnis, weil sie die Konfiguration festhält und sich gut mit dem Inspector kombinieren lässt: ```json { "watch": ["src"], "ext": "js,json", "ignore": ["node_modules"], "exec": "node --inspect src/server.js" } ``` Ü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. ## Ein schneller Blick auf den Speicher Bevor 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: ```js const { rss, heapTotal, heapUsed, external } = process.memoryUsage(); console.error( `rss=${(rss / 1e6).toFixed(1)}MB ` + `heapUsed=${(heapUsed / 1e6).toFixed(1)}MB ` + `heapTotal=${(heapTotal / 1e6).toFixed(1)}MB` ); ``` Die 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()`. Das 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. ## Profiling über denselben Inspector Wenn 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. Wer 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: ```bash node --prof ./src/server.js # ... generate load, then stop the process ... node --prof-process isolate-0x*.log > processed.txt ``` Daneben 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. ## Die vier Stufen im Überblick Zurü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: - `console.log` – immer aktiv, unstrukturiert, sofort verfügbar. Gut für den schnellen Einzelblick, schlecht als Dauerlösung und in Produktion fehl am Platz. - `debug` – namespacegesteuert, über die `DEBUG`-Umgebungsvariable ohne Codeänderung schaltbar, Ausgabe auf stderr. Ideal, um in der Entwicklung gezielt Schichten sichtbar zu machen. - Inspector – interaktive Breakpoints in Chrome DevTools oder VS Code, über `--inspect` beziehungsweise `--inspect-brk`. Der Schritt vom Vermuten zum Untersuchen. - 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. ```mermaid flowchart TB A["console.log
immer an, unstrukturiert"] --> B["debug
DEBUG-Env, namespacegesteuert"] B --> C["Inspector
interaktive Breakpoints"] C --> D["Profiler
CPU-Profil und Heap-Snapshot"] ``` ## Zwei Fallstricke, die teuer werden können Zum Schluss zwei Dinge, die ich in Code-Reviews ausdrücklich anspreche, weil sie in Produktion echten Schaden anrichten. Der 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. Der 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: ```bash ssh -L 9229:localhost:9229 user@remote-host ``` Und 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. ## Ein Ausblick Wer 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. ## Fazit Der 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. ## Weiterführende Quellen - Grounding aus dem Repository: `introduction-nodejs/19-quality-tools.md` (Kapitel "Quality Tools") - Debugging Node.js (offizieller Guide): https://nodejs.org/en/learn/getting-started/debugging - CLI-Optionen (`--inspect`, `--inspect-brk`, `--prof`, `--cpu-prof`, `--heap-prof`): https://nodejs.org/api/cli.html - Inspector-Modul: https://nodejs.org/api/inspector.html - `process.memoryUsage()`: https://nodejs.org/api/process.html#processmemoryusage - debug-Modul: https://www.npmjs.com/package/debug bzw. https://github.com/debug-js/debug - nodemon: https://github.com/remy/nodemon - Ausblick – diagnostics_channel: https://nodejs.org/api/diagnostics_channel.html · async_hooks: https://nodejs.org/api/async_hooks.html --- # Developer Productivity: verkürzte Zyklen bei hoher Qualität URL: https://www.mikebild.dev/de/blog/developer-productivity-verkuerzte-zyklen/ Datum: 2022-12-26 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. Nehmen 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. Mein 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. Produktivität entsteht in zwei verbundenen Feedbackschleifen, deren Takt und Ergebnis nicht verwechselt werden dürfen. ::::comparison{title="Zwei Schleifen, zwei Arten von Feedback" role="primary" preferredPattern="split-comparison"} :::side{label="Delivery-Schleife"} - Frage · Wie schnell erreicht eine kleine Änderung verlässlich Produktion? - Folge · Code, Build, Test, Review und Deploy - Zeitmaß · Minuten bis Stunden - Signal · technische Rückmeldung und Produktionsverhalten ::: :::side{label="Discovery-Schleife"} - Frage · Löst die Änderung das richtige Problem? - Folge · Hypothese, Umsetzung, Messung und Lernen - Zeitmaß · Tage bis Wochen - Signal · beobachtete Wirkung und validiertes Lernen ::: :::: Eine 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. ## Speed und Stability sind kein Trade-off Der 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. Der 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. Operationalisiert 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. Ein 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. ## Warum der Engpass das Warten ist Continuous 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. Die 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. ```yaml pipeline: - stage: commit # target: < 10 min -> fastest feedback steps: [compile, unit_tests, static_analysis] - stage: acceptance # target: minutes -> contract & integration steps: [contract_tests, integration_tests] - stage: deploy_staging # ephemeral environment on demand steps: [provision_env, smoke_tests] - stage: production # gated by feature_flag, not by branch age steps: [progressive_rollout, health_check] ``` Vor 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. Das 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. ## Die zwei Schleifen Kurze 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. Diese ä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. ```mermaid flowchart LR subgraph inner["Inner loop – Delivery (minutes)"] code["code"] --> commit["commit"] commit -->|"wait: build"| build["build"] build --> tests["automated tests"] tests -->|"wait: review"| review["review"] review -->|"wait: provision env"| deploy["deploy"] end subgraph outer["Outer loop – Discovery (days / weeks)"] hypothesis["hypothesis"] --> buildit["build"] buildit --> measure["measure"] measure --> learn["learn"] learn --> hypothesis end deploy --> measure learn -.-> code ``` Die 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. ## Qualität ist das Netz, nicht die Bremse Jetzt 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. Deshalb 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. ```javascript // consumer side: describe the expectation, generate a pact file consumerTest("fetches a user by id", () => { provider .given("a user exists") .uponReceiving("a request for user 42") .withRequest({ method: "GET", path: "/users/42" }) .willRespondWith({ status: 200, body: { id: 42, name: like("Ada") }, // matcher, not a fixed value }); // -> produces a pact contract, verified in the provider's own pipeline }); ``` Der 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. Auf 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. ## Wie man kurze Zyklen konkret baut Aus der Praxis ein paar Hebel, die zuverlässig wirken – geordnet nach dem, was die Wartekanten am direktesten trifft. Der 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. Branches 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. Umgebungen 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. Und 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. ## Fazit Produktivitä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. Wenn 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. ## Weiterführende Quellen - Nicole Forsgren, Jez Humble, Gene Kim: *Accelerate* (2018) – der Befund, dass Speed und Stability kein Trade-off sind: - Four Keys / DORA-Metrics erklärt: - DORA-Metrics und die Cluster-Einordnung: - Martin Fowler: *Deployment Pipeline*: - Martin Fowler: *Continuous Delivery*: - Teresa Torres: *Continuous Discovery Habits* (2021): --- # Slots, Context und Web Components in Svelte URL: https://www.mikebild.dev/de/blog/svelte-slots-context-und-web-components/ Datum: 2022-11-22 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. Slots, 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. **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. ## Slots: Inhalt von außen hineinreichen Ein 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: ```svelte
Nothing here yet
``` Der Inhalt zwischen den ``-Tags ist der Fallback: Er erscheint nur, wenn der Aufrufer nichts übergibt. Genutzt wird die Card dann so: ```svelte

This paragraph lands inside the card.

``` Interessant 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: ```svelte
{#if $$slots.header}
{/if}
``` ```svelte

Release notes

Everything else fills the default slot.

``` Das 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 `
`-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. Wichtig 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 props: Daten von innen nach außen Bis 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: ```svelte
    {#each items as item, index}
  • {item}
  • {/each}
``` Die 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: ```svelte {index + 1}. {item.name} – {item.role} ``` Die 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. ```mermaid flowchart LR Parent["Parent
<List let:item>"] Child["List.svelte
<slot name='item' {item}>"] Render["Rendered <li>"] Parent -- "markup (outside in)" --> Child Child -- "slot props: item, index (inside out)" --> Parent Child --> Render ``` ## Context: durch den Baum reichen ohne Prop-Drilling Slots 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. Die Provider-Komponente legt einen Wert unter einem Schlüssel ab, jede Nachfahr-Komponente kann ihn unter demselben Schlüssel wieder abrufen: ```svelte
``` ```svelte ``` Der 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. Der 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. Zwei 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. ```mermaid graph TD Provider["Toolbar
setContext('toolbar', …)"] Group["ButtonGroup
(keine Props)"] Divider["Divider
(keine Props)"] Consumer["ToolbarButton
getContext('toolbar')"] Provider --> Group --> Divider --> Consumer ``` ### Context ist nicht dasselbe wie ein Store Context 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/). Die 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. ```svelte ``` ```svelte ``` Jetzt 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. ## Web Components: framework-neutrale Wiederverwendung Slots 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. Zwei Dinge braucht es dafür. In der Komponente vergibt man über `` einen Tag-Namen, und beim Kompilieren aktiviert man die Option `customElement`: ```svelte {name} ``` Beim Bundling schaltet man den Compiler auf Custom-Element-Ausgabe. Mit Rollup und dem offiziellen Plugin sieht der relevante Teil so aus: ```js // rollup.config.js import svelte from 'rollup-plugin-svelte'; export default { // ... plugins: [ svelte({ compilerOptions: { customElement: true, }, }), ], }; ``` Danach ist `` ein ganz normales HTML-Element. Es lässt sich in jeder Seite verwenden, ganz ohne Svelte-Laufzeit im Aufrufer: ```html ``` Die 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. An 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. Ein 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. ## Wann welches Werkzeug Die drei Mechanismen lassen sich klar nach ihrem Zweck sortieren: - 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. - 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. - Web Components wählt man, wenn eine Komponente außerhalb der Svelte-Welt laufen soll – in fremden Frameworks oder in reinem HTML. Ein 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. ## Fazit Slots, 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-svelte.html - [Svelte 5: Snippets](https://svelte.dev/docs/svelte/snippet) - [Svelte: Slots im Legacy-Modus](https://svelte.dev/docs/svelte/legacy-slots) - [Svelte: Context-API](https://svelte.dev/docs/svelte/context) - [Svelte: Custom Elements](https://svelte.dev/docs/svelte/custom-elements) - [Svelte: ``](https://svelte.dev/docs/svelte/svelte-options) --- # Build vs. Runtime in Webprojekten URL: https://www.mikebild.dev/de/blog/build-vs-runtime-webprojekte/ Datum: 2022-11-12 # Build vs. Runtime in Webprojekten Beim 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. Der 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. ## Die zwei Zeitpunkte Jede 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. Der 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. ```mermaid flowchart LR subgraph Build["Build-Zeit (einmal)"] Content --> Prerender Prerender --> StaticHTML["Static HTML + Assets"] end subgraph Runtime["Laufzeit (pro Request)"] Request --> Fetch["Fetch data + auth"] Fetch --> Render["Render + hydrate"] end StaticHTML --> CDN CDN --> User Render --> User ``` Zwischen 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. ## Der Trade-off, konkret Ich 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. Eine 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. ```js // Build-time: runs once, per deploy export async function getStaticProps() { const posts = await cms.getPublishedPosts(); return { props: { posts } }; } ``` Ein 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. ```js // Runtime: runs on every request, knows "now" and the user export async function getServerSideProps(ctx) { const user = await auth(ctx.req); const balance = await ledger.getBalance(user.id); return { props: { balance } }; } ``` Die 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. ## Was das messbar bewirkt Der Grund, warum ich diese Grenze so ernst nehme, ist ihre Wirkung auf harte Zahlen. Eine 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. Umgekehrt 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. Ein 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. Ich fasse die Entscheidung deshalb gern in einer kleinen Tabelle zusammen, bevor ich ein Tool anfasse: | Seite | Datenfrist | Zeitpunkt | | --- | --- | --- | | Landingpage, Docs | quasi unendlich | Build | | Produktkatalog | Stunden | Build + Revalidierung | | Suchergebnis, Feed | Sekunden | Runtime (Server) | | Dashboard, Kontostand | null, personalisiert | Runtime (Server/Client) | Die Tabelle ist bewusst schlicht. Sie erzwingt genau die eine Frage, die man sonst überspringt: Wie alt darf das hier sein? ## Wie ich die Grenze ziehe Vor 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. Ein 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. ## Progressive Enhancement als dieselbe Grenze Diese 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. ```html Clock fallback ``` ```js const canvas = document.getElementById("clock"); if (canvas.getContext) { const ctx = canvas.getContext("2d"); // enhance at runtime, only when supported } ``` Genau 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. ## Der Fehlerpfad entscheidet Eine 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. Build-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. ## Typische Fehler Der 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. Der 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. ## Validierung Drei 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. ## Weiterführende Quellen - React: Thinking in React: https://react.dev/learn/thinking-in-react - React: Managing State: https://react.dev/learn/managing-state Die Quellen helfen beim Gegenprüfen von Standards und Plattformverhalten. Die eigentliche Aussage bleibt die Architekturentscheidung im konkreten Projekt. ## Schluss Build 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. --- # Streams im Objektmodus: Datenpipelines jenseits von Bytes URL: https://www.mikebild.dev/de/blog/nodejs-streams-im-objektmodus/ Datum: 2022-11-08 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. Im [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. Arbeitet 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. ## Was der Objektmodus wirklich ändert Ein 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. Der 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: ```javascript const { Readable } = require('stream'); const util = require('util'); // ES5 pattern from the training repo const ReadStream = function () { Readable.call(this, { objectMode: true }); this.data = [ { id: 0, value: 1 }, { id: 1, value: 2 }, ]; this.curIndex = 0; }; util.inherits(ReadStream, Readable); ReadStream.prototype._read = function () { if (this.curIndex === this.data.length) { return this.push(null); // signals end of stream } this.push(this.data[this.curIndex++]); }; ``` Das 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. ## Die eine Kernmethode: `_transform` Ein 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. Der 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. Hier eine Verarbeitungsstufe, die rohe CSV-Zeilen zu Objekten parst: ```javascript const { Transform } = require('stream'); class ParseRow extends Transform { constructor() { super({ objectMode: true }); } _transform(line, _encoding, callback) { const [id, name, status] = String(line).split(','); this.push({ id: Number(id), name, status }); callback(); } } ``` Und 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. ```javascript const { Transform } = require('stream'); class FilterActive extends Transform { constructor() { super({ objectMode: true }); } _transform(record, _encoding, callback) { if (record.status === 'active') { this.push(record); } // no push means the record is dropped callback(); } } ``` Eine Formatierungsstufe rundet das Bild ab. Sie nimmt das Objekt und formt eine Ausgabezeile daraus: ```javascript const { Transform } = require('stream'); class FormatOutput extends Transform { constructor() { super({ objectMode: true }); } _transform(record, _encoding, callback) { this.push(`#${record.id} ${record.name}\n`); callback(); } } ``` Drei kleine Stufen, jede mit einer einzigen Verantwortung. Genau so entsteht Komponierbarkeit. ## Stufen verketten: parsen, filtern, formatieren Die 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. ```javascript const { Readable, pipeline } = require('stream'); const csvLines = ['0,Ada,active', '1,Linus,inactive', '2,Grace,active']; pipeline( Readable.from(csvLines), new ParseRow(), new FilterActive(), new FormatOutput(), process.stdout, (err) => { if (err) { console.error('pipeline failed:', err); } else { console.log('pipeline finished'); } } ); ``` Die 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. Als Diagramm sieht dieser Datenfluss so aus: ```mermaid flowchart LR A["Source
Readable.from"] -->|"CSV line (string)"| B["Parse
_transform"] B -->|"JS object"| C["Filter
_transform"] C -->|"JS object"| D["Format
_transform"] D -->|"output line"| E["Sink
Writable"] ``` An 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. ## Aggregieren mit `_flush` Filtern 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. ```javascript const { Transform } = require('stream'); class CountByStatus extends Transform { constructor() { super({ objectMode: true }); this.counts = {}; } _transform(record, _encoding, callback) { this.counts[record.status] = (this.counts[record.status] || 0) + 1; callback(); // nothing pushed yet, just accumulate } _flush(callback) { this.push(this.counts); // emit the aggregate once at the end callback(); } } ``` Diese 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. ## Backpressure zählt jetzt Objekte `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. Das 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. Wichtig 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. Zur Abgrenzung die beiden Modi nebeneinander: - 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. - 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. ## Die Brücke zu Async-Iteratoren Bis 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`. `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. Die 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. ```javascript const { Readable } = require('stream'); async function* generateRecords() { yield { id: 0, value: 1 }; yield { id: 1, value: 2 }; yield { id: 2, value: 3 }; } async function main() { const source = Readable.from(generateRecords()); for await (const record of source) { console.log('received:', record); } } main(); ``` Man 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: ```javascript async function* filterEven(source) { for await (const n of source) { if (n % 2 === 0) { yield n; } } } ``` Ob 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. ## Zwei Fallstricke, die immer wieder zuschlagen Der 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. Der 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`. Ein 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. ## Fazit Der 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. Zwei 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. ## Weiterführende Quellen - [Node.js Stream API – Object mode](https://nodejs.org/api/stream.html#object-mode) - [Node.js Stream API – Buffering](https://nodejs.org/api/stream.html#buffering) - [Node.js Stream API – stream.Readable.from](https://nodejs.org/api/stream.html#streamreadablefromiterable-options) - [Node.js Stream API – readable[Symbol.asyncIterator]](https://nodejs.org/api/stream.html#readablesymbolasynciterator) - [Node.js Stream API – Class: stream.Transform](https://nodejs.org/api/stream.html#class-streamtransform) --- # Reproduzierbare Testpipelines: kein Flaky, kein Zufall URL: https://www.mikebild.dev/de/blog/reproduzierbare-testpipelines-kein-flaky/ Datum: 2022-10-26 ```text $ git rev-parse HEAD 9f2c1ab Lauf 412 FAIL checkout.spec.ts › Rabatt gilt bis Monatsende (2.1s) Lauf 413 PASS checkout.spec.ts › Rabatt gilt bis Monatsende (2.0s) ``` Derselbe 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. Ein 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. ## Warum das ein Business-Problem ist, kein Test-Detail Bevor 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. Martin 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. Das 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. Und 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. Der 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. ## Die drei Achsen als Gate Die 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. ```mermaid flowchart LR C[commit] --> B[build] --> T[test gate] --> M[merge] A1[TIME
frozen clock] --> T A2[DATA
seeded fixtures] --> T A3[ISOLATION
hermetic sandbox] --> T T -->|all three controlled| M T -->|any axis uncontrolled| F[flaky red] ``` Der 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. ## Achse 1: Zeit Die 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. Die 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. ```typescript interface Clock { now(): Date; } class SystemClock implements Clock { now(): Date { return new Date(); } } class FixedClock implements Clock { constructor(private readonly instant: Date) {} now(): Date { return this.instant; } } // domain code never calls `new Date()` directly: function isExpired(subscription: Subscription, clock: Clock): boolean { return subscription.expiresAt.getTime() <= clock.now().getTime(); } ``` In 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. Damit 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. ## Achse 2: Daten Die 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. Die 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. ```typescript import seedrandom from "seedrandom"; function makeRng(seed = "1337") { return seedrandom(seed); } // same seed -> same sequence -> reproducible failures function buildUser(rng: () => number): User { return { id: Math.floor(rng() * 1_000_000), tier: rng() > 0.5 ? "premium" : "free", }; } ``` Interessant 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. Contract 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. ## Achse 3: Isolation Die 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. Die 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. Fü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: ```typescript let db: EphemeralDatabase; let clock: Clock; let rng: () => number; beforeEach(async () => { rng = makeRng("1337"); // DATA: seeded clock = new FixedClock(FIXED_INSTANT); // TIME: frozen db = await startEphemeralContainer(); // ISOLATION: hermetic await db.beginTransaction(); }); afterEach(async () => { await db.rollbackTransaction(); // state reset, shared-nothing await db.stop(); }); ``` Der 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. Fowlers 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: ```mermaid flowchart LR ISO[lack of isolation] --> S[flaky build] TIME[time / clock] --> S ASYNC[async / remote services] --> S LEAK[resource leaks] --> S ``` Die 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". ## Der ehrliche Umgang mit Retries Bleibt 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. Die 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. ```yaml # conceptual pipeline stages stages: - name: test fail_on_red: true # the trustworthy merge gate - name: contract-verify run: pact can-i-deploy --to prod - name: quarantine tests: tagged("flaky") fail_on_red: false # does not block merge on_red: open_bug_ticket # but never silently ignored ``` Der 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. ## Fazit Der 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. Und 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. ## Weiterführende Quellen - Martin Fowler: "Eradicating Non-Determinism in Tests" (2011) – https://martinfowler.com/articles/nonDeterminism.html - 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 - 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 - Bazel: Hermeticity – https://bazel.build/basics/hermeticity und Test Encyclopedia – https://bazel.build/reference/test-encyclopedia - Pact: Consumer-driven contracts, deterministic data – https://docs.pact.io/ - Forsgren, Humble, Kim: "Accelerate" (2018), DORA-Metrics – https://www.atlassian.com/devops/frameworks/dora-metrics - Ford, Parsons, Kua: "Building Evolutionary Architectures" (1. Auflage, 2017) – https://nealford.com/books/buildingevolutionaryarchitectures.html - Trunk-Based Development – https://trunkbaseddevelopment.com --- # Adapter-Design: wo Frameworks enden URL: https://www.mikebild.dev/de/blog/adapter-design-framework-grenzen/ Datum: 2022-10-12 # Adapter-Design: wo Frameworks enden Ein 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. Die 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. Seit 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? ## Der Vertrag über den Build-Output SvelteKit 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. ```js import { writeFileSync } from 'node:fs'; export function adapter(options = {}) { return { name: 'sveltekit-adapter-aws', async adapt(builder) { const out = 'build'; builder.rimraf(out); builder.writeClient(`${out}/assets`); // static files for the CDN builder.writePrerendered(`${out}/prerendered`); builder.writeServer(`${out}/server`); // the SSR part const manifest = builder.generateManifest({ relativePath: './' }); writeFileSync( `${out}/server/manifest.js`, `export const manifest = ${manifest};` ); } }; } ``` Das 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. ```mermaid flowchart LR subgraph FW[Framework-Seite] SRC[Quellcode] --> VITE[Vite-Build] VITE --> CL[Client-Assets] VITE --> PR[Prerenderte
Seiten] VITE --> SV[Server-Code +
Manifest] end subgraph AD[Adapter] CL --> MAP[Zuordnung,
Bundling] PR --> MAP SV --> MAP end subgraph PF[Plattform-Seite] MAP --> S3[S3-Bucket] MAP --> LB[Lambda-Bundle] S3 --> CF[CloudFront] LB --> CF end ``` Die 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. ## Request und Response als gemeinsame Sprache Zur 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. Fü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: ```js import { Server } from './index.js'; import { manifest } from './manifest.js'; const server = new Server(manifest); await server.init({ env: process.env }); export async function handler(event) { const request = toRequest(event); // API Gateway event -> fetch Request const response = await server.respond(request, { getClientAddress: () => event.requestContext.http.sourceIp }); return toLambdaResult(response); // fetch Response -> Lambda result } ``` Die 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. ```js async function toLambdaResult(response) { const contentType = response.headers.get('content-type') || ''; const isBinary = !/^(text\/|application\/(json|javascript))/.test(contentType); return { statusCode: response.status, headers: Object.fromEntries(response.headers.entries()), // API Gateway expects cookies as a separate array cookies: splitSetCookie(response.headers.get('set-cookie')), body: isBinary ? Buffer.from(await response.arrayBuffer()).toString('base64') : await response.text(), isBase64Encoded: isBinary }; } ``` Nichts 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. ```mermaid sequenceDiagram participant C as Client participant CF as CloudFront participant L as Lambda-Handler participant K as SvelteKit-Server C->>CF: HTTP-Request CF->>L: API-Gateway-Event L->>K: fetch Request K-->>L: fetch Response L-->>CF: statusCode, headers,
cookies, body CF-->>C: HTTP-Response ``` ## Die stillen Annahmen der Umgebung Der 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. Auf Lambda sind mir drei Punkte immer wieder begegnet: - 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. - 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. - 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. Der 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. ## Wenn sich die Framework-Seite bewegt SvelteKit 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. Ich 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. Praktisch 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. ## Dieselbe Grenze, andere Frameworks Die Frage „was gehört auf welche Seite" stellt sich überall, nur beantworten die Frameworks sie unterschiedlich – und die Antworten sind aufschlussreich. Next.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. Remix 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. ## Was ich daraus mitnehme Ein 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. Deshalb 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. ## Weiterführende Quellen - Mein Adapter-Projekt: https://github.com/MikeBild/sveltekit-adapter-aws - SvelteKit-Doku zu Adaptern: https://svelte.dev/docs/kit/adapters - SvelteKit-Doku „Writing adapters": https://svelte.dev/docs/kit/writing-adapters - Remix Server Adapters: https://v2.remix.run/docs/other-api/adapter --- # RxJS in React: Observables im Datenfluss der Oberfläche URL: https://www.mikebild.dev/de/blog/rxjs-in-react-datenfluss/ Datum: 2022-10-05 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. **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. An 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. ## Kurz eingeordnet: Was ein Observable in diesem Kontext ist Ich 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. Ein 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. Diese 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. ```ts import { Observable } from 'rxjs'; // A cold observable: the callback runs once per subscriber const ticker$ = new Observable((subscriber) => { let count = 0; const id = setInterval(() => subscriber.next(count++), 1000); // teardown logic runs on unsubscribe return () => clearInterval(id); }); const subscription = ticker$.subscribe((value) => console.log(value)); // later, to stop and clean up: subscription.unsubscribe(); ``` Wer 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. ## Der erste sinnvolle Fall: Eingaben entprellen Das 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. Mit 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. ```tsx import { useEffect, useRef, useState } from 'react'; import { Subject } from 'rxjs'; import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; function SearchField() { const query$ = useRef(new Subject()).current; const [debounced, setDebounced] = useState(''); useEffect(() => { const subscription = query$ .pipe(debounceTime(300), distinctUntilChanged()) .subscribe(setDebounced); return () => subscription.unsubscribe(); }, [query$]); return ( query$.next(event.target.value)} placeholder="Enter search term" /> ); } ``` Zwei 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. ## Der Fall, in dem RxJS wirklich glänzt: Autocomplete mit switchMap Entprellen 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. `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. ```tsx import { useEffect, useRef, useState } from 'react'; import { Subject } from 'rxjs'; import { fromFetch } from 'rxjs/fetch'; import { debounceTime, distinctUntilChanged, filter, switchMap, } from 'rxjs/operators'; type Hit = { id: string; label: string }; function Autocomplete() { const query$ = useRef(new Subject()).current; const [results, setResults] = useState([]); useEffect(() => { const subscription = query$ .pipe( debounceTime(300), distinctUntilChanged(), filter((term) => term.length > 1), switchMap((term) => fromFetch(`/api/search?q=${encodeURIComponent(term)}`).pipe( switchMap((response) => response.json() as Promise), ), ), ) .subscribe(setResults); return () => subscription.unsubscribe(); }, [query$]); return (
query$.next(event.target.value)} />
    {results.map((hit) => (
  • {hit.label}
  • ))}
); } ``` Der 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. Das folgende Diagramm zeigt den Datenfluss der Pipeline von der Eingabe bis zum Re-Render. ```mermaid flowchart LR A["Input-Event
onChange"] --> B["Subject.next(term)"] B --> C["debounceTime(300)
Ruhephase abwarten"] C --> D["distinctUntilChanged
Duplikate filtern"] D --> E["switchMap
alten Request verwerfen"] E --> F["subscribe
setResults"] F --> G["React Re-Render"] ``` Wichtig 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. ```mermaid sequenceDiagram participant U as Nutzer participant P as Pipeline (switchMap) participant A as API U->>P: Eingabe "re" P->>A: Request "re" U->>P: Eingabe "rea" Note over P: switchMap cancelt
inneren Stream "re" P--xA: Request "re" abgebrochen P->>A: Request "rea" A-->>P: Antwort "rea" P->>U: setResults (nur letzte Antwort) ``` ## Kontinuierliche Ströme: WebSockets und Events Der 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. ```tsx import { useEffect, useState } from 'react'; import { webSocket } from 'rxjs/webSocket'; type Message = { id: string; text: string }; function LiveFeed() { const [messages, setMessages] = useState([]); useEffect(() => { const socket$ = webSocket('wss://example.com/feed'); const subscription = socket$.subscribe((message) => setMessages((prev) => [...prev, message]), ); // unsubscribe closes the underlying socket return () => subscription.unsubscribe(); }, []); return (
    {messages.map((message) => (
  • {message.text}
  • ))}
); } ``` Die 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. Eine 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: ```tsx import { useEffect, useRef, useState } from 'react'; import { fromEvent } from 'rxjs'; import { map, debounceTime } from 'rxjs/operators'; function ScrollTracker() { const boxRef = useRef(null); const [position, setPosition] = useState(0); useEffect(() => { const node = boxRef.current; if (!node) return; const subscription = fromEvent(node, 'scroll') .pipe( map((event) => (event.target as HTMLElement).scrollTop), debounceTime(50), ) .subscribe(setPosition); return () => subscription.unsubscribe(); }, []); return
{/* scrollable content */}
; } ``` ## Der Fallstrick, der jeden einmal erwischt: fehlendes unsubscribe Wenn 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. ```tsx // Wrong: no cleanup, the stream keeps running after unmount useEffect(() => { interval(1000).subscribe((n) => setTick(n)); // memory leak }, []); // Right: keep the subscription and release it in cleanup useEffect(() => { const subscription = interval(1000).subscribe((n) => setTick(n)); return () => subscription.unsubscribe(); }, []); ``` Der 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. Als 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. ## Wann RxJS sich lohnt – und wann nicht Die häufigste Fehlentscheidung ist, RxJS überall hineinzuziehen. Verhindern lässt sie sich mit zwei kurzen Listen. RxJS lohnt sich, wenn: - Eingaben zeitbasiert entprellt oder gedrosselt werden müssen, bevor ein teurer Effect läuft. - veraltete asynchrone Ergebnisse aktiv verworfen werden sollen (Autocomplete, konkurrierende Requests) – der Kernfall für `switchMap`. - kontinuierliche Ströme wie WebSockets, Server-Sent-Events oder Timer verarbeitet werden. - mehrere Event- und Zeitquellen kombiniert, gemischt oder zeitlich koordiniert werden müssen. RxJS lohnt sich nicht, wenn: - es um einfachen lokalen Komponentenzustand geht – dafür sind `useState` und `useReducer` da. - ein einzelner Request beim Mount geladen wird; ein `fetch` mit `AbortController` im Effect reicht, oder man greift zu einer Data-Fetching-Bibliothek. - das Team RxJS nicht beherrscht und die Anforderung keine echte asynchrone Koordination enthält – die Lernkurve zahlt sich dann nicht aus. Diese 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. ## Weiterführende Quellen - [RxJS: Push und Pull](https://www.mikebild.dev/de/blog/rxjs-push-und-pull/) – die Grundlagen zu Observables als Push-Producer. - [Observable-Guide (rxjs.dev)](https://rxjs.dev/guide/observable) - [Operators-Guide / pipe (rxjs.dev)](https://rxjs.dev/guide/operators) - [Subject (rxjs.dev)](https://rxjs.dev/guide/subject) - [switchMap (rxjs.dev)](https://rxjs.dev/api/operators/switchMap) - [debounceTime (rxjs.dev)](https://rxjs.dev/api/operators/debounceTime) - [fromEvent (rxjs.dev)](https://rxjs.dev/api/index/function/fromEvent) - [Subscription / unsubscribe (rxjs.dev)](https://rxjs.dev/guide/subscription) - [React: Using the Effect Hook (reactjs.org)](https://reactjs.org/docs/hooks-effect.html) --- # Motion, Transitions und Animationen in Svelte URL: https://www.mikebild.dev/de/blog/svelte-motion-transitions-animationen/ Datum: 2022-09-27 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. Die 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. **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. ## Drei Werkzeuge, drei Aufgaben Bevor 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. - `transition:` (aus `svelte/transition`) beschreibt, wie ein einzelnes Element in den DOM hinein- und wieder herauskommt. Es geht um Erscheinen und Verschwinden. - `animate:` (aus `svelte/animate`) beschreibt, wie sich Elemente bewegen, die im DOM bleiben, aber ihre Position ändern – also beim Umsortieren einer Liste. - `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. Diese 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. ## Transitions: Ein- und Ausblenden Eine 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. ```svelte {#if visible}

fades in and out

flies in from below, flies back down on exit
{/if} ``` `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:`. ```svelte {#if visible}
enters flying, leaves fading
{/if} ``` Das 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. Ein 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. ## Was der Compiler intern daraus macht Der 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. ```mermaid flowchart LR A["State change
{#if} / {#each}"] --> B["Svelte compiler"] B --> C["transition: / animate:
directive"] C --> D{"css function
available?"} D -->|yes| E["CSS keyframes
off main thread"] D -->|no| F["tick per frame
JS on main thread"] E --> G["DOM update"] F --> G ``` Nur 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. ## animate:flip für Listen Kommen 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. Der 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. Das 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: ```svelte
    {#each items as item (item.id)}
  • {item.label}
  • {/each}
``` Der Schlüssel `(item.id)` ist hier nicht dekorativ, sondern trägt die ganze Mechanik. Er erlaubt es Svelte, dieselbe DOM-`
  • ` ü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. Das 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. ## svelte/motion: Werte, die gleiten Das 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. `tweened` interpoliert über eine feste Dauer und eine Easing-Funktion – gut für alles, was einem klaren zeitlichen Verlauf folgen soll, etwa ein Fortschrittsbalken. ```svelte ``` `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. ```svelte
    {$size.toFixed(2)}
    ``` Zwei 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. Die 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. ## Eigene Transitions schreiben Der 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. ```js function spin(node, { duration = 400 }) { return { duration, css: (t, u) => ` transform: rotate(${u * 360}deg) scale(${t}); opacity: ${t}; ` }; } ``` Verwenden lässt sich diese eigene Transition exakt wie die eingebauten: ```svelte {#if visible}
    spins into view
    {/if} ``` Wenn 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. Zur Einordnung noch einmal die drei Werkzeuge im direkten Vergleich, damit die Zuordnung dauerhaft sitzt: - `transition:` / `in:` / `out:` – für das Erscheinen und Verschwinden einzelner Elemente, ausgelöst durch `{#if}` oder `{#each}`. - `animate:flip` – für Elemente, die im DOM bleiben und in einem keyed `{#each}` nur ihre Position tauschen. - `tweened` / `spring` aus `svelte/motion` – für interpolierte Werte, die man als Store mit `$` liest und ins Markup einbindet. ## Fazit Bewegung 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`. Der 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-svelte.html - Repository zur Artikelreihe mit den lauffähigen Svelte-Beispielen - [`svelte/transition` – offizielle Dokumentation](https://svelte.dev/docs/svelte/svelte-transition) - [`svelte/animate` – offizielle Dokumentation](https://svelte.dev/docs/svelte/svelte-animate) - [`svelte/motion` – offizielle Dokumentation](https://svelte.dev/docs/svelte/svelte-motion) --- # Buffers in Node.js: mit Binärdaten arbeiten URL: https://www.mikebild.dev/de/blog/nodejs-buffers-und-binaerdaten/ Datum: 2022-08-09 Ein `Buffer` ist kein kaputter String. Wenn nach dem Einlesen einer Datei `` 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. Solche 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. ## Warum Strings hier nicht reichen Ein 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. Node 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. ```js const fs = require('node:fs'); const data = fs.readFileSync('image.png'); console.log(Buffer.isBuffer(data)); // true console.log(data instanceof Uint8Array); // true console.log(data.length); // number of bytes console.log(data.subarray(0, 8)); // ``` Die 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. ## Einen Buffer anlegen: alloc, allocUnsafe, from Es gibt drei Wege, an einen Buffer zu kommen, und die Unterschiede sind wichtig – auch aus Sicherheitsgründen. - `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. - `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. - `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`. Der 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. ```js const zeroed = Buffer.alloc(8); console.log(zeroed); // const uninitialized = Buffer.allocUnsafe(8); console.log(uninitialized); // old memory ``` `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. Am 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`. ## Encodings: dieselben Bytes, andere Brille Der 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: ```mermaid graph LR S["String
    (Unicode-Text)"] -->|"Buffer.from(str, enc)"| B["Buffer
    (rohe Bytes)"] B -->|"buf.toString(enc)"| S B -->|"buf.toString('hex')"| H["Hex-Text"] B -->|"buf.toString('base64')"| B64["Base64-Text"] ``` Beim 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. Die 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. ```js const buf = Buffer.from('Grüße', 'utf8'); console.log(buf.length); // 7 bytes, not 5 console.log('Grüße'.length); // 5 characters console.log(buf.toString('hex')); // 4772c3bcc39f65 byte view as hex console.log(buf.toString('base64')); // R3LDvMOfZQ== byte view as base64 const b64 = buf.toString('base64'); const back = Buffer.from(b64, 'base64').toString('utf8'); console.log(back); // 'Grüße' again ``` Der 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. ## Zahlen als Bytes: die Byte-Reihenfolge Encodings 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. Das 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. ```js const buf = Buffer.alloc(4); buf.writeUInt32BE(0x89504e47, 0); // PNG signature bytes as one number console.log(buf); // console.log(buf.readUInt32BE(0).toString(16)); // 89504e47 console.log(buf.readUInt32LE(0).toString(16)); // 474e5089, reversed bytes ``` Dieselben 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. ## Die Speicherfalle: slice und subarray teilen den Speicher Jetzt 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. ```mermaid graph TD M["Byte-Block im Speicher
    [1, 2, 3, 4, 5]"] O["original
    (ganzer Block)"] --> M V["view = original.subarray(1, 3)
    (Bytes 1 und 2)"] --> M ``` Beide Namen, `original` und `view`, zeigen auf denselben Speicher. Ein Schreibzugriff über den einen ist über den anderen sichtbar. ```js const original = Buffer.from([1, 2, 3, 4, 5]); const view = original.subarray(1, 3); // shares memory, no copy view[0] = 99; console.log(view); // console.log(original); // byte 1 changed too! ``` Das 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. Wer 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. ```js const original = Buffer.from([1, 2, 3, 4, 5]); const copy = Buffer.from(original.subarray(1, 3)); // independent copy copy[0] = 99; console.log(copy); // console.log(original); // untouched ``` Zur 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. ## Wo Buffer im Alltag auftauchen Man 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. ```js const crypto = require('node:crypto'); const hash = crypto.createHash('sha256') .update('hello world') // accepts string or Buffer .digest(); // returns a Buffer console.log(Buffer.isBuffer(hash)); // true console.log(hash.toString('hex')); // b94d27b9934d3e08a52e52d7da7dabfa... ``` Hier 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. ## Was hängen bleibt Drei 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. Die 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. ## Weiterführende Quellen - [Node.js-Training: Event-Loop-Architektur](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/) - [Node.js-Streams und Backpressure](https://www.mikebild.dev/de/blog/nodejs-streams-und-backpressure/) - [Node.js-Dokumentation: Buffer](https://nodejs.org/api/buffer.html) - [Buffer.alloc(size, fill, encoding)](https://nodejs.org/api/buffer.html#static-method-bufferallocsize-fill-encoding) - [Buffer.allocUnsafe(size)](https://nodejs.org/api/buffer.html#static-method-bufferallocunsafesize) - [Buffers and character encodings](https://nodejs.org/api/buffer.html#buffers-and-character-encodings) - [buf.slice() – Legacy-Hinweis, subarray bevorzugen](https://nodejs.org/api/buffer.html#bufslicestart-end) - [MDN: Uint8Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array) --- # Svelte Stores: State außerhalb der Komponente URL: https://www.mikebild.dev/de/blog/svelte-stores-zustand-ausserhalb-der-komponente/ Datum: 2022-07-19 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? Svelte 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. Ein 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. **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. ## Die Besitzfrage steht vor der Werkzeugfrage Bevor 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. Warum 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. Fü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. ## Der Contract, nicht die Klasse Ein 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. In 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: ```js import { writable, readable, derived, get } from 'svelte/store'; ``` Die drei Store-Typen decken drei unterschiedliche Rollen ab: - `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)`. - `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. - `derived` – abgeleitet. Berechnet seinen Wert reaktiv aus einem oder mehreren anderen Stores und aktualisiert sich automatisch, wenn sich eine seiner Quellen ändert. ## Der writable Store und die $-Kurzschreibweise Fangen 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: ```js // stores.js import { writable } from 'svelte/store'; export const count = writable(0); ``` Das 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: ```svelte ``` Das `$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. Auch 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: ```svelte

    Current value: {$count}

    ``` Der 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. Und 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. ```mermaid graph TD S["writable count
    (stores.js)"] A["Component A
    $count lesen + schreiben"] B["Component B
    $count anzeigen"] C["Component C
    $count anzeigen"] S --> A S --> B S --> C A -. "count.set()" .-> S ``` Man 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. ## Abgeleiteter State mit derived Oft 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: ```js // stores.js import { writable, derived } from 'svelte/store'; export const count = writable(0); export const doubled = derived(count, $count => $count * 2); ``` `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: ```svelte

    {$count} doubled is {$doubled}

    ``` `derived` kann auch aus mehreren Quellen ableiten. Dann übergibt man ein Array von Stores und bekommt im Callback ein Array der Werte: ```js import { writable, derived } from 'svelte/store'; export const price = writable(100); export const quantity = writable(2); export const total = derived( [price, quantity], ([$price, $quantity]) => $price * $quantity ); ``` Neben 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: ```js import { derived } from 'svelte/store'; // Sets the value one second after each change of 'count' export const delayed = derived( count, ($count, set) => { const id = setTimeout(() => set($count), 1000); return () => clearTimeout(id); } ); ``` Der 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. ## readable für externe Quellen Nicht 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: ```js import { readable } from 'svelte/store'; export const time = readable(null, set => { const id = setInterval(() => set(new Date()), 1000); return () => clearInterval(id); }); ``` Hier 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. In der Komponente ist der Umgang wieder trivial, denn `$time` abonniert automatisch und räumt beim Unmount auf: ```svelte

    The time is {$time?.toLocaleTimeString()}

    ``` ## Der Fallstrick: subscribe ohne unsubscribe Bisher 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. Auß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: ```js // utils.js import { count } from './stores.js'; // WRONG: the callback runs forever. count.subscribe(value => { console.log('count is now', value); }); ``` Der 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: ```js // utils.js import { count } from './stores.js'; const unsubscribe = count.subscribe(value => { console.log('count is now', value); }); // ... later, when no longer needed: unsubscribe(); ``` Dieselbe 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. Wenn 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: ```js import { get } from 'svelte/store'; import { count } from './stores.js'; const current = get(count); ``` `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. ```mermaid sequenceDiagram participant C as Erster Subscriber participant S as Store participant Q as Externe Quelle C->>S: subscribe(run) S->>Q: start-Callback (0 -> 1) Q-->>S: Werte fließen S-->>C: run(value) C->>S: unsubscribe() S->>Q: stop-Callback (1 -> 0) ``` ## Wann Store, und wann nicht Der 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. Bleibt 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. Wo 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. Was 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-svelte.html - Repository: [mikebild.dev](https://www.mikebild.dev) – dieser und weitere Artikel zu Svelte und Frontend-Architektur - [Svelte-Reaktivität: der Compiler ohne Virtual DOM](https://www.mikebild.dev/de/blog/svelte-reaktivitaet-compiler-ohne-virtual-dom/) - [Svelte-Docs – Stores und Runes](https://svelte.dev/docs/svelte/stores) --- # Node.js-Streams: Backpressure verstehen und nutzen URL: https://www.mikebild.dev/de/blog/nodejs-streams-und-backpressure/ Datum: 2022-06-21 ``` FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory ``` Dieser 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. Streams 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. ## Warum Streams überhaupt Der 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. Ein 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. ## Die vier Typen Bevor 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. - Ein Readable ist eine Quelle, aus der man liest. `fs.createReadStream()` liefert einen Readable, ebenso der eingehende Body eines HTTP-Requests. - Ein Writable ist ein Ziel, in das man schreibt. `fs.createWriteStream()` oder die HTTP-Response sind Writables. - Ein Duplex ist lesbar und schreibbar zugleich, aber mit getrennten Kanälen. Ein `net.Socket` ist das Paradebeispiel: rein und raus über dieselbe Verbindung. - 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. Diese Typen kombiniert man zu einer Pipeline: eine Quelle, beliebig viele Transforms in der Mitte, ein Ziel am Ende. ```mermaid flowchart LR A["Readable
    fs.createReadStream"] -->|chunks| B["Transform
    gzip / uppercase"] B -->|chunks| C["Writable
    fs.createWriteStream"] C -.->|drain-Signal| A style A fill:#2b6cb0,color:#fff style B fill:#2f855a,color:#fff style C fill:#c05621,color:#fff ``` Die durchgezogenen Pfeile sind der Datenfluss vorwärts. Der gestrichelte Pfeil zurück ist das Entscheidende – er ist Backpressure. Dazu gleich mehr. ## Der naive Ansatz und warum er kippt Nehmen wir an, wir wollen eine große Datei in eine andere kopieren und dabei jeden Chunk verarbeiten. Der intuitive Code sieht so aus: ```js const fs = require('node:fs'); const source = fs.createReadStream('huge-input.log'); const target = fs.createWriteStream('huge-output.log'); source.on('data', (chunk) => { target.write(chunk); }); ``` Das 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. Das ist der Fallstrick in einem Satz: **Wer den Rückgabewert von `write()` ignoriert und einfach weiterschreibt, produziert unbegrenztes Puffern.** ## Was write() eigentlich sagt `writable.write(chunk)` gibt einen Boolean zurück, und dieser Boolean ist die halbe Miete: - `true` bedeutet: Der interne Puffer liegt noch unter `highWaterMark`, du darfst weiterschreiben. - `false` bedeutet: Der Puffer hat `highWaterMark` erreicht oder überschritten. Hör auf zu schreiben, sonst puffert Node.js ins Blaue. `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. Wenn `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: ```js source.on('data', (chunk) => { const ok = target.write(chunk); if (!ok) { source.pause(); target.once('drain', () => source.resume()); } }); ``` Wir 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. Den Zyklus kann man auch als State Machine zeichnen: ```mermaid stateDiagram-v2 [*] --> Schreiben Schreiben --> Schreiben: write() liefert true Schreiben --> Pausiert: write() liefert false
    source.pause() Pausiert --> Schreiben: 'drain'-Event
    source.resume() ``` ## pipe erledigt das automatisch Die gute Nachricht: Diesen ganzen `pause`/`resume`-Tanz muss man in der Praxis selten von Hand schreiben. `pipe()` macht genau das im Hintergrund. ```js const fs = require('node:fs'); fs.createReadStream('huge-input.log') .pipe(fs.createWriteStream('huge-output.log')); ``` `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: ```js const fs = require('node:fs'); const zlib = require('node:zlib'); fs.createReadStream('huge-input.log') .pipe(zlib.createGzip()) .pipe(fs.createWriteStream('huge-input.log.gz')); ``` Und trotzdem rate ich davon ab, produktiv nur `pipe()` zu verketten. Der Grund ist die Error Handling. ## Warum pipeline die bessere Wahl ist `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. `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: ```js const fs = require('node:fs'); const zlib = require('node:zlib'); const { pipeline } = require('node:stream'); pipeline( fs.createReadStream('huge-input.log'), zlib.createGzip(), fs.createWriteStream('huge-input.log.gz'), (err) => { if (err) { console.error('pipeline failed', err); return; } console.log('pipeline succeeded'); } ); ``` Seit 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: ```js const fs = require('node:fs'); const zlib = require('node:zlib'); const { pipeline } = require('node:stream/promises'); async function compress() { try { await pipeline( fs.createReadStream('huge-input.log'), zlib.createGzip(), fs.createWriteStream('huge-input.log.gz') ); console.log('done'); } catch (err) { console.error('pipeline failed', err); } } compress(); ``` Die 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. ## Einen eigenen Transform schreiben Der 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. ```js const { Transform } = require('node:stream'); const upperCase = new Transform({ transform(chunk, encoding, callback) { const transformed = chunk.toString().toUpperCase(); this.push(transformed); callback(); } }); ``` `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: ```js const fs = require('node:fs'); const { pipeline } = require('node:stream/promises'); await pipeline( fs.createReadStream('input.txt'), upperCase, fs.createWriteStream('output.txt') ); ``` Und 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. ## Async-Iteration als moderne Alternative Seit 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. ```js const fs = require('node:fs'); async function countLines(path) { const stream = fs.createReadStream(path, { encoding: 'utf8' }); let lines = 0; for await (const chunk of stream) { lines += chunk.split('\n').length - 1; } return lines; } ``` Der 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. ## Object-Mode und ein paar Regeln Standardmäß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. Eine 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. ## Was hängenbleiben soll Streams 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. Von 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. ## Weiterführende Quellen - Repository zur Node.js-Einführung: [mikebild/introduction-nodejs](https://github.com/mikebild/introduction-nodejs) – Lehrmaterial zu Async-Patterns inklusive `10-streams.md`. - [Node.js Stream API (v18)](https://nodejs.org/docs/latest-v18.x/api/stream.html) – Readable, Writable, Duplex, Transform, `pipe`, `pipeline`, `write`, `drain`, `highWaterMark`. - [Aktuelle Node.js Stream API – heutige Defaults](https://nodejs.org/api/stream.html) - [stream.pipeline in der offiziellen Doku](https://nodejs.org/api/stream.html#streampipelinesource-transforms-destination-callback) - [Guide: Backpressuring in Streams](https://nodejs.org/learn/modules/backpressuring-in-streams) --- # Fitness Functions: ausgewählte Architektureigenschaften prüfen URL: https://www.mikebild.dev/de/blog/fitness-functions-messbare-architektur/ Datum: 2022-05-24 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. Wie 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. Eine Fitness Function schließt den Weg von einer gewünschten Eigenschaft bis zu einer wiederholbaren Entscheidung. :::process{title="Von der Architekturaussage zum Gate" role="primary" preferredPattern="connected-process"} 1. Eigenschaft · eine fachlich relevante Architekturqualität benennen 2. Beobachtung · ein reproduzierbares Signal dafür auswählen 3. Schwelle · den noch akzeptablen Bereich ausdrücklich festlegen 4. Prüfung · das Signal automatisch und wiederholbar auswerten 5. Gate · eine Verletzung sichtbar machen oder die Änderung passieren lassen 6. Anpassung · Eigenschaft und Schwelle bei veränderten Bedingungen erneut prüfen ::: Die Reihenfolge bewahrt den fachlichen Grund der Messung. Ein vorhandenes Tool allein definiert noch keine sinnvolle Fitness Function. ## Vom Wunsch zur mechanischen Prüfung Der 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. Der 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. Damit 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. ## Die erste Fitness Function ist ein Unit-Test Der 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. Die eingangs beschworene Regel – Domäne kennt keine Infrastruktur – sieht als Fitness Function so aus: ```java @ArchTest static final ArchRule domain_must_not_depend_on_infrastructure = noClasses().that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("..infrastructure.."); @ArchTest static final ArchRule no_cyclic_dependencies = slices().matching("..(*)..").should().beFreeOfCycles(); ``` Das 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. Der 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. ## Mehr als eine Werkzeugliste Wer 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: ```mermaid quadrantChart title Einordnung von Fitness Functions x-axis "atomic (ein Merkmal)" --> "holistic (Zusammenspiel)" y-axis "triggered (bei Bedarf)" --> "continuous (laufend)" quadrant-1 "Latenz-/Lasttests in Prod" quadrant-2 "Chaos-/Resilienz-Checks" quadrant-3 "ArchUnit-Dependency-Rules" quadrant-4 "Security-Scan pro Commit" ``` *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. Der 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. ## Wenn die Eigenschaft dynamisch ist Nicht 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: ```python def latency_fitness(service): p95 = measure_p95_ms(service, window="5m") assert p95 < LATENCY_BUDGET_MS, f"p95 {p95} exceeds budget" ``` So 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. Die 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. Fü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. ## Kopplung zwischen Services messen Die 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. Als Fitness Function landet das direkt als Schritt in der Deployment-Pipeline: ```bash pact-broker can-i-deploy \ --pacticipant Checkout \ --version "$GIT_SHA" \ --to-environment production ``` Die 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. ## Der Ort, an dem alles zusammenläuft Eine 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: ```mermaid flowchart LR commit["commit"] --> build["build"] build --> gate{"Fitness
    Functions
    Gate"} gate -->|static / atomic| s["Dependency & Cycle Rules"] gate -->|dynamic / holistic| d["Latency p95 & Load"] gate -->|contract| c["Pact can-i-deploy"] s -->|fail = break build| commit d -->|fail = break build| commit c -->|fail = break build| commit gate -->|all green| deploy["deploy"] ``` Die 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. Und 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. ## Wo es in der Praxis kippt So überzeugend das Konzept ist, so zuverlässig scheitert seine naive Umsetzung an drei Stellen – und die habe ich in Projekten alle gesehen. Erstens 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. Zweitens 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. Drittens 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. ## Fazit Der 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. Der 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. Der 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. ## Weiterführende Quellen - 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) - [ArchUnit (TNG)](https://www.archunit.org/) – statische Architekturtests für Java - Nicole Forsgren, Jez Humble, Gene Kim: *Accelerate* (2018) und die DORA Four Key Metrics – [martinfowler.com: StateOfDevOpsReport](https://www.martinfowler.com/bliki/StateOfDevOpsReport.html) - Michael Nygard: *Release It!* (2. Auflage, 2018) – Stability Patterns für resilienzbezogene Fitness Functions - [Pact](https://docs.pact.io/) – Consumer-Driven Contract Testing mit `can-i-deploy` --- # Svelte-Reaktivität: der Compiler statt Virtual DOM URL: https://www.mikebild.dev/de/blog/svelte-reaktivitaet-compiler-ohne-virtual-dom/ Datum: 2022-04-26 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. Beide 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. Grundlage 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. **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. ## Der Denkfehler, den fast alle mitbringen Wer 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. Svelte 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. Wichtig 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. ## Was der Compiler übernimmt Bevor 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: - Die statische Analyse, welcher DOM-Knoten von welcher Zustandsvariablen abhängt – das Ergebnis landet als fester Code im Bundle, nicht als Berechnung zur Laufzeit. - Die Erzeugung gezielter Update-Anweisungen, die nur die tatsächlich betroffenen Knoten anfassen, statt einen kompletten Baum zu vergleichen. - 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. Dieser letzte Punkt ist der, an dem die meisten Fallen liegen, und ihm widme ich weiter unten den größten Teil. ## Reaktivität über Zuweisung Schauen 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 ` ``` Es 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. Das 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. ## Abgeleitete Werte mit `$:` Der 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. ```svelte

    {count} * 2 = {doubled}

    ``` `$: 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. `$:` kann mehr als abgeleitete Werte. Man kann damit auch Side Effects an State-Änderungen koppeln: ```svelte ``` Beide 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. Das folgende Diagramm zeigt diesen Fluss von der Zuweisung bis zum DOM-Update – inklusive des Seitenpfads, der später zur Falle wird. ```mermaid flowchart TD A[Zuweisung: count += 1] --> B[Variable als dirty markiert] B --> C[reaktive $:-Deklarationen
    neu ausgewertet] C --> D[gezieltes DOM-Update
    nur betroffene Knoten] E[array.push item] -.-> F[keine Zuweisung
    keine Referenzänderung] F -.-> G[kein Update ausgelöst] ``` ## Build-Zeit gegen Laufzeit Der 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. ```mermaid flowchart LR subgraph Build[Build-Zeit – Svelte] S1[.svelte-Komponente] --> S2[Svelte-Compiler] S2 --> S3[gezielter Update-Code
    optimiertes Vanilla-JS] end subgraph Runtime[Laufzeit – React] R1[Render-Funktion] --> R2[neuer Virtual-DOM-Baum] R2 --> R3[Diff gegen alten Baum] R3 --> R4[Patch auf echtes DOM] end ``` Der 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. Bei 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. ## Die push-Falle Jetzt 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`. ```svelte

    {numbers.join(", ")}

    ``` Man 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. Der Fix ist so einfach wie das Problem, sobald man das Modell verinnerlicht hat: Man muss eine Zuweisung erzwingen. Zwei Wege sind gängig. ```svelte
      {#each numbers as n}
    • {n}
    • {/each}
    ``` `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. Bei 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. ## Die stille Schwester: indirekte Abhängigkeiten Es 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. ```svelte ``` `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. ## Was das für die Praxis heißt Wenn ich diese beiden Fallen zusammenziehe, ergibt sich ein erstaunlich kompaktes mentales Modell für Svelte 3, das sich auf zwei Punkte eindampfen lässt: - 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. - Abhängigkeiten von `$:` müssen im Body direkt sichtbar sein. Was hinter einem Funktionsaufruf verschwindet, wird nicht getrackt. Diese 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. In 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. ## Fazit Svelte 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-svelte.html - [Svelte Docs](https://svelte.dev/docs) – die `$:`-Syntax und die zur Compile-Zeit bestimmten Abhängigkeiten im Component-Format - [Svelte Tutorial](https://svelte.dev/tutorial) – die Kapitel zu Reactivity, Assignments und Updating arrays and objects - [Virtual DOM is pure overhead](https://svelte.dev/blog/virtual-dom-is-pure-overhead) – Rich Harris zur Motivation hinter dem Compiler-Ansatz - [Write less code](https://svelte.dev/blog/write-less-code) – das Leitprinzip von Svelte, weniger Laufzeit-Code auszuliefern --- # EventEmitter: das Event-Modell im Kern von Node.js URL: https://www.mikebild.dev/de/blog/nodejs-eventemitter-und-ereignismodell/ Datum: 2022-04-19 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. Dieser 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. ## Der gemeinsame Nenner unter allem Node.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. Damit 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. ```mermaid classDiagram class EventEmitter { +on(event, listener) +once(event, listener) +off(event, listener) +emit(event, ...args) } EventEmitter <|-- HttpServer EventEmitter <|-- Readable EventEmitter <|-- Writable EventEmitter <|-- Process EventEmitter <|-- MyEmitter class HttpServer["http.Server"] class Readable["stream.Readable"] class Writable["stream.Writable"] class Process["process"] class MyEmitter["eigene Klasse"] ``` Die 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`. ## Die vier Methoden, um die sich alles dreht Die 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. ```js const { EventEmitter } = require('node:events'); const emitter = new EventEmitter(); function onTask(job) { console.log('received task', job.id); } emitter.on('task', onTask); // register a listener emitter.emit('task', { id: 1 }); // trigger it synchronously emitter.off('task', onTask); // remove it again ``` `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. Zwei Eigenschaften von `emit` sind für das Verständnis entscheidend, und beide werden gern übersehen: - `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. - Die Listener laufen **in der Reihenfolge ihrer Registrierung**. Wer zuerst `on()` aufgerufen hat, dessen Funktion läuft zuerst. ```mermaid sequenceDiagram participant C as Aufrufer participant E as EventEmitter participant L1 as Listener 1 participant L2 as Listener 2 C->>E: emit('task', data) E->>L1: listener(data) L1-->>E: fertig E->>L2: listener(data) L2-->>E: fertig E-->>C: return true ``` Der 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. ## on, once und off im Zusammenspiel Der 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. ```js const { EventEmitter } = require('node:events'); const queue = new EventEmitter(); let handled = 0; queue.on('task', () => { handled += 1; }); let drained = 0; queue.once('drain', () => { drained += 1; }); queue.emit('task'); // handled = 1 queue.emit('task'); // handled = 2 queue.emit('drain'); // drained = 1 queue.emit('drain'); // drained stays 1 - the listener is gone ``` Beim 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. Hier die Semantik der vier auf einen Blick: - `on` – Listener bleibt registriert und läuft bei jedem `emit` erneut. - `once` – Listener läuft genau einmal und entfernt sich vorher selbst. - `off` – entfernt eine Instanz eines benannten Listeners; anonyme Funktionen bekommt man so nicht mehr weg. - `emit` – ruft alle Listener synchron in Registrierungsreihenfolge auf und liefert `true`/`false` zurück. Neben 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?". ## Das error-Event ist ein Sonderfall Diese 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. Lö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. ```js const { EventEmitter } = require('node:events'); const source = new EventEmitter(); // Without this listener the next line would crash the process: source.on('error', (err) => { console.error('handled:', err.message); }); source.emit('error', new Error('boom')); // handled cleanly ``` Das 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. Es 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. ## Zu viele Listener: die Leck-Warnung Node 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`. Diese 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. ```js const { EventEmitter } = require('node:events'); const bus = new EventEmitter(); // Optional: make the warning itself visible process.on('warning', (warning) => { console.log(warning.name, '- count:', warning.count); }); // Anti-pattern: a new, never-removed listener on every iteration for (let i = 0; i < 12; i += 1) { bus.on('data', (chunk) => processChunk(chunk)); } // From the 11th listener on, the MaxListenersExceededWarning appears. ``` Entscheidend 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. ```js function onData(chunk) { processChunk(chunk); } bus.on('data', onData); // ... later, when done: bus.off('data', onData); // cleanly removed, no pile-up ``` Das 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. ## Eine eigene Klasse bauen Der `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. Das 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: ```js const { EventEmitter } = require('node:events'); class TaskQueue extends EventEmitter { constructor() { super(); this.pending = []; } add(job) { this.pending.push(job); this.emit('task', job); } run() { while (this.pending.length > 0) { const job = this.pending.shift(); try { job.handler(); this.emit('done', job); } catch (cause) { // Only emit 'error' when a listener exists - // otherwise it crashes the process: if (this.listenerCount('error') > 0) { this.emit('error', cause); } else { throw cause; } } } this.emit('drain'); } } const queue = new TaskQueue(); queue.on('task', (job) => console.log('queued', job.id)); queue.on('done', (job) => console.log('done', job.id)); queue.once('drain', () => console.log('all work finished')); queue.on('error', (err) => console.error('failed:', err.message)); queue.add({ id: 1, handler: () => {} }); queue.add({ id: 2, handler: () => { throw new Error('bad job'); } }); queue.run(); ``` An 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. Ein 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. ## Synchron heißt synchron Zum 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. Das 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. ## Fazit Der `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. ## Weiterführende Quellen - Node.js-Doku: [Events-API](https://nodejs.org/docs/latest-v16.x/api/events.html) - Node.js-Guide: [The Node.js Event emitter](https://nodejs.org/en/learn/asynchronous-work/the-nodejs-event-emitter) - Node.js-Doku: [Stream-API](https://nodejs.org/docs/latest-v16.x/api/stream.html) (erbt von `EventEmitter`) - Eigener Beitrag: [Node.js-Training rund um den Event Loop](https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/) --- # GraphQL-Paginierung: Cursor, Connections und das Relay-Pattern URL: https://www.mikebild.dev/de/blog/graphql-paginierung-und-connections/ Datum: 2022-03-08 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. Paginierung 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. ## Warum offset/limit bei bewegten Listen versagt Machen 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`. Das 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. Der 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. ```mermaid flowchart TB subgraph stabil["Stabil: cursor-basiert"] direction LR a1["Anfrage 1
    first: 2"] --> a2["liefert A, B
    endCursor = B"] a2 --> a3["Anfrage 2
    after: Cursor von B"] a3 --> a4["liefert C, D
    kein Duplikat"] end subgraph kaputt["Verschoben: offset/limit"] direction LR b1["Anfrage 1
    offset 0, limit 2"] --> b2["liefert A, B"] b2 --> b3["neuer Eintrag X
    wird vorne eingefügt"] b3 --> b4["Anfrage 2
    offset 2, limit 2"] b4 --> b5["liefert B, C
    B doppelt"] end ``` Der 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. ## Der cursor-basierte Ausweg Die 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. Der 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. Ein 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. ## Das Connections-Pattern: edges, node, pageInfo Damit 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? Die 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. Darü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. Was liefern die einzelnen Bausteine konkret? - `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). - `pageInfo.hasNextPage` sagt, ob es nach der aktuellen Seite noch weitere Elemente gibt. Das ist die maßgebliche Abbruchbedingung fürs Blättern. - `pageInfo.hasPreviousPage` ist das Gegenstück für die Rückwärtsrichtung. - `pageInfo.endCursor` ist der Cursor des letzten Elements der aktuellen Seite – genau der Wert, den der Client als `after` in die Folgeanfrage steckt. - `pageInfo.startCursor` ist das Pendant am Anfang der Seite, relevant fürs Rückwärtsblättern mit `before`. Zur 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. Ein 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. ## Ein Beispiel-Schema Genug Prosa, schauen wir auf SDL. Eine Query, die Nutzer paginiert ausliefert, sieht im Connections-Pattern so aus: ```graphql type Query { users(first: Int, after: String): UserConnection! } type UserConnection { edges: [UserEdge!]! pageInfo: PageInfo! totalCount: Int } type UserEdge { node: User! cursor: String! } type PageInfo { hasNextPage: Boolean! hasPreviousPage: Boolean! startCursor: String endCursor: String } type User { id: ID! name: String! createdAt: String! } ``` Die 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. Eine Query vom Client liest sich dann so: ```graphql query FirstPage { users(first: 2) { edges { cursor node { id name } } pageInfo { hasNextPage endCursor } } } ``` Und die Folgeanfrage nimmt den `endCursor` der vorigen Antwort als `after`: ```graphql query NextPage { users(first: 2, after: "dXNlcjpjdXJzb3I6NDI=") { edges { cursor node { id name } } pageInfo { hasNextPage endCursor } } } ``` Viele 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. ## Die Resolver-Skizze Auf 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`. Ein 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`. ```js function encodeCursor(id) { return Buffer.from(`cursor:${id}`).toString("base64"); } function decodeCursor(cursor) { const decoded = Buffer.from(cursor, "base64").toString("utf8"); return Number(decoded.split(":")[1]); } async function usersResolver(_parent, args, context) { const { first = 20, after } = args; const limit = Math.min(first, 100); // enforce an upper bound const afterId = after ? decodeCursor(after) : 0; // load one extra row to detect a following page const rows = await context.db.query( `SELECT id, name, created_at FROM users WHERE id > $1 ORDER BY id ASC LIMIT $2`, [afterId, limit + 1] ); const hasNextPage = rows.length > limit; const pageRows = hasNextPage ? rows.slice(0, limit) : rows; const edges = pageRows.map((row) => ({ node: row, cursor: encodeCursor(row.id), })); return { edges, pageInfo: { hasNextPage, hasPreviousPage: Boolean(after), startCursor: edges.length ? edges[0].cursor : null, endCursor: edges.length ? edges[edges.length - 1].cursor : null, }, }; } ``` Zwei 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. Der Ablauf über mehrere Seiten sieht als Dialog zwischen Client und Server so aus: ```mermaid sequenceDiagram participant C as Client participant S as Server C->>S: users(first: 2) S-->>C: edges A, B
    pageInfo endCursor=B, hasNextPage=true C->>S: users(first: 2, after: Cursor B) S-->>C: edges C, D
    pageInfo endCursor=D, hasNextPage=true C->>S: users(first: 2, after: Cursor D) S-->>C: edges E
    pageInfo endCursor=E, hasNextPage=false Note over C,S: Schleife endet, sobald hasNextPage=false ``` Der 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. ## Der Preis der Sortierung – und ein Datenbank-Bonus Ein 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`. Damit 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: ```sql SELECT id, created_at, name FROM users WHERE (created_at, id) > (:cursor_created_at, :cursor_id) ORDER BY created_at, id LIMIT :page_size; ``` Der 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. ## Wann offset trotzdem in Ordnung ist Cursor-Paginierung ist kein Selbstzweck. Es gibt Fälle, in denen offset völlig ausreicht: - Die Liste ist im Wesentlichen stabil, etwa ein historisches Archiv oder ein abgeschlossener Report, in den nichts mehr eingefügt wird. - 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. - Die Datenmengen sind klein und die Seiten flach, sodass weder Konsistenz- noch Performance-Probleme auftreten. Es 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. ## Fazit Paginierung 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. Das 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. ## Weiterführende Quellen - Grounding-Repository: [introduction-graphql](https://github.com/mikebild/introduction-graphql) (`introduction-graphql/paging.md`) - [GraphQL Pagination – offizielle Lernressource](https://graphql.org/learn/pagination/) - [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) --- # SvelteKit Adapter AWS: Framework trifft Cloud URL: https://www.mikebild.dev/de/blog/sveltekit-adapter-aws/ Datum: 2022-01-12 # SvelteKit Adapter AWS: Framework trifft Cloud Wenn 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. Genau 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. ## Adapter sind eine Architekturgrenze Ein 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. Diese 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`: ```javascript import { adapter } from 'sveltekit-adapter-aws'; import preprocess from 'svelte-preprocess'; export default { preprocess: preprocess(), kit: { adapter: adapter({ autoDeploy: true, FQDN: 'demo.example.com', MEMORY_SIZE: 256, LOG_RETENTION_DAYS: 7, }), }, }; ``` Mit `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. ## Warum es den AWS-Adapter nicht gab Dass 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. Ein 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. ## Die Architektur: drei Dienste, eine Anwendung Die 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. ```mermaid flowchart LR B[Browser] --> CF[CloudFront
    Verteilung] CF -->|/_app/*, Assets,
    vorgerenderte Seiten| S3[S3 Bucket
    statische Dateien] CF -->|alle übrigen
    Anfragen| GW[API Gateway
    HTTP API] GW --> L[Lambda
    SvelteKit SSR] R53[Route 53 + ACM
    eigene Domain] -.-> CF ``` Provisioniert 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: ```typescript import { Stack, StackProps, Duration } from 'aws-cdk-lib'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import { RetentionDays } from 'aws-cdk-lib/aws-logs'; export class AWSAdapterStack extends Stack { constructor(scope: Construct, id: string, props: StackProps) { super(scope, id, props); const serverHandler = new lambda.Function(this, 'ServerFunction', { runtime: lambda.Runtime.NODEJS_14_X, handler: 'index.handler', code: lambda.Code.fromAsset(process.env.SERVER_PATH!), memorySize: parseInt(process.env.MEMORY_SIZE ?? '128'), timeout: Duration.seconds(15), logRetention: RetentionDays.ONE_WEEK, }); // ... HTTP API, S3 bucket, CloudFront distribution, Route 53 records } } ``` Ein 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. ## Vom Lambda-Event zur Framework-Antwort Die 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: ```javascript // index.js – bridges API Gateway events to the SvelteKit server import { init, render } from './app.js'; init(); export async function handler(event) { const { rawPath, rawQueryString, headers, requestContext } = event; const response = await render({ method: requestContext.http.method, path: rawPath, query: new URLSearchParams(rawQueryString), headers, rawBody: event.body ? Buffer.from(event.body, event.isBase64Encoded ? 'base64' : 'utf8') : undefined, }); return { statusCode: response.status, headers: response.headers, body: response.body, }; } ``` Dieser 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: ```mermaid flowchart LR SRC[SvelteKit
    Quellcode] --> BUILD[svelte-kit build
    via Vite] BUILD --> ADAPT[adapter adapt()
    sortiert Ausgaben] ADAPT --> ESB[esbuild
    SSR-Bundle, cjs] ADAPT --> STATIC[statische Dateien
    + Prerendered] ESB --> CDK[AWS CDK v2
    synth + deploy] STATIC --> CDK CDK --> STACK[CloudFormation-Stack
    Lambda, S3, CloudFront] ``` ## Gegen eine bewegliche API bauen Der 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. Fü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. Was der Adapter beim Build konkret übernimmt, ist überschaubar und genau deshalb gut wartbar: - Client-Bundles, Assets und vorgerenderte Seiten in ein Artefakt-Verzeichnis sortieren - den Server-Teil mit esbuild zu einer Lambda-tauglichen Datei bündeln - den Handler-Shim für API Gateway daneben legen - einen CDK-Stack synthetisieren und auf Wunsch direkt ausrollen - Domain, Zertifikat und DNS-Einträge über Route 53 und ACM verdrahten, wenn ein `FQDN` gesetzt ist ## Was Serverless nicht abnimmt Auf 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. Kaltstarts 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. Dazu 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. Übrig bleibt eine ehrliche Arbeitsteilung. Der Adapter beantwortet die Frage „wie kommt die App nach AWS" – diese Fragen bleiben beim Team: - Beobachtbarkeit: welche Logs, Metrics und Alarme zeigen, dass das Rendering leidet? - Kosten: was kosten Anfragen, Datenverkehr und Logs bei realem Traffic? - Sicherheit: welche Rechte hat die SSR-Funktion, und welche braucht sie wirklich? - Lifecycle: wer zieht Framework- und Adapter-Updates nach, und wie wird ein Rollback geprobt? ## Unterm Strich Das 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. Zweitens: 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. ## Weiterführende Quellen - Repository: https://github.com/MikeBild/sveltekit-adapter-aws - SvelteKit-Dokumentation zu Adaptern: https://svelte.dev/docs/kit/adapters - AWS CDK v2 Developer Guide: https://docs.aws.amazon.com/cdk/v2/guide/home.html - Amazon CloudFront Functions: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-functions.html --- # Preview-Umgebungen pro Pull Request URL: https://www.mikebild.dev/de/blog/preview-umgebungen-pro-pull-request/ Datum: 2021-11-24 # Preview-Umgebungen pro Pull Request „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. ## Das Problem hinter dem Diff Ein 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`. Die 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. Der 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. Die 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. ## Der Outcome, nicht die Mechanik Bevor 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. *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. Der 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. Konzeptionell 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. ## Der Lifecycle einer ephemeren Umgebung Die 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. ```mermaid sequenceDiagram participant Dev as Developer participant PR as Pull Request participant CI as CI Pipeline participant Env as Preview Environment Dev->>PR: open / push commit PR->>CI: trigger build (HEAD commit) CI->>Env: deploy isolated instance (own URL + own DB) Env-->>PR: post preview URL as commit status Dev->>Env: reviewer clicks and tests Dev->>PR: push new commit PR->>CI: rebuild and redeploy Dev->>PR: merge or close PR->>Env: destroy environment ``` Wichtig 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: ```mermaid graph LR subgraph Classic["One staging for all"] A[PR 1] --> S[Shared Staging] B[PR 2] --> S C[PR 3] --> S S --> Q[Queue and conflicts] end subgraph Ephemeral["Preview per PR"] D[PR 1] --> P1[preview-1.example.com] E[PR 2] --> P2[preview-2.example.com] F[PR 3] --> P3[preview-3.example.com] end ``` ## Deklarativ statt Skript-Grab Der 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. ```json { "name": "my-app", "environments": { "review": { "addons": ["heroku-postgresql:hobby-dev"], "scripts": { "postdeploy": "bundle exec rake db:migrate db:seed" }, "env": { "FEATURE_FLAGS": "preview" } } } } ``` Der 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. Wer 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. ```yaml name: preview on: pull_request: types: [opened, synchronize, reopened] jobs: deploy-preview: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Build and push image run: | docker build -t registry.example.com/app:${{ github.sha }} . docker push registry.example.com/app:${{ github.sha }} - name: Deploy preview instance run: | ./scripts/deploy-preview.sh \ --name "pr-${{ github.event.number }}" \ --image "registry.example.com/app:${{ github.sha }}" \ --host "pr-${{ github.event.number }}.preview.example.com" - name: Comment preview URL run: | gh pr comment ${{ github.event.number }} \ --body "Preview ready: https://pr-${{ github.event.number }}.preview.example.com" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` Genauso 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: ```yaml name: preview-teardown on: pull_request: types: [closed] jobs: destroy-preview: runs-on: ubuntu-latest steps: - name: Tear down environment run: | ./scripts/destroy-preview.sh --name "pr-${{ github.event.number }}" ``` Ob 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. ## Ephemere Umgebungen als Prüfstand Eine 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: Contract-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. Feature-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. Der 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. ## Wo es wirklich wehtut Reibungslos ist das alles nicht. Der schwierige Teil ist nie das Deploy des Web-Prozesses – es ist alles darunter. Am 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. Der 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. Drittens 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. Und 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. ## Der rote Faden Preview-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. Der 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. Der 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“. ## Weiterführende Quellen - [Heroku Review Apps](https://devcenter.heroku.com/articles/github-integration-review-apps) - [Netlify: Introducing Deploy Previews (2016)](https://www.netlify.com/blog/2016/07/20/introducing-deploy-previews-in-netlify/) - [GitLab Review Apps](https://docs.gitlab.com/ci/review_apps/) - [Vercel: Every push now receives a unique URL](https://vercel.com/changelog/every-push-now-receives-a-unique-url) - [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) - Forsgren, Humble, Kim: *Accelerate* (2018) - Humble, Farley: *Continuous Delivery* (2010) - Ford, Parsons, Kua: *Building Evolutionary Architecture* (2017) --- # Jamstack als Architekturstil für Webanwendungen URL: https://www.mikebild.dev/de/blog/jamstack-architektur/ Datum: 2021-11-15 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. Deshalb fällt die Entscheidung auch eine Ebene höher als die Wahl des nächsten Frameworks. Genau dort setzt Jamstack an. Der 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. ## Der Paradigmenwechsel: klassisches SSR gegen Jamstack Um 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. Der 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. ```mermaid flowchart LR subgraph Classic["Klassisches SSR"] B1["Browser"] -->|"Request"| S1["App-Server"] S1 --> DB[("Datenbank")] DB --> S1 S1 -->|"HTML pro Request"| B1 end subgraph Jam["Jamstack"] B2["Browser"] -->|"statische Seite"| CDN["CDN
    (pre-rendered)"] B2 -->|"Dynamik zur Laufzeit"| API["APIs /
    Serverless"] end ``` Eine 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. ## Static-Site-Generatoren und Hybrid-Rendering Der 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: ```jsx import { loadMarkdown, renderToHtml, writeFile } from "./ssg"; for (const post of loadMarkdown("content/**/*.md")) { const html = renderToHtml(post); writeFile(`dist/${post.slug}/index.html`, html); } ``` In 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. Der 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: - SSG (Static Site Generation) über `getStaticProps` und `getStaticPaths` rendert die Seite im Build; danach ist sie reines Markup vom CDN. - SSR (Server-Side Rendering) über `getServerSideProps` erzeugt die Seite pro Request auf dem Server, für echt dynamische Inhalte. - ISR (Incremental Static Regeneration), seit Version 9.5 aus dem Jahr 2020 verfügbar, baut eine statische Seite zeit- oder ereignisgesteuert im Hintergrund neu. - CSR (Client-Side Rendering) über Hooks und Datenbibliotheken wie SWR lädt die Daten erst im Browser nach. Ein typisches SSG-Pattern mit einem Hauch ISR sieht so aus: ```ts export async function getStaticProps() { const posts = await fetchFromCMS(); return { props: { posts }, revalidate: 3600, // ISR: regenerate at most once per hour }; } ``` Das `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. ## CDN und Performance Warum 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. Hinzu 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. Ein 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. ## Entkopplung und API-Orchestrierung Damit 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. Die 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: ```jsx import useSWR from "swr"; const fetcher = (url) => fetch(url).then((res) => res.json()); function Cart() { const { data, error } = useSWR("/api/cart", fetcher); if (error) return

    Failed to load

    ; if (!data) return

    Loading…

    ; return ; } ``` Oder 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. Die Serverless-Functions selbst sind bewusst schlank und zustandslos. Ein Handler im Netlify- beziehungsweise Lambda-Stil sieht typischerweise so aus: ```js exports.handler = async (event, context) => { const { name } = JSON.parse(event.body); return { statusCode: 200, body: JSON.stringify({ message: `Hello ${name}` }), }; }; ``` Der 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. ## Build und CI: der Git-Push als Auslöser Die 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. ```mermaid flowchart LR Dev["git push"] --> CI["GitHub Actions
    (CI)"] CI --> SSG["Static Site Generator
    (Build)"] SSG --> Deploy["atomic deploy"] Deploy --> CDN["CDN
    (global edge)"] CDN --> User["User"] SSG -.->|"großer Katalog"| Shard["Sharding /
    parallele Builds"] Shard --> Deploy ``` Ein einfacher Workflow, der genau das tut, ist schnell aufgeschrieben: ```yaml name: build-and-deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: node-version: "16" - run: npm ci - run: npm run build - run: npx netlify deploy --prod --dir=dist ``` Bei 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. ## Sicherheit und Betriebsvorteile Ein 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: - Die TTFB kann sinken, weil vorgerendertes Markup direkt vom Edge kommt; die Interaktivität hängt zusätzlich am ausgelieferten JavaScript. - Für statische Seiten entfällt ein Applikationsserver pro Lesezugriff; die APIs bleiben eine eigene Angriffsfläche. - CDN und Plattform können einen großen Teil der Skalierung übernehmen, jeweils innerhalb ihrer Quoten und Laufzeitgrenzen. - Pay-per-execution kann bei passendem Lastprofil Kosten sparen, ist aber nicht grundsätzlich billiger. - Die Entwicklererfahrung profitiert von klarer Trennung, Git-basierten Deploys und schnellen Rollbacks. ## Drei Fallstudien aus der Praxis Damit das nicht theoretisch bleibt, drei Szenarien, die ich in dieser Reihenfolge fast als Reifegrad lese. Das 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. Das 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. Das 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. ## Wo der Jamstack an Grenzen stößt So ü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. Auch 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. ## Fazit Der 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. Entscheidend 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. ## Weiterführende Quellen - Mike Bild: „JAMstack-Architektur“ – der ausführliche Originalartikel: https://www.developer-world.de/dwx-insights/architecture-patterns/jamstack-architektur - Offizielle Jamstack-Ressource: https://jamstack.org/ - [Jamstack: Definition und Herkunft des Namens](https://jamstack.org/what-is-jamstack/) - Next.js 12 (Release vom 26.10.2021): https://nextjs.org/blog/next-12 - SvelteKit Public Beta: https://svelte.dev/blog/sveltekit-beta - Azure Static Web Apps GA (Mai 2021): https://www.infoq.com/news/2021/05/azure-static-web-apps-ga/ --- # Transaktionale E-Mails serverless: SES und Lambda URL: https://www.mikebild.dev/de/blog/ses-email-hook-serverless/ Datum: 2021-11-09 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. Dahinter 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? Diese 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. ## SES als Baustein in zwei Richtungen Bevor 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. Diese 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. - Ausgehend: `SendEmail` oder `SendTemplatedEmail`, ein verifizierter Absender, Sandbox und Quota im Blick, ausgelöst durch ein App-Event. - 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. Halten wir diese beiden Zeilen im Kopf – sie strukturieren den Rest des Artikels. ## E-Mail als Domänen-Event, nicht als SES-Konfiguration Der 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. Diese 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. Für die Frage, ob SES der richtige Baustein ist, helfen ein paar Entscheidungskriterien: - 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. - 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. - 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. Solange 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. ## Die ausgehende Bestätigungsmail Fangen 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. Das 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. ```mermaid flowchart LR A[Event: Signup] --> B[Lambda: sendConfirmationEmail] B --> C[ses:SendEmail] C --> D[Recipient Inbox] ``` Der 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. ```ts import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses"; const ses = new SESClient({ region: "eu-west-1" }); export async function sendConfirmationEmail(event: { email: string; name: string; }) { const command = new SendEmailCommand({ Source: "no-reply@example.com", Destination: { ToAddresses: [event.email], }, Message: { Subject: { Data: "Willkommen bei uns", Charset: "UTF-8", }, Body: { Html: { Data: `

    Hallo ${event.name}, dein Konto ist aktiv.

    `, Charset: "UTF-8", }, }, }, }); const result = await ses.send(command); return { messageId: result.MessageId }; } ``` Drei 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. ### Verifizierte Absender, Sandbox und Quota Der Code ist selten die Stolperstelle. Es sind drei organisatorische Punkte, die man vorher klären muss. Erstens 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. Zweitens 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. Drittens 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. Fü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. ### Templates statt String-Konkatenation Sobald 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`. ```ts import { SESClient, SendTemplatedEmailCommand } from "@aws-sdk/client-ses"; const ses = new SESClient({ region: "eu-west-1" }); export async function sendWelcome(email: string, name: string) { const command = new SendTemplatedEmailCommand({ Source: "no-reply@example.com", Destination: { ToAddresses: [email] }, Template: "WelcomeTemplate", TemplateData: JSON.stringify({ name }), }); return ses.send(command); } ``` `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. ## Der eingehende Mail-Hook Jetzt 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“. Verwaltet 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. Der ü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. ```mermaid flowchart LR A[Incoming Mail] --> B[SES Receipt Rule] B --> C[S3: raw MIME] B --> D[Lambda: handleInboundEmail] D --> E[Ticket / DB] B -.-> F[SNS Notification] ``` ### Die Receipt Rule als Infrastruktur Die 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: ```yaml Resources: SupportRule: Type: AWS::SES::ReceiptRule Properties: RuleSetName: default-rule-set Rule: Name: support-inbound Enabled: true Recipients: - support@example.com Actions: - S3Action: BucketName: inbound-mail-bucket - LambdaAction: FunctionArn: !GetAtt InboundFn.Arn InvocationType: Event ``` Zwei 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. ### Der Handler und der häufigste Irrtum Kommen 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. Das 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. ```ts import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; import { Readable } from "stream"; const s3 = new S3Client({ region: "eu-west-1" }); async function streamToString(stream: Readable): Promise { const chunks: Buffer[] = []; for await (const chunk of stream) { chunks.push(Buffer.from(chunk)); } return Buffer.concat(chunks).toString("utf-8"); } export async function handleInboundEmail(event: any) { const record = event.Records[0]; const mail = record.ses.mail; const messageId = mail.messageId; const raw = await s3.send( new GetObjectCommand({ Bucket: "inbound-mail-bucket", Key: messageId, }) ); const mime = await streamToString(raw.Body as Readable); // parse MIME, then create a support ticket await createTicket({ from: mail.commonHeaders.from?.[0], subject: mail.commonHeaders.subject, body: mime, }); return { status: "processed" }; } ``` Die 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. ### Berechtigungen und DNS, sonst passiert stumm nichts Der 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. Dazu kommt DNS. Damit Mail überhaupt bei SES ankommt, müssen die MX-Einträge deiner Domain auf `inbound-smtp..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. ## Wo SES in die größere Architektur passt Die 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. Interessant 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. ## Fazit Der 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. Der 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-aws-serverless.html - Repository: `introduction-aws`, Kapitel `ses.md` und `examples/email-hook` - [SendEmail (SES API Reference)](https://docs.aws.amazon.com/ses/latest/APIReference/API_SendEmail.html) - [SendTemplatedEmail (SES API Reference)](https://docs.aws.amazon.com/ses/latest/APIReference/API_SendTemplatedEmail.html) - [Verifizierte Identitäten anlegen](https://docs.aws.amazon.com/ses/latest/dg/creating-identities.html) - [E-Mail-Empfang – Konzepte](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-concepts.html) - [Invoke-Lambda-Action](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-action-lambda.html) - [Beispiel für ein eingehendes Mail-Event](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-action-lambda-event.html) - [Deliver-to-S3-Action](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-action-s3.html) - [Berechtigungen für den E-Mail-Empfang](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-permissions.html) --- # State-Management in React: Redux Toolkit, leichte Stores, MobX URL: https://www.mikebild.dev/de/blog/state-management-vergleich/ Datum: 2021-10-26 # State-Management in React: Redux Toolkit, leichte Stores, MobX Ein 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. **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. Bevor 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. Nicht jede State-Art braucht denselben Speicher. Die Wahl beginnt mit Besitz, Lebensdauer und Synchronisationsbedarf. ::::comparison{title="State nach Verantwortung einordnen" role="primary" preferredPattern="comparison-matrix"} :::side{label="Lokaler UI State"} - Besitzer · eine Komponente oder ein begrenzter Teilbaum - Lebensdauer · an die Darstellung gebunden - Beispiele · geöffneter Dialog und Eingabefokus ::: :::side{label="Geteilter Client State"} - Besitzer · mehrere Oberflächenbereiche - Lebensdauer · innerhalb der Anwendungssitzung - Beispiele · lokale Auswahl und noch nicht gespeicherter Arbeitsstand ::: :::side{label="Server State"} - Besitzer · Backend oder Fachsystem - Lebensdauer · unabhängig vom Browser - Beispiele · Datensätze, Berechtigungen und freigegebene Revisionen ::: :::: Die Matrix empfiehlt keine Bibliothek. Sie verhindert zunächst, dass Serverdaten wie lokaler State oder kurzlebige UI-Details wie globale Fachobjekte behandelt werden. ## Die Gegenprobe zuerst In 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. Der 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. Nimmt 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. ```mermaid flowchart TB Start["Ein Wert, der wie
    globaler State aussieht"] Start --> Global{"Wirklich app-weit,
    oder nur lokal gebraucht?"} Global -->|nur eine Ecke| Lokal["useState / useReducer
    in der Komponente"] Global -->|app-weit| URL{"Gehört er in die URL?
    Filter, Seite, Auswahl"} URL -->|ja| Adresse["Router / URL
    teilbar, überlebt Reload"] URL -->|nein| Server{"Gehört die Wahrheit
    dem Backend?"} Server -->|ja| Cache["Server-Cache
    TanStack Query / SWR"] Server -->|nein| Store["Echter Kern: erst
    jetzt Store-Wahl"] ``` Auf 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. ## Drei Familien für den echten Kern Angenommen, 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. ### Redux Toolkit: das klassische Modell, entschlackt Redux 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. Redux 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. ```js import { configureStore, createSlice } from "@reduxjs/toolkit"; const cartSlice = createSlice({ name: "cart", initialState: { items: [] }, reducers: { addItem(state, action) { // looks mutable, stays immutable via Immer state.items.push(action.payload); }, clear(state) { state.items = []; }, }, }); export const { addItem, clear } = cartSlice.actions; export const store = configureStore({ reducer: { cart: cartSlice.reducer } }); ``` Das `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. Die 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. ### Leichte Stores: Zustand und Jotai Die 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. Zustand (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. ```js import { create } from "zustand"; const useCartStore = create((set) => ({ items: [], addItem: (item) => set((state) => ({ items: [...state.items, item] })), clear: () => set({ items: [] }), })); // in a component: read only what you need const count = useCartStore((state) => state.items.length); ``` Jotai 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. ```js import { atom, useAtom } from "jotai"; const itemsAtom = atom([]); const countAtom = atom((get) => get(itemsAtom).length); // in a component const [count] = useAtom(countAtom); ``` Die 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. ### MobX: der reaktive Ansatz Die 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. Man 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. ```jsx import { makeAutoObservable } from "mobx"; import { observer } from "mobx-react-lite"; class CartStore { items = []; constructor() { makeAutoObservable(this); } addItem(item) { this.items.push(item); // direct mutation is fine here } get count() { return this.items.length; // derived, recomputes automatically } } const cart = new CartStore(); const CartBadge = observer(() => {cart.count}); ``` MobX 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. ## Woran man die Wahl festmacht Es 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. Daraus 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. ## Die Wahl ist nachrangig Kein 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. Der 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. ## Weiterführende Quellen - Repository introduction-react (Kapitel react-hooks, state-management): https://github.com/mikebild/introduction-react - Redux Toolkit, offizielle Dokumentation: https://redux-toolkit.js.org/ - Zustand: https://github.com/pmndrs/zustand - Jotai: https://jotai.org/ - MobX: https://mobx.js.org/ --- # DynamoDB-Datenmodellierung: Single-Table-Design URL: https://www.mikebild.dev/de/blog/dynamodb-datenmodellierung-single-table/ Datum: 2021-09-21 # DynamoDB-Datenmodellierung: Single-Table-Design Wer 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. Damit 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. ## Die drei Bausteine, mit denen alles anfängt Single-Table steht und fällt mit drei Grundbausteinen. Solange die nicht sitzen, hängt jede Beschreibung des Musters in der Luft. Eine 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. Der 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. Die `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. Damit die Rollen klar bleiben, die Aufgabenverteilung im Überblick: - PK bestimmt Partition und Verteilung. Jede Query braucht genau einen PK-Wert – er entscheidet, welche Item Collection überhaupt gelesen wird. - SK ordnet und filtert innerhalb der Collection, über Range-Bedingungen und `begins_with`. Damit modelliere ich 1:n-Beziehungen und Hierarchien. - 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. Der 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. ## Warum es keinen Join gibt – und was stattdessen passiert DynamoDB 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. Nehmen 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. ```mermaid flowchart LR A["Zugriffsmuster:
    Hole User + seine Posts"] --> B["PK wählen:
    USER#id"] B --> C["SK-Prefix wählen:
    USER# und POST#"] C --> D["Query auf PK = USER#123
    liefert eine Item Collection"] D --> E["Ergebnis:
    User-Item + alle Post-Items"] ``` Die Ableitungsrichtung ist wichtig: Ich starte links beim Muster und arbeite mich zum Key vor. Das ist die Denkbewegung, die relationale Entwickler am Anfang irritiert. ## Ein Single-Table-Layout Single-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: ```text PK SK weitere Attribute -------------- -------------------- ----------------------------- USER#123 USER#123 name, email, createdAt USER#123 POST#2021-09-01T... title, body, status USER#123 POST#2021-09-14T... title, body, status POST#987 COMMENT#2021-09-15.. author, text POST#987 COMMENT#2021-09-16.. author, text ``` Der 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. Fü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. Die Tabelle selbst definiere ich mit generischen Keys und einem GSI. Ein Ausschnitt der `CreateTable`-Parameter: ```json { "TableName": "cms", "BillingMode": "PAY_PER_REQUEST", "AttributeDefinitions": [ { "AttributeName": "PK", "AttributeType": "S" }, { "AttributeName": "SK", "AttributeType": "S" }, { "AttributeName": "GSI1PK", "AttributeType": "S" }, { "AttributeName": "GSI1SK", "AttributeType": "S" } ], "KeySchema": [ { "AttributeName": "PK", "KeyType": "HASH" }, { "AttributeName": "SK", "KeyType": "RANGE" } ], "GlobalSecondaryIndexes": [ { "IndexName": "GSI1", "KeySchema": [ { "AttributeName": "GSI1PK", "KeyType": "HASH" }, { "AttributeName": "GSI1SK", "KeyType": "RANGE" } ], "Projection": { "ProjectionType": "ALL" } } ] } ``` `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. ## Der Query über eine Item Collection Jetzt 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: ```ts import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb"; const client = DynamoDBDocumentClient.from(new DynamoDBClient({})); async function getPostsByUser(userId: string) { const result = await client.send( new QueryCommand({ TableName: "cms", KeyConditionExpression: "PK = :pk AND begins_with(SK, :prefix)", ExpressionAttributeValues: { ":pk": `USER#${userId}`, ":prefix": "POST#", }, }) ); return result.Items ?? []; } ``` Die `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. Für das Rückwärtsmuster über den Inverted Index ändert sich nur wenig. Ich adressiere den GSI und nutze dessen Keys: ```ts async function getCommentsByPost(postId: string) { const result = await client.send( new QueryCommand({ TableName: "cms", IndexName: "GSI1", KeyConditionExpression: "GSI1PK = :g AND begins_with(GSI1SK, :prefix)", ExpressionAttributeValues: { ":g": `POST#${postId}`, ":prefix": "COMMENT#", }, }) ); return result.Items ?? []; } ``` Das 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. ## Der Fallstrick mit dem FilterExpression Die `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. Die 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. ## Wann Single-Table lohnt – und wann es überzogen ist So 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. Single-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. Überzogen ist der Ansatz in mehreren Situationen: - 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. - 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. Alex 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. Ein 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. ## Ein Wort zu den harten Grenzen Damit 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. ## Fazit Der 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. Pragmatisch 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-aws-serverless.html - Repository und begleitender Artikel: [GraphQL-APIs mit AppSync und DynamoDB](https://www.mikebild.dev/de/blog/appsync-dynamodb-graphql-api/) - AWS Docs, Core Components (Tables, Items, Keys, Item Collections): https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html - AWS Docs, Query-API: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.html - AWS Docs, Global Secondary Indexes: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.html - AWS Docs, Local Secondary Indexes: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LSI.html - AWS Docs, Best Practices und relationale Modellierung: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-relational-modeling.html - Alex DeBrie, „The What, Why, and When of Single-Table Design“: https://www.alexdebrie.com/posts/dynamodb-single-table/ - Alex DeBrie, „How to model one-to-many relationships“: https://www.alexdebrie.com/posts/dynamodb-one-to-many/ - 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/ --- # CDN Construct: eine kleine Infrastruktur-API URL: https://www.mikebild.dev/de/blog/cdn-construct-infrastruktur-api/ Datum: 2021-09-14 # CDN Construct: eine kleine Infrastruktur-API Rund 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. Genau 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. ## Was da eigentlich verdrahtet wird Bevor 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: - ein privater S3-Bucket, bei dem Block Public Access vollständig aktiv ist - eine Origin Access Identity (OAI), über die CloudFront – und nur CloudFront – lesend auf den Bucket zugreift - die passende Bucket-Policy, die genau dieser Identität `s3:GetObject` erlaubt - eine CloudFront-Distribution mit HTTPS-Zwang, Default Root Object und sinnvollem Fehlerverhalten für Client-seitiges Routing - ein ACM-Zertifikat in us-east-1 samt DNS-Validierung - A- und AAAA-Alias-Records in der Route53-Zone, die auf die Distribution zeigen - der eigentliche Upload des Build-Outputs, inklusive Cache-Invalidierung nach dem Deployment Als Diagramm sieht die Verdrahtung so aus: ```mermaid flowchart LR Browser[Browser] -->|HTTPS| CF[CloudFront-Distribution] CF -->|nur via OAI| S3[S3-Bucket
    privat, Block Public Access] ACM[ACM-Zertifikat
    us-east-1] -.->|TLS| CF R53[Route53
    A/AAAA-Alias] -.->|löst Domain auf| CF Build[Build-Output
    z. B. ./build] -->|Upload + Invalidierung| S3 ``` Jede 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. ## Vom Template zur API Das 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. Mein 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: ```typescript #!/usr/bin/env node import { App } from '@aws-cdk/core'; import { CdkCdnConstructStack } from 'cdk-cdn-construct'; const app = new App(); // ... your own stacks ... new CdkCdnConstructStack(app, 'MyWebsite', { namespace: 'my-app-namespace', env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION, }, }); ``` Was von außen konfigurierbar bleibt, folgt bewusst einer schlichten Konvention über Umgebungsvariablen – Name, Build-Verzeichnis, Domain: ``` CDK_DEFAULT_REGION=eu-central-1 CDK_DEFAULT_ACCOUNT=default CDK_CDN_NAME=your-host-and-bucket-name CDK_CDN_PATH=./build CDK_CDN_DOMAIN=example.com ``` Das 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. Das 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. ## Ein Blick ins Innere Im 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: ```typescript const originAccessIdentity = new cloudfront.OriginAccessIdentity(this, 'WebsiteOAI'); const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', { blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, }); websiteBucket.grantRead(originAccessIdentity); const certificate = new acm.DnsValidatedCertificate(this, 'WebsiteCertificate', { domainName: props.domainName, hostedZone: props.hostedZone, region: 'us-east-1', // CloudFront accepts ACM certificates from us-east-1 only }); const distribution = new cloudfront.Distribution(this, 'WebsiteDistribution', { defaultBehavior: { origin: new origins.S3Origin(websiteBucket, { originAccessIdentity }), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, }, domainNames: [props.domainName], certificate, defaultRootObject: 'index.html', }); new route53.ARecord(this, 'WebsiteAliasRecord', { zone: props.hostedZone, recordName: props.domainName, target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)), }); ``` Drei Details daran finde ich bemerkenswert, weil sie zeigen, was ein L2/L3-Zuschnitt gegenüber rohem CloudFormation tatsächlich leistet. Erstens: `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. Zweitens: `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. Drittens: 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. ## Defaults sind Meinung, nicht Magie Der ü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. Ein 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. Beim 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. Es 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. ## Wann sich ein eigenes Construct lohnt Man 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. Der Prozess dorthin folgt einem einfachen Ablauf, den man auch als Zyklus lesen kann: ```mermaid flowchart TD A[Verdrahtung im Projekt
    zum zweiten Mal getippt] --> B[Entscheidungen benennen:
    fest, Parameter, ausgeschlossen] B --> C[Construct extrahieren
    mit minimaler Prop-Fläche] C --> D[In zwei Projekten einsetzen] D --> E{Nachjustiert?} E -->|ja, immer gleich| B E -->|nein| F[Meinung trägt] ``` Wichtig 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. Umgekehrt 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. ## Unterm Strich Aus 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. Ich 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. ## Weiterführende Quellen - Projekt-Repository: [github.com/MikeBild/cdk-cdn-construct](https://github.com/MikeBild/cdk-cdn-construct) - AWS CDK v1: [Construct Library](https://docs.aws.amazon.com/cdk/api/v1/docs/aws-construct-library.html) - 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) - ACM: [Unterstützte Regionen – Zertifikate für CloudFront in us-east-1](https://docs.aws.amazon.com/acm/latest/userguide/acm-regions.html) - AWS Solutions Constructs: [github.com/awslabs/aws-solutions-constructs](https://github.com/awslabs/aws-solutions-constructs) --- # Outcome vor Output: warum Velocity die falsche Zahl ist URL: https://www.mikebild.dev/de/blog/outcome-vor-output/ Datum: 2021-08-26 ```typescript // Sprint-Report, wie er im Statusdeck landet const sprint42 = { storyPointsCommitted: 34, storyPointsCompleted: 38, ticketsDone: 17, velocityTrend: "up", }; ``` Aus 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. Die 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. Die Trennlinie verläuft zwischen einer beobachteten Aktivität und der belegbaren Veränderung, die daraus folgt. ::::comparison{title="Aktivität und Wirkung beantworten andere Fragen" role="primary" preferredPattern="split-comparison"} :::side{label="Output"} - Beobachtung · Was wurde gebaut oder abgeschlossen? - Beispiele · Features, Tickets und Story Points - Nutzen · Planung und Nachverfolgung der Arbeit - Grenze · Belegt noch keine Veränderung beim Nutzer ::: :::side{label="Outcome"} - Beobachtung · Was hat sich im Verhalten oder Ergebnis verändert? - Beispiele · Aktivierung, Zeit bis zum ersten Wert und Fehlerquote - Nutzen · Prüft, ob eine Lösung auf das Ziel einzahlt - Grenze · Benötigt Kontext und eine belastbare Messung ::: :::: Damit bleibt Velocity als internes Planungsmaß brauchbar, ohne zur Aussage über Produktivität oder Geschäftswert zu werden. ## Velocity misst Vorhersagbarkeit, nicht Produktivität Fangen 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. Story 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. Und 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. Ich 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. ## Der wahre Output ist Wirkung – und die lässt sich nicht direkt messen Fowlers 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. Der 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. Das 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. ## Outcome statt Output: der Kern moderner Produktarbeit Genau 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. Marty 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. Das 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? ```mermaid graph TD subgraph output["Output-Sicht (Vanity)"] B[Backlog] --> S[Sprint] S --> V["Velocity-Chart
    viele erledigte Tickets"] V --> U["up and to the right"] end subgraph outcome["Outcome-Sicht"] O["Outcome
    activation rate +X%"] --> OP1[Opportunity A] O --> OP2[Opportunity B] OP1 --> SOL[Solution] SOL --> EXP[Experiment] end V -. "bewegt der Output
    die Wurzel?" .-> O ``` Das 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. ## Vanity Metrics: die Engineering-Variante der Selbsttäuschung Eric 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. Dem 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. In 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. ```typescript // Actionable: measures what actually moves the user track("activation_completed", { userId, cohort, timeToValueSeconds, // how fast does the user reach first value? }); // Vanity: feels like progress, says nothing about impact // track("features_shipped", { count }); // track("story_points_done", { total }); ``` Der 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?". ## Bessere Proxys: DORA und Fitness Functions Nun 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. DORA 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. Auf 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: ```python def test_checkout_latency_stays_within_budget(): """Architectural fitness function: checkout stays fast under load.""" result = load_test(endpoint="/checkout", concurrent_users=200) assert result.p99_ms < 500 # latency budget, not a story point assert result.error_rate < 0.01 # reliability is the real outcome ``` Der 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. ## Wie man Teams wirklich auf Outcome ausrichtet Wenn 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. ```mermaid flowchart LR A["Outcome definieren
    ein Ziel, messbar"] --> B["Opportunities
    Nutzerprobleme kartieren"] B --> C["Experimente
    hinter Feature-Flags"] C --> D["Wirkung messen
    Cohort-Delta"] D -->|"wirkt"| E["skalieren"] D -->|"wirkt nicht"| B ``` Am 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. Der 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. Ein 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. ## Fazit Der 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. Der 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. Fowlers 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? ## Weiterführende Quellen - Martin Fowler: [CannotMeasureProductivity](https://martinfowler.com/bliki/CannotMeasureProductivity.html) und [XpVelocity](https://martinfowler.com/bliki/XpVelocity.html) - Teresa Torres: [Continuous Discovery Habits](https://www.goodreads.com/book/show/58046715-continuous-discovery-habits) (2021) - 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) - Eric Ries: [The Lean Startup – Principles](https://theleanstartup.com/principles) (2011) - Neal Ford, Rebecca Parsons, Patrick Kua: [Building Evolutionary Architectures – Fitness Functions](https://www.oreilly.com/library/view/building-evolutionary-architectures/9781491986356/ch02.html) (2017) --- # Datenanalyse mit NumPy, Pandas und Matplotlib URL: https://www.mikebild.dev/de/blog/datenanalyse-mit-pandas/ Datum: 2021-08-24 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. Der 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. Die 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. Der 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. ## Warum keine Python-Schleife? NumPy und das ndarray Der 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. Dieser 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. ```python import numpy as np values = np.arange(1_000_000) # 0, 1, 2, ... 999999 result = values * 2 + 1 # vectorized, no Python loop total = result.mean() print(result.shape) # (1000000,) print(result.dtype) # int64 print(total) # 1000000.0 ``` Die 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. Neben 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. ```python import numpy as np data = np.array([-3, 7, -1, 4, 9, -8]) mask = data > 0 # array of True/False positives = data[mask] # keep only positive values clipped = np.where(data < 0, 0, data) # replace negatives with 0 print(positives) # [7 4 9] print(clipped) # [0 7 0 4 9 0] ``` `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. Eng 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. ## Pandas: Tabellen mit Beschriftung NumPy 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. Die 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. ```python import pandas as pd df = pd.DataFrame({ "region": ["north", "south", "north", "west", "south"], "amount": [120.0, 80.0, 45.0, 200.0, 95.0], "channel": ["online", "store", "online", "store", "online"], }) print(df.shape) # (5, 3) print(df["amount"].mean()) # 108.0 ``` Das 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. ### Auswahl mit loc und iloc – der klassische Stolperstein Sobald 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. `.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. ```python import pandas as pd df = pd.DataFrame( {"amount": [120, 80, 45, 200, 95]}, index=["a", "b", "c", "d", "e"], ) # label-based, endpoint INCLUDED -> rows a, b, c print(df.loc["a":"c", "amount"]) # position-based, endpoint EXCLUDED -> rows a, b, c (positions 0, 1, 2) print(df.iloc[0:3, 0]) ``` Beide 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. Zum Filtern nutzt man Boolean-Masken – genau wie in NumPy, nur dass die Bedingung sich auf eine Spalte bezieht. ```python import pandas as pd df = pd.DataFrame({ "region": ["north", "south", "north", "west"], "amount": [120, 80, 45, 200], }) big = df[df["amount"] > 100] # keep rows above 100 big_regions = df.loc[df["amount"] > 100, "region"] # filter + pick column ``` ## Der häufigste Fehler: verkettetes Indizieren Jetzt 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. ```python # problematic: chained indexing df[df["amount"] > 100]["tier"] = "high" # may write into a copy! ``` Das 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: ```python # correct: single .loc access df.loc[df["amount"] > 100, "tier"] = "high" ``` Und 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. Der 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. ## Gruppieren und aggregieren mit groupby Die 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). ```python import pandas as pd df = pd.DataFrame({ "region": ["north", "south", "north", "west", "south"], "amount": [120.0, 80.0, 45.0, 200.0, 95.0], }) summary = (df.groupby("region") .agg(total=("amount", "sum"), avg=("amount", "mean")) .reset_index()) print(summary) # region total avg # 0 north 165.0 82.5 # 1 south 175.0 87.5 # 2 west 200.0 200.0 ``` Die 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. Oft 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: ```python import pandas as pd sales = pd.DataFrame({ "region": ["north", "south", "west"], "amount": [165, 175, 200], }) managers = pd.DataFrame({ "region": ["north", "south", "east"], "manager": ["Ada", "Ben", "Cleo"], }) joined = sales.merge(managers, how="inner", on="region") print(joined) # region amount manager # 0 north 165 Ada # 1 south 175 Ben ``` Hier 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. ## Die Pipeline: laden, bereinigen, gruppieren, plotten Setzen wir die Teile zusammen. Eine typische Analyse durchläuft immer dieselben Stationen, und es hilft, diesen Ablauf einmal als Bild vor Augen zu haben. ```mermaid flowchart LR A[Rohdaten
    CSV-Datei] --> B[Laden
    pandas.read_csv] B --> C[Bereinigen
    dropna / fillna] C --> D[Transformieren
    groupby / agg] D --> E[Plotten
    matplotlib] ``` Vor 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. ```python import pandas as pd df = pd.read_csv("sales.csv") # clean: drop rows without an amount df = df.dropna(subset=["amount"]) # transform: aggregate per region summary = (df.groupby("region") .agg(total=("amount", "sum")) .reset_index()) ``` ## Datenqualität und Reproduzierbarkeit als Entscheidungsrisiko Der 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. Der 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. ```python before = len(df) df = df.dropna(subset=["amount"]) assert len(df) >= before * 0.9, "dropped more than 10% of rows" assert df["amount"].between(0, 10_000).all(), "amount out of expected range" ``` Solche 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. Der 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. ## Matplotlib: aus Zahlen wird ein Bild Am 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. ```python import matplotlib.pyplot as plt fig, ax = plt.subplots() ax.bar(summary["region"], summary["total"]) ax.set_xlabel("Region") ax.set_ylabel("Total sales") ax.set_title("Sales by region") plt.show() ``` Fü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. ## Wer macht was? Die Rollenteilung im Überblick Die Aufgabenteilung im Werkzeugkasten ist klar geschnitten, und wer sie kennt, greift für ein Problem nicht zur falschen Bibliothek: - NumPy bildet die numerische Basis: homogene Arrays, vektorisiertes Rechnen, mathematische Operationen. Alles darüber baut darauf auf. - pandas kümmert sich um strukturierte, beschriftete Tabellendaten: einlesen, bereinigen, auswählen, filtern, gruppieren, zusammenführen – der gesamte Umgang mit realen, unordentlichen Daten. - Matplotlib übernimmt die Visualisierung: Aus den fertig aufbereiteten Zahlen wird ein Diagramm, das eine Aussage transportiert. Diese Reihenfolge ist kein Zufall. Sie beschreibt genau den Weg, den die Daten nehmen: von rohen Zahlen über eine strukturierte Tabelle bis zum Bild. ## Roter Faden Der 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. Wirkung 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. Der 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-python.html - Repository `introduction-python` mit lauffähigen Beispielen zu NumPy, pandas und Matplotlib - NumPy – „the absolute basics for beginners“: https://numpy.org/doc/stable/user/absolute_beginners.html - pandas – Indexing and Selecting Data (loc/iloc, chained indexing): https://pandas.pydata.org/docs/user_guide/indexing.html - pandas – Group by: split-apply-combine: https://pandas.pydata.org/docs/user_guide/groupby.html - pandas – Merge, join, concatenate: https://pandas.pydata.org/docs/user_guide/merging.html - Matplotlib – pyplot API: https://matplotlib.org/stable/api/pyplot_summary.html --- # Wann CQRS und Event Sourcing zu viel sind URL: https://www.mikebild.dev/de/blog/wann-cqrs-und-event-sourcing-zu-viel-ist/ Datum: 2021-07-13 # Wann CQRS und Event Sourcing zu viel sind Eine 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. Diesen 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. Das 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. ## Die Baseline heißt CRUD Bevor 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." Der 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: ```ts async function updateCustomerEmail(id: string, email: string): Promise { const customer = await repo.findById(id); customer.email = email; await repo.save(customer); // read and write share the same model } ``` Ein 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. ## Was CQRS und Event Sourcing wirklich sind Damit 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. Event 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. Wichtig 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. ## Die Kosten, die man selten mitplant Getrennte 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. Ein illustrativer Kontrast zum CRUD-Handler oben zeigt die zusätzlichen beweglichen Teile. Erst die Messages: ```ts interface ChangeEmail { type: "ChangeEmail"; id: string; email: string; } interface EmailChanged { type: "EmailChanged"; id: string; email: string; version: number; } ``` Dann das Write Model, das aus seinen Events rehydriert wird und beim Ändern eine Invariante prüft, statt einfach ein Feld zu überschreiben: ```ts class Customer { private constructor( private readonly id: string, private email: string, private version: number, ) {} static empty(): Customer { return new Customer("", "", 0); } static from(events: EmailChanged[]): Customer { // fold the event stream into current state (replay) return events.reduce( (state, event) => state.apply(event), Customer.empty(), ); } private apply(event: EmailChanged): Customer { return new Customer(event.id, event.email, event.version); } changeEmail(email: string): EmailChanged[] { if (email === this.email) return []; // no change, no event return [{ type: "EmailChanged", id: this.id, email, version: this.version + 1 }]; } } ``` Und schließlich der Handler, der lädt, entscheidet, anhängt – und die Projection getrennt davon nachzieht: ```ts async function handle(command: ChangeEmail): Promise { const events = await store.load(command.id); const customer = Customer.from(events); const newEvents = customer.changeEmail(command.email); await store.append(command.id, newEvents); // source of truth // read model is updated asynchronously by a projection } ``` Die Leseseite ist ein eigenes, bewusst dummes Read Model, das aus denselben Events entsteht: ```ts class CustomerListView { private rows = new Map(); on(event: EmailChanged): void { this.rows.set(event.id, { id: event.id, email: event.email }); } // a query right after append may still return the previous email (stale read) find(id: string) { return this.rows.get(id); } } ``` Zwischen 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. Event 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. ## Ein Entscheidungsfluss Ich 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. ```mermaid flowchart TD Start([Neuer Context]) --> Inv{Echtes Domänenmodell
    mit kollidierenden Invarianten?} Inv -->|nein| Crud[CRUD genügt] Inv -->|ja| Asym{Starke Read/Write-Asymmetrie
    ODER Audit-/Zeitreise-Anforderung?} Asym -->|nein| Lite[Weiter CRUD
    oder CQRS-lite] Asym -->|ja| Team{Team trägt Eventual Consistency
    und Event-Versionierung?} Team -->|nein| Kompromiss[CRUD oder Kompromiss] Team -->|ja| Cqrs[CQRS – plus Event Sourcing,
    wenn Audit oder Temporal] ``` Der 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. ## Signale dafür und dagegen Im konkreten Projekt hängt die Antwort an ein paar Signalen, die man meist früher sieht, als man sie ernst nimmt. Dafür spricht: - ein task-basiertes UI mit vielen nebenläufig Zusammenarbeitenden, deren Aktionen fachlich kollidieren können - harte Audit-, Compliance- oder Zeitreise-Anforderungen, die eine lückenlose Historie verlangen – hier zieht besonders Event Sourcing - eine ausgeprägte Read/Write-Asymmetrie mit getrennten Skalierungszielen - eine reichhaltige Domäne mit echten, konkurrierenden Invarianten und deutlich unterschiedlichen Lese- gegenüber Schreib-Formen Dagegen spricht: - eine klassische CRUD- oder Formular-Anwendung ohne echte Invarianten-Komplexität - ein kleines Team, das mit Eventual Consistency noch keine Erfahrung hat - keine Audit- oder Historien-Anforderung, die den Aufwand rechtfertigt - der Wunsch nach einer globalen Einheitsarchitektur – oder schlicht "weil es alle machen" Der letzte Punkt führt zum eigentlichen Fallstrick. ## Der Fallstrick: das Muster als Selbstzweck Das 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. Drei 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. Dazu 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. ## Mehr bewegliche Teile, sichtbar gemacht Ein 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. ```mermaid flowchart LR subgraph CRUD C1[Client] --> H1[Handler] --> DB[(Database)] end subgraph CQRS_ES Cmd[Command] --> Agg[Aggregate] --> ES[(Event Store)] ES --> Proj[Projection] --> RM[(Read Model)] RM --> Q[Query] end ``` Diese 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. ## Fazit CQRS 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. Der 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html - Martin Fowler, CQRS: https://martinfowler.com/bliki/CQRS.html - Martin Fowler, Event Sourcing: https://martinfowler.com/eaaDev/EventSourcing.html - Martin Fowler, Command Query Separation: https://martinfowler.com/bliki/CommandQuerySeparation.html - Martin Fowler, What do you mean by Event-Driven?: https://martinfowler.com/articles/201701-event-driven.html - Greg Young, CQRS is not an Architecture (2012): https://gregfyoung.wordpress.com/2012/09/09/cqrs-is-not-an-architecture/ - Greg Young, CQRS (2012): https://gregfyoung.wordpress.com/2012/03/02/cqrs/ - Vaughn Vernon, Implementing Domain-Driven Design (Sample): https://ptgmedia.pearsoncmg.com/images/9780321834577/samplepages/0321834577.pdf - Eigenes Vortragsmaterial "CQRS + Event Sourcing" (SlideShare) Die 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. --- # GraphQL-Konzepte: Schema, Resolver, Ausführung URL: https://www.mikebild.dev/de/blog/graphql-konzepte-schema-resolver-ausfuehrung/ Datum: 2021-06-24 # GraphQL-Konzepte: Schema, Resolver, Ausführung GraphQL 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. Der 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. ## Das Schema ist ein typisierter Graph Ein 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: ```graphql type Author { id: ID! name: String! posts: [Post!]! } type Post { id: ID! title: String! author: Author! } ``` `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. Die 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. Und 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. ## Eine Query ist eine Form Der zweite Gedanke ist einfach und trotzdem der, den Umsteiger am längsten überrascht: Eine GraphQL-Query hat dieselbe Gestalt wie ihre Antwort. ```graphql query { author(id: "1") { name posts { title } } } ``` Die 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. Daraus 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. ## Das Resolver-Modell Jetzt 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: ```js function resolver(source, args, context, info) { // source – the parent object (the return value of the field above) // args – this field's arguments in the query // context – a per-request object (e.g. the logged-in user, data access) // info – information about execution state (which field, which path) } ``` Das 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. ```js const resolvers = { Query: { author: (source, { id }, context) => context.db.loadAuthor(id), }, Author: { // source here is the author from the resolver above posts: (author, args, context) => context.db.loadPostsForAuthor(author.id), }, Post: { // source here is a single post author: (post, args, context) => context.db.loadAuthor(post.authorId), }, }; ``` Fü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. Der `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. ## Die Reihenfolge der Ausführung Wie 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. ```mermaid flowchart TB Q["Query"] --> A["author-Resolver
    liefert Autor"] A --> N["name
    (Standard-Resolver)"] A --> P["posts-Resolver
    liefert Liste"] P --> T1["title von Post 1"] P --> T2["title von Post 2"] ``` Zwei 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. Zweitens, 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+1 und warum Batching dazugehört Aus 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. Der 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. ```mermaid flowchart LR R1["posts von Autor 1"] --> L["DataLoader
    sammelt IDs im Tick"] R2["posts von Autor 2"] --> L R3["posts von Autor 3"] --> L L -->|eine gebündelte Abfrage| DB[("Datenbank")] ``` Batching 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. ## Warum diese Konzepte bleiben Man 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. Genau 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. ## Weiterführende Quellen - Repository introduction-graphql (Kapitel resolver, schema, subscriptions): https://github.com/mikebild/introduction-graphql - Folienübersicht zu diesem Themenkomplex: https://slides.mikebild.dev/introduction-graphql.html - GraphQL, Ausführung: https://graphql.org/learn/execution/ - GraphQL, Schema und Typen: https://graphql.org/learn/schema/ - GraphQL-Spezifikation, Execution: https://spec.graphql.org/ - DataLoader (Batching und Caching pro Anfrage): https://github.com/graphql/dataloader --- # GraphQL-APIs mit AppSync und DynamoDB URL: https://www.mikebild.dev/de/blog/appsync-dynamodb-graphql-api/ Datum: 2021-06-15 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. Die 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. Bleibt 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. **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. ## Was AppSync eigentlich ist AppSync 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. Diese 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. Ein Resolver verbindet dabei immer genau ein Schema-Feld mit einer Data Source. Und davon gibt es 2021 eine überschaubare, aber gut abgedeckte Auswahl: - DynamoDB-Tabellen als Key-Value- und Dokument-Store - AWS Lambda für beliebige Custom-Logik - HTTP-Endpoints, um bestehende REST-APIs anzubinden - Amazon Elasticsearch Service für Volltextsuche - relationale Datenbanken über RDS bzw. Aurora Serverless sowie „None“-Local-Resolver ohne externe Quelle Fü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. ## Der Request-Fluss Bevor 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. ```mermaid flowchart LR Client[GraphQL Client] -->|Query / Mutation| AppSync[AppSync
    Schema + VTL Resolver] AppSync -->|Request Template| DDB[(DynamoDB Table)] DDB -->|Response Template| AppSync AppSync -->|JSON Result| Client AppSync -.->|other data sources| Lambda[AWS Lambda] AppSync -.->|other data sources| HTTP[HTTP Endpoint] ``` Diese 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. ## VTL: die Sprache dazwischen VTL 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. Nehmen 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: ```graphql type Post { id: ID! title: String! body: String! } type Query { getPost(id: ID!): Post } type Mutation { createPost(title: String!, body: String!): Post } type Subscription { onCreatePost: Post @aws_subscribe(mutations: ["createPost"]) } ``` Das 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`. ### Ein PutItem-Resolver Wenn ein Client `createPost` aufruft, soll ein neues Item in der DynamoDB-Tabelle landen. Das Request-Template beschreibt genau diesen `PutItem`-Aufruf: ```vtl { "version": "2017-02-28", "operation": "PutItem", "key": { "id": $util.dynamodb.toDynamoDBJson($util.autoId()) }, "attributeValues": $util.dynamodb.toMapValues($ctx.args) } ``` Hier 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. Der 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. Das Response-Template ist dagegen unspektakulär. Es gibt das geschriebene Item einfach als JSON zurück: ``` $util.toJson($ctx.result) ``` `$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. Neben 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. ### Ein GetItem-Resolver Für `getPost` ist die Sache noch schlichter. Das Request-Template beschreibt einen `GetItem`-Aufruf mit dem `id`-Argument als Key: ```vtl { "version": "2017-02-28", "operation": "GetItem", "key": { "id": $util.dynamodb.toDynamoDBJson($ctx.args.id) } } ``` Auch 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: ``` $util.toJson($ctx.result) ``` Wenn 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. Die 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. ## Subscriptions ohne eigenen Aufwand Der 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. Erinnern wir uns an die Direktive aus dem Schema: ```graphql type Subscription { onCreatePost: Post @aws_subscribe(mutations: ["createPost"]) } ``` Diese 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. ```mermaid sequenceDiagram participant A as Client A participant AS as AppSync participant D as DynamoDB participant B as Client B (subscribed) A->>AS: mutation createPost AS->>D: PutItem D-->>AS: written item AS-->>A: created Post AS-->>B: onCreatePost (WebSocket push) ``` Die 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. Ein 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. ## Wo VTL an seine Grenzen kommt So 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. Die 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. Der 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. ## Managed oder selbstbetrieben: die eigentliche Entscheidung Bis 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. Build-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. Wann 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. Wann 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. Damit 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. ## Was am Ende bleibt Ich 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. Die 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. Der 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. AppSync 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-aws-serverless.html - Repository mit den Schema- und Resolver-Beispielen aus meinen Serverless-Workshops - [DynamoDB-Resolver-Referenz (AWS)](https://docs.aws.amazon.com/appsync/latest/devguide/resolver-mapping-template-reference-dynamodb.html) - [`APPSYNC_JS`-Resolver-Referenz (AWS)](https://docs.aws.amazon.com/appsync/latest/devguide/resolver-reference-js-version.html) - [Tutorial: Simple Post Application mit DynamoDB-Resolvern (AWS)](https://docs.aws.amazon.com/appsync/latest/devguide/tutorial-dynamodb-resolvers.html) - [Resolver-Mapping-Template-Overview (AWS)](https://docs.aws.amazon.com/appsync/latest/devguide/resolver-mapping-template-reference-overview.html) - [$util-Utility-Referenz (AWS)](https://docs.aws.amazon.com/appsync/latest/devguide/resolver-util-reference.html) - [AppSync FAQs zu Data Sources und Subscriptions (AWS)](https://aws.amazon.com/appsync/faqs/) --- # Context richtig einsetzen: Verteilweg statt heimlicher globaler State URL: https://www.mikebild.dev/de/blog/context-richtig-einsetzen/ Datum: 2021-05-26 # Context richtig einsetzen: Verteilweg statt heimlicher globaler State ```jsx const AppContext = createContext(null); function AppProvider({ children }) { const [user, setUser] = useState(null); const [filter, setFilter] = useState(""); const [items, setItems] = useState([]); return ( {children} ); } ``` Drei 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. Context 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. ## Das Problem: prop drilling Daten 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. Unangenehm 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. ```jsx function Page({ user }) { return ; } function Layout({ user }) { return ; } function Sidebar({ user }) { return ; } function Profile({ user }) { return {user.name}; } ``` `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. ```mermaid flowchart TB subgraph drilling["prop drilling"] P1["Page (user)"] --> L1["Layout
    reicht user nur weiter"] L1 --> S1["Sidebar
    reicht user nur weiter"] S1 --> Pr1["Profile
    braucht user"] end subgraph context["Context"] P2["Provider (user)"] -.-> Pr2["Profile
    liest per useContext"] end ``` ## Wie Context funktioniert Context 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. ```jsx import { createContext, useContext } from "react"; const CurrentUserContext = createContext(null); function Page({ user }) { return ( ); } function Profile() { const user = useContext(CurrentUserContext); return {user.name}; } ``` `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. Wichtig 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. ## Die zentrale Falle: Context als Ersatz für State-Besitz Weil 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. Zwei 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. ```mermaid flowchart TB Change["Context-Wert ändert sich"] --> P["Provider"] P --> C1["Consumer A
    zeigt den Wert"] P --> C2["Consumer B
    liest ein anderes Feld –
    rendert trotzdem"] P --> C3["Consumer C
    liest ein anderes Feld –
    rendert trotzdem"] ``` In 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. Die 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. Der 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. ## Regeln für den sauberen Einsatz Aus dieser Trennung folgen drei Regeln, mit denen Context genau das bleibt, was er sein soll – ein Transportmittel. Erstens: 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. Zweitens: 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: ```jsx function CurrentUserProvider({ children }) { const [user, setUser] = useState(null); // new object on every render → all consumers re-render return ( {children} ); } ``` Die Lösung ist, den Wert zu memoisieren, sodass die Referenz nur wechselt, wenn sich tatsächlich etwas geändert hat: ```jsx function CurrentUserProvider({ children }) { const [user, setUser] = useState(null); const value = useMemo(() => ({ user, setUser }), [user]); return ( {children} ); } ``` Bei 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. ```jsx const StateContext = createContext(null); const DispatchContext = createContext(null); function StoreProvider({ children }) { const [state, dispatch] = useReducer(reducer, initialState); return ( {children} ); } ``` Drittens: 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. ## Wofür Context wirklich gut ist Context 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. Der 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. ## Context und Reducer als kleiner globaler Kern Fü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: ```jsx function useStore() { return useContext(StateContext); } function useDispatch() { return useContext(DispatchContext); } ``` Das 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. Man 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. ## Was bleiben soll Context 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. Wer 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. ## Weiterführende Quellen - Repository introduction-react: https://github.com/mikebild/introduction-react - react.dev, „Passing Data Deeply with Context“: https://react.dev/learn/passing-data-deeply-with-context - react.dev, „Scaling Up with Reducer and Context“: https://react.dev/learn/scaling-up-with-reducer-and-context - [react.dev: `useContext`, Wertvergleich und Re-Rendering](https://react.dev/reference/react/useContext) --- # Daten einlesen in Python: CSV, JSON, Excel und Datenbank URL: https://www.mikebild.dev/de/blog/python-daten-einlesen/ Datum: 2021-04-27 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. Welchen 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. Der 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. ## Daten sind eine Domäne, kein Nebenschauplatz Bevor 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. Ich 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? Die 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. ## Die zwei Lager: Standardbibliothek oder pandas Bevor 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. pandas 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. Der folgende Überblick zeigt den Weg von der Quelle zur Zielstruktur: ```mermaid flowchart LR CSV[CSV] --> STD[Standardbibliothek
    csv, json, etree, sqlite3] JSON[JSON] --> STD XML[XML] --> STD DB[(SQLite/DB)] --> STD CSV --> PD[pandas
    read_csv, read_excel, read_sql] XLSX[Excel .xlsx] --> PD DB --> PD STD --> DICTS[dicts / Listen] PD --> DF[DataFrame] ``` Die 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. ## CSV mit der Standardbibliothek: der Klassiker CSV 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. ```python import csv with open("customers.csv", newline="", encoding="utf-8") as f: reader = csv.DictReader(f) # header row becomes dict keys rows = [row for row in reader] # each row is a dict for row in rows[:3]: print(row["name"], row["email"]) ``` Zwei 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. Zweitens `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. Ein 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. ## JSON: von der Datei zum Python-Objekt JSON 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. ```python import json with open("config.json", encoding="utf-8") as f: data = json.load(f) # dict or list, depending on the document print(data["version"]) for item in data["items"]: print(item["id"], item["title"]) ``` Fü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. ## Excel: hier führt der Weg über pandas Sobald 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. ```python import pandas as pd df = pd.read_excel("report.xlsx", sheet_name="Sales") # openpyxl engine by default print(df.head()) print(df["revenue"].sum()) ``` Ein 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`. Gibst 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. ## Datenbanken: sqlite3 und der wichtigste Sicherheitshinweis SQLite 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. ```python import sqlite3 conn = sqlite3.connect("shop.db") cur = conn.cursor() category = "books" cur.execute( "SELECT name, price FROM products WHERE category = ?", (category,), ) result = cur.fetchall() for name, price in result: print(name, price) conn.close() ``` Der 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. Der Vergleich macht den Unterschied deutlich: ```python # unsafe: never do this cur.execute(f"SELECT name, price FROM products WHERE category = '{category}'") # safe: the driver handles quoting and escaping cur.execute( "SELECT name, price FROM products WHERE category = ?", (category,), ) ``` Der 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. Wenn 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. ## Und XML? Nicht vergessen, aber selten gebraucht XML 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. ```python import xml.etree.ElementTree as ET tree = ET.parse("catalog.xml") root = tree.getroot() for product in root.findall("product"): name = product.find("name").text price = product.get("price") # read an attribute print(name, price) ``` Fü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. ## Welcher Reader für welches Format? Damit du die Übersicht behältst, hier die Zuordnung als kompakte Liste: - Für CSV nimmst du `csv.DictReader` aus der Standardbibliothek zum zeilenweisen Lesen und `pandas.read_csv`, sobald es analytisch wird. - Für JSON reichen `json.load` und `json.loads` aus der Standardbibliothek; `pandas.read_json` hilft, wenn das JSON tabellarisch ist. - Für Excel im `.xlsx`-Format führt der Weg über `pandas.read_excel` mit installiertem `openpyxl` – die Standardbibliothek kann dieses Format nicht. - Für Datenbanken deckt `sqlite3` den direkten, parametrisierten Zugriff ab; `pandas.read_sql` oder SQLAlchemy kommen dazu, wenn Analyse oder Portabilität gefragt sind. - Für XML genügt `xml.etree.ElementTree` aus der Standardbibliothek bei überschaubaren Dokumenten. ## Drei Fallstricke, die immer wieder zuschlagen In der Praxis sind es selten die Reader selbst, die Probleme machen, sondern drei wiederkehrende Themen rund ums Einlesen. ### Encoding Warum 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. ### Große Dateien komplett im Speicher Es 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. ```python import pandas as pd total = 0 for chunk in pd.read_csv("big.csv", chunksize=100_000): total += chunk["amount"].sum() # process one block at a time print(total) ``` In 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. ### SQL-Injection Wichtig 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. Der folgende Entscheidungsbaum fasst zusammen, wie ich in der Praxis den Weg wähle: ```mermaid flowchart TD A{Tabellarische
    Analyse nötig?} -->|ja| B[pandas] A -->|nein| C{Zeilenweise,
    klein, ohne Abhängigkeit?} C -->|ja| D[Standardbibliothek] C -->|nein| B B --> E{Sehr große Datei?} E -->|ja| F[chunksize / Streaming] E -->|nein| G[direkt einlesen] ``` ## Fazit Die 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. Wer 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-python.html - Repository `introduction-python` – Beispiele zum Datenzugriff aus meinen Schulungen (`csv`, `json`, `sqlite3`, pandas). - csv – Python-Dokumentation: https://docs.python.org/3/library/csv.html - json – Python-Dokumentation: https://docs.python.org/3/library/json.html - sqlite3 – Python-Dokumentation: https://docs.python.org/3/library/sqlite3.html - xml.etree.ElementTree – Python-Dokumentation: https://docs.python.org/3/library/xml.etree.elementtree.html - pandas IO-Guide: https://pandas.pydata.org/docs/user_guide/io.html - pandas 1.2.0 – openpyxl als Default-Engine: https://pandas.pydata.org/docs/whatsnew/v1.2.0.html - SQLAlchemy-Dokumentation: https://docs.sqlalchemy.org/en/14/ --- # Architecture Decision Records: Entscheidungen, die man in zwei Jahren noch versteht URL: https://www.mikebild.dev/de/blog/adr-entscheidungen-die-man-versteht/ Datum: 2021-03-26 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. Der 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. ## Das Outcome zuerst: weniger Archäologie, schnellere Übergaben Bevor 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. Der 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. Wichtig 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“. ## Das Format: fünf Abschnitte, eine Entscheidung Nygard 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. So sieht ein ADR bei mir typischerweise aus. Es liegt als Markdown im selben Repository wie der Code, unter `docs/adr/`: ```markdown # 7. Use PostgreSQL as primary datastore Date: 2021-03-12 ## Status Accepted ## Context The core service needs a transactional store. Expected load is moderate (low thousands of writes per minute at peak). The team has deep operational experience with PostgreSQL and none with distributed NewSQL stores. Strong consistency is a hard requirement for the billing path. ## Decision We will use PostgreSQL as the primary datastore for the core service. ## Consequences Positive: strong consistency, mature tooling, no learning curve for the team. Negative: single-primary HA needs operational care; revisit this decision if sustained write throughput exceeds ~5k writes/second. ``` Der 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. Ein 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. ## Immutability: ADRs werden nie überschrieben Der 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. Die 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. ``` docs/adr/ # sequential, zero-padded, never renumbered 0001-record-architecture-decisions.md 0002-use-markdown-for-adrs.md 0007-use-postgres-as-primary-datastore.md # Status: superseded by 0012 0012-switch-to-cockroachdb.md # Status: accepted ``` Diesen 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: ```mermaid stateDiagram-v2 [*] --> proposed proposed --> accepted: PR merged proposed --> rejected: not adopted accepted --> superseded: replaced by newer ADR superseded --> [*]: points to successor ADR rejected --> [*] ``` Der 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. ## Flurfunk gegen Artefakt Um 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. ```mermaid flowchart LR subgraph Flurfunk A[Slack thread] --> X[lost knowledge] B[whiteboard photo] --> X C[hallway talk] --> X D[someone's memory] --> X end subgraph Artefakt E[ADR in docs/adr/] --> G[reviewed via PR] G --> H[versioned with the code] H --> I[readable in two years] end ``` Der 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. ## Tooling: gerade genug, um nicht im Weg zu stehen Man 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: ```bash # create a new, auto-numbered ADR from the standard template $ adr new "Use PostgreSQL as primary datastore" # -> docs/adr/0007-use-postgres-as-primary-datastore.md # create an ADR that supersedes an earlier one $ adr new -s 7 "Switch to CockroachDB" # -> docs/adr/0012-switch-to-cockroachdb.md # and sets ADR-0007 status to "superseded by 0012" ``` Wer 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. Der 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. Dass 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. ## Wo ADRs kippen – das ehrliche Kapitel ADRs sind kein Selbstläufer. Sie scheitern, und zwar auf drei vorhersehbare Arten. Der 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. Der 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. Der 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. Ein 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. ## Fazit ADRs 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. Der 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. Der 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. ## Weiterführende Quellen - Michael Nygard, „Documenting Architecture Decisions“ (Cognitect, 2011): https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions.html - Martin Fowler, bliki „Architecture Decision Record“: https://martinfowler.com/bliki/ArchitectureDecisionRecord.html - ThoughtWorks Technology Radar, „Lightweight Architecture Decision Records“ (Adopt, Nov 2017): https://www.thoughtworks.com/radar/techniques/lightweight-architecture-decision-records - adr.github.io – Community-Sammlung zu ADRs und Tooling: https://adr.github.io/ --- # GraphQL-Schema, das mitwächst: Evolution ohne Bruch URL: https://www.mikebild.dev/de/blog/graphql-schema-das-mitwaechst/ Datum: 2021-03-24 # GraphQL-Schema, das mitwächst: Evolution ohne Bruch Ein 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. Der 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. ## Kein /v2, sondern ein Schema Wer 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. GraphQL 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. Das 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. ## Sichere Änderungen: das, was man einfach hinzufügen darf Eine 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. Der 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. ## Breaking Changes: das, was Clients wirklich zerreißt Auf der anderen Seite stehen die Änderungen, die eine gestern gültige Query heute brechen. Die wichtigsten, mit konkreten Beispielen: Am 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. Subtiler, 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. Auch 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*. Und einen Enum-Wert zu entfernen bricht ebenso: Verschwindet ein Wert, den ein Client als Argument sendet oder in einer Antwort erwartet, bricht die Interaktion. ```mermaid flowchart LR subgraph Additiv["Sicher – additiv"] A1["Feld hinzufügen"] A2["Typ hinzufügen"] A3["optionales Argument"] A4["nullable → non-null"] end subgraph Breaking["Bricht Clients"] B1["Feld entfernen/umbenennen"] B2["non-null → nullable"] B3["Pflichtargument hinzufügen"] B4["Enum-Wert entfernen"] end ``` Der 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. ## @deprecated: veralten statt entfernen Weil 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`. ```graphql type User { id: ID! fullName: String @deprecated(reason: "Use `displayName` instead. Removal planned for a future release.") displayName: String! } ``` Das 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. Man 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. ## Nullability ist eine Design-Entscheidung, kein Reflex Ein 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. `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. Schwerer 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. Im 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. ## Input-Typen: Mutationen zukunftssicher schneiden Additive Evolution beginnt schon beim Entwurf, nirgends deutlicher als bei Mutationen. Wer eine Mutation mit einer Handvoll Einzelargumenten baut, verbaut sich die Zukunft: ```graphql type Mutation { createPost(title: String!, body: String!, authorId: ID!): Post! } ``` Kommt 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. Deshalb kapselt man die Eingabe in einen eigenen `input`-Typ: ```graphql input CreatePostInput { title: String! body: String! authorId: ID! } type Mutation { createPost(input: CreatePostInput!): Post! } ``` Die 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. ## Enums vorsichtig erweitern Zurü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. Viele 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. Die 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. ## Eine Feld-Migration Schritt für Schritt Fü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. ```mermaid flowchart TB S1["1 – displayName additiv
    hinzufügen"] --> S2["2 – fullName mit
    @deprecated markieren"] S2 --> S3["3 – Clients migrieren
    (Frist läuft)"] S3 --> S4["4 – fullName entfernen,
    wenn niemand mehr fragt"] ``` Schritt 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. Schritt 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. Entscheidend ist die Übergangszeit: Der Resolver liefert *beide* Felder korrekt. ```js const resolvers = { User: { // legacy field – kept working until the deprecation window closes fullName: (user) => user.displayName, displayName: (user) => user.displayName, }, }; ``` Schritt 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. Schritt 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. ## Ein Sicherheitsnetz in der CI All 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. Ein 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. ## Gepflegt, nicht neu geschnitten Ein 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. Das 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. ## Weiterführende Quellen - Repository introduction-graphql (Kapitel schema, resolver): https://github.com/mikebild/introduction-graphql - GraphQL, Schema und Typen: https://graphql.org/learn/schema/ - GraphQL Best Practices (Versionierung, Nullability, Deprecation): https://graphql.org/learn/best-practices/ - graphql-inspector (Schema-Checks in der CI): https://github.com/kamilkisiela/graphql-inspector --- # Serverless-Workflows mit AWS Step Functions URL: https://www.mikebild.dev/de/blog/step-functions-serverless-workflows/ Datum: 2021-03-23 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. AWS 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. Eine State Machine macht den Ablauf als Reihe benannter States und ausdrücklich behandelter Ergebnisse sichtbar. :::process{title="Ein expliziter Workflow" role="primary" preferredPattern="connected-process"} 1. Eingabe · fachlichen Auftrag und benötigten Kontext übernehmen 2. Task · genau eine begrenzte Fähigkeit ausführen 3. Choice · anhand eines Ergebnisses den nächsten zulässigen Pfad wählen 4. Parallel · unabhängige Zweige nur bei echter Concurrency starten 5. Catch · erwartbare Fehler in einen benannten Behandlungspfad führen 6. Abschluss · Erfolg oder Scheitern als eindeutigen Endzustand festhalten ::: Die Darstellung behauptet keinen linearen Happy Path. Verzweigung und Error Handling bleiben Teil des Modells, statt in verstreuten Funktionsaufrufen zu verschwinden. ## Das Problem: Orchestrierung im Code Schauen wir uns zuerst an, wie die Reflex-Lösung typischerweise aussieht. Eine Lambda-Function, die eine Bestellung verarbeitet, könnte intern so orchestrieren: ```js exports.handler = async (event) => { const order = await validateOrder(event); if (!order.valid) { await notifyFailure(order); return { status: "rejected" }; } if (order.amount > 1000) { await requestManualReview(order); } else { await autoApprove(order); } const [inventory, payment] = await Promise.all([ reserveInventory(order), chargePayment(order), ]); return finalize(order, inventory, payment); }; ``` Auf 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. Und 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. ## Die Idee: der Ablauf als State Machine Step 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. Der 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. Eine 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. Bevor wir in eine konkrete Definition einsteigen, hier die acht State-Typen, die 2021 zur Verfügung stehen – das ist der komplette Baukasten: - `Task` führt Arbeit aus, typischerweise eine Lambda-Function. - `Choice` verzweigt anhand von Bedingungen, das if/else der State Machine. - `Parallel` führt mehrere Zweige gleichzeitig aus und sammelt deren Ergebnisse. - `Map` wendet einen Ablauf auf jedes Element einer Liste an, 2021 nur als Inline-Variante. - `Pass` reicht Eingabe durch oder injiziert feste Daten, nützlich zum Testen. - `Wait` pausiert für eine Dauer oder bis zu einem Zeitpunkt. - `Succeed` beendet die Ausführung erfolgreich. - `Fail` beendet die Ausführung mit einem Fehler. Mit diesen acht Bausteinen lassen sich erstaunlich viele Prozesse abbilden, ohne eine Zeile Orchestrierungscode zu schreiben. ## Ein Task mit Retry und Catch Fangen 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. ```json { "Comment": "Order processing workflow", "StartAt": "ValidateOrder", "States": { "ValidateOrder": { "Type": "Task", "Resource": "arn:aws:lambda:eu-central-1:123456789012:function:ValidateOrder", "Retry": [ { "ErrorEquals": ["Lambda.ServiceException", "Lambda.SdkClientException"], "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2.0 } ], "Catch": [ { "ErrorEquals": ["States.ALL"], "Next": "NotifyFailure" } ], "Next": "CheckAmount" }, "NotifyFailure": { "Type": "Task", "Resource": "arn:aws:lambda:eu-central-1:123456789012:function:NotifyFailure", "End": true } } } ``` Hier 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. Der `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. Ein 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. Ein 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. ## Verzweigen mit Choice Nach 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. ```json { "CheckAmount": { "Type": "Choice", "Choices": [ { "Variable": "$.amount", "NumericGreaterThan": 1000, "Next": "ManualReview" } ], "Default": "FanOut" }, "ManualReview": { "Type": "Task", "Resource": "arn:aws:lambda:eu-central-1:123456789012:function:RequestManualReview", "Next": "FanOut" } } ``` Der 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`. Genau 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. ## Parallel: mehrere Zweige gleichzeitig Fü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`. ```json { "FanOut": { "Type": "Parallel", "Branches": [ { "StartAt": "ReserveInventory", "States": { "ReserveInventory": { "Type": "Task", "Resource": "arn:aws:lambda:eu-central-1:123456789012:function:ReserveInventory", "End": true } } }, { "StartAt": "ChargePayment", "States": { "ChargePayment": { "Type": "Task", "Resource": "arn:aws:lambda:eu-central-1:123456789012:function:ChargePayment", "End": true } } } ], "Next": "Finalize" }, "Finalize": { "Type": "Task", "Resource": "arn:aws:lambda:eu-central-1:123456789012:function:Finalize", "End": true } } ``` Der 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. ## Wie Daten zwischen den States fließen Sobald 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. Jeder 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. Der 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. ## Der Ablauf als Bild Das 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: ```mermaid flowchart TD Start([Start]) --> Validate[ValidateOrder
    Task] Validate --> Check{CheckAmount
    Choice} Check -->|amount > 1000| Review[ManualReview
    Task] Check -->|else| FanOut[FanOut
    Parallel] Review --> FanOut FanOut --> Reserve[ReserveInventory] FanOut --> Charge[ChargePayment] Reserve --> Finalize[Finalize
    Task] Charge --> Finalize Finalize --> Done([Succeed]) Validate -. "Catch States.ALL" .-> Notify[NotifyFailure
    Task] ``` Die 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“. ## Standard oder Express Bei 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. **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. **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. Die 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. ## Wann Step Functions – und wann nicht Ich 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. Ein 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. Und 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. ## Fazit Der 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. Die 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-aws-serverless.html - Repository zur Serverless-Schulung mit den ASL-Beispielen dieses Artikels - [AWS Step Functions – States (States)](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-states.html) - [Amazon States Language](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-amazon-states-language.html) - [Standard vs. Express Workflows](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-standard-vs-express.html) - [Error Handling: Retry und Catch](https://docs.aws.amazon.com/step-functions/latest/dg/concepts-error-handling.html) - [Task-State](https://docs.aws.amazon.com/step-functions/latest/dg/state-task.html) - [Choice-State](https://docs.aws.amazon.com/step-functions/latest/dg/state-choice.html) - [Tutorial: State Machine mit Lambda](https://docs.aws.amazon.com/step-functions/latest/dg/tutorial-creating-lambda-state-machine.html) --- # Event Sourcing: der Event-Store als Logbuch URL: https://www.mikebild.dev/de/blog/event-sourcing-der-event-store-als-logbuch/ Datum: 2021-02-23 ```sql UPDATE accounts SET balance = 300 WHERE id = 42; ``` Nach 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. Event 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. Genannt 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. ## Der Zustand ist ein Left Fold Martin 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. Greg 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: ```ts type Event = | { type: "AccountOpened"; owner: string } | { type: "MoneyDeposited"; amount: number } | { type: "MoneyWithdrawn"; amount: number }; type Account = { owner: string; balance: number }; const apply = (state: Account, event: Event): Account => { switch (event.type) { case "AccountOpened": return { owner: event.owner, balance: 0 }; case "MoneyDeposited": return { ...state, balance: state.balance + event.amount }; case "MoneyWithdrawn": return { ...state, balance: state.balance - event.amount }; } }; const fold = (events: Event[]): Account => events.reduce(apply, { owner: "", balance: 0 }); ``` Das 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. Ein 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. Damit 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. ## Das Logbuch als Wahrheitsquelle Schaut 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. ```mermaid flowchart LR C[Command] --> A[Aggregate decides] A --> E[New Event] E --> L[(Event Store
    append-only)] L --> F[fold / apply] F --> S[Current State] ``` Wichtig 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. Daraus folgen drei Vorteile, die Event Sourcing attraktiv machen und die alle an derselben Eigenschaft hängen: Das Log bleibt vollständig erhalten. - 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. - 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. - 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. Der dritte Punkt wiegt in der Praxis am schwersten: Beim Schreiben muss niemand vorhersehen, welche Sichten später gebraucht werden. ## Projections: Read Models aus dem Stream Wenn 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. ```ts type BalanceView = Record; const project = (events: Event[]): BalanceView => events.reduce((view, event) => { switch (event.type) { case "AccountOpened": return { ...view, [event.owner]: 0 }; case "MoneyDeposited": // update the read model per event return view; case "MoneyWithdrawn": return view; } }, {} as BalanceView); ``` Auch `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. ```mermaid flowchart LR L[(Event Store
    append-only)] -->|project| A[Read Model A
    balances] L -->|project| B[Read Model B
    monthly report] L -->|project| C[Read Model C
    fraud signals] ``` Zwei 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. ## Replay hat keine Unschuld Fowler 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. Deshalb 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. ## Snapshots: der gemerkte Fold Bleibt 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.“ Ein Snapshot ist eine gespeicherte Momentaufnahme des Faltungsergebnisses bei Event N. Man faltet dann ab dem Snapshot weiter, statt bei Event 0 anzufangen. ```ts type Snapshot = { version: number; state: Account }; const rebuild = (snapshot: Snapshot, newerEvents: Event[]): Account => newerEvents.reduce(apply, snapshot.state); // snapshot = memorized left fold at version N ``` Entscheidend 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. ## Die Abgrenzung: Event Sourcing ist nicht CQRS Event Sourcing und CQRS werden fast immer zusammen genannt, und viele halten sie für dasselbe. Sie sind es nicht. [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. Sie 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. Und 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. ## Die ehrlichen Kosten So 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. Der 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. Der 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. Dazu 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. ## Zwei Anti-Pattern, die den Nutzen zerstören Zwei Fehler sehe ich so oft, dass sie einen eigenen Absatz verdienen. Der 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. Der 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. ## Fazit Event 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. Der 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. Wer 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html - Martin Fowler, *Event Sourcing*: https://martinfowler.com/eaaDev/EventSourcing.html - Martin Fowler, *Retroactive Event* (zu Korrekturen durch neue Events): https://martinfowler.com/eaaDev/RetroactiveEvent.html - Greg Young, Vortrag *CQRS and Event Sourcing*, Code on the Beach 2014 (Left Fold, Snapshots als „memorized fold“) - Greg Young, *Functional Domain Models and Event Sourcing* (2012) - Vaughn Vernon, *Implementing Domain-Driven Design* (2013) – Kapitel zu Event Sourcing und CQRS - Mike Bild, Vortragsmaterial *CQRS + Event Sourcing* (SlideShare) – Autoren-Material, aus dem dieser Artikel schöpft --- # Flask-Apps testen: der Test-Client und zuverlässige Endpoints URL: https://www.mikebild.dev/de/blog/flask-apps-testen/ Datum: 2021-02-16 `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. Damit 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. ## Ein Test ohne Server Der 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. Das 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. ```mermaid flowchart LR A[Test] -->|client.get / post| B[test_client] B -->|WSGI in-process
    kein Socket, kein Server| C[Flask-App] C --> D[View-Funktion] D -->|Response
    status_code + body| B B -->|Response-Objekt| A A -->|assert| E[Status & Body geprüft] ``` Der 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. ## Vom unittest-Stil zu pytest-Fixtures Viele ä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: ```python import unittest from examples.app import app class HelloTestCase(unittest.TestCase): def setUp(self): app.testing = True self.client = app.test_client() def test_index(self): result = self.client.get("/") self.assertEqual(result.status_code, 202) self.assertEqual(result.data, b"Hello World!") if __name__ == "__main__": unittest.main() ``` Das 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. Der 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. ## Fixtures in conftest.py Bei 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. ```python import pytest from examples.app import app as flask_app @pytest.fixture def app(): flask_app.config.update({"TESTING": True}) yield flask_app @pytest.fixture def client(app): return app.test_client() ``` Zwei 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. Zweitens 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. ## Die AAA-Struktur Wenn 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. ```mermaid flowchart TD A["Arrange
    Fixtures: App + Client
    TESTING = True"] --> B["Act
    client.get / client.post"] B --> C["Assert
    status_code prüfen
    get_json / data prüfen"] ``` Ein erster, einfacher Test für den Index-Endpoint sieht damit so aus: ```python def test_index_returns_hello(client): # Act response = client.get("/") # Assert assert response.status_code == 202 assert response.data == b"Hello World!" ``` Kein `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!`. ## Bytes sind nicht String Über diese Stelle bin ich selbst oft genug gestolpert. Der naheliegende Vergleich ```python assert response.data == "Hello World!" # fails ``` schlä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. ```python def test_index_body_as_text(client): response = client.get("/") assert response.get_data(as_text=True) == "Hello World!" ``` `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. ## JSON-Antworten prüfen Die 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. Nehmen wir einen Endpoint, der ein JSON-Objekt entgegennimmt, es um Felder ergänzt und zurückgibt – ähnlich dem `/json`-Beispiel aus meinem Repository: ```python from flask import Flask, request, jsonify app = Flask(__name__) @app.route("/json", methods=["POST"]) def json_endpoint(): payload = request.json result = {"username": "John Doe", "role": payload.get("role")} return jsonify(result) ``` Der zugehörige Test schickt JSON hin und prüft die deserialisierte Antwort: ```python def test_json_endpoint(client): # Act response = client.post("/json", json={"role": "admin"}) # Assert assert response.status_code == 200 data = response.get_json() assert data["username"] == "John Doe" assert data["role"] == "admin" ``` Ein 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. ## Fehlerfälle gehören dazu Ein 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: ```python def test_unknown_route_returns_404(client): response = client.get("/does-not-exist") assert response.status_code == 404 ``` Was ich mindestens abdecke, lässt sich in eine kurze Liste fassen: - Der Status-Code – erwarte ich `200`, `202`, `201` oder einen bewussten Fehlercode? - Der Response-Body beziehungsweise die JSON-Struktur – stimmen die Felder und Werte? - Die Fehlerfälle – etwa `404` für unbekannte Routen und `400` für ungültige Eingaben. Diese 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. ## Der Fallstrick: geteilter Zustand Jetzt 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. Stell 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. Die 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. ## Datenbanken sauber isolieren Dasselbe 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. Die 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: ```python import pytest from myapp import create_app, db @pytest.fixture def app(): app = create_app({"TESTING": True, "DATABASE": ":memory:"}) with app.app_context(): db.create_all() # Setup: fresh schema yield app db.drop_all() # Teardown: clean up @pytest.fixture def client(app): return app.test_client() ``` Der 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. ## Was am Ende zählt Der 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. Die 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-python.html - Repository `introduction-python`, `web/4-testing.md` sowie die Beispiele `examples/5-testing.py`, `examples/app.py` und `examples/3-json.py` - Flask, Testing Flask Applications: https://flask.palletsprojects.com/en/1.1.x/testing/ - Flask, API-Referenz zu `Flask.test_client` und der `TESTING`-Config: https://flask.palletsprojects.com/en/1.1.x/api/ - Werkzeug, Test-Utilities und Response-Objekt (`get_json`, `data`): https://werkzeug.palletsprojects.com/en/stable/test/ - pytest, Fixtures: https://docs.pytest.org/en/6.2.x/fixture.html - Flask-Tutorial, Test Coverage: https://flask.palletsprojects.com/en/1.1.x/tutorial/tests/ --- # State erhalten und zurücksetzen: React und die Position im Baum URL: https://www.mikebild.dev/de/blog/react-zustand-erhalten-und-zuruecksetzen/ Datum: 2021-01-26 # State erhalten und zurücksetzen: React und die Position im Baum Zwei 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“. Rä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. ## Der State hängt an der Position, nicht an der Komponente Das 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. Daraus 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. ## Beispiel A: zwei Counter, zwei Positionen Nehmen wir zwei gleiche Komponenten nebeneinander. Beide sind derselbe `Counter`, geschrieben aus derselben Funktion: ```jsx function Counter({ label }) { const [count, setCount] = useState(0); return ( ); } function App() { return (
    ); } ``` Klickt 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. Jetzt der Fall, der den Punkt wirklich schärft. Wir blenden den rechten Zähler bedingt aus und wieder ein: ```jsx function App() { const [showRight, setShowRight] = useState(true); return (
    {showRight && }
    ); } ``` Man 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. ## Beispiel B: dieselbe Position, anderer Typ Der 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. Kippt 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: ```jsx function Panel({ editing }) { if (editing) { return ; } return

    draft

    ; } ``` An dieser Position wechselt der Typ zwischen `` und `

    `. Jeder Wechsel wirft den alten Knoten weg – der `` verliert seinen getippten Text, weil beim nächsten Umschalten dort ein `

    ` steht und danach ein ganz neuer ``. 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. ```mermaid flowchart TB subgraph erhalten["gleiche Position, gleicher Typ"] A1["Counter an Position 2
    count = 7"] --> A2["neue Props
    Typ bleibt Counter"] A2 --> A3["count = 7
    bleibt erhalten"] end subgraph reset["gleiche Position, anderer Typ"] B1["p an Position 2"] --> B2["jetzt input
    an Position 2"] B2 --> B3["alter Teilbaum weg,
    Zustand verworfen"] end ``` ## State absichtlich zurücksetzen Bis 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. Der 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. Der 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. Der Klassiker dafür ist ein Formular, das pro ausgewähltem Datensatz frisch starten soll: ```jsx function ContactEditor({ selectedId }) { return

    ; } ``` Ohne 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. ```mermaid flowchart LR S1["selectedId = 1"] --> F1["Form key=1
    frischer Zustand"] S2["selectedId = 2"] --> F2["Form key=2
    neues Element,
    Formular leer"] S3["selectedId = 2
    Feld getippt"] --> F3["Form key=2
    Zustand bleibt"] ``` ## State erhalten, obwohl sich die Struktur ändert Das 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. Und 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. ## Der fieseste Bug: eine Komponente in einer Komponente Den 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: ```jsx function Page() { function Sidebar() { const [open, setOpen] = useState(true); return ; } return (
    ); } ``` `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. In 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. Die 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. ## Ort und Zeit im Baum State 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?“. Wer 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. ## Weiterführende Quellen - Repository introduction-react: https://github.com/mikebild/introduction-react - React, „Preserving and Resetting State“: https://react.dev/learn/preserving-and-resetting-state - React, „State as a Snapshot“: https://react.dev/learn/state-as-a-snapshot - React, „Render and Commit“: https://react.dev/learn/render-and-commit --- # Serverless-Auth mit Cognito User Pools URL: https://www.mikebild.dev/de/blog/cognito-userpool-auth-serverless/ Datum: 2021-01-19 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. Passwö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. Serverless 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. ## Was ein User Pool eigentlich ist Ein 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. Zwei 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. So 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: ```js const { CfnUserPool, CfnUserPoolClient, CfnUserPoolGroup, } = require('@aws-cdk/aws-cognito'); const userPool = new CfnUserPool(this, 'UserPool', { userPoolName: 'UserPool', adminCreateUserConfig: { allowAdminCreateUserOnly: false, }, policies: { passwordPolicy: { minimumLength: 6, }, }, schema: [ { attributeDataType: 'String', name: 'email', required: true, }, ], autoVerifiedAttributes: ['email'], }); const userPoolClient = new CfnUserPoolClient(this, 'UserPoolClient', { clientName: 'UserPoolClient', explicitAuthFlows: ['ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'], refreshTokenValidity: 30, userPoolId: userPool.ref, }); ``` `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. ## Sign-up, Confirm, Sign-in Der 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: ```ts import { CognitoUserPool, CognitoUser, AuthenticationDetails, CognitoUserAttribute, } from 'amazon-cognito-identity-js'; const userPool = new CognitoUserPool({ UserPoolId: process.env.USER_POOL_ID, ClientId: process.env.USER_POOL_CLIENT_ID, }); // 1) sign-up: create the user, Cognito emails a confirmation code userPool.signUp( username, password, [new CognitoUserAttribute({ Name: 'email', Value: username })], [], (err, result) => { /* result.user is created but not yet confirmed */ } ); // 2) confirm: user submits the code from the email const cognitoUser = new CognitoUser({ Username: username, Pool: userPool }); cognitoUser.confirmRegistration(code, true, (err, result) => { /* result === 'SUCCESS' */ }); // 3) sign-in: authenticate and receive tokens cognitoUser.authenticateUser( new AuthenticationDetails({ Username: username, Password: password }), { onSuccess(session) { const idToken = session.getIdToken().getJwtToken(); const accessToken = session.getAccessToken().getJwtToken(); // refresh token is available too, but opaque }, onFailure(err) { /* wrong password, unconfirmed user, ... */ }, } ); ``` Der 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. ## Die drei Tokens – und warum „drei JWT“ falsch ist Nach 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. Das 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. Warum 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. Ein ID-Token, dekodiert, sieht im Kern so aus – man erkennt Issuer, Audience und den Verwendungszweck: ```json { "sub": "a1b2c3d4-....", "email": "user@example.com", "cognito:username": "user@example.com", "aud": "the-app-client-id", "iss": "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_ABC123", "token_use": "id", "exp": 1611060000 } ``` ## Den Endpoint absichern – ohne eigenen Verifikationscode Jetzt 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: ```ts export async function validateToken({ token, region, userPoolId }) { const response = await fetch( `https://cognito-idp.${region}.amazonaws.com/${userPoolId}/.well-known/jwks.json` ); const { keys } = await response.json(); const decoded = jwt.decode(token, { complete: true }); if (!decoded) throw new Error('Invalid JWT'); const key = keys.find((k) => k.kid === decoded.header.kid); if (!key) throw new Error('Invalid JWT'); const pem = jwkToPem({ kty: key.kty, n: key.n, e: key.e }); return new Promise((resolve, reject) => { jwt.verify(token, pem, (err, payload) => err ? reject(new Error('Invalid JWT')) : resolve(payload) ); }); } ``` Das 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. In CDK verbindest du das über einen `CognitoUserPoolsAuthorizer` mit einer REST-API-Route: ```ts import { RestApi, CognitoUserPoolsAuthorizer, AuthorizationType, LambdaIntegration, } from '@aws-cdk/aws-apigateway'; const authorizer = new CognitoUserPoolsAuthorizer(this, 'Authorizer', { cognitoUserPools: [userPool], }); const api = new RestApi(this, 'Api'); const items = api.root.addResource('items'); items.addMethod('GET', new LambdaIntegration(getItemsFn), { authorizer, authorizationType: AuthorizationType.COGNITO, }); ``` Wer 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: ```json { "type": "apiKey", "name": "Authorization", "in": "header", "x-amazon-apigateway-authtype": "cognito_user_pools", "x-amazon-apigateway-authorizer": { "type": "cognito_user_pools", "providerARNs": [ "arn:aws:cognito-idp:eu-central-1:123456789012:userpool/eu-central-1_ABC123" ], "identitySource": "method.request.header.Authorization" } } ``` Der Client schickt sein ID-Token also schlicht im `Authorization`-Header mit – in der Praxis meist als `Bearer ` – und muss sich um nichts weiter kümmern. Der gesamte Ablauf sieht so aus: ```mermaid sequenceDiagram participant C as Client participant Cog as Cognito User Pool participant GW as API Gateway participant L as Lambda C->>Cog: sign-in (username / password) Cog-->>C: ID + Access + Refresh tokens C->>GW: GET /items
    Authorization: Bearer idToken GW->>Cog: verify JWT via JWKS (signature, exp, aud, iss) GW->>L: invoke (token valid) L-->>C: 200 OK ``` ## Lambda-Trigger: eigene Logik in den Ablauf hängen Manchmal 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`. Ein `PreSignUp`-Trigger läuft, bevor Cognito einen neuen Nutzer anlegt. Hier prüfe ich etwa die E-Mail-Domain: ```js exports.handler = async (event) => { const email = event.request.userAttributes.email || ''; if (!email.endsWith('@example.com')) { throw new Error('Only example.com addresses are allowed'); } // do NOT auto-confirm: keep the email verification step intact event.response.autoConfirmUser = false; return event; }; ``` Zwei 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. Das kleine Komponentenbild dazu: ```mermaid flowchart LR Pool[User Pool + App Client] -->|JWKS| GW[API Gateway
    Cognito Authorizer] GW --> Fn[Lambda] Trigger[PreSignUp trigger] -.-> Pool ``` ## Der eigentliche Trade-off: managed Identity gegen Anbieterbindung Alles 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. Diese 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. Zwei 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. Daraus 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. ## Wichtige Abgrenzung: Infrastruktur-Auth ist nicht Resolver-Auth Zum 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. Was 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“. Fü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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-aws-serverless.html - 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) - [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) - [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) - [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) - [JWT dekodieren und verifizieren (JWKS)](https://repost.aws/knowledge-center/decode-verify-cognito-json-token) - [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) --- # Trunk-Based Development statt Branch-Friedhof URL: https://www.mikebild.dev/de/blog/trunk-based-development-und-feature-flags/ Datum: 2020-11-24 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. Der 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. ## Die eigentliche Idee: Integrationsdistanz klein halten Fangen 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. Der 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. Martin 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. ## Was die Daten sagen – vorsichtig formuliert Ich 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. Ich 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. ## Der Denkfehler: Deploy ist nicht Release Jetzt 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. Deploy 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. ```mermaid flowchart LR subgraph friedhof["Branch-Friedhof"] direction TB M0["main"] --> M1["main"] M0 --> B1["feature branch
    Tag 1"] B1 --> B2["... Tag 20 ..."] B2 --> B3["divergiert weit"] M1 --> MG(("dicker
    Merge-Konflikt")) B3 --> MG end subgraph tbd["Trunk-Based"] direction TB T0["trunk"] --> T1["+ commit"] T1 --> T2["+ commit"] T2 --> T3["+ commit"] T3 --> T4["trunk bleibt grün"] T4 -. "toggle on
    = Release" .-> R(("Release
    als Schalter")) end ``` Das 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. Hodgson 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. ## Wie unfertige Arbeit sicher auf dem Trunk lebt Ein 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: ```typescript // The domain code does not know about flag plumbing. // It asks a small, well-named abstraction. if (features.isEnabled("new-checkout-flow")) { return newCheckoutService.process(cart); } return legacyCheckoutService.process(cart); ``` Auf 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: ```yaml features: new-checkout-flow: enabled: false # deploy != release strategy: canary # gradual rollout instead of big-bang percentage: 5 # start with 5% of traffic ``` Fü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: ```java public interface PaymentGateway { PaymentResult charge(Order order); } // Both live in the trunk at the same time. final class LegacyPaymentGateway implements PaymentGateway { /* ... */ } final class NewPaymentGateway implements PaymentGateway { /* ... */ } // A factory selects the implementation behind a toggle. PaymentGateway gateway = features.isEnabled("new-payment-gateway") ? new NewPaymentGateway() : new LegacyPaymentGateway(); ``` Ergä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. ## Der Kontrast: GitFlow und der Reflex zum langen Branch Ich 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. Als 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. ## Ehrlich über die Fallstricke Der Ansatz löst nicht alles, und er bringt eigene Probleme mit. Drei Kanten sind es, an denen sich Teams zuverlässig schneiden. Erstens: 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. ```mermaid flowchart LR A["create toggle"] --> B["deploy dark"] B --> C["gradual release"] C --> D["fully on"] D --> E["remove toggle"] E --> F["trunk stays clean"] ``` Zweitens: 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. Drittens: 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. ## Warum sich der Aufwand rechnet Wenn 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. Die 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. Das 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. ## Weiterführende Quellen - Paul Hammant u. a.: [trunkbaseddevelopment.com](https://trunkbaseddevelopment.com/) - Martin Fowler & Pete Hodgson: [Feature Toggles (aka Feature Flags)](https://martinfowler.com/articles/feature-toggles.html) - Martin Fowler: [Patterns for Managing Source Code Branches](https://martinfowler.com/articles/branching-patterns.html) - Martin Fowler: [Branch by Abstraction](https://martinfowler.com/bliki/BranchByAbstraction.html) - Forsgren, Humble & Kim: *Accelerate* (2018) und die State-of-DevOps-Reports - Humble & Farley: *Continuous Delivery* (2010) - Vincent Driessen: [A successful Git branching model](https://nvie.com/posts/a-successful-git-branching-model/) (als Kontrast) --- # Wie React den Baum rendert und aktualisiert URL: https://www.mikebild.dev/de/blog/wie-react-den-baum-rendert-und-aktualisiert/ Datum: 2020-10-24 # Wie React den Baum rendert und aktualisiert ```js setCount(count + 1); ``` Nach 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. Im 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. Zwischen einer State-Änderung und einer sichtbaren DOM-Änderung liegen mehrere klar getrennte Stufen. :::process{title="Von der Aktualisierung zum DOM" role="primary" preferredPattern="vertical-journey"} 1. Auslösen · State, Props oder Kontext machen Arbeit notwendig 2. Rendern · React ruft Komponenten auf und erzeugt die nächste Elementstruktur 3. Abgleichen · alte und neue Struktur werden anhand von Typ und Schlüssel verglichen 4. Commit · notwendige Änderungen werden auf DOM und Effects angewendet ::: Die Trennung erklärt, warum ein erneuter Komponentenaufruf nicht automatisch einen vollständigen DOM-Neuaufbau bedeutet. ## Drei Schritte: Auslösen, Rendern, Anwenden React 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. ```mermaid flowchart LR Trigger["1. Auslösen
    initial oder setState"] --> Render["2. Rendern
    Komponenten aufrufen"] Render --> Commit["3. Anwenden
    minimale DOM-Änderung"] Commit --> Paint["Browser zeichnet neu"] ``` Der 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. ```jsx import { createRoot } from "react-dom/client"; const root = createRoot(document.getElementById("root")); root.render(); ``` ## Rendern heißt: React ruft deine Funktionen auf Der 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. Und 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. ```mermaid flowchart TB App["App()"] --> Liste["Rechnungsliste()"] App --> Kopf["Kopfzeile()"] Liste --> Z1["Zeile() #1"] Liste --> Z2["Zeile() #2"] Z1 --> Betrag1["Betrag()"] Z2 --> Betrag2["Betrag()"] ``` Damit 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. ## Der Abgleich: zwei Bäume vergleichen Jetzt 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. An derselben Stelle im Baum entscheidet der Typ des Elements. Ist der Typ gleich – vorher ein `
    `, jetzt wieder ein `
    `, 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 `

    `, jetzt ein `

    ` –, 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. ```mermaid flowchart LR subgraph Vorher A1["div"] --> A2["span: 'Hallo'"] end subgraph Nachher B1["div"] --> B2["span: 'Welt'"] end A1 -. "gleicher Typ:
    Knoten bleibt" .-> B1 A2 -. "nur Textinhalt
    ändert sich" .-> B2 ``` Diese 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 `
    ` steckte und jetzt in einem `
    `, 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. ## Listen sind der Sonderfall – und hier kommen die Keys Bei 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. Deshalb 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. ## Anwenden: React fasst nur das an, was sich geändert hat Erst 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. Das 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). ```mermaid sequenceDiagram participant N as Nutzer participant R as React participant D as DOM N->>R: setState (Zustand ändert sich) R->>R: Komponenten erneut aufrufen (rendern) R->>R: neuen Baum gegen alten abgleichen R->>D: nur die Unterschiede schreiben D->>N: Browser zeichnet das Ergebnis ``` Nach 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. ## Warum ein erneutes Rendern nicht teuer sein muss Mit 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. Das 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. ## Was man daraus mitnimmt Die 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. Wer 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. ## Weiterführende Quellen - Repository introduction-react (Beispiele zum Nachvollziehen): https://github.com/mikebild/introduction-react - React, Render und Commit: https://react.dev/learn/render-and-commit - React, State als Momentaufnahme: https://react.dev/learn/state-as-a-snapshot - React, State erhalten und zurücksetzen: https://react.dev/learn/preserving-and-resetting-state - React, Listen rendern (Keys): https://react.dev/learn/rendering-lists --- # Team-Automatisierung mit GitHub Actions URL: https://www.mikebild.dev/de/blog/team-automatisierung-github-actions/ Datum: 2020-10-12 # Team-Automatisierung mit GitHub Actions Eine 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. Sichtbar 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. Im 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. ## Automatisierung als ausführbare Vereinbarung Der 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. Damit 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. ## Wo die Grenze verläuft Bevor 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. Prü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. ## Ein konkretes Gate So sieht ein Pull-Request-Gate aus, das ich in dieser Form immer wieder aufsetze. Es prüft und verändert nichts: ```yaml name: pr-gate on: pull_request: branches: [main] jobs: verify: runs-on: ubuntu-latest strategy: fail-fast: false matrix: node-version: [12.x, 14.x] steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} - run: npm ci - run: npm run lint - run: npm run typecheck - run: npm test -- --ci ``` Ein 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. Der 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. ## Der teure Fehler ist der glückliche Pfad Der 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. Der 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. Der 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. Der 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. ## Der Weg von Prüfen zu Verändern Das Bild dahinter ist einfach, und ich zeichne es bewusst als Fluss, weil die Richtung die ganze Aussage trägt: ```mermaid flowchart LR PR[Pull Request] --> Gate{Gate
    lint, test, types} Gate -- rot --> Stop[Merge blockiert] Gate -- grün --> Merge[Merge auf main] Merge --> Trusted[Vertrauenswürdiger Lauf] Release --> Audit[Nachvollziehbar im Verlauf] Trusted --> Release[Tag, Publish, Deploy] ``` Links 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. ## Der bewusste Auslöser Nicht 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: ```yaml name: release on: workflow_dispatch: inputs: level: description: "semver bump level" required: true default: patch jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: node-version: 14.x - run: npm ci - run: npm run build - run: node ./scripts/release.js --level "${{ github.event.inputs.level }}" env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} ``` Das 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. ## Was das messbar bringt Ich 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. Der 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. Der 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. Der 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. ## Wann ich es bewusst nicht mache Ein 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. Der 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. ## Wie ich anfange In 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. Der 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. --- # Web-APIs mit Flask: von der App zur JSON-API URL: https://www.mikebild.dev/de/blog/web-apis-mit-flask/ Datum: 2020-09-22 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. Damit 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. **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. ## Hello World, und was dahinter steckt Der kleinste sinnvolle Flask-Server passt in sechs Zeilen. An ihm lässt sich das gesamte Modell ablesen. ```python from flask import Flask app = Flask(__name__) @app.route('/') def hello_world(): return 'Hello World!' if __name__ == '__main__': app.run(debug=True, port=8080) ``` `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. Der 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: ```python from flask import Flask app = Flask(__name__) @app.route('/') def hello_world(): return 'Hello World!', 202 ``` Hier 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. ## Routing, Request, Response Bevor die eigentliche API dran ist, lohnen die drei Begriffe eine genauere Betrachtung, weil sie durchgängig wiederkehren: - Route: Der Dekorator `@app.route(pfad, methods=[...])` bindet einen URL-Pfad an eine Funktion. Pfad-Parameter wie `` reichen Teile der URL direkt an die Funktion weiter, und `methods` legt fest, welche HTTP-Verben die Route bedient. - 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. - Response: Der Rückgabewert der Funktion. Ein String wird zu HTML, `jsonify(...)` zu JSON, ein Tuple `(rumpf, status)` steuert den Statuscode. Diese drei Achsen genügen, um erstaunlich viel zu bauen. Ein Pfad mit Parameter sieht etwa so aus: ```python @app.route('/user/') def profile(username): return f'Profile page of {username}' ``` Der Platzhalter `` wird als Argument an die Funktion übergeben. Braucht man einen numerischen Wert, verlangt der Konverter `` 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. Der Request-Lifecycle lässt sich in einem einfachen Diagramm zusammenfassen. Zwischen der ankommenden Anfrage und der fertigen Antwort stehen nur wenige, klar benannte Schritte. ```mermaid graph LR A[HTTP Request] --> B["@app.route
    Matching"] B --> C[View Handler] C --> D["jsonify()"] D --> E[JSON Response] ``` Der 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. ## Von der Seite zur Schnittstelle Jetzt 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. ```python from flask import Flask, request, jsonify app = Flask(__name__) items = [] # demo-only in-memory store @app.route('/items', methods=['GET', 'POST']) def items_collection(): if request.method == 'POST': payload = request.get_json() item = {'id': len(items) + 1, 'name': payload['name']} items.append(item) return jsonify(item), 201 return jsonify(items) # GET @app.route('/items/') def item_detail(item_id): match = next((i for i in items if i['id'] == item_id), None) return (jsonify(match), 200) if match else ('Not found', 404) ``` Ein 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. `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. ## Der historische Dreh: dict direkt zurückgeben Hier 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: ```python @app.route('/status') def status(): return {'ok': True, 'items': len(items)} # Flask >= 1.1 -> JSON ``` Das 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. ## SSR und API sind dieselbe Mechanik Serverseitiges 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. ```python from flask import Flask, request, render_template app = Flask(__name__) @app.route('/') def index(): return render_template('index.html', items=items) @app.route('/forms', methods=['POST']) def forms(): return 'You said: ' + request.form['text'] ``` Das zugehörige Template ist gewöhnliches HTML mit Platzhaltern. Der übergebene Kontext – hier `items` – steht in der Vorlage als Variable zur Verfügung: ```html
      {% for item in items %}
    • {{ item.name }}
    • {% endfor %}
    ``` Der 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. ```mermaid graph LR A[View Handler] --> B{Repräsentation?} B -->|render_template| C[HTML] B -->|jsonify| D[JSON] ``` Damit lösen sich „API-Framework“ und „Web-Framework“ als getrennte Kategorien auf. Es ist eine Entscheidung pro Route, kein Architekturbruch. ## Struktur mit Blueprints Sobald 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. ```python from flask import Blueprint, jsonify api = Blueprint('api', __name__, url_prefix='/api') @api.route('/items') def list_items(): return jsonify(items) # in the main app: # app.register_blueprint(api) ``` Alle 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. ## Drei Fallstricke Zum 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. Der 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`. ```python @app.route('/echo', methods=['POST']) def echo(): data = request.get_json(silent=True) if not data: return jsonify({'error': 'expected JSON body'}), 400 return jsonify(data) ``` Der 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. Der 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. ## Fazit Der 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. Wichtiger 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-python.html - 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. - Flask 1.1 Quickstart – Routing, Methoden und Pfad-Parameter: https://flask.palletsprojects.com/en/1.1.x/quickstart/ - Flask 1.1 API-Referenz – Request-Objekt und `jsonify`: https://flask.palletsprojects.com/en/1.1.x/api/ - Flask 1.1 Blueprints: https://flask.palletsprojects.com/en/1.1.x/blueprints/ - Flask 1.1 Deployment – warum der Entwicklungsserver nicht in Produktion gehört: https://flask.palletsprojects.com/en/1.1.x/deploying/ - [Flask 3.1: eingehende Request-Daten und `get_json()`](https://flask.palletsprojects.com/en/stable/api/#flask.Request.get_json) --- # Read Models aus Events: Projections statt zufälliger Caches URL: https://www.mikebild.dev/de/blog/read-models-sind-projektionen/ Datum: 2020-09-15 # Read Models aus Events: Projections statt zufälliger Caches Dieser 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. Der 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. ## Woher der Cache-Reflex kommt Der 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. Der 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*. Diese Berechnung ist die Projection. Und sie ist der Grund, warum ich auf der Unterscheidung bestehe. ## Was eine Projection ausmacht Vor 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. Die 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. Genau 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. Ein 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. ## Die Projection als Faltung Am 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: ```ts type OrderPlaced = { type: "OrderPlaced"; orderId: string; total: number } type OrderShipped = { type: "OrderShipped"; orderId: string } type OrderCancelled = { type: "OrderCancelled"; orderId: string } type OrderEvent = OrderPlaced | OrderShipped | OrderCancelled type OrderSummary = { orderId: string total: number status: "placed" | "shipped" | "cancelled" } // pure reducer: (state, event) => state function apply(state: OrderSummary, event: OrderEvent): OrderSummary { switch (event.type) { case "OrderPlaced": return { orderId: event.orderId, total: event.total, status: "placed" } case "OrderShipped": return { ...state, status: "shipped" } case "OrderCancelled": return { ...state, status: "cancelled" } } } // the projection is a fold over the event stream function project(events: OrderEvent[]): OrderSummary { return events.reduce(apply, {} as OrderSummary) } ``` `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. ## Ein Read Model pro Sicht Der 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. Eine 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: ```ts type CustomerOrderListItem = { orderId: string customerName: string // denormalized from the customer aggregate itemCount: number // precomputed, no join at read time totalAmount: number statusLabel: string // display-ready, e.g. "Versandt" } // this projection cuts across order and customer to build one flat view function toListItem( order: OrderSummary, customerName: string, itemCount: number, ): CustomerOrderListItem { return { orderId: order.orderId, customerName, itemCount, totalAmount: order.total, statusLabel: order.status === "shipped" ? "Versandt" : "Offen", } } ``` Wichtig 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. ## Der Rebuild gehört zum Design Weil die Projection deterministisch aus den Events entsteht, ist der Neuaufbau trivial zu formulieren – man leert die Read-Seite und spielt die Events erneut ein: ```ts interface ReadStore { clear(): void save(view: OrderSummary): void } interface EventStore { allEvents(): Iterable } function rebuild(events: EventStore, read: ReadStore): void { read.clear() const views = new Map() for (const event of events.allEvents()) { const current = views.get(event.orderId) ?? ({} as OrderSummary) views.set(event.orderId, apply(current, event)) } for (const view of views.values()) read.save(view) } ``` Dieser `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. Der 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. ## Der Datenfluss im Bild Eine Quelle, mehrere sichtenspezifische Projections – so sieht der Fluss aus: ```mermaid flowchart LR C[Command] --> W[Write Model /
    Event Store] W -->|Events| P[Projektor] P -->|async · eventual| A[Read Model A
    Liste] P -->|async · eventual| B[Read Model B
    Detail] P -->|async · eventual| R[Read Model C
    Report] ``` Der 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. ## Eventual Consistency ist ein bewusster Handel Die 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. Der 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. Gefä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. ## Projection oder Cache – die Unterscheidung in fünf Punkten Ob eine konkrete Leseseite eine Projection oder ein Cache ist, klärt sich an fünf Fragen: - 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. - Wie wird invalidiert? Die Projection wird eventgetrieben fortgeschrieben, der Cache über TTL oder Verdrängung. - 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. - Welche Form hat die Sicht? Die Projection ist pro Sicht denormalisiert und aggregatübergreifend zusammengezogen, der Cache meist eine Kopie des Quell-Datensatzes. - Wie steht es um die Konsistenz? Die Projection ist bewusst eventual consistent mit begrenztem Verzug, der Cache best-effort. Ein 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. ## Die drei Fallstricke, konkret Zum Schluss die Muster, die den Ärger verursachen, jeweils in einem Satz benannt und mit der Gegenmaßnahme. Der 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. Der 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. Der 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. ## Fazit Ein 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html - Martin Fowler, [CQRS](https://martinfowler.com/bliki/CQRS.html) – der konzeptuelle Modellsplit und die Warnung, ihn nur kontextlokal einzusetzen. - Martin Fowler, [Reporting Database](https://martinfowler.com/bliki/ReportingDatabase.html) – die verwandte, aber engere Idee einer separaten Lese-Datenbank. - Martin Fowler, [Event Sourcing](https://martinfowler.com/eaaDev/EventSourcing.html) – die Persistenzform, die Projections so natürlich macht. - 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. - Vaughn Vernon, *Implementing Domain-Driven Design* (2013), Kapitel zu Architektur und CQRS; Beispielcode in den [IDDD Samples](https://github.com/VaughnVernon/IDDD_Samples). - Mein eigenes Vortragsmaterial "CQRS + Event Sourcing" auf SlideShare – die Folien, aus denen ich in Schulungen den Kern herleite: Lesen und Schreiben sind unterschiedliche Verantwortlichkeiten. --- # Cloud Guardrails statt Cloud Gatekeeping URL: https://www.mikebild.dev/de/blog/cloud-guardrails/ Datum: 2020-09-12 # Cloud Guardrails statt Cloud Gatekeeping Ein 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. In 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. ## Was der Ticket-Prozess eigentlich schützt Bevor man den Prozess abschafft, lohnt der Blick darauf, welche berechtigten Sorgen er trägt. In dem Projekt waren es im Kern vier: - Kosten: Niemand soll versehentlich einen teuren Cluster in einer exotischen Instanzgröße starten. - Datenhoheit: Daten bleiben in europäischen Regionen, Punkt. - Sicherheitsgrundlinien: Kein öffentlicher S3-Bucket, kein abgeschaltetes CloudTrail, keine weit offenen Security Groups. - Rechte-Eskalation: Wer Rollen anlegen darf, darf sich sonst beliebig weitreichende Rechte selbst ausstellen. Alle 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. AWS 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. ```mermaid flowchart LR A[Team ändert
    Infrastruktur] --> B{Präventiv:
    SCP / Boundary} B -->|verstößt| C[API-Aufruf sofort
    abgelehnt] B -->|erlaubt| D[Ressource
    entsteht] D --> E{Detektivisch:
    AWS Config} E -->|konform| F[Nichts passiert] E -->|Abweichung| G[Alarm oder
    Auto-Korrektur] ``` ## Harte Grenzen: Service Control Policies Die ä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. ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "DenyOutsideAllowedRegions", "Effect": "Deny", "NotAction": [ "iam:*", "organizations:*", "route53:*", "cloudfront:*", "support:*" ], "Resource": "*", "Condition": { "StringNotEquals": { "aws:RequestedRegion": ["eu-central-1", "eu-west-1"] } } }, { "Sid": "DenyUnapprovedServices", "Effect": "Deny", "Action": ["gamelift:*", "robomaker:*", "chime:*"], "Resource": "*" } ] } ``` Zwei 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. Wichtig 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. ## Selbstbedienung bei IAM: Permission Boundaries Die 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. Seit 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: ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "AllowWorkloadServices", "Effect": "Allow", "Action": [ "s3:*", "dynamodb:*", "lambda:*", "sqs:*", "logs:*", "cloudwatch:*", "apigateway:*" ], "Resource": "*" }, { "Sid": "DenyCreateWithoutBoundary", "Effect": "Deny", "Action": ["iam:CreateRole", "iam:CreateUser"], "Resource": "*", "Condition": { "StringNotEquals": { "iam:PermissionsBoundary": "arn:aws:iam::111122223333:policy/workload-boundary" } } }, { "Sid": "DenyBoundaryTampering", "Effect": "Deny", "Action": [ "iam:DeleteRolePermissionsBoundary", "iam:DeletePolicy", "iam:CreatePolicyVersion" ], "Resource": "arn:aws:iam::111122223333:policy/workload-boundary" } ] } ``` Damit 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: ```ts import * as iam from '@aws-cdk/aws-iam'; const boundary = iam.ManagedPolicy.fromManagedPolicyName( this, 'WorkloadBoundary', 'workload-boundary' ); const serviceRole = new iam.Role(this, 'OrderServiceRole', { assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), permissionsBoundary: boundary, }); ``` Die 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. ## Erkennen statt nur verhindern: AWS Config Prä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. ```ts import * as config from '@aws-cdk/aws-config'; import * as budgets from '@aws-cdk/aws-budgets'; new config.ManagedRule(this, 'BucketsNotPublic', { identifier: 'S3_BUCKET_PUBLIC_READ_PROHIBITED', }); new config.ManagedRule(this, 'VolumesEncrypted', { identifier: 'ENCRYPTED_VOLUMES', }); new budgets.CfnBudget(this, 'TeamMonthlyBudget', { budget: { budgetType: 'COST', timeUnit: 'MONTHLY', budgetLimit: { amount: 500, unit: 'USD' }, }, notificationsWithSubscribers: [ { notification: { notificationType: 'ACTUAL', comparisonOperator: 'GREATER_THAN', threshold: 80, }, subscribers: [ { subscriptionType: 'EMAIL', address: 'team-orders@example.com' }, ], }, ], }); ``` Das 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. Der 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. Bei 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. ## Leitplanken sind Code, keine Klick-Konfiguration Der 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. Konkret 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. Nebenbei: 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. ```mermaid flowchart TD Root[Organization Root] --> Sec[OU Security
    Logging, Audit] Root --> Work[OU Workloads] Root --> Sand[OU Sandbox] Work --> A1[Account Team Orders] Work --> A2[Account Team Billing] SCP1[SCP: EU-Regionen,
    Service-Sperrliste] -.gilt für.-> Work SCP2[SCP: zusätzlich
    Kostengrenzen eng] -.gilt für.-> Sand ``` Die 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. ## Was am Ende anders ist Nach 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. Ein 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. ## Weiterführende Quellen - [Service Control Policies – AWS Organizations User Guide](https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps.html) - [Permissions Boundaries for IAM Entities – AWS IAM User Guide](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) - [What Is AWS Config? – AWS Config Developer Guide](https://docs.aws.amazon.com/config/latest/developerguide/WhatIsConfig.html) - [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/) --- # CDK Constructs: Abstraktion mit Haftung URL: https://www.mikebild.dev/de/blog/cdk-constructs-abstraktion-mit-haftung/ Datum: 2020-08-12 # CDK Constructs: Abstraktion mit Haftung Eine 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. Genau 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. ## Drei Ebenen, drei verschiedene Versprechen Das 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". Je 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: ```mermaid flowchart LR L3[L3: StaticSite
    eigenes Pattern] --> L2[L2: s3.Bucket
    CloudFrontWebDistribution] L2 --> L1[L1: CfnBucket
    CfnDistribution] L1 --> SYNTH[cdk synth] SYNTH --> TPL[CloudFormation-Template
    mehrere hundert Zeilen] TPL --> DEPLOY[cdk deploy] DEPLOY --> RES[echte Ressourcen:
    Kosten, Rechte, Angriffsfläche] ``` Der 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. ## Ein eigenes Construct und seine unbequemen Entscheidungen Mir 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ß. ```typescript import { Construct, RemovalPolicy } from '@aws-cdk/core'; import * as s3 from '@aws-cdk/aws-s3'; import * as cloudfront from '@aws-cdk/aws-cloudfront'; import * as deployment from '@aws-cdk/aws-s3-deployment'; export interface StaticSiteProps { readonly sourcePath: string; readonly retainBucket?: boolean; } export class StaticSite extends Construct { public readonly siteBucket: s3.Bucket; public readonly distribution: cloudfront.CloudFrontWebDistribution; constructor(scope: Construct, id: string, props: StaticSiteProps) { super(scope, id); const originAccessIdentity = new cloudfront.OriginAccessIdentity( this, 'OriginAccessIdentity' ); this.siteBucket = new s3.Bucket(this, 'SiteBucket', { encryption: s3.BucketEncryption.S3_MANAGED, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, removalPolicy: props.retainBucket ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY, }); this.siteBucket.grantRead(originAccessIdentity); this.distribution = new cloudfront.CloudFrontWebDistribution(this, 'Distribution', { originConfigs: [ { s3OriginSource: { s3BucketSource: this.siteBucket, originAccessIdentity, }, behaviors: [{ isDefaultBehavior: true }], }, ], }); new deployment.BucketDeployment(this, 'DeploySite', { sources: [deployment.Source.asset(props.sourcePath)], destinationBucket: this.siteBucket, distribution: this.distribution, }); } } ``` Jede 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`. Ü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. Genauso 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: ```typescript const cfnDistribution = site.distribution.node .defaultChild as cloudfront.CfnDistribution; cfnDistribution.addPropertyOverride( 'DistributionConfig.ViewerCertificate.MinimumProtocolVersion', 'TLSv1.2_2019' ); ``` Diese 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. ## cdk synth ist der Wahrheitstest Fü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. ```bash npm install @aws-cdk/aws-s3@1.57.0 @aws-cdk/aws-cloudfront@1.57.0 cdk synth StaticSiteStack > synthesized.yaml # what did the abstraction actually decide for me? grep -A 6 'PublicAccessBlockConfiguration' synthesized.yaml grep -B 2 'DeletionPolicy' synthesized.yaml # and before every deployment: what changes against reality? cdk diff StaticSiteStack ``` Bevor 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: - Welche IAM-Statements entstehen, und stehen darin Wildcards, die ich nicht bestellt habe? - Welche Ressourcen kosten laufend Geld, auch wenn niemand das System benutzt? - Was ist öffentlich erreichbar, was ist verschlüsselt, was hat welche `DeletionPolicy`? - Welche Properties kann ich über das Interface ändern – und für welche brauche ich schon am ersten Tag den Escape Hatch? AWS 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. ## Was der Autor schuldet Bleibt 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. Da 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. Dann 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. Dazu 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. Und 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. ## Unterm Strich Vor 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. Deshalb 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. ## Weiterführende Quellen - AWS Solutions Constructs auf GitHub: https://github.com/awslabs/aws-solutions-constructs - AWS CDK auf GitHub: https://github.com/aws/aws-cdk - Constructs im CDK Developer Guide: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html - 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/ - CDK Pipelines Developer Preview (AWS-Blog): https://aws.amazon.com/blogs/developer/cdk-pipelines-continuous-delivery-for-aws-cdk-applications/ --- # Bounded Contexts vor dem Serviceschnitt URL: https://www.mikebild.dev/de/blog/bounded-contexts-grenze-vor-microservice/ Datum: 2020-07-24 # Bounded Contexts vor dem Serviceschnitt Wer 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. Die Grenze, die vorher fehlt, ist keine technische. Sie verläuft dort, wo ein Wort aufhört, dasselbe zu bedeuten. Die Architektur entsteht an den sprachlichen Grenzen und an den Übersetzungen zwischen ihnen. :::relationship{title="Fachliche Grenzen vor technischen Schnitten" role="primary" preferredPattern="architecture-map"} - Subdomain · beschreibt einen fachlichen Problemraum - Bounded Context · gibt Begriffen und Regeln einen eindeutigen Geltungsbereich - Modell · implementiert die Sprache innerhalb dieser Grenze - Context Map · benennt Abhängigkeiten und Einfluss zwischen Kontexten - Anti-Corruption Layer · übersetzt ein fremdes Modell in die eigene Sprache - Service · kann eine Grenze ausführen, definiert sie aber nicht automatisch ::: Damit werden Kästen nicht zum Selbstzweck. Entscheidend bleibt, wo Bedeutung wechselt und wer diese Übersetzung verantwortet. ## Warum die Reihenfolge über das Ergebnis entscheidet Die 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. Der 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. ## Der Bounded Context ist eine Sprachgrenze Eric 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. Ein 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. Fowler 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. Praktisch 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. ## Subdomains ordnen, Bounded Contexts implementieren Bevor 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: Die 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. Diese 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. ## Die Kanten sind die Architektur, nicht die Kästen Isolierte 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. Eine 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? ```mermaid graph LR subgraph U["Upstream"] INV["Inventory
    Product = SKU + stock"] end subgraph D["Downstream"] SAL["Sales
    Product = price + description"] BIL["Billing
    Product = tax + revenue account"] end INV -->|"OHS / PL"| SAL SAL -->|"ACL"| BIL INV -.->|"SK shared kernel"| SAL ``` Die 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. Der 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. ## Das Muster, das die meiste Reibung spart: der Anti-Corruption Layer Von 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. Konkret: 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: ```java // Downstream context: Sales interface CatalogPort { Product findById(ProductId id); } // ACL: isolates the upstream "Inventory" model. // Only this class knows the foreign DTOs; the domain never does. class InventoryAntiCorruptionLayer implements CatalogPort { private final LegacyInventoryClient legacyClient; @Override public Product findById(ProductId id) { LegacyInventoryResponse raw = legacyClient.get(id.value()); return new Product( new ProductId(raw.sku), Money.of(raw.price_cents) // translate cents into the domain type ); } } ``` Der 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. Die 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. Zwischen 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. ## Grenzen deklarativ festhalten Damit 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: ```text ContextMap ShopMap { contains Sales, Inventory, Billing Inventory -> Sales : Customer-Supplier { implementationTechnology = "REST" downstreamRights = INFLUENCER exposedAggregates = Product } Sales -> Billing : Conformist { implementationTechnology = "Messaging" } } ``` Das 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. ## Wie ich in der Praxis vorgehe Mein 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. Dieses 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. ## Fazit Der 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? Wer 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. ## Weiterführende Quellen - [Martin Fowler – BoundedContext](https://martinfowler.com/bliki/BoundedContext.html) - [Martin Fowler – UbiquitousLanguage](https://martinfowler.com/bliki/UbiquitousLanguage.html) - [Martin Fowler – Conway's Law](https://martinfowler.com/bliki/ConwaysLaw.html) - [Vladik Khononov – Bounded Contexts are NOT Microservices](https://vladikk.com/2018/01/21/bounded-contexts-vs-microservices/) - [Context Mapper – Context Map](https://contextmapper.org/docs/context-map/) --- # Funktionale Muster in Python: Comprehensions, itertools und functools URL: https://www.mikebild.dev/de/blog/funktionale-muster-in-python/ Datum: 2020-07-15 ```python from itertools import groupby from operator import itemgetter records = [ {"team": "a", "pts": 3}, {"team": "b", "pts": 5}, {"team": "a", "pts": 2}, ] totals = { team: sum(r["pts"] for r in group) for team, group in groupby(records, key=itemgetter("team")) } # {"a": 2, "b": 5} – erwartet war {"a": 5, "b": 5} ``` Kein 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. Genau 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. Ich 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. ## Comprehensions statt map und filter Fangen 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: ```python nums = range(10) # map/filter with lambdas – works, but reads inside out squares_even = list(map(lambda n: n * n, filter(lambda n: n % 2 == 0, nums))) ``` Das 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: ```python nums = range(10) # comprehension – transform first, condition last, one line squares_even = [n * n for n in nums if n % 2 == 0] ``` Das 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: ```python words = ["alpha", "beta", "gamma"] # list, dict and set comprehensions lengths = [len(w) for w in words] by_length = {w: len(w) for w in words} initials = {w[0] for w in words} ``` Ein 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. Bedeutet 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: ```python words = ["alpha", "beta", "gamma"] # map shines with an existing named function – no lambda noise shouted = list(map(str.upper, words)) ``` Das `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. Ein 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: ```python nums = range(10) # if at the end filters elements out – only evens survive evens = [n for n in nums if n % 2 == 0] # if ... else at the front transforms – all elements stay masked = [n if n % 2 == 0 else 0 for n in nums] ``` Das `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. ## Lazy rechnen mit map, filter und Generator-Expressions Ein 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. Die 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: ```python # list comprehension: builds the whole list in memory total_eager = sum([n * n for n in range(1_000_000)]) # generator expression: yields one value at a time, constant memory total_lazy = sum(n * n for n in range(1_000_000)) ``` Beide 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. Ein 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. Damit haben wir drei Wege, eine Sequenz zu verarbeiten, und jeder hat seinen Platz: - Comprehension – lesbar, materialisiert sofort, eigener Namensraum, Filter und Transformation in einem Ausdruck. - `map`/`filter` – lazy, ideal in Kombination mit einer vorhandenen benannten Funktion, mit `lambda` weniger lesbar. - `itertools` – lazy Kombinatoren für lange oder unendliche Ströme, konstanter Speicher, gut komponierbar. Den dritten Punkt schauen wir uns jetzt genauer an. ## itertools: lazy Kombinatoren für Ströme `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: ```python from itertools import chain, islice, count # count: infinite counter, here 1, 3, 5, 7, 9 first_five_odds = list(islice(count(1, 2), 5)) # chain.from_iterable: flatten a list of lists lazily flat = list(chain.from_iterable([[1, 2], [3, 4], [5, 6]])) ``` Neben `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. `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. Der Baustein, der die meisten Aha-Momente auslöst, ist `groupby`. Damit lassen sich Datensätze nach einem Schlüssel gruppieren und aggregieren: ```python from itertools import groupby from operator import itemgetter records = [ {"team": "a", "pts": 3}, {"team": "a", "pts": 2}, {"team": "b", "pts": 5}, ] # IMPORTANT: sort by the same key before grouping records.sort(key=itemgetter("team")) totals = { team: sum(r["pts"] for r in group) for team, group in groupby(records, key=itemgetter("team")) } # {"a": 5, "b": 5} ``` Damit 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. Ein 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. Bevor 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. ```mermaid graph LR A[Raw data] --> B[Comprehension
    eager, materialised] B --> C[Generator expr
    lazy, constant memory] C --> D[itertools
    chain / islice / groupby] D --> E[reduce / sum / prod
    fold to a value] E --> F[Result] ``` ## functools: partial, reduce und lru_cache `functools` bündelt Werkzeuge, die auf Funktionen selbst wirken. Drei davon sind für funktionale Muster zentral. `partial` bindet Argumente einer Funktion vorab und gibt eine neue, aufrufbare Funktion zurück. Das ist praktisch, um allgemeine Funktionen zu spezialisieren: ```python from functools import partial def power(base, exponent): return base ** exponent # fix the exponent, get a specialised function square = partial(power, exponent=2) cube = partial(power, exponent=3) print(square(5)) # 25 print(cube(5)) # 125 ``` `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. ```python from functools import reduce flat = [1, 2, 3, 4] # reduce works, but a built-in is almost always clearer total = reduce(lambda acc, x: acc + x, flat, 0) # 10 better = sum(flat) # same result, obvious intent ``` Ein 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. Hier 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. Der 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: ```python from functools import lru_cache @lru_cache(maxsize=None) def fib(n: int) -> int: return n if n < 2 else fib(n - 1) + fib(n - 2) print(fib(50)) # 12586269025 – instant instead of minutes print(fib.cache_info()) # CacheInfo(hits=48, misses=51, maxsize=None, currsize=51) ``` Mit `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. Zwei 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`. `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. ## Der Hang zur Immutability Funktionales 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: ```python # immutable, hashable – usable as dict keys or set members point = (3, 4) allowed = frozenset({"read", "write"}) permissions = { frozenset({"read"}): "viewer", frozenset({"read", "write"}): "editor", } print(permissions[allowed]) # editor ``` Der 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. Das 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: ```python # don't do this: a comprehension used only for side effects [print(w) for w in words] # builds a useless list of None values # do this instead for w in words: print(w) ``` Die 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. ## Die passende Wahl treffen Entscheidend 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. ```mermaid graph TD A[Sequence to process] --> B{Build a new container?} B -->|Yes| C[Comprehension
    list / dict / set] B -->|No| D{Transform with a
    named function?} D -->|Yes| E[map] D -->|No| F{Large or infinite,
    save memory?} F -->|Yes| G[Generator expr
    or itertools] F -->|No| H{Fold to one value?} H -->|Yes| I[sum / prod / reduce] ``` Die 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. Die 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-python.html - Repository `introduction-python` – die Beispiele aus diesem Artikel zum Ausprobieren. - [functools – Higher-order functions (Python 3.8)](https://docs.python.org/3.8/library/functools.html) - [itertools – Functions creating iterators (Python 3.8)](https://docs.python.org/3.8/library/itertools.html) - [List Comprehensions – Python Tutorial (3.8)](https://docs.python.org/3.8/tutorial/datastructures.html#list-comprehensions) - [map und filter – Built-in Functions (Python 3.8)](https://docs.python.org/3.8/library/functions.html#map) --- # JSX genau: Die Regeln hinter dem Markup URL: https://www.mikebild.dev/de/blog/jsx-genau-die-regeln-hinter-dem-markup/ Datum: 2020-06-24 # JSX genau: Die Regeln hinter dem Markup JSX 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. **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/). Im 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. ## Was der Compiler wirklich daraus macht JSX 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. Stand Juni 2020 ist das Ziel dieser Übersetzung `React.createElement`. Aus dem Markup wird ein Aufruf, und aus dem Aufruf ein einfaches Objekt. ```jsx const el = ; ``` wird sinngemäß zu: ```js const el = React.createElement( "button", { className: "primary", onClick: handleClick }, "Save", ); ``` Das 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. ## Ausdruck, nicht Anweisung Die 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. ```jsx // works – an expression

    {user ? user.name : "Guest"}

    // does not work – if is a statement

    {if (user) { user.name }}

    ``` Deshalb 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`: ```jsx function Status({ status }) { let text; if (status === "open") text = "Open"; else if (status === "paid") text = "Paid"; else text = "Unknown"; return {text}; } ``` Das 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. ## Attribute sind Props, und sie heißen anders als in HTML JSX-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. Zwei Namen sind reserviert, weil `class` und `for` in JavaScript Schlüsselwörter sind. Deshalb heißt das Klassenattribut `className` und das Label-Attribut `htmlFor`: ```jsx ``` Alle 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: ```jsx // correct – a function reference // wrong – calls handleSave IMMEDIATELY on render and passes the return value ``` Der 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)}`. Werte gibt man in geschweiften Klammern, feste Strings in Anführungszeichen. Ein Attribut ohne Wert ist implizit `true`: ```jsx // disabled is equivalent to disabled={true} ``` ## Wie Werte im Inhalt gerendert werden – und was verschwindet Was 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. ```jsx
    {message} {/* string or number -> gets displayed */} {null} {/* nothing */} {isAdmin && } {/* Panel only when isAdmin is true */}
    ``` Ein 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: ```jsx // pitfall: if count is 0, a 0 appears in the UI, not nothing {count && } // correct: convert to a boolean first {count > 0 && } ``` Die `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. ## Ein Element, ein Wurzelknoten – und Fragmente Weil 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 `
    ` 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: ```jsx function Row() { return ( <> Name Amount ); } ``` Die Kurzschreibweise `<>...` reicht fast immer. Nur wenn das Fragment selbst ein `key` braucht – etwa als direktes Kind einer Liste – muss man die lange Form `...` verwenden, weil die Kurzform keine Attribute annimmt. Die 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. ## Listen und Keys: die am meisten unterschätzte Regel Eine 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. ```jsx
      {invoices.map((i) => (
    • {i.number}
    • ))}
    ``` Der 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. Der 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. ## Spread, Kinder und die kleinen Fallstricke am Rand Props kann man einzeln setzen oder mit dem Spread-Operator aus einem Objekt durchreichen. Das ist bequem, verdient aber Vorsicht: ```jsx const props = { type: "text", value: value, onChange: handleChange }; ``` Sauber 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 – `` überschreibt das `value` aus dem Objekt. Der 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. Ein paar weitere Details, die immer wieder auffallen: Inline-Styles sind in JSX ein Objekt, keine CSS-Zeichenkette, und ihre Schlüssel stehen in camelCase – `
    `, 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. ## Warum sich die Genauigkeit lohnt All 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. Wer 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. ## Weiterführende Quellen - Repository introduction-react (JSX-Kapitel und Beispiele): https://github.com/mikebild/introduction-react - React, JSX-Markup schreiben: https://react.dev/learn/writing-markup-with-jsx - React, JavaScript in JSX mit geschweiften Klammern: https://react.dev/learn/javascript-in-jsx-with-curly-braces - React, Listen rendern und Keys: https://react.dev/learn/rendering-lists --- # AWS Deployment Pipelines sind Architektur URL: https://www.mikebild.dev/de/blog/aws-deployment-pipelines-architektur/ Datum: 2020-05-12 # AWS Deployment Pipelines sind Architektur Die 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. Der 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. ## Die Pipeline legt die Änderbarkeit fest Architektur 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. Auf 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. Ein minimaler Stack sieht so aus: ```ts import * as cdk from "@aws-cdk/core"; import * as lambda from "@aws-cdk/aws-lambda"; import * as apigw from "@aws-cdk/aws-apigateway"; export class ApiStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const handler = new lambda.Function(this, "ApiHandler", { runtime: lambda.Runtime.NODEJS_12_X, handler: "index.handler", code: lambda.Code.fromAsset("dist/api"), }); new apigw.LambdaRestApi(this, "Api", { handler }); } } ``` Das 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. ## Die „CDK Action“ als kleinster ehrlicher Baustein Der einfachste Weg, dieses Deployment zu automatisieren, ist eine GitHub Action, die auf jeden Push nach `main` reagiert: ```yaml name: deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: node-version: 12 - run: npm ci - run: npm test - run: npx cdk deploy --require-approval never env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: eu-central-1 ``` Das 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. Genau 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. ## Der Review-Punkt liegt vor dem Deploy, nicht danach Bei 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: ```bash # what will actually change in the account cdk diff ApiStack ``` `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. So sieht der Weg als Ganzes aus: ```mermaid flowchart LR Commit --> Build["Build & Test"] Build --> Synth["cdk synth
    CloudFormation-Template"] Synth --> Diff["cdk diff
    Review-Artefakt"] Diff --> Approve{"Freigabe?"} Approve -->|ja| Deploy["cdk deploy"] Approve -->|nein| Stop["Halt"] Deploy --> Observe["Metriken &
    Alarme"] Observe -->|Fehler| Rollback["Rollback auf
    vorherige Revision"] ``` Jeder 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. ## Dasselbe Artefakt durch mehrere Umgebungen Sobald 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. ```ts const app = new cdk.App(); new ApiStack(app, "ApiStack-staging", { env: { account: "111111111111", region: "eu-central-1" }, }); new ApiStack(app, "ApiStack-prod", { env: { account: "222222222222", region: "eu-central-1" }, }); ``` Damit 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. ## Trade-offs, die man benennen muss Die interessante Arbeit an einer Pipeline besteht nicht darin, sie zum Laufen zu bringen, sondern darin, ihre Kompromisse offenzulegen. Drei davon tauchen regelmäßig auf: - 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. - 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. - 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. Keine 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. ## Warum das ein CTO interessieren sollte Fü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: - 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. - Lead Time – wie lange von „Code fertig“ bis „in Produktion“. `cdk diff` im PR plus automatischer Deploy drückt das von Tagen auf Minuten. - 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. - 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. Das 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. ## Woran ich eine Pipeline prüfe Belastbar 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? Am 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. ## Fazit Die 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. ## Weiterführende Quellen - AWS CDK Developer Guide: https://docs.aws.amazon.com/cdk/latest/guide/home.html - AWS CDK Constructs: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html - AWS CloudFormation – wie Deployments und Rollbacks intern ablaufen: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/Welcome.html - Nicole Forsgren, Jez Humble, Gene Kim: „Accelerate“ – die vier Delivery-Kennzahlen im Detail. --- # Fortgeschrittenes Python: Decorators, Magic Methods und Generatoren URL: https://www.mikebild.dev/de/blog/python-advanced-decorators-generatoren/ Datum: 2020-04-08 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. Der 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. ## Decorators: Funktionen, die Funktionen umschließen Der 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. ```python import functools import time def timer(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start print(f"{func.__name__} took {elapsed:.4f}s") return result return wrapper @timer def slow_sum(n): return sum(range(n)) ``` Die 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. Der Ablauf beim Aufruf einer dekorierten Funktion, aufgezeichnet – der Wrapper klammert die eigentliche Arbeit ein: ```mermaid flowchart LR Call["call slow_sum()"] --> W["wrapper()"] W -->|"before:
    start timer"| F["original func()"] F -->|"return value"| W W -->|"after:
    print elapsed"| Ret["return result"] ``` ### functools.wraps ist keine Kür Ein 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. ```python def timer_broken(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @timer_broken def greet(name): """Say hello to someone.""" return f"Hello, {name}" print(greet.__name__) # -> "wrapper" print(greet.__doc__) # -> None ``` `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. ### Decorators mit Argumenten Hä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. ```python def repeat(times): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): result = None for _ in range(times): result = func(*args, **kwargs) return result return wrapper return decorator @repeat(times=3) def ping(): print("ping") ``` `@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. ## Magic Methods: eigene Objekte, die sich wie eingebaute verhalten Der 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. Ein paar der gängigsten Dunder-Methoden und was sie auslösen: - `__init__` – die Konstruktion beim Erzeugen einer Instanz - `__repr__` – `repr()` und die Anzeige im REPL, idealerweise eindeutig und im besten Fall `eval`-fähig - `__str__` – `str()` und `print()`; fehlt sie, springt `__repr__` ein - `__eq__` – der Vergleich mit `==` - `__len__` – der Aufruf `len(obj)`, der auch für die Wahrheitsprüfung herangezogen wird, wenn `__bool__` fehlt - `__iter__` und `__next__` – die Iteration in `for`-Loops und beim Entpacken - `__enter__` und `__exit__` – der Ein- und Ausstieg eines `with`-Blocks - `__call__` – der Aufruf einer Instanz wie eine Funktion, `obj()` Damit 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. ```python class LineStore: def __init__(self, path): self.path = path def __repr__(self): return f"LineStore({self.path!r})" def __eq__(self, other): return isinstance(other, LineStore) and self.path == other.path def __enter__(self): self._file = open(self.path, encoding="utf-8") return self def __exit__(self, exc_type, exc_value, traceback): self._file.close() return False # do not swallow exceptions def __iter__(self): return (line.rstrip("\n") for line in self._file) ``` Ein `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: ```python with LineStore("access.log") as store: for line in store: if "ERROR" in line: print(line) ``` Das `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. Fü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. ## Generatoren: lazy Sequenzen mit yield Der `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. Sobald 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. ```python def stream_lines(path): with open(path, encoding="utf-8") as fh: for line in fh: # a file object is itself an iterator yield line.rstrip("\n") for line in stream_lines("big.log"): if line.startswith("2020"): print(line) ``` Hier 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. Dasselbe Prinzip gibt es kompakt als Generator-Expression – syntaktisch wie eine List Comprehension, nur mit runden statt eckigen Klammern: ```python # list comprehension: builds the whole list in memory lengths_list = [len(line) for line in stream_lines("big.log")] # generator expression: yields one length at a time total = sum(len(line) for line in stream_lines("big.log")) ``` Die 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: ```python def chained(*paths): for path in paths: yield from stream_lines(path) ``` ## Drei Fallstricke So klar die drei Bausteine sind, so zuverlässig treten in der Praxis dieselben Stolperstellen auf. Diese drei kosten die meiste Zeit. Der Klassiker sind veränderliche Default-Argumente. Wer eine leere Liste als Default schreibt, bekommt nicht bei jedem Aufruf eine frische Liste, sondern immer dieselbe. ```python def append_item(item, acc=[]): # trap: shared across calls acc.append(item) return acc print(append_item(1)) # [1] print(append_item(2)) # [1, 2] – surprise ``` Der 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`: ```python def append_item(item, acc=None): if acc is None: acc = [] acc.append(item) return acc ``` Die 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. Die 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. ```python gen = stream_lines("big.log") first = list(gen) # reads all lines second = list(gen) # empty – the generator is exhausted ``` Eine 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. ## Fazit Decorators, 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. Was 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-python.html - 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`) - Python Data Model / Special Methods: https://docs.python.org/3.8/reference/datamodel.html - Das `with`-Statement und das Context-Manager-Protokoll: https://docs.python.org/3.8/reference/compound_stmts.html#the-with-statement - `functools` mit `wraps` und `update_wrapper`: https://docs.python.org/3.8/library/functools.html - `contextlib.contextmanager`: https://docs.python.org/3.8/library/contextlib.html - Yield-Ausdrücke und Generatoren: https://docs.python.org/3.8/reference/expressions.html#yield-expressions - Tutorial zu Generators und Generator Expressions: https://docs.python.org/3.8/tutorial/classes.html#generators - PEP 318 – Decorators for Functions and Methods: https://www.python.org/dev/peps/pep-0318/ --- # Policy as Code für CDK URL: https://www.mikebild.dev/de/blog/policy-as-code-fuer-cdk/ Datum: 2020-03-12 # Policy as Code für CDK Wer 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. In 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. ## Warum Regeln aus dem Review herausfallen Ein 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. Daraus 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. Das 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. ## Der Prüfpunkt liegt zwischen Synthese und Deployment Man 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. ```mermaid flowchart LR Source["CDK-Code (TypeScript)"] --> Synth["cdk synth"] Synth --> Policy["Policy-Check"] Policy -->|verstoss| Fail["Feedback im PR"] Policy -->|ok| Deploy["Deployment"] ``` `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. Der 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. ## Eine Policy ist Code, kein Config-Eintrag Der 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. ```ts import { Policy, PolicyInterface, Reportable } from "cloudpatrol"; import * as s3 from "@aws-cdk/aws-s3"; export class BucketEncryptionPolicy extends Policy implements PolicyInterface { public policyName = "Bucket Encryption"; public description = "S3 buckets must be encrypted at rest."; public scope = s3.CfnBucket; public validator(node: s3.CfnBucket, reporter: Reportable): void { if (!node.bucketEncryption) { reporter.addError(node, this, "Bucket has no server-side encryption configured."); } } } ``` Der `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“. Angewandt wird das Ganze über eine Patrol, die einen Stack durchläuft: ```ts const patrol = new AwsCdkPatrol(awsDefaults); patrol.check(stack); ``` `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. ## warn oder block ist eine Entwurfsentscheidung Nicht 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. Ich 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. ```ts type Severity = "warn" | "block"; type RuleMeta = { policyName: string; severity: Severity; reason: string; suggestion: string; }; ``` ## Jede Regel braucht einen Grund und einen Ausweg Eine 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. ```json { "policy": "lambda-timeout-max", "severity": "block", "reason": "A timeout above 30s usually hides a synchronous call that should be async.", "suggestion": "Move the work behind a queue or a Step Function and return early." } ``` Diese 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. ## Der Pull Request ist der richtige Ort Guardrails 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. Der 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. Das 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. ## Kosten sind ein Architektursignal Ein 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. ```mermaid flowchart LR Usage["Nutzung"] --> Cost["Kostenspur"] Cost --> Signal["Signal"] Signal --> Rule["Policy als Leitplanke"] ``` Eine 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. ## Wo Policy as Code an ihre Grenze kommt Es 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“. Damit 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. Ein 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. ## Was ich nach ein paar Wochen mitnehme `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. Ob 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. ## Weiterführende Quellen - AWS CDK, Einführung und Konzepte: https://docs.aws.amazon.com/cdk/latest/guide/home.html - Constructs als Bausteine: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html - Policy as Code fürs CDK (cloudpatrol): https://github.com/MikeBild/cloudpatrol --- # Das Aggregat als Konsistenzgrenze URL: https://www.mikebild.dev/de/blog/aggregat-als-konsistenzgrenze/ Datum: 2020-02-19 ```typescript @Entity() class Order { @PrimaryGeneratedColumn() id: number; @ManyToOne(() => Customer) customer: Customer; @OneToMany(() => LineItem, (li) => li.order) lineItems: LineItem[]; @Column() status: string; } ``` Diese 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. Ein 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. ## Was ein Aggregat wirklich ist Martin 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. Innerhalb 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. Und 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. Das 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. ## Die Root schützt ihre Invarianten Sehen 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. ```typescript type OrderStatus = 'DRAFT' | 'CONFIRMED'; class Order { private status: OrderStatus = 'DRAFT'; private readonly lineItems: LineItem[] = []; constructor( readonly id: OrderId, private readonly customerId: CustomerId, ) {} addLineItem(product: ProductId, quantity: number): void { if (this.status === 'CONFIRMED') { throw new Error('Cannot modify a confirmed order'); } if (quantity <= 0) { throw new Error('Quantity must be positive'); } this.lineItems.push(new LineItem(product, quantity)); } confirm(): void { if (this.lineItems.length === 0) { throw new Error('Cannot confirm an order without line items'); } this.status = 'CONFIRMED'; } } ``` Zwei 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. Man 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. ## Eine Transaktion ändert genau ein Aggregat Aus 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. Diese 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. ```mermaid graph LR subgraph T1["Transaction T1"] O["Order
    confirm()"] end subgraph T2["Transaction T2"] I["Inventory
    reserve()"] end O -- "OrderConfirmed (event)" --> I ``` Wichtig 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. Wer 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. ## Andere Aggregate nur per Identity referenzieren Damit 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“. In der `Order` oben sieht man das bereits. Die Root hält `customerId: CustomerId`, nicht `customer: Customer`. Der Unterschied ist keine Kleinigkeit. ```typescript // Wrong: the customer is pulled inside the order class Order { private customer: Customer; // pulls another aggregate inside the boundary } // Right: only the identity crosses the boundary class Order { private readonly customerId: CustomerId; // reference by identity } ``` Sobald `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. Der 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. Damit 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. ```mermaid graph TB ext["Client code"] subgraph agg["Aggregate: Order (consistency boundary)"] root["Order (Aggregate Root)"] li["LineItem"] money["Money (Value Object)"] root --> li li --> money end cust["Customer
    (separate aggregate)"] ext -->|"references the root only"| root root -. "customerId (by identity)" .-> cust ``` Man 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. ## Aggregate klein halten Das 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. Die 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. Fassen wir die Regeln zusammen, an denen ich ein Aggregat im Entwurf prüfe: - Aller Zugriff von außen läuft über die Aggregate Root; innere Objekte sind gekapselt. - Die Root setzt die Invarianten durch, und zwar transaktional konsistent nach jeder Änderung. - Eine Transaktion ändert genau eine Aggregat-Instanz; alles Weitere läuft über eventual consistency und Domain Events. - Andere Aggregate werden nur über ihre Identität referenziert, nie als eingebettetes Objekt. - Aggregate bleiben klein und werden entlang echter Invarianten geschnitten, nicht entlang von Navigationswünschen. ## Die drei Fallstricke Drei Verwechslungen begegnen mir dabei immer wieder, und es lohnt sich, sie beim Namen zu nennen. Der 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. Der 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. Der 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. ## Eine kurze Abgrenzung Weil 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. Und 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. ## Fazit Das 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. Die 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html - Martin Fowler: [DDD_Aggregate](https://martinfowler.com/bliki/DDD_Aggregate.html) – die kompakte Definition von Aggregat und Aggregate Root. - 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“. - Eric Evans: *Domain-Driven Design* (2003), Kapitel 6 – die Primärquelle für Aggregate und Aggregate Roots. - Greg Young / Martin Fowler zur Abgrenzung von CQRS und Event Sourcing – nützlich, um das Aggregat sauber davon zu trennen. - Aus meinem eigenen Vortragsmaterial: das Deck „CQRS + Event Sourcing“ (SlideShare), in dem ich diese Grenzziehung an konkreten Beispielen durchspiele. --- # `ssmenv-cli`: kleine CLIs für große Ordnung URL: https://www.mikebild.dev/de/blog/ssmenv-cli-kleine-clis/ Datum: 2020-02-12 # `ssmenv-cli`: kleine CLIs für große Ordnung Konfiguration 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. Und 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. ## Das Problem hinter dem Werkzeug Das 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. Das 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. ## Die Entscheidung: SSM als einzige Quelle Die 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. Das 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. Was 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. ## Architektur: ein dünner Adapter, keine Plattform Die 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“. Diese 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. ## Kleines Beispiel Der praktische Kern passt in wenige Zeilen. Werte setzt man einmal – idealerweise über Automatisierung, hier zur Anschauung von Hand: ```bash # Store a secret as an encrypted SecureString under a namespaced path ssmenv set --path /myapp/prod DATABASE_URL "postgres://db.internal:5432/app" # List everything under a prefix (values masked by default) ssmenv list --path /myapp/prod ``` Der eigentliche Gewinn zeigt sich beim Laden. Statt eine Datei zu verteilen, löst man die Umgebung zur Laufzeit auf: ```bash # Run a process with the resolved environment, nothing written to disk ssmenv exec --path /myapp/prod -- node server.js # Or export into the current shell for a quick local session eval "$(ssmenv export --path /myapp/staging)" ``` Und weil zwei Umgebungen selten identisch sind, ist der Vergleich ein vollwertiger Befehl und keine Nebensache: ```bash # Show which keys differ between staging and prod ssmenv diff --path /myapp/staging --path /myapp/prod ``` Das 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. ## Skizze ```mermaid flowchart LR Store["SSM Parameter Store
    /myapp/prod/*"] --> CLI["ssmenv-cli
    thin adapter"] IAM["IAM
    permission boundary"] --> CLI KMS["KMS
    SecureString decrypt"] --> CLI CLI --> Env["process env"] Env --> App["node server.js"] ``` Die 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. ## Trade-offs: warum klein, warum nicht mehr Die 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. Ich 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. Der 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. ## Betriebliche Konsequenz Die 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. Zugriff 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. Rü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. ## Fehlerfälle Eine Idee ist erst belastbar, wenn der Fehlerpfad beschrieben ist. Drei Fälle gehören dazu. Fehlende 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. Teilweise 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. Netz- 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. ## Dieselbe Grenze im CI Der 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. ```bash # In CI: the job's role grants read on /myapp/staging/* only ssmenv exec --path /myapp/staging -- npm run deploy ``` Damit 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. ## Business-Outcome: kürzere Zyklen, weniger Reibung Der 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. Das 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. ## Das Muster für kleine CLIs `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. Deshalb 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. ## Schluss Wichtiger 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. ## Weiterführende Quellen - Repository ssmenv-cli: https://github.com/MikeBild/ssmenv-cli Die Quelle hilft beim Gegenprüfen der konkreten Umsetzung. --- # GitHub Actions mit AWS CDK URL: https://www.mikebild.dev/de/blog/github-actions-mit-aws-cdk/ Datum: 2020-01-12 # GitHub Actions mit AWS CDK Zwei 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. **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. In 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. ## Warum diese Kombination mehr ist als Automatisierung GitHub 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. Der 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. Diese 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. ## Vor dem Workflow steht die Freigabe Bevor 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. ## Der `cdk diff` als Review-Artefakt Der 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. Das ä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. Der 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“. ## Ein kleiner Stack Als Grundlage dient ein bewusst kleiner Stack – eine Lambda hinter einem API Gateway. Er soll keine vollständige Anwendung simulieren, sondern die Grenze zeigen. ```ts import * as cdk from "@aws-cdk/core"; import * as lambda from "@aws-cdk/aws-lambda"; import * as apigw from "@aws-cdk/aws-apigateway"; export class ApiStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const handler = new lambda.Function(this, "ApiHandler", { runtime: lambda.Runtime.NODEJS_12_X, handler: "index.handler", code: lambda.Code.fromAsset("dist/api"), timeout: cdk.Duration.seconds(10) }); new apigw.LambdaRestApi(this, "Api", { handler }); } } ``` Interessant 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. Ein 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. ## Die Pipeline Die 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. ```yaml name: deploy on: push: branches: [main] pull_request: jobs: diff: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - uses: actions/setup-node@v1 with: node-version: "12" - run: npm ci - run: npm run build - run: npx cdk diff env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: eu-central-1 deploy: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - uses: actions/setup-node@v1 with: node-version: "12" - run: npm ci - run: npm run build - run: npx cdk deploy --require-approval never env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: eu-central-1 ``` Die 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. Der Ablauf als Skizze: ```mermaid flowchart LR PR[Pull Request] --> Diff[cdk diff
    im Review sichtbar] Diff --> Merge[Merge nach main] Merge --> Build[Build + synth] Build --> Deploy[cdk deploy] Deploy --> CFN[CloudFormation
    Change Set] CFN --> Observe[Logs + Metriken] ``` ## Eine Umgebung, die sich reproduzieren lässt Sobald 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. Das 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. ## Rechte, die nicht mit jeder Bequemlichkeit mitwachsen Die 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. Serverless 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“. ## Rollback und der Irrtum des glücklichen Pfades Bleibt 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. Hier 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. ## Was sich dadurch messbar ändert Wenn 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. Konkret 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. Das 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. ## Worauf ich im Review prüfe Fü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? Wenn 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. ## Weiterführende Quellen - AWS CDK Developer Guide: https://docs.aws.amazon.com/cdk/latest/guide/home.html - AWS CDK Constructs: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html - [GitHub Actions: OpenID Connect mit AWS](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws) - Repository aws-cdk-js-github-action: https://github.com/MikeBild/aws-cdk-js-github-action Die Quellen helfen beim Gegenprüfen von Plattformverhalten. Die eigentliche Aussage bleibt die Architekturentscheidung im konkreten Beispiel. ## Schluss GitHub 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. --- # PouchDB und Offline-First URL: https://www.mikebild.dev/de/blog/pouchdb-offline-first/ Datum: 2019-11-12 # PouchDB und Offline-First Offline 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? Damit 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. ## Replikation statt Request/Response Im 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. Im 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. ```mermaid flowchart LR subgraph rr["Request/Response"] UI1["UI"] -->|"HTTP-Request
    blockiert ohne Netz"| API["API-Server"] API --> DB1[("zentrale
    Datenbank")] end subgraph repl["Replikation"] UI2["UI"] -->|"lokal lesen
    und schreiben"| LOCAL[("PouchDB
    IndexedDB")] LOCAL <-->|"Sync, wenn
    Netz da ist"| REMOTE[("CouchDB")] end ``` Der 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. ## PouchDB im Browser, CouchDB am anderen Ende PouchDB 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. Dass sich damit bidirektionale Synchronisation in wenigen Zeilen aufsetzen lässt, wirkt beim ersten Kontakt fast verdächtig: ```js const localNotes = new PouchDB('notes'); const remoteNotes = new PouchDB('https://couch.example.com/notes'); localNotes.sync(remoteNotes, { live: true, retry: true }) .on('change', info => renderChange(info)) .on('paused', () => setSyncStatus('idle')) .on('active', () => setSyncStatus('syncing')) .on('error', err => reportSyncError(err)); ``` `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. Wichtig 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? ## Der Revisionsbaum Der 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: ```js async function saveNote(note) { try { await localNotes.put(note); } catch (err) { if (err.status !== 409) throw err; // optimistic locking: fetch the current revision and retry once const current = await localNotes.get(note._id); await localNotes.put({ ...note, _rev: current._rev }); } } ``` Dieser 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: ```mermaid graph TD R1["Rev 1-a4f
    gemeinsamer Ausgangsstand"] --> R2A["Rev 2-c91
    geändert auf Gerät A"] R1 --> R2B["Rev 2-b07
    geändert auf Gerät B"] R2A --> R3["Rev 3-e12
    Zusammenführung
    durch die Anwendung"] R2B -. "Verlierer-Zweig
    bleibt erhalten" .-> R3 ``` CouchDB 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. ## Konflikte sind der Normalfall An 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. Die Auflösung selbst ist mechanisch unspektakulär: Konfliktrevisionen abholen, zusammenführen, Ergebnis schreiben, Verlierer entfernen. ```js async function resolveConflicts(id) { const winner = await localNotes.get(id, { conflicts: true }); if (!winner._conflicts) return winner; const losers = await Promise.all( winner._conflicts.map(rev => localNotes.get(id, { rev })) ); const merged = mergeNotes(winner, losers); // domain-specific merge await localNotes.put(merged); await Promise.all( losers.map(loser => localNotes.remove(loser._id, loser._rev)) ); return merged; } ``` Entscheidend 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. Deshalb 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. ## Was das mit dem Datenmodell macht Replikation 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: - Ein Dokument pro fachlicher Einheit, die ein Nutzer in einem Zug bearbeitet – kein Sammeldokument für den halben Anwendungszustand. - Sprechende IDs wie `note:2019-11-12` statt reiner Zufalls-UUIDs, wo die Fachlichkeit es hergibt; das macht Direktzugriffe planbar und erspart manchen Index. - Anfügen statt Überschreiben, wo es geht: Wer Events als neue Dokumente schreibt, statt ein bestehendes zu ändern, bekommt strukturell weniger Konflikte. - Löschungen als Teil des Modells begreifen: Gelöschte Dokumente bleiben als `_deleted`-Revisionen erhalten, damit die Löschung selbst repliziert werden kann. Für Queries jenseits des Schlüsselzugriffs gibt es Mango-Queries über pouchdb-find, inzwischen Teil des Standard-Builds: ```js await localNotes.createIndex({ index: { fields: ['updatedAt'] } }); const recent = await localNotes.find({ selector: { updatedAt: { $gte: '2019-11-01' } }, sort: [{ updatedAt: 'desc' }], limit: 20 }); ``` Dazu 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. ## Zwei eigene Erfahrungen 2016 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. Nebenbei 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. ## Wo ich es nicht einsetzen würde So ü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: - Feingranulare Zugriffsrechte: CouchDB autorisiert pro Datenbank, nicht pro Dokument. Das übliche Gegenmittel ist eine Datenbank pro Nutzer, was Betrieb und serverseitige Auswertung merklich verkompliziert. - 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. - Streng konsistente Abläufe: Kontostände, Lagerbestände, Buchungen mit harten Invarianten brauchen zentrale Koordination. Eventual Consistency lässt sich nicht wegkonfigurieren. - 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. Gefilterte 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. ## Unterm Strich Offline-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. ## Weiterführende Quellen - [awesome-pouchdb – kuratierte Liste von PouchDB-Werkzeugen und Plugins](https://github.com/MikeBild/awesome-pouchdb) - [graphql-pouch – GraphQL-Runtime auf PouchDB mit CouchDB-Sync](https://github.com/MikeBild/graphql-pouch) - [PouchDB Guide: Replication](https://pouchdb.com/guides/replication.html) - [PouchDB Guide: Conflicts](https://pouchdb.com/guides/conflicts.html) - [CouchDB Replication Protocol (offizielle Spezifikation)](https://docs.couchdb.org/en/stable/replication/protocol.html) - [Offline First – offlinefirst.org](http://offlinefirst.org/) --- # Commands, Events und Queries sauber trennen URL: https://www.mikebild.dev/de/blog/commands-events-queries-trennen/ Datum: 2019-10-08 `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. Command, 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. Die Form einer Message folgt ihrer Bedeutung. Gleich aussehende JSON-Objekte können dennoch völlig unterschiedliche Verträge tragen. ::::comparison{title="Messages nach ihrer Aussage unterscheiden" role="primary" preferredPattern="comparison-matrix"} :::side{label="Command"} - Bedeutung · Bitte, eine Zustandsänderung zu versuchen - Zeitbezug · auf eine beabsichtigte Zukunft gerichtet - Ergebnis · kann abgelehnt werden oder scheitern ::: :::side{label="Event"} - Bedeutung · eine bereits eingetretene Tatsache - Zeitbezug · immutable Vergangenheit - Ergebnis · wird nicht nachträglich abgelehnt ::: :::side{label="Query"} - Bedeutung · Anfrage nach vorhandener Information - Zeitbezug · Sicht auf den aktuellen oder angegebenen Stand - Ergebnis · verändert den fachlichen Zustand nicht ::: :::: Diese Unterscheidung macht Namen, Fehlerpfade und Verantwortlichkeiten präziser, bevor ein Framework ins Spiel kommt. ## Drei Messages, drei Bedeutungen Fangen 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. Ein **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. Ein **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. Eine **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. Diese 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. ## Der Name transportiert die Semantik Wenn 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. Vergleichen wir die drei Arten direkt: - Ein Command steht im Imperativ und in der Gegenwart (`CreateOrder`): eine Absicht, gerichtet an genau einen Handler, die abgelehnt werden und scheitern kann. - Ein Event steht in der Vergangenheit (`OrderCreated`): eine Tatsache, immutable und nicht ablehnbar, gerichtet an null bis viele Konsumenten. - Eine Query beginnt meist mit `Get` oder `Find` (`GetOrderSummary`): eine Frage, seiteneffektfrei, sie liefert Daten und verändert nichts. Sobald 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. ## Der Fluss: vom Wunsch zur Tatsache zur Antwort Wie 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. Martin 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. ```mermaid flowchart LR C[Command: CreateOrder] --> H[Command Handler] H -->|validate / business rules| E[Event: OrderCreated] E --> RM[(Read Model)] Q[Query: GetOrderSummary] --> RM ``` Man 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. ## In Code gegossen Genug 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: ```ts type LineItem = { sku: string; quantity: number }; type Command = | { type: 'CreateOrder'; orderId: string; customerId: string; items: LineItem[] }; type DomainEvent = | { type: 'OrderCreated'; orderId: string; customerId: string; occurredAt: Date }; type Query = | { type: 'GetOrderSummary'; orderId: string }; ``` Schon 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. Jetzt 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: ```ts class RejectedCommand extends Error {} function handleCreateOrder(cmd: Extract): DomainEvent[] { if (cmd.items.length === 0) { throw new RejectedCommand('order needs at least one item'); } return [ { type: 'OrderCreated', orderId: cmd.orderId, customerId: cmd.customerId, occurredAt: new Date(), }, ]; } ``` Hier 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. Auf der anderen Seite steht der Query-Handler. Er tut genau eine Sache: lesen. Keine Mutation, kein emittiertes Event, kein Side Effect: ```ts interface OrderReadModel { findSummary(orderId: string): OrderSummary; } type OrderSummary = { orderId: string; itemCount: number; status: string }; function handleGetOrderSummary( q: Extract, readModel: OrderReadModel, ): OrderSummary { return readModel.findSummary(q.orderId); // read only, no mutation, no events } ``` Und 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: ```ts function applyOrderCreated( event: Extract, store: Map, ): void { store.set(event.orderId, { orderId: event.orderId, itemCount: 0, status: 'created', }); } ``` Wer 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. ## Die Fallstricke, die ich immer wieder sehe So klar die Theorie ist, so hartnäckig sind die Fehler in der Praxis. Vier davon begegnen mir immer wieder. Der 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. Der 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. Der 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. Der 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. ## Ein kurzer Blick über den Tellerrand: CQS, CQRS und die Kosten Damit 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. Und 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. Die 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. ## Roter Faden Die 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. Diese 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html - Martin Fowler, [CQRS](https://martinfowler.com/bliki/CQRS.html) – die kompakte Definition samt Warnung vor der Komplexität. - Martin Fowler, [CommandQuerySeparation](https://martinfowler.com/bliki/CommandQuerySeparation.html) – Meyers ursprüngliches Prinzip auf Methodenebene. - 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. - Greg Young, [CQRS](https://gregfyoung.wordpress.com/2012/03/02/cqrs/) – der Ursprung der Objekt-/Serviceebene und das Verhältnis zu Event Sourcing. - Vaughn Vernon, *Implementing Domain-Driven Design* (2013), Kapitel zu Domain Events – zur Benennung und Bedeutung von Events in der Domäne. - Mein eigenes Vortragsmaterial „CQRS + Event Sourcing“ (SlideShare) – die Folien, auf denen ich diese Trennung ausführlicher durchgehe. --- # Infrastructure as Code braucht Code-Reviews URL: https://www.mikebild.dev/de/blog/infrastructure-as-code-code-reviews/ Datum: 2019-09-24 # Infrastructure as Code braucht Code-Reviews Wer 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. Auslö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. Denn 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. ## Was sich mit Infrastructure as Code wirklich ändert Vorher 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? Infrastructure 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. Nur: 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. ## Der Diff, den ich wirklich reviewen muss CDK 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. Das 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. ```ts const handler = new lambda.Function(this, "ApiHandler", { runtime: lambda.Runtime.NODEJS_10_X, handler: "index.handler", code: lambda.Code.fromAsset("dist/api") }); ``` Diese 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. ## Die teuerste Zeile ist oft eine Berechtigung Der 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. ```ts // convenient, and wrong bucket.grantReadWrite(handler); table.grant(handler, "dynamodb:*"); ``` Das 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. Das 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. ## Kosten gehören in den Review Bei 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. Ich 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. ## Wonach ich im Infrastruktur-Review sehe Ü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? Diese 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. ## Der Trade-off der Abstraktion CDK 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. Das 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. ## Wo der Review in die Pipeline gehört ```mermaid flowchart LR Source --> Synth Synth --> Diff["cdk diff"] Diff --> Review["Review
    Rechte · Kosten · Rollback"] Review --> Deploy Deploy --> Observe ``` Der 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. ## Was die Maschine prüft, was der Mensch prüft Ein 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. Weil 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. Die 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. ## Der Fehler, das Werkzeug für die Lösung zu halten Ein 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. Genau 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. ## Was das dem Team bringt Der 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. Ein 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. ## Weiterführende Quellen - AWS CDK Developer Guide: https://docs.aws.amazon.com/cdk/latest/guide/home.html - AWS CDK Constructs: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html Die 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. --- # Secrets im Build sind Produktentscheidungen URL: https://www.mikebild.dev/de/blog/secrets-im-build/ Datum: 2019-09-12 # Secrets im Build sind Produktentscheidungen ```console $ docker history --no-trunc myapp:staging IMAGE CREATED BY /bin/sh -c #(nop) COPY file:3f2a… in /app/.env /bin/sh -c #(nop) ENV DATABASE_URL=postgres://app:S3cr3t@db.prod.internal:5432/app ``` Zwei 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. Der 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. ## Die eigentliche Frage ist Vertrauen, nicht Verschlüsselung Verschlü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. Bevor 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. ## Build-Zeit und Laufzeit sind zwei verschiedene Vertrauenszonen Die 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. Das klassische Anti-Pattern sieht harmlos aus: ```dockerfile # ANTI-PATTERN: the secret becomes part of a shared, promotable artifact COPY .env /app/.env ``` ```dockerfile # ANTI-PATTERN: build args stay recoverable via `docker history` ARG NPM_TOKEN RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc \ && npm ci \ && rm .npmrc ``` Das `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. Die 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: ```dockerfile # build stage produces an artifact that knows nothing about any environment FROM node:12 AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # runtime stage receives config from the environment, never from the image FROM node:12-slim WORKDIR /app COPY --from=build /app/dist ./dist CMD ["node", "dist/server.js"] ``` Dieselbe 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. ## Parameter Store als Interface, nicht als Datenbank Sobald 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. ```bash # resolve runtime config just-in-time, scoped by environment path aws ssm get-parameters-by-path \ --path "/myapp/production/" \ --with-decryption \ --query "Parameters[].{Name:Name,Value:Value}" ``` Der 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. ```json { "Effect": "Allow", "Action": "ssm:GetParametersByPath", "Resource": "arn:aws:ssm:eu-central-1:123456789012:parameter/myapp/staging/*" } ``` Damit 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: ```bash #!/usr/bin/env bash set -euo pipefail # inject secrets at container start, keep them out of the image and the repo export DATABASE_URL="$(aws ssm get-parameter \ --name "/myapp/production/database-url" \ --with-decryption --query "Parameter.Value" --output text)" exec node dist/server.js ``` Ob 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. ## Skizze ```mermaid flowchart LR Repo[Code
    ohne Secrets] --> Build[Build
    reproduzierbares Artefakt] Build --> Registry[Artefakt
    je Umgebung gleich] Store[Parameter Store
    je Umgebung getrennt] --> Runtime Registry --> Runtime[Runtime
    injiziert Config just-in-time] ``` Die 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. ## Die Trade-offs, die man wirklich abwägt Kein 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. Umgekehrt 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. Der 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. ## Der Fehlerpfad entscheidet, nicht der Happy Path Eine 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. Rotation 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. Rollback 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. ## Der Business-Outcome ist Tempo bei kontrolliertem Risiko Der 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. Weniger 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. Der 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. ## Typische Fehler Der 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. Der 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. ## Worauf ich prüfe Drei 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? Wenn 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. ## Schluss Ein 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. --- # Product Discovery vor der ersten Zeile Code URL: https://www.mikebild.dev/de/blog/product-discovery-vor-der-ersten-zeile/ Datum: 2019-06-26 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. Genau 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. ## Discovery und Delivery sind zwei Spuren, keine Phasen Der 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. Der 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. ```mermaid flowchart LR subgraph Discovery A[Idea] --> B[Assumption] B --> C[Experiment] C --> D[Validated item] end subgraph Delivery E[Build] --> F[Test] F --> G[Release] end D -->|only validated work
    enters delivery| E ``` Der 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. Ebenso 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. ## Die vier großen Risiken, die Discovery adressiert Auf 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: Value-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. Feasibility 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. ## Annahmen sichtbar und priorisierbar machen Der 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. David 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. ```mermaid quadrantChart title Assumptions Map x-axis Low evidence --> High evidence y-axis Low importance --> High importance quadrant-1 Test first quadrant-2 Keep as leap of faith quadrant-3 Ignore for now quadrant-4 Already known Users self-configure reports: [0.2, 0.9] Payment API meets latency SLA: [0.35, 0.85] Sales can sell without training: [0.6, 0.4] Login flow is usable: [0.8, 0.3] ``` Diesen 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: ```yaml # assumptions.yaml — reviewed before any story enters delivery assumptions: - id: A-1 statement: "Users will self-configure their reports instead of asking support" type: desirability importance: high evidence: low test: landing-page kill_criterion: "signup-to-config rate below 20% after 200 visitors" - id: A-2 statement: "The payment API stays under our latency budget at peak load" type: feasibility importance: high evidence: low test: spike kill_criterion: "p95 response time above 200ms in a load test" - id: A-3 statement: "Sales can close the feature without extra training" type: viability importance: medium evidence: low test: interview kill_criterion: "3 of 5 reps ask for a dedicated enablement session" ``` Fü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. ## Fortschritt heißt validiertes Lernen, nicht ausgelieferte Features Eric 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? Hier 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. Wenn 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. ## Auch technische Annahmen gehören getestet Discovery 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: ```typescript // fitness function — runs the riskiest technical assumption as a test import { measureP95 } from "./load-harness"; const SLA_MS = 200; test("checkout endpoint stays under the latency budget at peak load", async () => { const p95 = await measureP95({ endpoint: "/api/checkout", concurrentUsers: 500, durationSeconds: 60, }); expect(p95).toBeLessThan(SLA_MS); }); ``` Der 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. ## Warum Effizienz allein nicht schützt Der 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. Nur 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. Genau 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. ## Wie das im Alltag konkret aussieht Ich 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. Das 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. ## Fazit Die 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. Der 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. ## Weiterführende Quellen - Marty Cagan: *INSPIRED* (2. Auflage, 2017) und die "four big risks" auf svpg.com/four-big-risks - Jeff Patton und Marty Cagan zu Dual-Track Agile, jpattonassociates.com und svpg.com - Eric Ries: *The Lean Startup* (2011), Build-Measure-Learn und MVP, theleanstartup.com - David J. Bland: Assumptions Mapping, precoil.com und strategyzer.com - Nicole Forsgren, Jez Humble, Gene Kim: *Accelerate* (2018) - Neal Ford, Rebecca Parsons, Patrick Kua: *Building Evolutionary Architectures* (2017) - Michael Nygard: *Release It!* zu Circuit Breaker und Stability Patterns --- # CQRS ist eine fachliche Grenze, kein Framework URL: https://www.mikebild.dev/de/blog/cqrs-fachliche-grenze-kein-framework/ Datum: 2019-06-25 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. Die 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. ## Woher der Begriff kommt CQRS 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. Martin 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. Wichtig 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. ## Die zwei Seiten haben eine unterschiedliche Form Schreib- 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. Die 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. Die 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. Genau 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. ```mermaid flowchart LR Client -->|Command| CommandHandler CommandHandler --> WriteModel WriteModel --> Store[(Store)] Client -->|Query| QueryService QueryService --> ReadModel ReadModel --> Store ``` Man beachte: Beide Pfade können denselben Store nutzen. Dazu gleich mehr – es ist wichtiger, als es aussieht. ## Ein Blick auf die Schreibseite Illustrativ, in TypeScript. Die Schreibseite nimmt einen Command entgegen, lädt das reiche Write Model, lässt es die fachliche Regel durchsetzen und speichert. ```ts interface DeactivateInventoryItem { itemId: string; reason: string; } class InventoryCommandHandler { constructor(private readonly repo: InventoryItemRepository) {} handle(cmd: DeactivateInventoryItem): void { const item = this.repo.findById(cmd.itemId); // rich write model item.deactivate(cmd.reason); // domain rule enforced here this.repo.save(item); } } ``` Das 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. ## Und ein Blick auf die Leseseite Die Leseseite ignoriert dieses reiche Modell vollständig. Sie fragt eine auf Anzeige optimierte Sicht ab und gibt ein flaches DTO zurück. ```ts interface InventoryListItemDto { id: string; name: string; isActive: boolean; } class InventoryQueryService { constructor(private readonly readDb: ReadDatabase) {} listActiveItems(): InventoryListItemDto[] { return this.readDb.query( "SELECT id, name, is_active FROM inventory_view WHERE is_active = true", ); } } ``` Kein 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. Der 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. ## Klein anfangen – ein Store genügt Und 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. Das 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. In 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. ## Was CQRS ist – und was nicht Weil sich rund um den Begriff so viele Halbwahrheiten sammeln, hier die klare Sortierung. CQRS ist: - die fachliche Trennung von Schreib- und Lesemodell, - ein Muster für ausgewählte Bounded Contexts, - kombinierbar mit Event Sourcing, wenn die Domäne es rechtfertigt, - von Anfang an mit einem einzigen Store praktizierbar. CQRS ist nicht: - ein Framework oder eine Library, - ein Synonym für Event Sourcing, - eine Pflicht zu zwei Datenbanken, - ein systemweiter Architekturstandard, den man überall ausrollt. An dieser zweiten Liste hängt der meiste Ärger, den ich in Reviews sehe. ## Die drei teuersten Verwechslungen Die 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. Die 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. Die 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. So sähe diese Ausbaustufe aus – die man bewusst erst dann geht, wenn die Last es verlangt: ```mermaid flowchart LR CommandHandler --> WriteModel WriteModel --> WriteStore[(Write Store)] WriteStore -.->|projection
    eventual consistency| ReadStore[(Read Store)] QueryService --> ReadStore ``` Der 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. ## Wann sich die Grenze wirklich lohnt Nach all den Warnungen die konstruktive Seite: Es gibt Situationen, in denen CQRS die richtige Antwort ist. Ich sehe im Wesentlichen drei. Die 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. Die 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. Die 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. Alle 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. ## Fazit Wer 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. Die 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-cqrs-event-sourcing.html - Martin Fowler, „CQRS“: [martinfowler.com/bliki/CQRS.html](https://martinfowler.com/bliki/CQRS.html) - Martin Fowler, „CommandQuerySeparation“: [martinfowler.com/bliki/CommandQuerySeparation.html](https://martinfowler.com/bliki/CommandQuerySeparation.html) - Greg Young, „CQRS Documents“ (PDF): [cqrs.wordpress.com](https://cqrs.wordpress.com/documents/) - Vaughn Vernon, „Implementing Domain-Driven Design“, Kapitel 4 (Architecture / CQRS), sowie die [IDDD_Samples](https://github.com/VaughnVernon/IDDD_Samples) - Mein eigenes Vortragsmaterial „CQRS + Event Sourcing“ (SlideShare), aus dem ein Teil der hier gezeigten Beispiele stammt --- # Node.js-Training: Vom Event Loop zur Architekturentscheidung URL: https://www.mikebild.dev/de/blog/nodejs-training-event-loop-architektur/ Datum: 2019-05-12 # Node.js-Training: Vom Event Loop zur Architekturentscheidung Express 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. Die 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. ## Der erste Codeschnipsel, der schon alles zeigt ```js const fs = require("fs"); const data = fs.readFileSync("input.txt"); console.log(data.toString()); console.log("Fertig"); ``` Das 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: ```js const fs = require("fs"); fs.readFile("input.txt", (err, data) => { if (err) return console.error(err); console.log(data.toString()); }); console.log("Fertig"); ``` Jetzt 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. ## Der Event Loop ist Architektur, keine Randnotiz Node 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. Das 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. ```mermaid flowchart LR subgraph Call["Call Stack (ein Thread)"] direction TB JS["dein JavaScript"] end JS -->|"gibt I/O ab"| LibUV["libuv / Kernel / Thread-Pool"] LibUV -->|"Ergebnis liegt vor"| Queue["Callback-Queue"] Queue -->|"Call Stack ist leer"| JS ``` Fü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. ## Warum Callbacks, EventEmitter, Streams und Promises in genau dieser Reihenfolge drankommen Im 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. Ein 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. Wenn 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? ## Streams und Backpressure am konkreten Beispiel Kein 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: ```js const fs = require("fs"); const zlib = require("zlib"); fs.createReadStream("input.txt") .pipe(zlib.createGzip()) .pipe(fs.createWriteStream("input.txt.gz")); ``` Auf 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. ```mermaid flowchart LR Source["Readable
    (z. B. Datei)"] --> Transform["Transform
    (z. B. Gzip)"] Transform --> Sink["Writable
    (z. B. Zieldatei)"] Sink -. "Backpressure" .-> Transform Transform -. "pause / resume" .-> Source ``` Fü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. ## Express ist kein Architekturmuster Damit 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? Fü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. Was Express tatsächlich beisteuert, ist etwas anderes, und es hat einen erstaunlichen Bezug zu Streams: eine Middleware-Pipeline. ```js const express = require("express"); const app = express(); app.use(express.json()); app.use((req, res, next) => { console.log(`${req.method} ${req.url}`); next(); }); app.get("/invoices/:id", async (req, res, next) => { try { const invoice = await loadInvoice(req.params.id); if (!invoice) return res.status(404).json({ error: "not_found" }); res.status(200).json(invoice); } catch (err) { next(err); } }); app.use((err, req, res, next) => { console.error(err); res.status(500).json({ error: "internal" }); }); app.listen(3000); ``` Jede 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. ## Skalierung ohne Illusionen Ein 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. Ein `fork` sieht im Repo zum Beispiel so aus: ```js const { fork } = require("child_process"); const worker = fork("./heavy-computation.js"); worker.send({ numbers: [1, 2, 3, 4, 5] }); worker.on("message", (result) => { console.log("Result from worker process:", result); }); ``` Der 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. Gerade 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. ## Fehler sind auch Architektur, nicht nur ein try/catch Im 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. ```js process.on("uncaughtException", (err) => { console.error("Unexpected error, process is in an undefined state:", err); // only synchronous cleanup, then exit process.exit(1); }); process.on("unhandledRejection", (reason) => { console.error("Promise rejected without .catch:", reason); }); ``` Die 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. Fü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. ## Testen, bevor es wehtut Der 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. ```js const assert = require("assert"); const { describe, it } = require("mocha"); describe("Array.reverse", () => { it("reverses the order of elements", () => { const sut = "ABC".split(""); const actual = sut.reverse(); assert.deepEqual(actual, ["C", "B", "A"]); }); }); ``` Arrange, 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: ```js it("resolves with the expected value after the async call", () => { return computeAsync().then((actual) => { assert.equal(actual, 2); }); }); ``` Gibt 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. ## Das Repository ist selbst schon Architektur Ein 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. Das 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? ## Wie ich die Schulung tatsächlich gebe Ich 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. Getragen 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. ## Was am Ende bleiben soll Am 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. Node.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. Selbst 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. ## Weiterführende Quellen - Repository introduction-nodejs (Basis für die gemeinsamen Übungen): https://github.com/mikebild/introduction-nodejs - Folienübersicht zum Training: https://slides.mikebild.dev/introduction-nodejs.html - Node.js, Event Loop und Timers (offizielle Dokumentation): https://nodejs.org/learn/asynchronous-work/event-loop-timers-and-nexttick - Node.js Streams API: https://nodejs.org/api/stream.html - Node.js 12 Release-Ankündigung: https://nodejs.org/en/blog/release/v12.0.0/ - Richardson Maturity Model, Übersicht: https://martinfowler.com/articles/richardsonMaturityModel.html --- # CDK-Anfänge: Infrastruktur mit einer Programmiersprache URL: https://www.mikebild.dev/de/blog/cdk-infrastruktur-als-programmiersprache/ Datum: 2019-03-12 # CDK-Anfänge: Infrastruktur mit einer Programmiersprache Seit 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. **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. Ich 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. ## Vom Template zum Programm CloudFormation 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. Das 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. In 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. ## Die drei Ebenen: App, Stack, Construct Das Modell hat drei Schichten, und es lohnt sich, sie sauber auseinanderzuhalten, weil jede eine andere Aufgabe hat. Ganz 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. Eine 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. Ganz 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. ```mermaid flowchart TD App["App (Einstiegspunkt)"] --> Stack["Stack (Deployment-Einheit)"] Stack --> C1["Construct: S3-Bucket"] Stack --> C2["Construct: Lambda-Funktion"] C1 --> Synth["Synthese"] C2 --> Synth Synth --> CFN["CloudFormation-Vorlage"] CFN --> AWS["Ressourcen in der Cloud"] ``` Diese 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. ## Der Stack als Code So sieht der erwähnte Upload-Verarbeiter in der aktuellen Bibliothek aus. Bewusst klein gehalten, damit die Entscheidung sichtbar bleibt und nicht im Beiwerk verschwindet. ```ts import cdk = require("@aws-cdk/core"); import s3 = require("@aws-cdk/aws-s3"); import lambda = require("@aws-cdk/aws-lambda"); class UploadStack extends cdk.Stack { constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { super(scope, id, props); // Storage for incoming files. // RETAIN keeps the bucket even when the stack is deleted. const uploads = new s3.Bucket(this, "Uploads", { removalPolicy: cdk.RemovalPolicy.RETAIN }); const handler = new lambda.Function(this, "ProcessUpload", { runtime: lambda.Runtime.NODEJS_10_X, handler: "index.handler", code: lambda.Code.fromAsset("dist/handler"), environment: { // The bucket name is resolved at runtime, not hard-coded. BUCKET_NAME: uploads.bucketName } }); // Instead of writing an IAM policy by hand: // the construct grants the necessary permissions and nothing more. uploads.grantReadWrite(handler); } } const app = new cdk.App(); new UploadStack(app, "UploadStack"); app.synth(); ``` Drei 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. Was 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. ## Was man sich mit der Programmiersprache einhandelt Solange 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. Damit 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. Tests 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: ```ts import { expect, haveResource } from "@aws-cdk/assert"; test("Bucket survives deletion of the stack", () => { const app = new cdk.App(); const stack = new UploadStack(app, "TestStack"); expect(stack).to(haveResource("AWS::S3::Bucket", { DeletionPolicy: "Retain" }, undefined, true)); }); ``` Dieser 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. ## Der Fehler, der wirklich passiert Der 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. CloudFormation 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: ```ts const uploads = new s3.Bucket(this, "UploadBucket", { removalPolicy: cdk.RemovalPolicy.RETAIN }); ``` Funktional ä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. An 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. Der 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. ```text $ cdk diff Resources [-] AWS::S3::Bucket Uploads destroy [+] AWS::S3::Bucket UploadBucket create ``` Daraus 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. ## Abstraktion ist eine Entscheidung, keine kostenlose Annehmlichkeit Der 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. Eine 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. Hier 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. ## Wie sich das in eine Pipeline einfügt Infrastruktur 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. Genau 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. ## Was ich nach den ersten Wochen mitnehme Das 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. Der 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. Der 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. ## Weiterführende Quellen - [AWS CDK v2, Einführung](https://docs.aws.amazon.com/cdk/v2/guide/home.html) - [Constructs als Bausteine](https://docs.aws.amazon.com/cdk/v2/guide/constructs.html) - [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) - [GitHub OIDC mit AWS](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws) - GitHub Action fürs CDK-Deployment: https://github.com/MikeBild/aws-cdk-js-github-action - Policy as Code fürs CDK (cloudpatrol): https://github.com/MikeBild/cloudpatrol --- # AWS Parameter Store im Build URL: https://www.mikebild.dev/de/blog/aws-parameter-store-im-build/ Datum: 2019-02-12 # AWS Parameter Store im Build ```bash $ gatsby build success Building production JavaScript and CSS bundles - 42.108s Done in 71.4s $ grep -o "undefined/graphql" public/app-8f2c1a.js undefined/graphql ``` `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. Der 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. Die 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. ## Das Problem: Konfiguration in einem statischen Build Gatsby 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. In 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. Das 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. ## Die Idee: eine einzige Quelle für Build-Konfiguration Der 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: ```bash # One source of truth, organized by app and environment aws ssm put-parameter \ --name "/mikebild-dev/production/GATSBY_API_URL" \ --value "https://api.mikebild.com/graphql" \ --type "String" aws ssm put-parameter \ --name "/mikebild-dev/production/CONTENTFUL_DELIVERY_TOKEN" \ --value "cda-xxxxxxxx" \ --type "SecureString" \ --key-id "alias/build-config" ``` Der Build zieht dann den ganzen Teilbaum einer Umgebung und exportiert ihn als Environment-Variablen. Im `gatsby-config.js` sieht das bewusst unspektakulär aus: ```js module.exports = { plugins: [ { resolve: "gatsby-plugin-aws-parameter-store-environment", options: { // Pull the whole subtree for this environment path: `/mikebild-dev/${process.env.DEPLOY_ENV}/`, withDecryption: true, }, }, ], }; ``` Die 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“. ## Architektur: Build-Zeit gegen Laufzeit Bevor 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. ```mermaid flowchart LR PS["Parameter Store
    /app/env/*"] -->|build time| B["gatsby build"] B --> OUT["Static bundle
    HTML / JS / CSS"] OUT --> CDN["CDN / Browser"] RT["Runtime secrets
    never here"] -.->|not in bundle| OUT ``` Alles, 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. Alles, 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. Der 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. ## Der zweite Trade-off: Reproduzierbarkeit Die 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. Das 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. Der 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. ## Was das messbar besser macht Der Aufwand lohnt sich, weil er an drei Stellen konkrete Wirkung zeigt, nicht nur Eleganz. Kü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. Weniger 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“. Hö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. Es 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. ## Worauf ich vor dem Einsatz prüfe Eine 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. Was 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. Ist 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. Und 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. ## Der typische Fehler Der 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. Genau 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. ## Schluss Parameter 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. Wer 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. --- # Eine eigene Docker Registry betreiben URL: https://www.mikebild.dev/de/blog/docker-eigene-registry/ Datum: 2018-11-27 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. **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. Wer 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. ## Warum überhaupt eine eigene Registry? Docker 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. Eine 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. Damit die Unterschiede greifbar werden, stelle ich beides gern direkt gegenüber: - 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. - 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. Beides 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. ## Die Registry ist selbst nur ein Container Kommen 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. ```bash docker run -d \ -p 5000:5000 \ --restart=always \ --name registry \ -v /mnt/registry:/var/lib/registry \ registry:2 ``` Ein 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. Damit läuft die Registry. Sie tut jetzt aber noch nichts Sichtbares – sie wartet auf ihr erstes Image. ## Taggen, pushen, pullen Der 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. Nehmen wir an, ich habe lokal ein Image `app:1.0` gebaut. Dann tagge ich es für meine Registry um: ```bash docker tag app:1.0 registry.local:5000/app:1.0 ``` `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: ```bash docker push registry.local:5000/app:1.0 ``` Docker 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: ```bash docker pull registry.local:5000/app:1.0 ``` Das 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: ```mermaid flowchart LR A[docker build
    app:1.0] --> B[docker tag
    registry.local:5000/app:1.0] B --> C[docker push] C --> D[(Private Registry
    :5000)] D --> E[docker pull
    anderer Host / CI] ``` Am 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. ## Der wunde Punkt: Ab Werk steht alles offen Die 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. Absicherung besteht dabei aus zwei voneinander unabhängigen Bausteinen, die man nicht verwechseln sollte: - TLS verschlüsselt den Transport, damit niemand mitlesen kann, was über die Leitung geht. - Basic-Auth per htpasswd schützt den Zugriff, damit nur berechtigte Nutzer pushen und pullen. Das 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. Schauen 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: ```bash docker run --rm --entrypoint htpasswd httpd:2 \ -Bbn deploy s3cret > auth/htpasswd ``` Die 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`. Fü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`. Jetzt starte ich die Registry neu – diesmal mit beiden Schutzschichten. Die Konfiguration übergebe ich über Umgebungsvariablen, die die Registry beim Start ausliest: ```bash docker run -d \ -p 5000:5000 \ --restart=always \ --name registry \ -v "$(pwd)"/auth:/auth \ -v "$(pwd)"/certs:/certs \ -v /mnt/registry:/var/lib/registry \ -e REGISTRY_AUTH=htpasswd \ -e "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm" \ -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \ -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \ -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \ registry:2 ``` Die `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: ```bash docker login registry.local:5000 ``` Nach 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. Der 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. Wie die beiden Schutzschichten zusammenspielen und wo der unsichere Notausgang liegt, zeigt dieses Bild: ```mermaid flowchart LR C[Client] -->|TLS| R[Registry-Container :5000] R -->|htpasswd-Auth| S[(Storage-Volume
    /var/lib/registry)] C2[Client ohne TLS] -.HTTP.-> I[insecure-registries
    nur Testnetz] I -.-> R ``` ## Wenn kein TLS zur Hand ist Beim 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. Fü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: ```json { "insecure-registries": ["registry.local:5000"] } ``` Danach 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. ## Die Registry als Kontrollpunkt in der Lieferkette Bis 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. Der 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: ```bash docker pull registry.local:5000/app@sha256:9b2a4c... ``` Der 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. Der 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: ```bash export DOCKER_CONTENT_TRUST=1 docker push registry.local:5000/app:1.0 ``` Im 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. Der 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. Daraus 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. ## Was man beim Betrieb im Blick behalten sollte Zwei 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. Das 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. ## Fazit Eine 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. Der 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-docker.html - [Deploy a registry server – docs.docker.com](https://docs.docker.com/registry/deploying/) - [Run an externally-accessible registry (Basic-Auth + TLS)](https://docs.docker.com/registry/deploying/#run-an-externally-accessible-registry) - [Registry configuration reference (config.yml)](https://docs.docker.com/registry/configuration/) - [Test an insecure registry (daemon.json)](https://docs.docker.com/registry/insecure/) - [docker tag / docker push / docker pull / docker login – CLI-Referenz](https://docs.docker.com/engine/reference/commandline/tag/) - [Docker Content Trust und Notary v1](https://docs.docker.com/engine/security/trust/) - [Docker: abgekündigte Produkte und empfohlene Alternativen](https://docs.docker.com/retired/) - [Portus – archiviertes Repository](https://github.com/SUSE/Portus) - [Node.js und Redis mit Docker Compose](https://www.mikebild.dev/de/blog/docker-compose-nodejs-redis/) --- # Kubernetes-Grundlagen durch Deployment-Druck URL: https://www.mikebild.dev/de/blog/kubernetes-grundlagen-deployment-druck/ Datum: 2018-10-24 # Kubernetes-Grundlagen durch Deployment-Druck Um 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. ## Die Doku erklärt alles, nur nicht die Reihenfolge Wer 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. Mir 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: - Pod – das, was wirklich läuft - Deployment – das, was Pods am Leben hält - Service – eine stabile Adresse vor unstabilen Pods - Ingress – die Tür nach draußen - ConfigMap und Secret – Konfiguration raus aus dem Image Alles 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. ## Zielzustand statt Kommando-Choreografie Die 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. ```mermaid flowchart LR M[Manifest
    todo-api.yaml] -->|kubectl apply| A[API-Server] A --> E[(etcd
    Zielzustand)] C[Controller-Loop] -->|liest Soll| E C -->|misst Ist| P[Pods im Cluster] C -->|korrigiert Differenz| P ``` Das 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. ## Der Pod: das, was wirklich läuft Ein 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. ## Das Deployment: Selbstheilung als Kern-Idee Weil Pods sterblich sind, braucht es etwas, das sie am Leben hält. Das ist das Deployment – für mich das eigentliche Herzstück: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: todo-api spec: replicas: 3 selector: matchLabels: app: todo-api template: metadata: labels: app: todo-api spec: containers: - name: todo-api image: mikebild/todo-api:1.4.2 ports: - containerPort: 3000 readinessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 5 livenessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 15 ``` Seit 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. Der Moment, in dem Kubernetes bei mir „klick" gemacht hat, war ein bewusst herbeigeführter Ausfall: ```bash kubectl apply -f todo-api.yaml kubectl get pods --watch kubectl delete pod todo-api-7d9f6c8b45-x2x7k # simulate a crash # a replacement pod appears within seconds kubectl rollout status deployment todo-api kubectl rollout undo deployment todo-api # roll back a bad release ``` Man 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. ## Der Service: eine stabile Adresse vor unstabilen Pods Wenn 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. ```yaml apiVersion: v1 kind: Service metadata: name: todo-api spec: selector: app: todo-api ports: - port: 80 targetPort: 3000 ``` Ab 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. ## Der Ingress: die Tür nach draußen Services 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: ```yaml apiVersion: extensions/v1beta1 kind: Ingress metadata: name: todo-api spec: rules: - host: todo.example.com http: paths: - path: / backend: serviceName: todo-api servicePort: 80 ``` ```mermaid flowchart LR B[Browser] --> I[Ingress
    todo.example.com] I --> S[Service
    todo-api] S --> P1[Pod] S --> P2[Pod] S --> P3[Pod] ``` Ehrlicherweise 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. ## ConfigMap und Secret: Konfiguration raus aus dem Image Der 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: ```yaml apiVersion: v1 kind: ConfigMap metadata: name: todo-api-config data: DATABASE_HOST: "postgres" LOG_LEVEL: "info" ``` Im 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. ## Fehlersuche ohne SSH Was 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ß: ```bash kubectl describe pod todo-api-7d9f6c8b45-x2x7k kubectl logs todo-api-7d9f6c8b45-x2x7k kubectl logs todo-api-7d9f6c8b45-x2x7k --previous # logs of the crashed container kubectl get events --sort-by=.metadata.creationTimestamp ``` `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. ## Was der Druck nicht wegdiskutiert Zur 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. Deshalb die nüchterne Gegenprobe, bevor der Umzug beginnt. Ein einzelner Host mit Docker Compose reicht schlicht aus, wenn ein paar Dinge zusammenkommen: - die Anwendung läuft auf einer Maschine, und kurze Ausfälle sind verkraftbar - eine Restart-Policy im Compose-File deckt den nächtlichen Prozess-Tod ausreichend ab - es gibt kein Team, das parallel deployt, und keine Last, die Skalierung erfordert Dann 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. ## Was ich daraus mitnehme Deployment-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. Die 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. ## Weiterführende Quellen - Repository mit den Beispielen: https://github.com/MikeBild/introduction-kubernetes - Kubernetes-Dokumentation zu Deployments: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/ - Kubernetes-Dokumentation zu Services: https://kubernetes.io/docs/concepts/services-networking/service/ - Kubernetes-Dokumentation zu Ingress: https://kubernetes.io/docs/concepts/services-networking/ingress/ - ConfigMaps in Pods verwenden: https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/ --- # Zentrales Logging mit dem ELK-Stack in Docker URL: https://www.mikebild.dev/de/blog/docker-logging-mit-elk/ Datum: 2018-09-18 `docker logs ` 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. **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. Die 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. Genau 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. ## Logs als eine von drei Säulen Bevor 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. Und 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. Diese 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. ## Wo Docker-Logs standardmäßig landen Docker 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. Der 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//-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. Die verfügbaren Driver, Stand 2018, sind eine ganze Reihe: - `json-file` und `journald` – lokal, mit `docker logs` lesbar. - `syslog`, `gelf`, `fluentd`, `splunk` – Weiterleitung an einen zentralen Empfänger. - `awslogs`, `gcplogs`, `etwlogs` – plattformspezifische Ziele. - `none` – Logging komplett aus. Fü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. Ein Logging-Driver lässt sich pro Container beim `docker run` setzen: ```bash docker run \ --log-driver=gelf \ --log-opt gelf-address=udp://logstash:12201 \ --log-opt tag="myapp" \ myorg/myapp:latest ``` In Compose sieht das gleiche pro Service so aus: ```yaml version: "3" services: app: image: myorg/myapp:latest logging: driver: "gelf" options: gelf-address: "udp://logstash:12201" tag: "myapp" ``` Ein 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. ## Die drei Rollen im ELK-Stack Bevor 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. Der Datenfluss ist eine kurze Kette: ```mermaid flowchart LR A[App Container] -->|stdout / gelf| B[Logstash] B -->|index| C[Elasticsearch] C -->|query| D[Kibana] E[Browser] -->|http :5601| D ``` Die 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. ## Der Compose-Stack Jetzt 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`: ```yaml version: "3" services: elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:6.4.0 environment: - discovery.type=single-node - "ES_JAVA_OPTS=-Xms512m -Xmx512m" volumes: - es-data:/usr/share/elasticsearch/data ports: - "9200:9200" - "9300:9300" logstash: image: docker.elastic.co/logstash/logstash:6.4.0 volumes: - ./pipeline:/usr/share/logstash/pipeline ports: - "12201:12201/udp" depends_on: - elasticsearch kibana: image: docker.elastic.co/kibana/kibana:6.4.0 environment: - ELASTICSEARCH_URL=http://elasticsearch:9200 ports: - "5601:5601" depends_on: - elasticsearch volumes: es-data: ``` Ein 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. Kibana 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. Jetzt 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: ```ruby input { gelf { port => 12201 } } filter { # room for grok, mutate, date & co. } output { elasticsearch { hosts => ["elasticsearch:9200"] index => "docker-logs-%{+YYYY.MM.dd}" } } ``` Der `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. ## Die App anschließen Fehlt 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: ```yaml app: image: myorg/myapp:latest logging: driver: "gelf" options: gelf-address: "udp://localhost:12201" tag: "myapp" depends_on: - logstash ``` Beim `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. Startet 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. Ein 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. ## In Kibana durchsuchen Kibana 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. ## Fallstricke aus der Praxis Drei Dinge gehen bei diesem Setup am häufigsten schief, und alle drei habe ich mehr als einmal live erlebt. Der 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: ```bash sudo sysctl -w vm.max_map_count=262144 ``` Auf 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. Der 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: ```yaml logging: driver: "json-file" options: max-size: "10m" max-file: "3" ``` Und 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. Der 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. ## Zwei Wege der Weiterleitung Zum 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. Wer 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. ## Fazit Container-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. Die 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. Ebenso 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. ## Weiterführende Quellen - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-docker.html - [Docker GELF logging driver – TCP- und UDP-Konfiguration](https://docs.docker.com/engine/logging/drivers/gelf/) - [Docker 17.11 Release Notes – Einführung der GELF-TCP-Unterstützung](https://docs.docker.com/engine/release-notes/17.11/) - [Docker: Configure logging drivers](https://docs.docker.com/config/containers/logging/configure/) - [Docker: json-file logging driver](https://docs.docker.com/config/containers/logging/json-file/) - [Docker: gelf logging driver](https://docs.docker.com/config/containers/logging/gelf/) - [Docker: syslog logging driver](https://docs.docker.com/config/containers/logging/syslog/) - [Elastic: Getting started with the Elastic Stack and Docker Compose](https://www.elastic.co/blog/getting-started-with-the-elastic-stack-and-docker-compose) - [Elasticsearch 6.4: Install with Docker](https://www.elastic.co/guide/en/elasticsearch/reference/6.4/docker.html) - [Kibana 6.4: Running Kibana on Docker](https://www.elastic.co/guide/en/kibana/6.4/docker.html) - [Logstash 6.4: Running Logstash on Docker](https://www.elastic.co/guide/en/logstash/6.4/docker.html) - [Referenz-Stack: deviantony/docker-elk](https://github.com/deviantony/docker-elk) --- # Micro-Frontends: Teamschnitt im Browser URL: https://www.mikebild.dev/de/blog/micro-frontends-teamschnitt-browser/ Datum: 2018-08-21 # Micro-Frontends: Teamschnitt im Browser Vier 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. ## Ein Organisationsproblem mit technischem Symptom Micro-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. Neu 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. ## Eine lauffähige Beispiel-Landschaft Um 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. ```mermaid flowchart LR B[Browser] --> G[Gateway
    :7000] G -->|/| H[home-app
    :7010
    Team A] G -->|/docs| D[docs-app
    :7020
    Team B] G -->|/products| P[products-app
    :7030
    Team C] G -->|/contact| C[contact-app
    :7040
    Team D] P --> Q[graphql-api
    :9000] H -. shared UI .-> L[components-library
    :5000] P -. shared UI .-> L ``` Das 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. ## Der einfache Teil: Integration Die Integrationstechniken sind schnell aufgezählt, und keine davon ist spektakulär. Die älteste ist der Hyperlink. Wenn `/products` und `/contact` getrennte Anwendungen sind, reicht ein `` – 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. Sollen Fragmente verschiedener Teams auf einer Seite erscheinen, komponiert man serverseitig. Server-Side-Includes beherrscht jeder nginx von Haus aus: ```nginx # gateway: compose one page from team fragments location / { ssi on; proxy_pass http://home-app:7010; } location /fragment/header { proxy_pass http://components-library:5000/header; } ``` ```html
    ``` Zalandos 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. Clientseitig sind Custom Elements die sauberste Klammer – seit v1 in Chrome und Safari nativ, in Firefox und Edge im Moment noch per Polyfill: ```js // products-app: expose a fragment as a custom element class ProductTeaser extends HTMLElement { connectedCallback() { const sku = this.getAttribute('sku'); fetch(`/products/api/teaser/${sku}`) .then((res) => res.text()) .then((html) => { this.innerHTML = html; }); } disconnectedCallback() { // team C cleans up its own listeners here } } customElements.define('product-teaser', ProductTeaser); ``` Das aufnehmende Team schreibt `` 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. Welche Technik trägt, entscheidet sich an wenigen Fragen: - Wie viel Seitenwechsel verträgt der Nutzerfluss – reicht ein Link? - Muss die Seite ohne JavaScript ein brauchbares Erstbild liefern (SSI, Tailor), oder ist die Zielgruppe ohnehin angemeldet (Client-Komposition)? - Wie hart müssen Fragmente isoliert sein – genügt Namensdisziplin, oder braucht es die iframe-Grenze? - Wer betreibt den Kompositionspunkt: ein Plattform-Team das Gateway, oder jedes Team seinen eigenen Einstieg? ## Sorgfaltspflichten: Routing, Abhängigkeiten, CSS Drei Dinge muss man trotzdem sauber regeln, sonst holt einen der Monolith durch die Hintertür wieder ein. Erstens 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. Zweitens 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. Drittens 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. ## Der schwere Teil: Wem gehört der Header? Bis 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. Der 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. Dieselbe 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. ## Was geteilter State kostet Die 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. Die günstigere Antwort: Fragmente teilen keinen Speicher, sie teilen Events. Das DOM bringt den Mechanismus mit, ganz ohne zusätzliche Bibliothek: ```js // products-app: announce the change, share no memory window.dispatchEvent(new CustomEvent('cart:item-added', { detail: { sku: '42', quantity: 1 } })); // header fragment: react and re-fetch its own truth window.addEventListener('cart:item-added', () => { fetch('/cart/api/summary') .then((res) => res.json()) .then((summary) => updateBadge(summary.count)); }); ``` Der 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. Ä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. ## Unterm Strich Der 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. ## Weiterführende Quellen - Repository mit den lauffähigen Beispielen: https://github.com/MikeBild/introduction-micro-frontends - ThoughtWorks Technology Radar zu Micro Frontends: https://www.thoughtworks.com/radar/techniques/micro-frontends - Zalando Tailor, Layout-Service aus Project Mosaic: https://github.com/zalando/tailor - single-spa, Router für mehrere Framework-Anwendungen: https://github.com/single-spa/single-spa - MDN zu Custom Elements: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements --- # Frontend-Schnittstellen sind APIs URL: https://www.mikebild.dev/de/blog/frontend-schnittstellen-sind-apis/ Datum: 2018-08-12 # Frontend-Schnittstellen sind APIs ```diff UserCard.propTypes = { - name: PropTypes.string.isRequired, + fullName: PropTypes.string.isRequired, avatarUrl: PropTypes.string, onSelect: PropTypes.func }; ``` Ein 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: ``` Warning: Failed prop type: The prop `fullName` is marked as required in `UserCard`, but its value is `undefined`. ``` Das 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. Hä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. ## Mehr Oberflächen, als im Architekturdiagramm stehen In 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: - Props und Callbacks der geteilten Komponenten-Bibliothek - URLs mit Pfaden und Query-Parametern, auf die verlinkt wird - CustomEvents und `postMessage`-Nachrichten zwischen Seitenteilen - CSS-Klassen und CSS Custom Properties, die andere Teile gezielt verwenden - localStorage-Schlüssel, die mehr als ein Modul liest Jede 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. ```mermaid flowchart LR subgraph FE[Frontend eines Teams] P[Props & Callbacks] U[URLs & Parameter] E[CustomEvents /
    postMessage] C[CSS-Klassen &
    Custom Properties] S[localStorage-
    Schlüssel] end A[Team B:
    Fachanwendung] --> P B[Nutzer: Bookmarks,
    geteilte Links] --> U M[Mails, Kampagnen,
    Monitoring-Checks] --> U W[Widget im iframe] --> E T[Team C:
    Theming] --> C X[Altes Portal-Skript] --> S ``` Das 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. ## Props: der Vertrag zwischen Teams Fü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. Für das `UserCard`-Beispiel von oben sieht das so aus: ```jsx import React from 'react'; import PropTypes from 'prop-types'; // v2.3.0: `name` is deprecated in favor of `fullName`. // Both work until v3.0.0, where `name` will be removed. export function UserCard({ fullName, name, avatarUrl, onSelect }) { const displayName = fullName || name; if (name && process.env.NODE_ENV !== 'production') { console.warn( 'UserCard: prop `name` is deprecated, use `fullName` instead. ' + 'Support for `name` will be removed in v3.0.0.' ); } return (
    {avatarUrl && } {displayName}
    ); } UserCard.propTypes = { fullName: PropTypes.string, name: PropTypes.string, // deprecated, see above avatarUrl: PropTypes.string, onSelect: PropTypes.func }; ``` Das 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. Als 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. ## URLs: der Vertrag mit allen anderen Die 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. Eine 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: ```jsx import { Switch, Route, Redirect } from 'react-router-dom'; {/* Deprecated 2018-08: keep this redirect for at least 12 months. */} ``` Die 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. Dasselbe 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. ## Events und Messages: Verträge zur Laufzeit Sobald 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. Wir haben dafür jede Nachricht wie ein kleines Wire-Format behandelt – benannter Typ, dokumentiertes Payload-Schema, Versionsfeld: ```js // Contract: 'cart:item-added' (version 1) // detail: { version: number, sku: string, quantity: number, source: string } window.dispatchEvent(new CustomEvent('cart:item-added', { detail: { version: 1, sku: 'A-4711', quantity: 1, source: 'product-page' } })); // Consumer side: tolerate unknown versions instead of guessing. window.addEventListener('cart:item-added', (event) => { if (event.detail.version !== 1) return; updateCartBadge(event.detail.quantity); }); ``` Das 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. ## CSS und localStorage: die Verträge, die niemand schließen wollte Die 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. localStorage 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. ## Versionierung ist Kommunikation, nicht Bürokratie Der 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: - Änderung im Changelog ankündigen, mit altem und neuem Vertrag - eine Minor-Version, in der beides funktioniert und die alte Form im Entwicklungsmodus warnt - Storybook-Story für die alte Form als deprecated markieren - Entfernung erst in der nächsten Major-Version, mit Migrationshinweis Das 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. Mir 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. ## Unterm Strich Ein 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. ## Weiterführende Quellen - Semantic Versioning 2.0.0 (deutsche Fassung): https://semver.org/lang/de/ - React-Dokumentation: Typechecking mit PropTypes: https://legacy.reactjs.org/docs/typechecking-with-proptypes.html - MDN: CustomEvent: https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent - MDN: Window.postMessage: https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage - Storybook: https://storybook.js.org/ --- # Node.js und Redis mit Docker Compose URL: https://www.mikebild.dev/de/blog/docker-compose-nodejs-redis/ Datum: 2018-07-24 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? Docker 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. Den 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`. **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. ## Warum überhaupt Compose Ein 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. Compose 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. Das Ganze folgt einer einfachen Idee: reproduzierbare, gekapselte Umgebungen, die auf jedem Rechner gleich hochfahren. ## Die docker-compose.yml Hier ist die Datei, mit der ich starte. Zwei Services, minimal gehalten, damit jede Zeile eine erkennbare Aufgabe hat. ```yaml version: '3' services: web: build: . environment: NODE_ENV: production REDIS_HOST: 'redis' REDIS_PORT: '6379' ports: - '8080:8080' depends_on: - 'redis' redis: image: redis:4.0.11 ``` Die 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. Zwei 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: - `build:` nutze ich für meinen eigenen Code, den es als fertiges Image noch nicht gibt. Compose ruft im Hintergrund einen `docker build` auf. - `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. Fü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. Auffä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. ## Service Discovery: Redis heißt einfach "redis" Bleibt die Frage, woher der Node-Prozess weiß, unter welcher Adresse er Redis findet. Es steht ja nirgends eine IP. Compose legt beim Start automatisch ein Default-Netzwerk an, benannt nach dem Projekt, etwa `_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. Deshalb 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. ```mermaid graph LR Client["Client / Browser"] -->|"Host-Port 8080"| Web subgraph net["Compose default network"] Web["web-Container"] -->|"DNS: redis:6379"| Redis["redis-Container"] end Redis --> Vol[("volume
    redis-data (/data)")] ``` Von 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. Wer 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: ```yaml version: '3' services: web: build: . environment: REDIS_HOST: 'redis' REDIS_PORT: '6379' ports: - '8080:8080' networks: - app-net depends_on: - 'redis' redis: image: redis:4.0.11 networks: - app-net networks: app-net: ``` Funktional ä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. ## Der Node-Client Damit 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. ```js const SERVICE_PORT = process.env.SERVICE_PORT || 8080; const REDIS_HOST = process.env.REDIS_HOST || 'localhost'; const REDIS_PORT = process.env.REDIS_PORT || '6379'; const express = require('express'); const redis = require('redis'); const app = express(); const client = redis.createClient(REDIS_PORT, REDIS_HOST); client.on('error', err => { console.error(`Redis connection error.\n${err.message}`); process.exit(1); }); app.get('/redis', (req, res, next) => { client.get('foo', (error, result) => { if (error) return next(error); res.send(result); }); }); app.post('/redis', (req, res, next) => { client.set('foo', 'bar', (error, result) => { if (error) return next(error); res.status(201).send('done'); }); }); app.listen(SERVICE_PORT, () => console.log(`Listen on ${SERVICE_PORT}`)); ``` Die 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. Ein 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. ## Der Fallstrick: depends_on wartet nicht auf "ready" `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. Der 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. Die 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. Eine 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. ## Persistenz: Redis braucht ein Volume Der 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. Wer Redis nicht nur als Wegwerf-Cache nutzt, mountet ein benanntes Volume auf `/data`. Das redis-Image legt seine RDB-Snapshots genau dort ab. ```yaml version: '3' services: web: build: . environment: REDIS_HOST: 'redis' REDIS_PORT: '6379' ports: - '8080:8080' depends_on: - 'redis' redis: image: redis:4.0.11 volumes: - 'redis-data:/data' volumes: redis-data: ``` Das 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. Damit habe ich die vier Bausteine beisammen, die eine Compose-Datei ausmachen: - `services` sind die Container der Anwendung, hier `web` und `redis`. - `ports` legt fest, welche Container-Ports auf den Host veröffentlicht werden; nur `web` bekommt einen. - `volumes` hält Daten in benannten Volumes, die den Container überleben sollen, hier `redis-data`. - `networks` beschreibt das meist automatische Netz, das Service Discovery per Servicename ermöglicht. ## Der Zyklus: up, logs, down Im 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. ```bash docker-compose up -d docker-compose logs web curl -X POST http://localhost:8080/redis curl http://localhost:8080/redis docker-compose down ``` Das `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. Wer 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. ## Der eigentliche Gewinn: Zeit bis zum ersten Commit Die 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. Das 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. Kostenlos 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. Die 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. ## Ein Wort zur Sicherheit Der 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. Am 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. ## Fazit Compose 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. Der 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. Wichtiger 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. ## Weiterführende Quellen - [Docker Compose: Startreihenfolge und `service_healthy`](https://docs.docker.com/compose/how-tos/startup-order/) - Foliensatz zu diesem Thema: https://slides.mikebild.dev/introduction-docker.html - [Compose file v3 reference](https://docs.docker.com/compose/compose-file/) - [Networking in Compose (DNS, Default-Netz)](https://docs.docker.com/compose/networking/) - [Control startup order in Compose](https://docs.docker.com/compose/startup-order/) - [Compose CLI overview](https://docs.docker.com/compose/reference/overview/) - [redis Official Image, Tag 4.0.11](https://hub.docker.com/_/redis) --- # Containerisierung und die falsche Sicherheit URL: https://www.mikebild.dev/de/blog/containerisierung-falsche-sicherheit/ Datum: 2018-07-12 # Containerisierung und die falsche Sicherheit „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“. Mir 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. ## Erste falsche Sicherheit: Isolation Ein 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. ```mermaid flowchart TB subgraph VM["Virtuelle Maschine"] A1[App] --> K1[Gast-Kernel] K1 --> H1[Hypervisor] H1 --> HW1[Host-Kernel
    + Hardware] end subgraph CT["Container"] A2[App als Prozess] --> NS[Namespaces
    + cgroups] NS --> HW2[gemeinsamer
    Host-Kernel] end ``` Die 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. Ein `--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. Docker 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. Was heute schon geht, ist die Angriffsfläche beim Start zu verkleinern: ```bash docker run -d \ --read-only \ --cap-drop ALL \ --cap-add NET_BIND_SERVICE \ --security-opt no-new-privileges \ --memory 256m \ order-service:1.4.2 ``` Alles 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. ## Zweite falsche Sicherheit: Reproduzierbarkeit Die 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: ```dockerfile FROM node:latest WORKDIR /app COPY . . RUN apt-get update && apt-get install -y imagemagick RUN npm install ENV API_SECRET=changeme-in-prod EXPOSE 3000 CMD ["node", "server.js"] ``` Hier 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. Dazu 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. ## Dritte falsche Sicherheit: Betrieb Die 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. Vorgelagert 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. ```mermaid flowchart LR CVE[CVE im Basis-Image
    veröffentlicht] --> BASE[Basis-Image
    aktualisiert] BASE --> BUILD[Rebuild
    aller Services] BUILD --> SCAN[Image-Scan
    z. B. Clair] SCAN --> DEPLOY[Rollout] DEPLOY -. ohne Routine reißt
    die Kette hier ab .- BUILD ``` Dass 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. Fü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. ## Was konkret hilft Nichts 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: ```dockerfile # build stage FROM node:8.11-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm install COPY . . RUN npm run build && npm prune --production # runtime stage FROM node:8.11-alpine WORKDIR /app RUN addgroup -S app && adduser -S app -G app COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules USER app EXPOSE 3000 CMD ["node", "dist/server.js"] ``` Multi-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. Darüber hinaus haben sich bei mir ein paar Punkte als Checkliste für Reviews eingespielt: - 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. - Tags pinnen, für Produktions-Deployments zusätzlich den Digest festhalten – `latest` hat in einem Deployment-Manifest nichts verloren. - 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. - 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. - Capabilities beim Start beschneiden und `--privileged` wie einen offenen root-Zugang behandeln: Wer es setzen will, muss begründen, warum. Keiner 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“. ## Unterm Strich Container 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. Wenn 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. ## Weiterführende Quellen - Repository introduction-docker: https://github.com/MikeBild/introduction-docker - Docker-Dokumentation zu Multi-Stage-Builds: https://docs.docker.com/build/building/multi-stage/ - Docker Engine Security Overview: https://docs.docker.com/engine/security/ - Clair, statische Analyse von Container-Images: https://github.com/quay/clair - Distroless-Images von Google: https://github.com/GoogleContainerTools/distroless - Bericht zum Tesla-Vorfall (CNBC, Februar 2018): https://www.cnbc.com/2018/02/21/hackers-hijack-teslas-cloud-system-to-mine-cryptocurrency-redlock.html --- # Meine Arbeitsdefinition für ein MVP: lernen statt halb fertig liefern URL: https://www.mikebild.dev/de/blog/mvp-kleinstes-lernendes-produkt/ Datum: 2018-05-24 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? Trotzdem 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. Fü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. ## Die Definition, die alles trägt Eric 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. Damit 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. ```mermaid flowchart LR A[Riskante Annahme] --> B[Build:
    MVP als Experiment] B --> C[Measure:
    echte Nutzer, echte Metrik] C --> D[Learn:
    validated learning] D --> E{pivot or
    persevere?} E -->|persevere| A E -->|pivot| A ``` Daraus 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. Steve 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. ## Vertikaler Schnitt statt horizontaler Amputation Der Unterschied lässt sich an zwei Schnitten durch denselben geplanten Funktionsumfang zeigen. Der 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. Der 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. ```mermaid flowchart TB subgraph L[Horizontal: halbfertig] L1[Feature A – 40%] L2[Feature B – 40%] L3[Feature C – 40%] end subgraph R[Vertikal: kleinstes lernendes Produkt] R1[Einstieg] --> R2[ein durchgehender Pfad] --> R3[Wert] --> R4[Metrik] end ``` Genau 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. ## Das MVP muss kein Code sein Der 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. Beim *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. Beide 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. Wichtig 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. ## Die Hypothese als Code Wenn 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. ```yaml # experiment.yaml – declares the MVP before any code is written experiment: name: "concierge-checkout" riskiest_assumption: "users will connect a payment method to finish an order" hypothesis: "at least 30% of activated users will add a payment method" target_metric: activation_rate # added payment / reached checkout success_threshold: 0.30 kill_criteria: "activation_rate < 0.10 after 200 sessions" sample_size: 200 cohort: "new signups, region DE" ``` Der 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. ## Den schmalen Pfad technisch sauber schneiden Der vertikale Schnitt hat auch eine technische Seite, und hier verbinden sich zwei Welten, die oft getrennt diskutiert werden: Produktdenken und Delivery. Der 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. ```javascript // the MVP path ships on trunk, gated by a flag – not hidden in a long branch function renderCheckout(user, cart) { if (featureFlags.isEnabled("concierge_checkout", user)) { trackExperiment("concierge-checkout", user); return conciergeCheckout(cart); // narrow but complete path to value } return standardCheckout(cart); // everyone else: unchanged status quo } ``` Das 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. Wenn 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. ```groovy // consumer-driven contract: the MVP fulfils a real, minimal contract // instead of faking a full integration Pact.provider("payment-service").upon("checkout") .given("an activated user") .uponReceiving("a request to attach a payment method") .withRequest(method: "POST", path: "/methods") .willRespondWith(status: 201, body: [status: "attached"]) ``` Der 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. ## Warum das schneller ist, obwohl es aufwendiger klingt Der 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. „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. Ein 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?“. Wer 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. ## Fazit Ein 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. Wenn 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. ## Weiterführende Quellen - Eric Ries, *The Lean Startup* (2011) – Definition des MVP, Build-Measure-Learn, validated learning, vanity vs. actionable metrics - Steve Blank, „A minimum viable product is not always…“ (Blog, 2013) und *The Four Steps to the Epiphany* (2005) – Customer Development - Melissa Perri, „Finding the Truth Behind MVPs“ (2016) – Concierge- und Wizard-of-Oz-MVP - Nicole Forsgren, Jez Humble, Gene Kim, *Accelerate* (2018) – die vier DORA-Metrics - Neal Ford, Rebecca Parsons, Patrick Kua, *Building Evolutionary Architectures* (O'Reilly, 2017) – fitness functions - Martin Fowler, „FeatureToggle“ (2017) und trunkbaseddevelopment.com – Feature-Flags und Trunk-Based Development - Consumer-Driven Contracts (Fowler/Robinson, 2006) sowie das Contract-Testing-Werkzeug Pact --- # GraphQL-Schema in der Fachsprache URL: https://www.mikebild.dev/de/blog/graphql-schema-in-der-fachsprache/ Datum: 2018-04-24 # GraphQL-Schema in der Fachsprache „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. Die 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. Seit 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. ## Zweimal übersetzen, zweimal verlieren In 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. Jede 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. ```mermaid flowchart LR F["Fachseite
    sagt „Angebot“"] -->|übersetzen| E["Entwicklung
    Offer? Quote? Bid?"] E -->|übersetzen| S["System
    OFFER, Quote, Proposal"] S -->|rückübersetzen| E E -->|rückübersetzen| F ``` Die 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. ## Das Experiment: uber-ng Im 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? Das 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: ```graphql enum STATUS { angefragt warteAufBestaetigung angenommen } type Angebot implements Node { id: ID! fahrgast: Fahrgast von: String! nach: String! fahrer: Fahrer wartezeit: String status: STATUS } type Mutation { fahrtAnfragen(input: AnfrageInput!): Angebot angebotAnnehmen(input: AngebotAnnehmenInput!): Angebot angebotAblehnen(input: AngebotAblehnenInput!): Angebot } type Subscription { onAngebotChanged: Angebot } ``` Wer 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. Auch die Anfragen der Clients sprechen anschließend diese Sprache: ```graphql mutation { fahrtAnfragen(input: { fahrgastId: "f1" von: "Alexanderplatz" nach: "Flughafen Tegel" }) { id status wartezeit } } ``` Man 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. Technisch 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`. ## Was das Schema zum Glossar macht Ein Glossar beschreibt eine Sprache. Das Schema erzwingt sie – und genau das hat es jedem Glossar voraus, das mir bisher untergekommen ist: - Jede Query wird gegen das Schema validiert. Wer einen Begriff erfindet oder falsch schreibt, bekommt einen Fehler statt eines stillen Missverständnisses. - Introspection macht das Vokabular abfragbar: GraphiQL zeigt Typen, Felder und Beschreibungen schon beim Tippen an – das Glossar liefert sich selbst aus. - `@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`. - 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. Dazu 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. Ein 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. ## Zustände haben Namen Der 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. Diesen 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: ```mermaid stateDiagram-v2 [*] --> angefragt: fahrtAnfragen angefragt --> warteAufBestaetigung: Vermittlung läuft warteAufBestaetigung --> angenommen: angebotAnnehmen warteAufBestaetigung --> [*]: angebotAblehnen ``` Hier 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. ## Schema deutsch, Implementierung englisch Die 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: ```javascript const uuid = require('uuid/v4'); const { PubSub } = require('graphql-subscriptions'); const pubsub = new PubSub(); const offers = {}; // in-memory store, resets on restart const resolvers = { Query: { angebote: (root, { take = 10, skip = 0 }) => Object.values(offers).slice(skip, skip + take), }, Mutation: { fahrtAnfragen: (root, { input }) => { // a ride request creates a new offer in its initial state const offer = { id: uuid(), von: input.von, nach: input.nach, status: 'angefragt', }; offers[offer.id] = offer; pubsub.publish('offerChanged', { onAngebotChanged: offer }); return offer; }, angebotAnnehmen: (root, { input }) => { const offer = offers[input.angebotId]; // accepting is only valid while the offer awaits confirmation if (offer.status !== 'warteAufBestaetigung') return offer; offer.status = 'angenommen'; pubsub.publish('offerChanged', { onAngebotChanged: offer }); return offer; }, }, Subscription: { onAngebotChanged: { subscribe: () => pubsub.asyncIterator('offerChanged'), }, }, }; ``` Die 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. Ein 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. ## Was dagegen spricht Ehrlich bleiben: Das Experiment hat auch die Widerstände sichtbar gemacht, und die sind nicht klein. Der 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. Dazu 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. Und 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. ## Wann die Fachsprache gewinnt Die Abwägung ist deshalb keine Geschmacksfrage, sondern eine Frage an die Domäne: In welcher Sprache ist sie selbst verfasst? Eine 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. Umgekehrt 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. ## Unterm Strich uber-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. Fü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. ## Weiterführende Quellen - [uber-ng – GraphQL-Demo mit deutschem Schema (GitHub)](https://github.com/MikeBild/uber-ng) - [GraphQL-Dokumentation: Schemas and Types](https://graphql.org/learn/schema/) - [GraphQL Specification](https://spec.graphql.org/) - [Martin Fowler: Ubiquitous Language](https://martinfowler.com/bliki/UbiquitousLanguage.html) - [Eric Evans: Domain-Driven Design Reference](https://www.domainlanguage.com/ddd/reference/) --- # Angular, React und der Wert von Übergängen URL: https://www.mikebild.dev/de/blog/angular-react-uebergaenge/ Datum: 2018-03-12 # Angular, React und der Wert von Übergängen Ein 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. Ich 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. ## Der Treiber: AngularJS bekommt ein Ablaufdatum Seit 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. Entsprechend 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. ## Umschreiben oder umziehen Damit 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. In 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. ## Hybridbetrieb mit ngUpgrade Fü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: ```typescript // app/app.module.ts — Angular 5 hosting AngularJS 1.6 import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { UpgradeModule } from '@angular/upgrade/static'; @NgModule({ imports: [BrowserModule, UpgradeModule], }) export class AppModule { constructor(private upgrade: UpgradeModule) {} ngDoBootstrap() { // AngularJS boots inside Angular, not the other way around this.upgrade.bootstrap(document.documentElement, ['orderApp'], { strictDi: true }); } } ``` Ab diesem Punkt laufen beide Frameworks im selben DOM-Baum. Neue Angular-Komponenten lassen sich in alte Templates hineinreichen: ```typescript // expose a new Angular component to old AngularJS templates import { downgradeComponent } from '@angular/upgrade/static'; import { InvoiceFooterComponent } from './invoice-footer.component'; angular .module('orderApp') .directive('invoiceFooter', downgradeComponent({ component: InvoiceFooterComponent })); ``` Umgekehrt 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. ## Route für Route: der Strangler-Ansatz Die 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. ```mermaid flowchart LR B[Browser] --> S[Router-Shell] S --> O["/orders
    AngularJS 1.6"] S --> I["/invoices
    Angular 5"] S --> R["/reports
    Angular 5"] O -. "Route für Route
    wandert um" .-> I ``` Der Schnitt entlang von Routen hat drei Eigenschaften, die ich an keinem anderen Migrationsschnitt in dieser Kombination gefunden habe: - Jeder Schritt ist einzeln zurücknehmbar – eine Route zurückzuschalten ist ein Router-Eintrag, kein Rollback-Drama. - Der Fortschritt ist messbar und für alle sichtbar: so viele Routen alt, so viele neu. - Die Grenze ist fachlich, nicht technisch. „Rechnungen sind umgezogen" versteht auch der Produktverantwortliche; „der Direktiven-Layer ist portiert" versteht niemand außerhalb des Teams. Die Big-Bang-Alternative hat keine dieser Eigenschaften. Sie hat nur einen Stichtag, und Stichtage verteidigen sich nicht selbst. Nebenbei ä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. ## Fachlogik, die den Umzug überlebt Das 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: ```javascript // billing/invoice-totals.js — plain ES module, no framework imports export function calculateInvoiceTotal(items, taxRate) { const net = items.reduce((sum, item) => sum + item.price * item.quantity, 0); const tax = net * taxRate; return { net, tax, gross: net + tax }; } ``` Die alte Welt nutzt das Modul über eine AngularJS-Komponente: ```javascript // old world: AngularJS 1.6 component wrapping the plain module import { calculateInvoiceTotal } from '../billing/invoice-totals'; angular.module('orderApp').component('invoiceFooter', { bindings: { items: '<', taxRate: '<' }, controller: function InvoiceFooterController() { this.$onChanges = () => { this.totals = calculateInvoiceTotal(this.items, this.taxRate); }; }, template: '{{ $ctrl.totals.gross | currency }}', }); ``` Die neue Welt – hier bewusst React 16, um zu zeigen, dass die Richtung egal ist – nutzt dasselbe Modul unverändert: ```jsx // new world: React 16 component, same module untouched import { calculateInvoiceTotal } from '../billing/invoice-totals'; export function InvoiceFooter({ items, taxRate }) { const totals = calculateInvoiceTotal(items, taxRate); return {totals.gross.toFixed(2)} €; } ``` Die Abhängigkeitsrichtung ist dabei die eigentliche Regel, nicht das Modulformat: ```mermaid flowchart TD OLD["AngularJS-Komponenten
    alte Welt"] --> CORE["Fachlogik-Module
    ohne Framework-Import"] NEW["React- oder Angular-Komponenten
    neue Welt"] --> CORE CORE -. "verboten:
    Kern kennt kein UI" .-> OLD ``` Framework-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. ## Custom Elements als neutrale Brücke Wer 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 `` 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. Mit 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. ## Der ehrliche Preis: zwei Bundles Parallelbetrieb 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. Das 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. ## Was ich daraus mitnehme Die 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. Was 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? ## Weiterführende Quellen - Angular-Dokumentation: Upgrading from AngularJS: https://angular.io/guide/upgrade - Martin Fowler: StranglerApplication (2004): https://martinfowler.com/bliki/StranglerApplication.html - Angular-Blog: Stable AngularJS and Long Term Support: https://blog.angular.io/stable-angularjs-and-long-term-support-7e077635ee9c - single-spa: https://single-spa.js.org/ --- # Prototypen: das Objektmodell hinter JavaScript-Klassen URL: https://www.mikebild.dev/de/blog/javascript-prototypen-modell-hinter-class/ Datum: 2017-11-21 ```js class Animal { constructor(name) { this.name = name; } speak() { return this.name + ' makes a sound'; } } const minka = new Animal('Minka'); const rex = new Animal('Rex'); minka.speak === rex.speak; // true minka.hasOwnProperty('speak'); // false Object.getPrototypeOf(minka) === Animal.prototype; // true ``` Drei 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. Dieses 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. ## Jedes Objekt hat einen Link nach oben Das 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`. Am klarsten sieht man das ohne jede `class` und ohne `new`, nur mit `Object.create`: ```js const animal = { describe() { return this.name + ' makes ' + this.sound; } }; const dog = Object.create(animal); dog.name = 'Rex'; dog.sound = 'woof'; dog.describe(); // 'Rex makes woof' Object.getPrototypeOf(dog) === animal; // true dog.hasOwnProperty('describe'); // false dog.hasOwnProperty('name'); // true ``` Schau 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. `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. ## Die Kette, visualisiert Wenn `dog.describe()` aufgerufen wird, läuft der Lookup die Kette hoch. Das folgende Diagramm zeigt die Stationen bis zum Ende bei `null`: ```mermaid graph TD A["dog
    own: name, sound"] -->|"[[Prototype]]"| B["animal
    own: describe"] B -->|"[[Prototype]]"| C["Object.prototype
    own: hasOwnProperty, toString"] C -->|"[[Prototype]]"| D["null"] ``` Jeder 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. ## Constructor-Functions: der Weg vor `class` Vor 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. ```js function Animal(name) { this.name = name; } Animal.prototype.speak = function () { return this.name + ' makes a sound'; }; const cat = new Animal('Minka'); cat.speak(); // 'Minka makes a sound' Object.getPrototypeOf(cat) === Animal.prototype; // true cat.hasOwnProperty('speak'); // false ``` Was 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. Vererbung war vor `class` Handarbeit – und genau diese Handarbeit zeigt, was `extends` später automatisiert: ```js function Dog(name) { Animal.call(this, name); // Parent-Konstruktor auf eigenem this } Dog.prototype = Object.create(Animal.prototype); // Kette einhängen Dog.prototype.constructor = Dog; // constructor wieder setzen Dog.prototype.speak = function () { return this.name + ' makes woof'; }; const rex = new Dog('Rex'); rex.speak(); // 'Rex makes woof' rex instanceof Dog; // true rex instanceof Animal; // true ``` Drei 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. ## Dieselbe Sache als `class` Jetzt derselbe Code in ES2015-Syntax, Seite an Seite gedacht. Das Ergebnis ist identisch im Objektmodell – dieselben Prototyp-Objekte, dieselbe Kette, dieselben geteilten Methoden: ```js class Animal { constructor(name) { this.name = name; } speak() { return this.name + ' makes a sound'; } } class Dog extends Animal { constructor(name) { super(name); } speak() { return this.name + ' makes woof'; } } const rex = new Dog('Rex'); Object.getPrototypeOf(rex) === Dog.prototype; // true Object.getPrototypeOf(Dog.prototype) === Animal.prototype; // true ``` Vergleiche 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. Ehrlich 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. ## `prototype`, `__proto__` und `Object.create` sauber trennen Die 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: - 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. - `__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. - `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. Der 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: ```js function Fn() {} const obj = new Fn(); Object.getPrototypeOf(obj) === Fn.prototype; // true (Instanz-Link) obj.prototype; // undefined (Instanz hat keine) Object.getPrototypeOf(Fn) === Function.prototype; // true (Fn ist selbst Objekt) ``` Achte 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. ## Der teuerste Fallstrick: Methoden im Konstruktor Jetzt 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: ```js function WidgetBad() { this.render = function () { /* ... */ }; // pro Instanz eine neue Funktion } function WidgetGood() {} WidgetGood.prototype.render = function () { /* ... */ }; // geteilt const a1 = new WidgetBad(); const a2 = new WidgetBad(); a1.render === a2.render; // false -> zwei getrennte Funktionen const b1 = new WidgetGood(); const b2 = new WidgetGood(); b1.render === b2.render; // true -> eine Funktion, geteilt ``` Der 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. Der 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: ```js const cat = new Animal('Minka'); const fn = cat.speak; fn(); // this ist nicht mehr cat -> Fehler oder falsches Ergebnis const bound = cat.speak.bind(cat); bound(); // 'Minka makes a sound' -> this wieder gebunden ``` Das 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. ## Warum sich das auszahlt Man 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. `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. Dieses 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. ## Weiterführende Quellen - MDN, Inheritance and the prototype chain: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Inheritance_and_the_prototype_chain - MDN, Object.create: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/create - MDN, Object.getPrototypeOf: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getPrototypeOf - MDN, Object.setPrototypeOf: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/setPrototypeOf - MDN, Object.prototype.\_\_proto\_\_: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/proto - MDN, Classes: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes - MDN, new operator: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/new - ECMAScript 2015 (ES6) Spec: https://www.ecma-international.org/ecma-262/6.0/ - ECMAScript 2017 (ES8) Spec: https://www.ecma-international.org/ecma-262/8.0/ --- # Redux als Protokoll für UI-Änderungen URL: https://www.mikebild.dev/de/blog/redux-als-protokoll/ Datum: 2017-10-12 # Redux als Protokoll für UI-Änderungen In 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. Die 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. ## Drei Bausteine, sehr ungleich verteilt Redux 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. Der Wert steckt in dem, was die beiden anderen Bausteine erzwingen. Eine Action ist ein einfaches Objekt mit einem `type` und Nutzdaten: ```js // actions.js export const ORDER_SUBMITTED = 'ORDER_SUBMITTED'; export const ORDER_ACCEPTED = 'ORDER_ACCEPTED'; export const ORDER_REJECTED = 'ORDER_REJECTED'; export const submitOrder = order => ({ type: ORDER_SUBMITTED, payload: { orderId: order.id, items: order.items } }); ``` Kein 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. Der 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. ```js // ordersReducer.js import { ORDER_SUBMITTED, ORDER_ACCEPTED } from './actions'; const initialState = { pending: [], accepted: [] }; export default function ordersReducer(state = initialState, action) { switch (action.type) { case ORDER_SUBMITTED: return { ...state, pending: [...state.pending, action.payload.orderId] }; case ORDER_ACCEPTED: return { ...state, pending: state.pending.filter(id => id !== action.payload.orderId), accepted: [...state.accepted, action.payload.orderId] }; default: return state; } } ``` Aus 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: - Jede Änderung lässt sich aufzeichnen und wieder abspielen, weil sie als Datenobjekt vorliegt. - Jede Änderung lässt sich ohne Browser testen, weil der Reducer eine Pure Function ist. - Jede Änderung hat einen Namen, über den man im Code-Review und mit der Fachseite reden kann. - Jede Änderung lässt sich von außen beobachten, ohne die UI anzufassen. Das sind keine vier Features. Das ist viermal dieselbe Eigenschaft: Es gibt ein Protokoll. ## Zeitreise ist keine Zauberei Die 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. In 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. Auch das Testen profitiert von der Nachrichtenform, und zwar ohne jede Test-Infrastruktur: ```js // ordersReducer.test.js import ordersReducer from './ordersReducer'; import { submitOrder } from './actions'; it('moves a submitted order into pending', () => { const state = ordersReducer(undefined, submitOrder({ id: '42', items: [] })); expect(state.pending).toEqual(['42']); }); ``` Kein 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. ## Events statt Befehle Genau 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. Der Unterschied wird im direkten Vergleich deutlich: ```js // commands: the sender prescribes how state must change dispatch({ type: 'SET_LOADING_TRUE' }); dispatch({ type: 'SET_ORDERS', payload: orders }); dispatch({ type: 'SET_LOADING_FALSE' }); // events: the sender reports what happened in the domain dispatch({ type: 'ORDERS_REQUESTED' }); dispatch({ type: 'ORDERS_LOADED', payload: { orders } }); dispatch({ type: 'ORDERS_LOADING_FAILED', payload: { error } }); ``` Die 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. Seitdem 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. ```mermaid sequenceDiagram participant V as View participant M as Middleware participant R as Reducer participant S as Store V->>M: dispatch(ORDER_SUBMITTED) Note over M: Abhörpunkt:
    Logging, Analytics, DevTools M->>R: action (unverändert) R->>S: neuer Zustand S-->>V: notify + render ``` ## Middleware: das Protokoll abhören Wer 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. ```js // analyticsMiddleware.js const analyticsMiddleware = store => next => action => { if (action.type.startsWith('ORDER_')) { trackEvent(action.type, action.payload); } return next(action); }; ``` Die 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. Auch 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. ## Die Grenze: Nicht jede Änderung verdient eine Nachricht So 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. Dan 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: - Lesen mehrere, im Baum weit voneinander entfernte Komponenten diesen State? - Hat die Änderung einen fachlichen Namen, der in einem Fehlerbericht etwas erklären würde? - Interessiert die Historie dieser Änderungen irgendwen – Support, Analyse, Debugging? Dreimal 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. ```mermaid flowchart TD A[Zustand entsteht] --> B{Lesen entfernte
    Komponenten mit?} B -- nein --> L[Lokal:
    setState in der Komponente] B -- ja --> C{Fachlicher Name?
    Historie relevant?} C -- nein --> L C -- ja --> S[Store:
    Action + Reducer] ``` Im 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. ## Was ich daraus mitnehme Mir 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. Deshalb 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. Sollte 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. ## Weiterführende Quellen - [MikeBild/introduction-react](https://github.com/MikeBild/introduction-react) – Repository mit React-Beispielen, inklusive Flux/Redux-Teil und Beispiel-App - [Redux-Dokumentation: Motivation](https://redux.js.org/understanding/thinking-in-redux/motivation) – warum Mutation plus Asynchronität das eigentliche Problem ist - [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 - [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 --- # JavaScript-Patterns, die geblieben sind: Module, Higher-Order Functions, Currying URL: https://www.mikebild.dev/de/blog/javascript-patterns-module-hof-currying/ Datum: 2017-09-19 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. Drei 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. Die drei Muster in einem Satz: - Module-Pattern kapselt State und gibt nur eine bewusst gewählte öffentliche API frei. - Higher-Order Functions behandeln Funktionen als Werte – sie nehmen Funktionen entgegen oder geben welche zurück. - Currying und Partial Application erzeugen aus einer Generic Function spezialisierte, vorkonfigurierte Varianten. ## Module-Pattern: zwei Wege, ein Ziel Das ä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. ```js const counterModule = (function () { let count = 0; // private, not accessible from outside return { increment() { return ++count; }, get() { return count; } }; })(); counterModule.increment(); // 1 counterModule.increment(); // 2 counterModule.get(); // 2 counterModule.count; // undefined – deliberately not exposed ``` Der 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. Seit 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: ```js // counter.js let count = 0; // module-private, only visible in this file export function increment() { return ++count; } export function get() { return count; } ``` ```js // app.js import { increment, get } from './counter.js'; increment(); // 1 increment(); // 2 get(); // 2 ``` Beide 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. ```mermaid graph LR A[IIFE + Closure] --> C[Kapselung
    privater Zustand
    klare öffentliche API] B[ES6 import/export] --> C ``` Ein 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. ## Higher-Order Functions: Funktionen sind Werte Der 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. Die 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: ```js const numbers = [1, 2, 3, 4, 5]; const doubled = numbers.map(n => n * 2); // [2, 4, 6, 8, 10] const evens = numbers.filter(n => n % 2 === 0); // [2, 4] const sum = numbers.reduce((acc, n) => acc + n, 0); // 15 ``` Jede 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. Die 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: ```js const multiplyBy = factor => n => n * factor; const double = multiplyBy(2); const triple = multiplyBy(3); double(5); // 10 triple(5); // 15 [1, 2, 3].map(double); // [2, 4, 6] ``` `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. ## Currying und Partial Application: aus generisch wird speziell Currying 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. ```js const add = (a, b) => a + b; // ordinary, two arguments at once const curriedAdd = a => b => a + b; // curried, unary chain curriedAdd(10)(5); // 15 const add10 = curriedAdd(10); // specialized function, waits for b add10(5); // 15 add10(90); // 100 ``` Eng 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: ```js const add = (a, b) => a + b; // bind fixes this (here null) and the leading arguments const add10 = add.bind(null, 10); add10(5); // 15 add10(90); // 100 ``` Der 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`: ```js const numbers = [1, 2, 3, 4]; const shifted = numbers.map(add10); // [11, 12, 13, 14] const total = shifted.reduce((sum, n) => sum + n, 0); // 50 ``` Der Bogen von der generischen zur spezialisierten Funktion lässt sich gut als Fluss lesen: ```mermaid graph LR A["add(a, b)
    generisch, zwei Argumente"] --> B["curriedAdd(10)
    bzw. add.bind(null, 10)"] B --> C["add10(n)
    spezialisiert, ein Argument"] ``` Und 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. ## Drei Fallstricke, die ich immer wieder sehe So elegant die Muster sind, so zuverlässig stolpern Teams über dieselben Stellen. Drei davon will ich benennen, weil sie in echten Codebases teuer werden. Der 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: ```js const counter = { count: 0, increment() { this.count += 1; // this depends on HOW it is called } }; [1, 2, 3].forEach(counter.increment); // TypeError, i.e. counter.count stays 0 – this is not counter ``` Die 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: ```js [1, 2, 3].forEach(counter.increment.bind(counter)); [1, 2, 3].forEach(() => counter.increment()); [1, 2, 3].forEach(counter.increment, counter); // thisArg ``` Arrow 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. Der 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. Der 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: ```js [].reduce((sum, n) => sum + n); // TypeError: Reduce of empty array [].reduce((sum, n) => sum + n, 0); // 0 – correct, because initial value set ``` Fü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. ## Fazit Wenn 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. Genau 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. ## Weiterführende Quellen - [ES-Module Guide (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) - [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) - [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) - [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) - [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) - [ECMAScript 2015 (ES6) Specification](https://www.ecma-international.org/ecma-262/6.0/) - [Eloquent JavaScript – Higher-Order Functions](https://eloquentjavascript.net/05_higher_order.html) --- # WebSockets sind nicht immer die Antwort URL: https://www.mikebild.dev/de/blog/websockets-nicht-immer-antwort/ Datum: 2017-09-12 # WebSockets sind nicht immer die Antwort Ein 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. Der 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. ## Was „Echtzeit“ im Anforderungstext wirklich bedeutet Nimmt 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. Ganz 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. Mir 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. ## Der Preis einer WebSocket-Verbindung Ein 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: - Verbindungs-State-Management auf dem Server: Wer ist verbunden, seit wann, mit welcher Session? - Eigene Heartbeats, denn tote TCP-Verbindungen fallen nicht von allein auf – Ping/Pong ist Handarbeit oder Bibliotheks-Feature, aber immer Teil der eigenen Verantwortung. - Reconnect-Logik im Client, inklusive Backoff und der Frage, was während der Funkstille verpasst wurde. - Sonderfälle in der Infrastruktur: Proxies, Load-Balancer und Firmen-Firewalls, die das Upgrade nicht kennen oder lange stille Verbindungen kappen. Nichts 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. ```javascript const WebSocket = require('ws') const server = new WebSocket.Server({ port: 8080 }) server.on('connection', socket => { socket.isAlive = true socket.on('pong', () => { socket.isAlive = true }) }) // dead connections are not detected for free – ping/pong is on us setInterval(() => { server.clients.forEach(socket => { if (!socket.isAlive) return socket.terminate() socket.isAlive = false socket.ping() }) }, 30000) ``` Das 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. ## Server-Sent Events: Push über gewöhnliches HTTP Server-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: ```javascript const express = require('express') const app = express() let nextEventId = 0 const eventLog = [] const clients = [] app.get('/events', (req, res) => { res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }) res.flushHeaders() // replay everything the client missed while it was offline const lastEventId = Number(req.get('Last-Event-ID') || 0) eventLog .filter(event => event.id > lastEventId) .forEach(event => res.write(toMessage(event))) clients.push(res) req.on('close', () => clients.splice(clients.indexOf(res), 1)) }) function broadcast(data) { const event = { id: ++nextEventId, data } eventLog.push(event) clients.forEach(res => res.write(toMessage(event))) } function toMessage({ id, data }) { return `id: ${id}\ndata: ${JSON.stringify(data)}\n\n` } app.listen(3000) ``` Der Client ist noch kürzer – und das ist der eigentliche Punkt: ```javascript const source = new EventSource('/events') source.onmessage = event => { const order = JSON.parse(event.data) renderOrder(order) } // no reconnect code here: EventSource reconnects on its own // and sends the Last-Event-ID header with every new attempt ``` Der 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. ```mermaid sequenceDiagram participant B as Browser (EventSource) participant S as Server (Express) B->>S: GET /events S-->>B: Event 41 (Bestellung A-1041) S-->>B: Event 42 (Bestellung A-1042) Note over B,S: Verbindung bricht ab
    (Deployment, Funkloch, Proxy-Timeout) B->>S: GET /events mit Header Last-Event-ID 42 S-->>B: Event 43 (alles seit 42 wird nachgeliefert) ``` Dazu 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: ```bash $ curl -N http://localhost:3000/events id: 1 data: {"orderId":"A-1041","total":89.9} id: 2 data: {"orderId":"A-1042","total":24.5} ``` Wer 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. ## Wo SSE ehrlich an Grenzen stößt Damit das keine Werbeveranstaltung wird: Server-Sent Events haben Grenzen, und die gehören auf den Tisch, bevor die Entscheidung fällt. Der 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. ## GraphQL-Subscriptions: derselbe Reflex, ein Stockwerk höher Interessant 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. Das 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: ```javascript import { SubscriptionClient, addGraphQLSubscriptions } from 'subscriptions-transport-sse' const httpClient = createNetworkInterface({ uri: 'https://example.com/graphql' }) const sseClient = new SubscriptionClient('https://example.com/subscriptions') const apolloClient = new ApolloClient({ networkInterface: addGraphQLSubscriptions(httpClient, sseClient) }) apolloClient.subscribeToMore({ document: gql` subscription onOrderCreated { orderCreated { id total } }`, updateQuery: (prev, { subscriptionData }) => { // merge the new order into the store and return the new state } }) ``` Aus 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. ## Wann WebSockets die richtige Antwort sind Nach 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: - Kollaboratives Editieren, bei dem Tastenanschläge und Operationen beider Seiten zusammenlaufen - Cursor-Positionen und Präsenz-Signale, die viele Male pro Sekunde in beide Richtungen fließen - Spiele und Simulationen, in denen jede Runde Client-Eingaben gegen Server-Zustand abgeglichen wird - Frontends, bei denen der Overhead eines eigenen POST pro Client-Message wirklich ins Gewicht fällt Der 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?“. ```mermaid flowchart TD A[Anforderung: Echtzeit] --> B{Fließen Daten fortlaufend
    in beide Richtungen?} B -- nein --> C[Server-Sent Events
    plus normale HTTP-Requests] B -- ja --> D{Ist der Rückkanal ein Strom
    mit Latenzanforderung?} D -- nein --> C D -- ja --> E[WebSockets –
    Betriebskosten einplanen] ``` Wer 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. ## Unterm Strich Aus 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. Die 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. ## Weiterführende Quellen - [subscriptions-transport-sse auf GitHub](https://github.com/CodeCommission/subscriptions-transport-sse) – GraphQL-Subscriptions über Server-Sent Events, API-kompatibel zu subscriptions-transport-ws - [graphql-subscriptions-sse-presence-list auf GitHub](https://github.com/MikeBild/graphql-subscriptions-sse-presence-list) – Presence-List-Beispiel mit React, Apollo und SSE - [Server-Sent Events in der HTML-Spezifikation (WHATWG)](https://html.spec.whatwg.org/multipage/server-sent-events.html) - [EventSource-API auf MDN](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) - [RFC 6455 – The WebSocket Protocol](https://tools.ietf.org/html/rfc6455) --- # KnockoutJS: fein-granulare Reaktivität mit Observables und Computed URL: https://www.mikebild.dev/de/blog/knockoutjs-observables-computed-vor-signals/ Datum: 2017-08-22 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. **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. Knockout 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. ## Ein Observable ist eine Funktion – keine Eigenschaft Der 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. ```js var name = ko.observable('Bob'); name(); // read -> 'Bob' name('Mary'); // write -> sets the value and notifies subscribers name(); // read -> 'Mary' ``` Das 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: ```js viewModel.name('Mary').age(50); ``` Fü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: ```js var todos = ko.observableArray([]); todos(); // read -> the current array todos.push({ title: 'Buy milk' }); todos.remove(someItem); todos.removeAll(); ``` Hier 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. ## Computed leitet ab – und verfolgt automatisch, was es liest Jetzt 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`. ```js var firstName = ko.observable('Mike'); var lastName = ko.observable('Bild'); var fullName = ko.computed(function () { return firstName() + ' ' + lastName(); }); fullName(); // 'Mike Bild' firstName('Michael'); fullName(); // 'Michael Bild' ``` Und 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. Wie? 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. Der 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: ```js var showFullName = ko.observable(true); var displayName = ko.computed(function () { if (showFullName()) { return firstName() + ' ' + lastName(); } return firstName(); }); ``` Solange `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. Als Graph gezeichnet sieht dieser Zusammenhang so aus: ```mermaid graph LR A[firstName
    observable] --> C[fullName
    computed] B[lastName
    observable] --> C C --> D[text-binding
    DOM] ``` Die 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. Für den Werkzeugkasten reicht fürs Erste dieses kleine Trio: - `ko.observable(value)` – ein einzelner reaktiver Wert, gelesen mit `obs()`, geschrieben mit `obs(newValue)`. - `ko.observableArray([...])` – eine reaktive Liste mit Mutations-Methoden wie `push`, `remove` und `splice`, die Bindings benachrichtigen. - `ko.computed(fn)` – ein abgeleiteter Wert, der automatisch verfolgt, welche Observables er liest, und sich bei deren Änderung neu berechnet. ## Vom ViewModel ins DOM: die Todos-App Damit das nicht abstrakt bleibt, hier eine kleine Todos-Anwendung. Sie zeigt alle drei Bausteine im Zusammenspiel. ```js function TodoViewModel() { var self = this; self.todos = ko.observableArray([]); self.newTodo = ko.observable(''); self.remaining = ko.computed(function () { return self.todos().filter(function (t) { return !t.done(); }).length; }); self.addTodo = function () { self.todos.push({ title: self.newTodo(), done: ko.observable(false) }); self.newTodo(''); }; } ko.applyBindings(new TodoViewModel()); ``` Ein 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. Beachtenswert 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. Die View koppelt das ViewModel deklarativ ans DOM. Hier der relevante Ausschnitt: ```html
    remaining ``` `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 `` aktualisiert. Jede dieser Aktualisierungen trifft gezielt genau die Stelle, die betroffen ist. ## Warum gezieltes Tracking ein anderes Modell ist An 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. Knockout 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. Die Sequenz einer einzelnen Änderung sieht so aus: ```mermaid sequenceDiagram participant U as User participant O as observable participant C as computed participant D as DOM-Binding U->>O: obs(newValue) O->>C: notify subscriber C->>C: re-evaluate C->>D: gezieltes text-Update ``` Beide 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. ## Fallstricke aus der Praxis Genau 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. Der Klassiker sind vergessene Klammern. Ein Observable ist eine Funktion. Wer sie ohne Klammern verwendet, arbeitet mit der Funktion selbst statt mit ihrem Wert: ```js // wrong: todo.done is the function itself, so always truthy if (todo.done) { // this branch ALWAYS runs } // correct: todo.done() reads the value if (todo.done()) { // ... } ``` Der 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. Der zweite Fallstrick sind die nativen Array-Methoden. Ich hatte es oben angedeutet: ```js // wrong: mutates the raw array without notification todos().push(newItem); // correct: KO wrapper, notifies dependent bindings todos.push(newItem); ``` `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. Der dritte betrifft den `this`-Kontext in Computed. Wenn der Evaluator auf das ViewModel zugreifen will, muss `this` auch wirklich aufs ViewModel zeigen: ```js function ViewModel() { this.price = ko.observable(10); this.count = ko.observable(3); // wrong: this does not reliably point to the ViewModel here this.total = ko.computed(function () { return this.price() * this.count(); // this.price is undefined }); } ``` Der 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: ```js this.total = ko.computed(function () { return this.price() * this.count(); }, this); // second argument binds this ``` Oder man verwendet das `self`-Muster, das ich im Todos-Beispiel schon genutzt habe: ```js function ViewModel() { var self = this; self.price = ko.observable(10); self.count = ko.observable(3); self.total = ko.computed(function () { return self.price() * self.count(); }); } ``` Ich 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. Am 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. ## Fazit Wer 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. Dieses 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. ## Weiterführende Quellen - [Observables – KnockoutJS Documentation](https://knockoutjs.com/documentation/observables.html) - [Computed Observables – KnockoutJS Documentation](https://knockoutjs.com/documentation/computedObservables.html) - [How Dependency Tracking Works – KnockoutJS Documentation](https://knockoutjs.com/documentation/computed-dependency-tracking.html) - [Observable Arrays – KnockoutJS Documentation](https://knockoutjs.com/documentation/observableArrays.html) - [Pure Computed Observables – KnockoutJS Documentation](https://knockoutjs.com/documentation/computed-pure.html) --- # PWA Weather: offline ist ein Feature URL: https://www.mikebild.dev/de/blog/pwa-weather-offline-feature/ Datum: 2017-08-12 # PWA Weather: offline ist ein Feature U-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. Dieser 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. ## Ein kleines Demo als Prüfstand Um 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. Und 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. ## Offline ist eine Produktfrage, keine Cache-Option Für die Wetter-App sieht meine Antwort so aus: - Die zuletzt geladene Wetterlage der gemerkten Städte wird angezeigt – immer, sofort, auch ohne Netz. - Die Oberfläche selbst (Layout, Icons, Schrift, Logik) lädt vollständig aus dem lokalen Cache. - Die Suche nach neuen Orten funktioniert offline nicht – und sagt das auch, statt einen Spinner drehen zu lassen. - Jede angezeigte Wetterkarte trägt einen Zeitstempel: „Stand: vor 2 Stunden“ statt stillschweigend alter Daten. Der 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. Der 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“. ## Der Service Worker: ein Proxy mit Lifecycle Technisch 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. Wer 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: ```mermaid flowchart LR A[register] --> B[install
    App-Shell precachen] B --> C[waiting
    alte Version läuft noch] C --> D[activate
    alte Caches aufräumen] D --> E[fetch
    Requests kontrollieren] ``` Die Registrierung selbst ist unspektakulär und gehört ans Ende des App-Starts: ```js // app.js if ('serviceWorker' in navigator) { navigator.serviceWorker .register('/service-worker.js') .then(function (registration) { console.log('service worker registered, scope:', registration.scope); }) .catch(function (error) { console.error('service worker registration failed', error); }); } ``` Das `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. Zwei 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. ## App-Shell: das Gerüst kommt immer aus dem Cache Die 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: ```js // service-worker.js var SHELL_CACHE = 'weather-shell-v1'; var DATA_CACHE = 'weather-data-v1'; var SHELL_FILES = [ '/', '/index.html', '/styles/app.css', '/scripts/app.js', '/images/icons/icon-192.png' ]; self.addEventListener('install', function (event) { event.waitUntil( caches.open(SHELL_CACHE).then(function (cache) { return cache.addAll(SHELL_FILES); }) ); }); self.addEventListener('activate', function (event) { event.waitUntil( caches.keys().then(function (keys) { return Promise.all( keys .filter(function (key) { return key !== SHELL_CACHE && key !== DATA_CACHE; }) .map(function (key) { return caches.delete(key); }) ); }) ); }); ``` Die 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. ## Zwei Datenarten, zwei Strategien Die 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. ```mermaid flowchart TD R[fetch-Event] --> Q{Pfad beginnt mit
    /api/weather?} Q -- ja --> N[Netzwerk zuerst] N -- Antwort ok --> C1[Kopie in weather-data-v1
    Antwort an die App] N -- kein Netz --> C2[letzte bekannte Antwort
    aus dem Cache] Q -- nein --> S[Cache zuerst:
    App-Shell] S -- Treffer --> C3[sofort ausliefern] S -- kein Treffer --> C4[ans Netzwerk durchreichen] ``` Im Code sieht diese Weiche so aus: ```js self.addEventListener('fetch', function (event) { var url = new URL(event.request.url); if (url.pathname.indexOf('/api/weather') === 0) { // network first, cached data as offline fallback event.respondWith( fetch(event.request) .then(function (response) { var copy = response.clone(); caches.open(DATA_CACHE).then(function (cache) { cache.put(event.request, copy); }); return response; }) .catch(function () { return caches.match(event.request); }) ); return; } // app shell: cache first, network as fallback event.respondWith( caches.match(event.request).then(function (cached) { return cached || fetch(event.request); }) ); }); ``` Eine 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. ## Der Datenstand gehört in die Oberfläche Damit 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: ```js // show data age when rendering a cached response caches.match('/api/weather?city=berlin').then(function (cached) { if (!cached) { return; } var fetchedAt = new Date(cached.headers.get('date')); var ageInMinutes = Math.round((Date.now() - fetchedAt.getTime()) / 60000); showLastUpdated(ageInMinutes); // "Stand: vor 120 Minuten" }); ``` Das 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. ## Manifest, HTTPS und der Home-Bildschirm Zur 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: ```json { "name": "PWA Weather", "short_name": "Weather", "start_url": "/index.html", "display": "standalone", "background_color": "#3f51b5", "theme_color": "#3f51b5", "icons": [ { "src": "images/icons/icon-192.png", "sizes": "192x192", "type": "image/png" } ] } ``` Chrome 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. HTTPS 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. ## Die ehrliche Grenze: Safari Jetzt 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. Das 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. Je 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. ## Unterm Strich Die 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? Meine 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. ## Weiterführende Quellen - Repository: [MikeBild/pwa-weather-demo](https://github.com/MikeBild/pwa-weather-demo) - Jake Archibald: [The Offline Cookbook](https://jakearchibald.com/2014/offline-cookbook/) - MDN: [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) - MDN: [Web App Manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) --- # Tic-Tac-Toe mit Minimax in React URL: https://www.mikebild.dev/de/blog/tic-tac-toe-minimax-react/ Datum: 2017-06-12 # Tic-Tac-Toe mit Minimax in React ```js function score(board, depth) { if (winnerOf(board) === COMPUTER) return 10 - depth; if (winnerOf(board) === HUMAN) return depth - 10; return 0; } ``` Mehr 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. Auffä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. Das 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. ## Das Brett ist ein Array, ein Zug ist ein neues Brett Die 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. ```js const HUMAN = 'X'; const COMPUTER = 'O'; const LINES = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6], ]; function winnerOf(board) { const line = LINES.find(([a, b, c]) => board[a] && board[a] === board[b] && board[a] === board[c]); return line ? board[line[0]] : null; } function movesFor(board) { return board.reduce((moves, field, index) => field === null ? moves.concat(index) : moves, []); } function applyMove(board, index, player) { const next = board.slice(); next[index] = player; return next; } ``` Die 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. Erstens 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. Zweitens 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. ## Minimax, rekursiv erzählt Der 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. ```js function minimax(board, player, depth) { if (winnerOf(board) || movesFor(board).length === 0) { return score(board, depth); } const values = movesFor(board).map(index => minimax( applyMove(board, index, player), player === COMPUTER ? HUMAN : COMPUTER, depth + 1 )); return player === COMPUTER ? Math.max(...values) : Math.min(...values); } function bestMove(board) { const moves = movesFor(board); const values = moves.map(index => minimax(applyMove(board, index, COMPUTER), HUMAN, 1)); return moves[values.indexOf(Math.max(...values))]; } ``` Die Rekursion hat drei Bausteine, und alle drei stehen sichtbar im Code: - Der Abbruch: Hat jemand gewonnen oder ist kein Feld mehr frei, liefert `score` den Wert des Endzustands. - 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. - Die Auswahl: Ist der Rechner am Zug, gewinnt das Maximum der Kindwerte, sonst das Minimum. `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: ```mermaid graph TD R["Brett, O am Zug
    nimmt das Maximum"] --> A["Zug auf Feld 2 blockt
    X am Zug, nimmt das Minimum"] R --> B["Zug auf Feld 5
    O gewinnt sofort: Wert +9"] A --> A1["X erzwingt später den Sieg
    Wert -6"] A --> A2["Remis
    Wert 0"] A -.-> AV["Minimum: -6"] B -.-> BV["Blatt: +9"] ``` Der 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. Zur 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. ## React rendert nur noch State Erst 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. ```jsx class Game extends React.Component { constructor(props) { super(props); this.state = { board: Array(9).fill(null) }; this.handleClick = this.handleClick.bind(this); } handleClick(index) { const afterHuman = applyMove(this.state.board, index, HUMAN); const finished = winnerOf(afterHuman) || movesFor(afterHuman).length === 0; const nextBoard = finished ? afterHuman : applyMove(afterHuman, bestMove(afterHuman), COMPUTER); this.setState({ board: nextBoard }); } render() { return ; } } const Board = ({ board, onSquareClick }) => (
    {board.map((field, index) => ( ))}
    ); ``` `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. Der 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. ```mermaid flowchart LR subgraph core["Reine Funktionen – kein React-Import"] score --> minimax winnerOf --> minimax movesFor --> minimax applyMove --> minimax minimax --> bestMove end subgraph ui["React 15"] state["state.board"] --> render["render()"] render --> onclick["onClick(index)"] end onclick -->|"applyMove + bestMove"| core core -->|"neues Brett"| setState["setState"] setState --> state ``` Die 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. ## Tests ohne Browser Diese 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: ```js test('computer blocks an open line', () => { const board = [ HUMAN, HUMAN, null, COMPUTER, null, null, null, null, null, ]; expect(bestMove(board)).toBe(2); }); test('computer prefers the immediate win over the block', () => { const board = [ HUMAN, HUMAN, null, COMPUTER, COMPUTER, null, null, null, null, ]; expect(bestMove(board)).toBe(5); }); ``` Das 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. Nun 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: - Jeder Logik-Test braucht eine gemountete Komponente und simulierte Klicks – aus einem Funktionsaufruf wird ein Browser-Ritual. - Die Stellung aus dem Fehlerfall lässt sich nicht direkt hinschreiben, sie muss durch eine Klickfolge erspielt werden. - Ein Wechsel der UI-Bibliothek oder auch nur ein größeres Umbauen des Komponentenbaums reißt die Spiellogik mit um. - Der Algorithmus ist nicht mehr am Stück lesbar, weil er über Handler, Render-Zweige und Lifecycle-Methoden verteilt ist. Nichts 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. ## Was ich daraus mitnehme Minimax 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. Bemerkenswert 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. Wer 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. ## Weiterführende Quellen - Repository `react-tic-tac-toe` („Tic Tac Toe in React with Minimax-AI"): https://github.com/MikeBild/react-tic-tac-toe - Carsten Königs Othello in Elm, die Inspirationsquelle: https://github.com/CarstenKoenig/ElmOthello - Das offizielle React-Tutorial (Tic-Tac-Toe, heutiger Stand): https://react.dev/learn/tutorial-tic-tac-toe - Minimax im Überblick: https://en.wikipedia.org/wiki/Minimax - Alpha-Beta-Pruning: https://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning --- # Einfacher Entwurf kann Prozess sparen URL: https://www.mikebild.dev/de/blog/einfach-schlagen-elegant-statt-prozess/ Datum: 2017-05-24 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. Die ü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. ## Overhead ist teuer, bevor er sichtbar wird Bevor 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. Der 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. Ein kleines Beispiel, das ich in Varianten immer wieder sehe: ```python # Over-engineered: a pluggable strategy, a factory, and a config object # for ONE rule that exists today. This needs a design review, a config # ticket, and a feature-flag rollout before it ships. class DiscountEngine: def __init__(self, strategy: DiscountStrategy, config: DiscountConfig): self.strategy = strategy self.config = config def apply(self, price: Money, customer: Customer) -> Money: return self.strategy.compute(price, customer, self.config) # YAGNI: the one rule you actually have today. One PR, no coordination, # no ticket for a config value nobody has asked for yet. def apply_discount(price: Money, is_returning_customer: bool) -> Money: return price * 0.9 if is_returning_customer else price ``` Die 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. ## Der eigentliche Hebel: Schnittgröße Der 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. ```mermaid flowchart TB A["One large change
    crossing three teams"] --> B[Design Review] B --> C[Cross-team Ticket] C --> D[Merge Conflict] D --> E[Change Board] E --> F[Coordination Meeting] F --> G[Release] A2["Same value, cut into three
    small independent changes"] --> H["Context A
    own pipeline"] A2 --> I["Context B
    own pipeline"] A2 --> J["Context C
    own pipeline"] H --> K[Release] I --> K J --> K ``` Die 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. Das 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. Und 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. ## Wenn Koordination bloß Symptom ist Ein 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. Consumer-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: ```javascript // The consumer declares only what it actually needs. No cross-team // integration meeting, no shared end-to-end test suite to schedule. pact .given("customer 42 exists") .uponReceiving("a request for the customer name") .withRequest({ method: "GET", path: "/customers/42" }) .willRespondWith({ status: 200, body: { name: like("Ada") }, }); // The provider replays this contract in ITS OWN pipeline and fails fast // if it ever breaks the expectation — independently, no meeting required. ``` Werkzeuge 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. ## Governance ohne Gremium Dasselbe 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. ```yaml # Architecture rules as executable guardrails in the pipeline, # not as a slide reviewed once a quarter by a committee. fitness_functions: - name: modules_stay_decoupled rule: "no cyclic dependencies between packages" - name: p95_latency_budget threshold_ms: 300 - name: public_api_is_stable rule: "no breaking change without a new version" ``` Ist 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. ## Wo die These an ihre Grenze kommt Die Gegenrede ist berechtigt. Drei ehrliche Einschränkungen, damit aus einer guten Heuristik keine Ideologie wird. Erstens: 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. Zweitens: 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? Drittens: 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. ## Wie ich es konkret angehe Die Reihenfolge, die sich dabei bewährt hat, beginnt nicht beim Prozess. ```mermaid flowchart LR A["Spot the heavy
    process step"] --> B["Ask: what complexity
    is it compensating?"] B --> C["Cut the change
    smaller"] C --> D["Make the expectation
    explicit as a contract"] D --> E["Turn the gate into a
    fitness function"] E --> F["Drop the step
    that is now redundant"] ``` Zuerst 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. Das 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. ## Fazit Prozess 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. Kleiner 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. Wo 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. ## Weiterführende Quellen - [Martin Fowler – Yagni](https://martinfowler.com/bliki/Yagni.html) - [Martin Fowler – Is Design Dead?](https://martinfowler.com/articles/designDead.html) - [Sacrificial Architecture (InfoQ, 2014)](https://www.infoq.com/news/2014/11/sacrificial-architecture/) - [Building Evolutionary Architectures / Fitness Functions (O'Reilly, 2017)](https://www.oreilly.com/library/view/building-evolutionary-architectures/9781491986356/ch02.html) - [Neal Ford – Building Evolutionary Architectures](https://nealford.com/books/buildingevolutionaryarchitectures.html) - [Pact – Dokumentation](https://docs.pact.io/) --- # HTML5, CSS3 und ECMAScript als Baseline URL: https://www.mikebild.dev/de/blog/html5-css3-ecmascript-baseline/ Datum: 2017-05-12 # HTML5, CSS3 und ECMAScript als Baseline Baseline 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? Diese Frage lohnt sich gerade jetzt besonders, denn die Baseline hat sich in den letzten Monaten so weit nach oben geschoben wie lange nicht. ## Ein ungewöhnliches Frühjahr Im 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. Grid 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: - Flexbox und CSS Grid für Layout, dazu `@supports` für saubere Feature-Queries im Stylesheet - CSS Custom Properties (seit letztem Jahr breit unterstützt, IE11 ausgenommen) - ES2015 nativ: Klassen, Arrow Functions, Destructuring, Template Literals, Promises - `async`/`await` aus ES2017 in den aktuellen Versionen der Evergreen-Browser - `fetch` als eingebauten HTTP-Client, seit Safari 10.1 auch dort - Custom Elements v1 und Shadow DOM v1 in Chrome und Safari - Service Worker in Chrome und Firefox – Safari und Edge fehlen hier noch, das gehört zur Ehrlichkeit dazu Vor 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. ## Layout gehört jetzt der Plattform Das 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. Das 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: ```css /* baseline for every browser: a simple flex wrap */ .products { display: flex; flex-wrap: wrap; } .product-card { flex: 1 1 240px; margin: 8px; } /* enhancement for browsers with grid support */ @supports (display: grid) { .products { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-gap: 16px; } .product-card { margin: 0; } } ``` Zwei 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. Ehrlich 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. ## Daten laden ohne Bibliothek Der 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. ```js async function loadProducts() { const response = await fetch('/api/products', { headers: { 'Accept': 'application/json' } }); // fetch resolves on HTTP errors too, so check explicitly if (!response.ok) { throw new Error('Request failed with status ' + response.status); } return response.json(); } ``` Der 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. Fü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. ## Komponenten direkt im Browser Der 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: ```js class ProductList extends HTMLElement { async connectedCallback() { this.textContent = 'Loading products ...'; try { const products = await loadProducts(); this.render(products); } catch (error) { this.textContent = 'Products are currently unavailable.'; } } render(products) { this.innerHTML = '
    '; const container = this.querySelector('.products'); products.forEach(product => { const card = document.createElement('article'); card.className = 'product-card'; card.textContent = product.name; container.appendChild(card); }); } } customElements.define('product-list', ProductList); ``` Im Markup steht danach schlicht ``, 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. Am Rand notiert: Native ES-Module im Browser stehen ebenfalls kurz bevor – Safari 10.1 kann `