Java 项目为什么要分 Controller、Service、Dao 三层?90% 的人只能答对一半
面试里这道题的出镜率高得离谱,但十个人里有九个会背一句"高内聚低耦合"就卡壳。面试官真正想听的,是每层的边界在哪、你在真实项目里踩过什么坑、又是怎么解决的。
这篇文章把多个一线项目的实战经验整合在一起,从原理、代码到踩坑和架构守护,一次讲透。不管你是准备面试,还是想在团队里把分层规范立起来,都值得读完。
一、先说结论:分层不是分代码,是分"变化"
很多人以为三层架构就是把代码分成三个文件夹,其实大错特错。
分层的本质是单一职责原则 + 关注点分离(Separation of Concerns) ,再往深了说,是按**"变化速率"**给代码分舱:
- Controller 层为前端变化而生------UI 改字段、接口协议从 HTTP 换成 RPC,只动它;
- Service 层为不变的业务规则而生------它是系统里最"值钱"也最稳定的资产;
- Dao 层为存储变化而生------MySQL 换 ES、MyBatis 换 JPA、加缓存做读写分离,也只动它。
一句话概括:改前端字段只动 Controller,改业务规则只动 Service,换数据库只动 Dao。
有三样东西必须钉死在固定位置:
| 钉死的东西 | 唯一归属 | 为什么 |
|---|---|---|
| 业务规则(if/else 里的判断) | Service | 业务规则是系统唯一"值钱"的资产,不能被 HTTP 协议或 SQL 细节污染 |
| 事务边界 | Service | 一次业务动作的原子性,天然是业务概念,不是 HTTP 概念 |
| SQL / 存储细节 | Dao | 换库、换 ORM、加缓存、读写分离,都只需要动这一层 |
打个不严谨但好记的比方:去饭店吃饭,跑堂负责接单上菜,厨师负责做菜,小工负责进库房取食材。你什么时候见过厨师一边颠勺一边跑去门口招呼客人?
代码世界也一样:Controller 是服务员,Service 是厨师,Dao 是采购员。 小店时代一个人全包还能撑,店一大就全乱了。
二、三层架构全景图:每层管什么,红线在哪
先看整体调用链路:
每层的职责和红线,背下面这张表就够了:
| 层级 | 角色定位 | 该干的 ✅ | 绝不能干的 ❌ | 入参 / 出参 | 常见组件 |
|---|---|---|---|---|---|
| Controller 控制层 | 前台接待:迎宾小哥,检查入场券,不干重活 | 接收 HTTP 请求、@Valid 格式校验、DTO 转换、鉴权入口、组装统一响应 |
写业务规则、写 SQL、开事务、编排多个 Dao | HTTP 参数 / DTO → 统一 Result / VO | @RestController、@RequestMapping、Validation |
| Service 业务层 | 后厨大厨:核心逻辑中枢 | 业务规则判断、业务校验、事务边界、编排多个 Dao 和远程调用 | 出现 HttpServletRequest、拼 SQL、把 Controller 的 VO 当入参 |
DTO / BO → BO / DTO | @Service、@Transactional、业务组件 |
| Dao 持久层 | 库房管理员:只管存取,不关心菜谱 | CRUD、SQL / ORM 映射、分页、批量操作 | 掺业务判断(if 用户是VIP 则改SQL)、调 Service、调第三方 |
PO / 查询条件 → PO / 数据结果 | MyBatis、JPA、MyBatis-Plus、JdbcTemplate |
Dao 应该只回答三个问题:数据有没有?查出来没有?写入成功没有? 剩下的判断,全是 Service 的事。
有两个细节经常被忽略,但恰恰是分层质量的试金石:
细节一:Service 层必须是无状态的。 不持有 Session、不碰 HTTP 对象,入参出参都是普通 Java 对象。做不到这一点,分层就只是个错觉。
细节二:依赖方向必须是单向的。 Controller → Service → Dao,上层知道下层,下层永远不知道谁在调用它。底层可替换、可 Mock、可加缓存,全靠这条规矩。
为什么 Service 通常要写"接口 + 实现类"?
不少人觉得这是样板代码,其实它有三层价值:
- 面向接口,方便替换实现------MyBatis 换 JPA、本地库换远程服务,调用方零感知;
- JDK 动态代理要求有接口------事务、日志、权限这些 AOP 增强才挂得稳;
- 单测可以直接 Mock 接口,不依赖任何实现。
当然这不是死规定:单实现、不需要多态的简单场景,直接用实现类也完全可以,别为了形式而形式。
三、先看反例:不分层会死在哪
实际开发中我们经常会遇到这种代码------一个 Controller 干完了所有事:
java
// 反面教材:看着能跑,其实是定时炸弹
@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired private JdbcTemplate jdbcTemplate; // 越界,干了 Dao 的活
@Autowired private PayClient payClient; // 越界,直接调三方
@PostMapping("/create")
public Map<String, Object> create(@RequestBody Map<String, Object> req) {
if (req.get("skuId") == null) return Map.of("code", 400); // 参数校验混着业务校验
// 业务规则直接写死在 Handler 里:满 100 减 20,VIP 再 9 折
BigDecimal amount = new BigDecimal(req.get("price").toString());
if (amount.compareTo(new BigDecimal("100")) >= 0) {
amount = amount.subtract(new BigDecimal("20"));
}
if (Boolean.TRUE.equals(req.get("vip"))) {
amount = amount.multiply(new BigDecimal("0.9"));
}
jdbcTemplate.update("INSERT INTO t_order(sku_id, amount) VALUES(?, ?)", req.get("skuId"), amount);
jdbcTemplate.update("UPDATE t_stock SET num = num - 1 WHERE sku_id = ?", req.get("skuId"));
payClient.pay(amount); // 三件事没有事务保护,扣了库存可能建单失败
return Map.of("code", 200, "amount", amount);
}
}
这段代码有四个病:没有事务边界 (扣了库存可能建单失败)、规则无法复用 (App 端、小程序、定时任务想用这套逻辑只能 Ctrl+C / Ctrl+V)、无法单测 (必须连库起 Tomcat)、换库全员重写。
我之前就踩过这个坑:一个订单 Controller 从 500 行涨到 2000 行之后,修 bug 的平均耗时从 1 小时涨到 7 小时。原因不复杂------加个优惠券逻辑,顺手改了一个共享的 Map,结果把会员等级计算带崩了。
这种代码有名字,叫大泥球(Big Ball of Mud) ,或者更形象一点:意大利面条,牵一根,全身动。痛点一句话总结:改不动、测不了、复用不了、事务还没边界。
除了"全糊一锅",还有三种更隐蔽的越界,Code Review 时要重点盯:
反例 1:Controller 直连 Dao
java
@RestController
public class BadUserController {
@Autowired private UserDao userDao;
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
return userDao.selectById(id); // 数据库实体直接返给前端
}
}
技术上能跑,但没有业务承载点、没有事务边界,后续加权限、风控、缓存都很麻烦,数据库实体还直接暴露给了前端。
反例 2:Service 里处理 HTTP 细节
java
public UserVO register(HttpServletRequest request, UserRegisterDTO dto) {
String ip = request.getHeader("X-Forwarded-For");
// ...
}
Service 被 Web 协议污染后,换成 MQ、RPC、定时任务来调就别扭了,复用性大打折扣。正确做法是 Controller 把需要的上下文提前组装好,再传给 Service。
反例 3:Dao 里写业务判断
java
default boolean canRegister(String username) {
return countByUsername(username) == 0
&& !username.contains("admin")
&& username.length() > 6;
}
业务规则跑到数据访问层,Dao 不再纯粹,规则一变就得翻到最底层去改。记住:Dao 只认表,不认业务。
四、标准姿势:用一个下单场景把三层落地
光说理论没意思,直接上代码。我们拿经典的用户下单场景,把事务、异常处理、依赖倒置、对象分层一次玩明白。
4.1 先搭基础设施:统一返回体
前后端协议统一,是分层架构的第一步。不管成功还是异常,返回格式永远一致:
java
public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.code = 200;
result.message = "success";
result.data = data;
return result;
}
public static <T> Result<T> fail(String message) {
Result<T> result = new Result<>();
result.code = 500;
result.message = message;
return result;
}
}
4.2 Controller:薄到只剩"翻译"
java
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService; // 依赖接口,不依赖实现
/** 下单:只做「参数进 → 结果出」,业务一概不管 */
@PostMapping
public Result<OrderVO> create(@Valid @RequestBody OrderCreateDTO dto) {
// @Valid 触发 JSR303 校验,非法参数直接 400,业务层拿到的永远是干净数据
return Result.success(orderService.createOrder(dto));
}
@GetMapping("/{id}")
public Result<OrderVO> detail(@PathVariable Long id) {
return Result.success(orderService.getOrderDetail(id));
}
}
关键原则:Controller 只做三件事------接参数、调 Service、返结果。 团队可以定个规矩:Controller 方法超过 15 行就问一句为什么,超过 30 行立刻重构。
4.3 Service:业务规则的唯一聚居地,事务边界的唯一归属
java
public interface OrderService { // 面向接口编程,方便多实现 / 动态代理 / Mock
OrderVO createOrder(OrderCreateDTO dto);
OrderVO getOrderDetail(Long id);
}
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderMapper orderMapper;
private final StockMapper stockMapper;
private final PriceCalculator priceCalculator; // 易变的计价规则抽成独立组件
private final ApplicationEventPublisher publisher; // 事件解耦下游
@Override
@Transactional(rollbackFor = Exception.class) // 业务动作 = 事务边界
public OrderVO createOrder(OrderCreateDTO dto) {
// 1. 业务校验(注意:不是格式校验,格式校验在 Controller)
Stock stock = stockMapper.selectBySkuId(dto.getSkuId());
if (stock == null || stock.getAvailable() < dto.getNum()) {
throw new BizException("库存不足");
}
// 2. 业务规则,规则本身可替换(策略模式)
BigDecimal payAmount = priceCalculator.calc(dto.getUserId(), dto.getSkuId(), dto.getNum());
// 3. 原子扣库存:把判断和更新压成一条 SQL,防超卖
int affected = stockMapper.deduct(dto.getSkuId(), dto.getNum());
if (affected == 0) {
throw new BizException("扣减库存失败");
}
// 4. 落订单,与扣库存在同一事务里,要么都成要么都回滚
Order order = Order.create(dto, payAmount); // 领域对象自己负责构造
orderMapper.insert(order);
// 5. 解耦下游:发事件而不是直接发 MQ,事务提交后才真正执行
publisher.publishEvent(new OrderCreatedEvent(this, order.getId()));
// 6. PO → VO,数据库实体绝不直接返回给前端
return OrderVO.from(order);
}
@Override
@Transactional(readOnly = true, rollbackFor = Exception.class)
public OrderVO getOrderDetail(Long id) {
Order order = orderMapper.selectById(id);
if (order == null) {
throw new BizException(ErrorCode.ORDER_NOT_FOUND);
}
return OrderVO.from(order);
}
}
// 事务提交成功后才执行,回滚则不发送,避免脏消息
@Component
class OrderEventListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCreated(OrderCreatedEvent e) { /* 发 MQ / 发券 / 发短信 */ }
}
这段代码里埋了几个亮点,值得单独拎出来:
亮点 1:原子扣库存防超卖。 不是"先查再减",而是一条带条件的 UPDATE:
java@Mapper public interface StockMapper { @Update("update t_stock set num = num - #{n} where sku_id = #{skuId} and num >= #{n}") int deduct(@Param("skuId") Long skuId, @Param("n") Integer n); }判断和更新压进同一条 SQL,靠数据库行锁保证并发安全,返回受影响行数让 Service 判断结果。
亮点 2:
@TransactionalEventListener(AFTER_COMMIT)解耦下游。 下单成功后要发 MQ、发券、发短信,直接在事务里调容易出脏消息(事务回滚了消息却发出去了)。用事件 + 提交后回调,失败不发送,干净利落。亮点 3:构造器注入 +
final。 用@RequiredArgsConstructor走构造器注入,依赖不可变、方便单测 Mock,还能让循环依赖在启动时就暴露出来,而不是埋到运行时。
4.4 Dao:只认表,不认业务
java
@Mapper
public interface OrderMapper {
int insert(Order order);
Order selectById(@Param("id") Long id);
List<Order> selectPage(@Param("status") Integer status,
@Param("offset") int offset,
@Param("size") int size);
}
xml
<!-- OrderMapper.xml:SQL 细节全部收敛在这里,换库 / 换 ORM 只改这层 -->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO t_order (user_id, sku_id, num, pay_amount, status, create_time)
VALUES (#{userId}, #{skuId}, #{num}, #{payAmount}, #{status}, NOW())
</insert>
分库分表、SQL 优化、索引调整,全部只在这一层迭代,Service 一行不动。
4.5 全局异常兜底:让 Service 只管抛
Service 抛出的业务异常,不需要 Controller 到处 try-catch,统一交给全局处理器:
java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public Result<Void> biz(BizException e) {
return Result.fail(e.getMsg());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> valid(MethodArgumentNotValidException e) {
return Result.fail(e.getBindingResult().getFieldError().getDefaultMessage());
}
}
这样一来,异常链路变成:Dao 抛原始异常 → Service 翻译成 BizException → Controller 零 try-catch → @RestControllerAdvice 统一兜底。异常不再跨层污染,每个接口的返回结构也统一了。
4.6 对象怎么流转:DTO / PO / VO 各就各位
几条红线必须守住:
- 请求/响应对象只活在 Controller 层,绝不往 Service 里传;
DTO/BO是业务对象,是 Service 的输入输出;PO/DO是数据库表的映射,只在 Dao 层和 Service 内部流转;- Entity/PO 绝不直接返回前端------表结构一变接口就被动变更,密码、盐值、逻辑删除字段随时可能泄露;
- 转换集中到一个 Converter / MapStruct,别在方法里散着写
BeanUtils.copyProperties。
转换工具推荐 MapStruct ------编译期生成转换代码,零反射、类型安全,高并发下性能比 BeanUtils 的反射拷贝高一个量级:
java
@Mapper(componentModel = "spring")
public interface OrderConvert {
OrderConvert INSTANCE = Mappers.getMapper(OrderConvert.class);
OrderVO toVO(Order po);
Order toPO(OrderCreateDTO dto, BigDecimal amount);
List<OrderVO> toVOList(List<Order> pos);
}
4.7 一次完整请求的全流程
把上面的代码串起来,看一个下单请求是怎么在三层中流转的:
4.8 换个场景再体会一遍:用户注册
下单是"写"的代表,再看一个注册场景,体会业务校验和事务怎么配合:
java
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserDao userDao;
private final PasswordEncoder passwordEncoder;
@Override
@Transactional(rollbackFor = Exception.class)
public UserVO register(UserRegisterDTO dto) {
// 1. 业务规则:用户名不能重复
if (userDao.existsByUsername(dto.getUsername())) {
throw new BizException(ErrorCode.USERNAME_EXIST);
}
// 2. DTO 转 PO,密码加密
User user = UserConverter.toPO(dto);
user.setPassword(passwordEncoder.encode(dto.getPassword()));
// 3. 保存用户 + 送积分 + 写注册日志,同一事务,任一步失败全回滚
userDao.insert(user);
pointDao.insert(user.getId());
userLogDao.insert(user.getId());
// 4. PO 转 VO 返回
return UserConverter.toVO(user);
}
}
@Mapper
public interface UserDao {
int insert(User user);
@Select("SELECT COUNT(1) FROM t_user WHERE username = #{username}")
int countByUsername(@Param("username") String username);
default boolean existsByUsername(String username) {
return countByUsername(username) > 0;
}
}
注意 Dao 里的 default 方法:它只是对查询结果做了一层数据层面的包装(count 转 boolean),没有掺任何业务规则,依然是纯粹的。
五、为什么必须分:五个实打实的收益
前面讲了怎么做,这一节把"为什么"讲透。先看一张对照表:
| # | 收益 | 不分层会怎样 | 分层之后 |
|---|---|---|---|
| 1 | 职责单一,改动互不影响 | 一个类 3000 行,改一处崩三处 | 每层只干一件事,改动影响面可控 |
| 2 | 解耦 & 复用 | 下单逻辑写在 Controller,App / 小程序 / RPC 各复制一份 | Service 一次编写,HTTP、RPC、MQ、定时任务都能调 |
| 3 | 事务边界清晰 | 事务加在 Controller 上,长事务拖垮连接池 | @Transactional 只圈住业务原子操作 |
| 4 | 可测试 | 单测要起 Servlet 容器、连数据库,没人愿意写 | Service 是纯 POJO,Mockito 一 Mock 就能测 |
| 5 | 团队并行 | 前后端、DBA 互相阻塞 | 按层分工,接口先行,Mock 并行开发 |
收益一:复用------写一次,处处调
同一个 UserService.register(),可以被所有入口复用:
HTTP 接口能调,后台任务能调,消息消费者能调,单元测试也能单独调。如果注册逻辑写在 Controller 里,领导说"再做个小程序下单入口",你就只能复制粘贴一遍。
同一份用户查询业务,可以同时给 Web 端、小程序端、OpenAPI 提供不同的 Controller 入口,但共用同一个 Service;多个 Service 也可以复用同一个 Dao 的基础查询。接口从 HTTP 换成 RPC,只需要改 Controller 的注解,业务层零改动。
收益二:事务边界有了天然的归属地
@Transactional 加在 Service,是一件"位置正确"的事。
一次业务动作(下单 = 扣库存 + 建订单 + 记流水)要么全成,要么全不成。加到 Controller 上是错的 :HTTP 请求是无状态的,一个请求可能触发多个业务动作,事务粒度会失控,而且 Controller 里还混着参数解析、鉴权这些不该回滚的动作;加到 Dao 上更错:Dao 只是一次数据库操作,它不知道业务边界在哪,跨表的原子性根本保证不了。
收益三:可测试性------最容易被低估的一条
业务逻辑能脱离 Web 容器和数据库跑单测。测"满 100 减 20 叠加 VIP 9 折"这条规则,只要 Mock 掉 Dao,不连数据库、不启 Tomcat,几百个用例几秒跑完:
java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock private StockDao stockDao; // Dao 被 Mock 掉
@InjectMocks private OrderServiceImpl orderService;
@Test
void 满100减20再打9折() {
when(stockDao.getBySkuId(1L)).thenReturn(new Stock(1L, 10));
BigDecimal pay = orderService.previewAmount(1L, 1L, 1, new BigDecimal("120"));
assertThat(pay).isEqualByComparingTo("90.00"); // (120-20)*0.9
}
@Test
void 库存不足应抛异常() {
when(stockDao.deduct(any(), any())).thenReturn(0);
assertThrows(BizException.class, () -> orderService.createOrder(dto));
}
}
如果业务和 SQL 混在一起,就只能启动整个服务用 Postman 点,测一次两分钟。分层后各层的测试分工也很清晰:Controller 测参数校验和响应格式,Service 测业务逻辑分支,Dao 测 SQL 正确性。 线上出问题顺着调用链逐层排查,定位效率提升非常明显。
收益四:技术选型的可替换性
- 今天 Spring MVC,明天换 WebFlux、换 Dubbo 暴露服务:Service 接口不变,业务代码一行不改;
- 今天 MyBatis,明天换 JPA、加 Redis 缓存、做读写分离多数据源:只替换 Dao 实现,Service 感知不到。
背后是一条朴素的架构原则:变化频率高的放外面,变化频率低的放里面。 协议变化最快,业务次之,存储最慢------把不同速度的东西分开,改动的影响半径就最小。
收益五:团队并行协作
接口定义好之后,前端对着接口 Mock 数据做页面,Controller / Service / Dao 甚至能分给不同的人同时开工:前端联调看 Controller,业务开发写 Service,DBA 优化 Dao 层 SQL,各角色并行互不阻塞。
加分金句:分层最大的价值是强制约定------它让团队里水平参差的 20 个人,写出结构一致的代码。这比任何个人技术都重要。
六、深挖到源码级:事务失效重灾区
讲到这里,面试官最喜欢的追问来了:"你说事务加在 Service,那为什么我在 Service 里这么写就不回滚了?"
java
// ❌ 事务失效重灾区:同类 this 调用
@Service
public class OrderService {
public void batchCreate(List<OrderDTO> list) {
list.forEach(this::createOne); // 问题就在这个 this
}
@Transactional(rollbackFor = Exception.class)
public void createOne(OrderDTO dto) { /* 扣库存 + 落单 */ }
}
根因 :Spring 声明式事务靠 AOP 动态代理实现,只有从代理对象进入 的调用才会被事务拦截器接管。this 指向的是原始目标对象,压根没经过代理,@Transactional 等于白加。
四种修法,按推荐度排序:
java
// ✅ 推荐①:把事务方法拆到独立的 Service,边界清晰,职责也更单一
@Service
@RequiredArgsConstructor
public class OrderTxService {
@Transactional(rollbackFor = Exception.class)
public void createOne(OrderDTO dto) { /* ... */ }
}
// ✅ ②:TransactionTemplate 编程式事务,边界自己控制,最直观
private final TransactionTemplate txTemplate;
public void batchSave(List<Order> list) {
txTemplate.executeWithoutResult(status -> {
try {
list.forEach(orderMapper::insert);
} catch (Exception e) {
status.setRollbackOnly(); // 精准控制回滚
throw e;
}
});
}
// ③:暴露代理,从代理进入(需 @EnableAspectJAutoProxy(exposeProxy = true))
((OrderService) AopContext.currentProxy()).createOne(dto);
// ⚠️ ④:注入自身代理,能用但有循环依赖风险,别当常规手段
@Lazy @Resource private OrderService self;
顺手把 Spring 事务失效的完整检查清单奉上------面试官一听就知道你踩过坑:
| 失效场景 | 为什么 | 怎么修 |
|---|---|---|
同类 this 自调用 |
绕过 AOP 代理 | 拆 Service / TransactionTemplate / AopContext |
方法不是 public |
代理无法拦截 private、final、static 方法 | 事务方法必须 public |
| 抛的是检查异常 | 默认只回滚 RuntimeException 和 Error |
rollbackFor = Exception.class |
try/catch 把异常吞了 |
事务管理器根本不知道出错了 | 别吞异常,或手动 setRollbackOnly() |
| 数据库引擎不支持事务 | MyISAM 上 commit/rollback 直接被忽略,还不报错 | 建表用 InnoDB |
| 传播行为配错 | 如 REQUIRES_NEW 配在了不该独立提交的方法上 |
明确每个业务方法的事务语义 |
比失效更隐蔽的:长事务拖垮连接池
事务里塞了 RPC、发短信、发 MQ,DB 连接被长时间占用,连接池很快被打满,接口集体超时。处理原则很明确:
事务里只做 DB 操作。 RPC、HTTP、文件、发消息全部挪到事务外;下游通知用
@TransactionalEventListener(AFTER_COMMIT)或TransactionSynchronization在事务提交后执行。跨服务的最终一致性,用本地消息表 / Outbox 模式 + MQ ,配合失败重试 + 幂等消费;强一致场景再考虑 TCC / Saga + 幂等补偿。
至于事务传播行为,日常开发默认 REQUIRED 够用,只有"这段逻辑必须独立提交、不受外层事务影响"(比如审计日志)时才用 REQUIRES_NEW。传播机制的完整版篇幅太长,我后面会单独写一篇拆透,感兴趣的话先点个关注,更新不迷路。
七、实战里的这些坑,几乎人人都踩过
分层不是分完就万事大吉,真正折磨人的是落地之后的坑。这些坑我基本都踩过,一个个说。
坑一:N+1 查询------列表页一查上千条 SQL
在 Service 里循环调 Dao,是分层后最常见的性能事故:
java
// ❌ 反模式:1 次订单查询 + N 次用户查询
for (Order o : orders) {
userMapper.selectById(o.getUserId());
}
// ✅ 正解:IN 批量查询 + 内存 Map 组装
List<Long> userIds = orders.stream().map(Order::getUserId).distinct().toList();
Map<Long, UserPO> userMap = userMapper.selectByIds(userIds).stream()
.collect(Collectors.toMap(UserPO::getId, Function.identity()));
把 100 次查询压成 1 次 IN 查询,MyBatis 用 <foreach> 拼批量条件,JPA 可以用 @BatchSize,必要时上 JOIN 或缓存。分层和性能不冲突,反而给了你一个固定的优化切点------Dao 提供批量接口,Service 负责编排。
坑二:深分页------limit 100000, 20 慢成狗
offset 越大,MySQL 扫的行越多。解法是游标分页 :where id > #{lastId} order by id limit #{size},配合覆盖索引,翻到多深都快。
坑三:对象爆炸------每层一套 PO/BO/VO/DTO,类数量膨胀
这是分层最直观的代价。应对有两招:一是用 MapStruct 编译期自动转换 替代手写 getter/setter 和 BeanUtils,性能提升 10 倍以上;二是合理复用对象,避免过度分层------极简业务没必要四套对象全上。
坑四:Entity 直接返前端------密码字段说漏就漏
json
{
"id": 1,
"username": "tom",
"password": "$2a$10$xxxx",
"deleted": 0,
"createTime": "2026-01-01"
}
密码、逻辑删除字段直接暴露,关联对象还可能导致 JSON 序列化死循环。解法:DTO 进、VO 出、PO 只在 Dao 流转 ,MapStruct 做转换,实在要临时挡一下用 @JsonIgnore,但那只是补丁,不是方案。
坑五:校验逻辑散落------Controller 校一遍,Service 又校一遍
分工其实很清晰:Controller 做格式校验 (@Valid + 分组校验,管"参数长得对不对"),Service 做业务规则校验(管"这事能不能办")。格式校验放 Service 是浪费,业务校验放 Controller 是越界。
坑六:异常跨层污染------Dao 抛 SQLException,Controller 到处 try-catch
统一用自定义 BizException + @RestControllerAdvice 兜底(4.5 节那套),Service 负责把底层异常翻译成业务异常,Controller 一个 try-catch 都不用写。
坑七:单例 Bean 的线程安全------并发下数据串号
Service 是单例的,禁止定义可变成员变量 。需要状态就用方法参数传递,或者 ThreadLocal(记得在 finally 里清理,否则线程池场景下会内存泄漏 + 数据串台)。
坑八:循环依赖------A 注入 B,B 又注入 A
启动直接报 BeanCurrentlyInCreationException。别靠字段注入硬撑,正确姿势是从设计上消除:抽取第三方 Service、用事件解耦、做接口隔离;构造器注入能让问题在启动时就暴露,反而是好事。实在绕不开才用 @Lazy,那是最后的逃生门,不是常规武器。
坑九:分层变成"机械搬砖"------Service 一路透传
很多项目分层后变成:Controller 调 Service,Service 调 Dao,一路透传,看起来分层了,实际上没有业务抽象。Service 不应该只是 Dao 的代理:
java
// 不够好:纯透传
public User getById(Long id) {
return userDao.selectById(id);
}
// 更好:承载了业务规则
public UserVO getDetail(Long id) {
User user = userDao.selectById(id);
if (user == null) {
throw new BizException(ErrorCode.USER_NOT_FOUND);
}
return UserConverter.toDetailVO(user);
}
如果一个 Service 方法真的只是转一遍,没有业务校验、没有跨 Dao 编排、没有事务,那说明这个接口本来就不需要 Service------这是代码问题,不是分层问题。
八、别靠人眼守分层:用 ArchUnit 锁死它
分层靠嘴说、靠 Code Review 提醒,都会慢慢烂掉。我的做法是把架构规则写成测试用例,跑进 CI ------新人第一天乱调用,mvn test 直接红给他看,不用等人来 Code Review 时尴尬。
xml
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>
java
@AnalyzeClasses(packages = "com.example.order", importOptions = ImportOption.DoNotIncludeTests.class)
class 分层架构守护Test {
/** Controller 不许绕过 Service 直连 Dao */
@ArchTest
static final ArchRule Controller不得直连Dao = noClasses()
.that().resideInAPackage("..controller..")
.should().dependOnClassesThat().resideInAPackage("..dao..");
/** Dao 是底,不许反向依赖上层 */
@ArchTest
static final ArchRule Dao不得反向依赖 = noClasses()
.that().resideInAPackage("..dao..")
.should().dependOnClassesThat().resideInAnyPackage("..service..", "..controller..");
/** Service 保持"业务纯净":不碰 Web 容器和 Servlet 对象 */
@ArchTest
static final ArchRule Service不得依赖Web层 = noClasses()
.that().resideInAPackage("..service..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework.web..", "javax.servlet..", "jakarta.servlet..");
/** Controller 不许出现 SQL ------ 这事不属于它 */
@ArchTest
static final ArchRule Controller不许写SQL = noClasses()
.that().resideInAPackage("..controller..")
.should().dependOnClassesThat().resideInAnyPackage("java.sql..", "org.springframework.jdbc..");
/** Controller 不许开事务 */
@ArchTest
static final ArchRule Controller不许开事务 = noClasses()
.that().resideInAPackage("..controller..")
.should().beAnnotatedWith(Transactional.class);
}
这就是"架构约束自动化":机器能卡的,别靠人记。 这也是分层能不能长期守住的关键。配合两条软约定效果更好:一是约定要具体(Controller 方法只做校验、转换、封装,超过 15 行就要问一句为什么);二是 Code Review 专盯边界,特别盯"新加的 if 属于业务还是格式"。
九、分层不是教条:什么时候可以打破边界
如果面试官反问"你们是不是过度设计了",别慌,正面接住。分层的判据始终是变化轴,而不是"教科书上写了三层":
- Service 只剩透传的简单查询 ,别硬套三层。单表详情、纯列表分页这类读接口,可以让 Controller 直连读模型(Dao / Repository),少一层空转发。这叫胖模型、瘦服务 ,是务实的选择。用 CQRS 的思路说就是:查询侧读多写少可以直通,但写侧必须保留 Service 做事务边界。
- 写操作、跨表编排、有业务规则的地方,必须过 Service,事务边界和规则聚居地不能丢。
- 小项目为了快速上线可以简化 ,甚至合并 Service/Dao,但逻辑边界要留,别为了三层而三层,也别为了一时省事把未来重构的路堵死。
一句话:理解分层,是合格工程师;知道什么时候可以不分层,才是架构师。
Service 越写越胖怎么办
业务复杂度上来后,单个 OrderServiceImpl 动辄几千行,成了"上帝类",谁都不敢改。演进路线有三档:
- 按业务域 / 用例拆 Service :
OrderQueryService、OrderPayService、UserRegisterService、RiskControlService,一个 Service 只负责一类业务能力; - 引入 Manager 层(阿里开发规范的经典四层):Controller → Service → Manager → Dao。Manager 层沉淀通用能力------第三方接口封装、缓存组合、复用逻辑,Service 只做编排;
- 复杂到"规则之间互相组合"时,上 DDD:应用层只做编排,领域层承载业务规则和充血模型,业务下沉到实体本身。
| 架构 | 结构 | 适用场景 |
|---|---|---|
| 经典三层 | Controller → Service → Dao | CRUD 为主、业务规则不复杂的中小项目,成本最低的成熟范式 |
| 阿里规范四层 | Controller → Service → Manager → Dao | 需要沉淀通用能力:三方封装、缓存组合、复用逻辑 |
| DDD 四层 | 接口层 → 应用层 → 领域层 → 基础设施层 | 业务规则复杂到贫血模型扛不住,团队开始用业务术语讨论问题 |
三层按技术职责 切分,DDD 按业务语义切分,本质是把三层里的 Service 再横切一刀。三层是基础款,DDD 是复杂业务的升级款------别为了架构而架构。
微服务下也一样成立:Dao 的物理形态从"访问我的库"变成"Feign 调别人的服务",但这一层要做的事反而更多------超时、重试、熔断、幂等、降级都得封在这一层,绝不能让业务代码直接感知 Feign 细节,否则 Service 又被 HTTP 细节污染了。
十、面试高频追问,一次答个痛快
把最常被追问的几个问题整理成快问快答,建议直接内化:
Q1:Service 只是把 Dao 的方法转一遍,那还要它干嘛?
如果真是转一遍,那是代码问题不是分层问题。我会反问一句:这个 Service 有没有业务校验、有没有跨 Dao 编排、有没有事务?有,它就不是透传;真没有,我同意简化。分层不是要求每个方法都走三层。
Q2:Controller 为什么不能直连 Dao?我就查个用户详情。
技术上能,@Autowired UserDao 就能跑。但分层约定破了口子之后,第二个人会直连 Dao 做多表关联,第三个人就在 Controller 里写 SQL 了。我的做法是留一个明确的出口:简单只读查询可以走读模型,写操作和有规则的查询必须过 Service,而且用 ArchUnit 卡住。
Q3:Service 一定要写接口吗?
不强制。单实现可以省略,但需要多实现、AOP 代理、Mock 解耦时,接口的价值就很大。
Q4:分层和 DDD 是什么关系?
三层按技术职责切,DDD 按业务语义切。三层适合 CRUD 为主的系统;业务规则复杂到团队开始用业务术语而不是"这个 SQL 怎么改"来讨论问题时,就该往 DDD 演进了。
Q5:分层会不会拖慢性能?
分层本身的开销可以忽略,多一层方法调用而已。真正影响性能的是跨层边界上的交互次数------也就是 N+1。解法在 7.1 节:Service 编排、Dao 提供批量接口。
Q6:复杂查询不知道该放哪?
列表页要查"用户信息 + 订单数 + 最近登录时间 + 标签 + 优惠券状态",在 Service 里循环查就是 N+1。按场景选方案:
| 场景 | 方案 |
|---|---|
| 简单业务查询 | Service 组装多个 Dao |
| 复杂报表 / 列表 | 单独写 QueryService / QueryDao |
| 高频复杂查询 | 冗余字段、缓存、ES、宽表 |
| 强事务写入 | 走主链路 Dao |
| 弱一致查询 | 走读库或异步同步 |
核心思想:命令查询可以分离(CQRS),复杂查询可以单独建模,不一定非要复用写入模型。
Q7:多端接口怎么复用?
Controller 按端拆(app/、admin/、openapi/),共用同一套 Service------这才是分层最大的复用红利。
Q8:你怎么保证团队真的按分层写?
三件事:约定要具体、Code Review 盯边界、架构守护测试进 CI。
Q9:分层有哪些代价?
主动说出来反而加分:类数量爆炸、调用链路变长、调试复杂度上升。对应的优化:通用基类(BaseController、BaseService)收样板代码、极简业务省略 Service 接口、MapStruct 消化转换成本。架构思维的全局观,就体现在敢讲弊端。
十一、写在最后
把全文浓缩成一段,面试前 30 秒扫一遍就够:
分 Controller、Service、Dao 三层,核心是为了职责分离和解耦。Controller 负责协议适配、参数校验和返回封装;Service 负责业务逻辑、事务控制和流程编排;Dao 负责数据库访问。改接口协议主要改 Controller,改业务规则主要改 Service,换数据库主要改 Dao。它的本质不是代码的分堆,而是**「变化速率」的分层**------把业务规则这份最值钱的资产,从 HTTP 协议和数据库细节里保护出来。当然,分层不是教条,没有业务规则的简单查询,允许它直连读模型。
落到行动上,给你一份可以直接抄的落地清单:
- 把三层职责和红线写进团队规范,越具体越好(比如"Controller 方法不超过 15 行");
- 事务统一收敛在 Service,全部加
rollbackFor = Exception.class,事务里只做 DB 操作; - 统一
Result返回体 +BizException+ 全局异常处理器; - DTO 进、VO 出、PO 不出 Service,转换交给 MapStruct;
- ArchUnit 架构守护测试进 CI,机器能卡的别靠人记;
- 简单读接口允许直通,写操作必须过 Service------把这条也写进规范。
三层架构不是银弹,也不是 Java 的强制规范,但它是经过无数互联网项目验证的、成本最低的成熟架构范式。它的好,在项目还小的时候感受不到,等到代码几十万行、团队十几个人、需求天天变的时候,你就知道这几层墙有多值钱了。
好的架构不是为了让代码看起来"专业",而是为了让系统在需求变化面前不崩溃。
聊到这儿,想问问大家:你们公司的项目分了几层?是标准三层、阿里四层,还是"勇气可嘉"的两层? 你有没有踩过事务失效的坑,遇到的是哪种场景?评论区聊聊,看看谁踩的坑最离谱。