Design Patterns #1: Strategy – A Different Perspective.

Design patterns have been extensively covered in countless articles, but I want to explore this topic from a fresh perspective. While the common rationale for using design patterns is their ability to enhance readability and flexibility, I’d like to focus on what happens when you take a different approach—choosing to create your own solutions that suit your immediate needs instead of following these established recommendations. In this discussion, I’ll highlight the benefits you can gain from using design patterns as well as the potential pitfalls of ignoring them. Rather than delving into the theory or history of design patterns, I’ll take a practical approach. My plan is to cover all the major patterns over time, starting with the behavioral pattern known as Strategy. What's Strategy? The core principle of this pattern lies in hiding the implementation details while determining the specific type to use dynamically at runtime. It involves grouping classes of a certain type and encapsulating them within a context that remains unaware of the specific classes being used. The decision regarding which class type to apply is made by the class that interacts with the context. What would I do if I didn't know about the Strategy? Imagine you need to implement several payment approaches in your internet shop. The typical solution is implementing separate services. public class CreditCardPayment : ICreditCardPayment { public void CreditCardPay(decimal amount) { Console.WriteLine($"Paid {amount:C} using Credit Card."); } } public class PayPalPayment : IPayPalPayment { public void PayPalPay(decimal amount) { Console.WriteLine($"Paid {amount:C} using PayPal."); } } public class CryptoCurrencyPayment : ICryptoCurrencyPayment { public void CryptoCurrencyPay(decimal amount) { Console.WriteLine($"Paid {amount:C} using Crypto Currency."); } } public interface ICreditCardPayment { void CreditCardPay(decimal amount); } public interface IPayPalPayment { void PayPalPay(decimal amount); } public interface ICryptoCurrencyPayment { void CryptoCurrencyPay(decimal amount); } For choosing different payments, let's create a new class: public class NonStrategy(ICreditCardPayment? creditCardPayment, IPayPalPayment? payPalPayment, ICryptoCurrencyPayment? cryptoCurrencyPayment) { public void Run() { Console.WriteLine("Choose payment method: 1. CreditCard, 2. PayPal, 3. Crypto"); var choice = new Random().Next(1, 3); Console.WriteLine($"Chosen payment method: {choice}"); Console.WriteLine("Enter payment amount:"); var input = 1 + new Random().NextDouble() * (1000 - 1); var amount = decimal.Parse(input.ToString(CultureInfo.CurrentCulture), CultureInfo.CurrentCulture); Console.WriteLine($"Entered amount: {amount}"); switch (choice) { case 1: creditCardPayment?.CreditCardPay(amount); break; case 2: payPalPayment?.PayPalPay(amount); break; case 3: cryptoCurrencyPayment?.CryptoCurrencyPay(amount); break; default: Console.WriteLine("Invalid choice."); return; } } } This class simulates user actions. We inject the necessary services and call the corresponding payment methods based on the user’s choice. To extend this class, you can add a new service and update the switch condition accordingly. Just remember to register the new services. For clarity and organization, I’ve moved the service registration logic to a separate class. public static class RegisterServices { public static void RegisterNonStrategyServices(this ServiceCollection services) { services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); } } For running, you can use this method in the Program class: public static void RunNonStrategy() { var services = new ServiceCollection(); services.RegisterNonStrategyServices(); var serviceProvider = services.BuildServiceProvider(); var nonStrategy = new NonStrategy.NonStrategy(serviceProvider.GetService(), serviceProvider.GetService(), serviceProvider.GetService()); nonStrategy.Run(); } If you check this out, you'll see something like this: This code works well and doesn’t present any visible issues. However, as your payment service grows with more injections and conditions, the code can become difficult to read and extend. This is precisely why the Strategy pattern was created. Implementing with the Strategy pattern Since we have services that function similarly, such as payment services, we can consolidate them into a single type. public class CreditCardPaymentStrategy : IPayment { public void Pay(decimal amount) { Console.WriteLine($"Paid {amount

Jan 21, 2025 - 14:20
 0
Design Patterns #1: Strategy – A Different Perspective.

Design patterns have been extensively covered in countless articles, but I want to explore this topic from a fresh perspective. While the common rationale for using design patterns is their ability to enhance readability and flexibility, I’d like to focus on what happens when you take a different approach—choosing to create your own solutions that suit your immediate needs instead of following these established recommendations.

In this discussion, I’ll highlight the benefits you can gain from using design patterns as well as the potential pitfalls of ignoring them. Rather than delving into the theory or history of design patterns, I’ll take a practical approach. My plan is to cover all the major patterns over time, starting with the behavioral pattern known as Strategy.

What's Strategy?

The core principle of this pattern lies in hiding the implementation details while determining the specific type to use dynamically at runtime. It involves grouping classes of a certain type and encapsulating them within a context that remains unaware of the specific classes being used. The decision regarding which class type to apply is made by the class that interacts with the context.

strategy

What would I do if I didn't know about the Strategy?

Imagine you need to implement several payment approaches in your internet shop. The typical solution is implementing separate services.

public class CreditCardPayment : ICreditCardPayment
{
    public void CreditCardPay(decimal amount)
    {
        Console.WriteLine($"Paid {amount:C} using Credit Card.");
    }
}

public class PayPalPayment : IPayPalPayment
{
    public void PayPalPay(decimal amount)
    {
        Console.WriteLine($"Paid {amount:C} using PayPal.");
    }
}

public class CryptoCurrencyPayment : ICryptoCurrencyPayment
{
    public void CryptoCurrencyPay(decimal amount)
    {
        Console.WriteLine($"Paid {amount:C} using Crypto Currency.");
    }
}

public interface ICreditCardPayment
{
    void CreditCardPay(decimal amount);
}

public interface IPayPalPayment
{
    void PayPalPay(decimal amount);
}

public interface ICryptoCurrencyPayment
{
    void CryptoCurrencyPay(decimal amount);
}

For choosing different payments, let's create a new class:

public class NonStrategy(ICreditCardPayment? creditCardPayment, IPayPalPayment? payPalPayment, ICryptoCurrencyPayment? cryptoCurrencyPayment)
{
    public void Run()
    {
        Console.WriteLine("Choose payment method: 1. CreditCard, 2. PayPal, 3. Crypto");
        var choice = new Random().Next(1, 3);
Console.WriteLine($"Chosen payment method: {choice}");
        Console.WriteLine("Enter payment amount:");
        var input = 1 + new Random().NextDouble() * (1000 - 1);
        var amount = decimal.Parse(input.ToString(CultureInfo.CurrentCulture), CultureInfo.CurrentCulture);
Console.WriteLine($"Entered amount: {amount}");

        switch (choice)
        {
            case 1:
                creditCardPayment?.CreditCardPay(amount);
                break;
            case 2:
                payPalPayment?.PayPalPay(amount);
                break;
            case 3:
                cryptoCurrencyPayment?.CryptoCurrencyPay(amount);
                break;
            default:
                Console.WriteLine("Invalid choice.");
                return;
        }
    }
}

This class simulates user actions. We inject the necessary services and call the corresponding payment methods based on the user’s choice. To extend this class, you can add a new service and update the switch condition accordingly. Just remember to register the new services. For clarity and organization, I’ve moved the service registration logic to a separate class.

public static class RegisterServices
{

    public static void RegisterNonStrategyServices(this ServiceCollection services)
    {
        services.AddSingleton<ICreditCardPayment, CreditCardPayment>();
        services.AddSingleton<IPayPalPayment, PayPalPayment>();
        services.AddSingleton<ICryptoCurrencyPayment, CryptoCurrencyPayment>();
    }
}

For running, you can use this method in the Program class:

public static void RunNonStrategy()
{
    var services = new ServiceCollection();
    services.RegisterNonStrategyServices();
    var serviceProvider = services.BuildServiceProvider();
    var nonStrategy = new NonStrategy.NonStrategy(serviceProvider.GetService<ICreditCardPayment>(), serviceProvider.GetService<IPayPalPayment>(), serviceProvider.GetService<ICryptoCurrencyPayment>());
        nonStrategy.Run();
}

If you check this out, you'll see something like this:

non-strategy result

This code works well and doesn’t present any visible issues. However, as your payment service grows with more injections and conditions, the code can become difficult to read and extend. This is precisely why the Strategy pattern was created.

Implementing with the Strategy pattern

Since we have services that function similarly, such as payment services, we can consolidate them into a single type.

public class CreditCardPaymentStrategy : IPayment
{
    public void Pay(decimal amount)
    {
        Console.WriteLine($"Paid {amount:C} using Credit Card.");
    }
}

public class CryptoCurrencyPaymentStrategy : IPayment
{
    public void Pay(decimal amount)
    {
        Console.WriteLine($"Paid {amount:C} using Crypto Currency.");
    }
}

public class PayPalPaymentStrategy : IPayment
{
    public void Pay(decimal amount)
    {
        Console.WriteLine($"Paid {amount:C} using PayPal.");
    }
}

public interface IPayment
{
    void Pay(decimal amount);
}

The Context class invokes the required type without any knowledge of the specific types. It simply holds a reference to the concrete service and executes its method. If you introduce a new payment method, the Context will continue to function seamlessly.

public class PaymentContext : IPaymentContext
{
    private IPayment? _paymentStrategy;

    public void SetPaymentStrategy(IPayment? payment)
    {
        _paymentStrategy = payment;
    }

    public void ExecutePayment(decimal amount)
    {
        if (_paymentStrategy == null)
        {
            throw new InvalidOperationException("Payment strategy is not set.");
        }

        _paymentStrategy.Pay(amount);
    }
}

public interface IPaymentContext
{
    void SetPaymentStrategy(IPayment? payment);

    void ExecutePayment(decimal amount);
}

Now, we need to add the client's class, where we define the payment type.

public class Strategy(IPaymentContext? paymentContext, IEnumerable<IPayment>? payments)
{
    public void Run()
    {
        Console.WriteLine("Choose payment method: 1. CreditCard, 2. PayPal, 3. Crypto");
        int choice = new Random().Next(1, 3);
        Console.WriteLine($"Chosen payment method: {choice}");
        IPayment? payment = choice switch
                {
                    1 => payments?.OfType<CreditCardPaymentStrategy>().FirstOrDefault(),
                    2 => payments?.OfType<PayPalPaymentStrategy>().FirstOrDefault(),
                    3 => payments?.OfType<CryptoCurrencyPaymentStrategy>().FirstOrDefault(),
                    _ => null
                };

                if (payment == null)
                {
                    Console.WriteLine("Invalid choice or strategy not found.");
                    return;
                }

                paymentContext?.SetPaymentStrategy(payment);

        Console.WriteLine("Enter payment amount:");
        var input = 1 + (new Random().NextDouble() * (1000 - 1));
        var amount = decimal.Parse(input.ToString(CultureInfo.CurrentCulture), CultureInfo.CurrentCulture);
        Console.WriteLine($"Entered amount: {amount}");
        paymentContext?.ExecutePayment(amount);
    }
}

As you can see, I injected a payment collection that groups services under a shared interface. I only need to filter by type to retrieve the appropriate service. The rest of the logic remains similar to the previous approach.

Finally, make sure to register your services and add a method to execute them in the Program.

public static class RegisterServices
{
    public static void RegisterStrategyServices(this ServiceCollection services)
    {
        services.AddSingleton<IPayment, CreditCardPaymentStrategy>();
        services.AddSingleton<IPayment, PayPalPaymentStrategy>();
        services.AddSingleton<IPayment, CryptoCurrencyPaymentStrategy>();
        services.AddSingleton<IPaymentContext, PaymentContext>();
    }
}

public static void RunStrategy()
{
    var services = new ServiceCollection();
    services.RegisterStrategyServices();
    var serviceProvider = services.BuildServiceProvider();
    var strategy = new Strategy.Strategy(serviceProvider.GetService<IPaymentContext>(), serviceProvider.GetServices<IPayment>());
        strategy.Run();
}

Check this out.

strategy result

As you can see, adding a new type is much easier. Only the class implementing the Strategy pattern needs to know about the specific types.

Once we've understood the differences in implementation, it's important to analyze the performance differences as well. To do this, I combined both approaches and utilized the Benchmark library, along with a small configuration to support benchmarking.

public class BenchmarkConfig : ManualConfig
{
    public BenchmarkConfig()
    {
        AddJob(Job.ShortRun
            .WithRuntime(CoreRuntime.Core90)
            .WithJit(Jit.Default)
            .WithPlatform(Platform.X64)
        );

        AddLogger(ConsoleLogger.Default);
        AddColumnProvider(BenchmarkDotNet.Columns.DefaultColumnProviders.Instance);
        AddExporter(BenchmarkDotNet.Exporters.HtmlExporter.Default);
    }
}

The final Program class should be looks like this:

public class Program
{
    public static void Main()
    {
        BenchmarkRunner.Run<Program>(new BenchmarkConfig());
    }

    [Benchmark]
    public void RunStrategy()
    {
        var services = new ServiceCollection();
        services.RegisterStrategyServices();
        var serviceProvider = services.BuildServiceProvider();
        var strategy = new Strategy.Strategy(serviceProvider.GetService<IPaymentContext>(), serviceProvider.GetServices<IPayment>());
        strategy.Run();
    }

    [Benchmark]
    public void RunNonStrategy()
    {
        var services = new ServiceCollection();
        services.RegisterNonStrategyServices();
        var serviceProvider = services.BuildServiceProvider();
        var nonStrategy = new NonStrategy.NonStrategy(serviceProvider.GetService<ICreditCardPayment>(), serviceProvider.GetService<IPayPalPayment>(), serviceProvider.GetService<ICryptoCurrencyPayment>());
        nonStrategy.Run();
    }
}

When you run the code, you’ll notice that the Strategy approach performs faster. While the difference may not be significant, keep in mind that we’re working with very simple logic here—nothing complex. Nevertheless, failing to adhere to the pattern can still impact performance.

benchmark

Conclusions

Using the Strategy pattern not only enhances readability and flexibility but also improves the structure of the code. This pattern aligns with SOLID principles, particularly the Open-Closed principle. As we’ve demonstrated, it also has an impact on performance.

The source: LINK

I hope you found this article helpful. Until next time, happy coding!

Buy Me A Beer

What's Your Reaction?

like

dislike

love

funny

angry

sad

wow