PHP 在领域驱动(DDD)设计中的核心实践
通用语言
通用语言是领域专家与工程师之间的共享词汇。领域层中的每个类名、方法和变量都必须来源于此,而非框架约定或持久化术语。
错误信号
名为 OrderManager、OrderHelper 或 OrderService(当"Service"不在领域词汇表中时)的类,说明实现行话正在污染领域语言。应使用领域专家所称的名称:Order、OrderFulfillment、Shipment。
将发现的通用语言直接映射为类型。如果某位专家说"订阅在连续两次付款失败后变为逾期",这句话蕴含了一条领域规则------它应归属于 Subscription::markDelinquent(),而非 SubscriptionController。
值对象
值对象没有标识。两个实例如果值相等则视为相同。它们必须不可变------任何改变状态的操作都应返回一个新实例。
实现
php
<?php
// src/Domain/Order/Money.php
declare(strict_types=1);
namespace App\Domain\Order;
use InvalidArgumentException;
final class Money
{
public function __construct(
private readonly int $amount, // cents
private readonly Currency $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException('Amount cannot be negative');
}
}
public function add(Money $other): self
{
if (!$this->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+ 枚举
后端枚举(enum Status: string)本质上是值对象------对于有限状态集,应使用它们代替字符串常量或独立的值对象类。
实体
实体具有跨状态变更持久存在的标识。两个具有相同 ID 的 Order 对象是同一个订单,无论它们的当前字段值如何。
标识类型
优先使用领域生成的 UUID,而非数据库分配的自增整数 ID。自增 ID 强制在实体存在于内存之前进行一次数据库往返,这会破坏聚合的一致性保证。
php
<?php
final class OrderId
{
public function __construct(private readonly string $value)
{
if (!preg_match('/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i', $value)) {
throw new InvalidArgumentException("Invalid OrderId: {$value}");
}
}
public static function generate(): self
{
return new self(sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
));
}
public function value(): string { return $this->value; }
public function equals(self $other): bool { return $this->value === $other->value; }
}
聚合
聚合是由单个聚合根组织的一组实体和值对象。所有外部访问都通过聚合根进行。跨越集群中多个对象的不变式由聚合根强制执行。
规则
仅通过 ID 引用其他聚合------切勿跨聚合边界持有直接对象引用。每次访问都加载外部聚合会降低性能并耦合边界。
php
<?php
// src/Domain/Order/Order.php
final class Order
{
private OrderStatus $status;
/** @var OrderLine[] */
private array $lines = [];
/** @var DomainEvent[] */
private array $events = [];
public function __construct(
private readonly OrderId $id,
private readonly CustomerId $customerId, // foreign ID, not object
private readonly Money $shippingFee,
) {
$this->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 引用。较大的聚合会导致更长的事务锁和更多的合并冲突。
聚合边界内不变式检查清单
| 问题 | 如果"是" |
|---|---|
| 该实体能否脱离聚合根存在? | 它可能是独立的聚合 |
| 聚合根是否对这些实体强制执行不变式? | 将它们保留在同一聚合中 |
| 它们是否在同一事务中一起持久化? | 良好的信号,说明它们应在一起 |
| 聚合是否只是为了读取某个子实体而被加载? | 考虑拆分 |
仓储
仓储提供类似集合的接口来访问聚合。它抽象了持久化机制------领域层定义接口,基础设施层实现它。
php
<?php
// src/Domain/Order/OrderRepository.php
interface OrderRepository
{
public function get(OrderId $id): Order; // throws if not found
public function find(OrderId $id): ?Order; // null if not found
public function save(Order $order): void;
public function delete(Order $order): void;
/** @return Order[] */
public function findByCustomer(CustomerId $customerId): array;
}
php
<?php
// src/Infrastructure/Persistence/DoctrineOrderRepository.php
final class DoctrineOrderRepository implements OrderRepository
{
public function __construct(private readonly EntityManagerInterface $em) {}
public function get(OrderId $id): Order
{
return $this->find($id)
?? throw new OrderNotFound($id);
}
public function find(OrderId $id): ?Order
{
return $this->em->find(Order::class, $id->value());
}
public function save(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 工厂。
测试用内存实现
php
<?php
final class InMemoryOrderRepository implements OrderRepository
{
/** @var array<string, Order> */
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 save(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),
));
}
}
领域事件
领域事件记录了领域中发生的事件。它们以过去时态命名,不可变,并携带处理程序所需的所有数据。
php
<?php
// src/Domain/Shared/DomainEvent.php
interface DomainEvent
{
public function occurredAt(): DateTimeImmutable;
}
php
<?php
// src/Domain/Order/OrderPlaced.php
final readonly class OrderPlaced implements DomainEvent
{
public DateTimeImmutable $occurredAt;
public function __construct(
public readonly OrderId $orderId,
public readonly CustomerId $customerId,
public readonly Money $total,
) {
$this->occurredAt = new DateTimeImmutable();
}
public function occurredAt(): DateTimeImmutable
{
return $this->occurredAt;
}
}
分发策略
| 策略 | 保证 | 适用场景 |
|---|---|---|
| 收集 + 保存后分发 | 仅当保存成功时触发事件 | 默认------适用于大多数用例 |
| 事务性发件箱 | 跨进程边界至少一次投递 | 异步处理程序、微服务 |
| 同步事务内 | 处理程序在同一数据库事务中运行 | 很少合理;耦合限界上下文 |
pullEvents() 模式(读取后清空事件队列)是标准的收集-分发方式。应用层在 $repository->save($order) 之后调用它,并将事件分发到事件总线。
领域服务
领域服务包含不属于单个实体或值对象的领域逻辑,通常是需要多个聚合或外部领域接口的操作。
过度使用警告
最终落入领域服务的大部分逻辑实际上应归属于聚合或值对象。如果第一反应是创建服务,不妨先问问该方法是否可以直接归属于某个输入对象。
php
<?php
// src/Domain/Pricing/PriceCalculator.php
// Needs access to multiple aggregates + a domain-level discount policy
final class PriceCalculator
{
public function __construct(
private readonly DiscountRepository $discounts,
) {}
public function calculate(Order $order, Customer $customer): Money
{
$base = $order->total();
$discount = $this->discounts->findFor($customer->tier());
return $discount !== null
? $discount->apply($base)
: $base;
}
}
应用层
应用层编排用例。它不包含领域逻辑------它协调领域对象、提交事务并分发事件。标准单元是命令 + 处理程序对。
php
<?php
// src/Application/Order/PlaceOrderCommand.php
final readonly class PlaceOrderCommand
{
public function __construct(
public readonly string $orderId,
public readonly string $customerId,
public readonly array $lines, // [{productId, qty, unitPrice}]
public readonly int $shippingFee,
public readonly string $currency,
) {}
}
php
<?php
// src/Application/Order/PlaceOrderHandler.php
final class PlaceOrderHandler
{
public function __construct(
private readonly OrderRepository $orders,
private readonly EventBusInterface $events,
) {}
public function __invoke(PlaceOrderCommand $cmd): void
{
$currency = new Currency($cmd->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->save($order);
foreach ($order->pullEvents() as $event) {
$this->events->dispatch($event);
}
}
}
层依赖规则
领域层 → 无依赖。应用层 → 仅依赖领域层。基础设施层 → 依赖领域层 + 应用层。表现层 → 依赖应用层。绝不允许领域层依赖基础设施层。
限界上下文
限界上下文是领域模型适用的显式边界。同一个词在不同上下文中可能有不同含义------计费上下文中的"客户"可能携带订单上下文不需要或不拥有的发票数据。
上下文映射模式
| 模式 | 方向 | 适用场景 |
|---|---|---|
| 共享内核 | 双向 | 两个团队共享一个稳定的小规模子模型;变更需双方共同协定 |
| 客户/供应商 | 上游/下游 | 下游团队的需求正式影响上游的排期 |
| 顺从者 | 下游遵循上游 | 下游按原样接受上游模型(例如第三方 API) |
| 防腐层 | 下游进行转换 | 下游需要隔离上游模型------见下文 |
| 开放主机服务 | 上游发布 | 上游为多个消费者暴露稳定的协议 |
| 发布语言 | 双方 | 文档完善的共享格式(例如消息总线上的领域事件) |
在 PHP 单体应用中,限界上下文通过命名空间和自动化架构测试(Deptrac、PHPArkitect)来强制执行。命名空间违规在 CI 中捕获,而非运行时。
yaml
# 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
防腐层
防腐层在外部上下文(或外部服务)与领域模型之间进行转换。它防止外部概念渗入领域层。
php
<?php
// src/Infrastructure/Legacy/LegacyOrderAcl.php
/**
* Translates the legacy ERP's order format into domain objects.
* The domain never sees LegacyOrder.
*/
final class LegacyOrderAcl
{
public function __construct(
private readonly LegacyErpClient $erp
) {}
public function findOrder(OrderId $id): ?Order
{
$raw = $this->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 引用;在应用层分别加载 |
| 保存前触发领域事件 | 处理程序可能看到未持久化的变更 | 在成功执行 save() 之后再分发事件 |
| 使用 int/string 作为 ID | 错误 ID 类型被静默传入;缺乏领域校验 | 使用类型化的 ID 值对象(OrderId、CustomerId) |
| 应用逻辑位于领域层 | 领域层依赖 HTTP 请求、会话或基础设施 | 领域层只包含纯 PHP 代码------不包含框架接口,不包含超全局变量 |