TL:DR
Polymorphism in C# isn’t just about virtual
and override
. It’s a tool for building extensible, testable, and maintainable systems.
In this post, you’ll learn how to apply polymorphism using:
- Template Method: Fixed process, customizable steps.
- Strategy: Swappable behaviors at runtime.
- Visitor: Add new operations without changing existing classes.
Master these patterns to write cleaner, flexible C# code.
Polymorphism isn’t just a language feature, it’s a way to build systems that are easier to extend, change, and maintain.
In this post, you’ll see how polymorphism shows up in real-world design patterns like Template Method, Strategy, and Visitor, and how you can use them to write cleaner, more flexible C# code.
Template Method Pattern
The Template Method pattern lets you define the overall structure of an algorithm in a base class while allowing subclasses to customize specific steps. This is perfect for workflows that have a fixed structure but variable details.
It typically uses abstract and virtual methods to define the blueprint:
Senior Dev Tip: When implementing Template Method, make key operations abstract to force subclasses to implement them, but offer sensible defaults with virtual methods when possible. This balances flexibility with ease of use.
public abstract class DataProcessor
{
// The "template" that defines the process steps
public async Task ProcessDataAsync(string source)
{
var data = await LoadDataAsync(source);
var cleaned = CleanData(data);
var processed = ProcessData(cleaned);
await SaveDataAsync(processed);
}
protected abstract Task<string> LoadDataAsync(string source);
protected virtual string CleanData(string data) => data.Trim();
protected abstract string ProcessData(string data);
protected abstract Task SaveDataAsync(string data);
}
public class CsvProcessor : DataProcessor
{
protected override async Task<string> LoadDataAsync(string source)
{
return await File.ReadAllTextAsync(source);
}
protected override string CleanData(string data)
{
return base.CleanData(data).Replace("\r\n", "\n");
}
protected override string ProcessData(string data)
{
// CSV-specific processing
return ConvertCsvToJson(data);
}
protected override async Task SaveDataAsync(string data)
{
await File.WriteAllTextAsync("output.json", data);
}
}
Strategy Pattern Two Ways
Strategy Pattern - Interface Version (Composition Friendly)
The Strategy pattern lets you define a family of algorithms, encapsulate them, and swap them at runtime. This gives you incredible flexibility when your requirements change.
My Experience: I’ve found interface-based strategies particularly valuable when writing unit tests. You can easily mock these strategies, unlike abstract classes.
// Interface version (more flexible)
public interface IDiscountStrategy
{
decimal CalculateDiscount(decimal amount, Customer customer);
}
public class VipDiscountStrategy : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount, Customer customer)
{
return amount * 0.20m; // 20% discount
}
}
Strategy Pattern - Abstract Base Version (With Shared Logic)
You can also implement Strategy with an abstract base class when you need to share code across strategies while keeping the polymorphic behavior.
Key Insight: This approach works well when all your strategies need common validation or processing logic. You get code reuse without sacrificing flexibility.
// Abstract class version (shared code)
public abstract class DiscountStrategyBase
{
protected virtual bool IsEligible(Customer customer) => customer.IsActive;
public decimal CalculateDiscount(decimal amount, Customer customer)
{
if (!IsEligible(customer)) return 0;
return CalculateDiscountAmount(amount, customer);
}
protected abstract decimal CalculateDiscountAmount(decimal amount, Customer customer);
}
Visitor Pattern
The Visitor pattern lets you separate operations from the objects they work on, making it easy to process different object types with new behaviors.
Advanced Tip: Visitor really shines when you’re working with a stable object hierarchy but need to frequently add new operations. Think of it as “double dispatch” - the object and operation type both determine what happens.
public interface IVisitor
{
void Visit(OrderItem item);
void Visit(ShippingItem item);
void Visit(TaxItem item);
}
public abstract class InvoiceItem
{
public abstract void Accept(IVisitor visitor);
}
public class OrderItem : InvoiceItem
{
public decimal Price { get; set; }
public int Quantity { get; set; }
public override void Accept(IVisitor visitor) => visitor.Visit(this);
}
public class InvoiceCalculator : IVisitor
{
public decimal Total { get; private set; }
public void Visit(OrderItem item) => Total += item.Price * item.Quantity;
public void Visit(ShippingItem item) => Total += item.Cost;
public void Visit(TaxItem item) => Total += item.Amount;
}
public class ShippingItem : InvoiceItem
{
public decimal Cost { get; set; }
public override void Accept(IVisitor visitor) => visitor.Visit(this);
}
public class TaxItem : InvoiceItem
{
public decimal Amount { get; set; }
public override void Accept(IVisitor visitor) => visitor.Visit(this);
}
When to Use Each Pattern
Pattern | Use When | Example |
---|---|---|
Template Method | The process structure is fixed, but some steps need customization | File parsing pipeline |
Strategy | You need runtime-swappable behaviors or algorithms | Discount calculations, tax rules |
Visitor | You need to add new operations without modifying existing classes | Invoice calculations, AST visitors |
Conclusion: Choosing the Right Pattern for Your Code
Polymorphism isn’t just about overriding methods, it’s about creating flexible, maintainable systems that can adapt to change. When you understand these patterns, you gain powerful tools for solving common design challenges.
Each pattern solves a different problem:
- Template Method gives you a structured way to customize steps in a fixed process, perfect when you have a workflow with variable implementations.
- Strategy lets you swap entire algorithms at runtime, ideal when business rules change frequently or need to be configurable.
- Visitor helps you add new operations to existing objects without changing them, invaluable when working with complex object hierarchies.
The next time you face a design challenge, remember these patterns. They’ll help you write code that’s not just working, but truly well-designed.
Want to level up further? Try refactoring one of your existing workflows using these patterns. Start small, pick the one that fits your current project and see how it improves testability and flexibility.
Related Posts
- When Inheritance Still Makes Sense in C#: Polymorphism Without Swapping
- How Polymorphism Makes C# Code Flexible: Real-World Examples and Best Practices
- Prefer Interfaces Over Abstract Classes in C#: Build Flexible, Testable, and Maintainable Code
- Static Classes vs Singleton Pattern in C#: Which One Should You Use?
- Object-Oriented Programming: Core Principles and C# Implementation