Die Anwendung, die ihre Mandanten selbst deployt
Inhaltsverzeichnis
Wenn in dem System, an dem ich arbeite, ein neuer Kunde angelegt wird, passiert Folgendes: Die Anwendung validiert die Konfiguration, legt eine Datenbank an, rendert einen Satz Helm-Values und ruft auf dem Produktionscluster helm upgrade --install auf. Kein Mensch öffnet dafür ein Terminal. Onboarding ist ein Use Case im Backend, mit Ports, Tests und Fehlerpfaden. Dieser Artikel beschreibt die Architektur dahinter: den Stack, das Isolationsmodell und das Kubernetes-Deployment im Detail.
Key Takeaways (TL;DR)
- Isolation als Deployment-Frage behandeln: Entscheide die Mandantentrennung auf Infrastruktur-Ebene, bevor du sie in jede Query deiner Anwendung einbaust.
- Provisionierung als Anwendungscode schreiben: Behandle Kunden-Onboarding wie jeden anderen Use Case, mit Interfaces, Fehlerpfaden und Health-Checks statt als Runbook für Menschen.
- Ein Chart, viele Value-Sätze: Parametrisiere ein einziges Helm-Chart pro Kunde, statt pro Kunde eigene Manifeste zu pflegen.
- Architektur-Grenzen maschinell erzwingen: Lass ein Analyse-Tool die Schichtenregeln im CI prüfen, bevor ein Reviewer sie im Diff suchen muss.
- Produktions-Topologie nicht lokal nachbauen: Trenne Production-Fidelity und Entwickler-Velocity bewusst, statt beides in einem Setup zu erzwingen.
Zwei Anwendungen, ein Vertrag: die Gesamtarchitektur
Das System ist ein B2B-SaaS-Produkt mit einem harten Anspruch an Datentrennung. Es besteht aus zwei getrennten Symfony-Anwendungen mit getrennten Repositories und getrennten Lebenszyklen.
Die erste ist die Supervisor-Anwendung, eine Control-Plane. Sie verwaltet Kunden, Subscriptions, Versionen und Deployments. Ihre Nutzer sind nicht die Endkunden, sondern das Team, das die Plattform betreibt. Die zweite ist die Tenant-Anwendung, das eigentliche Produkt. Sie läuft nicht einmal, sondern N-mal: pro Kunde eine vollständige Instanz mit eigenem Backend, eigenem Frontend-Build, eigener Datenbank und einem eigenen Mercure-Hub als SSE-Transport für Echtzeit-Updates im Browser.
Der Stack ist auf beiden Seiten bewusst gleich gehalten: PHP 8 mit Symfony im Backend, Doctrine als ORM, Symfony Messenger für asynchrone Verarbeitung, MySQL als Datenbank. Das Frontend ist eine Angular-Anwendung mit Standalone Components, Signals als State Management und Lazy Loading pro Feature-Route. Statische Analyse läuft auf PHPStan Level 8, und zwar als Gate, nicht als Empfehlung.
Beide Anwendungen sprechen über einen internen Control-Channel miteinander: tokenauthentifizierte HTTP-Aufrufe für synchrone Fragen, Symfony-Messenger-Messages für alles, was asynchron laufen darf. Die Control-Plane weiß, welche Instanzen existieren und welche Version sie fahren. Die Instanzen melden Zustand und Ereignisse zurück. Mehr müssen die beiden Seiten voneinander nicht wissen.
Innerhalb der Backends ist jedes fachliche Modul ein eigenes Symfony-Bundle mit hexagonalem Schnitt. Vereinfacht sieht ein solches Bundle so aus (illustratives Layout, nicht der echte Projektbaum):
src_bundles/Billing/
├── Domain/ Models, ValueObjects, Enums, Repository-Interfaces
├── Application/
│ ├── UseCase/ Orchestrierung, ein Use Case pro Fachvorgang
│ ├── Port/ Interfaces zur Außenwelt
│ ├── DTO/ ein- und ausgehende Datenverträge
│ └── MessageHandler/ asynchrone Verarbeitung
├── Adapter/
│ ├── Primary/ HTTP-Controller, CLI-Commands
│ └── Secondary/ Doctrine-Entities, HTTP-Clients, Prozess-Aufrufe
└── Resources/config/ Services und Routen des Bundles
Die Abhängigkeitsregel ist klassisch: Alles zeigt nach innen. Die Domain kennt weder Symfony noch Doctrine. Die Application-Schicht kennt nur die Domain und ihre eigenen Ports. Nur Adapter dürfen Framework und Außenwelt berühren, und Doctrine-Entities sind ein Adapter-Detail, nicht das Domain-Model. Jedes Bundle soll sich in der Bundle-Registrierung deaktivieren lassen, ohne dass der Rest des Systems bricht. An dieser Frage messen wir Architekturentscheidungen.
Schichtgrenzen, die das CI kennt, nicht nur das Wiki
Solche Regeln stehen in vielen Teams in einem Architektur-Dokument, das niemand gegen den Code prüft. Wir erzwingen sie maschinell mit Deptrac, einem statischen Analyse-Tool, das Schichten über Verzeichnis- und Package-Collectors definiert und jede nicht erlaubte Abhängigkeit als Build-Fehler meldet. So könnte eine minimale Konfiguration für das Bundle oben aussehen (vereinfachte Illustration):
# deptrac.yaml, Ausschnitt
deptrac:
paths: [./src_bundles]
layers:
- name: Domain
collectors:
- { type: directory, value: src_bundles/Billing/Domain }
- name: Application
collectors:
- { type: directory, value: src_bundles/Billing/Application }
- name: Adapter
collectors:
- { type: directory, value: src_bundles/Billing/Adapter }
ruleset:
Domain: [] # Domain darf nichts kennen
Application: [Domain]
Adapter: [Application, Domain]
Der Effekt ist unspektakulär und genau deshalb wertvoll: Ein Use Case, der eine Doctrine-Entity importiert, kommt nicht durch die Pipeline. Kein Review-Kommentar, keine Diskussion, kein Verfall über Monate. Architektur-Governance statt Architektur-Dokumentation. In Kombination mit PHPStan Level 8 heißt das: Die beiden anstrengendsten Reviewer im Projekt sind Maschinen, und sie werden nie müde.
Ein Namespace pro Mandant ist eine Architekturentscheidung, keine Infrastruktur-Laune
Jetzt zum Kern. Jeder Kunde der Plattform bekommt:
- einen eigenen Kubernetes-Namespace, dessen Name deterministisch aus der Tenant-Identität abgeleitet wird,
- einen eigenen Helm-Release desselben Charts, dessen Release-Name dem Namespace entspricht,
- eine eigene MySQL-Datenbank auf einem verwalteten Datenbank-Server bei einem europäischen Cloud-Provider,
- einen eigenen Mercure-Hub mit eigenen JWT-Secrets für den Realtime-Layer,
- eigene Ingress-Hostnamen nach dem Muster {tenant}.saas-example.io für die Anwendung und einem Präfix-Schema für den Realtime-Hub, TLS inklusive,
- ein eigenes Ressourcenbudget und eine eigene, pro Kunde gepinnte Image-Version.
Der letzte Punkt ist der unterschätzte: Weil die Version ein Feld am Kunden-Datensatz ist und als Image-Tag in die Helm-Values fließt, können zwei Kunden unterschiedliche Releases fahren. Ein Update lässt sich erst bei einem Pilotkunden ausrollen und danach für den Rest, ohne Feature-Flags im Code und ohne Big-Bang-Rollout. Und ein Kunde, der kündigt, ist ein Helm-Uninstall, ein Namespace-Delete und ein letzter Datenbank-Dump. Kein Datenlösch-Skript, das hoffentlich alle Fremdschlüssel erwischt.
Die Workloads pro Tenant laufen über einen nodeSelector auf einem dedizierten Node-Pool, getrennt von der Control-Plane. Damit bleibt der Blast-Radius in beide Richtungen begrenzt: Ein Tenant, der Amok läuft, wird von seinen Limits eingefangen, und ein Deployment der Control-Plane kann per Konstruktion keine Kundeninstanz anfassen.
Das stärkste Gegenargument: N Deployments sind teurer als eine Spalte tenant_id
Das Gegenargument gehört auf den Tisch, denn es ist gut. Eine geteilte Datenbank mit Tenant-Spalte oder Schema-per-Tenant bedeutet: ein Deployment, ein Migrationslauf, geteilte Ressourcen, praktisch null Grenzkosten pro neuem Kunde. Namespace-per-Tenant bedeutet dagegen: N Releases, N Migrationsläufe, N-mal Ressourcen-Overhead für Pods, die nachts idlen, und ein Betriebsaufwand, der linear mit der Kundenzahl wächst. Wer Tausende kleine Self-Service-Tenants erwartet, sollte dieses Modell nicht bauen. Dort gewinnt die geteilte Topologie, und zwar deutlich.
Die Einordnung hängt an zwei Fragen. Erstens: Ist Daten-Isolation eine harte, vertraglich zugesicherte Anforderung? Im B2B-Geschäft mit Kunden, deren Einkauf eine eigene Datenhaltung sehen will, ist “eure Daten liegen in einer eigenen Datenbank, eure Instanz läuft in einem eigenen Namespace” ein Satz, den man ohne Sternchen sagen kann. Eine WHERE-Klausel ist kein Isolationsversprechen, sie ist eine Konvention, die jeder neue Entwickler brechen kann. Zweitens: Skaliert der Betrieb im Kopf oder im Code? Wenn jeder neue Kunde ein manueller Ops-Vorgang ist, frisst das Modell das Team auf. Wenn Provisionierung Anwendungscode ist, sinken die Grenzkosten eines Kunden auf die Cluster-Ressourcen, die er tatsächlich verbraucht. Genau deshalb ist der nächste Abschnitt der wichtigste dieses Artikels.
Provisionierung ist ein Use Case, kein Runbook
Die Control-Plane-Anwendung besitzt selbst kubectl- und helm-Tooling und eine Kubeconfig mit den nötigen Rechten. Deployments von Kundeninstanzen sind bei uns keine CI-Jobs und keine manuellen Operator-Schritte, sondern Anwendungsfunktionen: Es gibt ein eigenes Deployment-Bundle mit Use Cases wie “Client deployen”, “Client aktivieren”, “Client deaktivieren”, und dahinter Ports für alles, was die Außenwelt berührt: ein Cluster-Client-Interface, ein Datenbank-Provisioner, ein Backup-Port, ein Health-Checker.
Der Ablauf eines Deployments, leicht vereinfacht:
- Start über Queue: Der Vorgang startet asynchron. Der HTTP-Request aus der Admin-Oberfläche legt nur eine Message auf den Messenger-Transport und kehrt sofort zurück. Ein Worker-Prozess arbeitet das Deployment ab, die Oberfläche verfolgt den Fortschritt live über Server-Sent Events.
- Validierung & Auflösung: Konfiguration und Kundendatensatz werden validiert, die Tenant-Identität (Namespace, Datenbankname, Hostnamen) wird deterministisch aufgelöst.
- Idempotente Datenbank-Erstellung: Die Datenbank wird idempotent angelegt: existiert sie schon, passiert nichts. Existiert sie und steht ein Versionswechsel an, zieht die Anwendung vor dem Rollout automatisch ein mysqldump-Backup. Erst wenn das Backup steht, geht es weiter.
- Helm Upgrade-or-Install: Die Anwendung baut den Value-Satz für diesen Tenant und ruft helm upgrade —install mit —create-namespace auf. Install und Update sind derselbe Pfad, das hält den Code idempotent und die Sonderfälle klein.
- Health-Checking: Ein Health-Checker pollt danach beide Tenant-Deployments (Webapp und Worker), bis sie ready sind, standardmäßig bis zu 60 Sekunden in 5-Sekunden-Schritten. Er unterscheidet dabei “noch pending” von “fatal”: Zustände wie ImagePullBackOff oder ErrImagePull brechen sofort mit den letzten 50 Log-Zeilen als Diagnose ab, statt den Timeout auszusitzen. Zusätzlich wird der Stand der Datenbank-Migrationen in der Instanz geprüft.
- Aktivierung: Erst wenn alles grün ist, markiert ein Finalizer den Kunden als aktiv und meldet das Ergebnis an die Oberfläche.
Als Code-Skizze, damit die Struktur greifbar wird (eigene Illustration, kein Produktionscode):
// PHP 8.2 / Symfony: Kern des Deployment-Use-Cases (vereinfacht)
final class TenantProvisioningService
{
public function __construct(
private ClusterClientInterface $cluster,
private TenantDatabaseProvisionerInterface $databases,
private HelmValuesFactory $values,
) {}
public function provision(Tenant $tenant): void
{
$identity = TenantIdentity::fromTenant($tenant);
$this->databases->createIfMissing($identity->databaseName());
$result = $this->cluster->upgradeOrInstall(
release: $identity->namespace(),
chart: '/opt/charts/tenant-app',
values: $this->values->forTenant($tenant),
createNamespace: !$this->cluster->namespaceExists($identity->namespace()),
);
if (!$result->successful) {
throw ProvisioningFailed::fromHelmOutput($result->errorOutput);
}
}
}
Der Cluster-Client dahinter ist bewusst unglamourös: Er startet helm und kubectl als Prozesse mit expliziter Kubeconfig, statt eine Kubernetes-API-Bibliothek einzubinden. Das klingt nach der schlechteren Lösung, hat sich aber als robuste erwiesen: Die Aufrufe sind identisch mit dem, was ein Mensch beim Debugging tippen würde, jede Fehlermeldung ist eine Helm-Fehlermeldung, und das Tooling altert mit den offiziellen Binaries statt mit einem Client-SDK. Wichtig ist nur, dass diese Prozess-Aufrufe hinter einem Port verschwinden. Der Use Case kennt upgradeOrInstall, nicht Symfony\Component\Process.
Das Onboarding-Runbook dieses Systems hat übrigens null Schritte. Es gibt keins, es gibt einen getesteten Use Case, und der vergisst nichts.
Das Deployment-Modell: ein Chart, N Value-Sätze
Technisch basiert das Ganze auf einem einzigen Helm-Chart für die Tenant-Anwendung. Alles Kundenspezifische ist ein Value, das die Control-Plane beim Deployment setzt. So könnte ein gerenderter Value-Satz für einen Tenant aussehen (vereinfachte Illustration):
# Von der Control-Plane pro Mandant gesetzte Values (illustrativ)
namespace: tenant-a12
databaseUrl: "mysql://app:changeme-in-vault@db.internal:3306/tenant_a12"
image:
repository: acme/tenant-app
tag: "2.14.1" # Version dieses Kunden, bewusst kein "latest"
pullPolicy: Always
resources:
webapp:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: "1", memory: 2Gi }
worker:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 1500m, memory: 4Gi }
ingress:
host: tenant-a12.saas-example.io
tlsSecretName: platform-tls
realtime:
enabled: true
publicHost: rt-tenant-a12.saas-example.io
jwtSecret: changeme-in-vault
nodeSelector:
workload: tenant
podAnnotations:
config-checksum: "3f9a…" # Hash über DB, Env und Version
Drei Details daraus verdienen einen zweiten Blick.
- Getrennte Ressourcenprofile pro Pod-Typ: Jede Instanz besteht aus zwei Deployments aus demselben Image: der Webapp, die HTTP-Requests bedient, und einem Worker, der die Messenger-Queues konsumiert. Beide starten mit denselben Requests (100m CPU, 256 MiB Speicher), aber die Limits gehen auseinander: 2 GiB für die Webapp, 4 GiB und mehr CPU für den Worker. Der Grund ist das Lastprofil: Ein HTTP-Request, der 2 GiB braucht, ist ein Bug. Ein asynchroner Export- oder Abrechnungsjob, der kurzzeitig 3 GiB braucht, ist Dienstag. Wer beiden Pod-Typen dasselbe Limit gibt, dimensioniert entweder die Webapp absurd groß oder killt nachts seine Worker per OOM.
- Die Config-Checksum-Annotation: Kubernetes startet Pods neu, wenn sich das Image ändert, aber nicht, wenn sich nur ein Umgebungswert in den Values ändert. Deshalb schreibt die Control-Plane einen Hash über die deploymentrelevanten Werte (Datenbank, Environment, Version) als Pod-Annotation in die Values. Ändert sich die Konfiguration, ändert sich die Annotation, und der Rollout passiert trotz identischem Image. Ein bekanntes Helm-Muster, das man aber selbst hinschreiben muss.
- Ein Ingress-Host pro Kunde plus ein zweiter für den Realtime-Hub: Der Mercure-Hub jedes Tenants bekommt einen eigenen öffentlichen Hostnamen mit Präfix-Schema und eigenem TLS. SSE-Verbindungen sind langlebig, und ein eigener Host pro Hub hält sie aus dem Request-Pfad der Webapp heraus. Terminiert wird alles über einen nginx-Ingress-Controller mit zentral verwaltetem Zertifikat.
Zwei Deployment-Pfade, zwei Geschwindigkeiten
Interessant ist auch, was dieses System nicht tut: Die Control-Plane deployt sich nicht selbst über Kubernetes. Ihr eigener Release-Pfad ist eine klassische CI-Pipeline, die das Angular-Frontend baut und dann mit einem PHP-Deployer-Tool per SSH auf dev, staging und live ausrollt: Release-Verzeichnis, Composer-Install, Doctrine-Migrationen, Symlink-Switch, Neustart der Messenger-Consumer über den Prozess-Supervisor. Die letzten drei Releases bleiben als Rollback-Option auf dem Server liegen. Der Live-Rollout hängt hinter einem manuellen Trigger in der Pipeline.
Die Tenant-Seite läuft komplett anders: Ein Build-Skript baut das Tenant-Image und pusht es mit Versions-Tag in die Registry. Ausgerollt wird es von dort aus gar nicht durch die CI, sondern durch die Control-Plane, Kunde für Kunde, über den beschriebenen Helm-Pfad. Die CI produziert Artefakte, die Anwendung entscheidet über deren Verteilung.
Man kann das als Stilbruch lesen: Die Control-Plane wird deployt wie 2015, die Mandanten wie es das Cloud-native-Lehrbuch will. Es ist aber eine bewusste Entkopplung. Der SSH-Pfad der Control-Plane hat keinerlei Berührungspunkte mit den Kundeninstanzen, ein kaputtes Control-Plane-Deployment lässt jede Tenant-Instanz ungestört weiterlaufen. Und umgekehrt braucht ein Tenant-Rollout keine Pipeline-Rechte auf dem Cluster, weil die Rechte dort liegen, wo die Entscheidung fällt: in der Anwendung, die weiß, welcher Kunde welche Version bekommen soll. Ein einheitlicher Deployment-Weg für beide Seiten wäre eleganter auf dem Architekturdiagramm und schlechter im Betrieb.
Lokal läuft kein Kubernetes, und das ist Absicht
Die erste Version der lokalen Entwicklungsumgebung hat die Produktions-Topologie treu nachgebaut: ein lokales Kubernetes über Docker Desktop beziehungsweise Minikube, echte Helm-Deployments pro Test-Mandant, ein NodePort-Schema, das Ports aus der Mandanten-ID ableitete. Es hat funktioniert. Es war auch die teuerste Art, eine Entwicklungsumgebung zu betreiben, die wir je hatten: gigabyteweise Cluster-Overhead auf jedem Entwickler-Rechner, Image-Rebuilds und Re-Deployments für jede Backend-Änderung, Kubeconfig- und Zertifikatspflege als Dauerrauschen.
Heute besteht die lokale Umgebung aus zwei Docker-Compose-Stacks, einer pro Anwendung, verbunden über ein gemeinsames Docker-Netzwerk. Der Control-Channel zwischen beiden läuft lokal genauso wie in Produktion, nur eben zwischen zwei Containern statt zwischen Cluster-Namespaces. Der Kubernetes-Codepfad ist lokal hart abgeschaltet: Eine Guard-Exception verhindert, dass ein lokaler Supervisor überhaupt versucht, gegen ein Cluster zu deployen. Wer das Deployment-Verhalten selbst testen will, tut das gegen die Dev-Stage mit echtem Cluster.
Die verallgemeinerbare Lehre: Production-Fidelity und Entwickler-Velocity sind zwei verschiedene Ziele, und es ist legitim, sie verschiedenen Umgebungen zuzuweisen. Lokal muss die fachliche Logik schnell iterierbar sein, inklusive des Zusammenspiels beider Anwendungen. Die Deployment-Maschinerie dagegen ändert sich selten und wird dort getestet, wo sie echt ist. Ein lokales Kubernetes, das die Produktions-Topologie imitiert, testet vor allem eines gründlich: die Geduld des Teams.
10 Schritte zur Umsetzung
Wenn du ein ähnliches Modell aufbauen willst, folge dieser Checkliste in genau dieser Reihenfolge:
- Kläre die Isolationsanforderung zuerst: Namespace-per-Tenant lohnt sich, wenn Daten-Isolation vertraglich oder regulatorisch hart ist und deine Tenant-Zahl in Dutzenden bis wenigen Hundert liegt. Sonst nimm die geteilte Topologie.
- Baue ein einziges Helm-Chart für die Tenant-Anwendung: Alles Kundenspezifische (Namespace, Datenbank-URL, Hosts, Ressourcen, Secrets, Image-Tag) wird ein Value. Kein Kunde bekommt eigene Manifeste.
- Leite die Tenant-Identität deterministisch ab: Release-Name gleich Namespace, Datenbankname und Hostnamen aus derselben Quelle. Jede Stelle, die raten muss, wird irgendwann falsch raten.
- Implementiere die Provisionierung als Use Case mit Ports: Cluster-Client, Datenbank-Provisioner, Backup und Health-Check als Interfaces, die Prozess-Aufrufe als austauschbare Adapter dahinter. Halte alle Schritte idempotent, damit ein Retry nie Schaden anrichtet.
- Führe Deployments asynchron aus: Message-Queue statt HTTP-Request, Fortschritt über SSE oder Events an die Oberfläche. Ein Deployment, das einen Request-Timeout fürchten muss, ist falsch aufgehängt.
- Prüfe den Rollout aktiv nach: Health-Check mit klarer Unterscheidung zwischen pending und fatal, Migrationsstatus inklusive. Ein erfolgreicher Helm-Exit-Code ist kein funktionierender Tenant.
- Automatisiere das Backup vor Versionswechseln: Wenn die Anwendung ausrollt, muss sie auch sichern. Ein Backup, das ein Mensch vorher machen sollte, existiert im Ernstfall nicht.
- Trenne die Ressourcenprofile pro Pod-Typ: Webapp und Worker haben verschiedene Lastprofile und verdienen verschiedene Limits.
- Erzwinge deine Schichtgrenzen im CI: Deptrac für die Abhängigkeitsrichtung, PHPStan auf hohem Level als Gate. Das Deployment-Bundle wird sonst als erstes zum Durchlauferhitzer für Abkürzungen.
- Entscheide die lokale Topologie separat: Compose-Stacks für die fachliche Iteration, ein echtes Dev-Cluster für die Deployment-Maschinerie. Beides in einem Setup zu wollen kostet mehr, als es prüft.
Der fleißigste Kubernetes-Operator in diesem Cluster ist eine PHP-Anwendung.
Häufig gestellte Fragen (FAQ)
Wann lohnt sich ein eigener Kubernetes-Namespace pro Tenant? ↓
Sobald harte Daten-Isolation eine vertragliche oder regulatorische Anforderung ist und die Zahl der Tenants im Bereich von Dutzenden bis wenigen Hundert liegt. Jeder Kunde bekommt eigene Workloads, eine eigene Datenbank und ein eigenes Ressourcenbudget. Bei Tausenden von Self-Service-Tenants kippt die Rechnung, weil Betriebsaufwand und Cluster-Objekte linear mitwachsen.
Was unterscheidet Namespace-per-Tenant von Schema-per-Tenant? ↓
Schema-per-Tenant isoliert nur Daten innerhalb einer geteilten Anwendung: ein Deployment, eine Codeversion, geteilte Ressourcen. Namespace-per-Tenant isoliert zusätzlich Laufzeit und Lebenszyklus: eigene Pods, eigene Ressourcen-Limits, eigene Version pro Kunde und ein Offboarding, das aus einem Helm-Uninstall und einem Datenbank-Dump besteht.
Wie löst eine Anwendung selbst Kubernetes-Deployments aus? ↓
Die Control-Plane-Anwendung besitzt kubectl- und helm-Tooling sowie eine Kubeconfig mit passenden Rechten. Ein Use Case im Backend baut pro Tenant einen Satz Helm-Values und startet helm upgrade --install als Prozess. Der Aufruf läuft asynchron über eine Message-Queue, Fortschritt und Fehler werden per Server-Sent Events an die Admin-Oberfläche gemeldet.
Senior Full-Stack Developer mit Fokus auf stabiler Software-Architektur, pragmatischem Engineering und der Realität von KI im Entwickler-Alltag. Seine Wurzeln liegen im praktischen Lösen komplexer Probleme unter realen Bedingungen.
github.com/hyretic-dev