Katabench
Start free
← All posts

Dependency inversion in C# is not about your DI container

Ask ten C# developers what the D in SOLID stands for and most will say "dependency injection." Half of those will follow up with "you know, like registering services in the container."

That answer is so common it has stopped sounding wrong, but it is. Dependency injection is a technique for handing an object its collaborators instead of letting it build them. Dependency inversion is a statement about which direction your dependencies are allowed to point. You can have a fully wired DI container and still violate dependency inversion on every line. You can also obey dependency inversion with no container at all.

The principle, stated plainly: high-level policy should not depend on low-level detail. Both should depend on an abstraction, and that abstraction should be owned by the policy. The second half of that sentence is the part everyone drops, and it is the part that does all the work.

The shape of a violation

Here is an order service that almost every codebase has shipped at some point:

public sealed class OrderService
{
    public void PlaceOrder(Order order)
    {
        using var db = new AppDbContext();
        db.Orders.Add(order);
        db.SaveChanges();

        var sender = new SmtpEmailSender("smtp.internal", 587);
        sender.Send(order.CustomerEmail, "Order confirmed", $"Thanks for order {order.Id}.");
    }
}

This compiles, it works, and it is the thing dependency inversion exists to prevent. OrderService is high-level policy: it knows what placing an order means. AppDbContext and SmtpEmailSender are low-level details: how rows get persisted, how bytes reach a mail server. By new-ing them up, the policy now depends directly on the details. The arrow points from business logic to infrastructure, which is exactly backwards.

The pain is not theoretical. You cannot unit test PlaceOrder without a live SMTP server and a real database connection. You cannot swap SMTP for a queue without editing the policy. And the class that should express your domain is now coupled to two libraries that have nothing to do with ordering.

Inverting the arrow

The fix is not "inject the dependencies." It is "define the abstraction in the domain, and make infrastructure implement it." The interface lives next to the policy that needs it, expressed in the policy's language:

// Owned by the domain. Phrased in domain terms, not SMTP terms.
public interface IOrderNotifier
{
    void NotifyConfirmed(Order order);
}

public interface IOrderRepository
{
    void Add(Order order);
}
public sealed class OrderService(IOrderRepository orders, IOrderNotifier notifier)
{
    public void PlaceOrder(Order order)
    {
        orders.Add(order);
        notifier.NotifyConfirmed(order);
    }
}

Now the infrastructure depends on the domain, not the other way around:

// Lives in the infrastructure project, references the domain.
public sealed class SmtpOrderNotifier(IEmailSender email) : IOrderNotifier
{
    public void NotifyConfirmed(Order order) =>
        email.Send(order.CustomerEmail, "Order confirmed", $"Thanks for order {order.Id}.");
}

public sealed class EfOrderRepository(AppDbContext db) : IOrderRepository
{
    public void Add(Order order) => db.Orders.Add(order);
}

The arrows have flipped. OrderService knows about IOrderNotifier, which it owns. SmtpOrderNotifier knows about OrderService's world, because it implements the domain's interface. The EmailSender, the DbContext, the SMTP host string: none of them can be seen from the domain anymore. That is the inversion. The container that eventually binds IOrderNotifier to SmtpOrderNotifier is a convenience, not the principle.

The direction of the arrow is the entire principle: the domain defines the interface, and infrastructure points inward to implement it.

The litmus test: if you deleted your infrastructure project, would the domain still compile? If yes, your dependencies point inward. If no, you have injection without inversion.

The cargo cult: an interface per class

Once a team internalizes "depend on abstractions," a predictable overcorrection follows. Every class gets an IFooService extracted next to it, one interface, one implementation, forever. The solution doubles in file count and the abstractions explain nothing.

This is cargo cult. An interface earns its place when it inverts a dependency that would otherwise point the wrong way: toward infrastructure, toward a volatile detail, toward something you want to fake in a test. IOrderNotifier earns it, because it hides SMTP from the domain. An IOrderMapper with one implementation that nobody ever swaps and that lives in the same project as its caller earns nothing. It is indirection cosplaying as decoupling.

A useful rule: introduce the abstraction at the boundary where the dependency would otherwise cross outward from policy to detail. Inside a single layer, a concrete class calling another concrete class in the same layer is fine. Inversion is about protecting a boundary, not about banning the new keyword.

Why this is hard to feel from a blog post

The trouble with dependency inversion is that nothing punishes you for getting it wrong on the day you write the code. The new AppDbContext() version ships, passes its happy-path test, and looks clean. The cost arrives months later, as untestability and as the slow ossification of a domain that can no longer move without dragging infrastructure behind it. By then the arrows are load bearing and nobody wants to flip them.

So the question is how you train the instinct before the bill comes due. Reading about it gets you the vocabulary. What changes your defaults is writing the inverted version enough times that the non-inverted version starts to look wrong on sight, and getting told, immediately, when an arrow points the wrong way.

That immediate signal is the part most practice is missing. On Katabench, the architecture katas are graded on exactly this. Beyond the behavioral tests, your submission is checked with architecture fitness functions: dependency direction, layering, depend-on-abstractions. The untrusted assembly is parsed statically, so a domain type that references AppDbContext does not get a polite note in review. It fails, the same way a wrong answer fails, with the offending dependency named.

That is the difference between knowing the definition and having the reflex. You stop relying on vibes about whether your dependencies point inward, because something deterministic is asserting it for you on every submission.

The D in SOLID was never about your container. It is about who depends on whom, and the only reliable way to make "inward" your default is to get caught, fast, every time you drift. If you want that loop, the architecture track is built around it.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.