PHP领域驱动设计核心实践完整权威指南:十大策略与实战案例
PHP 在领域驱动设计(DDD)中的工程落地关键
先厘清几个核心认知。在PHP技术栈中推行领域驱动设计,不少开发者第一反应是“这模式对PHP是不是过于沉重”?恰恰相反。DDD的真正价值不在于复杂的分层架构图,而在于它提供了一套让代码精准映射业务语义的方法论和工具。从通用语言到限界上下文,每一条设计原则背后都有可落地的工程依据。
通用语言
通用语言的核心目标是让领域专家与开发团队使用同一套术语沟通。领域层的类命名、方法命名、变量命名必须源自业务词汇表,而非框架约定或数据库表名。
一个极易被忽视的危险信号:当代码中出现 OrderManager、OrderHelper 或 OrderService(如果“Service”一词根本不在领域词汇表中),说明实现层面的命名已经渗透到了领域语言内部。正确的做法是什么?直接采用领域专家实际使用的词汇:Order、OrderFulfillment、Shipment。
另一个关键点是,将发现的通用语言直接映射为类型约束。假设领域专家说“订阅在连续两次付款失败后变为逾期”,这句话本身就蕴含了业务规则——它应该由 Subscription::markDelinquent() 来强制执行,而不是放在 SubscriptionController 中。
值对象
值对象无需标识。两个实例若值相等即视为相同,不可变性是硬性要求——任何修改状态的操作必须返回新实例。
实现
currency->equals($other->currency)) {
throw new InvalidArgumentException('Currency mismatch');
}
return new self($this->amount + $other->amount, $this->currency);
}
public function equals(Money $other): bool
{
return $this->amount === $other->amount
&& $this->currency->equals($other->currency);
}
public function amount(): int
{
return $this->amount;
}
public function currency(): Currency
{
return $this->currency;
}
}
值对象候选清单
| 值对象 | 说明 |
|---|---|
Money |
金额+币种;算术运算返回新实例 |
EmailAddress |
构造时校验,统一转换为小写 |
DateRange |
构造函数强制 start <= end 不变式 |
Percentage |
0–100 范围;无标识 |
Coordinates |
经纬度对;按值判等 |
OrderStatus |
类似枚举;PHP 8.1+ 优先使用枚举 |
PHP 8.1+ 枚举
PHP 8.1 引入的后端枚举(enum Status: string),本质上就是一个值对象。对于有限状态集,直接采用枚举替代字符串常量或独立的值对象类,代码更简洁且类型安全。
实体
实体拥有跨状态变更持续存在的标识。两个具有相同 ID 的 Order 对象代表同一个订单,这与它们当前的字段值无关。
标识类型
优先使用领域生成的 UUID,而非数据库分配的自增整数 ID。原因很直接:自增 ID 强制在实体存在于内存之前进行一次数据库往返,这会破坏聚合的一致性保证。
value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}
聚合
聚合由单个聚合根组织的一组实体和值对象构成。所有外部访问必须通过聚合根进行。跨越集群内多个对象的不变式,由聚合根统一强制执行。
规则
仅通过 ID 引用其他聚合——这条规则至关重要。绝不要跨聚合边界持有直接对象引用。每次访问都加载外部聚合不仅降低性能,还会耦合边界。
status = OrderStatus::Draft;
}
public function addLine(ProductId $productId, int $qty, Money $unitPrice): void
{
$this->assertStatus(OrderStatus::Draft);
if (count($this->lines) >= 50) {
throw new OrderLineLimit('Max 50 lines per order');
}
$this->lines[] = new OrderLine($productId, $qty, $unitPrice);
}
public function place(): void
{
$this->assertStatus(OrderStatus::Draft);
if ($this->lines === []) {
throw new EmptyOrderException('Cannot place an empty order');
}
$this->status = OrderStatus::Placed;
$this->events[] = new OrderPlaced($this->id, $this->customerId, $this->total());
}
public function total(): Money
{
return array_reduce(
$this->lines,
fn(Money $carry, OrderLine $line) => $carry->add($line->subtotal()),
$this->shippingFee,
);
}
public function pullEvents(): array
{
$events = $this->events;
$this->events = [];
return $events;
}
private function assertStatus(OrderStatus $expected): void
{
if ($this->status !== $expected) {
throw new InvalidOrderOperation("Operation requires status {$expected->name}, got {$this->status->name}");
}
}
}
聚合大小
保持聚合小巧是铁律。如果一个聚合包含数百个子实体,通常意味着其中一些子实体应该独立为单独的聚合,仅通过 ID 引用。过大的聚合会导致更长的事务锁和更多合并冲突。
聚合边界内不变式检查清单
| 问题 | 如果"是" |
|---|---|
| 该实体能否脱离聚合根存在? | 它可能是独立的聚合 |
| 聚合根是否对这些实体强制执行不变式? | 将它们保留在同一聚合中 |
| 它们是否在同一事务中一起持久化? | 良好的信号,说明它们应在一起 |
| 聚合是否只是为了读取某个子实体而被加载? | 考虑拆分 |
仓储
仓储提供类似集合的接口来访问聚合。它抽象了持久化机制——领域层定义接口,基础设施层实现接口。
find($id) ?? throw new OrderNotFound($id);
}
public function find(OrderId $id): ?Order
{
return $this->em->find(Order::class, $id->value());
}
public function sa ve(Order $order): void
{
$this->em->persist($order);
$this->em->flush();
}
public function delete(Order $order): void
{
$this->em->remove($order);
$this->em->flush();
}
public function findByCustomer(CustomerId $customerId): array
{
return $this->em->createQuery('SELECT o FROM Order o WHERE o.customerId = :id')
->setParameter('id', $customerId->value())
->getResult();
}
}
仓储与查询服务
仓储主要用于按标识或简单条件检索聚合。对于复杂的读模型(如仪表板、报表),应使用专用的查询服务或直接发出 SQL 的读模型——不要将领域模型强行扭曲为 DTO 工厂。
测试用内存实现
*/
private array $store = [];
public function get(OrderId $id): Order
{
return $this->store[$id->value()] ?? throw new OrderNotFound($id);
}
public function find(OrderId $id): ?Order
{
return $this->store[$id->value()] ?? null;
}
public function sa ve(Order $order): void
{
$this->store[$order->id()->value()] = $order;
}
public function delete(Order $order): void
{
unset($this->store[$order->id()->value()]);
}
public function findByCustomer(CustomerId $customerId): array
{
return array_values(
array_filter(
$this->store,
fn(Order $o) => $o->customerId()->equals($customerId),
)
);
}
}
领域事件
领域事件记录业务中发生的关键事件。以过去时态命名,不可变,并携带处理程序所需的所有数据。
occurredAt = new DateTimeImmutable();
}
public function occurredAt(): DateTimeImmutable
{
return $this->occurredAt;
}
}
分发策略
| 策略 | 保证 | 适用场景 |
|---|---|---|
| 收集 + 保存后分发 | 仅当保存成功时触发事件 | 默认——适用于大多数用例 |
| 事务性发件箱 | 跨进程边界至少一次投递 | 异步处理程序、微服务 |
| 同步事务内 | 处理程序在同一数据库事务中运行 | 很少合理;耦合限界上下文 |
pullEvents() 模式(读取后清空事件队列)是标准的收集-分发方式。应用层在 $repository->sa ve($order) 之后调用它,并将事件分发到事件总线。这确保了只有在持久化成功之后,事件才会被发出。
领域服务
领域服务包含不属于单个实体或值对象的领域逻辑,通常是需要多个聚合或外部领域接口的操作。但这里有一个容易踩的坑。
过度使用警告
必须警惕的是:最终落入领域服务的大部分逻辑,实际上应该归属于聚合或值对象。如果第一反应是创建服务,不妨先问问自己——这个方法能否直接归属于某个输入对象?
total();
$discount = $this->discounts->findFor($customer->tier());
return $discount !== null
? $discount->apply($base)
: $base;
}
}
应用层
应用层的职责是编排用例。它不含领域逻辑——它的工作是协调领域对象、提交事务、分发事件。标准单元是命令+处理程序对。
currency);
$order = new Order(
new OrderId($cmd->orderId),
new CustomerId($cmd->customerId),
new Money($cmd->shippingFee, $currency),
);
foreach ($cmd->lines as $line) {
$order->addLine(
new ProductId($line['productId']),
$line['qty'],
new Money($line['unitPrice'], $currency),
);
}
$order->place();
$this->orders->sa ve($order);
foreach ($order->pullEvents() as $event) {
$this->events->dispatch($event);
}
}
}
层依赖规则
这条规则很简单:领域层不依赖任何东西。应用层只依赖领域层。基础设施层依赖领域层和应用层。表现层依赖应用层。绝不允许领域层依赖基础设施层。简而言之,依赖箭头永远指向内部。
限界上下文
限界上下文是领域模型适用的显式边界。同一个词在不同上下文中可能有不同含义——计费上下文中的"客户",可能携带订单上下文不需要或不拥有的发票数据。这就是为什么要明确边界。
上下文映射模式
| 模式 | 方向 | 适用场景 |
|---|---|---|
| 共享内核 | 双向 | 两个团队共享一个稳定的小规模子模型;变更需双方共同协定 |
| 客户/供应商 | 上游/下游 | 下游团队的需求正式影响上游的排期 |
| 顺从者 | 下游遵循上游 | 下游按原样接受上游模型(例如第三方 API) |
| 防腐层 | 下游进行转换 | 下游需要隔离上游模型 |
| 开放主机服务 | 上游发布 | 上游为多个消费者暴露稳定的协议 |
| 发布语言 | 双方 | 文档完善的共享格式(例如消息总线上的领域事件) |
在 PHP 单体应用中,限界上下文通过命名空间和自动化架构测试(Deptrac、PHPArkitect)来强制执行。命名空间违规应该在 CI 中被捕获,而不是等到运行时。
# deptrac.yaml — prevent Billing from importing Order internals
layers:
- name: Billing
collectors:
- type: namespace
regex: ^App\Domain\Billing
- name: Order
collectors:
- type: namespace
regex: ^App\Domain\Order
ruleset:
Billing:
- Order # disallowed — Billing must go via ACL or events
防腐层
防腐层在外部上下文(或外部服务)与领域模型之间进行转换。它防止外部概念渗入领域层。可以说,这是保护领域模型不被外部污染的第一道防线。
erp->getOrder($id->value());
if ($raw === null) {
return null;
}
return $this->translate($raw);
}
private function translate(array $raw): Order
{
$currency = new Currency($raw['curr_code']);
$order = new Order(
new OrderId($raw['ord_uuid']),
new CustomerId($raw['cust_ref']),
new Money((int) ($raw['ship_fee'] * 100), $currency),
);
foreach ($raw['items'] as $item) {
$order->addLine(
new ProductId($item['sku']),
(int) $item['qty'],
new Money((int) ($item['unit_price'] * 100), $currency),
);
}
return $order;
}
}
目录结构
src/
├── Domain/
│ ├── Order/
│ │ ├── Order.php # aggregate root
│ │ ├── OrderId.php # VO identity
│ │ ├── OrderLine.php # child entity
│ │ ├── OrderStatus.php # backed enum
│ │ ├── OrderPlaced.php # domain event
│ │ ├── OrderRepository.php # interface
│ │ └── Exceptions/
│ ├── Pricing/
│ │ ├── PriceCalculator.php # domain service
│ │ └── DiscountRepository.php
│ └── Shared/
│ ├── DomainEvent.php
│ ├── Money.php
│ └── Currency.php
├── Application/
│ └── Order/
│ ├── PlaceOrderCommand.php
│ └── PlaceOrderHandler.php
└── Infrastructure/
├── Persistence/
│ ├── DoctrineOrderRepository.php
│ └── InMemoryOrderRepository.php
└── Legacy/
└── LegacyOrderAcl.php
共享内核范围
Domain/Shared 必须保持精简——只包含真正跨越所有上下文的类型,比如 Money、DomainEvent、聚合基类。如果它不断膨胀,就该考虑提取成按上下文划分的共享类型,并明确哪些上下文共享哪些类型。
常见错误
| 错误 | 后果 | 修复方法 |
|---|---|---|
| 贫血领域模型 | 所有逻辑都位于服务中,实体沦为 getter/setter 的集合 | 将行为迁移到实体中,实体应自行强制执行不变式 |
| 胖聚合 | 长数据库锁、合并冲突、慢速加载 | 按事务边界拆分;跨边界操作使用事件 |
| ORM 实体 = 领域实体 | 持久化模式渗入领域层(可空外键、袋里标识) | 将 ORM 映射与领域对象分离,或谨慎使用 Doctrine 嵌入对象/映射 |
| 仓储当作查询构建器 | 大量 findByX 方法;业务查询散落在基础设施层 |
添加专用的读模型/查询服务;保持仓储轻量 |
| 跨聚合对象引用 | 耦合加载、循环加载、聚合边界被破坏 | 仅通过 ID 引用;在应用层分别加载 |
| 保存前触发领域事件 | 处理程序可能看到未持久化的变更 | 在成功执行 sa ve() 之后再分发事件 |
| 使用 int/string 作为 ID | 错误 ID 类型被静默传入;缺乏领域校验 | 使用类型化的 ID 值对象(OrderId、CustomerId) |
| 应用逻辑位于领域层 | 领域层依赖 HTTP 请求、会话或基础设施 | 领域层只包含纯 PHP 代码——不包含框架接口,不包含超全局变量 |
PHP 在领域驱动(DDD)设计中的核心实践
