
先说点实际的。我在各种项目里待过见过把几千行代码堆在Controller里的“神作”也见过Service层把操作数据库的SQL拼得飞起的案子最后都变成了自己和同事的噩梦。Spring Boot三层架构这件事听起来像刚学JavaWeb时的老生常谈但真正能把它拆明白、用利索的人真不多。大部分教程都在讲“Controller调Service、Service调Mapper”可到了实际开发遇到事务失效、N1查询、DTO乱用、接口返回格式不统一的时候才发现当初根本没理解分层到底是为了什么。这篇文章我准备把Spring Boot三层架构掰碎了讲清楚包括每一层该干嘛、不该干嘛以及我在真实项目里踩过的一些坑。适合刚接触Spring Boot的入门者也适合写了一两年CRUD但总觉得代码越写越乱的同学。你可以把它当成一份能直接照着改代码的分层实践指南。1. 三层架构的核心设计思路拆解1.1 三层架构的本质别把代码全堆在Controller里很多新手第一次接触Spring Boot时习惯把所有逻辑直接写在Controller方法里接收参数、校验参数、拼SQL、操作数据库、组装返回结果一气呵成。这样写小DEMO没问题十几个接口也能跑得动但项目一旦到了几十上百张表问题立刻爆发同样的查询逻辑被复制到多个接口里改一个字段就要全局搜索替换没人说得清某个业务规则到底写在哪测试时想单独验证一段逻辑发现只能启动整个Web容器。三层架构要解决的就是这个问题。它把代码按照“表现层、业务逻辑层、数据访问层”分开让每一层只关心自己的事。Spring Boot项目里通常对应Controller层接收HTTP请求、参数校验、调用Service、封装响应。Service层业务规则、事务控制、业务流程编排。DAO/Repository层数据库操作、SQL映射、持久化。这个分层没有Spring Boot也一样存在Spring Boot只是让分层落地更容易了。它用IoC容器把各层对象的关系管理起来你只需要定义接口和实现不需要手动new。所以在Spring Boot里做三层架构核心不是会写几个注解而是要把握住依赖方向和职责边界。1.2 Controller、Service、DAO的职责边界怎么划先说依赖方向Controller依赖ServiceService依赖DAODAO负责跟数据库打交道。依赖只能从上往下不能反过来。这个规则看着简单实际项目里经常被打破。最容易出现的变味操作是Service层把DAO的返回对象直接丢给ControllerController再把它转成JSON输出。短期没问题时间长了会发现数据库表结构稍微变一变接口返回值跟着变对前端完全不友好。正确的做法是DAO只做持久化数据的存取Service负责组装业务数据Controller负责适配HTTP语义。每个层都有自己适合的“对象形状”后面我会专门讲DTO/VO/Entity的划分。还有一个常见争议参数校验到底放哪层我的做法是基本的空值、格式校验放在Controller层用Spring Validation做复杂业务校验比如“请假天数不能超过剩余年假”放在Service层。原因是HTTP入口处的校验是为了快速失败保护业务层不被脏数据打扰而业务校验是流程的一部分必须在Service层执行因为它们往往依赖数据库状态和其他业务条件。1.3 Spring Boot对三层架构的天然支撑IoC与约定优于配置Spring Boot能在三层架构里扮演“粘合剂”的角色靠的是两大机制依赖注入和自动配置。依赖注入让各层之间不需要自己管理创建关系。你只管在需要的类上加Autowired或构造器注入Spring容器会帮你把对象组装好。这样分层之后每一层的代码都变成“可独立替换的零件”。比如你从MyBatis换到JPA理论上只需要改DAO层Controller和Service不用动这就是分层的价值。自动配置则让三层架构的接入成本几乎降到零。spring-boot-starter-web会把Spring MVC、Tomcat、Jackson等组件配好spring-boot-starter-data-jpa或MyBatis的相关包会帮你自动创建数据库连接池、会话工厂。你不用像以前Spring时代那样写一堆XML。这些都是Spring Boot对架构规范的基础支撑但并不代表你只要把依赖加进来分层就自动正确了。真正的架构质量仍然取决于代码组织。2. 分层开发中的核心细节与实操要点2.1 Controller层参数接收、校验与统一响应Controller层的职责是HTTP适配核心工作有三块参数接收、参数校验、返回统一格式。参数接收这块我建议尽量使用DTO类不要用Map或一个个RequestParam散参数。用DTO的好处是集中管理字段校验注解可以直接写在字段上。比如RestController RequestMapping(/api/leave) public class LeaveController { PostMapping public ApiResponseLong create(Valid RequestBody LeaveCreateDTO dto) { Long leaveId leaveService.create(dto); return ApiResponse.success(leaveId); } }LeaveCreateDTO里可以用NotBlank、NotNull等做基础校验。很多人会把校验逻辑写成一大堆if代码又臭又长。Spring Validation这件事只需要在DTO字段上声明Controller方法参数加Valid就行。统一响应格式也很重要。我经常看到同一个项目里有的接口返回{code:0,data:{}}有的返回{ status: true, result: [...] }前端联调时得挨个适配。规范的做法是定义统一的ApiResponseT包含状态码、消息、数据三个字段。Controller里不要自己拼新的结构直接返回ApiResponse包装后的结果。Controller层还需要尽量保持“薄”。如果Controller方法里出现超过三行逻辑或者开始写if (a.equals(b))之类的业务判断就要警惕了。那不是Controller该管的事把它下放到Service层。2.2 Service层业务核心、事务边界与防腐逻辑Service层是三层的核心也是业务复杂度最集中的地方。它做三件事实现业务规则、控制事务边界、协调DAO和其他外部服务。业务规则最典型的是各种判断。比如请假审批普通员工请假超过3天需要经理审批超过7天需要总监审批。这种规则不能写在Controller里也不能散落在SQL里应该集中在Service方法中。这样才能保证所有入口都能用到同一套规则否则你就在多个Controller里复制规则改一次漏一处。事务边界通常用Transactional控制。我见过不少同学喜欢在Controller上加事务这是不对的。事务应该尽量放在Service层的方法级别。一个Service方法就是一个业务单元方法内操作多个表时要么全成功要么全回滚。把事务放在Controller层会导致事务范围过大、连接持有时间过长性能下降而且还会把HTTP响应过程也纳入事务没必要。Service层还有个容易被忽视的作用防腐。什么意思如果项目里集成了Redis、消息队列、外部API不要把底层的调用细节传到Controller或DAO里。Service层可以作为中间层把外部依赖封装成领域行为。比如“发送审批通知”这个动作Service层只关心调用notificationService.send(...)具体走短信还是站内信Controller和DAO都不关心。2.3 DAO层MyBatis还是JPA选择与常见坑DAO层的核心任务是数据持久化。Spring Boot里最长见的两种方案是Spring Data JPA和MyBatis还有MyBatis-Plus这类增强库。选择上没有绝对标准完全看项目场景。JPA更擅长快速开发和对象关系映射适合业务清晰的领域模型MyBatis更适合复杂SQL和数据库优化因为SQL是显式写的可控性强。国内企业项目里MyBatis系用得更多因为它直白也好Debug。如果你正在做毕设或者个人项目选Spring Data JPA能少写很多代码如果你在公司里接手老项目大概率是MyBatis。DAO层的常见坑是“查询逻辑泄漏到Service层”。比如有人为了省事直接在Service里创建QueryWrapper或者拼接SQL片段导致SQL散落在业务代码里。这不是说不能灵活使用而是应该把SQL封装到DAO层对应的方法里Service只调用方法名绝不写表字段、写SQL片段。这样才能保证数据访问逻辑只有一个出口。另外注意DAO层方法命名要体现业务意图而不是表操作细节。findByUserIdAndStatus就比selectUserLeaveList清晰前者说明查询条件和返回内容后者只是描述了“查了一个列表”。2.4 DTO、VO、Entity对象划分别再所有类塞一堆字段分层的对象设计是很多人忽略的重灾区。最典型的反模式是把数据库的Entity直接当作Controller的返回对象前端字段跟数据库字段一一对应。这里要区分三种对象Entity对应数据库表结构只在DAO层使用。DTO用于接口接收参数或内部传输数据比如LeaveCreateDTO。VO用于接口返回数据是呈现给前端的最终模型。它们之间可以互相转换。转换逻辑可以放在Service层也可以使用MapStruct这样工具。不要手动写一堆set转换不仅冗长还容易漏字段。我建议用MapStruct编译期生成转换代码效率高而且字段类型不匹配时早暴露。对象划分的核心思想是数据库结构是内向的接口契约是外向的。二者天然不平衡。比如用户表里有password_hash字段Entity一定会包含它但VO绝不允许出现。如果直接拿Entity返回密码哈希就泄漏了。又比如前端需要展示“剩余请假天数”这个字段是计算出来的不在数据库表里分页查询时应该在VO中补充而不是给Entity硬加一个临时字段。3. 实操过程从零实现一个请假审批模块这一部分我选一个最常见的业务场景来演示三层架构到底怎么写。这里以“员工请假申请”为例涉及员工、请假单、审批记录三张表用Spring Boot MyBatis实现。你自己写项目时也可以参照这个节奏。3.1 项目骨架与依赖准备我用Spring Initializr创建一个项目Java版本用17构建工具用Maven。核心依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency如果不是用MyBatis而是用Spring Data JPA那就把MyBatis的依赖换成spring-boot-starter-data-jpa。我在演示里用MyBatis更贴近国内项目习惯。配置application.yml里除了数据源还要配置MyBatis的mapper扫描路径和驼峰映射mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.leave.entity configuration: map-underscore-to-camel-case: true3.2 数据库表与Entity设计请假业务表结构很简单。员工表我们已经假定存在这里只列核心的请假单表CREATE TABLE leave_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, employee_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, reason VARCHAR(255), status TINYINT NOT NULL DEFAULT 0, create_by BIGINT, create_time DATETIME, update_time DATETIME );Entity类可以用MyBatis-Plus的TableName也可以纯MyBatis用XML映射。保持简单public class LeaveOrder { private Long id; private Long employeeId; private LocalDateTime startTime; private LocalDateTime endTime; private String reason; private Integer status; private Long createBy; private LocalDateTime createTime; private LocalDateTime updateTime; // getter/setter }3.3 Controller层实现Controller负责接收前端传入的请假数据调用Service创建请假单。先定义DTO和VO。LeaveCreateDTOpublic class LeaveCreateDTO { NotNull(message 员工ID不能为空) private Long employeeId; NotNull(message 开始时间不能为空) private LocalDateTime startTime; NotNull(message 结束时间不能为空) private LocalDateTime endTime; NotBlank(message 请假原因不能为空) private String reason; }LeaveVO返回给前端的数据载体可以包含额外计算字段比如请假天数public class LeaveVO { private Long id; private Long employeeId; private LocalDateTime startTime; private LocalDateTime endTime; private String reason; private Integer status; private Long days; // getter/setter }Controller实现RestController RequestMapping(/api/leave) public class LeaveController { private final LeaveService leaveService; public LeaveController(LeaveService leaveService) { this.leaveService leaveService; } PostMapping public ApiResponseLong createLeave(Valid RequestBody LeaveCreateDTO dto) { return ApiResponse.success(leaveService.create(dto)); } GetMapping(/{id}) public ApiResponseLeaveVO getLeave(PathVariable(id) Long id) { return ApiResponse.success(leaveService.getLeaveDetail(id)); } }注意Controller里没有任何业务判断也没有直接操作DAO只负责把参数交出去、把结果包装好返回。3.4 Service层实现Service层承载业务规则。这里的核心规则是请假的结束时间不能早于开始时间普通员工连续请假3天以上需要升级到经理审批审批前状态为“待审批”创建后发送一条通知。Service public class LeaveService { private final LeaveDao leaveDao; private final ApprovalDao approvalDao; private final NotificationService notificationService; public LeaveService(LeaveDao leaveDao, ApprovalDao approvalDao, NotificationService notificationService) { this.leaveDao leaveDao; this.approvalDao approvalDao; this.notificationService notificationService; } Transactional(rollbackFor Exception.class) public Long create(LeaveCreateDTO dto) { if (dto.getEndTime().isBefore(dto.getStartTime())) { throw new BusinessException(结束时间不能早于开始时间); } LeaveOrder order new LeaveOrder(); order.setEmployeeId(dto.getEmployeeId()); order.setStartTime(dto.getStartTime()); order.setEndTime(dto.getEndTime()); order.setReason(dto.getReason()); order.setStatus(0); leaveDao.insert(order); if (Duration.between(dto.getStartTime(), dto.getEndTime()).toDays() 3) { // 创建待经理审批的记录 approvalDao.createPending(order.getId(), MANAGER); } notificationService.sendApprovalNotify(order.getId()); return order.getId(); } public LeaveVO getLeaveDetail(Long id) { LeaveOrder order leaveDao.selectById(id); if (order null) { throw new BusinessException(请假单不存在); } LeaveVO vo new LeaveVO(); vo.setId(order.getId()); vo.setEmployeeId(order.getEmployeeId()); vo.setStartTime(order.getStartTime()); vo.setEndTime(order.getEndTime()); vo.setReason(order.getReason()); vo.setStatus(order.getStatus()); vo.setDays(Math.max(1, Duration.between(order.getStartTime(), order.getEndTime()).toDays())); return vo; } }事务为什么放在这里因为create方法涉及两次数据库写操作插入请假单、插入审批记录和一次外部通知服务。如果最后一步通知发送失败事务回滚请假单和审批记录都会撤回避免出现“数据库有记录但审核通知没发出”的脏状态。这里我用了rollbackFor Exception.class因为Spring默认只回滚RuntimeException如果业务异常继承了Exception不加这个参数就会导致事务不回滚这是非常经典的坑。3.5 DAO层实现与MyBatis映射DAO层在MyBatis里通常是一个Mapper接口加XML文件。接口定义方法XML写SQL。LeaveDao接口Mapper public interface LeaveDao { int insert(LeaveOrder order); LeaveOrder selectById(Long id); }对应的LeaveDao.xmlmapper namespacecom.example.leave.dao.LeaveDao insert idinsert useGeneratedKeystrue keyPropertyid INSERT INTO leave_order (employee_id, start_time, end_time, reason, status, create_by, create_time, update_time) VALUES (#{employeeId}, #{startTime}, #{endTime}, #{reason}, #{status}, #{createBy}, #{createTime}, #{updateTime}) /insert select idselectById resultTypecom.example.leave.entity.LeaveOrder SELECT id, employee_id, start_time, end_time, reason, status, create_by, create_time, update_time FROM leave_order WHERE id #{id} /select /mapper这里有一个我在验收代码时经常强调的点SQL的字段列表不要用SELECT *。虽然开发方便但一旦表加了大数据字段比如remark_text查询效率下降而且返回的映射对象也容易莫名其妙多出字段。显式列出字段既是性能习惯也是接口契约的体现。3.6 联调验证与结果所有层写完启动项目后用接口测试工具验证。创建请假单的请求POST /api/leave { employeeId: 1001, startTime: 2025-06-01T09:00:00, endTime: 2025-06-04T18:00:00, reason: 家里临时有事 }由于结束时间比开始时间晚了3天Service会创建审批记录。返回结果{code:200,message:success,data:1}1是生成的请假单ID。接着查询GET /api/leave/1返回的days字段为3。整个过程中Controller、Service、DAO各司其职改任何一层都不会影响到其他层的语法结构。比如想调整审批阈值3天改为5天只需要改Service层想改返回字段只需要改VO和Service转换逻辑想改查询SQL只需要动DAO的XML。这就是分层带来的直接收益。4. 常见问题与排查技巧实录4.1 Service层事务注解失效的典型案例Transactional失效是最让人头疼的问题之一。常见原因有三个第一方法不是public。Spring事务是基于AOP代理实现的只有当外部调用通过代理进入public方法时事务注解才能生效。如果方法被改成private或者类内部直接调用同类方法就会绕过代理事务形同虚设。第二自调用问题。同一个类里的一个方法调用另一个Transactional方法也没用。比如create方法里调用本类的generateApproval()而generateApproval上标了Transactional这个子事务不会生效。解决办法是拆分成不同的Bean或者把事务标记放在入口方法上。第三异常被吞。事务内捕获了异常却没有抛出事务自然就提交了。代码里打印了日志逻辑上以为执行失败会回滚实际数据已经写进去了。所以事务方法里不要用try-catch把异常消掉。排查技巧启动类上不要加EnableTransactionManagement也可以因为Spring Boot会默认开启但如果你自定义了代理方式就要检查是否与事务管理兼容。另外观察日志里是否有Rolling back transaction关键字可以帮助确认事务是否真正触发。4.2 循环依赖导致启动失败在三层架构中循环依赖经常出现。比如A依赖BB依赖CC又依赖ASpring容器在创建时发现无法完成构造就会抛异常。Spring Boot 2.6开始默认禁止循环依赖在启动阶段直接报警。这是个好消息逼着你从设计上解决问题。常见的循环依赖场景其实是因为职责没分干净。比如UserService依赖LeaveServiceLeaveService又依赖UserService为了让一个业务模块能调用另一个模块把两个Service打成环。解决办法有两个一是把公共逻辑下沉到更小的组件中两个Service都依赖它二是使用事件机制某个业务模块完成后发布事件其他模块监听事件从而解耦。在排查循环依赖时翻到启动日志里Relying upon circular references的提示顺着依赖链去理清谁引了谁。不要直接用Lazy糊弄过去那只是把问题延后到运行时。4.3 分层混乱导致的N1查询问题N1查询是指在查询列表时对主表的每一条记录又单独发一次SQL去查询关联数据。常见场景是在Service里循环调用DAO的查询方法。在MyBatis的resultMap里设置了关联查询但没有用collection或join聚合导致每个子对象都发一次SQL。使用JPA的OneToMany默认懒加载后循环访问时触发了多次查询。N1问题表面上不是分层混乱直接造成的但分层的边界失守常常助长了它。如果Service层随手在循环里写SQL查询那必然出现N条SQL。更好的做法是在设计DAO方法时支持批量查询比如传入ID集合一次查出或者在SQL里使用JOIN一次性把关联数据查出来放到VO里。排查技巧特别简单开发环境下开启MyBatis或JPA的SQL日志打印看一次接口请求实际发送了多少条SQL。如果发现数量远超页面展示条数基本可以断定有N1问题。4.4 快速定位分层问题的排查思路当项目出了故障从分层角度排查往往最快。我习惯先看请求链路到了哪一层再判断问题归属。如果HTTP请求能进Controller但参数解析报错多数是Controller层问题检查DTO校验、JSON格式、字段类型。如果请求进Controller后没有返回或者业务执行异常重点看Service层检查业务规则判断、事务回滚、外部服务调用。如果Controller和Service都没问题数据不对或者查询超时那就去DAO层看SQL、看索引、看连接池配置。这套思路的价值在于它不让所有问题都堆在一起模糊处理。比如一个接口超时新手可能先检查Controller代码再检查Service代码折腾半天才发现是SQL没走索引。按层去排查效率翻倍。还有一点建议日志必须分级区分。请求入口和出口可以打INFOService层关键业务节点打INFODAO层SQL执行时间打DEBUG。这样从一次完整请求的日志时间线能清楚看到耗时到底消耗在哪一层。我个人在实际项目里的体会是三层架构看起来是“老一套”但真正守住边界并不容易。它最大的价值是给团队一个统一的心智模型看到代码就知道该去哪里找问题该在哪里改代码。这个价值远大于任何花哨的框架特性。最后再分享一个小技巧如果你正在做毕设或者刚开始搭自己的Spring Boot项目先别急着引入过多设计模式、分布式组件把三层划分做得干净、统一返回格式做好、DTO/VO转换理清这个项目的代码质量已经超过大多数同行了。架构的好坏不是看用了多少“高级技术”而是看新人接手时能不能快速找到入口修改时能不能不破坏别的地方。Spring Boot的三层架构恰恰是帮你守住这条底线最实在的工具。