CQRS Architecture #

Coba bayangkan sebuah toko buku. Kasir harus memproses setiap transaksi dengan teliti — memvalidasi stok, menghitung total, menyimpan ke jurnal — satu per satu, dengan konsistensi yang ketat. Tapi di sisi lain, ribuan pengunjung ingin melihat katalog buku, mencari berdasarkan genre, melihat rating, melihat rekomendasi. Jika kasir dan katalog memakai sistem yang sama — satu model, satu database — setiap query katalog yang kompleks akan bersaing dengan transaksi untuk CPU dan lock yang sama. Hasilnya: kasir lambat, katalog lambat, semua orang menunggu. CQRS (Command Query Responsibility Segregation) memecahkan masalah ini dengan prinsip yang intuitif: pisahkan model dan jalur data untuk operasi yang mengubah state (Command) dari operasi yang membaca state (Query). Keduanya punya kebutuhan yang berbeda — konsistensi vs kecepatan, normalisasi vs denormalisasi, validasi bisnis vs presentasi data — dan mereka seharusnya dioptimalkan secara independen.

Prinsip Dasar: Command vs Query #

Dalam CQRS, setiap operasi masuk ke salah satu dari dua kategori yang berbeda secara fundamental:

flowchart LR
    subgraph CMD["Command — Mengubah State"]
        C1["PlaceOrder"]
        C2["CancelOrder"]
        C3["UpdatePrice"]
        C4["ProcessPayment"]
        NOTE_C["✓ Mengubah data\n✓ Harus valid & consistent\n✓ Mengembalikan: void / ID\n✗ Tidak mengembalikan data"]
    end

    subgraph QRY["Query — Membaca State"]
        Q1["GetOrderByID"]
        Q2["ListOrdersByCustomer"]
        Q3["SearchProducts"]
        Q4["GetDashboardStats"]
        NOTE_Q["✓ Hanya membaca\n✓ Dioptimalkan untuk performa\n✓ Mengembalikan: data / DTO\n✗ Tidak mengubah state"]
    end

Aturan dasarnya sederhana: Command tidak mengembalikan data domain, Query tidak mengubah state. Ini adalah pemisahan tanggung jawab yang paling fundamental.

Aspek Command Query
Tujuan Mengubah state sistem Membaca state sistem
Output void, ID, atau acknowledgment Data / DTO
Konsistensi Harus konsisten (ACID) Boleh eventual consistent
Model Rich domain model dengan business rules Flat read model dioptimalkan untuk tampilan
Database Normalized, relational Denormalized, bisa NoSQL, cache, atau search index
Scale Vertikal biasanya cukup Horizontal mudah — read replicas

Tiga Level Implementasi CQRS #

CQRS bisa diimplementasikan dengan tingkat kompleksitas yang berbeda — mulai dari pemisahan logis sampai pemisahan infrastruktur penuh:

flowchart TD
    subgraph L1["Level 1: In-Process Separation\nSatu DB, pisah handler"]
        CH1["CommandHandler"] --> WDB1[(Same DB)]
        QH1["QueryHandler"] --> WDB1
    end

    subgraph L2["Level 2: Separate Model\nSatu DB, dua model"]
        CH2["CommandHandler\nDomain Model"] --> WDB2[(Write DB)]
        QH2["QueryHandler\nRead Model"] --> WDB2
        WDB2 -.->|"sync via trigger/event"| RDB2[(Read Model\nViews/Cache)]
        QH2 --> RDB2
    end

    subgraph L3["Level 3: Separate DB\nInfrastruktur penuh terpisah"]
        CH3["CommandHandler"] --> WDB3[(Write DB\nPostgreSQL)]
        WDB3 -->|"event/CDC"| PROJ["Projector"]
        PROJ --> RDB3[(Read DB\nRedis/Elasticsearch)]
        QH3["QueryHandler"] --> RDB3
    end

    L1 -->|"traffic read meningkat"| L2
    L2 -->|"kebutuhan scale terpisah"| L3

Level 1 adalah titik awal yang paling pragmatis — pisahkan CommandHandler dari QueryHandler dalam kode, tapi masih pakai database yang sama. Ini sudah memberikan clarity dan testability yang jauh lebih baik.

Level 2 menambahkan read model yang terpisah tapi masih dalam database yang sama — misalnya materialized view atau denormalized table yang di-sync via database trigger atau background job.

Level 3 adalah CQRS penuh: write database dan read database benar-benar terpisah, disinkronisasi via event atau Change Data Capture (CDC).


Implementasi Level 1: In-Process CQRS #

Mulailah dari sini. Tidak perlu infrastruktur tambahan — hanya disiplin kode:

// command/handler.go — semua Command dan CommandHandler
package command

import (
	"context"
	"errors"
	"time"
)

// PlaceOrderCommand adalah Command untuk membuat order baru
// ✓ Command = instruksi untuk mengubah state
type PlaceOrderCommand struct {
	CustomerID string
	Items      []PlaceOrderItem
}

type PlaceOrderItem struct {
	ProductID  string
	Quantity   int
	PriceCents int64
}

// PlaceOrderResult adalah hasil minimal dari command — hanya ID, bukan data domain
type PlaceOrderResult struct {
	OrderID string
}

// OrderCommandHandler menangani semua command terkait order
type OrderCommandHandler struct {
	repo     OrderWriteRepository
	userSvc  UserValidator
}

func NewOrderCommandHandler(repo OrderWriteRepository, userSvc UserValidator) *OrderCommandHandler {
	return &OrderCommandHandler{repo: repo, userSvc: userSvc}
}

// Handle memproses PlaceOrderCommand — validasi bisnis ada di sini dan di domain
func (h *OrderCommandHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) (*PlaceOrderResult, error) {
	// Validasi command input
	if cmd.CustomerID == "" {
		return nil, errors.New("customer ID wajib diisi")
	}
	if len(cmd.Items) == 0 {
		return nil, errors.New("order harus memiliki minimal satu item")
	}

	// Validasi cross-service
	if err := h.userSvc.ValidateActive(ctx, cmd.CustomerID); err != nil {
		return nil, errors.New("customer tidak valid: " + err.Error())
	}

	// Buat domain entity — business rules di dalam entity
	order, err := domain.NewOrder(cmd.CustomerID, toOrderItems(cmd.Items))
	if err != nil {
		return nil, err
	}

	// Simpan ke write model
	if err := h.repo.Save(ctx, order); err != nil {
		return nil, err
	}

	// ✓ Command hanya mengembalikan ID — tidak mengembalikan data domain lengkap
	return &PlaceOrderResult{OrderID: order.ID()}, nil
}

// CancelOrderCommand
type CancelOrderCommand struct {
	OrderID string
	Reason  string
}

func (h *OrderCommandHandler) HandleCancel(ctx context.Context, cmd CancelOrderCommand) error {
	order, err := h.repo.FindByID(ctx, cmd.OrderID)
	if err != nil {
		return err
	}

	if err := order.Cancel(cmd.Reason); err != nil {
		return err
	}

	return h.repo.Save(ctx, order)
}
// query/handler.go — semua Query dan QueryHandler
package query

import "context"

// GetOrderQuery adalah Query untuk mendapatkan satu order
// ✓ Query = permintaan baca data
type GetOrderQuery struct {
	OrderID string
}

// OrderDetailView adalah Read Model — dioptimalkan untuk tampilan, bukan domain
// Bisa berisi data yang denormalized (nama produk, nama customer, dsb.)
type OrderDetailView struct {
	OrderID      string          `json:"order_id"`
	CustomerName string          `json:"customer_name"`
	CustomerEmail string         `json:"customer_email"`
	Items        []OrderItemView `json:"items"`
	TotalCents   int64           `json:"total_cents"`
	Status       string          `json:"status"`
	CreatedAt    string          `json:"created_at"`
}

type OrderItemView struct {
	ProductName string `json:"product_name"`
	Quantity    int    `json:"quantity"`
	PriceCents  int64  `json:"price_cents"`
	SubtotalCents int64 `json:"subtotal_cents"`
}

// ListOrdersByCustomerQuery
type ListOrdersByCustomerQuery struct {
	CustomerID string
	Page       int
	PageSize   int
}

// OrderSummaryView adalah Read Model ringkas untuk daftar
type OrderSummaryView struct {
	OrderID    string `json:"order_id"`
	Status     string `json:"status"`
	TotalCents int64  `json:"total_cents"`
	ItemCount  int    `json:"item_count"`
	CreatedAt  string `json:"created_at"`
}

// OrderQueryHandler menangani semua query terkait order
type OrderQueryHandler struct {
	readRepo OrderReadRepository
}

func NewOrderQueryHandler(readRepo OrderReadRepository) *OrderQueryHandler {
	return &OrderQueryHandler{readRepo: readRepo}
}

// Handle memproses GetOrderQuery — murni membaca, tidak ada side effect
func (h *OrderQueryHandler) Handle(ctx context.Context, q GetOrderQuery) (*OrderDetailView, error) {
	return h.readRepo.FindDetailByID(ctx, q.OrderID)
}

// HandleList memproses ListOrdersByCustomerQuery
func (h *OrderQueryHandler) HandleList(
	ctx context.Context,
	q ListOrdersByCustomerQuery,
) ([]*OrderSummaryView, int, error) {
	if q.Page < 1 {
		q.Page = 1
	}
	if q.PageSize < 1 || q.PageSize > 100 {
		q.PageSize = 20
	}
	return h.readRepo.FindSummariesByCustomer(ctx, q.CustomerID, q.Page, q.PageSize)
}

Implementasi Level 3: Separate Read Database #

Untuk sistem dengan read traffic tinggi, read model bisa ditempatkan di database yang berbeda — Redis untuk lookup cepat, Elasticsearch untuk pencarian, atau PostgreSQL read replica dengan materialized view:

// infrastructure/write/postgres_order_repo.go — Write Repository
package write

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"

	"myapp/domain"
)

type PostgresOrderWriteRepo struct {
	db *sql.DB
}

var _ query.OrderWriteRepository = (*PostgresOrderWriteRepo)(nil)

func (r *PostgresOrderWriteRepo) Save(ctx context.Context, order *domain.Order) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	// Simpan order ke write database (normalized)
	_, err = tx.ExecContext(ctx,
		`INSERT INTO orders (id, customer_id, status, total_cents, created_at)
		 VALUES ($1, $2, $3, $4, NOW())
		 ON CONFLICT (id) DO UPDATE
		 SET status = $3, total_cents = $4`,
		order.ID(), order.CustomerID(), order.Status(), order.TotalCents(),
	)
	if err != nil {
		return fmt.Errorf("gagal simpan order: %w", err)
	}

	// Simpan items
	for _, item := range order.Items() {
		_, err = tx.ExecContext(ctx,
			`INSERT INTO order_items (id, order_id, product_id, quantity, price_cents)
			 VALUES ($1, $2, $3, $4, $5)
			 ON CONFLICT (id) DO NOTHING`,
			item.ID(), order.ID(), item.ProductID(), item.Quantity(), item.PriceCents(),
		)
		if err != nil {
			return fmt.Errorf("gagal simpan order item: %w", err)
		}
	}

	// Simpan domain events ke outbox untuk dikirim ke projector
	for _, event := range order.PullEvents() {
		payload, _ := json.Marshal(event)
		_, err = tx.ExecContext(ctx,
			`INSERT INTO outbox_events (id, event_name, payload, created_at, published)
			 VALUES ($1, $2, $3, NOW(), FALSE)`,
			event.EventID(), event.EventName(), payload,
		)
		if err != nil {
			return fmt.Errorf("gagal simpan outbox event: %w", err)
		}
	}

	return tx.Commit()
}
// infrastructure/read/redis_order_repo.go — Read Repository menggunakan Redis
package read

import (
	"context"
	"encoding/json"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"

	"myapp/query"
)

type RedisOrderReadRepo struct {
	client *redis.Client
	ttl    time.Duration
}

func NewRedisOrderReadRepo(client *redis.Client) *RedisOrderReadRepo {
	return &RedisOrderReadRepo{
		client: client,
		ttl:    24 * time.Hour,
	}
}

func (r *RedisOrderReadRepo) FindDetailByID(
	ctx context.Context,
	orderID string,
) (*query.OrderDetailView, error) {
	key := fmt.Sprintf("order:detail:%s", orderID)

	data, err := r.client.Get(ctx, key).Bytes()
	if err == redis.Nil {
		return nil, fmt.Errorf("order tidak ditemukan: %s", orderID)
	}
	if err != nil {
		return nil, fmt.Errorf("gagal baca dari redis: %w", err)
	}

	var view query.OrderDetailView
	if err := json.Unmarshal(data, &view); err != nil {
		return nil, err
	}
	return &view, nil
}

// Store menyimpan read model ke Redis — dipanggil oleh projector
func (r *RedisOrderReadRepo) Store(ctx context.Context, view *query.OrderDetailView) error {
	key := fmt.Sprintf("order:detail:%s", view.OrderID)
	data, err := json.Marshal(view)
	if err != nil {
		return err
	}
	return r.client.Set(ctx, key, data, r.ttl).Err()
}

// Invalidate menghapus read model dari cache — dipanggil saat order diperbarui
func (r *RedisOrderReadRepo) Invalidate(ctx context.Context, orderID string) error {
	key := fmt.Sprintf("order:detail:%s", orderID)
	return r.client.Del(ctx, key).Err()
}

Projector: Sinkronisasi Write ke Read Model #

Projector adalah komponen yang mendengarkan domain event dari write side dan memperbarui read model:

// infrastructure/projector/order_projector.go
package projector

import (
	"context"
	"database/sql"
	"encoding/json"
	"log/slog"
	"time"

	"myapp/events"
	"myapp/query"
	"myapp/infrastructure/read"
)

// OrderProjector membangun dan memperbarui read model berdasarkan domain events
type OrderProjector struct {
	writeDB   *sql.DB         // untuk fetch data yang dibutuhkan read model
	readRepo  *read.RedisOrderReadRepo
	interval  time.Duration
}

func NewOrderProjector(writeDB *sql.DB, readRepo *read.RedisOrderReadRepo) *OrderProjector {
	return &OrderProjector{
		writeDB:  writeDB,
		readRepo: readRepo,
		interval: 500 * time.Millisecond,
	}
}

// Start menjalankan projector loop — baca outbox, update read model
func (p *OrderProjector) Start(ctx context.Context) {
	ticker := time.NewTicker(p.interval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			p.processOutbox(ctx)
		}
	}
}

func (p *OrderProjector) processOutbox(ctx context.Context) {
	rows, err := p.writeDB.QueryContext(ctx,
		`SELECT id, event_name, payload
		 FROM outbox_events
		 WHERE published = FALSE
		 ORDER BY created_at
		 LIMIT 50`,
	)
	if err != nil {
		return
	}
	defer rows.Close()

	for rows.Next() {
		var id, eventName string
		var payload []byte
		rows.Scan(&id, &eventName, &payload)

		if err := p.handleEvent(ctx, eventName, payload); err != nil {
			slog.Error("projector gagal handle event",
				"event_id", id,
				"event_name", eventName,
				"error", err,
			)
			continue
		}

		p.writeDB.ExecContext(ctx,
			`UPDATE outbox_events SET published = TRUE, published_at = NOW() WHERE id = $1`,
			id,
		)
	}
}

func (p *OrderProjector) handleEvent(ctx context.Context, eventName string, payload []byte) error {
	switch eventName {
	case "order.placed":
		var event events.OrderPlaced
		json.Unmarshal(payload, &event)
		return p.projectOrderPlaced(ctx, event)

	case "order.cancelled":
		var event events.OrderCancelled
		json.Unmarshal(payload, &event)
		return p.projectOrderCancelled(ctx, event)

	default:
		return nil // abaikan event yang tidak relevan
	}
}

// projectOrderPlaced membangun OrderDetailView dari event OrderPlaced
func (p *OrderProjector) projectOrderPlaced(ctx context.Context, event events.OrderPlaced) error {
	// Ambil data tambahan yang dibutuhkan read model (nama customer, nama produk)
	customerName, _ := p.fetchCustomerName(ctx, event.CustomerID)

	items := make([]query.OrderItemView, len(event.Items))
	for i, item := range event.Items {
		productName, _ := p.fetchProductName(ctx, item.ProductID)
		items[i] = query.OrderItemView{
			ProductName:   productName,
			Quantity:      item.Quantity,
			PriceCents:    item.PriceCents,
			SubtotalCents: item.PriceCents * int64(item.Quantity),
		}
	}

	view := &query.OrderDetailView{
		OrderID:      event.OrderID,
		CustomerName: customerName,
		Items:        items,
		TotalCents:   event.TotalCents,
		Status:       "pending",
		CreatedAt:    event.OccurredAt.Format("2006-01-02 15:04:05"),
	}

	return p.readRepo.Store(ctx, view)
}

func (p *OrderProjector) projectOrderCancelled(ctx context.Context, event events.OrderCancelled) error {
	// Invalidate read model — akan di-rebuild saat ada query
	return p.readRepo.Invalidate(ctx, event.OrderID)
}

func (p *OrderProjector) fetchCustomerName(ctx context.Context, customerID string) (string, error) {
	var name string
	err := p.writeDB.QueryRowContext(ctx,
		`SELECT full_name FROM users WHERE id = $1`, customerID,
	).Scan(&name)
	return name, err
}

func (p *OrderProjector) fetchProductName(ctx context.Context, productID string) (string, error) {
	var name string
	err := p.writeDB.QueryRowContext(ctx,
		`SELECT name FROM products WHERE id = $1`, productID,
	).Scan(&name)
	return name, err
}

Alur data keseluruhan CQRS Level 3:

sequenceDiagram
    participant C as Client
    participant CMD as Command Handler
    participant WDB as Write DB (PostgreSQL)
    participant OUT as Outbox
    participant PROJ as Projector
    participant RDB as Read DB (Redis)
    participant QRY as Query Handler

    C->>CMD: PlaceOrder(cmd)
    CMD->>WDB: BEGIN TRANSACTION
    CMD->>WDB: INSERT orders
    CMD->>OUT: INSERT outbox_events
    CMD->>WDB: COMMIT
    CMD-->>C: {order_id: "xyz"}

    Note over PROJ: Background process
    PROJ->>OUT: SELECT unpublished events
    PROJ->>WDB: Fetch customer & product names
    PROJ->>RDB: STORE OrderDetailView

    C->>QRY: GetOrder(order_id: "xyz")
    QRY->>RDB: GET order:detail:xyz
    RDB-->>QRY: OrderDetailView
    QRY-->>C: OrderDetailView

Struktur Direktori #

myapp/
├── command/                    ← Command handlers dan command structs
│   ├── place_order.go          ← PlaceOrderCommand + Handler
│   ├── cancel_order.go
│   └── update_price.go
│
├── query/                      ← Query handlers, query structs, dan read models
│   ├── get_order.go            ← GetOrderQuery + Handler
│   ├── list_orders.go
│   ├── views.go                ← Semua Read Model (View structs)
│   └── repository.go          ← Read Repository interface
│
├── domain/                     ← Domain entities dan business rules
│   ├── order.go
│   └── events.go
│
├── infrastructure/
│   ├── write/
│   │   └── postgres_order_repo.go  ← Write Repository impl
│   ├── read/
│   │   ├── redis_order_repo.go     ← Read Repository impl (Redis)
│   │   └── postgres_read_repo.go   ← Read Repository impl (PostgreSQL view)
│   └── projector/
│       └── order_projector.go      ← Projector — sync write to read
│
└── handler/
    └── http/
        └── order_handler.go    ← HTTP handler yang memisahkan CMD vs QRY routes

CQRS + Event Sourcing #

CQRS sering dikombinasikan dengan Event Sourcing, di mana write model tidak menyimpan state terkini tapi menyimpan semua event yang pernah terjadi:

flowchart LR
    CMD["Command\n(PlaceOrder)"] --> AGG["Aggregate\n(Order)"]
    AGG -->|"menghasilkan events"| ES[("Event Store\n[OrderPlaced,\nOrderConfirmed,\nOrderShipped]")]
    ES -->|"replay events"| AGG
    ES -->|"publish"| PROJ["Projector"]
    PROJ --> RM[("Read Model\n(Redis / PG View)")]
    QRY["Query"] --> RM

Dengan Event Sourcing, state order dibangun dengan mereplay semua event:

// domain/order_sourced.go — Order yang menggunakan Event Sourcing
type Order struct {
	id         string
	customerID string
	status     OrderStatus
	items      []OrderItem
	events     []DomainEvent // akumulasi event yang belum di-persist
}

// Apply menerapkan satu event ke state order
func (o *Order) Apply(event DomainEvent) {
	switch e := event.(type) {
	case OrderPlacedEvent:
		o.id = e.OrderID
		o.customerID = e.CustomerID
		o.items = e.Items
		o.status = StatusPending
	case OrderConfirmedEvent:
		o.status = StatusConfirmed
	case OrderCancelledEvent:
		o.status = StatusCancelled
	}
}

// Reconstruct membangun Order dari history event
func Reconstruct(events []DomainEvent) *Order {
	order := &Order{}
	for _, event := range events {
		order.Apply(event)
	}
	return order
}

// Cancel menghasilkan event baru — tidak langsung memodifikasi state
func (o *Order) Cancel(reason string) error {
	if o.status != StatusPending {
		return errors.New("hanya order pending yang bisa dibatalkan")
	}
	event := OrderCancelledEvent{
		OrderID:    o.id,
		Reason:     reason,
		OccurredAt: time.Now(),
	}
	o.events = append(o.events, event) // rekam event
	o.Apply(event)                      // terapkan ke state lokal
	return nil
}

Anti-Pattern yang Harus Dihindari #

// ✗ Command mengembalikan data domain lengkap — melanggar CQRS
func (h *OrderCommandHandler) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (*Order, error) {
	order := buildOrder(cmd)
	h.repo.Save(ctx, order)
	return order, nil // ✗ Command tidak boleh mengembalikan domain object
}

// ✓ Command hanya mengembalikan ID atau void
func (h *OrderCommandHandler) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (string, error) {
	order := buildOrder(cmd)
	h.repo.Save(ctx, order)
	return order.ID(), nil // ✓ hanya ID
}

// ✗ Query handler mengubah state — melanggar CQRS
func (h *OrderQueryHandler) GetOrder(ctx context.Context, orderID string) (*Order, error) {
	order, _ := h.repo.FindByID(ctx, orderID)
	order.ViewCount++ // ✗ query tidak boleh mengubah state
	h.repo.Save(ctx, order)
	return order, nil
}

// ✓ Query handler murni membaca
func (h *OrderQueryHandler) GetOrder(ctx context.Context, orderID string) (*OrderDetailView, error) {
	return h.readRepo.FindDetailByID(ctx, orderID) // ✓ hanya baca
}

// ✗ Menggunakan domain entity sebagai read model — model tidak optimal untuk tampilan
func (h *OrderQueryHandler) ListOrders(ctx context.Context) ([]*domain.Order, error) {
	return h.writeRepo.FindAll(ctx) // ✗ domain entity punya banyak field yang tidak diperlukan tampilan
}

// ✓ Gunakan read model yang dioptimalkan untuk tampilan
func (h *OrderQueryHandler) ListOrders(ctx context.Context) ([]*OrderSummaryView, error) {
	return h.readRepo.FindSummaries(ctx) // ✓ flat view yang dioptimalkan
}

// ✗ Satu handler yang campur command dan query
type OrderService struct{}

func (s *OrderService) PlaceOrder(cmd PlaceOrderCommand) (*Order, error) { ... }
func (s *OrderService) GetOrder(id string) (*Order, error) { ... }
func (s *OrderService) ListOrders() ([]*Order, error) { ... }
// ✗ satu service yang mengandung semua — sulit dioptimalkan dan ditest secara terpisah

// ✓ Handler terpisah per responsibility
type OrderCommandHandler struct { writeRepo OrderWriteRepository }
type OrderQueryHandler struct { readRepo OrderReadRepository }

Kapan CQRS, Kapan Tidak #

CQRS sangat cocok jika:
  ✓ Read traffic jauh lebih tinggi dari write — perlu scale read secara independen
  ✓ Query kompleks yang membutuhkan data dari banyak entity (join banyak tabel)
  ✓ Domain write sangat kaya aturan bisnis tapi read hanya butuh flat data
  ✓ Sistem sudah menggunakan atau akan menggunakan EDA — proyektor natural fit
  ✓ Dibutuhkan audit trail atau event replay (dikombinasikan dengan Event Sourcing)

Hindari CQRS untuk:
  ✗ Sistem CRUD sederhana — overhead tidak justified
  ✗ Tim kecil yang belum familiar — learning curve bisa menghambat delivery
  ✗ Kebutuhan consistency yang sangat ketat di semua query — eventual consistency tidak acceptable
  ✗ MVP atau prototype yang berubah cepat — read model harus ikut diperbarui setiap ada perubahan write model

Checklist Review CQRS Architecture #

COMMAND SIDE:
  □ Command object hanya berisi data yang diperlukan untuk operasi
  □ Command handler hanya mengembalikan ID atau void — tidak domain object
  □ Business rules dan validasi ada di domain entity, bukan di handler
  □ Write repository menggunakan domain entity

QUERY SIDE:
  □ Query handler tidak memiliki side effect — murni baca
  □ Read model (View) berbeda dari domain entity — dioptimalkan untuk tampilan
  □ Read repository menggunakan View struct, bukan domain entity
  □ Query bisa menggunakan database yang berbeda dari write

PROJECTOR:
  □ Projector idempotent — memproses event yang sama dua kali menghasilkan hasil yang sama
  □ Projector menangani semua event yang relevan untuk read model
  □ Error di projector tidak memblokir write side
  □ Lag projector dimonitor — alert jika read model terlalu jauh di belakang write

CONSISTENCY:
  □ Stakeholder memahami dan menerima eventual consistency di read model
  □ Ada mekanisme untuk memberitahu client jika read model sedang out of date
  □ Operasi yang butuh strong consistency menggunakan write side langsung

PENGUJIAN:
  □ Command handler diuji dengan domain entity dan mock repository
  □ Query handler diuji dengan mock read repository
  □ Projector diuji dengan mock event dan verifikasi read model yang dihasilkan
  □ Integration test memverifikasi alur end-to-end: command → event → projector → query

Ringkasan #

  • CQRS memisahkan tanggung jawab baca dan tulis secara fundamental — Command mengubah state dan tidak mengembalikan data domain; Query membaca data dan tidak mengubah state.
  • Mulai dari Level 1 (in-process) — pisahkan CommandHandler dari QueryHandler dalam kode terlebih dahulu; tidak perlu langsung dua database; ini saja sudah memberikan manfaat besar.
  • Read model berbeda dari domain entity — buat View struct yang dioptimalkan untuk tampilan (denormalized, flat, hanya field yang diperlukan); jangan paksa domain entity melayani kebutuhan query.
  • Projector membangun read model dari event — komponen background yang mendengarkan event dari write side dan memperbarui read model; ini adalah jembatan antara dua dunia.
  • Eventual consistency adalah trade-off yang disengaja — read model mungkin beberapa milidetik di belakang write model; ini acceptable untuk sebagian besar use case tapi harus dikomunikasikan ke stakeholder.
  • Command hanya mengembalikan ID atau void — jika client butuh data setelah command, lakukan query terpisah; ini mungkin butuh penyesuaian UX tapi adalah inti dari CQRS.
  • CQRS dan Event Sourcing adalah pair yang kuat tapi berdiri sendiri — CQRS bisa diimplementasikan tanpa Event Sourcing; tambahkan Event Sourcing hanya jika butuh audit trail lengkap atau time travel.
  • Read side bisa menggunakan teknologi berbeda — Redis untuk lookup cepat, Elasticsearch untuk pencarian, ClickHouse untuk analytics; setiap query dioptimalkan dengan tool yang tepat.
  • Cocok untuk sistem read-heavy dengan domain write yang kaya — e-commerce, fintech, ERP; tidak cocok untuk CRUD sederhana atau tim yang belum siap dengan kompleksitas tambahan.
  • Monitor projector lag — perbedaan waktu antara write dan read model harus selalu dimonitor; lag yang terlalu besar adalah sinyal bahwa projector butuh di-scale atau dioptimalkan.

← Sebelumnya: Event-Driven Architecture   Berikutnya: Serverless Architecture →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact