DEV Community

Spyros Ponaris
Spyros Ponaris

Posted on

Building a Dynamic Strategy Pattern in C# with Customer Tiers and Plugin Architecture

The Strategy Pattern is a staple of object-oriented design. It lets you encapsulate a family of algorithms and make them interchangeable at runtime. However, traditional implementations often suffer from heavy interface hierarchies and rigid factory logic.

In modern C#, language features like switch expressions, primary constructors, collection expressions, and collectible AssemblyLoadContext allow us to build clean, extensible strategy pipelines with zero unnecessary boilerplate.

In this article, we'll walk through building a flexible shipping rate calculator that supports:

  1. Multiple Carrier Strategies (DHL, FedEx, UPS).
  2. Customer Tier Rules (VIP, Corporate, Standard) via pattern matching.
  3. Dynamic Plugin Architecture to load new strategies from external .dll files without recompiling.
  4. Zero-Allocation Execution for performance-critical hot paths.

You can find the complete source code on GitHub: stevsharp/Shipping-strategy-demo.


1. Domain Contracts & Core Models

We start by defining clean, immutable records for our domain model and a clear IShippingStrategy interface.

namespace Shipping.Contracts;

public enum CustomerType { Standard, Vip, Corporate }

public record Customer(string Id, string Name, CustomerType Type, int LoyaltyPoints);

public record Order(string OrderId, decimal Subtotal, decimal WeightKg, Customer Customer);

public interface IShippingStrategy
{
    string ProviderName { get; }
    decimal CalculateCost(Order order);
}

Enter fullscreen mode Exit fullscreen mode

2. Encapsulating Carrier Strategies with Pattern Matching

Each strategy encapsulates carrier-specific pricing. Instead of cluttering our algorithms with if/else checks for customer ranks, we leverage C# switch expressions over the Customer object.

DHL Express Strategy

public class DhlShippingStrategy : IShippingStrategy
{
    public string ProviderName => "DHL Express";

    public decimal CalculateCost(Order order)
    {
        // DHL Base International Rate
        decimal baseRate = 25.00m + (order.WeightKg * 3.50m);

        return order.Customer switch
        {
            // VIPs get priority express at a 40% discount
            { Type: CustomerType.Vip } => baseRate * 0.60m,

            // Corporate accounts get a flat $15 discount
            { Type: CustomerType.Corporate } => Math.Max(5.00m, baseRate - 15.00m),

            // Standard customers with high loyalty points get $5 off
            { LoyaltyPoints: > 500 } => baseRate - 5.00m,

            _ => baseRate
        };
    }
}

Enter fullscreen mode Exit fullscreen mode

FedEx & UPS Implementations

public class FedExShippingStrategy : IShippingStrategy
{
    public string ProviderName => "FedEx";

    public decimal CalculateCost(Order order)
    {
        decimal baseRate = 15.00m + (order.WeightKg * 2.50m);

        return order.Customer switch
        {
            { Type: CustomerType.Vip } => baseRate * 0.50m,
            { Type: CustomerType.Corporate } => baseRate * 0.80m,
            _ => baseRate
        };
    }
}

public class UpsShippingStrategy : IShippingStrategy
{
    public string ProviderName => "UPS Ground";

    public decimal CalculateCost(Order order)
    {
        decimal baseRate = 12.00m + (order.WeightKg * 2.00m);

        return order.Customer switch
        {
            { Type: CustomerType.Vip } => baseRate * 0.70m,
            _ => baseRate
        };
    }
}

Enter fullscreen mode Exit fullscreen mode

3. Dynamic Strategy Factory & Plugin Loader

To keep our host application fully compliant with the Open/Closed Principle, we combine our strategies with a factory capable of scanning an external /plugins directory and instantiating .dll strategies at runtime using AssemblyLoadContext.

using System.Reflection;
using System.Runtime.Loader;
using Shipping.Contracts;

public class ShippingStrategyFactory
{
    private readonly Dictionary<string, IShippingStrategy> _strategies = new(StringComparer.OrdinalIgnoreCase);

    public ShippingStrategyFactory()
    {
        // Register built-in strategies
        Register(new DhlShippingStrategy());
        Register(new FedExShippingStrategy());
        Register(new UpsShippingStrategy());
    }

    public void Register(IShippingStrategy strategy)
    {
        _strategies[strategy.ProviderName] = strategy;
    }

    public IShippingStrategy GetStrategy(string providerKey)
    {
        return _strategies.GetValueOrDefault(providerKey)
            ?? throw new NotSupportedException($"Shipping provider '{providerKey}' is not supported.");
    }

    /// <summary>
    /// Scans a folder for external strategy DLLs and registers them dynamically.
    /// </summary>
    public void LoadExternalPlugins(string pluginsFolderPath)
    {
        if (!Directory.Exists(pluginsFolderPath)) return;

        foreach (string dllPath in Directory.GetFiles(pluginsFolderPath, "*.dll"))
        {
            var loadContext = new AssemblyLoadContext(dllPath, isCollectible: true);
            Assembly assembly = loadContext.LoadFromAssemblyPath(dllPath);

            foreach (Type type in assembly.GetTypes())
            {
                if (typeof(IShippingStrategy).IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface)
                {
                    if (Activator.CreateInstance(type) is IShippingStrategy pluginStrategy)
                    {
                        Register(pluginStrategy);
                        Console.WriteLine($"[Plugin Loaded]: {pluginStrategy.ProviderName}");
                    }
                }
            }
        }
    }

    public IEnumerable<string> AvailableProviders => _strategies.Keys;
}

Enter fullscreen mode Exit fullscreen mode

4. Zero-Allocation Hot-Path Variant

In high-throughput microservices or financial engines, object allocations add GC pressure. For those hot paths, you can express the strategy directly using a stack-allocated readonly ref struct:

public readonly ref struct FastPricingContext
{
    public required decimal Subtotal { get; init; }
    public required Customer Customer { get; init; }
}

public static class FastPricingStrategy
{
    public static decimal Calculate(in FastPricingContext ctx) => ctx switch
    {
        { Customer.Type: CustomerType.Vip, Subtotal: > 500m } => ctx.Subtotal * 0.70m,
        { Customer.Type: CustomerType.Vip }                  => ctx.Subtotal * 0.80m,
        { Customer.Type: CustomerType.Corporate }            => ctx.Subtotal * 0.85m,
        _                                                    => ctx.Subtotal
    };
}

Enter fullscreen mode Exit fullscreen mode

5. Putting It All Together

public static class Program
{
    public static void Main()
    {
        var vipCustomer = new Customer("C-101", "Alice", CustomerType.Vip, LoyaltyPoints: 1200);
        var order = new Order("ORD-9901", Subtotal: 200.00m, WeightKg: 4.5m, Customer: vipCustomer);

        var factory = new ShippingStrategyFactory();

        // Resolve and calculate using DHL Strategy
        IShippingStrategy dhl = factory.GetStrategy("DHL Express");
        decimal dhlCost = dhl.CalculateCost(order);
        Console.WriteLine($"[DHL Express] Final Cost: ${dhlCost:F2}");

        // Resolve and calculate using FedEx Strategy
        IShippingStrategy fedEx = factory.GetStrategy("FedEx");
        decimal fedExCost = fedEx.CalculateCost(order);
        Console.WriteLine($"[FedEx]       Final Cost: ${fedExCost:F2}");
    }
}

Enter fullscreen mode Exit fullscreen mode

Summary Matrix

Technique Primary Advantage Memory Overhead Use Case
Context Strategy Class Highly readable, unit-testable Standard heap allocations Typical enterprise logic
Plugin Factory (DLL) Hot-swappable providers without re-compiling Low assembly metadata cost Extensible SaaS / Monoliths
ref struct Strategy Zero heap allocations Zero GC cost High-frequency / Game engines

References & Further Reading


Top comments (0)