Every Go codebase has one: that function with a growing switch statement. It starts with two cases — a direct charge and a test payment. Then someone adds Stripe. Then PayPal. Then crypto. Six months later, the function is 300 lines of conditionals, every code change risks breaking an unrelated path, and writing a test means mocking half the internet.
The Strategy pattern is the standard fix. Instead of branching on which behavior to execute, you define a common interface and pass in the implementation that does what you need. The caller doesn’t know or care which concrete strategy is running. New behaviors are added as new types, not as new case branches in a bloated function.
Go makes this particularly pleasant because interfaces are implicit — you don’t need to declare “implements Strategy” anywhere. Any struct with the right method signature satisfies the contract. This article walks through the pattern from first principles, shows where it goes wrong in practice, and covers the idiomatic Go approach that keeps things testable without over-engineering.
The Problem: A Growing Switch Statement
Let’s use a concrete example: a notification service that sends messages through different channels. Here’s the version that will haunt your code review:
func SendNotification(channel, recipient, message string) error {
switch channel {
case "email":
// Validate email format, connect to SMTP, build MIME headers...
return smtp.SendMail("smtp.example.com:587", nil, from, []string{recipient}, []byte(message))
case "sms":
// Parse phone number, call Twilio API, handle rate limits...
return twilioClient.SendMessage(recipient, message)
case "slack":
// Build Slack webhook payload, handle channel mentions...
return postToSlackWebhook(recipient, message)
case "webhook":
// POST to arbitrary URL, handle retries, verify response...
return postWebhook(recipient, message)
default:
return fmt.Errorf("unknown channel: %s", channel)
}
}
This works for two channels. It falls apart at five. The problems compound: the function violates the open/closed principle (every new channel requires modifying this function), it’s impossible to unit test one channel without the others compiled in, and the SMTP, Twilio, and HTTP client dependencies are all baked into a single signature. Adding a new channel like a push notification means touching the same function that handles your production email path.
The Fix: Define a Strategy Interface
The strategy pattern replaces the switch with polymorphism. First, define a single-method interface that every channel must implement:
package notifier
// Notifier is the strategy interface. Any type that can send
// a message to a recipient satisfies it.
type Notifier interface {
Send(recipient, message string) error
}
That’s the entire abstraction. One method, one responsibility. Now each channel is a separate type that implements Notifier:
type EmailNotifier struct {
Host string
Port int
Username string
Password string
}
func (e *EmailNotifier) Send(recipient, message string) error {
addr := fmt.Sprintf("%s:%d", e.Host, e.Port)
auth := smtp.PlainAuth("", e.Username, e.Password, e.Host)
return smtp.SendMail(addr, auth, e.Username, []string{recipient}, []byte(message))
}
type SMSNotifier struct {
AccountSID string
AuthToken string
FromNumber string
}
func (s *SMSNotifier) Send(recipient, message string) error {
// Twilio REST API call
return s.callTwilio(recipient, message)
}
Notice what changed: each strategy carries its own configuration. The EmailNotifier owns its SMTP credentials. The SMSNotifier owns its Twilio config. There’s no giant parameter list — the caller that creates the strategy provides what it needs, and the Send method signature stays uniform across all implementations.
Using the Strategy
The consumer of the strategy doesn’t need to know which concrete type it holds. It just calls Send:
type AlertService struct {
notifier notifier.Notifier
}
func NewAlertService(n notifier.Notifier) *AlertService {
return &AlertService{notifier: n}
}
func (a *AlertService) Alert(recipient, message string) error {
return a.notifier.Send(recipient, message)
}
Wiring it up at startup:
func main() {
var n notifier.Notifier
channel := os.Getenv("NOTIFY_CHANNEL")
switch channel {
case "email":
n = ¬ifier.EmailNotifier{
Host: os.Getenv("SMTP_HOST"),
Port: 587,
}
case "sms":
n = ¬ifier.SMSNotifier{
AccountSID: os.Getenv("TWILIO_SID"),
}
default:
log.Fatal("unknown notification channel")
}
service := NewAlertService(n)
if err := service.Alert("admin@example.com", "Disk usage above 90%"); err != nil {
log.Fatal(err)
}
}
The switch hasn’t disappeared — it’s just moved. Instead of living inside the core Alert logic where it runs on every call, it now lives at the composition root (your main function or a DI container), where it runs once at startup. The important difference: AlertService and its tests are now completely decoupled from the concrete notification mechanisms.
Testing Becomes Trivial
Here’s the payoff. Because AlertService depends on an interface, not a concrete type, testing it doesn’t require a real SMTP server or a Twilio account. A simple fake suffices:
type FakeNotifier struct {
SentTo []string
SentMessage []string
ReturnError error
}
func (f *FakeNotifier) Send(recipient, message string) error {
f.SentTo = append(f.SentTo, recipient)
f.SentMessage = append(f.SentMessage, message)
return f.ReturnError
}
func TestAlertSendsToCorrectRecipient(t *testing.T) {
fake := &FakeNotifier{}
service := NewAlertService(fake)
err := service.Alert("ops@team.io", "CPU spike detected")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(fake.SentTo) != 1 || fake.SentTo[0] != "ops@team.io" {
t.Errorf("expected send to ops@team.io, got %v", fake.SentTo)
}
if fake.SentMessage[0] != "CPU spike detected" {
t.Errorf("unexpected message: %s", fake.SentMessage[0])
}
}
No mocking framework needed. The fake is a plain struct with a slice to record calls. You can assert on exactly what was sent, simulate failures by setting ReturnError, and verify retry logic — all without touching the network. This is the essence of Go’s testing philosophy: small, focused tests that run in milliseconds.
Functional Strategies: When a Method Is Overkill
Sometimes a single-method interface is heavier than you need. If the strategy has no internal state and the behavior is just “run this function,” a plain function type is more idiomatic in Go. The standard library does this with http.HandlerFunc — any function matching func(http.ResponseWriter, *http.Request) can be used where an http.Handler is expected.
The same approach works for your strategies:
type DiscountFunc func(amount float64) float64
// Concrete strategies as functions
var (
NoDiscount DiscountFunc = func(a float64) float64 { return a }
TenPercentOff DiscountFunc = func(a float64) float64 { return a * 0.9 }
Flat50Off DiscountFunc = func(a float64) float64 {
if a > 50 {
return a - 50
}
return 0
}
)
type ShoppingCart struct {
total float64
discount DiscountFunc
}
func (c *ShoppingCart) Checkout() float64 {
return c.discount(c.total)
}
Function-based strategies shine when the logic is simple and stateless. The trade-off: as the strategy grows — needing configuration, internal state, or multiple methods — you’ll want to convert it to an interface and a struct. Starting with functions and refactoring to an interface when needed is a reasonable progression.
Common Pitfalls
Don’t create a strategy for every branching decision. If you have two behaviors and they’ll never change, a simple if/else is clearer than a factory, a registry, and two implementing types. The pattern earns its complexity when behaviors are selected at runtime, change independently, or need separate testing.
Don’t leak strategy internals to the caller. The whole point is that AlertService doesn’t know whether it’s talking to email or SMS. If you find yourself writing if _, ok := notifier.(*EmailNotifier); ok, the abstraction is leaking. Either the interface needs a method for whatever you’re trying to access, or the type assertion belongs at the composition root.
Don’t over-parameterize strategies. A strategy that takes 15 parameters through its constructor is a code smell. If EmailNotifier needs host, port, username, password, TLS config, timeout, and retry policy, group those into a Config struct. It keeps the constructor readable and makes the configuration testable as a unit.
Strategy vs. Other Patterns
The strategy pattern gets confused with a few neighbors. Factory creates objects; strategy defines how objects behave. Command encapsulates a request as an object (with undo, queuing, history); strategy encapsulates an algorithm. The distinction matters: a command pattern’s objects are typically short-lived and specific to a single action, while strategies are long-lived policies selected for their behavior.
Template Method is the closest relative. It solves the same problem (varying parts of an algorithm) but uses inheritance — a base class defines the skeleton, subclasses fill in the steps. Go doesn’t have inheritance, so the strategy pattern is the idiomatic choice. You compose behaviors rather than deriving from a base.
Wrapping Up
The strategy pattern isn’t exotic — it’s what Go does naturally with interfaces. Define a one-method contract, implement it per behavior, inject the implementation at the boundary, and let the core logic stay blissfully unaware of the details. The benefits are concrete: each strategy is independently testable, new strategies are additive (no modifying existing code), and the switch statement shrinks from a per-call branching problem to a one-time wiring decision.
The next time you reach for a switch inside core business logic, ask: would this be better as an interface? If the cases share an input/output shape but differ in behavior, the answer is almost always yes. Define the strategy, inject the dependency, and watch the test coverage climb without a single mock framework in sight.