GoF Behavioral Pattern

Strategy Pattern

Same problem, different ways to solve it — and you can swap them without changing anything else. That's Strategy.

29 Q&As 6 Bug Studies 10 Pitfalls 4 Testing Strategies C# / .NET
Section 1

TL;DR

  • 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.
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 WorldWhat it representsIn code
Google Maps appThe thing that uses a strategyContext class
"Calculate route" buttonThe shared action all strategies performIRouteStrategy.CalculateRoute()
Fastest / Shortest / ScenicDifferent approaches to the same problemConcrete strategy classes
User picks route typeDecision made at runtimeDI or factory resolves the right strategy
Adding "Eco-friendly" routeNew approach, zero changes elsewhereNew class, same interface
User picks route type Google Maps (Context) FastestRoute ShortestRoute ScenicRoute Directions (result)
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
Strategy Pattern UML — Context holds IStrategy, delegates to ConcreteStrategyA, B, or C Context - strategy: IStrategy + Execute() strategy.Run() IStrategy (dashed = dependency) --> uses «interface» IStrategy + Run(data): Result ConcreteStrategyA + Run(data): Result ConcreteStrategyB + Run(data): Result IStrategy (implements) --> implements implements dependency (uses) implements

Sequence Diagram — How the Parts Interact at Runtime

Strategy-Sequence
Client Context ConcreteStrategyA setStrategy(concreteA) executeStrategy() algorithm() result result
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.

User Request Controller resolve by key «interface» IPaymentStrategy CreditCard Strategy .ProcessAsync() Stripe API Result Payment The Controller never imports CreditCardStrategy — DI resolves it by key at runtime 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

1994 GoF Book 2002 Delegates 2007 LINQ 2016 .NET Core DI 2023 Keyed Services

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:

Strategy Pattern in the .NET Ecosystem COMPARISON — swappable sorting & equality logic IComparer<T> IEqualityComparer<T> StringComparer FRAMEWORK STRATEGIES — pluggable behavior in ASP.NET Core IAuthorizationHandler IValidator<T> IOutputFormatter INLINE / LAMBDA — lightweight strategies via delegates Func<T, bool> Action<T> Predicate<T> Context picks a strategy LEGEND Data operations Middleware/pipeline LINQ & callbacks

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

Multiple ways to do the same thing? NO No Strategy needed YES Selected at runtime? NO Simple if/else or compile-time polymorphism YES Behavior tied to internal state? YES State pattern (not Strategy) NO How many algorithms? 2 fixed Simple conditional 3+ or growing Use Strategy pattern ✓
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 Client picks Algo A Algo B Algo C composition client chooses State Object transitions State A State B State C auto auto internal transitions Template Method BaseClass defines skeleton Step1() Step2() Step3() inheritance overrides specific steps Swap whole algorithms vs. transition states vs. vary steps in a skeleton 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:

PrincipleRelationExplanation
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."

DI Container CreditCard ✓ PayPal ✓ injects Factory Dictionary "CreditCard" → ✓ "PayPal" → ✓ BankTransferStrategy Class exists in codebase NOT registered in DI! User selects "Bank Transfer" at checkout factory.GetStrategy ("BankTransfer") NotSupportedException → 500 // ❌ 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%.

Singleton Strategy (1 instance) _discountPercentage = ??? Thread A: Gold Customer 1. Configure → writes 20 Thread B: Basic Customer 2. Configure → writes 0 3. Calculate(100) Reads 0 (Thread B's value!) 100 * 0/100 = $0 discount Gold customer gets $0 discount instead of $20! // ❌ STATEFUL strategy registered as Singleton public class LoyaltyDiscountStrategy : IDiscountStrategy { private decimal _discountPercentage; // ❌ Mutable state! public void Configure(Customer customer) { _discountPercentage = customer.LoyaltyTier switch { "Gold" => 20m, "Silver" => 10m, _ => 0m }; } public decimal Calculate(decimal subtotal) => subtotal * (_discountPercentage / 100m); } // ❌ Registered as Singleton — shared across ALL requests builder.Services.AddSingleton<IDiscountStrategy, LoyaltyDiscountStrategy>(); // Thread A: Configure(goldCustomer) → _discountPercentage = 20 // Thread B: Configure(basicCustomer) → _discountPercentage = 0 // Thread A: Calculate(100) → 100 * 0/100 = $0 ← WRONG! Gold customer got no discount

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.

IPaymentStrategy (fat) ProcessAsync() ✓ RefundAsync() ✗ for Bitcoin VoidAsync() ✗ for Bitcoin CaptureAsync() ✗ for Bitcoin CreditCardStrategy All 4 methods work ✓ BitcoinStrategy 3 of 4 throw exceptions! PayPalStrategy All 4 methods work ✓ Agent clicks "Refund" on Bitcoin order NotImplementedException → 500 error // ❌ 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").

Discount Factory GetStrategy(loyaltyTier) "Gold" → 20% discount ✓ null tier → returns null PricingEngine: discount.Calculate() NullReferenceException! // ❌ 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 in 3 places Web Controller CreditCard ✓ PayPal ✓ ApplePay ✓ (updated) switch #1 Mobile Controller CreditCard ✓ PayPal ✓ ApplePay ✗ MISSING! switch #2 Admin Controller CreditCard ✓ PayPal ✓ ApplePay ✓ (updated) switch #3 Mobile user picks ApplePay "Unknown payment type" → 500 // ❌ 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.

.NET DI: "Last Registration Wins" DI Registrations (Program.cs) 1st: AddScoped → CreditCardStrategy 2nd: AddScoped → PayPalStrategy ← LAST Resolve Single sp.GetService<IPayment>() Always → PayPalStrategy Resolve All IEnumerable<T> Gets both ✓ User picks "Credit Card" at checkout Gets PayPalStrategy anyway! No error, no warning. Silent. Stripe: $0 | PayPal: 2x revenue | Accounting: panic // ❌ 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 single IStrategy 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.

Over-Engineered IDiscountStrategy PremiumDiscount NoDiscount DiscountFactory DI Registration Tests x 3 Just Right isPremium ? 10 : 0 1 line, 0 files, 0 classes

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(...);

Fat Interface IPaymentStrategy Process() · Refund() · Void() Capture() · GetStatus() · SendReceipt() Bitcoin throws NotImpl x4 Focused Interfaces IPaymentProcessor IRefundable IVoidable Bitcoin only IPaymentProcessor

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.

Leaky Context CheckoutService is CreditCard? is PayPal? CreditCardStrategy PayPalStrategy Clean Context CheckoutService IPaymentStrategy

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.

Eager (all created) CreditCard PayPal Bitcoin All 3 instantiated + their deps Only CreditCard is actually used Wasted: Stripe + PayPal + Blockchain clients Lazy / Keyed (one created) CreditCard PayPal Bitcoin Only needed strategy resolved Zero wasted allocations

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.

3 Files IStringFormatter.cs UpperCaseFormatter.cs LowerCaseFormatter.cs Each: s => s.ToUpper() / s.ToLower() 1 Line Func<string, string> s => s.ToUpper() Zero files, zero ceremony

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.

No Validation Context PayPal email = null {"error": "invalid_request"} Cryptic API error to the user Strategy Validates Context PayPal email != null? has @? "Valid email required for PayPal" Clear, actionable message

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.

Stateful (Mutable Fields) ShippingStrategy _destination = ... _weight = ... Thread A sets fields Thread B overwrites them → wrong result! Stateless (Parameters) ShippingStrategy Calculate(dest, weight) → result No fields. Pure function. Thread-safe. Same input → same output, always

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.

Scattered (5 switch statements) CheckoutCtrl RefundCtrl ReportCtrl switch(type)... switch(type)... switch(type)... WebhookCtrl AdminCtrl switch(type)... switch(type)... New strategy = update 5 places! Centralized (1 factory) PaymentFactory key → IPaymentStrategy Credit PayPal Bitcoin New strategy = update 1 place

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.

1 Integration Test Checkout_Works() Controller → Factory → CreditCard → API mock PayPal, Bitcoin: untested Fails → which layer broke? ? 3 Isolated Test Suites Strategy each algo Factory key mapping Context mock strategy Fails → exact layer identified Credit PayPal Bitcoin All strategies tested individually

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.

Strategy (chosen once) User picks "PayPal" at checkout stays fixed for the request PayPalStrategy (fixed) This is Strategy State (transitions over time) Pending Processing Shipped Object transitions through stages based on internal condition changes This is State
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:

ConcernImpactRecommendation
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); } 0.5ns 1.0ns 1.5ns 2.0ns Direct ~0.5ns Interface ~1.8ns Func ~1.2ns Difference is negligible — pick based on design, not nanoseconds
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

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.

Before: Switch Statement case "CreditCard": ... 50 lines case "PayPal": ... 60 lines case "Bitcoin": ... 45 lines case "ApplePay": ... ← modify! case "GooglePay": ... ← modify! 500 lines, all tangled together Every new case = modify existing code Refactor After: Strategy Pattern Context: 20 lines CreditCardStrategy PayPalStrategy BitcoinStrategy NEW: ApplePayStrat Each class: 40-60 lines, isolated New case = new file, zero changes
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."
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.

Context CheckoutService has-a IPaymentStrategy delegates to «interface» IPaymentStrategy ProcessAsync(amount, ctx) CreditCardStrategy calls Stripe API PayPalStrategy calls PayPal API BitcoinStrategy calls blockchain node Context doesn't know which strategy it holds
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."
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."
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."
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."
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

Think First Think about .NET 8 Keyed Services, factory pattern, and IEnumerable injection.

Three main approaches in modern .NET:

  1. Keyed Services (.NET 8+): Register with AddKeyedScoped<IStrategy, ConcreteA>("keyA"), resolve with [FromKeyedServices("keyA")] or sp.GetKeyedService<T>(key).
  2. Factory + IEnumerable: Register all strategies, inject IEnumerable<IStrategy> into a factory, build a dictionary by key. Factory method returns the matching strategy.
  3. 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."
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.
Strategy Pattern Client picks once, stays fixed User picks "PayPal" PayPalStrategy Same strategy for entire request No transitions, no state changes Who changes it? The CLIENT State Pattern Object transitions through stages Pending Processing Shipped Behavior changes at each stage States know about each other Who changes it? The OBJECT itself

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."
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).

Adding Google Pay — What changes? CheckoutService No changes ✓ CreditCardStrategy No changes ✓ PayPalStrategy No changes ✓ Factory / Tests No changes ✓ Existing code: CLOSED for modification (untouched) NEW: GooglePayStrategy implements IPaymentStrategy NEW: 1 DI registration line AddKeyedScoped("GooglePay") New code: OPEN for extension (new files only)

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."
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)."
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."
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.

Without Null Object Guest user returns null NRE! With Null Object Guest user NoDiscount() Calculate() → $0, safe ✓ Context never needs null checks — strategy is always a valid object

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."
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.

Strategy (Composition) Context has-a IStrategy IPaymentStrategy ProcessAsync() CreditCardStrategy PayPalStrategy Entire algorithm replaced Classes are independent Template Method (Inheritance) abstract ReportGenerator Generate() { Header(); Body(); Footer(); } abstract Body(); // only this varies PdfReport : base ExcelReport : base Only steps overridden Skeleton shared via 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."
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."
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.

Incoming User hits /checkout Strategy Resolver checks user.AbGroup or feature flag % Group A (50%) ClassicCheckoutFlow 3-step checkout (current) Group B (50%) StreamlinedCheckoutFlow 1-page checkout (experiment) Same CheckoutController — identical code for both groups Metrics captured per-strategy: conversion rate, avg time, errors

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."
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."
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."
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

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."
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>())); PricingEngine (Context) sees 1 CompositeDiscount : IDiscountStrategy Calculate() = sum of all child discounts PercentageDiscount (10%) LoyaltyDiscount ($5 off) CouponDiscount ($20 off) Context sees ONE strategy. Composite delegates to MANY. Same interface, transparent composition.

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."
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."
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."
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."
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."
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."
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."
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."
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)."
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:

  1. Extract interface: Define IStrategy with the method signature matching the switch output. Zero behavior change.
  2. 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.
  3. Extract remaining cases: One PR per case. Each PR is small, reviewable, testable. The switch shrinks with each PR.
  4. Build factory: Once all cases are classes, replace the switch with a factory/dictionary. The switch is now gone.
  5. Register in DI: Wire strategies via DI. The factory resolves them automatically.
  6. Add assembly scanning: Future strategies auto-register. Zero manual DI lines needed.
1 Extract interface 500-line switch 2 1st case → class 400-line switch 3 More cases 1 PR each shrinking switch... 4 Add factory switch gone! 5 Wire DI + scanning Done! Clean Strategy Each step is a deployable, reversible PR. Revert any step without breaking earlier ones.

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
public interface IPricingStrategy { decimal CalculatePrice(Product product, UserContext user); } public sealed class ClassicPricing : IPricingStrategy { public decimal CalculatePrice(Product product, UserContext user) { decimal discount = user.IsPremium ? 0.10m : 0m; return product.BasePrice * (1 - discount); } } public sealed class DynamicPricing(IStockService stock) : IPricingStrategy { public decimal CalculatePrice(Product product, UserContext user) { var level = stock.GetStockLevel(product.Id); decimal demandMultiplier = level switch { < 10 => 1.15m, // Low stock = 15% premium < 50 => 1.0m, // Normal stock = base price _ => 0.90m // Overstock = 10% discount }; decimal loyaltyDiscount = user.IsPremium ? 0.05m : 0m; return product.BasePrice * demandMultiplier * (1 - loyaltyDiscount); } } public sealed class PricingResolver( IFeatureManager features, IServiceProvider sp) { public async Task<IPricingStrategy> ResolveAsync() { string key = await features.IsEnabledAsync("DynamicPricing") ? "Dynamic" : "Classic"; return sp.GetRequiredKeyedService<IPricingStrategy>(key); } } // Program.cs builder.Services.AddFeatureManagement(); builder.Services.AddKeyedScoped<IPricingStrategy, ClassicPricing>("Classic"); builder.Services.AddKeyedScoped<IPricingStrategy, DynamicPricing>("Dynamic"); builder.Services.AddScoped<PricingResolver>(); // appsettings.json for 10% rollout: // "FeatureManagement": { // "DynamicPricing": { // "EnabledFor": [{ "Name": "Percentage", "Parameters": { "Value": 10 } }] // } // }
Section 19

Cheat Sheet

  • 3+ algorithms for same task
  • Runtime selection needed
  • Each algo has own dependencies
  • Want to test independently
  • Eliminate growing switch/if
  • Only 2 simple options
  • Algorithm never changes
  • Trivial one-liner → use Func<T>
  • Behavior changes by state → use State pattern
  • Algorithms share 80% logic → use Template Method
  • Context → holds IStrategy ref → delegates execution
  • IStrategy (interface) → one focused method
  • ConcreteStrategy (classes) → each implements one algo
  • Keyed (.NET 8+): AddKeyedScoped<IStrat, A>("a")
  • [FromKeyedServices("a")]
  • Factory pattern: AddScoped<IStrat, A>(); AddScoped<IStrat, B>;
  • 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 Switch Statement Coupled, untestable Attempt 2 Interface + Factory Better, but manual wiring Attempt 3 DI + Keyed Services Production-ready // 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 pattern public 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 PaymentService Stripe API PayPal API Crypto API tangled Refactor AFTER Payment Service «interface» IPayment Stripe PayPal Crypto // 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

    #CheckWhy It MattersRed Flag
    1Strategy 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 strategiesinterface IStrategy { Process(); Refund(); Void(); Capture(); }
    2Context depends on interface, not concrete strategyConcrete dependency defeats the pattern's purposenew CreditCardStrategy() in the Context
    3Strategies are stateless (or registered with correct lifetime)Stateful Singleton strategies cause thread-safety bugsMutable field in a Singleton strategy
    4Strategy selection is centralized (factory or Keyed Services)Duplicated selection logic causes inconsistency bugsSame switch for strategy selection in multiple controllers
    5All strategies are registered in DIMissing registration = runtime NullReference or NotSupportedNew strategy class without DI registration line
    6Each strategy has its own unit testsTesting only the Context misses strategy-specific bugsStrategy class with 0% test coverage
    7Strategy classes are sealedEnables JIT devirtualization and prevents accidental inheritancepublic class MyStrategy without sealed
    8Null Object used instead of null returns from factoryNullReferenceException when no strategy matchesfactory.Get(key) returning null
    9Each strategy validates its own inputsContext can't know what each strategy requiresNo validation in strategy; relying on Context to validate
    10No NotImplementedException in any strategy methodViolates LSP — the strategy lies about its capabilitiesthrow 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.