跳到主要内容
版本:Next

三层架构实战:一个完整的用户模块

三层架构不是一种「标准答案」,而是 Spring Boot 项目最常见的职责划分约定:Controller 管请求、Service 管业务、Mapper 管数据。本篇不停留在概念上,带你从建表开始,完整实现一个可运行的用户 CRUD 模块,体验每一层的代码到底长什么样。

:::info 分层不是铁律 小项目可以合并层、大项目可能加 Manager/Domain 层。三层的价值在于约定清晰、可测试、可替换,而不是层数本身。 :::

分层总览​

HTTP 请求
│
┌──▼────────────────────────────────────────┐
│ Controller 控制层 │
│ 收参 → 校验 → 调 Service → 包统一返回 │
└──┬────────────────────────────────────────┘
│ 注入 Service 接口
┌──▼────────────────────────────────────────┐
│ Service 业务层 │
│ 业务校验、DTO↔Entity 转换、事务、组装数据 │
└──┬────────────────────────────────────────┘
│ 注入 Mapper
┌──▼────────────────────────────────────────┐
│ Mapper 数据访问层 │
│ 只执行 SQL、映射结果,不含任何业务判断 │
└──┬────────────────────────────────────────┘
│
┌──▼───┐
│ MySQL │
└──────┘

各层职责红线​

层注解该做的绝不能做的
Controller@RestController参数接收、@Valid 校验、调 Service、返回 Result写 if-else 业务规则、直接注入 Mapper
Service@Service业务规则、事务、多表组装、DTO/Entity 转换出现 HttpServletRequest、@PathVariable 等 Web 元素
Mapper@Mapper单表 SQL、结果映射拼业务逻辑、调别的 Service

跨层的东西(比如要在 Service 里拿当前登录用户),正确做法是 Controller 解析后作为参数传给 Service,而不是让 Service 依赖 Web 层。

第 0 步:建表​

CREATE TABLE `user` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
`age` INT DEFAULT NULL COMMENT '年龄',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 0禁用',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

第 1 步:Entity(数据库映射)​

Entity 与表字段一一对应,是给数据库层用的,不直接暴露给前端:

package com.example.demo.entity;

import java.time.LocalDateTime;

public class User {

private Long id;
private String username;
private String email;
private Integer age;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;

// 全部 getter / setter,此处省略
}

第 2 步:DTO 与 VO(接口出入参)​

DTO(请求进来)——带校验注解:

package com.example.demo.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class UserCreateDTO {

@NotBlank(message = "用户名不能为空")
@Size(min = 2, max = 50, message = "用户名长度 2-50")
private String username;

@Email(message = "邮箱格式不正确")
private String email;

@Min(value = 0, message = "年龄不能为负")
private Integer age;

// getter / setter
}

public class UserUpdateDTO {

@Size(min = 2, max = 50)
private String username;

@Email(message = "邮箱格式不正确")
private String email;

@Min(0)
private Integer age;

private Integer status;
// getter / setter
}

VO(响应出去)——决定前端能看到什么:

package com.example.demo.vo;

import java.time.LocalDateTime;

public class UserVO {

private Long id;
private String username;
private String email;
private Integer age;
private Integer status;
private LocalDateTime createTime;
// getter / setter
}

为什么要多这一层?假设未来表加了 password、id_card 字段,若直接返回 Entity 就会泄露敏感信息;VO 让「给前端什么」成为显式决定。

请求流:JSON ──▶ UserCreateDTO ──(Service 转换)──▶ User ──▶ DB
响应流:DB ──▶ User ──(Service 转换)──▶ UserVO ──▶ JSON

第 3 步:Mapper(数据访问层)​

先用注解版写单表 SQL(复杂 SQL 和动态 SQL 的 XML 写法见整合 MyBatis):

package com.example.demo.mapper;

import com.example.demo.entity.User;
import org.apache.ibatis.annotations.*;

import java.util.List;

@Mapper
public interface UserMapper {

@Select("SELECT * FROM user WHERE id = #{id}")
User selectById(Long id);

@Select("SELECT * FROM user WHERE username = #{username}")
User selectByName(String username);

@Select("""
<script>
SELECT * FROM user
<where>
<if test="name != null and name != ''">
AND username LIKE CONCAT('%', #{name}, '%')
</if>
</where>
ORDER BY id DESC
LIMIT #{offset}, #{size}
</script>
""")
List<User> selectPage(@Param("name") String name,
@Param("offset") int offset,
@Param("size") int size);

@Select("""
<script>
SELECT COUNT(*) FROM user
<where>
<if test="name != null and name != ''">
AND username LIKE CONCAT('%', #{name}, '%')
</if>
</where>
</script>
""")
long count(@Param("name") String name);

@Insert("INSERT INTO user(username, email, age) VALUES (#{username}, #{email}, #{age})")
@Options(useGeneratedKeys = true, keyProperty = "id") // 自增主键回填到 entity.id
int insert(User user);

@Update("""
<script>
UPDATE user
<set>
<if test="username != null">username = #{username},</if>
<if test="email != null">email = #{email},</if>
<if test="age != null">age = #{age},</if>
<if test="status != null">status = #{status},</if>
</set>
WHERE id = #{id}
</script>
""")
int update(User user);

@Delete("DELETE FROM user WHERE id = #{id}")
int deleteById(Long id);
}

要点:

  • @Mapper 让 MyBatis 为接口生成代理实现并注册为 Bean。
  • @Param 给参数起名,SQL 里用 #{name} 引用;#{} 是预编译占位符,防 SQL 注入。
  • 动态 SQL(<if>、<set>、<where>)让「只更新传入的字段」这类需求不用手写字符串拼接。

第 4 步:Service(业务层)​

接口先行,实现类写逻辑:

package com.example.demo.service;

import com.example.demo.dto.UserCreateDTO;
import com.example.demo.dto.UserUpdateDTO;
import com.example.demo.vo.PageVO;
import com.example.demo.vo.UserVO;

public interface UserService {

UserVO getById(Long id);

PageVO<UserVO> list(String name, int page, int size);

UserVO create(UserCreateDTO dto);

UserVO update(Long id, UserUpdateDTO dto);

void delete(Long id);
}

完整实现——注意业务校验、转换、事务全部在这一层:

package com.example.demo.service.impl;

import com.example.demo.common.BizException;
import com.example.demo.dto.UserCreateDTO;
import com.example.demo.dto.UserUpdateDTO;
import com.example.demo.entity.User;
import com.example.demo.mapper.UserMapper;
import com.example.demo.service.UserService;
import com.example.demo.vo.PageVO;
import com.example.demo.vo.UserVO;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;

public UserServiceImpl(UserMapper userMapper) {
this.userMapper = userMapper;
}

@Override
public UserVO getById(Long id) {
return toVO(mustExist(id));
}

@Override
public PageVO<UserVO> list(String name, int page, int size) {
int offset = (page - 1) * size;
List<UserVO> records = userMapper.selectPage(name, offset, size)
.stream().map(this::toVO).toList();
long total = userMapper.count(name);
return new PageVO<>(records, total, page, size);
}

@Override
@Transactional(rollbackFor = Exception.class)
public UserVO create(UserCreateDTO dto) {
// 1. 业务校验:用户名唯一
if (userMapper.selectByName(dto.getUsername()) != null) {
throw new BizException(1002, "用户名已存在");
}
// 2. DTO → Entity
User user = new User();
BeanUtils.copyProperties(dto, user);
// 3. 落库(insert 后自增 id 已回填)
userMapper.insert(user);
return toVO(user);
}

@Override
@Transactional(rollbackFor = Exception.class)
public UserVO update(Long id, UserUpdateDTO dto) {
mustExist(id); // 先确认存在
// 改名时检查冲突
if (dto.getUsername() != null) {
User same = userMapper.selectByName(dto.getUsername());
if (same != null && !same.getId().equals(id)) {
throw new BizException(1002, "用户名已存在");
}
}
User user = new User();
BeanUtils.copyProperties(dto, user);
user.setId(id);
userMapper.update(user); // 动态 SQL:只更新非空字段
return toVO(mustExist(id));
}

@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
mustExist(id);
userMapper.deleteById(id);
}

/** 查不到就抛业务异常,供多个方法复用 */
private User mustExist(Long id) {
User user = userMapper.selectById(id);
if (user == null) {
throw new BizException(1001, "用户不存在");
}
return user;
}

/** Entity → VO */
private UserVO toVO(User user) {
UserVO vo = new UserVO();
BeanUtils.copyProperties(user, vo);
return vo;
}
}

分页返回对象:

package com.example.demo.vo;

import java.util.List;

public class PageVO<T> {

private List<T> records;
private long total;
private int page;
private int size;

public PageVO(List<T> records, long total, int page, int size) {
this.records = records;
this.total = total;
this.page = page;
this.size = size;
}
// getter
}

第 5 步:Controller(控制层)​

薄如蝉翼——只做收发:

package com.example.demo.controller;

import com.example.demo.common.Result;
import com.example.demo.dto.UserCreateDTO;
import com.example.demo.dto.UserUpdateDTO;
import com.example.demo.service.UserService;
import com.example.demo.vo.PageVO;
import com.example.demo.vo.UserVO;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
public class UserController {

private final UserService userService;

public UserController(UserService userService) {
this.userService = userService;
}

@GetMapping("/{id}")
public Result<UserVO> getById(@PathVariable Long id) {
return Result.ok(userService.getById(id));
}

@GetMapping
public Result<PageVO<UserVO>> list(@RequestParam(required = false) String name,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return Result.ok(userService.list(name, page, size));
}

@PostMapping
public Result<UserVO> create(@RequestBody @Valid UserCreateDTO dto) {
return Result.ok(userService.create(dto));
}

@PutMapping("/{id}")
public Result<UserVO> update(@PathVariable Long id,
@RequestBody @Valid UserUpdateDTO dto) {
return Result.ok(userService.update(id, dto));
}

@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable Long id) {
userService.delete(id);
return Result.ok(null);
}
}

Result 与 BizException 的定义见统一返回与异常。

第 6 步:验证接口​

# 新增
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"username":"tom","email":"tom@a.com","age":20}'

# 分页查询
curl "http://localhost:8080/users?name=tom&page=1&size=10"

# 修改
curl -X PUT http://localhost:8080/users/1 \
-H "Content-Type: application/json" \
-d '{"age":21}'

# 删除
curl -X DELETE http://localhost:8080/users/1

返回统一为:

{"code": 0, "message": "success", "data": {"id": 1, "username": "tom", ...}}

为什么事务在 Service 层​

事务的边界是「一次完整业务操作」,它经常跨多个 Mapper 调用,只能由 Service 界定:

@Transactional(rollbackFor = Exception.class)
public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountMapper.decrease(fromId, amount); // 扣款
if (amount.compareTo(LIMIT) > 0) {
throw new BizException(2001, "超过限额"); // 业务规则也在 Service
}
accountMapper.increase(toId, amount); // 入账;任一步失败整体回滚
}

三个高频坑:

  1. @Transactional 默认只对 RuntimeException 回滚,受检异常必须写 rollbackFor = Exception.class。
  2. 同类内部 this.method() 自调用不走 Spring 代理,事务注解失效。
  3. 在事务方法里 try-catch 把异常吞掉,Spring 感知不到异常,不会回滚。

常见反模式清单​

反模式后果正确做法
Controller 直接操作 Mapper业务逻辑散在接口层,无法复用和测试Controller 只调 Service
Service 依赖 HttpServletRequestService 绑死 Web 环境,无法被定时任务/MQ 复用参数在 Controller 解析后传入
直接返回 Entity敏感字段泄露、表结构耦合接口返回 VO
校验全部堆在 Controller换个入口(如 MQ 消费)校验就失效格式校验在 DTO,业务校验在 Service
Mapper 里写业务 if-else数据层臃肿,SQL 与业务纠缠Mapper 只搬数据