一、问题从哪儿来:领域层为什么不能碰仓储实现

做后端开发的人,多多少少都听过“分层架构”这个词。我们常把一个系统分成表现层、应用层、领域层和基础设施层。领域层负责核心业务规则,基础设施层负责数据库、文件、第三方接口这些外部细节。听起来很清楚,但真正写代码的时候,很多人会不小心把数据库的“滋味”带到领域层里。

举个例子:用户下单,业务上要检查用户是否存在、库存是否够。很多初学者会直接在领域服务里手工创建一个数据库上下文对象,然后写一些查询条件去查表。这样写,领域服务直接和具体的数据库访问框架、数据库表结构耦合了。将来换数据库,领域服务也可能要跟着改。更麻烦的是,测试的时候,你想用一个内存数据库来跑业务逻辑,却发现领域服务里硬编码了连接配置,根本跑不起来。

“领域层依赖基础设施”指的就是这种状况。领域层本该只关心业务规则,结果却拿着数据库驱动、配置文件、甚至缓存组件。这样一来,领域层就失去了独立性,系统也变得越来越难维护。

要解决这个问题,不能靠“我小心一点,不在领域层写”这种自觉,而是要从代码结构上强制隔离。怎么强制?这就是我们今天要说的两个关键点:依赖反转和仓储接口隔离。

二、依赖反转的核心思想:让上层定义接口,让底层去实现

先别被“依赖反转”这个名字吓到,它说的事情其实很朴素。

传统思维里,我们写代码都是“上层依赖下层”,表现层依赖应用层,应用层依赖领域层,领域层依赖基础设施层。依赖关系是从上往下走的。依赖反转告诉你:把这种依赖方向倒过来。不是让领域层去依赖基础设施层,而是让领域层先定义出“我需要什么能力”,比如“我能读取订单”“我能保存订单”,然后让基础设施层去实现这些能力。这样领域层不再依赖任何基础设施,反过来,基础设施层倒是要依赖领域层定义的接口。

在 C# 里面,这个思想落地就是:用接口定义能力,用类去实现,再通过构造函数把实现注入进来。谁都要遵守这个接口约定。领域层只认接口,不认实现。

三、仓储接口隔离:把接口放在领域层,把实现放基础设施层

现在我们来构建一个完整的例子。技术栈我们统一使用 C#,配合 .NET 6 和 Entity Framework Core。下面的代码都用这个技术栈来解释。

3.1 先定义一个订单相关的领域模型

在领域层,我们先定义一个订单实体。这个实体里面只有业务字段和行为,不包含任何数据库注解或数据库访问代码。

// 技术栈:C# (.NET 6 + EF Core)
namespace Domain.Entities
{
    /// <summary>
    /// 订单聚合根
    /// </summary>
    public class Order
    {
        /// <summary>
        /// 主键ID,这里不写任何数据库访问框架相关特性
        /// </summary>
        public Guid Id { get; private set; }

        /// <summary>
        /// 订单编号,业务上叫它 OrderNo
        /// </summary>
        public string OrderNo { get; private set; }

        /// <summary>
        /// 客户ID,用于判断订单归属哪个客户
        /// </summary>
        public Guid CustomerId { get; private set; }

        /// <summary>
        /// 订单总金额,单位:分,避免小数误差
        /// </summary>
        public decimal TotalAmount { get; private set; }

        /// <summary>
        /// 订单状态:0-待支付,1-已支付,2-已取消
        /// </summary>
        public int Status { get; private set; }

        /// <summary>
        /// 创建一个新订单
        /// </summary>
        public Order(Guid customerId, decimal totalAmount)
        {
            Id = Guid.NewGuid();
            OrderNo = GenerateOrderNo();
            CustomerId = customerId;
            TotalAmount = totalAmount;
            Status = 0;
        }

        /// <summary>
        /// 将订单标记为已支付
        /// </summary>
        public void MarkAsPaid()
        {
            if (Status != 0)
            {
                throw new InvalidOperationException("只有待支付订单才能支付");
            }
            Status = 1;
        }

        /// <summary>
        /// 生成一个简单的订单号,实际项目里可能来自专门的ID生成器
        /// </summary>
        private string GenerateOrderNo()
        {
            return $"{DateTime.Now:yyyyMMddHHmmss}{Guid.NewGuid():N}"[..20];
        }
    }
}

3.2 在领域层定义仓储接口

注意,这个接口一定要放在领域层,而不是放在基础设施层。这是“接口隔离”的关键。接口只暴露业务需要的操作,不暴露任何数据库查询方面的技术类型。

// 技术栈:C# (.NET 6 + EF Core)
namespace Domain.Repositories
{
    /// <summary>
    /// 订单仓储接口,它属于领域层。
    /// 领域服务只需要依赖这个接口,
    /// 不关心它背后是 MySQL、SQL Server 还是内存列表。
    /// </summary>
    public interface IOrderRepository
    {
        /// <summary>
        /// 根据订单ID获取订单,没有则返回 null
        /// </summary>
        Task<Order?> GetByIdAsync(Guid orderId);

        /// <summary>
        /// 保存订单:如果订单不存在就新增,否则更新。
        /// 让领域层不关心“插入”和“更新”的区别。
        /// </summary>
        Task SaveAsync(Order order);

        /// <summary>
        /// 删除订单
        /// </summary>
        Task DeleteAsync(Order order);
    }
}

这里有人会问:为什么不直接在接口里返回一个可以继续拼接查询条件的查询对象?那样查询更加灵活。确实,很多简单的开发框架会这么干。但这个接口如果是在领域层,一旦返回那种查询对象,就把查询的技术细节泄漏给了领域层,领域层可能忍不住写出各种拼接查询条件,等于又把数据库逻辑带进来了。所以,我们这里刻意把接口收窄,每个方法都用业务语言命名。

3.3 在基础设施层实现仓储接口

接下来,我们在基础设施层写一个具体的实现。这个实现依赖数据库访问框架的上下文,并通过构造函数注入。注意,这里实现类放在基础设施项目里,它引用领域层,而不是反过来。

// 技术栈:C# (.NET 6 + EF Core)
using Domain.Entities;
using Domain.Repositories;
using Microsoft.EntityFrameworkCore;

namespace Infrastructure.Repositories
{
    /// <summary>
    /// 订单仓储的实现,只负责数据读写,不包含业务规则。
    /// </summary>
    public class OrderRepository : IOrderRepository
    {
        private readonly OrderDbContext _dbContext;

        /// <summary>
        /// 通过构造函数注入数据库上下文
        /// </summary>
        public OrderRepository(OrderDbContext dbContext)
        {
            _dbContext = dbContext;
        }

        /// <summary>
        /// 根据ID查找订单
        /// </summary>
        public async Task<Order?> GetByIdAsync(Guid orderId)
        {
            return await _dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId);
        }

        /// <summary>
        /// 保存订单:新增或更新
        /// </summary>
        public async Task SaveAsync(Order order)
        {
            var exists = await _dbContext.Orders.AnyAsync(o => o.Id == order.Id);

            if (exists)
            {
                _dbContext.Orders.Update(order);
            }
            else
            {
                await _dbContext.Orders.AddAsync(order);
            }

            await _dbContext.SaveChangesAsync();
        }

        /// <summary>
        /// 删除订单
        /// </summary>
        public async Task DeleteAsync(Order order)
        {
            _dbContext.Orders.Remove(order);
            await _dbContext.SaveChangesAsync();
        }
    }
}

这里你可能注意到,保存方法内部自己判断了新增还是更新,业务层就不需要关心这个差异了。这是一个很自然的封装。

3.4 在领域服务中使用仓储接口

现在,我们写一个领域服务,比如“创建订单并支付”的流程。这个服务看似要用到数据库,但代码里根本不出现数据库上下文,也不出现任何数据库类型。它只依赖订单仓储这个接口。

// 技术栈:C# (.NET 6 + EF Core)
using Domain.Entities;
using Domain.Repositories;

namespace Domain.Services
{
    /// <summary>
    /// 订单领域服务:处理订单创建和支付的核心业务逻辑。
    /// 它只依赖仓储接口,不依赖任何具体数据库实现。
    /// </summary>
    public class OrderDomainService
    {
        private readonly IOrderRepository _orderRepository;

        /// <summary>
        /// 构造注入接口,具体实现由外部容器提供。
        /// </summary>
        public OrderDomainService(IOrderRepository orderRepository)
        {
            _orderRepository = orderRepository;
        }

        /// <summary>
        /// 创建一笔新订单,并自动完成支付
        /// </summary>
        public async Task<Order> CreateAndPayOrderAsync(Guid customerId, decimal totalAmount)
        {
            // 1. 创建订单实体
            var order = new Order(customerId, totalAmount);

            // 2. 这里可以添加其他业务规则
            //    比如验证客户是否在黑名单里,这些规则可以放在领域服务中

            // 3. 先保存订单
            await _orderRepository.SaveAsync(order);

            // 4. 调用订单自身的业务方法,标记为已支付
            order.MarkAsPaid();

            // 5. 再保存一次,把状态更新到数据库
            await _orderRepository.SaveAsync(order);

            return order;
        }
    }
}

你看,整个领域服务里没有出现任何特定的数据库访问框架命名空间,也没有任何 SQL 字符串。将来从 SQL Server 换成 PostgreSQL,这个领域服务一行代码都不用改。

3.5 使用依赖注入把接口和实现接起来

最后,我们需要在系统启动的地方,把接口和具体实现绑定在一起。这一步通常发生在“组合根”,也就是 ASP.NET Core 的启动文件里。领域层完全不知道这个绑定过程。

// 技术栈:C# (.NET 6 + EF Core)
using Domain.Repositories;
using Domain.Services;
using Infrastructure.Repositories;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// 配置数据库访问框架使用 SQL Server
builder.Services.AddDbContext<OrderDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

// 注册仓储接口的实现
builder.Services.AddScoped<IOrderRepository, OrderRepository>();

// 注册领域服务
builder.Services.AddScoped<OrderDomainService>();

var app = builder.Build();

// 这里省略其他中间件配置
app.Run();

到了这一步,整个依赖链是这样的:领域服务依赖订单仓储接口,接口定义在领域层,实现类在基础设施层,基础设施层通过依赖注入在启动时被装配进去。领域层既不知道实现类存在,也不依赖它。这样就把“依赖反转”落实了。

四、应用场景与好处

这种解耦方案不是银弹,但有它特别合适的场景。我举几个典型情况。

第一,项目里有多套持久化方案。比如一部分数据在 SQL Server,一部分在 MongoDB,或者公司正从旧数据库迁到新数据库。这时每个数据库各自实现一套仓储,领域层不用改。

第二,业务需要做单元测试。领域服务要跑单元测试,如果硬编码了数据库访问,测试就必须连数据库。而有了接口,测试里可以扔一个假实现进去。比如下面的一个简易测试替身:

// 技术栈:C# (.NET 6 + EF Core) —— 单元测试中的替身实现
using Domain.Entities;
using Domain.Repositories;

namespace Tests.Fakes
{
    /// <summary>
    /// 一个内存版订单仓储,用于单元测试。
    /// 这样测试代码完全内存化,速度快,不依赖数据库环境。
    /// </summary>
    public class InMemoryOrderRepository : IOrderRepository
    {
        private readonly List<Order> _orders = new();

        public Task<Order?> GetByIdAsync(Guid orderId)
        {
            var order = _orders.FirstOrDefault(o => o.Id == orderId);
            return Task.FromResult(order);
        }

        public Task SaveAsync(Order order)
        {
            var index = _orders.FindIndex(o => o.Id == order.Id);
            if (index >= 0)
            {
                _orders[index] = order;
            }
            else
            {
                _orders.Add(order);
            }
            return Task.CompletedTask;
        }

        public Task DeleteAsync(Order order)
        {
            _orders.RemoveAll(o => o.Id == order.Id);
            return Task.CompletedTask;
        }
    }
}

这样测试代码就是完全内存化的,速度极快,也不需要提前准备数据库环境。这就是依赖注入带来的可测试性。

第三,团队分工明确。领域层代码由懂业务的人写,基础设施层代码由懂数据的人写。因为接口是稳定的契约,两边可以并行开发,只需要事先约定好接口方法就行。

好处再总结一下:降低耦合度,提高可测试性,增强代码可维护性,切换存储成本低,领域模型更纯净。

五、技术优缺点分析

依赖反转和仓储接口隔离,虽然经典,但也不是没有代价。

先讲优点。最直观的一点,领域层不会因为数据库驱动升级、连接串变动、数据库访问框架替换而改动。第二,领域服务可以独立测试。第三,代码结构清晰,每个类职责单一。第四,如果仓储实现还承担了缓存、审计、读写分离等横切关注点,那对领域层来说都是透明的,不影响业务代码。

再说缺点。首先,代码量会增加。原来直接在领域层操作数据库可能很简单,现在要定义接口、写实现、注册依赖,多出不少文件。其次,仓储接口如果设计得不好,很容易变得很厚,或者很薄。太厚的话,接口里全是复杂的查询方法,每个方法都对应一种特殊需求;太薄的话,只有几个最基本的方法,业务上一些复杂查询就得在外面拼凑,反而把查询逻辑泄漏到上层。第三,如果滥用仓储,可能导致数据访问性能下降。比如在领域层循环调用仓储读数据,而不是用一次查询带出全部数据,就会产生幺幺零问题,也就是俗称的“循环查询数据库”问题。这个问题和仓储模式本身有关,需要靠使用者的经验来规避。第四,对于简单的增删改查项目,这种分层会让开发效率变低,属于过度设计。

六、注意事项和常见坑

在实际落地的时候,有几个地方要特别小心。

第一,接口不要暴露可以继续拼接查询条件的查询对象。一旦暴露,领域层就拥有了直接构造查询的能力,会让业务代码和具体的表达式树耦合在一起。而且测试替身也不好模拟这种查询对象的行为。建议把需要的具体查询方法定义为领域语言,比如“获取某客户已支付的订单”,而不是提供一个通用的列表筛选方法。

第二,仓储里不要写业务规则。仓储的职责是持久化,不是判断订单能不能取消。如果把“能不能取消”这样的判断写进仓储,那业务规则就被拆散了。业务规则应该放在实体或领域服务中。

第三,事务边界怎么处理要提前想清楚。一个领域服务可能调用多个仓储,比如先保存订单,再保存客户积分。如果每个仓储各自保存一次,就可能出现前一个成功、后一个失败的情况。解决方式包括:使用工作单元模式,让多个仓储共享同一个数据库上下文,或者用显式事务。下面是一个简单示例,可以看到多个仓储如何共享同一个数据库上下文:

// 技术栈:C# (.NET 6 + EF Core)
using Microsoft.EntityFrameworkCore;

namespace Infrastructure.UnitOfWork
{
    /// <summary>
    /// 工作单元,用于在多个仓储操作之间开启一个事务。
    /// </summary>
    public class UnitOfWork
    {
        private readonly OrderDbContext _dbContext;

        /// <summary>
        /// 数据库上下文由依赖注入容器统一提供。
        /// </summary>
        public UnitOfWork(OrderDbContext dbContext)
        {
            _dbContext = dbContext;
        }

        /// <summary>
        /// 在一个数据库事务中执行多个仓储操作。
        /// </summary>
        public async Task ExecuteInTransactionAsync(Func<Task> action)
        {
            await using var transaction = await _dbContext.Database.BeginTransactionAsync();
            try
            {
                // 执行业务操作内部会调用各个仓储
                await action();

                // 统一保存一次,再提交事务
                await _dbContext.SaveChangesAsync();
                await transaction.CommitAsync();
            }
            catch
            {
                // 发生异常就回滚,保证数据一致性
                await transaction.RollbackAsync();
                throw;
            }
        }
    }
}

这样,领域服务可以把该方法包在业务逻辑外面,确保整个业务操作在一个事务里完成。

第四,仓储接口的粒度要以聚合为单位。通常一个聚合根对应一个仓储,不要为每个数据库表都建仓储。比如订单和订单明细是一个聚合,那就应该通过订单仓储一起处理,而不是搞一个订单仓储又搞一个订单明细仓储。如果每个表都建仓储,领域层会碎片化,而且事务控制会变得很别扭。

第五,注意依赖注入的生命周期匹配。比如数据库上下文如果被注册成一个请求作用域内共享的对象,仓储也应该保持同样的作用域,不能注册成单例,否则会有并发问题。这个错误在公司里很常见,值得写进代码审查清单。

第六,不要为了解耦而把整个数据库访问框架的查询能力都重新封装一遍。如果项目里大量报表查询、复杂聚合,用仓储接口去一个一个定义方法会很痛苦。这时可以把查询类的操作直接交给应用层使用数据库访问框架提供的查询能力,而写操作走仓储。这种混合做法在真实项目中也很多,需要根据实际情况取舍。

七、总结

回到最开始的问题:怎么让领域服务调用仓储时,既完成业务,又不沾数据库的边?答案就是两个动作:依赖反转加上仓储接口隔离。把仓储接口定义在领域层,把实现放在基础设施层,领域服务只面向接口编程,具体实现由外部组合根注入。这样领域层就能保持纯净,不关心数据库、不关心数据库访问框架、不关心文件存储。这个方案的代价是增加了一些结构和设计成本,但在中大型业务系统里,这些成本换来的可维护性和可测试性是非常值得的。

希望这篇文章能帮你理清思路。下次在自己的项目里设计仓储的时候,不妨先问自己一句:我的领域层真的不知道数据库的存在吗?如果答案是否定的,那就试试把接口往上提一层,把实现往下压一层吧。