Dependency Inversion, ports and adapters in .NET

Open the solution of any team that "does clean architecture" and look for IEmailSender.

Nine times out of ten you'll find it in Acme.Infrastructure, sitting in the same folder as SmtpEmailSender. One interface, one implementation, born on the same day, in the same commit, by the same person.

And the domain project? It references Acme.Infrastructure to get at that interface.

Every book on the shelf says the arrows have been inverted. The .csproj files say otherwise.

That's the Dependency Inversion Principle failing quietly, and it survives code review because everyone is checking for the presence of an interface instead of its address.

What this article covers:

โœ… Why DIP is not dependency injection, and why the container can't save you

โœ… The one compile-time test that tells you if your arrows actually point inward

โœ… Ports and adapters in a normal ASP.NET Core solution โ€” no hexagon drawings required

โœ… The port signatures that leak, and where the whole thing stops paying rent

๐Ÿงญ DIP Is Not DI

These get used interchangeably in interviews, and they're not the same thing.

Dependency Injection is a delivery mechanism. It answers "how does this class get its collaborator?" Constructor parameter, IServiceCollection, Program.cs. If you want the mechanics โ€” lifetimes, captive dependencies, keyed services โ€” I wrote that up separately in Unlock Dependency Injection and IoC in .NET.

Dependency Inversion is a design constraint. It answers "which project is allowed to reference which?"

The original phrasing has two halves, and almost everyone quotes only the first:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

Half two is where the money is. Registering services.AddScoped<IEmailSender, SmtpEmailSender>() satisfies exactly zero of it if IEmailSender ships inside the same assembly as the SMTP client.

You can have 100% constructor injection, a fully configured container, and a dependency graph that still points the wrong way. The container resolves at runtime. DIP is enforced at compile time, by project references.

๐Ÿงช The Ownership Test

Here's the test I run in code review. It takes ten seconds and it doesn't care what anyone's architecture diagram claims.

Delete the infrastructure project. Does the core still compile?

If yes, the arrows are inverted. If no, you have layers, not inversion โ€” and the interfaces are decoration.

That's it. No debate about whether something is "clean enough". Either Acme.Domain.csproj has a ProjectReference to Acme.Infrastructure.csproj or it doesn't.

Interface ownership: before and after inverting the dependency

The interface didn't change. Its address changed. That single move is the entire principle.

And notice what it buys you: the core project can't reference EF Core, HttpClient, or the SMTP library, because it doesn't have the package. The compiler now enforces the boundary you were previously enforcing with willpower and code review comments.

๐Ÿ”Œ Ports and Adapters, Without the Hexagon

The hexagon drawing is famous and unhelpful. Here are the two words, defined for a .csproj:

Port = an interface your core owns, written in your core's vocabulary, describing something it needs from the outside world. It lives in the core project.

Adapter = a class in an outer project that implements a port using a specific technology. SMTP, EF Core, SendGrid, Azure Service Bus.

The naming tell is brutal and reliable:

// โŒ Adapter pretending to be a port. Named after the technology.
public interface ISmtpClient
{
    Task SendAsync(MailMessage message, CancellationToken ct);
}

// โœ… A real port. Named after what the domain needs.
public interface IOrderNotifier
{
    Task NotifyConfirmedAsync(OrderConfirmation confirmation, CancellationToken ct);
}

If you can read the interface name and guess the vendor, it's an adapter that escaped into the core.

MailMessage in that first signature is the other tell โ€” a System.Net.Mail type in a domain interface means your domain now depends on System.Net.Mail. The abstraction depends on a detail. Half two of the principle, violated in a method parameter.

๐Ÿ”จ Before: The Core Reaches Outward

A perfectly normal OrderService, the kind that ships every day:

// Acme.Application โ€” and it references Acme.Infrastructure. That's the bug.
public sealed class OrderService(
    OrderDbContext db,
    SmtpEmailSender email,
    HttpClient payments)
{
    public async Task<Guid> ConfirmAsync(Guid orderId, CancellationToken ct)
    {
        var order = await db.Orders.FirstAsync(o => o.Id == orderId, ct);

        var response = await payments.PostAsJsonAsync("/charge", new { order.Total }, ct);
        response.EnsureSuccessStatusCode();

        order.Status = OrderStatus.Confirmed;
        await db.SaveChangesAsync(ct);

        await email.SendAsync(new MailMessage("noreply@acme.com", order.CustomerEmail)
        {
            Subject = "Order confirmed"
        }, ct);

        return order.Id;
    }
}

Read what this class actually knows: SQL Server, HTTP, SMTP, and EF Core's change tracker. The business rule โ€” charge, then confirm, then notify โ€” is in there somewhere, buried under three technologies.

The practical cost isn't philosophical. It's that you cannot unit test ConfirmAsync without a database, an HTTP handler, and a mail server, and you cannot swap the payment provider without opening a file that also contains business logic.

โœ… After: The Core Declares What It Needs

The core defines the ports. It stays boring.

// Acme.Application โ€” zero infrastructure package references.
public interface IOrderRepository
{
    Task<Order?> FindAsync(Guid orderId, CancellationToken ct);
    Task SaveAsync(Order order, CancellationToken ct);
}

public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(Money amount, string reference, CancellationToken ct);
}

public interface IOrderNotifier
{
    Task NotifyConfirmedAsync(OrderConfirmation confirmation, CancellationToken ct);
}

public sealed class OrderService(
    IOrderRepository orders,
    IPaymentGateway payments,
    IOrderNotifier notifier)
{
    public async Task<ConfirmOrderResult> ConfirmAsync(Guid orderId, CancellationToken ct)
    {
        var order = await orders.FindAsync(orderId, ct);
        if (order is null)
        {
            return ConfirmOrderResult.NotFound;
        }

        var payment = await payments.ChargeAsync(order.Total, order.Reference, ct);
        if (!payment.Succeeded)
        {
            return ConfirmOrderResult.PaymentDeclined(payment.Reason);
        }

        order.Confirm(payment.TransactionId);
        await orders.SaveAsync(order, ct);
        await notifier.NotifyConfirmedAsync(order.ToConfirmation(), ct);

        return ConfirmOrderResult.Confirmed(order.Id);
    }
}

The adapters live outside and are the only things that know a vendor exists:

// Acme.Infrastructure โ€” references Acme.Application, never the reverse.
internal sealed class SmtpOrderNotifier(SmtpClient client, IOptions<MailOptions> options)
    : IOrderNotifier
{
    public Task NotifyConfirmedAsync(OrderConfirmation confirmation, CancellationToken ct)
    {
        var message = new MailMessage(options.Value.From, confirmation.CustomerEmail)
        {
            Subject = $"Order {confirmation.Reference} confirmed"
        };

        return client.SendMailAsync(message, ct);
    }
}

And the composition root โ€” the only place in the solution that is allowed to know both sides:

// Program.cs โ€” the composition root. This is where the two halves meet.
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddScoped<IPaymentGateway, StripePaymentGateway>();
builder.Services.AddScoped<IOrderNotifier, SmtpOrderNotifier>();

Note the internal on the adapter. Nothing outside Acme.Infrastructure needs the concrete type โ€” the composition root registers it, everyone else asks for the port. If a consumer needs to name SmtpOrderNotifier, the port is doing nothing.

This is the same shape Microsoft Learn describes in the common web application architectures guide, where infrastructure depends inward on the application core rather than the other way around.

๐Ÿ’ง Ports That Leak

An interface in the right project is necessary, not sufficient. These four signatures pass the ownership test and still drag infrastructure into your core:

public interface ILeakyPort
{
    IQueryable<Order> Query();                               // โŒ callers now write EF Core
    Task<HttpResponseMessage> GetCustomerAsync(Guid id);     // โŒ HTTP is not a domain concept
    Task SaveChangesAsync(CancellationToken ct);             // โŒ a DbContext method with a hat on
    Task<Order> LoadAsync(Guid id, IDbTransaction tx);       // โŒ ADO.NET in a domain signature
}

IQueryable is the one that gets everyone. It looks abstract โ€” it's an interface, it's in the BCL โ€” but the expression tree gets translated by a specific provider, so what actually compiles depends entirely on which database is behind it. Swap the adapter and half your call sites throw at runtime. I unpacked the rest of that trade-off in Repository Pattern in .NET: When It Pays Rent.

The rule for port signatures: only types your core owns, plus BCL primitives. Guid, string, CancellationToken, your own records. If a parameter type comes from a NuGet package that your core doesn't reference, the port isn't finished.

๐Ÿ’ธ When It Doesn't Pay Rent

I've watched teams invert the dependency on everything, including their logger, and then maintain forty single-implementation interfaces forever. That's not architecture, that's a tax.

Skip the port when:

  • ๐Ÿ”ด The dependency will never have a second implementation and is trivially fakeable. TimeProvider, ILogger<T>, IMemoryCache โ€” the BCL already gave you the abstraction. Don't wrap the wrapper.
  • ๐Ÿ”ด The app is CRUD. If the controller's job is to move a DTO into a table, the "core" is a validation attribute and a DbContext. A port between two lines of code buys nothing.
  • ๐Ÿ”ด You're wrapping DbContext in a generic IRepository<T>. You've rebuilt DbSet<T> with a worse API and no inversion โ€” the interface still describes a database.

Add the port when:

  • ๐ŸŸข The dependency crosses a process boundary โ€” payment provider, message broker, third-party API. These are the ones that get replaced, go down, and need faking in tests.
  • ๐ŸŸข The vendor is a business decision, not a technical one. Anything procurement might renegotiate.
  • ๐ŸŸข The real thing is slow, flaky, or costs money per call. A port is the cheapest test seam you'll ever write.

The question is the same one I ask of every abstraction: does it pay rent? A port that has been swapped exactly zero times, protects zero tests, and adds one file per feature is a tenant who stopped paying in 2019.

๐Ÿ›ก๏ธ Make the Compiler Do Code Review

Direction of dependency is enforceable, so enforce it. A single NetArchTest assertion outlives every "please don't reference that project" comment:

[Fact]
public void Application_core_must_not_depend_on_infrastructure()
{
    var result = Types.InAssembly(typeof(OrderService).Assembly)
        .ShouldNot()
        .HaveDependencyOnAny("Acme.Infrastructure", "Microsoft.EntityFrameworkCore", "System.Net.Http")
        .GetResult();

    Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypeNames ?? []));
}

One test, red the moment someone adds the reference "just for now". I go deeper on using these as a guardrail โ€” including against AI-generated code โ€” in Architecture Tests with NetArchTest.

๐Ÿง  The Takeaway I'd Keep

Strip everything else and DIP is one habit:

The consumer defines the contract. The provider conforms to it.

Not the other way around. Not "we'll put the interfaces in a Shared project so everyone can reach them" โ€” that's a Shared project with a database dependency, and it will be referenced by all fourteen services within a year.

When a new dependency shows up, ask two questions:

  1. Who wrote this interface's method names โ€” my domain, or the vendor's SDK?
  2. If I delete the implementation project, does my core still compile?

Answer those honestly and you've applied the principle. Everything else โ€” the hexagon, the onion, the concentric circles โ€” is just diagrams of those two answers.


๐Ÿ“š Further Reading

Takeaway: Interfaces don't invert anything โ€” ownership does. Put the contract in the project that consumes it, keep vendor types out of its signatures, and let a project reference, not a code review, enforce the direction.