In diesem Artikel geht es darum, wie wir mit FastAPI und Domain-driven Design effizient große und komplexe Anwendungen meistern. Dass Microservices sich vor allem zum Organisieren von großen Entwicklerteams eignen und das eigentliche Ziel der Entkopplung damit nicht einfach erkauft werden kann, wurde von vielen Teams in den letzten Jahren schmerzhaft festgestellt. Vielmehr stieg oft die Komplexität bei gleichzeitigem Produktivitätsverlust. Bei ActiDoo favorisieren wir daher schon seit langem in vielen Fällen modulare Monolithen für typische Enterpise-Anwendungen. Gleichzeitig setzen wir aber auf modernes Tooling wie schnelle reaktive Frameworks (FastAPI, Quarkus) und Container (Docker, Kubernetes), da sie die Entwicklung beschleunigen und bei richtiger Anwendung Sicherheitsvorteile bieten können. Ein einfacher und typischer Entwicklungsstack ist bei uns also im Backend z.B. FastAPI, Postgres, Docker. Wir wollen nach Monaten oder Jahren Entwicklung nicht beim Big-Ball-of-Mud landen, bei dem sich niemand aus Angst vor Regressionsfehlern mehr so richtig traut, Code zu refactoren oder wegzuwerfen. Damit das nicht passiert, sollte man zu Beginn einige Regeln festlegen und überlegen, wie man diese nachhaltig forcieren kann.
Unser Ziel
Wir wollen eine Projektstruktur schaffen, die nachhaltig Entkoppelung fördert und forciert.
Aufteilung des Projekts in Domänen
In unserem fiktiven Beispiel soll es um einen B2B Online-Shop gehen. In einem Workshop haben wir die Informationsflüsse analysiert und technisch diskutiert. Anschließend sind wir zu folgender Aufteilung der Domänen (in DDD-Sprache "Bounded Contexts") gekommen:
1. Authentifizierung & Autorisierung
2. Katalog
3. Warenkorb & Checkout
Die Projektstruktur sieht entsprechend aus, wie auf dem Bild zu sehen. Im domains-Ordner gibt es für jede Domäne entsprechend einen eigenen Ordner.
Was ist eine Domäne?
Eine Domäne ist ein fachlich abgetrennter Bereich, in unserem Programm ein eigenes Modul, das standardmäßig erstmal keine Kommunikation mit anderen Domänen hat. Gewünschte Schnittstellen müssen explizit definiert werden.
Strukturierung der App: Mehrere Apps oder mehrere Router?
In FastAPI gibt es prinzipiell zwei Möglichkeiten, große Apps zu strukturieren: Entweder durch mehrere Sub-Apps, die in eine große App gemounted werden - oder durch eine Aufteilung in mehrere Router.
Die Docs sind unter einer gemeinsamen URL erreichbar
(/docs, ...)
Eigene Middleware pro Domäne geht nicht
Sofern wir keine Trennung der API-Dokumentation wollen oder es anderweitige Gründe gibt (z.B. Middleware oder komplett unterschiedliche Authentifizierungsverfahren), starten wir in der Regel mit Variante 2. Bei Bedarf kann man diese Entscheidung auch später noch ändern.
Welcher Code kommt in die Domänen-Module? Welcher ist global?
Möglichst wenig global!
Wir versuchen in der Regel möglichst viel Code in die Domänen zu verschieben. Es gibt nur wenig gemeinsamen Code, den wir auf globaler Ebene definieren. Warum machen wir das so? Die Möglichkeit selbstsicher Änderungen vornehmen zu können, ist uns wichtiger, als die Reduzierung der Zeilen an Quellcode. Beim DRY-Prinzip (Don't repeat yourself), geht es nicht darum, dass man gleichen Code nicht zweimal schreibt. Vielmehr geht es darum, dass man Code mit der gleichen Aufgabe nicht zweimal schreibt. In unserem B2B-Shop darf es also durchaus zweimal die Funktion "get_display_username" geben, wenn sie einmal im Cart-Kontext und einmal im Catalog-Kontext definiert ist. Wenn wir zu einem späteren Zeitpunkt die Anzeige des Benutzernamens im Cart-Kontext ändern wollen, so dass z.B. zusätzlich die E-Mail enthalten ist, wissen wir recht schnell, welche Stellen das alles betrifft. Das Prinzip gilt äquivalent für die Datenhaltung. Nur bei globalen Initialisierungsaufgaben wie z.B. dem Aufsetzen eines Datenbank Connection Pools machen wir Ausnahmen.
In unserem Beispiel legen wir folgende Module für jede Domäne an:
myshop.domains.*.model
Sämtliche Datenbankmodelle der Domäne
myshop.domains.*.service
Die Geschäftslogik der Domäne
myshop.domains.*.domain_api
Schnittstellen zu anderen Domänen
myshop.domains.*.web_api
Web Schnittstellen
Auch in der Datenhaltung ist möglichst alles getrennt
Produkte im Katalog haben einen anderen Zweck als Produkte im Warenkorb
Das Datenmodell ist hier vereinfacht und sähe in einer echten Anwendung sicherlich etwas komplexer aus. Der Punkt ist aber: Durch die starke Trennung bis hinein in das Datenmodell können wir selbstsicher Änderungen durchführen. Der Katalog wird zu langsam und Teile sollen in eine verteilte NoSQL Datenbank verlagert werden?
> Kein Problem! Die anderen Domänen sind nicht betroffen. Die Transaktionssicherheit im Warenkorb ist trotzdem noch gesichert. Wir wollen schrittweise in die Cloud migrieren?
> Fangen wir doch mit dem Warenkorb an!
Wie kommen die Produkte aus dem Katalog in den Warenkorb?
Die Domänen dürfen über wohldefinierte Schnittstellen miteinander kommunizieren.
Einschränkung der Imports zwischen den Domänen
Wie verhindern wir, dass ein Entwickler doch einfach Objekte aus anderen Domänen importiert? Natürlich mit einem Linter! Wir verwenden dazu das Projekt import-linter von David Seddon . Durch eine Definition von Contracts kann man definieren, welche Module von wo importieren dürfen.
In unserem Beispiel sieht das wie folgt aus:
Versuchen wir nun z.B. direkt in dem Modul myshop.domains.cart.service ein Objekt aus dem myshop.domains.auth.model Modul zu importieren, bekommen wir einen Fehler:
Am besten ist es, wenn man diesen Aufruf direkt in der CI-Strecke oder im Git Post-Commit Hook berücksichtigt. Erlaubte Kommunikation muss explizit über die in den Regeln definierten export/import Module passieren. Je nach Anzahl der Domänen ergibt es Sinn, diese weiter zu unterteilen.
Zwei Beispiele für explizit erlaubte Kommunikation:
Die Cart-Domäne könnte eine Funktion add_to_cart exportieren, die vom Katalog aus aufgerufen wird.
Die Auth-Domäne könnte eine Funktion bereitstellen, mit der man Zugriffsrechte prüfen kann.
Man kann in den Contracts sogar noch feinere Einstellungen treffen - z.B. eine Schichten-Architektur . Damit kann man definieren, dass Schichten nur von Schichten unter ihnen abhängen dürfen.
Zusammenfassung
Wir haben eine Projektstruktur gezeigt, mit der man in einem FastAPI Projekt Domänen voneinander trennen kann. Der Ansatz ist relativ einfach auch auf andere Python-basierte Applikationsserver übertragbar. Die Domänen sind in Modulen definiert, die nur über festgelegte Wege miteinander kommunizieren dürfen. Jede Domäne hat mindestens einen eigenen Router oder eine Sub-App.
Unterteilung der Anwendung nach fachlichen Domänen
In FastAPI pro Domäne eine Sub-App oder einen eigenen Router mit Prefix
Möglichst starke Entkoppelung der Domänen durch Trennung der Datenmodelle und Logik
Einschränkung der Imports zwischen Domänen-Module durch Linter
Gezieltes Erlauben von Ausnahmen für gewollte Schnittstellen
Marcel Sander
Über den Autor
Marcel ist Geschäftsführer und Mitgründer der ActiDoo GmbH. Er hat einen technischen Hintergrund als Full-Stack Entwickler und einen Master Of Science in Informatik von der Universität Paderborn.