C#领域驱动设计在 ERP 项目中的应用设计

  在 ERP(企业资源计划)项目开发中,我们常面临一个核心挑战:如何将复杂多变的业务规则转化为可维护、可扩展的代码?传统的 "数据库优先" 或 "贫血模型" 开发模式,往往导致业务逻辑分散在服务层、控制器甚至 UI 层,当业务规模扩大时,代码会变得臃肿且难以迭代。而领域驱动设计(DDD)通过聚焦业务领域本身,将代码与业务语言对齐,为解决这一问题提供了成熟的方法论。
  我在多个大型 ERP 项目中实践过 DDD,深刻体会到它在复杂业务场景中的价值。本文将结合 C# 语言特性,从设计思想到具体实现,聊聊 DDD 在 ERP 项目中的应用。

一、ERP 项目的痛点与 DDD 的契合点

ERP 系统的核心是 "业务流程数字化",其典型特点包括:
  • 业务规则复杂:从采购审批流程到库存预警策略,从成本核算到财务对账,每个模块都有大量行业特有的规则;
  • 模块耦合深:一个业务动作(如 "销售出库")可能涉及销售、库存、财务、物流等多个模块;
  • 变更频繁:企业会因政策调整、业务扩张、管理升级等原因频繁修改流程,代码需具备快速响应能力;
  • 数据一致性要求高:例如 "库存数量" 必须与 "订单明细"、"出入库单" 严格一致,不允许出现业务漏洞。
传统开发模式下,这些特点往往导致:
  • 业务逻辑与技术实现混杂(如在OrderService中同时写校验规则和数据库操作);
  • 跨模块修改时牵一发而动全身(改库存逻辑可能影响采购、销售两个模块);
  • 新人接手时需花大量时间理解 "代码背后的业务"(因为代码没有用业务语言表达)。
而 DDD 的核心思想 ——"围绕业务领域建模,用代码映射业务语言",恰好能解决这些问题:
  • 通过限界上下文划分模块边界,降低耦合;
  • 用聚合根、实体、值对象封装业务规则,确保数据一致性;
  • 用领域事件解耦跨模块协作;
  • 用领域服务封装复杂业务流程,避免逻辑分散。
C# 作为强类型面向对象语言,其特性(如record值对象、接口抽象、泛型约束)能完美支撑 DDD 的实现。

二、ERP 项目中的 DDD 核心组件设计

1. 限界上下文:划分模块的 "业务边界"

  限界上下文(Bounded Context)是 DDD 的核心概念,它定义了一个 "业务领域的边界",边界内的术语、规则、模型是统一的。在 ERP 中,我们可根据核心业务流程划分限界上下文:
限界上下文核心业务核心实体
采购管理 采购订单创建、供应商管理、采购入库 采购订单(PurchaseOrder)、供应商(Supplier)
销售管理 销售订单创建、客户管理、销售出库 销售订单(SalesOrder)、客户(Customer)
库存管理 库存盘点、出入库、库存预警 仓库(Warehouse)、库存记录(InventoryRecord)
财务管理 应收应付、成本核算 账单(Bill)、账户(Account)
  C# 实现技巧:通过命名空间(Namespace)体现限界上下文,例如:
// 销售管理上下文
namespace Mysoft.Sales.Domain

// 采购管理上下文
namespace Mysoft.Purchasing.Domain

  

  跨上下文通信应避免直接引用,而是通过领域事件或上下文映射(如防腐层)实现。例如 "采购入库" 完成后,库存上下文需要同步更新库存,此时采购上下文发布PurchaseReceiptCreated事件,库存上下文订阅事件并处理。

2. 领域模型:用代码表达业务规则

  领域模型是 DDD 的 "灵魂",它由聚合根(Aggregate Root)、实体(Entity)、值对象(Value Object)组成,核心是封装业务规则( invariants)。

(1)聚合根:确保业务一致性的 "管理者"

  聚合根是一组相关实体和值对象的集合,它负责维护整个聚合的业务规则,外部只能通过聚合根访问内部成员。在 ERP 中,"订单" 是典型的聚合根:
// 采购订单聚合根
public class PurchaseOrder : AggregateRoot
{
    // 聚合根唯一标识
    public PurchaseOrderId Id { get; private set; }
    
    // 关联的供应商(值对象,不可变)
    public SupplierInfo Supplier { get; private set; }
    
    // 订单明细(实体集合,受聚合根管理)
    private List<PurchaseOrderItem> _items = new();
    public IReadOnlyList<PurchaseOrderItem> Items => _items.AsReadOnly();
    
    // 订单状态(封装状态流转规则)
    public PurchaseOrderStatus Status { get; private set; }
    
    // 总金额(由明细计算得出,禁止直接修改)
    public Money TotalAmount => new Money(_items.Sum(i => i.TotalPrice.Amount), _items.FirstOrDefault()?.TotalPrice.Currency);
    
    // 业务规则:创建订单时必须有至少一条明细
    public static PurchaseOrder Create(PurchaseOrderId id, SupplierInfo supplier, List<PurchaseOrderItem> items)
    {
        if (items == null || items.Count == 0)
            throw new BusinessException("采购订单至少包含一条明细");
        
        return new PurchaseOrder
        {
            Id = id,
            Supplier = supplier,
            _items = items,
            Status = PurchaseOrderStatus.Draft
        };
    }
    
    // 业务规则:只有草稿状态的订单可添加明细
    public void AddItem(PurchaseOrderItem item)
    {
        if (Status != PurchaseOrderStatus.Draft)
            throw new BusinessException("只有草稿状态的订单可添加明细");
        
        _items.Add(item);
    }
    
    // 业务规则:订单提交后状态变为"待审批",并发布事件
    public void Submit()
    {
        if (Status != PurchaseOrderStatus.Draft)
            throw new BusinessException("只有草稿状态的订单可提交");
        
        Status = PurchaseOrderStatus.PendingApproval;
        AddDomainEvent(new PurchaseOrderSubmitted(this));
    }
}
设计要点:
  • 聚合根的属性通过private set禁止外部直接修改,仅通过内部方法(如AddItemSubmit)修改,确保业务规则被遵守;
  • 总金额(TotalAmount)由明细计算得出,避免手动赋值导致的数据不一致;
  • 状态流转(Submit方法)封装在聚合根内部,避免外部随意修改状态。

(2)实体:有唯一标识的 "可变对象"

实体(Entity)是具有唯一标识的对象,其生命周期中属性可能变化,但标识不变。例如采购订单明细(PurchaseOrderItem):
public class PurchaseOrderItem : Entity
{
    // 实体唯一标识(在聚合内唯一即可)
    public PurchaseOrderItemId Id { get; private set; }
    
    // 商品信息(值对象)
    public ProductInfo Product { get; private set; }
    
    // 采购数量
    public int Quantity { get; private set; }
    
    // 单价(值对象)
    public Money UnitPrice { get; private set; }
    
    // 小计金额(计算属性)
    public Money TotalPrice => new Money(Quantity * UnitPrice.Amount, UnitPrice.Currency);
    
    // 业务规则:采购数量不能为负
    public PurchaseOrderItem(PurchaseOrderItemId id, ProductInfo product, int quantity, Money unitPrice)
    {
        if (quantity <= 0)
            throw new BusinessException("采购数量必须大于0");
        
        Id = id;
        Product = product;
        Quantity = quantity;
        UnitPrice = unitPrice;
    }
    
    // 业务规则:修改数量时需校验
    public void UpdateQuantity(int newQuantity)
    {
        if (newQuantity <= 0)
            throw new BusinessException("采购数量必须大于0");
        
        Quantity = newQuantity;
    }
}

(3)值对象:无唯一标识的 "不可变对象"

值对象(Value Object)用于描述事物的属性,无唯一标识,且属性不可变(修改时需创建新对象)。ERP 中常见的 "金额"、"地址"、"商品信息" 等都适合作为值对象:
// 金额值对象(包含数值和币种,不可变)
public record Money(decimal Amount, Currency Currency)
{
    // 业务规则:金额不能为负
    public Money
    {
        if (Amount < 0)
            throw new BusinessException("金额不能为负");
    }
    
    // 金额相加(返回新对象,原对象不变)
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new BusinessException("币种不同,无法相加");
        
        return new Money(Amount + other.Amount, Currency);
    }
}

// 币种枚举
public enum Currency
{
    CNY, USD, EUR
}

// 供应商信息值对象
public record SupplierInfo(string Code, string Name, ContactInfo Contact)
{
    // 业务规则:供应商编码不能为空
    public SupplierInfo
    {
        if (string.IsNullOrWhiteSpace(Code))
            throw new BusinessException("供应商编码不能为空");
    }
}

// 联系人信息值对象
public record ContactInfo(string Name, string Phone, string Email);
C# 优势:record类型天生适合值对象 —— 不可变(默认属性为init)、重写了Equals(值相等即对象相等),无需手动实现。

3. 领域服务:处理跨聚合的业务逻辑

当业务逻辑涉及多个聚合根时,应使用领域服务(Domain Service)封装。例如 "库存不足时自动创建采购建议",需要查询库存聚合和供应商聚合:
public class PurchaseSuggestionService : IDomainService
{
    private readonly IInventoryRepository _inventoryRepository;
    private readonly ISupplierRepository _supplierRepository;
    
    // 依赖注入仓储(仅依赖接口,不依赖具体实现)
    public PurchaseSuggestionService(IInventoryRepository inventoryRepository, ISupplierRepository supplierRepository)
    {
        _inventoryRepository = inventoryRepository;
        _supplierRepository = supplierRepository;
    }
    
    // 生成采购建议
    public List<PurchaseSuggestion> GenerateSuggestions(WarehouseId warehouseId)
    {
        var lowStockProducts = _inventoryRepository.GetLowStockProducts(warehouseId);
        var suggestions = new List<PurchaseSuggestion>();
        
        foreach (var product in lowStockProducts)
        {
            // 查找该商品的默认供应商
            var defaultSupplier = _supplierRepository.GetDefaultSupplier(product.Id);
            if (defaultSupplier == null)
                continue;
            
            // 计算建议采购数量(基于安全库存和当前库存)
            var suggestedQuantity = product.SafeStock - product.CurrentStock;
            suggestions.Add(new PurchaseSuggestion(product, defaultSupplier, suggestedQuantity));
        }
        
        return suggestions;
    }
}
设计要点:领域服务仅包含领域逻辑,不涉及数据持久化细节(通过仓储接口操作);命名应体现业务动作(如GenerateSuggestions),而非技术动作(如GetXXX)。

4. 领域事件:解耦跨模块协作

领域事件(Domain Event)用于记录领域中发生的重要事件,并通知其他模块处理。例如 "销售订单创建后,需要扣减库存":
// 销售订单创建事件
public record SalesOrderCreated(SalesOrder Order) : IDomainEvent;

// 库存模块订阅事件,处理库存扣减
public class SalesOrderCreatedHandler : IDomainEventHandler<SalesOrderCreated>
{
    private readonly IInventoryRepository _inventoryRepository;
    private readonly IUnitOfWork _unitOfWork;
    
    public SalesOrderCreatedHandler(IInventoryRepository inventoryRepository, IUnitOfWork unitOfWork)
    {
        _inventoryRepository = inventoryRepository;
        _unitOfWork = unitOfWork;
    }
    
    public async Task Handle(SalesOrderCreated salesOrderCreatedEvent, CancellationToken cancellationToken)
    {
        var order = salesOrderCreatedEvent.Order;
        foreach (var item in order.Items)
        {
            var inventory = await _inventoryRepository.GetByProductAndWarehouse(item.Product.Id, order.WarehouseId);
            inventory.DeductStock(item.Quantity); // 扣减库存(库存聚合根的方法,封装扣减规则)
        }
        await _unitOfWork.SaveChangesAsync(cancellationToken);
    }
}

C# 实现:可借助 MediatR 库(.NET 生态常用的事件中介库)实现事件的发布与订阅,简化代码:

// 发布事件(在聚合根或领域服务中)
await _mediator.Publish(new SalesOrderCreated(order));

// 订阅事件(Handler自动被MediatR发现)
public class SalesOrderCreatedHandler : INotificationHandler<SalesOrderCreated>

5. 仓储:隔离数据访问细节

仓储(Repository)负责聚合根的持久化,屏蔽数据库操作细节,使领域层专注于业务。在 C# 中,通常通过接口定义仓储,在基础设施层用 EF Core 实现:
// 领域层:仓储接口
public interface IPurchaseOrderRepository : IRepository<PurchaseOrder>
{
    Task<PurchaseOrder> GetByIdAsync(PurchaseOrderId id);
    Task<List<PurchaseOrder>> GetBySupplierAsync(SupplierId supplierId);
    Task AddAsync(PurchaseOrder order);
    Task UpdateAsync(PurchaseOrder order);
}

// 基础设施层:EF Core实现
public class EfCorePurchaseOrderRepository : IPurchaseOrderRepository
{
    private readonly ErpDbContext _dbContext;
    
    public EfCorePurchaseOrderRepository(ErpDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    
    public async Task<PurchaseOrder> GetByIdAsync(PurchaseOrderId id)
    {
        return await _dbContext.PurchaseOrders
            .Include(o => o.Items) // 加载聚合内的子实体
            .FirstOrDefaultAsync(o => o.Id == id);
    }
    
    // 其他方法实现...
}
设计要点:仓储仅针对聚合根设计(避免直接操作子实体);查询应返回完整聚合(通过 EF Core 的Include加载关联数据),确保业务规则能被正确执行。

三、应用层:协调业务流程

应用层(Application Layer)位于领域层之上,负责协调领域对象完成用户用例,不包含业务规则(业务规则在领域层)。例如 "创建采购订单" 的用例:
public class CreatePurchaseOrderCommandHandler : ICommandHandler<CreatePurchaseOrderCommand, PurchaseOrderDto>
{
    private readonly IPurchaseOrderRepository _orderRepository;
    private readonly ISupplierRepository _supplierRepository;
    private readonly IUnitOfWork _unitOfWork;
    private readonly IMapper _mapper; // 用于领域模型与DTO的转换
    
    public CreatePurchaseOrderCommandHandler(/* 注入依赖 */)
    {
        // 初始化依赖
    }
    
    public async Task<PurchaseOrderDto> Handle(CreatePurchaseOrderCommand command, CancellationToken cancellationToken)
    {
        // 1. 验证输入(基础验证,如必填项)
        if (command.SupplierId == null)
            throw new ValidationException("供应商ID不能为空");
        
        // 2. 调用领域层获取数据
        var supplier = await _supplierRepository.GetByIdAsync(command.SupplierId);
        if (supplier == null)
            throw new BusinessException("供应商不存在");
        
        // 3. 转换DTO为领域对象
        var items = command.Items.Select(item => new PurchaseOrderItem(
            new PurchaseOrderItemId(Guid.NewGuid()),
            new ProductInfo(item.ProductCode, item.ProductName, null),
            item.Quantity,
            new Money(item.UnitPrice, command.Currency)
        )).ToList();
        
        // 4. 调用聚合根方法创建订单(业务规则在领域层执行)
        var order = PurchaseOrder.Create(
            new PurchaseOrderId(Guid.NewGuid()),
            supplier.Info,
            items
        );
        
        // 5. 持久化领域对象
        await _orderRepository.AddAsync(order);
        await _unitOfWork.SaveChangesAsync(cancellationToken);
        
        // 6. 转换领域对象为DTO返回
        return _mapper.Map<PurchaseOrderDto>(order);
    }
}
设计要点:应用层是 "协调者",负责:
  • 接收用户输入(DTO);
  • 调用领域层获取数据、执行业务逻辑;
  • 处理事务(通过IUnitOfWork);
  • 返回结果(DTO)。

四、实践中的挑战与解决方案

在 ERP 项目中落地 DDD,难免遇到一些挑战,结合 C# 生态可这样解决:
  1. 性能问题:聚合根过大时,查询可能加载过多数据。解决方案:用 "查询模型" 优化 —— 通过 EF Core 的Select投影查询,直接返回 DTO(绕过领域模型),仅在写操作时使用完整聚合。
  2. 与遗留系统集成:ERP 常需与旧系统(如财务软件)对接。解决方案:在限界上下文边缘添加 "防腐层"(Anti-Corruption Layer),将旧系统的数据格式转换为领域模型可识别的格式,避免领域层被污染。
  3. 团队认知成本:DDD 概念较多,团队上手慢。解决方案:先从核心模块(如订单管理)入手,通过代码示例(如本文的聚合根实现)建立共识;结合事件风暴(Event Storming)工作坊,让业务人员和开发人员共同梳理领域模型。
  4. 事务一致性:跨聚合操作需保证事务。解决方案:通过 "Saga 模式" 实现分布式事务 —— 将跨聚合操作拆分为多个本地事务,用领域事件串联,失败时执行补偿操作(如PurchaseOrderFailed事件触发库存回滚)。

五、总结

DDD 并非银弹,但在 ERP 这类复杂业务系统中,它能帮助我们构建 "业务驱动" 的代码 —— 让代码像业务文档一样易读,让业务规则在领域模型中得到严格封装,让模块边界通过限界上下文清晰划分。
C# 的面向对象特性(封装、继承、多态)、record值对象、泛型、依赖注入等,为 DDD 的实现提供了天然支持。结合 EF Core(数据持久化)、MediatR(事件处理)、AutoMapper(DTO 转换)等库,我们能快速搭建一套成熟的 DDD 开发框架。
最后想强调:DDD 的核心是 "理解业务",代码只是业务的映射。在 ERP 项目中,与其纠结 "聚合根是否设计正确",不如多花时间与业务人员沟通,让领域模型真正反映企业的业务流程 —— 这才是 DDD 的价值所在。
posted @ 2025-10-28 11:16  梳墨呀  阅读(46)  评论(0)    收藏  举报