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.
Infrastructure
database, email, HTTP
Application
use cases
Domain
pure, depends on nothing
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.