CQRS + Event Sourcing in Django: Ein praktischer Architekturansatz

In klassischen Django-Projekten wird die Anwendungslogik oft direkt in Views oder Modellen verarbeitet. Doch mit zunehmender Komplexität entstehen Fragen:

  • Wie trenne ich lesende von schreibenden Zugriffen sauber?

  • Wie kann ich Änderungen nachvollziehbar speichern?

  • Wie skaliere ich einzelne Aspekte meiner Architektur unabhängig voneinander?

Um das zu lösen, habe ich ein Projekt aufgebaut, das auf CQRS (Command Query Responsibility Segregation) und Event Sourcing setzt – in Django. In diesem Artikel zeige ich dir, wie ich es umgesetzt habe und welche Tools ich verwendet habe.

Veröffentlicht am 20.07.2025

✍️ Abschnitt 1: Einführung – Motivation & Zielsetzung

Quellcode & Repository

Der vollständige Quellcode dieses Projekts ist frei verfügbar auf GitHub unter github.com/dunkeltech/django-cqrs-example

Das Repository enthält:

  • das Django-Projekt mit CQRS + Event Sourcing

  • devenv.nix für eine sofort startbare Entwicklungsumgebung

  • API-Test-Dateien für Bruno

  • eine ausführliche README.md

  • und alles, was du brauchst, um das Projekt lokal zum Laufen zu bringen oder für eigene Ideen weiterzuentwickeln

CQRS und Event Sourcing in Django – Warum?

Die meisten Django-Projekte entstehen aus einem klassischen Ansatz:
Ein Model, ein View, ein Form oder Serializer – und schon kann man Objekte erstellen, lesen, updaten oder löschen. Für viele Anwendungsfälle ist das auch vollkommen ausreichend.

Doch sobald ein Projekt komplexer wird, entstehen typische Herausforderungen:

  • Lesende und schreibende Anforderungen unterscheiden sich stark.
    Beispiel: „Zeige eine aggregierte Liste aller Bestellungen nach Kunde“ vs. „Erstelle eine neue Bestellung mit Validierung und Geschäftslogik“.

  • Zustandsänderungen sind nicht mehr nachvollziehbar.
    Wer hat wann was geändert? Warum steht der Datensatz heute so da? Klassische Audit-Felder reichen irgendwann nicht mehr aus.

  • Fehlende Skalierbarkeit im Datenmodell.
    Ein ORM-Modell muss immer „alles“ können – lesen, schreiben, filtern, validieren – und wächst dadurch oft über seine Verantwortung hinaus.

Lösung: CQRS + Event Sourcing

Um diese Herausforderungen zu meistern, habe ich mich entschieden, mein Projekt auf Basis von CQRS und Event Sourcing aufzubauen:

  • CQRS (Command Query Responsibility Segregation):
    Ich trenne strikt zwischen Commands (schreibende Vorgänge wie "erstelle Bestellung") und Queries (lesende Zugriffe auf z. B. Bestellübersichten).

  • Event Sourcing:
    Ich speichere nicht nur den aktuellen Zustand, sondern alle Ereignisse, die zu diesem Zustand geführt haben. So kann ich jederzeit nachvollziehen, warum ein Objekt heute aussieht, wie es aussieht.

Ziel des Projekts

Das Ziel meines Projekts war es, eine schlanke, aber robuste Architektur zu bauen, die:

  • sauber strukturiert ist und gut testbar bleibt

  • Geschäftslogik in Commands kapselt

  • lesende Zugriffe durch eigene Read-Modelle beschleunigt

  • alle Änderungen als DomainEvent speichert

  • sich bei Bedarf vollständig aus Events neu aufbauen lässt (Rebuild/Replay)

  • und dabei Django & DRF weiterhin effizient nutzt

Stack & Werkzeuge

  • Django + Django REST Framework als solides Fundament

  • PostgreSQL als EventStore und ReadModel-Datenbank

  • devenv.nix für ein reproduzierbares lokales Dev-Setup

  • Bruno für automatisierte API-Tests

  • pre-commit-Hooks für einheitlichen Code-Style

✍️ Abschnitt 2: Architekturüberblick – CQRS + Event Sourcing in Django

In diesem Abschnitt zeige ich dir, wie ich CQRS und Event Sourcing in Django konkret umgesetzt habe: Welche Schichten es gibt, wie sie zusammenspielen, und warum das auch mit einem klassischen Django-Stack erstaunlich gut funktioniert.

Die Grundidee: Trennung von Lesen und Schreiben

Das System ist entlang der CQRS-Prinzipien in zwei Hauptpfade aufgeteilt:

Commands (Write Path)

  • Commands ändern den Zustand der Domäne

  • Aber: Sie schreiben keine Daten direkt in Django-Modelle

  • Stattdessen erzeugen sie Events, die den gewünschten Zustand beschreiben

  • Diese Events werden im zentralen DomainEvent-Modell gespeichert

{
  "aggregate_type": "Order",
  "aggregate_id": "1234",
  "event_type": "OrderCreated",
  "payload": {
    "customer_name": "Alice",
    "product": "Monitor",
    "quantity": 2
  },
  "created_at": "2025-07-13T09:00:00Z"
}

Die wichtigsten Bausteine

DomainEvent

Das zentrale EventStore-Modell, das alle Änderungen speichert:

class DomainEvent(models.Model):
    aggregate_type = models.CharField(...)
    aggregate_id = models.CharField(...)
    event_type = models.CharField(...)
    payload = models.JSONField()
    created_at = models.DateTimeField(auto_now_add=True)

Commands

Kleine Klassen, die spezifische Anwendungsfälle abbilden, z. B.:

  • CreateOrderCommand

  • UpdateOrderCommand

  • DeleteOrderCommand

Jeder Command erzeugt genau ein DomainEvent.

Projektionen (ReadModel-Sync)

Sobald ein Event gespeichert wird, wird es per Signal an eine Projektion weitergeleitet, z. B.:

class OrderProjection:
    def apply(self, event):
        match event.event_type:
            case "OrderCreated":
                self._handle_created(event)
            case "OrderUpdated":
                self._handle_updated(event)
            case "OrderDeleted":
                self._handle_deleted(event)

Queries

Einfach strukturierte Klassen, die direkt auf das ReadModel zugreifen, z. B.:

class GetOrderReadQuery:
    def execute(self, order_id):
        return OrderReadModel.objects.get(order_id=order_id)

Vorteile dieser Architektur

Vorteil Beschreibung
Zustand rekonstruierbar Events können jederzeit erneut abgespielt werden
Testbarkeit Commands und Projections können separat getestet werden
Auditierbarkeit Jedes Event bleibt nachvollziehbar gespeichert
Performance Queries laufen über optimierte Read-Modelle, nicht über Events
Erweiterbar Neue Felder oder Aggregates brauchen keine Migrations – nur neue Events

Wichtig zu verstehen

  • Das ReadModel ist eine Sicht auf den Zustand, keine Quelle der Wahrheit

  • Nur Events gelten als tatsächliche, unveränderbare Wahrheit

  • Es ist möglich (und gewollt), das gesamte ReadModel aus Events neu aufzubauen (rebuild_read_models)

✍️ Abschnitt 3: Technische Umsetzung in Django

In diesem Abschnitt zeige ich dir, wie ich die in Abschnitt 2 beschriebene Architektur konkret mit Django und Django REST Framework (DRF) umgesetzt habe – inklusive Commands, Events, Projections und der API-Schicht.

1. DomainEvent – der EventStore

Zentrales Modell für alle Änderungen, die im System passieren – unabhängig vom Aggregat (z. B. Order, Customer, Product):

class DomainEvent(models.Model):
    aggregate_type = models.CharField(max_length=100)
    aggregate_id = models.CharField(max_length=64)
    event_type = models.CharField(max_length=100)
    payload = models.JSONField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(fields=["aggregate_type", "aggregate_id", "created_at"]),
        ]

2. Commands – Use Cases kapseln

Commands sind Klassen, die Geschäftslogik ausführen und dabei ein oder mehrere Events erzeugen.

Beispiel: CreateOrderCommand:

class CreateOrderCommand:
    def execute(self, data):
        order_id = str(uuid.uuid4())
        DomainEvent.objects.create(
            aggregate_type="Order",
            aggregate_id=order_id,
            event_type="OrderCreated",
            payload=data,
        )
        return order_id

3. Projektion – das ReadModel aktuell halten

Wenn ein neues Event gespeichert wird, reagieren Signal-Handler und rufen die passende Projektion auf.

class OrderProjection:
    def apply(self, event):
        match event.event_type:
            case "OrderCreated":
                self._handle_created(event)
            case "OrderUpdated":
                self._handle_updated(event)
            case "OrderDeleted":
                self._handle_deleted(event)

4. Signals – Event getriggert → Projektion anwenden

Hier verwende ich die Django-Signale um über Änderungen informiert zu werden und das ReadModel aufzubauen.

@receiver(post_save, sender=DomainEvent)
def handle_domain_event(sender, instance, **kwargs):
    if instance.aggregate_type == "Order":
        OrderProjection().apply(instance)

5. Queries – Zugriff aufs ReadModel

Für alle Lesezugriffe nutze ich einfache Query-Klassen:

class GetOrderReadQuery:
    def execute(self, order_id):
        try:
            return OrderReadModel.objects.get(order_id=order_id)
        except OrderReadModel.DoesNotExist:
            return None

6. DRF API – mit Create, Update, Delete

Die OrderAPIView unterstützt:

  • GET /orders/ – alle Orders

  • GET /orders/<id>/ – einzelne Order

  • POST /orders/ – Bestellung erstellen (→ CreateOrderCommand)

  • PATCH /orders/<id>/ – Bestellung aktualisieren (→ UpdateOrderCommand)

  • DELETE /orders/<id>/ – Bestellung löschen (→ DeleteOrderCommand)

DRF-Serializer sorgen für Validierung:

class OrderUpdateSerializer(serializers.Serializer):
    customer_name = serializers.CharField(max_length=100, required=False)
    product = serializers.CharField(max_length=100, required=False)
    quantity = serializers.IntegerField(min_value=1, required=False)

ReadModel-Rebuild

Das ReadModel kann jederzeit neu aus dem EventStore aufgebaut werden:

python manage.py rebuild_read_models

Ergebnis

Man bekommt:

  • stabile, nachvollziehbare Command-Pfade

  • schnelle, einfache Query-Zugriffe

  • vollständige Historie durch Events

  • klare Trennung von Anwendungslogik und Persistenz

✍️ Abschnitt 4: Developer Experience & Setup

Eine Architektur ist nur dann produktiv nutzbar, wenn auch das Entwicklungserlebnis (Developer Experience) gut ist. Deshalb habe ich das Projekt so gestaltet, dass es sich mit einem einzigen Befehl sauber starten lässt – inklusive:

  • Python-Umgebung

  • PostgreSQL-Datenbank

  • Codequalitätstools

  • API-Testumgebung

1. Reproduzierbares Setup mit devenv.nix

Ich verwende devenv.sh auf Basis von Nix, um eine konsistente Entwicklungsumgebung bereitzustellen – unabhängig vom Betriebssystem oder Setup des Entwicklers.

Beispiel: devenv.nix

{
  packages = [ pkgs.git pkgs.openssl pkgs.python311Packages.pip ];
  languages.python = {
    enable = true;
    version = "3.11";
    venv.enable = true;
  };

  services.postgres = {
    enable = true;
    initialDatabases = [
      { name = "cqrs"; user = "cqrs"; pass = "cqrs"; }
    ];
  };

  pre-commit.hooks = {
    black.enable = true;
    isort.enable = true;
    flake8.enable = true;
  };
}

So startest du dein Dev-Setup

devenv up

2. API-Tests mit Bruno

Die API kann vollständig mit Bruno getestet werden – einem Open-Source-API-Client, ähnlich wie Postman, aber lokal und dateibasiert.

Ich habe alle relevanten API-Aufrufe in der Struktur api/ abgelegt:

  • GET /orders/

  • POST /orders/

  • PATCH /orders/<id>/

  • DELETE /orders/<id>/

→ Du kannst diese Dateien einfach öffnen und direkt testen, ohne manuelle Eingaben.

3. Codequalität via Pre-Commit

Ich nutze pre-commit mit folgenden Hooks:

  • black für auto-formatting

  • isort für sortierte Imports

  • flake8 für Linting

pre-commit install

✍️ Abschnitt 5: Fazit – Was ich aus dem Projekt gelernt habe

Ein Architekturansatz mit echten Vorteilen

Die Kombination aus CQRS und Event Sourcing hat sich in meinem Django-Projekt als starkes Fundament für eine langfristig wartbare Backend-Architektur erwiesen.
Ich konnte damit:

  • Geschäftslogik klar von Datenzugriff trennen

  • jede Zustandsänderung transparent nachvollziehbar machen

  • API-Zugriffe performanter und gezielter gestalten

  • Projektionen gezielt auf UI- und Anwendungsbedürfnisse zuschneiden

Was besonders gut funktioniert hat

  • Die Verwendung eines generischen DomainEvent-Modells statt spezialisierter Event-Tabellen hat das System schlank gehalten

  • Mit einer einheitlichen Projection-Klasse ließ sich das ReadModel sehr klar verwalten

  • Rebuild-Funktionalität aus Events hat das Vertrauen in die Architektur gestärkt

  • DRF hat mir viel Zeit gespart bei API-Aufbau und Validierung

Herausforderungen und Learnings

  • Signale und Transaktionen sauber zu synchronisieren, braucht etwas Sorgfalt (z. B. bei rebuild_read_models)

  • Read-Projektionen müssen bei Schemaänderungen explizit aktualisiert werden (kein „magisches“ ORM)

  • Man muss diszipliniert bleiben: Events sind unveränderlich, daher ist „Fixen“ alter Zustände nicht einfach (und auch nicht gewollt!)

Nächste Ausbaustufen

Das Projekt ist offen für viele sinnvolle Erweiterungen:

  • Snapshots zur Beschleunigung bei großen Event-Ketten

  • Event-Versionierung, wenn sich Payload-Strukturen ändern

  • Webhooks / EventBus, um auf Events in anderen Systemen zu reagieren

  • Admin-Interface für Event-Historie & Replay

Für wen ist dieser Ansatz geeignet?

Diese Architektur lohnt sich besonders, wenn du:

  • komplexe Geschäftsregeln abbildest

  • Versionierung & Nachvollziehbarkeit brauchst

  • skalierbare APIs mit unterschiedlicher Lese- und Schreiblast aufbaust

  • oder einfach ein bisschen mehr Ordnung und Klarheit im Backend willst

Danke fürs Lesen!

Wenn du dieses Projekt nachbauen oder erweitern möchtest, findest du im Repository alles, was du brauchst – inklusive README, devenv.nix, API-Tests und Lizenz.

Fragen, Anregungen oder eigene Erfahrungen? Ich freue mich über Feedback!