Introduction to the Power of Strategy Pattern in .NET (part 1)

Nikita Ivanov
ByteHide
Published in
8 min readJun 9, 2023

The goal of this story is to show how elegantly and quickly you can build software while keeping it totally extendable using the power of the strategy pattern in .NET (C#).

It seems to me that the strategy pattern is the most misunderstood pattern in the developers’ community. Yes, we use it regularly, but in primitive forms that don’t fully demonstrate its power. I have discovered ways to use it effectively, which have helped me and several teams easily and painlessly extend the functionality of applications. Now, it’s time to share this knowledge with a wider group of developers.

I have segregated strategies into several levels. The higher the level, the more advantages it provides, but at the same time, it becomes harder to implement. For each level, I have listed the benefits and limitations.

Before we start

I want to emphasize that strategies are not necessary in most cases. They can be extremely useful when you have actions such as registration, payment processing, or document processing that have multiple implementations and are expected to increase regularly. However, DO NOT use strategies for actions with fewer than five implementations or if there are no plans to reach such a number in the future. I have used a simple example of logic to better illustrate the key concepts.

Ultimately, it is up to you as an engineer to decide whether to adopt this approach or not. However, I must caution against integrating it everywhere. The decision to use strategies depends on the context of integration, and it is your responsibility as a developer to determine whether it is beneficial or not. Personally, I can offer the following insight:

This approach greatly facilitates growth, but it requires time to integrate. Use it when you believe that the time saved in the long run outweighs the time spent on integration.

Level 0 — Useless strategies (the basics)

Let’s examine how developers often struggle with strategies due to the disparity between theory and reality found in various books, courses, etc.

Suppose we have an application for corporate parties. We need an endpoint for submitting user registration requests with different ticket types. Each ticket type has its own set of logic, such as sending emails, hookah services, room reservations, etc. Initially, we expect only three types of tickets, but in the future, there will be many more with different logics.

1. Create registration request model.

This model contains data about the employee’s name and title, as well as the ticket type to be booked.

public sealed record RegistrationRequest
{
public required string FullName { get; init; }

public required string Title { get; init; }

public required TicketType Type { get; init; }
}

The required keyword is used for the fields. It has been available since C# 11 and ensures that properties are initialized during the creation of a class instance.

2. Define a common interface for all ticket types of strategies.

When using the strategy pattern, which implies having multiple ways of accomplishing something, it is necessary to define a contract that all implementations should adhere to.

public interface IRegistrationStrategy
{
public void SubmitRegistration(RegistrationRequest request);
}

3. Define strategies for each registration type.

Now we create a booking strategy implementation for each ticket type.

public class PremiumRegistrationStrategy : IRegistrationStrategy
{
public void SubmitRegistration(RegistrationRequest request)
{
... some logic ...
}
}

public class StandardRegistrationStrategy : IRegistrationStrategy
{
public void SubmitRegistration(RegistrationRequest request)
{
... some logic ...
}
}

public class VipRegistrationStrategy : IRegistrationStrategy
{
public void SubmitRegistration(RegistrationRequest request)
{
... some logic ...
}
}

4. Define a service to detect the strategy and invoke it.

This is the place where we choose which booking strategy to implement. Note that this is a simplified example, but the basic concept remains the same.

public class RegistrationService
{
public void SubmitRegistration(RegistrationRequest request)
{
IRegistrationStrategy strategy = request.Type switch
{
TicketType.Premium => new PremiumRegistrationStrategy(),
TicketType.Standard => new StandardRegistrationStrategy(),
TicketType.Vip => new VipRegistrationStrategy(),
_ => throw new ArgumentOutOfRangeException("No strategy found for this type")
};

strategy.SubmitRegistration(request);
}
}

Summary:

  1. It violates the Open-Closed principle because we need to modify the RegistrationService each time we add a new strategy.
  2. I want to use dependency injection (DI) in my strategies because I need access to the database, HttpClient, etc. Additionally, I don’t want to manually initialize each strategy object by passing arguments explicitly after DI provides them to the caller class!
  3. It’s not easily expandable. What if the condition for choosing a strategy is more complex than a simple enumeration check? Moreover, in a real project, this condition would be much more challenging to implement.
  4. What if the strategies have a very different list of parameters? In that case, the request model will grow and contain a lot of fields that are not needed for most of the implementations.

So you might be wondering, why should I use it? The answer is you shouldn’t. In fact, you must NOT use such a dreadful strategy implementation.

The problem with this implementation is evident: every time we add a new ticket type, we need to update the switch statement. While it might be acceptable for a small number of strategies, particularly in this example application, where I don’t anticipate a large number of ticket types being created, what if we potentially have many of them? Updating the switch statement every time we want to add or change something violates the Open-Closed principle. Furthermore, in a real project, the condition for choosing a strategy would involve more complex logic than just checking an enumeration (we will explore this in the next chapter).

In Level 1, we will enhance our registration service to automatically retrieve all strategy implementations from the project and dynamically choose which one to execute, even for new strategies, without the need to update its code. There are two ways to implement this: using reflection or DI + reflection. I prefer using DI because it’s easier to implement, understand, and allows you to resolve dependencies in your strategy classes. However, I will also demonstrate how to achieve it with reflection.

Level 1— Smart strategies

We want our service to have information about all registration strategies and be able to choose a specific one based on the ticket type. To achieve this, we need to store all the implementations somehow, and a dictionary is a perfect data structure for this purpose. We can use reflection or dependency injection (DI) to populate the dictionary with all the strategy implementations.

1. Update strategy interface.

public interface IRegistrationStrategy
{
public void SubmitRegistration(RegistrationRequest request);

public TicketType? Type { get; }
}

We have added a new nullable property called Type to the interface. This allows each strategy to define the type to which it belongs.

Why is it nullable? In this case, the default value for the property will be null, and later we can check whether the property has a defined value and throw an exception if it doesn’t. Another approach is to add a “None” value to the TicketType enum as the default value.

2. Update registration strategies.

public class StandardRegistrationStrategy : IRegistrationStrategy
{
...

public TicketType? Type => TicketType.Standard;
}

public class PremiumRegistrationStrategy : IRegistrationStrategy
{
...

public TicketType? Type => TicketType.Premium;
}

public class VipRegistrationStrategy : IRegistrationStrategy
{
...

public TicketType? Type => TicketType.Vip;
}

3.1. Update RegistrationService (Reflection only)

Using just reflection is not the preferred approach, but I have decided to cover it in this topic as well. The next section will focus on the DI + reflection implementation.

To modify the RegistrationService, we need to implement the logic that finds all registration strategies and creates the registration service by passing them to it.

public class RegistrationService
{
private readonly Dictionary<TicketType, IRegistrationStrategy> _registrationStrategies;

public RegistrationService(Dictionary<TicketType, IRegistrationStrategy> registrationStrategies)
{
_registrationStrategies = registrationStrategies;
}

public void SubmitRegistration(RegistrationRequest request)
{
if (!_registrationStrategies.TryGetValue(request.Type, out var strategy))
{
throw new ArgumentOutOfRangeException("No strategy found for this type");
}

strategy.SubmitRegistration(request);
}
}
public static class RegistrationServiceInitializer
{
public static RegistrationService InitRegistrationService()
{
Type type = typeof(IRegistrationStrategy);
IEnumerable<Type> types = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(s => s.GetTypes())
.Where(p => type.IsAssignableFrom(p) && type is { IsInterface: false, IsAbstract: false });

var registrationStrategies = new Dictionary<TicketType, IRegistrationStrategy>();
foreach (var registrationStrategyType in types)
{
var registrationStrategy = (IRegistrationStrategy)Activator.CreateInstance(registrationStrategyType)!;
if (registrationStrategy.Type is null)
{
throw new ArgumentOutOfRangeException($"Ticket type cannot be null! {registrationStrategy.Type}");
}

registrationStrategies.Add(registrationStrategy.Type.Value, registrationStrategy);
}

return new RegistrationService(registrationStrategies);
}
}

The InitRegistrationService method fetches all types (classes) from our application that implement the IRegistrationStrategy interface. Then, it iterates over each type and creates an instance of it.

It checks whether the Type property of the instance is null and throws an exception if it is. Otherwise, it adds the strategy instance to a dictionary as the value with the TicketType as the key. The resulting dictionary is then passed to the constructor of the RegistrationService.

3.2. Update RegistrationService (Reflection + DI)

Based on the fact of using a DI container, both the RegistrationService and RegistrationServiceInitializer should be changed. Firstly, the RegistrationService now accepts all the strategies implicitly from the DI container:

public class RegistrationService
{
private readonly IEnumerable<IRegistrationStrategy> _registrationStrategies;

public RegistrationService(
IEnumerable<IRegistrationStrategy> registrationStrategies)
{
_registrationStrategies = registrationStrategies;
}

public void SubmitRegistration(RegistrationRequest request)
{
IRegistrationStrategy? strategyToExecute = _registrationStrategies
.FirstOrDefault(x => x.Type == request.Type);

if (strategyToExecute is null)
{
throw new ArgumentOutOfRangeException("No strategy found for this type");
}

strategyToExecute.SubmitRegistration(request);
}
}

It is possible to get all implementation instances from the DI container using the IEnumerable<TInterface> type as a constructor argument. This argument contains a collection of all implementations for the generic type.

The logic of the RegistrationServiceInitializer remains quite similar. The initial step is to replace the static class with an extension method. Then, we still fetch the strategy implementations from the specified assembly, but instead of passing them into the RegistrationService constructor, we inject them into the DI container.

public static class DependencyInjection
{
public static IServiceCollection AddRegistrationStrategies(
this IServiceCollection services,
Assembly assembly)
{
IEnumerable<Type> strategyImplementations = assembly.GetTypes()
.Where(t => t.IsAssignableFrom(typeof(IRegistrationStrategy)));

foreach (Type implementation in strategyImplementations)
{
services.AddScoped(typeof(IRegistrationStrategy), implementation);
}

return services;
}

public static IServiceCollection AddRegistrationService(
this IServiceCollection services)
{
services.AddScoped<RegistrationService>();

return services;
}
}

You can inject services using method arguments instead of using generics. The arguments consist of the Type, where the first one is the definition (contract) and the second one is the implementation.

I have also added a separate method for injecting the RegistrationService for the sake of code organization. We’ll use it in the following way:

var builder = WebApplication.CreateBuilder(args);

...

// Main file (Program.cs) assembly is passed
builder.Services.AddRegistrationStrategies(typeof(Program).Assembly);
builder.Services.AddRegistrationService();

Conclusion

Now, let’s compare this implementation with the previous one to see if we have fixed its problems:

  1. Open-closed principle: It works great now! You can simply add new strategies for new types or edit existing ones without affecting anything else. You don’t need to change the existing code when adding new functionality.
  2. DI support: It is now fully supported without any problems! Each strategy is treated as a regular service and has access to the entire DI container.
  3. Complex conditions for choosing a strategy: This issue is not fixed yet. However, it will be addressed in Level 2.
  4. Different list of parameters: This issue is still not fixed; it will be addressed in level 3.

Why didn’t I fix 3–4 points in Level 1? There are two reasons:

  1. Simple conditions for choosing a strategy are commonly used, and that’s why I moved them to a separate level. If the logic doesn’t require complex conditions, there is no need to support it. If it becomes necessary in the future, you can easily upgrade to Level 2 or 3.
  2. For the reader’s better understanding: I believe it’s easier to grasp the idea when the story covers all the problems step by step with explanations and examples.

And it will be explained and addressed in Part 2, which will be posted soon. Thank you for reading, and please subscribe for Part 2!

--

--

Nikita Ivanov
ByteHide

.NET-focused engineer, Web3 enthusiast, Crypto startup participant, content creator 👨‍💻