聊了三年 DDD,代码里全是贫血模型:老陈一句话点破,落地先过这几关

作者:姚杨 | 专栏:阿杰的技术成长

阿杰 的团队开了三个月 DDD 分享会:限界上下文、事件风暴、聚合根、领域服务、防腐层......黑话一个比一个多,白板上画满了圈圈框框。

可上周代码评审,阿杰 翻完三个模块,差点没把咖啡喷出来------业务逻辑全写在路由闭包里,实体类清一色 getter/setter,数据库表怎么设计没人说得清。

他忍不住在群里吐槽:「我们到底做没做 DDD?」

老陈 只回了一句:「DDD 是上层建筑,你的地基呢?

一、先别聊方法论,回答我三个问题

老陈 在语音里连问三个问题:

「第一,你的订单和订单项,在数据库里怎么设计?第二,你的实体是胖的还是瘦的?业务逻辑在实体里还是在闭包里?第三,你的事务边界画在哪?」

阿杰 愣了:「这跟 DDD 有什么关系?」

「关系大了。」老陈 说,「DDD 讲究领域模型,可领域模型不是画在 PPT 上的,是长在数据库表、类图、代码里的 。方法论告诉你『要有聚合根』,但聚合根怎么落地,靠的是 E-R 设计、聚合组合、范式建模、实体封装------这些才是地基。地基不稳,DDD 就是空中楼阁。」

「我给你把这几关过一遍,你就知道差距在哪了。」

二、第一关:E-R 关系设计------领域模型先画在表上

「领域建模的第一步,不是画事件风暴,是画 E-R 图。」老陈 说,「每个领域实体,最终都要落成表;实体之间的关系,落成外键。

「拿电商举例:用户、订单、订单项、支付流水。谁跟谁是一对多?订单和订单项,一个订单多个订单项;订单和支付流水,一个订单多笔支付(可能分多次付款)。关系没理清,聚合根就是空谈------你连『哪些表属于同一个聚合』都分不清,怎么圈边界?」

「很多人跳过 E-R 直接聊聚合,等于没打地基先盖楼。先画清楚实体关系,再谈领域边界。」老陈 说着,丢过来两张建表语句:

sql 复制代码
-- 订单表:级牌是 version,乐观锁由框架自动维护
CREATE TABLE `order` (
  `id`          BIGINT PRIMARY KEY,
  `user_id`     BIGINT NOT NULL,
  `status`      TINYINT NOT NULL DEFAULT 0,
  `total_amount` DECIMAL(10,2) NOT NULL DEFAULT 0,
  `version`     INT NOT NULL DEFAULT 0,
  `create_time` DATETIME NOT NULL,
  `update_time` DATETIME NOT NULL
);

-- 订单项表:外键 order_id 指向订单,一对多
CREATE TABLE `order_item` (
  `id`          BIGINT PRIMARY KEY,
  `order_id`    BIGINT NOT NULL,
  `product_id`  BIGINT NOT NULL,
  `quantity`    INT NOT NULL,
  `price`       DECIMAL(10,2) NOT NULL
);

「E-R 画清楚,这两张表的外键关系就定了------哪张表属于哪个聚合,先看外键朝谁。

三、第二关:聚合 vs 组合------UML 类图里的两种「拥有」

「然后是最容易被混的一对概念:聚合和组合。」老陈 说,「UML 类图里,它们都表示『拥有』,但语义完全不同。」

聚合 :整体和部分可以独立存在。订单聚合了订单项------订单删了,订单项的数据还在(留着对账);订单项换到另一个订单也行。生命周期不同步,谁离开了谁都能活。」

组合 :整体和部分同生共死。订单和支付流水是组合------订单没了,支付流水跟着消失;支付流水的生命周期被订单牢牢绑定。部分不能脱离整体单独存在。」

「怎么在代码里表达?看你实体里怎么声明关系。」老陈 打开他的框架,一个订单实体:

php 复制代码
// domain/entity/order.php
class order extends entity
{
    // 业务字段:框架按 structs 快照做脏检测,只认这些字段
    public $structs = [
        'user_id'      => 0,
        'status'       => 0,
        'total_amount' => 0,
    ];

    public static function create(int $user_id): order
    {
        $order = parent::init();
        $order->user_id = $user_id;
        return $order;
    }

    public function __construct()
    {
        // 聚合:订单项可独立存在 ------ has_many
        $this->has_many('order_items', 'order_item', 'order_id');

        // 组合:支付流水与订单同生共死 ------ has_one 强绑定
        $this->has_one('payment_flow', 'payment_flow', 'order_id');

        // 反向:订单属于用户 ------ belongs_to
        $this->belongs_to('user');
    }
}

「看到区别没有?」老陈 说,「has_many 是聚合,has_one 是组合 ------虽然都叫关系,但语义完全不一样。框架里 order_items 是懒加载的,$order->order_items 第一次访问才查库,用不到不背性能债;而 payment_flow 强绑定订单,订单生命周期结束它就没了。」

「搞反了就会出笑话:你把支付流水设计成 has_many,订单删了流水还在,财务对账直接炸;你把订单项设计成强绑定,用户改单删项,历史数据全没了。」

「DDD 里的『聚合根』,本质就是拿聚合/组合关系圈出来的边界------这个边界,画在 UML 类图上,落地在实体的关系声明里。」

四、第三关:范式与反范式------表的「设计哲学」

「再往下,是表设计的四张牌:一范式、二范式、三范式、反三范式。」老陈 一张张翻。

一范式:字段原子性。 一个字段只存一个值。你存『商品:苹果,香蕉,橘子』这种逗号串,就是违反一范式------查『哪些订单买了苹果』,索引直接废掉。」

二范式:消除部分依赖。 联合主键的表里,非主键字段必须依赖完整主键。订单项表(order_id+product_id 联合)里存『商品名』,商品名只依赖 product_id 不依赖 order_id,就得拆出去------不然改个商品名,要更新几百行订单项。」

三范式:消除传递依赖。 字段不能间接依赖主键。订单表里存『用户城市』------城市属于用户,不属于订单,这就是传递依赖,拆到用户表去。」

反三范式:故意冗余。 读多写少的场景,为了少 join 一次,把『用户城市』冗余回订单表,用一致性兜底。」老陈 又甩了一段:

sql 复制代码
-- 反三范式:读多写少场景,订单表冗余 user_city,少一次 JOIN
CREATE TABLE `order` (
  `id`          BIGINT PRIMARY KEY,
  `user_id`     BIGINT NOT NULL,
  `user_city`   VARCHAR(32) NOT NULL DEFAULT '',  -- 冗余字段,由一致性兜底
  `status`      TINYINT NOT NULL DEFAULT 0,
  `version`     INT NOT NULL DEFAULT 0
);

范式是理论正确,反范式是工程取舍------DDD 落地时,读模型用反范式(CQRS 的影子),写模型守范式。写模型的实体 structs 声明什么字段,表就长什么样,别让实体和表各说各话。」

五、第四关:胖实体 vs 贫血实体------业务逻辑住在哪

「表设计完,轮到代码层最要命的一关:实体胖不胖。」老陈 加重了语气。

贫血实体 :一个只有 structs + getter 的壳,业务逻辑全在路由闭包里。你写了三年 DDD,如果实体全是贫血的,那不好意思------你做的不是 DDD,是『贫血模型 + 上帝闭包』,只是换了个名字的 MVC。」老陈 写了两种写法:

php 复制代码
// 贫血写法:业务规则全在闭包里,实体只是数据容器
if_post('/order/submit', function () {
    $order = dao('order')->find(input('order_id', ''));

    if ($order->status !== 0) {
        return ['code' => 400, 'msg' => '订单状态不允许提交'];
    }
    if ($order->total_amount <= 0) {
        return ['code' => 400, 'msg' => '订单金额不能为 0'];
    }

    $order->status = 1;
    return ['code' => 0, 'data' => ['order_id' => $order->id]];
});

胖实体(充血模型):行为长在实体上,规则、校验、状态流转都在实体内部,闭包只负责编排。」

php 复制代码
// 胖实体写法:业务规则在 order::submit() 里,闭包只编排
if_post('/order/submit', function () {
    $order = dao('order')->find(input('order_id', ''));
    $order->submit();   // 规则在实体里:状态校验、金额校验、状态流转

    return ['code' => 0, 'data' => ['order_id' => $order->id]];
});
php 复制代码
// domain/entity/order.php ------ submit 长在实体上
public function submit(): void
{
    if ($this->status !== 0) {
        throw new Exception('订单状态不允许提交');
    }
    if ($this->total_amount <= 0) {
        throw new Exception('订单金额不能为 0');
    }
    $this->status = 1;   // 改属性即脏检测,unit_of_work 自动 UPDATE
}

「判断自己是胖是瘦,就看一个例子:『订单提交时校验库存』------贫血写法在闭包里 if 一遍,胖写法在 $order->submit() 内部。改一个订单的行为,胖实体只动一个文件;贫血的要在一堆闭包里找。

「还有两个容易踩的坑:懒加载关系懒加载字段 。框架里 has_many 关系天生懒加载------$order->order_items 用到了才查,不会一个订单查询把整棵对象树拉爆;大字段(商品描述、富文本)别进 structs 常驻内存,单独按需查。这是实体封装的性能基本功,和 DDD 无关,但没有它,胖实体就是性能灾难。

六、第五关:DAO、ORM、工作单元------代码层的三根支柱

「最后,落地代码要有三根支柱。」老陈 说,「很多人以为 DDD 是设计问题,其实一半是持久化基建问题。」

DAO 模式:数据访问对象。把『怎么读怎么写』封装在 DAO 里,业务层不碰 SQL。」

php 复制代码
// domain/dao/order_dao.php ------ 类名即表名,继承即拥有查询能力
class order_dao extends dao
{
    protected $table_name = 'order';
    protected $db_config_key = 'default';
}

// 业务里统一入口:dao('order')->find($id)
// 关联查询:dao('order_item')->find_all_by_column(['order_id' => $id])

「SQL 只出现在 DAO,闭包里只调方法------这是最朴素的边界。」

ORM 模式 :对象关系映射。让实体和表映射起来,对象就是行。但要小心------ORM 用不好,等于给 DDD 埋雷:隐式 N+1 查询、lazy load 失控、把对象图当数据库搬。框架的实体是 Active Record,structs 存数据库快照、attributes 存内存值,改一个字段就自动脏检测。ORM 是工具,不是目的,别让它替你决定领域设计。

「还有一个绕不开的问题:主键从哪来? 」老陈 说,「单体时代靠数据库自增,但分布式、分库分表之后,自增 ID 会撞 ------两个库各自从 1 开始,一合并就重。DDD 里实体的 ID 是身份标识,必须全局唯一,所以 ORM 层得内置 ID 生成器:雪花(Snowflake)、美团 Leaf 号段、百度 UidGenerator......方案不少,但都得在 ORM 这一层解决,不是业务的事。」

「框架的做法是号段模式:entity::init() 里自动调用 generate_id()Redis 原子自增批量取号,一次取一段,内存发号,用完再去取。多实例并发各拿各的号段,谁都不撞谁------分库分表后照样安全,不依赖数据库自增。」

php 复制代码
// 框架 entity::init() 内部自动调用 ------ 主键不用你操心
$static->id = self::generate_id();
// 号段模式:Redis 批量取号(如一次取 100 个),内存逐个发
// 实例 A 拿 1~100,实例 B 拿 101~200 ------ 分布式下全局唯一,不撞车

// 而你写业务,只需要:
$order = order::create($user_id);   // id 已由框架生成,直接可用

实体 ID 的生成,是 ORM 的一部分。 你要是用数据库自增,将来拆库的时候,所有外键关系全要重刷------那时候才想起 ID 生成器,就晚了。」

工作单元模式 :Unit of Work。把一次业务操作里的所有写操作,包进同一个事务。」老陈 打开框架的控制器入口:

php 复制代码
// 路由闭包被 unit_of_work() 自动包裹:
// 一个闭包 = 一个工作单元 = 一个事务
if_post('/order/pay', function () {
    $order = dao('order')->find(input('order_id', ''));

    $flow = payment_flow::create($order->id, input('amount', 0));  // 记流水
    $order->status = 2;                                              // 改状态

    return ['code' => 0];
});
// 闭包结束统一提交:扣库存、改状态、记流水,要么全成要么全败
// 任何一个异常,整体回滚------事务边界跟着聚合根的一次操作走

「看见没有?创建实体、改属性,都不用手动 save ------unit_of_work() 在闭包结束自动提交,just_new 生成 INSERT、just_updated 生成 UPDATE(带 version 乐观锁)、just_deleted 走软删除。你的事务边界画在一个闭包上,而一个闭包对应聚合根的一次操作------这才是 DDD 说的事务边界。

「这三根支柱,DAO 管查询、ORM 管映射、工作单元管事务,缺一根,胖实体就立不起来------业务逻辑写进实体了,可实体操作数据库的边界不清晰,最后还是一团浆糊。」

尾声:DDD 不神秘,基本功很诚实

阿杰 挂了语音,打开代码仓库,把订单模块的实体翻出来看了一眼------果然,全是贫血的,一个方法都没有。

他默默把闭包里的 submit 逻辑,搬进了 order::submit(),给订单项配了懒加载,把支付流水表的外键检查了一遍,最后确认路由闭包被 unit_of_work() 包成了同一个事务。

三个小时后,他在群里发了句话:「原来 DDD 不是画出来的,是一层层基本功垒出来的------E-R 关系、聚合组合、范式建模、胖实体、DAO、ORM、工作单元。方法论告诉你往哪走,基本功决定你能不能走到。」

老陈 回了个「对」,又补了一句:「少聊点概念,多建几张表。 你写的每一行 SQL、每一个方法签名,才是你真正的 DDD。」

至于阿杰 后来把工作单元模式固化进自家框架,让事务边界跟着聚合根走、自动包裹每个业务闭包------那是另一个故事了。

相关推荐
DotNet10017 分钟前
别再只会 new List() 了!C#15 的 with(capacity:) 到底香在哪?
后端
思考着亮21 分钟前
1.路由与请求参数校验
后端
半个落月22 分钟前
NestJS 入门实战:从工厂模式到 Todo CRUD,讲透模块化、依赖注入与测试
后端·nestjs
思考着亮23 分钟前
11.MVCC、行锁与事务隔离级别
后端
一开24 分钟前
一个自己开发的 Agent Harness-持久化与恢复篇
后端
一开25 分钟前
一个自己开发的 Agent Harness-模型降级篇
后端
Geek漫游指南30 分钟前
AI 会回答还不够:ProofOps 业务研判平台落地实战
后端
SimonKing44 分钟前
白嫖国产多模态大模型:商汤 SenseNova 接入指南
java·后端·程序员
小江的记录本44 分钟前
【ORM框架】MyBatis核心原理、ORM思想、MyBatis vs JPA
java·数据库·后端·spring·spring cloud·oracle·mybatis