App in Handy · Fallstudie
Eine Vertrauensplattform für den Raum zwischeneinem Problem und der App, die es löst.
Drei Repositories, dreizehn Backend-Services und eine bewusst langweilige Regel: Ein Service besitzt seine Daten und spricht mit dem Rest der Welt über ein Event-Log. Es läuft inzwischen auf einem echten Server, ausgeliefert von einer Pipeline, die prüft statt annimmt, welcher Build gerade ausgeliefert wird. Es folgt, wie das gebaut wurde, warum jede Entscheidung so ausfiel und was noch offen ist.
13
Services
26
Event-Topics
1.524
Backend-Tests
146
Anwendungsfälle
106
Migrationen
4
Sprachen
Überblick
Das Produkt in sechzig Sekunden
Die Architektur ergibt erst Sinn, wenn das Produkt es tut; deshalb steht dieser Teil vorn und bleibt kurz.
Die Prämisse
Zu fragen, ob eine App gut ist, ist die falsche Frage. Dasselbe Werkzeug ist für das Problem der einen Person hervorragend und für das einer anderen nutzlos. Deshalb bewertet die Plattform keine Apps für sich: Sie bewertet das Match (eine bestimmte App als Antwort auf ein bestimmtes Problem), und diese Paarung trägt die Note.
HandyScore
Jede Stimme bewertet mehrere kontextbezogene Achsen von 1 bis 10. Ein deterministischer Motor normalisiert sie, gewichtet neuere Meinungen stärker, ohne die alten zu verwerfen, zieht dünne Stichproben zu einem neutralen Prior und mischt die Achsen zu einer Zahl, die zusammen mit ihrer Aufschlüsselung ausgeliefert wird. Die Formel steht im Abschnitt Entscheidungen; nichts daran ist eine Blackbox.
„Me Too“
Probleme werden getrennt und viel einfacher bewertet: ein Netto-Signal aus Zustimmung und Ablehnung, das beantwortet, wie viele Menschen dieses Problem tatsächlich haben. Nachfrage und Qualität sind verschiedene Fragen, also werden sie mit verschiedenen Instrumenten gemessen.
Drei Repositories
Getrennte Repositories, getrennte Auslieferungszyklen, getrennte Commit-Historien, mit Absicht. Die Marketing-Seite sollte das Produkt nie kaputtmachen können, und beide sollten die Flotte nicht kaputtmachen können.
| Repository | Stack |
|---|---|
| platform13 Services, ein Edge-Gateway, 9 gemeinsame Bibliotheken | Java 21 · Spring Boot 3.5 · PostgreSQL · Kafka |
| web-uiDie Produktoberfläche | Next.js 16 · React 19 · RSC-first |
| landingÖffentliche Marketing-Oberfläche | Next.js 16 · next-intl |
Der Weg einer Anfrage
Das ist zugleich die Karte für den Rest der Seite: jeder Schritt wird weiter unten ausgeführt.
- 01
Der Edge
Das Gateway prüft das Identitätstoken einmal, stellt ein kurzlebiges internes aus, wendet das Ratenlimit an und setzt eine Korrelations-ID.
- 02
Genau ein zuständiger Service
Die Anfrage erreicht genau einen Service, der innen hexagonal ist: Infrastruktur ruft Anwendung, Anwendung ruft eine reine Domäne.
- 03
Eine lokale Transaktion
Zustandsänderung und ausgehendes Event werden gemeinsam geschrieben. Entweder committen beide oder keines.
- 04
Das Relay
Ein geplantes Relay greift offene Zeilen mit SKIP LOCKED ab, setzt eine stabile Event-ID und veröffentlicht mit dem Schlüssel des Aggregats.
- 05
Idempotente Consumer
Jeder interessierte Service wendet das Event auf sein eigenes Read Model an und dedupliziert über diese ID in derselben Transaktion wie die Wirkung.
Architektur
Ein Edge, dreizehn Eigentümer, ein Log
Wählen Sie einen Service, um zu sehen, was er besitzt und mit wem er spricht. Kein Pfeil hier ist die Zeichnung einer Absicht; jeder ist ein Topic, das im Repository existiert.
Darunter
- PostgreSQL 16
- Kafka / Redpanda
- Redis
- pgvector
- OpenTelemetry Collector
- Tempo
- Prometheus
- Grafana
- Alertmanager
- identity-service: Alleiniger Eigentümer der Nutzer und einziger Service, der mit dem Identitätsanbieter spricht: Webhook, Bereitstellung beim ersten Zugriff und der Nutzer-Snapshot, den alle anderen projizieren.
- catalog-service: Eigentümer der Apps und der Kategoriedimension. Hier lebt der „Orphan Killer“: Sobald ein Match entsteht, ist die verknüpfte App keine Waise mehr.
- problem-service: Eigentümer der Probleme und ihrer Nachfragebewertung. Nur Consumer: jedes fremde Feld, das er liest, ist eine lokale Projektion aus fremden Events.
- scoring-service: Der deterministische HandyScore-Motor. Eine Stimme berechnet eine Bewertung neu und veröffentlicht sie; drei verschiedene Kontexte cachen das Ergebnis.
- social-service: Der Knotenpunkt: Matches, ihre Nachrichtenverläufe und Nachrichtenstimmen, dazu Autorenabzeichen, die bei jedem Lesen neu berechnet werden; eine aufgehobene Sperre setzt sich damit von selbst zurück.
- moderation-service: Meldungen, Support-Tickets und das flottenweite Veröffentlichungstor: die Warteschlange, in der jede neue App, jedes Problem und jedes Match auf Freigabe wartet.
- search-service: Reine CQRS-Leseseite. Sie besitzt überhaupt keine Quelldaten: Der Index entsteht vollständig aus Events, und ihn neu aufzubauen heißt, das Log erneut abzuspielen.
- media-service: Ein zustandsloses Blatt und der einzige Ort mit den Zugangsdaten für den Objektspeicher. Prüft Typ und Größe, liefert URLs und leitet niemals Bytes durch die Anwendung.
- notification-service: Der Kanal für E-Mail-Rundschreiben. Speichert keine einzige Adresse; Empfänger werden beim Versand von identity geholt und nur vorübergehend genutzt.
- admin-service: Querschnittsflächen für den Betrieb: Dashboard-Kennzahlen über mehrere Services hinweg und ein Blackbox-Test des Ratenlimits am Edge.
- ai-service: Chat mit Retrieval, streng im eigenen Korpus der Plattform verankert, Insight-Karten zu Matches, ein Evaluations-Prüfstand und ein vollständiges Kostenbuch.
- seed-service: Sammelt öffentliche Daten, neutralisiert sie und schreibt sie über die normale Schreib-API des zuständigen Services. Erfindet nie eine Stimme.
- billing-service: Abonnement-Lebenszyklus und der Kanal für kostenloses Premium. Wird mit abgeschalteter Bezahlfläche ausgeliefert.
Acht Regeln, die der Build erzwingt
Das sind keine Empfehlungen. Jede wird von etwas geprüft, das einen Build scheitern lässt, oder ist durch die Topologie unmöglich.
| # | Regel |
|---|---|
| 01 | Ein Service besitzt seine Daten. Kein anderer fasst seine Datenbank an: kein gemeinsames Schema, kein serviceübergreifendes SQL, keine serviceübergreifenden Fremdschlüssel.Eine Datenbank pro Service plus eine ArchUnit-Regel gegen fremde Persistenz-Importe |
| 02 | Serviceübergreifend heißt HTTP-Aufruf oder Kafka-Event. Niemals ein Aufruf im selben Prozess.Keine gemeinsamen Fach-Jars; die Bibliotheken tragen nur den Kernel und die Event-Verträge |
| 03 | Keine verteilten Transaktionen. Eine Arbeitseinheit umfasst genau eine Datenbank.Transaktionen nur innerhalb der Interactors eines Services; serviceübergreifende Konsistenz ist eventual |
| 04 | Jeder Service ist innen hexagonal, mit einer Domäne ohne Framework darin.Ein ArchUnit-Regelsatz je Service, aus dem Monolithen übernommen |
| 05 | Events sind dank Outbox zuverlässig, und Consumer sind idempotent.Outbox-Tabelle und Relay je Producer; Deduplizierung über die Event-ID in der Transaktion des Consumers |
| 06 | Authentifizierung wird einmal geprüft, am Edge.JWT-Filter im Gateway; Services vertrauen nur dem intern ausgestellten Token |
| 07 | Contract-first: Die synchrone API ist OpenAPI, die asynchrone ein versioniertes Event-Schema.Ein Vertragstest lässt den Root-Build scheitern, wenn ein Event-Record im Katalog fehlt |
| 08 | Alles ist beobachtbar: Trace, Metriken und Korrelations-ID laufen über HTTP wie über Kafka.Ein automatisch konfigurierter Observability-Starter; nichts pro Service zu vergessen |
Wie es hierher kam
Microservices von Grund auf zu schreiben und sie aus einem laufenden Monolithen herauszuschneiden, sind zwei verschiedene Aufgaben. Das hier war die zweite, und der Monolith blieb den ganzen Weg über Byte für Byte eingefroren: lesbar, lauffähig, nie bearbeitet.
- Grundlage
Infrastruktur vor der Extraktion
Broker, Datenbank, Cache und der Observability-Stack liefen zuerst lokal. Der Monolith wurde für eingefroren erklärt: lesen, daraus kopieren, nie ändern, damit der Rückweg immer real blieb.
- Strangler
Das Gateway übernimmt die Eingangstür
Alles wurde an den Monolithen weitergereicht. Von da an fügte jede Extraktion davor eine höher priorisierte Route ein. Das war der Umschaltmechanismus: ein Pfad nach dem anderen, immer umkehrbar.
- Plattform
Die gemeinsamen Schienen
Korrelations-ID, Einmalprüfung plus Ausstellung des internen Tokens, Redis-Ratenlimit, die Bibliothek für Outbox, Idempotenz und Dead Letter sowie der OpenTelemetry-Starter. Bewusst als automatisch konfigurierte Bibliotheken gebaut, damit kein Service vergessen kann, mitzumachen.
- Entkopplung
Jede serviceübergreifende Leseoperation wird zur Projektion
Die eigentliche Arbeit. Nacheinander wurden Lesezugriffe, die in die Tabellen eines anderen Moduls griffen, zu lokalen Projektionen aus dem Event-Log. Der erste synchrone Aufruf zwischen Services wurde mit Wiederholungen und Circuit Breaker gebaut und dann überflüssig gemacht und abgeschaltet.
- Aufräumen
Die letzten gemeinsamen Lesezugriffe
Die verbliebenen sieben kontextübergreifenden Lesezugriffe wurden einzeln geschlossen, jeder ersetzt durch eine Projektion im Besitz des Lesers. Der ehrliche Teil: Ein früherer Bericht behauptete, das sei bereits erledigt. Das war überzogen, und die Retrospektive sagt es.
- Trennung
Physische Trennung, dann verschwindet die Auffangroute
Jeder zustandsbehaftete Service zog in seine eigene Datenbank mit eigener Migrationskette. Danach wurde die Auffangroute des Monolithen entfernt, und er verließ den Anfrageweg vollständig. Das Binary blieb als Referenz lauffähig.
- Danach
Parität, Pipeline, Härtung
Rund fünfzig Anwendungsfall- und Domänentests wurden Service für Service zurückgewonnen, die CI-Matrix und die Release-Pipeline gebaut und die Flotte auf grüner Wiese gestartet, mit live geprüfter Observability.
Drei Dinge, die erst auftauchen, wenn man es wirklich tut
Einen Monolithen zu teilen tötet prozessinterne Listener lautlos
Zwei Verhaltensweisen liefen über prozessinterne Event-Listener: dass eine App die „Waisen“-Liste verlässt, sobald sie verknüpft wird, und dass sich das Ansehen eines Autors ändert, wenn seine Nachricht bewertet wird. In dem Moment, in dem Sender und Empfänger in verschiedenen Prozessen lagen, feuerte beides nicht mehr: ohne Fehler, ohne Exception, ohne irgendetwas im Log. Beides wurde gefunden und als Kafka-Consumer neu verdrahtet. Dass nichts explodiert, ist kein Beweis dafür, dass nichts kaputtgegangen ist.
Ein abgeleitetes Flag muss erneut gesendet werden, sonst erfährt es niemand
Als eine App keine Waise mehr war, kippte das Flag im zuständigen Service, aber es wurde kein Event veröffentlicht; die Waisenliste der Suche war damit dauerhaft veraltet. Die Korrektur war eine Zeile an der richtigen Stelle; sie zu finden hieß, darüber nachzudenken, welche Zustandsübergänge Events erzeugen und welche stillschweigend nicht.
Den eigenen Migrationsbericht zu prüfen gehört zur Migration
Ein Phasenbericht behauptete, der letzte Leser der gemeinsamen Datenbank sei abgeschaltet. Es gab noch sieben. Diese Korrektur wurde in die Retrospektive hineingeschrieben statt aus ihr entfernt, denn eine falsche Aussage in einem Dokument ist ein Fehler des Dokuments.
Event-Rückgrat
Wie eine Zustandsänderung zur Wahrheit aller anderen wird
Jeder Pfeil im vorigen Abschnitt ist eines dieser Topics. Die Kette unten trägt sie, und jeder Schritt existiert wegen einer konkreten Art, auf die das vorige Design Daten verlieren konnte.
Die Kette
- 01
Der Interactor ändert den Zustand
Ein Anwendungsfall verändert sein eigenes Aggregat und veröffentlicht ein prozessinternes Domänenereignis. Von Kafka weiß er nichts.
- 02
Outbox-Appender, vor dem Commit
Ein Listener schreibt das Event innerhalb derselben Transaktion in eine Outbox-Tabelle. Ein Rollback veröffentlicht nichts; ein Commit kann das Event nicht verlieren.
Das frühere Design veröffentlichte direkt nach dem Commit. Schlug dieser Versand fehl, verschwand das Event spurlos: der Zustand war geändert, und niemand weiter unten erfuhr je davon.
- 03
Das Relay veröffentlicht
Ein geplantes Relay greift offene Zeilen mit SKIP LOCKED ab (über mehrere Instanzen hinweg sicher), setzt eine stabile Event-ID und veröffentlicht mit dem Schlüssel des Aggregats.
Der Schlüssel des Aggregats erzeugt die Reihenfolge je Aggregat. Es gibt genau eine bewusste Ausnahme, und sie steht in den Anmerkungen unten.
- 04
Das Log
Topics tragen den Namen des Kontexts, dem sie gehören. Das Log ist der Grund, warum ein Read Model neu aufgebaut werden kann: erneutes Abspielen ist ein vollwertiger Wiederherstellungsweg, keine Fantasie.
- 05
Consumer wenden es an
Jeder interessierte Service projiziert das Event in sein eigenes Schema. Die Deserialisierung ist abgesichert, sodass eine fehlerhafte Nachricht die Partition nicht vergiften kann.
- 06
Deduplizierung in derselben Transaktion
Die Markierung des verarbeiteten Events und die fachliche Wirkung committen gemeinsam. Genau das macht aus einer Zustellung „mindestens einmal“ eine Wirkung „genau einmal“.
- 07
Begrenzte Wiederholungen, dann Dead Letter
Drei Versuche mit wachsender Wartezeit, dann wandert der Datensatz in die Dead-Letter-Queue des Topics. Ein Deserialisierungsfehler überspringt die Wiederholungen ganz; eine Nachricht erneut zu versuchen, die nie geparst werden kann, ist nur ein langsamerer Fehlschlag.
- 08
Erneutes Abspielen auf Anforderung
Ein Betriebsendpunkt spielt tote Datensätze bytegetreu inklusive Headern erneut ein: die ursprüngliche Event-ID überlebt, und die Deduplizierung schützt weiterhin einen halb angewendeten Stapel.
Der Katalog
Dieses Verzeichnis ist keine Dokumentation. Ein Vertragstest findet jeden Event-Record der gemeinsamen Bibliothek und lässt den Root-Build scheitern, wenn er hier keine Zeile hat; die asynchrone API kann sich also nicht von ihrer Beschreibung entfernen.
- scoring.app-score-updatedSnapshot
Aktualisiert die zwischengespeicherte Qualitätsbewertung der App.
- Producer
- scoring
- Consumer
- catalog, ai
- Schlüssel
- appId
- scoring.problem-score-updatedSnapshot
Aktualisiert die zwischengespeicherte Nachfragebewertung.
- Producer
- scoring
- Consumer
- problem, ai
- Schlüssel
- problemId
- scoring.handy-score-updatedSnapshot
Aktualisiert die Match-Bewertung überall dort, wo sie gecacht ist.
- Producer
- scoring
- Consumer
- social, problem, search, catalog, ai
- Schlüssel
- matchId
- scoring.problem-vote-castSnapshot
Füllt das eigene „Me Too“-Urteil des Aufrufers vor.
- Producer
- scoring
- Consumer
- problem
- Schlüssel
- problemId:userId
- scoring.problem-vote-clearedTombstone
Entfernt eine zurückgezogene Stimme aus der Vorbelegung.
- Producer
- scoring
- Consumer
- problem
- Schlüssel
- problemId:userId
- scoring.app-vote-castSnapshot
Füllt die eigene achsenweise App-Bewertung des Aufrufers vor.
- Producer
- scoring
- Consumer
- catalog
- Schlüssel
- appId:userId
- scoring.match-vote-castSnapshot
Füllt die eigene achsenweise Match-Bewertung des Aufrufers vor.
- Producer
- scoring
- Consumer
- social
- Schlüssel
- matchId:userId
- catalog.category-upsertedSnapshot
Aktualisiert das Kategorielabel, das Consumer lokal anzeigen.
- Producer
- catalog
- Consumer
- problem, social, search
- Schlüssel
- categoryId
- catalog.app-upsertedSnapshot
Aktualisiert die lokale App-Projektion: Label, Suchtext, Waisen-Flag.
- Producer
- catalog
- Consumer
- social, scoring, problem, search, ai
- Schlüssel
- appId
- catalog.app-pending-reviewTor
Stellt die App in die Freigabewarteschlange.
- Producer
- catalog
- Consumer
- moderation
- Schlüssel
- appId
- catalog.app-deletedTombstone
Entfernt die App aus allen Projektionen.
- Producer
- catalog
- Consumer
- social, scoring, problem, search, ai
- Schlüssel
- appId
- catalog.category-deletedTombstone
Entfernt die Kategorie; Labels fallen auf „ohne Kategorie“ zurück.
- Producer
- catalog
- Consumer
- problem, social, search
- Schlüssel
- categoryId
- identity.user-upsertedSnapshot
Aktualisiert die lokale Nutzerprojektion: Abzeichen, Ansehen, Sperre.
- Producer
- identity
- Consumer
- social, moderation
- Schlüssel
- userId
- identity.user-deletedTombstone
Entfernt den Nutzer aus allen Projektionen.
- Producer
- identity
- Consumer
- social, moderation
- Schlüssel
- userId
- social.match-message-postedSnapshot
Sichert die Nachricht, damit sie meldbar bleibt.
- Producer
- social
- Consumer
- moderation
- Schlüssel
- messageId
- social.match-message-deletedTombstone
Markiert die Nachricht als gelöscht, behält die Zeile als Beleg.
- Producer
- social
- Consumer
- moderation
- Schlüssel
- messageId
- social.match-createdSnapshot
Aktualisiert die Match-Projektion und hebt den Waisenstatus der App auf.
- Producer
- social
- Consumer
- problem, catalog, search, scoring, ai
- Schlüssel
- matchId
- social.match-deletedTombstone
Entfernt das Match aus allen Projektionen.
- Producer
- social
- Consumer
- problem, search, scoring, catalog, ai
- Schlüssel
- matchId
- social.match-pending-reviewTor
Stellt das Match in die Freigabewarteschlange.
- Producer
- social
- Consumer
- moderation
- Schlüssel
- matchId
- social.user-reputation-changedDelta
Wendet ein Ansehens-Delta auf den Verfasser an.
- Producer
- social
- Consumer
- identity
- Schlüssel
- userId
- problem.problem-upsertedSnapshot
Aktualisiert die Problemprojektion: Suchtext, Status, Verfasser.
- Producer
- problem
- Consumer
- search, scoring, social, catalog, ai
- Schlüssel
- problemId
- problem.problem-deletedTombstone
Entfernt das Problem aus allen Projektionen.
- Producer
- problem
- Consumer
- search, scoring, social, catalog, ai
- Schlüssel
- problemId
- problem.problem-pending-reviewTor
Stellt das Problem in die Freigabewarteschlange.
- Producer
- problem
- Consumer
- moderation
- Schlüssel
- problemId
- catalog.contribution-rewardedDelta
Vergibt einmalig Karma für eine angenommene App.
- Producer
- catalog
- Consumer
- identity
- Schlüssel
- userId
- problem.contribution-rewardedDelta
Vergibt einmalig Karma für ein angenommenes Problem.
- Producer
- problem
- Consumer
- identity
- Schlüssel
- userId
Vier Feinheiten, die einen Absatz verdienen
Snapshots verzeihen, Deltas nicht
Fast jedes Topic trägt den vollständigen aktuellen Zustand; der Consumer setzt also einen Wert, und eine erneute Zustellung konvergiert einfach. Zwei Topics tragen stattdessen eine Änderung: eine Ansehenskorrektur und eine Beitragsbelohnung. Für sie ist Deduplizierung keine Bequemlichkeit, sondern das Einzige zwischen „mindestens einmal“ und Doppelzählung. Der Belohnungspfad geht weiter und fügt beim Producer eine zweite, unabhängige Absicherung hinzu: ein einmaliger Zeitstempel am Inhalt selbst, sodass veröffentlichen → zurückziehen → erneut veröffentlichen genau einmal belohnt.
Dasselbe Wort, zwei richtige Verhalten
Eine gelöschte App, ein gelöschtes Match oder Problem wird aus jeder Projektion entfernt: Gelöschtes darf in keiner Liste auftauchen. Eine gelöschte Thread-Nachricht nicht: Ihre Zeile bleibt mit einer Löschmarkierung, denn eine inzwischen entfernte Nachricht muss weiterhin meldbar sein und der Beleg darf nicht verschwinden. Beides heißt Tombstone; beide gleich zu behandeln würde eines davon kaputtmachen.
Eine Consumer-Gruppe ist eine Korrektheitsentscheidung
Ein Service reagiert aus zwei unabhängigen Gründen auf die Entstehung eines Matches: Er verändert ein Aggregat und pflegt eine Projektion. Legt man beide Listener in dieselbe Gruppe, teilen sie sich die Partitionen: jede Reaktion sieht nur einen Teil der Events, und keine wirkt offensichtlich defekt. Getrennte Gruppen machen daraus ein sauberes Fan-out, bei dem beide alles sehen.
Der Partitionsschlüssel ist nicht immer die Aggregat-ID
Stimm-Events werden über das Paar aus Problem und abstimmender Person geschlüsselt, nicht über die Zeile der Stimme. Die Identität des Read Models ist dieses Paar, und die Abfolge abstimmen → ändern → zurückziehen einer Person muss in Reihenfolge bleiben. Die ID der Stimmzeile ändert sich beim Zurückziehen und erneuten Abstimmen; sie als Schlüssel zu nehmen würde eine veraltete Stimmabgabe nach dem Widerruf landen lassen, der sie eigentlich entfernen sollte.
Technische Entscheidungen
Warum jedes Teil so ist, wie es ist
Jede Karte nennt Entscheidung und Grund in wenigen Zeilen. Für das vollständige Argument, die akzeptierte Abwägung und den Ort im Repository eine davon öffnen.
DatenEin Service besitzt seine Daten: kein gemeinsames Schema, keine serviceübergreifenden Joins, keine serviceübergreifenden FremdschlüsselFremde Daten sind eine weiche Kennung plus eine Projektion, die der Leser besitzt und aus dem Event-Log aktuell hält.Eine gemeinsame Tabelle ist eine gemeinsame Auslieferung. Sobald zwei Services dieselben Zeilen lesen, kann keiner sein Schema mehr allein ändern, und die Servicegrenze wird zur Dekoration.
Jeder zustandsbehaftete Service hat seine eigene Datenbank und seine eigene Migrationskette ab der eigenen ersten Version. Braucht ein Service die Daten eines anderen, hält er die Kennung und ein lokales Read Model hinter einem Port, den er selbst definiert; die gespeicherte Form ist also die, die er braucht, nicht die, die der Eigentümer zufällig hat.
Die Regel wird nicht durch Disziplin gehalten. ArchUnit lässt den Build scheitern, wenn ein Service die Persistenztypen eines anderen importiert, und nach der physischen Trennung gibt es kein gemeinsames Schema mehr, in das man auch nur versehentlich greifen könnte.
AbwägungJede serviceübergreifende Leseoperation ist eventual konsistent. Prüfungen im Schreibpfad wie „gibt es diese App?“ sind keine Gewissheiten mehr, sondern konvergieren; hier vertretbar und an den betroffenen Stellen notiert, statt später entdeckt zu werden.
Im Repository
- 13 × <svc>_db
- ArchUnit: SERVICES_DO_NOT_DEPEND_ON_EACH_OTHER
- *LookupPort → EventFed*Adapter
DatenDie Leseseite besitzt überhaupt keine QuelldatenDie Suche hat eine Datenbank, aber keine einzige Fachtabelle: vier rein aus Events gebaute Projektionen plus ein Helfer für Volltextsuche.Die Suche liest über alle Kontexte hinweg: also genau die Abfrage, die zum Join über Grenzen hinweg verführt. Sie zu einer reinen Projektion zu machen, nimmt die Versuchung weg und macht aus dem Neuaufbau eines Index ein erneutes Abspielen statt einer Migration.
Die Abfragen laufen mit einfachem SQL gegen lokale Projektionen, mit Postgres-Volltextsuche über einen GIN-Index. Hier gibt es bewusst keine ORM-Entitäten: Die Leseseite hat keine Domäne zu schützen, ein Objekt-Mapper wäre reiner Overhead.
Zwei Regeln halten die Seitenaufteilung ehrlich. Die Sortierung ist ein Enum, und das Enum ist die Erlaubnisliste: Ein unbekannter Wert wird abgelehnt, bevor unser Code überhaupt läuft, denn die Klausel muss in eine native Abfrage interpoliert werden und dorthin darf nie eine Zeichenkette vom Client gelangen. Und jede Sortierung endet mit der Zeilen-ID, denn ohne strikte Gesamtordnung wiederholen sich Zeilen mit gleichem Sortierwert lautlos oder verschwinden zwischen den Seiten.
AbwägungAlles, wonach gesucht werden soll, muss zuerst auf einem Event reisen. Es gibt keinen bequemen Join für ein neues Feld; genau das ist der Sinn, bedeutet aber auch, dass eine Suchfunktion manchmal in einem anderen Service beginnt.
-- Every ORDER BY on the paged union ends with the id.
-- Without a strict total order, rows tying on the sort column
-- silently repeat or vanish between pages.
ORDER BY ts_rank(search_vector, query) DESC, created_at DESC, idIm Repository
- search_db
- app/problem/category/match_projection
- SearchRepositoryAdapter
- SearchSort
DatenEine Liste mit mehreren Achsen ist eine zusammengesetzte Abfrage, keine VorrangketteJeder Filter ist ein null-tolerantes Prädikat in derselben Abfrage. Einen hinzuzufügen heißt eine Zeile hinzufügen, nicht einen Zweig.Die Liste war früher eine if/else-Leiter über die Filterachsen: Der erste gesetzte gewann, der Rest fiel lautlos weg. Eine Anfrage nach Name und Waisenstatus beantwortete nur die Namenshälfte, und kein Aufrufer konnte das erkennen.
Die Zusammenführung zu einem Kriterienobjekt und einer nativen Abfrage löschte neun inzwischen tote Repository-Methoden und ließ den Endpunkt sich so verhalten, wie seine Dokumentation es immer behauptet hatte.
JSON-Attribute werden über Containment des gesamten Dokuments abgefragt statt über Feldextraktion (Extraktion kann Array-Mitgliedschaft gar nicht beantworten), und dadurch bedient ein einziger GIN-Index alle JSON-Achsen, heutige wie künftige.
AbwägungEine große Abfrage liest sich auf den ersten Blick schlechter als fünf kleine Methoden. Dafür ist es unmöglich, dass zwei Filter sich uneins sind, welcher gewinnt.
-- One query, every axis null-tolerant. A new filter is a predicate here,
-- never a new repository method and never a new branch.
WHERE (CAST(:categoryId AS uuid) IS NULL OR a.category_id = CAST(:categoryId AS uuid))
AND (CAST(:platform AS text) IS NULL OR a.metadata @> :platformJson::jsonb)
AND (CAST(:name AS text) IS NULL OR a.search_vector @@ plainto_tsquery(:name))Im Repository
- AppSearchCriteria
- AppSearchNativeQuery
- GIN jsonb_path_ops (V6)
DatenEin flottenweiter Mapping-Standard machte ein Feld unlöschbarNullwerte zu ignorieren ist richtig für „lösche nicht, was der Aufrufer nicht geschickt hat“, und falsch für ein Feld, dessen Abwesenheit der neue Wert ist.War einmal ein Screenshot an einem Problem gesetzt, ließ er sich nie wieder entfernen. Die Domäne sagte null, der Mapper ignorierte null, die alte URL überlebte jedes Speichern, und nichts schlug fehl.
Die Korrektur ist eine Ausnahme pro Eigenschaft für dieses eine Feld, nicht eine Änderung des globalen Standards, der für alles andere richtig ist. Auch die Abwesenheit bekam genau eine Darstellung: Leere Eingabe wird schon im Kommando zu null normalisiert, sodass kein Leser zwei Arten von „kein Bild“ prüfen muss.
Bemerkenswert ist, wie es auffiel. Mock-basierte Tests bestanden in beiden Fällen: sie prüften einen Mapper, der genau tat, was ihm gesagt wurde. Erst ein Integrationstest gegen eine echte Datenbank zeigte, dass sich die Zeile nie änderte.
AbwägungJedes künftige optionale und entfernbare Feld braucht dieselbe ausdrückliche Ausnahme und einen Test, der es wirklich leert. Ein kleiner, wiederkehrender Aufwand, akzeptiert, weil die Alternative eine ganze Klasse stiller Datenfehler ist.
// IGNORE is right for "don't blank what the caller didn't send" and
// silently wrong for a field whose absence IS the new value.
@Mapping(target = "screenshotUrl", source = "screenshotUrl",
nullValuePropertyMappingStrategy = SET_TO_NULL)
void updateEntity(Problem domain, @MappingTarget ProblemEntity entity);Im Repository
- MapStructGlobalConfig
- problems.screenshot_url (V5)
- ProblemPersistenceIT
MessagingZustand und Event committen in derselben TransaktionDas ausgehende Event wird innerhalb der fachlichen Transaktion in eine Outbox-Tabelle geschrieben. Veröffentlicht wird danach durch ein Relay.Das frühere Design veröffentlichte direkt nach dem Commit. Schlug das fehl (Broker weg, Netzwerkaussetzer, Prozess beendet), überlebte die Zustandsänderung und das Event nicht. Niemand weiter unten erfuhr davon, und nichts hielt fest, dass etwas verloren ging.
Ein Listener, der vor dem Commit läuft, hängt das Event über dieselbe Verbindung an die Outbox des zuständigen Services an. Ein Rollback veröffentlicht nichts. Ein Commit macht das Event so dauerhaft wie die Daten, die es beschreibt.
Ein geplantes Relay greift offene Zeilen mit SKIP LOCKED ab (das macht mehrere Instanzen sicher), setzt eine stabile Event-ID für die spätere Deduplizierung und veröffentlicht mit dem Schlüssel des Aggregats, sodass dessen Events in Reihenfolge bleiben.
AbwägungDie Veröffentlichung ist nun asynchron und leicht verzögert, und das Abfrageintervall des Relays wird zu einem echten Latenzregler. Change Data Capture würde das Pollen ersetzen; das ist eine dokumentierte, bewusste Zurückstellung und kein Versehen.
// BEFORE_COMMIT: the event row joins the business transaction, so a
// rollback publishes nothing and a commit can never lose the event.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
void on(AppScoreUpdatedEvent event) {
outbox.append(ScoringTopics.APP_SCORE_UPDATED, event.appId(), event);
}Im Repository
- OutboxWriter
- OutboxRelay
- <svc>.outbox_events
- ScoreOutboxAppender
MessagingZustellung mindestens einmal, Wirkung genau einmalDie Deduplizierungsmarke wird in derselben Transaktion geschrieben wie die fachliche Wirkung. Nicht davor, nicht danach.Jeder Broker stellt erneut zu. Committet die Marke getrennt, entsteht ein Fenster, in dem die Wirkung angewendet wurde und die Marke nicht; die nächste Zustellung wendet sie erneut an.
Bei Snapshot-Events ist das Bequemlichkeit: Der Consumer setzt einen Wert, ein erneutes Abspielen konvergiert ohnehin. Bei den zwei Topics, die eine Änderung statt eines Zustands tragen (eine Ansehenskorrektur und eine Beitragsbelohnung), ist es das Einzige, was Doppelzählung verhindert.
Der Belohnungspfad trägt beim Producer eine zweite, unabhängige Absicherung: ein einmaliger Zeitstempel am Inhalt selbst. Die Deduplizierung im Consumer stoppt eine erneute Zustellung; der Stempel beim Producer stoppt, dass ein Zyklus aus veröffentlichen → zurückziehen → erneut veröffentlichen zweimal belohnt. Zwei Schichten, weil ein Delta wirklich beide braucht.
AbwägungJeder Consumer zahlt einen kleinen Schreibvorgang pro Event, und das Register muss irgendwann beschnitten werden. Günstig neben der Fehlerklasse, die es beseitigt.
-- The marker is written in the same transaction as the business effect,
-- which is what turns at-least-once delivery into exactly-once effect.
INSERT INTO processed_events (event_id, consumer)
VALUES (:eventId, :consumer)
ON CONFLICT (event_id, consumer) DO NOTHING;Im Repository
- ProcessedEventStore
- <svc>.processed_events
- apps.contribution_rewarded_at
MessagingEine vergiftete Nachricht stoppt einen Datensatz, nicht das SystemBegrenzte Wiederholungen, dann ein Dead-Letter-Topic und ein Betriebsendpunkt, der diese Datensätze bytegetreu erneut abspielt.Ohne das blockiert eine einzige fehlerhafte Nachricht ihre Partition für immer und legt eine ganze Projektion lahm. Mit naiven endlosen Wiederholungen tut sie das laut und teuer.
Ein einziger Fehler-Handler bedient die ganze Flotte: drei Versuche mit wachsender Wartezeit, dann das Dead-Letter-Topic. Deserialisierungsfehler überspringen die Wiederholungen vollständig; eine Nachricht erneut zu versuchen, die nie geparst werden kann, ist nur ein langsamerer Fehlschlag.
Wiederherstellung zählt so viel wie Eindämmung. Der Wiedergabe-Endpunkt erhält die Header, die ursprüngliche Event-ID überlebt also; einen halb angewendeten Stapel erneut abzuspielen ist damit sicher, weil die Deduplizierung weiterhin erkennt, was schon angekommen war.
AbwägungDie Wiederholungen blockieren, statt über Retry-Topics zu laufen. Für idempotente Consumer mit geringem Volumen ist das einfacher und ausreichend; der Ausbauweg ist für den Tag notiert, an dem das Volumen etwas anderes sagt.
Im Repository
- ConsumerErrorHandlingAutoConfiguration
- DltReplayer
- POST /actuator/dltreplay
MessagingBaue den robusten synchronen Client und mache ihn dann überflüssigDer erste serviceübergreifende Aufruf der Flotte wurde ordentlich gehärtet, dann durch eine event-gespeiste Projektion ersetzt, und der letzte synchrone Client wurde abgeschaltet.Ein synchroner Aufruf koppelt Verfügbarkeit: Fällt der Aufgerufene aus, ist der Aufrufer beeinträchtigt. Manchmal ist das die richtige Abwägung; für eine Leseoperation, die sich projizieren lässt, ist es eine dauerhafte Steuer.
Der erste Aufruf wurde sorgfältig gebaut (typisierte HTTP-Schnittstelle, Transport-Timeouts, Wiederholungen, Circuit Breaker, Bulkhead, ein Anti-Corruption-Mapper und ein sanfter Rückfall), und sein vollständiger Breaker-Zyklus wurde unter einem echten Ausfall der Abhängigkeit nachgewiesen.
Danach begann der Eigentümer, dieselben Daten als Event zu veröffentlichen, zwei Consumer projizierten sie lokal, und der Client wurde gelöscht. Die Transportbibliothek und der interne Endpunkt blieben erhalten statt entfernt zu werden: Sie haben heute keine Konsumenten und sind genau das, was der nächste echte synchrone Bedarf nutzen wird. Derselbe Stack trägt heute die Freigabeaufrufe des Moderationsservices.
AbwägungDie projizierte Leseoperation ist eventual konsistent, eine Existenzprüfung im Schreibpfad kann also kurz von der Wirklichkeit abweichen. Bewusst akzeptiert, mit dokumentierten betroffenen Pfaden.
Im Repository
- libs/platform-http
- PlatformHttpClientFactory
- AppUpsertedEvent
- social/scoring app_projection
SicherheitAuthentifizierung passiert einmal, am EdgeDas Gateway prüft das externe Token und ersetzt es durch ein kurzlebiges internes, dem die Services vertrauen.Dreizehn Services, die einen externen Identitätsanbieter je selbst prüfen, sind dreizehn Stellen für Fehlkonfiguration, dreizehn ausgehende Abhängigkeiten und dreizehn verschiedene Antworten auf „was darf dieser Nutzer?“.
Die Services sehen den externen Anbieter nie. Ein einziges Decoder-Bean in einem gemeinsamen Starter stellte alle auf einmal um, ohne Code pro Service. Das interne Token lebt zwei Minuten, kurz genug, dass ein abgegriffenes praktisch wertlos ist.
Der feine Punkt ist die Rolle. Sie wird nicht aus dem externen Token kopiert: Das Gateway löst sie aus der Datenbank des Identitätsservices auf, kurz zwischengespeichert; eine im Produkt vergebene Berechtigung wirkt damit innerhalb dieses Fensters in der ganzen Flotte und nicht erst beim nächsten Login.
AbwägungDer Preis ist Verfügbarkeit. Kann identity nicht antworten, weist der Edge die Anfrage mit einem 503 ab, statt der im externen Token behaupteten Rolle zu vertrauen; eine frühere Fassung fiel dort offen, und eine Resilienz-Kampagne hat das geschlossen. Anonymer Verkehr bleibt unberührt und ein Cache von dreißig Sekunden trägt bekannte Nutzer über einen kurzen Ausfall, abgelehnt wird also nur eine authentifizierte Anfrage für jemanden, den zuletzt niemand gesehen hat.
platform:
security:
internal-jwt:
issuer: api-gateway
ttl-seconds: 120 # short enough that a stolen token is worthless
role-cache:
ttl-seconds: 30 # role comes from identity's DB, not from ClerkIm Repository
- GatewaySecurityConfig
- InternalIdentityMintGlobalFilter
- GatewayRoleResolver
- platform-security-starter
SicherheitPremium hat genau eine AbleitungEine einzige Methode entscheidet, und ein Administrator ist immer Premium. Es gibt kein zweites Flag, das synchron gehalten werden müsste.In drei Services verdoppelte Berechtigungslogik wird zu drei fein voneinander abweichenden Antworten, und der Fehler zeigt sich als „dieser eine Bildschirm hält mich für nicht abonniert“.
Die Weitergabe erfolgt rein durch Ableitung: kein neues Event, kein Read Model, kein synchroner Aufruf. Der Identitätsservice stellt den abgeleiteten Wert bereit, das Gateway prägt ihn beim Ausstellen des internen Tokens als Claim ein, und jeder Service liest den Claim. Ein Premium-Feature hinzuzufügen heißt, einen Boolean zu lesen.
Die Berechtigung selbst ist die Vereinigung der aktiven Ansprüche eines Nutzers: bezahltes Abonnement, Aktionscode, vom Administrator gewährte Kulanz. Ein auslaufendes Abonnement stuft deshalb nie jemanden herab, der noch eine gültige Aktion hält: Die Frage „ist dieser Nutzer berechtigt?“ wird an einer Stelle gestellt und aus allen Gründen zugleich beantwortet.
AbwägungDer Claim ist nur so frisch wie das Cache-Fenster. Das ist bewusst gewählt: Bei jeder Anfrage am Edge zu identity und zurück wäre ein deutlich schlechteres Geschäft.
// The one and only derivation of "premium" in the fleet.
// An ADMIN is always premium: no second flag to keep in sync.
public boolean isPremium() {
return accountType == AccountType.PREMIUM || role == Role.ADMIN;
}Im Repository
- User.isPremium()
- InternalPrincipalView
- EntitlementPolicy.isEntitled
SicherheitDie Geldfläche wird abgeschaltet ausgeliefertEin einziges, standardmäßig deaktiviertes Flag schließt die gesamte Bezahlfläche in der Sicherheitsschicht und noch einmal im Anwendungsfall.Zahlungen wurden aus einem Grund außerhalb des Codes zurückgestellt: Der Anbieter nimmt nur ein eingetragenes Unternehmen auf, und ein solches gibt es noch nicht. Funktionierenden Code zu löschen wäre die falsche Antwort gewesen; ihn erreichbar zu lassen wäre schlimmer gewesen.
Solange das Flag aus ist, sind Checkout, Abonnement-Lebenszyklus, Zahlungsbuch, Planliste, Anbieter-Webhook und die administrative Erstattungsfläche für alle unerreichbar: für Nutzer, Administratoren und Anonyme gleichermaßen. Zwei Schichten an einem Flag: Die Sicherheitskonfiguration verweigert die Routen, und der Checkout-Anwendungsfall verweigert im Prozess, sodass auch von innen keine Abbuchung gestartet werden kann.
Der Rest des Entwurfs geht davon aus, dass es eines Tages eingeschaltet wird. Es werden nie Kartendaten gespeichert; das hält das gehostete Formular des Anbieters. Der Betrag wird immer serverseitig aus einer Plan-Kennung aufgelöst und nie vom Client übernommen. Zugang wird nur aus einem per Webhook bestätigten Zustand gewährt, denn ein Browser-Rücksprung kann abgebrochen oder gefälscht werden und ist keine Autorität.
AbwägungCode, der nicht läuft, wird nicht geübt. Deshalb hat die Reihenfolge des Schalters einen eigenen Test, damit das äußerste Tor beim Warten nicht verrottet.
Im Repository
- BillingSecurityConfig.PAID_SURFACE
- StartCheckoutInteractor
- BillingKillSwitchSecurityIT
SicherheitEine Prüfung dort, wo ein Geheimnis benutzt wird, schützt nur dieses GeheimnisEin Register benennt jedes Geheimnis, ohne das die Flotte nicht starten darf, und ein einziger Konfigurations-Hook verweigert einen Produktionsstart, bevor der Anwendungskontext überhaupt existiert.Die Codebasis kannte dieses Muster bereits und hatte es in vier Beans von Hand geschrieben. Trotzdem hatte sie hundertvierundvierzig Konfigurationsschlüssel ganz ohne Schutz, denn eine Prüfung im Bean, das ein Geheimnis verbraucht, schützt genau einen Schlüssel. Welche Schlüssel geschützt waren, war zur Aufzeichnung dessen geworden, wessen Bean jemand an jenem Tag bearbeitet hatte.
Wo eine solche Regel wohnt, entscheidet ihre Reichweite. Die einzige Bibliothek, die alle vierzehn startfähigen Module bereits teilten, war die für Observability, und der Security-Starter lässt sich dem Gateway überhaupt nicht hinzufügen: er zieht den Servlet-Stack mit, und ein reaktives Gateway verweigert den Start, wenn es diesen im Klassenpfad findet. Ein neues Modul zu eröffnen war besser, als eine Sicherheitsregel in einem Jar zu verstecken, das nach etwas anderem heißt.
Der schärfste Fund war ein eingecheckter Standardwert, der funktionierte. Der Schlüssel-Endpunkt des Identitätsanbieters zeigte standardmäßig auf einen aktiven Entwicklungs-Tenant; ihn in der Produktion zu vergessen schlug also nicht fehl, sondern akzeptierte stillschweigend dessen Signaturen und machte jeden, der sich dort registriert hatte, hier zu einem gültigen Benutzer. Wenn ein Standardwert unvermeidlich ist, wähle einen, der nicht funktionieren kann — einen reservierten, ungültigen Host — statt eines, der gegen das Falsche funktioniert.
AbwägungVierzehn Module verweigern jetzt den Start bei einem fehlenden Schlüssel: ein schlechteres erstes Deployment und ein besseres zweites Jahr. Der Fehler ist laut, sofort und nennt den Schlüssel.
// The secret is registered once, centrally, with the consequence of
// forgetting it. Not a check inside the bean that happens to use it:
// that version only ever guards the one key someone remembered.
GuardedSecret.fleetWide(
"SENTRY_DSN",
List.of("sentry.dsn", "SENTRY_DSN"),
Set.of(),
"production exceptions reach nobody — they go to container stdout"
+ " and are lost on the next restart, with no symptom anywhere"),Im Repository
- libs/platform-config-guard
- PlatformSecretRegistry
- EnvironmentPostProcessor
- security-gates.sh
SicherheitAktuell zu sein ist nicht dasselbe wie gescannt zu seinEine Stückliste wird planmäßig erzeugt und gescannt, Image-Basen sind gepinnt, und jede Unterdrückung trägt ein Ablaufdatum, das der Scanner selbst durchsetzt.Diese Phase wurde in der Erwartung geplant, nichts zu finden: jedes Framework war auf seinem neuesten Stand. Der erste Scan lieferte sechsundvierzig hohe oder kritische Advisories, sechs davon kritisch und drei direkt im Anfragepfad — darunter eine Schwachstelle höchster Schwere in der Ausdruckssprache des Gateways, der einzigen Eingangstür der Flotte. Eine Version ist an dem Tag sauber, an dem sie gewählt wird. Das Advisory erscheint später, und im Repository ändert sich dabei nichts.
Zwei Abhängigkeits-Overrides bleiben bestehen, und jedes trägt seine Entfernungsbedingung in einem Kommentar statt in jemandes Gedächtnis: lösche es an dem Tag, an dem ein Scan ohne es grün ist. Für Unterdrückungen gilt derselbe Maßstab. Die Datei verlangt eine Begründung und ein Ablaufdatum, und der Scanner setzt das Datum durch: ein Eintrag hört auf, etwas zu verschweigen, sobald er verfällt, und das Tor wird von selbst rot.
Auch Build-Eingaben sind Abhängigkeiten. Der Tag einer Workflow-Action ist ein beweglicher Zeiger: wandert er, läuft anderer Code gegen unseren Checkout mit unserem Token, und in unserem Diff ändert sich nichts — deshalb sind Actions per Commit gepinnt. Der Build-Wrapper prüft das heruntergeladene Archiv per Hash, denn Transportsicherheit authentifiziert den Server, nicht das Artefakt. Auch Image-Basen sind gepinnt, ohne die ihr Scan bedeutungslos wäre: auf einer einzigen Maschine hatten sich still sieben Builder- und fünf Run-Images angesammelt, und nichts hielt fest, welches ein bestimmter Build benutzt hatte.
AbwägungEin Scanner statt zwei. Zwei hätten zwei Schwachstellendatenbanken, zwei Schwellenwerte und zwei Unterdrückungsdateien bedeutet, die man in Übereinstimmung halten muss — genau die Divergenz, die diese Codebasis anderswo immer wieder bezahlt, importiert in die Lieferkette.
# An empty bill of materials scans perfectly clean, and a clean scan is
# indistinguishable from a good one by exit code alone. Anything that
# narrows the reactor lands here, so the floor is asserted first.
components=$(jq '.components | length' "$SBOM")
if [ "$components" -lt "$MIN_COMPONENTS" ]; then
fail "SBOM has $components components (< $MIN_COMPONENTS) — scan not trustworthy"
fiIm Repository
- dependency-scan.sh
- CycloneDX SBOM → Trivy
- .trivyignore.yaml (expired_at)
- supply-chain.yml (weekly)
SicherheitWer dies lesen darf, war aufgeschrieben; wann es verschwindet, nichtJede benutzerbezogene Spalte ist einer von vier Aufbewahrungsklassen zugeordnet, und das Löschen erreicht die zweite Kopie ebenso wie die erste.Zugriffskontrolle beantwortet, wer etwas sehen darf. Nichts davon beantwortet, wann diese Sache aufhört zu existieren, und diese Lücke sieht nicht nach einem Berechtigungsfehler aus, taucht also in keiner Berechtigungsprüfung auf.
Das Kontolöschungs-Event hatte zwei Verbraucher, und beide taten dasselbe Kleine: einen projizierten Anzeigenamen entfernen. Der Name verschwand, der Inhalt nicht — jedes Assistenzgespräch blieb mit vollständigem Prompt und vollständiger Antwort unbefristet dem gelöschten Benutzer zugeordnet. Das Löschen erreicht nun den Inhalt, und es erreicht zuerst das Duplikat: ein Auswertungsdatensatz bettet dasselbe Material wörtlich ein und hat keine eigene Benutzerspalte, muss also in derselben Transaktion und vor der verbindenden Zeile verschwinden.
Löschen ist nicht für jede Klasse die richtige Antwort, und genau das zu benennen ist die Arbeit. Ein privates Gespräch wird gelöscht. Ein öffentlicher Beitrag bleibt und wird beim Lesen de-identifiziert, denn ihn zu entfernen schreibt die Seiten anderer Leute um. Eine Stimme bleibt, denn sie ist Eingabe einer bereits veröffentlichten Zahl. Zahlungsbelege bleiben, weil das Gesetz es verlangt. Davon getrennt: eine Logzeile ist eine zweite Kopie ohne Eigentümer, ohne Aufbewahrungsfrist und ohne Weg, eine Löschanfrage zu erfüllen — dorthin reist die pseudonyme Kennung. Der schlimmste Fall war der Standardfall: ein Platzhalter-Mailversender schrieb eine Zeile pro Empfänger, sodass jede Rundsendung das Benutzerverzeichnis ins Log kippte.
AbwägungDe-Identifizierung beim Lesen kostet eine Abfrage auf Pfaden, die früher direkt einen Namen verbunden haben. Es ist die einzige Variante, die die Historie aller anderen unversehrt lässt.
Im Repository
- identity.user-deleted
- chat_turn_log
- judge_evaluation.full_prompt
- SECURITY_RUNBOOK §6
ArchitekturEine nicht ausgeführte Prüfung ist keine PrüfungDie letzte Phase der Sicherheitskampagne prüfte das laufende System statt des Quelltextes und fand vier weitere Mängel — darunter, dass die Flotte dem Code um fünf Phasen hinterherhinkte.Neun Phasen hatten das Repository verifiziert. Keine hatte der laufenden Flotte irgendetwas gefragt. Der Unterschied klingt kleinlich — genau bis er etwas kostet.
Die erste Live-Messung sah aus wie ein Rückfall: eine anonyme Health-Anfrage lieferte die vollständige Komponentenaufschlüsselung, ein Leck, das Tage zuvor geschlossen worden war. Es war kein Rückfall. Die Images waren zwei Stunden vor dieser Korrektur getaggt worden, und keine der fünf folgenden Phasen existierte in irgendeinem Container, während im Quellbaum jede Prüfung grün war. Ein Image-Repository veraltet unter einem beweglichen Tag lautlos; deshalb stempelt nun jeder Service seinen Build, und eine Prüfung verweigert eine Flotte, die älter ist als der Code, den sie zu betreiben behauptet.
Zwei Suites waren rot oder gar nicht vorhanden, und niemand hätte es wissen können, weil nichts sie ausführte. Die Header-Suite des Frontends hatte weder ein Skript noch einen Workflow. Die tiefe Ende-zu-Ende-Stufe wird nur von einem Release-Tag ausgelöst, und dieses Repository hatte nie eines; ihr erster echter Lauf gegen eine laufende Flotte fand zwei frische Mängel, die das Lesen des Quelltexts nicht hätte finden können, weil der Quelltext in beiden Fällen richtig aussieht. Ein Catch-all-Handler verwandelte Framework-Ausnahmen für fehlerhafte Anfragen an einem anonymen Endpunkt in Serverfehler, und das eine Modul, das den gemeinsamen Fehlervertrag nicht erben kann, erwies sich als das zum Internet gerichtete.
AbwägungDas laufende System zu testen ist langsam, braucht Container und kann nicht bei jedem Commit laufen. Also ist es eine Stufe mit eigenem Auslöser — und die daneben notierte Lehre lautet: ein Auslöser, den niemand zieht, ist dasselbe wie kein Test.
# Ask the running fleet what it is, rather than assuming it is the repo.
# The first time this was asked, the answer was five phases old: every
# check in the source tree was green and none of it was deployed.
built=$(curl -fsS "$svc/actuator/info" | jq -r '.build.time')
[ "$built" \> "$LAST_FIX" ] || fail "$svc image predates the fix ($built)"Im Repository
- fleet-build-check.sh
- /actuator/info build stamp
- fleet-e2e.sh negative tier
- ActuatorExposureIT
ArchitekturDreizehn Services mit demselben InnerenInfrastruktur hängt von der Anwendung ab, die Anwendung von einer reinen Domäne, nie umgekehrt. Ein Anwendungsfall ist ein Interactor und ein Kommando.Verteilte Systeme verdanken ihren Ruf dem Raum zwischen Services, aber der meiste Verwirrungsstoff steckt tatsächlich in ihnen. Dass alle dreizehn Inneren gleich aussehen, heißt: Wer einen Service lernt, kennt alle.
Die Domäne ist schlichtes Java ohne Framework darin; genau das macht den Bewertungsmotor als Arithmetik testbar statt als Spring-Kontext. Das Persistenzmodell ist bewusst vom Domänenmodell getrennt: Eine ORM-Annotation ist eine Speicherfrage und hat eine Fachregel nicht zu formen.
Transaktionsgrenzen leben nur in Interactors, Fehler sind RFC-7807-Problemdokumente, und jeder Service besitzt seine Migrationskette. Die Schichtung prüft ArchUnit statt eines Reviews; sie stimmt also auch an einem müden Freitag noch.
AbwägungMehr Dateien pro Funktion als in einem pragmatischen Dreischichtdienst. Bei dreizehn Services lohnt es sich; bei einem wäre es vermutlich zu viel.
Im Repository
- *Interactor per use case
- ArchUnit rule sets
- platform-kernel
- RFC 7807
ArchitekturAlles wird unsichtbar geborenNeue Apps, Probleme und Matches starten in Prüfung und erreichen die Öffentlichkeit nur über eine einzige Freigabewarteschlange.Eine Vertrauensplattform, auf der alles sofort erscheint, trägt kein Vertrauen in sich. Aber ein zweites „veröffentlicht“-Flag neben einem bestehenden Statuslebenszyklus schafft zwei Wahrheiten über Sichtbarkeit, und die driften auseinander.
Deshalb gibt es nur ein Tor: Der bestehende Statuslebenszyklus beantwortet „ist das öffentlich sichtbar?“. Geändert hat sich, wann Events feuern. Die öffentlichen Ströme veröffentlichen nicht mehr beim Anlegen (die Entität ist noch nicht öffentlich), sondern bei der Freigabe, und das Zurückziehen sendet den Lösch-Tombstone. Die Consumer haben keinen einzigen Filter ergänzt; sie erhalten jetzt schlicht nur noch Events für das, was die Öffentlichkeit sehen darf.
Diese Verschiebung legte eine ganze Fehlerklasse offen. Jeder Schreibpfad, der die Sichtbarkeit ändert, muss das Event senden, und ein Verwaltungsendpunkt tat es nicht, sodass ein dort abgelehnter Eintrag im Suchindex und im Korpus des Assistenten lebendig blieb. Die Korrektur sendet auf dem Sichtbarkeitsübergang selbst, statt darauf zu vertrauen, dass jeder Aufrufer daran denkt.
AbwägungDie Freigabe ist ein menschlicher Engpass, und dafür mussten Massenaktionen gebaut werden, mit ehrlicher Teilerfolgs-Meldung, denn ein Stapel, der „fertig“ meldet, wenn elf von zwölf geklappt haben, ist schlimmer als gar keine Meldung.
Im Repository
- AppStatus.isPubliclyVisible()
- PendingItem
- *-pending-review topics
- BulkProgressStream
ArchitekturEine Teststufe, die die Infrastruktur absichtlich kaputtmachtEchte Container, echter Broker und ein Netzwerk-Proxy, den der Test kappen kann: das geprüfte System auf der einen Seite, der eigene Client des Tests auf der anderen.Jede Garantie dieses Systems (Haltbarkeit der Outbox, Deduplizierung, Dead Letter) ist eine Aussage darüber, was während eines Ausfalls passiert. Unit-Tests prüfen den glücklichen Pfad jedes Einzelteils; ob die Kette hält, können sie nicht sagen.
Die Asymmetrie ist der ganze Entwurf. Die Anwendung erreicht Broker und Datenbank über den Proxy; der eigene Consumer des Tests verbindet sich direkt. So kann ein Test der Anwendung Kafka wegnehmen, zusehen, wie sich die Outbox mit ihren Wiederholungszählern füllt, den Broker zurückholen und prüfen, dass sie sich leert, und das alles, während der eigene Prüfweg lebendig bleibt.
Ein wenig offensichtliches Detail macht es überhaupt möglich. Ein Kafka-Client verbindet sich zunächst mit einer Adresse und bekommt dann die angekündigte Adresse des Brokers, zu der er direkt geht; nur die Startadresse zu proxen proxt also gar nichts. Deshalb startet der Broker so, dass er den Host-Port des Proxys ankündigt; Start- und Datenweg laufen damit beide durch ihn. Ohne das kappt das Kappen des Proxys nichts, und der Test besteht aus dem falschen Grund.
AbwägungDiese Tests verbringen absichtlich echte Sekunden in Ausfällen, deshalb ist die Stufe markiert und vom Standard-Build ausgeschlossen. Die schnelle Schleife bleibt schnell; Chaos läuft mit eigenem Profil.
// The system under test reaches Kafka through the proxy; the test's own
// consumer does not. Without that asymmetry, cutting the broker would
// blind the assertions as well as the application.
registry.add("spring.kafka.bootstrap-servers", ChaosIntegrationTest::proxiedBootstrapServers);
// …and the broker advertises Toxiproxy's host port, or only bootstrap
// would be proxied and cutBroker() would cut nothing.Im Repository
- ChaosIntegrationTest
- Toxiproxy 2.12
- OutboxOutageChaosIT
- mvn -Pchaos verify
Angewandte KIEin Assistent, der nur im eigenen Korpus der Plattform verankert istVektorsuche über Inhalte, die der Service aus dem Event-Log projiziert, hinter einem anbieterneutralen Port mit einem einzeiligen Modellwechsel.Ein Assistent, der auf einer Vertrauensplattform aus allgemeinem Weltwissen antwortet, ist schlimmer als gar keiner: Er klingt maßgeblich über Dinge, für die die Plattform nicht einstehen kann.
Der Korpus ist das Dreieck aus App, Problem und Match, aus denselben Events in den KI-Service projiziert, die auch alle anderen konsumieren. Das Retrieval ist eine Suche nach ungefähren nächsten Nachbarn über mehrsprachige Embeddings, mit einem Index, der so eingestellt ist, dass die Trefferabdeckung nicht still einbricht, wenn Filter die Kandidatenmenge verengen.
Die Anwendungsschicht weiß nie, welcher Anbieter antwortet. In dieser Grenze stecken zwei Lehren. Ein Modell zu bitten, „JSON zurückzugeben“, ist kein Vertrag: Es umschließt die Ausgabe mit einem Codeblock, und das Parsen scheitert. Die öffnende Klammer vorzugeben besteht den ersten Test und stirbt dann an einem nicht maskierten Anführungszeichen mitten in einer Zeichenkette, eine Fehlerklasse, die eine Vorgabe bauartbedingt nicht verhindern kann. Die dauerhafte Lösung ist eingeschränktes Dekodieren gegen ein Schema: Ungültige Ausgabe wird damit nicht bloß unerwünscht, sondern undarstellbar.
AbwägungStrikt im eigenen Inhalt zu verankern heißt, dass der Assistent lieber sagt, dass er es nicht weiß, als zu raten. Hier ist das richtig und trotzdem gelegentlich die schwächere Demo.
Im Repository
- pgvector vector(1024) · BGE-M3
- HNSW vector_cosine_ops
- LlmPort
- AnthropicChatClient
Angewandte KIDer Zähler ist das HauptbuchVier Ausgabengrenzen laufen vor jedem Modellaufruf, und die Nutzung wird aus der Aufzeichnung des tatsächlich Abgerechneten summiert, nie aus einem separaten Zähler.Ein zweiter Zähler neben dem Hauptbuch ist eine zweite Sache, die falsch sein kann, und beide werden auseinanderdriften. Die einzige Zahl, die über Ausgaben nicht lügen kann, ist die aus den Ausgabendatensätzen selbst.
Vier Schichten laufen der Reihe nach ganz oben in jedem ausgebenden Anwendungsfall: ein kurzes rollendes Fenster, eine Wochenobergrenze, ein globales Tageslimit und eine kumulative Sicherung. Lesezugriffe schlagen schließend fehl: die Brieftasche öffnet sich nicht, weil eine Datenbank kurz nicht erreichbar war, und ein Fenster öffnet sich nur mit einer Anfrage, die alle Schichten passiert hat; eine Ablehnung startet also nie jemandes Uhr.
Die Kosten werden an genau einer Stelle berechnet, im Adapter des Anbieters, und ein Modell ohne hinterlegten Preis verweigert den Start: Was sich nicht berechnen lässt, darf nie Verkehr bedienen. Außerdem wurde ein teurer automatischer Pfad nicht begrenzt, sondern entfernt: der sicherste Budgetwächter ist es, keinen unbeaufsichtigten Ausgeber zu haben.
AbwägungEin Hauptbuch bei jeder Anfrage zu summieren kostet eine Abfrage. Günstig neben dem Modellaufruf, den sie schützt, und die einzige wahre Fassung der Zahl.
Im Repository
- BudgetGuard
- chat_turn_log
- ai.usage_window
- insight_run_log
Angewandte KIAutomatisierung darf Inhalte erzeugen, niemals ein VertrauenssignalDer Seeding-Service schreibt Apps und Probleme über die normale API des zuständigen Services und gibt nie eine Stimme ab.Auf einer Plattform, deren gesamtes Erzeugnis eine Bewertung ist, ist eine erfundene Bewertung keine Abkürzung; sie ist eine Lüge über das Einzige, was das Produkt verkauft.
Gesammelte öffentliche Daten werden zu neutralem Plattforminhalt normalisiert und über die übliche Schreib-API des Eigentümers geschrieben, wo sie wie alles andere in Prüfung landen. Der Seeder bildet die Moderation nicht nach, er speist sie. Die Sicherheit bei Wiederholungen kommt aus einem Herkunftsregister nach Quelle und externer Kennung, nicht aus Event-Deduplizierung: er konsumiert keine Events und erzeugt keine.
Er hat außerdem überhaupt keine öffentliche Fläche: keine Gateway-Route, keine externe API, nur einen netzwerkisolierten Betriebsendpunkt. Eine Komponente, die im Namen aller anderen schreibt, sollte für so wenige Menschen wie möglich erreichbar sein.
AbwägungEin gesäter Katalog ohne Bewertungen wirkt leerer als einer mit erfundenen. Das ist der ehrliche Zustand einer Plattform vor ihren ersten Nutzern, und ihn zu zeigen ist genau der Punkt.
Im Repository
- seed-service (8093)
- provenance (source, external_id)
- no /api/v1 surface
ProduktDie Bewertung ist eine Formel, kein GefühlReine Arithmetik in der Domänenschicht: normalisieren, mit dem Alter abklingen lassen, zu einem Prior regularisieren, nach Achsengewicht mischen.Eine Note, der man vertrauen soll, muss erklärbar sein. Wenn niemand sagen kann, warum ein Match 7,4 erreicht, ist die Zahl Dekoration.
Drei Entscheidungen tragen sie. Ein bayesscher Prior zieht dünne Stichproben zur Mitte, sodass drei begeisterte Stimmen keine glatte Zehn ergeben. Das Abklingen gewichtet alte Meinungen schwächer (Software altert), hat aber einen Boden, denn eine alte Einschätzung ist keine wertlose Einschätzung. Und die Aufschlüsselung nach Achsen wird neben der Hauptzahl veröffentlicht, weil eine einzelne Zahl das „Warum“ nie beantwortet.
Der Motor nimmt Stimmen und einen Zeitstempel und liefert ein Ergebnis. Kein Repository, keine Uhr, kein Konfigurationszugriff; er wird also als Arithmetik getestet, und dieselben Eingaben liefern immer dieselbe Ausgabe. Die Bewertungen, die andere Services cachen, kommen als Events mit dem berechneten Wert an, sodass eine erneute Zustellung konvergiert statt sich aufzuaddieren.
AbwägungDie Regularisierung lässt neue Matches eine Weile unauffällig wirken. Das ist ehrlich: Eine Note aus drei Stimmen sollte nicht aussehen wie eine aus dreihundert.
q = (rating − 1) / 9 // 1–10 ballot → [0,1]
decay(d) = 0.75 + 0.25 · σ(0.005 · (730 − d)) // sigmoid recency, 75% floor
axisQ = (Σ decay·q + k·0.5) / (Σ decay + k) // Bayesian prior, k = 3
score = 10 · Σ(wᵢ · axisQᵢ) / Σ wᵢ // weighted blend → 0–10
confidence = LOW (<5) · MEDIUM (<30) · HIGH (≥30) ballotsIm Repository
- HandyScoreCalculator
- pure domain, no I/O
- HandyScoreCalculatorTest
Frontend
Die andere Hälfte des Systems
Dreizehn Backend-Services nützen nur, wenn davor etwas zusammenhängend bleibt. Die Oberfläche ist mit demselben Instinkt gebaut: Regeln, die einen Build scheitern lassen, statt Regeln, an die man sich erinnern soll.
Server zuerst, mit einer dünnen Routing-Schicht
Alles ist standardmäßig eine Server-Komponente; die Client-Direktive taucht nur dort auf, wo Interaktivität sie wirklich verlangt. Die Routing-Schicht bleibt bewusst dünn: sie liest Parameter und Metadaten und übergibt an eine View, die die Seite orchestriert. Diese Trennung wird erzwungen: Importiert die Routing-Schicht einen Service, scheitert der Build.
Eine vierstufige Datenschicht
Mit dreizehn Services dahinter muss eine Vertragsänderung genau eine Datei berühren. Jede Stufe darf nur mit der nächsttieferen sprechen, und eine zu überspringen bricht den Build statt ein Review.
- 1. Endpoints
Rohe URLs, einmal je Domäne deklariert.
- 2. API
Die einzige Stufe, die einen Netzwerk-Client anfassen darf.
- 3. Services
Anti-Corruption: Externe Formen werden zu internen Modellen, Fehler werden vereinheitlicht.
- 4. Consumers
Server-Actions und Query-Hooks. Sie sehen nie eine URL.
Fünf Architekturregeln, bei jedem Lauf geprüft
- 01
Kein Modul greift in das Innere eines anderen, nur über dessen öffentlichen Einstiegspunkt
- 02
Kein Konsument umgeht die Service-Schicht
- 03
Keine Komponente umgeht die API-Schicht, um direkt Daten zu holen
- 04
Keine zyklischen Abhängigkeiten zwischen Modulen
- 05
Die Routing-Schicht importiert kein Service- und kein Server-Modul
Vier Sprachen, null fest verdrahtete Texte
Vier Sprachen in voller Parität mit je über zwölfhundert Schlüsseln. Parität ist kein Versprechen: Zwei Prüfskripte vergleichen die Wörterbücher und suchen nach fest verdrahtetem Text, beide laufen im Validierungsbefehl. Einen unübersetzten Text hinzuzufügen lässt den Build scheitern.
Tests und Barrierefreiheit
Tests liegen neben dem, was sie prüfen, und laufen gegen simulierte Netzwerk-Handler statt gegen ein echtes Backend; sie sind also deterministisch, ohne fiktiv zu sein. Barrierefreiheit wird in derselben Suite geprüft statt hinterher auditiert, und die gesamte Suite läuft mit aktiviertem React-Compiler, also so, wie die Anwendung tatsächlich ausgeliefert wird.
Drei Dinge, die mich je einen Tag gekostet haben
Ein Formular, das unter dem Compiler lautlos stirbt
Ein Formular innerhalb eines Effekts zurückzusetzen funktioniert nicht mehr, sobald der Compiler aktiv ist: kein Fehler, das Formular wird einfach reglos. Standardwerte zu setzen und neu zu validieren ist das richtige Muster, und es steht jetzt dort, wo die nächste Person nachsehen wird.
Ein asynchroner Identitätsanbieter erzeugt eine dauerhaft falsche Antwort
Die Authentifizierung lädt asynchron. Eine nutzerbezogene Abfrage, die zu früh startet, kommt leer zurück und wird als Wahrheit gecacht; der Bildschirm ist nicht kaputt, er ist selbstbewusst falsch. Die Abfrage an den Ladezustand zu binden ist eine Zeile; den Fehler zu bemerken ist der schwierige Teil.
Erst messen, dann das Werkzeug beschuldigen
Der Speicher des Entwicklungsservers wuchs, bis er starb, und das sah genau nach einem Leck in unserem Code aus. Es war ein unbegrenzter Kompilierungs-Cache stromaufwärts, der nur die Entwicklung betraf. Das mit Belegen und mit dem Zwischenweg zu dokumentieren war mehr wert als eine Vermutung.
Qualität und Pipeline
Was bestehen muss, bevor irgendetwas zusammengeführt wird
Jede auf dieser Seite beschriebene Regel ist nur deshalb real, weil sich etwas weigert zu bauen, wenn sie verletzt wird. Das hier ist diese Mechanik.
Vier Teststufen
| Stufe | Was sie abdeckt |
|---|---|
| Offline-Wächter | Architekturregeln, Domänen- und Anwendungsfalltests, VertragsprüfungenDie Standard-Testphase |
| Integration | Echte Datenbank und echter Broker: Outbox-Umläufe, Persistenz, native Abfragen, Dead-Letter-WiedergabeDie Verify-Phase |
| Chaos | Eingespeister Ausfall: Broker weg, Datenbank weg, LatenzEigenes Profil, vom Standard-Build ausgeschlossen |
| Ende zu Ende | Die Flotte gestartet, per Health-Check abgesichert und dann über das Gateway geprüftAuf Anforderung und bei Release-Tags |
Continuous Integration
Eine dynamische, pfadgefilterte Matrix
Eine Änderung an einem Service baut diesen Service und seine Bibliotheken. Eine Änderung an einer gemeinsamen Bibliothek baut alles. Eine reine Dokumentationsänderung baut nichts. Fehlschläge brechen ihre Geschwister nicht ab, ein kaputter Service verdeckt also keinen anderen.
Release-Tags
Ein Versions-Tag baut zehn Container-Images per Buildpack (im Repository steht kein einziges handgeschriebenes Dockerfile) und lässt die Ende-zu-Ende-Stufe dagegen laufen.
Ein lokaler Befehl für das Ganze
Eine vierstufige Pipeline: Formatprüfung, dann die Tests des gesamten Reaktors, dann zehn Container-Images, dann die Flotte mit diesen frischen Images gestartet und per Health-Check abgesichert, bis jeder Service bereit meldet. Sie meldet nicht bloß Erfolg, sie lässt die Flotte laufend und aktuell zurück.
Das Frontend-Tor
Format, Lint, Architekturregeln, Konsistenzprüfung der Oberfläche, zwei Internationalisierungsprüfungen, Typprüfung und die Testsuite: ein Befehl, und zwar derselbe, den die Pipeline ausführt.
Die Dokumentation ist ein Test
Ein Vertragstest findet jeden Event-Record der gemeinsamen Bibliothek und lässt den Root-Build scheitern, wenn er im Event-Katalog fehlt. Daneben stehen Referenzdateien für die Serialisierung, Umlaufprüfungen und eine Waisenprüfung. Die asynchrone API kann sich damit nicht vom Dokument entfernen, das sie beschreibt, die einzige Fassung von „die Dokumentation bleibt aktuell“, die je funktioniert hat.
Observability und zwei Fehler, die sie fand
Traces, Metriken und Logs verlassen jeden Service über einen einzigen automatisch konfigurierten Starter, und die Korrelations-ID reist über HTTP- wie über Kafka-Header; eine Anfrage lässt sich also über einen Broker-Sprung hinweg verfolgen. Service-Ziele stehen neben Messaging- und Sicherheitsregeln, Alarme erreichen ein echtes Ziel, und eine getrennte Senke nimmt Fehler auf — an die Error-Logstufe gebunden, damit die Entscheidung, was als Fehler zählt, einmal im Exception-Handler fällt und nicht ein zweites Mal in einer Fremdbibliothek.
Sie hat sich zweimal bezahlt gemacht. Perzentil-Latenzen waren flottenweit unbrauchbar, weil die Histogramme ihre Buckets gar nicht ausgaben; die Korrektur ging an alle zehn Images, danach meldete jeder Service ein echtes p99 statt gar nichts, und ein begrenzter Lasttest bestätigte, dass das Ratenlimit am Edge Überlast ohne einen einzigen Serverfehler abweist. Später stellte sich heraus, dass die Alarmstrecke Ende zu Ende gesund war und an niemanden zustellte: ein Null-Empfänger nimmt jede Benachrichtigung an und verwirft sie, was von „nichts zu melden zu haben“ nicht zu unterscheiden ist. Er ist nicht als verboten dokumentiert, sondern aus der Datei gelöscht — eine Route, die auf ihn zeigt, lässt jetzt die Konfigurationsprüfung scheitern.
Auslieferung
Von einem Push zu einer Flotte, die beweist, welcher Build sie ist
Das Interessante an einem Deployment ist nicht, dass es gelingt. Interessant ist, ob ein erfolgreicher Lauf und ein Lauf, der nichts getan hat, unterscheidbar sind. Alles Folgende existiert, weil sie es mindestens einmal nicht waren.
Was ein Push auf den Entwicklungszweig auslöst
- 01
Eine Änderungskarte entscheidet, was neu gebaut wird
Eine einzige Datei besitzt die Antwort auf „welches der vierzehn Module hat dieser Commit berührt“, und sowohl der Integrations- als auch der Deployment-Workflow lesen sie. Ein Modul, das in dieser Karte fehlt, wird nicht abgelehnt — es wird stillschweigend übersprungen, und ein übersprungener Job meldet grün. Deshalb gilt die Karte als Regel und nicht als Bequemlichkeit.
- 02
Nur geänderte Module werden zu Images
Images entstehen mit Buildpacks; in keinem Backend-Modul gibt es eine handgeschriebene Container-Datei. Jedes wird mit dem Commit getaggt und nicht nur mit einem beweglichen Label, denn ein Registry-Tag, das wandert, ist eine Flotte, die altert, ohne dass sich im Repository etwas ändert.
- 03
Angewendet wird die Komposition, nicht die Differenz
Der Server zieht und ersetzt nur die geänderten Services und geht anschließend die Komposition durch, um Fehlendes zu ergänzen, ohne Laufendes anzufassen. Das erste Deployment zeigte, warum: Ein Delta kann nicht beantworten, „was auf dem Ziel fehlt“, sondern nur, „was sich seit dem letzten Mal geändert hat“.
- 04
Ein Health-Gate, keine Hoffnung
Jeder Service muss sich als bereit melden, bevor der Lauf weitergeht. Die Obergrenze ist am bindenden Fall bemessen — vierzehn Services und elf Schema-Migrationen, die auf vier Kernen kalt starten — denn zu lang zu warten meldet nur spät, zu kurz zu warten erklärt eine gesunde Flotte für kaputt.
- 05
Die Flotte wird gefragt, welcher Build sie ist
Jedes Modul veröffentlicht einen Build-Stempel, und das Deployment vergleicht ihn mit dem, was die Pipeline gerade erzeugt hat. Diesen Schritt gibt es wegen des schlimmsten Fundes des ganzen Projekts: Fünf Phasen lang war die laufende Flotte älter als der Code, und jede Prüfung auf der Quellseite war grün. Ein Deployment, das sich nicht datieren lässt, ist ein Deployment, das nicht verifiziert wurde.
- 06
Und ob irgendwo etwas feststeckt
Zuletzt wird die Outbox auf Zeilen geprüft, die nie veröffentlicht wurden. Eine kaputte Nachrichtenstrecke sieht genauso aus wie eine untätige — die Gesundheit bleibt grün, es erscheinen keine Fehler, die Warteschlange bewegt sich einfach nicht mehr — also ist das einzige ehrliche Signal die Zahl der unveröffentlichten Zeilen.
Die Maschine und der Weg hinein
Ein einziger kleiner Server betreibt die gesamte Flotte. Aus dem Internet erreicht ihn nichts: Ein rein ausgehender Tunnel verbindet sich mit dem Edge-Netz, das Gateway lauscht auf Loopback, und die Firewall bleibt außer für die Administration geschlossen. Der Deployment-Schlüssel folgt demselben Prinzip: nur lesend und auf ein Repository begrenzt statt eines kontoweiten Tokens — eine Zugangsberechtigung soll ihre Aufgabe erfüllen können und nichts darüber hinaus.
Zwei dieser Entscheidungen waren Messungen, keine Vorlieben. Die Subdomain musste ein Label verlieren, weil ein Wildcard-Zertifikat genau eines abdeckt und der ursprüngliche Name den Handshake überhaupt nicht abschließen konnte — ein Fehler, der wie eine veraltete Protokolleinstellung aussieht und keine ist. Und wie vielen Proxys zu trauen ist, wurde vor jeder Konfiguration an einem Wegwerf-Echo-Container abgelesen, denn diese Konstante entscheidet, ob die anonyme Ratenbegrenzung einen Aufrufer zählt oder das ganze Internet als einen.
Was die ersten echten Läufe zutage brachten
Eine Pipeline, die nie gelaufen ist, wurde nie verifiziert. Drei Funde aus den ersten Läufen, von denen keiner aus einem Review hätte kommen können:
Zwei Monate grüne Builds, die nie kompiliert haben
Der Maven-Wrapper war seit dem allerersten Commit ohne sein Ausführbar-Bit eingecheckt, also wären fünf Workflows schon beim ersten Kommando gestorben. Nichts zeigte es, weil die laufenden Jobs ihn nicht brauchten und die Jobs, die ihn brauchten, von der Änderungskarte übersprungen wurden. Der erste Job, der wirklich einen Compiler benötigte, war das erste echte Deployment.
Ein Sicherheitsbericht, der gelogen hat
Der Image-Scan meldete vierzehn Images mit kritischen Funden. Es gab keine: Dem Runner war die Festplatte ausgegangen, und der Scanner hatte kein einziges Image geöffnet. Sein Absturz und seine Funde teilten sich einen Exit-Code, also wurde ein Fehler, der mehr Plattenplatz brauchte, als vierzehn Schwachstellen gemeldet. Heute sind es getrennte Codes — und der abschließende Ratschlag gilt als Teil des Berichts.
Ein Prüfer, der seine eigene Blindheit als Urteil meldete
Die Build-Stempel-Prüfung erklärte dreizehn Module für nicht verifizierbar und riet, sie alle neu zu bauen. Die Stempel waren da; die Prüfung hatte ihre eigenen Zugangsdaten verloren und las einen Authentifizierungsfehler als fehlenden Stempel. Eine Kontrolle, die ihr Ziel nicht erreichen kann, muss das sagen — „das Geprüfte ist falsch“ und „ich konnte nicht laufen“ sind verschiedene Sätze.
Eine Regel, die im Repository richtig und im Internet abwesend war
Die Crawler-Richtlinie erklärt elf gesperrte Pfade, einer davon eine bewusste Datenschutzentscheidung darüber, Mitgliedsprofile namentlich zu indexieren. Die ins Internet ausgelieferte Datei enthält keinen davon und sagt das Gegenteil. Das Edge-Netz erzeugt für eine Funktion, die niemand aktiviert hat, seine eigene Fassung — die Quelle stimmt also, das Review stimmte, und die Regel gilt schlicht nicht. Einen Reverse Proxy zu wählen heißt, etwas zu wählen, das anstelle des Origin antworten darf — und was es übernommen hat, erfährt man nur, indem man das laufende System fragt, niemals durch Lesen des Codes.
Was als Nächstes kommt: zwei Umgebungen, gewählt durch einen Branch
Heute gibt es eine Umgebung, und sie ist die live betriebene. Die folgende Form wird aufgeschrieben, bevor sie gebaut ist, weil drei ihrer Randbedingungen kontraintuitiv genug sind, um sie sonst auf die teure Art zu lernen.
Der Branch ist das Einzige, was ein Mensch wählt
Der Entwicklungs-Branch liefert die dauerhafte Testumgebung aus, der Haupt-Branch die echte. Alles andere — Hostnamen, Identitäts-Instanz, Image-Tags, Secrets — leitet sich aus dieser einen Wahl ab. Die Deployment-Definitionen werden bewusst nicht in zwei Kopien geteilt: zwei Kopien driften auseinander, und bearbeitet wird die, die jemand an dem Tag zufällig geöffnet hat. Der Branch wählt also Werte, nicht Dateien.
Die eine Hälfte lässt sich nicht promoten, die andere schon
Üblich ist, ein Artefakt einmal zu bauen und durch die Umgebungen zu schieben. Das Browser-Bundle kompiliert seinen API-Host zur Bauzeit hinein, das für den Test-Host gebaute Image trägt diesen Host also in sich, und Produktion muss neu bauen statt neu zu taggen. Die Backend-Images haben keine Bauzeit-Umgebung und lassen sich sauber promoten. Dass die beiden Hälften eines Deployments wirklich unterschiedliche Regeln haben, ist es wert, aufgeschrieben zu werden, bevor es ein Produktions-Frontend hervorbringt, das mit einer Test-API spricht.
Zwei Umgebungen sind zwei Maschinen
Das ist eine Messung, keine Vorliebe. Die Flotte sitzt bereits an der Grenze ihrer Maschine, und der einzige Datenbank-Cluster ist durch seine Verbindungsobergrenze gebunden — deshalb ist jede Pool-Größe in der Komposition ausdrücklich gedeckelt. Zwei Projekte auf einer Maschine sind keine Trennung von Umgebungen; sie sind eine Umgebung mit zwei Namen und einem gemeinsamen Weg auszufallen.
Die Testumgebung bekommt eine Vordertür, und die ist kein Anwendungscode
Eine in der Anwendung geschriebene Tür läuft nur in einer einzigen Umgebung: Sie kann von der Umgebung, die sie schützt, nicht geübt werden, und sie geht als toter Code hinter einem Flag in Produktion. Ihr Platz ist der Rand, wo die Anfrage abgewiesen wird, bevor der Origin sie sieht. Und ihre ehrliche Grenze gehört genannt: Sie kann den API-Host nicht abdecken, weil der Browser dort direkt mit einem Bearer-Token und nicht mit einem Cookie anfragt — und ein in ein Browser-Bundle kompiliertes gemeinsames Geheimnis ist kein Geheimnis. Was wirklich zu schließen lohnt, ist deshalb keine URL, sondern die Kontoerstellung, und das ist eine Einstellung beim Identitätsanbieter statt eines Zweigs im Produkt.
Auslieferung in Zahlen
14
ausgelieferte Module
9
CI/CD-Workflows
32
Konfigurationstore pro Pull Request
49
dokumentierte Fallen
Nichts davon macht das System korrekt. Es macht seine Behauptungen überprüfbar — und das ist die einzige Eigenschaft, die es übersteht, sich zu irren.
In Zahlen
Gezählt, nicht geschätzt
Jede Zahl auf dieser Seite stammt aus dieser Tabelle, und jede Zeile sagt, wie sie gezählt wurde, damit man es nachprüfen kann. Die Zeilenzahlen enthalten Kommentare: in dieser Codebasis lebt die Begründung absichtlich neben dem Code.
| Kennzahl | Wert |
|---|---|
| Maven-Module | 25 |
| Services | 13 |
| Ausgelieferte Module | 14 |
| Backend-Quelldateien | 1.516 |
| Backend-Quellzeilen | 70.869 |
| Backend-Testdateien | 331 |
| Backend-Testfälle | 1.524 |
| Integrationstest-Klassen | 27 |
| Anwendungsfall-Interactors | 146 |
| REST-Controller | 55 |
| HTTP-Endpunkte | 191 |
| Kafka-Listener-Methoden | 73 |
| Registrierte Event-Topics | 26 |
| Datenbank-Migrationen | 106 |
| Funde der Sicherheitsprüfung | 54 |
| Konfigurationstore pro Pull Request | 32 |
| Alarmregeln | 18 |
| CI/CD-Workflows | 9 |
| Dokumentierte Fallen | 49 |
| Frontend-Quelldateien | 616 |
| Frontend-Quellzeilen | 55.022 |
| Frontend-Testfälle | 436 |
| Frontend-Domänenmodule | 12 |
| Übersetzungsschlüssel je Sprache | 1.298 |
| Commits über alle Repositories | 270 |
Am 2026-08-09 aus den Repositories gezählt. Entwicklungszeitraum: 2026-02-28 → 2026-08-09.
Status
Was fertig ist, was zurückgestellt und was offen
Das System ist ausgeliefert; das Produkt ist nicht gestartet. Das sind zwei verschiedene Sätze, und aufzuschreiben, welcher zutrifft — samt dem, was noch fehlt und warum — ist nützlicher als eine Seite, die nahelegt, alles sei fertig.
Fertig
- Die Migration vom modularen Monolithen zu einer Flotte aus dreizehn Services, im Code abgeschlossen und in Containern durchgängig lauffähig.
- Physisch eine Datenbank pro Service, mit jeder serviceübergreifenden Leseoperation aus einer lokalen Projektion.
- Transactional Outbox, idempotente Consumer, Dead Letter und Wiedergabe auf Anforderung in der gesamten Flotte.
- Eine einzige Auth-Grenze am Edge, mit Rollen aus dem Identitätsservice statt aus dem Token.
- Live geprüfte Observability — Traces, Metriken, Alarme mit einem echten Ziel und eine Fehlersenke, die Störungen aufnimmt und sonst nichts — und ein begrenzter Lasttest ohne einen einzigen Serverfehler.
- Eine Oberfläche in vier Sprachen über zwölf Domänenmodule, mit Architekturregeln, die in der Continuous Integration erzwungen werden.
- Eine Resilienz-Kampagne, die echte Ausfälle in die laufende Flotte injizierte. Sie fand sieben tatsächliche Defekte: darunter ein Edge, der bei nicht erreichbarem Identity-Service offen blieb, eine dokumentierte Rebuild-Prozedur, die das Read Model löschte, das sie wiederherstellen sollte, ein verlorener Broadcast-Audit-Eintrag und vier Services, die die Continuous Integration nie gebaut hatte. Alle sieben sind geschlossen — zusammen mit den drei Konsistenzfragen, die die Kampagne zunächst bewusst offen gelassen hatte.
- Eine vollständige Sicherheitsprüfung über elf Phasen, zuletzt ausgeführt, damit sie gegen die endgültige Angriffsfläche lief. Vierundfünfzig Funde, kein einziger falsch positiv, neun davon kritisch, und jeder geschlossen oder mit seiner Begründung festgehalten. Sie hinterließ Maschinerie statt eines Berichts: Konfigurationstore bei jedem Pull Request, eine gescannte Stückliste, eine negative Ende-zu-Ende-Stufe und eine Prüfung, die eine Flotte ablehnt, die älter ist als ihr eigener Code.
- Auslieferung von Anfang bis Ende und selbstprüfend: Ein Push baut nur das Geänderte, veröffentlicht es, wendet die Komposition auf dem Server an, wartet, bis sich jedes Modul bereit meldet, und fragt dann die laufende Flotte, welcher Build sie ist und ob eine Nachricht feststeckt. Der eingefrorene Monolith wurde abgeschaltet, nachdem die Flotte einen ganzen Tag live war — zuletzt und nur mit ausdrücklicher Freigabe, genau wie im Plan.
- Die Oberfläche läuft live auf derselben Maschine, in vier Sprachen und hinter demselben Tunnel — das Produkt ist also von Anfang bis Ende erreichbar und nicht nur die API. Die erste Sondierung des laufenden Systems stellte dreizehn Fragen, zehn kamen sauber zurück, darunter jene, die ein Repository nicht beantworten kann: Der Health-Endpunkt gibt einen Status preis und sonst nichts, zehn Management-Endpunkte weisen einen anonymen Aufrufer ab, eine nicht geroutete Anfrage liefert ein Problemdokument, das das Erfragte nicht zurückspiegelt, und sechzig parallele Anfragen werden zu vierzig Antworten und zwanzig Ablehnungen.
Zurückgestellt, mit Begründung
Zahlungen
Aus einem Grund außerhalb des Codes zurückgestellt: Der Zahlungsanbieter vermittelt nur für ein eingetragenes Unternehmen, und ein solches gibt es noch nicht; der Live-Einzug lässt sich also rechtlich nicht freischalten. Der Billing-Service wurde nicht gelöscht: Er wird mit abgeschalteter Bezahlfläche ausgeliefert, während der Kanal für kostenloses Premium weiterläuft. Der Auslöser ist ein Unternehmen, kein Commit.
Dokumentierte Nicht-Ziele
Nicht blockierende Retry-Topics, Change Data Capture für die Outbox und eine Kubernetes-Topologie mit Autoscaling. Jedes wurde mit niedergeschriebener Begründung zurückgestellt, damit eine künftige Entscheidung vom Argument aus startet und nicht bei null.
Offen
- Jede Schwelle im System — Service-Ziele, Ratenlimits, das tägliche Upload-Kontingent — wurde ohne Verkehr gewählt, an dem man sie hätte wählen können. Es sind bewusste Schätzungen mit danebengeschriebener Begründung, und sie bleiben Schätzungen, bis eine Stunde echter Last etwas anderes sagt.
- Die Content-Security-Policy läuft im Nur-Melden-Modus. Sie durchzusetzen ist keine Codeänderung, sondern eine Messung: eine Stunde echter Verkehr und ein leerer Verstoßbericht. Eine Policy einmal auf einer Annahme durchzusetzen bedeutete, dass sich niemand anmelden konnte.
- Zwei Dinge, die die Prüfung im Code nicht schließen konnte und auch nicht so tat. Die semantische Hälfte der Abwehr gegen Prompt Injection braucht einen Live-Lauf gegen ein absichtlich vergiftetes Korpus und einen kostenpflichtigen Modellaufruf. Die Widerstandsfähigkeit gegen eine abgestimmte Menge frischer Konten ist eine Produktentscheidung über das Kontoalter, kein Ratenlimit: kein Eimer füllt sich, wenn jedes Konto einmal abstimmt.
- Die Go-live-Checkliste selbst: ein visueller Abnahmedurchlauf über fünf Bildschirmbreiten, zwei Designs und vier Sprachen, ein Alarm, der im Kanal gesehen und nicht bloß von der Pipeline angenommen wurde, und der Anmeldefluss gegen die produktive Identitätsinstanz. All das braucht ein laufendes System und ein Paar Augen, und deshalb wird nichts davon hier behauptet.
Es ist ausgeliefert, messbar und überprüfbar — und immer noch ehrlich darüber, was nicht fertig ist. Das ist kein Widerspruch: Das Zweite ist, was das Erste erst sagenswert macht.