Blog/The Strategy Pattern in Multi-Tenant Billing Systems
design-patternscsharpbilling-systemssaas

The Strategy Pattern in Multi-Tenant Billing Systems

April 18, 2024·13 min read·by Bishwambhar Sen
A pattern map showing a client class delegating billing calculations to dynamically bound strategy implementation classes.

Concept

In multi-tenant Software-as-a-Service (SaaS) platforms, billing requirements are rarely uniform. As platforms scale from early-stage startups to enterprise solutions, they must support a diverse range of pricing models:

  • Flat-Rate Subscription: A fixed monthly fee regardless of usage (e.g., $99/month).
  • Per-Seat (User) Pricing: Charges proportional to the number of active users (e.g., $15/user/month).
  • Usage-Based (Metered) Pricing: Fees based on resource consumption (e.g., $0.002 per API request).
  • Tiered Volume/Overage Pricing: Hybrid models that include a baseline allowance with exponential charges for overage.

Attempting to model these billing calculations inside a monolithic controller using nested conditional blocks (if/else or switch statements) leads to high cyclomatic complexity, fragile regression risks, and a direct violation of the Open/Closed Principle (classes should be open for extension but closed for modification).

The Strategy Pattern resolves this complexity by defining a family of billing algorithms, encapsulating each algorithm inside its own class, and making them interchangeable at runtime. The billing processor (the client) delegates the calculation to the resolved strategy, remaining completely agnostic to how the final invoice amount is calculated.


Constraints

When designing a dynamic billing strategy engine, engineers must work within strict constraints concerning numeric precision, data consistency, and database isolation:

1. Numeric Precision and Financial Safety

Financial calculations must never use binary floating-point types (float or double in C#). Floating-point representations introduce minor rounding errors due to base-2 binary conversion. Over millions of transactions, these fractions accumulate into significant financial discrepancies. Financial systems must exclusively use base-10 decimal representations (decimal in C#), which guarantee exact precision for fractional currency values.

2. Strategy Configuration Persistence

A strategy cannot be selected at random; the database must associate each tenant with a specific billing model and its associated parameters. For example, a tenant on a per-seat pricing model needs a record containing:

  • The strategy key (e.g., "PerSeat").
  • The unit rate (e.g., $15.00).
  • The free allowance tier (if applicable). The system must dynamically map this database configuration to the correct C# strategy instance during the billing execution cycle.

3. Idempotency and Auditability

Billing processes are critical transaction boundaries. If a billing job fails halfway through execution, retrying it must not double-charge the client. Every execution of a billing strategy must be idempotent (producing the same result for the same input parameters) and generate immutable audit logs detailing the exact inputs (usage counters, seat counts) used by the strategy to calculate the invoice.


Trade-offs

Stateless vs. Stateful Strategies

  • Stateless Strategies: The strategy class acts as a pure function. It accepts all required metrics (e.g., seat count, usage units) as inputs and returns the calculated cost. Stateless strategies are easy to test, can be registered as singletons, and do not introduce database dependencies. However, the calling orchestration layer must load all usage metrics beforehand.
  • Stateful Strategies: The strategy class is injected with repository dependencies and fetches its own usage data from the database. This keeps the orchestrator simple, but makes the strategies harder to test and mock, while potentially causing redundant database round-trips.

Compile-time Dependency Injection vs. Dynamic Plugin Architecture

Resolving strategies through standard Dependency Injection (DI) is safe and simple, but requires recompiling and redeploying the application whenever a new billing model is introduced. A dynamic plugin architecture (e.g., loading external DLLs at runtime) allows the deployment of custom billing strategies for specific enterprise customers without affecting the core codebase. The trade-off is operational complexity, assembly loading issues, and heightened security risks.


Code

The following implementation demonstrates a production-grade multi-tenant billing engine in C#. It uses a stateless strategy interface, specialized strategy implementations, a factory resolver leveraging dependency injection, and a processor orchestrator.

First, we define the billing models and the strategy interface:

using System.Collections.Generic;

namespace Mpc.Patterns.Billing
{
    public enum BillingModelType
    {
        FlatRate,
        PerSeat,
        UsageBased
    }

    public record TenantBillingConfig(
        string TenantId, 
        BillingModelType ModelType, 
        decimal BaseRate, 
        Dictionary<string, string> Parameters);

    public record BillingContext(
        int ActiveSeats, 
        long UsageUnits);

    // The core Strategy interface
    public interface IBillingStrategy
    {
        BillingModelType HandledType { get; }
        decimal CalculateFee(TenantBillingConfig config, BillingContext context);
    }
}

Next, we implement the individual billing strategies:

using System;

namespace Mpc.Patterns.Billing.Strategies
{
    // Flat Rate Billing: Fixed price charge
    public class FlatRateBillingStrategy : IBillingStrategy
    {
        public BillingModelType HandledType => BillingModelType.FlatRate;

        public decimal CalculateFee(TenantBillingConfig config, BillingContext context)
        {
            return config.BaseRate;
        }
    }

    // Per-Seat Billing: Base rate + fee * active users
    public class PerSeatBillingStrategy : IBillingStrategy
    {
        public BillingModelType HandledType => BillingModelType.PerSeat;

        public decimal CalculateFee(TenantBillingConfig config, BillingContext context)
        {
            if (!config.Parameters.TryGetValue("PerSeatRate", out var rateStr) || 
                !decimal.TryParse(rateStr, out var perSeatRate))
            {
                throw new InvalidOperationException($"Invalid configuration for PerSeat billing on tenant {config.TenantId}.");
            }

            return config.BaseRate + (perSeatRate * context.ActiveSeats);
        }
    }

    // Usage-Based Billing: Base rate + fee * usage units consumed
    public class UsageBasedBillingStrategy : IBillingStrategy
    {
        public BillingModelType HandledType => BillingModelType.UsageBased;

        public decimal CalculateFee(TenantBillingConfig config, BillingContext context)
        {
            if (!config.Parameters.TryGetValue("PerUnitRate", out var rateStr) || 
                !decimal.TryParse(rateStr, out var perUnitRate))
            {
                throw new InvalidOperationException($"Invalid configuration for UsageBased billing on tenant {config.TenantId}.");
            }

            return config.BaseRate + (perUnitRate * context.UsageUnits);
        }
    }
}

Now, we build the Strategy Resolver and the Orchestrator. The resolver scans the registered strategies to find the correct one at runtime:

using System;
using System.Collections.Generic;
using System.Linq;

namespace Mpc.Patterns.Billing.Resolver
{
    public interface IBillingStrategyResolver
    {
        IBillingStrategy Resolve(BillingModelType type);
    }

    public class BillingStrategyResolver : IBillingStrategyResolver
    {
        private readonly Dictionary<BillingModelType, IBillingStrategy> _strategies;

        public BillingStrategyResolver(IEnumerable<IBillingStrategy> strategies)
        {
            _strategies = strategies.ToDictionary(s => s.HandledType);
        }

        public IBillingStrategy Resolve(BillingModelType type)
        {
            if (!_strategies.TryGetValue(type, out var strategy))
            {
                throw new NotSupportedException($"Billing model '{type}' is not currently registered.");
            }
            return strategy;
        }
    }

    public class BillingProcessor
    {
        private readonly IBillingStrategyResolver _resolver;

        public BillingProcessor(IBillingStrategyResolver resolver)
        {
            _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
        }

        public decimal ProcessBillingPeriod(TenantBillingConfig config, BillingContext context)
        {
            // 1. Resolve the correct strategy dynamically
            var strategy = _resolver.Resolve(config.ModelType);

            // 2. Delegate the calculation to the strategy
            var calculatedFee = strategy.CalculateFee(config, context);

            // 3. Return the calculated amount
            return calculatedFee;
        }
    }
}

Dynamic Strategy Resolution Flow

The diagram below shows how the BillingProcessor coordinates with the BillingStrategyResolver to dynamically fetch the correct calculator based on a tenant's database configuration:

graph LR
    TenantDB[(Tenant Config DB)] -->|1. Load Config| Processor[Billing Processor]
    Processor -->|2. Resolve strategy type| Resolver[Billing Strategy Resolver]
    
    Resolver -->|Option A| Flat[FlatRateBillingStrategy]
    Resolver -->|Option B| Seat[PerSeatBillingStrategy]
    Resolver -->|Option C| Usage[UsageBasedBillingStrategy]
    
    Processor -->|3. CalculateFee| Resolved[Resolved Strategy]
    Resolved -->|4. Return Decimal Amount| Processor
    
    style TenantDB fill:#0d101a,stroke:#d4a359,stroke-width:2px,color:#fff
    style Processor fill:#d4a359,stroke:#0d101a,color:#0d101a
    style Resolver fill:#d4a359,stroke:#0d101a,color:#0d101a

Further Reading