领域模型应放在 src/Domain 下按业务域分包,如 src/Domain/Order/Entity/Order.php;值对象须不可变、无标识、不映射为实体;领域实体与 Doctrine 实体必须分离,确保业务逻辑与框架解耦。

领域模型应放在 src/Domain 下按业务域分包
Symfony本身不强制领域层结构,但DDD实践要求将核心业务逻辑与框架解耦。官方推荐的DDD Skeleton和主流生产项目(如Symfony 8微服务架构)都把领域模型统一放在 src/Domain 目录下,而非混在 src/Entity 或 src/Model 中。
每个子域对应一个子目录,例如订单、用户、库存:
src/Domain/Order/Entity/Order.phpsrc/Domain/Order/ValueObject/OrderStatus.phpsrc/Domain/User/Entity/User.phpsrc/Domain/User/ValueObject/Email.php
这样组织能清晰表达“订单状态是订单域内的值对象”,而不是全局可变的通用类型。Doctrine实体映射仍可存在,但仅作为基础设施层的持久化适配器,src/Domain/Order/Entity/Order 应是纯PHP类,不含 @ORM\* 注解。
ValueObject 必须不可变且无标识
值对象不是数据库记录,它没有主键、不参与生命周期管理,也不该被 Doctrine 直接映射为独立表。常见错误是给 Email 加 @ORM\Entity 或定义 $id 属性——这违背了值对象语义。
正确做法是:
- 所有属性设为
private readonly(PHP 8.2+),或用构造函数一次性赋值 + 无 setter - 重写
__toString()和equals()(或实现EquatableInterface)用于值比较 - 若需序列化,显式定义
jsonSerialize(),避免暴露内部结构 - 禁止在值对象里调用仓储、事件总线等基础设施服务
例如 Email 的构造应校验格式并抛出 InvalidArgumentException,而不是返回布尔值或静默修正。
领域实体与 Doctrine 实体必须分离
很多团队直接把 App\Entity\User 当作领域实体用,结果导致业务逻辑被 ORM 绑定:比如在 User::changePassword() 里调用 $this->setUpdatedAt(...),或依赖 EntityManager 触发状态变更。这会让单元测试变得脆弱,也阻碍 CQRS 读写分离。
正确分层方式是:
-
src/Domain/User/Entity/User.php:只含业务规则、领域事件触发、不变式校验 -
src/Infrastructure/User/DoctrineUserRepository.php:负责把User转成App\Entity\User并存入数据库 -
src/Infrastructure/User/DoctrineUser.php(可选):仅含@ORM\*映射,无业务方法
这种分离让 User::activate() 可以自由抛出 UserAlreadyActivatedException,而无需关心数据库字段是否已更新。
值对象的序列化和 Doctrine 嵌入式映射要谨慎
Doctrine 支持 @ORM\Embedded 将值对象映射到同一张表的多个字段,比如把 Address 拆成 address_street、address_city。但这只适用于简单、扁平、无行为的值对象。
容易踩的坑包括:
- 嵌入对象含嵌套值对象(如
Address包含PostalCode)时,Doctrine 不支持多层嵌入 - 嵌入字段名硬编码在注解里,重构
Address::getStreet()时易遗漏同步修改映射 - 嵌入后无法对值对象整体加约束(如“城市和邮编必须匹配”需靠数据库 CHECK 或应用层校验)
更稳妥的做法是:用 Doctrine 自定义类型(CustomType)把整个值对象 JSON 序列化进单个字段,或彻底放弃 ORM 映射,在仓储中手动处理转换逻辑。
复杂业务系统里,值对象越靠近领域核心,就越该远离数据库细节——这是 DDD 分层最常被忽略的边界。


















