Every codebase has one: a function that started as a simple if/else and grew into a 200-line switch statement with nested conditions, special cases, and a comment saying “TODO: refactor this.” The Strategy pattern is one of the most practical tools for untangling this kind of code, and it works especially well in Go and Python where interfaces and protocols make the implementation clean and idiomatic.
In this post, you’ll see the Strategy pattern applied to a real problem — a notification service that needs to send messages through multiple channels (email, SMS, push, Slack) — and how to refactor from a growing switch statement into composable, testable strategies.
The Problem: The Switch That Ate Your Codebase
Consider a notification service. Version one only sends email. Then product asks for SMS. Then push notifications. Then Slack for internal alerts. Each new channel adds another branch:
func (s *NotificationService) Send(
recipient string,
message string,
channel Channel,
) error {
switch channel {
case ChannelEmail:
// 30 lines of email logic
smtpClient, err := s.connectSMTP()
if err != nil {
return fmt.Errorf("smtp connect: %w", err)
}
// ... build MIME, send, handle retries
case ChannelSMS:
// 20 lines of SMS gateway logic
resp, err := s.twilioClient.SendMessage(recipient, message)
// ... parse response, handle rate limits
case ChannelPush:
// 25 lines of FCM/APNs logic
deviceTokens := s.lookupDeviceTokens(recipient)
// ... batch send, handle failures
case ChannelSlack:
// 15 lines of Slack webhook logic
// ...
default:
return fmt.Errorf("unsupported channel: %s", channel)
}
return nil
}
This function is now responsible for four completely different protocols, each with its own authentication, retry logic, and error handling. Adding a fifth channel means modifying this function again — risking regressions in the other four branches. Testing requires mocking all four gateway clients for a single function.
The Strategy Pattern: One Interface, Many Behaviors
The Strategy pattern extracts each behavior into its own type, all satisfying a single interface. The context (the notification service) holds a reference to the strategy and delegates to it. New channels become new strategy implementations — the context never changes.
Here’s the Go implementation. First, the interface:
// Notifier defines the contract for sending a notification.
// Each channel implements this interface independently.
type Notifier interface {
Send(ctx context.Context, recipient string, message Message) error
Channel() Channel
}
// Message carries the notification payload.
type Message struct {
Subject string
Body string
HTML bool
}
Then each channel becomes its own struct with its own dependencies:
type EmailNotifier struct {
smtpHost string
smtpPort int
auth smtp.Auth
fromAddress string
maxRetries int
}
func (e *EmailNotifier) Send(ctx context.Context, recipient string, msg Message) error {
// Only email-specific logic lives here
client, err := e.dialSMTP(ctx)
if err != nil {
return fmt.Errorf("email send to %s: %w", recipient, err)
}
defer client.Close()
return e.sendWithRetry(client, recipient, msg)
}
func (e *EmailNotifier) Channel() Channel { return ChannelEmail }
type SMSNotifier struct {
twilioClient *twilio.Client
fromNumber string
}
func (s *SMSNotifier) Send(ctx context.Context, recipient string, msg Message) error {
// Only SMS-specific logic lives here
_, err := s.twilioClient.Messages.Create(
ctx, &openapi.CreateMessageParams{
To: recipient,
From: s.fromNumber,
Body: msg.Body,
},
)
return err
}
func (s *SMSNotifier) Channel() Channel { return ChannelSMS }
The notification service becomes trivial:
type NotificationService struct {
notifiers map[Channel]Notifier
logger *slog.Logger
}
func (s *NotificationService) Send(
ctx context.Context,
recipient string,
message Message,
channel Channel,
) error {
notifier, ok := s.notifiers[channel]
if !ok {
return fmt.Errorf("unsupported channel: %s", channel)
}
if err := notifier.Send(ctx, recipient, message); err != nil {
s.logger.Error("notification failed",
"channel", channel,
"recipient", recipient,
"error", err,
)
return err
}
s.logger.Info("notification sent",
"channel", channel, "recipient", recipient,
)
return nil
}
Adding a Slack notifier? Write a new struct implementing Notifier, register it in the map at startup. The NotificationService.Send method never changes again.
The Same Pattern in Python
Python gives you two paths to polymorphism. The typing.Protocol class provides structural subtyping — any class with the right methods satisfies the interface without inheritance. For explicit contracts enforced at runtime, abc.ABC with abstractmethod requires subclasses to implement the interface:
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class Message:
subject: str
body: str
html: bool = False
class Notifier(ABC):
@abstractmethod
def send(self, recipient: str, message: Message) -> None:
...
@property
@abstractmethod
def channel(self) -> str:
...
class EmailNotifier(Notifier):
def __init__(self, smtp_host: str, smtp_port: int, from_addr: str):
self._smtp_host = smtp_host
self._smtp_port = smtp_port
self._from_addr = from_addr
def send(self, recipient: str, message: Message) -> None:
with smtplib.SMTP(self._smtp_host, self._smtp_port) as server:
server.sendmail(
self._from_addr, recipient,
self._build_mime(message),
)
@property
def channel(self) -> str:
return "email"
def _build_mime(self, message: Message) -> str:
# Email-specific formatting
...
class SMSNotifier(Notifier):
def __init__(self, twilio_client, from_number: str):
self._client = twilio_client
self._from = from_number
def send(self, recipient: str, message: Message) -> None:
self._client.messages.create(
to=recipient,
from_=self._from,
body=message.body,
)
@property
def channel(self) -> str:
return "sms"
In Python you can also use typing.Protocol for structural subtyping — any class with send() and channel automatically satisfies the interface without explicit inheritance. This is useful when integrating third-party SDKs that you can’t modify.
Why This Matters for Testing
One of the biggest practical wins is test isolation. Instead of mocking four different gateway clients to test one function, you test each strategy in isolation:
func TestEmailNotifier_Send(t *testing.T) {
// Only mock SMTP — no SMS, push, or Slack infrastructure needed
server := httptest.NewServer(mockSMTPHandler(t))
defer server.Close()
notifier := &EmailNotifier{
smtpHost: "localhost",
smtpPort: serverPort(server),
fromAddress: "noreply@example.com",
}
err := notifier.Send(ctx, "user@example.com", Message{
Subject: "Test",
Body: "Hello",
})
assert.NoError(t, err)
}
The notification service itself only needs a mock Notifier to verify it routes correctly:
func TestNotificationService_RoutesToCorrectChannel(t *testing.T) {
mock := &mockNotifier{channel: ChannelEmail}
svc := &NotificationService{
notifiers: map[Channel]Notifier{ChannelEmail: mock},
}
err := svc.Send(ctx, "user@example.com", msg, ChannelEmail)
assert.NoError(t, err)
assert.Equal(t, 1, mock.sendCount)
}
Composition: Strategies That Wrap Other Strategies
Strategies become truly powerful when you compose them. A RetryNotifier wraps any Notifier and adds retry logic without modifying the original:
type RetryNotifier struct {
inner Notifier
maxRetries int
backoff time.Duration
}
func (r *RetryNotifier) Send(ctx context.Context, recipient string, msg Message) error {
var lastErr error
for attempt := 0; attempt <= r.maxRetries; attempt++ {
err := r.inner.Send(ctx, recipient, msg)
if err == nil {
return nil
}
lastErr = err
if attempt < r.maxRetries {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(r.backoff * time.Duration(1<<attempt)):
}
}
}
return fmt.Errorf("after %d retries: %w", r.maxRetries, lastErr)
}
func (r *RetryNotifier) Channel() Channel { return r.inner.Channel() }
Combine it with logging, rate limiting, or metrics wrapping — all as separate strategies that decorate the base notifier. The core email/sms/push logic stays untouched.
When Strategy Beats Switch
The Strategy pattern isn’t always the right tool. Here’s when it shines and when to skip it:
- Use Strategy when you have 3+ branches in a switch that do meaningfully different things (different protocols, different algorithms, different external dependencies).
- Use Strategy when new variants arrive regularly — each one becomes a new file, not a new
casein an existing file. - Use Strategy when each branch has its own dependencies (SMTP client, Twilio client, FCM client) — isolating them makes testing and dependency injection straightforward.
- Skip Strategy when the branches are simple one-liners mapping an enum to a string or constant — that’s just a lookup table, not a pattern.
- Skip Strategy when you have exactly 2 variants and neither is likely to grow — the
if/elseis clearer than two files.
From Patterns to Habit
The Strategy pattern is one of those techniques that changes how you read code. Once you start noticing 20-line switch branches doing independent work, you start reaching for an interface and a map instead. The result is code where adding a new capability means adding a new file — not editing an existing one. That’s the Open/Closed principle in its most tangible form: open for extension, closed for modification.
Go’s implicit interfaces and Python’s Protocols make this pattern particularly low-cost to implement. No framework, no abstract class hierarchy, no factory boilerplate — just a small interface and a map. The next time you’re about to add another case to a switch that’s already too long, consider whether extracting a strategy would be the cleaner path.