Skip to content
Go back

C# 依赖倒置原则:让业务代码依赖抽象

依赖倒置原则(Dependency Inversion Principle,DIP)是 SOLID 的 D。它解决的问题很常见:业务代码一旦直接依赖数据库、文件系统、HTTP 客户端或具体日志实现,后面替换实现、写单元测试、调整架构都会变重。

原文用 C# 和 .NET 10 示例讲了一个清楚的路径:先识别高层模块和低层模块,再用接口建立抽象契约,接着用构造函数注入和 DI 容器完成对象组装。读完这篇,你应该能判断一段代码是在遵守 DIP,还是只用了依赖注入的外壳。

DIP 在说什么

DIP 有两条核心规则:

在 C# 里,这通常意味着业务类依赖接口,例如 IOrderRepositoryIPaymentGatewayILogger<T>,具体实现由 SQL、文件、第三方服务或日志提供方承担。

这里的“高层模块”通常是业务逻辑:OrderServicePaymentProcessorNotificationManager。它们描述软件要做什么。

“低层模块”通常是基础设施:SqlOrderRepositoryFileLoggerStripePaymentGateway。它们描述事情怎么被完成。

如果 OrderService 直接引用 SqlOrderRepository,业务逻辑就知道了存储细节。换数据库时,你会改到业务服务。DIP 要把这个方向转过来:业务逻辑只声明自己需要一个订单仓储契约,SQL 仓储去实现这个契约。

典型坏味道

原文先给了一个典型 DIP 违规例子:

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
{
    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);

这段代码的问题集中在一行:

private readonly SqlOrderRepository _repository = new();

OrderService 直接创建了 SqlOrderRepository。这会带来三个后果:

根因很简单:业务服务知道了太多实现细节。

引入抽象

修法是把 OrderService 真正需要的能力抽出来:

public interface IOrderRepository
{
    Order? GetById(int id);
    void Save(Order order);
}

低层模块实现这个契约:

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...");
    }
}

高层模块依赖契约:

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 不关心数据来自 SQL Server、内存字典、NoSQL,还是测试替身。它只依赖 IOrderRepository 这个契约。

这就是 DIP 最关键的变化:业务逻辑看见的是能力,具体技术选择留在外部组装位置。

构造函数注入

DIP 要求依赖从外部传入,而不是在类内部创建。C# 里最常用的方式是构造函数注入。

原文把构造函数注入作为 .NET 10 的主要写法,原因很实际:

C# 12 之后,主构造函数让这段代码更短:

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);
    }
}

repositorylogger 都从外部进入 OrderService。类本身只处理订单相关业务。

注册到 DI 容器

.NET 的内置容器来自 Microsoft.Extensions.DependencyInjection。它负责把接口映射到实现,并在运行时创建对象。

原文中的注册方式如下:

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

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<OrderService>();

builder.Logging.AddConsole();

var host = builder.Build();

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();

这里有两个关键点。

AddScoped<IOrderRepository, SqlOrderRepository>() 表示:当有人需要 IOrderRepository 时,容器提供 SqlOrderRepository

OrderService 自己不用知道这个映射。容器解析 OrderService 时,会读取构造函数,发现它需要 IOrderRepositoryILogger<OrderService>,再把已注册的对象传进去。

生命周期要选对

原文也提醒,DI 注册不能只看“能跑起来”。服务生命周期选错,会制造很隐蔽的问题。

常见生命周期有三个:

比如数据库仓储常用 AddScoped,让每个请求拿到自己的仓储实例。后台服务、桌面应用或手动管理 scope 的场景,还要结合实际生命周期重新判断。

DIP 和 DI 的区别

这两个词经常混在一起,但它们解决的问题不同。

DIP 是设计原则。它说业务逻辑和基础设施都依赖抽象。

DI 是实现模式。它把依赖从外部传进对象,常见方式包括构造函数注入、方法注入、属性注入,以及手动组装。

你可以遵守 DIP,但不用 DI 容器:

var repository = new SqlOrderRepository();
var logger = LoggerFactory.Create(b => b.AddConsole())
                          .CreateLogger<OrderService>();
var service = new OrderService(repository, logger);

这段手动组装依然遵守 DIP,因为 OrderService 依赖的是 IOrderRepository

反过来,你也可能用了 DI 容器,却没有遵守 DIP。比如直接注入 SqlOrderRepository 这个具体类型,容器确实帮你创建了对象,但业务服务仍然绑在具体实现上。

判断时别只看有没有容器。要看业务类依赖的是抽象,还是具体实现。

容器如何解析

当你写下:

builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();

容器会记录一条映射。后面解析 OrderService 时,它会检查构造函数,发现需要 IOrderRepository,再根据映射创建 SqlOrderRepository 并传入。

原文说这是一个简化理解模型。真实容器会缓存解析路径,也会做各种优化。对日常开发来说,理解这个过程已经足够解释很多现象:

所以构造函数注入通常是必需依赖的默认选择。可选依赖或运行时选择依赖时,再考虑工厂、方法参数或其他模式。

日志是好例子

.NET 里的 ILogger<T> 很适合说明 DIP。

业务类依赖的是 ILogger<PaymentProcessor>。日志写到控制台、文件、Application Insights,还是 Serilog,是外部注册决定的细节。

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);

        return true;
    }
}

如果要把内置控制台日志换成 Serilog,通常改的是启动注册代码。PaymentProcessor 不需要变。

这个例子很有代表性:业务代码只依赖稳定抽象,输出目标由基础设施配置决定。

测试会变轻

DIP 带来的直接收益是单元测试更轻。

原文用 NSubstitute 写了一个测试例子:

using NSubstitute;
using Xunit;

public sealed class OrderServiceTests
{
    [Fact]
    public void GetOrder_WhenOrderExists_ReturnsOrder()
    {
        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);

        var result = service.GetOrder(42);

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

测试只关心 OrderService 的行为,不需要真实 SQL Server、HTTP 服务或文件系统。仓储用测试替身代替,日志也可以用替身代替。

如果没有 DIP,OrderService 内部直接 new 了 SqlOrderRepository,这个测试就会被迫靠近集成测试。每次测业务分支,都可能要准备数据库或其他外部资源。

架构里的 DIP

DIP 不只适用于一个类。原文把它放进几个常见架构模式里看:

把范围放大后,原则仍然一样:稳定的业务规则和模块边界不该直接依赖具体基础设施类。

什么时候别过度抽象

DIP 不等于每个类都要配一个接口。抽象应该服务于变化点、测试需求和边界隔离。

比较适合引入抽象的场景:

可以暂缓抽象的场景:

好的接口应该描述能力。为了“看起来符合设计原则”硬加接口,通常只会让代码更绕。

实践检查清单

你可以用这几个问题检查一段 C# 代码:

如果这些问题频繁出现,DIP 很可能能帮你减轻耦合。

结语

DIP 的重点不是容器,也不是接口数量。重点是让业务代码依赖稳定契约,把具体技术选择留给外部组装。

在 C# 和 .NET 里,最常见的组合是接口、构造函数注入和 DI 容器注册。OrderService 依赖 IOrderRepositorySqlOrderRepository 实现这个接口,测试替身也实现同一个接口。业务逻辑因此更容易测试,也更能承受基础设施变化。

如果你关注 AI 助手、开发工具和软件工程实践,可以关注 Aide Hub。这里会继续分享可操作的工具教程、技术观察和项目经验。

参考


Tags


Next

C# 接口隔离原则:把胖接口拆成清晰角色