
后端各层是否都要有数据 Bean这个问题几乎每次代码评审都能吵起来。我见过一个 Controller 直接把 PO 返回给前端的项目也见过一个查询接口背后塞了五六个 DTO、VO、Query、Command 的重度分层项目。DTO、Command、Entity、PO 这几个词大家都认识可真到分边界的时候很多人靠的不是原则而是直觉。这篇文章想把它们的边界彻底讲清楚。先说清楚这里说的 Bean不是 Spring 容器里的 Bean而是我们代码里到处 new 的数据模型类也就是数据对象。一个后端服务从接口进来到数据库落库中间至少要经过网络协议、应用逻辑、ORM 三层每一层关心的数据都不一样。如果从头到尾只用一个对象传递短期看很省事长期看就是在给未来的自己埋坑。这篇文章适合刚工作一两年的后端开发也适合正在做架构梳理、被 Bean 数量搞到头疼的技术负责人。文章里不会有标准答案式的结论而是给一套你可以拿去用的判断方法。1. 先从一次代码评审说起一个 Bean 贯穿三层出事时有多痛1.1 一张用户表引发的连锁反应很多项目早期的代码长这样数据库里有一张user表MyBatis 或者 JPA 里有一个UserPO字段和表结构一一对应。Controller 为了省事直接把UserPO返回给前端。Service 层操作数据库也用它入参也用它。看起来一切都很美好直到某个迭代加了一个内部字段。我之前参与过一个后台管理项目user表里有个内部备注字段secret_note运营同学手动维护前台用户不应该看到。因为统一用的 PO 直接序列化前端接口有一次无意间把内部备注返回了出来。查了半天问题就出在 PO 上多了一个字段而 PO 的职责只是映射数据库列它根本不知道哪些字段能对外暴露。这个例子特别典型。PO 是持久化对象它存在的意义是让数据库表和 Java 对象之间有一个简单的映射。一旦这个对象被当成接口出入参使用数据库列的每一次变化都会直接传导到 API。今天加个字段前端就多收一个字段明天改个列名前端接口就挂了。这类问题不是你代码写得不够好而是从一开始就没有给每一层设置应有的边界。1.2 数据对象不是“类”而是每一层的“语言”我经常用一个快递的类比来解释这件事。同一个包裹在快递面单上显示的是收件人姓名、地址、电话在仓储系统里记录的是货号、批次、库位在用户心里它就是一件商品。如果非要让这三个环节用同一张单据那快递员就得查看仓库的库存字段用户也得理解物流内部编码整个系统会变得特别拧巴。后端的数据对象也是一样。Controller 这一层的“语言”是外部请求和响应关心参数有没有传全、字段返回给谁、版本兼容怎么处理。Service 这一层的“语言”是业务动作用户下单、订单支付、库存扣减它关心的是订单状态能不能流转、金额是不是正确。Repository 这一层的“语言”是数据存储表名、列名、主键、索引它关心的是怎么把对象变成 SQL再把结果集变回对象。DTO、Command、Entity、PO 这些数据 Bean本质上就是每一层各自的语言。DTO 对着接口说话Command 对着调用方说话Entity 对着业务说话PO 对着数据库说话。你不一定每一层都要建一个独立的类但必须清楚每一层说的话是不同的。如果强行用同一种语言覆盖所有场景前面说的secret_note泄露事件迟早重演。2. DTO、Command、Entity、PO 分别是什么为什么要分成四类2.1 逐一定义和使用边界先把四个最常见的对象定义清楚。POPersistent Object持久化对象。它对应数据库的一张表字段基本和表列一一对应。PO 只负责数据载体不应该写业务方法。比如Table(name order) public class OrderPO { Id private Long id; private String orderNo; private Integer status; // 0创建 1已支付 2已取消 private BigDecimal amount; private Long userId; }Entity领域实体。它表达核心业务概念不关心数据库结构。同样是订单Entity 里的status应该是业务枚举OrderStatus而不是数据库里的 Integer。Entity 里可以写业务方法比如“这个订单能不能支付”public class OrderEntity { private Long id; private String orderNo; private OrderStatus status; private BigDecimal amount; private Long userId; public boolean canPay() { return status OrderStatus.CREATED; } }DTOData Transfer Object数据传输对象。它服务于接口的输入输出。DTO 的字段由“调用方需要什么”决定跟数据库没关系。比如订单列表返回给前端时需要显示状态文案statusText和下单人姓名userName但不需要 userId。于是public class OrderDTO { private Long id; private String orderNo; private String statusText; // 待支付 private BigDecimal amount; private String userName; }Command命令对象。它专门承载外部传入的“意图”一般用于写操作的入参。Command 和 DTO 看起来像但语义不同DTO 可以双向Command 更强调“用户想让我做什么”。Command 里适合做参数校验比如创建订单时必须传用户 ID 和商品明细public class CreateOrderCommand { NotNull private Long userId; NotEmpty private ListOrderItemCommand items; }这四个类各有各的职责边界组合起来正好覆盖了一个请求的完整生命周期Command 进来Entity 处理业务PO 落库DTO 出去。2.2 为什么不能只用一个 Bean 通吃很多反对拆分 Bean 的人会说一个对象也能跑啊四个对象来回转代码量翻倍。这话在小型项目里有一定道理但只用一个对象的成本往往不是当下显现的而是等到改动的时候突然爆发。第一是安全问题。PO 或 Entity 里可能包含密码哈希、内部状态、租户 ID、审计字段这些都不该出现在前端响应里。如果直接返回等于把内部细节暴露给外部。第二是语义冲突。数据库里status往往是 Integer业务层需要的是枚举前端需要的是展示文案一个类同时承载三种形态注定要四处写 if 判断。第三是性能陷阱。JPA 里常见的懒加载代理如果直接把 PO 转 JSON序列化框架一碰关联对象就容易触发 LazyInitializationException一个本来很简单的查询就能在半夜炸掉线上。第四是演化隔离。数据库加列、分表、换字段名这些是基础设施层的变化不应该影响对外 APIDTO 在这里就是一道防火墙。这几个问题单看都不致命但组合在一起代码维护成本会指数上升。最恐怖的是改一个字段要全局搜索它的所有使用点改完还怕漏掉。为什么要有这么多 Bean不是为了好看是为了让变化被限制在局部。2.3 那是不是每层都一定要建 Bean直接回答不一定。分层建模是手段不是目的。我见过只用一个对象就把业务跑得很顺的小项目也见过四个 Bean 齐全但依然乱成一团的大项目。关键不是“有几个 Bean”而是“每个 Bean 的边界是否清晰”。判断标准可以看一句话一个字段的变化会影响多少层如果数据库字段改名Controller、Service、Repository 全都要跟着改说明耦合太深了需要拆。如果一个内部临时工具接口只给管理后台用数据库一年都不动一次那硬拆四个对象纯粹是给自己找事。实际项目里最常见的组合有三种一个 Bean 通吃、DTO PO/Entity 两个 Bean、Command DTO Entity PO 四个 Bean。这三种我会在第 6 部分详细展开这里先给出一个核心观点Bean 不是越多越好但“输入、输出、业务、存储”这四个语义你至少要在脑子里分开。哪怕代码里只有两个类你也要清楚哪些字段属于输入边界哪些字段属于输出边界。3. 如何设计边界先画字段地图再定 Bean3.1 字段地图从入参到数据库列很多人在建类的时候是随手建的想到什么字段加什么字段最后类和类之间大量重叠边界自然模糊。我的习惯是先画一张“字段地图”。以用户注册为例。外部调用方会传username、password、phone和验证码业务层要处理的是userId、username、加密后的passwordHash、phone、status数据库表里还要有created_at、updated_at响应给前端的是userId、username、phone和createdAt。把这些字段按生命周期放到一张表里字段CommandEntityPODTOusernameYYYYpasswordY转为哈希存 password_hash不返回phoneYYYYstatus-YY一般不返回createdAt-只读YY这张表一画完哪些类该有哪些字段就很清楚了。password永远不应该出现在 DTO 里createdAt在 Command 里不存在因为这是系统生成的status在 DTO 里是否出现要看产品需求而不是数据库里有就返回。画字段地图还有一个额外好处你会自然发现有些字段根本不属于任何一个对象比如验证码。验证码是一次性输入不需要存到业务表里也不应该进入 Entity最多在 Command 里承载一下。很多团队把验证码也塞进 PO然后为了不落库到处写Transient这就是边界不清导致的多余动作。3.2 依赖方向与命名规范数据 Bean 的依赖方向比数量更重要。我的经验是Controller 只依赖 Service 接口和 DTO/CommandService 内部可以接触 Entity 和 RepositoryRepository 只接触 PO。Entity 绝对不依赖 DTO 和 POPO 也绝不向上传递。一个典型的包结构长这样controller/ OrderController.java service/ OrderService.java impl/ OrderServiceImpl.java domain/ model/ OrderEntity.java dal/ entity/ OrderPO.java dto/ OrderDTO.java command/ CreateOrderCommand.java OrderQueryCommand.java注意这里有个容易踩的坑dal/entity/OrderPO和domain/model/OrderEntity长得非常像很容易让人想只留一个。但它们的演进方向完全不同。PO 跟着数据库表变Entity 跟着业务规则变。你今天在 PO 里加一个version字段做乐观锁不代表领域模型需要知道乐观锁你在 Entity 里加一个canPay()方法也不代表数据库要存这个方法。命名上我有一个朴素的建议对象名后缀直接写明语义。XxxCommand表示入参动作XxxDTO表示接口数据XxxEntity表示业务实体XxxPO表示持久化对象。不要搞XxxVO、XxxDO、XxxAO一堆别名同一个团队对同一个语义必须只有一个叫法。3.3 转换时机和转换工具边界拉好之后怎么在对象之间转换就成了另一个问题。有人喜欢在 Controller 里手动 set从 Command 到 Entity 写十几行从 Entity 到 DTO 再写十几行。这样做的后果是转换代码散落各处字段一多就看花眼。我的做法是转换收敛在应用服务层并且用显式转换器统一管理。如果你的项目用的是 JavaMapStruct 是一个非常可靠的方案。它能在编译期生成转换代码字段映射错误会直接编译失败比反射拷贝安全得多。Mapper public interface OrderConvert { OrderConvert INSTANCE Mappers.getMapper(OrderConvert.class); OrderEntity toEntity(CreateOrderCommand command); OrderPO toPO(OrderEntity entity); OrderDTO toDTO(OrderEntity entity); }在 Service 里使用时转换逻辑非常干净public OrderDTO createOrder(CreateOrderCommand command) { OrderEntity entity OrderConvert.INSTANCE.toEntity(command); entity.initStatus(); OrderPO po OrderConvert.INSTANCE.toPO(entity); orderRepository.save(po); return OrderConvert.INSTANCE.toDTO(entity); }我知道很多人会用BeanUtils.copyProperties我强烈不建议在核心链路上依赖它。原因有几点第一它按属性名反射拷贝源对象和目标对象属性名字一样但类型不同时可能静默失败第二ORM 的代理对象字段复杂很容易拷出隐藏问题第三它把转换关系藏起来了代码里搜索不到“这个字段是从哪来的”。MapStruct 或者手写静态工厂方法虽然啰嗦但每一次转换都是显式的排查问题的时候能看到完整链路。4. 实战案例从订单查询功能看 Bean 拆分前后4.1 需求描述和第一版实现用一个最常见的订单查询功能来完整过一遍。需求很简单用户登录后查询自己的订单列表支持按状态筛选分页返回订单号、订单金额、状态、下单时间。第一版实现往往是这样Controller 直接注入一个 Repository查询条件从RequestParam拼出来然后findByUserId返回 ListOrderPO直接序列化给前端。代码量很小Demo 跑起来确实很快。但马上会遇到两个问题。第一个问题是接口响应里会出现不该出现的字段比如userId、regionId、callbackUrl。这些字段前端不需要但因为是 PO全部被序列化出去了。第二个问题是需求很快变成“订单状态显示为中文文案”也就是数据库里存的是 0、1、2前端要显示“待支付”“已支付”“已取消”。如果直接返回 PO前端就得自己维护一套枚举映射后端没法控制展示逻辑。更棘手的是没过多久产品又要显示下单人的昵称。昵称存在user表订单表里只有userId。这时候 Controller 不得不先查订单 PO再循环查用户再拼一个MapLong, String。代码越来越像在织补丁而且每次改动都会碰 Controller、Service、Repository 三层。4.2 用 Command DTO Entity PO 重构重构后的结构是这样的。查询入参封装成OrderQueryCommandpublic class OrderQueryCommand { private Long userId; private Integer status; private Integer pageNo; private Integer pageSize; }注意pageNo、pageSize也是入参但它们不代表任何业务实体只是查询请求的一部分。把它们放在 Command 里Controller 的RequestParam就能干净地收敛成一个对象。响应封装成OrderDTOpublic class OrderDTO { private Long id; private String orderNo; private BigDecimal amount; private String statusText; private String userName; private LocalDateTime createdAt; }Service 内部的流程是校验 Command 里的分页参数给一个默认值。通过 Repository 查询 OrderPO 列表并查询关联的用户昵称。把 OrderPO 转换成 OrderEntity状态从 Integer 变成枚举。根据枚举生成statusText再组装进 OrderDTO。返回分页结果。这样改完之后Controller 变得非常薄它不知道数据库表长什么样只知道 Command 和 DTO。Service 负责编排Repository 负责持久化。OrderPO 加了字段只要 DTO 没加前端就不会感知数据库字段改了名字只需要改 PO 和 Repository接口完全不用动。4.3 演进过程先合并再拆分是常态我特别想提醒一点不要一上来就四个 Bean 齐飞。一个从零开始的项目业务还没成型类先拆出四份每个字段要同步维护好几处团队只会觉得这套规范是负担。更务实的路线是先用最小模型把功能跑通等出现明显拆分信号后再拆。哪些信号值得注意第一PO 里开始出现非数据库字段比如临时展示用的userName。第二同一个 PO 在三个接口里被拼装成三种不同结构Controller 到处做循环改名。第三业务规则开始往 Service 里堆Entity 慢慢变成一个只有 getter/setter 的壳。第四数据库一改字段前端接口就跟着要发版。当这些信号出现的时候说明语义已经被一个对象硬撑到极限了。这时候再按“Command 收口入参、DTO 收口出参、Entity 表达业务、PO 表达存储”拆开是顺势而为团队接受度也高。反过来如果项目很稳定几年都不怎么改那就没必要为了架构美感去拆。5. 常见问题与排查实录5.1 常见问题速查表我在代码评审和问题排查中攒了不少典型问题整理成一个速查表方便你遇到类似情况直接对照。现象可能原因处理建议接口突然多出字段PO/Entity 被直接当 DTO 使用接口统一返回 DTO敏感字段收敛到内部对象序列化报 LazyInitializationException把带 ORM 代理的 PO 直接转 JSON在事务内完成 DTO 组装或使用关联抓取同样一段参数校验写了好几遍Command 校验和 Entity/Service 校验职责重叠输入格式校验放 Command业务规则校验放 Entity/Service数据库字段改名后前端接口挂掉PO 到 DTO 没有隔离对外契约用 DTO数据库变化不上抛转换代码散落各处字段对不上手写 set 分散在多个 Controller/Service用 MapStruct 或静态工厂统一转换新增需求不知道该改哪个类对象职责划分不清先画字段地图明确字段属于哪一层5.2 踩坑细节第一个坑不要用同一个 Bean 既当入参又当返回值。创建用户和更新用户需要的参数不同创建时不需要传id更新时id必须存在用户详情响应里的phone可能是脱敏后的创建时传的phone可能是明文。一个对象硬要同时覆盖输入输出就会为了兼容某一边而加很多可有可无的字段。拆成CreateUserCommand、UpdateUserCommand、UserDTO才是干净的做法。第二个坑不要把 Repository 的查询条件透传到 Controller。很多团队喜欢定义一个OrderQuery直接给前端传参用里面包含分页pageNo/pageSize、排序字段、数据库过滤条件。这也不是不行但你要清楚OrderQueryCommand是外部语义OrderQueryPO是持久化查询条件二者可以合并前提是团队对数据库查询能力有严格约束。如果 Controller 能随意传排序字段那就可能被恶意传入大数据量limit拖垮数据库。第三个坑枚举映射想当然。数据库存的是 IntegerEntity 里用枚举DTO 里返回字符串文案这三层一定要做显式转换。我见过因为在 PO 里直接返回枚举名导致前端收到的状态从“1”变成“PAID”然后联动逻辑全部崩掉。MapStruct 里可以用ValueMapping和ValueMappings精确控制枚举转换建议花点时间写清楚。第四个坑Command 里塞了不该有的状态。Command 只应该描述“调用方希望发生什么”不应该包含“当前状态是什么”。比如更新订单状态的 Command 里如果带了status又带了expectedStatus很容易引发并发更新问题。正确的设计是 Command 表达“期望从什么状态变到什么状态”真正执行时在 Entity 里做乐观锁或状态校验。6. 我的选型经验给不同团队的三套方案6.1 方案A一个BeanPO兼Entity适用场景内部工具、原型验证、短期活动页面、纯 CRUD 管理后台。这种项目生命周期短或者使用者都是内部团队数据库和接口耦合也不是不能接受。做法直接用 PO 对接数据库也直接返回给前端敏感字段加JsonIgnore。这个方案最省代码但我会坚持两个底线不把密码等核心敏感字段暴露出去不在 PO 里写业务方法。一旦发现 PO 开始承担复杂状态流转或者接口被外部系统调用立刻切换到方案 B。6.2 方案BDTO PO/Entity 两个Bean适用场景大多数中小型业务系统、对外提供 API 的常规项目。这是我在实际项目里用得最多的一套组合。Controller 和外部交互使用 DTO/CommandService 和 Repository 之间直接用 PO 或 Entity。如果 ORM 对象和业务概念差异不大PO 可以兼任 Entity如果业务复杂则 Service 层增加 EntityPO 只在 Repository 层出现。这样做的好处是接口稳定性有了保障数据库字段变更不会直接污染外部契约同时类数量没有膨胀到让人崩溃。6.3 方案CCommand DTO Entity PO 四个Bean适用场景中大型业务系统、核心交易链路、多个调用方、领域逻辑复杂、团队规模 10 人以上。DDD 风格项目基本是这种结构。入口用 Command 接收意图出口用 DTO 定义契约中间用 Entity 承载业务规则底层用 PO 隔离存储。优点是每层可以独立演化测试时可以只构造 Entity 而不需要数据库连接领域模型也能真正“活”起来。缺点很明显类多、转换多、需要较强的架构纪律。如果团队执行不到位很容易变成四个类互相对敲反而拖慢效率。6.4 怎么判断自己该用哪种方案给你一套提问清单每次犹豫的时候就快速过一遍这个接口是否被外部系统长期依赖如果只是内部调用方案 A 或 B 足够。数据库表结构多久会变一次如果经常变必须有 DTO 做隔离。业务规则是集中在 Service 里还是分散在多个服务里规则复杂就引入 Entity。团队里新人不多且能守住边界可以上方案 C如果人员流动性大更推荐简单方案。同一个数据在几个接口里展示形式差别是否很大差别大就要用 DTO 区分视图。我个人在实际操作中的体会是数据 Bean 的拆分本质上是给“变化”买保险。你不需要为永远不会发生的变化付保费但也不能在变化已经出现时毫无准备。把 Command 当作输入契约把 DTO 当作输出契约把 Entity 当作业务核心把 PO 当作存储细节这四个语义即使在代码里没有完全分开也要在讨论方案、评审代码时始终清晰。真到需要拆的那一天顺着边界拆就好不用重构到伤筋动骨。