我在不同团队里反复看到同一种模式:一个 Spring Boot 应用最初直接从 Controller 返回 JPA Entity。在 Postman 里调试时一切正常,第一个 UI 版本也顺利上线。可随着真实需求不断涌入——隐藏字段、调整响应结构、校验输入、避免 JSON 循环引用、绕过懒加载崩溃——代码虽然还能跑,但设计已经变得相当脆弱。
这篇文章主要面向初级和中级开发者,尤其是正在构建 Angular + Spring Boot 应用的读者。先看看最简单直接的做法,再分析它为什么在真实项目中会出问题,最后介绍如何用 DTO 完成一次切实可行的改造。

为什么 Spring Boot API 必须区分 DTO 与 Entity
Entity 是持久化模型,它的职责是映射数据库,包括表结构、关系、懒加载和级联操作。DTO(Data Transfer Object,数据传输对象)则是 API 契约,负责跨越边界传递数据,例如 Controller 与客户端之间、Service 与外部集成之间,有时甚至包括 Service 与 Service 之间。
如果直接返回 Entity,就等于向每一个客户端宣告:数据库长什么样,API 就长什么样。今天这个客户端可能是 Angular,明天可能就是移动端。正是这种耦合,埋下了后续一系列麻烦的种子。
在大多数真实应用中,你总会需要自由调整以下内容:
- 数据库 schema,最好不破坏任何客户端;
- 对外暴露哪些字段,以保障安全与隐私;
- 如何加载关联数据,以控制性能;
- 如何校验数据,以保证正确性。
所以,“DTO 与 Entity”的区分,常常是代码从教程示例迈向生产级设计时,第一件需要认真对待的事。
如果想进一步了解 Spring 应用通常如何划分职责,不妨同步阅读《为什么 Spring Boot 项目中会有 Controller、Service 和 Repository》。
错误模式:直接从 Controller 返回 Entity
下面这种写法,很多人刚开始都写过:
@RestController
@RequestMapping("/api/users")
class UserController {
private final UserRepository userRepository;
UserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@GetMapping("/{id}")
User getById(@PathVariable Long id) {
return userRepository.findById(id)
.orElseThrow();
}
}
@Entity
class User {
@Id @GeneratedValue
private Long id;
private String email;
private String passwordHash;
@OneToMany(mappedBy = "user")
private List<Order> orders;
}
乍一看没什么问题,但麻烦很快就会出现。
真实项目里会暴露哪些问题
敏感数据泄露。 passwordHash 字段绝不应该离开服务器,可一旦直接返回 Entity,它就可能被序列化出去。
JSON 序列化问题。 双向关联很容易引发无限递归,或者产生体积巨大的响应体。
懒加载异常。 Controller 返回的是 User,Jackson 尝试序列化 orders;如果 Session 已经关闭,Hibernate 会直接抛出 LazyInitializationException。
API 契约在无意间改变。 重命名字段、调整关系或新增一列,都可能迫使 Angular 应用跟着改动——哪怕这些改动和 UI 的实际需求毫无关系。
这正是为什么“DTO 与 Entity”会被反复讨论。它从来不只是代码风格问题,而是在控制损害范围。
解决方案:用 DTO 定义 API 契约,把 Entity 留在内部
一种切实可行的做法,是分别为请求和响应定义 DTO。不需要堆砌几十层架构,只需要建立一条清晰的边界。
第 1 步:创建响应 DTO 与请求 DTO
让它们保持简单、明确:
// 客户端接收的内容
public record UserResponseDto(
Long id,
String email,
int orderCount
) {}
// 允许客户端发送的内容
public record CreateUserRequestDto(
@jakarta.validation.constraints.Email String email,
@jakarta.validation.constraints.Size(min = 8, max = 100) String password
) {}
注意这里缺失了什么:没有 passwordHash,也没有 List<Order>。这是刻意为之。DTO 的结构应该服务于 API,而不是数据库。
Spring 的验证支持可参考官方文档《Spring 中的 Bean Validation》。
第 2 步:在专门的位置完成 Entity 与 DTO 的映射
映射可以放在 Service 层,也可以单独写一个 Mapper 类。关键是让 Controller 保持精简。
@Component
class UserMapper {
UserResponseDto toDto(User user) {
int orderCount = user.getOrders() == null ? 0 : user.getOrders().size();
return new UserResponseDto(user.getId(), user.getEmail(), orderCount);
}
User toEntity(CreateUserRequestDto request, String passwordHash) {
User user = new User();
user.setEmail(request.email());
user.setPasswordHash(passwordHash);
return user;
}
}
手动映射算不算“额外工作”?算。但这里也是你明确表达设计决策的地方,这笔投入很值得。
第 3 步:从 Controller 返回 DTO
Controller 应该处理 HTTP 和 DTO,而不是持久化模型。这个原则同样体现在《Spring Boot Controller:应该做什么,不应该做什么》中。
@RestController
@RequestMapping("/api/users")
class UserController {
private final UserService userService;
UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
UserResponseDto getById(@PathVariable Long id) {
return userService.getUser(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
UserResponseDto create(@Valid @RequestBody CreateUserRequestDto request) {
return userService.createUser(request);
}
}
第 4 步:在 Service 中有意识地控制 Entity 加载
如果 DTO 需要派生数据,比如 orderCount,就明确地加载所需内容,而不是在无意间序列化半个数据库。
@Service
class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final UserMapper userMapper;
UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, UserMapper userMapper) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.userMapper = userMapper;
}
public UserResponseDto getUser(Long id) {
User user = userRepository.findById(id).orElseThrow();
return userMapper.toDto(user);
}
public UserResponseDto createUser(CreateUserRequestDto request) {
String hash = passwordEncoder.encode(request.password());
User user = userMapper.toEntity(request, hash);
User saved = userRepository.save(user);
return userMapper.toDto(saved);
}
}
如果读取逻辑开始变得复杂,可以考虑编写专门的 Repository 方法,只获取真正需要的数据。对于 JPA 相关实践,官方《Spring Data JPA 参考文档》值得收藏。
这样做如何终结前端的痛苦
Angular 并不需要你的 Entity。Angular 需要的是一个稳定的 JSON 结构,用于类型定义、校验和渲染。
一旦开始返回 DTO,Angular 模型就会变得可预测:
// user-response.dto.ts
export interface UserResponseDto {
id: number;
email: string;
orderCount: number;
}
// create-user-request.dto.ts
export interface CreateUserRequestDto {
email: string;
password: string;
}
Service 同样会变得清爽:
@Injectable({ providedIn: 'root' })
export class UserApi {
constructor(private http: HttpClient) {}
getUser(id: number) {
return this.http.get<UserResponseDto>(`/api/users/${id}`);
}
createUser(payload: CreateUserRequestDto) {
return this.http.post<UserResponseDto>('/api/users', payload);
}
}
如果想了解这层 HTTP 边界背后“不靠魔法”的原理,可以阅读《Angular 如何与 Spring Boot 通信:没有魔法的 HTTP》。
实际收益很直接:Angular 应用不再与后端的持久化决策绑定。你的 API 成为真正的产品,而不是 ORM 的副作用。
团队反复踩过的 DTO 坑
为所有场景使用一个“超级 DTO”
如果同一个 UserDto 同时处理创建、更新和响应,它迟早会变成什么都往里塞的大杂烩。当不同场景允许的字段不同时,就应当拆分请求 DTO 和响应 DTO。
任由 DTO 随意演变
DTO 是契约的一部分,任何修改都应该是刻意的。把 DTO 变更当作 API 变更来管理,而不是普通重构。
“只为内部客户端”返回 Entity
内部客户端迟早会变成外部客户端。也许会有第二个前端,也许会有合作伙伴接入。只要数据跨越 HTTP 边界,就应该使用 DTO。
用注解掩盖数据泄露
@JsonIgnore 有时确实有用,但它替代不了一套完整的 DTO 策略。它会把 JSON 相关问题与持久化代码耦合在一起,日后很容易漏掉某个字段。
明天就能用上的思维模型
- Entity 回答:“我该如何存储这些数据?”
- DTO 回答:“我通过网络暴露和接收什么?”
- Mapper 回答:“我如何在两者之间安全地转换?”
- Service 回答:“需要应用哪些业务规则?”
- Controller 回答:“我如何处理 HTTP?”
如果始终遵循这个模型,“DTO 与 Entity”就不再是一个需要纠结的难题。代码会保持易于修改,改起来也不再让人心惊胆战。
快速检查清单:什么时候该引入 DTO
- 你正在为 Angular 或其他客户端构建 REST API;
- 你需要隐藏字段,比如涉及安全、隐私或内部 ID 的字段;
- 数据存在一对多或多对多关系,且你希望控制响应体大小;
- 创建、更新和响应需要不同的数据结构;
- 你已经遇到过序列化问题或懒加载异常。
只要命中其中任何一条,就应该停止返回 Entity,认真定义 DTO。
如果你目前还在从 Controller 返回 Entity,不妨这周就试着改造一个端点:挑一个使用频率最高的接口,引入请求 DTO 和响应 DTO,把映射放到 Service 层,然后观察后续修改是不是变得从容很多。