CQRS: memisahkan write model dan read model, command bus query bus dan mediator pattern, kapan CQRS-lite satu DB dua jalur vs CQRS penuh dengan denormalized read store — @nestjs/cqrs CommandBus QueryBus handlers, handler struct Fiber, action class dan laravel-mediator di Laravel — praktik split endpoint create-order dan list-orders optimized

Perhatikan kebohongan arsitektur yang biasa: model yang dioptimalkan untuk menulis (normalisasi, invarian ketat) dipaksa melayani pembacaan (query kompleks, laporan, agregasi). CQRS mengakui kenyataan itu: jalur menulis dan membaca punya kebutuhan berbeda — jadi pisahkan mereka.
PlaceOrder), divalidasi terhadap invarian, return minim.Mediator pattern (GoF) dalam wujud modern: pemanggil tidak tahu handler siapa; ia menyerahkan command/query ke bus, bus mencari handler-nya. Manfaatnya: middleware lintas-perintah (logging, validasi, transaksi) tinggal ditempel di bus.
Tingkatan adopsi yang realistis:
| Tingkat | Bentuk | Kapan Cukup |
|---|---|---|
| CQRS-lite | Satu DB, dua jalur kode: write via aggregate/repository, read via query object langsung | 90% aplikasi — asimetri ringan |
| CQRS + read store | Read model denormalisasi tersinkron via event | Query berat/berbeda skala baca-tulis |
| CQRS penuh + event sourcing | State = replay events (ep.22) | Audit penuh, replay historis |
Warning
CQRS penuh tanpa kebutuhan nyata = distributed monolith kecil: sinkronisasi read model, eventual consistency, debugging lebih sulit. Mulai dari lite.
// Command + handler:
export class PlaceOrder { constructor(
public readonly customerId: string,
public readonly items: OrderItemDto[],
) {} }
@CommandHandler(PlaceOrder)
export class PlaceOrderHandler implements ICommandHandler<PlaceOrder> {
constructor(private orders: OrdersRepository, private eventBus: EventBus2) {}
async execute(cmd: PlaceOrder) {
const order = Order.place(cmd.customerId, cmd.items);
await this.orders.save(order);
this.eventBus.publishAll(order.pullEvents());
return { orderId: order.id };
}
}
// Query handler - BEBAS aggregate, langsung proyeksi:
@QueryHandler(ListOrders)
export class ListOrdersHandler implements IQueryHandler<ListOrders> {
constructor(private db: PrismaService) {}
async execute(q: ListOrders) {
return this.db.orderView.findMany({ where: { customerId: q.customerId } });
}
}
// Controller: bus saja, nol logika:
@Post() create(@Body() dto) { return this.commandBus.execute(new PlaceOrder(...dto)); }
@Get() list(@Query() q) { return this.queryBus.execute(new ListOrders(q)); }Kunci CQRS+read-store: proyeksi — subscriber event OrderPlaced yang menulis baris siap-baca:
CREATE TABLE orders_read (
order_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
total_minor INT NOT NULL,
items_count INT NOT NULL,
placed_at TIMESTAMPTZ NOT NULL
); -- ditulis oleh projector saat OrderPlaced; query list tak pernah JOIN lagiTarget outline: split endpoint create-order (command) & list-orders (read model optimized).
1. Endpoint POST /orders -> jalur COMMAND:
DTO -> command object -> handler -> aggregate Order (ep.19)
2. Endpoint GET /orders?customerId= -> jalur QUERY:
query object -> tabel/view orders_read (bukan aggregate!)
3. Buat proyeksi: listener OrderPlaced menulis orders_read
4. Benchmark: seed 100k order; bandingkan GET list sebelum
(JOIN via repo) vs sesudah (read table flat)
5. Tulis ADR mini: cukup CQRS-lite atau butuh read store?
(data benchmark = argumen)Tip
Untuk UX yang sensitif delay, pola umum: response POST mengembalikan orderId, UI redirect ke detail yang dibaca dari WRITE side (by-id konsisten), list baru pakai read model.
Rangkuman episode ini:
Episode 21 melangkah ke integrasi antar bagian sistem: Event-Driven Architecture — broker, idempotency, saga, dan order-created yang memicu email + statistik di ketiga stack. Sampai jumpa!