Skip to content

Part 2: Your First Query

Build a GET /tasks endpoint that lists all tasks. You will learn how queries work and how to build a live read model by replaying events.

Time: ~15 minutes Prerequisite: Part 1 complete.

Queries vs commands

Command Query
Purpose Change state Read state
Side effects Persists events None
Returns AppendResult Your read model
Example CreateTask ListTasks

A live read model is rebuilt from events on every query. It's simple and always consistent — but slower for large streams. Use it when the data volume is small or you need guaranteed freshness.

Step 1: Define the query and read model

// slices/listtasks/query.go
package listtasks

import (
    "context"
    "fmt"

    "github.com/terraskye/eventsourcing"
    "task-management/events"
)

// ListTasks is the query type. ID() is used as a cache key (empty = no specific aggregate).
type ListTasks struct{}

func (q ListTasks) ID() []byte { return []byte("list-tasks") }

// Task is one item in the read model.
type Task struct {
    ID    string `json:"id"`
    Title string `json:"title"`
}

// TaskList is the read model returned by the query.
type TaskList struct {
    Tasks []Task `json:"tasks"`
}

// evolve builds the TaskList from a stream of events.
func evolve(state *TaskList, envelope *eventsourcing.Envelope) *TaskList {
    switch e := envelope.Event.(type) {
    case *events.TaskCreated:
        state.Tasks = append(state.Tasks, Task{
            ID:    e.TaskID.String(),
            Title: e.Title,
        })
    }
    return state
}

// QueryHandler rebuilds the read model on every call.
type QueryHandler struct {
    store eventsourcing.EventStore
}

func NewQueryHandler(store eventsourcing.EventStore) *QueryHandler {
    return &QueryHandler{store: store}
}

func (h *QueryHandler) HandleQuery(ctx context.Context, _ ListTasks) (*TaskList, error) {
    iter, err := h.store.LoadFromAll(ctx, eventsourcing.Any{})
    if err != nil {
        return nil, fmt.Errorf("list tasks: %w", err)
    }

    result := &TaskList{Tasks: make([]Task, 0)}
    for iter.Next(ctx) {
        result = evolve(result, iter.Value())
    }
    if err := iter.Err(); err != nil {
        return nil, err
    }

    return result, nil
}

Step 2: Create the HTTP handler

// slices/listtasks/http.go
package listtasks

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

type HTTPHandler struct {
    queryHandler *QueryHandler
}

func NewHTTPHandler(qh *QueryHandler) *HTTPHandler {
    return &HTTPHandler{queryHandler: qh}
}

func (h *HTTPHandler) Handle(c *gin.Context) {
    result, err := h.queryHandler.HandleQuery(c.Request.Context(), ListTasks{})
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    c.JSON(http.StatusOK, result)
}

func (h *HTTPHandler) RegisterRoutes(g *gin.RouterGroup) {
    g.GET("", h.Handle)
}

Step 3: Register the route

Add the query handler to main.go:

import "task-management/slices/listtasks"

// After creating store and createTaskHandler:
listTasksHandler := listtasks.NewQueryHandler(store)
listTasksHTTP := listtasks.NewHTTPHandler(listTasksHandler)
listTasksHTTP.RegisterRoutes(tasks) // same router group as createtask

Step 4: Test it

# Create some tasks
curl -X POST http://localhost:8080/api/v1/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Task One"}'

curl -X POST http://localhost:8080/api/v1/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Task Two"}'

# List all tasks
curl http://localhost:8080/api/v1/tasks

Expected:

{
  "tasks": [
    {"id": "...", "title": "Task One"},
    {"id": "...", "title": "Task Two"}
  ]
}

How it works

LoadFromAll returns an iterator over every event ever stored. The query handler walks through all events and applies only the ones it cares about (TaskCreated) to build the list.

This is the same evolve pattern used in commands — the only difference is the output is a read model rather than aggregate state used for business rules.

When to use live vs cached

Situation Use
Small number of events Live read model (simpler)
Large event streams Cached projection
Real-time updates needed Cached projection with event bus
Eventual consistency is fine Either

Next: Part 3: Business Rules — enforce rules with the decide/evolve pattern.