发布于 2026-01-06 10 阅读
0

使用 PHP 和 Symfony 进行领域驱动设计 (DDD) 的意义在于 DDD 和 Symfony 的总结和附加曲目

使用 PHP 和 Symfony 进行领域驱动设计

意义即一切

DDD 和 Symfony

结论

奖励曲目

基于PHPSymfony 框架 (2.x 至 5.x)的领域驱动设计个人见解

这篇文章最初是用法语写的,链接在此。

意义即一切

领域驱动设计(DDD)在Symfony和PHP社区正日益流行。我致力于探寻其“意义”,因此我是一名DDD的倡导者。

定义

DDD是一种范式、一种方法、一种途径。
请花点时间思考一下这个缩写,因为要真正理解 DDD 的价值,你只需要理解这三个词的组合:“领域驱动设计”(Domain-Driven Design)。如果你只能记住 DDD 的一件事,那就是它的名字。

领域驱动设计(DDD)不是一种软件架构或架构风格。DDD与实体、值对象或聚合根无关。它们只是面向对象世界中方便但又容易误导的技术细节。

DDD本质上是开发人员在基础代码中体现的对业务领域的共同理解。

实际应用

如果业务人员愿意花时间阅读领域驱动设计(DDD)代码库,他们应该能够理解其中 80% 的内容。实际上,像 PHP 这样的高级语言可以被视为英语的一个非常糟糕的子集。在 DDD 中,PHP 代码用一种“面向对象的英语”来描述业务规则、需求和不变式。

我的代码看起来怎么样?以下是用蹩脚英语描述的业务领域流程:

$cashier->checkout($cart);
$cashier->charge($card);
$accountant->write($payment);
$compliance->verify($payment);
Enter fullscreen mode Exit fullscreen mode

我的课程看起来怎么样?用蹩脚英语写的商业概念:

namespace Legal\Company\France;

/**
 * French unique registration number for a specific establishments of a company
 * issued by INSEE
 * SIRET: "Système d'Identification du Répertoire des ETablissements"
 * 
 * @see https://www.economie.gouv.fr/entreprises/numeros-siren-siret
 */
Class Siret implements RegistrationNumber
{
    private Siren $siren;
    private Nic $nic;

    public function __construct(string $number)
    {
        try {
            $this->siren = new Siren(mb_substr($number, 0, 9));
            $this->nic = new Nic(mb_substr($number, 9));
        } catch (\Exception $exception) {
            throw new \DomainException('Invalid SIRET number');
        }
    }

    public function getNumber(): string
    {
        return $this->siren->getNumber() . $this->nic->getNumber()
    }
}
Enter fullscreen mode Exit fullscreen mode
namespace Legal\Company\France;

/**
 * Unique registration number for a french company
 * Issued by INSEE, composing the first part of SIRET
 * Système d'Identification du Répertoire des ENtreprises
 *
 * @see https://bpifrance-creation.fr/encyclopedie/formalites-creation-dune-entreprise/formalites-generalites/numeros-didentification
 */
class Siren
{
    private string $number;

    public function __construct(string $number)
    {
         if (preg_match('/^[0-9]{9}$/', $number) !== 1) {
            throw new \DomainException('Invalid SIREN number');
         }

         $this->number = $number;
    }

    public function getNumber(): string
    {
        return $this->number;
    }
}
Enter fullscreen mode Exit fullscreen mode

法国开发者可能熟悉文中举例的概念。但是,如果您不熟悉,只需阅读代码,您很快就能清楚地了解法国公司注册号的相关规则。

直接收益

我们编写的代码不仅可读性强,而且表达力丰富,能够直白地传达业务概念和领域知识。

这段代码也是“不可变的”。
我没有遵循任何最佳实践或特定模式,只是遵循了法国车牌号码的规则。这些号码是不可变的,所以我的代码也是不可变的……因此这些类自然而然地符合值对象(Value Object)的定义。这并非实现的初衷,而是一个附带结果。

解放你的思想

忘掉“最佳实践”、“设计模式”、“流畅接口”、“事件分发”、MVCSoCSRP 、 SOARADCRUDDRY等等吧……
技术什么的都先放一边,领域驱动你的设计,领域优先。用你选择的语言(这里是 PHP)以最简单的方式对领域进行建模。就像业务专家试图向幼儿讲解流程一样,开发人员试图让机器在保持清晰含义的前提下处理它。

如果你在命名空间中使用带有 setter 和 getter 的实体以及一些值对象/ValueObject/,你就错过了 DDD 的精髓。

再次强调,领域驱动设计(DDD)并非要求遵循某种特定的实现方式,而是强调代码与业务领域之间语言的统一。

领域驱动设计 (DDD) 的另一个价值在于:代码在运行时和静态保存时都具有价值。代码能够自我文档化地描述业务流程,成为任何与之交互的人员的知识中心。

DDD 和 Symfony

我大约在2014年开始阅读和实践领域驱动设计(DDD)。
我花了一整年的时间阅读和实践,才开始对我的工具(PHP/Symfony/Doctrine)和DDD感到得心应手。正如Evans所说:

“不要对抗你的框架”。

领域驱动设计:解决软件核心的复杂性

我使用 Symfony 实现 DDD 的“务实”方法是什么?
这实际上取决于项目及其复杂程度。但当我启动一个新项目或指导一位新开发人员时,我通常会遵循以下简单的指导原则:

为什么没有 CQRS 和 ES?

CQRS 和 ES 成本很高。它们是功能极其强大的解决方案,但只有在成本和业务方面都合理的情况下才应该使用它们。

ES

很多 PHP 开发人员并不熟悉事件溯源,能够正确设计或使用事件存储的人也寥寥无几。如果你缺乏成熟的模型,请记住,你的事件存储会直接反映你过去的错误,这可能会大幅增加维护成本。

CQRS

CQRS 在规模化应用中才能真正发挥优势。这通常会在写入模型和读取模型之间实现最终一致性。你真的需要这种复杂性和成本吗?

我在不同的项目中使用 PHP/Symfony、有时也使用 Doctrine、SQL 和 MongoDB 实现了这两种模式。

源结构

  • src/Controller/Symfony 控制器
  • src/Command/Symfony CLI 命令

  • src/Action/:命令 + 命令处理程序

  • src/Domain/AR、实体、VO、存储库接口

  • src/*基础设施及其他方面。

有个东西叫“/Domain/烂透了”,但它也管用。
通常我会把“Domain”替换成我们正在处理的实际领域,比如/Ecommerce/……

多域问题

问题在于拥有多个域名时。人们对核心域名和辅助域名的看法非常明确。至于我?我不在乎。我把所有东西都放进去/Domain/,然后根据需要拆分,不去区分“核心”或“辅助”域名。

  • /Domain/Payment
  • /Domain/Compliance
  • /Domain/Delivery

它简单易懂,而且始终如一。

“域”中包含什么?

在 `<class>` 标签内src/Domain/,我放置了所有实现业务领域关注点/概念的类。有些是持久化类,有些则不是,我们还在那里放置了领域服务。

由于src/Entity不再存在,Doctrine 配置已进行自定义以进行扫描src/Domain。使用注解来配置持久化映射,因为“局部性”非常重要。

显然,当项目规模扩大时,我会重新组织结构,/Domain/以反映业务领域:

/Domain/Legal/Company/France/RegistrationNumber/Siret.php
/Domain/Legal/Company/France/RegistrationNumber/Siren.php
/Domain/Legal/Company/France/RegistrationNumber/Nic.php
Enter fullscreen mode Exit fullscreen mode

征服的命令

好的

我喜欢这种命令模式:

  • 命令处理程序与六边形架构配合良好

  • 命令处理程序为操作我的模型层带来了一致性,它们是唯一的入口点。

  • 命令处理程序为应用程序服务层带来一致性,因为它们本身就是应用程序服务。

  • 命令处理程序定义了业务流程/操作的事务边界

  • 命令处理程序有助于处理和设计聚合根。

坏的和丑陋的

在某些情况下,我确实会滥用命令处理程序:

  • 当面对未知规范、缺乏理解、缺乏可用知识,或者当我偷懒或赶时间时:我会在命令处理程序代码中实现,该代码稍后会移至域服务或域对象中。

  • 当需要同步多个业务流程时,我会使用处理程序而不是SAGA地狱。

  • 当使用 Doctrine/Symfony 实现AR非常棘手或成本很高时,我会在处理程序中泄露实现代码(这是不好的做法,请勿在家尝试)。

对纯粹主义者来说,这听起来可能很糟糕。我真的很抱歉,但同时我也完全接受,因为这对我来说很有效。

给命令命名

命令的命名依据是流程、它们
所实现的业务操作或行为。我会根据具体的业务需求进行调整:

src/Action/Checkout.php
src/Action/CheckoutHandler.php

src/Action/FraudulentPaymentDeclaration.php
src/Action/FraudulentPaymentDeclarationHandler.php

src/Action/Compliance/Registration.php
src/Action/Compliance/RegistrationHandler.php
src/Action/Compliance/FrenchEstablishmentRegistration.php
src/Action/Compliance/FrenchEstablishmentRegistrationHandler.php
Enter fullscreen mode Exit fullscreen mode

或许我应该用祈使句来表示命令:注册(Register)而不是注册(Register)。

——弗洛里安·克莱因

业务流程、领域流程或领域操作通常都有一个唯一的名称。务必花时间准确识别用于描述该流程的名词。但这并非总是可行。

动词使用不当可能反映出对相关领域理解不足,例如:

  • MarkAsRead
  • ValidateStatus

这只是一条建议,尽力而为。实际上,我的代码仓库里确实有很多命令是用动词构成的。

实施细节

命令是(领域)意图的表达。它们被设计成简单的DTO,只接受基本类型

,从而简化了六边形架构和端口/适配器实现。在我的应用程序中,命令是基本类型和领域概念之间的边界:

namespace Action\Compliance;

use Domain\Geo\Address;
use Domain\Geo\Country;

class Registration
{
  private string $userId;
  private string $name;
  private string $addressLine1;
  private string $addressLine2;
  private string $addressZip;
  private string $addressLocality;
  private string $addressCountry;

  public function __construct(
    string $userId,
    string $name, 
    string $addressLine1, 
    string $addressLine2,
    string $addressZip,
    string $addressLocality,
    string $addressCountry
  ) {
    $this->userId = $userId
    // ... boring stuff
  }

  public function getUserId(): Uuid
  {
    return new Uuid($this->userId);
  }

  public function getName(): string
  {
    return $this->name;
  }

  public function getAddress(): Address
  {
    return new Address(
      $this->addressLine1,
      $this->addressLine2,
      $this->addressZip,
      $this->addressLocality,
      new Country($this->addressCountry)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

我确实在命令中添加了带有注解的 Symfony 验证,以便预先验证并将任何输入错误传递给用户。然而,当涉及到强制执行业务规则时:我的领域概念(主要是值对象)包含了实现,任何违反规则的行为都会引发异常。

以这种方式实现命令,简化六边形:

  • 命令可以像Symfony/Form 模型一样使用,您知道的就是“数据类”。

  • 命令可以由 Symfony/Console CLI 命令的参数组成。

  • RabbitMQ 工作进程/消费者可以根据未序列化的消息组成命令。

  • 命令可以由 Symfony/EventListener 处理的事件组成。

  • 来自 WebSocket 等……

一旦我的命令传递给其处理程序,我们就进入了领域。领域内只使用领域对象(VO、实体)。领域对象提供强大的保证:

  • 它的状态始终一致且有效(根据业务规则)。

  • 它的状态基本不变。

  • 其公共 API 仅允许按照域规范更改预期状态。

使用这些领域对象是一种乐趣和解脱:认知负荷非常低,对系统的信心非常高。

几点建议:

  • 我没有为每个 AR/实体使用类型化的 ID(例如 UserId、BookId)。用 Doctrine 维护起来很麻烦。

  • 何时应该使用领域对象 (VO) 而不是基本类型?如果概念对领域很重要/相关,并且遵循领域规则:那么它就是一个领域对象。否则,基本类型就足够了。我们现在还在 PHP 中,不是 Java。

命令处理程序?

命令处理程序协调整个领域概念(VO、实体、AR)。

它通常依赖于存储库来获取相关的 AR。

处理程序随后调用领域对象(AR、实体)上的方法。由于 Doctrine 遵循数据映射,处理程序负责管理持久化周期。有时,它还会协调其他基础架构细节,通常是与构建正确状态以执行业务操作相关的任何 I/O 操作。

Symfony Messenger 负责处理繁重的工作。你只需要通过 CLI 命令、HTTP 控制器或 RabbitMQ 消费者将命令分发到总线上,瞧,你的处理程序就会被调用并执行该命令。

由于我没有使用 CQRS,我的大部分命令都是通过 HTTP 同步发送的。我使用 Symfony/Messenger获取命令执行的最后一个结果,只需要添加一些序列化操作,就能得到HTTP 响应的REST表示形式。

有限上下文(BC)

我将一个 Git 仓库视为一个限界上下文。我的理解以后可能会改变。以后我也可以拆分向后兼容性(BC)。

很多BC可能意味着:

  • 领域误解
  • 域名信息未知
  • 高复杂性

组织卑诗省的

微服务架构的编排非常棘手。如果你有一支强大的运维团队,那当然很好。如果没有,本地化部署又能派上用场了:

src/BC1/Controller/
src/BC1/Command/
src/BC1/Domain/

src/BC2/Controller/
src/BC2/Command/
src/BC2/Domain/
Enter fullscreen mode Exit fullscreen mode

为什么?因为拆分业务连续性(BC)风险很高,同步业务连续性成本也很高。你需要不断加深理解,这需要大量的重构和迭代:在业务连续性(BC)和领域代码之间来回迁移。一旦你的模型成熟,就可以拆分代码库,开始构建微服务了。

结论

这些是我发现的一些技巧或折衷方案,它们能有效地将领域驱动设计(DDD)方法与 PHP、Symfony 和 Doctrine 结合使用。但你不应该把重点放在这些方面。

这些工具可以帮助你避免思考“如何做”,而只需专注于“做什么”和“为什么做”。

奖励曲目

我可以在BC上共享/重用概念吗?

首先:绝对不要在业务连续性 (BC) 中共享实体。
其次:虚拟对象 (VO) 共享非常困难,尤其是在使用 Doctrine 时,您需要通过 Composer/Vendor 添加您的域,并通过 YAML 添加映射。
最后:域共享效果一般。例如,您可以共享地理位置域(ISO 国家/地区、ISO 电话号码、ISO 货币),但这将是一个非常通用的支持域。

为什么“核心”域名共享不好?因为如果两个不同的业务连续性(BC)对同一个域名有着完全相同的属性、规则和结构……那么它们实际上只有一个业务连续性。注意,两个业务连续性共享域名并不意味着它们的含义相同。

例如:对于人力资源部门(BC)和技术管理(BC)而言,“员工”(领域)的含义截然不同,即使它们有一些相似之处。

BC协作、同步?

事情就变得棘手了。首先,BC 应该是隔离的,不应该有任何直接耦合。

数据方面,如果你使用 SQL,要么选择两个存储位置,要么选择两个模式。
如果为了节省成本而共享同一个模式……那么不要在业务连续体 (BC) 的表之间建立关联。

命令模式在这里非常有用。假设我有一个结账流程,其中包含一个关于支付的业务组件 (BC) 和一个关于合规性的业务组件 (BC)。

优点:我可以在同一层级(HTTP 控制器和 CLI 命令)调用 PaymentCharge 和 PaymentFraudAnalyze 这两个命令。优点:保证了松耦合。缺点:你的控制器/CLI 命令需要进行一些应用/服务/业务同步。

最佳方案:使用事件。使用 Symfony 的调度器,不要自己编写代码。从你的命令处理程序中显式地分发一个事件。在 BC2 中创建一个特定的事件监听器。不要偷懒,不要使用“命令溯源模式”。你的 BC1 发出一个事件(“妈妈,我做完了”),你的 BC2 监听该事件,然后将其转换为自己的命令。

您还可以添加静态分析来强制执行耦合规则(确保业务组件不会混用)。如果您将业务组件隔离(数据存储)并与事件同步,则可以构建符合微服务要求的可扩展业务组件。这意味着您可以快速地将业务组件迁移并拆分到代码仓库和基础架构中。

文章来源:https://dev.to/ludofleury/domain-driven-design-with-php-and-symfony-1bl6