Implementing Polymorphism Patterns in C# Applications

Understanding Polymorphism in C#

Polymorphism lets you write code that works with objects through their base types while executing type-specific behavior. This fundamental concept makes your code flexible and extensible without changing existing implementations.

C# supports two forms: compile-time polymorphism through method overloading, and runtime polymorphism through virtual methods and interfaces. Runtime polymorphism is what most developers mean when discussing polymorphism—it's the ability to treat different objects uniformly.

You'll learn how to implement polymorphism using virtual methods, interfaces, and abstract classes to build maintainable, extensible applications.

Using Virtual Methods and Overrides

Virtual methods in base classes can be overridden by derived classes. The runtime determines which implementation to call based on the actual object type, not the reference type.

Payment.cs - Virtual Method Pattern
public class Payment
{
    public decimal Amount { get; set; }
    
    // Virtual method can be overridden
    public virtual void ProcessPayment()
    {
        Console.WriteLine($"Processing payment of {Amount:C}");
    }
    
    public virtual string GetReceiptMessage()
    {
        return $"Payment of {Amount:C} processed";
    }
}

public class CreditCardPayment : Payment
{
    public string CardNumber { get; set; }
    
    // Override base implementation
    public override void ProcessPayment()
    {
        Console.WriteLine($"Processing credit card payment: {Amount:C}");
        Console.WriteLine($"Card: ****{CardNumber.Substring(CardNumber.Length - 4)}");
    }
    
    public override string GetReceiptMessage()
    {
        return $"Credit card payment of {Amount:C} successful";
    }
}

public class PayPalPayment : Payment
{
    public string Email { get; set; }
    
    public override void ProcessPayment()
    {
        Console.WriteLine($"Processing PayPal payment: {Amount:C}");
        Console.WriteLine($"Account: {Email}");
    }
}

// Usage - polymorphic behavior
List<Payment> payments = new()
{
    new CreditCardPayment { Amount = 100, CardNumber = "1234567890123456" },
    new PayPalPayment { Amount = 50, Email = "user@example.com" },
    new Payment { Amount = 25 }
};

foreach (var payment in payments)
{
    payment.ProcessPayment(); // Calls correct override
}

Mark methods as virtual in the base class and override in derived classes. The runtime calls the most derived implementation, enabling different behaviors through a common interface.

Implementing Interface-Based Polymorphism

Interfaces define contracts that classes must implement. They enable polymorphism without requiring inheritance, allowing unrelated classes to be used interchangeably.

INotification.cs - Interface Pattern
public interface INotification
{
    void Send(string message);
    bool IsAvailable();
}

public class EmailNotification : INotification
{
    public string EmailAddress { get; set; }
    
    public void Send(string message)
    {
        Console.WriteLine($"Sending email to {EmailAddress}: {message}");
    }
    
    public bool IsAvailable()
    {
        return !string.IsNullOrEmpty(EmailAddress);
    }
}

public class SmsNotification : INotification
{
    public string PhoneNumber { get; set; }
    
    public void Send(string message)
    {
        Console.WriteLine($"Sending SMS to {PhoneNumber}: {message}");
    }
    
    public bool IsAvailable()
    {
        return !string.IsNullOrEmpty(PhoneNumber);
    }
}

public class NotificationService
{
    private readonly List<INotification> _notifiers;
    
    public NotificationService(List<INotification> notifiers)
    {
        _notifiers = notifiers;
    }
    
    public void NotifyAll(string message)
    {
        foreach (var notifier in _notifiers)
        {
            if (notifier.IsAvailable())
            {
                notifier.Send(message);
            }
        }
    }
}

// Usage
var notifiers = new List<INotification>
{
    new EmailNotification { EmailAddress = "user@example.com" },
    new SmsNotification { PhoneNumber = "555-1234" }
};

var service = new NotificationService(notifiers);
service.NotifyAll("Your order has shipped!");

Working with Abstract Classes

Abstract classes combine concrete implementation with abstract methods that derived classes must implement. They provide a middle ground between interfaces and regular classes.

Shape.cs - Abstract Class Pattern
public abstract class Shape
{
    public string Name { get; set; }
    
    // Abstract method - must be implemented
    public abstract double CalculateArea();
    
    // Virtual method - can be overridden
    public virtual void Draw()
    {
        Console.WriteLine($"Drawing {Name}");
    }
    
    // Concrete method - inherited by all
    public void DisplayInfo()
    {
        Console.WriteLine($"{Name} - Area: {CalculateArea():F2}");
    }
}

public class Circle : Shape
{
    public double Radius { get; set; }
    
    public override double CalculateArea()
    {
        return Math.PI * Radius * Radius;
    }
    
    public override void Draw()
    {
        Console.WriteLine($"Drawing circle with radius {Radius}");
    }
}

public class Rectangle : Shape
{
    public double Width { get; set; }
    public double Height { get; set; }
    
    public override double CalculateArea()
    {
        return Width * Height;
    }
}

// Usage
List<Shape> shapes = new()
{
    new Circle { Name = "Circle 1", Radius = 5 },
    new Rectangle { Name = "Rectangle 1", Width = 4, Height = 6 }
};

foreach (var shape in shapes)
{
    shape.DisplayInfo();
    shape.Draw();
}

Practical Polymorphism Patterns

Here's a real-world example showing how polymorphism simplifies code when handling different data sources.

DataRepository.cs - Repository Pattern
public interface IRepository<T>
{
    Task<T> GetByIdAsync(int id);
    Task<List<T>> GetAllAsync();
    Task AddAsync(T entity);
}

public class SqlRepository<T> : IRepository<T> where T : class
{
    private readonly DbContext _context;
    
    public async Task<T> GetByIdAsync(int id)
    {
        Console.WriteLine("Fetching from SQL database");
        return await _context.Set<T>().FindAsync(id);
    }
    
    public async Task<List<T>> GetAllAsync()
    {
        return await _context.Set<T>().ToListAsync();
    }
    
    public async Task AddAsync(T entity)
    {
        await _context.Set<T>().AddAsync(entity);
        await _context.SaveChangesAsync();
    }
}

public class MongoRepository<T> : IRepository<T> where T : class
{
    private readonly IMongoCollection<T> _collection;
    
    public async Task<T> GetByIdAsync(int id)
    {
        Console.WriteLine("Fetching from MongoDB");
        return await _collection.Find(/* filter */).FirstOrDefaultAsync();
    }
    
    public async Task<List<T>> GetAllAsync()
    {
        return await _collection.Find(_ => true).ToListAsync();
    }
    
    public async Task AddAsync(T entity)
    {
        await _collection.InsertOneAsync(entity);
    }
}

public class DataService<T> where T : class
{
    private readonly IRepository<T> _repository;
    
    public DataService(IRepository<T> repository)
    {
        _repository = repository;
    }
    
    public async Task<T> GetData(int id)
    {
        return await _repository.GetByIdAsync(id);
    }
}

// Usage - switch implementations easily
IRepository<Product> repo = new SqlRepository<Product>(dbContext);
// Or: IRepository<Product> repo = new MongoRepository<Product>(mongoCollection);

var service = new DataService<Product>(repo);
var product = await service.GetData(1);

This pattern lets you swap data sources without changing business logic. Dependencies on interfaces instead of concrete classes make testing easier through mocking.

Polymorphism Best Practices

Prefer interfaces for flexibility: Interfaces support multiple inheritance and work better with dependency injection. Use them when you need maximum flexibility.

Use abstract classes for shared code: When derived classes share significant implementation, abstract classes avoid code duplication while maintaining polymorphic behavior.

Always use override keyword: Forgetting override creates a new method that hides the base method, breaking polymorphism. The compiler warns you, but always be explicit.

Follow Liskov Substitution Principle: Derived classes should work anywhere the base class works. Don't violate expectations or throw unexpected exceptions in overrides.

Frequently Asked Questions (FAQ)

What's the difference between compile-time and runtime polymorphism?

Compile-time polymorphism uses method overloading where multiple methods have the same name but different parameters. Runtime polymorphism uses virtual methods and interfaces where the actual method called is determined at runtime based on the object's type. Runtime polymorphism provides more flexibility for extensibility.

When should I use interfaces versus abstract classes?

Use interfaces when you need multiple inheritance or want to define a contract without implementation. Use abstract classes when you want to share common code among related classes. Since C# 8, interfaces can have default implementations, blurring this distinction, but abstract classes still allow fields and constructors.

Do I need the virtual keyword for interface methods?

No, interface methods are automatically virtual. When you implement an interface method, it's implicitly virtual and can be overridden in derived classes. You only need the virtual keyword when creating overridable methods in base classes that don't come from interfaces.

What happens if I forget the override keyword?

Without override, you create a new method that hides the base method rather than overriding it. This breaks polymorphism—calling through a base class reference won't execute your derived method. The compiler warns you with CS0108. Always use override to maintain polymorphic behavior.

Back to Articles