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:
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:
Compile-time dependencies:
Infrastructure -> Application -> Domain
^
|
IOrderRepository
At runtime, the call still reaches infrastructure:
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:
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:
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:
public interface IOrderRepository
{
Task<Order?> GetForConfirmationAsync(
OrderId orderId,
CancellationToken cancellationToken);
}
public interface IUnitOfWork
{
Task SaveChangesAsync(
CancellationToken cancellationToken);
}
The use case depends on those abstractions:
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:
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:
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:
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:
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:
// Avoid
public Task HandleAsync(HttpRequest request);
Translate it into an application request:
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:
Application -> SMTP adapter -> SMTP server
At compile time:
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
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
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:
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:
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:
IPaymentAuthorizer
IOrderRepository
IExchangeRateProvider
IIntegrationEventPublisher
TimeProvider
Weak abstraction candidates:
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:
public sealed class ConfirmOrderHandler(
IOrderRepository orders)
{
}
Method injection:
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:
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:
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:
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:
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:
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:
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:
public interface IPaymentAuthorizer
{
Task<PaymentAuthorization> AuthorizeAsync(
Money amount,
PaymentMethod method,
CancellationToken cancellationToken);
}
Infrastructure translates to the vendor:
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:
public bool IsExpired()
{
return ExpiresAtUtc <= DateTimeOffset.UtcNow;
}
Modern .NET provides TimeProvider:
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:
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:
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:
public IActionResult ConfirmOrder(Guid id);
inside the application layer.
Prefer:
public Task<ConfirmOrderResult> HandleAsync(
ConfirmOrder command,
CancellationToken cancellationToken);
The API adapter maps the application result:
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:
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:
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:
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:
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:
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:
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:
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:
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:
Billing -> CustomerManagement.Infrastructure.Database
Better module contract:
Billing -> CustomerManagement.Contracts
CustomerManagement -> CustomerManagement.Contracts
Or the consuming module can own a port:
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.
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
TimeProviderinstead 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:
HttpContextin application services.IActionResultin 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:
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?