The notification trap
You're building a notification service. Right now, it sends emails. The product manager asks for SMS support. Next week, they want Slack. You start with a simple function. Then you add a flag. Then you add a switch statement. Before long, your code looks like this:
func SendNotification(user User, msg string, channel string) {
if channel == "email" {
// 50 lines of email logic
} else if channel == "sms" {
// 40 lines of SMS logic
} else if channel == "slack" {
// 30 lines of Slack logic
}
}
Adding a new channel means touching the function that handles all channels. You risk breaking email when you add Discord. The function grows. Tests become fragile. You need a way to swap the sending logic without rewriting the code that triggers the notification.
Go solves this with interfaces. You define the behavior you need, and any struct that provides that behavior fits the slot. You don't need a special "Strategy Pattern" library. You just use the language's built-in type system.
Interfaces decouple behavior from structure. Define the contract, not the implementation.
Interfaces as contracts
Think of a power drill. The drill body holds the motor, the trigger, and the battery. That's your context. The bit at the front does the actual work. You swap a Phillips head for a flathead, and the drill doesn't care. It just spins whatever is attached. The interface is the chuck that holds the bit. As long as the bit fits the chuck, the drill works.
In Go, the interface is the contract. Any struct that implements the methods in the interface fits the contract. Go doesn't use an implements keyword. The compiler checks the method sets. If a type has the methods defined in the interface, it satisfies the interface. This is implicit implementation.
This design has a surprising benefit. You can define an interface in one package and implement it in another without importing the interface definition. The implementing package doesn't need to know the interface exists. This keeps packages loosely coupled. You can add implementations to third-party types without modifying their code.
Implicit interfaces keep packages loosely coupled. Define the interface where it's used, not where it's implemented.
Minimal strategy
Here's the skeleton. An interface defines the behavior, structs implement it, and a context holds the interface. The context delegates work to the strategy without knowing the concrete type.
// Notifier defines the contract for message delivery.
// Types satisfy this interface implicitly by implementing Send.
type Notifier interface {
Send(msg string) error
}
// EmailNotifier implements Notifier for email.
type EmailNotifier struct {
to string
}
// Send dispatches the message via email.
func (n *EmailNotifier) Send(msg string) error {
// Simulate email transmission to n.to
return nil
}
// SMSNotifier implements Notifier for SMS.
type SMSNotifier struct {
number string
}
// Send dispatches the message via SMS.
func (n *SMSNotifier) Send(msg string) error {
// Simulate SMS transmission to n.number
return nil
}
The context holds the interface and uses it.
// Dispatcher holds a Notifier and delegates the work.
// Using the interface type allows swapping strategies at runtime.
type Dispatcher struct {
notifier Notifier
}
// NewDispatcher creates a Dispatcher with the specified notifier.
func NewDispatcher(n Notifier) *Dispatcher {
return &Dispatcher{notifier: n}
}
// Dispatch triggers the notification using the current strategy.
func (d *Dispatcher) Dispatch(msg string) error {
return d.notifier.Send(msg)
}
Usage is straightforward. You create a dispatcher with a concrete notifier. You can swap the notifier later by creating a new dispatcher or updating the field.
email := &EmailNotifier{to: "user@example.com"}
disp := NewDispatcher(email)
disp.Dispatch("Hello")
sms := &SMSNotifier{number: "+1234567890"}
disp = NewDispatcher(sms)
disp.Dispatch("Hello")
The Dispatcher code never changes when you add SlackNotifier. You just implement Notifier and pass it in.
Method sets matter. Match the receiver type to the interface requirement.
How the compiler checks
Go interfaces are satisfied by method sets. A method set includes all methods with a specific receiver type. For a pointer receiver *T, the method set includes methods with receiver *T and methods with receiver T. For a value receiver T, the method set includes only methods with receiver T.
This distinction causes a common compiler error. If your interface requires a method with a pointer receiver, you must pass a pointer to the struct. If you pass a value, the compiler rejects it.
// Sender requires a pointer receiver method.
type Sender interface {
Send(msg string) error
}
// MySender has a pointer receiver.
type MySender struct{}
func (s *MySender) Send(msg string) error {
return nil
}
func main() {
var s Sender
// Error: MySender does not implement Sender (Send method has pointer receiver)
s = MySender{}
// Correct: pointer satisfies the interface.
s = &MySender{}
}
The compiler complains with MySender does not implement Sender (Send method has pointer receiver) if you try to assign a value. The fix is simple: use a pointer.
Go code looks the same everywhere. Run gofmt on your files. Don't argue about braces; let the tool decide. Most editors run it on save.
The community follows a strong convention: accept interfaces, return structs. Functions take the interface as a parameter so callers can pass any implementation. Functions return concrete structs so the caller knows exactly what they got. This keeps the interface boundary at the function signature.
Receiver names are usually one or two letters matching the type. (n *Notifier) is standard. Avoid (this *Notifier) or (self *Notifier). The receiver name is rarely used in the method body; the short name signals that the method belongs to the type.
Real-world archive
Strategies shine when behavior varies but the workflow stays the same. Consider an archive system that compresses data before saving. You might support gzip, zstd, or no compression. The archive logic remains identical; only the compression changes.
// Compressor defines the contract for compression algorithms.
// Any type with Compress and Decompress methods satisfies this.
type Compressor interface {
Compress(data []byte) ([]byte, error)
Decompress(data []byte) ([]byte, error)
}
// GzipCompressor implements Compressor using standard gzip.
type GzipCompressor struct{}
// Compress returns gzip-compressed bytes.
func (c *GzipCompressor) Compress(data []byte) ([]byte, error) {
// Real implementation uses compress/gzip
return data, nil
}
// Decompress returns the original bytes from gzip input.
func (c *GzipCompressor) Decompress(data []byte) ([]byte, error) {
// Real implementation uses compress/gzip
return data, nil
}
The archive injects the compressor via the constructor.
// Archive stores data using a pluggable compressor.
// Injecting the interface allows testing with mock compressors.
type Archive struct {
compressor Compressor
}
// NewArchive returns an Archive configured with the given compressor.
func NewArchive(c Compressor) *Archive {
return &Archive{compressor: c}
}
// Save compresses the data and persists it.
func (d *Archive) Save(data []byte) error {
// Delegate compression to the injected strategy
compressed, err := d.compressor.Compress(data)
if err != nil {
return err
}
// Write compressed bytes to storage
return nil
}
This design makes testing easy. You can pass a mock compressor that returns deterministic data or errors. You don't need to hit the network or disk to test the archive logic.
If your strategy involves I/O, pass context.Context as the first argument. This allows cancellation to propagate through the strategy. Functions that take a context should respect cancellation and deadlines. The context flows through the call chain, enabling timeouts and graceful shutdowns.
The error handling pattern if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You can't accidentally ignore an error.
Inject dependencies through constructors. Hard-coded strategies make testing impossible.
Pitfalls and errors
Interfaces add flexibility, but they can introduce complexity if misused. The most common mistake is creating an interface that is too large. If an interface has five methods, every implementation must provide all five. This forces types to do work they might not need. Keep interfaces small. One method is ideal. Two is fine. Five suggests you're grouping unrelated behaviors.
Go's standard library models this well. io.Reader has one method. io.Writer has one method. io.Closer has one method. Complex behaviors compose from small interfaces. A type can implement io.ReadCloser by implementing Read and Close. You get the flexibility of composition without the bloat of a monolithic interface.
Another trap is the nil interface. An interface value holds two things: the type and the value. If you assign a nil pointer to an interface, the interface is not nil. It holds the type of the pointer and a nil value.
var n Notifier = (*EmailNotifier)(nil)
if n == nil {
// This branch never executes.
// n is not nil; it holds type *EmailNotifier and value nil.
}
Calling a method on this interface panics with runtime error: invalid memory address or nil pointer dereference. The method runs, but the receiver is nil. Always check the concrete value, not the interface, or ensure the method handles nil receivers safely.
The compiler helps catch mismatched types. If a struct claims to implement an interface but misses a method, you get cannot use s (variable of type Struct) as Notifier in argument: missing method Send. This error is precise. It tells you exactly which method is missing.
Small interfaces are easy to implement. Big interfaces are a design debt.
When to use strategies
Go gives you several ways to vary behavior. Pick the right tool for the job.
Use an interface-based strategy when you need to swap behavior at runtime and the behavior involves multiple methods or state. Use a type switch when you have a fixed set of known types and need to handle each one differently without a common interface. Use a function type when the behavior is a single action with no state, like a callback. Use a struct with embedded behavior when the strategy is tightly coupled to the context and rarely changes. Use plain sequential code when you don't need flexibility: the simplest thing that works is usually the right thing.
Interfaces add flexibility. Flexibility costs clarity. Pay for it only when you need it.