Part 4: Real-time Projections¶
Replace the live read model with a cached projection that stays up to date via the event bus.
Time: ~25 minutes Prerequisite: Part 3 complete.
Why cache projections?¶
The live read model in Part 2 replays all events on every query. For a small system this is fine, but it becomes slow as events accumulate.
A cached projection: 1. Builds the read model once from existing events. 2. Keeps it updated in real time as new events arrive via the event bus. 3. Serves queries directly from memory — O(1) lookup.
Architecture¶
Command
│
▼
EventStore ──saves──► Envelope
│ │
│ ▼
│ EventBus ──publishes──► Projector.OnTaskCreated()
│ │
└─────────────────────────────────────────► cache updated
The memory event store has an internal Events() channel. You feed this into the event bus, which dispatches to subscribers.
Step 1: Build the projector¶
// slices/tasklist/projector.go
package tasklist
import (
"context"
"sync"
"github.com/terraskye/eventsourcing"
"task-management/events"
)
// Task is the cached read model entry.
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
}
// TaskList is the cached read model served by queries.
type TaskList struct {
Tasks []Task
}
// Projector maintains the cached TaskList.
type Projector struct {
mu sync.RWMutex
tasks map[string]*Task
}
func NewProjector() *Projector {
return &Projector{tasks: make(map[string]*Task)}
}
// All returns a snapshot of the current task list.
func (p *Projector) All() []Task {
p.mu.RLock()
defer p.mu.RUnlock()
result := make([]Task, 0, len(p.tasks))
for _, t := range p.tasks {
result = append(result, *t)
}
return result
}
// OnTaskCreated is called by the event bus when a TaskCreated event arrives.
func (p *Projector) OnTaskCreated(_ context.Context, e *events.TaskCreated) error {
p.mu.Lock()
defer p.mu.Unlock()
p.tasks[e.TaskID.String()] = &Task{
ID: e.TaskID.String(),
Title: e.Title,
}
return nil
}
// OnTaskCompleted is called by the event bus when a TaskCompleted event arrives.
func (p *Projector) OnTaskCompleted(_ context.Context, e *events.TaskCompleted) error {
p.mu.Lock()
defer p.mu.Unlock()
if t, ok := p.tasks[e.TaskID.String()]; ok {
t.Completed = true
}
return nil
}
// EventHandlers returns the typed handlers to register on the event bus.
func (p *Projector) EventHandlers() *eventsourcing.EventGroupProcessor {
return eventsourcing.NewEventGroupProcessor(
eventsourcing.OnEvent(p.OnTaskCreated),
eventsourcing.OnEvent(p.OnTaskCompleted),
)
}
Step 2: Write the query handler¶
// slices/tasklist/query.go
package tasklist
import "context"
type ListTasks struct{}
func (q ListTasks) ID() []byte { return []byte("task-list") }
type QueryHandler struct {
projector *Projector
}
func NewQueryHandler(p *Projector) *QueryHandler {
return &QueryHandler{projector: p}
}
func (h *QueryHandler) HandleQuery(_ context.Context, _ ListTasks) ([]Task, error) {
return h.projector.All(), nil
}
Step 3: Wire the event bus¶
// main.go
package main
import (
"context"
"log"
"github.com/gin-gonic/gin"
membus "github.com/terraskye/eventsourcing/eventbus/memory"
memstore "github.com/terraskye/eventsourcing/eventstore/memory"
"task-management/slices/createtask"
"task-management/slices/completetask"
"task-management/slices/tasklist"
)
func main() {
store := memstore.NewMemoryStore(100)
defer store.Close()
bus := membus.NewEventBus(100)
defer bus.Close()
// Create the projector and register it on the bus.
projector := tasklist.NewProjector()
if err := bus.Subscribe(context.Background(), "task-list-projector", projector.EventHandlers()); err != nil {
log.Fatal(err)
}
// Forward events from the store to the bus.
go func() {
for env := range store.Events() {
bus.Dispatch(env)
}
}()
// Handlers
createTaskHTTP := createtask.NewHTTPHandler(createtask.NewHandler(store))
completeTaskHTTP := completetask.NewHTTPHandler(completetask.NewHandler(store))
listTasksHTTP := newTaskListHTTP(tasklist.NewQueryHandler(projector))
r := gin.Default()
tasks := r.Group("/api/v1/tasks")
createTaskHTTP.RegisterRoutes(tasks)
completeTaskHTTP.RegisterRoutes(tasks)
listTasksHTTP.RegisterRoutes(tasks)
log.Fatal(r.Run(":8080"))
}
Step 4: Test it¶
# Create tasks
curl -s -X POST http://localhost:8080/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Buy groceries"}' | jq .
curl -s -X POST http://localhost:8080/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Write tests"}' | jq .
# List — both tasks appear immediately
curl -s http://localhost:8080/api/v1/tasks | jq .
# Complete one task (use the task_id from above)
curl -X POST "http://localhost:8080/api/v1/tasks/<task_id>/complete"
# List again — completed field is true
curl -s http://localhost:8080/api/v1/tasks | jq .
How EventGroupProcessor routes events¶
NewEventGroupProcessor builds a map of event type → handler. When an event arrives via Handle, it looks up the correct typed handler by type name and calls it. Unrecognised event types return ErrSkippedEvent (not an error — it's expected).
StreamFilter() returns the list of event type names the processor handles, which can be used to subscribe only to relevant events in networked event buses.
Notes on concurrency¶
The in-memory event bus dispatches events asynchronously in a goroutine per subscriber. The projector uses sync.RWMutex to protect the cache. For production use, consider KurrentDB's event bus, which provides durable subscriptions.
Next: Part 5: Background Processing — trigger commands from events.