Das CQRS-Pattern: Lesen und Schreiben trennen
CQRS nutzt getrennte Modelle für Lese- und Schreibzugriffe — wann das echte Probleme löst und wann es nur unnötige Komplexität ist.

Die meisten Anwendungen verwenden dasselbe Modell zum Lesen und Schreiben von Daten. Eine User-Entität wird auf eine users-Tabelle abgebildet, und dasselbe Modell dient sowohl der API-Antwort als auch der Update-Logik. Das funktioniert, bis deine Lesemuster deutlich von deinen Schreibmustern abweichen — wenn die Datenform, die deine UI braucht, völlig anders ist als die, in der dein Domänenmodell sie speichert. CQRS löst dies, indem für jeden Zweck separate, optimierte Modelle verwendet werden.
Die Kernidee
Commands verändern den Zustand. Queries lesen den Zustand. In CQRS nutzen sie unterschiedliche Modelle, möglicherweise gestützt auf verschiedene Datenspeicher.
// ❌ Single model for reads and writes
class OrderService {
async getOrders(userId: string): Promise<Order[]> {
// Query returns domain objects with all business logic
// UI only needs id, total, status — gets entire aggregate
return this.orderRepo.findByUser(userId);
}
async createOrder(data: CreateOrderDto): Promise<Order> {
const order = new Order(data);
order.validate();
return this.orderRepo.save(order);
}
}
// ✅ Separate read and write models
// Write side: rich domain model with business rules
class OrderCommandHandler {
async handle(command: CreateOrderCommand): Promise<string> {
const order = new Order(command.userId);
for (const item of command.items) {
order.addItem(item.productId, item.quantity, item.price);
}
order.validate();
await this.orderRepo.save(order);
return order.id;
}
}
// Read side: flat, optimized projections for queries
class OrderQueryHandler {
async getOrderSummaries(userId: string): Promise<OrderSummary[]> {
// Direct database query returning exactly what the UI needs
return this.db.query(`
SELECT o.id, o.status, o.total_amount, o.created_at,
COUNT(oi.id) as item_count
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.user_id = $1
GROUP BY o.id
ORDER BY o.created_at DESC
`, [userId]);
}
}Wann CQRS hilft
CQRS fügt architektonische Komplexität hinzu. Es ist gerechtfertigt, wenn deine Lese- und Schreibmuster fundamental unterschiedlich sind.
// Scenario: Dashboard that aggregates data from multiple entities
// Without CQRS, the dashboard query joins 5 tables and computes aggregates on every request
// ❌ Complex query on every read
async function getDashboard(userId: string) {
const orders = await orderRepo.findByUser(userId);
const payments = await paymentRepo.findByUser(userId);
const returns = await returnRepo.findByUser(userId);
// Expensive aggregation on every request
return {
totalOrders: orders.length,
totalSpent: orders.reduce((sum, o) => sum + o.total, 0),
pendingPayments: payments.filter(p => p.status === "pending").length,
returnRate: returns.length / orders.length,
recentActivity: mergeAndSort(orders, payments, returns).slice(0, 10),
};
}
// ✅ Pre-computed read model updated when writes happen
interface UserDashboardReadModel {
userId: string;
totalOrders: number;
totalSpent: number;
pendingPayments: number;
returnRate: number;
recentActivity: ActivityEntry[];
lastUpdated: string;
}
// Read model is updated asynchronously when events occur
async function handleOrderCreated(event: OrderCreatedEvent) {
await db.query(`
UPDATE user_dashboard
SET total_orders = total_orders + 1,
total_spent = total_spent + $2,
last_updated = NOW()
WHERE user_id = $1
`, [event.data.userId, event.data.totalAmount]);
}
// Dashboard query is now a single table lookup
async function getDashboard(userId: string): Promise<UserDashboardReadModel> {
const result = await db.query(
"SELECT * FROM user_dashboard WHERE user_id = $1",
[userId]
);
return result.rows[0];
}Projektionen: Read Models aufbauen
Projektionen wandeln Events der Schreibseite in leseoptimierte Datenstrukturen um.
class OrderSummaryProjection {
async handle(event: DomainEvent): Promise<void> {
switch (event.type) {
case "order.created":
await this.onOrderCreated(event as OrderCreatedEvent);
break;
case "order.item_added":
await this.onItemAdded(event as ItemAddedEvent);
break;
case "order.submitted":
await this.onOrderSubmitted(event as OrderSubmittedEvent);
break;
case "order.cancelled":
await this.onOrderCancelled(event as OrderCancelledEvent);
break;
}
}
private async onOrderCreated(event: OrderCreatedEvent): Promise<void> {
await this.db.query(`
INSERT INTO order_summaries (id, user_id, status, item_count, total_amount, created_at)
VALUES ($1, $2, 'draft', 0, 0, $3)
`, [event.aggregateId, event.data.userId, event.occurredAt]);
}
private async onItemAdded(event: ItemAddedEvent): Promise<void> {
await this.db.query(`
UPDATE order_summaries
SET item_count = item_count + 1,
total_amount = total_amount + $2
WHERE id = $1
`, [event.aggregateId, event.data.price * event.data.quantity]);
}
private async onOrderSubmitted(event: OrderSubmittedEvent): Promise<void> {
await this.db.query(`
UPDATE order_summaries SET status = 'submitted' WHERE id = $1
`, [event.aggregateId]);
}
private async onOrderCancelled(event: OrderCancelledEvent): Promise<void> {
await this.db.query(`
UPDATE order_summaries SET status = 'cancelled' WHERE id = $1
`, [event.aggregateId]);
}
}Eventual Consistency zwischen den Modellen
Das Read Model wird asynchron nach den Schreibvorgängen aktualisiert. Das bedeutet, dass Queries möglicherweise leicht veraltete Daten zurückgeben.
// ❌ Ignoring the consistency gap
app.post("/orders", async (req, res) => {
await commandBus.dispatch(new CreateOrderCommand(req.body));
// Immediately redirecting to a page that reads the new order
// But the read model might not have the new order yet!
res.redirect(`/orders`);
});
// ✅ Handling the consistency gap explicitly
app.post("/orders", async (req, res) => {
const orderId = await commandBus.dispatch(new CreateOrderCommand(req.body));
// Option 1: Return the command result directly (not from read model)
res.status(202).json({ orderId, status: "processing" });
});
// Client-side: poll or use websocket for read model updates
async function waitForReadModel(orderId: string, maxAttempts = 10): Promise<OrderSummary> {
for (let i = 0; i < maxAttempts; i++) {
const summary = await fetch(`/api/orders/${orderId}/summary`);
if (summary.ok) return summary.json();
await new Promise(resolve => setTimeout(resolve, 200 * Math.pow(2, i)));
}
throw new Error("Read model not yet updated");
}Read Models neu aufbauen
Ein Vorteil von CQRS: Read Models lassen sich aus der Event-Historie neu aufbauen. Wenn du eine neue Dashboard-Ansicht hinzufügst, baue die Projektion aus den historischen Events neu auf.
async function rebuildProjection(
projection: OrderSummaryProjection,
eventStore: EventStore
): Promise<void> {
// Clear existing read model
await projection.reset();
// Replay all events in order
let lastPosition = 0;
const batchSize = 1000;
while (true) {
const events = await eventStore.getEvents({
afterPosition: lastPosition,
limit: batchSize,
});
if (events.length === 0) break;
for (const event of events) {
await projection.handle(event);
lastPosition = event.position;
}
console.log(`Rebuilt up to position ${lastPosition}`);
}
console.log("Projection rebuild complete");
}Die wichtigsten Erkenntnisse
- CQRS trennt Lese- und Schreibmodelle — jede Seite ist für ihre spezifischen Zugriffsmuster optimiert
- Vorberechnete Read Models eliminieren teure Joins — Dashboard-Queries werden zu einfachen Lookups
- Projektionen wandeln Events in leseoptimierte Strukturen um — sie sind die Brücke zwischen Schreib- und Leseseite
- Akzeptiere Eventual Consistency — Read Models werden asynchron aktualisiert, gestalte deine UI entsprechend
- Read Models lassen sich neu aufbauen — spiele Events erneut ab, um neue Ansichten zu erstellen oder Projektionsfehler zu beheben
- CQRS ist nicht immer nötig — wenn deine Lese- und Schreibzugriffe dieselbe Form nutzen, ist ein einzelnes Modell einfacher


