Warum wir NATS überall einsetzen — und nicht bereuen
Wenn man zum ersten Mal in den Quellcode von onisin OS schaut, fällt
etwas auf: Es gibt keine HTTP-Clients zwischen den Services. Kein
fetch("http://oosai:8080/event"), keine base URLs in
Konfigurationsdateien, keinen Reverse Proxy der Requests verteilt.
Stattdessen sieht man das überall:
// oosgql/src/command-handler.ts
for await (const msg of nats.subscribe("oos.cmd.gql.query")) {
const result = await graphql({ schema: host.current(), source: query });
nats.publish(msg.reply, sc.encode(JSON.stringify(result)));
}// oosai/src/event-listener.ts
for await (const msg of nats.subscribe("oos.events.police")) {
void processEventNotification(sql, embed, mapping, sc.decode(msg.data));
}Das ist kein Zufall und kein Experiment. Das ist eine bewusste, durchgezogene Entscheidung — und dieser Artikel erklärt warum.
Die eigentliche Frage hinter “Warum NATS?”
Die häufige Antwort auf diese Frage lautet: “NATS ist schnell.” Das stimmt. Millionen Nachrichten pro Sekunde, Latenz im Mikrosekundenbereich, Server-Binary unter 20 MB. Aber Geschwindigkeit war für uns nicht der Hauptgrund.
Der Hauptgrund war: Wir wollten Services schreiben, nicht Infrastruktur verwalten.
REST zwingt dich, sehr früh sehr viele Entscheidungen zu treffen. Wo läuft Service B? Auf welchem Port? Mit welchem TLS-Zertifikat? Wie verteilst du Last auf drei Instanzen? Was passiert wenn Service B kurz nicht erreichbar ist — retry, circuit breaker, timeout?
Das sind keine schlechten Fragen. Aber sie sind — wenn du ein kleines, fokussiertes System baust — die falschen Fragen zu Beginn.
Standort-Unabhängigkeit, ohne es zu merken
In onisin OS weiß oos (das Desktop-Frontend) nicht, wo
oosgql läuft. Es kennt nicht seine IP-Adresse, nicht seinen
Port, nicht mal ob er gerade lokal oder in einem Container betrieben
wird. Es kennt nur einen Subject-Namen:
oos.cmd.gql.query
Das ist alles. NATS übernimmt das Routing vollständig.
In der klassischen Welt braucht Service A eine Konfiguration für
Service B. In der NATS-Welt braucht Service A nur den Namen des Themas.
Der Unterschied klingt klein, ist es aber nicht: Wenn
oosgql auf eine andere Maschine umzieht, eine neue Instanz
dazukommt oder der Prozess kurz neustartet — kein einziger Client muss
angepasst werden.
Das ist Location Transparency. Nicht als Konzept aus einem Buch, sondern als gelebte Realität im täglichen Betrieb.
Request-Reply fühlt sich synchron an — ist es aber nicht
Einer der häufigsten Einwände gegen Message-Systeme lautet: “Aber ich brauche manchmal eine Antwort. Ich kann nicht einfach Nachrichten abfeuern und hoffen.”
NATS kennt das Muster. Es heißt Request-Reply, und es ist in die Library eingebaut:
// Client-Seite: Anfrage stellen
const response = await nats.request("oos.cmd.gql.query", payload, { timeout: 5000 });
const result = JSON.parse(sc.decode(response.data));// Server-Seite: Anfrage beantworten
for await (const msg of nats.subscribe("oos.cmd.gql.query")) {
const result = await runQuery(msg.data);
nats.publish(msg.reply, sc.encode(JSON.stringify(result)));
}Das fühlt sich im Code wie ein normaler Funktionsaufruf an. Was im Hintergrund passiert ist trotzdem grundlegend anders als bei HTTP: NATS hält keine dauerhafte TCP-Verbindung zwischen Client und Server offen. Es vermittelt nur das Paket. Wenn der Server kurz überlastet ist, staut sich nichts auf beiden Seiten auf — NATS managed die Warteschlange.
Das macht das System wesentlich unanfälliger für kaskadierende Ausfälle. Service A wird nicht träge, weil Service B gerade langsam ist.
Pub/Sub für den Rest
Nicht jede Kommunikation braucht eine Antwort. oosai zum
Beispiel muss wissen, wenn ein neues Event in der Datenbank landet —
aber es muss dafür nichts zurückschicken. Reine Benachrichtigung.
// oosai/src/event-listener.ts — keine Antwort nötig
for await (const msg of nats.subscribe("oos.events.police")) {
void processEventNotification(sql, embed, mapping, sc.decode(msg.data));
}Mit HTTP müsste oosai aktiv pollen oder oos
müsste eine Liste von Webhook-URLs verwalten. Mit NATS ist es ein
publish() auf der einen Seite und ein
subscribe() auf der anderen. Wer zuhört, ist dem Sender
völlig egal.
Das gilt auch für oosgql, das beim Speichern einer
Domain automatisch seinen GraphQL-Schema-Cache invalidiert:
oos.domain.changed → oosgql reagiert, baut Schema neu
Keine direkte Abhängigkeit, kein HTTP-Aufruf, kein Contract zwischen den Services außer dem Subject-Namen.
Load Balancing ohne einen einzigen Load Balancer
Wenn wir in Zukunft mehrere Instanzen von oosgql
betreiben wollen — etwa weil das System unter Last steht — reicht es,
eine zweite Instanz zu starten. NATS verteilt die Requests automatisch
via Queue Groups. Kein NGINX, kein HAProxy, keine Ingress-Konfiguration
in Kubernetes.
oosgql-1 ─┐
oosgql-2 ─┤── subscriben beide "oos.cmd.gql.*"
oosgql-3 ─┘ → NATS verteilt Round-Robin
Das ist kein theoretischer Vorteil. Das ist eine Eigenschaft, die wir kostenlos bekommen, weil wir uns von Anfang an auf NATS als Transport festgelegt haben.
Die Subject-Hierarchie als lebende Dokumentation
Wer verstehen will, was ein Service tut, liest seine NATS-Subjects. In onisin OS ist die Konvention konsequent:
oos.cmd.gql.query — GraphQL-Abfrage ausführen
oos.cmd.gql.mutation — Mutation ausführen (permission-gated)
oos.cmd.gql.view — View-DSL-Quelle laden
oos.cmd.gql.domain — Domain-DSL-Quelle laden
oos.cmd.event.refresh — Event-Mappings neu laden
oos.events.<mapping> — Event-Notification (Pub/Sub)
oos.domain.changed — Domain wurde gespeichert
Das ist nicht nur Architektur. Das ist Dokumentation. Wer im Code
nach oos.cmd.gql sucht, sieht sofort welche Services darauf
reagieren und welche es aufrufen.
Ist das noch ein Microservice?
Eine Frage die wir uns selbst gestellt haben: Wenn alle Services den gleichen NATS-Server benutzen, ist das nicht wieder eine enge Kopplung — nur auf einer anderen Ebene?
Die ehrliche Antwort: Ja, es gibt eine Kopplung. Der NATS-Server ist ein Single Point of Failure. Aber er ist auch der einzige. Kein Service kennt einen anderen Service direkt. Jeder kennt nur Subjects.
Der Unterschied zu einem Monolithen: Jeder Service lässt sich
unabhängig deployen, neustarten und skalieren. Kein Service blockiert
einen anderen beim Hochfahren. Wenn oosai abstürzt, können
oos und oosd weiterarbeiten — sie bekommen nur
keine Event-Verarbeitung mehr.
Ob man das “Microservice” nennt oder nicht, ist uns ehrlich gesagt egal. Es funktioniert.
Was NATS nicht kann
Der Vollständigkeit halber: Es gibt Fälle wo wir NATS bewusst nicht einsetzen.
Persistenz. NATS hat JetStream für Durability — aber
wir nutzen es noch nicht. Wenn oosai gerade offline ist und
ein Event reinkommt, geht die NATS-Nachricht verloren. Deshalb gibt es
in oosai einen Startup-Backfill, der unverarbeitete Events
aus der Datenbank nachholt. NATS at-most-once ist für uns derzeit
ausreichend; JetStream liegt als nächste Ausbaustufe parat.
Externe Clients. Das Browser-Frontend
apps/oos (Electrobun) redet nicht direkt mit NATS. Es geht
über einen lokalen Bun-HTTP-Gateway, der intern NATS-Requests feuert.
Der Grund ist schlicht: WebSockets zu einem NATS-Server aus dem
Browser-Kontext sind unnötige Komplexität wenn ein einfacher HTTP-Tunnel
genauso gut funktioniert.
File-Transfers. Große Binärdaten gehören nicht in NATS-Messages. Das wäre Cargo-Kult.
Fazit
NATS hat onisin OS nicht zu einem besseren Projekt gemacht, weil es schnell ist. Es hat es besser gemacht, weil es uns erlaubt hat, über Services nachzudenken statt über Infrastruktur.
Kein Service weiß wo der andere wohnt. Kein Load Balancer braucht
Konfiguration. Kein Webhook-Vertrag muss gepflegt werden. Und wenn wir
morgen oosgql in drei Instanzen aufteilen wollen, sind es
drei Zeilen in einem Prozess-Manager — keine Architekturdebatte.
Das ist der eigentliche Grund.