Open this lesson in your favourite AI. It'll walk you through the why, explain the demo, and quiz you on the try-it list.
The simplest complete CQRS implementation doesn't need message queues or event sourcing — it just needs a commit hook that updates the read store in the same request. This synchronous two-database pattern is underrated because it is immediately deployable, easy to reason about, and handles the majority of real-world CQRS use cases. The tradeoff is that every write now has two latencies: the PostgreSQL commit and the Redis update. For most applications that latency is acceptable, and the read-path speedup more than compensates. Understanding synchronous CQRS deeply also makes the jump to eventual consistency easier, because you know exactly what you are giving up and why.
A PostService that writes to PostgreSQL then synchronously updates a Redis projection shows the full two-database flow in one request.
Use these three in order. Each builds on the one before.
In one paragraph, explain synchronous CQRS with two databases to someone who has only ever used a single database.
Walk me through what happens inside a CreatePost call that writes to both PostgreSQL and Redis — what commits first, what can fail, and what the read path sees at each step.
Given a synchronous two-database CQRS system under 500 writes/sec, what failure modes emerge when Redis has a 50ms spike in latency and how would you handle them without rolling back PostgreSQL writes?
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
_ "github.com/lib/pq"
)
var ctx = context.Background()
type Post struct {
ID string
AuthorID string
AuthorName string
Title string
PublishedAt time.Time
}
type PostService struct {
pg *sql.DB
rdb *redis.Client
}
// CreatePost writes to PostgreSQL and synchronously updates the Redis read projection.
func (s *PostService) CreatePost(p Post) error {
// 1. Durable write — PostgreSQL is the source of truth.
_, err := s.pg.ExecContext(ctx,
"INSERT INTO posts(id, author_id, title, published_at) VALUES($1,$2,$3,$4)",
p.ID, p.AuthorID, p.Title, p.PublishedAt)
if err != nil {
return fmt.Errorf("pg write: %w", err)
}
// 2. Read projection update — Redis for the feed query.
// If this fails, the write already committed; the projection is stale.
// A background reconciler can re-build from PostgreSQL if needed.
feedItem, _ := json.Marshal(map[string]any{
"postId": p.ID,
"authorName": p.AuthorName,
"title": p.Title,
"publishedAt": p.PublishedAt.Unix(),
})
key := fmt.Sprintf("feed:%s", p.AuthorID)
if err := s.rdb.ZAdd(ctx, key, redis.Z{
Score: float64(p.PublishedAt.Unix()),
Member: string(feedItem),
}).Err(); err != nil {
log.Printf("WARN: redis projection update failed for post %s: %v", p.ID, err)
// Do NOT return error — the write succeeded; Redis is eventually fixable.
}
return nil
}
func (s *PostService) GetFeed(authorID string) ([]map[string]any, error) {
raw, err := s.rdb.ZRevRange(ctx, fmt.Sprintf("feed:%s", authorID), 0, 19).Result()
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(raw))
for _, r := range raw {
var item map[string]any
if json.Unmarshal([]byte(r), &item) == nil {
out = append(out, item)
}
}
return out, nil
}
func main() {
fmt.Println("Synchronous CQRS: PostgreSQL writes, Redis reads.")
fmt.Println("Step 1: INSERT into posts table (PostgreSQL)")
fmt.Println("Step 2: ZADD into feed:{authorId} (Redis)")
fmt.Println("Step 3: GET /feed reads from Redis only — zero SQL joins.")
}go run main.go