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 command side of CQRS is where invariants live. If your write path accepts raw HTTP bodies and passes them directly to the database, you have no single place to enforce business rules — validation scatters across controllers, services, and database constraints, and the first time two of those layers disagree you get corrupt state. A well-designed command model makes the write path a narrow, explicit contract: a DTO declares what fields a command carries, a validator enforces preconditions before any persistence happens, and a command handler owns the single transition from intent to stored fact. This structure also makes writes testable in isolation, without a running database.
A CreatePost command with a DTO, validator, and handler shows how to make the write path an explicit, testable contract.
Use these three in order. Each builds on the one before.
In one paragraph, explain what a Command DTO is and why it's different from a database model.
Walk me through how a command handler enforces business invariants step by step, from HTTP request to committed write.
Given a command handler that must enforce a uniqueness constraint (no two posts with the same title per author), how would you implement that check without a full table scan on every write?
package main
import (
"errors"
"fmt"
"strings"
"time"
)
// DTO — the wire shape of the command. No DB types, no ORM tags.
type CreatePostCommand struct {
AuthorID string
Title string
Body string
Tags []string
}
// Validate enforces preconditions before any side effect runs.
func (c CreatePostCommand) Validate() error {
if strings.TrimSpace(c.Title) == "" {
return errors.New("title is required")
}
if len(c.Title) > 200 {
return errors.New("title exceeds 200 characters")
}
if strings.TrimSpace(c.Body) == "" {
return errors.New("body is required")
}
if len(c.Tags) > 5 {
return errors.New("maximum 5 tags allowed")
}
return nil
}
// Post is the write-side aggregate — only what writes need.
type Post struct {
ID string
AuthorID string
Title string
Body string
Tags []string
CreatedAt time.Time
}
// PostRepository is a minimal write-side interface.
type PostRepository interface {
Save(p Post) error
}
// CommandHandler owns the write transition.
type CreatePostHandler struct {
repo PostRepository
}
func (h CreatePostHandler) Handle(cmd CreatePostCommand) (string, error) {
if err := cmd.Validate(); err != nil {
return "", fmt.Errorf("validation: %w", err)
}
post := Post{
ID: fmt.Sprintf("post-%d", time.Now().UnixNano()),
AuthorID: cmd.AuthorID,
Title: strings.TrimSpace(cmd.Title),
Body: strings.TrimSpace(cmd.Body),
Tags: cmd.Tags,
CreatedAt: time.Now().UTC(),
}
return post.ID, h.repo.Save(post)
}
func main() {
cmd := CreatePostCommand{AuthorID: "u1", Title: "Hello CQRS", Body: "...", Tags: []string{"go"}}
fmt.Println(cmd.Validate()) // <nil>
bad := CreatePostCommand{AuthorID: "u1", Title: "", Body: "..."}
fmt.Println(bad.Validate()) // validation: title is required
}go run main.go