C# Interfaces and Inheritance

📖 9 min read

Interfaces

Interfaces define contracts that types can implement. They specify what a type can do, not how it does it.

Basic Interface Definition

public interface IRepository<T> where T : class
{
    T? GetById(int id);
    IEnumerable<T> GetAll();
    void Add(T entity);
    void Update(T entity);
    void Delete(int id);
}

public interface IEmailService
{
    Task SendAsync(string to, string subject, string body);
    Task SendBulkAsync(IEnumerable<string> recipients, string subject, string body);
}

Implementing Interfaces

public class CustomerRepository : IRepository<Customer>
{
    private readonly DbContext context;

    public CustomerRepository(DbContext context)
    {
        this.context = context;
    }

    public Customer? GetById(int id) =>
        context.Customers.Find(id);

    public IEnumerable<Customer> GetAll() =>
        context.Customers.ToList();

    public void Add(Customer entity) =>
        context.Customers.Add(entity);

    public void Update(Customer entity) =>
        context.Customers.Update(entity);

    public void Delete(int id)
    {
        var entity = GetById(id);
        if (entity != null)
            context.Customers.Remove(entity);
    }
}

Multiple Interface Implementation

public interface IComparable<T>
{
    int CompareTo(T other);
}

public interface IEquatable<T>
{
    bool Equals(T other);
}

public interface IFormattable
{
    string ToString(string format, IFormatProvider formatProvider);
}

public class Money : IComparable<Money>, IEquatable<Money>, IFormattable
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    public int CompareTo(Money? other)
    {
        if (other is null) return 1;
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot compare different currencies");
        return Amount.CompareTo(other.Amount);
    }

    public bool Equals(Money? other) =>
        other is not null &&
        Amount == other.Amount &&
        Currency == other.Currency;

    public string ToString(string? format, IFormatProvider? formatProvider)
    {
        return format switch
        {
            "C" => $"{Currency} {Amount:N2}",
            "S" => $"{Amount:N2}",
            _ => $"{Amount} {Currency}"
        };
    }
}

Explicit Interface Implementation

When two interfaces have conflicting members, or you want to hide interface members from the class’s public API.

public interface IDrawable
{
    void Draw();
}

public interface IPrintable
{
    void Draw(); // Same name, different meaning
}

public class Document : IDrawable, IPrintable
{
    // Explicit implementation - only accessible through interface
    void IDrawable.Draw()
    {
        Console.WriteLine("Drawing to screen");
    }

    void IPrintable.Draw()
    {
        Console.WriteLine("Drawing to printer");
    }

    // Public method available on the class itself
    public void Display()
    {
        Console.WriteLine("Displaying document");
    }
}

// Usage
var doc = new Document();
doc.Display();           // OK
// doc.Draw();           // Error - not accessible directly

IDrawable drawable = doc;
drawable.Draw();         // "Drawing to screen"

IPrintable printable = doc;
printable.Draw();        // "Drawing to printer"

Default Interface Methods (C# 8.0)

Add methods with implementations to interfaces without breaking existing implementers.

public interface ILogger
{
    void Log(string message);

    // Default implementation - existing implementers don't break
    void LogWarning(string message) =>
        Log($"WARNING: {message}");

    void LogError(string message) =>
        Log($"ERROR: {message}");

    void LogError(Exception ex) =>
        LogError($"{ex.GetType().Name}: {ex.Message}");
}

public class ConsoleLogger : ILogger
{
    public void Log(string message) =>
        Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {message}");

    // Can override default implementations
    public void LogError(string message)
    {
        Console.ForegroundColor = ConsoleColor.Red;
        Log($"ERROR: {message}");
        Console.ResetColor();
    }
}

// Usage
ILogger logger = new ConsoleLogger();
logger.Log("Info");           // Custom implementation
logger.LogWarning("Careful"); // Default implementation
logger.LogError("Oops");      // Overridden implementation

Static Abstract Members (C# 11)

Define static members in interfaces for generic math and factory patterns.

public interface IAddable<T> where T : IAddable<T>
{
    static abstract T operator +(T left, T right);
    static abstract T Zero { get; }
}

public readonly struct Fraction : IAddable<Fraction>
{
    public int Numerator { get; }
    public int Denominator { get; }

    public Fraction(int numerator, int denominator)
    {
        Numerator = numerator;
        Denominator = denominator;
    }

    public static Fraction Zero => new(0, 1);

    public static Fraction operator +(Fraction left, Fraction right) =>
        new(
            left.Numerator * right.Denominator + right.Numerator * left.Denominator,
            left.Denominator * right.Denominator);
}

// Generic method using static interface members
public static T Sum<T>(IEnumerable<T> values) where T : IAddable<T>
{
    T result = T.Zero;
    foreach (var value in values)
    {
        result = result + value;
    }
    return result;
}

Inheritance

Basic Inheritance

public class Animal
{
    public string Name { get; set; }

    public virtual void Speak()
    {
        Console.WriteLine("Some sound");
    }

    public void Eat()
    {
        Console.WriteLine($"{Name} is eating");
    }
}

public class Dog : Animal
{
    public string Breed { get; set; }

    public override void Speak()
    {
        Console.WriteLine("Woof!");
    }

    public void Fetch()
    {
        Console.WriteLine($"{Name} is fetching");
    }
}

public class Cat : Animal
{
    public override void Speak()
    {
        Console.WriteLine("Meow!");
    }
}

Virtual, Override, and New

public class BaseClass
{
    public virtual void VirtualMethod()
    {
        Console.WriteLine("Base virtual");
    }

    public void NonVirtualMethod()
    {
        Console.WriteLine("Base non-virtual");
    }
}

public class DerivedClass : BaseClass
{
    // Override - replaces base implementation polymorphically
    public override void VirtualMethod()
    {
        Console.WriteLine("Derived override");
    }

    // New - hides base member (not polymorphic)
    public new void NonVirtualMethod()
    {
        Console.WriteLine("Derived new");
    }
}

// Demonstration
BaseClass b = new DerivedClass();
b.VirtualMethod();    // "Derived override" (polymorphic)
b.NonVirtualMethod(); // "Base non-virtual" (hidden, not replaced)

DerivedClass d = new DerivedClass();
d.VirtualMethod();    // "Derived override"
d.NonVirtualMethod(); // "Derived new"

Abstract Classes and Methods

public abstract class Shape
{
    public string Color { get; set; }

    // Abstract - must be implemented by derived class
    public abstract double Area { get; }
    public abstract double Perimeter { get; }

    // Virtual - can be overridden
    public virtual void Draw()
    {
        Console.WriteLine($"Drawing {Color} shape");
    }

    // Regular - inherited as-is
    public void Describe()
    {
        Console.WriteLine($"{Color} shape: Area={Area:F2}, Perimeter={Perimeter:F2}");
    }
}

public class Rectangle : Shape
{
    public double Width { get; set; }
    public double Height { get; set; }

    public override double Area => Width * Height;
    public override double Perimeter => 2 * (Width + Height);
}

public class Circle : Shape
{
    public double Radius { get; set; }

    public override double Area => Math.PI * Radius * Radius;
    public override double Perimeter => 2 * Math.PI * Radius;

    public override void Draw()
    {
        base.Draw(); // Call base implementation
        Console.WriteLine($"Circle with radius {Radius}");
    }
}

Sealed Methods and Classes

public class Animal
{
    public virtual void Move() { }
}

public class Bird : Animal
{
    // Seal to prevent further overriding
    public sealed override void Move()
    {
        Console.WriteLine("Flying");
    }
}

public class Penguin : Bird
{
    // Error: cannot override sealed method
    // public override void Move() { }
}

// Sealed class - cannot be inherited
public sealed class String { }

Constructor Chaining

public class Person
{
    public string Name { get; }
    public int Age { get; }

    public Person(string name) : this(name, 0) { }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}

public class Employee : Person
{
    public string Department { get; }

    // Call base constructor
    public Employee(string name, string department)
        : base(name)
    {
        Department = department;
    }

    public Employee(string name, int age, string department)
        : base(name, age)
    {
        Department = department;
    }
}

Polymorphism

Runtime Polymorphism

public abstract class PaymentProcessor
{
    public abstract Task<bool> ProcessAsync(decimal amount);
    public abstract decimal CalculateFee(decimal amount);
}

public class CreditCardProcessor : PaymentProcessor
{
    public override async Task<bool> ProcessAsync(decimal amount)
    {
        // Credit card processing logic
        await Task.Delay(100);
        return true;
    }

    public override decimal CalculateFee(decimal amount) =>
        amount * 0.029m + 0.30m; // 2.9% + $0.30
}

public class BankTransferProcessor : PaymentProcessor
{
    public override async Task<bool> ProcessAsync(decimal amount)
    {
        // Bank transfer processing logic
        await Task.Delay(200);
        return true;
    }

    public override decimal CalculateFee(decimal amount) =>
        Math.Min(amount * 0.01m, 5.00m); // 1% max $5
}

// Polymorphic usage
public class PaymentService
{
    public async Task<bool> ProcessPayment(
        PaymentProcessor processor,
        decimal amount)
    {
        decimal fee = processor.CalculateFee(amount);
        decimal total = amount + fee;
        return await processor.ProcessAsync(total);
    }
}

Interface-Based Polymorphism

Prefer interfaces over inheritance for polymorphism.

public interface INotificationSender
{
    Task SendAsync(string recipient, string message);
}

public class EmailSender : INotificationSender
{
    public async Task SendAsync(string recipient, string message)
    {
        // Send email
        await Task.CompletedTask;
    }
}

public class SmsSender : INotificationSender
{
    public async Task SendAsync(string recipient, string message)
    {
        // Send SMS
        await Task.CompletedTask;
    }
}

public class PushNotificationSender : INotificationSender
{
    public async Task SendAsync(string recipient, string message)
    {
        // Send push notification
        await Task.CompletedTask;
    }
}

// Polymorphic usage
public class NotificationService
{
    private readonly IEnumerable<INotificationSender> senders;

    public NotificationService(IEnumerable<INotificationSender> senders)
    {
        this.senders = senders;
    }

    public async Task NotifyAllAsync(string recipient, string message)
    {
        var tasks = senders.Select(s => s.SendAsync(recipient, message));
        await Task.WhenAll(tasks);
    }
}

Choosing Between Interfaces and Abstract Classes

Both define contracts, but they serve different purposes.

Use an interface when:

  • Multiple unrelated types need to share a capability (like IDisposable, IComparable)
  • You want a type to support multiple contracts (a class can implement many interfaces)
  • The contract is purely about behavior, not about shared implementation
  • You want to enable dependency injection and testability

Use an abstract class when:

  • Types share both behavior AND implementation
  • You need to define state (fields) that derived classes inherit
  • You want to provide a partial implementation as a starting point
  • The relationship is truly “is-a” (a Dog IS an Animal)

Why this matters: Inheritance creates tight coupling. When you inherit from a class, you take on its implementation details and any changes to the base class can break derived classes. Interfaces are pure contracts—implementing IComparable doesn’t tie you to any specific implementation.

Common pattern: Use an interface for the public contract and an abstract class for shared implementation among related types.

// Interface for the contract
public interface IPaymentProcessor
{
    Task<PaymentResult> ProcessAsync(Payment payment);
}

// Abstract class for shared implementation among similar processors
public abstract class BasePaymentProcessor : IPaymentProcessor
{
    protected abstract string ProviderName { get; }

    public async Task<PaymentResult> ProcessAsync(Payment payment)
    {
        ValidatePayment(payment); // Shared logic
        return await ProcessWithProviderAsync(payment); // Provider-specific
    }

    protected abstract Task<PaymentResult> ProcessWithProviderAsync(Payment payment);

    protected virtual void ValidatePayment(Payment payment)
    {
        // Shared validation logic
    }
}

Composition Over Inheritance

Favor object composition for code reuse.

// Inheritance approach - tight coupling
public class LoggingRepository<T> : Repository<T>
{
    private readonly ILogger logger;

    protected override void Add(T entity)
    {
        logger.Log($"Adding {typeof(T).Name}");
        base.Add(entity);
    }
}

// Composition approach - flexible
public class Repository<T>
{
    private readonly IDataStore<T> dataStore;
    private readonly ILogger? logger;

    public Repository(IDataStore<T> dataStore, ILogger? logger = null)
    {
        this.dataStore = dataStore;
        this.logger = logger;
    }

    public void Add(T entity)
    {
        logger?.Log($"Adding {typeof(T).Name}");
        dataStore.Save(entity);
    }
}

// Decorator pattern for cross-cutting concerns
public class LoggingRepositoryDecorator<T> : IRepository<T>
{
    private readonly IRepository<T> inner;
    private readonly ILogger logger;

    public LoggingRepositoryDecorator(IRepository<T> inner, ILogger logger)
    {
        this.inner = inner;
        this.logger = logger;
    }

    public void Add(T entity)
    {
        logger.Log($"Adding {typeof(T).Name}");
        inner.Add(entity);
    }

    // Delegate other methods...
}

Type Checking and Casting

object obj = GetSomething();

// Type checking with pattern matching
if (obj is string text)
{
    Console.WriteLine(text.ToUpper());
}

// Multiple type patterns
string result = obj switch
{
    string s => s.ToUpper(),
    int i => i.ToString(),
    null => "null",
    _ => obj.ToString() ?? ""
};

// is with negation
if (obj is not null)
{
    Console.WriteLine(obj.ToString());
}

// as operator (returns null if cast fails)
var customer = obj as Customer;
if (customer != null)
{
    customer.ProcessOrder();
}

Covariance and Contravariance

// Covariance (out) - can use derived where base is expected
public interface IReadOnlyRepository<out T>
{
    T GetById(int id);
    IEnumerable<T> GetAll();
}

// Can assign IReadOnlyRepository<Dog> to IReadOnlyRepository<Animal>
IReadOnlyRepository<Animal> animals = new AnimalRepository();
IReadOnlyRepository<Animal> dogs = new DogRepository(); // Covariant

// Contravariance (in) - can use base where derived is expected
public interface IComparer<in T>
{
    int Compare(T x, T y);
}

// Can use IComparer<Animal> where IComparer<Dog> expected
IComparer<Animal> animalComparer = new AnimalComparer();
IComparer<Dog> dogComparer = animalComparer; // Contravariant

Version History

Feature Version Significance
Interfaces C# 1.0 Contract definitions
Generics in interfaces C# 2.0 Type-safe contracts
Covariance/contravariance C# 4.0 Flexible generic interfaces
Default interface methods C# 8.0 Add members without breaking
Static abstract members C# 11 Generic math, factory patterns

Key Takeaways

Program to interfaces, not implementations: Define behavior through interfaces and inject dependencies.

Favor composition over inheritance: Inheritance creates tight coupling. Use composition and delegation for flexibility.

Keep inheritance hierarchies shallow: Deep hierarchies become hard to understand and maintain.

Use sealed by default: Unless a class is designed for inheritance, seal it to communicate intent and enable optimizations.

Default interface methods for evolution: Add new members to interfaces with default implementations to avoid breaking existing implementers.

Explicit implementation for conflicts: When interface members conflict or shouldn’t be part of the public API, use explicit implementation.

Found this guide helpful? Share it with your team:

Share on LinkedIn