You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

DDD:文件系统领域聚合的合理设计方案选型咨询

如何在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

火山引擎 最新活动