Skip to main content
ANVISoftware Solutions
Lesson 5 of 19Advanced18 min

Dependency Inversion

By the end of this lesson

Depend on abstractions so infrastructure can change independently.

The principle has two halves. High-level policy should not depend on low-level detail — both should depend on an abstraction. And the abstraction belongs to the policy side.

That second half is the part most explanations skip, and it is the part that does the work. If the interface lives in your infrastructure project and your application project references it to compile, the arrow still points outwards. Nothing was inverted; a layer of indirection was added. The interface has to be defined where the policy lives, describing what the policy needs, in the policy's own vocabulary.

A service that constructs its own dependencies
C#
public sealed class InvoiceService
{
    private readonly SqlInvoiceRepository _invoices = new(
        Environment.GetEnvironmentVariable("INVOICE_DB_CONNECTION")!);

    private readonly HttpEmailSender _email = new(new HttpClient
    {
        BaseAddress = new Uri("https://email.internal.example"),
    });

    public async Task IssueAsync(int invoiceId)
    {
        Invoice invoice = await _invoices.GetAsync(invoiceId);

        invoice.Issue(DateTime.UtcNow);
        await _invoices.SaveAsync(invoice);

        await _email.SendAsync(invoice.CustomerEmail, "Invoice issued",
            $"Invoice {invoice.Number} for {invoice.Total:C} is now due.");
    }
}
  • The service names two concrete classes, so it depends on SQL Server and on an HTTP email service by construction. You cannot use this class without both.
  • It also decides where its configuration comes from. A caller that wants a different database has no way to say so.
  • DateTime.UtcNow is a third hidden dependency — on the clock. A rule involving due dates cannot be tested for a specific date.
  • The practical consequence: to check that issuing an invoice sets the right status, you need a SQL Server instance, a reachable email endpoint, and patience. Most teams in this position write no test at all, which is the real cost.

Inverting this means the service stops deciding what its collaborators are. It declares what it needs as interfaces, and something outside it — the composition root, usually Program.cs — chooses the implementations.

Pay attention to where the interface files live. IInvoiceRepository and IEmailSender go in the project that holds the service, next to the policy that uses them. The EF Core class and the HTTP client class go in the infrastructure project, which references the policy project in order to implement them. The build now enforces the direction.

The same service, inverted, with a test that needs no infrastructure
C#
// Application project — the interface is declared where it is needed
public interface IInvoiceRepository
{
    Task<Invoice?> FindAsync(int id, CancellationToken token);
    Task SaveAsync(Invoice invoice, CancellationToken token);
}

public sealed class InvoiceService(
    IInvoiceRepository invoices,
    IEmailSender email,
    IClock clock)
{
    public async Task<bool> IssueAsync(int invoiceId, CancellationToken token)
    {
        Invoice? invoice = await invoices.FindAsync(invoiceId, token);
        if (invoice is null) return false;

        invoice.Issue(clock.UtcNow);
        await invoices.SaveAsync(invoice, token);
        await email.SendInvoiceIssuedAsync(invoice, token);
        return true;
    }
}

// Test project — two hand-written stand-ins, no database, no network
[Fact]
public async Task IssueAsync_SetsDueDateThirtyDaysOut()
{
    var invoice = Invoice.Draft(customerId: 42, total: 250m);
    var repository = new FakeInvoiceRepository(invoice);
    var clock = new FixedClock(new DateTime(2025, 3, 1, 9, 0, 0, DateTimeKind.Utc));

    await new InvoiceService(repository, new NullEmailSender(), clock)
        .IssueAsync(invoice.Id, CancellationToken.None);

    Assert.Equal(new DateTime(2025, 3, 31), invoice.DueUtc!.Value.Date);
}
  • The interface describes what the policy needs — find one invoice, save one invoice. It does not expose EF Core types, so the application project needs no data-access package.
  • IClock replaces DateTime.UtcNow. Fixing the clock is what makes the due-date assertion possible; with the static call, this test could only check a value relative to now, which is a weaker check.
  • The test constructs the service directly. No container, no configuration, no fixture — the dependencies are parameters, so supplying them is ordinary code.
  • The fakes are small classes you write by hand. A mocking library is an option here, not a requirement, and for an interface with two methods a hand-written fake is often clearer to read a year later.

Summary

  • Policy depends on an abstraction, and the abstraction is defined on the policy side
  • Where the interface file lives is the mechanism, not a detail — it decides which way the build forces dependencies
  • This is the principle that makes fast tests possible, which is why it outranks the other four in practice
  • Invert dependencies that are slow, external or non-deterministic; leave pure calculations alone
  • An interface with one implementation that is never faked is indirection, not inversion

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Write the fake

Write FakeInvoiceRepository from the test in this lesson. It should hold invoices in a list, return one by id, and record how many times SaveAsync was called.

Then answer this: should your test assert on that save count?

Show solution

The fake is about fifteen lines with a Dictionary and a counter. Writing it once for the whole test project is cheaper than configuring a mock in each test, and it reads as ordinary code.

On the save count: usually no. Asserting that SaveAsync was called once couples the test to how the service does its work, so a later change that batches two saves into one breaks a test even though nothing a user sees has changed.

Assert on the outcome instead — the invoice's status and due date. The exception is when the call itself is the behaviour you care about: sending exactly one email to a customer is an observable outcome, and asserting you did not send two is reasonable.

C#
public sealed class FakeInvoiceRepository : IInvoiceRepository
{
    private readonly Dictionary<int, Invoice> _invoices;

    public FakeInvoiceRepository(params Invoice[] invoices) =>
        _invoices = invoices.ToDictionary(invoice => invoice.Id);

    public int SaveCount { get; private set; }

    public Task<Invoice?> FindAsync(int id, CancellationToken token) =>
        Task.FromResult(_invoices.GetValueOrDefault(id));

    public Task SaveAsync(Invoice invoice, CancellationToken token)
    {
        _invoices[invoice.Id] = invoice;
        SaveCount++;
        return Task.CompletedTask;
    }
}

Think about it

Who owns the abstraction?

A team puts all of its interfaces in a project called Contracts, referenced by the domain, application and infrastructure projects. They describe this as dependency inversion.

What works about this, and what has quietly been lost?

Show solution

What works: implementations can be swapped, and tests can supply fakes. The main practical benefit is intact, which is why this arrangement is common and not unreasonable.

What is lost: ownership. An interface in a shared Contracts project belongs to nobody, so it tends to accumulate whatever any caller wanted — methods for one screen, methods shaped like the database, methods nothing uses any more. Nobody can remove one confidently because the consumers are spread across projects.

It also weakens the compiler's help. With interfaces next to the policy that needs them, an application handler cannot reference an infrastructure type at all. With a shared Contracts project, that check disappears, and anything can be published into the shared space by anyone.

There is a defensible version: a Contracts project that holds only the abstractions crossing a module boundary, kept deliberately small, with the interfaces used inside one module staying inside it.

Saved in this browser only.