CQRS memisahkan operasi write (Command) dari read (Query) ke dalam model berbeda. Tujuannya: skalabilitas dan optimasi terpisah.
Masalah yang dipecahkan:
Dalam CRUD tradisional, satu model melayani read dan write:
User ──► Controller ──► UserModel ──► Database
(save/load)
Masalah skala:
- Read jauh lebih sering dari write (biasanya 10:1 atau 100:1)
- Read butuh denormalized data (join, aggregasi)
- Write butuh strict validation dan consistency
- Satu model harus melayani keduanya → kompromi di dua sisi
CQRS memisah:
User ─► Command ──► Write Model ──► Event/DB
│ │
└──► Query ──► Read Model ◄──────────┘
(denormalized)
Command side:
// Command = intent (imperative)
class RegisterUserCommand {
constructor(email, password, name) {
this.email = email;
this.password = password;
this.name = name;
}
}
class RegisterUserHandler {
async handle(cmd) {
// Validation ketat
if (!isValidEmail(cmd.email)) throw new Error("Invalid email");
if (await userRepo.exists(cmd.email)) throw new Error("Taken");
// Persist ke write model
const hash = await bcrypt.hash(cmd.password, 10);
const user = await userRepo.create({ email: cmd.email, hash, name: cmd.name });
// Emit event untuk sync ke read model
await eventBus.publish({ type: "UserRegistered", userId: user.id, email: cmd.email });
}
}
Query side:
// Query = pertanyaan (tidak mutasi state)
class GetUserProfileQuery {
constructor(userId) { this.userId = userId; }
}
class GetUserProfileHandler {
async handle(query) {
// Read dari denormalized table/cache
return await userProjectionRepo.findById(query.userId);
// userProjection sudah join user + profile + stats, siap pakai
}
}
Tingkatan CQRS:
Level 1 — Logical split (paling ringan): Pisahkan hanya kode: CommandService vs QueryService. Database yang sama.
Level 2 — Separate models, same DB: Table berbeda untuk write dan read. Write table = normalized (3NF). Read table = denormalized (satu row berisi semua yang frontend butuh).
Level 3 — Separate databases: Write DB (Postgres) + Read DB (Elasticsearch/Redis/read replica). Sync via event stream.
Pilih level berdasarkan problem nyata. Jangan jump ke Level 3 tanpa alasan.
Sync write ke read:
// Setelah Command berhasil
eventBus.on("UserRegistered", async (event) => {
await readDb.userProjection.insert({
id: event.userId,
email: event.email,
displayName: event.name,
createdAt: event.timestamp,
postCount: 0,
followerCount: 0,
});
});
Eventual consistency — konsekuensi penting:
Update ke write → propagasi ke read butuh waktu. User yang baru register lalu langsung query profile bisa dapat 404.
Solusi:
- Tampilkan optimistic UI (asumsi sukses, rollback kalau fail)
- Tunggu ack dari read side sebelum return response (sacrifice latency)
- Gunakan read-your-writes consistency (read dari primary untuk user sendiri)
Kelebihan:
✅ Read dan write bisa di-scale independen (read heavy? tambah replica) ✅ Model optimal per use case (write: normalized + strict validation; read: denormalized + cached) ✅ Bisa pakai tech berbeda (write: Postgres; read: Elasticsearch) ✅ Audit trail natural (semua command bisa di-log)
Kekurangan:
❌ Complexity bertambah (2 model, sinkronisasi, eventual consistency) ❌ Overkill untuk CRUD kecil ❌ Debugging harder (flow lebih panjang)
Kapan pakai:
✅ Read-heavy application (social media, analytics dashboard) ✅ Kompleks business logic di write ✅ Skala besar — write 1k TPS, read 100k TPS ❌ Simple CRUD — pakai single model
Sering dipasangkan dengan:
- Event Sourcing — command menghasilkan event, event disimpan sebagai source of truth, read model dibangun ulang dari event
- Saga pattern — untuk transaksi distributed
- Materialized views — physical table yang di-maintain sebagai read projection