Interfaces and inheritance in modern C# (revisited)

Interfaces and inheritance in modern C# (revisited)

Not your grandparents' OOP anymore

In traditional Object-Oriented Programming (OOP), interfaces are a common way to ensure flexibility, testability, and adherence to SOLID principles. However, in modern C#, we can often avoid interfaces by embracing a more functional programming style. This can simplify code in certain scenarios, improve readability, and allow for quick adaptability, while still retaining the key benefits of interfaces.

What is an interface?

From Wiki:

In object-oriented programming, an interface or protocol type[a] is a data type that acts as an abstraction of a class. It describes a set of method signatures, the implementations of which may be provided by multiple classes that are otherwise not necessarily related to each other.[1] A class which provides the methods listed in a protocol is said to adopt the protocol,[2] or to implement the interface.

Well, what else do we know about interfaces? From Gang of Four 1995:

Program to an interface, not an implementation.

What does this mean in practice? It encourages developers to write code that depends on abstractions (interfaces or abstract classes) rather than specific concrete implementations. This leads to more flexible, maintainable, and reusable code.

Show me the code

Without programming to an interface, you would couple your application directly to a specific implementation:

public class NotificationService(EmailSender emailSender)
{
    public void Notify(string message)
    {
        emailSender.SendEmail(message);
    }
}

In this case, NotificationService is tightly coupled to EmailSender. If you want to add another notification method (like SMS), you’d have to modify NotificationService.

Here’s a better approach using the “program to an interface” principle:

public interface INotificationSender
{
    void Send(string message);
}

public class EmailSender : INotificationSender
{
    public void Send(string message)
    {
        Console.WriteLine($"Sending Email: {message}");
    }
}

public class SmsSender : INotificationSender
{
    public void Send(string message)
    {
        Console.WriteLine($"Sending SMS: {message}");
    }
}

public class NotificationService(INotificationSender notificationSender)
{
    public void Notify(string message)
    {
        notificationSender.Send(message);
    }
}

public class Program
{
    public static void Main(string[] args)
    {
        var emailSender = new EmailSender();
        var notificationService = new NotificationService(emailSender);
        notificationService.Notify("Hello via Email!");

        var smsSender = new SmsSender();
        notificationService = new NotificationService(smsSender);
        notificationService.Notify("Hello via SMS!");
    }
}

But we all already know and respect this. You can add more notification methods (e.g., PushNotificationSender) without changing the NotificationService code. You can easily swap the INotificationSender implementation with mock-in unit tests. The NotificationService class is open for extension (by adding more INotificationSender implementations) but closed for modification (you don’t need to change the class when adding new senders).

Interface is a contract, right?

You hear that a million times. Interface is a contract. No, it is not. The interface is a syntax, the contract is semantics.

Imagine we have a banking system with multiple types of accounts (e.g., SavingsAccount, CheckingAccount). The “contract” for any account should ensure that the balance can’t go below zero after a withdrawal. An interface alone can’t enforce this, but an abstract class can.

public interface IAccount
{
    decimal Balance { get; }
    void Deposit(decimal amount);
    void Withdraw(decimal amount);
}

The IAccount interface only specifies syntax. It says that any account should have a Balance, and methods for Deposit and Withdraw. However, it doesn’t define the contract (i.e., the semantics that the balance should never go below zero).

Now we enforce this contract using an abstract class:

public abstract class AccountContract : IAccount
{
    private decimal balance;

    public decimal Balance => balance;

    public void Deposit(decimal amount)
    {
        ArgumentException.ThrowIfNullOrEmpty(amount.ToString(), nameof(amount));
        if (amount <= 0)
        {
            throw new ArgumentException("Deposit amount must be positive.");
        }
        _balance += amount;
        OnDeposit(amount);  // Allow subclasses to extend deposit logic
    }

    public void Withdraw(decimal amount)
    {
        ArgumentException.ThrowIfNullOrEmpty(amount.ToString(), nameof(amount));
        if (amount <= 0)
        {
            throw new ArgumentException("Withdraw amount must be positive.");
        }

        if (_balance - amount < 0)
        {
            throw new InvalidOperationException("Insufficient funds.");
        }

        _balance -= amount;
        OnWithdraw(amount);  // Allow subclasses to extend withdrawal logic
    }

    // Abstract methods that must be implemented by subclasses for specific logic
    protected abstract void OnDeposit(decimal amount);
    protected abstract void OnWithdraw(decimal amount);
}

Here, the AccountContract class enforces several semantic rules:

  • A deposit must be a positive number.

  • A withdrawal can’t result in a negative balance.

This logic is locked in the abstract class, ensuring that any subtype will follow these rules, which wouldn’t be possible with just an interface.

Implementation in a subclass:

public class SavingsAccount(decimal interestRate) : AccountContract
{
    protected override void OnDeposit(decimal amount)
    {
        Console.WriteLine($"SavingsAccount: Deposit of {amount:C} applied. Interest rate: {interestRate:P}.");
    }

    protected override void OnWithdraw(decimal amount)
    {
        Console.WriteLine($"SavingsAccount: Withdrawal of {amount:C} applied.");
    }
}

Interfaces alone don’t convey behavior (the “contract”). In real systems, semantics are critical to ensure correctness (e.g., ensuring accounts can’t have negative balances). Abstract classes are more useful for defining and enforcing this behavior, making the system more robust.

What about inheritance?

There is another famous postulate in the OOP community that originates from the Gang Of Four book:

Favor object composition over class inheritance

You might think: “Wait, but you just showed the example of a contract enforcing by using subclassing, what are you talking about?”

Well, OOP is hard and often contradictory. We could refactor code to follow this principle, but instead, let's look at some other ways of dealing with complexity that have no contradictions and very few principles you need to know to write SOLID code.

How modern C# and FP vibe could spice up things?

To take a more functional programming (FP) approach in modern C# without relying on interfaces, you can use delegates, higher-order functions, and pure functions. These techniques emphasize composition, immutability, and function over object-oriented inheritance.

Let’s revisit the banking system example, but instead of using interfaces and abstract classes, we’ll refactor it using FP principles like delegates and higher-order functions.

Step 1: Define Pure Functions

// Delegate to represent a banking operation
public delegate (decimal NewBalance, string Message) BankingOperation(decimal balance, decimal amount);

// Pure function to handle deposits
public static BankingOperation Deposit => (balance, amount) =>
    amount <= 0
        ? (balance, "Deposit amount must be positive.")
        : (balance + amount, $"Deposited {amount:C}. New balance: {balance + amount:C}");

// Pure function to handle withdrawals
public static BankingOperation Withdraw => (balance, amount) =>
    amount <= 0
        ? (balance, "Withdraw amount must be positive.")
        : balance - amount < 0
            ? (balance, "Insufficient funds.")
            : (balance - amount, $"Withdrew {amount:C}. New balance: {balance - amount:C}");

These functions take the current balance and amount as arguments and return a tuple representing the new balance and a message. This keeps the functions pure and side-effect-free, as they don’t mutate any external state.

Step 2: Higher-Order Function for Business Rules

Now, we can create a higher-order function that accepts these operations and applies additional business rules. For example, we may want to log every operation or prevent negative balances.

public static BankingOperation EnforceMinimumBalance(BankingOperation operation) => 
    (decimal balance, decimal amount) =>
    {
        var result = operation(balance, amount);
        return result.NewBalance < 0 
            ? (balance, "Operation denied: Balance cannot be negative.") 
            : result;
    };

This higher-order function takes a BankingOperation delegate (like Deposit or Withdraw) and wraps it with an additional rule, ensuring the balance can’t be negative.

Step 3: Composing Operations

Next, we can compose these functions to create an account with specific rules and behaviors. Instead of relying on inheritance or interfaces, we pass around these pure functions and higher-order functions.

public class Account
{
    public decimal Balance { get; private set; }
    private readonly BankingOperation _deposit;
    private readonly BankingOperation _withdraw;

    public Account(BankingOperation deposit, BankingOperation withdraw)
    {
        _deposit = deposit;
        _withdraw = withdraw;
        Balance = 0;
    }

    public void PerformDeposit(decimal amount)
    {
        var result = _deposit(Balance, amount);
        Balance = result.NewBalance;
        Console.WriteLine(result.Message);
    }

    public void PerformWithdrawal(decimal amount)
    {
        var result = _withdraw(Balance, amount);
        Balance = result.NewBalance;
        Console.WriteLine(result.Message);
    }
}

Here, the Account class holds the BankingOperation functions for deposit and withdrawal, which are passed during instantiation. This allows us to compose behavior by passing different functions or wrapping them with rules.

Step 4: Using the Account

Now we can instantiate an Account and use the composed functions.

public class Program
{
    public static void Main()
    {
        // Create an account with the enforced balance rule applied to both deposit and withdraw operations
        var account = new Account(
            EnforceMinimumBalance(Deposit),
            EnforceMinimumBalance(Withdraw)
        );

        account.PerformDeposit(100); // Deposited 100.00. New balance: 100.00
        account.PerformWithdrawal(50); // Withdrew 50.00. New balance: 50.00
        account.PerformWithdrawal(100); // Operation denied: Balance cannot be negative.
    }
}

By utilizing pure functions, higher-order functions, immutability, and composition we achieved all the points of SOLID by applying functional design without using any interfaces or inheritance.

Key Advantages of This Approach

  1. Immutability: The operations (Deposit and Withdrawal) are pure functions. They don’t mutate external states directly, making the system easier to reason about and test.

  2. Function Composition: By using higher-order functions, we can easily add or remove rules (like enforcing minimum balance) without modifying the core logic of Deposit or Withdraw.

  3. No Need for Inheritance: There’s no inheritance hierarchy or need for abstract classes or interfaces. Everything is based on function composition.

  4. Testability: Since all logic is encapsulated in pure functions, it’s easy to test each piece in isolation, without worrying about side effects.

Interface is about polymorphism

Another problem with the interfaces I see often is that they used in "auto-pilot“ mode without even thinking whether you need polymorphism or not. The most common excuse I hear is that we need interface for mocking when testing code. Test suites with heavy mocking is a topic for another article altogether. But that's a bad, bad excuse.

Interfaces are primarily about polymorphism and providing a way to have multiple implementations. The fundamental reason to introduce an interface is to allow different behaviors or strategies to be swapped in and out. Even when we speak about "specifying needs," the ultimate reason we want this specification at a higher level (the domain) and its implementation at a lower level (the infrastructure) is to enable flexibility and substitution — a core facet of polymorphism.

Having only one implementation of a given interface is a code smell.

I bet that this kind of code looks very familiar to you and you have seen it a thousand times:

public interface IFileReader
{
    string ReadFile(string path);
}

public interface IFileWriter
{
    void WriteFile(string path, string content);
}

public interface ITransformer
{
    string Transform(string content);
}
public class FileReader : IFileReader
{
    public string ReadFile(string path) => System.IO.File.ReadAllText(path);
}

public class FileWriter : IFileWriter
{
    public void WriteFile(string path, string content) => System.IO.File.WriteAllText(path, content);
}

public class UpperCaseTransformer : ITransformer
{
    public string Transform(string content) => content.ToUpper();
}
public class FileProcessor
{
    private readonly IFileReader _reader;
    private readonly ITransformer _transformer;
    private readonly IFileWriter _writer;

    public FileProcessor(IFileReader reader, ITransformer transformer, IFileWriter writer)
    {
        _reader = reader;
        _transformer = transformer;
        _writer = writer;
    }

    public void ProcessFile(string inputPath, string outputPath)
    {
        var content = _reader.ReadFile(inputPath);
        var transformedContent = _transformer.Transform(content);
        _writer.WriteFile(outputPath, transformedContent);
    }
}

This is a traditional approach using interfaces and dependency injection. In a typical application most of the time you will have a single implementation of FileReader and FileWriter. If you ever need to read or write somewhere else probably your interface definitions are wrong anyway and you need to have a better one, more abstract. To which degree is more abstract? It depends. Most probably you need to start introducing more interfaces:

public interface IReader<TResult>
{
    TResult Read(ISource source);
}

public interface ISource
{
    Stream Open();
}

If you don't know the future don't optimize for it, it is a lot of effort and probably all for nothing, because your assumptions most probably are wrong. Build iteratively as soon as you know more details about the problem. Refactor, repeat.

The moral of the story is that if you have 1:1 interface—implementation pairs in your code that is a smell. You could just have a file utilities class:

public static class FileUtilities
{
    public static string Read(string path)
    {
        return System.IO.File.ReadAllText(path);
    }

    public static void Write(string path, string content)
    {
        System.IO.File.WriteAllText(path, content);
    }
}

Unlike reading or writing to a file, ITransformer could have and probably will have multiple implementations, hence it requires polymorphic behavior and that is a proper usage of interface.

public class FileProcessor(ITransformer transformer)
{
    public void ProcessFile(string inputPath, string outputPath)
    {
        var content = FileUtilities.Read(inputPath);
        var transformedContent = transformer.Transform(content);
        FileUtilities.Write(outputPath, transformedContent);
    }
}

Consumer code looks cleaner and simpler. We only need to inject ITransformer now for content transformation behavior. And it could have many implementations.

It is just a delegate

Let's look one more time at the definition of ITransformer:

public interface ITransformer
{
    string Transform(string content);
}

In essence, it could be represented as a delegate:

public delegate string TransformerDelegate(string content)

With that, we could create a functional version of FileProcessor:

public class FunctionalFileProcessor
{
    private readonly TransformerDelegate _transform;

    public FunctionalFileProcessor(TransformerDelegate transform) => _transform = transform;

    public void ProcessFile(string inputPath, string outputPath)
    {
        var content = FileUtilities.Read(inputPath);
        var transformedContent = _transform(content);
        FileUtilities.Write(outputPath, transformedContent);
    }
}

We can use it like this:

class Program
{
    static void Main(string[] args)
    {
        var processor = new FunctionalFileProcessor(TransformToUpperAndReverse);
        processor.ProcessFile("input.txt", "output.txt");
    }

    static string TransformToUpperAndReverse(string content)
      => new string(content.ToUpper().Reverse().ToArray());
}

The transformation logic is still dynamic and flexible, allowing you to inject different behaviors at runtime, adhering to the Open/Closed Principle (OCP). This maintains the pipeline’s flexibility without needing to define interfaces. The static utility class handles the fixed I/O operations, while the dynamic part (transformation) remains customizable via the delegate. This separation makes the system both simple and extensible.

Anticipating the question about dependency injection, the answer is yes, you could easily inject delegates:

services.AddSingleton<TransformerDelegate>(content => TransformToUpperAndReverse(content));
services.AddTransient<FunctionalFileProcessor>();

Mushrooming Interfaces

Let’s look at “I” in SOLID, which is the Interface Segregation Principle.

No code should be forced to depend on methods it does not use. ISP splits interfaces that are very large into smaller and more specific ones so that clients will only have to know about the methods that are of interest to them

Such shrunken interfaces are also called role interfaces. ISP is intended to keep a system decoupled and thus easier to refactor, change, and redeploy.

Yeah, right. Good on the paper. What do we have in reality? It all starts with a simple interface like IRepository:

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

and eventually, with time, it grows like crazy because we want to add more functionality, so it ends up with something like this (and believe me, I have seen repositories much uglier than in this naive example):

public interface IRepository<T> where T : class
{
    T GetById(int id);
    IEnumerable<T> GetAll();
    IEnumerable<T> Find(Expression<Func<T, bool>> predicate);
    void Add(T entity);
    void AddRange(IEnumerable<T> entities);
    void Update(T entity);
    void Remove(T entity);
    void RemoveRange(IEnumerable<T> entities);
}

What if I have a piece of code that needs only GetById and nothing more? How can I do that without passing the whole interface? How would you segregate such an interface? It does not conform to the Principle of the least privilege.

On the other hand, when using delegates you force segregation by design. If a class to operate requires only GetById that’s the only dependency it will need:

public delegate T GetByIdDelegate(int id) where T : class;

public class Accountant(GetByIdDelegate getByIdDelegate)
{
    private readonly GetByIdDelegate _getById = getByIdDelegate;

    public void TransferFunds(int fromAccountId, int toAccountId, decimal amount)
    {
        var fromAccount = _getById(fromAccountId);
        var toAccount = _getById(toAccountId);
        ...
    }
}

Conclusion

So, what's the takeaway here? Interfaces aren’t the magical contract enforcers they’re often made out to be—at least, not always. Sure, they can be useful when you need polymorphism, but if you find yourself writing an interface for a class that only has one implementation, that's a little like buying a fancy sports car just to drive it in your backyard. Overkill, right?

Instead of jumping straight to interfaces and abstractions, sometimes it’s better to keep things simple. Static methods, delegates, and good old function composition can save you from a sea of boilerplate code and unnecessary complexity. Plus, you still enjoy all the cool features like testability and flexibility, without needing an interface for everything under the sun.

By embracing a functional approach, you can keep your code flexible, simple, and easy to extend—no interfaces required. So next time you find yourself adding another "I" to your codebase, take a step back and ask: "Do I need this, or can I handle this with a delegate and a bit of functional flair?" Sometimes, the simplest solution is the best one. Plus, fewer interfaces mean fewer files, and that’s a win for everyone!