前言
在 Java 后端开发中,采用经典的三层架构(Controller - Service - DAO/Mapper)是业界广泛接受的工程实践。这种分层结构通过职责分离,提升了代码的可维护性、可测试性和可扩展性。
然而,在实际开发过程中,一个常见且关键的设计问题常常困扰开发者:
在 Service 层中,当需要访问其他模块的数据或功能时,应该注入对应的 Mapper(或 Repository/DAO),还是注入另一个 Service?
这个问题看似简单,但其背后涉及架构设计原则、职责边界划分、事务管理、代码复用性与系统耦合度等多个维度的考量。
一、三层架构回顾:职责与边界
在典型的基于 Spring Boot + MyBatis 的 Java Web 应用中,三层架构的职责如下:
| 层级 | 职责 | 典型组件 |
|---|---|---|
| Controller 层 | 接收 HTTP 请求,参数校验,调用 Service,封装响应 | @RestController, DTO, 参数校验注解 |
| Service 层 | 实现核心业务逻辑,协调多个数据操作,管理事务 | @Service,@Transactional |
| DAO / Mapper 层 | 封装数据库操作,提供 CRUD 接口 | MyBatisMapper接口,JPARepository |
📌关键原则:每一层只应与其直接下层交互,避免跨层调用(如 Controller 直接调用 Mapper)。
二、Service 层的依赖注入选项
当一个 Service(例如OrderService)需要访问其他实体(如用户、商品、库存)的数据或行为时,它有两种主要的依赖注入选择:
- 注入目标实体的 Mapper(如
UserMapper) - 注入目标实体的 Service(如
UserService)
这两种方式在语法上均可行,但其适用场景和设计含义截然不同。
三、何时注入 Mapper?—— 数据访问的直接路径
✅ 适用场景
当你仅需读取或写入原始数据,且不涉及目标模块的业务规则、校验、事务或副作用时,应直接注入对应的 Mapper。
🧩 示例场景
- 查询用户基本信息用于订单创建;
- 更新商品浏览次数;
- 记录操作日志到日志表;
- 批量插入中间表关联数据。
💡 优势
- 职责清晰:Service 只负责自己的业务逻辑,数据访问委托给 Mapper。
- 性能高效:避免不必要的方法调用栈和代理开销。
- 低耦合:不依赖其他 Service 的实现细节,仅依赖数据结构。
- 易于测试:Mock Mapper 即可完成单元测试,无需启动整个 Service 上下文。
📄 代码示例
@ServicepublicclassOrderService{@AutowiredprivateOrderMapperorderMapper;@AutowiredprivateUserMapperuserMapper;// 直接注入,仅用于查询用户是否存在publicvoidcreateOrder(CreateOrderDTOdto){// 仅验证用户是否存在,无复杂业务逻辑Useruser=userMapper.selectById(dto.getUserId());if(user==null){thrownewBusinessException("用户不存在");}Orderorder=newOrder();order.setUserId(dto.getUserId());order.setProductId(dto.getProductId());orderMapper.insert(order);}}🔍 注意:此处
userMapper.selectById()仅返回数据,不包含“激活用户”、“检查黑名单”等业务逻辑。
四、何时注入其他 Service?—— 复用完整业务逻辑
✅ 适用场景
当你需要复用目标模块封装好的完整业务行为,包括但不限于:
- 数据校验(如用户状态是否有效);
- 事务控制(如库存扣减需回滚);
- 副作用处理(如发送通知、记录审计日志);
- 状态机变更(如订单状态流转);
- 权限或安全检查。
此时,应注入对应的 Service,而非直接操作其 Mapper。
🧩 示例场景
- 创建订单时需扣减库存(库存服务包含超卖检查、事务、日志);
- 用户注册时需发送欢迎邮件(邮件服务封装了模板、重试、异步);
- 支付成功后需更新会员等级(等级计算涉及多张表和规则引擎)。
💡 优势
- 逻辑复用:避免重复实现相同业务规则,符合 DRY(Don’t Repeat Yourself)原则;
- 一致性保障:所有入口都走同一套业务流程,确保系统状态一致;
- 可维护性高:业务规则变更只需修改一处。
⚠️ 注意事项
- 避免循环依赖:A Service 注入 B,B 又注入 A,会导致 Spring 启动失败或运行时异常;
- 事务传播行为:需明确
@Transactional的传播机制(如REQUIREDvsREQUIRES_NEW); - 代理调用限制:在同一个类中通过
this.otherMethod()调用带事务的方法会绕过 Spring 代理,应通过注入的 Bean 调用。
📄 代码示例
@ServicepublicclassOrderService{@AutowiredprivateOrderMapperorderMapper;@AutowiredprivateInventoryServiceinventoryService;// 注入 Service,因需完整业务逻辑@TransactionalpublicvoidcreateOrder(CreateOrderDTOdto){// 检查用户(可直接用 Mapper)Useruser=userMapper.selectById(dto.getUserId());if(user==null)thrownewBusinessException("用户不存在");// 扣减库存 —— 必须通过 Service,因其包含:// - 库存充足性检查// - 乐观锁更新// - 库存流水记录// - 可能触发补货通知inventoryService.deductStock(dto.getProductId(),dto.getQuantity());// 创建订单Orderorder=newOrder(dto.getUserId(),dto.getProductId(),dto.getQuantity());orderMapper.insert(order);}}五、错误实践与反模式
❌ 反模式 1:为了“解耦”而强行通过 Service 访问简单数据
// 错误示例:UserService.getUserById() 仅返回 userMapper.selectById(id)Useruser=userService.getUserById(userId);// 无必要!问题:增加调用链深度,引入无意义的 Service 层包装,降低性能,且若未来
UserService添加了权限校验,可能意外破坏OrderService的逻辑。
❌ 反模式 2:在 Service 中直接操作其他模块的 Mapper,却忽略了业务规则
// 危险示例:直接更新用户余额userMapper.updateBalance(userId,newBalance);// 绕过了资金变动审计、风控等逻辑后果:系统出现“幽灵资金变动”,审计日志缺失,违反金融合规要求。
❌ 反模式 3:Service 内部通过 this 调用自身带事务的方法
@ServicepublicclassOrderService{publicvoidmethodA(){this.methodB();// ❌ 不会触发 @Transactional}@TransactionalpublicvoidmethodB(){...}}正确做法:通过 self-injection 或重构为两个 Service。
六、决策流程图:如何选择?
七、高级考量:领域驱动设计(DDD)视角
在更复杂的系统中,可引入领域驱动设计(DDD)思想进一步指导分层:
- 聚合根(Aggregate Root):只有聚合根的 Repository 可被外部 Service 直接调用;
- 领域服务(Domain Service):跨聚合的业务逻辑应封装在领域服务中;
- 应用服务(Application Service):即传统 Service 层,协调领域对象和基础设施。
在此模型下,跨聚合的数据访问必须通过领域服务或聚合根方法,禁止直接操作其他聚合的 Mapper。
虽然本文聚焦于传统三层架构,但 DDD 提供了更高阶的解耦思路,值得进阶开发者参考。
八、总结
Service 层应优先注入 Mapper 来访问数据;仅当需要复用其他模块的完整业务逻辑时,才注入其他 Service。
具体判断标准如下:
| 判断维度 | 注入 Mapper | 注入 Service |
|---|---|---|
| 目的 | 获取/存储原始数据 | 执行完整业务行为 |
| 是否含业务规则 | 否 | 是 |
| 是否含副作用 | 否 | 是(如发消息、记日志) |
| 是否需事务协调 | 否 | 是 |
| 是否可能变更 | 数据结构稳定 | 业务逻辑可能演进 |