Domain-driven Design mit Python und FastAPI

14.03.2023

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.
FastAPI DDD Projektstruktur

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.

Variante 1: Mehrere Apps

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from fastapi import FastAPI
from fastapi.router import APIRouter

app = FastAPI()

app_auth = FastAPI()
app_cart = FastAPI()
app_catalog = FastAPI()

app.mount("/auth", app_auth)
app.mount("/cart", app_cart)
app.mount("/catalog", app_catalog)
Folgen:
  • Die Docs sind für jede Domäne unter einer anderen URL erreichbar (/auth/docs, ...)
  • Es ist möglich für jede App eigene Middlewares zu definieren

Variante 2: Mehrere Router

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from fastapi import FastAPI
from fastapi.router import APIRouter

app = FastAPI()

router_auth = APIRouter(prefix="/auth")
router_cart = APIRouter(prefix="/cart")
router_catalog = APIRouter(prefix="/catalog")

app.include_router(router_auth)
app.include_router(router_cart)
app.include_router(router_catalog)
Folgen:
  • 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
FastAPI DDD Projektstruktur

Auch in der Datenhaltung ist möglichst alles getrennt

Produkte im Katalog haben einen anderen Zweck als Produkte im Warenkorb

Datenmodell für Domäne: Katalog

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# myshop.domains.catalog.model

from sqlalchemy import Column
import sqlalchemy.types as ty

from myshop.database import Base


class CatalogUser(Base):
    __tablename__ = "catalog_users"
    id = Column(ty.UUID, primary_key=True)
    email = Column(ty.Text)
    firstname = Column(ty.Text)
    lastname = Column(ty.Text)
    country = Column(ty.Text)


class CatalogCategory(Base):
    __tablename__ = "catalog_category"
    id = Column(ty.UUID, primary_key=True)
    name =  Column(ty.Text)


class CatalogProduct(Base):
    __tablename__ = "catalog_products"
    id = Column(ty.UUID, primary_key=True)
    name =  Column(ty.Text)
    price = Column(ty.Float)
    description = Column(ty.Text)

Datenmodell für Domäne: Warenkorb & Checkout

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# myshop.domains.cart.model

from sqlalchemy import Column
import sqlalchemy.types as ty

from myshop.database import Base


class CartUser(Base):
    __tablename__ = "cart_users"
    id = Column(ty.UUID, primary_key=True)
    email = Column(ty.Text)
    firstname = Column(ty.Text)
    lastname = Column(ty.Text)
    street = Column(ty.Text)
    zip = Column(ty.Text)
    city = Column(ty.Text)
    country = Column(ty.Text)


class CartProduct(Base):
    __tablename__ = "cart_products"
    id = Column(ty.UUID, primary_key=True)
    name =  Column(ty.Text)
    price = Column(ty.Float)
    amount = Column(ty.Integer)
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:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[tool.importlinter]
root_packages = [
    "myshop"
]

[[tool.importlinter.contracts]]
name = "Domain contract"
type = "independence"
modules = [
    "myshop.domains.auth",
    "myshop.domains.cart",
    "myshop.domains.catalog",
]
ignore_imports = [
    "myshop.domains.catalog.domain_api.imports -> myshop.domains.auth.domain_api.exports",
    "myshop.domains.catalog.domain_api.imports -> myshop.domains.cart.domain_api.exports",
    "myshop.domains.cart.domain_api.imports -> myshop.domains.auth.domain_api.exports"
]
Wir definieren hier:
Das Prüfen des Vertrags passiert mit einem Aufruf von lint-imports. Wenn alles in Ordnung ist, sieht das wie folgt aus:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ lint-imports 
=============
Import Linter
=============

---------
Contracts
---------

Analyzed 23 files, 16 dependencies.
-----------------------------------

Domain contract KEPT

Contracts: 1 kept, 0 broken.
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:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ lint-imports 
=============
Import Linter
=============

---------
Contracts
---------

Analyzed 23 files, 17 dependencies.
-----------------------------------

Domain contract BROKEN

Contracts: 0 kept, 1 broken.


----------------
Broken contracts
----------------

Domain contract
---------------

myshop.domains.cart is not allowed to import myshop.domains.auth:

- myshop.domains.cart.service -> myshop.domains.auth.model (l.3)
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:
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.
Marcel Sander

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.

Kostenlos, online & schnell
Zur Terminauswahl

Wir verwenden Cookies

Wir verwenden Cookies, um Ihnen die bestmögliche Erfahrung auf unserer Website zu bieten. Analysetools helfen uns, die beliebtesten Inhalte zu ermitteln und zu verbessern. Außerdem möchten wir herausfinden, wie gut unsere Werbeanzeigen funktionieren. Details finden Sie im Bereich Datenschutz. Bitte wählen Sie aus, welche Cookies Sie akzeptieren möchten: