Strategy Pattern: Choosing Without Attachment
The Problem with Hardcoded Decisions
Every application reaches a point where it must make a choice. How should a list be sorted? How should a payment be processed? Which discount should be applied? How should a user be authenticated? These decisions often begin innocently, expressed through a few if statements or a growing switch block. Over time, however, these branches accumulate. What starts as a simple method becomes a dense cluster of conditions, each representing a slightly different way of doing the same thing.
The problem is not the logic itself—it is where that logic lives. When decisions are hardcoded into a class, that class becomes responsible not just for performing an operation, but for deciding how that operation should be performed. This dual responsibility makes the code rigid. Every new requirement forces you to reopen the same method, adding yet another branch, increasing the risk of regression and reducing clarity.
The Strategy pattern exists to address precisely this kind of rigidity. It allows a system to make decisions without being permanently tied to any one of them.
The Illusion of the “Right” Choice
As developers, we often behave as though there is a single correct way to perform an operation, and our job is to discover and implement it. But in many real-world systems, the “right” choice is not fixed. It varies depending on context—user type, configuration, environment, or even time.
When a class embeds a specific algorithm, it quietly assumes permanence. It says, in effect, “this is how it is done.” But requirements evolve. Business rules shift. What was once the correct approach becomes just one option among many.
The Strategy pattern challenges this assumption. It encourages us to treat behavior not as a fixed truth, but as something that can vary. Instead of committing to a single implementation, we define a space in which multiple implementations can coexist, and we defer the choice until it is actually needed.
Encapsulating Behavior, Not Decisions
At its core, the Strategy pattern is about separating what is done from how it is done. Formally, it defines a family of algorithms, encapsulates each one, and makes them interchangeable. The client depends only on an abstraction, not on the concrete implementation.
In .NET, this maps naturally to interfaces. You define a contract that represents the behavior, and then implement that contract in multiple ways.
Consider a payment processing scenario:
public interface IPaymentStrategy
{
void Process(decimal amount);
}
public class CreditCardPayment : IPaymentStrategy
{
public void Process(decimal amount)
{
Console.WriteLine
($"Processing credit
card payment of {amount}");
}
}
public class UpiPayment : IPaymentStrategy
{
public void Process(decimal amount)
{
Console.WriteLine
($"Processing UPI payment of {amount}");
}
}
The consuming service does not concern itself with how payments are processed. It simply delegates the operation to whichever strategy it has been given.
public class PaymentService
{
private readonly IPaymentStrategy _strategy;
public PaymentService
(IPaymentStrategy strategy)
{
_strategy = strategy;
}
public void Pay(decimal amount)
{
_strategy.Process(amount);
}
}
This is the essential shift: the service no longer owns the decision. It only executes it.
Another Shape of Strategy: Discounts and Pricing
The same pattern appears in many domains. Consider an e-commerce system where different customers receive different types of discounts. A loyal customer might receive a percentage discount, a new user might get a flat reduction, and a wholesale buyer might follow tiered pricing rules.
Instead of embedding all of this logic inside an OrderService, we extract it:
public interface IDiscountStrategy
{
decimal Apply(decimal originalPrice);
}
public class PercentageDiscount :
IDiscountStrategy
{
private readonly decimal _percent;
public PercentageDiscount(decimal percent)
{
_percent = percent;
}
public decimal Apply(decimal originalPrice)
=> originalPrice -
(originalPrice * _percent / 100);
}
public class FlatDiscount : IDiscountStrategy
{
private readonly decimal _amount;
public FlatDiscount(decimal amount)
{
_amount = amount;
}
public decimal Apply(decimal originalPrice)
=> Math.Max(0, originalPrice - _amount);
}
Now, the OrderService simply applies whatever strategy it is given:
public class OrderService
{
private readonly IDiscountStrategy
_discountStrategy;
public OrderService(IDiscountStrategy
discountStrategy)
{
_discountStrategy = discountStrategy;
}
public decimal CalculateFinalPrice
(decimal price)
=> _discountStrategy.Apply(price);
}
The service no longer knows about business rules. It knows only that some rule will be applied.
Strategy Selection in ASP.NET Core
ASP.NET Core’s dependency injection system makes the Strategy pattern feel natural. Instead of constructing strategies manually, you register them with the container and let the framework resolve the appropriate one.
With modern .NET, especially .NET 8, keyed services provide a clean way to select strategies at runtime.
builder.Services.AddKeyedSingleton
<IDiscountStrategy, PercentageDiscount>("member");
builder.Services.AddKeyedSingleton
<IDiscountStrategy, FlatDiscount>("new-user");
At the point of use, the appropriate strategy can be selected based on context:
public class CheckoutController : ControllerBase
{
private readonly IServiceProvider
_serviceProvider;
public CheckoutController
(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
[HttpPost("checkout")]
public IActionResult Checkout
(CheckoutRequest request)
{
var strategy = _serviceProvider
.GetRequiredKeyedService
<IDiscountStrategy>
(request.CustomerType);
var orderService = new OrderService(strategy);
var finalPrice = orderService.
CalculateFinalPrice(request.Price);
return Ok(new { FinalPrice = finalPrice });
}
}
This keeps controllers thin and focused. The decision of which strategy to use is externalized, and the system remains open to extension without modifying existing code.
Small Units, Clear Tests
One of the quieter advantages of the Strategy pattern is how naturally it supports testing. Each strategy is a small, self-contained unit with a single responsibility. Testing a PercentageDiscount or FlatDiscount class requires no elaborate setup. You provide an input and verify the output.
At higher levels, such as testing OrderService, you can inject a mock or stub implementation of IDiscountStrategy. This allows you to test the service in isolation, without depending on any concrete business rules.
The result is a codebase where behavior is not only flexible but also verifiable in small, predictable pieces.
The Subtle Trap of Over-Engineering
Like any pattern, Strategy can be overused. Not every decision warrants abstraction. If a piece of logic is simple and unlikely to change, introducing an interface and multiple classes may add unnecessary complexity.
The pattern becomes valuable when variation is real—when you genuinely expect multiple implementations, and when the choice between them is likely to evolve. A good signal is the presence of repeated conditional logic that branches based on type, configuration, or context. When you see the same decision structure growing over time, it is often a sign that the behavior wants to be extracted.
Good design is not about applying patterns everywhere. It is about applying them where change is inevitable.
Strategy and the Open/Closed Principle
The Strategy pattern aligns naturally with the open/closed principle: software entities should be open for extension but closed for modification. When behavior is encapsulated in strategies, adding a new variation does not require altering existing classes. You introduce a new implementation, register it, and the system adapts.
This reduces the risk of breaking existing functionality and allows systems to evolve incrementally. The more your application grows, the more valuable this property becomes.
Choosing Without Attachment
At a deeper level, the Strategy pattern reflects a particular mindset. When a class hardcodes its behavior, it becomes inseparable from its decisions. It cannot see alternatives; it cannot adapt without being rewritten. But when behavior is injected, the class becomes agnostic. It knows that a decision will be made, but it holds no attachment to which one.
This is what gives the pattern its quiet power. It is not just about swapping algorithms—it is about designing systems that remain open to change. Strategies can be selected based on configuration, toggled through feature flags, or varied across users and environments. The core logic remains untouched, stable, and focused.
In ASP.NET Core and the broader .NET ecosystem, this approach feels almost native. Interfaces are lightweight, dependency injection is built in, and modern features like keyed services make runtime selection explicit and clean. The framework encourages you to separate concerns, to defer decisions, and to embrace flexibility.
Choosing without attachment, then, is not merely a design technique. It is a way of thinking about software—one that accepts change as inevitable and prepares for it, not by predicting every possibility, but by making room for them.
That’s all for now. May your intention be clear and your mind be still. With this quiet wish, I rest my pen and return to the silence.