PHP领域驱动设计核心实践完整权威指南:十大策略与实战案例

2026-06-10阅读 0热度 0
php

PHP 在领域驱动设计(DDD)中的工程落地关键

先厘清几个核心认知。在PHP技术栈中推行领域驱动设计,不少开发者第一反应是“这模式对PHP是不是过于沉重”?恰恰相反。DDD的真正价值不在于复杂的分层架构图,而在于它提供了一套让代码精准映射业务语义的方法论和工具。从通用语言到限界上下文,每一条设计原则背后都有可落地的工程依据。

通用语言

通用语言的核心目标是让领域专家与开发团队使用同一套术语沟通。领域层的类命名、方法命名、变量命名必须源自业务词汇表,而非框架约定或数据库表名。

PHP 在领域驱动(DDD)设计中的核心实践

一个极易被忽视的危险信号:当代码中出现 OrderManagerOrderHelperOrderService(如果“Service”一词根本不在领域词汇表中),说明实现层面的命名已经渗透到了领域语言内部。正确的做法是什么?直接采用领域专家实际使用的词汇:OrderOrderFulfillmentShipment

另一个关键点是,将发现的通用语言直接映射为类型约束。假设领域专家说“订阅在连续两次付款失败后变为逾期”,这句话本身就蕴含了业务规则——它应该由 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 必须保持精简——只包含真正跨越所有上下文的类型,比如 MoneyDomainEvent、聚合基类。如果它不断膨胀,就该考虑提取成按上下文划分的共享类型,并明确哪些上下文共享哪些类型。

常见错误

错误 后果 修复方法
贫血领域模型 所有逻辑都位于服务中,实体沦为 getter/setter 的集合 将行为迁移到实体中,实体应自行强制执行不变式
胖聚合 长数据库锁、合并冲突、慢速加载 按事务边界拆分;跨边界操作使用事件
ORM 实体 = 领域实体 持久化模式渗入领域层(可空外键、袋里标识) 将 ORM 映射与领域对象分离,或谨慎使用 Doctrine 嵌入对象/映射
仓储当作查询构建器 大量 findByX 方法;业务查询散落在基础设施层 添加专用的读模型/查询服务;保持仓储轻量
跨聚合对象引用 耦合加载、循环加载、聚合边界被破坏 仅通过 ID 引用;在应用层分别加载
保存前触发领域事件 处理程序可能看到未持久化的变更 在成功执行 sa ve() 之后再分发事件
使用 int/string 作为 ID 错误 ID 类型被静默传入;缺乏领域校验 使用类型化的 ID 值对象(OrderIdCustomerId
应用逻辑位于领域层 领域层依赖 HTTP 请求、会话或基础设施 领域层只包含纯 PHP 代码——不包含框架接口,不包含超全局变量

PHP 在领域驱动(DDD)设计中的核心实践

免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

相关阅读

更多
欢迎回来 登录或注册后,可保存提示词和历史记录
登录后可同步收藏、历史记录和常用模板
注册即表示同意服务条款与隐私政策