← Tilbage til koncepter

CQRS

Arkitektur

Command Query Responsibility Segregation — arkitekturmønster der adskiller read-modeller fra write-modeller for at optimere hver side uafhængigt.

Beskrivelse

CQRS står for Command Query Responsibility Segregation. Kerneidéen er enkel: opdel din datamodel i to. Én model håndterer commands — operationer der ændrer state (CreateOrder, UpdateAddress, CancelSubscription). En anden model håndterer queries — operationer der læser state uden at ændre den. De to modeller kan bruge forskellige databaser, forskellige skemaer og optimeres for hver deres workload. I en klassisk CRUD-applikation deler read og write den samme model. Det er simpelt indtil systemet vokser: dashboards trækker tunge aggregations der låser tabeller, checkout-flowet lider under samme JOIN-planer som analytics-siden, og enhver denormalisering til læse-performance skaber komplekse skrive-stier. CQRS løser det ved at give hver side sin egen optimerede repræsentation. CQRS er ofte kombineret med Event Sourcing — write-modellen persisterer domain events, og read-modellen bygges op ved at afspille eventsne mod projections. Men CQRS kræver ikke Event Sourcing. Man kan lige så godt bruge synkron replikering fra en OLTP-database til en OLAP-database eller en denormaliseret search index. Kombinationen af CQRS og en klassisk relationel write-model med denormaliserede read-tabeller opdateret via triggers eller change data capture er faktisk en pragmatisk mellemvej der giver mange af fordelene uden fuld Event Sourcing-kompleksitet. En central beslutning ved CQRS er valget mellem synkron og asynkron opdatering af read-modeller. Synkron opdatering — hvor read-modellen skrives inden en command returnerer — giver read-your-writes garantier, men fjerner meget af den skalerbarhedsfordel CQRS lovede. Asynkron opdatering giver bedre performance men skaber eventual consistency: en bruger der opretter en ordre og straks refresher listen kan opleve at ordren ikke er der endnu. Det problem skal designes bevidst rundt om, typisk med UI-feedback eller optimistic updates. En variant der ofte overses er 'CQRS light' — samme database, samme skema, men forskellige DAO-klasser til read og write. Det giver ikke skalerbarhedsfordele, men det giver kode-struktur og gør senere udspaltning til rigtig CQRS lettere. For teams der lige er begyndt at flirte med mønstret, er det ofte det rigtige første skridt. Mønstret er mest værdifuldt i systemer med asymmetriske workloads: 90% reads mod 10% writes, eller omvendt. For CRUD-heavy interne værktøjer er CQRS overkill — kompleksiteten er ikke betalt tilbage. Regnestykket handler ikke om system-størrelse alene men om hvor asymmetrisk læsning og skrivning er, og hvor mange forskellige read-mønstre applikationen skal understøtte.

Problem

Klassiske CRUD-arkitekturer tvinger read og write ind i samme datamodel. Det giver problemer ved skala: læsninger til dashboards låser tabeller for skrivninger, denormalisering til queries komplicerer commands, og forskellige clients (mobil, web, admin) har vidt forskellige data-behov men må dele én model. Enhver optimering på én side kompromitterer den anden.

Løsning

Adskil commands (writes) og queries (reads) i to modeller. Commands går til en normaliseret write-model der garanterer invarianter. Queries læser fra en eller flere denormaliserede read-modeller optimeret til hver use case. De to sider synkroniseres via events, replikering eller batch-jobs. Hver side kan skaleres, indekseres og lagres uafhængigt.

Eksempel

// Klassisk CRUD-model (én model, deles af alt)
class Order {
    id: string
    customerId: string
    items: OrderItem[]
    status: OrderStatus
    
    // Both used for writes and reads
    save() { db.orders.upsert(this) }
    static find(id) { return db.orders.findById(id) }
}

// -----------------------------------------------------------
// CQRS: adskil write og read
// -----------------------------------------------------------

// WRITE SIDE — commands håndhæver domænelogik
class PlaceOrderCommand {
    constructor(customerId, items) {
        this.customerId = customerId
        this.items = items
    }
}

class OrderCommandHandler {
    async handle(cmd: PlaceOrderCommand) {
        // Valideringer, invarianter, business rules
        if (cmd.items.length === 0) {
            throw new Error('Ordre skal have mindst 1 item')
        }
        
        const order = new Order(cmd.customerId, cmd.items)
        await this.writeDb.orders.insert(order)
        
        // Publish event så read-side kan opdatere sig
        await this.eventBus.publish(new OrderPlacedEvent(order))
        
        return order.id
    }
}

// READ SIDE — queries mod denormaliserede projections
class OrderListView {
    orderId: string
    customerName: string      // denormaliseret fra customers-tabellen
    totalAmount: number       // pre-kalkuleret
    itemCount: number
    statusLabel: string       // 'Afsendt' i stedet for enum-værdi
}

class OrderQueryHandler {
    // Læser fra optimeret read-model
    async getCustomerOrders(customerId: string): OrderListView[] {
        return this.readDb.query(`
            SELECT * FROM order_list_view
            WHERE customer_id = $1
            ORDER BY created_at DESC
        `, [customerId])
    }
}

// PROJECTION — abonnerer på events og opdaterer read-model
class OrderListProjection {
    async on(event: OrderPlacedEvent) {
        const customer = await this.readDb.customers.findById(event.customerId)
        await this.readDb.order_list_view.insert({
            orderId: event.orderId,
            customerName: customer.name,
            totalAmount: event.items.reduce((s, i) => s + i.price * i.qty, 0),
            itemCount: event.items.length,
            statusLabel: 'Afventer'
        })
    }
    
    async on(event: OrderShippedEvent) {
        await this.readDb.order_list_view.update(
            { orderId: event.orderId },
            { statusLabel: 'Afsendt' }
        )
    }
}

Fordele

  • +Read og write skaleres uafhængigt — hver side kan have sin egen database og infrastruktur
  • +Read-modeller kan denormaliseres uden at komplicere writes
  • +Flere read-modeller — samme data optimeret til mobil, admin og analytics
  • +Write-modellen fokuserer rent på domænelogik og invarianter
  • +Naturlig kombination med Event Sourcing og audit-krav

Udfordringer

  • !Eventual consistency — read-siden ligger bagud efter writes
  • !To modeller at vedligeholde giver højere kompleksitet
  • !Projections skal genopbygges hvis logikken ændres
  • !Sværere at debugge — data flyder gennem events og handlers
  • !Overkill for CRUD-heavy applikationer uden reelle skala-krav

Anvendelsesområder

  • -E-handel med tunge produktlister og reporting oven på simple ordrer
  • -Systemer med mange forskellige clients der har vidt forskellige data-behov
  • -Domænekomplekse applikationer der bruger DDD
  • -Applikationer der kombineres med Event Sourcing for fuld audit-trail
  • -Systemer hvor read og write har fundamentalt forskellige skaleringskrav

Eksempler fra den virkelige verden

  • -Bank- og finansielle systemer der skal audit-logge alle ændringer
  • -Streaming-tjenester hvor katalog-læsninger dominerer sjældne skrivninger
  • -SaaS-analytics hvor rå eventdata går ind, aggregeringer læses ud
  • -Ordrebehandling i logistik — ét write-flow, mange operationelle dashboards
  • -Trading-systemer der skal håndtere millisekund-writes og sekunder-reads