news 2026/9/17 18:09:46

盒马DDD实践:贫血与充血模型的场景化选型与落地

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
盒马DDD实践:贫血与充血模型的场景化选型与落地

简介:围绕领域驱动设计在真实业务中的落地,这份 PDF 资料以盒马流程中心与基础资料为案例,梳理领域模型从数据建模到对象建模的演进路径,适合后端开发、架构设计人员以及正在准备 DDD 落地实践的团队参考。内容涉及失血模型、贫血模型、充血模型的差异与取舍,以及依赖注入(推荐构造器注入)、Repository 模式实现、测试友好设计和微服务部署结构等知识点,可帮助读者理解数据库设计与代码设计的边界。资料共 1 个 PDF 文件,压缩包约 1.63MB,以图文对照的讲稿形式呈现,便于按页浏览与快速检索。目前已有 1790 人学习,属于关注度较高的实践类案例。读者可借此对照自身项目,判断团队当前处于哪类领域模型,并参考盒马在流程中心采用 DATA+METHOD、在基础资料采用 DATA+METHOD+REPO 的做法,形成可复用的建模与重构思路。

1. 从盒马的两套代码风格说起:为什么同一个团队里贫血和充血并存

同一个技术团队里,流程中心用贫血模型,基础资料用充血模型,这不是风格不统一,而是被业务形态逼出来的选择。流程中心的核心复杂度在状态流转和编排,实体本身规则薄,把方法挂在对象上收益不大,反而让流程编排的入口变散;基础资料的核心复杂度在实体自身的业务规则约束,规则跟着对象走,才不会被各处 Service 复制粘贴成五份。很多团队一上来就问「DDD 该用充血还是贫血」,正确问法应该是「这个限界上下文里,复杂度长在哪」。盒马的实践给出的是一个分场景选型的样本:数据建模负责把关系理清楚,对象建模负责把行为收拢到该在的地方,两者不是对立关系,而是同一套领域模型在不同层的表达。这套东西适合已经有一定业务体量、被「数据库设计即系统设计」坑过的后端团队,尤其适合正在做服务拆分、需要给领域划边界的人。

2. 失血、贫血、充血三种领域模型的分界与选型依据

2.1 数据建模和对象建模到底差在哪

Data Modeling 的思路是先定表,字段、主键、外键、索引定完,系统结构基本就定了。数据字典就是领域模型,外键就是关系,Manager 类负责把逻辑组织起来。它的优点是直观,任何能写 SQL 的人都能看懂系统长什么样。缺点是:一旦业务规则复杂,逻辑会散落在 Manager、Service、定时任务、MQ 消费者里,同一个规则改动要改 n 处。

Object Modeling 的思路相反:假设内存无限大、永远不宕机,那么持久化就是一个无关紧要的细节,这叫 Persistence Ignorance。对象之间的关系就是业务关系,方法就是业务动作。但现实是内存有限、会宕机、会重启,所以数据库仍然存在,只是它的职责被压缩成两件事:持久化和高效查询(QUERY)。也就是说,Object Modeling 不是不要数据库,而是不让数据库结构反向决定代码结构。

这两条路的取舍可以归结成一句话:数据建模优化的是「怎么查得快」,对象建模优化的是「怎么改得动」。系统的读侧压力大、规则稳定,数据建模占优;系统的写侧规则频繁变化、状态机复杂,对象建模占优。

2.2 三种模型的判断标准

区分失血、贫血、充血,不要看类名,看三件事:数据和行为是否在同一个类里、领域对象是否持有仓储(Repository)、业务规则是否可以被其他类绕过。

模型组成业务逻辑位置典型场景
失血模型只有字段的 POJO全部散在 Service / ManagerCRUD 台账、配置表
贫血模型DATA + METHOD部分方法在对象内,编排在 Service状态流转、流程编排
充血模型DATA + METHOD + REPO方法内直接调用仓储完成聚合主数据、有强规则的实体

盒马流程中心落在贫血模型区间:对象上挂了少量行为,但真正的流转编排仍然由流程引擎和服务层承担,因为流程的复杂度不在单个实体上,而在实体之间的时序。盒马基础资料落在充血模型区间:品类、门店、商品这类主数据有自己的不变量约束,把这些约束写成对象方法、并让它通过 Repository 拉取自己需要的关联数据,能显著减少跨服务查库的胶水代码。

2.3 从失血迁移到充血的最小步骤

不要整体重构,按聚合根逐个迁移,步骤如下。

  1. 选一个业务规则最集中的实体,通常是主数据,统计当前所有地方对它字段的写入点。
  2. 把「字段校验 + 状态变更」收拢成对象方法,禁止外部直接 set。
  3. 引入仓储接口,只暴露聚合根级别的方法,不要暴露单表 CRUD。
  4. 把原来在 Service 里的查询逻辑改成先取聚合、再在内存里判断。
// 充血模型:规则内聚在对象内,外部只能通过行为改变状态 public class Category { private CategoryId id; private CategoryStatus status; private List<CategoryRule> rules; private final CategoryRepository categoryRepository; // 构造器注入,依赖显式可见,测试友好 public Category(CategoryId id, CategoryRepository categoryRepository) { this.id = id; this.categoryRepository = categoryRepository; } // 上架是一个业务动作,不是一次字段赋值 public void publish(Operator operator) { // 不变量:必须有至少一条规则,且规则不能互相冲突 if (rules == null || rules.isEmpty()) { throw new IllegalStateException("category has no rule"); } if (hasConflictRule()) { throw new IllegalStateException("category rule conflict"); } // 需要外部数据时通过仓储获取,而不是让调用方传进来 if (categoryRepository.hasOfflineStore(this.id)) { throw new IllegalStateException("offline store not ready"); } this.status = CategoryStatus.PUBLISHED; } }

这段代码的关键点有三个。一是publish代替了setStatus(PUBLISHED),把校验、跨数据检查、状态变更绑成一个不可分割的动作,调用方无法绕过规则。二是仓储通过构造器注入,对象在构造完成时就具备了完整的协作能力。三是异常类型用 IllegalStateException 而不是业务异常,是因为这属于不变量被破坏,应该由上层统一转成对外错误码。

提示:充血模型里注入 Repository 是有争议的做法。判断标准是「这个对象是否需要为了维护自身不变量而读取外部数据」。如果只是为了给查询做缓存,就不要注入。

3. 依赖注入在领域对象上的真实约束与测试友好性

3.1 Spring 容器里的对象和 new 出来的对象不是一回事

依赖注入在 runtime 里通常是 singleton 对象,而且只有落在 Spring 扫描范围内的类(@Component、@Service、@Repository 等)才能用 @Autowired 拿到注入。这意味着一个残酷的现实:你用new Category(...)造出来的领域对象,是拿不到任何注入的。很多团队迁移到充血模型后第一周就会踩这个坑——对象方法里categoryRepository是 null,报空指针,然后开始怀疑人生。

常见做法有两种。一种是让领域对象不依赖容器,仓储由外部在构造时传进来;另一种是领域对象仍然从容器取,但只取无状态的领域服务。我一般推荐前者,因为领域对象的生命周期不该被容器管,容器管的是单例的无状态组件。

// 推荐:构造器注入,依赖显式,测试时可自由替换 @Service public class CategoryApplicationService { private final CategoryRepository categoryRepository; private final DomainEventPublisher eventPublisher; // 只有一个构造器时 Spring 会自动注入,不需要 @Autowired public CategoryApplicationService(CategoryRepository categoryRepository, DomainEventPublisher eventPublisher) { this.categoryRepository = categoryRepository; this.eventPublisher = eventPublisher; } public void publishCategory(CategoryId id, Operator operator) { // 从仓储取出完整聚合,注意这里是聚合而不是单表行 Category category = categoryRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("category not found")); // 行为在领域对象上,应用服务只做编排 category.publish(operator); categoryRepository.save(category); eventPublisher.publish(new CategoryPublishedEvent(id, operator)); } }

用构造器注入而不是字段注入,收益很直接:字段是 final 的,对象构造完整性由编译器保证;测试时不需要启动 Spring 容器,直接 new 一个应用服务,传 mock 的仓储进去就行;哪个依赖是必需的,看构造器签名一目了然。字段注入的@Autowired private Foo foo;在单元测试里必须靠反射工具塞值,写起来别扭,读起来也看不出这个类到底依赖了多少东西。

3.2 测试友好性取决于模型设计而不是测试框架

失血模型和贫血模型是天然测试友好的——因为它们本来就没什么可测的,一个只有 getter/setter 的 POJO,测它等于测编译器。真正需要测试友好的是充血模型:对象内部调用了仓储,如果不做依赖注入,你就只能连数据库测。

这里有个容易忽略的点:构造器注入让「必须 mock 哪个对象」变成显式契约。上面Category的构造器要求传CategoryRepository,写测试的人一眼就知道要准备这个桩。如果换成字段注入或者在方法内部new一个仓储实现,测试者就得去读实现代码才知道依赖是什么。

对比一下两种写法的测试成本:

注入方式单元测试是否需要容器依赖是否显式是否能发现循环依赖
字段注入 @Autowired需要或需要反射工具启动时才报错
构造器注入不需要编译期或启动早期暴露
方法内 new无法替换无法发现

4. 盒马模式下的 Repository 实现与部署结构

4.1 Repository 该暴露什么粒度的方法

Repository 的常见误用是把它做成 DAO 的改名版,一个聚合根配一套单表 CRUD,结果领域对象里全是碎片化查询。盒马模式下的做法是:Repository 只以聚合为单位暴露方法,返回的必须是完整的聚合根,不能是半成品 DTO。

// 仓储接口定义在领域层,实现在基础设施层 public interface CategoryRepository { // 按聚合根 ID 取完整聚合 Optional<Category> findById(CategoryId id); // 保存整个聚合,内部处理新增/更新差异 void save(Category category); // 领域层需要的判定型查询,语义化命名 boolean hasOfflineStore(CategoryId id); // 批量取,避免循环单查 List<Category> findByIds(Collection<CategoryId> ids); } // 实现层用 MyBatis 或 JPA 均可,关键是把多表拼装收在这里 @Repository public class CategoryRepositoryImpl implements CategoryRepository { private final CategoryMapper categoryMapper; private final CategoryRuleMapper ruleMapper; @Override public Optional<Category> findById(CategoryId id) { CategoryDO data = categoryMapper.selectById(id.value()); if (data == null) { return Optional.empty(); } // 一次把规则捞全,避免领域对象内部再触发查询 List<CategoryRule> rules = ruleMapper.selectByCategoryId(id.value()); return Optional.of(data.toDomain(rules)); } @Override public boolean hasOfflineStore(CategoryId id) { // 判定型查询走 count,不要为了一个布尔值把整行捞回来 return categoryMapper.countOfflineStore(id.value()) > 0; } }

接口放在领域层、实现放在基础设施层,方向是倒置的:领域层不依赖具体持久化技术,实现层依赖领域层定义的接口。hasOfflineStore这种语义化判定方法很关键,它让领域对象里写的是if (repo.hasOfflineStore(id)),而不是让对象自己拼 SQL 条件。另外要注意findById里一次性把规则捞全,否则领域对象内部触发懒加载查询,在事务边界外就会出问题。

4.2 命名和参数上的几个具体约定

CategoryDOCategory分开是不可省的。DO 是持久化结构,字段和表一一对应,可以带isDeletedgmtCreate这类技术字段;领域对象只保留业务属性,不带技术字段。转换放在toDomain里,别让 Mapper 直接返回领域对象,否则表结构一改,领域对象跟着抖。

参数上,聚合根的 ID 用值对象包装而不是裸 String 或 Long,好处是编译期就能防止把门店 ID 传成品类 ID。仓储方法尽量不要接受一堆零散条件,如果查询条件超过三个,说明这个查询不属于仓储,应该单独建一个读模型(Read Model)走 CQRS 的路子。

批量方法要成对出现。findByIds存在的原因是避免在循环里调findById,这是 N+1 查询最典型的来源,在主数据场景下尤其致命,因为品类下的门店可能成百上千。

4.3 部署结构上应用服务和领域对象的分层

盒马模式下的部署结构里,应用服务是进程边界,领域对象活在进程内,仓储实现跨进程访问数据库和缓存。典型的分层是:

  • 接入层:HTTP / RPC 入口,只做参数解析和鉴权,不写业务判断
  • 应用层:编排用例,管事务、管事件发布,不写领域规则
  • 领域层:聚合、实体、值对象、领域服务、仓储接口,纯内存逻辑
  • 基础设施层:仓储实现、Mapper、消息发送、外部接口适配

分层带来的部署灵活性在于,应用层可以独立拆成微服务,领域层跟着走,基础设施层按依赖方向被反向引用。需要注意的一点是事务边界:事务应该开在应用层,一次用例一个事务,不要把事务加到仓储方法上,否则一个用例涉及多个聚合时会出现事务嵌套。

5. 充血模型的排错经验与聚合边界的验证技巧

充血模型上线后最常见的三类问题,我按踩坑频率排一下。

第一类是懒加载穿透。领域对象里持有仓储,如果仓储返回的对象在事务外还有未加载的关联,调用方法时就会抛 LazyInitializationException。验证方法是写一个不启动 Spring 容器的单元测试,用 mock 仓储返回完整聚合,看对象方法能否纯内存跑通。如果 mock 得费劲,说明聚合边界划错了。

第二类是不变量被绕过。有人图省事,在别的服务里直接调categoryMapper.updateStatus(id, PUBLISHED),规则就废了。防护手段是让 Mapper 只在仓储实现层可见,领域对象和外部服务拿不到 Mapper;再配合代码扫描规则,禁止在领域层之外出现对 DO 的直接写操作。

第三类是聚合过大导致的性能问题。一个聚合里塞了几百个实体,每次加载都全量拉取。判断聚合是否过大的标准是「这些对象是否需要强一致地一起变更」,如果只是查询时想一起返回,那不属于同一个聚合,应该走读模型。

// 用纯内存测试验证聚合边界是否合理 class CategoryTest { @Test void publish_should_fail_when_rule_conflict() { // 构造一个 mock 仓储,只桩出方法内部真正用到的那两个调用 CategoryRepository repo = mock(CategoryRepository.class); when(repo.hasOfflineStore(any())).thenReturn(false); Category category = new Category(CategoryId.of("C001"), repo); category.addRule(new CategoryRule("R1", ConflictType.A)); category.addRule(new CategoryRule("R2", ConflictType.A)); // 与 R1 冲突 // 纯内存执行,不需要数据库、不需要 Spring 容器 assertThrows(IllegalStateException.class, () -> category.publish(Operator.of("u1"))); } }

这个测试的价值不在于覆盖率,而在于它是一个边界探针:如果写这个测试时需要 mock 十几个依赖,说明这个聚合捆了太多东西,该拆。如果完全不需要 mock 仓储,说明这个对象其实不依赖外部数据,仓储注入就是多余的,可以退化成贫血模型。

最后一个技巧是关于演进节奏的。从失血迁到充血,不要按模块切,要按变更频率切。变更最频繁的主数据先迁,规则稳定的台账类数据留在失血模型里,反而更省心。每迁一个聚合,把原来的 Service 方法标记为 deprecated 但保留两个迭代周期,用日志对比新旧逻辑的输出差异,差异为零再删旧路径。这比一次性重构的风险低得多,也更容易说服团队里持怀疑态度的人。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 18:09:44

3步装好你的第一个Folo插件:个性化配置指南

3步装好你的第一个Folo插件&#xff1a;个性化配置指南 订阅、视频、播客&#xff0c;一天刷完50条信息&#xff0c;转头却想不起几条。Folo 插件就是来解决这件事的&#xff1a;Folo 是一款 AI 信息流阅读器&#xff0c;把零散内容汇进一条干净的时间线&#xff0c;支持翻译、…

作者头像 李华
网站建设 2026/9/17 18:09:33

Tabby三协议终端:SSH/FTP/RDP统一工作区原理与实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 18:07:59

Doris连接池报错ERROR 1203根因与实战治理指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华