领域驱动设计 —— 战术模式

发布于
...
AI 翻译

领域驱动设计(Domain-Driven Design,DDD)是一种把业务领域和它的模型放在代码正中央的软件设计方法。它给了我们一组模式,让业务概念、规则和对象的生命周期能在软件里被清楚地表达出来。

这篇文章会介绍 DDD 里的术语和概念,并配上代码示例。

速查表

#模式什么时候用它
1分层你要动手做任何活得比一个 sprint 久的东西。
2实体用户需要在时间轴上区分不同的实例。
3值对象把它换成一个一模一样的副本,什么都不会变。
4服务一个操作需要两个以上的对象,或者需要外部系统。
5模块你正在给最上层的文件夹命名。
6聚合有一条规则必须在每次事务结束时都成立。
7工厂构造要好几个步骤,或者需要协作的对象。
8仓储必须在不顺着引用走的情况下找到聚合根。

1. 分层(Layers)

在 DDD 里,软件通常会被切成四层:UI、应用、领域、基础设施。最重要的是依赖只往一个方向流动,尤其是领域必须和 UI 与应用的关注点隔离开来。

UI → Application → Domain

同一条规则,两个不同的调用方

躺在 controller 或 SQL 里的规则很难找,而且没有一个数据库和一个 request 就测不动。

分层 —— 一条规则,两个调用方
<?php

declare(strict_types=1);

/*
 * Layers — the same rule, reached by two different callers.
 *
 * The two halves are separate tabs, so each can be read on its own:
 * 1-before.php is the version that breaks, 2-after.php is the version
 * that holds. Run executes both, in that order.
 */

require __DIR__ . '/1-before.php';
echo "\n";
require __DIR__ . '/2-after.php';
ReadyRuns in your browser
Console

No output yet.

2. 实体(Entities)

由属性而不是身份定义的对象。它通常是不可变的。

实体是 DDD 里最基本的构成要素之一。它给了我们一个可靠的方式,去判断两个对象是不是代表同一个东西,通常是通过像 ID 这样的唯一身份。实体一般用在有生命周期或历程、而且用户需要区分不同实例的对象上。

实践中,把实体的 UUID 在创建的当下就分配好、而不是等到写进数据库时才给,往往是有帮助的。否则实体会有一段时间没有身份,这会让相等性判断、以及对刚创建的实体的引用,变得比较困难也比较容易出错。

来得太晚的标识符不算身份

实体 —— 身份要早于 INSERT
<?php

declare(strict_types=1);

/*
 * Entities — an identifier that arrives late is not an identity.
 *
 * The two halves are separate tabs, so each can be read on its own:
 * 1-before.php is the version that breaks, 2-after.php is the version
 * that holds. Run executes both, in that order.
 */

require __DIR__ . '/1-before.php';
echo "\n";
require __DIR__ . '/2-after.php';
ReadyRuns in your browser
Console

No output yet.

3. 值对象(Value Objects)

只由属性定义的对象。它没有 id,构造之后也不再改变。

值对象是 DDD 里另一个最基本的构成要素。当“把一个对象换成另一个属性相同的对象”对系统的行为完全没有影响时,通常就该用值对象。重要的是值本身,而不是那个对象的身份。

举个例子,new Money(200) 不管属于 John 还是 Josh,代表的都是同一个值。我们不在乎手上拿到的是哪一个 Money 实例,只在乎它的值是 200。

为什么被共享的值一定要不可变

值对象 —— 不可变,共享才安全
<?php

declare(strict_types=1);

/*
 * Value objects — why a shared value has to be immutable.
 *
 * The two halves are separate tabs, so each can be read on its own:
 * 1-before.php is the version that breaks, 2-after.php is the version
 * that holds. Run executes both, in that order.
 */

require __DIR__ . '/1-before.php';
echo "\n";
require __DIR__ . '/2-after.php';
ReadyRuns in your browser
Console

No output yet.

4. 服务(Services)

一个没有状态的操作,用活动来命名。

当一个重要的领域操作不自然地属于任何一个实体或值对象时,通常就会用到服务。与其硬把这个行为塞进一个放不下它的对象,不如把它表达成一个服务。

举个例子,在两个银行账户之间转账,也许用 FundsTransferService 来表达会更好,因为这个操作牵涉多个 Account,而且它本身就是银行领域里一个重要的概念。

一个好的领域服务应该没有状态、在接口上使用领域的概念,并且代表一个在通用语言(Ubiquitous Language)里说得通的操作。

那个把实体的规则抢过去的服务

服务 —— 规则该和数据待在一起
<?php

declare(strict_types=1);

/*
 * Services — the service that holds rules the entity should hold.
 *
 * The two halves are separate tabs, so each can be read on its own:
 * 1-before.php is the version that breaks, 2-after.php is the version
 * that holds. Run executes both, in that order.
 */

require __DIR__ . '/1-before.php';
echo "\n";
require __DIR__ . '/2-after.php';
ReadyRuns in your browser
Console

No output yet.

5. 模块(Modules)

一组应该被放在一起理解的领域概念,在代码里通常以模块、包或命名空间呈现。

模块谈的是我们怎么围绕着有意义的业务概念,去组织模型和代码。因为最上层的文件夹常常是新人最先看到的东西,取自业务的名字有助于描述这个系统实际上在做什么,而取自技术模式的名字,几乎可以拿来描述任何系统。

因为之后才改命名空间会产生很大的 diff、合并冲突,以及嘈杂的历史,这个结构最好一开始就弄对。按业务概念分组,比如 Tenant/Tenancy/,而不是按模式分组,比如 Entity/Service/Dto/

# 按模式分组 —— 拿这个问题问它:“押金的规则在哪里?”
src/
├── Entity/
│ ├── Landlord.php
│ ├── Property.php
│ └── Tenancy.php
├── Repository/
│ └── TenancyRepository.php
├── Service/
│ ├── TenancyService.php
│ └── DepositService.php
└── Dto/
├── TenancyDto.php
└── TenancyMapper.php
# 按概念分组 —— 同样那些类,重新编组
src/
└── Letting/
├── Booking/
│ ├── Tenancy.php
│ ├── Rent.php
│ ├── DepositCap.php
│ └── TenancyRepository.php
├── Property/
│ ├── Property.php
│ ├── Address.php
│ └── PropertyRepository.php
├── Customer/
│ ├── Landlord.php
│ └── Tenant.php
└── Infrastructure/
└── Persistence/
└── DoctrineTenancyRepository.php

6. 聚合(Aggregates)

一组被当成单个单位来变更的对象,有唯一的根,以及一条明确的边界。

到目前为止,我们介绍了实体和值对象这两个 DDD 里最基本的构成要素。但一旦这些对象开始彼此交互,我们就需要一些规则,来界定哪些对象属于同一组、它们可以怎么被访问,以及一致性必须在事务结束时的哪个范围内被保证。聚合给了我们一个界定那条边界的方式。

聚合是一组在变更时被当成单个单位看待的实体和值对象。每个聚合都有一个实体作为它的聚合根,聚合外部的对象应该通过这个根去和聚合交互。根同时也负责确保聚合的业务规则和不变量,在事务完成的时候仍然成立。

举个例子,我们可能把一份租约(Tenancy)建模成聚合根,里面装着承租人、租金信息、押金细节这些相关的对象。与其让系统的其他部分直接改动那些对象,变更会走过 Tenancy,让它能确保整份租约维持在合法的状态。

两个各自合法的编辑,凑出一个不合法的结果

聚合 —— 两个人一起破掉的上限
<?php

declare(strict_types=1);

/*
 * Aggregates — two valid edits that produce one invalid result.
 *
 * The two halves are separate tabs, so each can be read on its own:
 * 1-before.php is the version that breaks, 2-after.php is the version
 * that holds. Run executes both, in that order.
 */

require __DIR__ . '/1-before.php';
echo "\n";
require __DIR__ . '/2-after.php';
ReadyRuns in your browser
Console

No output yet.

7. 工厂(Factories)

专门负责构造复杂对象或完整聚合的代码。

工厂谈的是我们怎么创建复杂的实体或聚合,而不用把它们全部的构造逻辑摊在调用方面前。当创建一个对象需要很多步骤、规则或内部对象时,把这些逻辑全塞进构造函数,会让模型更难理解。

取而代之,工厂提供一个清楚的方式,去创建一个完整而且合法的对象。调用方只需要提供创建它所需要的信息,内部的构造细节则交给工厂负责。

沿用前面的例子,一份 Tenancy 可能需要创建承租人、租金细节、押金信息,以及聚合内部的其他对象。与其让调用方一个一个把它们建出来,TenancyFactory 可以创建整个聚合,并返回一个合法的 Tenancy 聚合根。

半成品对象,以及加载时走的另一道门

工厂 —— 创建和重建是两道不同的门
<?php

declare(strict_types=1);

/*
 * Factories — half-built objects, and the second door for loading.
 *
 * The two halves are separate tabs, so each can be read on its own:
 * 1-before.php is the version that breaks, 2-after.php is the version
 * that holds. Run executes both, in that order.
 */

require __DIR__ . '/1-before.php';
echo "\n";
require __DIR__ . '/2-after.php';
ReadyRuns in your browser
Console

No output yet.

8. 仓储(Repositories)

一个行为上像是“某类型所有实例的内存集合”的对象。

仓储谈的是我们怎么找到并取回已有的聚合,同时不让持久化的细节渗进领域。与其让调用方知道数据是怎么存储、查询或重建的,仓储给了我们一个像集合一样的接口去访问聚合根。

举个例子,如果我们需要一份 Tenancy,应用层可以拿 ID 去跟 TenancyRepository 要。仓储负责查数据库、把 Tenancy 聚合重建起来,其他代码则只跟领域对象打交道。

仓储一般只为聚合根创建,而不是为聚合里的每一个实体都建一个。内部的对象应该通过它们的聚合根来访问。

同一个答案,两种差很多的代价

仓储 —— 一万个对象,或者零个
<?php

declare(strict_types=1);

/*
 * Repositories — the same answer, at two very different costs.
 *
 * The two halves are separate tabs, so each can be read on its own:
 * 1-before.php is the version that breaks, 2-after.php is the version
 * that holds. Run executes both, in that order.
 */

require __DIR__ . '/1-before.php';
echo "\n";
require __DIR__ . '/2-after.php';
ReadyRuns in your browser
Console

No output yet.

结语

这篇文章聚焦在领域模型是怎么被表达出来的,但领域对象也有它们的生命周期,这就带出了聚合、工厂和仓储:用来界定事务边界、创建合法的对象,以及在之后把它们再找回来。

如果你喜欢这篇文章,欢迎点击下方按钮分享给更多人,你的支持对我来说意义重大。

订阅更新

订阅以获取每周精选,包含更多背景、制作笔记,以及背后的想法。

Buy me a coffee