Skip to content

Java 后端分层架构与领域对象详解

作者:Atom
字数统计:5.9k 字
阅读时长:23 分钟

在 Java 后端开发中, 一个 HTTP 请求从进入系统到返回响应, 数据会以不同的"形态"在各层之间流转: 进来时是 DTO, 处理时是 BO, 落库时是 PO/Entity, 返回时又变成 VO。初学者常常被这一堆缩写绕晕——它们到底有什么区别? 为什么不能用一个对象走天下?

本文从最基础的 POJO 概念讲起, 逐个拆解 EntityPODTOBOVODAO/Repository 的定义与边界, 再串联起 Controller → Service → Repository 的完整调用链路, 最后落到一个可运行的最小 demo。无论你是刚接触 Java 的新手, 还是想厘清"对象命名混乱"的老手, 都能在这里建立一套清晰的心智模型

一、先建立全局认知

1.1 为什么需要这么多对象

设想一个最朴素的写法: 从数据库查出来的对象, 直接返回给前端

java
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
    return userRepository.findById(id).orElseThrow();
}

这段代码能跑, 但隐藏着一系列问题:

问题后果
Userpassword 字段密码哈希直接暴露给前端
Userdeletedversion 等内部字段数据库设计细节泄露到 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 对象——不继承框架的类, 不实现框架的接口, 不被框架的注解侵入

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, 因为它继承了框架的类、被框架强绑定:

java
// 继承了 HttpServlet, 被 Servlet 框架侵入, 不是 POJO
public class UserServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) { }
}

2.2 POJO 与 JavaBean 的区别

很多人把 POJO 和 JavaBean 混为一谈, 它们有细微差别:

特征POJOJavaBean
无参构造函数不强制必须有
属性私有 + getter/setter不强制必须
实现 Serializable不强制通常要求
定位宽泛的"简单对象"概念有严格规范的组件

可以理解为: JavaBean 是一种符合特定规范的 POJO, POJO 的范围更大

2.3 Lombok: 消除 POJO 的样板代码

手写 getter/setter 非常繁琐。本项目(以及绝大多数现代 Java 项目)使用 Lombok 注解自动生成:

java
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 指代

java
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)
含敏感/内部字段passworddeletedversion 等不该外泄的字段
生命周期受 ORM 管理被持久化上下文(Persistence Context)托管

Entity 绝不能直接返回给前端

Entity 含 passworddeleted 等字段。一旦直接序列化返回, 这些字段会全部暴露。Entity 只应在 Service 与 Repository 之间流转, 跨越到 Controller 边界前必须转换为 DTO / VO

3.3 Entity 关联关系

Entity 之间可以通过注解表达表关联, 这是它区别于其他对象的重要能力:

java
@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: 接收 + 校验

java
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: 脱敏 + 裁剪

java
@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 字段命名风格

java
@Data
public class UserResponseDTO {
    private String userName;   // 驼峰命名
    private LocalDateTime createTime;
}
json
{
  "user_name": "张三",
  "create_time": "2026-06-29 17:00:00"
}

通过全局 Jackson 配置, 实现 Java 驼峰字段与 JSON 蛇形字段的自动转换:

yaml
# application.yml
spring:
  jackson:
    property-naming-strategy: SNAKE_CASE

团队规范

前后端接口字段统一使用蛇形下划线风格, 是很多团队的硬性约定。后端代码内部保持 Java 驼峰习惯, 仅在序列化边界转换, 互不干扰

五、BO: 承载业务逻辑的领域对象

5.1 概念

BO(Business Object, 业务对象) 是封装了业务逻辑和行为的对象。它区别于前面所有"贫血"对象(只有数据没有行为)的关键在于: BO 既有数据, 又有方法

举个例子, 一个"订单 BO"不只是存储订单数据, 还能计算总价、判断是否可退款、校验状态流转是否合法:

java
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, 视图对象) 是为特定页面或视图量身定制的对象。它的字段完全由"前端这个页面要展示什么"决定, 而不是由数据库或业务逻辑决定

java
@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 的核心区别

维度DTOVO
面向对象接口调用方(可能是另一个服务)具体页面/视图
设计依据接口契约UI 展示需求
数据来源通常对应单一资源可能聚合多个数据源
典型加工字段裁剪、脱敏格式化、翻译、拼接、聚合

实践建议

不要教条。在纯 API 项目中, DTO 和 VO 经常合二为一——用一个 Response DTO 同时承担两者职责完全合理。分层是为了解耦, 不是为了凑齐缩写。当某个页面的展示逻辑明显复杂、与接口契约产生分歧时, 再把 VO 独立出来

七、DAO / Repository: 数据访问的抽象

7.1 概念

前面讲的都是"数据对象"(名词), 而 DAO / Repository 是"操作接口"(动词)。它封装了对数据库的访问, 让上层无需关心 SQL 细节

术语全称出身
DAOData Access Object传统 Java EE / MyBatis 生态
Repository仓储DDD / Spring Data JPA 生态

两者职责一致, 区别在语义层次: DAO 更偏"贴着数据库的 CRUD", Repository 更偏"面向领域的集合抽象"。Spring Data JPA 中统一叫 Repository

7.2 Spring Data JPA 的 Repository

最强大的地方在于: 只需声明接口, 无需写实现, Spring 自动根据方法名生成查询:

java
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> 后, 自动获得 savefindByIdfindAlldeleteById 等方法

方法名派生查询(Query Derivation)

Spring Data 会解析方法名: findBy + 字段名 + 条件关键字(GreaterThanLikeBetweenIn...), 自动翻译成 SQL。复杂查询则用 @Query 注解手写 JPQL 或原生 SQL

7.3 MyBatis 风格的 Mapper(对比)

本项目部分场景也可能使用 MyBatis, 它的 DAO 叫 Mapper(注意: 与对象转换的 Mapper 同名但完全不同):

java
@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、返回响应。绝不写业务逻辑

java
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、管理事务、完成对象转换

java
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 之间反复转换, 手写转换代码会非常啰嗦:

java
// 手写转换: 字段多了就是灾难
UserPO po = new UserPO();
po.setUserName(dto.getUserName());
po.setAge(dto.getAge());
// ... 几十个字段

9.2 MapStruct: 编译期生成转换代码

本项目使用 MapStruct——只需定义接口, 它在编译期自动生成转换实现(零运行时反射开销):

java
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> 就是统一响应封装, 让所有接口返回一致的结构:

java
@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:

java
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 记录操作日志的切面示例:

java
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 工具类无状态的静态方法集合DateUtilsJsonUtils

枚举的典型用法:

java
@Getter
@AllArgsConstructor
public enum OrderStatus {
    CREATED(0, "待支付"),
    PAID(1, "已支付"),
    CANCELLED(2, "已取消");

    private final int code;
    private final String desc;
}

十一、最小可运行 Demo

下面用一个完整的"创建用户"功能, 把所有组件串起来。这是一个可直接运行的最小骨架

11.1 项目结构

text
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.java

11.2 五个核心文件

java
@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;
}
java
@Data
public class UserCreateDTO {
    @NotBlank(message = "用户名不能为空")
    private String userName;

    @Min(value = 0, message = "年龄不能为负")
    private Integer age;
}
java
@Data
public class UserVO {
    private Long id;
    private String userName;
    private String ageDesc; // "18 岁"
}
java
@Mapper(componentModel = "spring")
public interface UserConverter {
    UserPO dtoToPo(UserCreateDTO dto);

    @Mapping(target = "ageDesc",
             expression = "java(po.getAge() + \"\")")
    UserVO poToVo(UserPO po);
}
java
public interface UserRepository
        extends JpaRepository<UserPO, Long> {
    boolean existsByUserName(String userName);
}

11.3 Service 与 Controller

java
@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);
    }
}
java
@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 跑起来验证

sh
# 启动应用
./gradlew bootRun

# 发起请求
curl -X POST http://localhost:8080/api/user \
  -H "Content-Type: application/json" \
  -d '{"user_name": "张三", "age": 18}'

预期响应:

json
{
  "code": 0,
  "message": "success",
  "data": {
    "id": 1,
    "user_name": "张三",
    "age_desc": "18 岁"
  }
}

观察这条链路: 入参的 user_name(蛇形) → DTO 的 userName(驼峰) → PO 落库 → VO 的 ageDesc 加工成 "18 岁" → 出参 age_desc(蛇形)。每个对象都恰好只做了自己该做的事

十二、总结

12.1 对象速查表

对象全称定位是否含行为是否过框架
POJOPlain Old Java Object简单对象统称
PO / EntityPersistent Object对应数据库表是(ORM 注解)
DTOData Transfer Object跨层/跨系统传输
BOBusiness Object承载业务逻辑
VOView Object面向页面展示
DAO / RepositoryData Access Object数据访问接口(是, 操作方法)

12.2 分层职责一图流

12.3 心法

  1. 每个对象只为一个目的服务——这是分层的初心, 不要让一个类身兼数职
  2. Entity 不出 Service 层——跨越到 Controller 前必转 DTO/VO, 杜绝敏感字段泄露
  3. 依赖单向向下——Controller → Service → Repository, 永不反向
  4. 分层是手段不是目的——中小项目不必凑齐所有缩写, DTO 与 VO 合并、跳过 BO 都是合理的工程取舍
  5. 转换交给 MapStruct——编译期生成, 既安全又高效

理解了这套对象体系与协作链路, 再看任何 Java 后端项目的目录结构, 都能迅速对号入座、心中有数

延伸阅读