If you have a DI container, you don't need inheritance

# programming# dotnet# csharp# architecture
If you have a DI container, you don't need inheritanceThatGhost

Part 2 of a series of unconventional programming opinions This one tends to get a reaction. So let...

Part 2 of a series of unconventional programming opinions

This one tends to get a reaction. So let me be clear about what I'm actually saying before you close the tab.

I'm not saying inheritance is always wrong. I'm saying that if your project already uses a dependency injection container — and most modern backend projects do — then you're already holding every tool you need to do what inheritance does. Using both at the same time doesn't give you more power. It gives you two competing mental models pulling the codebase in different directions.

Pick one. And if DI is already in the project, let it do the job.


What inheritance is actually promising you

Inheritance shows up in codebases for two main reasons: code reuse and polymorphic behaviour. A base class that all your repositories extend so they share CRUD logic. A base handler that all your request handlers extend so they share validation. Abstract methods that force subclasses to implement specific behaviour.

These are real problems worth solving. But inheritance isn't the only way to solve them — and in a DI heavy project, it's usually not the cleanest way either.


Reuse: just use a service

The most common argument for inheritance is reuse. You have shared logic — database calls, logging, mapping — and you want it available everywhere without copy pasting.

But that's exactly what services are for. Instead of a BaseRepository<T> that everything inherits from, you write a QueryService (or a DataAccessService, or whatever fits your domain) that exposes the shared logic as methods, and you inject it wherever it's needed.

// Instead of this:
public class ProductRepository : BaseRepository<Product> { }

// Do this:
public class ProductService(IQueryService<Product> query)
{
    public async Task<Product?> GetById(Guid id) => 
        await query.FindAsync(id);
}
Enter fullscreen mode Exit fullscreen mode

The logic lives in one place. It's testable in isolation. Any service that needs it just asks for it. And you haven't introduced a class hierarchy that locks your types into a rigid vertical structure.


Polymorphism: that's what interfaces are for

The second argument is behaviour — specifically, the ability to swap one implementation for another at runtime or test time. A PaymentProcessor that handles Stripe differently than PayPal. A NotificationSender that emails in production and does nothing in tests.

Interfaces handle this cleanly. You define the contract, and each implementation stands on its own. No shared base class, no fragile inheritance chain where changing the parent breaks six children you didn't expect.

public interface IPaymentProcessor
{
    Task<PaymentResult> Charge(decimal amount, string token);
}

public class StripeProcessor(HttpClient http) : IPaymentProcessor { ... }
public class PayPalProcessor(HttpClient http) : IPaymentProcessor { ... }
Enter fullscreen mode Exit fullscreen mode

Your DI container decides which one gets injected. The calling code never knows which implementation it's talking to. That's the polymorphism promise — and you got it without touching abstract or override once.


The hidden cost: base classes need dependencies too

Here's a concrete problem that doesn't show up until a project grows. Base classes aren't isolated — they often need services of their own. And when they do, every single subclass has to carry those dependencies through its constructor, even if it never directly uses them.

Say you have a BaseService that handles logging and auditing. You start with one dependency:

public abstract class BaseService(ILogger logger) { ... }

public class OrderService(ILogger logger) : BaseService(logger) { }
public class InvoiceService(ILogger logger) : BaseService(logger) { }
public class ProductService(ILogger logger) : BaseService(logger) { }
// ... 17 more
Enter fullscreen mode Exit fullscreen mode

Manageable. Then six months later you need to add auditing to the base class. BaseService now needs an IAuditService. So you update the base constructor — and now you have to touch every single subclass to thread that new dependency through.

// You change this:
public abstract class BaseService(ILogger logger, IAuditService audit) { ... }

// And now you're doing this across 20 files:
public class OrderService(ILogger logger, IAuditService audit) 
    : BaseService(logger, audit) { }

public class InvoiceService(ILogger logger, IAuditService audit) 
    : BaseService(logger, audit) { }

public class ProductService(ILogger logger, IAuditService audit) 
    : BaseService(logger, audit) { }

public class ShipmentService(ILogger logger, IAuditService audit) 
    : BaseService(logger, audit) { }

public class CustomerService(ILogger logger, IAuditService audit) 
    : BaseService(logger, audit) { }

// ... 15 more files, same change, every one of them
Enter fullscreen mode Exit fullscreen mode

One dependency added to a base class. Twenty files changed. Zero new behaviour delivered.

With a service based approach, this problem doesn't exist. The auditing logic lives in its own injected service. Only the classes that actually need auditing ask for it. Adding it to more places is opt in, not a cascading update across the whole hierarchy.


Why you should not mix the two

On its own, having to check whether a piece of logic comes from a base class or an injected service is a small task. But you don't do it once. You do it for every class you touch, every time you touch it. That compounds. Over a large enough codebase, that constant context switching quietly eats a significant chunk of your day.

The mental models behind the two approaches are also not equally complex. A dependency graph is straightforward: a class declares what it needs, the container provides it, you can see the full picture from the constructor. Inheritance is a different story. Behaviour is hidden up the chain. Logic you didn't write and didn't ask for shows up in your class because something three levels up decided to put it there. And if that base class itself extends another base class, which extends another — and yes, this happens — you're now archaeology, not programming.

That layered inheritance problem deserves its own callout. Base classes having base classes having base classes is not a theoretical concern. It shows up in real projects, usually gradually, one "it made sense at the time" abstraction at a time. By the time someone new joins the team, understanding what a single service actually does requires tracing a chain that nobody fully holds in their head anymore.

Keeping everything in the dependency graph doesn't eliminate complexity, but it keeps it visible. What a class does is what its constructor says it does. No hidden behaviour, no invisible ancestors, no archaeology required.


The one exception worth naming

There's a legitimate use of inheritance that services and interfaces don't fully replace: extending framework types. ASP.NET controllers extending ControllerBase. EF entities occasionally extending a common audit base. That kind of thing is mostly an API contract imposed by the framework, not a design choice you're making yourself.

That's fine. The principle is about your domain logic — the business layer, the services, the orchestration. Don't build inheritance hierarchies there when your DI container is already set up to do the job more transparently.


The promise of both inheritance and dependency injection is the same thing: write something once, use it many places, swap behaviour without rewriting callers. If you have a DI container, you already have the infrastructure to deliver on that promise. You don't need another mechanism fighting for the same ground.

Keep the hierarchy flat. Let the container wire things together. Your future self will be able to read it faster.