Command Pattern in Go: Action Queuing

The Command pattern encapsulates a request as an object, letting you parameterize clients with different requests, queue operations, and support undoable actions. It's one of the Gang of Four...

Key Insights

  • The Command pattern transforms operations into objects, enabling queuing, logging, and undo functionality that simple function calls cannot provide
  • Go’s small interfaces and first-class functions make Command implementations clean and testable, while goroutines enable concurrent execution patterns
  • Use this pattern when you need to decouple request creation from execution timing—skip it when a simple function slice would suffice

Introduction to the Command Pattern

The Command pattern encapsulates a request as an object, letting you parameterize clients with different requests, queue operations, and support undoable actions. It’s one of the Gang of Four behavioral patterns, and it maps remarkably well to Go’s design philosophy.

Action queuing is where this pattern shines. Instead of executing operations immediately, you package them as command objects and process them later—either sequentially, in parallel, or based on priority. This separation between “what to do” and “when to do it” enables powerful architectural patterns: job queues, transaction logs, macro recording, and undo/redo systems.

Go brings specific strengths to this pattern. Small interfaces (often single-method) align perfectly with the Command interface. Goroutines and channels provide natural primitives for concurrent command processing. And struct embedding lets you compose complex commands from simpler ones without inheritance hierarchies.

Core Components and Structure

The Command pattern has four key players:

  • Command: The interface defining the execution contract
  • ConcreteCommand: Implementations that perform specific actions
  • Invoker: The component that stores and triggers commands
  • Receiver: The object that actually performs the work

In Go, we keep the Command interface minimal:

type Command interface {
    Execute() error
}

That’s it. One method. This follows Go’s preference for small interfaces and makes testing straightforward—any type with an Execute() error method is a command.

Here’s a concrete command that operates on a receiver:

type Document struct {
    Content string
}

type AppendTextCommand struct {
    doc  *Document
    text string
}

func NewAppendTextCommand(doc *Document, text string) *AppendTextCommand {
    return &AppendTextCommand{doc: doc, text: text}
}

func (c *AppendTextCommand) Execute() error {
    c.doc.Content += c.text
    return nil
}

The command captures everything needed to perform the action: the receiver (doc), the parameters (text), and the logic (Execute). The invoker doesn’t need to know any of these details.

Building an Action Queue

The invoker in an action queue scenario is the queue itself. It accumulates commands and executes them on demand:

type CommandQueue struct {
    commands []Command
    mu       sync.Mutex
}

func NewCommandQueue() *CommandQueue {
    return &CommandQueue{
        commands: make([]Command, 0),
    }
}

func (q *CommandQueue) Add(cmd Command) {
    q.mu.Lock()
    defer q.mu.Unlock()
    q.commands = append(q.commands, cmd)
}

func (q *CommandQueue) Execute() error {
    q.mu.Lock()
    if len(q.commands) == 0 {
        q.mu.Unlock()
        return nil
    }
    cmd := q.commands[0]
    q.commands = q.commands[1:]
    q.mu.Unlock()
    
    return cmd.Execute()
}

func (q *CommandQueue) ExecuteAll() []error {
    var errors []error
    for {
        q.mu.Lock()
        if len(q.commands) == 0 {
            q.mu.Unlock()
            break
        }
        cmd := q.commands[0]
        q.commands = q.commands[1:]
        q.mu.Unlock()
        
        if err := cmd.Execute(); err != nil {
            errors = append(errors, err)
        }
    }
    return errors
}

func (q *CommandQueue) Len() int {
    q.mu.Lock()
    defer q.mu.Unlock()
    return len(q.commands)
}

This gives you FIFO execution. For priority-based ordering, swap the slice for a heap:

type PriorityCommand interface {
    Command
    Priority() int
}

type PriorityQueue struct {
    commands []PriorityCommand
    mu       sync.Mutex
}

func (pq *PriorityQueue) Add(cmd PriorityCommand) {
    pq.mu.Lock()
    defer pq.mu.Unlock()
    pq.commands = append(pq.commands, cmd)
    // In production, use container/heap for O(log n) operations
    sort.Slice(pq.commands, func(i, j int) bool {
        return pq.commands[i].Priority() > pq.commands[j].Priority()
    })
}

Practical Example: Task Processing System

Let’s build a notification system that queues different types of messages:

package main

import (
    "fmt"
    "log"
    "time"
)

// Command interface
type Command interface {
    Execute() error
    Description() string
}

// Receivers
type EmailService struct{}

func (s *EmailService) Send(to, subject, body string) error {
    log.Printf("EMAIL to %s: %s", to, subject)
    time.Sleep(100 * time.Millisecond) // Simulate network delay
    return nil
}

type SMSService struct{}

func (s *SMSService) Send(phone, message string) error {
    log.Printf("SMS to %s: %s", phone, message)
    time.Sleep(50 * time.Millisecond)
    return nil
}

type SlackService struct{}

func (s *SlackService) Post(channel, message string) error {
    log.Printf("SLACK #%s: %s", channel, message)
    time.Sleep(75 * time.Millisecond)
    return nil
}

// Concrete Commands
type EmailCommand struct {
    service *EmailService
    to      string
    subject string
    body    string
}

func (c *EmailCommand) Execute() error {
    return c.service.Send(c.to, c.subject, c.body)
}

func (c *EmailCommand) Description() string {
    return fmt.Sprintf("Email to %s", c.to)
}

type SMSCommand struct {
    service *SMSService
    phone   string
    message string
}

func (c *SMSCommand) Execute() error {
    return c.service.Send(c.phone, c.message)
}

func (c *SMSCommand) Description() string {
    return fmt.Sprintf("SMS to %s", c.phone)
}

type SlackCommand struct {
    service *SlackService
    channel string
    message string
}

func (c *SlackCommand) Execute() error {
    return c.service.Post(c.channel, c.message)
}

func (c *SlackCommand) Description() string {
    return fmt.Sprintf("Slack to #%s", c.channel)
}

// Processor (Invoker)
type NotificationProcessor struct {
    queue []Command
}

func (p *NotificationProcessor) Queue(cmd Command) {
    p.queue = append(p.queue, cmd)
    log.Printf("Queued: %s", cmd.Description())
}

func (p *NotificationProcessor) ProcessAll() {
    log.Printf("Processing %d notifications...", len(p.queue))
    for _, cmd := range p.queue {
        if err := cmd.Execute(); err != nil {
            log.Printf("Failed: %s - %v", cmd.Description(), err)
        }
    }
    p.queue = nil
}

func main() {
    emailSvc := &EmailService{}
    smsSvc := &SMSService{}
    slackSvc := &SlackService{}
    
    processor := &NotificationProcessor{}
    
    // Queue various notifications
    processor.Queue(&EmailCommand{emailSvc, "user@example.com", "Welcome!", "Thanks for signing up"})
    processor.Queue(&SMSCommand{smsSvc, "+1234567890", "Your code is 123456"})
    processor.Queue(&SlackCommand{slackSvc, "alerts", "New user registered"})
    processor.Queue(&EmailCommand{emailSvc, "admin@example.com", "Daily Report", "See attached"})
    
    // Process them all at once (e.g., at end of request, on schedule, etc.)
    processor.ProcessAll()
}

Adding Undo/Redo Support

Extend the interface to support reversal:

type UndoableCommand interface {
    Command
    Undo() error
}

type CommandHistory struct {
    executed []UndoableCommand
    undone   []UndoableCommand
    mu       sync.Mutex
}

func (h *CommandHistory) Execute(cmd UndoableCommand) error {
    if err := cmd.Execute(); err != nil {
        return err
    }
    h.mu.Lock()
    h.executed = append(h.executed, cmd)
    h.undone = nil // Clear redo stack on new action
    h.mu.Unlock()
    return nil
}

func (h *CommandHistory) Undo() error {
    h.mu.Lock()
    if len(h.executed) == 0 {
        h.mu.Unlock()
        return fmt.Errorf("nothing to undo")
    }
    cmd := h.executed[len(h.executed)-1]
    h.executed = h.executed[:len(h.executed)-1]
    h.mu.Unlock()
    
    if err := cmd.Undo(); err != nil {
        return err
    }
    
    h.mu.Lock()
    h.undone = append(h.undone, cmd)
    h.mu.Unlock()
    return nil
}

func (h *CommandHistory) Redo() error {
    h.mu.Lock()
    if len(h.undone) == 0 {
        h.mu.Unlock()
        return fmt.Errorf("nothing to redo")
    }
    cmd := h.undone[len(h.undone)-1]
    h.undone = h.undone[:len(h.undone)-1]
    h.mu.Unlock()
    
    if err := cmd.Execute(); err != nil {
        return err
    }
    
    h.mu.Lock()
    h.executed = append(h.executed, cmd)
    h.mu.Unlock()
    return nil
}

Commands must store enough state to reverse themselves:

type AppendTextCommand struct {
    doc      *Document
    text     string
    executed bool
}

func (c *AppendTextCommand) Execute() error {
    c.doc.Content += c.text
    c.executed = true
    return nil
}

func (c *AppendTextCommand) Undo() error {
    if !c.executed {
        return fmt.Errorf("command not executed")
    }
    c.doc.Content = c.doc.Content[:len(c.doc.Content)-len(c.text)]
    c.executed = false
    return nil
}

Concurrency Considerations

For high-throughput systems, use a worker pool:

type WorkerPool struct {
    commands chan Command
    results  chan error
    wg       sync.WaitGroup
}

func NewWorkerPool(workers, queueSize int) *WorkerPool {
    wp := &WorkerPool{
        commands: make(chan Command, queueSize),
        results:  make(chan error, queueSize),
    }
    
    for i := 0; i < workers; i++ {
        wp.wg.Add(1)
        go wp.worker(i)
    }
    
    return wp
}

func (wp *WorkerPool) worker(id int) {
    defer wp.wg.Done()
    for cmd := range wp.commands {
        err := cmd.Execute()
        wp.results <- err
    }
}

func (wp *WorkerPool) Submit(cmd Command) {
    wp.commands <- cmd
}

func (wp *WorkerPool) Shutdown() {
    close(wp.commands)
    wp.wg.Wait()
    close(wp.results)
}

This processes commands concurrently while maintaining a bounded queue. Commands must be safe for concurrent execution—avoid shared mutable state in receivers, or protect it with synchronization.

When to Use (and Avoid) This Pattern

Use the Command pattern when you need:

  • Queuing operations for later execution
  • Undo/redo functionality
  • Transaction logging and replay
  • Macro recording (composite commands)
  • Decoupling request creation from execution

Skip it when:

  • You’re just calling functions in sequence
  • There’s no need for queuing, logging, or undo
  • The overhead of command objects isn’t justified

In Go, you have alternatives. A slice of closures often works:

type Action func() error

func executeAll(actions []Action) []error {
    var errs []error
    for _, a := range actions {
        if err := a(); err != nil {
            errs = append(errs, err)
        }
    }
    return errs
}

This is simpler but loses the benefits of command objects: you can’t inspect them, serialize them, or easily implement undo. Choose based on your actual requirements.

The Command pattern adds structure that pays off when you need it. For action queuing specifically, it provides a clean abstraction that scales from simple FIFO queues to sophisticated concurrent processors with undo support.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.