BrandGhost
Dependency Inversion Principle C#: Abstractions Over Concretions

Dependency Inversion Principle C#: Abstractions Over Concretions

Dependency Inversion Principle in C#: Abstractions Over Concretions

The dependency inversion principle c# is the final letter in SOLID -- and arguably the most impactful one for real-world architecture. The principle has two parts. First: high-level modules should not depend on low-level modules. Second: both should depend on abstractions, not concretions. That sounds academic until you see it in code. An OrderService that directly creates a SqlOrderRepository is tightly coupled to SQL Server. Swap the database, and you are rewriting service logic. DIP breaks that coupling. This article walks through exactly how, with practical .NET 10 examples.

High-Level vs Low-Level Modules

Before applying the dependency inversion principle c#, you need a clear picture of what "high-level" and "low-level" mean in context.

High-level modules contain business logic -- the rules and orchestration that make your application do something meaningful. OrderService, PaymentProcessor, NotificationManager. These are the modules that define what your software does.

Low-level modules handle infrastructure concerns -- database access, file I/O, HTTP calls, external APIs, logging sinks. SqlOrderRepository, FileLogger, StripePaymentGateway. These are the modules that define how your software does it.

In a traditional layered design, high-level modules directly reference low-level ones. Business logic imports data access. OrderService depends on SqlOrderRepository. The problem is immediate: high-level modules are coupled to specific implementations. Change the database technology and you are touching business logic. That is wrong -- business logic should not know or care how data is stored.

DIP inverts this. High-level modules define the abstractions they need. Low-level modules implement those abstractions. Both point toward the abstraction -- not at each other.

The Classic dependency inversion principle c# Violation

Here is a textbook violation in plain C#:

// ❌ DIP violation: OrderService directly instantiates SqlOrderRepository
public sealed class SqlOrderRepository
{
    public Order? GetById(int id)
    {
        Console.WriteLine($"Fetching order {id} from SQL Server...");
        return new Order(id, "Widget", 49.99m);
    }

    public void Save(Order order)
    {
        Console.WriteLine($"Saving order {order.Id} to SQL Server...");
    }
}

public sealed class OrderService
{
    // ❌ Tightly coupled to a concrete implementation
    private readonly SqlOrderRepository _repository = new();

    public Order? GetOrder(int id) => _repository.GetById(id);
    public void PlaceOrder(Order order) => _repository.Save(order);
}

public record Order(int Id, string ProductName, decimal Price);

OrderService has three serious problems here. First, it cannot be tested without hitting SQL Server. Second, switching from SQL to any other storage mechanism requires modifying OrderService directly -- business logic changes because of an infrastructure decision. Third, the new() call means OrderService is managing the lifetime of SqlOrderRepository, which is not its responsibility.

Every one of these problems stems from the same root cause: OrderService knows too much about its dependency.

Applying DIP: Introducing the Abstraction

The dependency inversion principle c# often follows the same pattern: introduce an abstraction. A common fix is to introduce an interface -- an abstraction that both sides depend on:

// ✅ Abstraction: both high-level and low-level depend on this contract
public interface IOrderRepository
{
    Order? GetById(int id);
    void Save(Order order);
}

// ✅ Low-level module implements the abstraction
public sealed class SqlOrderRepository : IOrderRepository
{
    public Order? GetById(int id)
    {
        Console.WriteLine($"Fetching order {id} from SQL Server...");
        return new Order(id, "Widget", 49.99m);
    }

    public void Save(Order order)
    {
        Console.WriteLine($"Saving order {order.Id} to SQL Server...");
    }
}

// ✅ High-level module depends on the abstraction, not the implementation
public sealed class OrderService(IOrderRepository repository)
{
    public Order? GetOrder(int id) => repository.GetById(id);
    public void PlaceOrder(Order order) => repository.Save(order);
}

public record Order(int Id, string ProductName, decimal Price);

OrderService no longer knows or cares whether data comes from SQL Server, a NoSQL store, a mock, or an in-memory dictionary. It depends on IOrderRepository. The abstraction is the contract. The implementation is a detail -- and details can change freely without touching business logic.

Constructor Injection as the Primary .NET 10 Pattern

The dependency inversion principle c# requires that dependencies be provided from outside -- not created internally. Constructor injection is the primary mechanism in .NET 10 and a strong default for required dependencies.

Why constructor injection over property injection or method injection?

  • Explicit dependencies -- you can see all required dependencies at a glance from the constructor signature
  • Immutability -- constructor-injected dependencies are captured by the primary constructor, naturally readonly
  • Fail-fast -- if a required dependency is missing, the DI container throws at startup, not at runtime during a method call
  • Testability -- you pass mocks directly in test constructors with no ceremony

Property injection, method injection, or factory patterns are still legitimate choices for optional or runtime-selected dependencies.

Primary constructors (available since C# 12 / .NET 8, and continuing in .NET 10) make constructor injection even cleaner:

// ✅ Primary constructor -- concise, no boilerplate field declarations
public sealed class OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
    public Order? GetOrder(int id)
    {
        logger.LogInformation("Getting order {OrderId}", id);
        return repository.GetById(id);
    }

    public void PlaceOrder(Order order)
    {
        logger.LogInformation("Placing order {OrderId}", order.Id);
        repository.Save(order);
    }
}

Clean. Readable. Complete. The primary constructor captures repository and logger without boilerplate field declarations and assignments.

The .NET 10 DI Container: Registering Your Abstractions

.NET 10 ships with Microsoft.Extensions.DependencyInjection -- the built-in DI container that wires your abstractions to their implementations. Here is how you register IOrderRepository and configure the application:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = Host.CreateApplicationBuilder(args);

// Register the abstraction mapped to its implementation
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<OrderService>();

// Console logging is registered automatically, but you can customize it
builder.Logging.AddConsole();

var host = builder.Build();

// Resolve and exercise the service
using var scope = host.Services.CreateScope();
var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
var order = orderService.GetOrder(42);
Console.WriteLine($"Retrieved: {order?.ProductName}");

await host.RunAsync();

Understanding service lifetimes is critical for getting this right:

  • AddSingleton -- one shared instance for the entire application lifetime. Use for stateless services, configuration objects, or expensive-to-create infrastructure.
  • AddScoped -- one instance per request or per DI scope. A strong default for database-backed services like SqlOrderRepository in web applications -- each request gets its own repository instance.
  • AddTransient -- a new instance every time the type is resolved. Use for lightweight, stateless services where sharing state is undesirable.

Registering IOrderRepository with AddScoped means each web request gets its own repository instance, which is a common approach for database-backed repositories in web applications that should not share connection state. In background services, desktop applications, or explicit scope management scenarios, lifetime choices may differ.

The article on how dependency injection containers use reflection internally explains the mechanics of how the container resolves types at startup and runtime. The companion article on C# reflection in .NET 10 covers the reflection APIs that make that resolution possible.

DIP vs Dependency Injection: Two Different Things

This distinction comes up constantly and is worth being precise about.

Dependency Inversion Principle (DIP) is a design principle. It says high-level modules should not depend on low-level modules -- both should depend on abstractions. It says nothing about how those abstractions get resolved or injected.

Dependency Injection (DI) is a pattern -- one specific mechanism for implementing DIP. Instead of a class creating its own dependencies, those dependencies are provided from outside. DI is one way to achieve DIP. It is not the only way.

You can follow DIP without a DI container. Manual construction is perfectly valid:

// Manual DI -- no container needed
var repository = new SqlOrderRepository();
var logger = LoggerFactory.Create(b => b.AddConsole())
                          .CreateLogger<OrderService>();
var service = new OrderService(repository, logger);

That code follows DIP. OrderService still depends on IOrderRepository, not SqlOrderRepository. DI containers just automate that wiring, manage lifetimes, and detect missing registrations at startup rather than at runtime.

The flip side: you can also have DI without DIP. If you inject concrete types -- SqlOrderRepository directly instead of IOrderRepository -- you are using DI infrastructure but violating DIP. The abstraction is the important part. Think of DIP as the what: "depend on abstractions." DI is the how: "have someone else provide those abstractions." Together, they express the dependency inversion principle c# in practice.

How Reflection Powers the DI Container

When you call builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>(), you register a mapping. When OrderService is resolved later, the DI container conceptually inspects OrderService's constructor, finds that it requires an IOrderRepository, looks up the registered mapping, creates a SqlOrderRepository, and passes it in. (Real containers optimize resolution paths and may cache reflection results -- this is a simplified mental model.)

This is exactly why constructor injection is the standard -- the container can introspect constructors at startup, build a complete resolution graph, and fail immediately if any registration is missing. Property injection, by contrast, cannot be verified at startup.

Understanding why certain DI patterns work and others fail in subtle ways comes down to how reflection is used at resolution time -- the earlier link on the DI container internals covers this in depth.

DIP and Logging in .NET 10

Logging is one of the most visible DIP examples in the .NET ecosystem. ILogger<T> is the abstraction. The actual log destination -- console, file, Application Insights, Serilog -- is the implementation detail.

Your classes depend on ILogger<T> rather than a concrete logger:

public sealed class PaymentProcessor(
    IOrderRepository repository,
    ILogger<PaymentProcessor> logger)
{
    public bool ProcessPayment(int orderId, decimal amount)
    {
        var order = repository.GetById(orderId);

        if (order is null)
        {
            logger.LogWarning("Order {OrderId} not found during payment processing", orderId);
            return false;
        }

        logger.LogInformation(
            "Processing payment of {Amount:C} for order {OrderId}",
            amount,
            orderId);

        // Payment logic here...
        return true;
    }
}

Switch from the built-in console logger to Serilog in .NET by changing one registration line in the composition root. PaymentProcessor does not change. That is DIP working exactly as intended -- the business logic is completely insulated from the infrastructure decision.

The logging abstraction is a convenient illustration, but DIP applies equally to any stable abstraction your domain logic depends on.

The complete guide to logging in .NET covers all the configuration options for ILogger<T> across different output providers and structured logging backends.

Testing Benefits: DIP Enables True Unit Tests

The dependency inversion principle c# directly enables unit testing. When your classes depend on interfaces, you can pass in test doubles -- mocks, stubs, fakes -- without touching real infrastructure.No SQL Server in your unit tests. No HTTP calls. No filesystem access.

using NSubstitute;
using Xunit;

public sealed class OrderServiceTests
{
    [Fact]
    public void GetOrder_WhenOrderExists_ReturnsOrder()
    {
        // Arrange
        var repository = Substitute.For<IOrderRepository>();
        var logger = Substitute.For<ILogger<OrderService>>();

        var expectedOrder = new Order(42, "Widget", 49.99m);
        repository.GetById(42).Returns(expectedOrder);

        var service = new OrderService(repository, logger);

        // Act
        var result = service.GetOrder(42);

        // Assert
        Assert.NotNull(result);
        Assert.Equal("Widget", result.ProductName);
        repository.Received(1).GetById(42);
    }

    [Fact]
    public void GetOrder_WhenOrderDoesNotExist_ReturnsNull()
    {
        // Arrange
        var repository = Substitute.For<IOrderRepository>();
        var logger = Substitute.For<ILogger<OrderService>>();
        repository.GetById(Arg.Any<int>()).Returns((Order?)null);

        var service = new OrderService(repository, logger);

        // Act
        var result = service.GetOrder(99);

        // Assert
        Assert.Null(result);
    }
}

Pure business logic testing. Fast. Isolated. Deterministic. No external dependencies. Without DIP, you cannot substitute SqlOrderRepository in tests -- you are forced into full integration test territory for every unit test. Every small behavioral change requires spinning up a database. That is slow, fragile, and completely avoidable.

DIP makes unit testing the default path -- not the exception.

DIP in Architectural Patterns

DIP shows up naturally in architectural patterns that show up throughout C# codebases.

The Facade Design Pattern creates a simplified interface over a complex subsystem. The facade and its consumers both depend on abstractions, not concrete subsystem classes. DIP is what keeps the facade decoupled from the things it simplifies.

The Proxy Design Pattern wraps an implementation behind the same interface -- caching proxies, logging proxies, authorization proxies. All of these work because DIP ensures both the proxy and the consumer depend on the same abstraction.

The Mediator Design Pattern defines an IMediator abstraction that handler components depend on -- decoupling senders from receivers through a shared abstraction. Without DIP, every sender would reference every handler directly.

In modular monolith architectures in C#, DIP is foundational. Module boundaries are defined by interfaces. Modules communicate through abstractions, never through direct class references. That is DIP applied at the architectural scale -- the same principle that governs a single class governs an entire module boundary.

FAQ

What is the dependency inversion principle in C#?

The dependency inversion principle c# has two rules: high-level modules should not depend on low-level modules -- both should depend on abstractions; and abstractions should not depend on details -- details should depend on abstractions. In C#, this means your business logic classes should depend on interfaces, not concrete implementations. The result is loosely coupled code that can change one layer without cascading changes through the others.

What is the difference between DIP and dependency injection?

DIP is a design principle -- depend on abstractions, not concretions. Dependency injection is a pattern and mechanism for implementing DIP -- it provides dependencies from outside rather than letting a class create them internally. DI containers like Microsoft.Extensions.DependencyInjection automate that wiring. You can follow DIP without a container (manual construction works). You can also use a DI container without following DIP if you inject concrete types directly.

What are the three service lifetimes in .NET 10 DI?

AddSingleton creates one instance shared for the entire application lifetime -- use for stateless services or expensive objects. AddScoped creates one instance per request or per DI scope -- use for database-backed services and per-request state. AddTransient creates a new instance every time the type is resolved -- use for lightweight, stateless services. Choosing the wrong lifetime causes subtle bugs: a singleton depending on a scoped service is a classic captive dependency problem.

How does DIP enable unit testing?

When your class depends on an interface, you can substitute a mock or stub in tests with no real infrastructure. Without DIP, testing business logic requires spinning up real databases, real HTTP servers, or real file systems. With DIP, unit tests are fast, isolated, and focused purely on the business rule being tested. NSubstitute, Moq, and FakeItEasy all depend on this principle to function.

Can I follow DIP without a DI container?

Yes. DIP is about depending on abstractions. You can achieve this by manually constructing and passing dependencies through constructors -- often called "pure DI" or "poor man's DI." DI containers are a convenience. They automate wiring, manage lifetimes, and catch missing registrations at startup. For small applications, manual wiring is straightforward and valid. For larger codebases, the container pays for itself quickly.

Why is constructor injection preferred over property injection in .NET?

Constructor injection makes all dependencies explicit and mandatory. If a required dependency is missing, the container fails at startup -- not silently during a method call hours later. Property injection allows objects to be created in an invalid state (no dependency set yet). Constructor injection also enables immutability through readonly fields (or primary constructor captures). .NET 10 primary constructors make constructor injection even more concise by eliminating boilerplate field declarations.

How does DIP apply to logging in .NET?

ILogger<T> is the abstraction. Your classes depend on it. The concrete log destination -- console, file, Application Insights, Serilog -- is an implementation detail registered in the DI container. Switching providers requires changing one registration line. Every class that logs keeps working without modification. That is DIP applied to a cross-cutting concern, and it is exactly how logging is designed in the .NET ecosystem.

Conclusion

The dependency inversion principle c# is the architectural cornerstone of loose coupling. High-level modules define what they need through interfaces. Low-level modules implement those interfaces. Registrations are configured at startup; services are resolved according to their lifetime and scope as the app handles requests.

The immediate payoff is testability -- swap real implementations for mocks with no ceremony. The long-term payoff is flexibility -- swap database providers, add logging backends, extend behavior through proxies and decorators, all without touching business logic.

DIP is not about using a DI container. It is about depending on abstractions. The container is just the tool that makes the dependency inversion principle c# effortless at scale in .NET 10.

Dependency Injection: How to Start with Autofac the Easy Way

Want more flexible, extensible, and testable code? We'll use Autofac net core! What is Autofac? It's a powerful Dependency Injection framework in dotnet!

What is Inversion of Control - A Simplified Beginner's Guide

What is Inversion of Control? Learn about Inversion of Control (IOC) and Dependency Injection and how it can be applied in real-world scenarios.

Interface Segregation Principle C#: Focused Interfaces That Scale

Master the interface segregation principle c# with before/after examples, DI registration in .NET 10, and tips for designing focused, role-based interfaces.

An error has occurred. This application may no longer respond until reloaded. Reload