Skip to content

Bits of .NET

Daily micro-tips for C#, SQL, performance, and scalable backend engineering.

  • Asp.Net Core
  • C#
  • SQL
  • JavaScript
  • CSS
  • About
  • ErcanOPAK.com
  • No Access
  • Privacy Policy
Asp.Net Core / ASP.Net MVC / ASP.Net WebForms / C# / Yazılım

Strategy Pattern & Pipeline Pattern in C# .NET – A Deep Dive with Real-World Examples

- 04.07.26 | 04.07.26 - ErcanOPAK

Two of the most practical design patterns you will reach for in enterprise .NET development are the Strategy Pattern and the Pipeline Pattern. They solve different problems — but they complement each other beautifully, and understanding both will change the way you architect complex business logic.

In this post we go from first principles to production-ready C# code, with real-world analogies along the way.

Table of Contents

  1. What is the Strategy Pattern?
  2. Real-world analogy
  3. Strategy Pattern in C#
  4. What is the Pipeline Pattern?
  5. Real-world analogy
  6. Pipeline Pattern in C#
  7. Combining Strategy + Pipeline
  8. SOLID principles
  9. When to use which?
  10. Summary

1. What is the Strategy Pattern?

The Strategy Pattern is a behavioral design pattern that lets you define a family of algorithms, encapsulate each one in its own class, and make them interchangeable at runtime — without changing the code that uses them.

GOF Definition
Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it. — Gang of Four

In practical terms: instead of a giant if/else or switch block that grows every time a new business rule arrives, you have a clean interface and a set of independent classes — one per rule. Adding a new rule means adding a new class; nothing else changes.

The problem it solves

Imagine you are building a payment system. Today you support credit cards. Tomorrow, PayPal. Next month, crypto. Without Strategy Pattern:

public string ProcessPayment(string method, decimal amount)
{
    if (method == "creditcard")     return ChargeCreditCard(amount);
    else if (method == "paypal")  return ChargePayPal(amount);
    else if (method == "crypto") return ChargeCrypto(amount);
    // Next month: add another else-if... forever
    throw new NotSupportedException("Unknown payment method");
}

This breaks the Open/Closed Principle — every new payment method forces you to modify existing, tested code. With Strategy Pattern, you never touch this method again.


2. Real-World Analogy

Think of a navigation app
You open Google Maps and say “get me from A to B.” The app asks: car, walking, cycling, or public transit? Each option uses a completely different algorithm — different speed assumptions, different road types, different cost functions. But from your perspective, you always ask the same question and get the same type of answer: a route. The navigation engine does not change; only the strategy does.

Other real-world parallels:

  • A sorting algorithm selector — quicksort for large sets, insertion sort for small ones
  • A compression tool — zip, gzip, or brotli depending on the file type
  • A discount calculator — student discount, loyalty discount, seasonal sale, or no discount

3. Strategy Pattern in C#

Step 1 — Define the interface

/// The contract every strategy must fulfil.
public interface IPaymentStrategy
{
    string ProviderName { get; }
    Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct);
}

Step 2 — Implement strategies

public class CreditCardStrategy : IPaymentStrategy
{
    public string ProviderName => "CreditCard";

    public async Task<PaymentResult> ProcessAsync(PaymentRequest req, CancellationToken ct)
    {
        // Tokenize, call Stripe, handle 3DS...
        return new PaymentResult { Success = true, TransactionId = Guid.NewGuid().ToString() };
    }
}

public class PayPalStrategy : IPaymentStrategy
{
    public string ProviderName => "PayPal";

    public async Task<PaymentResult> ProcessAsync(PaymentRequest req, CancellationToken ct)
    {
        // Redirect URL, IPN webhook, currency conversion...
        return new PaymentResult { Success = true, TransactionId = Guid.NewGuid().ToString() };
    }
}

public class CryptoStrategy : IPaymentStrategy
{
    public string ProviderName => "Crypto";

    public async Task<PaymentResult> ProcessAsync(PaymentRequest req, CancellationToken ct)
    {
        // Wallet address, blockchain confirmation, exchange rate...
        return new PaymentResult { Success = true, TransactionId = Guid.NewGuid().ToString() };
    }
}

Step 3 — The selector

public interface IPaymentStrategySelector
{
    IPaymentStrategy GetStrategy(string providerName);
}

public class PaymentStrategySelector : IPaymentStrategySelector
{
    private readonly IEnumerable<IPaymentStrategy> _strategies;
    private readonly IPaymentStrategy _defaultStrategy;

    public PaymentStrategySelector(
        IEnumerable<IPaymentStrategy> strategies,
        IPaymentStrategy defaultStrategy)
    {
        _strategies      = strategies;
        _defaultStrategy = defaultStrategy;
    }

    public IPaymentStrategy GetStrategy(string providerName)
        => _strategies.FirstOrDefault(s =>
               s.ProviderName.Equals(providerName, StringComparison.OrdinalIgnoreCase))
           ?? _defaultStrategy;
}

Step 4 — Register in DI and use

// Program.cs
services.AddScoped<IPaymentStrategy, CreditCardStrategy>();
services.AddScoped<IPaymentStrategy, PayPalStrategy>();
services.AddScoped<IPaymentStrategy, CryptoStrategy>();
services.AddScoped<IPaymentStrategySelector, PaymentStrategySelector>();

// In your service
public class CheckoutService(IPaymentStrategySelector selector)
{
    public async Task<PaymentResult> CheckoutAsync(Order order, CancellationToken ct)
    {
        var strategy = selector.GetStrategy(order.PaymentMethod);
        return await strategy.ProcessAsync(new PaymentRequest(order), ct);
        // Adding a new provider = one new class. Zero changes here.
    }
}
Key benefit
When a new payment provider arrives, you write one new class that implements IPaymentStrategy, register it in DI, and you are done. The CheckoutService, the selector, and all existing strategies remain completely untouched. This is the Open/Closed Principle in action.

4. What is the Pipeline Pattern?

The Pipeline Pattern is a structural pattern where an input object is passed through a sequence of processing steps — called stages or handlers — each one transforming or validating it before passing it on to the next. The output of one stage is the input to the next.

Key properties:

  • Each stage has a single responsibility
  • Stages are composable — add, remove, or reorder without touching others
  • The pipeline as a whole is transparent — the caller does not need to know what happens inside
  • Stages can short-circuit — if validation fails, later stages are skipped

5. Real-World Analogy

Think of an airport security checkpoint
Every passenger goes through the same ordered sequence: show passport → X-ray luggage → metal detector → boarding pass scan. Each checkpoint is independent — the X-ray machine does not care what the passport check found. If you fail at any stage, you stop and go no further. A new stage (retinal scan) can be added without redesigning the others. The passenger — your data — just flows through.

More examples:

  • An HTTP middleware chain in ASP.NET Core — authentication, logging, rate limiting, routing
  • A CI/CD pipeline — lint → compile → unit tests → integration tests → deploy
  • An order processing flow — validate stock → reserve inventory → charge → send confirmation
  • A user request validation chain — check identity → verify role → validate dates → generate username → save

6. Pipeline Pattern in C#

Approach A — Simple sequential pipeline

public class UserRequestContext
{
    public UserRequest    Request { get; set; } = default!;
    public List<string> Errors  { get; } = new();
    public bool         IsValid => !Errors.Any();
}

public interface IValidationStage
{
    Task ExecuteAsync(UserRequestContext ctx, CancellationToken ct);
}

/// Stage 1: validate national identity number
public class IdentityValidationStage : IValidationStage
{
    private readonly IIdentityService _identityService;
    public IdentityValidationStage(IIdentityService s) => _identityService = s;

    public async Task ExecuteAsync(UserRequestContext ctx, CancellationToken ct)
    {
        var isValid = await _identityService.ValidateAsync(
            ctx.Request.NationalityNumber, ctx.Request.BirthDate, ct);
        if (!isValid)
            ctx.Errors.Add("Identity number could not be verified.");
    }
}

/// Stage 2: ensure the requesting user has the required role
public class RoleAuthorizationStage : IValidationStage
{
    private readonly IRoleRepository _roleRepo;
    public RoleAuthorizationStage(IRoleRepository r) => _roleRepo = r;

    public async Task ExecuteAsync(UserRequestContext ctx, CancellationToken ct)
    {
        var hasRole = await _roleRepo.UserHasRoleAsync(
            ctx.Request.ResponsibleContactId, ctx.Request.UserTypeId, ct);
        if (!hasRole)
            ctx.Errors.Add("User does not have permission for this request type.");
    }
}

/// Stage 3: validate account expiry against contract dates
public class ExpireDateValidationStage : IValidationStage
{
    private readonly IContractRepository _contractRepo;
    public ExpireDateValidationStage(IContractRepository r) => _contractRepo = r;

    public async Task ExecuteAsync(UserRequestContext ctx, CancellationToken ct)
    {
        var contract = await _contractRepo.GetByIdAsync(ctx.Request.ContractId, ct);
        if (ctx.Request.ExpireDate < contract.StartDate ||
            ctx.Request.ExpireDate > contract.EndDate)
        {
            ctx.Errors.Add($"Expire date must be within contract range: "
                         + $"{contract.StartDate:dd.MM.yyyy} – {contract.EndDate:dd.MM.yyyy}.");
        }
    }
}

/// Stage 4: generate username using the Strategy Pattern
public class UsernameGenerationStage : IValidationStage
{
    private readonly IUsernameGenerationStrategySelector _selector;
    public UsernameGenerationStage(IUsernameGenerationStrategySelector s) => _selector = s;

    public async Task ExecuteAsync(UserRequestContext ctx, CancellationToken ct)
    {
        if (!ctx.IsValid) return; // skip if earlier stages failed
        var strategy = _selector.GetStrategy(ctx.Request.UserTypeId);
        var (empNo, userName) = await strategy.GenerateAsync(ctx.Request, ct);
        ctx.Request.EmployeeNumber = empNo;
        ctx.Request.UserName       = userName;
    }
}

The pipeline executor

public class UserRequestPipeline
{
    private readonly IEnumerable<IValidationStage> _stages;
    public UserRequestPipeline(IEnumerable<IValidationStage> stages) => _stages = stages;

    public async Task<UserRequestContext> RunAsync(
        UserRequest request, CancellationToken ct = default)
    {
        var ctx = new UserRequestContext { Request = request };
        foreach (var stage in _stages)
        {
            await stage.ExecuteAsync(ctx, ct);
            if (!ctx.IsValid) break; // short-circuit on failure
        }
        return ctx;
    }
}

// DI registration — order matters!
services.AddScoped<IValidationStage, IdentityValidationStage>();
services.AddScoped<IValidationStage, RoleAuthorizationStage>();
services.AddScoped<IValidationStage, ExpireDateValidationStage>();
services.AddScoped<IValidationStage, UsernameGenerationStage>();
services.AddScoped<UserRequestPipeline>();

// Usage
var result = await pipeline.RunAsync(userRequest, ct);
if (!result.IsValid)
    throw new ValidationException(result.Errors);

Approach B — Generic middleware pipeline (like ASP.NET Core)

A more powerful approach uses a delegate chain. Each handler receives a next delegate giving it full control over pre- and post-processing:

public delegate Task PipelineDelegate<T>(T context);

public interface IPipelineMiddleware<T>
{
    Task InvokeAsync(T context, PipelineDelegate<T> next);
}

public class Pipeline<T>
{
    private readonly List<IPipelineMiddleware<T>> _middlewares = new();

    public Pipeline<T> Use(IPipelineMiddleware<T> middleware)
    {
        _middlewares.Add(middleware);
        return this;
    }

    public Task RunAsync(T context)
    {
        PipelineDelegate<T> pipeline = _ => Task.CompletedTask;
        foreach (var middleware in _middlewares.AsEnumerable().Reverse())
        {
            var next    = pipeline;
            var current = middleware;
            pipeline = ctx => current.InvokeAsync(ctx, next);
        }
        return pipeline(context);
    }
}

// Example middleware with pre- AND post-processing
public class LoggingMiddleware<T> : IPipelineMiddleware<T>
{
    private readonly ILogger _logger;
    public LoggingMiddleware(ILogger<LoggingMiddleware<T>> logger) => _logger = logger;

    public async Task InvokeAsync(T ctx, PipelineDelegate<T> next)
    {
        _logger.LogInformation("Pipeline starting for {Type}", typeof(T).Name);
        var sw = Stopwatch.StartNew();
        await next(ctx); // calls the rest of the pipeline
        _logger.LogInformation("Completed in {Ms}ms", sw.ElapsedMilliseconds);
    }
}

7. Combining Strategy + Pipeline

The relationship in one sentence
The Pipeline is the skeleton; the Strategy is the muscle. The skeleton does not change when muscles grow stronger — and muscles can be swapped without rebuilding the skeleton.
// The pipeline defines the ordered contract:
// 1. Validate identity        (always)
// 2. Validate role            (always)
// 3. Validate dates           (always)
// 4. Generate username        (STRATEGY decides HOW — different per UserType)
// 5. Save to database         (always)

var pipeline = new Pipeline<UserRequestContext>()
    .Use(new LoggingMiddleware<UserRequestContext>(logger))
    .Use(new IdentityValidationStage(identityService))
    .Use(new RoleAuthorizationStage(roleRepo))
    .Use(new ExpireDateValidationStage(contractRepo))
    .Use(new UsernameGenerationStage(strategySelector)) // Strategy lives here
    .Use(new PersistenceStage(userRequestRepo));

await pipeline.RunAsync(context);

When a new user type arrives with a completely different username format, you:

  1. Write one new class implementing IUsernameGenerationStrategy
  2. Register it in DI
  3. Done — the pipeline is untouched, all other strategies are untouched

8. SOLID Principles — How These Patterns Help

SOLID Principle Strategy Pattern Pipeline Pattern
S — Single Responsibility Each strategy handles exactly one algorithm variant Each stage does exactly one validation or transformation
O — Open/Closed New strategy class = extension; existing code untouched New stage = extension; executor unchanged
L — Liskov Substitution Any strategy is substitutable for any other Any stage is substitutable at the same position
I — Interface Segregation IPaymentStrategy is thin — only what the caller needs IValidationStage is thin — one method only
D — Dependency Inversion Business logic depends on the interface, not concrete strategies The executor depends on the interface, not concrete stages

9. When to Use Which?

Use Strategy Pattern when…

You have multiple ways to do the same operation and want to switch without growing if/else chains. Classic signals: logic that varies per customer/type/region, payment providers, discount calculators, report formatters, notification channels.

Use Pipeline Pattern when…

You have a sequence of steps that must run in order, each with a single responsibility, and you want to add, remove, or reorder steps without touching the others. Classic signals: request validation chains, ETL processes, middleware stacks, order processing workflows.

Question Answer → Pattern
“Same operation, works differently depending on X” Strategy
“A sequence of operations all running on the same data” Pipeline
“Adding a new X means changing existing code” Strategy eliminates this
“Adding a new step means touching the orchestrator” Pipeline eliminates this
“One step in my flow works differently per type” Pipeline + Strategy together

10. Summary

The Strategy Pattern answers the question: “How should this operation be done — and who decides at runtime?” It replaces conditional branching with polymorphism, keeping your business logic open to extension and closed to modification.

The Pipeline Pattern answers the question: “What sequence of steps does this data need to pass through?” It decomposes a complex process into small, single-purpose stages that can be composed, reordered, and extended without touching one another.

Used together, they produce systems that are genuinely maintainable at scale: when a new business requirement arrives, you add a new strategy class or a new pipeline stage — and the rest of the system does not even notice.

The single rule of thumb
If you find yourself writing if (type == X) { } else if (type == Y) { } for business logic — reach for Strategy. If you find yourself chaining method calls like Validate(); Authorize(); Transform(); Save(); in a single method — reach for Pipeline. And if you need both in the same feature, compose them.

Tags

design-patterns
strategy-pattern
pipeline-pattern
csharp
dotnet
software-architecture
clean-code
solid-principles
enterprise-dotnet
backend-development
refactoring
dependency-injection

Related posts:

C# Dictionary Lookups Are Slower Than Expected

C#: Async/Await Best Practices for High-Performance Apps

Pass model from Razor view to JavaScript

Post Views: 7

Post navigation

C#: Use ValueTuple for Lightweight Multiple Return Values
Visual Studio: Use Code Snippets to Write Code Faster

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

October 2026
M T W T F S S
 1234
567891011
12131415161718
19202122232425
262728293031  
« Sep    

Most Viewed Posts

  • Get the User Name and Domain Name from an Email Address in SQL (973)
  • How to make theater mode the default for Youtube (953)
  • How to add default value for Entity Framework migrations for DateTime and Bool (940)
  • Get the First and Last Word from a String or Sentence in SQL (847)
  • How to select distinct rows in a datatable in C# (837)
  • How to enable, disable and check if Service Broker is enabled on a database in SQL Server (624)
  • Add Constraint to SQL Table to ensure email contains @ (590)
  • Average of all values in a column that are not zero in SQL (554)
  • How to use Map Mode for Vertical Scroll Mode in Visual Studio (526)
  • Find numbers with more than two decimal places in SQL (468)

Recent Posts

  • CSS: Fix a prefers-color-scheme Media Query That Gets Silently Overridden by a Browser Extension’s Forced Dark Mode
  • Git: Fix a Merge Commit That Silently Drops a File Because Both Branches Deleted It Differently
  • HTML5: Fix a Native Lazy-Loading Image That Never Loads Because It Sits Inside a Hidden Tab Until the User Clicks It
  • The AI Prompt That Traces a Null Reference Exception Back to the Exact Line That First Produced the Null
  • The AI Prompt That Turns a Gym Membership Contract’s Fine Print Into a Plain-English List of Cancellation Steps
  • Photoshop: Fix a Color Profile Mismatch That Makes Printed Output Look Nothing Like What You Saw On Screen
  • WordPress: Fix Search Results That Return Pages From a Theme You Deactivated Months Ago
  • Visual Studio: Fix a Test Project That Builds Fine Alone but Fails to Discover Any Tests After a NuGet Restore
  • ASP.NET Core: Fix a File Upload That Times Out on Slow Connections Only Because Kestrel’s Minimum Data Rate Feature Kicked In
  • JavaScript: Fix an Array Destructuring Default Value That Silently Never Applies Because null Was Passed Instead of Undefined

Most Viewed Posts

  • Get the User Name and Domain Name from an Email Address in SQL (973)
  • How to make theater mode the default for Youtube (953)
  • How to add default value for Entity Framework migrations for DateTime and Bool (940)
  • Get the First and Last Word from a String or Sentence in SQL (847)
  • How to select distinct rows in a datatable in C# (837)

Recent Posts

  • CSS: Fix a prefers-color-scheme Media Query That Gets Silently Overridden by a Browser Extension’s Forced Dark Mode
  • Git: Fix a Merge Commit That Silently Drops a File Because Both Branches Deleted It Differently
  • HTML5: Fix a Native Lazy-Loading Image That Never Loads Because It Sits Inside a Hidden Tab Until the User Clicks It
  • The AI Prompt That Traces a Null Reference Exception Back to the Exact Line That First Produced the Null
  • The AI Prompt That Turns a Gym Membership Contract’s Fine Print Into a Plain-English List of Cancellation Steps

Social

  • ErcanOPAK.com
  • GoodReads
  • LetterBoxD
  • Linkedin
  • The Blog
  • Twitter
© 2026 Bits of .NET | Built with Xblog Plus free WordPress theme by wpthemespace.com