刚接触Spring Boot的读者,十有八九会对“三层架构”这个词产生疑惑:一个登录注册的小功能,为什么非要让请求从Controller走到Service,再从Service走到Mapper,绕一大圈才碰数据库?直接在Controller里写SQL不是更快吗?坦白说,我刚工作那会儿也这么干过,而且确实能跑。直到后来接手一个“大泥球”项目——几千行的Controller,SQL和业务逻辑缠在一起,改一个字段要全局搜索替换,单测几乎没法写,我才明白三层架构不是给代码找麻烦,而是在给未来的维护留后路。这篇文章我会从Spring Boot的实际代码出发,把三层架构的职责划分、Spring容器如何支撑这三层、用注册功能演示三层怎么写,以及常见的坑一次讲清楚。适合正在学Spring Boot、准备面试或做毕设的读者直接参考。
1. 三层架构到底在拆什么?先想清楚“层”的边界
1.1 一次请求穿越三层的完整路线
三层架构(Three-Tier Architecture)在Java后端世界里通常指的是表现层(Controller)、业务逻辑层(Service)、数据访问层(Mapper/DAO)。它是从早期JSP+Servlet时代就流传下来的经典分层方式,到了Spring Boot时代不但没过时,反而因为Spring的组件扫描和依赖注入变得更清爽。
用一个注册请求来看完整路径:
- 浏览器发起POST /api/user/register,携带用户名和密码。
- Controller层接收参数,做基础校验(参数非空、格式对不对),然后调用Service。
- Service层处理真正的业务:检查用户名是否被占用、密码要不要加密、调用Mapper把数据写入数据库。
- Mapper层只做一件事:把SQL执行掉,把结果映射成Java对象返回给Service。
- Service再把结果告诉Controller,Controller封装成统一的JSON响应返回给前端。
这四步看起来繁琐,但每一层的职责很纯粹。为了直观,我写一个最简的三层骨架:
// Controller层 @RestController @RequestMapping("/api/user") public class UserController { @Resource private UserService userService; @PostMapping("/register") public Result<?> register(@RequestBody RegisterDTO dto) { userService.register(dto); return Result.success(); } }// Service层 public interface UserService { void register(RegisterDTO dto); }// Mapper层 @Mapper public interface UserMapper { int insert(User user); }这里注意一个细节:Controller只负责“接收请求、调用Service、返回响应”,它不写业务规则,也不碰SQL。Service只负责业务编排,它不关心HTTP参数长什么样,也不直接操作数据库连接。Mapper只负责数据读写,它不感知Controller存在。层与层之间通过接口和DTO传递数据,这就是“依赖倒置”在实践中的样子。
1.2 分层带来的三个直接好处(以及一个副作用)
好处一:可替换性。数据库从MySQL换到PostgreSQL,只要Mapper接口不变,ServiceImpl基本不用动;把Controller从Spring MVC换成JAX-RS,Service层也能原样复用。好处二:可测试性。有了独立的Service层,就可以不启动Web容器,直接写单元测试去验证业务逻辑。如果业务逻辑全都埋在Controller里,你得Mock HTTP请求,测试成本高得让人想放弃。好处三:可维护性。每个类的代码量可控。Controller通常只有几十行,Service控制在两三百行,Mapper跟SQL相关。出了问题去对应层找,而不是在一个千行类里大海捞针。
副作用也很明显:对于极小的项目(比如一个只有几个接口的演示项目),三层会让代码显得“重”。不过我的建议是,哪怕项目只有三个接口,也至少拆出Controller和Service两层,Mapper可以直接用Spring Data JPA或者MyBatis注解很薄的实现。三层不是银弹,但它是最低成本的防痴呆方案。哪怕是毕设项目,评审老师看到清晰的三层结构,印象分会高很多。
2. Spring Boot对三层架构的天然支撑:自动装配与依赖注入
2.1 从自动装配原理理解“为什么Spring Boot天然适合三层”
很多教程上来就讲@RestController、@Service、@Mapper,却没有解释为什么这些注解一标上,类的实例就能被互相注入。这背后是Spring Boot最重要的机制:自动装配和组件扫描。
当你启动一个Spring Boot应用时,入口类上的@SpringBootApplication是一个组合注解,等同于@Configuration + @EnableAutoConfiguration + @ComponentScan。@ComponentScan默认扫描启动类所在包及其子包,凡是标注了@Component(以及由它派生的@Controller、@Service、@Repository)的类,都会被Spring容器实例化成Bean。@MapperScan或Mapper接口上的@Mapper,则把MyBatis的Mapper接口也注册成Bean。
@EnableAutoConfiguration是Spring Boot区别于Spring MVC的杀手锏。它通过AutoConfigurationImportSelector读取classpath下的META-INF/spring.factories或者AutoConfiguration.imports文件,把项目里用到的starter对应的配置类加载进来。比如你引入了spring-boot-starter-web,自动装配就会创建DispatcherServlet、内置Tomcat等;引入了mybatis-spring-boot-starter,就会创建SqlSessionFactory、MapperScannerConfigurer等。这也是为什么你在三层架构里写代码时不需要手动new对象——容器在启动阶段已经帮你把Bean之间的依赖关系准备好了。
我在面试里经常被问到“Spring Boot自动装配原理”,其实说白了就是:启动类加载时,Spring Boot根据你pom里的依赖,自动去执行一堆预先写好的配置类。这套机制和三层架构配合起来,价值非常直接——每一层都成了容器管理的Bean,层与层之间的依赖关系用注解声明即可,不再需要自己维护对象生命周期。
2.2 包结构怎么摆:从目录上看懂三层
Spring Boot不强制规定包名,但为了清晰,我习惯用这样的结构:
com.example.demo ├── controller │ └── UserController.java ├── service │ ├── UserService.java │ └── impl │ └── UserServiceImpl.java ├── mapper │ ├── UserMapper.java │ └── UserMapper.xml ├── entity │ └── User.java ├── dto │ └── RegisterDTO.java ├── vo │ └── UserVO.java ├── common │ └── Result.java └── DemoApplication.java这里有几个细节值得注意:
第一,实体类entity和数据传输对象dto分开。entity对应数据库表结构,dto对应接口输入参数,vo对应接口返回参数。很多人图省事直接用entity接收前端参数,这在后期会引来大麻烦,第4部分会详细说。
第二,Service接口和实现类分开放。接口定义合同,实现类写细节。这么做的好处是方便Spring AOP代理和Mock测试,也符合面向接口编程的习惯。第三,common放统一返回包装、统一异常处理类。这部分不属于三层架构里的任何一层,更像是横切关注点,但放在独立包里可以让Controller代码更清爽。
看到这里你可能已经发现,Spring Boot的项目结构其实就是在告诉你怎么摆三层。启动类扫描Controller包,Controller注入Service,Service注入Mapper,依赖方向从上层指向下层,没有反向依赖,整个项目编译期就能看到清晰的层级边界。
2.3 依赖注入的三种姿势,以及我为什么推荐构造器注入
三层架构里,层与层之间通过依赖注入(DI)连接。Spring支持三种注入方式:
- 字段注入:@Autowired或@Resource直接打在成员变量上。
- Setter注入:配置一个setter方法,加上@Autowired。
- 构造器注入:在构造方法上打注解,或者直接利用Lombok的@RequiredArgsConstructor生成构造器。
代码对比如下:
// 字段注入:最简洁,但隐藏了依赖 @Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; } // 构造器注入:显式声明依赖,Spring官方推荐 @Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { private final UserMapper userMapper; }我建议新项目一律用构造器注入。原因有三个:一是构造器注入能明确看出这个类需要哪些依赖,字段注入会让依赖列表变得模糊,别人看代码要逐个扫描字段上的注解;二是构造器注入天然配合final关键字,可以保证依赖在对象创建后不可变,减少运行时替换导致的状态混乱;三是字段注入容易引起不必要的循环依赖,比如两个Service互相持有对方时,构造器注入会在启动阶段直接报错,而字段注入要等到调用才暴露,排查成本更高。
有一个额外的小技巧:如果你用了Lombok,构造器注入可以写得非常简洁——在类上标注@RequiredArgsConstructor,然后给依赖字段都加上final,Lombok会自动生成包含这些字段的构造方法。这个写法在Spring Boot项目里几乎成了默认姿势。
3. 用一个注册功能把三层写透:Controller、Service、Mapper逐层击破
3.1 Controller层:只做参数接收、校验和响应封装
先看一个实际可用的Controller:
@RestController @RequestMapping("/api/user") @RequiredArgsConstructor public class UserController { private final UserService userService; @PostMapping("/register") public Result<Void> register(@RequestBody @Valid RegisterDTO dto) { userService.register(dto); return Result.success(); } }这里有几个关键点:
@RequestBody把JSON参数绑定到RegisterDTO,@Valid触发参数校验。校验规则放在DTO字段上,比如@NotBlank(message = "用户名不能为空")、@Email、@Pattern等,Controller里就不用手写if判断了。Controller里没有出现任何业务规则。用户名重复、密码强度、加密逻辑,全都不出现在这里。如果你发现Controller里出现了“如果用户存在就提示X,否则插入”这样的代码,说明该把逻辑往Service挪了。
Controller的返回值统一用Result包装,而不是直接返回User对象或裸字符串。这样前端拿到的是固定的结构:code、message、data。接口的分页、错误信息也能保持一致。统一返回还能配合全局异常处理器@RestControllerAdvice,把业务异常翻译成合理的HTTP状态码和响应体,Controller本身可以保持极简。
很多人刚开始写三层架构,最先写的就是Controller,但Controller恰恰是最好写、最不该有“技术含量”的一层。真正需要设计的是Service层的业务编排和事务边界。不过Controller也最容易失控——因为参数校验、权限判断、日志打印、参数转换都往这里塞,等反应过来,Controller已经变成一大坨了。我的原则是:Controller里不出现任何业务关键字,只出现“接收参数、调用服务、包装返回”。
3.2 Service层:业务逻辑与事务边界的真正归属
Service层的代码会稍微多些。注册业务至少要做:校验用户名是否已存在、密码加密、写入数据库。按我的习惯,写出来是这样:
@Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; @Override @Transactional public void register(RegisterDTO dto) { // 1. 业务校验 if (userMapper.selectByUsername(dto.getUsername()) != null) { throw new BusinessException("用户名已存在"); } // 2. 组装实体 User user = new User(); user.setUsername(dto.getUsername()); user.setPassword(passwordEncoder.encode(dto.getPassword())); user.setCreatedAt(LocalDateTime.now()); // 3. 落库 userMapper.insert(user); } }到这里,三层之间的边界感就很清晰了:Service对输入参数做了业务规则的判断,决定“能不能注册”;密码加密放在Service而不是Controller,体现了数据被持久化之前的最后一道业务处理;@Transactional放在业务方法上,保证“校验+插入”要么整体成功、要么整体回滚。
关于事务,我多说一句。事务的边界应该既不能太粗也不能太细。太粗——把Controller调用Service的整个请求都包在事务里,会让数据库连接被长时间占用,高并发时连接池很容易被打满;太细——只对单条insert加事务,多步操作就会留下中间状态。放在ServiceImpl的业务方法上是最常见的折中。
如果你用Spring Security的BCryptPasswordEncoder,注入PasswordEncoder后记得在启动类里定义一个Bean,不然Service层无法自动装配。这类“少定义一个Bean就启动失败”的坑,很多时候不是三层架构的问题,而是对Spring容器管理不够熟悉。
3.3 Mapper层:MyBatis接口与SQL的两种放法
Mapper层是三层里最靠近数据库的一层。Spring Boot整合MyBatis时,有两种写法。
一种是注解SQL,适合简单场景:
@Mapper public interface UserMapper { @Select("SELECT * FROM user WHERE username = #{username}") User selectByUsername(String username); @Insert("INSERT INTO user(username, password, created_at) VALUES(#{username}, #{password}, #{createdAt})") int insert(User user); }另一种是XML,适合复杂查询、动态SQL:
@Mapper public interface UserMapper { User selectByUsername(String username); }<select id="selectByUsername" resultType="com.example.demo.entity.User"> SELECT * FROM user WHERE username = #{username} </select>两种方式都要求接口方法名和SQL的id一一对应。在XML方式下,记得在application.yml里配置mapper-locations,让Spring Boot能找到XML文件:
mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity这里有一个新手常踩的坑:接口上忘记加@Mapper注解,或者启动类上没有加@MapperScan,然后报错“Field userMapper in com.example.demo.service.impl.UserServiceImpl required a bean of type 'com.example.demo.mapper.UserMapper' that could not be found”。解决办法就是二选一:在每个Mapper接口上加@Mapper,或者在启动类上统一加@MapperScan("com.example.demo.mapper")。我习惯用@MapperScan,因为不用每次都记着加注解,而且扫描范围更可控。
XML方式还有一个容易踩的点:忘了定义namespace。MyBatis要求XML的命名空间和Mapper接口全限定类名一致,否则启动也会报错。第一次写XML时先检查namespace,能省掉很多排查时间。另外,SQL查询结果映射到实体类时,如果数据库列名是下划线风格(created_at),最好在application.yml里开启mapUnderscoreToCamelCase,否则createdAt这个字段没法自动映射到实体的createdAt属性上。
4. 三层架构里最容易踩的坑:事务失效、循环依赖与DTO风暴
4.1 事务注解放在哪一层、为什么会出现“假失效”
三层架构里最常见的问题之一,就是@Transactional放在ServiceImpl私有方法或同类内部调用时失效。看这个例子:
@Service public class UserServiceImpl implements UserService { @Override public void register(RegisterDTO dto) { this.insertUser(dto); } @Transactional(rollbackFor = Exception.class) // 注意:这是私有方法,事务不会生效 private void insertUser(RegisterDTO dto) { userMapper.insert(...); throw new RuntimeException("触发回滚"); } }为什么失效?因为Spring的声明式事务基于AOP代理。当外层register方法被调用时,进入的是代理对象;但register内部调用this.insertUser,是直接调用原始对象的方法,根本没经过代理,事务自然无法创建。同理,同类中方法A调用方法B(B上有事务注解),也会失效。
正确的做法是:把事务方法放在ServiceImpl的public方法上,并且通过代理对象调用。如果事务逻辑必须拆到多个方法,可以把这些方法放到不同的ServiceImpl类中,互相注入调用;或者利用AopContext.currentProxy()这样绕开“自调用”的写法。更稳妥的方案是,在Controller层只调用一个Service方法,事务边界就落在那个方法上,不要在Service内部玩太多自调用。
还有一个常见误区:捕获了异常却不抛出。事务回滚需要感知异常,如果你在方法内部catch住异常后吃掉,事务自然提交,数据就写到一半。要么让异常向上抛,要么在catch里调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),第二种写法很繁琐,我建议直接抛业务异常。
另外要注意,Spring事务默认只对RuntimeException和Error回滚。如果你在方法里抛了一个普通的Exception子类,比如new IOException(),事务不会回滚。这就是为什么大家在@Service方法上写@Transactional(rollbackFor = Exception.class)的原因——明确告诉Spring,任何异常都要回滚。
4.2 循环依赖的真相:为什么三层架构里也会出现
理论上Controller依赖Service、Service依赖Mapper,是单向依赖,不会循环。但实际项目里,为了复用,经常会出现Service互相调用的场景:比如UserService需要调用OrderService查询订单,OrderService又需要调用UserService查询用户信息,于是两个ServiceImpl互相注入。如果用的是构造器注入,Spring Boot 2.6以后默认禁止循环依赖,启动时直接报错。
循环依赖的本质是设计问题。三层架构虽然拆出了层次,却约束不住同层之间的横向依赖。遇到这种状况我一般先从业务上找原因:是不是有些通用能力(比如发送短信、获取当前用户)应该下沉到独立的CommonService或者工具类里?是不是订单查询用户、用户查询订单的逻辑边界没划清?如果确实需要互相调用,可以用@Lazy延迟一方加载,但这是治标不治本。最好的办法是引入一个新的服务类去协调两个Service,或者将共用逻辑抽象到底层组件里。
这里说一个真实经历:我当时在两个Service之间互相调用了用户信息和订单信息,强行用@Lazy解决了启动问题,结果上线以后因为延迟代理导致事务传播行为变得诡异,最后重构了一个UserQueryService和OrderQueryService,才把问题彻底解决。所以遇到循环依赖,别急着加@Lazy,先回头想想是不是拆分粒度过粗。
4.3 DTO、VO、Entity三分天下,别混着用
三层架构里有一个特别隐蔽的坑:实体对象被当做万能对象到处传。以注册为例,如果直接用User实体接收前端请求,那么前端传什么字段就能绑定什么字段,很容易出现接口字段和数据库字段的耦合。更严重的是,查询用户时直接把User实体返回给前端,会把password、salt这类敏感字段也暴露出去。
我的划分标准是:
- Entity:和数据库表字段一一对应,只存在于Mapper层和Service内部。
- DTO:Controller接口的入参对象,字段只包含前端需要传进来的东西,加上校验注解。
- VO:Controller接口的出参对象,字段只包含前端需要看到的东西,密码、内部状态码一概不放。
看一个对比:
public class User { // 数据库实体 private Long id; private String username; private String password; private String salt; private LocalDateTime createdAt; } public class UserVO { // 对外输出 private Long id; private String username; private LocalDateTime createdAt; }在Service里做Entity和VO的转换,不要用BeanUtils.copyProperties一把梭在Controller里做。转换逻辑集中在Service或单独的assembler类里,方便测试和复用。如果字段很多,可以引入MapStruct在编译期生成转换代码,性能比反射的BeanUtils好很多。
这里扯远一点:“优化”掉DTO会让代码看起来短,但维护成本翻倍。我曾见过一个接口因为直接返回Entity,被安全问题审计要求返工。做毕设的同学尤其注意,论文里如果有“表现层、业务层、持久层”的描述,最好在代码里真的体现DTO/VO的区别,答辩时这是一个加分项。
5. 再往前走一步:三层架构的进化方向与务实落地建议
5.1 当项目变复杂,三层架构还够用吗
三层架构不是终点。当业务复杂到一定程度,Service层会慢慢膨胀——用户注册、登录、密码重置、资料修改、权限管理等全部堆在UserServiceImpl里,几百行还算正常,上千行就开始难受了。这时候有两种常见的进化方向。
第一种是在Controller和Service之间再插入一个“应用层”或者“门面层”,负责编排多个Service,处理事务边界和跨聚合的协调。Service则更偏向业务规则,尽量保持单薄。这种分层模式和微服务里的“防腐层”思路类似,本质上还是三层的变体。第二种是向领域驱动设计(DDD)靠拢,把业务能力聚合到一起。这种设计会把原来的Service层拆成应用服务、领域服务、聚合根等,适合业务规则复杂、需要和企业级业务专家深入交互的系统。但我不建议新手一上来就学DDD,因为它对建模能力要求很高,如果连三层架构的边界都理不清,强行DDD只会得到一堆互相纠缠的“伪聚合”。
我的判断标准很简单:如果项目里Service层的代码超过2000行,且你开始频繁因为一个改动影响多个接口而头疼,再去考虑更细的分层;如果只是做毕设、做个中小型管理系统,老老实实的三层架构完全够用。开源的若依这一类的快速开发框架,本质上也是三层架构的延伸,Controller、Service、Mapper分得很清楚,可见这套东西的适用面有多广。
5.2 给新手的落地建议:从三层架构起步的最佳姿势
最后聊点实际建议。如果你现在准备用Spring Boot做项目或者准备面试,三层架构可以这样入手:
第一,写代码前先画包结构图。不用多正式,脑子里有个概念:controller只放接口,service放业务,mapper放SQL。每写一个类之前问自己:这个类属于哪一层?如果两个层都沾边,说明职责还没想清楚。
第二,通过“注册、登录、查询列表”三个功能把三层跑通。这三个功能覆盖了参数校验、事务、SQL查询、分页等核心场景,跑通以后你会发现三层架构不是文档里的概念,而是你每天写代码的肌肉记忆。很多新手练习项目都是“springboot+mybatis结合mvc框架设计”,其实核心就是这三板斧。
第三,项目做完以后回头review一遍。看看有没有Controller里有业务逻辑、Service里出现了SQL片段、Mapper里塞了复杂业务判断这类坏味道。如果能找到一个并改掉,这比多做十个功能都有价值。第四,如果你的目标是面试,至少要把“三层架构每一层的职责”“事务失效怎么排查”“Spring Boot自动装配大概原理”这三连问准备好,这些都是面试官最常切入的角度。
我在实际项目里带过不少实习生,发现大家最常犯的错误不是不会写代码,而是不尊重分层。总觉得功能跑通就行,结果三个月后自己回来改需求,看到自己写的千行Controller都会头疼。如果让我给一条最核心的建议,那就是:让每一层只做它该做的事,说话的时候用对方听得懂的话。Spring Boot的三层架构不难,难的是在赶工时依然守住这条边界。先从小项目开始练,哪怕慢一点,后面维护起来绝对值。