High-level policy not depending on low-level details, abstraction ownership, DIP versus DI versus IoC containers, ports and adapters, and when not to invert.
DIP says high-level policy should not depend on low-level details; both should depend on abstractions, and the abstraction belongs to the high-level side. For example, the domain or application layer defines IPaymentGateway, and the infrastructure project implements it with Stripe; the arrow of source dependency points inwards. Dependency injection is a technique for supplying those implementations (constructor injection), and the .NET DI container is a tool that automates it. You can use a container without inverting anything (if interfaces live next to their implementations in infrastructure), and you can invert dependencies without a container.
"The Dependency Inversion Principle has two parts. High-level policy shouldn't depend on low-level details: both should depend on abstractions. And abstractions shouldn't depend on details: details depend on abstractions. The part people miss is ownership: the abstraction belongs to the consumer, the high-level side. So the application layer defines
IPaymentGatewayin business terms, the infrastructure project references the application project and implements it with the vendor SDK, and the host wires them together. The source-code arrow points inwards, against the flow of control: that's the 'inversion'. Dependency injection is just the technique of handing a class its dependencies (usually through the constructor), and the .NET DI container is a tool that automates building the object graph. Neither guarantees DIP: you can registerIStripeClientfrom the infrastructure project in a container and still have your domain depend on Stripe concepts. I invert dependencies on volatile details (vendors, I/O, messaging) and leave stable ones alone (string, collections,ILogger<T>,TimeProvider), and I enforce the direction with project references and architecture tests."
| Term | Kind | What it is | Guarantees DIP? |
|---|---|---|---|
| Dependency Inversion Principle | Design rule | Direction of source dependencies; abstraction ownership | β |
| Dependency injection | Technique | Pass dependencies in (constructor/method/property) instead of new-ing them |
No |
| IoC container | Tool | Microsoft.Extensions.DependencyInjection builds object graphs, manages lifetimes |
No |
| Inversion of Control | Broader idea | Framework calls your code (e.g. ASP.NET Core calling controllers) | No |
| Project | Contains | References |
|---|---|---|
Shop.Domain |
Entities, value objects, domain rules | Nothing infrastructure |
Shop.Application |
Use cases/handlers, ports (IPaymentGateway, IOrderRepository, IEmailSender) |
Domain |
Shop.Infrastructure |
Adapters: EF Core, vendor SDKs, HTTP clients, message bus | Application (+ Domain) |
Shop.Api (host) |
Program.cs composition root, endpoints |
Application + Infrastructure |
This is the core of ports-and-adapters, onion and clean architecture.
| Abstraction | Owned by | Shape | DIP? |
|---|---|---|---|
IStripeChargesApi in Infrastructure |
Detail | Vendor-shaped (CreateCharge(stripeToken, ...)) |
No: app still thinks in Stripe terms |
IPaymentGateway in Application |
Consumer | Business-shaped (ChargeAsync(orderId, amount)) |
Yes |
IHttpClientWrapper |
Nobody useful | Too low-level | No real decoupling |
IExchangeRates |
Consumer | Business capability | Yes |
| Dependency | Invert? | Why |
|---|---|---|
| Payment, email, SMS, storage vendors | Yes | Volatile; you'll switch or test without them |
| Message broker | Usually | Keeps handlers testable and portable |
| Database | Often, or use DbContext directly in simple apps |
DbContext is already an abstraction; wrapping it can add little |
| Clock | Use TimeProvider |
Already an abstraction (.NET 8) |
| Logging | ILogger<T> |
Already an abstraction |
string, List<T>, LINQ |
No | Stable; inverting adds noise |
| Pure functions | No | No I/O to decouple |
Our [retail platform's] domain project referenced the payment vendor's SDK directly: vendor types appeared in [140] files across domain and application code, and domain unit tests needed the SDK plus network stubs. When the business decided to switch vendors, the estimate was [six weeks]. I defined an application-owned IPaymentGateway port in business terms (ChargeAsync(orderId, amount, ct) returning our own PaymentResult), moved all vendor code into an infrastructure adapter registered via AddHttpClient<IPaymentGateway, VendorGateway>, and added NetArchTest rules so the application layer can't reference infrastructure or vendor packages. The vendor migration itself then touched only the new adapter and one registration line, domain tests became fast and dependency-free, and the architecture tests have kept the boundary intact since.
Checks architectural understanding beyond DI mechanics.
Green flags
Red flags
Flow of control (runtime): PlaceOrderHandler ββcallsβββΆ VendorGateway βββΆ vendor API
Without DIP (source deps): Application βββΆ Infrastructure βββΆ Vendor SDK
With DIP (source deps): Application (owns IPaymentGateway) βββ Infrastructure (VendorGateway : IPaymentGateway)
Host references both and composes them
Control still flows outward at runtime; source dependencies now point inward. That's the inversion.
Robert C. Martin (1996) stated it as: (A) high-level modules should not depend on low-level modules; both should depend on abstractions; (B) abstractions should not depend on details; details should depend on abstractions. The "plug-in" framing in Clean Architecture (the database and web are plug-ins to the business rules) is the same idea at system level.
// Shop.Api/Program.cs: the only place that knows concrete types
builder.Services.AddScoped<PlaceOrderHandler>();
builder.Services.AddHttpClient<IPaymentGateway, VendorGateway>(c =>
c.BaseAddress = new Uri(builder.Configuration["Payments:BaseUrl"]!));
builder.Services.AddDbContext<ShopDb>(o => o.UseSqlServer(builder.Configuration.GetConnectionString("Shop")));
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
Application code never calls IServiceProvider.GetService (service locator): dependencies arrive through constructors, so they're visible and testable.
[Fact]
public void Application_does_not_depend_on_infrastructure()
{
var result = Types.InAssembly(typeof(PlaceOrderHandler).Assembly)
.ShouldNot().HaveDependencyOnAny("Shop.Infrastructure", "Stripe", "Microsoft.EntityFrameworkCore")
.GetResult();
Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypeNames ?? []));
}
(Using NetArchTest; ArchUnitNET offers similar rules.)
Inverting doesn't remove DI lifetime bugs:
DbContext) is a captive dependency: stale data and thread-safety bugs.ValidateScopes and ValidateOnBuild (on by default in Development) catch many of these at startup.HttpClients are transient by design; don't capture them in singletons.| Layer | Test with |
|---|---|
| Application/domain | Simple fakes of ports (in-memory IPaymentGateway) |
| Adapters | Integration tests against real dependencies (Testcontainers, WireMock.Net for HTTP vendors) |
| Host | A few end-to-end tests via WebApplicationFactory |
IDateTimeWrapper, IStringHelper, IListFactory: interfaces for stable things.DbContext that exposes IQueryable everywhere: the abstraction doesn't hide anything.App, Infra and Host projects; put IPaymentGateway in App, implement it in Infra, wire it in Host.Infra reference to App to see it fail.ValidateScopes catching it.In a real solution each block below is its own project. They are shown in one snippet with block-scoped namespaces so it compiles as a single file (two file-scoped namespace X; declarations in one file are a compile error).
using System.Net.Http.Json;
// ---- Application project (owns the port) ----
namespace Shop.Application
{
public sealed record PaymentResult(bool Success, string Reference);
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(Guid orderId, decimal amountAud, CancellationToken ct);
}
public sealed class PlaceOrderHandler(IPaymentGateway payments)
{
public async Task<string> HandleAsync(Guid orderId, decimal total, CancellationToken ct)
{
var result = await payments.ChargeAsync(orderId, total, ct);
return result.Success ? $"Paid ({result.Reference})" : "Payment declined";
}
}
}
// ---- Infrastructure project (adapter, references Application) ----
namespace Shop.Infrastructure
{
using Shop.Application;
public sealed class CardVendorGateway(HttpClient http) : IPaymentGateway
{
public async Task<PaymentResult> ChargeAsync(Guid orderId, decimal amountAud, CancellationToken ct)
{
// Vendor-specific request and mapping live only here.
using var response = await http.PostAsJsonAsync("charges",
new { reference = orderId, amount_cents = (int)(amountAud * 100), currency = "aud" }, ct);
return new PaymentResult(response.IsSuccessStatusCode, orderId.ToString("N"));
}
}
}
// ---- Host project (Program.cs wires them together) ----
// builder.Services.AddScoped<PlaceOrderHandler>();
// builder.Services.AddHttpClient<IPaymentGateway, CardVendorGateway>(c =>
// c.BaseAddress = new Uri(builder.Configuration["Payments:BaseUrl"]!));
What happens: everything is registered in Program.cs, but application code depends on infrastructure types.
Why it is wrong: a container wires dependencies in any direction.
Do instead: check source dependency direction and abstraction ownership.
What happens: IStripeClient with Stripe-specific methods used by the application.
Why it is wrong: the application still depends on vendor concepts.
Do instead: ports defined by the consuming layer in business terms.
What happens: wrappers around DateTime, collections, string, framework types.
Why it is wrong: indirection with no decoupling benefit.
Do instead: invert volatile details (I/O, vendors); use built-in abstractions (TimeProvider, ILogger<T>).
What happens: serviceProvider.GetRequiredService<IPaymentGateway>() inside handlers.
Why it is wrong: hides dependencies, complicates tests, couples to the container.
Do instead: constructor injection; resolve only in the composition root.
What happens: singleton handlers capturing scoped DbContexts.
Why it is wrong: stale data, thread-safety bugs.
Do instead: align lifetimes; keep scope validation on.
What happens: Task<Stripe.Charge> ChargeAsync(...).
Why it is wrong: consumers depend on vendor types anyway.
Do instead: map to your own result types inside the adapter.
What happens: boundaries erode as developers add "just one" reference. Why it is wrong: DIP becomes folklore. Do instead: project reference rules and architecture tests in CI.
DbContext in a thin generic repository by defaultWhat happens: IRepository<T> exposing IQueryable<T> over EF Core.
Why it is wrong: little isolation, more code; persistence concerns still leak.
Do instead: use DbContext directly in simple apps, or use-case-specific repository ports where isolation matters.
What happens: IHttpClientWrapper, ISqlExecutor.
Why it is wrong: the application still orchestrates infrastructure details.
Do instead: ports at the level of business capability (IExchangeRates, IOrderHistory).
What happens: every class gets an I-prefixed twin in the same project.
Why it is wrong: no change in dependency direction; doubled code.
Do instead: introduce abstractions at architectural boundaries, owned by the consumer.
DIP is the principle that makes architecture boundaries real. I enforce it with project references and architecture tests, keep ports in business language, and avoid inverting stable dependencies just to have an interface.
| Dependency | Invert? |
|---|---|
| Payment, email, SMS vendors | Yes, port in application layer |
| Database | Often; or use DbContext directly in simple apps |
| Clock | Use TimeProvider (already abstract) |
| Logging | ILogger<T> (already abstract) |
| Collections, primitives | No |
A dependency is the weakest relationship: class A uses class B, so a change to B may affect A, but A does not keep B as part of its state. It shows up as a method parameter, a local variable, a return type, a static call or a new B() inside a method. Association is stronger: A stores a reference to B. Direction matters: high-level policy (business rules) should not depend on low-level details (SQL, HTTP). The Dependency Inversion Principle says both should depend on abstractions owned by the high-level side. Dependency injection supplies those abstractions from outside (usually through constructors), so classes do not create or find their own dependencies, which makes them testable and replaceable.
ISP says clients should not be forced to depend on methods they do not use. A fat interface such as IOrderService with 25 members couples every consumer to all of them, makes test doubles painful and pushes implementers to throw NotImplementedException. Split interfaces by what clients need (role interfaces): IOrderReader for queries, IOrderWriter for commands, IClock instead of a giant IUtilities. The BCL does this well (IReadOnlyCollection<T> versus ICollection<T>, IAsyncDisposable). Do not go to the extreme of one interface per method; group members that the same client uses together.
The Open/Closed Principle says modules should be open for extension but closed for modification: you add new behaviour by adding code, not by editing tested code. In C# this usually means a strategy interface with one implementation per variant, registered in DI, so adding a new payment method is a new class rather than another case in a growing switch. I apply it where change actually happens repeatedly. A small, stable switch (or a switch expression over a closed set) is fine; building extension points for variants that never arrive is speculative generality.
How to tell it (STAR)
When we had to switch [payment vendors], the change touched 140 files because the vendor SDK was used everywhere. I introduced a payment port owned by the application layer and moved the vendor code into one adapter, with architecture tests to keep it that way. The next vendor change only touched that adapter and one registration line.
Last updated 31 Oct 2026.