Blogs / FastAPI-Projektstruktur: Große Apps organisieren

FastAPI-Projektstruktur: Große Apps organisieren

Veröffentlicht
26. August 2026
Autor
Faizan Nadeem
Tags
FastAPI Python Backend Development Software Architecture
Eine Nahaufnahme eines Computerbildschirms mit farbigen Zeilen von Programmiercode
Foto von Glen Carrie auf Unsplash

Jedes FastAPI-Projekt beginnt auf die gleiche Weise: eine main.py-Datei mit einer Handvoll Routen, ein oder zwei Pydantic-Modelle und ein Datenbankaufruf direkt im Handler. Es funktioniert. Es ist schnell geschrieben, schnell gelesen, und in den ersten Wochen fühlt es sich wie der Beweis an, dass man nicht zu viel über Struktur nachdenken muss. Dann wächst das Projekt — mehr Routen, mehr Modelle, ein Hintergrundjob, eine Auth-Abhängigkeit, die über sechs Endpunkte geteilt wird — und diese eine Datei wird zu einem 2.000-Zeilen-Scrollmonster, in dem man nur noch etwas findet, indem man nach einem Funktionsnamen greppt und hofft.

Der Fehler ist nicht, anfangs ein flaches main.py zu schreiben. Der Fehler ist, keinen Plan für den Moment zu haben, in dem es nicht mehr funktioniert. Die meisten Teams warten entweder zu lange und machen dann eine panische Big-Bang-Neuschreibung, oder sie überkorrigieren schon am ersten Tag mit einer Ordnerstruktur, die aus einer Java-Enterprise-App kopiert wurde und einem Projekt mit drei Endpunkten fünf Ebenen von Indirektion verpasst. Keines dieser Extreme übersteht Wachstum wirklich. Was funktioniert, ist eine Struktur, die einfach beginnen und gezielt weiterentwickelt werden kann — eine, die Verantwortlichkeiten trennt, bevor man dazu gezwungen ist, und die Circular-Import-Fehler vermeidet, die genau dann auftauchen, wenn die Codebasis zu groß ist, um sie noch leicht zu entwirren.

Sie lernen:

  • Wann eine flache main.py-Struktur tatsächlich auseinanderfällt und auf welche konkreten Anzeichen Sie achten sollten
  • Den echten Unterschied zwischen einem geschichteten (technischen) Layout und einem Domain-driven (featurebasierten) Layout — und welches zu welchem Team passt
  • Wie man Router mit APIRouter zusammensetzt, damit die Routenregistrierung nicht selbst zur Wartungslast wird
  • Warum FastAPI-Projekte gerade zu Circular Imports neigen und welche Import-Richtungsregeln sie verhindern
  • Eine vollständige, meinungsstarke Ordnerstruktur, die Sie direkt in ein neues Projekt übernehmen können
  • Wie Sie eine bestehende flache App ohne vollständige Neuschreibung in eine skalierbare Struktur migrieren

Inhaltsverzeichnis

  1. Die Grundlagen
  2. Geschichtete vs. Domain-driven Layouts
  3. Router-Komposition mit APIRouter
  4. Warum Circular Imports in FastAPI-Apps passieren
  5. Eine Struktur, die skaliert
  6. Gemeinsam genutzter Code: Core, Schemas und Dependencies
  7. Eine bestehende flache App migrieren
  8. Häufige Fallstricke
  9. Best Practices für Produktion
  10. Wie es von hier aus weitergeht

Die Grundlagen

Die Anzeichen, dass eine flache Struktur an ihre Grenzen stößt

Eine FastAPI-App in nur einer Datei ist im kleinen Maßstab nicht falsch — sie hat genau die richtige Menge an Struktur für die Größe des Problems. Das Signal für eine Änderung ist nicht die Zeilenanzahl, sondern Reibung: Sie öffnen main.py, um einen Endpunkt hinzuzufügen, und müssen an fünfzehn unzusammenhängenden Endpunkten vorbeiscrollen, um die richtige Stelle zu finden; zwei Personen bearbeiten dieselbe Datei und erzeugen bei jedem PR Merge-Konflikte; ein Pydantic-Modell, das von drei Routen verwendet wird, wird kopiert und eingefügt statt importiert, weil niemand schnell erkennen kann, wo die „echte“ Version liegt.

Nichts davon sind im abstrakten Sinn Architekturfehler — es sind Navigationsfehler. Die Lösung ist kein Framework, sondern zusammengehörigen Code nebeneinander zu platzieren und Imports eine Richtung zu geben, die nicht zu sich selbst zurückführt.

Warum gerade FastAPI einen Plan braucht

FastAPI erzwingt keine Projektstruktur wie Django mit apps, und diese Flexibilität ist ein zweischneidiges Schwert. Sie bedeutet, dass Sie eine Struktur bauen können, die zu Ihrem Team passt, aber sie bedeutet auch, dass es kein Geländer gibt, das einen routers-Ordner davon abhält, aus services zu importieren, das wiederum noch einmal aus routers importiert, um einen Helper wiederzuverwenden — ein Fehler, zu dem Djangos App-Grenzen schon am ersten Tag weniger leicht verleiten. Eine große FastAPI-Anwendung ist letztlich einfach eine große Python-Anwendung mit angeschraubter Web-Schicht, also erbt sie jedes Python-Packaging-Problem plus FastAPIs eigenes Dependency-Injection-System, das ebenfalls strukturelle Vorstellungen darüber hat, wo Depends-Callables leben sollten.

Geschichtete vs. Domain-driven Layouts

Es gibt zwei vorherrschende Wege, eine wachsende FastAPI-Codebasis zu organisieren, und der richtige hängt weniger von der Projektgröße ab als davon, wie Ihr Team im Alltag tatsächlich arbeitet.

Geschichtete (technische) Struktur

Eine geschichtete Struktur gruppiert Dateien danach, was sie sind — alle Router zusammen, alle Schemas zusammen, alle Datenbankmodelle zusammen:

app/
├── routers/
│   ├── users.py
│   ├── orders.py
│   └── payments.py
├── schemas/
│   ├── users.py
│   ├── orders.py
│   └── payments.py
├── models/
│   ├── users.py
│   ├── orders.py
│   └── payments.py
├── services/
│   ├── users.py
│   ├── orders.py
│   └── payments.py
└── main.py

Das ist die Struktur, die die meisten FastAPI-Tutorials lehren, und sie funktioniert gut für kleine bis mittelgroße APIs, besonders wenn ein oder zwei Engineers den Großteil der Codebasis anfassen. Der Nachteil zeigt sich, wenn die App wächst: Das Hinzufügen eines einzelnen Features — sagen wir einer refunds-Fähigkeit — bedeutet, vier oder fünf verschiedene Top-Level-Ordner für ein einziges Stück Funktionalität zu berühren, und es ist leicht, ein Schema ohne den passenden Router hinzuzufügen oder einen Router zu bauen, der stillschweigend die Service-Schicht überspringt und direkt mit der Datenbank spricht.

Domain-driven (featurebasierte) Struktur

Eine Domain-driven Struktur gruppiert Dateien danach, was sie für das Geschäft tun — alles, was mit Bestellungen zu tun hat, lebt zusammen, unabhängig davon, ob es ein Router, Schema oder Service ist:

app/
├── users/
│   ├── router.py
│   ├── schemas.py
│   ├── models.py
│   ├── service.py
│   └── dependencies.py
├── orders/
│   ├── router.py
│   ├── schemas.py
│   ├── models.py
│   ├── service.py
│   └── dependencies.py
├── payments/
│   ├── router.py
│   ├── schemas.py
│   ├── models.py
│   ├── service.py
│   └── dependencies.py
└── main.py

Das skaliert besser für größere Teams und größere Domänen, weil jeder Ordner nahe an einer in sich geschlossenen Einheit ist — ein neuer Engineer, der an orders arbeitet, muss payments selten überhaupt öffnen, und der Ordner ist eine natürliche Grenze für Code-Ownership. Der Nachteil ist mehr anfängliche Förmlichkeit für wirklich kleine Apps und etwas Duplizierung, wenn zwei Domänen sehr ähnliche Schemas benötigen.

Faustregel: Beginnen Sie geschichtet, wenn Sie grob unter zehn Routen und ein oder zwei Mitwirkende haben. Sobald Sie darüber hinausgehen oder sobald sich ein einzelnes Feature regelmäßig über vier unzusammenhängende Ordner erstreckt, wechseln Sie zu Domain-driven. Öffentliche Engineering-Texte von Netflix und die meisten FastAPI-Referenzen für große Maßstäbe laufen genau aus diesem Grund auf Domain-driven hinaus — es ist das Layout, das Merge-Konflikte und gegenseitiges In-die-Quere-Kommen zwischen Teams klein hält, wenn die Teamgröße wächst, auch wenn es am ersten Tag etwas mehr Struktur kostet.

Router-Komposition mit APIRouter

Egal, für welches Layout Sie sich entscheiden, der Mechanismus zum Zusammensetzen der Routen ist derselbe: APIRouter. Jedes Feature-Modul definiert seinen eigenen Router, statt Routen direkt auf der globalen FastAPI()-App zu registrieren:

# app/orders/router.py
from fastapi import APIRouter, Depends
from app.orders.schemas import OrderOut, OrderCreate
from app.orders.service import create_order, get_order
from app.core.dependencies import get_current_user

router = APIRouter(prefix="/orders", tags=["orders"])

@router.post("/", response_model=OrderOut)
async def create_order_endpoint(
    payload: OrderCreate,
    user=Depends(get_current_user),
):
    return await create_order(payload, user)

@router.get("/{order_id}", response_model=OrderOut)
async def read_order(order_id: int, user=Depends(get_current_user)):
    return await get_order(order_id, user)

Dann setzt eine einzelne Stelle — meist app/api.py oder app/main.py — alle Feature-Router in der App zusammen:

# app/api.py
from fastapi import APIRouter
from app.users.router import router as users_router
from app.orders.router import router as orders_router
from app.payments.router import router as payments_router

api_router = APIRouter()
api_router.include_router(users_router)
api_router.include_router(orders_router)
api_router.include_router(payments_router)
# app/main.py
from fastapi import FastAPI
from app.api import api_router

app = FastAPI(title="My API")
app.include_router(api_router, prefix="/api/v1")

Das gibt Ihnen drei Dinge, die eine flache App nicht kostenlos mitbringt: einen einzigen Ort, an dem Sie jede registrierte Routengruppe sehen, einen natürlichen Platz zur Versionierung der API (/api/v1, /api/v2 als separate APIRouter-Bäume) und Router-spezifische tags und prefix, damit die OpenAPI-Dokumentation ohne Boilerplate pro Route organisiert bleibt. Es ist auch die strukturelle Grundlage, auf der der Rest dieses Clusters aufbaut — die Dependencies, die Sie hier an einen Router hängen, sind genau das, worauf wir in FastAPI Dependency Injection: Patterns and Anti-Patterns eingehen.

Warum Circular Imports in FastAPI-Apps passieren

Circular Imports sind der mit Abstand häufigste strukturelle Fehler in wachsenden FastAPI-Apps, und sie folgen einem vorhersehbaren Muster: Ein Router importiert einen Service, der Service importiert ein Schema, und — weil jemand einen Validierungs-Helper wiederverwenden wollte — importiert das Schema etwas aus dem Router-Modul. Python wirft ImportError: cannot import name 'X' from partially initialized module, und die Lösung sieht unverbunden mit der eigentlichen Ursache aus.

Die wahre Ursache ist fast immer eine fehlende Richtungsregel. In einer gut strukturierten FastAPI-App sollten Imports nur in eine Richtung fließen:

router → service → models/schemas → core

Ein Router darf einen Service importieren. Ein Service darf Modelle und Schemas importieren. Nichts in models, schemas oder core sollte jemals aus einem router oder service importieren — diese liegen oben im Abhängigkeitsgraphen, nicht unten. Wenn diese Regel gebrochen wird, liegt das meist daran, dass ein Schema einen Typ braucht, der in der Nähe eines Routers definiert ist, oder zwei Domänen einen Helper teilen müssen und jemand direkt zwischen den Domänen importiert, statt das gemeinsam genutzte Ding nach core hochzustufen.

Drei konkrete Korrekturen decken fast jeden realen Fall ab:

  1. Verschieben Sie wirklich gemeinsam genutzten Code nach core oder in ein eigenes shared-Paket. Wenn orders und payments beide einen Money-Typ brauchen, gehört er nicht in eine der beiden Domänen — er gehört in app/core/types.py, und beide Domänen importieren ihn nach unten.
  2. Verwenden Sie TYPE_CHECKING für typbezogene Querverweise. Wenn ein Schema einen Type Hint aus einem anderen Modul nur für Annotationen braucht, schützen Sie den Import, damit er zur Laufzeit nicht ausgeführt wird:
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from app.orders.models import Order

def summarize(order: "Order") -> str:
    ...
  1. Durchbrechen Sie den Zyklus mit einem lokalen Import innerhalb der Funktion als letztes Mittel — das funktioniert, ist aber ein Zeichen dafür, dass die Modulgrenze falsch ist, nicht ein Muster, zu dem man standardmäßig greifen sollte.

Die Entscheidung zwischen geschichtet und Domain-driven spielt auch hier eine Rolle: Ein Domain-driven Layout macht domänenübergreifende Imports visuell offensichtlich (from app.payments.service import x innerhalb von app/orders/), was es sehr viel leichter macht zu bemerken — und zu hinterfragen —, wenn eine Domänengrenze überschritten wird, als in einer geschichteten Struktur, in der ohnehin schon alles in gemeinsam genutzten Top-Level-Ordnern lebt.

Eine Struktur, die skaliert

Für eine mittelgroße bis große FastAPI-Anwendung ist dies eine Struktur, die sich in der Praxis gut hält und einen Domain-driven Kern mit einem kleinen geschichteten core-Paket für wirklich querschnittliche Belange kombiniert:

app/
├── main.py                 # creates the FastAPI() instance, mounts api_router
├── api.py                  # top-level APIRouter composing all domain routers
├── config.py                # Settings via pydantic-settings, loaded once
├── core/
│   ├── database.py           # engine, session factory
│   ├── security.py           # JWT/password hashing helpers
│   ├── dependencies.py       # get_current_user, get_db, shared Depends
│   ├── exceptions.py         # custom exception classes + handlers
│   └── logging.py
├── users/
│   ├── router.py
│   ├── schemas.py
│   ├── models.py
│   ├── service.py
│   └── dependencies.py
├── orders/
│   ├── router.py
│   ├── schemas.py
│   ├── models.py
│   ├── service.py
│   └── dependencies.py
└── tests/
    ├── users/
    └── orders/

Jeder Domänenordner ist in sich abgeschlossen und isoliert importierbar — Sie könnten orders/ vollständig löschen, und users/ würde weiterhin funktionieren. core sitzt unterhalb jeder Domäne und importiert nie aus einer von ihnen, was den gesamten Graphen azyklisch macht. Auf dieses Layout wird im restlichen Cluster Bezug genommen: Die get_db-Dependency in core/dependencies.py ist der Ort, an dem die Sessions aus Async SQLAlchemy Sessions in FastAPI, Done Right eingebunden werden, und core/dependencies.py ist auch der Ort, an dem Sie die gemeinsame Auth-Dependency unterbringen, statt sie pro Router zu duplizieren.

Gemeinsam genutzter Code: Core, Schemas und Dependencies

core verdient eine spezifische Regel: Es ist für Code da, der keine Meinung über eine einzelne Domäne hat. Eine Datenbank-Session-Factory weiß nicht und es ist ihr egal, ob sie orders oder users bedient — sie gehört in core. Eine get_current_user-Dependency wird überall verwendet, aber nur einmal implementiert — sie gehört in core. Demgegenüber bleibt etwas wie OrderStatus, ein Enum ohne Bedeutung außerhalb der orders-Domäne, in orders/models.py und nicht in core, auch wenn es sich „geteilt“ anfühlen mag, weil mehrere Funktionen darauf verweisen.

Ein häufiges Anti-Pattern ist eine schemas.py, die zu einer Ablage für jedes Pydantic-Modell der App anwächst, einschließlich solcher, die in Wirklichkeit domänenspezifisch sind. Wenn ein Schema nur vom Router und Service einer Domäne verwendet wird, gehört es in den Ordner dieser Domäne — das Verschieben in ein globales schemas/-Paket macht es nicht wiederverwendbarer, sondern nur schwerer auffindbar.

# app/core/dependencies.py
from fastapi import Depends, HTTPException, status
from app.core.database import get_db
from app.core.security import decode_token

async def get_current_user(token: str = Depends(decode_token), db=Depends(get_db)):
    user = await db.get_user_by_token(token)
    if user is None:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
    return user

Jeder Domänenrouter importiert get_current_user aus core, niemals aus dependencies.py einer anderen Domäne — das ist der Einbahnfluss, der den Importgraphen zu einem Baum statt zu einem Netz macht.

Eine bestehende flache App migrieren

Eine funktionierende flache main.py in einem einzigen Pull Request in eine vollständige Domain-driven Struktur umzuschreiben, ist ein guter Weg, Regressionen einzuführen und die Migration auf halbem Weg zum Stillstand zu bringen. Ein sichererer Weg:

  1. Erstellen Sie zuerst app/core/ und verschieben Sie Datenbank-Session, Konfiguration und gemeinsame Auth-Dependency dorthin — sonst ändert sich zunächst nichts, und die App läuft weiter.
  2. Wählen Sie die am stärksten belastete Domäne (meist die mit den meisten Routen oder den jüngsten Bugs) und extrahieren Sie sie in ihren eigenen Ordner — Router, Schemas, Modelle, Service — und aktualisieren Sie dabei die Imports.
  3. Aktualisieren Sie main.py, sodass der neu extrahierte Bereich per include_router neben dem noch flachen Rest eingebunden wird.
  4. Wiederholen Sie das pro Domäne, ein PR nach dem anderen, damit jede Änderung reviewbar bleibt und die App nach jedem Schritt deploybar ist.
  5. Löschen Sie die flache Datei zuletzt, sobald jede Route in einen Domänenordner verschoben wurde und main.py nur noch die Verdrahtung übernimmt.

Das entspricht dem, wie die meisten echten Teams es tatsächlich machen — inkrementell, abgesichert durch funktionierende Tests, statt als Big-Bang-Neuschreibung.

Häufige Fallstricke

Fehler: Router importieren aus anderen Routern. Das bedeutet fast immer, dass eine gemeinsame Dependency dupliziert statt nach core verschoben wurde. Lösung: Wenn zwei Router dasselbe brauchen, gehört es in core, nicht in einen der beiden Router.

Fehler: Business-Logik lebt in Route-Handlern. Ein Handler, der die Datenbank abfragt, Business-Regeln anwendet und eine Antwort formatiert, macht die Logik ohne die gesamte HTTP-Schicht untestbar. Lösung: Halten Sie Handler dünn — Eingabe parsen, eine Service-Funktion aufrufen, Ergebnis zurückgeben — und legen Sie die Logik in service.py, wo sie direkt per Unit-Test getestet werden kann.

Fehler: Eine riesige schemas.py für die ganze App. Es wird unklar, welche Schemas wo tatsächlich verwendet werden, und Refactorings verändern eine Datei, die alle anderen ebenfalls bearbeiten. Lösung: Begrenzen Sie Schemas auf die Domäne, der sie gehören.

Fehler: Geschichtete und Domain-driven Struktur werden inkonsistent gemischt. Die eine Hälfte der App ist nach Domänen organisiert, die andere nach technischer Schicht, ohne klare Regel für neuen Code. Lösung: Wählen Sie ein Muster bewusst und dokumentieren Sie es, selbst wenn es nur ein Satz in der README ist.

Fehler: Ein Prototyp mit drei Endpunkten wird mit fünf Schichten überkonstruiert. Repository-Interfaces, abstrakte Service-Klassen und Dependency-Injection-Container für eine App mit drei Routen erzeugen Kosten ohne entsprechenden Nutzen. Lösung: Passen Sie die Struktur an die tatsächliche Größe an — beginnen Sie geschichtet oder sogar flach und wechseln Sie erst dann zu Domain-driven, wenn der Schmerz spürbar wird, nicht vorher.

Best Practices für Produktion

  • Erzwingen Sie die Einbahn-Importregel. router → service → models/schemas → core, niemals umgekehrt. Wenn Sie das automatisieren möchten, kann import-linter Modulgrenzen in CI erzwingen.
  • Versionieren Sie die API auf der Router-Kompositionsschicht, nicht pro Endpunkt — ein v2-APIRouter-Baum, der neben v1 eingebunden wird, ist weit weniger fehleranfällig, als Versionsprüfungen durch Handler zu verstreuen.
  • Halten Sie main.py langweilig. Sie sollte die App erstellen, Router einbinden und Exception-Handler sowie Lifespan-Events registrieren — sonst nichts.
  • Schreiben Sie einen tests/<domain>/-Ordner pro Domäne, der die App-Struktur spiegelt, damit klar ist, wohin ein neuer Test gehört.
  • Bewerten Sie die Struktur an echten Wendepunkten neu — eine neue Domäne, ein zweites Team, eine Service-Extraktion — statt nach einem festen Zeitplan.

Wie es von hier aus weitergeht

Die Projektstruktur ist das Fundament, auf dem das restliche Verhalten einer FastAPI-Anwendung aufbaut, aber sie löst nicht alles allein. Sobald die Ordner stehen, sind die nächsten Probleme meist Laufzeitprobleme — und dieser Cluster deckt die fünf ab, die am häufigsten auftreten:

Zum Abschluss

Es gibt nicht die eine korrekte FastAPI-Ordnerstruktur — es gibt eine Struktur, die zur Größe Ihres Teams und zur Form Ihrer Domäne passt, konsistent angewendet wird und eine klare Regel dafür hat, in welche Richtung Imports fließen. Beginnen Sie einfach, achten Sie auf die konkrete Reibung, die signalisiert, dass es Zeit für eine Änderung ist, und wenn Sie ändern, verschieben Sie eine Domäne nach der anderen hinter funktionierenden Tests, statt eine Neuschreibung zu versuchen. Das Ziel waren nie die Ordnernamen — es ist eine Codebasis, in der das Hinzufügen des vierzigsten Endpunkts genauso einfach ist wie das Hinzufügen des vierten.

Besteht Ihre aktuelle FastAPI-App noch aus einer einzigen Datei, oder ist sie ihrer Struktur bereits entwachsen?

Weitere Artikel