ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Service 返回 DTO 还是返回 Entity 给 Controller?跨层传对象的边界

Service 返回 DTO 还是返回 Entity 给 Controller?跨层传对象的边界 每个写分层项目的人都面对过同一个犹豫Service 查出来的 Entity要不要转成 DTO 再给 Controller不转吧总觉得“不规范”——分层教材里 Service 就不该吐数据库对象转吧一个查询接口要写 Entity、DTO、VO 三个类外加两段拷贝代码明明就一个字段都没改。更纠结的是团队里两种写法往往并存老接口直接返回 Entity新接口全套 DTOcode review 时谁也说服不了谁。这个问题的答案不在“规范”两个字里而在 Entity 穿过 Service 边界之后到底会发生什么。一、Entity 直接出去炸的不是 Service是接口契约1. 表结构一变接口跟着变Entity 是表的镜像。表加一个字段Entity 就多一个属性——这本来是数据库层的私事。但如果 Service 直接把 Entity 交给 Controller 序列化返回表结构的每一次变动都变成了接口契约的变动。前端对着接口文档确认好的字段列表随着一次数据库发版悄悄多了个deleted、version反过来某次字段重命名前端页面直接崩掉而这一切发生在没有改过一行接口代码的情况下。接口契约的价值在于稳定Entity 的宿命是跟着表变。让一个“注定要变的东西”去承担“必须保持稳定”的职责这就是根本矛盾。2. 字段泄漏无声无息Entity public class User { private Long id; private String username; private String password; // 密文 private String phone; // 待脱敏 private Integer balance; // 余额敏感 private Integer deleted; }这个 Entity 直接序列化返回密码密文、明文手机号、用户余额全部出现在响应里——没有任何一行代码写错了每一行都符合各自的规范泄漏却发生了。敏感字段的防护如果是“序列化前删掉”那防护就依赖每一次新增字段时的自觉而新增字段的人往往根本不知道这个 Entity 会被吐给前端。3. 懒加载的定时炸弹// Controller 直接返回 Entity GetMapping(/orders/{id}) public Order getOrder(PathVariable Long id) { return orderService.getById(id); // 事务已结束 }Order 里有个TableField(exist false)的关联字段或 JPA 懒加载的子集合Service 里没碰它Controller 返回时 Jackson 开始序列化——触发懒加载此时事务早已关闭LazyInitializationException直接炸在响应阶段。表现出来的现象是“接口时报错但 Service 日志全正常”排查方向一开始就是歪的。结论Entity 出 Service 的三宗罪——契约漂移、字段泄漏、序列化期爆炸——没有一宗发生在 Service 里全部在下游引爆。这就是它隐蔽的原因。二、DTO 也不能无脑加三层对象的账要算清看到这里先别急着给所有 Service 加 DTO。另一个极端同样常见CRUD 小项目里 Entity → BO → DTO → VO 四连转每个对象字段几乎一样转换代码占了 Service 的一半。业务逻辑一行没写光对象搬运就写了三百行。这笔账要算清楚每个新增的 DTO都是一笔“隔离表结构”的保险费。保费值不值看被保的东西有多贵表结构稳定、字段不敏感、内部系统消费——保险费白交Entity 透传完全够用接口对外前端、第三方、表在活跃演进、有敏感字段——不买保险赔起来就是线上事故。所以 DTO 不是“规范要求”是按风险定价的设计决策。三、判断标准这个对象会不会被“表外的人”消费把纠结翻译成一句可执行的话Service 的返回值代表一次业务结果它应该用业务的词汇说话而不是用表的词汇说话。1. 三问定边界问题一调用方需要知道表结构吗**订单详情页需要的是“订单号、金额、状态、收货地址”——业务词汇。它不需要知道订单表和地址表是分表的还是合表的、有没有 version 乐观锁列。调用方不该知道的就不要出现在返回对象上。问题二这张表半年内会变吗**核心业务表几乎一定会变——加冗余字段、加状态、拆表。会变的表出口处就该有隔离层。问题三有没有不该出网的字段**密码、余额、内部成本价、deleted 标记。有就必须有显式的出口对象做白名单。三问里有一个答“是”出口处就需要一个专门的对象。三个都答“否”透传 Entity 是干净的做法不是偷懒。2. DTO 和 VO 的分工顺便理清既然出口要新对象很多人又被 DTO/VO 绕进去。放在这个场景里其实很简单DTOService → Controller业务结果的载体字段按业务语义组织可以来自多张表VOController → 前端页面视图的载体负责展示形态——时间转字符串、状态翻译成文案、字段裁剪。小项目里两者可以合一接口一旦多端消费App 要时间戳、后台要字符串就必须拆开——App 和后台消费的是同一个 DTO 的两种视图。⚠️注意DTO 不需要和 Entity 一一对应。“订单详情 DTO”里拼着订单表、地址表、用户表的信息恰恰是它价值的证明——它重组的是业务语义不是表的形状。一比一照抄 Entity 的 DTO 只是把保费交了、保险却没买上。四、两个真实场景对号入座1. 内部管理后台的字典表维护一张sys_dict表字段就 code、name、remark 三个结构三年没动过消费方只有内部后台。Service 直接返回 Entity 列表——这里加 DTO 纯属仪式感。2. 面向前端的订单列表订单表在活跃迭代最近三个月加了两个状态字段接口被 App 和小程序同时消费表里有成本价字段。Service 出口必须是 DTO字段白名单天然挡住成本价状态字段以业务枚举形态出现表结构再怎么演进接口契约纹丝不动。两个场景没有谁更“规范”只有风险定价不同。结论跨层传对象的边界不在“第几层”这个位置上在“对象的服务对象是谁”上——为表服务的留在里层为调用方服务的送出门。五、写给落地三条团队规则对外接口前端、第三方一律走 DTO 白名单——这条没有例外敏感字段泄漏没有第二次机会内部工具类接口、稳定字典类查询允许 Entity 透传——但透传的前提是团队明确知道“这是评估后的决定”而不是“没人提过这事”DTO 字段跟着接口文档走不跟着表走——新增表字段时DTO 的更新是显式的、要过 review 的这正是隔离层存在的全部意义。最后回到那个 code review 场景老接口返回 Entity、新接口返回 DTO怎么统一不是把老的都改掉而是先把那条分界线在团队里说清楚——哪些接口必须 DTO、哪些可以透传、判断依据是什么。规则定了两种写法就不再是对立派系而是同一个决策框架下的两种结论。
返回列表