CQRS (Command Query Responsibility Segregation) — Design Patterns

CQRS memisahkan operasi write (Command) dari read (Query) ke dalam model berbeda. Tujuannya: skalabilitas dan optimasi terpisah. Masalah yang dipecahkan: Dalam

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:

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:

  1. Tampilkan optimistic UI (asumsi sukses, rollback kalau fail)
  2. Tunggu ack dari read side sebelum return response (sacrifice latency)
  3. 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: