当创建一个对象非常复杂的时候,可以使用工厂模式来应付。
我这边的真实场景是连锁餐饮/零售里最传统的一件事:门店每天要做库存盘点。只要是连锁店,门店店员基本都是天天拿着盘点单挨个数货,目的就是把系统里的物料库存矫正回来,这是整个进销存链路里非常核心的一次动作——盘点做错了,后面的订货、生产、报表全部跟着跑偏。
这篇文章就围绕这条“门店盘点”链路里的一张盘点单,展开讲讲我们为什么用工厂模式来创建它,以及这段代码是如何在生产环境里长期稳定跑着的。
顺带交代一下背景:目前我司是按 领域驱动设计(DDD)来搭建微服务的,盘点单是一整个“聚合”,有自己的 聚合根、 实体和 值对象,也有对应的仓储接口和实现。
如果你对 DDD 里的“聚合根”“实体”“值对象”等概念还不太熟,建议先简单读一读相关的入门资料,脑子里先有个大致轮廓,再回来看下面的代码会更顺。
不讲那些“工厂模式的定义”“简单工厂 vs 抽象工厂”的八股,而是老老实实拆一段真实业务代码:
- 这段代码为什么必须用工厂模式?
- 工厂到底帮我们收拾了哪些“对象创建地狱”?
- 它在生产上到底解决了什么实际问题?
说明:文中的类名做了简单脱敏,保留结构和关键字段,逻辑与线上代码一致,只是为了方便公开分享和对外交流。
盘点单的真实长相:远比一个 DTO 复杂
先把业务场景说清楚,否则很难体会到“为什么创建一个对象会这么难”。
我们要解决的是门店库存盘点的问题,每次盘点会生成一张“盘点单”,大致长这样:
- 盘点单主信息(主表)
- 门店 ID / 名称
- 盘点模板 ID / 类型(每日 / 每周 / 临时)
- 单据编号
code - 状态
status(暂存 / 已审核) - ERP 同步状态
erpStatus - 盘点日期
createDate - 开始时间 / 结束时间
- 创建人 / 更新人 / 用户 ID
- 盘点明细行(子表)
- 物料 ID / 编码 / 名称 / 规格
- 盘点单位
unit - 盘点数量
qty
- 模板规则(模板聚合根里)
- 某些模板支持“跨日盘点”(比如凌晨 2 点算前一天)
- 某些模板只允许一天一次
这些字段分散在不同地方:
- 前端传来的
SubmitCheckingDocsCommand(用户输入 + 一些基础信息) - 数据库里的盘点模板
CheckingTemplateAggregateRoot - 模板规则解析辅助类
CheckingHelper - 单据号生成服务
DocsDayIdGeneratorDomainService - 历史盘点单(更新时要沿用原来的
code)
从数据来源看,构造一张盘点单至少要拼接4类信息:
- 用户提交的命令对象
- 模板的主数据 + 规则
- 单据号生成器输出
- 历史单据(更新场景)
如果把这些逻辑散落在 Controller / Service 里,后期维护基本是灾难。
调用链拆开看:工厂藏在这条“提交流水线”的中段
这是提交盘点单时的大致调用链:
真正负责“把一堆数据拼成完整聚合根”的,正是中间的CheckingDocsFactory。
- Controller 负责 HTTP / 参数校验
- ApplicationService 负责跨服务编排(比如校验门店、调用库存中心)
- DomainService 负责并发控制、业务校验(今天是否已盘点)
- Factory 负责“复杂对象的构造”
- Repository 负责持久化
在这条调用链上,只有一层关心“盘点单内部到底有多少字段、这些字段从哪来”——就是工厂。这样一来,修改构造逻辑时只需要看一个类,而不是满工程搜索new CheckingDocsAggregateRoot。
代码主角:盘点单工厂的两条构造路径
这个工厂类有两个核心方法,对应读写两条路径:
factoryOf(CheckingDocsCreator, List<CheckingDocsItemCreator>)- 用于从数据库 PO 还原聚合根(读侧)
factoryOf(SubmitCheckingDocsCommand)- 用于从前端命令创建 / 更新聚合根(写侧)
先看写侧,这才是“对象创建地狱”真正爆发的地方。
public class CheckingDocsFactory { private final DocsDayIdGeneratorDomainService docsDayIdGenerator; private final CheckingTemplateRepository templateRepository; private final CheckingHelper checkingHelper; // 读侧:从数据库 Creator + ItemCreator 还原聚合根 public CheckingDocsAggregateRoot factoryOf(CheckingDocsCreator creator, List<CheckingDocsItemCreator> itemCreators) { CheckingDocsMainEntity main = convertToMainEntity(creator); List<CheckingDocsItemEntity> items = convertToItemEntities(itemCreators); return CheckingDocsAggregateRoot.builder() .checkingDocsMainEntity(main) .checkingDocsItemEntityList(items) .build(); } // 写侧:从提交命令创建 / 更新聚合根 public CheckingDocsAggregateRoot factoryOf(SubmitCheckingDocsCommand command) { List<CheckingDocsItemEntity> items = buildItemsFromCommand(command); NameTypeAndDate info = resolveNameTypeAndDate(command); String code = resolveCode(command); CheckingDocsMainEntity main = buildMainEntity(command, info, code); return CheckingDocsAggregateRoot.builder() .checkingDocsMainEntity(main) .checkingDocsItemEntityList(items) .build(); } //补充说明,下面这个不是真实方法,是写本文时,用来说明创建对象的复杂步骤的。真实是使用上面两个factoryOf去创建对象的 public CheckingDocsAggregateRoot create(SubmitCheckingDocsCommand cmd) { // 1)组装明细 List<CheckingItem> items = cmd.getMaterialList().stream() .map(m -> new CheckingItem(m.getId(), m.getCode(), m.getQty(), m.getUnit())) .toList(); // 2)根据模板或临时盘点决定名称、类型、盘点日期 String name = cmd.getName() != null ? cmd.getName() : "临时盘点任务"; String type = cmd.getTemplateTypeCode(); LocalDate checkingDate = resolveCheckingDate(cmd.getTemplateId()); // 3)更新沿用旧单号,新建生成新单号 String code = cmd.getDocsId() != null ? loadOldCode(cmd.getDocsId()) : generateNewCode(); // 4)构造聚合根 return new CheckingDocsAggregateRoot(code, name, type, checkingDate, items); } }这段工厂代码把“盘点单构造过程里所有会变的东西”都集中在了一个地方:
- 明细行构造 + 数量合法性校验
- 基于模板的名称 / 类型 / 盘点日期决策
- 单号生成策略(新建 vs 更新)
- 状态 / ERP 状态 / 开始结束时间处理
如果没有工厂,这些逻辑极大概率会被填进 Controller / ApplicationService / DomainService 里,最后谁都不敢碰。
调用方视角:DomainService 只关心“要不要提交”,不关心“怎么构造”
再往上看一层,领域服务CheckingDocsDomainServiceImpl.submitDocs是怎么用工厂的:
public class CheckingDocsDomainService { private final CheckingDocsFactory factory; private final CheckingDocsRepository repository; public CheckingDocsAggregateRoot submit(SubmitCheckingDocsCommand cmd) { // 领域服务更关心“能不能提交”,而不是“对象怎么 new” checkRepeatChecking(cmd); CheckingDocsAggregateRoot root = factory.create(cmd); return repository.saveOrUpdate(root); } }在这里,领域服务关注的是:
- 并发控制:用 Redisson 锁避免同一门店并发提交
- 业务约束:今天是否已经做过模板盘点
- 提交意图:是“暂存”还是“已审核”
它完全不关心“盘点单内部怎么拼起来”——那是工厂的工作。
这个分工非常干净:
- DomainService 负责“要不要创建 / 更新”“什么时候允许创建”“怎么控制并发”
- Factory 负责“怎么构造这个复杂对象”
这正好踩中了工厂模式最适合的点:当创建一个对象非常复杂时,用工厂把复杂度收进去,让调用方只关心“我要一个什么对象”,而不是“这个对象细节怎么拼”。
读侧也走工厂:读写同一口径,出问题好排查
这套工厂不仅管写入时的创建,还负责从数据库还原聚合根。
看仓储实现里的读逻辑(核心部分):
public class CheckingDocsRepository { public CheckingDocsAggregateRoot findById(Long id, boolean withItems) { // 这里的关键点是:从数据库查出的原始数据,不直接 new 聚合根 CheckingDocsCreator creator = loadMainRecord(id); List<CheckingDocsItemCreator> itemCreators = withItems ? loadItems(id) : List.of(); return factory.create(creator, itemCreators); } }这样设计有几个直接好处:
- 写入:
SubmitCheckingDocsCommand → factoryOf(command) - 读取:
PO → Creator → factoryOf(creator, items)
读写两条路径在“如何构造聚合根”这一步完全共用同一个工厂,提高了一致性:
- 新增字段时,只要工厂多处理一个字段,读写两侧立刻保持一致
- 排查某个字段错误时,只需要看工厂里那一处逻辑
- 自动化测试可以只围绕工厂写用例,覆盖读写两侧的构造流程
为什么说“创建一个对象非常复杂时,就该用工厂”?
结合这段代码,回头看那句非常朴素的话:
当创建一个对象非常复杂的时候,可以使用工厂模式。
落到盘点单这个场景,“复杂”具体体现在哪些点?
- 数据来源多样
- 命令对象:前端传参
- 模板聚合根:名称、类型、规则
- 时间辅助类:盘点日期的计算
- 单号生成服务:按业务模块生成流水号
- 历史单据:更新时沿用
code
- 分支条件多
- 临时盘点 vs 模板盘点:
- 名称来源不同
- 盘点类型不同
- 盘点日期算法不同
- 新建 vs 更新:
- 单号生成策略不同
- 状态不同:
- 是否需要写
endTime
- 是否需要写
- 临时盘点 vs 模板盘点:
- 构造阶段就需要做业务校验
- 每一行明细的数量必须 ≥ 0
- 盘点单位不能为空
- 模板必须存在且能够推导出盘点日期
如果把这些 if/else、校验、查询库、调用辅助类的逻辑都堆在 Controller 或 Service 里,最后代码会变成:
- 不敢重构
- 不敢复用
- 不敢给新人改
而工厂模式在这里做的事情很简单:
- 把所有“如何构造聚合根”的逻辑归拢到一个类里
- 调用方只传入上下文(命令 / Creator),只关心“我要一个完整的盘点单”
- 一旦字段或规则变化,只需要修改工厂,不需要满工程找 new
这是我更愿意在团队里强调的“工厂模式”:不是 UML 图上的模式,而是解决工程里“对象构造复杂度爆表”的那块砖。
这套工厂在生产上的实际收益
说几个我在生产环境里感受最明显的收益。
1. 改字段的心理负担小了
典型场景:
业务说:“盘点单要加一个字段 X,这个值要根据模板 + 门店某个配置计算出来,还得兼容老数据。”
之前的做法(没有工厂时)大概是:
- Controller 里加一段 if/else
- ApplicationService 再加一段
- Repository 读出来时再补一段
现在有工厂之后:
- 直接在
CheckingDocsFactory里接入 X 的构造逻辑 - Creator / Command 衔接好之后,读写两边自动共用这一套构造
维护成本明显下降,代码的“风险集中点”也更清晰。
2. 线上排查路径变短了
遇到“盘点单某个字段为什么不对”这种问题,我现在基本这么查:
- 看数据库里的值
- 在工厂里搜索这个字段名
- 看它是从哪一个数据源拿到的,逻辑是否走到了
如果是写入错:
- 要么是命令对象没传;
- 要么是模板数据本身就有问题;
- 要么是工厂里拼装逻辑写错。
不会再出现那种“Controller 改了一段、Service 改了一段、Repository 又补了一段”的拼图式排查。
3. 与 DDD 聚合根契合得很好
在这套盘点系统里,CheckingDocsAggregateRoot是一个标准聚合根:
- 聚合主实体 + 明细实体 + 值对象
- 包含领域行为(删除、提交、同步 ERP 成功 / 失败)
- 需要维护业务不变量(状态合法、数量合法等)
在这种情况下,“工厂 + 聚合根”的组合非常自然:
- 工厂负责从各种上下文构造出一个“合法的初始聚合根”
- 聚合根内部方法负责后续状态变更
- 仓储只关心持久化,不管内部构造细节
如果你想在自己的项目里落地类似的工厂,可以从这几步开始
我自己在团队里推工厂模式,基本按这个 Checklist 来:
- 先识别“构造足够复杂的对象”
- 构造时需要访问3个以上的上下文(命令、配置、数据库、第三方服务)
- Service 里充斥着各种
new+ if/else 去拼对象 - 经常有人问:“这个字段到底谁来赋值?”
- 把“构造阶段”的逻辑收拢进工厂
- 只处理:字段来源决策、复杂构造、构造阶段的校验
- 不处理:后续业务行为、事务控制、跨聚合协作
- 读写统一走工厂
- 写入:命令对象 → 工厂 → 聚合根 → 仓储
- 读取:PO → Creator → 工厂 → 聚合根
- 循序渐进迁移
- 先画出“这个对象现在是在哪几段代码里被拼的”
- 把这些拼装逻辑平移到工厂,调用方改成只调工厂
- 保留旧逻辑一小段时间做对比验证,再彻底删掉
这套做法我在盘点模块里反复实践过几次,没遇到过任何事故,主要原因很简单:构造逻辑集中在一个类里,测试和验证都更可控。
最后:别为“用模式而用模式”,而是为“减少痛苦而用模式”
回头看CheckingDocsFactory这段代码,它并不“花哨”,也没有玩什么高阶语法,甚至可以说是很“土”的几百行 if/else + Builder。
但正是这一层工厂,把原本散落在各处的构造逻辑收到了一个地方,让:
- 新字段有了唯一的落点
- 线上问题有了清晰的排查入口
- 读写路径有了统一的构造口径
我现在脑子里对工厂模式的印象,其实就一句话:
当你开始害怕在业务代码里写new的时候,就该给它建一个工厂了。