CQRS
ArkitekturCommand 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