NestJS Enterprise Backend APIs · บทเรียน

การสอบถามและโพรเจกชันของโมเดลการอ่าน

แยกการอ่านด้วยตัวจัดการของ QueryBus ที่ใช้โพรเจกชันแบบลดความเป็นนอร์มัลไลซ์ซึ่งได้รับการปรับประสิทธิภาพ

บทเรียน 2 จาก 413 ขั้นตอน

การสอบถามและโพรเจกชันของโมเดลการอ่าน เป็นบทเรียน NestJS Enterprise Backend APIs ฟรีบน CoddyKit นี่คือบทเรียนที่ 2 จากทั้งหมด 4 บทเรียน คุณสามารถอ่านบทเรียนทั้งหมดด้านล่างฟรี — จากนั้นลองปฏิบัติด้วยตัวคุณเองในเบราว์เซอร์พร้อมตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7 บทเรียนนี้เป็นส่วนหนึ่งของเส้นทางการเรียน NestJS Enterprise Backend APIs และความก้าวหน้าของคุณจะซิงค์ข้ามเว็บและแอป CoddyKit คอร์ส NestJS Enterprise Backend APIs มีบทเรียนทั้งหมด 4 บทเรียน

บางส่วนของบทเรียนนี้ยังไม่ได้รับการแปล และแสดงเป็นภาษาอังกฤษ

Why a Separate Read Side?

In CQRS (Command Query Responsibility Segregation) you split the system into a write side (commands that mutate state) and a read side (queries that return data). The two have fundamentally different needs.

  • The write model is optimized for consistency and business invariants — normalized aggregates.
  • The read model is optimized for fast, shape-perfect reads — denormalized projections tailored to each screen or endpoint.

A projection is a precomputed, query-friendly view of your data, usually built by listening to domain events. Instead of joining six tables at request time, the query handler reads one already-shaped row.

Queries Are Not Commands

A query is a plain DTO describing what the caller wants to read. It carries no behavior and must never mutate state. In @nestjs/cqrs, queries flow through the QueryBus to a matching @QueryHandler.

Keep queries free of domain rules. Their only job is to name an intent and carry parameters (ids, filters, paging). All the heavy lifting lives in the handler against the read model.

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,
  ) {}
}

The QueryBus and QueryHandler

A @QueryHandler(SomeQuery) class implements IQueryHandler<SomeQuery, Result> and exposes an execute() method. Register handlers in the module's providers, then dispatch with queryBus.execute(new SomeQuery(...)).

Notice the handler reads directly from a projection table (here order_summary) — no aggregate rehydration, no event replay at request time.

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;
  }
}

Designing the Projection Shape

A projection is denormalized on purpose. You duplicate data so the read is a single-row, single-table lookup. Design the shape around the consumer (the endpoint or UI), not around your domain model.

  • Flatten relationships: store the customer name inside the order summary row.
  • Precompute totals, counts, and labels so the API does zero arithmetic.
  • Add the indexes the query needs (e.g., (tenantId, customerId, placedAt)).

This entity maps to a read-only table that the write side never touches directly.

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;
}

Building Projections from Events

Projections are kept up to date by projectors — event handlers that translate domain events into upserts on the read table. In @nestjs/cqrs a projector is an @EventsHandler.

Each event mutates exactly the columns it affects. The projector is the only writer of the projection table, which keeps ownership clear and avoids contention with the command side.

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'],
    );
  }
}

Incremental Updates per Event

Most events do not rebuild the whole row — they patch a slice of it. An OrderShippedEvent only flips the status and stamps a ship date. Keep projectors small and event-specific.

Because the projector owns the table, an UPDATE by primary key is cheap and contention-free. Idempotency matters here — replaying the same event must not corrupt the row (more on that soon).

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' },
    );
  }
}

Eventual Consistency Is the Trade-off

When the read model is updated asynchronously after the command commits, the projection is eventually consistent. For a brief window the query may return stale data — for example, an order that was just placed might not yet appear in its summary list.

  • Embrace it for dashboards, lists, reports, and search where small lag is fine.
  • Mitigate it in the UI: optimistic updates, or return the new id from the command and let the client poll the read side.
  • For strict read-your-writes needs, query the write model directly or update the projection synchronously inside the same transaction.

Document the consistency guarantee per endpoint so consumers know what to expect.

Idempotent Projectors

Event delivery is usually at-least-once, so a projector may receive the same event twice. Make handlers idempotent so reprocessing is harmless.

  • Use upsert / UPDATE by key rather than blind INSERT.
  • Track the last processed event position (a checkpoint) per projection and skip anything you've already seen.
  • Avoid relative math like count = count + 1 unless you also dedupe by event id.

This small helper shows the dedupe idea in pure TypeScript: a checkpoint set guards against double application.

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')); // SHIPPED

Paging and Filtering on the Read Side

List endpoints belong entirely to the read model. Because the projection is already flat and indexed, paging and filtering are simple WHERE + LIMIT/OFFSET (or keyset) queries — no joins, no N+1.

Return a small page DTO with the items plus total count. Keep sorting on indexed columns so the database can satisfy the order without a 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 };
  }
}

Wiring It in the Controller

Controllers stay thin: translate the HTTP request into a query and hand it to the QueryBus. No business logic, no repository access in the controller.

This keeps the transport layer decoupled from how reads are served. You could later swap the projection store (Postgres → Elasticsearch) without touching the controller.

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)),
    );
  }
}

Rebuilding Projections

A huge advantage of event-sourced read models: you can rebuild a projection from scratch by replaying the event stream. This lets you change the read shape, fix a projector bug, or add a brand-new view without migrating old data manually.

  • Truncate (or version) the projection table.
  • Replay every relevant event through the projector in order.
  • Track a checkpoint so you can resume and switch reads over when caught up.

Strategies like blue/green projections build the new version alongside the old, then flip readers atomically — zero-downtime read-model migrations.

Quick Check: Serving a Fast List Read

You need a high-traffic endpoint that lists a customer's orders with customer name, total, and item count per row. The data is spread across normalized orders, order_lines, and customers tables. Reads vastly outnumber writes and small staleness is acceptable.

What is the most appropriate CQRS approach?

Recap

You separated reads from writes with the query side of CQRS:

  • Queries are behavior-free DTOs dispatched via the QueryBus to @QueryHandler classes.
  • Projections are denormalized, indexed read tables shaped for the consumer, owned and updated by projectors (@EventsHandler) reacting to domain events.
  • Async projection brings eventual consistency — great for lists/dashboards; handle read-your-writes deliberately when needed.
  • Projectors must be idempotent (upsert by key, checkpoints) because delivery is at-least-once.
  • Read models can be rebuilt or migrated by replaying events, enabling blue/green, zero-downtime view changes.

The payoff: reads become single-row, single-table lookups — fast, scalable, and decoupled from your write-side aggregates.

เริ่มต้นได้ฟรี

เรียนรู้ TypeScript ด้วย AI tutor — ฟรี

เขียนและเรียกใช้โค้ดจริงในเบราว์เซอร์ของคุณ รับความช่วยเหลือทันทีจาก AI tutor 24/7 และเรียนรู้ต่อจากที่คุณหยุดบนเว็บหรือในแอป

คอร์ส
20
บทเรียน
76

คำถามที่พบบ่อย

บทเรียน “การสอบถามและโพรเจกชันของโมเดลการอ่าน” ฟรีหรือไม่

ใช่ — ข้อความเต็มของ “การสอบถามและโพรเจกชันของโมเดลการอ่าน” ฟรีให้อ่านที่นี่บนเว็บ เพื่อปฏิบัติแบบโต้ตอบ (ตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7) และปลดล็อคส่วนที่เหลือของคอร์ส NestJS Enterprise Backend APIs ให้อัปเกรดเป็น CoddyKit PRO คอร์ส NestJS Enterprise Backend APIs มีบทเรียนทั้งหมด 4 บทเรียน

คุณจะเรียนรู้อะไรในบทเรียน “การสอบถามและโพรเจกชันของโมเดลการอ่าน”

แยกการอ่านด้วยตัวจัดการของ QueryBus ที่ใช้โพรเจกชันแบบลดความเป็นนอร์มัลไลซ์ซึ่งได้รับการปรับประสิทธิภาพ คุณปฏิบัติ NestJS Enterprise Backend APIs ด้วยโค้ดที่ใช้งานได้จริงที่คุณเรียกใช้โดยตรงในเบราว์เซอร์ และติวเตอร์ AI ตลอด 24/7 ตอบคำถามของคุณขณะที่คุณไปผ่านบทเรียน

คุณต้องมีประสบการณ์ก่อนที่จะเริ่มเรียน NestJS Enterprise Backend APIs หรือไม่

ไม่จำเป็นต้องมีประสบการณ์มาก่อน NestJS Enterprise Backend APIs บน CoddyKit ออกแบบมาสำหรับผู้เริ่มต้นไปจนถึงผู้เรียนขั้นสูง คุณสามารถเริ่มต้นที่นี่หรือเริ่มจากตัวแรกและเรียนด้วยความเร็วของคุณเอง นี่คือบทเรียนที่ 2 จากทั้งหมด 4 บทเรียน

บทเรียน “การสอบถามและโพรเจกชันของโมเดลการอ่าน” ใช้เวลานานแค่ไหน

บทเรียน CoddyKit ส่วนใหญ่ใช้เวลาประมาณ 5–10 นาที แต่ละบทเรียนจึงสั้นและเป็นแบบโต้ตอบ คุณสามารถก้าวหน้าอย่างต่อเนื่องและกลับมาเรียนต่อจากตรงที่เพิ่งหยุดบนเว็บและแอปได้เลย

ฉันเขียนและรันโค้ดในบทเรียน NestJS Enterprise Backend APIs นี้ได้ไหม

ได้ บทเรียน NestJS Enterprise Backend APIs ทุกบทมีตัวแก้ไขโค้ดในตัว คุณจึงเขียนและรันโค้ดจริงได้เลยในเบราว์เซอร์ และได้รับข้อเสนอแนะจาก AI ในทันที — ไม่ต้องติดตั้งในเครื่องของคุณ

บทเรียนทั้งหมดในหลักสูตรนี้

  1. คำสั่ง ตัวจัดการ และรถบัสคำสั่ง
  2. การสอบถามและโพรเจกชันของโมเดลการอ่าน
  3. เหตุการณ์โดเมนและ AggregateRoot
  4. ซากาสำหรับเวิร์กโฟลว์ที่ทำงานเป็นเวลานาน
← กลับไปที่ NestJS Enterprise Backend APIs