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.