Design patterns get a bad reputation when they’re taught as rigid recipes: memorize the UML diagram, create an abstract class, subclass it three times, and congratulations — you’ve “applied” the pattern. But patterns are really about identifying structural forces in your code and choosing a response. The Strategy pattern is one of the most useful and most misunderstood, and the way it’s implemented has changed dramatically as languages have adopted first-class functions. Let’s look at what the pattern actually solves and how modern code makes it nearly invisible.
The Problem Strategies Solve
You have a calculation that needs to vary. A pricing engine that applies different discount rules. A notification system that delivers via email, SMS, or push. A sorting function that sometimes sorts ascending, sometimes descending, sometimes by a computed key. The naive approach is conditional branching — a growing forest of if-statements that eventually makes the function unreadable and untestable.
The Strategy pattern says: extract the varying part into its own abstraction, and let the caller choose which implementation to use. That’s it. The value isn’t the class hierarchy — it’s the separation between the algorithm’s skeleton (which stays fixed) and the varying steps (which get injected).
The Classic OOP Approach
In a language like Java or C#, the traditional implementation defines an interface with a single method, then creates concrete implementations for each variant:
// The strategy interface
public interface IDiscountStrategy
{
decimal Apply(decimal originalPrice);
}
// Concrete strategies
public class PercentageDiscount : IDiscountStrategy
{
private readonly decimal _percentage;
public PercentageDiscount(decimal percentage)
{
_percentage = percentage;
}
public decimal Apply(decimal originalPrice)
{
return originalPrice * (1 - _percentage / 100);
}
}
public class FixedDiscount : IDiscountStrategy
{
private readonly decimal _amount;
public FixedDiscount(decimal amount)
{
_amount = amount;
}
public decimal Apply(decimal originalPrice)
{
return Math.Max(0, originalPrice - _amount);
}
}
// The context that uses the strategy
public class PricingEngine
{
public decimal Calculate(decimal price, IDiscountStrategy strategy)
{
var discounted = strategy.Apply(price);
return Math.Round(discounted, 2);
}
}
This works. It’s clean, testable, and each strategy is independently verifiable. But notice the ceremony: each strategy needs a class declaration, a constructor, a field, and a method. For something that might be three lines of logic, you’ve written twenty lines of boilerplate. In a large codebase, this adds up — you end up with dozens of small class files that each exist solely to implement a single method.
Go: Interfaces and Functions
Go’s interface system makes strategies cleaner. Interfaces are satisfied implicitly — no implements keyword — so you can define a strategy as a single-method interface and any matching function becomes a valid strategy. Even better, Go lets you treat a function as an interface implementation through the function-as-adapter pattern:
package pricing
// DiscountStrategy is a function type, not an interface.
// Simpler and more composable than a single-method interface.
type DiscountStrategy func(originalPrice float64) float64
// Strategies are just functions.
func PercentageDiscount(pct float64) DiscountStrategy {
return func(price float64) float64 {
return price * (1.0 - pct/100.0)
}
}
func FixedDiscount(amount float64) DiscountStrategy {
return func(price float64) float64 {
if price < amount {
return 0
}
return price - amount
}
}
// The engine takes a strategy function directly.
func Calculate(price float64, strategy DiscountStrategy) float64 {
discounted := strategy(price)
return math.Round(discounted*100) / 100
}
Notice what disappeared: the class declarations, the constructors, the field assignments. A strategy that applies a 20% discount is just PercentageDiscount(20) — a function call returning a closure. The strategy's configuration (the percentage) is captured by the closure, replacing the instance fields in the OOP version. This is the same pattern, just without the scaffolding. Go's implicit interface satisfaction means you don't even need the DiscountStrategy type alias for most use cases — any function with the right signature is interchangeable.
The same approach works for the built-in sort.Slice function. Instead of implementing a Comparator interface with a class, you pass a comparison function inline — see how the sort package handles this across sort.Slice, sort.SliceStable, and sort.Func:
sort.Slice(users, func(i, j int) bool {
return users[i].CreatedAt.After(users[j].CreatedAt)
})
Python: Callables as First-Class Citizens
Python makes this even more transparent. Since functions are first-class objects, a strategy is just a function. No type declaration, no interface — just pass the function where it's needed. For strategies that need configuration, closures or functools.partial from the standard library handle it elegantly:
from functools import partial
from typing import Callable
# A strategy is just a callable that takes a price and returns a price
DiscountStrategy = Callable[[float], float]
def percentage_discount(pct: float) -> DiscountStrategy:
def apply(price: float) -> float:
return price * (1 - pct / 100)
return apply
def fixed_discount(amount: float) -> DiscountStrategy:
def apply(price: float) -> float:
return max(0, price - amount)
return apply
def calculate(price: float, strategy: DiscountStrategy) -> float:
return round(strategy(price), 2)
# Usage
final = calculate(99.99, percentage_discount(20))
print(final) # 80.0
# partial works too for simple cases
def bulk_discount(price: float, threshold: float, pct: float) -> float:
if price >= threshold:
return price * (1 - pct / 100)
return price
# partial lets you pre-bind config without a closure factory
loyalty = partial(bulk_discount, threshold=50, pct=10)
final = calculate(99.99, loyalty)
When Classes Still Make Sense
The functional approach is lighter, but it's not always better. Classes earn their keep when a strategy carries significant state that changes over time, or when the strategy needs lifecycle methods (setup, teardown, connection pooling). A caching strategy that maintains a connection to Redis and needs cleanup logic is better expressed as a class than a closure:
// A stateful strategy needs structure.
type CacheStrategy interface {
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
Close() error
}
type RedisCache struct {
client *redis.Client
}
func (r *RedisCache) Get(ctx context.Context, key string) ([]byte, error) {
return r.client.Get(ctx, key).Bytes()
}
func (r *RedisCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
return r.client.Set(ctx, key, value, ttl).Err()
}
func (r *RedisCache) Close() error {
return r.client.Close()
}
Here the class structure communicates intent: this strategy owns a resource, has a lifecycle, and needs explicit teardown. Forcing this into a closure factory would hide important information and make resource management harder to reason about.
Testing Strategies in Isolation
One of the biggest wins of extracting strategies is testability. Instead of testing the full pricing pipeline with real discount logic, you can inject a test strategy that returns a known value. In Go:
func TestCalculate(t *testing.T) {
// A no-op strategy for testing the engine's rounding logic
noop := DiscountStrategy(func(p float64) float64 { return p })
result := Calculate(99.999, noop)
if result != 100.0 {
t.Errorf("expected 100.0, got %v", result)
}
// A strategy that returns zero to test edge cases
zeroOut := DiscountStrategy(func(p float64) float64 { return 0 })
result = Calculate(99.99, zeroOut)
if result != 0 {
t.Errorf("expected 0, got %v", result)
}
}
Each strategy variant gets its own focused test, and the engine's logic (rounding, validation) is tested independently with trivial stubs. This is the real payoff: not the pattern itself, but the test seam it creates. When you can swap the varying part for a one-liner in a test, the fixed part becomes easy to verify.
A Common Mistake: Over-Abstraction
The temptation with strategies is to abstract too early. You see two similar functions and immediately extract a strategy interface. But strategies pay off when you have genuine variation — three or more implementations that differ in meaningful ways, or when the variation is driven by a runtime decision (user preferences, A/B tests, feature flags). If you only have two implementations and the decision is static, a simple conditional is clearer than an indirection layer.
The pattern's value scales with the number of implementations and the frequency of change. A pricing engine that adds discount rules every quarter is a great candidate. A one-off branch in a handler function is not. The goal isn't to eliminate conditionals — it's to separate the code that changes often from the code that doesn't.
Wrapping Up
The Strategy pattern hasn't gone away — it's become more expressive. What used to require a class hierarchy can now be a function or a closure. What used to need an interface can be a type alias or a protocol. The underlying principle is unchanged: separate what varies from what stays fixed, and make the varying parts injectable. Whether you reach for a class or a closure depends on the complexity of what you're varying, not on language dogma. The best strategy implementations are the ones that feel obvious in hindsight — they make you wonder why the code was ever organized any other way.