PHP大型项目如何优雅拆分DDD领域驱动架构与模块边界

各位好,坐稳了。今天我们不聊“Hello World”,也不聊“怎么用PHP最快写出Hello World”。今天我们聊点重口味的。

如果你们的代码库现在长这样:一个 src 文件夹,里面有1000个类文件,每个类都有500行代码,Controller里混杂了数据库查询、缓存逻辑、业务规则、甚至还有“判断一下今天是周五再发货”这种代码……如果你看着这坨东西不觉得反胃,那说明你的胃大概是钛合金做的。

欢迎来到“PHP屎山现场”。

我们要解决的问题是:在PHP这个被误读为“低端脚本语言”的领域,如何用DDD(领域驱动设计)和优雅的模块拆分,把这坨屎山变成瑞士奶酪,或者说,变成一座精密的瑞士钟表。

准备好了吗?让我们开始解剖。

第一章:限界上下文——你是谁?你的边界在哪里?

很多PHP开发者对DDD的理解非常肤浅。他们认为DDD就是把类名改得更花哨一点,比如把 User 改成 UserEntity,把 OrderService 改成 OrderApplicationService。这就好比给一只穿着西装的猴子贴了个标签,它还是猴子。

DDD的核心在于限界上下文

想象一下你的公司。有销售部、财务部、客服部、IT部。销售部有他们的语言(比如“单子”、“提成”、“客户”),财务部也有他们的语言(比如“发票”、“报销”、“审计”)。IT部把销售部和财务部的代码混在一个命名空间 AppService 里,这就是灾难的开始。

限界上下文就是那个“地域”概念。

在代码里,它表现为一个命名空间

代码示例:错误的开始

// App/Service/User.php
class User {
    private $name;
    private $email;
    private $isAdmin = false; // 这里的业务逻辑太脏了

    public function updateProfile($name, $email) {
        // 还可能混杂了发送邮件的逻辑
        // 甚至混杂了缓存刷新逻辑
    }
}

// App/Service/Order.php
class Order {
    private $items = [];
    private $total;

    public function addItem($productId) {
        // 这里试图去调用数据库查询库存
    }
}

在这个世界里,User类知道订单的事,Order类想操作User,User想查库存。这叫什么?这叫耦合的绞肉机。大家都在一个大缸里搅和,谁也救不了谁。

代码示例:优雅的开始

我们要把“用户”和“订单”切分开。

// App/Domain/User/Identity.php
namespace AppDomainUser;

class Identity {
    private string $id;

    public function __construct(string $id)
    {
        if (empty($id)) {
            throw new InvalidArgumentException("ID不能为空");
        }
        $this->id = $id;
    }
}

// App/Domain/User/Profile.php
namespace AppDomainUser;

class Profile {
    private string $name;
    private string $email;

    // 这是纯粹的领域逻辑,没有任何HTTP请求或数据库连接
    public function changeEmail(string $newEmail): void {
        if (!filter_var($newEmail, FILTER_VALIDATE_EMAIL)) {
            throw new DomainException("邮箱格式错误");
        }
        $this->email = $newEmail;
    }
}

// App/Domain/User/User.php
namespace AppDomainUser;

class User {
    private Identity $id;
    private Profile $profile;
    // ... 其他领域属性

    public function __construct(Identity $id, Profile $profile)
    {
        $this->id = $id;
        $this->profile = $profile;
    }

    public function changeEmail(string $newEmail): void {
        $this->profile->changeEmail($newEmail);
    }
}

看懂了吗?User 类现在非常干净。它不知道数据库长什么样,不知道HTTP请求长什么样,甚至不知道外面还有个叫 Order 的东西。它只关心“我是谁,我的资料是什么”。这就是单一职责原则的极致体现。

第二章:整洁架构——洋葱模型

PHP项目怎么组织?很多人喜欢 app/Controller, app/Model, app/View, app/Service, app/Entity, app/Utils, app/Config, app/Database……文件夹多到打开IDE都要转个圈。

在DDD里,我们用洋葱模型。或者更学术一点,叫整洁架构

核心原则:依赖方向。
你要记住,依赖方向必须是单向的。内层不依赖外层,外层依赖内层。

  1. 最外层:Interface(接口层):负责HTTP请求、CLI命令、序列化JSON。也就是你的Controller和CommandBus。
  2. 第二层:Application(应用层):负责用例编排。这里不写业务规则,只写“发生了什么事”。比如“创建订单”、“修改用户”。
  3. 第三层:Domain(领域层):业务规则的核心。这是代码的灵魂。这里定义了实体、值对象、领域事件、仓储接口。
  4. 最内层:Infrastructure(基础设施层):数据库、第三方API、日志、邮件发送。这里实现了Domain层定义的接口。

目录结构规划

不要搞那些花里胡哨的。一个清晰的目录结构比什么都强。

/project-root
    /config
    /src
        /Interface
            /Http        // Controllers, Middleware
            /Console     // Commands
        /Application    // Use Cases, DTOs
        /Domain         // The Meat!
            /User
                Entity
                ValueObject
                Repository
            /Order
                Entity
                Service
                Repository
        /Infrastructure // The Support Staff
            /Persistence // Doctrine, PDO implementations
            /ExternalApi // 3rd party SDKs
    /tests

第三章:深入Domain层——实体与聚合根

很多人问:“我在Domain层里能不能写 echo 'Hello World'?”

不能。Domain层里绝对不能有任何I/O操作(数据库、文件系统、网络)。如果你在一个Entity里写了 file_put_contents,那你就是对DDD的侮辱。

聚合根

聚合根是聚合的守护神。它定义了内部其他实体(如OrderItem)的生命周期。

假设我们在做一个电商系统。Order 是聚合根。OrderItem 是实体。Money 是值对象。

错误的代码:

// Domain/Order/Order.php
class Order {
    public $items = [];

    public function addItem($product, $quantity) {
        // 这里的逻辑是反的!你把数据映射的逻辑放在了Domain层!
        // 而且直接操作了数据库?
        $stmt = $this->pdo->prepare("SELECT price FROM products WHERE id = ?");
        $stmt->execute([$product->id]);
        $price = $stmt->fetchColumn();

        $this->items[] = new OrderItem($product, $quantity, $price);
    }
}

正确的代码:

// Domain/Order/Order.php
namespace AppDomainOrder;

use AppDomainUserProfile;

class Order {
    // 聚合根包含其他实体
    private $items = []; 
    private $status;

    public function __construct() {
        $this->status = OrderStatus::CREATED;
    }

    /**
     * 领域行为:添加商品
     * 这里只进行逻辑校验,不涉及数据库操作
     */
    public function addLineItem(OrderItem $item): void {
        if ($this->status->isShipped()) {
            throw new DomainException("订单已发货,无法添加商品");
        }

        // 这里仅仅是引用,真正的数据持久化交给Infrastructure层
        $this->items[] = $item;
    }

    // 计算总价
    public function getTotalAmount(): Money {
        $total = Money::zero();
        foreach ($this->items as $item) {
            $total = $total->add($item->getTotal());
        }
        return $total;
    }
}

注意看 Order 类。它没有 $pdo 属性,没有 Repository 属性。它只关心业务逻辑。如果我要添加一个商品,系统先检查“能不能添加”,能的话就添加,不能就抛异常。

第四章:Application层——编排的艺术

现在Domain层很干净了,但是谁来调用它呢?谁来创建 Order 对象?谁来把HTTP请求的数据转换成 Order 对象?

这就是Application层(也叫用例层)的活儿。

在PHP里,最流行的方式是 CQRS(命令查询职责分离),或者更简单的 Command/Query 模式。

我们来看一个“创建订单”的流程。

代码示例:Command Handler

// Application/Order/CreateOrderHandler.php
namespace AppApplicationOrder;

use AppDomainOrderOrder;
use AppDomainOrderOrderRepository;
use AppDomainOrderCommandCreateOrderCommand;

class CreateOrderHandler {
    private $orderRepository;
    private $productService; // 这里的依赖是接口,不是具体实现

    public function __construct(OrderRepository $orderRepository, ProductServiceInterface $productService)
    {
        $this->orderRepository = $orderRepository;
        $this->productService = $productService;
    }

    public function handle(CreateOrderCommand $command): string {
        // 1. 加载聚合根(这里简化处理,实际可能需要从DB Load)
        // 注意:这里我们不直接查数据库,而是查领域服务的内存缓存或内存状态
        // 因为Domain层不应该知道如何从DB拿数据,它只知道Product是个什么概念

        // 假设我们根据Command里的User ID找到了对应的User
        // $user = $this->userService->getById($command->userId);

        // 2. 构建聚合根
        $order = new Order();

        // 3. 编排业务流程
        foreach ($command->items as $itemInput) {
            // 去领域服务里查查这个商品存不存在,库存够不够
            $product = $this->productService->getById($itemInput->productId);

            // 转换成领域实体
            $orderItem = new OrderItem(
                $product->getId(),
                $product->getName(),
                $product->getPrice(),
                $itemInput->quantity
            );

            $order->addLineItem($orderItem);
        }

        // 4. 保存
        // 这里依然不写SQL!OrderRepository是一个接口,它的实现可能是PDO,也可能是Redis
        $this->orderRepository->save($order);

        // 5. 触发领域事件(后续章节细讲)
        // $order->record(new OrderCreatedEvent($order->getId()));

        return $order->getId();
    }
}

Application层很轻量。它就像个导演。它指挥 UserOrder 这两个演员去演戏,它不写剧本(业务规则),它只负责把道具搬上舞台。

第五章:Infrastructure层——数据持久化的替罪羊

这是最“脏”的一层,但也是PHP项目最常见的一层。它负责把Domain层的抽象变成具体的数据库操作。

这里我们需要用到 Repository Pattern(仓储模式)

代码示例:Eloquent/Doctrine 实现

我们要实现刚才 OrderRepository 接口。

// Infrastructure/Persistence/OrderRepository.php
namespace AppInfrastructurePersistence;

use AppDomainOrderOrder;
use AppDomainOrderOrderRepository as OrderRepositoryInterface;

class OrderRepository implements OrderRepositoryInterface {
    private $db; // PDO或者Doctrine EntityManager

    public function __construct(PDO $db)
    {
        $this->db = $db;
    }

    public function save(Order $order): void {
        // 1. 开始事务
        $this->db->beginTransaction();

        try {
            // 2. 插入主表
            $stmt = $this->db->prepare("INSERT INTO orders (status) VALUES (?)");
            $stmt->execute([$order->getStatus()->getValue()]);
            $orderId = $this->db->lastInsertId();

            // 3. 插入明细表
            foreach ($order->getItems() as $item) {
                $stmt = $this->db->prepare("INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)");
                $stmt->execute([
                    $orderId, 
                    $item->getProductId(), 
                    $item->getQuantity(), 
                    $item->getUnitPrice()->getAmount() // Money是值对象,要取值
                ]);
            }

            $this->db->commit();
        } catch (Exception $e) {
            $this->db->rollBack();
            throw $e; // 或者抛出更具体的仓储异常
        }
    }

    public function findById(string $id): ?Order {
        // 复杂的JOIN查询,甚至可能用到Query Builder
        // 返回的必须是领域对象 Order,而不是 DB Row
    }
}

看到了吗?在 Infrastructure 层,你可以尽情地写 SELECT * FROM ...,你可以用 file_get_contents,你可以用 curl。这是你的自由,因为外层根本看不见这些“脏活累活”。

第六章:值对象——不可变的宝石

在PHP中,对象通常是引用传递的。如果你修改了一个对象,所有持有这个对象引用的地方都会变。这是巨大的隐患。

DDD引入了 Value Object (VO)。值对象是不可变的。一旦创建,它就是死的。

例如:Money 是一个值对象。

// Domain/Money/Money.php
namespace AppDomainMoney;

class Money {
    private int $amount;
    private string $currency; // 'CNY', 'USD'

    private function __construct(int $amount, string $currency)
    {
        $this->amount = $amount;
        $this->currency = $currency;
    }

    public static function fromInt(int $amount, string $currency): self {
        return new self($amount, $currency);
    }

    // 关键点:修改钱的方法,必须返回一个新的Money对象
    // 原对象保持不变
    public function add(Money $other): self {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException("货币单位不一致");
        }
        return new self($this->amount + $other->amount, $this->currency);
    }

    public function equals(Money $other): bool {
        return $this->amount === $other->amount && $this->currency === $other->currency;
    }

    // 为了打印,提供一个getter
    public function getAmount(): int {
        return $this->amount;
    }
}

Order 里,我们怎么用?

// Domain/Order/Order.php
class Order {
    private $total; // Money 类型

    public function addItem(OrderItem $item): void {
        $currentTotal = $this->total !== null ? $this->total : Money::zero();
        $this->total = $currentTotal->add($item->getTotal()); // 这里返回了新的 Money
    }
}

这种“不可变性”大大降低了Bug率。你不用到处去 unset($order->total),也不用担心一个地方改了总价,整个系统都乱套了。

第七章:领域事件——不要大喊大叫,发个消息

在传统PHP MVC里,Controller往往是“上帝”。Controller里充满了:
$this->userService->create();
$this->emailService->send();
$this->inventoryService->deduct();
$this->logService->record();

这叫上帝控制器。它什么都管,什么都耦合。

DDD推崇 领域事件。业务流程发生变化时,发布一个事件。

比如:订单已创建

  1. Domain层创建Order后,发布 OrderCreated 事件。
  2. Domain层不关心谁监听了这个事件。
  3. Application层或者Infrastructure层监听这个事件,去干“发送邮件”、“扣库存”这种杂事。

代码示例:领域事件

// Domain/Order/Order.php
namespace AppDomainOrder;

use AppDomainEvent DomainEvent;

class Order implements DomainEvent {
    private $createdAt;
    private $orderId;

    public function __construct()
    {
        $this->createdAt = new DateTimeImmutable();
    }

    public function getOrderId(): string {
        return $this->orderId;
    }

    // 发布事件
    public function publishCreatedEvent(): OrderCreatedEvent {
        return new OrderCreatedEvent($this->orderId, $this->createdAt);
    }
}

// Domain/Event/OrderCreatedEvent.php
namespace AppDomainEvent;

use AppDomainEventDomainEvent;

class OrderCreatedEvent implements DomainEvent {
    private $orderId;
    private $occurredOn;

    public function __construct(string $orderId, DateTimeImmutable $occurredOn)
    {
        $this->orderId = $orderId;
        $this->occurredOn = $occurredOn;
    }

    public function getOrderId(): string {
        return $this->orderId;
    }

    public function getOccurredOn(): DateTimeImmutable {
        return $this->occurredOn;
    }
}

代码示例:事件监听器

// Infrastructure/Event/OrderCreatedListener.php
namespace AppInfrastructureEvent;

use AppDomainEventOrderCreatedEvent;
use AppDomainEventListenerInterface;

class OrderCreatedListener implements ListenerInterface {
    private $emailService;

    public function __construct(EmailServiceInterface $emailService)
    {
        $this->emailService = $emailService;
    }

    public function handle(OrderCreatedEvent $event): void {
        // 像个烧锅炉的,不管上面造什么,收到信号就烧锅炉
        $this->emailService->sendWelcomeEmail($event->getOrderId());
    }
}

通过事件,你的 Order 类变得非常纯粹。它只管“我发生了什么事”,而不管“谁来通知”。这样,以后如果你想加一个“发短信通知”,不需要修改 Order 类,只需要加一个监听器。这就是开闭原则

第八章:循环依赖——死结与解药

在拆分模块时,最容易遇到的坑就是循环依赖

比如:OrderModule 需要查 UserModule 的资料,而 UserModule 也想给 User 对象打上“最近购买过Order”的标记。

在PHP里,这会编译报错。

解决方案:接口隔离。

不要互相依赖。如果 Order 需要 User 的信息,Order 只需要依赖 UserInterface。具体的实现细节(比如Profile、Avatar)由 Order 去问。

或者,更狠一点,把这两个上下文合并。如果它们必须强耦合,说明它们本来就应该在一个限界上下文里,别强行拆分了。

第九章:测试策略——别指望手动测试

用了DDD,测试就变得简单了。因为你的Domain层是纯函数式的,没有I/O。

写单元测试时,你不需要Mock数据库,不需要Mock HTTP请求。

代码示例:Domain测试

// Tests/Domain/Money/MoneyTest.php
namespace TestsDomainMoney;

use AppDomainMoneyMoney;
use PHPUnitFrameworkTestCase;

class MoneyTest extends TestCase {
    public function testCanAddMoney() {
        $money1 = Money::fromInt(10, 'CNY');
        $money2 = Money::fromInt(20, 'CNY');

        $result = $money1->add($money2);

        $this->assertEquals(30, $result->getAmount());
    }

    public function testThrowExceptionOnDifferentCurrency() {
        $money1 = Money::fromInt(10, 'CNY');
        $money2 = Money::fromInt(20, 'USD');

        $this->expectException(InvalidArgumentException::class);
        $money1->add($money2);
    }
}

这就是测试的优雅。闭着眼睛写,点一下按钮,全绿。这就是DDD的魅力。

第十章:实战场景——一个完整的数据流

为了让你彻底明白,我们来走一遍完整的流程。假设用户在Web端下单。

  1. 用户点击提交

    • src/Interface/Http/Controllers/OrderController.php
    • 接收 POST /orders,解析JSON。
  2. 转换与校验

    • OrderController 验证请求参数。
    • 创建 CreateOrderCommand 对象。
  3. 分发Command

    • src/Application/CommandBus.php (使用 Symfony Messenger 或类似库)
    • bus->dispatch($command)
  4. 执行Handler

    • CreateOrderHandler 被调用。
    • CreateOrderHandler 注入了 OrderRepository
    • CreateOrderHandler 创建 Order 聚合根。
    • CreateOrderHandler 调用 Order->addLineItem(...)
    • Domain层生效Order 检查逻辑,抛出异常(如果库存为0)。
  5. 持久化

    • 如果成功,OrderRepository->save($order)
    • Infrastructure/Persistence/OrderRepositoryOrder 对象序列化成SQL INSERT
  6. 事件触发

    • Order->publishCreatedEvent()
    • 事件总线监听到 OrderCreated
    • 监听器收到通知。
    • Infrastructure层生效EmailService 发送邮件。

整个过程中,Order 对象从来没见过 PDO,也没见过 SMTP。它只在纯粹的逻辑世界里跳舞。而 OrderController 只是个传话的,不知道业务细节。

总结:不要为了DDD而DDD

最后,我要泼一盆冷水。

DDD不是万灵药。不要为了用DDD而把一个简单的“Hello World”项目搞成几百个文件夹。那样你会累死的。

DDD的适用场景:

  1. 业务逻辑极其复杂。
  2. 业务规则多变。
  3. 需要支持多端(Web、App、小程序)接入同一个业务核心。

什么情况下别用DDD:

  1. 传统的CRUD增删改查项目。
  2. 周期性执行的脚本(CLI)。
  3. 你只有一个人,且代码量小于1000行。

优雅拆分的秘诀:
就是“尊重业务”。先画出你的业务流程图,找到那些变化频繁的地方,把它们圈起来,这就是你的限界上下文。然后,把代码的“脏活累活”扔到最外层,把“纯粹的业务规则”锁在最里层。

记住,代码是写给人看的,顺便给机器运行。如果你的代码结构清晰,同事们读起来像读小说一样顺畅,那你就成功了。

好了,今天的讲座就到这里。现在,去把你那个 lib 文件夹里的500个类删了吧。相信我,你会感觉像是在戒毒一样爽快。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注