post

Read Models sind Projektionen, keine Caches

Ein Read Model ist eine bewusst gepflegte Projektion aus Events, zugeschnitten auf eine Abfrage – kein zufälliger Cache. Der Unterschied entscheidet darüber, ob Neuaufbau und Konsistenz zum Design gehören oder zur Notfallmaßnahme werden.

≈ 9 Min. Lesezeit

In fast jeder Schulung zu CQRS kommt der Moment, in dem jemand die Leseseite entdeckt und erleichtert sagt: "Ah, das ist also ein Cache." Ich verstehe den Reflex. Eine denormalisierte, schnelle Sicht, die neben der eigentlichen Datenbank steht – das sieht aus wie ein Cache, es fühlt sich an wie ein Cache. Und trotzdem führt genau dieser Vergleich in die falsche Richtung. Ein Read Model ist keine Beschleunigung, die man weglassen könnte. Es ist eine Projektion: eine deterministisch aus Events abgeleitete, jederzeit neu aufbaubare Sicht auf den Zustand. Der Unterschied klingt nach Wortklauberei, aber er entscheidet darüber, wie man das System betreibt, migriert und im Fehlerfall repariert.

Ich schreibe das auf, weil ich diesen Denkfehler in Reviews immer wieder sehe – und weil er teuer wird, sobald das erste Schema wandern soll. Wer die Leseseite als Cache versteht, baut sie so, dass sie sich nicht mehr sauber wiederherstellen lässt. Wer sie als Projektion versteht, hat den Neuaufbau von Anfang an im Design.

Woher der Cache-Reflex kommt

Der Reflex hat einen wahren Kern. Ein Read Model und ein Cache lösen oberflächlich dasselbe Problem: Abfragen sollen schnell sein, ohne das teure Schreibmodell bei jeder Anzeige zu befragen. Beide sind denormalisiert, beide liegen dem Nutzer näher als die Wahrheitsquelle, beide sind potenziell veraltet.

Der Bruch liegt eine Ebene tiefer, bei der Frage: Wer ist die Wahrheitsquelle, und wie entsteht die schnelle Sicht daraus? Ein Cache hat eine autoritative Datenbank hinter sich. Fällt der Cache-Eintrag weg, zieht man den Datensatz erneut aus dieser Quelle und legt ihn wieder ab. Die Invalidierung ist ein Nebeneffekt von TTL oder Verdrängung, der Kaltstart ist ein Betriebsdetail. Ein Read Model dagegen hat keine "eigentliche" Lese-Datenbank hinter sich, aus der man es bei Bedarf nachlädt. Es ist die autoritative Lese-Sicht. Was dahinter liegt, ist der Event Store oder das Write Model – und daraus wird das Read Model nicht "nachgeladen", sondern berechnet.

Diese Berechnung ist die Projektion. Und sie ist der Grund, warum ich auf der Unterscheidung bestehe.

Was eine Projektion ausmacht

Bevor ich zu Code komme, drei Begriffe, damit wir dieselbe Sprache sprechen. Martin Fowler beschreibt CQRS als bewusste Trennung: Man nutzt ein anderes Modell zum Schreiben als zum Lesen. Das Read Model ist dabei kein Blick durch das Domänenmodell hindurch, sondern ein eigenständiges Modell, das ausschließlich Abfragen bedient. Greg Young und Vaughn Vernon liefern das Bindeglied: die Projektion – der Prozess, der aus den Änderungen der Schreibseite, konkret aus den Domain Events, das Read Model ableitet und fortschreibt.

Die entscheidende Eigenschaft einer Projektion ist ihre Herkunft aus einem unveränderlichen Event-Strom. Weil die Events feststehen und die Projektion eine reine Funktion über diesen Events ist, gilt: Gleicher Event-Strom, gleiches Read Model. Immer. Das ist keine nette Zusatzeigenschaft, das ist die Definition. Und daraus folgt direkt die zweite Eigenschaft: Man kann das Read Model wegwerfen und aus den Events vollständig neu berechnen. Der Rebuild ist kein Notfallplan, sondern ein reguläres Werkzeug.

Genau hier trennt sich die Projektion vom Cache. Ein Cache kann man auch neu füllen, aber der Neuaufbau ist ein Kaltstart-Nebeneffekt, dessen Ergebnis von der Quelle im aktuellen Moment abhängt. Ein Rebuild einer Projektion ist deterministisch und zeitunabhängig, weil er auf der Historie fußt, nicht auf einem Momentanzustand.

Ein Vorbehalt gehört hierher: Dieser freie Rebuild setzt voraus, dass die Events dauerhaft und vollständig vorliegen – also einen Schreibweg mit Event Sourcing, bei dem der Event Store die Wahrheitsquelle ist. CQRS und Event Sourcing sind zwei getrennte Entscheidungen, die man oft zusammen trifft, aber nicht muss; Fowler beschreibt CQRS ausdrücklich unabhängig von der Persistenzform. Wer CQRS ohne persistente Event-Historie betreibt und die Leseseite nur aus flüchtigen Benachrichtigungen fortschreibt, hat eine Projektion im Betrieb, aber nicht den deterministischen Neuaufbau aus der Historie. Die Beispiele hier setzen den Event Store voraus – und mit ihm die Eigenschaft, die das Read Model vom Cache trennt.

Die Projektion als Faltung

Am klarsten wird das im Code. Eine Projektion ist im Kern eine Faltung – ein reduce über die Events, das mit einem leeren Zustand beginnt und jedes Event auf den bisherigen Zustand anwendet. Illustrativ, in TypeScript:

type OrderPlaced = { type: "OrderPlaced"; orderId: string; total: number }
type OrderShipped = { type: "OrderShipped"; orderId: string }
type OrderCancelled = { type: "OrderCancelled"; orderId: string }

type OrderEvent = OrderPlaced | OrderShipped | OrderCancelled

type OrderSummary = {
  orderId: string
  total: number
  status: "placed" | "shipped" | "cancelled"
}

// pure reducer: (state, event) => state
function apply(state: OrderSummary, event: OrderEvent): OrderSummary {
  switch (event.type) {
    case "OrderPlaced":
      return { orderId: event.orderId, total: event.total, status: "placed" }
    case "OrderShipped":
      return { ...state, status: "shipped" }
    case "OrderCancelled":
      return { ...state, status: "cancelled" }
  }
}

// the projection is a fold over the event stream
function project(events: OrderEvent[]): OrderSummary {
  return events.reduce(apply, {} as OrderSummary)
}

apply ist eine reine Funktion (state, event) => state. Kein Zugriff auf die Uhr, kein Netzwerk, keine Zufälligkeit. Das ist keine stilistische Vorliebe, sondern die Bedingung dafür, dass der Rebuild deterministisch bleibt. Sobald in apply ein Date.now() oder ein externer Lookup steckt, ist die schöne Eigenschaft "gleiche Events, gleiches Read Model" verloren – und man hat sich unbemerkt einen Cache gebaut, dessen Neuaufbau vom Zeitpunkt abhängt.

Ein Read Model pro Sicht

Der zweite Kernpunkt: Es gibt nicht das eine Read Model. Es gibt eines pro Abfrage, jeweils denormalisiert und exakt auf die Sicht zugeschnitten, die es bedient. Vernon weist darauf hin, dass Read Models besonders dort nützlich sind, wo die angezeigten Daten quer über mehrere Aggregates schneiden. Das Read Model zieht diese Daten vorberechnet zusammen, sodass die Abfrage flach und billig bleibt.

Eine Sicht für eine Bestellliste zieht andere Felder als eine Detailsicht. Statt zur Laufzeit zu joinen und zu rechnen, legt die Projektion die fertigen Werte ab:

type CustomerOrderListItem = {
  orderId: string
  customerName: string   // denormalized from the customer aggregate
  itemCount: number      // precomputed, no join at read time
  totalAmount: number
  statusLabel: string    // display-ready, e.g. "Versandt"
}

// this projection cuts across order and customer to build one flat view
function toListItem(
  order: OrderSummary,
  customerName: string,
  itemCount: number,
): CustomerOrderListItem {
  return {
    orderId: order.orderId,
    customerName,
    itemCount,
    totalAmount: order.total,
    statusLabel: order.status === "shipped" ? "Versandt" : "Offen",
  }
}

Wichtig ist die Denkrichtung: Das Read Model wird für die Abfrage gebaut, nicht die Abfrage für das Read Model. Das ist genau das, was ich in CQRS ist eine fachliche Grenze meine, wenn ich sage, dass die Trennung von Lesen und Schreiben eine Aussage über Verantwortlichkeit ist, nicht über zwei Datenbanken. Die Leseseite darf ihre eigene Form haben, weil sie eine eigene Aufgabe hat.

Der Rebuild gehört zum Design

Weil die Projektion deterministisch aus den Events entsteht, ist der Neuaufbau trivial zu formulieren – man leert die Read-Seite und spielt die Events erneut ein:

interface ReadStore {
  clear(): void
  save(view: OrderSummary): void
}

interface EventStore {
  allEvents(): Iterable<OrderEvent>
}

function rebuild(events: EventStore, read: ReadStore): void {
  read.clear()
  const views = new Map<string, OrderSummary>()
  for (const event of events.allEvents()) {
    const current = views.get(event.orderId) ?? ({} as OrderSummary)
    views.set(event.orderId, apply(current, event))
  }
  for (const view of views.values()) read.save(view)
}

Dieser rebuild ist der praktische Lackmustest. Wenn er sich sauber schreiben lässt, hat man eine Projektion. Wenn er sich nicht sauber schreiben lässt – weil Zustand von außen einfließt, weil Events verworfen wurden, weil das apply nicht rein ist –, dann hat man einen Cache und sollte aufhören, ihn Read Model zu nennen.

Der Rebuild ist auch die Antwort auf eine Frage, die in Projekten früher oder später kommt: Was passiert bei einer Schema-Änderung auf der Leseseite? Bei einem Cache ist das ein Migrationsproblem mit Datenübernahme. Bei einer Projektion ist es ein neuer Projektor, den man über die vorhandenen Events laufen lässt. Die neue Sicht entsteht aus derselben Quelle wie die alte. Das ist der Moment, in dem sich die anfängliche Disziplin auszahlt.

Der Datenfluss im Bild

Eine Quelle, mehrere sichtenspezifische Projektionen – so sieht der Fluss aus:

flowchart LR
  C[Command] --> W[Write Model /<br/>Event Store]
  W -->|Events| P[Projektor]
  P -->|async · eventual| A[Read Model A<br/>Liste]
  P -->|async · eventual| B[Read Model B<br/>Detail]
  P -->|async · eventual| R[Read Model C<br/>Report]

Der Command läuft gegen das Write Model und erzeugt Events. Der Projektor abonniert diese Events und schreibt daraus so viele Read Models fort, wie es Sichten gibt. Die Kanten zu den Read Models sind bewusst mit "async · eventual" beschriftet, denn hier steckt der dritte Kernpunkt: die Konsistenz.

Eventual Consistency ist ein bewusster Handel

Die Projektion läuft asynchron, nachdem der Schreibvorgang erfolgreich war. Fowler formuliert die Konsequenz nüchtern: Man kann nicht exakt garantieren, wann das Read Model konsistent ist. Kurz nach dem Schreiben ist es potenziell veraltet. Das ist kein Bug, kein Datenverlust und keine Korruption – es ist ein begrenzter, bekannter Verzug, den man in Kauf nimmt, um Lese- und Schreibseite zu entkoppeln.

Der Fehler, den ich am häufigsten sehe, ist nicht der Verzug selbst, sondern der Umgang damit. Wer eine synchrone Read-after-write-Konsistenz erwartet, baut die UI so, dass sie sofort nach dem Command aus dem Read Model liest und sich wundert, dass der neue Datensatz "fehlt". Er ist nicht weg, die Projektion ist nur noch nicht durch. Das gehört ins Design der Oberfläche, nicht in einen Bugreport.

Gefährlicher ist die zweite Variante: aus dem Read Model heraus Command-seitige Invarianten prüfen. Greg Young ist hier deutlich – das Read Model darf keine Geschäftsentscheidung tragen, die auf aktuellem, konsistentem Zustand beruhen muss. Weil es veraltet sein kann, würde man auf veralteten Daten entscheiden. Die Prüfung, ob ein Konto gedeckt ist oder eine Bestellung noch storniert werden darf, gehört auf die Schreibseite, gegen das Aggregate, nicht gegen die Leseansicht. Diese Arbeitsteilung habe ich in Commands, Events und Queries trennen genauer aufgeschrieben: Eine Query stellt eine Frage und ändert nichts; sie darf deshalb auch keine Entscheidung erzwingen.

Projektion oder Cache – die Unterscheidung in fünf Punkten

Wenn ich in Reviews entscheiden muss, ob etwas eine Projektion oder ein Cache ist, gehe ich fünf Fragen durch:

  • Wer ist die Wahrheitsquelle? Bei der Projektion ist der Event Store die Quelle, das Read Model ist die autoritative Lese-Sicht. Beim Cache liegt die autoritative Quelle in der Datenbank dahinter.
  • Wie wird invalidiert? Die Projektion wird eventgetrieben fortgeschrieben, der Cache über TTL oder Verdrängung.
  • Wie läuft der Neuaufbau? Die Projektion ist by design deterministisch aus der Historie neu berechenbar; der Cache-Neuaufbau ist ein Kaltstart-Nebeneffekt, abhängig vom aktuellen Quellzustand.
  • Welche Form hat die Sicht? Die Projektion ist pro Sicht denormalisiert und aggregatübergreifend zusammengezogen, der Cache meist eine Kopie des Quell-Datensatzes.
  • Wie steht es um die Konsistenz? Die Projektion ist bewusst eventual consistent mit begrenztem Verzug, der Cache best-effort.

Ein oder zwei dieser Punkte kann man diskutieren. Aber wenn die Antworten überwiegend in der Cache-Spalte landen, dann sollte man das Ding auch so nennen und nicht die Erwartungen an ein Read Model daran hängen.

Die drei Fallstricke, konkret

Zum Schluss die Muster, die den Ärger verursachen, jeweils in einem Satz benannt und mit der Gegenmaßnahme.

Der erste ist das Cache-Denken selbst: Das Read Model als optionalen Beschleuniger behandeln, den man bei einem Fehltreffer aus der "eigentlichen" Datenbank neu zieht. Es gibt diese eigentliche Datenbank nicht. Die Fortschreibung erfolgt eventgetrieben, und der Rebuild kommt aus den Events. Wer das ernst nimmt, hört auf, nach der Quelle hinter dem Read Model zu suchen.

Der zweite ist das Read Model als Wahrheitsquelle: Aus einer potenziell veralteten Lese-Sicht heraus über Command-Invarianten entscheiden. Die Konsequenz sind Entscheidungen auf altem Stand. Die Gegenmaßnahme ist Disziplin über die Seitengrenze – Entscheidungen auf die Schreibseite, gegen das Aggregate.

Der dritte ist der vergessene Neuaufbau: Eine Projektion bauen, die sich nicht replayen lässt, weil das apply nicht rein ist oder Events verworfen wurden. Dann wird die erste Schema-Migration zur Sackgasse. Die Gegenmaßnahme steht oben im Code – halte den Projektor als reine Faltung über den Events, und schreibe den rebuild früh, nicht erst wenn er brennt.

Fazit

Ein Read Model verdient einen besseren Namen als "Cache", weil der Name die Erwartungen setzt. Nennt man es Cache, baut man einen optionalen Beschleuniger mit einer vermeintlichen Quelle dahinter, TTL-Invalidierung und einem Neuaufbau, der Nebeneffekt bleibt. Nennt man es Projektion, baut man eine reine Faltung über einem unveränderlichen Event-Strom, pro Sicht zugeschnitten, eventgetrieben fortgeschrieben und jederzeit deterministisch neu berechenbar. Die drei Eigenschaften, an denen ich das festmache, sind immer dieselben: deterministisch abgeleitet, jederzeit neu aufbaubar, bewusst eventual consistent. Wer diese drei ins Design zieht, hat eine Leseseite, die man betreiben und migrieren kann, ohne bei jeder Änderung ins Schwitzen zu geraten.

Weiterführende Quellen

Kommentare