CQRS mendapatkan kompleksitasnya ketika model baca dan model tulis benar-benar berbeda — misalnya pada alur kerja persetujuan di mana model tulis menangani transisi mesin status dan aturan audit, sementara model baca cukup menampilkan persetujuan yang tertunda per pengguna. Untuk modul yang berat CRUD seperti profil pengguna atau buku alamat, lapisan layanan sederhana dengan PostgreSQL adalah arsitektur yang tepat. Martin Fowler sendiri memperingatkan bahwa untuk sebagian besar sistem, CQRS menambahkan kompleksitas yang berisiko.
CQRS dan Event Sourcing adalah pola terpisah yang sering dibahas bersama, tetapi bisa digunakan secara independen. CQRS memisahkan model tulis (command) dari model baca (query), sementara Event Sourcing menyimpan urutan event yang menyebabkan status saat ini alih-alih status itu sendiri. Post ini merekomendasikan CQRS tanpa Event Sourcing sebagai pendekatan paling pragmatis — Anda mendapatkan manfaat arsitektural dari pemisahan jalur baca dan tulis tanpa overhead operasional dari pengelolaan log event.
Paket @nestjs/cqrs menyediakan Command Bus, Query Bus, dan Event Bus. Command adalah kelas TypeScript biasa; CommandHandler mengimplementasikan antarmuka ICommandHandler dan terdaftar di modul. Query mengikuti pola yang sama melalui QueryHandler. Event dikeluarkan setelah perubahan status dan diproses oleh EventHandler untuk efek samping seperti mengirim email atau memperbarui proyeksi.
Saga dalam @nestjs/cqrs mendengarkan event domain dan mengirimkan command sebagai respons, bertindak sebagai penghubung event-driven antar bounded context untuk proses bisnis multi-langkah — misalnya, mengirimkan GeneratePdfCommand dan SendEmailCommand ketika event InvoiceCreated dipecat. Saga harus idempoten karena event yang sama bisa diproses lebih dari sekali akibat retry; post ini merekomendasikan penggunaan correlation ID pada command dan event untuk mendeteksi dan melewati duplikat.
Evolusi skema event lebih sulit daripada migrasi skema relasional — menambahkan bidang wajib ke event membutuhkan logika migrasi untuk mengisi ulang replay lama. Pembacaan data memerlukan tabel proyeksi yang terus diperbarui (menambah kompleksitas operasional) atau memutar ulang event pada setiap pembacaan (menambah latensi). Debugging juga lebih sulit karena Anda harus memutar ulang riwayat event untuk memahami status saat ini. Greg Young, yang mempopulerkan pola ini, merekomendasikan penggunaannya hanya untuk bounded context tertentu di mana riwayat audit lengkap atau kueri temporal adalah kebutuhan produk yang nyata.
Peringatan Martin Fowler tentang CQRS adalah salah satu peringatan yang paling banyak dikutip dalam arsitektur perangkat lunak: 'Untuk sebagian besar sistem, CQRS menambahkan kompleksitas yang berisiko.' Dia benar. Saya telah membangun modul ERP di mana lapisan layanan sederhana dengan pembacaan dan penulisan PostgreSQL adalah arsitektur yang benar. CQRS mendapatkan kompleksitasnya pada modul tertentu — mesin alur kerja persetujuan — di mana model baca (siapa yang dapat menyetujui berikutnya, apa yang tertunda) benar-benar berbeda dari model tulis.
CQRS (Command Query Responsibility Segregation) pertama kali dideskripsikan oleh Greg Young. Ide intinya: gunakan model yang berbeda untuk memperbarui informasi daripada model yang Anda gunakan untuk membacanya. Dalam praktiknya, ini berarti aplikasi Anda memiliki dua stack — Command (operasi tulis yang mengubah status) dan Query (operasi baca yang mengembalikan data).
Kekuatan nyata CQRS muncul ketika model baca dan model tulis Anda perlu berbeda. Alur kerja persetujuan memiliki model tulis yang kompleks (aturan bisnis tentang siapa yang dapat menyetujui apa, transisi mesin status, jejak audit) dan model baca yang sederhana (tunjukkan persetujuan yang tertunda untuk pengguna ini). Memisahkan keduanya memungkinkan Anda mengoptimalkan masing-masing secara independen.
CQRS Architecture with @nestjs/cqrs
┌──────────────────────────────────────────────────────────────┐
│ Client (React) │
└──────────────────┬───────────────────┬───────────────────────┘
│ Commands │ Queries
│ (write intent) │ (read request)
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────────────┐
│ Command Bus │ │ Query Bus │
│ ApproveInvoiceCommand │ │ GetPendingApprovalsQuery │
└────────────┬────────────┘ └────────────────┬────────────────┘
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────────────┐
│ ApproveInvoice │ │ GetPendingApprovals │
│ CommandHandler │ │ QueryHandler │
│ │ │ │
│ - Validate rules │ │ - Read from projection table │
│ - Update write model │ │ - No business rules │
│ - Emit InvoiceApproved │ │ - Fast, indexed query │
└────────────┬────────────┘ └─────────────────────────────────┘
│ event
▼
┌─────────────────────────┐
│ Event Bus │
│ InvoiceApproved │
└──────┬──────────────────┘
│
┌────┴────────────────────────────┐
▼ ▼
ApprovalProjection InvoiceSaga
(updates read model) (sends approval email)Dari membangun alur kerja persetujuan untuk ERP Commsult: gunakan CQRS untuk modul di mana model baca secara material berbeda dari model tulis. Lewati CQRS untuk modul yang berat CRUD seperti profil pengguna, buku alamat, atau halaman pengaturan sederhana. Paket @nestjs/cqrs membuat pola dapat diakses, tetapi menerapkannya tanpa pandang bulu menambahkan boilerplate tanpa manfaat.
Event sourcing menyimpan urutan event yang menyebabkan status saat ini, bukan status saat ini itu sendiri. Alih-alih UPDATE invoices SET status='paid', Anda menambahkan event InvoicePaid ke log event. Status saat ini diturunkan dengan memutar ulang riwayat event. Manfaatnya: jejak audit lengkap, replay event, dan kueri temporal. Biayanya: evolusi skema event lebih sulit daripada migrasi skema relasional.
// @nestjs/cqrs implementation
// 1. Command
export class ApproveInvoiceCommand {
constructor(
public readonly invoiceId: string,
public readonly approverId: string,
public readonly level: number,
) {}
}
// 2. Command Handler
@CommandHandler(ApproveInvoiceCommand)
export class ApproveInvoiceHandler implements ICommandHandler<ApproveInvoiceCommand> {
constructor(
private repo: InvoiceRepository,
private eventBus: EventBus,
) {}
async execute(cmd: ApproveInvoiceCommand) {
const invoice = await this.repo.findById(cmd.invoiceId);
invoice.approve(cmd.approverId, cmd.level); // domain logic
await this.repo.save(invoice);
this.eventBus.publish(new InvoiceApprovedEvent(invoice.id, cmd.approverId));
}
}
// 3. Query + Handler
export class GetPendingApprovalsQuery { constructor(public userId: string) {} }
@QueryHandler(GetPendingApprovalsQuery)
export class GetPendingApprovalsHandler implements IQueryHandler<GetPendingApprovalsQuery> {
async execute({ userId }: GetPendingApprovalsQuery) {
// Read from denormalized projection — no joins, no business rules
return this.projectionRepo.findPendingFor(userId);
}
}
// 4. Saga — orchestrates side effects
@Injectable()
export class InvoiceSaga {
@Saga()
invoiceApproved = (events$: Observable<any>): Observable<ICommand> => {
return events$.pipe(
ofType(InvoiceApprovedEvent),
map(event => new SendApprovalEmailCommand(event.invoiceId, event.approverId)),
);
}
}NestJS menyediakan paket @nestjs/cqrs yang mengimplementasikan pola Command Bus, Query Bus, dan Event Bus. Command adalah kelas TypeScript biasa dengan properti. CommandHandler mengimplementasikan antarmuka ICommandHandler dan terdaftar di modul. Event dikeluarkan setelah perubahan status dan ditangani oleh EventHandler untuk efek samping.
Pikirkan matang-matang sebelum berkomitmen pada event sourcing untuk seluruh aplikasi. Evolusi skema event itu menyakitkan — jika Anda menambahkan bidang wajib ke event, Anda memerlukan logika migrasi untuk mengisi ulang ketika memutar ulang event lama. Men-debug lebih sulit — Anda perlu memutar ulang riwayat event untuk memahami status saat ini. Greg Young sendiri merekomendasikan menggunakannya hanya untuk konteks terikat tertentu.
Proyeksi adalah sisi baca dari event sourcing — mereka berlangganan event dan mempertahankan tampilan data yang didenormalisasi dan dioptimalkan kueri. Untuk alur kerja persetujuan: ApprovalProjection mendengarkan event ApprovalRequested, ApprovalGranted, dan ApprovalRejected dan mempertahankan tabel yang menunjukkan status saat ini setiap permintaan dan penyetuju berikutnya.
Dalam @nestjs/cqrs, Saga adalah kelas yang mendengarkan event domain dan mengirimkan command sebagai respons. Saga menangani koreografi proses bisnis multi-langkah — ketika InvoiceCreated dipecat, saga mengirimkan GeneratePdfCommand dan SendEmailCommand. Saga harus idempoten — jika event yang sama diproses dua kali, saga tidak boleh mengirimkan command duplikat.
Versi paling pragmatis dari pola ini adalah CQRS tanpa event sourcing. Pisahkan command handler (operasi tulis yang menggunakan model domain penuh) dari query handler (bacaan yang mengkueri proyeksi atau tampilan yang dioptimalkan) — tetapi tetap simpan status sebagai status saat ini di PostgreSQL, bukan sebagai log event. Ini memberikan sebagian besar manfaat arsitektural tanpa kompleksitas operasional event sourcing.