Architecture

Implementing CQRS in Go with PostgreSQL and Redis: Separating Commands and Queries in High-Load Services

Ruslan Ismailov Published 14 min read
I

What Is CQRS and Why It Matters in 2026

CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates write operations (commands) from read operations (queries) at the model, storage, and processing levels. It should not be confused with simple layer separation (controller / service / repository), where a single data model still handles both reads and writes.

In 2026, traffic on public APIs continues to grow: the read/write ratio in typical consumer services reaches 100:1. A single PostgreSQL table with indexes for OLTP and complex JOIN queries for dashboards can no longer keep up. CQRS allows the read side and write side to scale independently, lets you choose the optimal storage for each task, and simplifies the domain model.

Key benefits of CQRS in a microservice architecture:

  • The write side is optimized for transactional writes and consistency.
  • The read side is optimized for read speed and denormalization.
  • Changes to the read model do not affect domain logic.
  • Horizontal scaling of the read side without putting load on the primary database.

Solution Architecture

Let's consider an order management service. The architecture consists of three layers:

  1. Write side — PostgreSQL. Stores aggregates (orders, line items) in normalized form. All commands go through domain validation and are persisted within a transaction.
  2. Read side — Redis. Stores denormalized projections: ready-made JSON objects or hashes returned to the client without additional JOINs.
  3. Synchronization — domain events. After a successful commit in PostgreSQL, the Command Handler publishes an event that updates the Redis projection. In more complex cases, the Outbox Pattern or Kafka is used.

Data flow diagram: HTTP Request → Command Handler → PostgreSQL → Domain Event → Projection Updater → Redis → Query Handler → HTTP Response.

Implementing the Command Handler in Go

Let's define the base interfaces. Each command is a value object with no methods, carrying the input data. The Command Handler accepts a command and returns an error.

// command.go
package cqrs

import "context"

// Command is a marker interface for all commands.
type Command interface {
	CommandName() string
}

// CommandHandler handles a specific command.
type CommandHandler[C Command] interface {
	Handle(ctx context.Context, cmd C) error
}

// CommandBus routes commands to their handlers.
type CommandBus interface {
	Dispatch(ctx context.Context, cmd Command) error
}

Implementation of the create order command and its handler with a transaction via pgx:

// create_order_command.go
package order

import (
	"context"
	"fmt"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

// CreateOrderCommand is the command for creating an order.
type CreateOrderCommand struct {
	UserID    string
	Items     []OrderItem
	Currency  string
}

func (c CreateOrderCommand) CommandName() string { return "order.create" }

// OrderItem represents a line item in an order.
type OrderItem struct {
	ProductID string
	Quantity  int
	Price     float64
}

// CreateOrderHandler handles CreateOrderCommand.
type CreateOrderHandler struct {
	db        *pgxpool.Pool
	events    EventPublisher
}

func NewCreateOrderHandler(db *pgxpool.Pool, events EventPublisher) *CreateOrderHandler {
	return &CreateOrderHandler{db: db, events: events}
}

func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) error {
	// Validation
	if cmd.UserID == "" {
		return fmt.Errorf("userID is required")
	}
	if len(cmd.Items) == 0 {
		return fmt.Errorf("order must contain at least one item")
	}

	// PostgreSQL transaction
	tx, err := h.db.Begin(ctx)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback(ctx)

	var orderID string
	err = tx.QueryRow(ctx,
		`INSERT INTO orders (user_id, currency, status, created_at)
		 VALUES ($1, $2, 'pending', NOW()) RETURNING id`,
		cmd.UserID, cmd.Currency,
	).Scan(&orderID)
	if err != nil {
		return fmt.Errorf("insert order: %w", err)
	}

	for _, item := range cmd.Items {
		_, err = tx.Exec(ctx,
			`INSERT INTO order_items (order_id, product_id, quantity, price)
			 VALUES ($1, $2, $3, $4)`,
			orderID, item.ProductID, item.Quantity, item.Price,
		)
		if err != nil {
			return fmt.Errorf("insert item: %w", err)
		}
	}

	if err = tx.Commit(ctx); err != nil {
		return fmt.Errorf("commit: %w", err)
	}

	// Publish domain event after a successful commit
	h.events.Publish(ctx, OrderCreatedEvent{
		OrderID:  orderID,
		UserID:   cmd.UserID,
		Items:    cmd.Items,
		Currency: cmd.Currency,
	})

	return nil
}

Note that the event is published after a successful transaction commit. This is a fundamental principle — never allow a situation where an event has been published but the data has not yet been written to PostgreSQL.

Building the Read Model in Redis

Let's define the Query Handler interface and implement the order projection in Redis:

// query.go
package cqrs

import "context"

// Query is a marker interface for all queries.
type Query interface {
	QueryName() string
}

// QueryHandler handles a query and returns a result.
type QueryHandler[Q Query, R any] interface {
	Handle(ctx context.Context, query Q) (R, error)
}
// get_order_query.go
package order

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

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

// GetOrderQuery is the query for retrieving an order by ID.
type GetOrderQuery struct {
	OrderID string
}

func (q GetOrderQuery) QueryName() string { return "order.get" }

// OrderReadModel is the denormalized order projection.
type OrderReadModel struct {
	ID       string      `json:"id"`
	UserID   string      `json:"user_id"`
	Currency string      `json:"currency"`
	Status   string      `json:"status"`
	Items    []OrderItem `json:"items"`
}

// GetOrderHandler reads an order from Redis.
type GetOrderHandler struct {
	rdb *redis.Client
}

func NewGetOrderHandler(rdb *redis.Client) *GetOrderHandler {
	return &GetOrderHandler{rdb: rdb}
}

func (h *GetOrderHandler) Handle(ctx context.Context, q GetOrderQuery) (*OrderReadModel, error) {
	key := fmt.Sprintf("order:%s", q.OrderID)
	data, err := h.rdb.Get(ctx, key).Bytes()
	if err == redis.Nil {
		return nil, fmt.Errorf("order %s not found", q.OrderID)
	}
	if err != nil {
		return nil, fmt.Errorf("redis get: %w", err)
	}

	var model OrderReadModel
	if err = json.Unmarshal(data, &model); err != nil {
		return nil, fmt.Errorf("unmarshal: %w", err)
	}
	return &model, nil
}

// ProjectionUpdater updates Redis when an event is received.
type ProjectionUpdater struct {
	rdb *redis.Client
}

func (u *ProjectionUpdater) OnOrderCreated(ctx context.Context, event OrderCreatedEvent) error {
	model := OrderReadModel{
		ID:       event.OrderID,
		UserID:   event.UserID,
		Currency: event.Currency,
		Status:   "pending",
		Items:    event.Items,
	}
	data, err := json.Marshal(model)
	if err != nil {
		return err
	}
	key := fmt.Sprintf("order:%s", event.OrderID)
	return u.rdb.Set(ctx, key, data, 0).Err()
}

Redis uses a string key order:{id} with a JSON value. For more complex projections (e.g., a user's list of orders), ZSET (sorted by date) or HASH structures are a better fit.

Synchronization and Eventual Consistency

The key question with CQRS: what happens when Redis and PostgreSQL fall out of sync? This is a normal situation for eventually consistent systems, but it must be handled explicitly.

Desynchronization scenarios and strategies for handling them:

  • Redis goes down after a PostgreSQL commit. Use the Outbox Pattern: write the event to an outbox table within the same transaction as the main data. A separate worker reads the table and publishes the events.
  • Duplicate event delivery. Make the Projection Updater idempotent: check the version or timestamp before overwriting.
  • Projection recovery. Implement replay: read events from PostgreSQL (or an event log) and rebuild the Redis projection from scratch.
  • Staleness on the client. For critical operations (e.g., when a client immediately queries data after a successful command), use a read-your-writes strategy: temporarily read from PostgreSQL instead of Redis.

Eventual consistency is not a bug — it is a deliberate trade-off between performance and strict consistency. It is important to document this contract for your team and API consumers.

Deploying with Docker and Docker Compose

A multi-container environment for local development and CI:

# docker-compose.yml
version: "3.9"

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgres://user:password@postgres:5432/orders?sslmode=disable
      REDIS_URL: redis:6379
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: orders
    volumes:
      - pg_data:/var/lib/postgresql/data
      - ./migrations:/docker-entrypoint-initdb.d
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d orders"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pg_data:
  redis_data:

Dockerfile for the Go service with a multi-stage build:

# Dockerfile
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server

FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Testing CQRS Components

Testing is split into two levels: unit tests for domain logic and integration tests for interactions with PostgreSQL and Redis.

Unit test for the Command Handler with a mock EventPublisher:

// create_order_handler_test.go
package order_test

import (
	"context"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

type MockEventPublisher struct {
	mock.Mock
}

func (m *MockEventPublisher) Publish(ctx context.Context, event interface{}) {
	m.Called(ctx, event)
}

func TestCreateOrderHandler_EmptyItems_ReturnsError(t *testing.T) {
	publisher := &MockEventPublisher{}
	// In the unit test we pass nil for db — validation happens before any DB call
	handler := NewCreateOrderHandler(nil, publisher)

	err := handler.Handle(context.Background(), CreateOrderCommand{
		UserID:   "user-1",
		Items:    []OrderItem{},
		Currency: "USD",
	})

	assert.EqualError(t, err, "order must contain at least one item")
	publisher.AssertNotCalled(t, "Publish")
}

Integration test with testcontainers-go:

// integration_test.go
package order_test

import (
	"context"
	"testing"

	"github.com/stretchr/testify/require"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
	"github.com/testcontainers/testcontainers-go/modules/redis"
)

func TestCreateOrderHandler_Integration(t *testing.T) {
	ctx := context.Background()

	// Start the PostgreSQL container
	pgContainer, err := postgres.RunContainer(ctx,
		testcontainers.WithImage("postgres:16-alpine"),
		postgres.WithDatabase("testdb"),
		postgres.WithUsername("test"),
		postgres.WithPassword("test"),
	)
	require.NoError(t, err)
	t.Cleanup(func() { pgContainer.Terminate(ctx) })

	// Start the Redis container
	redisContainer, err := redis.RunContainer(ctx,
		testcontainers.WithImage("redis:7-alpine"),
	)
	require.NoError(t, err)
	t.Cleanup(func() { redisContainer.Terminate(ctx) })

	// Initialize dependencies and execute the command
	// ... (connect, run migrations, create handler)

	cmd := CreateOrderCommand{
		UserID:   "user-42",
		Items:    []OrderItem{{ProductID: "prod-1", Quantity: 2, Price: 9.99}},
		Currency: "USD",
	}
	err = handler.Handle(ctx, cmd)
	require.NoError(t, err)

	// Verify the projection exists in Redis
	// ...
}

Using testcontainers-go allows you to spin up real PostgreSQL and Redis instances in isolated containers directly from Go tests, without having to set up infrastructure manually.

Performance Considerations and Pitfalls

When implementing CQRS in high-load Go services, keep the following in mind:

  • Avoid synchronous Redis updates inside the Command Handler while holding a transaction lock. Publish the event in a separate goroutine or via a queue.
  • pgxpool connection pool: configure MaxConns based on your load. Default values are often too low for high-load services.
  • Concurrent writes to Redis: use SET NX or Lua scripts for atomic operations when multiple workers update the same projection in parallel.
  • TTL for Redis keys: set a reasonable TTL to prevent stale data from accumulating for rarely accessed objects.
  • Observability: log command and query latency separately. A slow Command Handler should not affect the p99 of the Query Handler.
  • Don't over-engineer: CQRS is justified under high load or with a complex domain model. For CRUD services running at 100 RPS, it is excessive architecture.

Summary

The CQRS pattern in Go — with PostgreSQL as the write store and Redis as the read store — delivers real performance gains in high-load services when implemented correctly. Clear separation of CommandHandler and QueryHandler interfaces, idempotent projections, and reliable event-driven synchronization form the foundation of a resilient architecture. Docker and testcontainers-go provide a reproducible environment for development and testing. The golden rule: adopt CQRS where the problem of separating read and write load genuinely exists — not simply as an architectural trend.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →