1. 分层这件事,到底在解决什么问题
先讲个我早年踩过的坑。那时候刚带团队,接了一个中等规模的交易系统,代码写了大半年,功能倒是全跑通了,但凡是新需求进来,改动一个接口往往要连带改七八个文件。最离谱的一次,为了给列表页加一个排序字段,我从 Controller 层一路改到 SQL,改完发现另一个模块的接口也被带崩了——因为有人在公共的 Entity 里塞了一个只跟当前业务有关的字段,还顺手在 Service 里改了别人正在用的状态枚举。
后来复盘,问题的根子不在某一个人身上,而是整个项目没有分层意识。所有代码堆在一起,面向数据库编程,业务逻辑、参数校验、数据访问、DTO 转换全都混在 Service 里,一个方法几百行,谁都敢动,谁都动不清。那之后我把项目推倒重来,严格按分层结构重新梳理,同样的功能,代码量没减多少,但维护成本肉眼可见地降下来了。
这就是应用分层这件事的意义:它不是某种教条,而是用一套明确的边界规则,把“变化频繁的”和“相对稳定的”隔离开,把“业务逻辑”和“技术细节”解耦开。说白了,分层是软件工程对抗复杂性的第一种手段,也是成本最低的一种。
这篇内容我打算这么展开:先把分层背后的通用逻辑讲清楚,然后落到具体代码层面,讲透 Controller、Service、DAO 甚至 Application 层各自的职责边界,再分享一些我在实际项目中总结出来的落地经验和踩坑记录。文章面向的是已经写过一段时间业务代码、但还没系统梳理过分层思想的同学,以及那些想把现有代码库治理得更清爽的团队。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分层的本质:不是规则,而是依赖关系
2.1 为什么要分成“三层”而不是“两层”或“四层”
很多人理解分层,停留在“Controller 管请求、Service 管业务、DAO 管数据库”这个口径上。这个说法没错,但过于表面。真正决定分几层的,不是惯例,而是依赖关系。
我们先设想一个极简场景:不分层,一个类既接收 HTTP 请求,又直接操作数据库连接,还处理业务规则。这样写,编码速度前期最快,因为没有任何跳转,一行接一行全是实际逻辑。我见过不少原型系统就是这么写的,跑起来一点问题没有。
问题出在变化出现的时候。一旦业务规则变了,你需要动的是那一整块代码;一旦数据库从 MySQL 换成了 PostgreSQL,你要动的也是同一块代码;一旦接口要从 HTTP 改成 RPC,还是同一块代码。三个维度的变化耦合在一起,每一次变化都要在同一个大泥球里找自己需要的部分,这就是“泥球”架构的由来。
分层的本质,是把不同变化频率和不同变化原因的代码,分隔到不同的模块里,然后用依赖关系把它们串起来。为什么一般建议三层而不是两层?因为在这套维度下,绝大多数业务系统恰好有三个变化维度:
- 对外交互方式(接口形态、输入输出协议)——变化频率中等,跟着前端和外部系统走;
- 核心业务规则(订单怎么创建、优惠怎么计算、库存怎么扣减)——变化频率最高,这是业务的核心;
- 数据存取方式(SQL、缓存、ES、文件)——变化频率中等,跟着技术选型走。
于是就有了 Controller 层(接口)、Service 层(业务)、DAO 层(数据)。这不是谁发明的,是无数项目沉淀出来的经验。如果某个系统外部的输入输出协议极端稳定,而数据存储方式极其复杂多变,那么把持久化和业务拆开可能比把接口和业务拆开更有价值——这就是分层需要结合场景灵活设计的原因。
2.2 分层的“两道墙”:单向依赖与逐层传递
分层架构有一个灵魂约束:上层可以依赖下层,下层绝不能反过来依赖上层。Controller 可以调用 Service,Service 可以调用 DAO,但 DAO 里绝不能出现 Controller 或者 Servlet API 的影子。这个约束一旦被打破,分层的意义就接近归零。
我在实际代码审查中发现,最常见的破坏方式是“快捷通道”。某天为了查一个问题,临时在 Controller 里直接注入了一个 DAO 的 Mapper,或者在某一个 Service 里拿到的数据,通过一个静态方法塞到了另一个 Service 里。一次两次觉得无所谓,时间长了这些快捷通道会织成一张网,分层全都白搭了。
另一条规则是数据模型的逐层传递,而不是跳层传递。Controller 层用的视图对象(VO),Service 层用的业务对象(DTO),DAO 层用的持久化对象(Entity/PO),三者职责不同,应该各管各的。最忌讳的是把 DAO 层的 Entity 直接返回给前端,一是不安全(容易把敏感字段漏出去),二是底层模型一旦调整,接口也就跟着变了,耦合又回来了。
有的团队觉得“三套对象复制来复制去太麻烦,直接用 Entity 一把梭”。这个取舍我能理解,在内部系统、生命周期短的项目里,确实可以酌情简化。但简化要有一个前提:你能接受底层字段变化直接影响到接口契约。一旦你的系统要面向大量外部调用方,这个简化的成本就会迅速膨胀,到时候加固的代价,远高于当初多写几个转换方法的代价。
2.3 分层不是“包结构”,而是“边界”
很多项目分了包,比如 controller、service、mapper 三个目录,看起来分层了,实际上一看代码,Service 里照样把业务判断和数据库操作写在一个两三百行的方法里。这算什么分层?只算目录分类,不算边界。
边界意味着不可随意跨越,意味着每一层都有自己的“语言”。DAO 层讲的是“数据”——表、字段、SQL、查询条件;Service 层讲的是“业务”——订单状态机、优惠规则、库存策略;Controller 层讲的是“交互”——参数格式、状态码、返回结构、异常消息。当你在 Service 里看到 INSERT INTO xxx 这种原生 SQL,或者在 Controller 里看到某个金额是用 if (a > b) 算出来的,就说明边界已经失守了。
所以,分层最后落到实处的表现,是每一层的代码只允许它关心的事情,其余全部交给别层或注进来。这需要团队有很强的纪律性,也依赖 Code Review 环节真正发挥作用。
3. 经典三层到底怎么落:Controller、Service、DAO 的边界与分工
这一节是比较实操的内容,我会把三层各自的职责、代码里常见的坏味道、以及相对合理的写法都过一遍。
3.1 Controller 层:保持“薄”,保持“哑”
Controller 层唯一合理的职责,是接收外部请求、解析参数、调用业务层、把结果包装成响应。它不应该知道任何业务规则,不应该知道订单状态机的流转,不应该知道数据库长什么样。
我经常用一句话衡量一个 Controller 合不合格:“你把它整个删掉,换成 RPC 接口或者 MQ 消费者,业务代码一行都不用改。”如果删掉 Controller 之后,你还需要牵动一波业务逻辑,那说明业务逻辑没有下沉到 Service 层。
Controller 层常见的坏味道有三个:
第一个是把业务判断写在 Controller 里。比方说:
java复制@PostMapping("/order")
public Result<Long> createOrder(@RequestBody OrderCreateRequest request) {
if (request.getAmount() < 0) {
return Result.error("金额非法");
}
if (userService.getBalance(request.getUserId()) < request.getAmount()) {
return Result.error("余额不足");
}
Long orderId = orderService.createOrder(request);
return Result.success(orderId);
}
这个写法的直接问题是:校验规则和余额判断都是业务规则,如果换一个入口渠道(比如后台管理员代下单、定时任务批量下单),这些逻辑没法复用。真正合理的写法,是 Controller 里的方法只有三行:参数收拢、调 Service、包装返回。校验和余额判断全部下沉到 Service。
第二个坏味道是参数校验代码冗长且分散。参数校验分两层:入参的格式校验(非空、长度、枚举值)应该靠 Bean Validation 注解解决,比如 @NotNull、@Min、@Size;而跨字段、依赖业务数据的校验(比如下单数量和库存的校验、金额和折扣的校验)应该放到 Service 里。Controller 最适合的角色,就是“只做格式层校验,业务校验通通不管”。
第三个坏味道是Controller 里做协议转换之外的额外逻辑,比如把返回数据重新组装、从某个服务拿额外信息拼装。这属于让 Controller 承担了本应属于组装层(Application 层)的职责。如果系统简单,偶尔拼一下问题不大;但一旦拼装逻辑复杂到一定程度,就该单独抽一层。
3.2 Service 层:业务逻辑的集中地,但不是垃圾堆
Service 是整个分层的核心。它的职责是:定义业务流程、维护业务规则、协调各个领域对象和基础设施。数据校验的语义、事务的边界、领域事件的处理,全都应该在这里展开。
但 Service 层也是最容易腐烂的地方。如果团队没有额外的约束,Service 类常常会越长越肥,一个类上干招接口下管 SQL。我见过一个订单 Service 有两千行,方法大几十个,依赖注入了一堆 Mapper 和辅助工具类。这种类看起来还能跑,但改起来极其痛苦,因为根本不知道某个方法的改动会影响哪些调用方。
给 Service 层瘦身,我有几个方法,都是实测有效的:
-
把一个大型 Service 拆成多个小 Service,按业务域划分。比如 OrderService 拆成 OrderQueryService(查询)、OrderWriteService(写操作)、OrderStateMachineService(状态流转)。拆分不是按功能多少拆,而是按变化原因拆——查询逻辑和写逻辑的变化频率往往不一样,状态流转的核心规则又要稳一些。
-
把跨实体的协作逻辑往上提到编排层。在一个订单流程里,往往要调用户服务、商品服务、库存服务、优惠服务。如果把这些逻辑全塞进 OrderService 里,这个类就是上帝类。更合理的做法是单独建一个 OrderApplicationService(应用服务),负责编排,而 OrderDomainService 只负责订单自身规则的校验和执行。
-
事务放到应用服务层控制,而不是数据访问层控制。道理很简单:一个业务用例往往要跨越多个 DAO 操作,事务必须覆盖整个用例,而不是某一条 SQL。这也是为什么事务注解通常出现在 Service 方法上,而不是 DAO 类上。
Service 层的另一个重要职责是统一处理业务异常。我建议业务系统里定义一个 BizException,所有业务规则不满足的场景都抛这个异常,由 Controller 或者全局异常处理器统一捕获转换为响应报文。技术异常(数据访问异常、第三方调用异常)则不应该被业务代码捕获后伪装成正常返回,而应该往上抛,让全局处理器兜底。这两类异常分开处理,排查问题时会省很多力气。
3.3 DAO 层:只做数据存取,没有任何业务判断
DAO 层是离数据库最近的一层。它的职责非常单一:把上层传入的条件翻译成查询或写入动作,把数据库返回的数据翻译成对象。这里不该有任何业务判断,不该有 if-else 去决定某条数据怎么写,更不该有跨表的事务控制逻辑。
我在一些项目里看到过 DAO 层出现“把查询结果在内存里过滤”的写法。比如:
java复制List<Order> orders = orderMapper.selectAll();
return orders.stream()
.filter(o -> o.getStatus() == OrderStatus.PAID)
.collect(Collectors.toList());
这种写法的隐患非常大。如果订单表数据量上来了,全表查询一次,哪怕只是几万条,也会拖垮数据库。正确的做法是把过滤条件下沉到 SQL 里,让数据库做它擅长的事情:
java复制List<Order> orders = orderMapper.selectByStatus(OrderStatus.PAID);
DAO 层还有两个容易被忽视的细节。一个是查询条件的模型和返回模型不要混用。查询条件单独定义一个 Query 对象(比如 OrderQuery),返回对象用 Entity,这样添加新的查询维度不会污染实体类。另一个是批量操作优先于循环单条操作。不少新手喜欢在 for 循环里调 mapper.update,这个习惯在数据量小的时候看不出问题,但一旦单条数据操作涉及锁、事务日志、网络往返,性能就会急剧下降。能用 updateBatch 或 insertBatch 就用批量。
3.4 那四层呢?Application 层和 Domain 层什么时候出现
说完经典三层,再提一个进阶话题:四层架构。在三层和 DDD 分层之间,还有一个常见的折中方案——把一个 Service 层拆成 Application(应用服务)层和 Domain(领域)层。
Application 层管的是“用例的编排”。举个例子,创建订单这个用例,它的流程是:检查用户是否合法、检查商品库存、扣减库存、创建订单、发送消息通知。这些步骤里,“扣减库存、创建订单、发送消息”属于不同领域对象之间的协作,Application 服务把协作过程串起来,同时负责开启事务。而 Domain 层管理的是“对象自身的规则”,比如订单对象里有价格计算、状态校验、支付结果确认这些方法。
如果一个项目的业务复杂度不高,领域模型贫血,那么硬拆 Domain 层会徒增成本。我认为四层架构适合的场景是:业务规则本身足够复杂、存在多个有状态的领域对象、且跨领域协作频繁。如果你的系统大部分逻辑只是“查出来、算一下、写回去”,那么经典三层已经够用,不必强上四层。
4. 实操中容易踩的坑,以及我怎么避开的
分层这件事,纸上谈兵很容易,真正落到项目里,各种边界问题层出不穷。这一节把我在实际工作中遇到的高频问题整理出来,每个都配了原因分析和处理建议。
4.1 校验到底放哪层:Controller 还是 Service
“参数校验放在哪一层”是我被问得最多的问题。我的答案是:格式校验放 Controller,业务校验放 Service。
格式校验指的是“这个字段是否是数字”“这个字段是否为空”“枚举值是否合法”这类与业务无关的规则,交给 Bean Validation 注解就能搞定:
java复制public class OrderCreateRequest {
@NotNull(message = "用户ID不能为空")
private Long userId;
@NotNull(message = "商品ID不能为空")
private Long skuId;
@Min(value = 1, message = "数量至少为1")
private Integer quantity;
}
业务校验指的是“用户是否有购买资格”“商品库存是否充足”“优惠券是否可用”这类需要查库或结合上下文判断的规则。这些必须放到 Service 里,因为多个入口(HTTP、定时任务、MQ)可能会共用这些规则,放在 Controller 里就复用不起来了。
但有一个容易忽略的坑:如果某个校验需要访问数据库,又只涉及单条数据,可以直接放在 DAO 查询阶段完成。比如 selectByIdForUpdate 查出来如果为空,直接抛异常,这算是一种“在数据层完成的业务校验”,但在代码可读性上不如 Service 层显式校验直观。我一般建议:能简单尽量简单,但不要让代码读起来需要绕三个弯。
4.2 事务的边界:不放在 Controller,也不要覆盖无关操作
事务应该放在 Service 方法的入口上,这是基本共识。但具体到方法粒度,经常出问题。
一个典型坏味道是:一个 Service 方法里既做了 A 业务的写操作,又调用了 B 服务的远程接口,然后整个方法外面套了一个 @Transactional。如果 B 服务响应很慢,数据库连接就会被长时间占用,连接池一旦耗尽,整个应用就开始雪崩。
我的做法是,事务只覆盖本地数据库操作,远程调用不能放在事务内。如果需要“先更新本地、再调用远程、失败后补偿”,那正确的姿势是把调用远程放到事务提交之后(比如用 TransactionSynchronizationManager.registerSynchronization 或者 Spring 的 @TransactionalEventListener)。另外,事务方法内部尽量只做当前用例必要的操作,别把查询下拉数据、拼装统计报表这些无关操作塞进来,否则锁的持有时间会莫名变长。
4.3 对象转换:到底用 MapStruct 还是手写 BeanUtils
分层必然带来对象之间的转换。Controller 用 VO,Service 用 DTO,DAO 用 Entity。三套对象之间频繁转换,是很多团队觉得分层“太麻烦”的第一个理由——所以不少人选择砍掉某套对象来回避转换成本。
但对象转换本身不是问题,选错工具才是。早期我用过 Spring 的 BeanUtils.copyProperties,优点是少写代码,缺点是隐式映射——字段名对不上时它静默跳过,类型不一致时它直接抛异常,运行时才发现,排查成本高。另一个大坑是,它会把不该拷贝的字段也拷过去(比如审核状态、内部逻辑标记字段)。
我现在的默认选择是 MapStruct。它在编译期生成转换代码,性能好、类型安全,字段名对不上直接编译报错。更重要的是,它能显式表达字段映射规则:
java复制@Mapper(componentModel = "spring")
public interface OrderConvert {
OrderDTO toDTO(OrderEntity entity);
@Mapping(target = "userId", source = "user.id")
OrderDTO toDTO(OrderEntity entity, UserEntity user);
}
如果团队里不想引入 MapStruct,手写转换方法反而更可控。两个对象之间字段很多的时候,手写确实繁琐,但至少每次编译都能发现遗漏。比 BeanUtils 那种运行时静默失败强太多。
4.4 循环依赖:分层带来的新问题,怎么破
分层之后,业务往往会拆成多个 Service,有些 Service 之间天然存在互相调用的需求。比如 OrderService 需要调 UserService 查用户信息,UserService 在某些场景下又需要调 OrderService 查订单数据。这种循环依赖在代码里很常见,Spring 默认不支持构造器注入的循环依赖,于是有些团队就把注释放到字段上,绕过检测。
字段注入的循环依赖其实是很坏的信号。它说明你的服务边界没有划清楚:A 和 B 互相依赖,那谁才是上层?化解方案通常有两种。一种是把互相依赖的那部分逻辑下沉到底层服务,形成一个三者结构,让 A 和 B 不再直接互调。另一种是引入事件机制,当 A 需要 B 的结果时,不直接调 B,而是发送一个事件,由 B 的监听器去处理,异步解耦。
我在实践中发现,大多数循环依赖都源于“查询类服务”和“业务类服务”没有分开。如果把 OrderQueryService 和 OrderWriteService 拆开,让 UserWriteService 只依赖 OrderQueryService,循环依赖往往会自动消失。
4.5 分层要避免过度设计:什么时候适可而止
分层虽好,但也不是越复杂越好。我见过一些团队,一个查字典的小功能,也要搞 Controller、Service、DAO、DTO、VO、Convert 六件套,改一个字段要动六个文件。这种“仪式感”对项目是负担。
我的经验法则是:分层的深度取决于项目的生命周期和团队规模。如果一个接口写出来只给自己系统的管理后台用,永远不会有外部调用方,那 Entity 直接返回也不是不可以;如果三套对象转换的成本明显高于业务逻辑本身的复杂度,那说明这个模块不需要这么多层。
但有一点要提醒:简化分层要显式决策,而不是顺手写坏。如果某个项目决定省掉 DTO,直接在 Controller 返回 Entity,那要在团队里明确这个决策,并且保持这个模块内部的一致性。最怕的是同一个项目里,一部分接口好好分层,另一部分图省事全糊在一起,后人接手时根本无法判断哪里该守住边界、哪里可以随意改。
4.6 VO、DTO、Entity 的命名与落位规范
分层落地之后,对象命名常常成为新的混乱源头。我见过一个系统里,DTO、VO、BO、PO 混着用,同一个对象的三种形态叫法五花八门,代码审查效率极低。
我建议团队内部对齐一套命名约定,不一定按 DDD 的词汇,但必须一致。我自己习惯的约定是:
| 名称 | 全称 | 位置 | 职责 |
|---|---|---|---|
| VO | View Object | Controller 层 | 接口入参/出参,与前端契约绑定 |
| DTO | Data Transfer Object | Service 层 | 业务方法入参/返回值,服务间传输 |
| Entity/PO | Persistence Object | DAO 层 | 与数据库表结构一一对应 |
| Query | Query Object | DAO 层 | 查询条件封装,分页、排序、筛选都在里面 |
这套约定里最关键的一点是:Entity 绝不允许传到 Controller 层。哪怕只是返回给内网的管理后台,你也没法保证未来不会加一个外部的只读接口。把 Entity 当作内部私密资产保护起来,是防止分层腐化的底线。
5. 常见问题速查表
我整理了一份高频问题速查表,方便你在 Review 代码或者重构时对照使用。
| 问题 | 典型现象 | 推荐做法 |
|---|---|---|
| Controller 里塞业务逻辑 | if-else 判断金额、库存、状态 | 下沉到 Service 方法 |
| Service 方法超过 200 行 | 一个方法完成了建单、扣库存、发消息 | 拆编排层/领域服务 |
| DAO 层做内存过滤 | 查出全部再 stream filter | 过滤条件下沉 SQL |
| Service 之间循环依赖 | 字段注入互相调用 | 拆分读写服务或引入事件 |
| Entity 直接返回前端 | 接口出参和表字段重合 | 定义 VO 并做显式转换 |
| 远程调用放在事务内 | 数据库连接被长事务占用 | 事务提交后再发起远程调用 |
| 事务注解加在私有方法上 | 同事说事务不生效 | 移到 public 方法或代理类 |
| DTO 和 VO 混用 | 一个对象同时出现在 Controller 和 Service | 按层分层定义对象 |
| 枚举值直接存字符串 | 代码里出现魔术字符 | 定义枚举统一管理 |
| 用 BeanUtils 静默拷贝 | 字段类型变化后运行时才报错 | 用 MapStruct 编译期检查 |
6. 最后分享一点我的实际体会
说实话,分层这个问题,属于“听起来都懂、做起来容易跑偏”的典型。我见过不少项目,刚开始分层分得干干净净,半年之后 Service 里就开始出现 SQL,Controller 里开始出现 if-else 业务判断,Entity 也开始在接口层抛头露面。腐烂不是一天发生的,而是每一次“这次先这样,后面再改”积累出来的。
所以在团队里,比“设计一套分层规范”更重要的,是把分层边界当成代码评审的硬指标。我每次 review 代码,都会先看类依赖,再看方法长度,最后看对象落位。依赖是否单向、事务是否覆盖合理、Entity 有没有跑错层,这三条过关了,分层基本不会塌。如果评审阶段守不住,后面靠重构挽回的成本,通常远超当初写这几行代码的成本。
另外一个体会是,分层方案不应该是一成不变的。项目初期业务简单,三层足够;中期业务复杂了,把 Application 层和 Domain 层拆出来;后期团队多人协作,进一步把查询和写操作分离。架构演进是常态,承认这一点,你就不必为了“标准的三层架构”而强行塞东西,也不会因为“要动架构”而拖延重构。
最后给一个实操建议:如果你现在有一个没有分层的存量系统,别想着一次性大重构。先挑一个高频变动的业务域,比如订单、用户、支付,把它的读写路径按分层重新梳理一遍,跑通之后形成模板,再逐步推广。这样改造的失败风险最小,团队也能在真实的对比中体会到分层带来的好处——毕竟,只有自己改过一遍,才知道原来那套“一把梭”的开发方式,到底浪费了多少时间在查问题和改 bug 上。
