Queries och projektioner av läsmodeller
Separera läsningar med QueryBus-handlers som bygger på optimerade denormaliserade projektioner.
Queries och projektioner av läsmodeller är en gratis lektion i NestJS: backend-API:er för företag på CoddyKit. Detta är lektion 2 av 4. Ni kan läsa hela lektionen gratis nedan och sedan öva praktiskt i webbläsaren med en inbyggd kodredigerare och en AI-handledare som är tillgänglig dygnet runt. Den ingår i lärvägen för NestJS: backend-API:er för företag, och Era framsteg synkroniseras mellan webben och CoddyKit-appen. Kursen i NestJS: backend-API:er för företag innehåller totalt 4 lektioner.
Varför en separat lässida?
I CQRS (Command Query Responsibility Segregation) delar ni upp systemet i en skrivsida (kommandon som ändrar tillstånd) och en lässida (queries som returnerar data). De två sidorna har fundamentalt olika behov.
- Skrivmodellen är optimerad för konsistens och verksamhetsinvarianter – normaliserade aggregat.
- Läsmodellen är optimerad för snabba läsningar med exakt rätt struktur – denormaliserade projektioner anpassade för varje vy eller endpoint.
En projektion är en förberäknad, query-vänlig vy av era data, som vanligtvis byggs genom att lyssna på domän-events. I stället för att sammanfoga sex tabeller vid varje anrop läser query-handlern en redan strukturerad rad.
Queries är inte kommandon
En query är ett vanligt DTO som beskriver vad anroparen vill läsa. Den innehåller inget beteende och får aldrig ändra tillstånd. I @nestjs/cqrs går queries via QueryBus till en matchande @QueryHandler.
Håll queries fria från domänregler. Deras enda uppgift är att ange en avsikt och bära parametrar (id:n, filter och paginering). Allt det tunga arbetet sker i handlern mot läsmodellen.
export class GetOrderSummaryQuery {
constructor(
public readonly orderId: string,
public readonly tenantId: string,
) {}
}
export class ListCustomerOrdersQuery {
constructor(
public readonly customerId: string,
public readonly page = 1,
public readonly pageSize = 20,
) {}
}QueryBus och QueryHandler
En klass med @QueryHandler(SomeQuery) implementerar IQueryHandler<SomeQuery, Result> och exponerar en execute()-metod. Registrera handlers i modulens providers och skicka sedan en query med queryBus.execute(new SomeQuery(...)).
Observera att handlern läser direkt från en projektionstabell (här order_summary) – inget återskapande av aggregat och ingen uppspelning av events vid anropstillfället.
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OrderSummaryView } from './order-summary.view';
import { GetOrderSummaryQuery } from './get-order-summary.query';
@QueryHandler(GetOrderSummaryQuery)
export class GetOrderSummaryHandler
implements IQueryHandler<GetOrderSummaryQuery, OrderSummaryView> {
constructor(
@InjectRepository(OrderSummaryView)
private readonly repo: Repository<OrderSummaryView>,
) {}
async execute(query: GetOrderSummaryQuery): Promise<OrderSummaryView> {
const row = await this.repo.findOne({
where: { orderId: query.orderId, tenantId: query.tenantId },
});
if (!row) throw new Error('Order summary not found');
return row;
}
}Utforma projektionens struktur
En projektion är denormaliserad med avsikt. Ni duplicerar data så att läsningen blir en uppslagning av en enda rad i en enda tabell. Utforma strukturen utifrån konsumenten (endpointen eller användargränssnittet), inte utifrån er domänmodell.
- Platta ut relationer: lagra kundens namn i orderöversiktsraden.
- Förberäkna summor, antal och etiketter så att API:t inte behöver utföra någon aritmetik.
- Lägg till de index som queryn behöver (t.ex.
(tenantId, customerId, placedAt)).
Den här entiteten mappar till en skrivskyddad tabell som skrivsidan aldrig ändrar direkt.
import { Entity, PrimaryColumn, Column, Index } from 'typeorm';
@Entity('order_summary')
@Index(['tenantId', 'customerId', 'placedAt'])
export class OrderSummaryView {
@PrimaryColumn('uuid')
orderId: string;
@Column('uuid')
tenantId: string;
@Column('uuid')
customerId: string;
@Column()
customerName: string; // denormalized copy
@Column('int')
lineItemCount: number; // precomputed
@Column('numeric', { precision: 12, scale: 2 })
totalAmount: string;
@Column()
status: string;
@Column('timestamptz')
placedAt: Date;
}Bygg projektioner från events
Projektioner hålls uppdaterade av projectors – event-handlers som översätter domän-events till upserts i lästabellen. I @nestjs/cqrs är en projector en @EventsHandler.
Varje event ändrar exakt de kolumner som det påverkar. Projectorn är den enda som skriver till projektionstabellen, vilket tydliggör ägarskapet och undviker konkurrens med kommandosidan.
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OrderPlacedEvent } from '../events/order-placed.event';
import { OrderSummaryView } from './order-summary.view';
@EventsHandler(OrderPlacedEvent)
export class OrderPlacedProjector
implements IEventHandler<OrderPlacedEvent> {
constructor(
@InjectRepository(OrderSummaryView)
private readonly repo: Repository<OrderSummaryView>,
) {}
async handle(event: OrderPlacedEvent): Promise<void> {
await this.repo.upsert(
{
orderId: event.orderId,
tenantId: event.tenantId,
customerId: event.customerId,
customerName: event.customerName,
lineItemCount: event.lines.length,
totalAmount: event.total,
status: 'PLACED',
placedAt: event.occurredAt,
},
['orderId'],
);
}
}Inkrementella uppdateringar per event
De flesta events bygger inte om hela raden – de uppdaterar en del av den. Ett OrderShippedEvent ändrar bara statusen och anger ett leveransdatum. Håll projectors små och specifika för varje event.
Eftersom projectorn äger tabellen är en UPDATE via primärnyckeln billig och fri från konkurrens. Idempotens är viktig här – om samma event spelas upp igen får raden inte skadas (mer om det snart).
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OrderShippedEvent } from '../events/order-shipped.event';
import { OrderSummaryView } from './order-summary.view';
@EventsHandler(OrderShippedEvent)
export class OrderShippedProjector
implements IEventHandler<OrderShippedEvent> {
constructor(
@InjectRepository(OrderSummaryView)
private readonly repo: Repository<OrderSummaryView>,
) {}
async handle(event: OrderShippedEvent): Promise<void> {
await this.repo.update(
{ orderId: event.orderId },
{ status: 'SHIPPED' },
);
}
}Eventuell konsistens är kompromissen
När läsmodellen uppdateras asynkront efter att kommandot har genomförts är projektionen eventuellt konsistent. Under en kort period kan queryn returnera inaktuella data – en order som nyss lades kanske till exempel ännu inte syns i sammanställningslistan.
- Acceptera detta för dashboards, listor, rapporter och sökningar där en liten fördröjning är acceptabel.
- Hantera det i användargränssnittet med optimistiska uppdateringar, eller returnera det nya id:t från kommandot och låt klienten fråga lässidan igen.
- Om ni behöver strikt read-your-writes-beteende kan ni fråga skrivmodellen direkt eller uppdatera projektionen synkront i samma transaktion.
Dokumentera konsistensgarantin för varje endpoint så att konsumenterna vet vad de kan förvänta sig.
Idempotenta projectors
Events levereras vanligtvis minst en gång, så en projector kan ta emot samma event två gånger. Gör handlers idempotenta så att upprepad behandling är ofarlig.
- Använd
upsertellerUPDATEmed nyckel i stället för en blindINSERT. - Spåra positionen för det senast behandlade eventet (en checkpoint) per projektion och hoppa över allt som redan har setts.
- Undvik relativa beräkningar som
count = count + 1om ni inte också deduplicerar med hjälp av eventets id.
Den här lilla hjälpfunktionen visar dedupliceringsidén i ren TypeScript: en mängd med checkpoints skyddar mot dubbel tillämpning.
type Event = { id: string; type: string; orderId: string };
class IdempotentProjection {
private processed = new Set<string>();
private rows = new Map<string, { orderId: string; status: string }>();
apply(event: Event): boolean {
if (this.processed.has(event.id)) return false; // already seen
this.processed.add(event.id);
const row = this.rows.get(event.orderId) ?? { orderId: event.orderId, status: 'NEW' };
if (event.type === 'OrderShipped') row.status = 'SHIPPED';
this.rows.set(event.orderId, row);
return true;
}
status(orderId: string): string | undefined {
return this.rows.get(orderId)?.status;
}
}
const p = new IdempotentProjection();
const e = { id: 'evt-1', type: 'OrderShipped', orderId: 'ord-9' };
console.log(p.apply(e)); // true -> applied
console.log(p.apply(e)); // false -> duplicate ignored
console.log(p.status('ord-9')); // SHIPPEDPaginering och filtrering på lässidan
List-endpoints hör helt och hållet hemma i läsmodellen. Eftersom projektionen redan är platt och indexerad är paginering och filtrering enkla queries med WHERE + LIMIT/OFFSET (eller keyset) – inga sammanfogningar och inget N+1.
Returnera ett litet sid-DTO med objekten och det totala antalet. Begränsa sorteringen till indexerade kolumner så att databasen kan uppfylla ordningen utan filesort.
@QueryHandler(ListCustomerOrdersQuery)
export class ListCustomerOrdersHandler
implements IQueryHandler<ListCustomerOrdersQuery> {
constructor(
@InjectRepository(OrderSummaryView)
private readonly repo: Repository<OrderSummaryView>,
) {}
async execute(q: ListCustomerOrdersQuery) {
const [items, total] = await this.repo.findAndCount({
where: { customerId: q.customerId },
order: { placedAt: 'DESC' },
take: q.pageSize,
skip: (q.page - 1) * q.pageSize,
});
return { items, total, page: q.page, pageSize: q.pageSize };
}
}Koppla in det i controllern
Controllers förblir tunna: översätt HTTP-anropet till en query och skicka den till QueryBus. Ingen verksamhetslogik och ingen åtkomst till repositories i controllern.
Detta håller transportlagret frikopplat från hur läsningar hanteras. Ni kan senare byta ut projektionens lagring (Postgres → Elasticsearch) utan att ändra controllern.
import { Controller, Get, Param, Query } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GetOrderSummaryQuery } from './get-order-summary.query';
import { ListCustomerOrdersQuery } from './list-customer-orders.query';
@Controller('orders')
export class OrdersQueryController {
constructor(private readonly queryBus: QueryBus) {}
@Get(':id/summary')
getSummary(@Param('id') id: string, @Query('tenantId') tenantId: string) {
return this.queryBus.execute(new GetOrderSummaryQuery(id, tenantId));
}
@Get()
list(@Query('customerId') customerId: string, @Query('page') page = 1) {
return this.queryBus.execute(
new ListCustomerOrdersQuery(customerId, Number(page)),
);
}
}Bygg om projektioner
En stor fördel med eventbaserade läsmodeller är att ni kan bygga om en projektion från grunden genom att spela upp eventströmmen igen. Då kan ni ändra lässtruktur, rätta ett fel i en projector eller lägga till en helt ny vy utan att manuellt migrera gamla data.
- Töm projektionstabellen (eller versionshantera den).
- Spela upp alla relevanta events genom projectorn i rätt ordning.
- Spåra en checkpoint så att ni kan återuppta arbetet och växla över läsningarna när ni har kommit ikapp.
Strategier som blue/green-projektioner bygger den nya versionen parallellt med den gamla och växlar sedan läsarna atomärt – migreringar av läsmodellen utan driftstopp.
Snabbtest: Servera en snabb listläsning
Ni behöver en endpoint med hög trafik som listar en kunds ordrar med kundnamn, totalsumma och antal artiklar per rad. Data finns utspridda i de normaliserade tabellerna orders, order_lines och customers. Läsningar är betydligt fler än skrivningar, och en liten fördröjning i data är acceptabel.
Vilket är det lämpligaste CQRS-tillvägagångssättet?
Sammanfattning
Ni har separerat läsningar från skrivningar med frågesidan i CQRS:
- Frågor är beteendefria DTO:er som skickas via
QueryBustill klasser med@QueryHandler. - Projektioner är denormaliserade, indexerade lästabeller som är utformade för konsumenten och som ägs och uppdateras av projektorer (
@EventsHandler) som reagerar på domänhändelser. - Asynkron projektion medför eventual consistency — utmärkt för listor och instrumentpaneler; hantera read-your-writes medvetet när det behövs.
- Projektorer måste vara idempotenta (upsert med nyckel, kontrollpunkter) eftersom leveransen sker minst en gång.
- Läsmodeller kan byggas om eller migreras genom att spela upp händelser igen, vilket möjliggör blue/green och vyändringar utan driftstopp.
Vinsten: läsningar blir uppslag i en enda rad och en enda tabell — snabba, skalbara och frikopplade från aggregaten på skrivsidan.
Lär dig TypeScript med en AI-lärare – gratis
Skriv och kör riktig kod i webbläsaren, få omedelbar hjälp av en AI-lärare dygnet runt och fortsätt där du slutade – på webben eller i appen.
- Kurser
- 20
- Lektioner
- 76
Vanliga frågor
Är lektionen ”Queries och projektioner av läsmodeller” gratis?
Ja – hela texten till ”Queries och projektioner av läsmodeller” kan läsas gratis här på webben. Om Ni vill öva interaktivt med en inbyggd kodredigerare och en AI-handledare som är tillgänglig dygnet runt och låsa upp resten av kursen i NestJS: backend-API:er för företag, kan Ni uppgradera till CoddyKit PRO. Kursen i NestJS: backend-API:er för företag innehåller totalt 4 lektioner.
Vad lär jag mig i ”Queries och projektioner av läsmodeller”?
Separera läsningar med QueryBus-handlers som bygger på optimerade denormaliserade projektioner. Ni övar på NestJS: backend-API:er för företag med praktisk kod som körs direkt i webbläsaren, medan en AI-handledare som är tillgänglig dygnet runt svarar på Era frågor under lektionen.
Behöver jag någon erfarenhet för att börja lära mig NestJS: backend-API:er för företag?
Du behöver inga förkunskaper. Utbildningen i NestJS: backend-API:er för företag på CoddyKit är upplagd för allt från nybörjare till avancerade elever, så att du kan börja här eller från början och gå fram i din egen takt. Detta är lektion 2 av 4.
Hur lång tid tar lektionen ”Queries och projektioner av läsmodeller”?
De flesta CoddyKit-lektioner tar cirka 5–10 minuter. Varje lektion är kort och interaktiv, så att du gör stadiga framsteg och kan fortsätta precis där du slutade – på webben eller i appen.
Kan jag skriva och köra kod i den här NestJS: backend-API:er för företag-lektionen?
Ja. Varje NestJS: backend-API:er för företag-lektion innehåller en inbyggd kodredigerare, så att du kan skriva och köra riktig kod direkt i webbläsaren och få omedelbar AI-feedback – utan lokal installation.
Alla lektioner i den här kursen
- Kommandon, handlers och Command Bus
- Queries och projektioner av läsmodeller
- Domänhändelser och AggregateRoot
- Sagas för långvariga arbetsflöden