CQRS isn’t about what data you store, but how you read and write it.
Let’s see it in action. Imagine a simple e-commerce system. We have a ProductService that handles writes (creating, updating products) and a ProductQueryService that handles reads (getting product details, searching).
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
)
// --- Command Side ---
type Product struct {
ID string `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
type ProductCommandService struct {
products map[string]Product
mu sync.RWMutex
}
func NewProductCommandService() *ProductCommandService {
return &ProductCommandService{
products: make(map[string]Product),
}
}
func (s *ProductCommandService) CreateProduct(name string, price float64) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
id := fmt.Sprintf("prod-%d", len(s.products)+1)
product := Product{ID: id, Name: name, Price: price}
s.products[id] = product
log.Printf("Created product: %+v", product)
return id, nil
}
func (s *ProductCommandService) UpdateProductPrice(id string, newPrice float64) error {
s.mu.Lock()
defer s.mu.Unlock()
product, ok := s.products[id]
if !ok {
return fmt.Errorf("product with id %s not found", id)
}
product.Price = newPrice
s.products[id] = product
log.Printf("Updated product %s price to %.2f", id, newPrice)
return nil
}
// --- Query Side ---
type ProductQueryService struct {
products map[string]Product
mu sync.RWMutex
}
func NewProductQueryService() *ProductQueryService {
return &ProductQueryService{
products: make(map[string]Product),
}
}
func (s *ProductQueryService) GetProductByID(id string) (Product, error) {
s.mu.RLock()
defer s.mu.RUnlock()
product, ok := s.products[id]
if !ok {
return Product{}, fmt.Errorf("product with id %s not found", id)
}
return product, nil
}
func (s *ProductQueryService) SearchProductsByName(nameQuery string) []Product {
s.mu.RLock()
defer s.mu.RUnlock()
var results []Product
for _, p := range s.products {
if containsIgnoreCase(p.Name, nameQuery) {
results = append(results, p)
}
}
return results
}
func containsIgnoreCase(s, substr string) bool {
return len(s) >= len(substr) && s[:len(substr)] == substr // Simplified for demo
}
// --- HTTP Handlers ---
func main() {
commandService := NewProductCommandService()
queryService := NewProductQueryService() // Note: In a real app, this would read from a separate read model
// Command Handlers
http.HandleFunc("/products", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Name string `json:"name"`
Price float64 `json:"price"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
id, err := commandService.CreateProduct(req.Name, req.Price)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// In a real CQRS, the command side would publish an event,
// and a separate process would update the query model.
// For this simple demo, we'll manually sync.
product, _ := commandService.GetProductByID(id) // Simulating fetch after create
queryService.products[id] = product // Direct sync for demo
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"id": id})
})
http.HandleFunc("/products/", func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Path[len("/products/"):]
if r.Method == http.MethodPut { // Update price
var req struct {
NewPrice float64 `json:"new_price"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := commandService.UpdateProductPrice(id, req.NewPrice); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Sync query model
product, _ := commandService.GetProductByID(id) // Simulating fetch after update
queryService.products[id] = product // Direct sync for demo
w.WriteHeader(http.StatusOK)
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
})
// Query Handlers
http.HandleFunc("/query/products/", func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Path[len("/query/products/"):]
product, err := queryService.GetProductByID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(product)
})
http.HandleFunc("/query/products/search", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
nameQuery := r.URL.Query().Get("name")
results := queryService.SearchProductsByName(nameQuery)
json.NewEncoder(w).Encode(results)
})
log.Println("Starting server on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
This is a simplified illustration. The core idea is separating the "write" path (commands) from the "read" path (queries). The command side is optimized for accepting changes and ensuring consistency (e.g., validating business rules). The query side is optimized for fast retrieval, potentially using denormalized data structures or different databases entirely. In a real-world scenario, the command side would publish domain events (e.g., ProductCreated, ProductPriceUpdated), and a separate process (an "event handler" or "projection") would consume these events to update the read model(s) that the query side uses. This asynchronous update is crucial for decoupling and scalability.
The problem CQRS solves is the impedance mismatch between how you typically want to write data (often normalized, transactional) and how you want to read data (often denormalized, optimized for specific queries). Trying to do both efficiently with a single data model is frequently a losing battle. By having distinct models and services, you can tune each for its specific purpose. The command model might use ACID transactions to ensure data integrity, while the query model might use eventual consistency for high read throughput.
The core levers you control are the design of your commands (what actions can the system take?), the design of your queries (what information can users retrieve?), and how you synchronize data between the command and query sides. This synchronization is often achieved through event sourcing, where the sequence of domain events is the source of truth, and read models are projections built from these events. In simpler CQRS implementations without event sourcing, you might directly update a read-optimized database from the command side after a successful command execution, but this requires careful handling of consistency.
Most people don’t realize that the "query" side can be multiple, different read models optimized for entirely different use cases. You might have one read model for a product catalog API, another for an order fulfillment dashboard, and yet another for a search index, all derived from the same stream of command-side events. This allows each part of your system to have the exact data shape it needs for its specific job, without the command side needing to know or care about these diverse consumption patterns.
The next step is usually exploring event sourcing to make the command side’s state truly append-only.