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 query side of CQRS exists to make reads embarrassingly fast by pre-computing exactly what the UI needs. A normalized write model is correct by design — it eliminates redundancy and enforces consistency. But correctness is the write side's job; the read side's job is speed. Denormalizing a projection means storing data in the shape the query returns, so a single key lookup or a sequential scan over a small materialized view replaces a chain of joins. This is not a hack — it is the intentional trade: you accept a second copy of data in exchange for reads that never touch the write database.
A PostFeedProjection stored in Redis pre-computes exactly what the feed endpoint returns — no joins at query time.
Use these three in order. Each builds on the one before.
In one paragraph, explain what a read-side projection is and why it deliberately duplicates data.
Walk me through how denormalization in a Redis sorted set eliminates join overhead at query time — trace from write event to stored projection to query response.
Given a social feed where authors can change their display name, how would you efficiently update all their denormalized author_name values across millions of feed projection entries?
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
)
var ctx = context.Background()
// Read-side projection shape — exactly what the feed endpoint returns.
// No password hash. No normalized foreign keys. Just what the UI renders.
type PostFeedItem struct {
PostID string `json:"postId"`
AuthorName string `json:"authorName"`
Title string `json:"title"`
TagCSV string `json:"tagCsv"`
PublishedAt time.Time `json:"publishedAt"`
}
func upsertFeedProjection(rdb *redis.Client, authorID string, item PostFeedItem) error {
b, err := json.Marshal(item)
if err != nil {
return err
}
key := fmt.Sprintf("feed:%s", authorID)
// ZADD with score = unix timestamp keeps the list time-sorted.
return rdb.ZAdd(ctx, key, redis.Z{
Score: float64(item.PublishedAt.Unix()),
Member: string(b),
}).Err()
}
func queryFeed(rdb *redis.Client, authorID string, limit int) ([]PostFeedItem, error) {
key := fmt.Sprintf("feed:%s", authorID)
// ZREVRANGE returns newest-first. No JOIN. No PostgreSQL touch.
raw, err := rdb.ZRevRange(ctx, key, 0, int64(limit-1)).Result()
if err != nil {
return nil, err
}
items := make([]PostFeedItem, 0, len(raw))
for _, r := range raw {
var item PostFeedItem
if err := json.Unmarshal([]byte(r), &item); err == nil {
items = append(items, item)
}
}
return items, nil
}
func main() {
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
defer rdb.Close()
item := PostFeedItem{PostID: "p1", AuthorName: "Alice", Title: "Hello CQRS", TagCSV: "go,arch", PublishedAt: time.Now()}
if err := upsertFeedProjection(rdb, "u1", item); err != nil {
log.Fatal(err)
}
feed, err := queryFeed(rdb, "u1", 20)
if err != nil {
log.Fatal(err)
}
for _, f := range feed {
fmt.Printf("[%s] %s by %s\n", f.PostID, f.Title, f.AuthorName)
}
}go run main.go