引言
大多数 PHP 项目的代码形态是「控制器里写业务、模型里放数据、服务类里堆流程」——功能能跑,但业务规则散落在各处,改一个促销规则要翻五个文件,写测试要先连数据库。这类代码的问题不是语法,而是缺少一个能被业务和技术共同理解的模型。
领域驱动设计(DDD)提供的不是框架,而是一套控制复杂度的思维工具:用通用语言把业务概念固化进代码,用限界上下文划清模型边界,用聚合定义一致性单位,用领域事件解耦副作用,用防腐层隔离外部系统。它最直接的效果是:业务规则集中在一处、可以脱离数据库单元测试、新人读代码就能看懂业务。
但 DDD 也是被误用最多的方法论之一:有人把每个表都包装成「实体」,有人在 CRUD 系统里强行上聚合,有人把 DDD 等同于「多写几层目录」。本文从贫血模型的实际痛点出发,给出 PHP 语境下可落地的取舍。
关联阅读:https://plumephp.com/php-oop-design-patterns/ 讲透了 SOLID 与依赖注入,是理解本文的基础;分层与部署视角可参考 https://plumephp.com/php-microservices-patterns/。
目录
- 1. 为什么要 DDD:从贫血模型说起
- 2. 限界上下文与通用语言
- 3. 实体、值对象与领域服务
- 4. 聚合与一致性边界
- 5. 领域事件与最终一致性
- 6. 仓储、工厂与持久化
- 7. 防腐层与六边形架构
- 8. 目录结构与 Laravel 落地
- 9. 落地节奏与常见陷阱
- 延伸阅读
1. 为什么要 DDD:从贫血模型说起
贫血模型把业务规则写在 Service 里,实体只是数据袋:
class OrderService
{
public function pay(int $orderId, float $amount): void
{
$order = Order::find($orderId);
if ($order->status === 'paid') { throw new \RuntimeException('已支付'); }
if (abs($order->amount - $amount) > 0.01) { throw new \RuntimeException('金额不符'); }
if ($order->expired_at < time()) { throw new \RuntimeException('订单已过期'); }
$order->status = 'paid';
$order->save();
}
}
三个致命问题:规则可被绕过(任何地方都能直接改 $order->status)、规则重复(退款、发货各自又写一遍校验)、无法脱离数据库测试。
充血模型把行为还给对象,规则与数据同处一地,status 私有、只能通过领域方法变更:
final class Order
{
private OrderStatus $status;
public function pay(Money $amount, \DateTimeImmutable $now): void
{
if ($this->status !== OrderStatus::Pending) { throw new OrderAlreadyPaid($this->id); }
if (!$this->amount->equals($amount)) { throw new AmountMismatch($this->amount, $amount); }
if ($this->expiresAt < $now) { throw new OrderExpired($this->id); }
$this->status = OrderStatus::Paid;
$this->paidAt = $now;
$this->record(new OrderPaid($this->id, $amount));
}
}
但 DDD 不是免费的:
| 收益 | 成本 |
|---|---|
| 业务规则集中、可测 | 需要与业务方共建通用语言 |
| 模型可长期演进 | 学习曲线陡(聚合/事件/防腐层) |
| 边界清晰、易拆分 | 简单 CRUD 场景属于过度设计 |
判断标准:业务规则复杂、变化频繁、系统寿命长 → 值得;纯增删改查的后台管理 → 不值得。
2. 限界上下文与通用语言
「商品」在销售上下文里有价格、促销、上下架状态;在仓储上下文里有库位、批次、盘点;在履约上下文里只有体积重量。强行合成一个 Product 模型,结果是所有字段都可空、所有方法都要判上下文。
限界上下文(Bounded Context) 就是划清边界的做法:每个上下文有自己的模型、自己的数据库表、自己的语言,上下文之间通过明确的接口或事件通信。
通用语言(Ubiquitous Language) 要求代码里的命名与业务方口中的词完全一致:业务说「预订单」,代码里就不要写 DraftOrder。技术词污染业务概念($order->setFlag(3))与用业务动词表达($order->confirm()、$order->ship($trackingNo))是两种截然不同的代码气质。
上下文之间的四种关系:
| 关系 | 说明 | PHP 落地 |
|---|---|---|
| 共享内核 | 双方共用一小块模型 | 独立的 Composer 包 |
| 客户-供应商 | 下游依赖上游接口 | 上游提供 DTO 契约 |
| 防腐层 | 下游自行转换上游模型 | 适配器类 |
| 遵奉者 | 完全照抄上游模型 | 慎用,会污染自身模型 |
识别上下文的实操方法:把业务方的组织架构图与关键名词表叠在一起——一个团队负责、一套术语自洽、可以独立发布的边界,通常就是一个限界上下文。
3. 实体、值对象与领域服务
实体由标识(ID)定义,而非属性:两个属性完全相同的用户仍是两个不同的人;属性可变,身份不变,相等判断只看 ID。
值对象由属性值定义,没有 ID,创建后不可变。金额、邮箱、地址、时间段都是典型值对象:
final readonly class Money
{
private function __construct(
public int $amount, // 以「分」为单位,避免浮点误差
public string $currency,
) {}
public static function ofCents(int $amount, string $currency = 'CNY'): self
{
return new self($amount, strtoupper($currency));
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('币种不一致');
}
return new self($this->amount + $other->amount, $this->currency);
}
}
| 维度 | 实体 | 值对象 |
|---|---|---|
| 身份 | 有 ID | 无 ID |
| 可变性 | 属性可变 | 完全不可变 |
| 相等判断 | 按 ID | 按全部属性 |
| 典型例子 | 用户、订单 | 金额、邮箱、地址 |
领域服务承载那些跨多个对象、又不属于任何一方的无状态逻辑,例如转账 TransferService::transfer($from, $to, $amount)。领域服务里不应出现数据库与 HTTP,它只编排领域对象。
4. 聚合与一致性边界
聚合(Aggregate) 是一组必须保持一致的对象的集合,聚合根(Aggregate Root) 是唯一的对外入口。外部只能通过聚合根访问内部对象,且一次事务只修改一个聚合。
final class Order // 聚合根
{
/** @var OrderLine[] */
private array $lines = [];
public function addLine(Sku $sku, int $qty, Money $price): void
{
foreach ($this->lines as $line) { // 不变量:同一 SKU 不重复
if ($line->sku()->equals($sku)) {
throw new DuplicateLine($sku);
}
}
$this->lines[] = new OrderLine($sku, $qty, $price);
$this->total = $this->total->add($price->multiply($qty)); // 根维护一致性
}
public function total(): Money { return $this->total; }
}
聚合设计四条经验:
- 尽量小:聚合越大,并发冲突与加载成本越高。
- 通过 ID 引用外部聚合:
Order里存customerId,而不是Customer对象。 - 一个事务一个聚合:跨聚合的一致性交给领域事件(第 5 节)。
- 不变量必须在聚合内:如果某规则跨两个聚合,说明边界划错了。
| 错误做法 | 后果 |
|---|---|
| 把整张 ER 图当一个聚合 | 加载慢、锁冲突严重 |
| 直接修改聚合内部对象 | 不变量被绕过 |
| 跨聚合强事务 | 数据库锁竞争、无法拆分服务 |
| 聚合根只做数据容器 | 退化为贫血模型 |
5. 领域事件与最终一致性
领域事件描述业务上已经发生的事实,命名用过去式:OrderPaid、InventoryDeducted、UserRegistered。
final readonly class OrderPaid
{
public function __construct(
public string $orderId,
public int $amountCents,
public \DateTimeImmutable $occurredAt,
) {}
}
聚合只负责记录事件,不负责发布——发布交给应用层在事务提交后执行。基类只需维护一个事件列表,record() 追加、releaseEvents() 取出并清空。
订单支付后要扣库存、发通知、加积分——这些属于不同聚合甚至不同服务,做法是「本地事务 + 事件驱动」:本地事务只写自己的聚合与一张 outbox 表,再由后台进程可靠投递,保证事件不丢。
$em->transactional(function () use ($em, $order) {
$em->persist($order);
foreach ($order->releaseEvents() as $event) {
$em->persist(OutboxMessage::fromEvent($event)); // 与聚合同一事务
}
});
消息中间件的可靠投递、幂等与重试细节可参考 https://plumephp.com/php-microservices-message-queue/。
6. 仓储、工厂与持久化
仓储对外表现得像一个内存集合,内部才是 SQL。接口定义在领域层(只出现领域概念),实现放在基础设施层:
interface OrderRepository // 领域层
{
public function nextIdentity(): OrderId;
public function find(OrderId $id): ?Order;
public function save(Order $order): void;
}
final class DoctrineOrderRepository implements OrderRepository // 基础设施层
{
public function __construct(private EntityManagerInterface $em) {}
public function find(OrderId $id): ?Order
{
return $this->em->find(Order::class, $id->toString());
}
public function save(Order $order): void
{
$this->em->persist($order);
$this->em->flush();
}
}
仓储的单位是聚合,不是表:OrderLine 属于 Order 聚合,就没有 OrderLineRepository——它只能经由 Order 访问。当创建过程涉及多个不变量校验与多步构造时,用工厂封装,把构造逻辑从调用方收回。
| 持久化方式 | 适用 | 说明 |
|---|---|---|
| Doctrine 直接映射领域对象 | 大多数项目 | 需注意懒加载与代理类 |
| 领域对象 + 独立持久化模型 | 领域模型复杂时 | 多一层转换,领域层零 ORM 依赖 |
| 手写 SQL 映射 | 极简或性能敏感 | 完全可控,维护成本高 |
无论哪种方式,领域层都不应 use 任何 ORM 类,这是分层是否干净的最硬指标。
7. 防腐层与六边形架构
六边形架构(端口与适配器)的核心思想是:应用是中心,所有外部依赖都通过端口(接口)接入,由适配器实现。
HTTP/CLI 适配器 ──▶ ┌──────────────────────────┐
│ 应用层(用例编排) │
(入站端口) │ ┌────────────────────┐ │
│ │ 领域层(模型与规则) │ │
│ └────────────────────┘ │
└──────────────────────────┘
▲ ▲
MySQL/Redis 适配器 ─────┘ └───── 第三方 API 适配器
- 端口:领域/应用层定义的接口(
OrderRepository、PaymentGateway)。 - 适配器:基础设施层的具体实现(Doctrine、Stripe SDK、Console 命令)。
当外部系统的模型与你的领域模型不一致时,不要让它污染你的模型。防腐层(ACL)做的是翻译:
final class AlipayPaymentAdapter implements PaymentGateway
{
public function __construct(private AlipayClient $client) {}
public function charge(OrderId $orderId, Money $amount): PaymentResult
{
$raw = $this->client->createTrade([
'out_trade_no' => $orderId->toString(),
'total_amount' => $amount->toDecimalString(),
]);
return match ($raw['trade_status'] ?? '') {
'TRADE_SUCCESS' => PaymentResult::succeeded($raw['trade_no']),
'TRADE_CLOSED' => PaymentResult::failed('交易关闭'),
default => PaymentResult::pending($raw['trade_no']),
};
}
}
领域层只认识 PaymentGateway 与 PaymentResult,换支付渠道时改一个适配器即可。依赖方向规则:所有依赖必须指向内侧——领域层不依赖应用层,应用层不依赖基础设施层,可用 PHPStan layers 规则或 deptrac 在 CI 中强制检查。
8. 目录结构与 Laravel 落地
src/
├── Domain/ # 领域层:零框架依赖
│ └── Ordering/{Model,Event,Repository,Exception}/
├── Application/ # 应用层:编排用例
│ └── Ordering/PayOrder/{PayOrderCommand.php,PayOrderHandler.php}
├── Infrastructure/ # 基础设施层:适配器
│ ├── Persistence/Doctrine/DoctrineOrderRepository.php
│ └── Payment/AlipayPaymentAdapter.php
└── Interface/ # 接口层
├── Http/Controller/OrderController.php
└── Console/CloseExpiredOrdersCommand.php
应用层只做编排:取聚合、调用领域方法、保存、发事件,不含业务规则。
final class PayOrderHandler
{
public function __construct(
private OrderRepository $orders,
private PaymentGateway $gateway,
private EventDispatcherInterface $events,
) {}
public function handle(PayOrderCommand $command): void
{
$order = $this->orders->find(OrderId::fromString($command->orderId))
?? throw new OrderNotFound($command->orderId);
$result = $this->gateway->charge($order->id(), $order->total());
if (!$result->isSucceeded()) {
throw new PaymentFailed($result->reason());
}
$order->pay($order->total(), new \DateTimeImmutable());
$this->orders->save($order);
foreach ($order->releaseEvents() as $event) {
$this->events->dispatch($event);
}
}
}
与 Laravel 惯例的对应调整:
| Laravel 惯例 | DDD 调整 |
|---|---|
app/Models 放 Eloquent 模型 | Eloquent 降级为持久化对象,放 Infrastructure |
| Controller 直接写业务 | Controller 只做 DTO 转换,调用 Handler |
app/Services 大杂烩 | 拆为 Application(用例)与 Domain(模型) |
| 全局 Facade 随处调用 | 领域层禁用 Facade,依赖走构造器注入 |
测试策略也随之分层:Domain 与 Application 用纯单元测试(不连库、Mock 仓储),Infrastructure 用集成测试,Interface 用端到端测试。领域层零依赖的最大红利就在这里——核心业务规则的测试在毫秒级完成,完整体系见 https://plumephp.com/php-testing-practice/。
9. 落地节奏与常见陷阱
9.1 渐进式落地四步
- 先建通用语言:与业务方一起梳理关键名词与动词,形成术语表。
- 从核心域选一个聚合切入:挑业务规则最复杂、变化最频繁的那个(通常是订单或计价),不要从用户表开始。
- 隔离依赖:为这个聚合建 Domain 目录,仓储接口化,Eloquent 模型降级为适配器。
- 用事件解耦副作用:把「支付成功后发短信、加积分」这类逻辑改为监听领域事件。
9.2 常见陷阱
| 陷阱 | 表现 | 纠正 |
|---|---|---|
| 到处套 DDD | 每个 CRUD 都写四层 | 只对核心域使用 |
| 聚合过大 | 加载慢、锁冲突 | 按不变量重新划分 |
| 领域层依赖框架 | use Illuminate\... | 用依赖倒置隔离 |
| 值对象可变 | 共享对象被意外修改 | 声明为 readonly |
| 事件当成命令 | SendEmail 这种命名 | 事件必须是过去式事实 |
9.3 什么时候不该用 DDD
- 系统以增删改查为主,业务规则不到 10 条;
- 产品还在验证期,需求每周大改,模型无法稳定;
- 团队规模小且无长期维护预期。
9.4 一句话总结
DDD 的价值不在于「目录分层」,而在于把业务复杂度收敛到可命名、可测试、可演进的模型里。分层、事件、仓储都是手段;判断做得好不好的唯一标准是:业务方说的话,能不能在代码里原样找到。
延伸阅读
- https://plumephp.com/php-oop-design-patterns/ — SOLID、工厂与策略模式,DDD 落地的基础功
- https://plumephp.com/php-microservices-patterns/ — 限界上下文如何指导微服务拆分
- https://plumephp.com/php-microservices-message-queue/ — 领域事件与消息中间件的可靠投递
- https://plumephp.com/php-testing-practice/ — 分层测试策略与测试金字塔
- https://plumephp.com/php-database-migrations-architecture/ — 聚合持久化与数据库演进的配合
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。