Java 后端分层架构与领域对象详解
在 Java 后端开发中, 一个 HTTP 请求从进入系统到返回响应, 数据会以不同的"形态"在各层之间流转: 进来时是 DTO, 处理时是 BO, 落库时是 PO/Entity, 返回时又变成 VO。初学者常常被这一堆缩写绕晕——它们到底有什么区别? 为什么不能用一个对象走天下?
本文从最基础的 POJO 概念讲起, 逐个拆解 Entity、PO、DTO、BO、VO、DAO/Repository 的定义与边界, 再串联起 Controller → Service → Repository 的完整调用链路, 最后落到一个可运行的最小 demo。无论你是刚接触 Java 的新手, 还是想厘清"对象命名混乱"的老手, 都能在这里建立一套清晰的心智模型
一、先建立全局认知
1.1 为什么需要这么多对象
设想一个最朴素的写法: 从数据库查出来的对象, 直接返回给前端
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}这段代码能跑, 但隐藏着一系列问题:
| 问题 | 后果 |
|---|---|
User 含 password 字段 | 密码哈希直接暴露给前端 |
User 含 deleted、version 等内部字段 | 数据库设计细节泄露到 API |
| 数据库加了个字段 | 前端响应结构无意中改变 |
| 前端想要"年龄", 但库里只存了"生日" | 没有地方放计算字段 |
根本矛盾在于: 数据库的数据模型 和 接口的数据契约 是两件不同的事, 却被强行用同一个类承担。分层对象的本质, 就是用不同的类隔离不同的关注点, 让每个对象只为一个目的服务
一句话区分
- POJO 是统称(所有简单 Java 对象的总称)
- PO / DTO / BO / VO 是 POJO 在不同场景下的"具体角色"
- Entity 通常等同于 PO(在 JPA 语境下)
- DAO / Repository 不是数据对象, 而是操作数据的接口
1.2 全景图
记住这张图的数据流向, 下面逐个拆解每种对象
二、POJO: 一切的基础
2.1 什么是 POJO
POJO(Plain Old Java Object, 普通老式 Java 对象) 是 Martin Fowler 在 2000 年提出的概念。它指的是一个普通的、不依赖任何特定框架的 Java 对象——不继承框架的类, 不实现框架的接口, 不被框架的注解侵入
// 这是一个标准的 POJO
public class User {
private Long id;
private String name;
private Integer age;
// 无参构造
public User() {}
// getter / setter
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Integer getAge() { return age; }
public void setAge(Integer age) { this.age = age; }
}POJO 的"反面教材"
下面这个类不是 POJO, 因为它继承了框架的类、被框架强绑定:
// 继承了 HttpServlet, 被 Servlet 框架侵入, 不是 POJO
public class UserServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) { }
}2.2 POJO 与 JavaBean 的区别
很多人把 POJO 和 JavaBean 混为一谈, 它们有细微差别:
| 特征 | POJO | JavaBean |
|---|---|---|
| 无参构造函数 | 不强制 | 必须有 |
| 属性私有 + getter/setter | 不强制 | 必须 |
实现 Serializable | 不强制 | 通常要求 |
| 定位 | 宽泛的"简单对象"概念 | 有严格规范的组件 |
可以理解为: JavaBean 是一种符合特定规范的 POJO, POJO 的范围更大
2.3 Lombok: 消除 POJO 的样板代码
手写 getter/setter 非常繁琐。本项目(以及绝大多数现代 Java 项目)使用 Lombok 注解自动生成:
import lombok.Data;
@Data // 自动生成 getter/setter/toString/equals/hashCode
public class User {
private Long id;
private String name;
private Integer age;
}对前端开发者的类比
@Data 之于 Java, 就像 TypeScript 的 interface + 自动属性访问。一个 @Data 注解, 省去了几十行 getter/setter 模板代码
常用 Lombok 注解速查:
| 注解 | 作用 |
|---|---|
@Data | 集合体: getter + setter + toString + equals + hashCode |
@Getter / @Setter | 单独生成读/写方法 |
@NoArgsConstructor | 生成无参构造 |
@AllArgsConstructor | 生成全参构造 |
@Builder | 生成建造者模式 API |
@Slf4j | 注入一个 log 日志对象 |
三、PO / Entity: 数据库的镜像
3.1 概念
PO(Persistent Object, 持久化对象) 是与数据库表结构一一对应的对象。一张表对应一个 PO 类, 表的每个字段对应 PO 的一个属性, 一条记录对应一个 PO 实例
在 JPA / Hibernate 生态中, PO 通常被称为 Entity(实体), 用 @Entity 注解标记。两者在多数语境下可以互换, 本文统一以 Entity 指代
import javax.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "t_user") // 映射到 t_user 表
public class UserPO {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // 主键自增
private Long id;
@Column(name = "user_name", nullable = false, length = 50)
private String userName;
@Column(name = "password") // 加密后的密码哈希
private String password;
@Column(name = "age")
private Integer age;
@Column(name = "deleted")
private Boolean deleted; // 逻辑删除标记, 纯内部字段
@Column(name = "create_time")
private LocalDateTime createTime;
@Column(name = "update_time")
private LocalDateTime updateTime;
}3.2 Entity 的关键特征
| 特征 | 说明 |
|---|---|
| 与表结构对齐 | 字段名、类型严格对应数据库列 |
| 含框架注解 | @Entity、@Table、@Column、@Id 等(已不是纯 POJO) |
| 含敏感/内部字段 | password、deleted、version 等不该外泄的字段 |
| 生命周期受 ORM 管理 | 被持久化上下文(Persistence Context)托管 |
Entity 绝不能直接返回给前端
Entity 含 password、deleted 等字段。一旦直接序列化返回, 这些字段会全部暴露。Entity 只应在 Service 与 Repository 之间流转, 跨越到 Controller 边界前必须转换为 DTO / VO
3.3 Entity 关联关系
Entity 之间可以通过注解表达表关联, 这是它区别于其他对象的重要能力:
@Entity
@Table(name = "t_order")
public class OrderPO {
@Id
@GeneratedValue
private Long id;
// 多个订单属于一个用户(多对一)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private UserPO user;
// 一个订单含多个订单项(一对多)
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItemPO> items;
}四、DTO: 跨层与跨系统的数据搬运工
4.1 概念
DTO(Data Transfer Object, 数据传输对象) 是用于在不同层之间或不同系统之间传递数据的对象。它的核心使命是定义接口契约——明确"这个接口收什么、吐什么"
DTO 通常分为两类, 一进一出:
4.2 Request DTO: 接收 + 校验
import lombok.Data;
import javax.validation.constraints.*;
import org.hibernate.validator.constraints.Length;
@Data
public class UserRegisterDTO {
@NotBlank(message = "用户名不能为空")
@Length(min = 3, max = 20, message = "用户名长度需在 3-20 之间")
private String userName;
@NotBlank(message = "密码不能为空")
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).{8,}$", message = "密码至少 8 位且含字母和数字")
private String password;
@Min(value = 0, message = "年龄不能为负")
@Max(value = 150, message = "年龄超出合理范围")
private Integer age;
}注意它只有注册需要的字段: 没有 id(由数据库生成)、没有 createTime(由后端填充)、没有 deleted(内部字段)
4.3 Response DTO: 脱敏 + 裁剪
@Data
public class UserResponseDTO {
private Long id;
private String userName;
private Integer age;
private String createTime; // 已格式化为字符串
// 注意: 没有 password! 没有 deleted!
}DTO 与 VO 的边界之争
在很多团队里, Response DTO 和 VO 的职责高度重叠, 甚至合并为一个。一般约定:
- DTO 偏向接口契约(后端对外暴露的数据结构), 与具体页面无关
- VO 偏向页面展示(为某个具体视图定制), 可能聚合多个数据源
如果你的项目是纯 RESTful API, 用 Response DTO 就够了; 如果有复杂的页面聚合需求, 才需要单独的 VO 层
4.4 字段命名风格
@Data
public class UserResponseDTO {
private String userName; // 驼峰命名
private LocalDateTime createTime;
}{
"user_name": "张三",
"create_time": "2026-06-29 17:00:00"
}通过全局 Jackson 配置, 实现 Java 驼峰字段与 JSON 蛇形字段的自动转换:
# application.yml
spring:
jackson:
property-naming-strategy: SNAKE_CASE团队规范
前后端接口字段统一使用蛇形下划线风格, 是很多团队的硬性约定。后端代码内部保持 Java 驼峰习惯, 仅在序列化边界转换, 互不干扰
五、BO: 承载业务逻辑的领域对象
5.1 概念
BO(Business Object, 业务对象) 是封装了业务逻辑和行为的对象。它区别于前面所有"贫血"对象(只有数据没有行为)的关键在于: BO 既有数据, 又有方法
举个例子, 一个"订单 BO"不只是存储订单数据, 还能计算总价、判断是否可退款、校验状态流转是否合法:
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Data
public class OrderBO {
private Long id;
private OrderStatus status;
private List<OrderItemBO> items;
private BigDecimal discountRate; // 折扣率
// ===== 业务行为(这是 BO 的灵魂) =====
/** 计算订单总价(含折扣) */
public BigDecimal calculateTotal() {
BigDecimal sum = items.stream()
.map(OrderItemBO::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return sum.multiply(discountRate);
}
/** 判断订单是否可退款 */
public boolean canRefund() {
return status == OrderStatus.PAID
&& !items.isEmpty();
}
/** 状态流转: 支付 */
public void pay() {
if (status != OrderStatus.CREATED) {
throw new IllegalStateException("只有待支付订单才能支付");
}
this.status = OrderStatus.PAID;
}
}5.2 贫血模型 vs 充血模型
这是理解 BO 必须知道的一对概念:
| 模型 | 数据 | 业务逻辑位置 | 代表 |
|---|---|---|---|
| 贫血模型 | 对象只有字段 | 全部写在 Service 里 | 传统三层架构 |
| 充血模型 | 对象既有字段又有方法 | 写在领域对象(BO)内部 | DDD 领域驱动设计 |
如何选择
- 中小型 CRUD 项目: 贫血模型足够, 不必为了 BO 而 BO, 直接 Service + Entity 即可
- 复杂业务领域(金融、电商交易、医疗): 充血模型(DDD)能让业务规则内聚, 避免 Service 膨胀成"上帝类"
本项目这类后台管理系统, 大量场景是贫血模型, 业务编排集中在 Service。只有在核心交易/计费等复杂域才考虑引入 BO
5.3 BO 与 Entity 的区别
很容易混淆 BO 和 Entity, 关键区别:
- Entity 关注怎么存(对应数据库表, 可能拆成多张表)
- BO 关注怎么用(对应一个完整业务概念, 可能聚合多个 Entity)
例如一个 OrderBO 可能聚合了 OrderPO + OrderItemPO + UserPO 三个 Entity 的数据, 形成一个完整的"订单"业务视角
六、VO: 面向展示的视图对象
6.1 概念
VO(View Object, 视图对象) 是为特定页面或视图量身定制的对象。它的字段完全由"前端这个页面要展示什么"决定, 而不是由数据库或业务逻辑决定
@Data
public class UserProfileVO {
private Long id;
private String userName;
private String ageDesc; // "28 岁"(拼接了单位)
private String avatarUrl; // 头像完整 URL(拼接了 CDN 前缀)
private String memberLevel; // "黄金会员"(枚举翻译成中文)
private Integer orderCount; // 订单数(聚合自订单表)
}注意这些字段的特点: ageDesc 是拼接的、avatarUrl 是补全的、memberLevel 是翻译的、orderCount 是跨表聚合的。这些都是纯展示需求, 不属于任何单一 Entity
6.2 VO 与 DTO 的核心区别
| 维度 | DTO | VO |
|---|---|---|
| 面向对象 | 接口调用方(可能是另一个服务) | 具体页面/视图 |
| 设计依据 | 接口契约 | UI 展示需求 |
| 数据来源 | 通常对应单一资源 | 可能聚合多个数据源 |
| 典型加工 | 字段裁剪、脱敏 | 格式化、翻译、拼接、聚合 |
实践建议
不要教条。在纯 API 项目中, DTO 和 VO 经常合二为一——用一个 Response DTO 同时承担两者职责完全合理。分层是为了解耦, 不是为了凑齐缩写。当某个页面的展示逻辑明显复杂、与接口契约产生分歧时, 再把 VO 独立出来
七、DAO / Repository: 数据访问的抽象
7.1 概念
前面讲的都是"数据对象"(名词), 而 DAO / Repository 是"操作接口"(动词)。它封装了对数据库的访问, 让上层无需关心 SQL 细节
| 术语 | 全称 | 出身 |
|---|---|---|
| DAO | Data Access Object | 传统 Java EE / MyBatis 生态 |
| Repository | 仓储 | DDD / Spring Data JPA 生态 |
两者职责一致, 区别在语义层次: DAO 更偏"贴着数据库的 CRUD", Repository 更偏"面向领域的集合抽象"。Spring Data JPA 中统一叫 Repository
7.2 Spring Data JPA 的 Repository
最强大的地方在于: 只需声明接口, 无需写实现, Spring 自动根据方法名生成查询:
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.List;
public interface UserRepository extends JpaRepository<UserPO, Long> {
// 方法名即查询: SELECT * FROM t_user WHERE user_name = ?
Optional<UserPO> findByUserName(String userName);
// SELECT EXISTS(... WHERE user_name = ?)
boolean existsByUserName(String userName);
// 多条件: WHERE age > ? AND deleted = false
List<UserPO> findByAgeGreaterThanAndDeletedFalse(Integer age);
}继承 JpaRepository<UserPO, Long> 后, 自动获得 save、findById、findAll、deleteById 等方法
方法名派生查询(Query Derivation)
Spring Data 会解析方法名: findBy + 字段名 + 条件关键字(GreaterThan、Like、Between、In...), 自动翻译成 SQL。复杂查询则用 @Query 注解手写 JPQL 或原生 SQL
7.3 MyBatis 风格的 Mapper(对比)
本项目部分场景也可能使用 MyBatis, 它的 DAO 叫 Mapper(注意: 与对象转换的 Mapper 同名但完全不同):
@Mapper
public interface UserMapper {
@Select("SELECT * FROM t_user WHERE id = #{id}")
UserPO selectById(Long id);
@Insert("INSERT INTO t_user(user_name, age) VALUES(#{userName}, #{age})")
int insert(UserPO user);
}两种 "Mapper" 别混淆
- MyBatis Mapper: 数据访问接口(等价于 DAO)
- MapStruct Mapper: 对象转换器(DTO ↔ Entity ↔ VO)
它们名字相同, 职责天差地别。本文第九章讲的是后者
八、Controller 与 Service: 把对象串起来
8.1 Controller: 表现层
Controller 是 HTTP 世界与 Java 世界的边界。职责只有三件事: 接收请求、调用 Service、返回响应。绝不写业务逻辑
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor // Lombok: 自动生成构造注入
public class UserController {
private final UserService userService;
@PostMapping("/register")
public Result<UserProfileVO> register(@Valid @RequestBody UserRegisterDTO dto) {
// @Valid 触发 DTO 校验, 不通过则抛异常(由全局异常处理器捕获)
UserProfileVO vo = userService.register(dto);
return Result.success(vo);
}
@GetMapping("/{id}")
public Result<UserProfileVO> getProfile(@PathVariable Long id) {
return Result.success(userService.getProfile(id));
}
}8.2 Service: 业务层
Service 是业务逻辑的中枢: 编排校验、调用 Repository、管理事务、完成对象转换
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final UserConverter userConverter; // 对象转换器(见第九章)
private final PasswordEncoder passwordEncoder;
@Transactional // 开启事务: 方法内任何异常都会回滚
public UserProfileVO register(UserRegisterDTO dto) {
// 1. 业务校验
if (userRepository.existsByUserName(dto.getUserName())) {
throw new BusinessException("用户名已存在");
}
// 2. DTO → Entity(借助 Converter)
UserPO po = userConverter.dtoToPo(dto);
// 3. 业务处理: 密码加密、填充默认值
po.setPassword(passwordEncoder.encode(dto.getPassword()));
po.setDeleted(false);
po.setCreateTime(LocalDateTime.now());
// 4. 持久化
UserPO saved = userRepository.save(po);
// 5. Entity → VO 返回
return userConverter.poToVo(saved);
}
public UserProfileVO getProfile(Long id) {
UserPO po = userRepository.findById(id)
.orElseThrow(() -> new BusinessException("用户不存在"));
return userConverter.poToVo(po);
}
}单向依赖原则
依赖方向永远是 Controller → Service → Repository, 上层依赖下层, 下层不感知上层。Repository 不该调用 Service, Service 不该感知 HTTP。这种单向性是分层架构可维护的根基
九、Mapper / Converter: 对象转换层
9.1 为什么需要转换层
既然对象要在 DTO → PO → VO 之间反复转换, 手写转换代码会非常啰嗦:
// 手写转换: 字段多了就是灾难
UserPO po = new UserPO();
po.setUserName(dto.getUserName());
po.setAge(dto.getAge());
// ... 几十个字段9.2 MapStruct: 编译期生成转换代码
本项目使用 MapStruct——只需定义接口, 它在编译期自动生成转换实现(零运行时反射开销):
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring") // 生成 Spring Bean, 可被注入
public interface UserConverter {
// DTO → PO(同名字段自动映射)
UserPO dtoToPo(UserRegisterDTO dto);
// PO → VO(不同名字段用 @Mapping 指定规则)
@Mapping(target = "ageDesc", expression = "java(po.getAge() + \" 岁\")")
@Mapping(target = "avatarUrl", source = "avatar", defaultValue = "")
UserProfileVO poToVo(UserPO po);
// 批量转换
List<UserProfileVO> poListToVoList(List<UserPO> list);
}MapStruct vs BeanUtils
BeanUtils.copyProperties(): 运行时反射, 性能差, 无编译期检查, 字段名不匹配时静默跳过(不报错, 值悄悄为 null)- MapStruct: 编译期生成纯 getter/setter 代码, 性能等同手写, 字段不匹配编译就报错
现代项目应优先选 MapStruct
9.3 完整转换链路
十、横切组件: 串联各层的"基础设施"
除了上述沿请求链路纵向排列的组件, 还有一类横切关注点(Cross-Cutting Concerns)组件, 它们服务于所有层
10.1 统一响应体
前面代码里的 Result<T> 就是统一响应封装, 让所有接口返回一致的结构:
@Data
public class Result<T> {
private Integer code; // 业务状态码
private String message; // 提示信息
private T data; // 业务数据(泛型)
public static <T> Result<T> success(T data) {
Result<T> r = new Result<>();
r.setCode(0);
r.setMessage("success");
r.setData(data);
return r;
}
public static <T> Result<T> fail(int code, String message) {
Result<T> r = new Result<>();
r.setCode(code);
r.setMessage(message);
return r;
}
}10.2 全局异常处理器
用 @RestControllerAdvice 集中捕获异常, 避免每个 Controller 写一堆 try-catch:
import org.springframework.web.bind.annotation.*;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 捕获业务异常
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusiness(BusinessException e) {
return Result.fail(e.getCode(), e.getMessage());
}
// 捕获参数校验异常(@Valid 不通过时抛出)
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldError().getDefaultMessage();
return Result.fail(400, msg);
}
// 兜底: 捕获所有未预期异常
@ExceptionHandler(Exception.class)
public Result<Void> handleUnknown(Exception e) {
log.error("未预期异常", e); // 打日志, 但不把堆栈细节暴露给前端
return Result.fail(500, "系统繁忙, 请稍后再试");
}
}不要吞掉异常
兜底处理器里必须记录日志(log.error), 否则异常被静默吃掉, 线上出问题无从排查。返回给前端的是友好提示, 但服务端日志要保留完整堆栈
10.3 拦截器与 AOP 切面
| 组件 | 机制 | 典型用途 |
|---|---|---|
| Interceptor 拦截器 | Spring MVC HandlerInterceptor | 登录鉴权、TraceID 注入、请求计时 |
| Filter 过滤器 | Servlet 规范 | 跨域 CORS、字符编码、XSS 过滤 |
| AOP 切面 | 动态代理 | 操作日志、缓存、限流、事务增强 |
一个用 AOP 记录操作日志的切面示例:
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.ProceedingJoinPoint;
@Aspect
@Component
@Slf4j
public class OperationLogAspect {
// 拦截所有标注了 @OperationLog 的方法
@Around("@annotation(com.example.annotation.OperationLog)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
Object result = pjp.proceed(); // 执行目标方法
long cost = System.currentTimeMillis() - start;
log.info("方法 {} 耗时 {}ms", pjp.getSignature().getName(), cost);
return result;
}
}10.4 Config / Enums / Constants / Utils
| 组件 | 作用 | 示例 |
|---|---|---|
| Config 配置类 | @Configuration 集中装配 Bean | 数据源、Redis、CORS、Security 配置 |
| Enums 枚举 | 替代魔法值, 让状态可读 | OrderStatus.PAID 而非 status == 1 |
| Constants 常量 | 集中管理不变值 | CacheKey.USER_PREFIX |
| Utils 工具类 | 无状态的静态方法集合 | DateUtils、JsonUtils |
枚举的典型用法:
@Getter
@AllArgsConstructor
public enum OrderStatus {
CREATED(0, "待支付"),
PAID(1, "已支付"),
CANCELLED(2, "已取消");
private final int code;
private final String desc;
}十一、最小可运行 Demo
下面用一个完整的"创建用户"功能, 把所有组件串起来。这是一个可直接运行的最小骨架
11.1 项目结构
src/main/java/com/example/demo/
├── DemoApplication.java # 启动类
├── controller/
│ └── UserController.java # 表现层
├── service/
│ └── UserService.java # 业务层
├── repository/
│ └── UserRepository.java # 持久层
├── entity/
│ └── UserPO.java # 实体(PO)
├── dto/
│ ├── UserCreateDTO.java # 入参 DTO
│ └── UserVO.java # 出参 VO
├── converter/
│ └── UserConverter.java # 转换层
└── common/
├── Result.java # 统一响应
└── GlobalExceptionHandler.java11.2 五个核心文件
@Data
@Entity
@Table(name = "t_user")
public class UserPO {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String userName;
private Integer age;
private LocalDateTime createTime;
}@Data
public class UserCreateDTO {
@NotBlank(message = "用户名不能为空")
private String userName;
@Min(value = 0, message = "年龄不能为负")
private Integer age;
}@Data
public class UserVO {
private Long id;
private String userName;
private String ageDesc; // "18 岁"
}@Mapper(componentModel = "spring")
public interface UserConverter {
UserPO dtoToPo(UserCreateDTO dto);
@Mapping(target = "ageDesc",
expression = "java(po.getAge() + \" 岁\")")
UserVO poToVo(UserPO po);
}public interface UserRepository
extends JpaRepository<UserPO, Long> {
boolean existsByUserName(String userName);
}11.3 Service 与 Controller
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final UserConverter userConverter;
@Transactional
public UserVO create(UserCreateDTO dto) {
if (userRepository.existsByUserName(dto.getUserName())) {
throw new BusinessException("用户名已存在");
}
UserPO po = userConverter.dtoToPo(dto);
po.setCreateTime(LocalDateTime.now());
UserPO saved = userRepository.save(po);
return userConverter.poToVo(saved);
}
}@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping
public Result<UserVO> create(
@Valid @RequestBody UserCreateDTO dto) {
return Result.success(userService.create(dto));
}
}11.4 跑起来验证
# 启动应用
./gradlew bootRun
# 发起请求
curl -X POST http://localhost:8080/api/user \
-H "Content-Type: application/json" \
-d '{"user_name": "张三", "age": 18}'预期响应:
{
"code": 0,
"message": "success",
"data": {
"id": 1,
"user_name": "张三",
"age_desc": "18 岁"
}
}观察这条链路: 入参的 user_name(蛇形) → DTO 的 userName(驼峰) → PO 落库 → VO 的 ageDesc 加工成 "18 岁" → 出参 age_desc(蛇形)。每个对象都恰好只做了自己该做的事
十二、总结
12.1 对象速查表
| 对象 | 全称 | 定位 | 是否含行为 | 是否过框架 |
|---|---|---|---|---|
| POJO | Plain Old Java Object | 简单对象统称 | 否 | 否 |
| PO / Entity | Persistent Object | 对应数据库表 | 否 | 是(ORM 注解) |
| DTO | Data Transfer Object | 跨层/跨系统传输 | 否 | 否 |
| BO | Business Object | 承载业务逻辑 | 是 | 否 |
| VO | View Object | 面向页面展示 | 否 | 否 |
| DAO / Repository | Data Access Object | 数据访问接口 | (是, 操作方法) | 是 |
12.2 分层职责一图流
12.3 心法
- 每个对象只为一个目的服务——这是分层的初心, 不要让一个类身兼数职
- Entity 不出 Service 层——跨越到 Controller 前必转 DTO/VO, 杜绝敏感字段泄露
- 依赖单向向下——
Controller → Service → Repository, 永不反向 - 分层是手段不是目的——中小项目不必凑齐所有缩写, DTO 与 VO 合并、跳过 BO 都是合理的工程取舍
- 转换交给 MapStruct——编译期生成, 既安全又高效
理解了这套对象体系与协作链路, 再看任何 Java 后端项目的目录结构, 都能迅速对号入座、心中有数
延伸阅读
- Java Spring Boot 快速上手指南 — 从零搭建 Spring Boot 项目
- 后端工程化全链路最佳实践 — 跨框架的分层架构与防腐层设计