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?
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.
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
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.
}
}
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
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 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:
- Write one new class implementing
IUsernameGenerationStrategy - Register it in DI
- 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.
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
strategy-pattern
pipeline-pattern
csharp
dotnet
software-architecture
clean-code
solid-principles
enterprise-dotnet
backend-development
refactoring
dependency-injection
