CQRS Pattern Implementation Guide
Why separating reads and writes matters for scalable systems today

In modern distributed systems, the CQRS pattern is seeing a resurgence because it directly addresses two pain points developers face daily: read scaling bottlenecks and write contention in high-traffic domains. When I first encountered CQRS, it looked like overkill for the small service I was building. Over time, the real-world tradeoffs became clearer, especially as read loads spiked and domain logic grew more complex. This guide walks you through practical implementation, grounded in real scenarios, with code that you can adapt to your own projects.
If you have worked on a system where the same models power both the UI and the database, you have likely felt the tension: your queries want simple shapes, but your writes require rich validation, invariants, and business rules. CQRS clarifies that boundary. It is not a silver bullet, but in domains with high read/write divergence, it helps teams move faster, scale independently, and reason about performance without sacrificing correctness.
Where CQRS fits today and how teams use it
CQRS is most valuable in systems where read and write workloads have different scaling profiles or where the read side benefits from denormalization. E-commerce catalogs, audit-heavy financial ledgers, IoT telemetry ingestion, and real-time analytics dashboards are common places you will find it. It pairs naturally with event-driven architectures and, when combined with Event Sourcing, provides a durable audit trail.
Teams that use CQRS typically include backend engineers and architects working in microservices or modular monoliths. In regulated industries, the auditability that comes with event-sourced commands can be a decisive factor. Compared to a traditional CRUD approach, CQRS trades complexity for flexibility: you can optimize read models independently from the write path. In contrast to a purely synchronous REST service, CQRS often introduces asynchronous messaging for propagating changes, which is beneficial for eventual consistency but requires careful error handling and monitoring.
Core concepts: commands, queries, and the boundaries
In CQRS, commands represent intent to change state and are processed by the write side. Queries read projected state and are served by the read side. The write side publishes events when state changes. The read side subscribes to these events and builds query-optimized views.
Key distinctions:
- Commands are validated, enforce invariants, and should be idempotent where possible.
- Queries are simple, fast, and can be eventually consistent with the write side.
- Events are immutable facts that describe what happened.
The pattern encourages explicit boundaries. You might model commands with a dedicated contract and a handler that persists domain aggregates, then publish domain events to update read models. This separation allows the write side to remain normalized and transactional, while the read side can be denormalized and tailored for specific screens or endpoints.
Example domain: an IoT telemetry ingestion and dashboard
Let’s consider an IoT system that ingests device telemetry and exposes dashboards. Writes come from devices sending measurements, which must be validated, deduplicated, and stored with an audit trail. Reads come from dashboards that need aggregated metrics per device and per region. The write side needs strong consistency around device identity; the read side benefits from precomputed aggregates.
Project structure
src/
api/
main.go
handlers/
command.go
query.go
core/
domain/
aggregate.go
event.go
messaging/
publisher.go
subscriber.go
modules/
telemetry/
commands/
ingest.go
events/
ingested.go
readmodels/
device_stats.go
infrastructure/
postgres/
store.go
redis/
cache.go
nats/
publisher.go
subscriber.go
config/
dev.yaml
scripts/
migrate.sql
This structure reflects a layered approach: the API boundary is thin, the core domain contains shared abstractions, and modules implement specific business capabilities. The infrastructure layer handles adapters for Postgres, Redis, and NATS, enabling the write side to persist transactions while the read side subscribes to events and updates caches or materialized views.
Practical implementation: Go code for commands, queries, and events
We will implement a simple TelemetryIngest command handler on the write side, persisting to Postgres and publishing an Ingested event via NATS. The read side will subscribe to the event and update a Redis-backed device stats view. This example demonstrates realistic concerns: idempotency keys, deduplication, and error handling.
Command definition and handler
package telemetry.commands
import (
"context"
"errors"
"time"
"github.com/google/uuid"
)
// TelemetryIngest is a command to ingest device measurements.
type TelemetryIngest struct {
DeviceID string
Timestamp time.Time
Temperature float64
Humidity float64
IdempotencyKey string // deduplication key
}
// CommandHandler defines how to handle ingest commands.
type CommandHandler interface {
Handle(ctx context.Context, cmd TelemetryIngest) error
}
// NewCommandHandler builds a handler with dependencies.
func NewCommandHandler(store Store, publisher Publisher) CommandHandler {
return &telemetryHandler{store: store, publisher: publisher}
}
type telemetryHandler struct {
store Store
publisher Publisher
}
func (h *telemetryHandler) Handle(ctx context.Context, cmd TelemetryIngest) error {
// Basic validation
if cmd.DeviceID == "" {
return errors.New("device_id is required")
}
if cmd.Timestamp.IsZero() {
cmd.Timestamp = time.Now().UTC()
}
if cmd.IdempotencyKey == "" {
cmd.IdempotencyKey = uuid.NewString()
}
// Deduplicate on idempotency key
exists, err := h.store.HasIdempotencyKey(ctx, cmd.IdempotencyKey)
if err != nil {
return err
}
if exists {
// Idempotent response, safe to return success
return nil
}
// Persist in a transaction
tx, err := h.store.BeginTx(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
// Insert measurement
err = tx.InsertMeasurement(ctx, InsertMeasurementParams{
DeviceID: cmd.DeviceID,
Timestamp: cmd.Timestamp,
Temperature: cmd.Temperature,
Humidity: cmd.Humidity,
IdempotencyKey: cmd.IdempotencyKey,
})
if err != nil {
return err
}
// Mark idempotency key as used
err = tx.MarkIdempotency(ctx, cmd.IdempotencyKey)
if err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
// Publish an event to update read models
event := telemetry.Ingested{
DeviceID: cmd.DeviceID,
Timestamp: cmd.Timestamp,
Temperature: cmd.Temperature,
Humidity: cmd.Humidity,
}
if err := h.publisher.Publish(ctx, "telemetry.ingested", event); err != nil {
// In production, consider dead-letter or retry policies
return err
}
return nil
}
// Interfaces used by the handler
type Store interface {
HasIdempotencyKey(ctx context.Context, key string) (bool, error)
BeginTx(ctx context.Context) (Tx, error)
}
type Tx interface {
InsertMeasurement(ctx context.Context, p InsertMeasurementParams) error
MarkIdempotency(ctx context.Context, key string) error
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
}
type Publisher interface {
Publish(ctx context.Context, topic string, payload interface{}) error
}
type InsertMeasurementParams struct {
DeviceID string
Timestamp time.Time
Temperature float64
Humidity float64
IdempotencyKey string
}
This snippet shows a realistic command flow: validation, deduplication, transactional persistence, and event publication. Note the idempotency key; it’s a practical mechanism for devices that may retry due to network issues.
Event definition and subscription
package telemetry.events
import "time"
// Ingested is a domain event representing a successful ingestion.
type Ingested struct {
DeviceID string
Timestamp time.Time
Temperature float64
Humidity float64
}
package telemetry.readmodels
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/go-redis/redis/v8"
"github.com/yourorg/telemetry-core/telemetry/events"
"github.com/yourorg/telemetry-core/core/messaging"
)
// DeviceStats is the read model used by dashboards.
type DeviceStats struct {
LastSeen time.Time `json:"last_seen"`
AvgTemperature float64 `json:"avg_temperature"`
AvgHumidity float64 `json:"avg_humidity"`
SampleCount int `json:"sample_count"`
}
// IngestedSubscriber updates Redis read models.
type IngestedSubscriber struct {
redis *redis.Client
}
func NewIngestedSubscriber(redis *redis.Client) *IngestedSubscriber {
return &IngestedSubscriber{redis: redis}
}
func (s *IngestedSubscriber) HandleMessage(ctx context.Context, msg messaging.Message) error {
var evt events.Ingested
if err := json.Unmarshal(msg.Payload, &evt); err != nil {
return fmt.Errorf("unmarshal event: %w", err)
}
key := fmt.Sprintf("device:stats:%s", evt.DeviceID)
// Simple update: compute new average
// In production, consider more robust aggregation or materialized views
val, err := s.redis.Get(ctx, key).Result()
if err != nil && err != redis.Nil {
return err
}
var stats DeviceStats
if err == redis.Nil {
stats = DeviceStats{SampleCount: 0}
} else {
if err := json.Unmarshal([]byte(val), &stats); err != nil {
return err
}
}
// Update averages
stats.LastSeen = evt.Timestamp
total := float64(stats.SampleCount) * ((stats.AvgTemperature + stats.AvgHumidity) / 2)
// A simplified average calculation; in reality, compute separate averages
stats.AvgTemperature = ((stats.AvgTemperature * float64(stats.SampleCount)) + evt.Temperature) / float64(stats.SampleCount+1)
stats.AvgHumidity = ((stats.AvgHumidity * float64(stats.SampleCount)) + evt.Humidity) / float64(stats.SampleCount+1)
stats.SampleCount++
// Store back to Redis
payload, err := json.Marshal(stats)
if err != nil {
return err
}
return s.redis.Set(ctx, key, payload, 24*time.Hour).Err()
}
The subscriber listens to NATS topics and updates a Redis-backed read model. This separation allows the read side to scale independently and serve low-latency queries without hitting the write database.
Query endpoint
package api.handlers
import (
"encoding/json"
"net/http"
"github.com/go-redis/redis/v8"
"github.com/gorilla/mux"
"github.com/yourorg/telemetry-modules/telemetry/readmodels"
)
type QueryHandler struct {
redis *redis.Client
}
func NewQueryHandler(redis *redis.Client) *QueryHandler {
return &QueryHandler{redis: redis}
}
func (h *QueryHandler) GetDeviceStats(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
deviceID := vars["id"]
if deviceID == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
key := fmt.Sprintf("device:stats:%s", deviceID)
val, err := h.redis.Get(r.Context(), key).Result()
if err == redis.Nil {
w.WriteHeader(http.StatusNotFound)
return
}
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
var stats readmodels.DeviceStats
if err := json.Unmarshal([]byte(val), &stats); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(stats)
}
This endpoint is intentionally thin; it reflects the CQRS principle that queries should be simple and fast. The read model is eventually consistent, which is acceptable for dashboards but may require mitigation strategies for use cases that demand strict consistency.
Configuration and infrastructure setup
The following files illustrate configuration and migration setup, which are often overlooked but essential in production. We will use Postgres for the write store, Redis for the read cache, and NATS for messaging.
config/dev.yaml
postgres:
host: localhost
port: 5432
user: telemetry
password: dev_password
dbname: telemetry_write
redis:
addr: localhost:6379
db: 0
nats:
url: nats://localhost:4222
stream: TELEMETRY_EVENTS
scripts/migrate.sql
-- Write-side schema
CREATE TABLE IF NOT EXISTS measurements (
id BIGSERIAL PRIMARY KEY,
device_id VARCHAR(64) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
temperature DOUBLE PRECISION,
humidity DOUBLE PRECISION,
idempotency_key VARCHAR(128) UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_measurements_device_time ON measurements(device_id, timestamp DESC);
CREATE TABLE IF NOT EXISTS idempotency_keys (
key VARCHAR(128) PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW()
);
infrastructure/nats/publisher.go
package nats
import (
"context"
"encoding/json"
"fmt"
"github.com/nats-io/nats.go"
"github.com/yourorg/telemetry-core/core/messaging"
)
type NatsPublisher struct {
js nats.JetStreamContext
}
func NewNatsPublisher(js nats.JetStreamContext) *NatsPublisher {
return &NatsPublisher{js: js}
}
func (p *NatsPublisher) Publish(ctx context.Context, topic string, payload interface{}) error {
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
_, err = p.js.Publish(topic, data)
return err
}
// Ensure NatsPublisher implements the Publisher interface
var _ messaging.Publisher = (*NatsPublisher)(nil)
infrastructure/postgres/store.go
package postgres
import (
"context"
"database/sql"
"fmt"
_ "github.com/lib/pq"
"github.com/yourorg/telemetry-modules/telemetry/commands"
)
type PostgresStore struct {
db *sql.DB
}
func NewPostgresStore(connStr string) (*PostgresStore, error) {
db, err := sql.Open("postgres", connStr)
if err != nil {
return nil, err
}
return &PostgresStore{db: db}, nil
}
func (s *PostgresStore) HasIdempotencyKey(ctx context.Context, key string) (bool, error) {
var exists bool
err := s.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM idempotency_keys WHERE key = $1)", key).Scan(&exists)
if err != nil {
return false, err
}
return exists, nil
}
func (s *PostgresStore) BeginTx(ctx context.Context) (commands.Tx, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
return &postgresTx{tx: tx}, nil
}
type postgresTx struct {
tx *sql.Tx
}
func (t *postgresTx) InsertMeasurement(ctx context.Context, p commands.InsertMeasurementParams) error {
_, err := t.tx.ExecContext(ctx,
`INSERT INTO measurements (device_id, timestamp, temperature, humidity, idempotency_key)
VALUES ($1, $2, $3, $4, $5)`,
p.DeviceID, p.Timestamp, p.Temperature, p.Humidity, p.IdempotencyKey)
return err
}
func (t *postgresTx) MarkIdempotency(ctx context.Context, key string) error {
_, err := t.tx.ExecContext(ctx, "INSERT INTO idempotency_keys (key) VALUES ($1)", key)
return err
}
func (t *postgresTx) Commit(ctx context.Context) error {
return t.tx.Commit()
}
func (t *postgresTx) Rollback(ctx context.Context) error {
return t.tx.Rollback()
}
The write side uses a transactional store to ensure data integrity. The idempotency table prevents duplicate processing on retries. This pattern is essential for IoT systems where devices may send the same payload multiple times due to network instability.
Async patterns and messaging
A common pitfall in CQRS is assuming that event propagation is instant. In practice, the read side is eventually consistent. When building dashboards, you can mitigate this by:
- Publishing events within the same transaction if using aTransactional Outbox pattern.
- Adding versioning to events to handle out-of-order delivery.
- Providing last-updated timestamps in read models to inform users.
Here is a simple transactional outbox sketch in Go. This pattern ensures that the event is persisted and published atomically.
package infrastructure
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/google/uuid"
)
type OutboxEvent struct {
ID string
Topic string
Payload []byte
CreatedAt time.Time
}
type OutboxStore struct {
db *sql.DB
}
func NewOutboxStore(db *sql.DB) *OutboxStore {
return &OutboxStore{db: db}
}
func (s *OutboxStore) SaveAndPublish(ctx context.Context, topic string, payload interface{}, publish func(topic string, payload []byte) error) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Persist outbox event
eventID := uuid.NewString()
_, err = tx.ExecContext(ctx,
`INSERT INTO outbox_events (id, topic, payload, created_at) VALUES ($1, $2, $3, $4)`,
eventID, topic, data, time.Now().UTC())
if err != nil {
return err
}
// Commit the transaction first
if err := tx.Commit(); err != nil {
return err
}
// Publish to NATS after commit (or in a background worker polling outbox)
if err := publish(topic, data); err != nil {
// In production, you would retry or log for manual intervention
return err
}
return nil
}
For more details on the outbox pattern, see the seminal post by Bobby Calderwood: What is the Outbox Pattern?.
Strengths and tradeoffs
Strengths:
- Clear separation of concerns that helps teams evolve the read and write sides independently.
- Enables read model optimization for specific screens or queries without polluting the write domain.
- Supports event-driven architectures and auditability when combined with Event Sourcing.
- Scales well when reads and writes have different performance profiles.
Weaknesses:
- Operational complexity: more services, queues, and eventual consistency to manage.
- Debugging can be harder because data flows through multiple pathways.
- Not a good fit for simple CRUD apps where reads and writes are similar and consistency requirements are strong.
- Requires disciplined idempotency and error handling, especially when retries and message ordering are involved.
Guidance:
- Use CQRS when your read/write patterns diverge or when audit and event-driven workflows are critical.
- Avoid CQRS if the team and infrastructure are not ready to manage eventual consistency and additional operational overhead.
- Consider starting with command and query separation within a single service before moving to fully distributed CQRS.
Personal experience: learning curve and common mistakes
In my experience, the biggest learning curve was not in the code but in the mindset shift. When I first built a CQRS service, I struggled with two things:
- Over-normalizing the write side and under-projecting the read side. I kept trying to reuse the same models across both sides, which defeated the purpose.
- Ignoring idempotency. Devices retried and sent duplicate telemetry, which caused skewed averages and duplicate events.
A turning point was embracing simple event payloads and versioning. Instead of trying to make the event schema perfect on day one, I focused on stability and backward compatibility. When I introduced an outbox, I solved a class of problems related to lost events, which had previously caused silent read model drift.
Moments where CQRS proved especially valuable:
- During a spike in dashboard traffic, we scaled the read side independently without touching the write database.
- When compliance required an audit trail, event sourcing layered on top gave us a reliable ledger without disrupting existing queries.
Getting started: workflow and mental models
Workflow:
- Define bounded contexts. Identify where commands and queries diverge.
- Model commands as intents with clear invariants. Keep them small and focused.
- Design events as immutable facts. Version them from the start.
- Build the write side to be transactionally consistent. Consider the outbox pattern for reliable event publishing.
- Build the read side as projections. Start with simple in-memory caches and evolve to Redis or materialized views.
- Monitor consistency. Track the lag between write and read propagation.
Mental models:
- Think of the write side as the source of truth and the read side as a cache optimized for usage patterns.
- Treat eventual consistency as a feature, not a bug, and design UX accordingly.
- Use idempotency keys everywhere, especially at the ingress boundary.
Free learning resources
- Martin Fowler’s CQRS article: https://martinfowler.com/bliki/CQRS.html — a clear introduction to the pattern and its implications.
- Microsoft’s CQRS pattern documentation: https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs — practical guidance and considerations for distributed systems.
- EventStoreDB documentation: https://eventstore.com/docs/ — helpful if you choose Event Sourcing alongside CQRS.
- Greg Young’s material on CQRS and Event Sourcing — search for his talks and writings, which provide foundational context.
- The Saga pattern and distributed transactions: https://microservices.io/patterns/data/saga.html — important when coordinating across services in a CQRS architecture.
Who should use CQRS and who might skip it
Use CQRS if:
- Your system has distinct read and write workloads, or you need strong auditability and event-driven updates.
- You are building dashboards, catalogs, or ledgers where queries benefit from denormalization.
- Your team is ready to manage eventual consistency and operational complexity.
Skip it if:
- Your application is simple CRUD with uniform workloads.
- You do not have the infrastructure for reliable messaging or monitoring.
- Strict read-after-write consistency is a must and cannot be accommodated by UX adjustments.
In summary, CQRS is a powerful pattern when used judiciously. It clarifies boundaries, enables scale, and supports event-driven workflows, but it introduces complexity that requires thoughtful error handling, observability, and team discipline. Start small, iterate, and let the domain guide the separation between commands and queries.




