Skip to content

碎片化对象的成本核算:一个Java项目的改造实录

核心问题

VO、BO、DTO、DO、Entity、PO、Form、Query、Command——每一个碎片化对象,都在增加 AI 的理解成本。更致命的是:AI 不知道该创建什么。


碎片化对象带来的"AI灾难"

传统的 Java 三层架构中,一个业务实体可能对应这些碎片化对象:

缩写全称职责
Entity实体ORM 映射
VOView Object视图层展示
DTOData Transfer Object层间数据传输
BOBusiness Object业务对象
POPersistent Object持久化对象
DODomain 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 项目中做了一套改造。核心思路:用同一套标准化规则,代替碎片化的对象体系。

改造围绕三个目标:

  1. 减少文件数量——让 AI 跨文件拼接的次数降到最低
  2. 统一对象规范——让 AI 始终知道该用什么对象,不用猜
  3. 消灭转换层——让 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 方法,命名风格各异:selectXxxListqueryXxxfindXxxgetXxxInfo……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:

  • insertupdateByIddeleteById
  • selectByIdselectListselectPage

效果:

  • AI 只需要记住一套标准接口名,不用猜
  • 大部分 CRUD 甚至不需要写 SQL——AI 直接调用 BaseMapper 的方法
  • 独特的查询用 JPA 规范命名(findByXxx),规则统一

改造三:扁平化对象体系——只有三种对象

这是最核心的改造。我们定义了一套极简的规则:

三种对象定义

对象位置职责示例
Entitydomain 层数据库表映射SysUserEntity
Requestweb 层入参前端请求参数,包含分页等基础参数CreateUserRequestUserListQueryRequest
VOweb 层出参前端展示数据UserVOUserListVO

传递规则

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);
    }
}

同样可以定义 Tuple3Tuple4……按需扩展,和 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,就要想一个名字:

  • UserDeptInfoUserDetailDTOUserWithRoleBODeptUserVO……

每多一个有意义的名字,就是给 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 不需要记忆 UserDeptDTOUserDeptStatsBO 这些中间类名,只需要知道 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");
    }
}

两种模式的分界线

业务增删改查组件服务
是否有接口无(直接实现类)有(标准化接口契约)
参数形式扁平参数 / EntityRequest 入参 + 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 会因为规则不统一而产生不一致的输出。

我们的改造经验是:

  1. 去掉 Service 接口 → 文件减半,间接层消失
  2. 标准化 Mapper → AI 记住一套标准接口,不用猜
  3. 扁平化对象体系 → Entity / Request / VO 三种对象覆盖 90% 场景
  4. 元组组合 → 跨表查询用 Tuple 组合实体返回,不创建中间对象
  5. 业务与组件分离 → 业务层扁平无接口,组件层标准化接口,两类互不干扰

核心思想:给 AI 一套统一的规则,它就懂得怎么干活。不给出规则,它就随机发挥。


关联:下一章通过一个实际重构案例,看 Growth AIOS 如何从四层精简到三层。