PHP 领域驱动设计实战:限界上下文、聚合与六边形架构

系统讲解 PHP 中的 DDD 与分层架构:从贫血模型到充血模型、限界上下文与通用语言、实体与值对象、聚合与一致性边界、领域事件、仓储与工厂、防腐层与六边形架构,并给出 Laravel 项目中的目录结构与落地节奏。

引言

大多数 PHP 项目的代码形态是「控制器里写业务、模型里放数据、服务类里堆流程」——功能能跑,但业务规则散落在各处,改一个促销规则要翻五个文件,写测试要先连数据库。这类代码的问题不是语法,而是缺少一个能被业务和技术共同理解的模型。

领域驱动设计(DDD)提供的不是框架,而是一套控制复杂度的思维工具:用通用语言把业务概念固化进代码,用限界上下文划清模型边界,用聚合定义一致性单位,用领域事件解耦副作用,用防腐层隔离外部系统。它最直接的效果是:业务规则集中在一处、可以脱离数据库单元测试、新人读代码就能看懂业务。

但 DDD 也是被误用最多的方法论之一:有人把每个表都包装成「实体」,有人在 CRUD 系统里强行上聚合,有人把 DDD 等同于「多写几层目录」。本文从贫血模型的实际痛点出发,给出 PHP 语境下可落地的取舍。

关联阅读:https://plumephp.com/php-oop-design-patterns/ 讲透了 SOLID 与依赖注入,是理解本文的基础;分层与部署视角可参考 https://plumephp.com/php-microservices-patterns/。


目录


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; }
}

聚合设计四条经验:

  1. 尽量小:聚合越大,并发冲突与加载成本越高。
  2. 通过 ID 引用外部聚合:Order 里存 customerId,而不是 Customer 对象。
  3. 一个事务一个聚合:跨聚合的一致性交给领域事件(第 5 节)。
  4. 不变量必须在聚合内:如果某规则跨两个聚合,说明边界划错了。
错误做法后果
把整张 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 渐进式落地四步

  1. 先建通用语言:与业务方一起梳理关键名词与动词,形成术语表。
  2. 从核心域选一个聚合切入:挑业务规则最复杂、变化最频繁的那个(通常是订单或计价),不要从用户表开始。
  3. 隔离依赖:为这个聚合建 Domain 目录,仓储接口化,Eloquent 模型降级为适配器。
  4. 用事件解耦副作用:把「支付成功后发短信、加积分」这类逻辑改为监听领域事件。

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/ — 聚合持久化与数据库演进的配合

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「php」更多文章

  1. Symfony 框架实战:组件化架构、依赖注入容器与 Doctrine 集成
  2. PHP 遗留代码现代化实战:坏味道识别、绞杀者模式与灰度切换
  3. PHP 文件上传与图片处理实战:安全校验、分片续传与对象存储