DEV_NET_CORE
GET_STARTED
Design & ArchitectureClean Architecture and modular boundaries

Dependency inversion and inward-facing dependencies

Overview

Dependency Inversion is the principle that high-level policy should not depend directly on low-level implementation details. Both should depend on abstractions, and those abstractions should be shaped by the needs of the higher-level policy.

Without dependency inversion, application code commonly follows its runtime call direction:

Code
Order service
    -> EF Core repository
        -> SQL Server

The order service has a compile-time reference to the concrete persistence code. Business behavior therefore changes or becomes harder to test when persistence details change.

With dependency inversion, the high-level module defines the capability it needs:

Code
Compile-time dependencies:

Infrastructure -> Application -> Domain
                     ^
                     |
              IOrderRepository

At runtime, the call still reaches infrastructure:

Code
Runtime calls:

Order use case
    -> IOrderRepository
        -> EfOrderRepository
            -> SQL Server

The dependency has been inverted because the infrastructure implementation depends on an abstraction owned by the application rather than the application depending on the infrastructure implementation.

Inward-facing dependencies are the architectural application of this principle. Outer details such as ASP.NET Core, EF Core, message brokers, file systems, and vendor SDKs can depend on inner application policy. Inner layers do not depend on those outer details.

This concept is central to Clean Architecture, Onion Architecture, and Ports-and-Adapters. It is also relevant in ordinary layered applications whenever business behavior must be protected from technology-specific code.

This topic is important in interviews because several related concepts are often confused:

  • Dependency Inversion Principle.
  • Dependency injection.
  • Inversion of Control.
  • Interfaces and abstractions.
  • Compile-time dependencies.
  • Runtime control flow.
  • Composition roots.
  • Service location.

A strong answer explains not only how to inject an interface but also who should own that interface, why the dependency arrow changes, which dependencies deserve inversion, and when introducing an abstraction is unnecessary.

Core Concepts

What Is a Dependency?

A dependency is something a class, function, module, or service requires to perform its work.

Examples:

  • A use case depends on an order repository.
  • A repository depends on a database context.
  • A controller depends on an application service.
  • A notification service depends on an email gateway.
  • A pricing rule depends on a clock or exchange-rate provider.

Dependencies can be:

  • Concrete classes.
  • Interfaces.
  • Functions or delegates.
  • Configuration values.
  • Framework services.
  • External processes or services.

The architectural concern is not whether dependencies exist. Software must collaborate. The concern is which direction the source-code dependencies point and whether high-level policy is coupled to details that change independently.

High-Level Policy and Low-Level Detail

A high-level policy describes what the business or application is trying to accomplish.

Examples:

  • An order can be confirmed only when it contains lines.
  • A withdrawal must not exceed the available balance.
  • A customer must be authorized before viewing an invoice.
  • A payment must be recorded before fulfillment begins.

A low-level detail describes how technical work is performed.

Examples:

  • SQL Server and EF Core.
  • Azure Service Bus.
  • An SMTP server.
  • An HTTP payment SDK.
  • A local file system.
  • ASP.NET Core routing.

High-level policy tends to be more valuable and longer-lived. Low-level details often change because of frameworks, vendors, hosting choices, or operational requirements.

Dependency inversion protects policy from those changes.

The Dependency Inversion Principle

The principle is commonly expressed in two parts:

Code
High-level modules should not depend on low-level modules.
Both should depend on abstractions.

Abstractions should not depend on details.
Details should depend on abstractions.

This does not mean high-level code stops using low-level behavior. It means the compile-time contract is placed at a boundary controlled by the high-level need.

Direct Dependency Without Inversion

Consider an application service that creates a database context directly:

Code
public sealed class ConfirmOrderService
{
    public async Task ConfirmAsync(
        Guid orderId,
        CancellationToken cancellationToken)
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer("connection-string")
            .Options;

        await using var db = new AppDbContext(options);

        Order order = await db.Orders
            .Include(item => item.Lines)
            .SingleAsync(
                item => item.Id == orderId,
                cancellationToken);

        order.Confirm();

        await db.SaveChangesAsync(cancellationToken);
    }
}

Problems include:

  • Application policy knows EF Core.
  • Connection and lifetime management are mixed with the use case.
  • Tests need EF Core configuration.
  • Persistence changes affect application code.
  • The service has multiple reasons to change.

The code may still be acceptable for a small script or prototype. Dependency inversion is most valuable when the boundary has meaningful change or testing pressure.

Inverted Dependency

The application defines the capability it requires:

Code
public interface IOrderRepository
{
    Task<Order?> GetForConfirmationAsync(
        OrderId orderId,
        CancellationToken cancellationToken);
}

public interface IUnitOfWork
{
    Task SaveChangesAsync(
        CancellationToken cancellationToken);
}

The use case depends on those abstractions:

Code
public sealed class ConfirmOrderHandler(
    IOrderRepository orders,
    IUnitOfWork unitOfWork)
{
    public async Task HandleAsync(
        ConfirmOrder command,
        CancellationToken cancellationToken)
    {
        Order order = await orders.GetForConfirmationAsync(
            command.OrderId,
            cancellationToken)
            ?? throw new OrderNotFoundException(command.OrderId);

        order.Confirm();

        await unitOfWork.SaveChangesAsync(cancellationToken);
    }
}

Infrastructure implements the ports:

Code
public sealed class EfOrderRepository(AppDbContext db)
    : IOrderRepository
{
    public Task<Order?> GetForConfirmationAsync(
        OrderId orderId,
        CancellationToken cancellationToken)
    {
        return db.Orders
            .Include(order => order.Lines)
            .SingleOrDefaultAsync(
                order => order.Id == orderId,
                cancellationToken);
    }
}

public sealed class EfUnitOfWork(AppDbContext db) : IUnitOfWork
{
    public Task SaveChangesAsync(
        CancellationToken cancellationToken)
    {
        return db.SaveChangesAsync(cancellationToken);
    }
}

Compile-time references now point toward application policy:

Code
Infrastructure -> Application -> Domain

Inward-Facing Dependencies

An inward dependency points from an outer, more technical layer toward an inner, more policy-oriented layer.

Typical direction:

Code
API ------------\
Worker ----------> Application -> Domain
Infrastructure --/

Examples:

  • API references application commands and use cases.
  • Infrastructure references application repository ports.
  • Application references domain entities and value objects.
  • Domain references only basic language or carefully selected foundational libraries.

The inner layer must not import types from the outer layer.

Invalid examples:

Code
Domain -> EF Core
Application -> ASP.NET Core IActionResult
Application -> Azure Service Bus SDK
Domain -> Infrastructure repository

Dependency Rule

The Dependency Rule is the architectural constraint that source-code dependencies cross boundaries only toward higher-level policy.

Information crossing inward should be expressed in forms the inner layer owns or understands:

  • Primitive values.
  • Domain value objects.
  • Application commands.
  • Application results.
  • Narrow interfaces.

Outer-layer types should be translated at the boundary.

For example, do not pass an ASP.NET Core HttpRequest into a use case:

Code
// Avoid
public Task HandleAsync(HttpRequest request);

Translate it into an application request:

Code
public sealed record RegisterCustomer(
    EmailAddress Email,
    CustomerName Name);

Compile-Time Dependency vs Runtime Flow

These directions are often confused.

Suppose an application service calls an email adapter.

At runtime:

Code
Application -> SMTP adapter -> SMTP server

At compile time:

Code
SMTP adapter -> Application's IEmailSender
Application -> Domain

The application source references only IEmailSender. The concrete adapter references and implements that interface.

This is why the runtime arrow can point outward while the source-code dependency points inward.

Interface Ownership

The location of an interface determines whether the dependency is truly inverted.

Infrastructure-Owned Interface

Code
Application -> Infrastructure.Abstractions
Infrastructure.Implementation -> Infrastructure.Abstractions

The application still depends on a package owned by infrastructure. This may reduce coupling to a concrete class, but it does not fully protect the high-level module from the lower-level module's model.

Consumer-Owned Interface

Code
Infrastructure -> Application.IOrderRepository
Application -> Domain

The application defines the behavior it needs. Infrastructure adapts to that contract.

This follows the Interface Segregation Principle as well: the consuming use case should not depend on operations it does not need.

Design Interfaces from the Consumer's Need

Avoid designing a port as a generic mirror of a technology:

Code
public interface IRepository<T>
{
    IQueryable<T> Query();
    void Add(T entity);
    void Update(T entity);
    void Delete(T entity);
}

This contract:

  • Leaks query-provider behavior.
  • Exposes persistence mechanics.
  • Gives every consumer a broad API.
  • Makes infrastructure shape the application.

Prefer a narrow application-facing port:

Code
public interface ICustomerCreditReader
{
    Task<CreditProfile?> GetForOrderAsync(
        CustomerId customerId,
        CancellationToken cancellationToken);
}

The port names the capability required by the use case.

Stable Abstractions

An abstraction should represent something more stable than its implementations.

Good abstraction candidates:

  • A business capability.
  • An external resource boundary.
  • A policy with multiple implementations.
  • A module contract.
  • A platform capability needed by the core.

Examples:

Code
IPaymentAuthorizer
IOrderRepository
IExchangeRateProvider
IIntegrationEventPublisher
TimeProvider

Weak abstraction candidates:

Code
IOrderServiceImpl
IGenericManager<T>
IHelper
IProcessor<TInput, TOutput>

The goal is not interface quantity. The goal is dependency direction and a stable contract.

Dependency Inversion vs Dependency Injection

These are related but different.

Dependency Inversion Principle is an architectural design rule about dependency direction and abstraction ownership.

Dependency injection is a construction technique where dependencies are supplied from outside an object.

Constructor injection:

Code
public sealed class ConfirmOrderHandler(
    IOrderRepository orders)
{
}

Method injection:

Code
public Task HandleAsync(
    ConfirmOrder command,
    ICurrentUser currentUser,
    CancellationToken cancellationToken);

Property injection exists but is usually avoided because it can leave objects incompletely initialized.

DI can be used without proper dependency inversion:

Code
public sealed class ConfirmOrderHandler(
    EfOrderRepository repository)
{
}

The dependency is injected, but the high-level handler still depends on a concrete low-level type.

Dependency inversion can also be implemented without a DI container by constructing objects manually in the composition root.

Dependency Inversion vs Inversion of Control

Inversion of Control is a broader concept in which the framework or external mechanism controls execution or object creation.

Examples:

  • ASP.NET Core calls an endpoint when a request arrives.
  • A test framework invokes test methods.
  • A UI framework invokes event handlers.
  • A DI container constructs objects.

Dependency injection is one form of IoC for object construction.

Dependency Inversion is specifically about source-code dependencies between high-level policy and low-level details.

The Composition Root

The composition root is the single location where the application's object graph is assembled.

In ASP.NET Core:

Code
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("Orders")));

builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddScoped<IUnitOfWork, EfUnitOfWork>();
builder.Services.AddScoped<
    IConfirmOrderUseCase,
    ConfirmOrderHandler>();

var app = builder.Build();

The composition root is allowed to know concrete types. The rest of the application should not use the DI container as a service locator.

Project References in a .NET Solution

A common dependency structure:

Code
Orders.Domain
  references:
  - no application projects

Orders.Application
  references:
  - Orders.Domain

Orders.Infrastructure
  references:
  - Orders.Application
  - Orders.Domain
  - EF Core and external SDKs

Orders.Api
  references:
  - Orders.Application
  - Orders.Infrastructure for composition

The API's infrastructure reference should be confined to startup registration or an infrastructure registration extension:

Code
builder.Services.AddOrdersInfrastructure(
    builder.Configuration);

Application and domain code should not reference API or infrastructure.

Inbound and Outbound Boundaries

Dependency inversion applies on both sides of the application.

Inbound Boundary

An inbound adapter drives a use case:

Code
public interface ITransferFundsUseCase
{
    Task<TransferFundsResult> ExecuteAsync(
        TransferFunds command,
        CancellationToken cancellationToken);
}

Possible adapters:

  • HTTP endpoint.
  • Message consumer.
  • CLI.
  • Scheduled job.
  • Integration test.

Outbound Boundary

The use case drives an external capability:

Code
public interface IAccountRepository
{
    Task<Account?> GetAsync(
        AccountId id,
        CancellationToken cancellationToken);
}

Possible adapters:

  • EF Core.
  • Remote banking API.
  • In-memory fake.

The application owns both ports because both describe its interaction model.

External API Adapter Example

The domain should not depend on a vendor SDK:

Code
public interface IPaymentAuthorizer
{
    Task<PaymentAuthorization> AuthorizeAsync(
        Money amount,
        PaymentMethod method,
        CancellationToken cancellationToken);
}

Infrastructure translates to the vendor:

Code
public sealed class AcmePaymentAuthorizer(
    AcmePaymentsClient client) : IPaymentAuthorizer
{
    public async Task<PaymentAuthorization> AuthorizeAsync(
        Money amount,
        PaymentMethod method,
        CancellationToken cancellationToken)
    {
        AcmeAuthorizationResponse response =
            await client.AuthorizeAsync(
                new AcmeAuthorizationRequest
                {
                    Amount = amount.ToMinorUnits(),
                    Currency = amount.Currency,
                    PaymentToken = method.Token
                },
                cancellationToken);

        return new PaymentAuthorization(
            response.Id,
            response.Status == "approved",
            MapDeclineReason(response.DeclineCode));
    }
}

The adapter absorbs:

  • Vendor models.
  • Vendor naming.
  • Authentication.
  • Error translation.
  • Retry and timeout policy.
  • SDK upgrades.

Time and Other Environmental Dependencies

Code often depends implicitly on the environment:

Code
public bool IsExpired()
{
    return ExpiresAtUtc <= DateTimeOffset.UtcNow;
}

Modern .NET provides TimeProvider:

Code
public sealed class SubscriptionService(TimeProvider timeProvider)
{
    public bool IsExpired(Subscription subscription)
    {
        return subscription.ExpiresAtUtc <=
            timeProvider.GetUtcNow();
    }
}

Tests can use a controllable time provider. This is preferable to inventing a custom interface when the platform abstraction already expresses the need.

Other environmental dependencies include:

  • Current user.
  • Random values.
  • File system.
  • Process environment.
  • Network.
  • Host shutdown.

Invert them when deterministic behavior or boundary isolation matters.

Persistence Ignorance

Persistence ignorance means domain behavior is not shaped by a persistence technology.

Good domain model:

Code
public sealed class Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0)
        {
            throw new DomainException(
                "Money cannot be negative.");
        }

        Amount = amount;
        Currency = currency;
    }
}

Infrastructure maps it with EF Core configuration:

Code
builder.OwnsOne(
    order => order.Total,
    money =>
    {
        money.Property(value => value.Amount)
            .HasColumnName("TotalAmount");
        money.Property(value => value.Currency)
            .HasColumnName("Currency");
    });

Persistence ignorance is not absolute technology neutrality. Aggregate size, consistency, query needs, and storage behavior still influence practical design. The objective is to avoid unnecessary direct dependencies in business code.

Framework Independence

Framework independence means the application's core behavior is not expressed in framework-specific types.

Avoid:

Code
public IActionResult ConfirmOrder(Guid id);

inside the application layer.

Prefer:

Code
public Task<ConfirmOrderResult> HandleAsync(
    ConfirmOrder command,
    CancellationToken cancellationToken);

The API adapter maps the application result:

Code
return result switch
{
    ConfirmOrderResult.Confirmed => Results.NoContent(),
    ConfirmOrderResult.NotFound => Results.NotFound(),
    ConfirmOrderResult.Invalid invalid =>
        Results.BadRequest(new { invalid.Message }),
    _ => Results.StatusCode(500)
};

Domain Events and Infrastructure

Domain events should be domain concepts:

Code
public sealed record OrderConfirmed(
    OrderId OrderId) : IDomainEvent;

They should not depend on a broker message type. Infrastructure or application coordination can translate them into integration events:

Code
public sealed record OrderConfirmedV1(
    Guid OrderId,
    DateTimeOffset ConfirmedAtUtc);

This prevents Azure Service Bus, Kafka, or another broker SDK from becoming a domain dependency.

Transactions and Inward Dependencies

The application may define a transaction-oriented port:

Code
public interface IUnitOfWork
{
    Task SaveChangesAsync(
        CancellationToken cancellationToken);
}

Infrastructure decides whether this means:

  • DbContext.SaveChangesAsync.
  • An explicit database transaction.
  • A document-database batch.
  • Another persistence mechanism.

Do not create an abstraction that falsely claims all transaction technologies are identical. The port should express only the guarantees the application actually requires.

Testing Benefits and Limits

Dependency inversion allows high-level policy to be tested with controlled implementations:

Code
public sealed class StubPaymentAuthorizer(
    bool approved) : IPaymentAuthorizer
{
    public Task<PaymentAuthorization> AuthorizeAsync(
        Money amount,
        PaymentMethod method,
        CancellationToken cancellationToken)
    {
        return Task.FromResult(
            new PaymentAuthorization(
                "test-auth",
                approved,
                approved ? null : "declined"));
    }
}

This is useful for domain or application scenarios.

It does not replace integration tests:

  • EF Core queries must be tested against a realistic database.
  • HTTP routing and model binding require pipeline tests.
  • Broker configuration and serialization require integration or contract tests.
  • Vendor adapters require sandbox, stub-server, or contract testing.

Testability is a benefit of a good boundary, not a reason to mock every class.

Service Locator Anti-Pattern

Service location asks a global container for dependencies:

Code
public sealed class ConfirmOrderHandler(
    IServiceProvider services)
{
    public async Task HandleAsync(
        ConfirmOrder command,
        CancellationToken cancellationToken)
    {
        var repository =
            services.GetRequiredService<IOrderRepository>();

        // ...
    }
}

Problems:

  • Required dependencies are hidden.
  • Invalid objects can be constructed.
  • Tests require container setup.
  • Runtime failures replace compile-time feedback.
  • Application code depends on the DI framework.

Prefer explicit constructor injection:

Code
public sealed class ConfirmOrderHandler(
    IOrderRepository repository)
{
}

Use IServiceProvider only in infrastructure-level factories or framework integration where dynamic resolution is genuinely required.

Captive Dependencies and Lifetimes

Correct dependency direction does not guarantee correct DI lifetimes.

A singleton must not capture a scoped dependency:

Code
builder.Services.AddSingleton<OrderCache>();
builder.Services.AddScoped<AppDbContext>();

If OrderCache directly receives AppDbContext, the scoped context becomes captive for the singleton's lifetime.

Choose lifetimes based on ownership and thread-safety:

  • Transient: New instance for each resolution.
  • Scoped: One instance per request or explicit scope.
  • Singleton: One instance for the application lifetime.

Validate scopes during development and avoid manually building nested service providers.

Circular Dependencies

Circular dependencies are a sign that responsibilities or boundaries are unclear:

Code
Application A -> Application B -> Application A

Possible fixes:

  • Extract a shared lower-level concept.
  • Move behavior to the module that owns the invariant.
  • Define an event or callback where temporal decoupling is appropriate.
  • Introduce a higher-level orchestrator.
  • Reconsider the module boundary.

Do not resolve a conceptual cycle merely by adding interfaces on both sides. That can hide the cycle without fixing ownership.

Dependency Inversion Across Modules

The principle applies beyond classes.

Suppose Billing needs customer status from Customer Management.

Weak coupling:

Code
Billing -> CustomerManagement.Infrastructure.Database

Better module contract:

Code
Billing -> CustomerManagement.Contracts
CustomerManagement -> CustomerManagement.Contracts

Or the consuming module can own a port:

Code
CustomerManagement.Adapter -> Billing.ICustomerCreditStatus

The correct ownership depends on whether the interaction is a published provider contract or a consumer-specific adapter.

For cross-process communication, contracts must also address:

  • Versioning.
  • Availability.
  • Timeouts.
  • Idempotency.
  • Eventual consistency.
  • Observability.

Dependency Inversion in a Modular Monolith

Modules can expose narrow application contracts while hiding implementation types.

Code
Orders
  - Domain
  - Application
  - Infrastructure

Payments
  - Domain
  - Application
  - Infrastructure

Orders should not query Payments tables directly. It can use:

  • A published Payments contract.
  • An internal event.
  • A consumer-owned port implemented by an adapter.

The modular monolith keeps calls in-process while preserving boundaries that could support later extraction.

When Not to Invert a Dependency

Do not add an abstraction automatically.

Direct dependency can be appropriate when:

  • The dependency is a stable language or framework primitive.
  • The code is local and unlikely to vary.
  • The abstraction would only mirror the concrete API.
  • The dependency and consumer belong to the same cohesive module.
  • Integration tests are more valuable than substitution.

Examples:

  • Depending on List<T>.
  • Using ILogger<T> directly.
  • Using CancellationToken.
  • Using TimeProvider instead of a custom clock interface.
  • Calling a cohesive internal class directly.

Invert dependencies at architectural boundaries and volatile details, not at every new expression.

Common Mistakes

Confusing DI with DIP

Injecting a concrete class is dependency injection but not necessarily dependency inversion.

Putting All Interfaces in a Shared Project

A large Common.Abstractions project can become a dependency magnet. Place contracts near the policy or consumer that owns them.

Generic Lowest-Common-Denominator Ports

Generic abstractions can hide useful capabilities while still leaking technical concepts.

Leaking Outer Types Inward

Examples:

  • HttpContext in application services.
  • IActionResult in use cases.
  • EF Core DbSet<T> in domain services.
  • Vendor DTOs in domain entities.
  • Broker message types in domain events.

Using Interfaces Only for Mocks

This often creates production abstractions shaped by a testing tool rather than by the application design.

Hiding Dependencies

Static globals, ambient context, and service location make classes appear simpler while increasing runtime coupling.

Over-Inverting Stable Internal Code

An interface around every internal class creates navigation and configuration without protecting a meaningful boundary.

Claiming Complete Technology Independence

Abstractions reduce coupling but cannot erase important semantics such as transactions, consistency, query capabilities, delivery guarantees, and latency.

Best Practices

  • Identify high-level policy before choosing abstractions.
  • Point project references toward policy.
  • Let consumers own the ports they require.
  • Keep interfaces narrow and use-case-oriented.
  • Translate transport, persistence, and vendor models at adapters.
  • Keep construction in the composition root.
  • Use constructor injection for required dependencies.
  • Avoid service location.
  • Prefer platform abstractions when they already fit.
  • Test core policy in isolation and adapters through integration tests.
  • Validate DI lifetimes.
  • Enforce boundaries with project references and architecture tests.
  • Do not introduce an interface without a boundary or variation reason.
  • Revisit abstractions when the system's real needs become clearer.

Decision Checklist

Before inverting a dependency, ask:

Code
1. Is this dependency an external or volatile detail?
2. Does high-level policy need protection from it?
3. What exact capability does the consumer require?
4. Who should own the abstraction?
5. Can a platform abstraction already satisfy the need?
6. What technology-specific models must be translated?
7. What guarantees must the port express?
8. How will the adapter be integration-tested?
9. Does the added abstraction reduce total change cost?

Interview Practice

PreviousSOLID Principles in .NET DesignNext UpLayered architecture vs Clean Architecture vs ports-and-adapters