Part 3: Business Rules¶
Add a CompleteTask command. You will learn how to use state to enforce business rules across multiple commands.
Time: ~20 minutes Prerequisite: Part 2 complete.
Why state matters¶
Business rules often depend on what has already happened:
- A task cannot be completed if it doesn't exist.
- A task cannot be completed twice.
- A task must be assigned before it can be completed.
The evolve function rebuilds this state from the event history. The decide function uses it to guard the command.
Step 1: Add a second event¶
// events/task_completed.go
package events
import (
"time"
"github.com/google/uuid"
)
type TaskCompleted struct {
TaskID uuid.UUID `json:"task_id"`
CompletedBy uuid.UUID `json:"completed_by"`
CompletedAt time.Time `json:"completed_at"`
}
func (e *TaskCompleted) AggregateID() string { return e.TaskID.String() }
func (e *TaskCompleted) EventType() string { return "TaskCompleted" }
Step 2: Write the CompleteTask command handler¶
This handler shares the same stream as CreateTask (same TaskID), so it sees the full history.
// slices/completetask/command.go
package completetask
import (
"fmt"
"time"
"github.com/google/uuid"
"github.com/terraskye/eventsourcing"
"task-management/events"
)
type CompleteTask struct {
TaskID uuid.UUID
CompletedBy uuid.UUID
}
func (c CompleteTask) AggregateID() string { return c.TaskID.String() }
// taskState tracks everything needed to enforce completion rules.
type taskState struct {
Exists bool
Completed bool
}
var initialState = func() taskState { return taskState{} }
// evolve handles events from both CreateTask and CompleteTask handlers —
// they all write to the same stream.
func evolve(state taskState, envelope *eventsourcing.Envelope) taskState {
switch envelope.Event.(type) {
case *events.TaskCreated:
return taskState{Exists: true, Completed: false}
case *events.TaskCompleted:
return taskState{Exists: true, Completed: true}
}
return state
}
func decide(state taskState, cmd CompleteTask) ([]eventsourcing.Event, error) {
if !state.Exists {
return nil, fmt.Errorf("task %s does not exist", cmd.TaskID)
}
if state.Completed {
return nil, fmt.Errorf("task %s is already completed", cmd.TaskID)
}
return []eventsourcing.Event{
&events.TaskCompleted{
TaskID: cmd.TaskID,
CompletedBy: cmd.CompletedBy,
CompletedAt: time.Now(),
},
}, nil
}
func NewHandler(store eventsourcing.EventStore) eventsourcing.CommandHandler[CompleteTask] {
return eventsourcing.NewCommandHandler(store, initialState, evolve, decide)
}
Step 3: Add the HTTP handler¶
// slices/completetask/http.go
package completetask
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/terraskye/eventsourcing"
)
type HTTPHandler struct {
handler eventsourcing.CommandHandler[CompleteTask]
}
func NewHTTPHandler(h eventsourcing.CommandHandler[CompleteTask]) *HTTPHandler {
return &HTTPHandler{handler: h}
}
func (h *HTTPHandler) Handle(c *gin.Context) {
taskID, err := uuid.Parse(c.Param("taskID"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task ID"})
return
}
cmd := CompleteTask{
TaskID: taskID,
CompletedBy: uuid.New(), // replace with user from auth context
}
if _, err := h.handler(c.Request.Context(), cmd); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) RegisterRoutes(g *gin.RouterGroup) {
g.POST("/:taskID/complete", h.Handle)
}
Step 4: Wire it up and test¶
Add to main.go:
import "task-management/slices/completetask"
completeTaskHandler := completetask.NewHandler(store)
completeTaskHTTP := completetask.NewHTTPHandler(completeTaskHandler)
completeTaskHTTP.RegisterRoutes(tasks)
# Create a task
TASK_ID=$(curl -s -X POST http://localhost:8080/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Finish tutorial"}' | jq -r '.task_id')
# Complete it
curl -X POST "http://localhost:8080/api/v1/tasks/$TASK_ID/complete"
# → 204 No Content
# Try to complete it again
curl -X POST "http://localhost:8080/api/v1/tasks/$TASK_ID/complete"
# → 400 Bad Request: task already completed
# Try to complete a non-existent task
curl -X POST "http://localhost:8080/api/v1/tasks/00000000-0000-0000-0000-000000000000/complete"
# → 400 Bad Request: task does not exist
How the state is shared¶
Both createtask and completetask write to the same stream — the task's UUID. When completetask loads events, it sees:
Stream: <task-uuid>
Version 1: TaskCreated{...}
Version 2: TaskCompleted{...} ← after completion
The evolve function in completetask handles both event types so it can correctly reconstruct the full state from any stream position.
Key insights¶
State is ephemeral — it's always rebuilt from events. Never persist taskState to a database; always replay events.
Each command handler has its own state struct — the state only needs to contain what's required for this handler's business rules. completetask only needs Exists and Completed; it doesn't need the task title.
Decide is a pure function — given the same state and command, it always produces the same events. This makes it trivially testable:
func TestCompleteTask_AlreadyCompleted(t *testing.T) {
state := taskState{Exists: true, Completed: true}
_, err := decide(state, CompleteTask{TaskID: uuid.New()})
if err == nil {
t.Fatal("expected error")
}
}
Next: Part 4: Real-time Projections — use the event bus to keep a cached read model up to date.