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