资讯动态

Spring Boot三层架构:Controller、Service与Mapper职责与实战指南

发布时间:2026/10/3 7:09:51 来源:尧图企业网站定制
刚接触Spring Boot的读者十有八九会对“三层架构”这个词产生疑惑一个登录注册的小功能为什么非要让请求从Controller走到Service再从Service走到Mapper绕一大圈才碰数据库直接在Controller里写SQL不是更快吗坦白说我刚工作那会儿也这么干过而且确实能跑。直到后来接手一个“大泥球”项目——几千行的ControllerSQL和业务逻辑缠在一起改一个字段要全局搜索替换单测几乎没法写我才明白三层架构不是给代码找麻烦而是在给未来的维护留后路。这篇文章我会从Spring Boot的实际代码出发把三层架构的职责划分、Spring容器如何支撑这三层、用注册功能演示三层怎么写以及常见的坑一次讲清楚。适合正在学Spring Boot、准备面试或做毕设的读者直接参考。1. 三层架构到底在拆什么先想清楚“层”的边界1.1 一次请求穿越三层的完整路线三层架构Three-Tier Architecture在Java后端世界里通常指的是表现层Controller、业务逻辑层Service、数据访问层Mapper/DAO。它是从早期JSPServlet时代就流传下来的经典分层方式到了Spring Boot时代不但没过时反而因为Spring的组件扫描和依赖注入变得更清爽。用一个注册请求来看完整路径浏览器发起POST /api/user/register携带用户名和密码。Controller层接收参数做基础校验参数非空、格式对不对然后调用Service。Service层处理真正的业务检查用户名是否被占用、密码要不要加密、调用Mapper把数据写入数据库。Mapper层只做一件事把SQL执行掉把结果映射成Java对象返回给Service。Service再把结果告诉ControllerController封装成统一的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-RSService层也能原样复用。好处二可测试性。有了独立的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注入ServiceService注入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然后给依赖字段都加上finalLombok会自动生成包含这些字段的构造方法。这个写法在Spring Boot项目里几乎成了默认姿势。3. 用一个注册功能把三层写透Controller、Service、Mapper逐层击破3.1 Controller层只做参数接收、校验和响应封装先看一个实际可用的ControllerRestController RequestMapping(/api/user) RequiredArgsConstructor public class UserController { private final UserService userService; PostMapping(/register) public ResultVoid register(RequestBody Valid RegisterDTO dto) { userService.register(dto); return Result.success(); } }这里有几个关键点RequestBody把JSON参数绑定到RegisterDTOValid触发参数校验。校验规则放在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适合复杂查询、动态SQLMapper public interface UserMapper { User selectByUsername(String username); }select idselectByUsername resultTypecom.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调用方法BB上有事务注解也会失效。正确的做法是把事务方法放在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内部。DTOController接口的入参对象字段只包含前端需要传进来的东西加上校验注解。VOController接口的出参对象字段只包含前端需要看到的东西密码、内部状态码一概不放。看一个对比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查询、分页等核心场景跑通以后你会发现三层架构不是文档里的概念而是你每天写代码的肌肉记忆。很多新手练习项目都是“springbootmybatis结合mvc框架设计”其实核心就是这三板斧。第三项目做完以后回头review一遍。看看有没有Controller里有业务逻辑、Service里出现了SQL片段、Mapper里塞了复杂业务判断这类坏味道。如果能找到一个并改掉这比多做十个功能都有价值。第四如果你的目标是面试至少要把“三层架构每一层的职责”“事务失效怎么排查”“Spring Boot自动装配大概原理”这三连问准备好这些都是面试官最常切入的角度。我在实际项目里带过不少实习生发现大家最常犯的错误不是不会写代码而是不尊重分层。总觉得功能跑通就行结果三个月后自己回来改需求看到自己写的千行Controller都会头疼。如果让我给一条最核心的建议那就是让每一层只做它该做的事说话的时候用对方听得懂的话。Spring Boot的三层架构不难难的是在赶工时依然守住这条边界。先从小项目开始练哪怕慢一点后面维护起来绝对值。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑