Mert Özen Further With Every Line
Software Design Design Patterns — #21

Strategy Pattern in Depth: Keyed Services, IEnumerable<IStrategy> and Func for Runtime Selection

Mert Özen Nov 7, 2026 12 min 4 views
Strategy Pattern in Depth: Keyed Services, IEnumerable<IStrategy> and Func for Runtime Selection

Writing a strategy is easy; choosing the right one at runtime is the hard part: keyed services select by a single key, IEnumerable<IStrategy> with CanHandle looks at several properties of the object, and Dictionary<string, Func<>> selects with the least ceremony when formulas have no dependencies. How registration order silently changes prices, why keyed registrations don't show up in GetServices, and IComparer and StringComparer as strategies in the BCL.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

The Bookstore checkout page offers three shipping options: standard post, express courier and store pickup. Someone wrote a strategy class for each and registered all three in the DI (Dependency Injection) container under the same interface. The service that produces the price quote asks its constructor for a single IShippingRateStrategy:

using Bookstore.Shipping;
using Microsoft.Extensions.DependencyInjection;

ServiceCollection services = new();
services.AddSingleton<IShippingRateStrategy, StandardPostRate>();
services.AddSingleton<IShippingRateStrategy, ExpressCourierRate>();
services.AddSingleton<IShippingRateStrategy, StorePickupRate>();
services.AddSingleton<ShippingQuoteService>();

using ServiceProvider provider = services.BuildServiceProvider();
ShippingQuoteService quotes = provider.GetRequiredService<ShippingQuoteService>();

Shipment shipment = new("standard-post", "TR-34", 1.2m);
Console.WriteLine(quotes.Quote(shipment));
Console.WriteLine(provider.GetServices<IShippingRateStrategy>().Count());

public sealed class ShippingQuoteService(IShippingRateStrategy strategy)
{
    public string Quote(Shipment shipment) =>
        $"{strategy.Carrier}: {strategy.Calculate(shipment):0.00}";
}

The customer picked standard post and the parcel weighs 1.2 kg. What gets printed? What does the second line print? Make a guess before reading on; here are the strategies themselves:

namespace Bookstore.Shipping;

public sealed record Shipment(string Carrier, string Region, decimal WeightKg);

public interface IShippingRateStrategy
{
    string Carrier { get; }
    decimal Calculate(Shipment shipment);
}

public sealed class StandardPostRate : IShippingRateStrategy
{
    public string Carrier => "standard-post";
    public decimal Calculate(Shipment shipment) => 2.90m + 0.80m * shipment.WeightKg;
}

public sealed class ExpressCourierRate : IShippingRateStrategy
{
    public string Carrier => "express-courier";
    public decimal Calculate(Shipment shipment) =>
        6.50m + 1.50m * shipment.WeightKg + (shipment.Region.StartsWith("TR-") ? 0m : 4.00m);
}

public sealed class StorePickupRate : IShippingRateStrategy
{
    public string Carrier => "store-pickup";
    public decimal Calculate(Shipment shipment) => 0m;
}

The Definition and the Naive switch

Output:

store-pickup: 0.00
3

The customer chose standard post and the system quoted store pickup: zero. The container holds all three registrations (the second line says 3), but when a single IShippingRateStrategy is requested it returns the last one. No error, no warning; an innocent pull request that reorders registrations silently changes shipping prices. That is exactly what this post is about: writing strategies is easy, choosing the right one at runtime is the hard part.

GoF (the Gang of Four: Gamma, Helm, Johnson and Vlissides, authors of the 1994 Design Patterns book) states Strategy's intent like this: define a family of algorithms, encapsulate each one and make them interchangeable, so the algorithm can vary independently from the clients that use it. The book names the roles context (the user of the strategy), strategy (the common interface) and concrete strategy (each algorithm). Here the context is ShippingQuoteService, the strategy is IShippingRateStrategy and the concrete strategies are the three rate classes.

In the C# OOP (Object-Oriented Programming) series post "A Few Design Patterns in C#: Factory, Strategy and Observer" we built shipping by country with a factory and a strategy; there the selection lived in a single switch expression. This post picks up from there: we won't talk about the strategy itself but about where and how the selection happens. The starting point is the code before the pattern:

using Bookstore.Shipping;

Console.WriteLine($"{NaiveShippingCalculator.Calculate(new("express-courier", "DE-BE", 1.2m)):0.00}");

public static class NaiveShippingCalculator
{
    public static decimal Calculate(Shipment shipment)
    {
        switch (shipment.Carrier)
        {
            case "standard-post":
                return 2.90m + 0.80m * shipment.WeightKg;
            case "express-courier":
                decimal cost = 6.50m + 1.50m * shipment.WeightKg;
                if (!shipment.Region.StartsWith("TR-"))
                {
                    cost += 4.00m;
                }
                return cost;
            case "store-pickup":
                return 0m;
            default:
                throw new ArgumentException($"Unknown carrier: {shipment.Carrier}");
        }
    }
}
12.30

It works and it reads fine for three carriers. The trouble starts as it grows. When a fourth carrier arrives, the method gets another case; when one carrier wants a weekend surcharge, another if. Every change opens the same method, the tests for every carrier depend on the same file, and whoever changes the express courier's international rule also touches the standard post lines. The axis of change we discussed in the SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) and Design Principles series post "Open/Closed Principle: Extending Without Modifying and the Speculative Generality Trap" is plain to see: what changes is each carrier's price formula, what stays the same is the quoting flow.

Strategy separates the two. Each formula moves into its own class and the flow knows only the interface. But the moment you split the formulas, a new question appears: who turns the string "standard-post" into a StandardPostRate object? In the naive code the switch did that job too. The three approaches below are .NET's three answers to that question.

Selection 1: Keyed Services

Keyed services, introduced in .NET 8, let you register several implementations of the same interface under a key. The key doesn't have to be a string; any object that implements Equals correctly will do, an enum included.

using Bookstore.Shipping;
using Microsoft.Extensions.DependencyInjection;

ServiceCollection services = new();
services.AddKeyedSingleton<IShippingRateStrategy, StandardPostRate>("standard-post");
services.AddKeyedSingleton<IShippingRateStrategy, ExpressCourierRate>("express-courier");
services.AddKeyedSingleton<IShippingRateStrategy, StorePickupRate>("store-pickup");
services.AddSingleton<ExpressCheckout>();
services.AddSingleton<ShippingRateSelector>();

using ServiceProvider provider = services.BuildServiceProvider();

// The key is known at compile time: the attribute is enough
ExpressCheckout express = provider.GetRequiredService<ExpressCheckout>();
Console.WriteLine(express.Quote(new("express-courier", "DE-BE", 1.2m)));

// The key arrives at runtime, from the user's choice
ShippingRateSelector selector = provider.GetRequiredService<ShippingRateSelector>();
Console.WriteLine($"{selector.For("standard-post").Calculate(new("standard-post", "TR-34", 1.2m)):0.00}");
Console.WriteLine(provider.GetServices<IShippingRateStrategy>().Count());

try
{
    selector.For("drone");
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

public sealed class ExpressCheckout(
    [FromKeyedServices("express-courier")] IShippingRateStrategy rate)
{
    public string Quote(Shipment shipment) => $"{rate.Carrier}: {rate.Calculate(shipment):0.00}";
}

// The only class that touches the container; the rest of the app sees only this
public sealed class ShippingRateSelector(IServiceProvider services)
{
    public IShippingRateStrategy For(string carrier) =>
        services.GetRequiredKeyedService<IShippingRateStrategy>(carrier);
}
express-courier: 12.30
3.86
0
No keyed service for type 'Bookstore.Shipping.IShippingRateStrategy' using key type 'System.String' has been registered.

There are two different uses here and the difference matters. ExpressCheckout always works with the express courier; since the key is known at compile time, the [FromKeyedServices("express-courier")] attribute is enough. This is the cleanest form: the dependency is visible in the constructor and the class knows nothing about the container.

The carrier the customer chose, however, is only known at runtime. You can't pass a runtime value to an attribute, so someone has to call GetRequiredKeyedService. If you do that inside OrderService, you've injected IServiceProvider into the class, which is Service Locator itself; we'll cover it in detail in the series' anti-pattern post. Writing a single small ShippingRateSelector that touches the container, as here, locks the locator up right next to the composition root; the rest of the application only sees For(carrier).

The last two lines of output show two traps. First, keyed registrations don't show up in a non-keyed GetServices<T>() query: the result is 0. If you plan to also take your keyed strategies as an IEnumerable<T>, you need to know this. For all of them there is GetKeyedServices<T>(KeyedService.AnyKey); according to the dependency injection page on Microsoft Learn, starting with .NET 10 passing AnyKey to the singular GetKeyedService throws an InvalidOperationException. Second, the exception for an unregistered key tells you only the key's type, not its value: the word "drone" doesn't appear in the message. If you want logs to say which carrier is missing, check in the selector yourself and produce a meaningful message.

Selection 2: IEnumerable<IStrategy> and CanHandle

Sometimes the choice doesn't hinge on a single key. Let regional pricing work like this: parcels heavier than 30 kg go on the freight tariff, parcels to Gökçeada and Bozcaada islands on the island tariff, everything else on the flat tariff. Here each strategy decides whether it applies:

using Bookstore.Shipping;
using Microsoft.Extensions.DependencyInjection;

Shipment heavy = new("standard-post", "TR-34", 32m);

Console.WriteLine(Quote(registerFallbackFirst: false, heavy));
Console.WriteLine(Quote(registerFallbackFirst: true, heavy));

static string Quote(bool registerFallbackFirst, Shipment shipment)
{
    ServiceCollection services = new();
    if (registerFallbackFirst)
    {
        services.AddSingleton<IRegionalRate, FlatRegionalRate>();
    }
    services.AddSingleton<IRegionalRate, FreightRate>();
    services.AddSingleton<IRegionalRate, IslandRate>();
    if (!registerFallbackFirst)
    {
        services.AddSingleton<IRegionalRate, FlatRegionalRate>();
    }
    services.AddSingleton<RegionalQuoteService>();

    using ServiceProvider provider = services.BuildServiceProvider();
    return provider.GetRequiredService<RegionalQuoteService>().Quote(shipment);
}

public interface IRegionalRate
{
    bool CanHandle(Shipment shipment);
    decimal Calculate(Shipment shipment);
}

public sealed class FreightRate : IRegionalRate
{
    public bool CanHandle(Shipment shipment) => shipment.WeightKg > 30m;
    public decimal Calculate(Shipment shipment) => 45m + 0.50m * shipment.WeightKg;
}

public sealed class IslandRate : IRegionalRate
{
    private static readonly HashSet<string> Islands = ["TR-17-GOKCEADA", "TR-17-BOZCAADA"];
    public bool CanHandle(Shipment shipment) => Islands.Contains(shipment.Region);
    public decimal Calculate(Shipment shipment) => 9.90m + 1.10m * shipment.WeightKg;
}

public sealed class FlatRegionalRate : IRegionalRate
{
    public bool CanHandle(Shipment shipment) => true;
    public decimal Calculate(Shipment shipment) => 2.90m + 0.80m * shipment.WeightKg;
}

public sealed class RegionalQuoteService(IEnumerable<IRegionalRate> rates)
{
    public string Quote(Shipment shipment)
    {
        IRegionalRate rate = rates.First(r => r.CanHandle(shipment));
        return $"{rate.GetType().Name}: {rate.Calculate(shipment):0.00}";
    }
}
FreightRate: 61.00
FlatRegionalRate: 28.50

The same 32 kg parcel got two different prices from the same code. The only difference is registration order. Microsoft's DI container fills IEnumerable<T> in registration order and First picks the first strategy that matches. Because FlatRegionalRate.CanHandle always returns true, registering it first swallows every special rule. This approach closely resembles a Chain of Responsibility and carries the same disease: a hidden dependency on order.

There are two ways to put that in code. Take the fallback out of the IRegionalRate list and use it explicitly inside RegionalQuoteService (rates.FirstOrDefault(...) ?? fallback), or give each strategy a Priority property and sort by it. The first is less magical. Whichever you pick, the answers to "what if none matches" and "which one wins if two match" should be written in code; registration order is a fragile place to keep them.

The strength of this approach is that adding a rule never touches existing code: a new class, a new registration, done. The weakness is that to understand which tariff a parcel lands on, you have to read every CanHandle.

Selection 3: Dictionary and Func

If a strategy is a single method with no dependencies and no state, an interface plus classes can be a bit of ceremony. Since functions are values in C#, the strategy shrinks to a Func and the selection to a dictionary lookup:

using System.Collections.Frozen;
using Bookstore.Shipping;

ShippingRateTable table = new();
Console.WriteLine($"{table.Calculate(new("store-pickup", "TR-06", 0.4m)):0.00}");
Console.WriteLine($"{table.Calculate(new("express-courier", "TR-06", 0.4m)):0.00}");
Console.WriteLine(table.TryCalculate(new("drone", "TR-06", 0.4m), out decimal cost) ? $"{cost:0.00}" : "not supported");

public sealed class ShippingRateTable
{
    private static readonly FrozenDictionary<string, Func<Shipment, decimal>> Rates =
        new Dictionary<string, Func<Shipment, decimal>>
        {
            ["standard-post"] = s => 2.90m + 0.80m * s.WeightKg,
            ["express-courier"] = s => 6.50m + 1.50m * s.WeightKg + (s.Region.StartsWith("TR-") ? 0m : 4.00m),
            ["store-pickup"] = _ => 0m,
        }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);

    public decimal Calculate(Shipment shipment) => Rates[shipment.Carrier](shipment);

    public bool TryCalculate(Shipment shipment, out decimal cost)
    {
        if (Rates.TryGetValue(shipment.Carrier, out Func<Shipment, decimal>? rate))
        {
            cost = rate(shipment);
            return true;
        }
        cost = 0m;
        return false;
    }
}
0.00
7.10
not supported

This is exactly the observation in Peter Norvig's 1996 talk "Design Patterns in Dynamic Languages": in languages with first-class functions, Strategy stops being a separate structure and becomes an ordinary function value. C# has allowed this for years through delegates and lambdas. FrozenDictionary arrived in .NET 8 and speeds up lookups for tables that are built once and only read afterwards. StringComparer.OrdinalIgnoreCase, meanwhile, hands the key comparison itself to the dictionary as a strategy; that's the topic of the next section.

The table is readable and shows every carrier at a glance. But its limit is clear too: once a formula needs a database query, an external API (Application Programming Interface) call or configuration, the lambdas start capturing dependencies through closures and become harder to test. At that point going back to an interface isn't a step backwards, it's the right tool.

ApproachSelects byAdding a strategyWatch out for
Keyed servicesA single keyA new class and a new AddKeyed... lineA runtime key needs access to the container
IEnumerable<IStrategy> and CanHandleSeveral properties of the objectA new class and a new registrationRegistration order changes the result
Dictionary<string, Func<>>A single keyOne row in the tableFalls apart once a formula has dependencies

Strategies in the BCL: IComparer and StringComparer

The BCL (Base Class Library), .NET's core library, uses Strategy most heavily for comparison and equality. Array.Sort knows the sorting algorithm but not which of two elements comes first; it learns that from an IComparer<T>. Dictionary and HashSet likewise learn key equality from an IEqualityComparer<T>. Turkish book titles make the difference easy to see:

using System.Globalization;

string[] titles = ["Zeytin", "Çalıkuşu", "Ince Memed", "İnce Memed", "Saatleri Ayarlama Enstitüsü"];

string[] ordinal = [.. titles];
Array.Sort(ordinal, StringComparer.Ordinal);
Console.WriteLine(string.Join(" | ", ordinal));

string[] turkish = [.. titles];
Array.Sort(turkish, StringComparer.Create(CultureInfo.GetCultureInfo("tr-TR"), ignoreCase: false));
Console.WriteLine(string.Join(" | ", turkish));

Dictionary<string, int> stock = new(StringComparer.OrdinalIgnoreCase) { ["ISBN-978-0441013593"] = 4 };
Console.WriteLine(stock["isbn-978-0441013593"]);
Ince Memed | Saatleri Ayarlama Enstitüsü | Zeytin | Çalıkuşu | İnce Memed
Çalıkuşu | Ince Memed | İnce Memed | Saatleri Ayarlama Enstitüsü | Zeytin
4

The same Array.Sort produced two different orders with two different comparers. Ordinal looks at the numeric code of each character and pushes Ç and İ after Z; the tr-TR culture's comparer knows the Turkish alphabet. The sorting algorithm never changed, only the comparison strategy did. The stock lookup on the last line takes case-insensitive equality from outside in the same way. What these examples share is that the strategy is handed to the context explicitly, through a constructor or a parameter; there is no hidden selection anywhere. The C# Collections series post "Sorted Collections in C#: SortedList, SortedDictionary, SortedSet and IComparer" covers writing comparers in detail.

Strategy or State?

The class diagrams of the previous post's State pattern and Strategy are almost identical: a context, an interface, several implementations. The difference is in intent and in who makes the choice. In Strategy the outside client chooses and the strategies don't know about each other; standard post is unaware that express courier exists. In State, the state objects know the transitions: the Paid state knows the next step is Shipped and changes the context's state itself.

A practical test: if the answer to "who changes this behavior?" is the user or configuration, it's Strategy; if it's an event in the object's own life cycle, it's State. The carrier is the customer's choice; the order status is the order's story.

Common Pitfalls

Writing a strategy for a single implementation. If IShippingRateStrategy has one implementation and there is no concrete plan for a second, the interface only adds a layer of indirection. "Another carrier will come someday" is speculative generality; extracting the interface when the second carrier arrives is a refactoring of a few minutes.

Scattering the selection. Splitting out strategies and then writing if (carrier == "express-courier") separately in OrderService, CartController and the invoicing job copies the naive switch into three places. The selection needs a single owner: a selector, a table or a CanHandle chain.

Confusing a single T with multiple registrations. The bug in Guess First is common in real projects: a library registers its default implementation, you add your own, and which one wins is left to registration order. Not requesting an interface with several implementations as a single T, and using keyed registrations when you do need one, removes the ambiguity.

Leaking the context's internal state into the strategy. A Calculate(Order order) signature hands the whole order to the strategy; a few months later a strategy starts reading the customer's note and adjusting the price by it. A narrow type that gives the strategy only what it needs (like Shipment) makes testing easier and prevents that leak.

Silently falling back to a default for an unknown key. If the table or the selector falls back to standard post for an unknown carrier, the wrong price is charged and nobody notices. An unknown case should be visible, either through an exception or an explicit "not supported" result like TryCalculate.

When Not to Use It

When the algorithm doesn't change, or the change is just a number. If shipping is only "X per kilogram", putting X in configuration is enough; a strategy hierarchy is no substitute for a parameter.

When the options are few and fixed. For a rule with three cases that changes once a year, a well-named switch expression reads more easily than three classes and a selector. Strategy pays for itself when change becomes frequent or when different teams own different implementations.

When the choice is made once and never changes. For behavior chosen once at startup and fixed for the life of the process, don't build a runtime selection mechanism; one registration in the composition root is enough.

A Small Experiment

★☆☆ Add a new carrier, "same-day", to ShippingRateTable: a flat 14.90, valid only in the TR-34 region. Make TryCalculate return false for any other region. Did this rule fit better in a lambda or in a separate class?

★★☆ Change RegionalQuoteService to take the fallback as a separate constructor parameter instead of part of the list, and show it gives the same price in both registration orders. Then write a rule that explicitly decides which strategy wins when two match the same parcel (a 32 kg parcel going to Gökçeada).

★★★ Turn ShippingRateSelector into a class that, instead of holding IServiceProvider, collects all strategies at startup with GetKeyedServices<IShippingRateStrategy>(KeyedService.AnyKey) and puts them in a FrozenDictionary keyed by their Carrier property. If two strategies with the same Carrier are registered, the application should fail at startup with a meaningful error.

With Strategy we gave behavior to an object and plugged it into the context, with no need for inheritance. The next post asks the same question from the other side: when the skeleton of an algorithm is fixed and only some steps vary, leave those steps to the abstract methods of a base class. We'll cover the Template Method pattern, hooks and how to get the same result with delegates.