碎片化对象的成本核算:一个Java项目的改造实录
核心问题
VO、BO、DTO、DO、Entity、PO、Form、Query、Command——每一个碎片化对象,都在增加 AI 的理解成本。更致命的是:AI 不知道该创建什么。
碎片化对象带来的"AI灾难"
传统的 Java 三层架构中,一个业务实体可能对应这些碎片化对象:
| 缩写 | 全称 | 职责 |
|---|---|---|
| Entity | 实体 | ORM 映射 |
| VO | View Object | 视图层展示 |
| DTO | Data Transfer Object | 层间数据传输 |
| BO | Business Object | 业务对象 |
| PO | Persistent Object | 持久化对象 |
| DO | Domain Object | 领域模型 |
| Form | 表单对象 | 前端提交参数 |
| Query | 查询对象 | 条件查询 |
| Command | 命令对象 | 写操作参数 |
每种对象有自己的生命周期和转换逻辑:
前端参数 → Form → Controller → VO
→ Service 接口 → DTO → ServiceImpl → BO
→ Mapper → Entity/PO → 数据库对 AI 来说,这意味什么?
1. AI 不知道该用什么
当你告诉 AI "新增一个用户模块",它会陷入选择困难:
- Controller 的参数是放到 Form 还是 Command 还是 Req?
- Service 的返回值用 BO 还是 DTO 还是直接 Entity?
- 中间传递用 DO 还是 PO?
没有统一规则的情况下,AI 每次生成的结果都不一样——这次创建了 BO,下次创建了 DTO,再下次用了个新的 XxxReq。代码风格不统一本身就是一种技术债。
2. 转换逻辑是 Bug 高发区
Entity → VO、DTO → BO、Command → Entity……每一层转换都要求 AI 精确匹配字段。一个字段漏了、拼错了、类型不一致,代码就无法运行。
AI 调试这些转换错误的成本,远高于调试业务逻辑本身的成本——因为转换没有"业务意义",纯粹是框架要求。
3. Token 的严重浪费
以 wukong 项目中的一个典型 Mapper 文件为例:
java
package com.mifeng.wukong.business.xxx.mapper;
import com.mifeng.wukong.business.xxx.domain.MiBill;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface MiBillMapper {
List<MiBill> selectList(MiBill query);
MiBill selectById(Long id);
int insert(MiBill entity);
int update(MiBill entity);
int deleteById(Long id);
}5 行实际逻辑 + 10 行 boilerplate(package/import/注解)。boilerplate 占 60% 以上。
VO、DTO、Entity、BO 每个文件都有类似的 boilerplate。7 个文件读下来,真正有业务价值的代码不到 30%。AI 的上下文窗口是固定的,大部分被这些"框架胶水"占用了。
改造原则
面对这些问题,我们在 Java 项目中做了一套改造。核心思路:用同一套标准化规则,代替碎片化的对象体系。
改造围绕三个目标:
- 减少文件数量——让 AI 跨文件拼接的次数降到最低
- 统一对象规范——让 AI 始终知道该用什么对象,不用猜
- 消灭转换层——让 AI 不需要在无意义的映射上浪费 token
改造一:去掉 Service 接口
改造前:
IBillService.java ← 接口(声明方法)
BillServiceImpl.java ← 实现(写逻辑)每有一个 Service 功能,就要维护两个文件。AI 需要先读接口确认方法签名,再读实现理解逻辑。接口和实现之间的对应关系,本身就是一种间接成本。
改造后:
java
@Service
@RequiredArgsConstructor
public class BUserService {
// 直接写实现,没有接口
// 方法参数完全扁平
public SysUserEntity getById(Long userId) { ... }
public PageResult<SysUserEntity> listPage(String userName, String status, Integer pageNum, Integer pageSize) { ... }
public Long create(String userName, String nickName, String email, String phonenumber) { ... }
}去掉接口后:
- 文件数量减半
- AI 不需要在接口和实现之间来回跳转
- 方法签名只维护一份,不会出现接口改了实现忘了改的情况
改造二:标准化 Mapper 接口
改造前:
每个 Mapper 各自定义一套 CRUD 方法,命名风格各异:selectXxxList、queryXxx、findXxx、getXxxInfo……AI 每次都要猜测当前项目用的到底是哪种风格。
改造后:
java
@Mapper
public interface SysUserMapper extends BaseMapper<SysUserEntity> {
// 只声明非标准查询
SysUserEntity findByUserName(@Param("userName") String userName);
List<SysUserEntity> findAllByDeptId(@Param("deptId") Long deptId);
}继承 MyBatis-Plus 的 BaseMapper,获得标准 CRUD:
insert、updateById、deleteByIdselectById、selectList、selectPage
效果:
- AI 只需要记住一套标准接口名,不用猜
- 大部分 CRUD 甚至不需要写 SQL——AI 直接调用 BaseMapper 的方法
- 独特的查询用 JPA 规范命名(
findByXxx),规则统一
改造三:扁平化对象体系——只有三种对象
这是最核心的改造。我们定义了一套极简的规则:
三种对象定义
| 对象 | 位置 | 职责 | 示例 |
|---|---|---|---|
| Entity | domain 层 | 数据库表映射 | SysUserEntity |
| Request | web 层入参 | 前端请求参数,包含分页等基础参数 | CreateUserRequest、UserListQueryRequest |
| VO | web 层出参 | 前端展示数据 | UserVO、UserListVO |
传递规则
web 层(Request/VO)
↓ Request(扁平入参,基础参数继承自 BaseRequest)
↓ Service 层方法参数完全扁平
service 层
↓ Service 内部组装 Entity
↓ 调用 Mapper 插入/查询
mapper 层
↓ 返回 Entity / PageResult<Entity>核心原则:中间层全部用 Entity 传递,不再定义 BO、DTO、Command 等中间对象。
具体来说:
- 入参扁平化:Request 统一继承
BaseRequest(包含分页等基础参数),Service 方法参数完全扁平,没有包装对象 - 查询返回:所有 Mapper 返回
Entity;如果需要多表 JOIN,用 ExtMapper 返回 DTO(DTO 是 Entity 的组合视图,不是额外的碎片化对象) - 写操作:web 层的 Request 传到 Service,Service 内部将参数组装成 Entity 后调用 Mapper
- 分页:统一用
PageResult<Entity>传递 - 组合查询:需要跨表组合时,用类似元组的组合结构(下文详述)
这样设计还有一个巨大的好处——测试类非常好构建。Service 的方法参数都是基础类型或扁平对象,不需要构造复杂的 BO/DTO 链条,几行代码就能搞定一个测试用例。

改造前后对比
改造前(传统三层架构):
新增一个用户模块,需要定义:
SysUserEntity.java ← Entity
SysUserVO.java ← VO
SysUserDTO.java ← DTO
SysUserBO.java ← BO
CreateUserCommand.java ← Command
UpdateUserCommand.java ← Command
UserListQuery.java ← Query
IBillService.java ← 接口
BillServiceImpl.java ← 实现
SysUserMapper.java ← Mapper
SysUserMapper.xml ← SQL
UserVOConverter.java ← 转换器改造后(扁平化对象):
SysUserEntity.java ← Entity(MyBatis 映射)
CreateUserRequest.java ← Request(web 入参)
UserListQueryRequest.java ← Request(分页查询入参,继承 BaseRequest)
UserVO.java ← VO(web 出参)
UserListVO.java ← VO(web 出参)
BUserService.java ← Service(无接口,方法参数扁平)
SysUserMapper.java ← Mapper(继承 BaseMapper)
SysUserExtMapper.java ← ExtMapper(跨表查询)
UserVOConverter.java ← VO 转换器文件数量从 12+ 压缩到 9 个。更重要的是——AI 知道规则了:
- web 层只有 Request 和 VO
- service 层只处理 Entity
- mapper 层只有单表 BaseMapper + 必要的 ExtMapper

改造四:用元组组合代替碎片化对象
当需要跨表组合数据时(例如 User + Dept 展示在一起),传统做法是创建一个 VO 或 DTO 来聚合字段。这个做法的问题是——每个组合场景就需要一个新文件。
我们的做法是参考 C# 的元组(Tuple)思路,定义一个通用的元组工具类,一行代码组合任意数据:
java
// 通用元组类(参考 C# Tuple 设计)
public class Tuple2<T1, T2> {
private T1 item1;
private T2 item2;
public Tuple2(T1 item1, T2 item2) {
this.item1 = item1;
this.item2 = item2;
}
public T1 getItem1() { return item1; }
public T2 getItem2() { return item2; }
public static <T1, T2> Tuple2<T1, T2> of(T1 t1, T2 t2) {
return new Tuple2<>(t1, t2);
}
}同样可以定义 Tuple3、Tuple4……按需扩展,和 C# 的 Tuple<T1,T2>、Tuple<T1,T2,T3> 一模一样。
使用时不需要为每个组合创建独立类:
java
// Mapper 层跨表查询的结果——直接用 Tuple 组合实体
Tuple2<UserEntity, List<UserDept>> result = Tuple2.of(user, deptList);
// Service 层继续组合,每多一层计算就用 Tuple 扩展
Tuple3<UserEntity, List<UserDept>, DeptStats> enriched = Tuple3.of(
result.getItem1(), result.getItem2(), computedStats
);Tuple 的定位很明确:组合 Mapper 层查询结果,在 Service 层传递和扩展,最终到 Web 层转 VO。 整个过程不创建任何中间对象。
在 Mapper 中的使用:
java
@Mapper
public interface SysUserExtMapper {
// 跨表 JOIN 查询,返回值直接用 Tuple 组合实体
Tuple2<UserEntity, List<UserDept>> selectUserWithDepts(@Param("userId") Long userId);
}在 Service 层传递和扩展:
java
@Service
public class BUserService {
public Tuple3<UserEntity, List<UserDept>, DeptStats> getDetailWithStats(Long userId) {
// Mapper 返回 Tuple2<UserEntity, List<UserDept>>
Tuple2<UserEntity, List<UserDept>> base = sysUserExtMapper.selectUserWithDepts(userId);
// 继续计算——用 Tuple3 扩展新属性,不创建中间对象
DeptStats stats = computeStats(base.getItem2());
Tuple3<UserEntity, List<UserDept>, DeptStats> result = Tuple3.of(
base.getItem1(), base.getItem2(), stats
);
return result;
}
}关键区别在于:Tuple 是通用的,不需要为每个场景创建一个新类。 一个 Tuple2 类就能覆盖所有需要组合两个实体的场景。对 AI 来说,它只需要知道 Tuple2.of() 这一种写法,不需要为每个跨表查询记忆新的 DTO 类名。
最终在 Web 层转 VO:
java
// VO 转换器——唯一做字段映射的地方
public UserVO toVO(SysUserEntity entity) { ... }
public UserDetailVO toDetailVO(Tuple3<UserEntity, List<UserDept>, DeptStats> tuple) {
UserDetailVO vo = new UserDetailVO();
vo.setUserInfo(tuple.getItem1());
vo.setDeptList(tuple.getItem2());
vo.setDeptStats(tuple.getItem3());
return vo;
}少一个名字,少一次理解
Tuple 带来的一个隐藏好处是——不用为组合结果取名字了。
传统做法每多一个 DTO,就要想一个名字:
UserDeptInfo、UserDetailDTO、UserWithRoleBO、DeptUserVO……
每多一个有意义的名字,就是给 AI 多一个需要理解的概念。AI 必须记住这个类型是干什么的、有哪些字段、什么时候该用它。更要命的是——AI 会瞎命名。 没有统一规则时,AI 这次生成 UserXxxDTO,下次生成 XxxUserBo,风格完全随机。
使用 Tuple 后,组合结果没有"名字":
java
// Mapper 查询结果——Tuple2<UserEntity, List<UserDept>>
// 不需要创建 UserDeptDTO 这样的中间类
Tuple2<UserEntity, List<UserDept>> result = Tuple2.of(user, deptList);
// Service 继续扩展——Tuple3<UserEntity, List<UserDept>, DeptStats>
// 也不需要创建 UserDeptStatsDTO
Tuple3<UserEntity, List<UserDept>, DeptStats> enriched = Tuple3.of(
result.getItem1(), result.getItem2(), stats
);含义来自泛型参数本身——UserEntity 是什么、List<UserDept> 是什么——类型系统已经说清楚了。AI 不需要记忆 UserDeptDTO、UserDeptStatsBO 这些中间类名,只需要知道 getItem1 是 UserEntity、getItem2 是 Dept 列表。
引入的变量和规则越少,AI 瞎命名的空间就越小。
给 Tuple 加注释:为什么 AI 不需要读注释
和 C# 的命名元组不同,Java 的泛型元组是弱类型的——Tuple2<Long, String> 不会告诉你第一个值是 userId 还是 deptId。
我们的做法是强制在方法上写注释说明元素定义:
java
/**
* 查询用户及部门列表
* @param userId 用户ID
* @return Tuple2<item1=用户实体, item2=部门列表>
*/
public Tuple2<UserEntity, List<UserDept>> getUserWithDepts(Long userId) {
return sysUserExtMapper.selectUserWithDepts(userId);
}注释是写给人看的。但这里有一个更深层的问题:AI 需要读注释吗?
答案是——AI 不需要。
理解这一点,需要先理解 LLM 大模型在代码场景下的工作方式。
人的理解方式是语义驱动的:看到 Tuple2<Long, String> → 不知道这是什么 → 读注释 → "哦,第一个是用户ID,第二个是部门名" → 理解了。
AI 的理解方式是数据流追溯的:
AI 看到 getUserWithDepts() 返回了 Tuple2<UserEntity, List<UserDept>>
→ 回溯 Tuple2.of(user, deptList)
→ 回溯 user = sysUserMapper.selectById(...)
→ user 来自 SysUserEntity → 数据库 sys_user 表
→ 回溯 deptList = sysDeptMapper.selectList(...)
→ deptList 来自 SysDeptEntity → 数据库 sys_dept 表AI 不是通过"语义"来理解代码的。它通过注意力机制追踪变量的赋值链和调用链。只要代码结构保持直接(没有过多的间接层),AI 就能沿着数据流一路追溯到数据库字段。
AI 不需要注释解释"这是什么",它只需要代码结构中能追踪到"这个字段是从哪里来的"。
这就是为什么参数扁平化、减少间接层如此重要——它们缩短了 AI 的溯源路径。每少一层包装,AI 就少一次跨文件的注意力跳转。
这也解释了为什么注释是给人写的,不是给 AI 写的:
- 人读代码需要注释来解释 Tuple 的元素含义,因为人的工作记忆有限,不可能实时追溯每个 Tuple 的赋值来源
- **AI"读代码"**是同时扫描整个文件内容,注意力覆盖所有变量。它不需要注释来解释,因为它看到了完整的赋值路径
所以我们对 Tuple 的处理看似矛盾,实则合理:注释按最高标准写给人类看,编码规则按最短路径设计给 AI 看。
业务的增删改查 vs 组件服务
前面所有的改造都是针对业务层的增删改查逻辑——用户管理、订单查询、列表分页这类场景。
但项目中还有另一类代码:组件服务。例如消息推送、交易记账、文件存储、短信发送等。这类代码的特点是:
- 功能独立,可复用性强
- 有明确的调用契约(入参什么、出参什么)
- 通常需要独立测试,甚至可以独立成工程
对这类服务,我们保留标准的三层模式:一个 Service 接口 + 入参定义(可选)+ 出参定义(可选)。
以 wukong-component 模块为例,按子目录组织不同的组件服务:
wukong-component/
├── jpush/ ← 极光推送组件
│ └── service/
│ └── JpushService.java ← 推送服务接口
├── minio/ ← 文件存储组件
│ └── MinioService.java ← 存储服务
├── trans/ ← 交易记账组件
│ ├── service/
│ │ ├── ITransAccountService.java ← 账户服务接口
│ │ └── IBusinessTransAccountService.java ← 业务交易接口
│ └── base/
│ ├── TransRequest.java ← 入参定义
│ └── TransResult.java ← 出参定义
└── bill/ ← 账单组件
├── recorder/
│ └── BillRecorder.java ← 账单记录接口
└── dto/
├── BusinessBillCreateRequest.java
└── ChargeBillCreateRequest.java每个组件的 Service 接口定义清晰:
java
public interface IBusinessTransAccountService {
TransResult companyRecharge(long companyUserId, String outTradeNo, String remark, Money amount, Money redPacketAmount);
TransResult companyScheduling(long companyUserId, String outTradeNo, String remark, Money amount, Money chargeAmount, Money redPacketAmount, Money couponAmount);
TransResult employeeCompleteWork(long companyUserId, long employeeUserId, String outTradeNo, String remark, Money amount, Money chargeAmount, Money redPacketAmount, Money couponAmount);
// ...
}业务 Service 则组合调用这些组件服务:
java
@Service
public class BUserService {
// 组件服务注入
private final IBusinessTransAccountService transAccountService;
private final JpushService jpushService;
// 业务逻辑——组合组件服务 + 自身查询
public void processUserSettlement(Long userId) {
// 1. 查用户信息
SysUserEntity user = sysUserMapper.selectById(userId);
// 2. 调用组件服务——交易记账
TransResult transResult = transAccountService.employeeCompleteWork(
user.getCompanyId(), userId, generateTradeNo(), "结算",
calculateAmount(userId), null, null, null
);
// 3. 调用组件服务——消息推送
jpushService.sendCustomMessage(userId, "结算通知", "您的工资已结算", "text");
}
}两种模式的分界线
| 业务增删改查 | 组件服务 | |
|---|---|---|
| 是否有接口 | 无(直接实现类) | 有(标准化接口契约) |
| 参数形式 | 扁平参数 / Entity | Request 入参 + Result 出参 |
| 返回值 | Entity / Tuple | 标准化 Result 对象 |
| 复用方式 | 不直接复用(业务专用) | 跨业务复用 |
| 测试方式 | 简单,参数扁平直接构造 | 可独立测试,Mock 接口即可 |
| 典型场景 | 用户CRUD、列表查询、配置管理 | 消息推送、交易记账、文件存储 |
这样分工后,两类代码各得其所:
- 业务层追求最简路径给 AI 写——扁平参数、直接 Entity、Tuple 组合,没有多余的命名和包装
- 组件层追求标准契约给人调——清晰的接口声明、明确的入参出参,方便组合和复用
两条线互不干扰。AI 看到业务层知道该用扁平参数,看到组件层知道该定义接口——规则明确,不需要猜。
为什么统一规则对 AI 更友好
改造的核心结论是:统一规则比没有规则好。
改造前:AI 的困惑
给 AI 的指令:帮我实现一个用户查询功能
AI 的内心活动:
- 返回值用 BO?还是 DTO?还是 VO?
- 参数用 Command、Query 还是直接 Map?
- Entity 能不能直接返回给前端?
- 要不要建一个 VOConverter?因为规则不统一,AI 每次的选择都可能不同。今天生成的代码用 BO,明天生成的代码用 DTO——没有统一的规范,AI 就是随机的。
改造后:AI 的确定性
给 AI 的指令:帮我实现一个用户查询功能
AI 的内心活动:
- web 层入参 → XxxRequest(继承 BaseRequest)
- Service 层 → 直接 class,方法参数扁平
- 查询返回 → Entity(单表)或 Tuple(跨表)
- web 层出参 → VO
- 转换 → XxxVOConverter规则一旦固定,AI 就不需要做"架构决策"——它只需要按规则执行。这对 AI 来说是巨大的解脱:有限的上下文窗口,不再浪费在"用什么对象"的决策上,而是集中在真正的业务逻辑上。
改造效果对比
| 维度 | 传统三层架构 | 扁平化改造后 |
|---|---|---|
| 一个功能涉及的碎片对象 | 5-8 种 | 2-3 种(Entity + Request + VO) |
| AI 需要猜测的规则 | 多(命名/分层/转换) | 少(只有 Entity/VO/Request 三种) |
| 跨文件拼接次数 | 5-7 个文件 | 2-3 个文件 |
| 转换逻辑 | Entity↔DTO↔BO↔VO 多层 | 只有 Entity→VO 一层 |
| 每次生成的风格一致性 | 无法保证 | 完全一致 |
这不是银弹
这套改造方案不是完美的,它有适用边界:
什么时候值得做:
- 项目以 CRUD 为主,业务逻辑密度不高
- 团队规模小(1-5 人),不需要接口作为"契约"
- 大量使用 AI 辅助编码
什么时候不适用:
- 需要暴露给外部系统的 RPC 接口(接口和 DTO 仍然是必要的)
- 超大型团队(50+ 人),接口作为契约的沟通价值大于维护成本
- 已有稳定的代码生成工具,改造成本高于收益
本章小结
碎片化对象的本质是为人的认知分治服务的中间产物。在 AI 时代,多一个对象就多一层理解成本,而且 AI 会因为规则不统一而产生不一致的输出。
我们的改造经验是:
- 去掉 Service 接口 → 文件减半,间接层消失
- 标准化 Mapper → AI 记住一套标准接口,不用猜
- 扁平化对象体系 → Entity / Request / VO 三种对象覆盖 90% 场景
- 元组组合 → 跨表查询用 Tuple 组合实体返回,不创建中间对象
- 业务与组件分离 → 业务层扁平无接口,组件层标准化接口,两类互不干扰
核心思想:给 AI 一套统一的规则,它就懂得怎么干活。不给出规则,它就随机发挥。
关联:下一章通过一个实际重构案例,看 Growth AIOS 如何从四层精简到三层。