Skip to content

项目结构:分层架构实战

难度:中级 | 预计时间:40 分钟 | 前置:MyBatis


📖 一个 Controller 三千行

故事背景

你打开项目中唯一的 OrderController.java——3000 行。它做了:参数校验(200 行 if-else)、业务计算(折扣、优惠券、积分叠加)、数据库读写(内联 SQL)、日志记录、异常处理、甚至发邮件。改一行折扣逻辑,你不知道会影响什么——因为所有东西都揉在一起。

分层架构就是来解决这个问题的。Controller 只做路由和参数校验、Service 管业务逻辑和事务、Repository 管数据存取。每一层只做一件事,各层之间通过接口通信。这节课把你的代码从"一团面"重构为"千层饼"。

💻 代码演示

一个用户管理功能的分层结构——看看代码是怎么被"拆散"的:

java
// ===== 项目结构 =====
// com.example.demo/
// ├── controller/UserController.java   ← HTTP 层:只做路由和参数校验
// ├── service/UserService.java         ← 业务层:逻辑 + 事务边界
// ├── repository/UserMapper.java       ← 数据层:只做数据存取
// ├── domain/User.java                 ← 领域对象:零框架依赖
// └── dto/UserDTO.java                 ← 数据传输:API 对外壳

// ===== Controller: 只做路由和参数校验 =====
@RestController
@RequestMapping("/api/users")
public class UserController {
    private final UserService userService;
    public UserController(UserService userService) { this.userService = userService; }

    @PostMapping
    public ResponseEntity<UserDTO> register(@Valid @RequestBody CreateUserRequest req) {
        UserDTO created = userService.register(req);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }
}

// ===== Service: 业务逻辑 + 事务边界 =====
@Service
@Transactional
public class UserService {
    private final UserMapper userMapper;
    public UserService(UserMapper userMapper) { this.userMapper = userMapper; }

    public UserDTO register(CreateUserRequest req) {
        if (userMapper.existsByEmail(req.email())) {
            throw new BusinessException("邮箱已被注册");
        }
        User user = new User(null, req.name(), req.email());
        userMapper.insert(user);
        return UserDTO.from(user);
    }
}

// ===== Repository (MyBatis): 只做数据库操作 =====
@Mapper
interface UserMapper {
    @Insert("INSERT INTO users(name, email) VALUES(#{name}, #{email})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    void insert(User user);
}

// ===== Domain: 纯 POJO,零框架依赖 =====
public class User { Long id; String name; String email; }

// ===== DTO: API 外壳 =====
public record UserDTO(Long id, String name, String email) {
    public static UserDTO from(User u) {
        return new UserDTO(u.getId(), u.getName(), u.getEmail());
    }
}

运行输出:

现在修改折扣逻辑只需要动 UserService,修改数据库查询只需要动 UserMapper,修改 API 格式只需要动 UserDTO。改动的影响范围被"封装"在各层内部。

🎯 三个核心问题

这是什么?

分层架构把应用水平切分为:Controller(表现层,接收请求、返回响应)、Service(业务逻辑层,处理业务规则、定义事务边界)、Repository/Mapper(数据访问层,只做 CRUD)、Domain(领域对象,纯数据结构,不依赖框架)、DTO(数据传输对象,API 外壳)。依赖方向:Controller → Service → Repository,绝不反向。

为什么需要它?

分层的核心目的是控制变更的影响范围。改数据库表结构 → 只影响 Repository 和 Domain。改业务规则 → 只影响 Service。改 API 格式 → 只影响 Controller 和 DTO。每层可以独立测试。

前端类比:你把 3000 行的 App.tsx 拆成 Page → Feature → UI Component,每层职责单一。

如果没有它会怎样?

如果不分层,所有代码堆在一个类里——重构等于重写,单元测试无法进行(数据库、HTTP、业务逻辑耦合),新同事不敢改代码。分层后,新同事可以只看 Service 层理解业务逻辑,不被 SQL 和 HTTP 细节干扰。

📝 原理讲解

分层架构规范速查:

  • Controller:只做三件事——①参数校验(@Valid)②调用 Service ③封装 ResponseEntity。绝不包含业务逻辑或 SQL。

  • Service:业务逻辑唯一居所。@Transactional 定义事务边界。

  • Repository/Mapper:只做数据存取。方法名体现数据库语义(findById、insert),非业务语义(register、checkout)。

  • Domain:纯 POJO/record,不 import 任何框架注解

  • DTO:API 数据壳。Controller 返回 DTO 而非 Domain。

  • 构造器注入 > @Autowired 字段注入:依赖在编译期确定,方便测试。

🎨 生活类比

类比理解

Controller 是餐厅服务员——只负责接待客人(HTTP 请求)、检查衣着(参数校验)、传菜(调用 Service)、收钱(返回响应)。不炒菜,不买菜。 Service 是大厨——掌握所有菜谱(业务逻辑),管理出菜顺序(事务)。 Repository 是仓库管理员——只管存取食材(数据库 CRUD),大厨要什么他拿什么。 Domain 是食材——纯粹的鸡蛋、面粉、牛肉,不依赖厨房设备。 DTO 是外卖包装盒——后厨做好的菜(Domain),装进外卖盒(DTO)才能送给客人。

✏️ 动手练习

练习 1

把 MyBatis 练习代码按分层架构重组:创建 UserController、UserService、UserMapper、User(Domain)、UserDTO(record)五个类。

<details> <summary>💡 查看提示</summary>

UserDTO 用 record:record UserDTO(Long id, String name, String email) { static UserDTO from(User u) {...} }。Controller 用构造器注入 UserService。

</details>

练习 2

在 UserService.register 上加 @Transactional,故意在插入后抛异常,验证数据是否回滚。再加一个积分初始化逻辑——积分初始失败,注册也应回滚。

<details> <summary>💡 查看提示</summary>

@Transactional 默认只回滚 RuntimeException。Checked Exception 需要 @Transactional(rollbackFor = Exception.class)。

</details>

练习 3

用 Mockito 写 UserService 单元测试:Mock UserMapper,验证"邮箱重复抛异常"和"正常注册"两种场景。

<details> <summary>💡 查看提示</summary>

@Mock UserMapper mapper; @InjectMocks UserService service; when(mapper.existsByEmail(anyString())).thenReturn(true) 模拟邮箱已存在。

</details>

✅ 自检站

<details> <summary><strong>Controller、Service、Repository 各自的职责边界是什么?</strong></summary>

Controller:HTTP 路由 + 参数校验 + 响应封装。Service:业务逻辑 + 事务边界。Repository:数据存取(CRUD SQL)。如果 Controller 里写 SQL:①Controller 依赖数据库(改表要改 Controller)②无法复用查询逻辑 ③无法对业务逻辑做单元测试(和 HTTP 耦合)。

</details>

<details> <summary><strong>@Transactional 为什么放在 Service 层而不是 Controller 层?</strong></summary>

@Transactional 保证方法内数据库操作原子化——全成功或全回滚。放在 Service 层因为事务是业务概念——"注册用户"可能需要插入用户表+初始化积分表+发消息记录,三步必须原子化。Controller 不关心事务——它只处理 HTTP。

</details>

<details> <summary><strong>构造器注入比 @Autowired 字段注入好在哪里?</strong></summary>

①不可变性——依赖在构造时确定,可声明 final。②测试友好——单元测试可直接 new Service(mockMapper) 传 Mock,不需要启动 Spring。③编译期检查——缺少依赖直接编译报错,不像 @Autowired 等到运行才 NPE。

</details>