DDD:文件系统领域聚合的合理设计方案选型咨询
首先,你的核心困惑点其实是对聚合边界的理解偏差——DDD中的聚合并不是要把所有关联的数据都打包成一个不可分割的整体,而是要围绕业务一致性规则来划定边界。我们来逐一分析你的方案,并给出更合理的设计思路:
为什么你的两种方案都有问题?
第一种方案:聚合边界过大
你最初的Catalog聚合包含了父Catalog实体,这直接导致聚合边界无限延伸(因为每个父目录又有自己的父目录),完全违背了聚合“可整体加载、可作为一致性单元”的核心原则——加载一个目录就要加载整个文件树,这在实际系统中根本不可行。但这个方案的核心思路(把Document放在Catalog聚合内)其实是对的,只是错把父目录实体也纳入了聚合边界。
第二种方案:丢失聚合内的业务规则
只存储ID的方式,虽然避免了加载整个树,但把Catalog和Document变成了完全独立的聚合,导致原本属于Catalog的业务规则(比如“目录内文档名称唯一”)无法在聚合内维护——你需要跨聚合查询才能验证规则,不仅增加了复杂度,还可能引发并发一致性问题。
正确的聚合设计思路
我们需要重新划定聚合边界,围绕业务规则的归属来设计:
方案1:将Document作为Catalog聚合内的实体(推荐)
这是最符合DDD原则的设计,因为“目录内文档名称唯一”是Catalog需要保证的核心业务规则,所以Document应该作为Catalog聚合内的实体,由Catalog统一管理其生命周期。关键调整是:父目录只存储ID,不存储实体引用,这样聚合边界就限定为「单个目录 + 其直接下属的所有文档」,加载时只需要获取当前目录和它的文档,完全可控。
代码示例:
// 聚合根:Catalog public class Catalog : AggregateRoot { public CatalogId Id { get; private set; } public CatalogId ParentId { get; private set; } // 仅存父目录ID,不属于当前聚合 private readonly List<Document> _documents = new(); public IReadOnlyList<Document> Documents => _documents.AsReadOnly(); // 业务方法:检查文档名称是否唯一 public bool IsDocumentNameUnique(string name) { return !_documents.Any(doc => doc.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); } // 业务方法:添加文档(同时验证规则) public void AddDocument(Document document) { if (!IsDocumentNameUnique(document.Name)) { throw new InvalidOperationException("目录内已存在同名文档"); } _documents.Add(document); } } // Catalog聚合内的实体:Document public class Document { public DocumentId Id { get; private set; } public string Name { get; private set; } // 其他文档属性:内容、大小等 public Document(DocumentId id, string name) { Id = id; Name = name ?? throw new ArgumentNullException(nameof(name)); } } // 值对象:ID类型 public record CatalogId(Guid Value); public record DocumentId(Guid Value);
这种设计的优势:
- 聚合边界清晰,加载成本可控(仅加载单个目录及其文档)
- 业务规则(名称唯一)在聚合内维护,保证了内存中的一致性,避免跨聚合查询
- Catalog完全掌控下属文档的生命周期(创建、删除、重命名等),符合“聚合根管理边界内所有实体”的DDD原则
方案2:将Document设为独立聚合根(适用于特殊场景)
如果你的业务中Document需要被单独频繁访问(比如直接通过文档ID查询、修改,而不需要关联目录),可以把Document设为独立聚合根,同时通过领域服务来维护跨聚合的业务规则,再配合数据库层面的唯一索引(CatalogId + Name)来兜底并发问题。
代码示例:
// 聚合根:Catalog public class Catalog : AggregateRoot { public CatalogId Id { get; private set; } public CatalogId ParentId { get; private set; } } // 聚合根:Document public class Document : AggregateRoot { public DocumentId Id { get; private set; } public CatalogId CatalogId { get; private set; } public string Name { get; private set; } } // 领域服务:维护跨聚合的业务规则 public class DocumentDomainService { private readonly IDocumentRepository _documentRepository; public DocumentDomainService(IDocumentRepository documentRepository) { _documentRepository = documentRepository; } public bool IsNameUniqueInCatalog(CatalogId catalogId, string name) { var existingDocuments = _documentRepository.GetByCatalogId(catalogId); return !existingDocuments.Any(doc => doc.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); } } // 数据库层面添加唯一约束:CatalogId + Name
这种方案的注意点:
- 跨聚合的规则检查可能存在并发漏洞,必须依赖数据库的唯一索引来保证最终一致性
- 领域服务只是封装了查询逻辑,无法像聚合内那样在内存中保证强一致性
总结
优先选择方案1,因为它完全符合DDD的聚合设计原则,能够高效维护业务一致性,且实现成本更低。只有当Document需要脱离目录独立存在(你的需求中明确说每个文档必须隶属于某个目录,所以这种场景不适用),或者有特殊的性能需求时,才考虑方案2。
内容的提问来源于stack exchange,提问作者Adiras




