DDD中如何让仓储重构聚合时绕过业务逻辑校验?
解决方案:分离数据加载与业务操作的入口
要解决仓储加载历史数据时绕过业务校验,同时强制终端用户遵守规则的问题,核心思路是为聚合提供两套创建/修改入口:一套专供仓储重建历史数据使用(跳过业务规则),另一套面向终端用户(严格执行校验)。以下是两种落地方案:
方案一:内部方法+程序集可见性控制
通过C#的internal访问修饰符,给Project添加仅允许仓储程序集调用的内部方法,直接添加历史Disbursement;终端用户只能调用带校验的公开方法。
步骤1:修改Project类,添加内部方法
public class Project { public Budget Budget { get; } private List<Disbursement> Disbursements { get; } // 公开构造函数:用于创建新项目 public Project(Budget budget) { Budget = budget ?? throw new ArgumentNullException(nameof(budget)); Disbursements = new List<Disbursement>(); } // 终端用户操作的公开方法:带业务规则校验 public void AddDisbursement(Disbursement newDisbursement) { if (newDisbursement == null) throw new ArgumentNullException(nameof(newDisbursement)); var source = Budget.GetSource(newDisbursement.SourceNo); if (source == null) throw new Exception("无效的资金来源编号"); if (source.AvailableAmount < newDisbursement.Amount) throw new Exception("可用资金不足"); var use = Budget.GetUse(newDisbursement.UseNo); if (use == null) throw new Exception("无效的资金用途编号"); if (use.AvailableAmount < newDisbursement.Amount) throw new Exception("该用途已超支"); Disbursements.Add(newDisbursement); // 更新资金来源和用途的已使用金额 source.UpdateUsedAmount(newDisbursement.Amount); use.UpdateUsedAmount(newDisbursement.Amount); } // 内部方法:专供仓储加载历史数据,跳过校验 internal void AddDisbursementForRehydration(Disbursement disbursement) { if (disbursement == null) throw new ArgumentNullException(nameof(disbursement)); Disbursements.Add(disbursement); } }
步骤2:允许仓储程序集访问内部方法
在领域模型项目的AssemblyInfo.cs中添加:
[assembly: InternalsVisibleTo("YourWarehouseAssemblyName")]
这样仓储所在的程序集就能调用AddDisbursementForRehydration方法,而外部用户无法访问。
步骤3:完善Source和Use类的金额更新逻辑
public class Source { public int SourceNo { get; } public decimal BudgetedAmount { get; set; } public decimal UsedAmount { get; private set; } public decimal AvailableAmount => BudgetedAmount - UsedAmount; // 供重建时重置已使用金额 internal void ResetUsedAmount() { UsedAmount = 0; } // 供添加支出时更新已使用金额 internal void UpdateUsedAmount(decimal amount) { UsedAmount += amount; } } // Use类逻辑与Source一致,此处省略
步骤4:仓储加载数据时的逻辑
仓储从数据库读取所有数据后,先创建Project实例,再调用内部方法批量添加历史Disbursement,最后重新计算资金来源和用途的已使用金额:
public Project GetProject(Guid projectId) { // 从数据库读取项目、预算、所有支出数据 var budgetData = _dbContext.GetBudget(projectId); var budget = MapToBudget(budgetData); var disbursementDatas = _dbContext.GetDisbursements(projectId); var disbursements = disbursementDatas.Select(MapToDisbursement).ToList(); // 创建Project实例 var project = new Project(budget); // 批量添加历史支出 foreach (var disbursement in disbursements) { project.AddDisbursementForRehydration(disbursement); } // 重新计算已使用金额,确保数据一致性 ResetAndRecalculateUsedAmounts(project); return project; } private void ResetAndRecalculateUsedAmounts(Project project) { // 重置所有资金来源和用途的已使用金额 foreach (var source in project.Budget.Sources) source.ResetUsedAmount(); foreach (var use in project.Budget.Uses) use.ResetUsedAmount(); // 遍历支出,累加已使用金额 foreach (var disbursement in project.GetDisbursements()) { // 需添加GetDisbursements()公开方法返回只读集合 var source = project.Budget.GetSource(disbursement.SourceNo); var use = project.Budget.GetUse(disbursement.UseNo); source.UpdateUsedAmount(disbursement.Amount); use.UpdateUsedAmount(disbursement.Amount); } }
方案二:工厂类负责聚合重建
创建专门的工厂类,封装历史数据的重建逻辑,通过私有构造函数确保只有工厂能直接初始化聚合的内部集合。
步骤1:创建ProjectFactory
public class ProjectFactory { public static Project Reconstruct(Budget budget, List<Disbursement> disbursements) { if (budget == null) throw new ArgumentNullException(nameof(budget)); if (disbursements == null) disbursements = new List<Disbursement>(); // 直接调用Project的私有构造函数 var project = new Project(budget, disbursements); // 校验关联关系(确保支出对应的资金来源/用途存在) ValidateDisbursementAssociations(project); // 重新计算已使用金额 ResetAndRecalculateUsedAmounts(project); return project; } private static void ValidateDisbursementAssociations(Project project) { foreach (var disbursement in project.GetDisbursements()) { if (project.Budget.GetSource(disbursement.SourceNo) == null) throw new InvalidOperationException($"支出引用了无效的资金来源编号:{disbursement.SourceNo}"); if (project.Budget.GetUse(disbursement.UseNo) == null) throw new InvalidOperationException($"支出引用了无效的资金用途编号:{disbursement.UseNo}"); } } private static void ResetAndRecalculateUsedAmounts(Project project) { foreach (var source in project.Budget.Sources) source.ResetUsedAmount(); foreach (var use in project.Budget.Uses) use.ResetUsedAmount(); foreach (var disbursement in project.GetDisbursements()) { var source = project.Budget.GetSource(disbursement.SourceNo); var use = project.Budget.GetUse(disbursement.UseNo); source.UpdateUsedAmount(disbursement.Amount); use.UpdateUsedAmount(disbursement.Amount); } } }
步骤2:修改Project类,添加私有构造函数
public class Project { public Budget Budget { get; } private List<Disbursement> Disbursements { get; } // 私有构造函数:专供工厂重建历史数据 private Project(Budget budget, List<Disbursement> disbursements) { Budget = budget; Disbursements = disbursements; } // 公开构造函数:用于创建新项目 public Project(Budget budget) { Budget = budget ?? throw new ArgumentNullException(nameof(budget)); Disbursements = new List<Disbursement>(); } // 终端用户操作的公开方法:带业务规则校验 public void AddDisbursement(Disbursement newDisbursement) { // 同方案一的校验逻辑 } // 公开方法:返回只读的支出集合 public IReadOnlyList<Disbursement> GetDisbursements() { return Disbursements.AsReadOnly(); } }
步骤3:仓储加载数据时调用工厂
public Project GetProject(Guid projectId) { // 从数据库读取数据并映射为领域对象 var budget = MapToBudget(_dbContext.GetBudget(projectId)); var disbursements = _dbContext.GetDisbursements(projectId) .Select(MapToDisbursement) .ToList(); // 调用工厂重建Project return ProjectFactory.Reconstruct(budget, disbursements); }
方案对比
- 方案一:实现简单,依赖C#的程序集可见性控制,适合中小型项目。
- 方案二:更符合DDD的单一职责原则,将重建逻辑从聚合中剥离,适合复杂领域模型或需要多维度重建的场景。
两种方案都能确保:
- 终端用户只能通过带校验的公开方法修改聚合,严格遵守业务规则;
- 仓储加载历史数据时绕过业务校验,但仍会做必要的一致性校验(如关联关系、金额计算),避免数据无效。
内容的提问来源于stack exchange,提问作者Jason 'Bug' Fenter
相关产品推荐
相关产品推荐

