CQRS
Separate the model used for writing state (Commands) from the model used for reading it (Queries), allowing each side to be optimised independently.
CQRS (Command Query Responsibility Segregation) separates every operation into one of two kinds: commands (mutate state, return nothing or an error) and queries (read state, return data, change nothing). The core insight is that read and write models want different shapes. Commands need rich domain validation, while queries usually want flat, denormalized views. Force one model to serve both jobs and you'll usually end up with either an anemic domain or bloated query results.
Each command and query gets its own handler type, its own input struct, and sometimes its own data store when the workloads diverge far enough.
Problem
A single NoteService handles both writes and reads. The GetNote method returns the full domain struct, which exposes internal state. The CreateNote and GetNoteSummary methods share the same repository, so optimising the read path requires touching the write path too. Every new read shape requires a new method on the same service.
go// One service doing everything — reads and writes entangled type NoteService struct { repo NoteRepository } func (s *NoteService) CreateNote(ctx context.Context, title, body string) error { // mutates state } func (s *NoteService) GetNote(ctx context.Context, id string) (*Note, error) { // returns full domain object, exposes internals } func (s *NoteService) GetNoteSummary(ctx context.Context, id string) (*NoteSummary, error) { // different read shape, service now has two query methods with different return types }
Solution
Separate every operation into a command or a query. Commands mutate; queries read. Each has its own handler.
text┌─────────────────────────────────────────────────────────┐ │ Client │ └─────────┬──────────────────────────┬────────────────────┘ │ Commands │ Queries ▼ ▼ ┌──────────────────┐ ┌───────────────────────────┐ │ Command Handler │ │ Query Handler │ │ (mutate, err) │ │ (read, return DTO) │ └────────┬─────────┘ └────────────┬───────────────┘ │ │ ▼ ▼ ┌──────────────────┐ ┌───────────────────────────┐ │ Write Store │ │ Read Store │ │ (normalised DB) │ │ (same DB or read replica, │ │ │ │ denormalised views, etc.) │ └──────────────────┘ └───────────────────────────┘
Define commands and queries as plain structs:
command/create_note.gopackage command import ( "context" "fmt" "time" ) type Note struct { ID string Title string Body string CreatedAt time.Time } type NoteRepository interface { Save(ctx context.Context, n *Note) error } type CreateNote struct { ID string Title string Body string } type CreateNoteHandler struct { repo NoteRepository } func NewCreateNoteHandler(repo NoteRepository) *CreateNoteHandler { return &CreateNoteHandler{repo: repo} } func (h *CreateNoteHandler) Handle(ctx context.Context, cmd CreateNote) error { if cmd.Title == "" { return fmt.Errorf("title is required") } n := &Note{ ID: cmd.ID, Title: cmd.Title, Body: cmd.Body, CreatedAt: time.Now(), } return h.repo.Save(ctx, n) }
command/update_note.gopackage command import ( "context" "fmt" ) type NoteReader interface { FindByID(ctx context.Context, id string) (*Note, error) } type NoteWriter interface { NoteReader Save(ctx context.Context, n *Note) error } type UpdateNote struct { ID string Body string } type UpdateNoteHandler struct { repo NoteWriter } func NewUpdateNoteHandler(repo NoteWriter) *UpdateNoteHandler { return &UpdateNoteHandler{repo: repo} } func (h *UpdateNoteHandler) Handle(ctx context.Context, cmd UpdateNote) error { n, err := h.repo.FindByID(ctx, cmd.ID) if err != nil { return fmt.Errorf("finding note: %w", err) } n.Body = cmd.Body return h.repo.Save(ctx, n) }
Queries return purpose-built DTOs, not domain objects:
query/get_note.gopackage query import "context" // NoteView is a read-optimized projection, not the domain type. type NoteView struct { ID string Title string Preview string // first 100 chars of body WordCount int } type NoteSummary struct { ID string Title string } type NoteReadStore interface { FindByID(ctx context.Context, id string) (*NoteView, error) List(ctx context.Context) ([]NoteSummary, error) } type GetNoteHandler struct { store NoteReadStore } func NewGetNoteHandler(store NoteReadStore) *GetNoteHandler { return &GetNoteHandler{store: store} } func (h *GetNoteHandler) Handle(ctx context.Context, id string) (*NoteView, error) { return h.store.FindByID(ctx, id) } type ListNotesHandler struct { store NoteReadStore } func NewListNotesHandler(store NoteReadStore) *ListNotesHandler { return &ListNotesHandler{store: store} } func (h *ListNotesHandler) Handle(ctx context.Context) ([]NoteSummary, error) { return h.store.List(ctx) }
The read store can be the same database with a purpose-built query or a separate projection:
infra/postgres/note_read_store.gopackage postgres import ( "context" "database/sql" "myapp/query" ) type NoteReadStore struct{ db *sql.DB } func (s *NoteReadStore) FindByID(ctx context.Context, id string) (*query.NoteView, error) { var v query.NoteView err := s.db.QueryRowContext(ctx, ` SELECT id, title, LEFT(body, 100) AS preview, array_length(string_to_array(trim(body), ' '), 1) AS word_count FROM notes WHERE id = $1 `, id).Scan(&v.ID, &v.Title, &v.Preview, &v.WordCount) return &v, err } func (s *NoteReadStore) List(ctx context.Context) ([]query.NoteSummary, error) { rows, err := s.db.QueryContext(ctx, "SELECT id, title FROM notes ORDER BY created_at DESC", ) if err != nil { return nil, err } defer rows.Close() var result []query.NoteSummary for rows.Next() { var s query.NoteSummary rows.Scan(&s.ID, &s.Title) result = append(result, s) } return result, rows.Err() }
Wire it up in the HTTP layer, where commands and queries have separate endpoints:
adapter/http/note_handler.gopackage httpadapter import ( "encoding/json" "myapp/command" "myapp/query" "net/http" ) type NoteHandler struct { createNote *command.CreateNoteHandler updateNote *command.UpdateNoteHandler getNote *query.GetNoteHandler listNotes *query.ListNotesHandler } func (h *NoteHandler) Create(w http.ResponseWriter, r *http.Request) { var req struct { ID string `json:"id"` Title string `json:"title"` Body string `json:"body"` } json.NewDecoder(r.Body).Decode(&req) if err := h.createNote.Handle(r.Context(), command.CreateNote{ ID: req.ID, Title: req.Title, Body: req.Body, }); err != nil { http.Error(w, err.Error(), 422) return } w.WriteHeader(201) } func (h *NoteHandler) Get(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") view, err := h.getNote.Handle(r.Context(), id) if err != nil { http.Error(w, err.Error(), 404) return } json.NewEncoder(w).Encode(view) }
When to Use
- Read and write workloads have different performance profiles, and queries need denormalized views or aggregations that don't fit the write model.
- The domain is complex and the write side needs a rich model, but the read side only needs flat projections.
- You want to scale reads and writes independently (read replicas, caching layers).
- Different teams own the read path and the write path.
When Not to Use
- Simple CRUD. CQRS adds two handler types, two store interfaces, and two data shapes where one would do.
- The read and write models are identical, so there are no distinct query shapes or read optimizations to justify the split.
- The team is small and the added structure costs more than it returns.
Tradeoffs
The most immediate cost is volume: each operation gets its own struct and handler, so a ten-operation service becomes closer to twenty files. The split pays back through independent evolution — you can add a new query shape or optimize a read projection without touching the write model — but that dividend arrives only with enough operations to make the separation feel natural rather than forced. Eventual consistency is the non-obvious danger: if the read store is a separate projection updated asynchronously, queries may return stale data until it catches up, and this surprises users who expect to see their own write immediately. Even with a shared database, the separation doubles the integration test surface because both command handlers and query handlers need coverage.
Related Patterns
- Event-Driven Architecture — Commands naturally emit Domain Events that update read-side projections asynchronously. CQRS and event-driven systems fit together well, but CQRS does not require them. A single database with separate read and write models is enough to get started.
- Domain-Driven Design — Pairs naturally with DDD. The command side uses the rich aggregate model with enforced invariants, while the query side uses flat DTOs that bypass the domain model for read performance.
- Hexagonal Architecture — Command and query handlers are driving ports called by HTTP or queue adapters. Write and read stores are driven ports implemented by database adapters.
- Clean Architecture — Commands map to Use Cases in the inner ring, while queries can bypass the domain model and read directly from the store. The Dependency Rule still applies to both sides.
- Repository — The write side of CQRS typically uses a Repository for its write store, while the read side often uses a lighter read store interface that returns projections rather than aggregates.