如何在 C# 中处理聚合根之间的关系?

你想知道在 C# 中遵循 DDD 原则处理多个聚合根之间关联关系的方法,核心是既要保证每个聚合根的独立性和边界完整性,又能实现跨聚合的业务交互,同时避免强耦合、数据不一致或循环依赖等问题。

聚合根之间的关系处理是 DDD 落地的核心难点之一,C# 中有一套贴合语言特性(如强类型、依赖注入、事件机制)的最佳实践,下面从核心原则、具体实现方式、避坑指南三个维度展开讲解。

一、处理聚合根关系的核心原则(C# 适配版)

在动手编码前,先明确 3 个不可违背的原则,这是保证聚合边界不被破坏的基础:

  1. 仅通过 ID 关联,不直接持有对象引用
    聚合根内只存储其他聚合根的强类型 ID (而非裸 Guid/int,更非对象实例),避免跨聚合的强耦合,也解决持久化时的级联加载/循环引用问题。
  2. 单向关联优先,避免双向依赖
    尽量设计「单向 ID 引用」(如 Order → UserId),而非双向关联(Order→UserId + User→List<OrderId>),除非有强业务需求(如"用户必须查看所有订单"),否则会大幅增加复杂度。
  3. 跨聚合逻辑通过领域服务/领域事件协调
    聚合根内部不直接调用其他聚合根的方法,跨聚合的业务规则交给领域服务 ,跨聚合的状态同步交给领域事件,保障聚合根的内聚性。

二、C# 中聚合根关系的具体实现方式

1. 基础实现:用强类型 ID 存储关联(核心)

C# 中推荐用 record 定义强类型 ID(替代裸 Guid/int),避免不同聚合根的 ID 混用(比如把 OrderId 传给 UserId 参数)。

csharp 复制代码
// 1. 定义各聚合根的强类型ID(C# record 天然支持值相等、不可变)
public record UserId(Guid Value);
public record OrderId(Guid Value);
public record ProductId(Guid Value);

// 2. 聚合根1:User(用户)------ 独立聚合根
public class User
{
    public UserId Id { get; }
    public string Name { get; }
    public bool IsFrozen { get; private set; } // 用户状态:是否冻结
    public int PaidOrderCount { get; private set; } // 已支付订单数

    private User(UserId id, string name)
    {
        Id = id ?? throw new ArgumentNullException(nameof(id));
        Name = !string.IsNullOrWhiteSpace(name) ? name : throw new ArgumentException("用户名不能为空");
        IsFrozen = false;
        PaidOrderCount = 0;
    }

    // 工厂方法创建
    public static User Create(string name) => new User(new UserId(Guid.NewGuid()), name);

    // 聚合根内部行为:更新已支付订单数(仅内部/领域事件可调用)
    internal void IncrementPaidOrderCount() => PaidOrderCount++;
}

// 3. 聚合根2:Order(订单)------ 关联User的ID(仅存ID,不存User对象)
public class Order
{
    // 核心属性:仅存储关联聚合根的ID
    public OrderId Id { get; }
    public UserId UserId { get; } // 关联用户ID(核心:仅存ID,非对象)
    public OrderStatus Status { get; private set; }

    // 领域事件容器(用于跨聚合通信)
    private readonly List<INotification> _domainEvents = new();
    public IReadOnlyCollection<INotification> DomainEvents => _domainEvents.AsReadOnly();

    private Order(OrderId id, UserId userId)
    {
        Id = id ?? throw new ArgumentNullException(nameof(id));
        UserId = userId ?? throw new ArgumentNullException(nameof(userId));
        Status = OrderStatus.PendingPayment;
    }

    // 工厂方法创建订单(仅依赖UserId)
    public static Order Create(UserId userId) => new Order(new OrderId(Guid.NewGuid()), userId);

    // 聚合根行为:标记订单为已支付(触发领域事件)
    public void MarkAsPaid()
    {
        if (Status != OrderStatus.PendingPayment)
            throw new InvalidOperationException("仅待支付订单可支付");
        
        Status = OrderStatus.Paid;
        // 发布领域事件:订单支付成功(用于跨聚合同步)
        _domainEvents.Add(new OrderPaidDomainEvent(Id, UserId));
    }

    // 清空领域事件(仓储保存后调用)
    public void ClearDomainEvents() => _domainEvents.Clear();
}

public enum OrderStatus
{
    PendingPayment, // 待支付
    Paid,           // 已支付
    Cancelled       // 已取消
}

2. 按需加载关联的聚合根(通过仓储)

当业务需要访问关联聚合根的信息时,通过仓储根据 ID 查询,而非在聚合根内直接持有对象引用(避免聚合边界被突破)。

csharp 复制代码
// 1. 仓储接口(仅针对聚合根设计)
public interface IUserRepository
{
    Task<User?> FindByIdAsync(UserId userId, CancellationToken ct = default);
    Task SaveAsync(User user, CancellationToken ct = default);
}

public interface IOrderRepository
{
    Task<List<Order>> FindByUserIdAsync(UserId userId, CancellationToken ct = default);
    Task SaveAsync(Order order, CancellationToken ct = default);
}

// 2. 领域服务:按需加载关联聚合根(查询用户+用户的所有订单)
public class OrderQueryService
{
    private readonly IOrderRepository _orderRepo;
    private readonly IUserRepository _userRepo;

    public OrderQueryService(IOrderRepository orderRepo, IUserRepository userRepo)
    {
        _orderRepo = orderRepo;
        _userRepo = userRepo;
    }

    // 核心逻辑:先查用户,再根据UserId查订单(按需加载,不耦合)
    public async Task<(User User, List<Order> Orders)> GetUserOrdersAsync(UserId userId)
    {
        // 1. 查询用户聚合根
        var user = await _userRepo.FindByIdAsync(userId) 
            ?? throw new KeyNotFoundException($"用户ID {userId.Value} 不存在");
        
        // 2. 查询该用户的所有订单聚合根
        var orders = await _orderRepo.FindByUserIdAsync(userId);
        
        return (user, orders);
    }
}

3. 跨聚合业务规则:用领域服务协调

当需要跨聚合根校验业务规则(如"冻结用户不能创建订单")时,不要在一个聚合根内依赖另一个聚合根,而是通过领域服务整合。

csharp 复制代码
// 领域服务:处理跨聚合的订单创建逻辑
public class OrderCreationService
{
    private readonly IOrderRepository _orderRepo;
    private readonly IUserRepository _userRepo;
    private readonly IStockService _stockService;

    public OrderCreationService(IOrderRepository orderRepo, IUserRepository userRepo, IStockService stockService)
    {
        _orderRepo = orderRepo;
        _userRepo = userRepo;
        _stockService = stockService;
    }

    public async Task<Order> CreateOrderAsync(UserId userId, List<OrderItemDto> itemDtos)
    {
        // 1. 跨聚合校验:查询用户并校验状态(聚合根不直接依赖,由领域服务协调)
        var user = await _userRepo.FindByIdAsync(userId)
            ?? throw new InvalidOperationException("用户不存在");
        
        if (user.IsFrozen)
            throw new InvalidOperationException("用户已被冻结,无法创建订单");

        // 2. 创建订单聚合根(仅依赖UserId,不依赖User对象)
        var order = Order.Create(userId);
        
        // 3. 可选:校验商品(另一个聚合根)的库存
        foreach (var itemDto in itemDtos)
        {
            var productId = new ProductId(itemDto.ProductId);
            var product = await _productRepo.FindByIdAsync(productId);
            order.AddItem(product, itemDto.Quantity, _stockService);
        }

        // 4. 保存订单聚合根
        await _orderRepo.SaveAsync(order);
        return order;
    }
}

4. 跨聚合数据一致性:用领域事件(C# 实现)

当一个聚合根的状态变更需要同步到另一个聚合根(如"订单支付成功后,更新用户的已支付订单数"),通过领域事件异步处理 ,避免分布式事务。C# 中常用 MediatR 实现事件发布/订阅。

csharp 复制代码
// 第一步:定义领域事件(C# record 天然适合做事件)
public record OrderPaidDomainEvent(OrderId OrderId, UserId UserId) : INotification;

// 第二步:实现事件处理器(处理跨聚合逻辑)
public class OrderPaidDomainEventHandler : INotificationHandler<OrderPaidDomainEvent>
{
    private readonly IUserRepository _userRepo;

    public OrderPaidDomainEventHandler(IUserRepository userRepo)
    {
        _userRepo = userRepo;
    }

    // 事件处理逻辑:更新用户的已支付订单数
    public async Task Handle(OrderPaidDomainEvent notification, CancellationToken cancellationToken)
    {
        var user = await _userRepo.FindByIdAsync(notification.UserId, cancellationToken);
        if (user != null)
        {
            user.IncrementPaidOrderCount(); // 调用User聚合根的内部方法
            await _userRepo.SaveAsync(user, cancellationToken);
        }
    }
}

// 第三步:仓储保存时触发事件(EF Core 示例)
public class EfCoreOrderRepository : IOrderRepository
{
    private readonly AppDbContext _dbContext;
    private readonly IMediator _mediator;

    public EfCoreOrderRepository(AppDbContext dbContext, IMediator mediator)
    {
        _dbContext = dbContext;
        _mediator = mediator;
    }

    public async Task SaveAsync(Order order, CancellationToken ct = default)
    {
        // 1. 保存订单聚合根
        if (_dbContext.Orders.Contains(order))
            _dbContext.Update(order);
        else
            _dbContext.Add(order);
        
        await _dbContext.SaveChangesAsync(ct);

        // 2. 发布所有领域事件(触发跨聚合逻辑)
        foreach (var domainEvent in order.DomainEvents)
        {
            await _mediator.Publish(domainEvent, ct);
        }
        
        // 3. 清空事件,避免重复发布
        order.ClearDomainEvents();
    }
}

三、C# 中处理聚合根关系的避坑指南

常见错误 问题后果 C# 正确做法
聚合根内直接持有其他聚合根对象 强耦合、循环引用、持久化级联加载异常 仅存储强类型 ID,按需通过仓储查询
使用裸 Guid/int 作为关联 ID 容易传错参数(如把 OrderId 传给 UserId) record 定义强类型 ID(如 UserId/OrderId
在聚合根构造函数/方法中依赖其他聚合根 聚合根内聚性被破坏,测试难度增加 跨聚合逻辑交给领域服务,聚合根仅依赖自身状态
双向关联(User 存 OrderId 列表 + Order 存 UserId) 复杂度飙升,易出现数据不一致 优先单向关联,仅在业务必需时保留双向 ID 引用(不存对象)

总结

处理 C# 中聚合根之间的关系,核心要抓住 3 个关键点:

  1. 关联方式 :仅通过强类型 ID 关联(用 record 实现),不直接持有其他聚合根对象,避免耦合;
  2. 规则协调 :跨聚合的业务规则交给领域服务处理,聚合根内只封装自身的核心逻辑;
  3. 状态同步 :跨聚合的状态变更通过领域事件(如 MediatR) 异步处理,保障数据一致性且避免分布式事务。

这套方式既符合 DDD 的聚合边界原则,又充分利用了 C# 的语言特性(强类型、record、依赖注入),是工业级项目中最常用的实践方案。

本文使用 文章同步助手 同步

相关推荐
better_liang21 小时前
每日Java面试场景题知识点之-DDD领域驱动设计
java·ddd·实体·领域驱动设计·架构设计·聚合根·企业级开发
喵个咪14 天前
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:分层设计的取舍之道(从 “简单粗暴” 到依赖倒置)
后端·go·领域驱动设计
rolt1 个月前
[漫画]《软件方法》微服务的遮羞布
微服务·ddd·领域驱动设计
canonical_entropy1 个月前
对于《目前程序语言与软件工程研究中真正严重的缺陷是什么?》一文的解读
后端·架构·领域驱动设计
youkezan1 个月前
在 DDD 中如何正确使用 ConfigureAwait(false):原理、误区与最佳实践
领域驱动设计
没逻辑1 个月前
Gopher 带你学 DDD:一套不烧脑的业务建模指南
架构·领域驱动设计
信码由缰1 个月前
在企业级 Java 中应用领域驱动设计:一种行为驱动方法
领域驱动设计
kevinzeng1 个月前
MVC 和 DDD
后端·领域驱动设计
canonical_entropy2 个月前
Nop平台到底有什么独特之处,它能用在什么场景?
java·后端·领域驱动设计