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.