领域驱动设计瘦身术:胖聚合瘦身记!


别再把你的聚合根喂成猪了!DDD性能优化实战!胖聚合根导致性能崩溃和并发灾难。只保留同一事务必需的数据,跨规则交给领域服务,系统才能健康运行。

领域驱动设计中的聚合根很容易长成胖子,因为程序员天生喜欢往里面塞东西,就像去自助餐厅总觉得拿少了亏本。但胖聚合根会导致数据库性能崩溃、并发冲突爆炸、代码维护变成噩梦。正确的做法是只保留必须在同一个事务中保持一致的数据,其他东西全部拆出去,让聚合根瘦成一道闪电。跨聚合的业务规则交给领域服务,流程编排交给应用服务,这样系统才能在生产环境下扛住真实流量冲击。

为什么程序员天生就是养猪专业户,总把聚合根喂成500斤大胖子

程序员在建模领域对象的时候,脑子里第一个蹦出来的问题永远是“这个对象里面有什么”,而不是“这个对象能干什么”。这就像你去相亲,第一眼只看对方口袋里装了多少钱,却不关心这个人到底会做什么工作、有什么本事。这种思维方式太自然了,自然到每个人都觉得理所当然,但恰恰就是这种自然,把无数个项目推进了火坑。

举个例子,假设我们在做一个项目管理系统。项目(Project)这个聚合根看起来天经地义要管理项目的整个生命周期,还要强制执行所有业务规则。一个项目下面有任务、有团队成员、有各种附件文档,那按照正常人的思维,我们就把这些东西全部塞进Project类里面。代码写出来大概长这样:

public class Project
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }
    public ProjectStatus Status { get; private set; }

    private readonly List<ProjectTask> _tasks = new();
    private readonly List<TeamMember> _members = new();
    private readonly List<Document> _documents = new();

    public IReadOnlyList<ProjectTask> Tasks => _tasks;
    public IReadOnlyList<TeamMember> Members => _members;
    public IReadOnlyList<Document> Documents => _documents;
}

看起来没毛病对吧?所有人都这么干,教科书上的例子也是这么写的。一个Project类里面有Id、Name、Status这些基本属性,然后一个私有的任务列表、一个私有的成员列表、一个私有的文档列表,再通过只读属性把这三个列表暴露出去。这个设计干净整洁,每个初学者看到都会觉得这就是领域驱动设计的标准答案。

但是问题来了,我们做的是富领域模型,不是贫血模型,所以Project类还得封装行为。于是我们给Project加上一堆方法:

public class Project
{
    // ...
    
    public void AssignTask(string title, TeamMember assignee)
    {
        if (_members.All(m => m.Id != assignee.Id))
            throw new DomainException(
"Assignee must be a project member.");

        if (Status == ProjectStatus.Completed)
            throw new DomainException(
"Cannot add tasks to a completed project.");

        _tasks.Add(new ProjectTask(title, assignee));
    }

    public void AttachDocument(Document document)
    {
        if (Status == ProjectStatus.Completed)
            throw new DomainException(
"Cannot attach documents to a completed project.");

        _documents.Add(document);
    }

    public void Complete()
    {
        if (_tasks.Any(t => !t.IsDone))
            throw new DomainException(
"Cannot complete project while there are open tasks.");

        Status = ProjectStatus.Completed;
    }
}

这段代码看起来完美无缺,聚合根保护了自己的不变量,防止了非法状态的出现。AssignTask方法先检查被分配的人是不是项目成员,如果不是就抛异常;再检查项目状态是不是已完成,如果已完成就抛异常;最后把新任务塞进列表。AttachDocument方法同样要检查项目是不是已完成。Complete方法要先检查是不是所有任务都完成了,如果有任何一个没做完就抛异常。每个写代码的人看到这段代码都会觉得很舒服,很优雅,很符合DDD的教义。但问题就像冰山一样,水面下的部分已经开始腐烂了,只是我们还没看到而已。

胖聚合根是怎么偷偷摸摸把数据库搞崩溃的,而且你还以为是服务器挂了

在领域驱动设计的世界里,Repository(仓储)在每次写入操作之前,必须把完整的聚合根从数据库里加载出来,这样才能验证所有的业务规则。如果Project是整个系统的核心聚合根,那么每次有人做任何写入操作——不管是分配任务、添加成员、挂载文档还是调整预算——Repository都要把整个Project对象图从数据库里拽出来。

这意味着什么?意味着你每次只想挂一个文档,数据库却要把这个项目下的所有任务、所有成员、所有文档全部读进内存。项目越大,这个查询就越慢。几百个任务的时候还能忍,几千个任务的时候就开始卡了,几万个任务的时候数据库直接跪在地上喊爸爸。更要命的是,数据库会产生表锁,因为你要读那么多关联表,并发操作之间会互相打架,一个用户正在挂文档,另一个用户想分配任务,两个人一起卡死在锁等待上。

看一下Repository的代码就知道问题有多严重了:

public class ProjectRepository
{
    // ...

    public async Task<Project> GetByIdAsync(Guid id)
    {
        return await _db.Projects
            .Include(p => p.Tasks)
            .Include(p => p.Members)
            .Include(p => p.Documents)
            .FirstOrDefaultAsync(p => p.Id == id)
            ?? throw new NotFoundException($
"Project {id} not found.");
    }
}

GetByIdAsync方法接收一个项目Id,然后用EF Core或者类似的ORM工具,把Projects表查出来,还要Include加载Tasks、Include加载Members、Include加载Documents,最后还要判断如果没找到就抛异常。这一条查询在数据库层面可能产生几十甚至上百个JOIN操作,索引稍微配得不好就直接超时。生产环境上遇到这种问题的时候,DBA会拿着慢查询日志来找你,眼神里写满了“你是不是在报复公司”。

更糟糕的是,业务规则每增加一条,Project类就要多一个方法,多一堆判断逻辑。今天加一个“项目逾期不能分配任务”,明天加一个“预算超支不能挂载文档”,后天加一个“项目经理变更后需要重新审批所有任务”。Project类很快就膨胀成了上帝类,几千行代码堆在一起,一个文件打开要滚动几十屏,谁都看不懂,谁都不敢改。改一行代码可能引发十个意想不到的bug,代码评审的时候大家轮流骂娘但谁都不敢说重写,因为重写的成本比继续忍受还高。

瘦身的核心秘诀就一句话:只留必须一起改的东西,其他的滚出去

一个聚合根应该包含一个业务场景所需要的所有信息,但不能多一条废话。这句话说起来简单,做起来需要一把锋利的手术刀。最关键的问题只有一个:到底哪些数据必须在同一个事务里保持一致?如果两个数据不需要同时修改,那它们大概率不应该住在同一个聚合根里。

还是拿刚才的项目管理系统说事。挂载一个文档的时候,真的需要把项目的所有任务和所有成员都加载到内存里吗?不需要,完全不需要。文档挂载只需要知道两件事:项目存在不存在,以及项目是不是已完成。任务列表和成员列表跟文档挂载没有半毛钱关系。这说明什么?说明文档根本就不应该住在Project这个聚合根里面。

于是我们动手重构。把Project瘦身,只保留最核心的东西:

public class Project
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }
    public ProjectStatus Status { get; private set; }

    public void Complete() => Status = ProjectStatus.Completed;
}

然后把Document拆成独立的聚合根,通过ProjectId来引用项目:

public class Document
{
    public Guid Id { get; private set; }
    public Guid ProjectId { get; private set; }
    public string Name { get; private set; }

    Document(Guid projectId, string name)
    {
        Id = Guid.NewGuid();
        ProjectId = projectId;
        Name = name;
    }

    public static Document Attach(Guid projectId, string name)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new DomainException("Document name is required.");

        return new Document(projectId, name.Trim());
    }
}

在这个版本里,Document完全负责自己的挂载操作,因为它拥有执行这个规则所需要的所有信息。调用方的代码也变得极其简单:

var document = Document.Attach(projectId, fileName);
await _documentRepository.AddAsync(document);

看到了吗?Document不再需要从Project里面读取任何东西,它自己就能判断文档名是否合法。这就好比你去食堂打饭,以前你必须先找到项目经理签字才能拿餐盘,现在你自己就能决定今天吃红烧肉还是糖醋排骨。数据库的压力瞬间降下来了,因为挂载文档的时候只需要插入一条Document记录,不需要碰Tasks表和Members表。

跨聚合的规则怎么办?别慌,领域服务就是专门干这个脏活的

现在我们假设业务方提了一个新需求:项目完成后不能挂载任何文档。这个规则在胖聚合根版本里很容易实现,因为Project把所有数据都放在内存里,简单加一个状态判断就搞定了。但是现在Project和Document分家了,这个规则跨越了两个聚合根,它不应该住在Document里面,因为Document不知道Project的状态。那是不是说明我们拆分错了?当然不是,我们只需要把跨聚合的规则放到该去的地方。

这时候就要请出领域服务(Domain Service)了。领域服务的职责很纯粹:封装那些不属于任何一个聚合根的业务规则。同时还需要一个应用服务(Application Service)来协调仓储和持久化操作。

先写领域服务:

public class DocumentDomainService
{
    public void EnsureCanAttach(Project project)
    {
        if (project.Status == ProjectStatus.Completed)
            throw new DomainException("Cannot attach documents to a completed project.");
    }
}

这个领域服务里面的EnsureCanAttach方法接收一个Project对象,检查它的状态是不是已完成。如果项目已经完成了,就抛出异常说“不能往已完成的项目里挂载文档”。这个方法不负责加载数据,不负责保存数据,只负责判断规则。它就像一个保安,站在门口检查每个人的入场资格,但不管你怎么买票、怎么进门。

再写应用服务:

public class DocumentApplicationService
{
    readonly IProjectRepository _projectRepository;
    readonly IDocumentRepository _documentRepository;
    readonly DocumentDomainService _documentDomainService;

    public DocumentApplicationService(
        IProjectRepository projectRepository,
        IDocumentRepository documentRepository,
        DocumentDomainService documentDomainService)
    {
        _projectRepository = projectRepository;
        _documentRepository = documentRepository;
        _documentDomainService = documentDomainService;
    }

    public async Task AttachDocument(Guid projectId, string fileName)
    {
        var project = await _projectRepository.GetByIdAsync(projectId);
        _documentDomainService.EnsureCanAttach(project);

        var document = Document.Attach(projectId, fileName);
        await _documentRepository.AddAsync(document);
    }
}

应用服务的工作流程非常清晰:第一步,通过ProjectRepository把项目加载出来;第二步,调用领域服务检查项目是否可以挂载文档;第三步,调用Document.Attach工厂方法创建文档实体;第四步,通过DocumentRepository把文档保存到数据库。每一步都各司其职,没有人在那里包办一切。

最终的使用代码干净得像刚洗过的盘子:

await _documentApplicationService.AttachDocument(projectId, fileName);

这种设计让整个模型变得诚实透明。本地不变量留在各自的聚合根里,跨聚合的业务规则住在领域服务里,流程编排住在应用服务里。每个人都知道自己该干什么,代码读起来像看说明书一样顺畅。

瘦身之后的三大好处,每个都能让你的老板笑着给你加薪

第一个好处是性能炸裂。每次写入操作只触碰少数几个表和行,查询速度快得像法拉利起步,锁的粒度小得像蚂蚁的腰。以前挂一个文档要锁整个项目表,现在只锁Documents表的一行,其他用户该分配任务分配任务,该添加成员添加成员,大家各忙各的谁也不碍谁。数据库的CPU使用率从百分之九十掉到百分之二十,DBA终于不再半夜给你打电话了。

第二个好处是并发能力暴涨。两个用户可以同时更新同一个项目的不同部分,比如小张在挂文档,小李在分配任务,两个人同时操作互不冲突。以前这两个操作会互相等待,因为都要加载整个Project对象图,现在各走各的路,数据库连接池的压力直接减半。用户反馈说系统变快了,你淡定地说“我们只是做了一次常规优化”,心里却在狂笑。

第三个好处是可维护性直线上升。业务规则按照真正的数据一致性边界来分组,每个模型都很小,小到你可以把整个类的代码在一屏之内看完。改Document的逻辑不会影响到Task,改Task的逻辑不会炸掉Member,每个程序员都可以放心大胆地提交代码,不用再担心“我改了一行会不会引发连环车祸”。代码评审的时候大家不再骂娘,而是开始互相点赞,团队氛围从互相甩锅变成了互相吹捧。

当然瘦身也有一个代价:有些原本在一个内存对象里就能搞定的规则,现在需要跨越多个聚合根来执行。我们在命令处理的时候明确地执行这些规则,通常就是通过一个短小精悍的应用服务流程来调用领域逻辑。换句话说,我们用一点点流程编排的复杂度,换来了生产环境下健康得多的模型。这笔买卖怎么算都不亏,因为流程编排的复杂度是可控的,而胖聚合根带来的性能灾难是不可控的。

团队最容易踩的三个坑,每一个都能让你的瘦身计划变成增肥计划

第一个坑是把流程编排塞进领域服务里。有些程序员看到领域服务这个词就觉得“这是我的地盘我做主”,于是把加载仓储、管理事务、保存变更的逻辑全部写进领域服务。这就好比你请了一个保安来看门,结果保安不但要看门,还要帮你修电脑、遛狗、做饭、带孩子。领域服务应该只表达业务规则,不碰任何基础设施相关的东西。加载数据、保存数据、管理事务这些脏活累活,统统交给应用服务或者命令处理器去干。

第二个坑是按照数据库表的划分来拆分聚合根,而不是按照数据一致性边界来拆分。有些团队看到一张表太大了,就决定把它拆成几张表,然后把每张表对应一个聚合根。这种做法十有八九会翻车,因为业务规则不会因为你拆了表就跟着变简单。如果两个东西在业务上必须同时修改、同时有效,那它们就应该住在同一个聚合根里,不管表有多大。

举个例子说明这个坑有多深。假设我们把ProjectTask和TaskAssignment拆成两个独立的聚合根:

public class ProjectTask
{
    public Guid Id { get; private set; }
    public TaskStatus Status { get; private set; }

    public void Start()
    {
        Status = TaskStatus.InProgress;
    }
}

public class TaskAssignment
{
    public Guid TaskId { get; private set; }
    public Guid AssigneeId { get; private set; }
}

现在业务规则说:一个任务要变成“进行中”状态,必须先有人分配给它。按照这个拆分,ProjectTask.Start()方法可以在没有TaskAssignment的情况下被调用,因为ProjectTask根本不知道TaskAssignment的存在。这就像你让一个员工开始干活,却没有告诉他老板是谁,他干到一半发现没人给他发工资,直接撂挑子不干了。如果业务规则明确要求“进行中”状态必须有一个分配人,那么这两个概念就应该在同一个数据一致性边界内。

正确的重构版本应该是这样的:

public class ProjectTask
{
    public Guid Id { get; private set; }
    public Guid? AssigneeId { get; private set; }
    public TaskStatus Status { get; private set; }

    public void AssignTo(Guid assigneeId)
    {
        AssigneeId = assigneeId;
    }

    public void Start()
    {
        if (AssigneeId is null)
            throw new DomainException("A task must be assigned before it can start.");

        Status = TaskStatus.InProgress;
    }
}

现在不变量被保护在同一个聚合根里面了。任务没有分配人就不能开始,这个规则在代码层面被强制执行,没有任何办法绕过。

第三个坑的症状更容易识别,如果你在项目中遇到以下任何一个情况,说明你的拆分可能出了问题:经常需要加载多个聚合根才能执行一个业务规则;不断为各种操作添加跨聚合根的检查逻辑;业务用户认为某些临时出现的非法状态完全不可接受;频繁需要分布式事务来完成一个用户操作。这些症状就像发烧一样,不是病本身,但说明身体里面一定有地方发炎了。

总结一句话:聚合根不是集装箱,别什么都往里装

瘦聚合根从一个简单的自律开始:先建模内在的行为,然后只包含保护这些行为所必需的数据。一个聚合根不是一个用来装所有相关数据的大集装箱,它是一道围绕着那些必须同时成立的业务规则所画出来的数据一致性边界。当你按照这个原则来设计边界的时候,每个模型都会变得更小、更清晰、更容易演化。你成功避开了上帝聚合根的陷阱,减少了写入路径上的资源争用,并且让每一条领域规则都待在它该待的地方。



极客一语道破

聚合是上下文的涌现出来的聚焦焦点,焦点是单一职责的,只要把握好上下文中上下、前后、左右两个边界之差,也就是误差,梯度,就能左右取其中,多言数穷,唯有取中,取“注意力焦点”。输入给大语言模型的信息也必须掌握这个原则,智能体Harness工程就干这事的,裁剪组织你向大模型提出的问题,最重要的放前面,然后最后面,中间部分容易被大模型忽视。

在聚合根中,最重要的是ID标识,根标识可以是一个实体对象。这项都是为了纲举目张,聚焦单一职责!