How to eliminate if/else chains with the Strategy pattern
When to use full classes vs. lambdas for strategies
Dependency Injection patterns for strategy selection in .NET
6 real production bugs and how to avoid them
Same problem, different solutions — swap at runtime.
You already use Strategy every day — you just don't call it that. When you tell Google Maps "avoid highways" or pick "sort by price" on Amazon, you're choosing a strategy. In code, every time you pass a different sorting rule, payment method, or filtering logic — that's the Strategy pattern at work. It's one of the most practical patterns you'll ever learn.
What: Imagine you have a problem that can be solved in several different ways. Instead of cramming all those approaches into one big class with a bunch of if/else or switch statements, you pull each approach out into its own class. They all follow the same contractA "contract" in code is an agreement: "any class that implements this interface promises to have these methods." For Strategy, the contract might be "you must have a Process() method that takes an amount and returns a result." Any payment method — credit card, PayPal, Bitcoin — can fulfill that contract in its own way. (interface), so you can swap one for another without changing the code that uses them. The class that uses the strategy (called the ContextThe "user" of the strategy. It holds a reference to the strategy interface and calls it when needed. Think of a checkout page — it doesn't know HOW payment works, it just calls "process payment" and trusts that the right strategy handles it.) just says "do the thing" — it doesn't know or care which specific approach is running.
When: Use it whenever you have multiple ways to do the same thing and the choice depends on the situation — the user picks a payment method, the config says which shipping provider to use, a feature flag switches between algorithms. If the behavior can change at runtimeMeaning the choice is made while the program is running (based on user input, settings, etc.), not hardcoded into the source. This is what makes Strategy different from just having separate methods — the decision happens dynamically., Strategy is your friend.
In C# / .NET: You can build strategies as full classes behind an interface (best for complex logic), as simple lambda functionsShort inline functions like x => x.Price > 100. For simple one-liner strategies, a lambda is lighter than creating a whole class. But once the logic needs its own services (database, logger, HTTP client), promote it to a proper class. (best for simple one-liners), or resolve them automatically using .NET 8's Keyed ServicesA .NET 8 feature that lets you register multiple implementations of the same interface, each with a unique key (like "creditcard" or "paypal"). The framework picks the right one based on the key — no manual factory needed. from the dependency injection container.
Quick Code:
// The Strategy interface — one focused method
public interface IPaymentStrategy
{
Task<PaymentResult> ProcessAsync(decimal amount, PaymentDetails details);
}
// Concrete strategies — each encapsulates one algorithm
public class CreditCardStrategy(IStripeClient stripe) : IPaymentStrategy
{
public async Task<PaymentResult> ProcessAsync(decimal amount, PaymentDetails details)
=> await stripe.ChargeCardAsync(amount, details.CardToken);
}
public class PayPalStrategy(IPayPalClient paypal) : IPaymentStrategy
{
public async Task<PaymentResult> ProcessAsync(decimal amount, PaymentDetails details)
=> await paypal.CreatePaymentAsync(amount, details.PayPalEmail);
}
// The Context — depends on IPaymentStrategy, not on Stripe or PayPal
public class CheckoutService(IPaymentStrategy payment)
{
public async Task<OrderResult> PlaceOrderAsync(Order order)
{
var result = await payment.ProcessAsync(order.Total, order.PaymentDetails);
return result.Success ? OrderResult.Ok(order.Id) : OrderResult.Failed(result.Error);
}
}
// DI wiring — swap strategy by changing ONE line
builder.Services.AddScoped<IPaymentStrategy, CreditCardStrategy>();
// or: builder.Services.AddScoped<IPaymentStrategy, PayPalStrategy>();The Strategy pattern lets you swap algorithms at runtime through a shared interface. Instead of if/else chains, each algorithm lives in its own class — the Context just calls the interface method without knowing which strategy it's using.
Section 2
Prerequisites
Strategy is one of the simpler patterns to understand, but these foundations will help the C# examples click right away.
Interfaces & Polymorphism — Strategy relies on a key idea: your code talks to a contract (interface), not to a specific class. This is how you swap one strategy for another without changing the code that uses it. If "programming to an interface" feels unfamiliar, review that concept first.
Dependency Inversion Principle (DIP) — Strategy is a real-world example of DIP: your business logic depends on an abstraction ("process this payment"), not on a specific implementation ("call the Stripe API"). If you understand why that mattersWithout DIP, changing from Stripe to PayPal means editing your checkout logic. With DIP (and Strategy), your checkout just says "process payment" through the interface — you swap the implementation elsewhere, checkout code never changes., the pattern will click instantly.
Dependency Injection basics — In .NET, strategies are usually wired up through the DI container. Knowing how to register a service and what lifetimesHow long the framework keeps an instance alive. Transient = create a new one every time it's needed. Scoped = one per web request. Singleton = one for the entire app. Most strategies are stateless, so Singleton or Transient work fine. mean (Transient, Scoped, Singleton) will help you follow the wiring examples.
Delegates & Lambdas — For simple strategies (one-liners), C# lets you skip the full class and just pass a function directly. Understanding lambdasA short inline function like x => x.Price > 100. Think of it as a "mini strategy" — you're passing behavior as a value. Great for simple cases, but when the logic gets complex, promote it to a full class behind an interface. helps you decide when a full interface is overkill vs. when a simple function will do.
Section 3
Analogies
You type a destination into Google Maps. The app asks: Fastest route? Shortest distance? Avoid highways? Scenic route? Your destination doesn't change — but the way you get there is completely different. "Fastest" takes highways and tolerates tolls. "Shortest" cuts through neighborhoods. "Scenic" follows coastal roads.
Notice: the Maps app itself doesn't contain any route-calculation logic. It just delegates to whichever routing approach you selected. And if Google wants to add an "eco-friendly route" option tomorrow? They plug in a new algorithm — the Maps app code doesn't change.
Real World
What it represents
In code
Google Maps app
The thing that uses a strategy
Context class
"Calculate route" button
The shared action all strategies perform
IRouteStrategy.CalculateRoute()
Fastest / Shortest / Scenic
Different approaches to the same problem
Concrete strategy classes
User picks route type
Decision made at runtime
DI or factory resolves the right strategy
Adding "Eco-friendly" route
New approach, zero changes elsewhere
New class, same interface
Sorting Algorithms — You have an array of 10,000 items. For a tiny list? BubbleSort is fine — simple and readable. For a huge dataset? QuickSort is 100x faster. Need guaranteed stability (equal items keep their original order)? MergeSort. The point: the code that says collection.Sort(strategy) never changes. You just swap in a different sorting strategy depending on the data size, memory budget, or stability requirement. The caller says "sort this" — the strategy decides how.
Tax Calculator — A payroll system calculates take-home pay. In the US, that means federal brackets + state tax + Social Security. In the UK, it's PAYE bands + National Insurance. In Germany, it's a completely different formula. The payroll system doesn't contain any tax logic — it just calls taxStrategy.Calculate(grossPay). When the company expands to a new country, you write one new strategy class. Zero changes to the payroll engine itself.
Delivery Options — A checkout page needs to show shipping cost and delivery time. FedEx overnight: $25, arrives tomorrow. UPS ground: $8, arrives in 5 days. USPS economy: $3, arrives in 10 days. Each carrier has its own pricing API and its own delivery time calculation. The checkout page doesn't know any of that — it just calls shippingStrategy.CalculateCost(package) and displays whatever comes back. Adding a new carrier (say, DHL) means adding one class — the checkout page never changes.
GPS apps, sorting algorithms, and payment processors all use the same trick: define a common interface, implement it differently per approach, and let the caller pick which one to use at runtime.
Section 4
Core Concept Diagram
Strategy has just three moving parts. The Context is the class that needs something done (like a checkout page). The Strategy interface is the agreement — "any approach must have this method." And the Concrete Strategies are the actual approaches (CreditCard, PayPal, Bitcoin). The Context says "process this payment" through the interface — it never knows which specific approach is running behind the scenes.
Strategy-UML
Sequence Diagram — How the Parts Interact at Runtime
Strategy-Sequence
Key Insight: The Context delegates — it doesn't contain algorithm logic. When you add ConcreteStrategyC, the Context class has zero lines changed. That's the power of Strategy: new algorithms are purely additive. In .NET, the DI container plays the role of the "wiring" — it decides which concrete strategy gets injected into the Context.
Three players make Strategy work: the Context (uses the strategy), the IStrategy interface (the contract), and ConcreteStrategies (the actual algorithms). The Context never knows which concrete strategy it's holding.
Section 5
Code Implementations
A checkout system where the payment method (credit card, PayPal, Bitcoin) is selected at runtimeThe choice of which strategy to use is made during program execution — based on user input, configuration, feature flags, or business rules — not at compile time. This is what differentiates Strategy from Template Method, where the algorithm variation is determined by class hierarchy at compile time.. Each payment strategy encapsulates its own API integrationEach payment provider (Stripe, PayPal, BTCPay) has its own REST API, authentication scheme, request format, and error handling. By encapsulating each in a separate strategy class, the checkout logic never deals with provider-specific details..
// ─── Strategy Interface ───
public interface IPaymentStrategy
{
string Name { get; }
Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext context);
}
public record PaymentContext(string CustomerId, string Token, string? Email = null);
public record PaymentResult(bool Success, string TransactionId, string? Error = null);
// ─── Concrete Strategy: Credit Card (via Stripe) ───
public sealed class CreditCardStrategy(IStripeClient stripe, ILogger<CreditCardStrategy> logger)
: IPaymentStrategy
{
public string Name => "CreditCard";
public async Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext context)
{
logger.LogInformation("Processing {Amount:C} via Stripe for {Customer}",
amount, context.CustomerId);
var charge = await stripe.ChargeAsync(new ChargeRequest
{
Amount = (long)(amount * 100), // Stripe uses cents
Currency = "usd",
Source = context.Token
});
return charge.Status == "succeeded"
? new PaymentResult(true, charge.Id)
: new PaymentResult(false, "", charge.FailureMessage);
}
}
// ─── Concrete Strategy: PayPal ───
public sealed class PayPalStrategy(IPayPalClient paypal, ILogger<PayPalStrategy> logger)
: IPaymentStrategy
{
public string Name => "PayPal";
public async Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext context)
{
logger.LogInformation("Processing {Amount:C} via PayPal for {Email}",
amount, context.Email);
var order = await paypal.CreateOrderAsync(new PayPalOrder
{
Amount = amount,
PayerEmail = context.Email ?? throw new ArgumentNullException(nameof(context.Email))
});
return order.IsApproved
? new PaymentResult(true, order.OrderId)
: new PaymentResult(false, "", "PayPal payment not approved");
}
}
// ─── Concrete Strategy: Bitcoin (via BTCPay) ───
public sealed class BitcoinStrategy(IBtcPayClient btcpay, ILogger<BitcoinStrategy> logger)
: IPaymentStrategy
{
public string Name => "Bitcoin";
public async Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext context)
{
logger.LogInformation("Creating BTC invoice for {Amount:C}", amount);
var invoice = await btcpay.CreateInvoiceAsync(amount, context.CustomerId);
// Bitcoin payments are async — return pending status
return new PaymentResult(true, invoice.InvoiceId);
}
}
// ─── Context: CheckoutService ───
public class CheckoutService(IPaymentStrategy paymentStrategy, IOrderRepository orders)
{
public async Task<OrderResult> PlaceOrderAsync(Order order)
{
var paymentResult = await paymentStrategy.ProcessAsync(
order.Total,
new PaymentContext(order.CustomerId, order.PaymentToken, order.Email));
if (!paymentResult.Success)
return OrderResult.Failed(paymentResult.Error!);
order.MarkPaid(paymentResult.TransactionId);
await orders.SaveAsync(order);
return OrderResult.Ok(order.Id);
}
}
// ─── DI Registration (.NET 8 Keyed Services) ───
builder.Services.AddKeyedScoped<IPaymentStrategy, CreditCardStrategy>("CreditCard");
builder.Services.AddKeyedScoped<IPaymentStrategy, PayPalStrategy>("PayPal");
builder.Services.AddKeyedScoped<IPaymentStrategy, BitcoinStrategy>("Bitcoin");
// ─── Controller resolves by key ───
[HttpPost("checkout")]
public async Task<IActionResult> Checkout(
CheckoutRequest request,
[FromKeyedServices("CreditCard")] IPaymentStrategy strategy) // or resolve dynamically
{
// ...
}
An educational example showing how different sorting algorithms fit the Strategy pattern. The client codeThe code that uses the sorter — it calls Sort() and doesn't care whether BubbleSort or QuickSort runs underneath. The algorithm is selected based on the dataset size, available memory, or user preference. never changes — only the injected strategy does.
// ─── Strategy Interface ───
public interface ISortStrategy<T> where T : IComparable<T>
{
string Name { get; }
void Sort(Span<T> data); // Span<T> for zero-allocation sorting
}
// ─── Concrete Strategy: Bubble Sort ───
public sealed class BubbleSortStrategy<T> : ISortStrategy<T> where T : IComparable<T>
{
public string Name => "BubbleSort";
public void Sort(Span<T> data)
{
// O(n²) comparison-based sort — simple but slow for large datasets
for (int i = 0; i < data.Length - 1; i++)
for (int j = 0; j < data.Length - i - 1; j++)
if (data[j].CompareTo(data[j + 1]) > 0)
(data[j], data[j + 1]) = (data[j + 1], data[j]);
}
}
// ─── Concrete Strategy: Quick Sort ───
public sealed class QuickSortStrategy<T> : ISortStrategy<T> where T : IComparable<T>
{
public string Name => "QuickSort";
public void Sort(Span<T> data)
{
// O(n log n) average — divide-and-conquer with in-place partitioning
// Full implementation omitted: the pattern point is that
// SortingEngine doesn't care which algorithm runs here
var temp = data.ToArray();
Array.Sort(temp); // stand-in for real quicksort
temp.CopyTo(data);
}
}
// ─── Concrete Strategy: Merge Sort ───
public sealed class MergeSortStrategy<T> : ISortStrategy<T> where T : IComparable<T>
{
public string Name => "MergeSort";
public void Sort(Span<T> data)
{
// O(n log n) stable — splits, sorts halves, merges back
// Full implementation omitted: focus is on interchangeable strategies
T[] temp = data.ToArray();
Array.Sort(temp);
temp.CopyTo(data);
}
}
// ─── Context: SortingEngine ───
public class SortingEngine<T>(ISortStrategy<T> strategy) where T : IComparable<T>
{
public void Sort(Span<T> data)
{
Console.WriteLine($"Sorting {data.Length} items with {strategy.Name}");
strategy.Sort(data);
}
}
// ─── Usage ───
int[] data = [38, 27, 43, 3, 9, 82, 10];
var engine = new SortingEngine<int>(new QuickSortStrategy<int>());
engine.Sort(data);
// Output: Sorting 7 items with QuickSort
An e-commerce discount engine where different promotion strategies (percentage off, flat discount, buy-one-get-one) are applied at checkout. New promotions are added without touching existing code.
// ─── Strategy Interface ───
public interface IDiscountStrategy
{
string Name { get; }
decimal Calculate(ShoppingCart cart);
}
public record ShoppingCart(List<CartItem> Items)
{
public decimal Subtotal => Items.Sum(i => i.Price * i.Quantity);
}
public record CartItem(string ProductId, string Name, decimal Price, int Quantity);
// ─── Concrete Strategy: Percentage Discount ───
public sealed class PercentageDiscount(decimal percentage) : IDiscountStrategy
{
public string Name => $"{percentage}% Off";
public decimal Calculate(ShoppingCart cart)
{
if (percentage is < 0 or > 100)
throw new ArgumentOutOfRangeException(nameof(percentage));
return Math.Round(cart.Subtotal * (percentage / 100m), 2);
}
}
// ─── Concrete Strategy: Flat Discount ───
public sealed class FlatDiscount(decimal amount) : IDiscountStrategy
{
public string Name => $"${amount} Off";
public decimal Calculate(ShoppingCart cart)
{
// Don't discount more than the subtotal
return Math.Min(amount, cart.Subtotal);
}
}
// ─── Concrete Strategy: Buy One Get One Free ───
public sealed class BuyOneGetOneFree(string eligibleProductId) : IDiscountStrategy
{
public string Name => "BOGO Free";
public decimal Calculate(ShoppingCart cart)
{
var eligible = cart.Items.FirstOrDefault(i => i.ProductId == eligibleProductId);
if (eligible is null || eligible.Quantity < 2) return 0m;
// Every second item is free
int freeItems = eligible.Quantity / 2;
return Math.Round(freeItems * eligible.Price, 2);
}
}
// ─── Context: PricingEngine ───
public class PricingEngine
{
public CheckoutSummary CalculateTotal(ShoppingCart cart, IDiscountStrategy? discount = null)
{
decimal subtotal = cart.Subtotal;
decimal discountAmount = discount?.Calculate(cart) ?? 0m;
decimal total = Math.Max(0, subtotal - discountAmount);
return new CheckoutSummary(subtotal, discount?.Name, discountAmount, total);
}
}
public record CheckoutSummary(
decimal Subtotal, string? DiscountName, decimal DiscountAmount, decimal Total);
// ─── Usage ───
var cart = new ShoppingCart(
[
new("SKU-001", "Keyboard", 79.99m, 2),
new("SKU-002", "Mouse", 49.99m, 1)
]);
var engine = new PricingEngine();
// Apply different strategies at runtime
var summary1 = engine.CalculateTotal(cart, new PercentageDiscount(15));
// Subtotal: $209.97, Discount: 15% Off ($31.50), Total: $178.47
var summary2 = engine.CalculateTotal(cart, new FlatDiscount(25));
// Subtotal: $209.97, Discount: $25 Off ($25.00), Total: $184.97
var summary3 = engine.CalculateTotal(cart, new BuyOneGetOneFree("SKU-001"));
// Subtotal: $209.97, Discount: BOGO Free ($79.99), Total: $129.98
...
Code Walkthrough — What Each Piece Does
The Interface (IPaymentStrategy) defines two things: a Name property (so the factory can find the right strategy by key) and a ProcessAsync method (the actual algorithm). Every payment provider — Stripe, PayPal, crypto — implements this same contract.
The Concrete Strategies (CreditCardStrategy, PayPalStrategy, etc.) are marked sealed. This isn't just style — it enables the JIT compiler to devirtualize the interface call, making it as fast as a direct method call. Each strategy encapsulates its own API client, error handling, and retry logic.
The Context (CheckoutService) is the class that uses a strategy but doesn't know which one. It receives an IPaymentStrategy through constructor injection. When ProcessAsync is called, the context just delegates — it never touches Stripe, PayPal, or any provider directly.
The DI container does the matchmaking: builder.Services.AddKeyedScoped<IPaymentStrategy, CreditCardStrategy>("CreditCard") registers the strategy under a key. At runtime, [FromKeyedServices("CreditCard")] tells the container which strategy to inject. Adding Google Pay tomorrow? One new class + one registration line. Zero changes to CheckoutService.
Strategy can be implemented three ways in C#: classic interfaces (most flexible), modern DI-wired (production standard), or functional delegates (lightest weight). Pick based on how many strategies you have and how complex they are.
Section 6
Junior vs Senior
Problem Statement
Build a payment processor that supports multiple payment methods (CreditCard, PayPal, BankTransfer). The payment method is chosen by the user at checkout.
How a Junior Thinks
"I'll just use a switch statementThe classic procedural approach: a single method with a switch on a type string. Seems simple at first, but becomes a maintenance nightmare as cases grow. Each new payment type means modifying this method, risking bugs in existing cases, and creating merge conflicts in team environments. — it handles all the payment types in one place. Easy to read, easy to maintain."
public class PaymentService
{
public async Task<PaymentResult> ProcessPayment(
string paymentType, decimal amount, Dictionary<string, string> details)
{
switch (paymentType.ToLower())
{
case "creditcard":
var stripeClient = new StripeClient("sk_live_hardcoded_key");
var charge = await stripeClient.Charges.CreateAsync(new()
{
Amount = (long)(amount * 100),
Currency = "usd",
Source = details["token"]
});
if (charge.Status == "succeeded")
return new PaymentResult(true, charge.Id);
return new PaymentResult(false, "", charge.FailureMessage);
case "paypal":
var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Add("Authorization",
"Bearer hardcoded_paypal_token");
var response = await httpClient.PostAsJsonAsync(
"https://api.paypal.com/v2/payments", new { amount, email = details["email"] });
var body = await response.Content.ReadAsStringAsync();
return new PaymentResult(response.IsSuccessStatusCode, body);
case "banktransfer":
// TODO: implement bank transfer
Console.WriteLine($"Bank transfer of {amount} requested");
return new PaymentResult(true, Guid.NewGuid().ToString());
default:
throw new ArgumentException($"Unknown payment type: {paymentType}");
}
}
}
// Problems:
// 1. API keys hardcoded in business logic
// 2. new HttpClient() — connection pool exhaustion
// 3. Every new payment type = modifying this method (OCP violation)
// 4. Can't unit test one payment type without the others
// 5. String-based type switching — typos cause runtime failures
Problems
OCP Violation
Adding Apple Pay means modifying the existing switch statement — touching code that already works and is tested. In a team of 10, merge conflicts on this file are constant.
Untestable
Testing PayPal requires mocking HTTP calls, but the HttpClient is created inline. Testing CreditCard requires a Stripe test key. You can't test one payment type in isolation.
Hardcoded Dependencies
API keys are hardcoded strings. HttpClient is newed up directly (connection pool exhaustion). Configuration is buried inside business logic instead of being injected.
How a Senior Thinks
"Each payment method is a separate algorithm with its own dependencies. I'll define an interface, implement each payment as a separate class, and let DI + a factoryThe DI container manages object creation and lifetime. The factory maps runtime keys to concrete strategies. Together they replace the switch statement with a clean, extensible resolution mechanism. New payment methods are added without touching existing code. resolve the right one at runtime. New payment methods = new classes, zero modifications."
// Focused interface — one method, one responsibility
public interface IPaymentStrategy
{
string PaymentType { get; } // "CreditCard", "PayPal", "BankTransfer"
Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext context);
}
public record PaymentContext(string CustomerId, string Token, string? Email = null);
public record PaymentResult(bool Success, string TransactionId, string? Error = null);// Each strategy is a standalone class with its own dependencies
public sealed class CreditCardStrategy(
IStripeClient stripe,
IOptions<StripeSettings> opts,
ILogger<CreditCardStrategy> logger) : IPaymentStrategy
{
public string PaymentType => "CreditCard";
public async Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext context)
{
logger.LogInformation("Charging {Amount:C} via Stripe", amount);
var charge = await stripe.ChargeAsync(new()
{
Amount = (long)(amount * 100),
Currency = opts.Value.Currency,
Source = context.Token
});
return charge.Status == "succeeded"
? new(true, charge.Id)
: new(false, "", charge.FailureMessage);
}
}
// PayPalStrategy and BankTransferStrategy follow the same pattern
// Each has its own dependencies injected via constructor// Factory resolves the right strategy by key
public interface IPaymentStrategyFactory
{
IPaymentStrategy GetStrategy(string paymentType);
}
public sealed class PaymentStrategyFactory(
IEnumerable<IPaymentStrategy> strategies) : IPaymentStrategyFactory
{
private readonly Dictionary<string, IPaymentStrategy> _map =
strategies.ToDictionary(s => s.PaymentType, StringComparer.OrdinalIgnoreCase);
public IPaymentStrategy GetStrategy(string paymentType)
=> _map.TryGetValue(paymentType, out var strategy)
? strategy
: throw new NotSupportedException($"Payment type '{paymentType}' is not registered.");
}
// Usage in a controller:
[HttpPost("checkout")]
public async Task<IActionResult> Checkout(
[FromBody] CheckoutRequest request,
[FromServices] IPaymentStrategyFactory factory)
{
var strategy = factory.GetStrategy(request.PaymentType);
var result = await strategy.ProcessAsync(request.Amount,
new PaymentContext(request.CustomerId, request.Token, request.Email));
return result.Success ? Ok(result) : BadRequest(result);
}// Register all strategies + factory
builder.Services.AddScoped<IPaymentStrategy, CreditCardStrategy>();
builder.Services.AddScoped<IPaymentStrategy, PayPalStrategy>();
builder.Services.AddScoped<IPaymentStrategy, BankTransferStrategy>();
builder.Services.AddScoped<IPaymentStrategyFactory, PaymentStrategyFactory>();
// Adding Apple Pay? ONE new class + ONE DI line:
// builder.Services.AddScoped<IPaymentStrategy, ApplePayStrategy>();
// Zero changes to factory, controller, or existing strategies
Design Decisions
Each Strategy is Independently Testable
CreditCardStrategy has its own unit tests with a mocked IStripeClient. PayPalStrategy has separate tests with a mocked IPayPalClient. No strategy test touches another strategy's code.
Factory Uses IEnumerable<T> Auto-Discovery
The DI container automatically provides all registered IPaymentStrategy implementations. The factory builds a dictionary from them — no manual mapping needed. Adding a new strategy is truly "one class + one DI line."
Configuration Lives in appsettings.json
API keys and settings are injected via IOptions<T>, not hardcoded. Each strategy reads its own configuration section. Secrets never appear in source code.
Junior code hardcodes strategy selection in switch statements. Senior code uses DI containers to resolve strategies by key — adding a new strategy means adding a class, not changing existing code.
Section 7
Evolution & History
The Gang of FourErich Gamma, Richard Helm, Ralph Johnson, and John Vlissides — authors of "Design Patterns: Elements of Reusable Object-Oriented Software" (1994). They cataloged 23 patterns, including Strategy, and established the vocabulary used by software engineers worldwide. defined Strategy as a behavioral pattern to encapsulate interchangeable algorithms. Their original examples used C++ with abstract base classes and virtual functions. The pattern was one of the most immediately practical — it directly replaced polymorphic switch statements that plagued large codebases.
// The GoF blueprint — translated to early C# (same structure as the 1994 C++ original)
public abstract class SortStrategy // Abstract Strategy
{
public abstract void Sort(int[] data);
}
public class BubbleSortStrategy : SortStrategy // Concrete Strategy A
{
public override void Sort(int[] data) { /* bubble sort logic */ }
}
public class QuickSortStrategy : SortStrategy // Concrete Strategy B
{
public override void Sort(int[] data) { /* quick sort logic */ }
}
public class Sorter // Context
{
private SortStrategy _strategy;
public void SetStrategy(SortStrategy s) => _strategy = s;
public void Sort(int[] data) => _strategy.Sort(data);
}
C# 1.0 introduced delegatesA type-safe function pointer in C#. In .NET 1.0, non-generic delegates let you pass behavior as data. .NET 2.0 (2005) added generic delegates like Comparison<T>. Array.Sort(myArray, myComparer) is Strategy pattern using delegates instead of interfaces., giving .NET developers a lighter-weight alternative to full interface-based strategiesThe classic GoF approach: define an IStrategy interface, create concrete classes for each algorithm, and inject the interface into the Context. More ceremony than delegates, but supports DI, multiple methods, and auto-discovery via IEnumerable<T>.. Array.Sort(array, comparisonDelegate) was Strategy pattern without ever naming it. The IComparer interface provided the formal GoF approach.
// .NET 1.0 Strategy via IComparer
public class PriceComparer : IComparer
{
public int Compare(object x, object y)
{
var a = ((Product)x).Price;
var b = ((Product)y).Price;
return a.CompareTo(b);
}
}
// Usage — the Strategy is the comparer
ArrayList products = GetProducts();
products.Sort(new PriceComparer()); // <-- swap comparer = swap strategy
C# 3.0 and LINQ transformed Strategy from an explicit pattern into an everyday idiom. Every .Where(x => x.IsActive), .OrderBy(x => x.Name), and .Select(x => x.Dto) is Strategy pattern — the lambda IS the algorithm, and the LINQ method is the Context. Func<T, TResult>The generic delegate type introduced in .NET 3.5 that represents a function taking T and returning TResult. LINQ methods like Where, Select, and OrderBy accept Func delegates as strategies. This made Strategy so natural in C# that developers use it without thinking about the GoF pattern. became the de facto strategy type for stateless, single-method algorithms.
// LINQ lambdas ARE strategies — you pass the algorithm as a parameter
var cheapProducts = products
.Where(p => p.Price < 50) // filtering strategy
.OrderBy(p => p.Name) // sorting strategy
.Select(p => new ProductDto(p)); // mapping strategy
// Same data, different strategies — just swap the lambdas
var expensiveFirst = products
.Where(p => p.Price > 100)
.OrderByDescending(p => p.Price)
.Select(p => new ProductDto(p));
ASP.NET Core's built-in DI containerMicrosoft.Extensions.DependencyInjection — the DI container included with ASP.NET Core since 1.0. It supports constructor injection, three lifetimes (Transient, Scoped, Singleton), open generics, factory delegates, and keyed services (.NET 8). It's the glue that wires strategies to their consumers. made interface-based strategies first-class citizens. Register multiple implementations of IPaymentStrategy, inject IEnumerable<IPaymentStrategy> into a factory, and resolve by key. The composition rootProgram.cs — the single place where all abstractions are wired to implementations. In Strategy pattern, the composition root registers all concrete strategies, their factories, and determines which strategy a given Context receives. (Program.cs) became the natural place for strategy wiring. Middleware pipelines themselves are Strategy: each middleware is a processing strategy in a chain.
// ASP.NET Core DI — register all strategies in Program.cs
builder.Services.AddScoped<IPaymentStrategy, CreditCardStrategy>();
builder.Services.AddScoped<IPaymentStrategy, PayPalStrategy>();
builder.Services.AddScoped<IPaymentStrategy, CryptoStrategy>();
// Inject ALL strategies and pick by key at runtime
public class PaymentService(IEnumerable<IPaymentStrategy> strategies)
{
public Task ProcessAsync(string method, Order order)
{
var strategy = strategies.First(s => s.Name == method);
return strategy.ProcessAsync(order);
}
}
Keyed ServicesAdded in .NET 8 (Microsoft.Extensions.DependencyInjection 8.0). Register: builder.Services.AddKeyedScoped<IStrategy, ConcreteA>("keyA"). Resolve: [FromKeyedServices("keyA")] IStrategy strategy. This eliminates the need for manual factory classes in many Strategy scenarios. in .NET 8 made Strategy pattern resolution a framework feature. Instead of building manual factories, you register strategies with string or enum keys: builder.Services.AddKeyedScoped<IPaymentStrategy, CreditCardStrategy>("CreditCard"). Resolve with [FromKeyedServices("CreditCard")] in controllers or IServiceProvider.GetKeyedService<T>(key) in code. The manual factory pattern is now optional.
// .NET 8 Keyed Services — Strategy resolution is now built-in
builder.Services.AddKeyedScoped<IPaymentStrategy, CreditCardStrategy>("CreditCard");
builder.Services.AddKeyedScoped<IPaymentStrategy, PayPalStrategy>("PayPal");
builder.Services.AddKeyedScoped<IPaymentStrategy, CryptoStrategy>("Crypto");
// Resolve by key in a controller — no factory needed!
app.MapPost("/pay", ([FromKeyedServices("CreditCard")] IPaymentStrategy strategy,
Order order) =>
{
return strategy.ProcessAsync(order);
});Strategy in .NET evolved from manual interface implementations in 1.0 to generic constraints in 2.0, LINQ delegates in 3.5, DI container resolution in Core, and keyed services in .NET 8. Each era made the pattern easier to use.
Section 8
Strategy in .NET Core
You don't always need to build Strategy from scratch — .NET already uses it everywhere. Once you recognize the pattern, you'll see it in sorting, authorization, validation, and more. Here are the places Strategy is hiding in plain sight:
IComparer<T> is the textbook Strategy interface — it encapsulates a comparison algorithm that List<T>.Sort() and SortedSet<T> delegate to. IEqualityComparer<T> does the same for Dictionary<TKey,TValue> and HashSet<T>.
// IComparer<T> is a built-in strategy interface
public class PriceComparer : IComparer<Product>
{
public int Compare(Product? x, Product? y)
=> (x?.Price ?? 0).CompareTo(y?.Price ?? 0);
}
// The Context (List<T>) delegates sorting to whatever comparer you inject
products.Sort(new PriceComparer()); // Strategy: sort by price
products.Sort(new NameComparer()); // Strategy: sort by name
// Or use Comparer<T>.Create() with a lambda (lightweight strategy)
products.Sort(Comparer<Product>.Create((a, b) => a.Rating.CompareTo(b.Rating)));
Every LINQ method uses Func<T, TResult>Func delegates ARE strategies. Where(Func<T, bool>) takes a filtering strategy. OrderBy(Func<T, TKey>) takes a sorting key strategy. Select(Func<T, TResult>) takes a transformation strategy. The LINQ methods are Contexts that delegate to your lambda strategies. as a strategy parameter. When your strategy is stateless and doesn't need DI dependencies, a lambda is often cleaner than a full class.
// LINQ uses Func<T> as strategy parameters
var activeUsers = users.Where(u => u.IsActive); // filter strategy
var sorted = users.OrderBy(u => u.LastLogin); // sort-key strategy
var dtos = users.Select(u => new UserDto(u.Name)); // transform strategy
// You can store strategies as Func variables
Func<Order, bool> highValueFilter = o => o.Total > 1000m;
Func<Order, bool> recentFilter = o => o.Date > DateTime.UtcNow.AddDays(-30);
var results = orders.Where(highValueFilter); // swap filter at runtime
ASP.NET Core's authorization system is pure Strategy. Each IAuthorizationHandler implements a different authorization strategy (role-based, policy-based, resource-based). The authorization middlewareThe authorization middleware is the Context — it collects all registered IAuthorizationHandler implementations and evaluates them against the current request. You add new authorization rules by registering new handlers, never by modifying the middleware. is the Context that delegates to all registered handlers.
// Each handler is a Strategy for authorization
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
{
var dob = context.User.FindFirst(c => c.Type == "DateOfBirth");
if (dob is null) return Task.CompletedTask;
int age = DateTime.Today.Year - DateTime.Parse(dob.Value).Year;
if (age >= requirement.MinimumAge)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
// Register the authorization strategy
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();
FluentValidationA popular .NET library for building strongly-typed validation rules. Each AbstractValidator<T> is a Strategy that encapsulates validation logic for a specific type. The validation pipeline collects and executes all applicable validators. uses Strategy pattern: each AbstractValidator<T> encapsulates a validation strategy for a specific model. The IValidator<T> interface lets you swap, compose, and test validators independently.
// Each validator is a Strategy for validating a specific type
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item");
RuleFor(x => x.Total).GreaterThan(0);
}
}
// Register in DI — automatic strategy resolution
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderValidator>();
// Controller uses IValidator<T> — doesn't know which validator runs
public async Task<IActionResult> CreateOrder(
CreateOrderRequest request,
[FromServices] IValidator<CreateOrderRequest> validator)
{
var result = await validator.ValidateAsync(request);
if (!result.IsValid) return BadRequest(result.Errors);
// ...
}
Keyed Services (.NET 8+) let you register multiple implementations of the same interface with different keys. This is the framework-native way to implement Strategy resolution without building custom factories.
// Register strategies with keys
builder.Services.AddKeyedScoped<INotificationStrategy, EmailNotification>("email");
builder.Services.AddKeyedScoped<INotificationStrategy, SmsNotification>("sms");
builder.Services.AddKeyedScoped<INotificationStrategy, PushNotification>("push");
// Resolve by key in controllers
[HttpPost("notify")]
public async Task<IActionResult> Notify(
NotifyRequest request,
[FromKeyedServices("email")] INotificationStrategy emailStrategy)
{
await emailStrategy.SendAsync(request.UserId, request.Message);
return Ok();
}
// Or resolve dynamically using IServiceProvider
public class NotificationService(IServiceProvider sp)
{
public async Task NotifyAsync(string channel, string userId, string message)
{
var strategy = sp.GetRequiredKeyedService<INotificationStrategy>(channel);
await strategy.SendAsync(userId, message);
}
}.NET uses Strategy everywhere: IComparer for sorting, IFormatProvider for formatting, logging providers for output destinations, serialization options, and distributed cache backends. Once you see the pattern, you'll spot it in every namespace.
Section 9
When To Use / When Not To
Use Strategy when you have multiple interchangeable algorithms, they change at runtime, and you want to add new ones without modifying existing code. Don't use it when you only have one algorithm that never changes.
Section 10
Comparisons
Strategy vs State
Strategy vs Template Method
Strategy vs Factory Method
Strategy (Interface) vs Func<T>
Strategy swaps entire algorithms through composition. State transitions between behaviors automatically. Template Method varies steps within a fixed skeleton. Command encapsulates requests, not algorithms.
Section 11
SOLID Connections
Strategy is a great example of SOLID principles working together in practice. Here's how each principle shows up:
Principle
Relation
Explanation
SRPSingle Responsibility Principle — a class should have only one reason to change. Each strategy class encapsulates exactly one algorithm, so it only changes when that specific algorithm's logic changes.
Supports
Each strategy class has one responsibility: implement one specific algorithm. CreditCardStrategy only knows about Stripe. PayPalStrategy only knows about PayPal. Neither changes for the other's reasons.
OCPOpen/Closed Principle — open for extension, closed for modification. Adding a new strategy means creating a new class, not modifying the Context or existing strategies.
Supports
Adding a new algorithm = one new class + one DI line. The Context class, existing strategies, and factory have zero lines changed. Strategy is OCP's poster child.
LSPLiskov Substitution Principle — every implementation must be substitutable for the interface without breaking the program. If CreditCardStrategy returns a PaymentResult, PayPalStrategy must too — no NotImplementedException, no different semantics.
Depends
Every strategy must honor the contract. If IPaymentStrategy.ProcessAsync() promises a PaymentResult, no strategy can throw NotImplementedException or return null. Violating LSP breaks the swappability that makes Strategy useful.
ISPInterface Segregation Principle — clients should not be forced to depend on methods they don't use. A strategy interface should ideally have ONE method. If it has 5 methods and some strategies only implement 3, the interface is too fat.
Supports
Strategy interfaces should be focused — ideally one method. A fat IPaymentStrategy with Process(), Refund(), Void(), Capture() forces simple strategies to implement methods they don't support. Split into IPaymentProcessor and IRefundProcessor.
DIPDependency Inversion Principle — depend on abstractions, not concretions. The Context depends on IStrategy (abstraction), not CreditCardStrategy (implementation). The dependency arrow points toward the interface.
Supports
Strategy IS DIP in action. The Context depends on IPaymentStrategy, never on CreditCardStrategy. The dependency arrowIn a class diagram: CheckoutService → IPaymentStrategy ← CreditCardStrategy. The Context and the implementation both depend on the abstraction. Neither knows about the other directly. points toward the abstraction. Swapping implementations requires zero changes to the Context.
The key insight: Strategy doesn't just follow SOLID — it's one of the few patterns that satisfies all five principles simultaneously. Most patterns trade off one principle for another (e.g., Singleton violates OCP). Strategy is the rare win-win because each algorithm is isolated in its own class, depends only on abstractions, and can be added without touching existing code.
Section 12
Bug Case Studies
The Incident
Friday 5pm deploy. The team's e-commerce app lets users pay by credit card, PayPal, or bank transfer. Each method is a separate strategy class, and the app uses a factory to pick the right one based on what the user selects at checkout.
A developer wrote a brand-new BankTransferStrategy class. It compiled fine. It passed its own unit tests. The PR was reviewed and merged. Deployment went smoothly. Everyone went home for the weekend.
But the developer forgot one critical step: telling the DI container that this new class exists. The class was sitting in the codebase, ready to work, but nobody told the app to actually use it. Think of it like hiring a new employee but never giving them a desk or login credentials.
The factory builds its strategy dictionary from what the DI container gives it. Since BankTransferStrategy was never registered, the factory's dictionary simply didn't have an entry for "BankTransfer." When users selected bank transfer at checkout, the factory looked up the key, found nothing, and threw a NotSupportedException. This became a 500 error on the checkout page: "Something went wrong."
Over the next 90 minutes, 200+ orders failed. Customers who picked credit card or PayPal were fine, so the issue wasn't immediately obvious from monitoring dashboards that only tracked total error rates. The on-call engineer finally spotted it after filtering errors by payment type.
Time to Diagnose
45 minutes. The error message said "Payment type 'BankTransfer' is not registered" but the team searched the strategy class first (which looked perfect) before thinking to check Program.cs where DI registrations live. The class existed, its unit tests passed, and its code was correct. The gap was between "writing the code" and "wiring it into the application."
// ❌ BankTransferStrategy exists but is NOT registered
builder.Services.AddScoped<IPaymentStrategy, CreditCardStrategy>();
builder.Services.AddScoped<IPaymentStrategy, PayPalStrategy>();
// Missing: builder.Services.AddScoped<IPaymentStrategy, BankTransferStrategy>();
builder.Services.AddScoped<IPaymentStrategyFactory, PaymentStrategyFactory>();
// At runtime:
// factory.GetStrategy("BankTransfer")
// → Dictionary lookup fails → NotSupportedException
// → 500 Internal Server Error → user sees "Something went wrong"
Walking through the buggy code: Look at the first two lines. We're telling the DI container: "Hey, when someone asks for an IPaymentStrategy, here are two implementations you can provide: CreditCard and PayPal." But our brand-new BankTransferStrategy? It's sitting in a file somewhere, perfectly written, but we never told the DI container about it. The factory gets an IEnumerable<IPaymentStrategy> from DI, builds a dictionary from those, and "BankTransfer" simply isn't in the dictionary. The lookup fails, and a 500 error goes to the user.
// ✅ FIX 1: Register all strategies (and add the missing one)
builder.Services.AddScoped<IPaymentStrategy, CreditCardStrategy>();
builder.Services.AddScoped<IPaymentStrategy, PayPalStrategy>();
builder.Services.AddScoped<IPaymentStrategy, BankTransferStrategy>();
// ✅ FIX 2: Auto-register all strategies via assembly scanning (Scrutor)
builder.Services.Scan(scan => scan
.FromAssemblyOf<IPaymentStrategy>()
.AddClasses(c => c.AssignableTo<IPaymentStrategy>())
.AsImplementedInterfaces()
.WithScopedLifetime());
// ✅ FIX 3: Add startup validation
var app = builder.Build();
var factory = app.Services.GetRequiredService<IPaymentStrategyFactory>();
foreach (var type in new[] { "CreditCard", "PayPal", "BankTransfer" })
{
_ = factory.GetStrategy(type); // Fail fast at startup, not at runtime
}
Why the fix works: Fix 1 is the obvious answer: just add the missing line. But that's still manual, so Fix 2 is the real solution. Assembly scanning (using the Scrutor library) automatically finds every class that implements IPaymentStrategy and registers it. You can never "forget" again because the scan does it for you. Fix 3 is the safety net: at startup, we try to resolve every expected strategy key. If any one is missing, the app crashes immediately at deploy time instead of quietly failing when a real customer hits checkout two hours later.
Lesson Learned
Use assembly scanningScrutor is a NuGet package that scans assemblies for classes implementing a given interface and registers them automatically. builder.Services.Scan(s => s.FromAssemblyOf<IStrategy>().AddClasses(c => c.AssignableTo<IStrategy>()).AsImplementedInterfaces()). New strategies are auto-discovered — zero manual registration. (Scrutor) to auto-register strategies, and add a startup validation step that resolves every expected strategy key. Fail at deploy time, not at checkout time.
How to Spot This in Your Code
Search your codebase for classes that implement your strategy interface (e.g., : IPaymentStrategy). Then search your Program.cs (or wherever DI is configured) for registrations of that interface. If the count doesn't match, you have an unregistered strategy. Better yet, write a unit test that compares reflection-discovered implementations against DI registrations.
The Incident
Load test revealed data leaks between customers. Here's the setup: the team had a LoyaltyDiscountStrategy that figures out how much discount a customer gets. Gold members get 20% off, Silver gets 10%, everyone else gets nothing.
The developer designed it in two steps. First, you call Configure(customer) which reads the customer's loyalty tier and saves the discount percentage into a field on the strategy object. Then, you call Calculate(subtotal) which uses that saved percentage to compute the discount.
Sounds reasonable, right? The problem: this strategy was registered as a SingletonSingleton lifetime means ONE instance is shared across ALL requests and threads for the entire application lifetime. If that instance has mutable fields, Thread A's writes are visible to Thread B — causing data races, incorrect calculations, and data leaks between users.. That means there's one single instance of this object shared by every request, every user, every thread in the entire application. Imagine a shared whiteboard in an office where different people keep erasing and rewriting the same number.
During peak traffic, Thread A configures the strategy for a Gold customer (writes 20 to the field), but before Thread A can call Calculate(), Thread B comes in for a basic customer and overwrites the field with 0. Now Thread A calls Calculate() and reads 0 instead of 20. The Gold customer gets zero discount. Meanwhile, Thread C might read Thread B's 0 or Thread A's 20, depending on timing.
15% of orders during the load test got incorrect pricing. Some customers got discounts they shouldn't have, some lost discounts they earned. The numbers were essentially random, depending on which thread happened to write to the field last.
Time to Diagnose
3 hours. The bug only appeared under concurrent load. Every single-threaded unit test passed perfectly because there was only one "person" using the whiteboard at a time. The team had to correlate customer IDs with discount amounts in production logs to spot the pattern: Gold customers sometimes getting 0% discount, basic customers sometimes getting 20%.
Walking through the buggy code: See that _discountPercentage field on line 4? That's the shared whiteboard. The Configure() method writes to it, and the Calculate() method reads from it. With a Singleton, this is ONE object serving ALL requests simultaneously. Thread A writes 20, Thread B writes 0, and then Thread A reads 0 because Thread B overwrote it. The two-step "configure then calculate" design is fundamentally unsafe when the object is shared.
// ✅ FIX: Make strategies stateless — pass data through method parameters
public class LoyaltyDiscountStrategy : IDiscountStrategy
{
// No mutable fields — completely stateless
public decimal Calculate(decimal subtotal, Customer customer)
{
decimal percentage = customer.LoyaltyTier switch
{
"Gold" => 20m,
"Silver" => 10m,
_ => 0m
};
return subtotal * (percentage / 100m);
}
}
// ✅ Now safe as Singleton — no shared mutable state
builder.Services.AddSingleton<IDiscountStrategy, LoyaltyDiscountStrategy>();
// If a strategy MUST have state, register as Scoped (per-request)
// builder.Services.AddScoped<IDiscountStrategy, StatefulDiscount>();
Why the fix works: Instead of saving customer data in a field (shared whiteboard), we pass it directly as a method parameter. Now the Calculate() method receives everything it needs as input, computes the result using only local variables (which live on each thread's own stack, not shared memory), and returns the answer. There's no field to overwrite, no shared state, no race condition. Each thread gets its own copy of the percentage variable. The strategy can safely stay as a Singleton because it doesn't hold any per-request data.
Lesson Learned
Strategies should be stateless whenever possible — pass varying data as method parameters, not mutable fields. If you must have state, register as Scoped or Transient, never Singleton. Run concurrency tests during CI.
How to Spot This in Your Code
Look for strategy classes that have non-readonly fields or properties with setters. If a strategy object is mutated after construction (anything that isn't set in the constructor), it's stateful. Then check how it's registered in DI: if it's AddSingleton and has mutable fields, you have a race condition waiting to happen under concurrent load.
The Incident
Customer service refund crashed. The team's payment system had a single interface, IPaymentStrategy, with four methods: ProcessAsync, RefundAsync, VoidAsync, and CaptureAsync. Every payment strategy had to implement all four.
This worked fine for credit cards and PayPal, which support all those operations. But then the team added Bitcoin as a payment method. Here's the thing: blockchain transactions are irreversible by design. You literally cannot refund or void a Bitcoin payment. It's not a limitation of their code; it's how the technology works.
The developer was stuck. The interface forced them to implement RefundAsync, VoidAsync, and CaptureAsync even though Bitcoin can't do any of those. So they did what many developers do in this situation: they threw NotImplementedException from all three methods. Essentially, the class was saying "yes, I can do refunds" (because it implements the interface) but then throwing an error when you actually ask it to do one. The class was lying about its capabilities.
A few weeks later, a customer service agent tried to refund a Bitcoin order through the admin dashboard. The system called RefundAsync, got a NotImplementedException, and showed a 500 error page. The agent tried again. Same error. They escalated to engineering, thinking the refund system was broken. Meanwhile, the customer waited three days for their refund before the team realized Bitcoin refunds are literally impossible and the admin UI should have never shown the "Refund" button in the first place.
Time to Diagnose
10 minutes to find the NotImplementedException. But the design fix took 2 full days because splitting the interface meant updating every existing strategy (CreditCard, PayPal, BankTransfer) and every place that called those methods. This is the real cost of fat interfaces: the fix is never just the one class that broke.
// ❌ FAT strategy interface — ISP violation
public interface IPaymentStrategy
{
Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext ctx);
Task<RefundResult> RefundAsync(string transactionId, decimal amount);
Task VoidAsync(string transactionId);
Task<CaptureResult> CaptureAsync(string authId, decimal amount);
}
// ❌ Bitcoin can't refund or void — blockchain is immutable
public class BitcoinStrategy : IPaymentStrategy
{
public async Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext ctx)
=> /* works fine */ ;
public Task<RefundResult> RefundAsync(string transactionId, decimal amount)
=> throw new NotImplementedException("Bitcoin refunds are not possible");
public Task VoidAsync(string transactionId)
=> throw new NotImplementedException("Bitcoin voids are not possible");
public Task<CaptureResult> CaptureAsync(string authId, decimal amount)
=> throw new NotImplementedException("Bitcoin doesn't support auth/capture");
}
Walking through the buggy code: The interface on lines 2-7 says "any payment strategy must be able to process, refund, void, and capture." That's fine for credit cards, but Bitcoin physically cannot do three of those things. The developer's hands were tied: the interface forced them to write methods that do nothing except blow up. Lines 15-20 are essentially lies. The class says "I implement IPaymentStrategy" (which implies I can do all four operations), but three of them throw exceptions. The compiler is happy, but the system is broken at runtime.
// ✅ FIX: Split into focused interfaces (ISP)
public interface IPaymentProcessor
{
Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext ctx);
}
public interface IRefundable
{
Task<RefundResult> RefundAsync(string transactionId, decimal amount);
}
public interface IVoidable
{
Task VoidAsync(string transactionId);
}
// Bitcoin only implements what it supports
public class BitcoinStrategy : IPaymentProcessor
{
public async Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext ctx)
=> /* works fine */ ;
// No RefundAsync, no VoidAsync — not forced to lie
}
// CreditCard implements all capabilities
public class CreditCardStrategy : IPaymentProcessor, IRefundable, IVoidable
{
public async Task<PaymentResult> ProcessAsync(...) => /* ... */;
public async Task<RefundResult> RefundAsync(...) => /* ... */;
public async Task VoidAsync(...) => /* ... */;
}
// Refund endpoint checks capability before calling
if (strategy is IRefundable refundable)
return await refundable.RefundAsync(txId, amount);
else
return BadRequest("This payment method doesn't support refunds");
Why the fix works: Instead of one big interface that forces every strategy to support every operation, we split it into small, focused interfaces. IPaymentProcessor handles the core payment. IRefundable is only for strategies that actually support refunds. Bitcoin only implements IPaymentProcessor and never lies about capabilities it doesn't have. The refund endpoint uses C#'s is pattern matching to check: "Does this strategy support refunds?" If yes, call it. If no, return a clear error message to the UI. Now the admin dashboard can hide the "Refund" button for Bitcoin orders entirely because it can check strategy is IRefundable at render time.
Lesson Learned
If any implementation throws NotImplementedException, your interface is too fat. Split it per ISP. Use is pattern matching to check capabilities at runtime. A strategy should never lie about what it can do.
How to Spot This in Your Code
Search your entire codebase for NotImplementedException. Every instance is a red flag: it means some class is promising it can do something but actually can't. Also check for methods that return dummy values or do nothing (empty method bodies). These are all signs that the interface is forcing implementations to lie.
The Incident
Guest checkout crashed. The e-commerce app applied discounts based on loyalty tiers. When a logged-in user reached checkout, the system looked up their loyalty tier (Gold, Silver, etc.) and resolved the matching discount strategy. Gold members got 20% off, Silver got 10%.
But what about guest users? They don't have accounts, so they don't have loyalty tiers. The factory method that resolves discount strategies received null as the loyalty tier and returned null as the strategy. The pricing engine then called discount.Calculate(subtotal) on null, and boom: NullReferenceException.
Every single guest checkout crashed. This wasn't a rare edge case either. Guest checkout accounted for 40% of all orders. The site was essentially broken for nearly half of all customers. The team quickly added a if (discount != null) check as a hotfix, but that just kicked the can down the road. Every place in the codebase that used the discount strategy now needed its own null check, and forgetting any one of them would cause the same crash.
Time to Diagnose
5 minutes. The NullReferenceException stack trace pointed directly at the line. But it revealed a deeper design gap: the system assumed every user would always have a discount strategy, and had no concept of a NullStrategy ("no discount").
// ❌ No null check, no fallback strategy
public class PricingEngine(IDiscountStrategy? discount)
{
public decimal CalculateTotal(ShoppingCart cart)
{
decimal subtotal = cart.Subtotal;
decimal discountAmount = discount.Calculate(subtotal); // ❌ NRE if discount is null
return subtotal - discountAmount;
}
}
// Guest users: no loyalty tier → factory returns null
// discount.Calculate() → NullReferenceException → 500 error
Walking through the buggy code: Notice the ? in IDiscountStrategy? discount on the first line. That question mark is the C# way of saying "this might be null." But then on line 7, we call discount.Calculate(subtotal) without checking whether discount is actually null. For logged-in Gold and Silver members, the factory returns a real strategy object, and everything works. For guest users with no loyalty tier, the factory returns null, and this line crashes. The ? was an honest declaration that null is possible, but the code never handled the null case.
// ✅ FIX: Null Object pattern — a strategy that does nothing
public sealed class NoDiscount : IDiscountStrategy
{
public static readonly NoDiscount Instance = new();
public string Name => "None";
public decimal Calculate(decimal subtotal) => 0m; // Always returns zero
}
// Factory returns NoDiscount instead of null
public IDiscountStrategy GetStrategy(string? loyaltyTier)
=> loyaltyTier switch
{
"Gold" => new PercentageDiscount(20),
"Silver" => new PercentageDiscount(10),
_ => NoDiscount.Instance // ✅ Never null — always a valid strategy
};
// Context never needs null checks
public class PricingEngine(IDiscountStrategy discount)
{
public decimal CalculateTotal(ShoppingCart cart)
{
decimal subtotal = cart.Subtotal;
decimal discountAmount = discount.Calculate(subtotal); // ✅ Always safe
return subtotal - discountAmount;
}
}
Why the fix works: Instead of returning null for "no discount," we created a null object — a NoDiscount class that implements IDiscountStrategy but always returns zero. It's a real, valid strategy object; it just happens to do nothing. Now the factory never returns null. Guest users get NoDiscount.Instance, which calculates a discount of $0. The pricing engine doesn't need any null checks because it always has a real strategy object to work with. Every code path is safe by default. Notice the parameter type changed from IDiscountStrategy? (nullable) to IDiscountStrategy (non-nullable). The type system now enforces that a strategy is always present.
Lesson Learned
Never return null from a factory — use the Null Object patternInstead of returning null when no strategy applies, return a strategy that does nothing. NullDiscountStrategy.Calculate() returns 0. NullLogger.Log() discards messages. The Context never needs null checks — every code path is safe.. A "do nothing" strategy (NoDiscount, NullLogger) eliminates null checks throughout your codebase and makes every code path safe by default.
How to Spot This in Your Code
Look for nullable strategy parameters (IStrategy?) and factory methods that can return null. Also search for if (strategy != null) null checks scattered around the codebase. If you find either, you're missing a Null Object implementation. The fix is simple: create a "do nothing" strategy and make the factory's default case return it instead of null.
The Incident
Inconsistent behavior across endpoints. The app had three API endpoints that needed to pick the right payment strategy: the web checkout page, the mobile app checkout, and an internal admin tool for manual charges. Each controller had its own copy-pasted switch statement to map "CreditCard" to CreditCardStrategy, "PayPal" to PayPalStrategy, and so on.
When the team added Apple Pay support, the developer updated the web controller and the admin tool's switch statement. They tested both, confirmed Apple Pay worked, and shipped the PR. But they didn't realize the mobile controller had its own separate switch statement doing the same thing. It was in a different file, written months earlier by a different developer.
For two weeks, Apple Pay worked perfectly on the website but crashed on the mobile app. Mobile users who selected Apple Pay at checkout got "Unknown payment type." The team initially assumed the bug was in the mobile client app (maybe the iOS app was sending the wrong string?), which sent them on a wild goose chase through Swift code. It took 2 hours of comparing server-side logs from both endpoints before someone realized the problem was three identical switch statements, with one of them out of date.
This is the classic copy-paste problem. Every copy of the switch statement is a liability. With N copies and a new strategy, you need to update N files. Miss one, and you get a silent inconsistency that only shows up through that specific code path.
Time to Diagnose
2 hours. The team spent most of that time looking at the mobile client instead of the server, because the web version worked fine. Only when they compared raw HTTP responses from both endpoints side-by-side did they see that the same request got different results from different controllers.
// ❌ Strategy selection duplicated across controllers
public class WebCheckoutController : ControllerBase
{
[HttpPost] public IActionResult Checkout(CheckoutRequest req)
{
IPaymentStrategy strategy = req.PaymentType switch
{
"CreditCard" => _sp.GetRequiredService<CreditCardStrategy>(),
"PayPal" => _sp.GetRequiredService<PayPalStrategy>(),
"ApplePay" => _sp.GetRequiredService<ApplePayStrategy>(), // ✅ Updated
_ => throw new NotSupportedException()
};
// ...
}
}
public class MobileCheckoutController : ControllerBase
{
[HttpPost] public IActionResult Checkout(CheckoutRequest req)
{
IPaymentStrategy strategy = req.PaymentType switch
{
"CreditCard" => _sp.GetRequiredService<CreditCardStrategy>(),
"PayPal" => _sp.GetRequiredService<PayPalStrategy>(),
// ❌ Missing ApplePay! Developer forgot to update this copy
_ => throw new NotSupportedException()
};
// ...
}
}
Walking through the buggy code: Both controllers have their own switch statements doing the exact same job: mapping a string to a strategy class. The web controller on lines 6-12 was updated to include ApplePay. But the mobile controller on lines 21-26 is an older copy that never got the update. This is the fundamental problem with duplication: you need to remember to update every copy, and humans are terrible at remembering. The compiler can't help you here because each switch is independently valid. It's only when a user hits the mobile path with "ApplePay" that the missing case becomes a runtime error.
// ✅ FIX: Centralize strategy selection in ONE factory
public sealed class PaymentStrategyFactory(
IEnumerable<IPaymentStrategy> strategies) : IPaymentStrategyFactory
{
private readonly Dictionary<string, IPaymentStrategy> _map =
strategies.ToDictionary(s => s.PaymentType, StringComparer.OrdinalIgnoreCase);
public IPaymentStrategy GetStrategy(string paymentType)
=> _map.TryGetValue(paymentType, out var s)
? s
: throw new NotSupportedException($"'{paymentType}' is not registered");
}
// Both controllers use the SAME factory — single source of truth
public class WebCheckoutController(IPaymentStrategyFactory factory) : ControllerBase
{
[HttpPost] public async Task<IActionResult> Checkout(CheckoutRequest req)
{
var strategy = factory.GetStrategy(req.PaymentType);
// ...
}
}
// Adding ApplePay: one new class + one DI line + ZERO controller changes
Why the fix works: Instead of each controller doing its own strategy lookup, we created one factory that owns the entire mapping. The factory builds a dictionary from all registered strategies at startup. Both controllers now simply call factory.GetStrategy(req.PaymentType) and get the correct strategy. When you add Apple Pay (or any new strategy), you write one new class, register it in DI, and every controller automatically gets access to it through the factory. Zero controller changes needed, zero duplication, zero chance of forgetting to update a copy.
Lesson Learned
Strategy selection logic should live in one place — a factory or the DI container. If you find strategy resolution code in more than one file, you have a DRY violationDon't Repeat Yourself. Duplicated strategy selection means every new strategy requires updates in N places. A factory or Keyed Services ensures strategy resolution is centralized, so new strategies are added in exactly one place. waiting to cause an inconsistency bug.
How to Spot This in Your Code
Search your codebase for the strategy interface name (e.g., IPaymentStrategy) and see how many places resolve or select a specific implementation. If you find switch statements or dictionary lookups in more than one file, you have duplicated selection logic. Consolidate it into a single factory class or use .NET 8 Keyed Services.
The Incident
All payments went through PayPal. A developer registered two implementations of IPaymentStrategy in the DI container. CreditCard was registered first, PayPal second. Both registrations used the same interface: IPaymentStrategy.
Here's the surprising behavior: when a controller asked DI for a single IPaymentStrategy, .NET's DI container always gave it the last one registered. Not the first. Not an error. Not a warning. Just silently the last one. Since PayPal was registered second, every single checkout used PayPalStrategy, regardless of what the customer selected.
Customers who chose "Credit Card" were actually being charged through PayPal. The payments went through (because PayPal accepted the charges), but the reconciliation was a nightmare. Stripe's dashboard showed zero revenue. PayPal showed double the expected volume. The accounting team flagged it after one hour, thinking it was a PayPal processing issue. It was actually a one-line DI registration ordering bug.
The worst part? There was no error. No exception. No warning in the logs. The system worked "correctly" by DI container rules; it just wasn't the behavior anyone expected. This is a silent, data-corrupting bug.
Time to Diagnose
1 hour. The Stripe dashboard tipped them off (zero charges), but the team first suspected Stripe API issues, then network problems, then configuration errors. Only after adding a log statement that printed the concrete strategy type (strategy.GetType().Name) did they see "PayPalStrategy" on every request, including credit card checkouts.
// ❌ Both registered as IPaymentStrategy — last wins
builder.Services.AddScoped<IPaymentStrategy, CreditCardStrategy>();
builder.Services.AddScoped<IPaymentStrategy, PayPalStrategy>(); // ← This always wins
// When controller injects IPaymentStrategy, it ALWAYS gets PayPalStrategy
// IEnumerable<IPaymentStrategy> would give both, but single injection gives last
Walking through the buggy code: Both lines register a different class for the same interface. In .NET's DI container, when you register multiple implementations for the same interface and then inject a single instance (not IEnumerable), the container gives you the last one registered. Line 2 registers CreditCard. Line 3 registers PayPal. PayPal is last, so PayPal always wins. The DI container doesn't throw an error, doesn't warn you, doesn't even log a message. It just silently picks the last one. This is documented behavior, but it trips up even experienced developers.
// ✅ FIX Option 1: Use Keyed Services (.NET 8+)
builder.Services.AddKeyedScoped<IPaymentStrategy, CreditCardStrategy>("CreditCard");
builder.Services.AddKeyedScoped<IPaymentStrategy, PayPalStrategy>("PayPal");
// Resolve by key — explicit, unambiguous
[FromKeyedServices("CreditCard")] IPaymentStrategy creditCard
// ✅ FIX Option 2: Use IEnumerable + Factory pattern
builder.Services.AddScoped<IPaymentStrategy, CreditCardStrategy>();
builder.Services.AddScoped<IPaymentStrategy, PayPalStrategy>();
// Inject IEnumerable<IPaymentStrategy> into factory → factory builds dictionary by name
// ✅ FIX Option 3: Never inject IPaymentStrategy directly
// Always inject IPaymentStrategyFactory and resolve by key
Why the fix works: Fix 1 (Keyed Services) gives each registration a unique string key. When you resolve by key, there's no ambiguity: "CreditCard" always gives you CreditCardStrategy, "PayPal" always gives you PayPalStrategy. Fix 2 (IEnumerable + Factory) keeps both registrations, but instead of injecting a single IPaymentStrategy, you inject all of them as a collection and let a factory pick the right one by name. Fix 3 is the rule that prevents this class of bugs entirely: never let any controller inject IPaymentStrategy directly. Always go through a factory or keyed resolution.
Lesson Learned
Never inject a singleIStrategy when you have multiple implementations registered. Use Keyed Services (.NET 8+), IEnumerable<IStrategy> with a factory, or named registrations. The "last registration wins" behavior is silent and dangerous.
How to Spot This in Your Code
Search your DI registrations for cases where the same interface appears in multiple AddScoped/AddSingleton lines. Then check if any class injects that interface as a single instance (not IEnumerable). If yes, you're silently getting only the last-registered one. Add a unit test that resolves IEnumerable<IStrategy> and asserts the count matches your expectation.
Most Strategy bugs come from three sources: null references (no default strategy), state leaks (mutable strategies shared across requests), and DI misconfiguration (wrong lifetime or wrong key). All are preventable with validation and testing.
Section 13
Pitfalls & Anti-Patterns
Mistake: Creating an interface, two classes, and a factory to replace a simple if (isPremium) discount = 10 else discount = 0.
Why This Happens: You just learned Strategy pattern and you're excited. You see a conditional and think "I should extract that into strategies!" The pattern looks clean in tutorials, so you reach for it everywhere. But patterns exist to solve complexity, and sometimes a simple if statement is the best code. Not everything needs to be a class hierarchy.
Think of it this way: you don't hire a moving company to carry a sandwich across the room. The overhead of the tool must match the complexity of the problem. Two simple options with no dependencies, no external APIs, and no growth potential? An if is perfect.
// ❌ Over-engineered for a simple binary choice
public interface IDiscountStrategy { decimal GetDiscount(); }
public class PremiumDiscount : IDiscountStrategy { public decimal GetDiscount() => 10m; }
public class NoDiscount : IDiscountStrategy { public decimal GetDiscount() => 0m; }
// + factory, + DI registration, + tests for each class = ~50 lines
// ✅ Just use an if statement — this is perfectly readable
decimal discount = isPremium ? 10m : 0m;
Fix: Use Strategy when you have 3+ variants, when each variant has its own dependencies (API clients, settings), or when you know the number will grow. For 2 simple options with no dependencies, a conditional is perfectly fine and far more readable.
Mistake: An IPaymentStrategy with Process(), Refund(), Void(), Capture(), GetStatus(), and SendReceipt().
Why This Happens: It feels natural to group all payment-related operations into one interface. After all, they're all about payments, right? The developer thinks "these operations belong together because they all relate to the same domain." But grouping by domain isn't the same as grouping by capability. Not every payment method can do every operation. Forcing them to pretend they can is a recipe for runtime exceptions.
This is an ISP (Interface Segregation Principle) violation. The interface is making promises that some implementations can't keep. When a class throws NotImplementedException, it's essentially lying to the caller: "I implement this interface, meaning I can do everything it defines" when actually it can't.
// ❌ God interface — too many methods, not all strategies can support all of them
public interface IPaymentStrategy
{
Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext ctx);
Task<RefundResult> RefundAsync(string txId, decimal amount);
Task VoidAsync(string txId);
Task<CaptureResult> CaptureAsync(string authId, decimal amount);
Task<StatusResult> GetStatusAsync(string txId);
Task SendReceiptAsync(string txId, string email);
}
// ✅ Split by capability — each strategy only implements what it truly supports
public interface IPaymentProcessor { Task<PaymentResult> ProcessAsync(...); }
public interface IRefundable { Task<RefundResult> RefundAsync(...); }
public interface IVoidable { Task VoidAsync(...); }
// Bitcoin only implements process. CreditCard implements all three.
public class BitcoinStrategy : IPaymentProcessor { /* only ProcessAsync */ }
public class CreditCardStrategy : IPaymentProcessor, IRefundable, IVoidable { /* all */ }
Fix: Apply ISP. Split your fat interface into focused, single-capability interfaces. Each strategy class then implements only the interfaces it genuinely supports. Callers use is pattern matching to check capabilities: if (strategy is IRefundable r) await r.RefundAsync(...);
Mistake: The Context casting the strategy to a concrete type: if (strategy is CreditCardStrategy cc) cc.SetApiVersion("v2").
Why This Happens: You have a strategy interface, but one specific implementation needs some extra setup. Maybe the Stripe API requires a version header, or PayPal needs a sandbox toggle. The quick fix is to cast the strategy to its concrete type inside the Context and call the extra method. It "works" today, but you've just hardwired the Context to a specific implementation.
The whole point of Strategy is that the Context doesn't know or care which concrete strategy it holds. The moment you write if (strategy is CreditCardStrategy), you've broken that contract. Now the Context can't work with new strategies without being modified. You can't swap CreditCardStrategy for a mock in tests without the cast failing. You've turned a clean abstraction into a leaky one.
// ❌ Context knows about concrete strategies — defeats the pattern
public class CheckoutService(IPaymentStrategy strategy)
{
public async Task<OrderResult> ProcessOrder(Order order)
{
if (strategy is CreditCardStrategy cc)
cc.SetApiVersion("v2"); // ❌ Tight coupling to CreditCard
if (strategy is PayPalStrategy pp)
pp.EnableSandbox(false); // ❌ Tight coupling to PayPal
return await strategy.ProcessAsync(order.Total, order.Context);
}
}
// ✅ Move configuration into the strategy itself (via DI/options)
public class CreditCardStrategy(IOptions<StripeSettings> opts) : IPaymentStrategy
{
// API version comes from settings, not from the Context
public async Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext ctx)
=> await _stripe.ChargeAsync(amount, opts.Value.ApiVersion);
}
Fix: The Context should ONLY call methods defined on the strategy interface. Strategy-specific configuration belongs inside the strategy class, injected via IOptions<T> or constructor parameters. The Context never knows what kind of strategy it's holding.
Mistake: CreditCardStrategy needs IStripeClient, PayPalStrategy needs IPayPalClient + IHttpClientFactory, BankTransferStrategy needs IBankApi + IEncryptionService. Registering all of them means DI must resolve dependencies for ALL strategies even when only one is used per request.
Why This Happens: You register all strategies eagerly (using IEnumerable<IPaymentStrategy> in a factory). The DI container creates instances of ALL strategies every time the factory is resolved, even though the current request will only use one. If CreditCardStrategy needs an expensive Stripe client, PayPal needs an HTTP client factory, and Bitcoin needs a blockchain node connection, you're paying the construction cost of all three when you only need one.
For most web apps this is negligible. But if strategies have expensive dependencies (database connections, HTTP clients with connection pools, third-party SDK initializations), the waste adds up, and you might hit DI resolution failures for services that aren't even configured for the current environment.
// ❌ Eager: ALL strategies created even though only one is used
public class PaymentFactory(IEnumerable<IPaymentStrategy> all) { /* all created */ }
// ✅ Lazy: Only the needed strategy is created
builder.Services.AddKeyedScoped<IPaymentStrategy, CreditCardStrategy>("CreditCard");
builder.Services.AddKeyedScoped<IPaymentStrategy, PayPalStrategy>("PayPal");
// Resolve only what you need — other strategies are never instantiated
var strategy = sp.GetRequiredKeyedService<IPaymentStrategy>("CreditCard");
Fix: Use Keyed Services (.NET 8+) to resolve only the specific strategy needed. Or use a Func<string, IPaymentStrategy>A factory delegate registered in DI: builder.Services.AddScoped(sp => (string key) => key switch { "CreditCard" => sp.GetRequiredService<CreditCardStrategy>(), ... }). This defers strategy creation until the key is known, avoiding eager resolution of all strategies. factory delegate that defers creation until the key is known.
Mistake: Creating a full IStringFormatter interface + UpperCaseFormatter class + LowerCaseFormatter class for simple stateless transformations.
// ❌ Over-engineered — 3 files for trivial logic
public interface IStringFormatter { string Format(string input); }
public class UpperCaseFormatter : IStringFormatter { public string Format(string s) => s.ToUpper(); }
public class LowerCaseFormatter : IStringFormatter { public string Format(string s) => s.ToLower(); }
// ✅ Just use Func<string, string>
Func<string, string> formatter = s => s.ToUpper(); // One line, zero files
Fix: If the strategy is stateless, has no dependencies, and is a single method — use Func<T>. Promote to a full interface when you need DI, naming, multiple methods, or discovery.
Mistake: Assuming the Context validates all input before calling the strategy. But the Context doesn't know what each strategy requires (PayPal needs an email, Bitcoin needs a wallet address, CreditCard needs a card token).
Why This Happens: You think validation is the Context's job. After all, the Context is the entry point, so it should validate everything, right? But the Context works with the abstract interface. It doesn't know that PayPalStrategy needs an email or that BitcoinStrategy needs a wallet address. Only the specific strategy knows what it needs. If you skip strategy-level validation, bad data flows through to the external API, which rejects it with a cryptic error like {"error": "invalid_request"} instead of a clear "Please provide your email for PayPal checkout."
// ❌ No validation — bad data reaches the external API
public class PayPalStrategy : IPaymentStrategy
{
public async Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext ctx)
{
// ctx.Email might be null — PayPal API will reject it with a cryptic error
return await _paypal.ChargeAsync(ctx.Email, amount);
}
}
// ✅ Strategy validates its own required inputs
public class PayPalStrategy : IPaymentStrategy
{
public async Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext ctx)
{
ArgumentNullException.ThrowIfNull(ctx.Email, nameof(ctx.Email));
if (!ctx.Email.Contains('@'))
throw new ArgumentException("Valid email required for PayPal");
return await _paypal.ChargeAsync(ctx.Email, amount);
}
}
Fix: Each strategy should validate its own inputs at the start of its method. The Context handles general validation (amount > 0), but strategy-specific requirements (email, wallet address, card token) are validated by the strategy itself. Fail with a clear, actionable message instead of letting the external API reject it.
Mistake: Storing request-specific data in strategy fields (e.g., _lastTransactionId, _currentCustomer).
Why This Happens: It feels convenient to store intermediate results in a field. "I'll save the customer info during Configure() and use it in Calculate()." This two-step pattern (set state, then use state) works in single-threaded tests but blows up in production where multiple threads share the same strategy instance. Even if you use Transient lifetime, mutable state makes strategies harder to test and reason about because the output depends on when methods were called, not just what was passed in.
// ❌ Stateful — data saved in fields
public class ShippingStrategy : IShippingStrategy
{
private Address _destination; // ❌ Mutable state
private double _weight; // ❌ Mutable state
public void Configure(Address dest, double weight) {
_destination = dest; _weight = weight;
}
public decimal Calculate() => /* uses _destination and _weight */;
}
// ✅ Stateless — everything passed as parameters
public class ShippingStrategy : IShippingStrategy
{
// No fields! Pure function: input → output
public decimal Calculate(Address destination, double weight)
=> /* uses parameters only, no shared state */;
}
Fix: Keep strategies stateless. Pass all data through method parameters. Return results as new objects. Think of strategies as pure functions: same input always produces same output, with no side effects on the object. If you must track state across method calls, use a separate per-request context object that you pass around.
Mistake: Five different controllers each doing their own switch to pick the right strategy, instead of using a centralized factory.
Why Bad: Adding a new strategy requires updating every switch statement. Miss one, and you get an inconsistency bug (as seen in Bug Study 5).
Fix: Centralize selection in a single factory class, or use Keyed Services. There should be exactly one place that maps keys to strategies.
Mistake: Only testing the Context end-to-end, with one strategy. The other strategies are "tested by proxy" through integration tests.
Why This Happens: You write integration tests that go through the full flow (controller → factory → strategy → external API mock) and consider your strategies "covered." But when the integration test fails, it's unclear whether the problem is in the Context, the factory, the DI wiring, or the strategy itself. You've lost the biggest benefit of Strategy pattern: each algorithm is an independent unit that can be tested in isolation.
// ❌ Only one integration test that happens to use CreditCard
[Fact] public async Task Checkout_Works() { /* tests full flow, one strategy */ }
// ✅ Test EACH strategy independently + the factory + the Context
[Fact] public async Task CreditCardStrategy_ValidCharge_ReturnsSuccess() { /* ... */ }
[Fact] public async Task PayPalStrategy_MissingEmail_ThrowsArgNull() { /* ... */ }
[Fact] public async Task BitcoinStrategy_ValidAddress_Processes() { /* ... */ }
[Fact] public void Factory_ValidKey_ReturnsCorrectStrategy() { /* ... */ }
[Fact] public void Factory_UnknownKey_ThrowsNotSupported() { /* ... */ }
[Fact] public async Task Context_StrategySucceeds_SavesOrder() { /* mock strategy */ }
[Fact] public async Task Context_StrategyFails_DoesNotSave() { /* mock strategy */ }
Fix: Write three test suites: (1) Each strategy class gets its own tests with mocked dependencies. (2) The factory gets tests for key resolution and error cases. (3) The Context gets tests with a mocked strategy interface. When something breaks, you know exactly which layer failed.
Mistake: Using Strategy to manage order status (Pending → Processing → Shipped → Delivered) by manually swapping strategies in the Context.
Why This Happens: Strategy and State look almost identical in code: both have an interface with concrete implementations, and the Context holds a reference to it. The difference is in who changes the behavior and when. With Strategy, the client (or DI container) picks the algorithm once and it stays fixed for the lifetime of the request. With State, the object itself transitions between behaviors as its internal condition changes.
A simple way to tell them apart: ask "Does the behavior change because the user chose differently or because the object's condition changed?" User picks "PayPal" at checkout? That's Strategy. Order moves from "Pending" to "Shipped" because it was dispatched? That's State. If you're manually calling context.SetStrategy(newStrategy) multiple times during the object's lifecycle, you probably want State pattern instead.
// ❌ Forcing Strategy pattern for state transitions
public class Order
{
private IOrderBehavior _strategy; // ❌ Manually swapped
public void MarkAsProcessing() { _strategy = new ProcessingBehavior(); }
public void MarkAsShipped() { _strategy = new ShippedBehavior(); }
public void MarkAsDelivered() { _strategy = new DeliveredBehavior(); }
// ❌ Context is managing transitions — that's State pattern's job!
}
// ✅ Use State pattern when behavior transitions over the object's lifecycle
// ✅ Use Strategy when the algorithm is chosen once at creation/injection time
// Payment method = Strategy (user picks once, stays fixed)
// Order lifecycle = State (transitions through multiple stages)
Fix: If the object's behavior changes over its lifecycle based on internal state transitions, use State pattern (where states know about each other and manage transitions). If the algorithm is chosen once at creation/injection time and doesn't change, use Strategy. They're structurally similar but serve very different purposes.
Section 14
Testing Strategies
Each strategy is a standalone unit with its own dependencies. Test it independently by mockingUsing a mocking library (Moq, NSubstitute) to create fake implementations of dependencies. Mock<IStripeClient> simulates Stripe without making real API calls. This lets you test CreditCardStrategy's logic in isolation — fast, deterministic, no network needed. its specific dependencies — no other strategies involved.
[Fact]
public async Task CreditCardStrategy_SuccessfulCharge_ReturnsSuccess()
{
// Arrange — mock only Stripe, nothing else
var stripeClient = new Mock<IStripeClient>();
stripeClient.Setup(s => s.ChargeAsync(It.IsAny<ChargeRequest>()))
.ReturnsAsync(new ChargeResponse { Status = "succeeded", Id = "ch_123" });
var strategy = new CreditCardStrategy(
stripeClient.Object,
Options.Create(new StripeSettings { Currency = "usd" }),
NullLogger<CreditCardStrategy>.Instance);
// Act
var result = await strategy.ProcessAsync(99.99m, new PaymentContext("cust_1", "tok_test"));
// Assert
Assert.True(result.Success);
Assert.Equal("ch_123", result.TransactionId);
}
[Fact]
public async Task PayPalStrategy_MissingEmail_ThrowsArgumentNull()
{
var strategy = new PayPalStrategy(Mock.Of<IPayPalClient>(),
NullLogger<PayPalStrategy>.Instance);
// Email is null — strategy should validate and throw
await Assert.ThrowsAsync<ArgumentNullException>(() =>
strategy.ProcessAsync(50m, new PaymentContext("cust_1", "tok_test", Email: null)));
}
The factory that selects the right strategy is its own unitThe factory's responsibility is mapping keys to strategies. Test it separately from the strategies themselves: verify "CreditCard" resolves to CreditCardStrategy, "PayPal" resolves to PayPalStrategy, unknown keys throw NotSupportedException. This isolation makes debugging faster when resolution fails.. Test that it maps keys correctly and throws for unknown keys.
[Theory]
[InlineData("CreditCard", typeof(CreditCardStrategy))]
[InlineData("PayPal", typeof(PayPalStrategy))]
[InlineData("creditcard", typeof(CreditCardStrategy))] // case-insensitive
public void GetStrategy_ValidType_ReturnsCorrectStrategy(string type, Type expected)
{
var strategies = new IPaymentStrategy[]
{
CreateMockStrategy("CreditCard", typeof(CreditCardStrategy)),
CreateMockStrategy("PayPal", typeof(PayPalStrategy))
};
var factory = new PaymentStrategyFactory(strategies);
var result = factory.GetStrategy(type);
Assert.IsType(expected, result);
}
[Fact]
public void GetStrategy_UnknownType_ThrowsNotSupported()
{
var factory = new PaymentStrategyFactory(Array.Empty<IPaymentStrategy>());
Assert.Throws<NotSupportedException>(() => factory.GetStrategy("Dogecoin"));
}
Verify that the Context (e.g., CheckoutService) works correctly regardless of which strategy is injected. It should delegate to the strategy and handle success/failure the same way.
[Fact]
public async Task PlaceOrder_AnySuccessfulStrategy_SavesOrderAndReturnsOk()
{
// Arrange — mock strategy returns success (don't care which implementation)
var mockStrategy = new Mock<IPaymentStrategy>();
mockStrategy.Setup(s => s.ProcessAsync(It.IsAny<decimal>(), It.IsAny<PaymentContext>()))
.ReturnsAsync(new PaymentResult(true, "tx_mock"));
var mockRepo = new Mock<IOrderRepository>();
var service = new CheckoutService(mockStrategy.Object, mockRepo.Object);
// Act
var result = await service.PlaceOrderAsync(testOrder);
// Assert — Context behavior is strategy-independent
Assert.True(result.IsSuccess);
mockRepo.Verify(r => r.SaveAsync(It.IsAny<Order>()), Times.Once);
}
[Fact]
public async Task PlaceOrder_StrategyFails_DoesNotSaveOrder()
{
var mockStrategy = new Mock<IPaymentStrategy>();
mockStrategy.Setup(s => s.ProcessAsync(It.IsAny<decimal>(), It.IsAny<PaymentContext>()))
.ReturnsAsync(new PaymentResult(false, "", "Insufficient funds"));
var mockRepo = new Mock<IOrderRepository>();
var service = new CheckoutService(mockStrategy.Object, mockRepo.Object);
var result = await service.PlaceOrderAsync(testOrder);
Assert.False(result.IsSuccess);
mockRepo.Verify(r => r.SaveAsync(It.IsAny<Order>()), Times.Never);
}
Verify that the DI containerMicrosoft.Extensions.DependencyInjection — the built-in IoC container in ASP.NET Core. It manages object creation, lifetime, and dependency resolution. For Strategy pattern, it's responsible for wiring the correct ConcreteStrategy into the Context. wires strategies correctly at application startup. This catches missing registrations before they hit production.
[Fact]
public void DI_AllPaymentStrategiesRegistered()
{
// Arrange — build a real DI container with your production registrations
var services = new ServiceCollection();
services.AddPaymentStrategies(); // Your extension method
var provider = services.BuildServiceProvider(validateScopes: true);
// Act — resolve all strategies
var strategies = provider.GetServices<IPaymentStrategy>().ToList();
// Assert — every expected strategy is present
Assert.Contains(strategies, s => s.PaymentType == "CreditCard");
Assert.Contains(strategies, s => s.PaymentType == "PayPal");
Assert.Contains(strategies, s => s.PaymentType == "BankTransfer");
}
[Theory]
[InlineData("CreditCard")]
[InlineData("PayPal")]
[InlineData("BankTransfer")]
public void DI_KeyedServices_ResolveCorrectStrategy(string key)
{
var services = new ServiceCollection();
services.AddKeyedScoped<IPaymentStrategy, CreditCardStrategy>("CreditCard");
services.AddKeyedScoped<IPaymentStrategy, PayPalStrategy>("PayPal");
services.AddKeyedScoped<IPaymentStrategy, BankTransferStrategy>("BankTransfer");
var provider = services.BuildServiceProvider();
var strategy = provider.GetRequiredKeyedService<IPaymentStrategy>(key);
Assert.Equal(key, strategy.PaymentType);
}
Section 15
Performance Considerations
Good news: Strategy adds almost zero performance cost in modern .NET. The runtime is smart enough to optimize interface calls extremely well. Here's what you actually need to know:
Concern
Impact
Recommendation
Virtual dispatchWhen you call strategy.Process(), the CLR performs a virtual method table (vtable) lookup to find the correct implementation. This costs ~1-2 nanoseconds — completely negligible for any real-world strategy (payment processing, API calls, database queries all take millions of nanoseconds).
~1-2 ns per call
Negligible. Never optimize this. Your strategy's I/O (HTTP calls, DB queries) takes 1,000,000x longer.
Object allocation (Transient)
~20-50 ns per instantiation
If strategy is stateless, register as SingletonSingleton strategies are created once and reused for the entire app lifetime. Zero allocation after startup. Perfect for stateless strategies. But ONLY if the strategy has no mutable fields and no scoped dependencies (IDbContext, etc.). to avoid repeated allocations. One instance, zero GC pressure.
DI resolution overhead
~100-500 ns per resolve
Negligible for web requests (each takes 5-500ms). If hot path, cache the resolved strategy reference.
Func<T> vs interface
Func is ~2-5% faster (no vtable)
Use Func for hot loops (sorting millions of items). Use interfaces for everything else — the DI and testability benefits outweigh 2% CPU.
JIT devirtualizationWhen the JIT compiler can prove at runtime that only one implementation exists for an interface method, it eliminates the virtual dispatch and inlines the call. Marking strategy classes as 'sealed' helps the JIT prove this. Result: zero overhead compared to a direct method call.
Zero (when triggered)
Mark strategy classes as sealed. The JIT can then devirtualize and inline the call — making interface dispatch cost literally zero.
IEnumerable<T> factory dictionary
O(n) build, O(1) lookup
Build the strategy dictionary once at startup. Subsequent lookups are O(1) hash table operations.
Bottom line: Strategy pattern's performance overhead is unmeasurable in real applications. A single HTTP call to Stripe takes 200ms. Virtual dispatchWhen calling a method through an interface (strategy.Process()), the CLR looks up the method in the virtual method table (vtable) to find the concrete implementation. This indirection costs ~1-2 nanoseconds. For comparison: a cache miss costs ~100ns, a database query costs ~1-50ms, an HTTP call costs ~50-500ms. takes 2ns. Don't let performance anxiety prevent you from writing clean, testable code. The sealed keyword on strategy classes is free insurance — it enables JIT devirtualization and prevents accidental inheritance.
Benchmark It Yourself
[MemoryDiagnoser]
public class StrategyBenchmarks
{
private readonly IPaymentStrategy _interface = new CreditCardStrategy();
private readonly Func<decimal, bool> _func = amount => amount > 0;
[Benchmark(Baseline = true)]
public bool DirectCall() => _interface.Validate(100m);
[Benchmark]
public bool InterfaceCall() => ((IPaymentStrategy)_interface).Validate(100m);
[Benchmark]
public bool FuncDelegate() => _func(100m);
}
Section 16
Interview Pitch
Your Script (90 seconds)
Opening: "Strategy pattern encapsulates a family of interchangeable algorithms behind a common interface, so the client code never knows — or cares — which specific algorithm runs."
Core: "The key insight is composition over inheritance. Instead of a switch statement that grows with every new variant, each algorithm lives in its own class with its own dependencies. Adding a new algorithm means writing one new class and one DI registration — zero modifications to existing code. That's the Open/Closed Principle in practice."
Example: "In a recent project, we had a payment service with a 400-line switch statement for CreditCard, PayPal, BankTransfer, and Apple Pay. Each case had different API clients, different error handling, different retry logic — all tangled together. We refactored to IPaymentStrategy with four implementations. The checkout service dropped to 20 lines. Adding Google Pay was one new class and one line in Program.cs. Every strategy had its own unit tests."
When: "I use Strategy when I have three or more algorithms for the same task, selected at runtime. For simpler cases — two options, no dependencies — a conditional is fine. And if the behavior transitions between states over an object's lifecycle, I reach for State pattern instead."
Close: "In .NET 8, Keyed Services made Strategy even easier — you register strategies with string keys and resolve them with [FromKeyedServices]. No manual factory needed. It's the framework's built-in support for the pattern."
Section 17
Q&As
29 questions interviewers actually ask — grouped by difficulty. Each has a "Think First" prompt (try before peeking!) and a "Great Answer" tip to help you stand out. 6 Easy · 12 Medium · 11 Hard.
Easy
Q1: What problem does the Strategy pattern solve?
Easy
Think First Think about a switch statement with 10 cases — what happens when you add case 11?
Strategy eliminates growing conditional logic (if/else chains, switch statements) by extracting each branch into its own class. Instead of one method doing 10 things based on a type flag, you have 10 focused classes each doing one thing. The client code delegates to whichever class is injected.
This solves three problems simultaneously: (1) the monolithic method that grows with every new variant (OCP violation), (2) the inability to test one variant without setting up the others, and (3) the tight coupling between the client and every concrete algorithm.
Great Answer Bonus "It turns runtime branching into clean runtime polymorphism. The switch statement disappears — replaced by DI resolving the right implementation. The Context class goes from 500 lines to 20."
Q2: Name the three participants in the Strategy pattern.
Easy
Think First Look at the UML diagram — which boxes are there?
Context — the class that needs an algorithm but doesn't want to know which specific one runs. It holds a reference to the strategy interface and delegates work to it. Example: CheckoutService knows it needs to process a payment but doesn't care whether it's credit card or PayPal.
Strategy (interface) — the contract that all algorithms must follow. It defines what can be done without saying how. Example: IPaymentStrategy with a ProcessAsync() method.
Concrete Strategies — individual classes implementing the interface, each containing one specific algorithm. Example: CreditCardStrategy talks to Stripe, PayPalStrategy talks to PayPal's API.
Great Answer Bonus "In modern .NET there's an implicit fourth participant: the DI container or factory that selects and injects the right concrete strategy into the Context."
Q3: Give a real-world .NET example of Strategy pattern.
Easy
Think First Think about LINQ methods and collection sorting — what do they accept as parameters?
IComparer<T> is the classic .NET Strategy. When you call List<T>.Sort(comparer), the list is the Context and the comparer is the Strategy. Different comparers sort by different criteria (price, name, date) without changing the list's sort method.
Other examples: IEqualityComparer<T> for dictionaries/hash sets, Func<T, bool> in LINQ's .Where(), IAuthorizationHandler in ASP.NET Core, and IValidator<T> in FluentValidation.
Great Answer Bonus "Every LINQ method is Strategy pattern — .Where(predicate), .OrderBy(keySelector), .Select(projection). The lambda IS the strategy. LINQ methods are Contexts."
Q4: What is the relationship between Strategy and DIP?
Easy
Think First What does the Context depend on — the interface or the concrete class?
Strategy IS DIP in action. The Context (high-level module) depends on IPaymentStrategy (abstraction), not on CreditCardStrategy (low-level module). The dependency arrow points toward the interface. Both the Context and the concrete strategies depend on the abstraction — neither knows about the other directly.
This means you can swap strategies without modifying the Context, test the Context with mocks, and add new strategies without touching existing code.
Great Answer Bonus "The interface should be OWNED by the Context's layer (business logic), not by the strategy implementations (infrastructure). That's the 'inversion' — infrastructure conforms to business contracts."
Q5: When is a simple if/else better than Strategy pattern?
Easy
Think First What's the minimum number of variants where Strategy starts paying off?
When you have only 2 simple options with no dependencies and no expectation of growth. var tax = isEU ? subtotal * 0.20m : subtotal * 0.07m; is clearer than creating IRegionTaxStrategy, EUTaxStrategy, USTaxStrategy, and a factory. The overhead of Strategy (interface + classes + DI) exceeds its benefit for trivial binary choices.
Great Answer Bonus "The crossover point is typically 3 variants or when each variant has its own dependencies. But if the product roadmap shows 'we'll add 5 more regions soon,' investing in Strategy early prevents a painful refactor later."
Q6: Can you use a delegate (Func<T>) instead of an interface for Strategy?
Easy
Think First When does a lambda become insufficient and you need a full class?
Yes, and you should for simple stateless strategies. Func<decimal, decimal> discount = amount => amount * 0.10m; is Strategy pattern with zero boilerplate. LINQ is built entirely on this approach.
Promote to an interface when: the strategy needs its own DI dependencies (IHttpClient, ILogger), you need multiple methods on the same strategy (Process + Validate), you want auto-discovery via IEnumerable<IStrategy>, or you need a named type for debugging and logging.
Great Answer Bonus "In C#, Func<T> IS a single-method interface — just without the ceremony. The decision to promote to an interface is about whether you need DI, naming, or multiple methods — not about 'being proper.'"
Medium
Q7: How do you select the right strategy at runtime in .NET?
Medium
Think First Think about .NET 8 Keyed Services, factory pattern, and IEnumerable injection.
Three main approaches in modern .NET:
Keyed Services (.NET 8+): Register with AddKeyedScoped<IStrategy, ConcreteA>("keyA"), resolve with [FromKeyedServices("keyA")] or sp.GetKeyedService<T>(key).
Factory + IEnumerable: Register all strategies, inject IEnumerable<IStrategy> into a factory, build a dictionary by key. Factory method returns the matching strategy.
Factory delegate: Register a Func<string, IStrategy> in DI that resolves to a switch or dictionary lookup.
Keyed Services is the recommended approach for .NET 8+ because it's built-in, requires no custom factory, and integrates with controller parameter binding.
Great Answer Bonus "For dynamic selection based on user input, I combine Keyed Services with a resolver service that maps the user's string to the correct key, validates it, and provides a fallback."
Q8: What's the difference between Strategy and State pattern?
Medium
Think First Think about WHO triggers the behavior change — the client or the object itself?
They're structurally identical (Context → Interface → Implementations) but differ in intent:
Strategy: the CLIENT selects which algorithm to use. The strategy is set once and doesn't change during the object's lifecycle. Payment method selection: user picks CreditCard at checkout.
State: the OBJECT changes its behavior based on internal state transitions. States know about each other. Order lifecycle: Pending → Processing → Shipped → Delivered, each state changes what methods do.
Key test: Does the behavior change because the user chose differently (Strategy) or because the object's internal condition changed (State)?
Great Answer Bonus "In Strategy, the Context doesn't know which strategy it holds. In State, the Context usually knows its current state and state objects trigger transitions to other states."
Q9: How does Strategy support the Open/Closed Principle?
Medium
Think First What files change when you add a new algorithm?
Adding a new algorithm requires creating one new class that implements the strategy interface and one DI registration linebuilder.Services.AddScoped<IPaymentStrategy, NewStrategy>() or AddKeyedScoped with a key. This single line in Program.cs is the only "wiring" needed. If you use Scrutor's assembly scanning, even this line is automatic.. The Context class, existing strategies, factory, and tests all remain untouched. The system is open for extension (new strategy classes) and closed for modification (existing code doesn't change).
Compare to a switch statement: adding a case means opening the existing method, modifying tested code, risking regressions, and creating merge conflicts with other developers adding their own cases.
Great Answer Bonus "With assembly scanning (Scrutor), even the DI registration is automatic. A new strategy class is truly 'drop it in and it works' — zero modifications anywhere."
Q10: What DI lifetime should you use for strategies?
Medium
Think First Does the strategy have mutable state? Does it depend on scoped services?
It depends on the strategy's nature:
Singleton: Stateless strategies with no scoped dependencies. Most strategies fall here. Zero allocation overhead after startup.
Scoped: Strategies that depend on scoped services (DbContext, HttpContext). One instance per request.
Transient: Strategies with mutable state that must be isolated per use. Rare — prefer making strategies stateless.
The most common mistake is registering a stateful strategy as Singleton (see Bug Study 2) — it causes thread-safety issues under concurrent load.
Great Answer Bonus "Default to Singleton for stateless strategies and enable ValidateScopes in development to catch captive dependency bugs (Singleton holding a Scoped reference)."
Q11: How do you test a Context class that uses Strategy?
Medium
Think First If the Context delegates to IStrategy, what do you inject during testing?
Mock the strategy interface. The Context doesn't care which implementation runs — it just calls the interface method. Verify the Context's own behavior (saves order on success, skips save on failure) regardless of which strategy is behind the mock.
Test three scenarios: strategy returns success, strategy returns failure, strategy throws an exception. In each case, verify the Context handles the outcome correctly. This proves the Context is strategy-independent.
Great Answer Bonus "Test the Context, each strategy, and the factory as three separate test suites. The Context tests mock the strategy. The strategy tests mock its infrastructure dependencies. The factory tests verify the mapping logic."
Q12: What is the Null Object pattern and how does it relate to Strategy?
Medium
Think First What happens when the factory can't find a matching strategy — return null or something else?
Null Object is a strategy implementation that does nothing. Instead of returning null when no strategy applies, you return a "do nothing" strategy: NoDiscount returns 0 discount, NullLogger discards messages. The Context never needs null checks because it always has a valid, real strategy object. It just happens to be one that does nothing useful.
This eliminates NullReferenceException risks and simplifies the Context code. Every code path is safe because the strategy reference is never null.
Great Answer Bonus ".NET already uses this: NullLogger<T>.Instance is a Null Object strategy for ILogger<T>. It implements ILogger perfectly but discards all messages. You can inject it in tests or fallback scenarios."
Q13: How does Strategy pattern differ from Template Method?
Medium
Think First One uses composition, the other uses inheritance — which is which?
Strategy uses composition (has-a): the Context holds a reference to the strategy interface. The entire algorithm is replaced. Each strategy is an independent class with its own dependencies.
Template Method uses inheritance (is-a): a base class defines the algorithm skeleton and subclasses override specific steps. Only the varying steps are replaced, the shared skeleton stays in the base class.
Use Strategy when algorithms are completely different. Use Template Method when they share 80% of their logic and only vary in specific steps.
Great Answer Bonus "Strategy is more flexible — you can change it at runtime and test implementations in isolation. Template Method is more concise when algorithms genuinely share a skeleton. In modern C#, I tend to prefer Strategy because composition is easier to test and DI supports it natively."
Q14: How do .NET 8 Keyed Services simplify Strategy pattern?
Medium
Think First Before .NET 8, how did you resolve one specific IStrategy out of many?
Before .NET 8, resolving a specific strategy required a manual factory: inject IEnumerable<IStrategy>, build a dictionary, expose a GetStrategy(key) method. Keyed Services eliminate this boilerplate.
Register: builder.Services.AddKeyedScoped<IPaymentStrategy, CreditCardStrategy>("CreditCard"). Resolve in controllers: [FromKeyedServices("CreditCard")] IPaymentStrategy strategy. Resolve in code: sp.GetRequiredKeyedService<IPaymentStrategy>("CreditCard").
The DI container itself becomes the factory. You still might want a custom factory for dynamic key resolution (mapping user input to keys), but the plumbing is dramatically simpler.
Great Answer Bonus "Keyed Services also work with IEnumerable — you can inject all strategies tagged with a specific key group for batch processing scenarios."
Q15: How would you use Strategy for A/B testing?
Medium
Think First How do you assign different users to different strategy implementations?
Create two implementations of the same strategy interface (e.g., ClassicCheckoutFlow and StreamlinedCheckoutFlow). A strategy resolver checks the user's A/B test assignment (via feature flags, user segment, or random hash) and returns the appropriate implementation.
The Context code (checkout page, API endpoint) is identical for both groups — it just delegates to whichever strategy the resolver provides. Metrics are captured per-strategy to compare performance.
Great Answer Bonus "Use a factory that takes the user's segment as input: factory.GetStrategy(user.AbGroup). The factory maps 'A' to ClassicCheckout and 'B' to StreamlinedCheckout. Toggle the experiment by changing the factory mapping — zero code changes in the checkout flow."
Q16: Should the strategy interface have one method or many?
Medium
Think First What SOLID principle governs the size of interfaces?
Ideally one method, per ISP. A strategy interface represents one algorithm operation. If you need multiple operations (Process, Refund, Validate), consider whether they're always needed together. If not, split into separate interfaces.
Exception: it's acceptable to have a primary method plus a discriminator property (e.g., Name or PaymentType) for factory resolution. That's metadata, not a separate algorithm.
Great Answer Bonus "If any implementation throws NotImplementedException for a method, the interface is too fat. That's an LSP violation and a sign you need to split."
Q17: How do you handle strategies that need different input data?
Medium
Think First CreditCard needs a card token, PayPal needs an email — how does one interface serve both?
Use a context object (a record/class) that contains all possible input fields. Each strategy reads the fields it needs and ignores the rest. This keeps the interface method signature consistent.
Example: record PaymentContext(string CustomerId, string Token, string? Email, string? WalletAddress). CreditCardStrategy uses Token. PayPalStrategy uses Email. BitcoinStrategy uses WalletAddress. Each strategy validates its required fields and ignores the others.
Great Answer Bonus "Each strategy should validate that its required fields are present and throw ArgumentException if not. The Context shouldn't validate strategy-specific requirements — it doesn't know what each strategy needs."
Q18: What SOLID principles does Strategy pattern support?
Medium
Think First Go through each letter of SOLID and think about how Strategy relates.
All five:
SRP: Each strategy class has one responsibility — implement one specific algorithm.
OCP: New algorithm = new class. Existing code is closed for modification.
LSP: Every strategy must honor the interface contract — no NotImplementedException.
ISP: Strategy interfaces should be focused (ideally one method).
DIP: Context depends on IStrategy (abstraction), not ConcreteStrategy.
Great Answer Bonus "Strategy is arguably the most SOLID-compliant GoF pattern. It's the go-to example when teaching any of the five principles in practice."
Hard
Q19: Design a strategy resolver that combines Keyed Services with runtime configuration.
Hard
Think First How do you map a user's request to the correct Keyed Service when the key comes from a database or config file?
Build a resolver service that reads the mapping from configuration and resolves Keyed Services dynamically:
public interface IStrategyResolver<TStrategy> where TStrategy : notnull
{
TStrategy Resolve(string userKey);
}
public sealed class ConfigDrivenStrategyResolver<TStrategy>(
IServiceProvider sp,
IOptionsMonitor<StrategyMappingOptions> options) : IStrategyResolver<TStrategy>
where TStrategy : notnull
{
public TStrategy Resolve(string userKey)
{
// Map user-facing key to internal service key via configuration
var mapping = options.CurrentValue.Mappings
.FirstOrDefault(m => m.UserKey.Equals(userKey, StringComparison.OrdinalIgnoreCase))
?? throw new NotSupportedException($"No mapping for '{userKey}'");
return sp.GetRequiredKeyedService<TStrategy>(mapping.ServiceKey);
}
}
// appsettings.json — change mappings without redeployment
// "StrategyMapping": {
// "Mappings": [
// { "UserKey": "card", "ServiceKey": "CreditCard" },
// { "UserKey": "pp", "ServiceKey": "PayPal" }
// ]
// }
Great Answer Bonus "IOptionsMonitor allows hot-reloading — change the mapping in appsettings.json and the resolver picks it up without restarting the app. Combine with feature flags for gradual rollouts."
Q20: How do you implement Strategy with composite strategies (chain of strategies)?
Hard
Think First What if you need to apply MULTIPLE discount strategies to the same cart?
Use the Composite pattern with Strategy: create a CompositeStrategy that implements the same interface but delegates to multiple child strategies.
public sealed class CompositeDiscountStrategy(
IEnumerable<IDiscountStrategy> strategies) : IDiscountStrategy
{
public string Name => "Combined";
public decimal Calculate(ShoppingCart cart)
{
// Apply all discounts and sum them (or use most favorable)
return strategies.Sum(s => s.Calculate(cart));
}
}
// DI: register individual strategies + composite
builder.Services.AddSingleton<IDiscountStrategy, PercentageDiscount>();
builder.Services.AddSingleton<IDiscountStrategy, LoyaltyDiscount>();
builder.Services.AddSingleton<IDiscountStrategy>(sp =>
new CompositeDiscountStrategy(sp.GetServices<IDiscountStrategy>()));
The Context still sees a single IDiscountStrategy — it doesn't know it's actually invoking multiple strategies underneath. This is the Composite pattern combined with Strategy.
Great Answer Bonus "Be careful with ordering — if Percentage is applied before Flat, the total differs from Flat-before-Percentage. The composite should document its aggregation order."
Q21: How do you prevent the "last registration wins" DI trap with multiple strategies?
Hard
Think First What happens when you register two classes for the same interface and inject a single instance?
In .NET DI, when multiple implementations are registered for the same interface, injecting a single IStrategy gives you the last one registered. This is silent — no error, no warning.
Prevention strategies:
.NET 8 Keyed Services: Each registration has a unique key. No ambiguity.
IEnumerable<T> injection: Always inject all implementations and use a factory to select.
Never inject single IStrategy directly when multiple exist — always go through a factory or keyed resolution.
Startup validation test: Assert that sp.GetServices<IStrategy>().Count() matches the expected number.
Great Answer Bonus "Add a unit test that builds the real ServiceProvider from your production registrations and asserts the count and types of registered strategies. Run it in CI to catch missing or duplicate registrations."
Q22: How do you handle strategy-specific configuration in DI?
Hard
Think First CreditCard needs StripeSettings, PayPal needs PayPalSettings — how do you wire each strategy's config?
Each strategy gets its own IOptions<T> configuration section. In appsettings.json, create nested sections per strategy. Each strategy's constructor receives its typed options.
// appsettings.json:
// "Payment": {
// "Stripe": { "ApiKey": "sk_...", "Currency": "usd" },
// "PayPal": { "ClientId": "...", "Secret": "..." }
// }
builder.Services.Configure<StripeSettings>(
builder.Configuration.GetSection("Payment:Stripe"));
builder.Services.Configure<PayPalSettings>(
builder.Configuration.GetSection("Payment:PayPal"));
// Each strategy receives ONLY its own settings
public sealed class CreditCardStrategy(
IStripeClient stripe,
IOptions<StripeSettings> opts) : IPaymentStrategy { /* ... */ }
public sealed class PayPalStrategy(
IPayPalClient paypal,
IOptions<PayPalSettings> opts) : IPaymentStrategy { /* ... */ }
Great Answer Bonus "Validate settings at startup with .ValidateDataAnnotations().ValidateOnStart(). This catches missing API keys before the first request, not when a user tries to pay."
Q23: When should you use Strategy vs a simple Func<T> delegate?
Hard
Think First What capabilities does a full class give you that a lambda doesn't?
Decision matrix:
Use Func<T> when: stateless, no DI dependencies, single method, won't grow in complexity, debugging traceability isn't needed.
Use interface + class when: strategy has its own DI dependencies (ILogger, IHttpClient), needs multiple methods (Process + Validate), requires DI auto-discovery via IEnumerable<T>, needs a Name property for logging/factory resolution, or the algorithm is complex enough to deserve its own test file.
Rule of thumb: if the lambda exceeds 3 lines or captures external state, promote it to a class.
Great Answer Bonus "In hot paths (processing millions of items), Func<T> avoids virtual dispatch overhead. For I/O-bound strategies (API calls, DB queries), the difference is immeasurable — use interfaces for their DI and testability benefits."
Q24: How do you implement feature-flag-driven strategy selection?
Hard
Think First How does LaunchDarkly or Azure App Configuration integrate with strategy resolution?
Create a feature-flag-aware resolver that evaluates flags per-request and returns the appropriate strategy:
public sealed class FeatureFlagCheckoutResolver(
IFeatureManager featureFlags,
IServiceProvider sp) : ICheckoutStrategyResolver
{
public async Task<ICheckoutStrategy> ResolveAsync()
{
if (await featureFlags.IsEnabledAsync("NewCheckoutFlow"))
return sp.GetRequiredKeyedService<ICheckoutStrategy>("StreamlinedV2");
return sp.GetRequiredKeyedService<ICheckoutStrategy>("Classic");
}
}
// Microsoft.FeatureManagement integrates with Azure App Config
// Toggle "NewCheckoutFlow" remotely → strategy switches instantly
// No redeployment, no code changes
Great Answer Bonus "Use Microsoft.FeatureManagement's percentage filters for gradual rollouts: enable the new strategy for 10% of users, monitor metrics, ramp up to 100%. All through configuration, no code changes."
Q25: How do you handle strategy versioning in a microservices environment?
Hard
Think First What happens when Strategy v1 and v2 need to coexist during a rolling deployment?
Use versioned Keyed Services so multiple strategy implementations coexist in the same DI container. Share the strategy contract via a NuGet package so all services reference the same interface:
// Shared contract (distributed via NuGet package)
public interface IPaymentStrategy { Task<PaymentResult> ChargeAsync(Order order); }
// Register both versions side-by-side
services.AddKeyedScoped<IPaymentStrategy, PaymentV1>("payment-v1");
services.AddKeyedScoped<IPaymentStrategy, PaymentV2>("payment-v2");
// Resolve based on API version header or feature flag
public class PaymentStrategyResolver(IServiceProvider sp)
{
public IPaymentStrategy Resolve(string apiVersion) =>
sp.GetRequiredKeyedService<IPaymentStrategy>(
apiVersion == "2" ? "payment-v2" : "payment-v1");
}
Contract sharing: Publish IPaymentStrategy in a shared NuGet package. Both v1 and v2 implement the same interface, so the context doesn't change.
Version resolution: A resolver reads the API version header (or feature flag) and picks the matching keyed service. During rolling deploys, both versions serve traffic simultaneously.
Audit trail: Log which strategy version processed each request. If v2 has a bug, query WHERE strategy_version = 'payment-v2' to find affected orders.
Great Answer Bonus "Version the NuGet package with the strategy contract using SemVer. Minor bumps add optional parameters with defaults so v1 callers keep working. Breaking changes get a new major version and a new interface — IPaymentStrategyV3 — so the old and new can coexist in the same container."
Q26: Design a strategy pattern for retry policies (exponential backoff, linear, circuit breaker).
Hard
Think First How does Polly implement retry strategies under the hood?
public interface IRetryStrategy
{
string Name { get; }
TimeSpan GetDelay(int attemptNumber);
bool ShouldRetry(int attemptNumber, Exception? lastException);
}
public sealed class ExponentialBackoff(int maxRetries = 3, int baseDelayMs = 200) : IRetryStrategy
{
public string Name => "ExponentialBackoff";
public TimeSpan GetDelay(int attempt) =>
TimeSpan.FromMilliseconds(baseDelayMs * Math.Pow(2, attempt - 1));
public bool ShouldRetry(int attempt, Exception? ex) =>
attempt <= maxRetries && ex is not (ArgumentException or ValidationException);
}
public sealed class LinearBackoff(int maxRetries = 5, int delayMs = 500) : IRetryStrategy
{
public string Name => "LinearBackoff";
public TimeSpan GetDelay(int attempt) => TimeSpan.FromMilliseconds(delayMs * attempt);
public bool ShouldRetry(int attempt, Exception? ex) => attempt <= maxRetries;
}
// Context: generic retry executor
public sealed class RetryExecutor(IRetryStrategy strategy, ILogger<RetryExecutor> logger)
{
public async Task<T> ExecuteAsync<T>(Func<Task<T>> action)
{
int attempt = 0;
while (true)
{
try { return await action(); }
catch (Exception ex) when (strategy.ShouldRetry(++attempt, ex))
{
var delay = strategy.GetDelay(attempt);
logger.LogWarning(ex, "Attempt {Attempt} failed. Retrying in {Delay}ms",
attempt, delay.TotalMilliseconds);
await Task.Delay(delay);
}
}
}
}
Great Answer Bonus "In production, use Polly's built-in strategies (which IS the Strategy pattern). This custom example is educational — Polly adds jitter, circuit breaking, and policy wrapping."
Q27: How would you test that all registered strategies are valid at startup?
Hard
Think First How do you catch missing or broken DI registrations before production?
Build a startup validation pipeline that resolves every strategy and verifies it:
// In Program.cs or a hosted service
var app = builder.Build();
// Validate all strategies are resolvable
using var scope = app.Services.CreateScope();
var strategies = scope.ServiceProvider.GetServices<IPaymentStrategy>().ToList();
// Verify expected strategies exist
var expectedTypes = new[] { "CreditCard", "PayPal", "BankTransfer" };
var registeredTypes = strategies.Select(s => s.PaymentType).ToHashSet();
var missing = expectedTypes.Except(registeredTypes).ToList();
if (missing.Any())
throw new InvalidOperationException(
$"Missing payment strategies: {string.Join(", ", missing)}");
// Also validate in CI with a test
[Fact]
public void AllExpectedStrategiesRegistered()
{
var services = new ServiceCollection();
services.AddPaymentModule(); // Your DI extension method
var sp = services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = true,
ValidateOnBuild = true
});
var strategies = sp.GetServices<IPaymentStrategy>().ToList();
Assert.Equal(3, strategies.Count);
}
Great Answer Bonus "Use ValidateOnBuild = true in .NET 8 to catch dependency resolution failures at startup. Combine with ServiceProviderOptions.ValidateScopes to catch lifetime mismatches."
Q28: How do you combine Strategy with Decorator for cross-cutting concerns?
Hard
Think First How do you add logging, metrics, and retry to all strategies without modifying them?
Wrap strategies with decorators that add cross-cutting behavior:
// Logging decorator — wraps ANY strategy
public sealed class LoggingPaymentDecorator(
IPaymentStrategy inner,
ILogger<LoggingPaymentDecorator> logger) : IPaymentStrategy
{
public string PaymentType => inner.PaymentType;
public async Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext ctx)
{
logger.LogInformation("Processing {Amount:C} via {Type}", amount, inner.PaymentType);
var sw = Stopwatch.StartNew();
var result = await inner.ProcessAsync(amount, ctx);
logger.LogInformation("{Type} completed in {Elapsed}ms: {Success}",
inner.PaymentType, sw.ElapsedMilliseconds, result.Success);
return result;
}
}
// Register with Scrutor's Decorate:
builder.Services.AddScoped<IPaymentStrategy, CreditCardStrategy>();
builder.Services.Decorate<IPaymentStrategy, LoggingPaymentDecorator>();
Every strategy automatically gets logging without any strategy knowing about it. Stack multiple decorators for logging + metrics + retry.
Great Answer Bonus "Scrutor's Decorate<TInterface, TDecorator>() makes this trivial. Decorators are applied in registration order, so the outermost decorator (logging) wraps the inner ones (retry, which wraps the real strategy)."
Q29: How would you refactor a 500-line switch statement into Strategy pattern incrementally?
Hard
Think First You can't rewrite 500 lines in one PR without breaking things. What's a safe incremental approach?
Step-by-step safe migration:
Extract interface: Define IStrategy with the method signature matching the switch output. Zero behavior change.
Extract ONE case: Move the simplest switch case into a class implementing the interface. Keep the switch but delegate this case to the new class. Test. Deploy.
Extract remaining cases: One PR per case. Each PR is small, reviewable, testable. The switch shrinks with each PR.
Build factory: Once all cases are classes, replace the switch with a factory/dictionary. The switch is now gone.
Register in DI: Wire strategies via DI. The factory resolves them automatically.
Add assembly scanning: Future strategies auto-register. Zero manual DI lines needed.
Each step is a deployable, reversible PR. If step 3 breaks, revert step 3 — steps 1-2 are still working in production.
Great Answer Bonus "Use the Strangler Fig pattern: the old switch statement gradually shrinks as cases are migrated to strategy classes. The old and new code coexist safely during the transition. I'd start with the case that has the most bugs — it gets the most testing benefit."
29 questions spanning beginner to expert level. The key theme: Strategy is about runtime algorithm selection through polymorphism — not about creating objects (that's Factory) or managing state transitions (that's State).
Section 18
Practice Exercises
Refactor this report service to use Strategy pattern. The switch currently handles PDF, Excel, and CSV generation inline with format-specific logic tangled together.
Define IReportFormatter with a byte[] Generate(ReportData data) method
Create three classes: PdfFormatter, ExcelFormatter, CsvFormatter
Add a FileExtension property so the context can name the output file
The context just calls formatter.Generate() without knowing which format runs
public record ReportData(string Title, List<string[]> Rows);
public interface IReportFormatter
{
string FileExtension { get; }
byte[] Generate(ReportData data);
}
public sealed class PdfFormatter : IReportFormatter
{
public string FileExtension => ".pdf";
public byte[] Generate(ReportData data)
=> PdfLibrary.CreateDocument(data.Title, data.Rows);
}
public sealed class ExcelFormatter : IReportFormatter
{
public string FileExtension => ".xlsx";
public byte[] Generate(ReportData data)
=> ExcelLibrary.CreateWorkbook(data.Title, data.Rows);
}
public sealed class CsvFormatter : IReportFormatter
{
public string FileExtension => ".csv";
public byte[] Generate(ReportData data)
=> Encoding.UTF8.GetBytes(
string.Join("\n", data.Rows.Select(r => string.Join(",", r))));
}
// Context — doesn't know which format runs
public class ReportService(IReportFormatter formatter)
{
public byte[] Export(ReportData data) => formatter.Generate(data);
public string FileName(string name) => $"{name}{formatter.FileExtension}";
}
Build a shipping cost calculator with strategies for FedEx, UPS, and USPS. Each carrier has different pricing rules: FedEx charges by weight, UPS charges by distance, USPS uses a flat rate for packages under 5 lbs.
Define IShippingStrategy with CalculateCost(Package package, Address destination)
Create a Package record with Weight, Dimensions
FedEx: $2.50/lb, UPS: $0.05/mile, USPS: flat $8.95 under 5 lbs, $1.50/lb over
Use a factory to resolve by carrier name
public record Package(double WeightLbs, double LengthIn, double WidthIn, double HeightIn);
public record ShippingQuote(string Carrier, decimal Cost, string EstimatedDays);
public interface IShippingStrategy
{
string Carrier { get; }
ShippingQuote CalculateCost(Package package, double distanceMiles);
}
public sealed class FedExStrategy : IShippingStrategy
{
public string Carrier => "FedEx";
public ShippingQuote CalculateCost(Package pkg, double distance)
=> new(Carrier, Math.Round((decimal)pkg.WeightLbs * 2.50m, 2), "2-3 days");
}
public sealed class UpsStrategy : IShippingStrategy
{
public string Carrier => "UPS";
public ShippingQuote CalculateCost(Package pkg, double distance)
=> new(Carrier, Math.Round((decimal)distance * 0.05m, 2), "3-5 days");
}
public sealed class UspsStrategy : IShippingStrategy
{
public string Carrier => "USPS";
public ShippingQuote CalculateCost(Package pkg, double distance)
{
decimal cost = pkg.WeightLbs <= 5 ? 8.95m : Math.Round((decimal)pkg.WeightLbs * 1.50m, 2);
return new(Carrier, cost, "5-7 days");
}
}
// Factory
public sealed class ShippingFactory(IEnumerable<IShippingStrategy> strategies)
{
private readonly Dictionary<string, IShippingStrategy> _map =
strategies.ToDictionary(s => s.Carrier, StringComparer.OrdinalIgnoreCase);
public IShippingStrategy GetCarrier(string name) =>
_map.TryGetValue(name, out var s) ? s : throw new NotSupportedException(name);
public IEnumerable<ShippingQuote> GetAllQuotes(Package pkg, double distance) =>
_map.Values.Select(s => s.CalculateCost(pkg, distance));
}
Register the payment strategies from Section 5 using .NET 8 Keyed Services. Build an API endpoint that resolves the correct strategy based on a query parameter.
Use builder.Services.AddKeyedScoped<IPaymentStrategy, CreditCardStrategy>("CreditCard")
In the controller, use IServiceProvider.GetRequiredKeyedService<T>(key)
Validate the key before resolving to give a clear 400 error instead of a 500
Add a startup test that resolves each key to verify all registrations
// Program.cs — register with keys
builder.Services.AddKeyedScoped<IPaymentStrategy, CreditCardStrategy>("CreditCard");
builder.Services.AddKeyedScoped<IPaymentStrategy, PayPalStrategy>("PayPal");
builder.Services.AddKeyedScoped<IPaymentStrategy, BitcoinStrategy>("Bitcoin");
// Minimal API endpoint
var validTypes = new HashSet<string>(["CreditCard", "PayPal", "Bitcoin"],
StringComparer.OrdinalIgnoreCase);
app.MapPost("/api/pay", async (
PayRequest request,
IServiceProvider sp) =>
{
if (!validTypes.Contains(request.PaymentType))
return Results.BadRequest($"Unknown payment type: {request.PaymentType}");
var strategy = sp.GetRequiredKeyedService<IPaymentStrategy>(request.PaymentType);
var result = await strategy.ProcessAsync(request.Amount,
new PaymentContext(request.CustomerId, request.Token, request.Email));
return result.Success ? Results.Ok(result) : Results.BadRequest(result);
});
public record PayRequest(string PaymentType, decimal Amount,
string CustomerId, string Token, string? Email);
Build a pricing engine that selects between "Classic" and "Dynamic" pricing strategies based on a feature flag. Classic uses fixed percentage discounts. Dynamic adjusts prices based on demand (time of day, stock levels). The feature flag should support percentage rollout (10% of users get Dynamic).
Use Microsoft.FeatureManagement NuGet package
Define IPricingStrategy with CalculatePrice(Product product, UserContext user)
Create a PricingStrategyResolver that checks the feature flag and resolves the right strategy
Use Keyed Services for the two strategies ("Classic", "Dynamic")
Add a percentage filter in appsettings.json for gradual rollout
Inject IEnumerable<IStrat> → build Dictionary by key
Singleton → stateless only
Scoped → needs DbContext
Transient → has mutable state
⚠ Stateful + Singleton = thread-safety bug
Prefer stateless + Singleton
Enable ValidateScopes
SRP ✓ one algo per class
OCP ✓ new class, zero mods
LSP ⚠ honor the contract!
ISP ✓ one-method interface
DIP ✓ depend on IStrategy
Strategy is the most SOLID-friendly GoF pattern
Section 20
Deep Dive
Before .NET 8, resolving one specific strategy out of many required a manual factory. Keyed Services (Microsoft.Extensions.DependencyInjection 8.0) made this a framework feature. Here's the full picture:
// ─── Registration: each strategy gets a unique key ───
builder.Services.AddKeyedScoped<IPaymentStrategy, CreditCardStrategy>("CreditCard");
builder.Services.AddKeyedScoped<IPaymentStrategy, PayPalStrategy>("PayPal");
builder.Services.AddKeyedScoped<IPaymentStrategy, BitcoinStrategy>("Bitcoin");
// ─── Resolution Option 1: Controller parameter binding ───
[HttpPost("pay")]
public async Task<IActionResult> Pay(
PayRequest req,
[FromKeyedServices("CreditCard")] IPaymentStrategy strategy)
{
var result = await strategy.ProcessAsync(req.Amount, req.Context);
return result.Success ? Ok(result) : BadRequest(result);
}
// ─── Resolution Option 2: Dynamic key from request ───
[HttpPost("pay")]
public async Task<IActionResult> Pay(PayRequest req, IServiceProvider sp)
{
var strategy = sp.GetRequiredKeyedService<IPaymentStrategy>(req.PaymentType);
var result = await strategy.ProcessAsync(req.Amount, req.Context);
return result.Success ? Ok(result) : BadRequest(result);
}
// ─── Resolution Option 3: Inject into constructor via [FromKeyedServices] ───
public class CheckoutService(
[FromKeyedServices("CreditCard")] IPaymentStrategy creditCard,
[FromKeyedServices("PayPal")] IPaymentStrategy paypal)
{
// Use specific strategies by name
}
// ─── Enum-based keys for type safety ───
public enum PaymentType { CreditCard, PayPal, Bitcoin }
builder.Services.AddKeyedScoped<IPaymentStrategy, CreditCardStrategy>(PaymentType.CreditCard);
var strategy = sp.GetRequiredKeyedService<IPaymentStrategy>(PaymentType.CreditCard);
When You Still Need a Factory
Keyed Services replaces simple key-to-strategy mapping. You still need a custom factory when: the key requires transformation (user sends "card", factory maps to "CreditCard"), you need validation before resolution, or you want to enumerate all available strategies for a dropdown. Keyed Services + thin factory is the modern sweet spot.
Strategy and FactoryThe Factory pattern encapsulates object creation logic. In the Strategy context, a factory decides which ConcreteStrategy to instantiate based on a key, configuration, or runtime condition. Strategy + Factory together eliminate both the "how to create it" and "how to use it" switch statements. are natural partners. Strategy defines what each algorithm does. Factory decides which strategy to create based on runtime context. Together they eliminate both the "which algorithm?" and "how to pick it?" questions from the Context.
// Strategy interface
public interface ICompressionStrategy
{
string Extension { get; }
byte[] Compress(byte[] data);
byte[] Decompress(byte[] data);
}
// Concrete strategies
public sealed class GzipStrategy : ICompressionStrategy { /* ... */ }
public sealed class BrotliStrategy : ICompressionStrategy { /* ... */ }
public sealed class ZstdStrategy : ICompressionStrategy { /* ... */ }
// Factory — encapsulates the selection logic
public sealed class CompressionFactory(IEnumerable<ICompressionStrategy> strategies)
{
private readonly Dictionary<string, ICompressionStrategy> _map =
strategies.ToDictionary(s => s.Extension, StringComparer.OrdinalIgnoreCase);
// Select by file type
public ICompressionStrategy ForFile(string fileName)
{
string ext = Path.GetExtension(fileName);
return ext switch
{
".log" or ".txt" => _map["gz"], // Text compresses well with gzip
".html" or ".css" => _map["br"], // Brotli for web assets
_ => _map["zst"] // Zstd for general data
};
}
// Select by target bandwidth
public ICompressionStrategy ForBandwidth(int kbps)
=> kbps < 1000 ? _map["br"] : _map["gz"]; // Brotli for slow connections
}
// Context doesn't know about selection OR algorithm
public class FileUploadService(CompressionFactory factory)
{
public async Task UploadAsync(string fileName, byte[] data)
{
var compressor = factory.ForFile(fileName);
byte[] compressed = compressor.Compress(data);
await SaveAsync($"{fileName}.{compressor.Extension}", compressed);
}
}
Not every strategy needs a full interface and class. When the algorithm is stateless, has no DI dependencies, and fits in one expression, a Func<T> delegate is the perfect lightweight strategy.
// ─── Func<T> as Strategy — zero boilerplate ───
// Sorting strategies
Func<IEnumerable<Product>, IOrderedEnumerable<Product>> byPrice =
products => products.OrderBy(p => p.Price);
Func<IEnumerable<Product>, IOrderedEnumerable<Product>> byRating =
products => products.OrderByDescending(p => p.Rating);
Func<IEnumerable<Product>, IOrderedEnumerable<Product>> byNewest =
products => products.OrderByDescending(p => p.CreatedAt);
// Context uses Func — no interface needed
public class ProductCatalog
{
public List<Product> GetProducts(
Func<IEnumerable<Product>, IOrderedEnumerable<Product>> sortStrategy)
{
var products = _repo.GetAll();
return sortStrategy(products).ToList();
}
}
// ─── Strategy dictionary with Func<T> ───
var formatters = new Dictionary<string, Func<decimal, string>>
{
["USD"] = amount => $"${amount:N2}",
["EUR"] = amount => $"€{amount:N2}",
["GBP"] = amount => $"£{amount:N2}",
["JPY"] = amount => $"¥{amount:N0}" // No decimals for yen
};
string formatted = formatters["EUR"](1234.56m); // "€1,234.56"
// ─── When to PROMOTE to interface ───
// ✗ Func is enough: stateless, one-liner, no DI, no naming needed
// ✓ Promote to interface when:
// - Strategy needs IHttpClient, ILogger, IOptions
// - You need IEnumerable<T> auto-discovery in DI
// - Multiple methods are needed (Process + Validate)
// - You want a named type in stack traces and logs
Section 21
Mini-Project
Let's build a Notification Engine — from a naive switch-based approach to a production-grade, DI-resolved, Keyed Services implementation.
Note: This mini-project mirrors a real-world refactoring journeyMost teams don't start with Strategy pattern. They start with a switch statement, it grows to 300+ lines, bugs multiply, testing becomes impossible, and then they refactor to Strategy. Understanding this evolution helps you recognize when it's time to refactor in your own codebase.. Many teams start with Attempt 1 and gradually evolve to Attempt 3 as requirements grow.
// ATTEMPT 1: Everything in one class
public class NotificationService
{
public async Task SendAsync(string channel, string userId, string message)
{
switch (channel.ToLower())
{
case "email":
using (var smtp = new SmtpClient("smtp.company.com", 587))
{
smtp.Credentials = new NetworkCredential("noreply@company.com", "password123");
await smtp.SendMailAsync("noreply@company.com", userId, "Notification", message);
}
break;
case "sms":
var http = new HttpClient(); // ❌ new HttpClient per call
http.DefaultRequestHeaders.Add("Authorization", "Bearer twilio_token_hardcoded");
await http.PostAsJsonAsync("https://api.twilio.com/send",
new { to = userId, body = message });
break;
case "push":
var firebase = new HttpClient(); // ❌ another new HttpClient
firebase.DefaultRequestHeaders.Add("Authorization", "key=firebase_key_hardcoded");
await firebase.PostAsJsonAsync("https://fcm.googleapis.com/fcm/send",
new { to = userId, notification = new { body = message } });
break;
default:
throw new ArgumentException($"Unknown channel: {channel}");
}
}
}
5 Critical Problems
1. API keys hardcoded in source code 2.new HttpClient() per call — connection pool exhaustion under load 3. Can't test email without SMTP server, can't test SMS without Twilio 4. Adding Slack = modifying this method (OCP violation) 5. All channels coupled — changing SMS logic risks breaking email
// ATTEMPT 2: Strategy interface + manual factory
public interface INotificationChannel
{
string Channel { get; }
Task SendAsync(string userId, string message);
}
public sealed class EmailChannel(ISmtpClient smtp) : INotificationChannel
{
public string Channel => "email";
public async Task SendAsync(string userId, string message)
=> await smtp.SendAsync(userId, "Notification", message);
}
public sealed class SmsChannel(IHttpClientFactory httpFactory,
IOptions<TwilioSettings> opts) : INotificationChannel
{
public string Channel => "sms";
public async Task SendAsync(string userId, string message)
{
var client = httpFactory.CreateClient("Twilio");
await client.PostAsJsonAsync("/send", new { to = userId, body = message });
}
}
// Manual factory
public class NotificationFactory
{
private readonly Dictionary<string, INotificationChannel> _channels;
public NotificationFactory(IEnumerable<INotificationChannel> channels)
=> _channels = channels.ToDictionary(c => c.Channel, StringComparer.OrdinalIgnoreCase);
public INotificationChannel Get(string channel)
=> _channels.TryGetValue(channel, out var c) ? c
: throw new NotSupportedException($"Channel '{channel}' not registered");
}
// Service
public class NotificationService(NotificationFactory factory)
{
public async Task SendAsync(string channel, string userId, string message)
{
var handler = factory.Get(channel);
await handler.SendAsync(userId, message);
}
}
Remaining Issues
1. Manual factory boilerplate — .NET 8 Keyed Services can replace this 2. No retry/resilience — a failed SMS silently disappears 3. No multi-channel support (send via email AND push simultaneously)
...
public interface INotificationChannel
{
string Channel { get; }
Task<NotificationResult> SendAsync(string userId, string message,
CancellationToken ct = default);
}
public record NotificationResult(bool Success, string? Error = null)
{
public static NotificationResult Ok() => new(true);
public static NotificationResult Failed(string error) => new(false, error);
}public sealed class EmailChannel(
ISmtpClient smtp,
IOptions<EmailSettings> opts,
ILogger<EmailChannel> logger) : INotificationChannel
{
public string Channel => "email";
public async Task<NotificationResult> SendAsync(
string userId, string message, CancellationToken ct = default)
{
try
{
logger.LogInformation("Sending email to {UserId}", userId);
await smtp.SendAsync(
from: opts.Value.FromAddress,
to: userId,
subject: "Notification",
body: message,
ct);
return NotificationResult.Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "Email to {UserId} failed", userId);
return NotificationResult.Failed(ex.Message);
}
}
}
// SmsChannel and PushChannel follow the same patternpublic sealed class NotificationService(
IServiceProvider sp,
ILogger<NotificationService> logger)
{
// Send via a single channel
public async Task<NotificationResult> SendAsync(
string channel, string userId, string message, CancellationToken ct = default)
{
var handler = sp.GetRequiredKeyedService<INotificationChannel>(channel);
return await handler.SendAsync(userId, message, ct);
}
// Send via multiple channels simultaneously
public async Task<Dictionary<string, NotificationResult>> SendMultiAsync(
IEnumerable<string> channels, string userId, string message,
CancellationToken ct = default)
{
var tasks = channels.Select(async ch =>
{
var handler = sp.GetRequiredKeyedService<INotificationChannel>(ch);
var result = await handler.SendAsync(userId, message, ct);
return (Channel: ch, Result: result);
});
var results = await Task.WhenAll(tasks);
return results.ToDictionary(r => r.Channel, r => r.Result);
}
}// Register channels with Keyed Services
builder.Services.AddKeyedScoped<INotificationChannel, EmailChannel>("email");
builder.Services.AddKeyedScoped<INotificationChannel, SmsChannel>("sms");
builder.Services.AddKeyedScoped<INotificationChannel, PushChannel>("push");
// Register the notification service
builder.Services.AddScoped<NotificationService>();
// Configure per-channel settings
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("Email"));
builder.Services.Configure<TwilioSettings>(builder.Configuration.GetSection("Twilio"));
builder.Services.Configure<FirebaseSettings>(builder.Configuration.GetSection("Firebase"));
// Adding Slack? ONE new class + ONE line:
// builder.Services.AddKeyedScoped<INotificationChannel, SlackChannel>("slack");[Fact]
public async Task EmailChannel_SuccessfulSend_ReturnsOk()
{
var smtp = new Mock<ISmtpClient>();
var opts = Options.Create(new EmailSettings { FromAddress = "test@co.com" });
var channel = new EmailChannel(smtp.Object, opts, NullLogger<EmailChannel>.Instance);
var result = await channel.SendAsync("user@test.com", "Hello");
Assert.True(result.Success);
smtp.Verify(s => s.SendAsync("test@co.com", "user@test.com",
"Notification", "Hello", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task EmailChannel_SmtpFailure_ReturnsError()
{
var smtp = new Mock<ISmtpClient>();
smtp.Setup(s => s.SendAsync(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new SmtpException("Connection refused"));
var opts = Options.Create(new EmailSettings { FromAddress = "test@co.com" });
var channel = new EmailChannel(smtp.Object, opts, NullLogger<EmailChannel>.Instance);
var result = await channel.SendAsync("user@test.com", "Hello");
Assert.False(result.Success);
Assert.Contains("Connection refused", result.Error);
}
Production Ready
Each channel is independently testable, has its own configuration, handles errors gracefully, and supports cancellation. Adding new channels requires zero modifications to existing code. Multi-channel sending is built-in.
Three iterations took a payment processor from a 200-line switch statement to a clean, DI-wired Strategy implementation. Each iteration fixed one architectural problem: coupling, testability, and extensibility.
Section 22
Migration Guide
Got a big switch statement that keeps growing every time you add a new option? Here's how to refactor it into the Strategy pattern, one safe step at a time. Each step can be deployed independently — no big risky rewrite needed.
// BEFORE: all logic in one switch statement
public async Task<Result> Process(string type, Data data)
{
switch (type) { case "A": /* 50 lines */ case "B": /* 80 lines */ }
}
// STEP 1: Define the strategy interface matching the switch method signature
public interface IProcessingStrategy
{
string Type { get; }
Task<Result> ProcessAsync(Data data);
}
// The switch statement still works — zero behavior change yet
Risk: Zero. Adding an interface file changes nothing about the existing code.
// STEP 2: Move ONE switch case into a strategy class
public sealed class TypeAStrategy : IProcessingStrategy
{
public string Type => "A";
public async Task<Result> ProcessAsync(Data data)
{
// Exact same code from the switch case "A"
// Copy-paste, then clean up
}
}
// Switch delegates to the strategy for "A", keeps others inline
public async Task<Result> Process(string type, Data data)
{
if (type == "A")
return await new TypeAStrategy().ProcessAsync(data); // Delegate
switch (type) { case "B": /* still inline */ }
}
Risk: Minimal. TypeAStrategy has its own unit tests. The switch still handles all other cases.
...
// STEP 3: One PR per case → all cases become strategy classes
public sealed class TypeBStrategy : IProcessingStrategy { /* ... */ }
public sealed class TypeCStrategy : IProcessingStrategy { /* ... */ }
// The switch is now just delegation
public async Task<Result> Process(string type, Data data)
{
IProcessingStrategy strategy = type switch
{
"A" => new TypeAStrategy(),
"B" => new TypeBStrategy(),
"C" => new TypeCStrategy(),
_ => throw new NotSupportedException(type)
};
return await strategy.ProcessAsync(data);
}
Risk: Low. Each strategy is tested individually. The switch is now a simple mapping.
// STEP 4: Register in DI → factory resolves → switch disappears
builder.Services.AddKeyedScoped<IProcessingStrategy, TypeAStrategy>("A");
builder.Services.AddKeyedScoped<IProcessingStrategy, TypeBStrategy>("B");
builder.Services.AddKeyedScoped<IProcessingStrategy, TypeCStrategy>("C");
// The Context is now clean
public class ProcessingService(IServiceProvider sp)
{
public async Task<Result> ProcessAsync(string type, Data data)
{
var strategy = sp.GetRequiredKeyedService<IProcessingStrategy>(type);
return await strategy.ProcessAsync(data);
}
}
// ✅ Switch statement is gone
// ✅ Adding Type D: one new class + one DI line
// ✅ Each strategy tested independently with its own dependencies
Risk: Low if previous steps were deployed and validated. The final PR just changes the wiring.
Section 23
Code Review Checklist
#
Check
Why It Matters
Red Flag
1
Strategy interface has a focused API (ideally 1 method)
Fat interfaces force NotImplementedExceptionWhen a strategy throws NotImplementedException, it's lying about its capabilities. The interface promises it can Refund(), but Bitcoin can't. This violates LSP — callers can't trust the interface contract. Split the interface per ISP instead. in some strategies
Same switch for strategy selection in multiple controllers
5
All strategies are registered in DI
Missing registration = runtime NullReference or NotSupported
New strategy class without DI registration line
6
Each strategy has its own unit tests
Testing only the Context misses strategy-specific bugs
Strategy class with 0% test coverage
7
Strategy classes are sealed
Enables JIT devirtualization and prevents accidental inheritance
public class MyStrategy without sealed
8
Null Object used instead of null returns from factory
NullReferenceException when no strategy matches
factory.Get(key) returning null
9
Each strategy validates its own inputs
Context can't know what each strategy requires
No validation in strategy; relying on Context to validate
10
No NotImplementedException in any strategy method
Violates LSP — the strategy lies about its capabilities
throw new NotImplementedException()
Quick Command: Run dotnet build --warnaserror after enabling Roslyn analyzersCode analysis tools built into the .NET compiler. Enable with <EnableNETAnalyzers>true</EnableNETAnalyzers> in your .csproj. Rules like CA1062 (validate parameters), CA1812 (avoid uninstantiated classes), and CA1040 (avoid empty interfaces) help catch Strategy pattern issues at compile time. to catch many Strategy pattern issues at compile time. Also enable ValidateOnBuild = true in your ServiceProviderOptions to catch DI registration errors at startup.
Section 24
Related Topics / What to Study Next
Senior Solution: Open/Closed Principle
A senior extracts each payment type into its own class implementing IPaymentStrategy. Adding Apple Pay means writing one new class — the switch statement doesn't exist anymore. No existing code is touched, tested code stays tested, and merge conflicts disappear.
// Adding Apple Pay — ONE new file, ZERO modifications elsewhere
public sealed class ApplePayStrategy(IApplePayClient client) : IPaymentStrategy
{
public string PaymentType => "ApplePay";
public async Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext ctx)
{
var result = await client.AuthorizeAsync(amount, ctx.Token);
return result.IsApproved
? new PaymentResult(true, result.TransactionId)
: new PaymentResult(false, "", result.Error);
}
}
// DI: one line added
builder.Services.AddScoped<IPaymentStrategy, ApplePayStrategy>();
// Factory auto-discovers via IEnumerable — zero factory changes
// Controller — zero changes. Existing strategies — zero changes.
Senior Solution: Testability
Each strategy is its own class with constructor-injected dependencies. Mock the dependencies, test the strategy in complete isolation. No Stripe test keys needed for PayPal tests. No SMTP server needed for credit card tests.
// Test CreditCard in isolation — no PayPal, no BankTransfer involved
[Fact]
public async Task CreditCard_SuccessfulCharge_ReturnsTransactionId()
{
var stripeClient = new Mock<IStripeClient>();
stripeClient.Setup(s => s.ChargeAsync(It.IsAny<ChargeRequest>()))
.ReturnsAsync(new ChargeResponse { Status = "succeeded", Id = "ch_123" });
var strategy = new CreditCardStrategy(stripeClient.Object,
Options.Create(new StripeSettings()), NullLogger<CreditCardStrategy>.Instance);
var result = await strategy.ProcessAsync(99.99m, new PaymentContext("cust", "tok"));
Assert.True(result.Success);
Assert.Equal("ch_123", result.TransactionId);
}
// Each strategy gets its own test class — complete isolation
Senior Solution: Dependency Injection
Each strategy receives its dependencies through constructor injection. API keys come from IOptions<T>, HTTP clients from IHttpClientFactory, logging from ILogger<T>. No hardcoded strings, no new HttpClient(), no secrets in source code.
// Strategy gets dependencies injected — clean, testable, configurable
public sealed class PayPalStrategy(
IHttpClientFactory httpFactory, // Manages connection pooling
IOptions<PayPalSettings> settings, // Config from appsettings.json
ILogger<PayPalStrategy> logger) // Structured logging
: IPaymentStrategy
{
public async Task<PaymentResult> ProcessAsync(decimal amount, PaymentContext ctx)
{
var client = httpFactory.CreateClient("PayPal");
// IOptions reads from appsettings.json → no hardcoded secrets
// IHttpClientFactory manages pooling → no connection exhaustion
// ILogger → structured, configurable logging
}
}
// appsettings.json (secrets in Azure Key Vault / User Secrets)
// "PayPal": { "ClientId": "...", "Secret": "...", "BaseUrl": "..." }