领域驱动设计 —— 战术模式
- 发布于
- ...AI 翻译查看English原文
领域驱动设计(Domain-Driven Design,DDD)是一种把业务领域和它的模型放在代码正中央的软件设计方法。它给了我们一组模式,让业务概念、规则和对象的生命周期能在软件里被清楚地表达出来。
这篇文章会介绍 DDD 里的术语和概念,并配上代码示例。
速查表
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';
No output yet.
2. 实体(Entities)
由属性而不是身份定义的对象。它通常是不可变的。
实体是 DDD 里最基本的构成要素之一。它给了我们一个可靠的方式,去判断两个对象是不是代表同一个东西,通常是通过像 ID 这样的唯一身份。实体一般用在有生命周期或历程、而且用户需要区分不同实例的对象上。
实践中,把实体的 UUID 在创建的当下就分配好、而不是等到写进数据库时才给,往往是有帮助的。否则实体会有一段时间没有身份,这会让相等性判断、以及对刚创建的实体的引用,变得比较困难也比较容易出错。
来得太晚的标识符不算身份
<?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';
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';
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';
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';
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';
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';
No output yet.
结语
这篇文章聚焦在领域模型是怎么被表达出来的,但领域对象也有它们的生命周期,这就带出了聚合、工厂和仓储:用来界定事务边界、创建合法的对象,以及在之后把它们再找回来。
订阅更新
订阅以获取每周精选,包含更多背景、制作笔记,以及背后的想法。