Part 1: Your First Command¶
Build your first event-sourced feature: creating a task. You will learn how commands, events, and the event store work together.
Time: ~30 minutes
Goal: A working POST /tasks endpoint that persists a TaskCreated event.
What happens when a command runs¶
HTTP Request
│
▼
CreateTask (Command)
│
▼
Load events from store → rebuild taskState via evolve()
│
▼
decide(state, command) → []Event or error
│
▼
Save events to store
│
▼
HTTP Response
The framework handles loading, evolving state, and saving. You only write evolve and decide.
Step 1: Define the event¶
Events are immutable facts. Name them in the past tense — they describe something that already happened.
// events/task_created.go
package events
import (
"time"
"github.com/google/uuid"
)
type TaskCreated struct {
TaskID uuid.UUID `json:"task_id"`
Title string `json:"title"`
Description string `json:"description"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
func (e *TaskCreated) AggregateID() string { return e.TaskID.String() }
func (e *TaskCreated) EventType() string { return "TaskCreated" }
AggregateID() tells the event store which stream this event belongs to. EventType() is used for serialization.
Step 2: Write the command handler¶
A command handler has three parts: state, evolve, and decide.
// slices/createtask/command.go
package createtask
import (
"fmt"
"time"
"github.com/google/uuid"
"github.com/terraskye/eventsourcing"
"task-management/events"
)
// CreateTask is the command — present tense, expresses intent.
type CreateTask struct {
TaskID uuid.UUID
Title string
Description string
CreatedBy uuid.UUID
}
func (c CreateTask) AggregateID() string { return c.TaskID.String() }
// taskState is the minimal state needed to enforce business rules.
type taskState struct {
Exists bool
}
// initialState is a function that returns a fresh state — required because
// the framework calls it to create a new instance each time.
var initialState = func() taskState {
return taskState{}
}
// evolve rebuilds state from a single event. It must be a pure function.
func evolve(state taskState, envelope *eventsourcing.Envelope) taskState {
switch envelope.Event.(type) {
case *events.TaskCreated:
return taskState{Exists: true}
}
return state
}
// decide enforces business rules and returns the events to persist.
// Return an error to reject the command.
func decide(state taskState, cmd CreateTask) ([]eventsourcing.Event, error) {
if state.Exists {
return nil, fmt.Errorf("task %s already exists", cmd.TaskID)
}
if cmd.Title == "" {
return nil, fmt.Errorf("title is required")
}
return []eventsourcing.Event{
&events.TaskCreated{
TaskID: cmd.TaskID,
Title: cmd.Title,
Description: cmd.Description,
CreatedBy: cmd.CreatedBy,
CreatedAt: time.Now(),
},
}, nil
}
// NewHandler wires the three pieces together.
func NewHandler(store eventsourcing.EventStore) eventsourcing.CommandHandler[CreateTask] {
return eventsourcing.NewCommandHandler(store, initialState, evolve, decide)
}
Step 3: Create the HTTP handler¶
// slices/createtask/http.go
package createtask
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/terraskye/eventsourcing"
)
type HTTPHandler struct {
handler eventsourcing.CommandHandler[CreateTask]
}
func NewHTTPHandler(handler eventsourcing.CommandHandler[CreateTask]) *HTTPHandler {
return &HTTPHandler{handler: handler}
}
type request struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
}
func (h *HTTPHandler) Handle(c *gin.Context) {
var req request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cmd := CreateTask{
TaskID: uuid.New(),
Title: req.Title,
Description: req.Description,
CreatedBy: uuid.New(), // replace with user from auth context
}
result, err := h.handler(c.Request.Context(), cmd)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"task_id": cmd.TaskID,
"version": result.NextExpectedVersion,
})
}
func (h *HTTPHandler) RegisterRoutes(g *gin.RouterGroup) {
g.POST("", h.Handle)
}
Step 4: Wire it together¶
// main.go
package main
import (
"log"
"github.com/gin-gonic/gin"
"github.com/terraskye/eventsourcing/eventstore/memory"
"task-management/slices/createtask"
)
func main() {
store := memory.NewMemoryStore(100)
defer store.Close()
createTaskHandler := createtask.NewHandler(store)
createTaskHTTP := createtask.NewHTTPHandler(createTaskHandler)
r := gin.Default()
tasks := r.Group("/api/v1/tasks")
createTaskHTTP.RegisterRoutes(tasks)
log.Fatal(r.Run(":8080"))
}
Step 5: Test it¶
go run main.go
# Create a task
curl -X POST http://localhost:8080/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn Event Sourcing", "description": "Complete the tutorial"}'
Expected response:
{"task_id": "...", "version": 1}
Try submitting without a title:
curl -X POST http://localhost:8080/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"description": "Missing title"}'
Expected: 400 Bad Request.
What just happened¶
- The HTTP handler created a
CreateTaskcommand and calledhandler(ctx, cmd). NewCommandHandlerloaded events from the store for this stream ID (none, since it's new).- It called
evolvefor each event to buildtaskState(initial:{Exists: false}). - It called
decide(state, cmd)— our rules passed, so it returnedTaskCreated. - The framework wrapped the event in an
Envelopeand saved it to the store.
Common mistakes¶
Business logic in the HTTP handler — keep it in decide:
// Wrong: validation in HTTP handler
func (h *HTTPHandler) Handle(c *gin.Context) {
if req.Title == "" { ... }
}
// Right: validation in decide
func decide(state taskState, cmd CreateTask) ([]eventsourcing.Event, error) {
if cmd.Title == "" {
return nil, fmt.Errorf("title is required")
}
...
}
Mutating state in decide — decide must be side-effect free:
// Wrong
func decide(state taskState, cmd CreateTask) ([]eventsourcing.Event, error) {
state.Exists = true // mutating state here has no effect
...
}
// Right: return events, the framework calls evolve to update state
func decide(state taskState, cmd CreateTask) ([]eventsourcing.Event, error) {
return []eventsourcing.Event{&events.TaskCreated{...}}, nil
}
Present-tense event names — events are facts, not intentions:
type CreateTask struct{} // Wrong: this is a command name
type TaskCreated struct{} // Right: past tense
Next: Part 2: Your First Query — list all tasks using a live read model.
Exercise: Add a DueDate time.Time field to the command and event. You should not need to change evolve or decide.