编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 面向对象设计

  • 常见设计原则

  • 巧学设计模式

  • 系统架构设计

    • README
    • 分层架构设计详解
      • 1. 案例引入
        • 1.1 一次上线全栈崩
        • 1.2 顺藤摸到根因
        • 1.3 我们要回答什么
      • 2. 架构概览
        • 2.1 经典四层总图
        • 2.2 为什么这么切
      • 3. 各层职责边界
        • 3.1 Controller 接入层
        • 3.2 Service 业务层
        • 3.3 Repository 持久层
        • 3.4 Domain 领域模型
        • 3.5 DTO/VO/PO/BO 数据载体
      • 4. 依赖方向铁律
        • 4.1 自上而下单向
        • 4.2 反向依赖的代价
        • 4.3 跨层调用的禁忌
        • 4.4 依赖倒置救场
      • 5. 循环依赖破解
        • 5.1 循环依赖现场
        • 5.2 三种破解手术
        • 5.3 ArchUnit 守门
      • 6. 分层不彻底反例
        • 6.1 贫血模型陷阱
        • 6.2 Service 上帝类
        • 6.3 Controller 漏业务
        • 6.4 PO 直返前端
      • 7. 事务与异常边界
        • 7.1 事务该放哪层
        • 7.2 异常的翻译规则
        • 7.3 日志的分层策略
      • 8. 测试金字塔回扣
        • 8.1 单元测试落 Service
        • 8.2 集成测试落 Repository
        • 8.3 接口测试落 Controller
      • 9. 演进实战剖析
        • 9.1 三层到四层的进化
        • 9.2 引入 Application 层
        • 9.3 向六边形架构过渡
      • 10. 综合案例串讲
        • 10.1 案例真相揭晓
        • 10.2 一个请求的一生
        • 10.3 设计哲学回扣
        • 10.4 分层速查清单
    • 六边形架构设计
    • 命令查询职责分离
    • 事件驱动架构设计
    • 微服务拆分策略
    • 领域驱动战略设计
    • 架构评审方法论
    • 架构演进实战指南
  • 编程
  • 系统架构设计
杨充
2021-09-25
目录

分层架构设计详解

# 01.分层架构设计详解

# 目录介绍

  • 1. 案例引入
    • 1.1 一次上线全栈崩
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 经典四层总图
    • 2.2 为什么这么切
  • 3. 各层职责边界
    • 3.1 Controller 接入层
    • 3.2 Service 业务层
    • 3.3 Repository 持久层
    • 3.4 Domain 领域模型
    • 3.5 DTO/VO/PO/BO 数据载体
  • 4. 依赖方向铁律
    • 4.1 自上而下单向
    • 4.2 反向依赖的代价
    • 4.3 跨层调用的禁忌
    • 4.4 依赖倒置救场
  • 5. 循环依赖破解
    • 5.1 循环依赖现场
    • 5.2 三种破解手术
    • 5.3 ArchUnit 守门
  • 6. 分层不彻底反例
    • 6.1 贫血模型陷阱
    • 6.2 Service 上帝类
    • 6.3 Controller 漏业务
    • 6.4 PO 直返前端
  • 7. 事务与异常边界
    • 7.1 事务该放哪层
    • 7.2 异常的翻译规则
    • 7.3 日志的分层策略
  • 8. 测试金字塔回扣
    • 8.1 单元测试落 Service
    • 8.2 集成测试落 Repository
    • 8.3 接口测试落 Controller
  • 9. 演进实战剖析
    • 9.1 三层到四层的进化
    • 9.2 引入 Application 层
    • 9.3 向六边形架构过渡
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一个请求的一生
    • 10.3 设计哲学回扣
    • 10.4 分层速查清单

# 1. 案例引入

# 1.1 一次上线全栈崩

先看一段在真实项目里跑了两年的代码,看起来"分了层"——有 Controller、有 Service、有 Mapper,标准三件套,却在某次"加一个字段"的需求后,导致 全链路返回 NPE,30 分钟无法回滚:

// OrderController.java —— "看起来分层了"的接入层
@RestController
@RequestMapping("/order")
public class OrderController {

    @Autowired private OrderMapper orderMapper;        // ← 直接注入 Mapper
    @Autowired private UserMapper userMapper;          // ← 跨 Service 注入
    @Autowired private OrderService orderService;

    @GetMapping("/{id}")
    public OrderPO getOrder(@PathVariable Long id) {   // ← 直接返回 PO
        OrderPO order = orderMapper.selectById(id);
        UserPO user = userMapper.selectById(order.getUserId());
        order.setUserName(user.getName());             // ← 给 PO 塞了个临时字段
        return order;
    }

    @PostMapping("/create")
    @Transactional                                     // ← 事务挂在 Controller
    public Long create(@RequestBody OrderPO order) {
        orderMapper.insert(order);
        orderService.sendNotify(order.getId());        // ← 又跳回去调 Service
        return order.getId();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

现象:

  • 上线前 QA 测试环境:全部通过
  • 上线后第 3 分钟:APM 告警 NullPointerException,日志只有一句 at OrderController.getOrder(OrderController.java:23)
  • 回滚?不行——这次上线连带数据库 DDL 一起改了 user.name → user.nickname,回滚 SQL 不向后兼容

直觉怀疑:是不是新加的字段没初始化?翻日志一看,崩溃栈打印的 order 不是 null,user 也不是 null——崩的是 user.getName(),因为列被改成了 nickname,而 UserPO 的 name 字段读出来变成了 null。

更糟的是同一个 OrderPO 在 17 个 Controller 方法里被直接返回给前端,前端 H5、Android、iOS、小程序、开放 API 五端同时炸——一个数据库字段改名,引发五端联合事故。

# 1.2 顺藤摸到根因

带着这条线往下挖:

  • 假设 1:是不是漏改了字段映射?—— 是,但不该一处变动就引爆五端。
  • 假设 2:为什么 PO 能直接返回给前端?—— 因为 Controller 拿 Mapper 就用了,根本没经过 Service 转换。
  • 假设 3:为什么 Controller 能直接拿 Mapper?—— Spring 的 @Autowired 不挑剔,任何 Bean 都能注入。
  • 假设 4:那"分层"这件事在哪个环节没被守住?—— 整个工程没有任何机制保证"上层不能直接调下下层"。
  • 假设 5:事务为什么放在 Controller?—— 因为"放哪都能跑",没人告诉过新人事务的语义是业务一致性,应该在 Service。
  • 假设 6:为什么测试环境通不出?—— QA 用的是同一份 PO,DDL 也是同步改的,绕过了真实的字段映射验证。
  • 假设 7:根因到底是什么?—— 分层只在文件夹层面分了,没在依赖关系上分——没有铁律,只有约定,约定一旦松懈就崩。

看起来"按 MVC 分了三层",毛病不在哪一行代码,毛病在"分层是个 PPT,不是个机制"——这条代码碰到的不是 Java 的坑,是架构纪律的坑。

这一段事故里至少藏着 7 个原理点:

① 每一层到底该承担什么职责?                          → 第 3 章
② 为什么 Controller 不能直接调 Mapper?               → 第 4 章
③ PO/VO/DTO 凭什么不能合二为一?                      → 第 3.5 节
④ 循环依赖怎么发生、怎么破?                          → 第 5 章
⑤ @Transactional 到底放哪一层?                       → 第 7.1 节
⑥ "贫血模型"为什么被骂、又为什么还在用?               → 第 6.1 节
⑦ 怎么用自动化手段保证"分层不被破坏"?                 → 第 5.3 节
1
2
3
4
5
6
7

# 1.3 我们要回答什么

这个事故就是本篇的主线案例。我们带着上面 7 个问号往下走,每讲完一段原理就解开一两个;最后在第 10 章把案例彻底剖开,并给出修复后的代码与对应的演进路径。

本篇路线:

架构总图 (第 2 章)
   ↓
四层职责边界 (第 3 章) ─→ 解开"每层该做什么不该做什么"
   ↓
依赖方向铁律 (第 4 章) ─→ 解开"为什么不能跨层、不能反向"
   ↓
循环依赖与反例 (第 5-6 章) ─→ 解开"分层不彻底的具体长相"
   ↓
事务/异常/测试 (第 7-8 章) ─→ 解开"分层的运行时与验证手段"
   ↓
演进路径 (第 9 章) ─→ 三层→四层→六边形
   ↓
综合案例 (第 10 章) ─→ 案例彻底剖开
1
2
3
4
5
6
7
8
9
10
11
12
13

📌 本篇定位:这是整个系列的地基篇。第 02-08 篇讲的六边形、CQRS、事件驱动、微服务、DDD,本质都是"在分层这张图上某一刀切得更深"。读完本篇后,再看后面任何一个架构模式,都能立刻回答:"它是在哪一层、解决了分层架构的哪个痛点"。

# 2. 架构概览

# 2.1 经典四层总图

我们看一个典型 Java/Spring 服务的分层结构:

请求 (HTTP / RPC / MQ)
        │
        ▼
┌──────────────────────────────────────────────────┐
│         Controller / Web 层 (接入)                │  ← 协议解析、参数校验、鉴权
│         @RestController / @GrpcService            │
├──────────────────────────────────────────────────┤
│                                                  │
│         Application / Service 层 (业务编排)        │  ← 用例 (use case)、事务边界
│         @Service                                  │
│                                                  │
├──────────────────────────────────────────────────┤
│                                                  │
│         Domain 层 (领域模型 + 领域服务)             │  ← 核心业务规则、值对象、实体
│         POJO + 领域行为                            │
│                                                  │
├──────────────────────────────────────────────────┤
│                                                  │
│         Repository / Infrastructure 层 (基础设施)  │  ← 持久化、缓存、外部服务
│         @Repository / @Component                  │
│                                                  │
└──────────────────────────────────────────────────┘
        │
        ▼
   MySQL / Redis / 外部 API
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

⚠️ 命名差异提示:业界对四层的叫法不统一——经典 J2EE 叫 Web/Service/DAO,DDD 叫 Interface/Application/Domain/Infrastructure,阿里规约叫 web/manager/service/dao。名字不重要,职责切割与依赖方向才是本质。

四层的核心属性速查:

层 关键词 输入 输出 不应做的事
Controller 协议适配 HTTP/RPC 请求 DTO/VO 业务规则、事务、SQL
Service 业务编排 DTO DTO HTTP 协议、SQL 拼接
Domain 业务规则 值对象/实体 值对象/实体 框架依赖、IO
Repository 数据访问 查询参数 PO/实体 业务判断、事务编排

# 2.2 为什么这么切

为什么把一个 Java 应用切成"四层",而不是统一一锅粥?

疑惑:直接一个 Controller 把所有逻辑写完,简单直接不行吗?小项目不就这么干的?

论证:

  1. 变化的频率与方向不同——HTTP 协议三五年变一次(HTTP/2、HTTP/3、gRPC),业务规则三五天变一次,数据库引擎五年迁一次。把变化频率不同的东西切开,是为了让"业务变更不动协议","数据库迁移不动业务"。这是变化的隔离。
  2. 替换的代价不同——MyBatis → JOOQ、MySQL → PostgreSQL、REST → GraphQL,这些都是技术选型的替换。良好分层让替换只影响一层;分层不彻底,则一次替换牵动全身(本篇主线案例就是这条)。
  3. 测试的难度不同——Service 是纯 Java 逻辑,单测应该秒级、零依赖;Repository 需要数据库,集成测试分钟级;Controller 需要起 HTTP 服务。测试金字塔的不同层对应不同分层(第 8 章)。
  4. 复用的颗粒度不同——同一个"扣减库存"业务,可能被 HTTP 接口、定时任务、MQ 消费者三个入口同时调用。Service 是复用单元,Controller 只是入口适配器——分层让复用成为可能。
  5. 依赖外部的程度不同——Domain 应该是"纯净的业务知识",Infrastructure 是"脏活累活"。把"业务知识"与"技术细节"切开,是 DDD 的核心思想,也是六边形架构的起点。
  6. 反向验证:如果不分层会怎样?参考"上千行的 Controller"——加新需求要先读懂所有历史 if-else;写单测要起整个 Spring;换 ORM 要重写所有 Controller。整个工程对修改的"边际成本"会指数级上升。

结论:分层不是为了"好看",而是把变化频率、替换代价、测试难度、复用颗粒度、外部依赖程度这五个独立维度同时编码进代码结构——一层一种语义,需求变更、技术升级、自动化测试、代码复用全都得益于此。这是企业级软件架构的根基哲学。

下面我们从最上层的 Controller 开始,看每一层"该做什么、不该做什么"。

# 3. 各层职责边界

# 3.1 Controller 接入层

Controller 的唯一职责:把外部协议(HTTP/gRPC/MQ)翻译成 Java 方法调用。

@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderAppService orderAppService;   // ← 只依赖 Service

    @PostMapping
    public Response<OrderVO> create(@RequestBody @Valid CreateOrderReq req) {
        // ① 参数校验(@Valid 已做大部分)
        // ② 协议层 → 业务层:DTO 转换
        CreateOrderCmd cmd = OrderConverter.toCmd(req);
        // ③ 调用业务层
        OrderDTO dto = orderAppService.create(cmd);
        // ④ 业务层 → 协议层:VO 转换
        return Response.ok(OrderConverter.toVO(dto));
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

该做的:

职责 说明
协议解析 HTTP 路由、参数绑定、Content-Type 协商
入参校验 JSR-303 注解 (@NotNull/@Size/@Pattern)
鉴权与限流 Spring Security / Sentinel 切面
DTO 转换 Req → Cmd、DTO → VO(用 MapStruct)
统一响应封装 Response<T> 包一层错误码
全局异常处理 @RestControllerAdvice 翻译异常为 HTTP 状态码

不该做的:

// ❌ 反例集合
@PostMapping
public OrderPO create(@RequestBody OrderPO order) {
    if (order.getAmount() < 0) throw new RuntimeException("金额异常");   // ❌ 业务规则
    order.setStatus(OrderStatus.CREATED);                                 // ❌ 业务状态
    BigDecimal discount = calculateDiscount(order);                        // ❌ 业务计算
    orderMapper.insert(order);                                            // ❌ 直接持久化
    return order;                                                          // ❌ 直接返回 PO
}
1
2
3
4
5
6
7
8
9

口诀:Controller 只做"翻译官",不做"决策者"。

# 3.2 Service 业务层

Service 是业务编排者,承担一个完整"用例(use case)"的执行:

@Service
@RequiredArgsConstructor
public class OrderAppService {

    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    private final DomainEventPublisher publisher;

    @Transactional(rollbackFor = Exception.class)
    public OrderDTO create(CreateOrderCmd cmd) {
        // 1. 加载领域对象
        Customer customer = customerRepository.findById(cmd.getCustomerId())
                .orElseThrow(() -> new BizException(ErrorCode.CUSTOMER_NOT_FOUND));

        // 2. 领域行为:让 Order 自己来计算价格、生成订单
        Order order = Order.create(customer, cmd.getItems());   // ← 业务规则在 Domain
        order.validate();

        // 3. 跨聚合协作:扣库存
        inventoryService.deduct(order.getItems());

        // 4. 持久化
        orderRepository.save(order);

        // 5. 发布领域事件
        publisher.publish(new OrderCreatedEvent(order.getId()));

        // 6. 组装出参
        return OrderAssembler.toDTO(order);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

该做的:

职责 说明
用例编排 顺序调用多个领域服务、跨聚合
事务边界 @Transactional 标在 Service 方法上
跨上下文调用 调外部 RPC、MQ、Redis 协作
分布式锁/幂等 防止并发与重复执行
领域事件发布 在事务内或外发布事件

不该做的:

  • ❌ 写 SQL / 拼 JdbcTemplate(应该委托 Repository)
  • ❌ 解析 HTTP/gRPC 协议
  • ❌ 把业务规则"if 商品价格 > 1000 则 ..."写在 Service 方法体里(应该在领域对象)

# 3.3 Repository 持久层

Repository 是领域对象与存储介质之间的翻译器,向上提供"集合语义",向下处理 ORM/SQL:

public interface OrderRepository {                       // ← 接口属于 Domain 层
    Optional<Order> findById(OrderId id);
    void save(Order order);
    List<Order> findByCustomer(CustomerId customerId);
}

@Repository                                              // ← 实现属于 Infrastructure 层
@RequiredArgsConstructor
public class OrderRepositoryImpl implements OrderRepository {

    private final OrderMapper orderMapper;               // MyBatis Mapper
    private final OrderItemMapper itemMapper;

    @Override
    public Optional<Order> findById(OrderId id) {
        OrderPO po = orderMapper.selectById(id.value());
        if (po == null) return Optional.empty();
        List<OrderItemPO> items = itemMapper.selectByOrderId(id.value());
        return Optional.of(OrderConverter.toDomain(po, items));   // ← PO → Domain
    }

    @Override
    public void save(Order order) {
        OrderPO po = OrderConverter.toPO(order);          // ← Domain → PO
        if (po.getId() == null) {
            orderMapper.insert(po);
            order.setId(new OrderId(po.getId()));         // ← 回写 ID
        } else {
            orderMapper.updateById(po);
        }
        // 子表全量重写(也可以做增量 diff)
        itemMapper.deleteByOrderId(po.getId());
        po.getItems().forEach(itemMapper::insert);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

关键设计:

  • 接口在 Domain,实现在 Infrastructure——这是"依赖倒置"在分层架构里的体现(第 4.4 节会展开)
  • 入参出参都是领域对象,PO 不出 Repository
  • 聚合根整存整取,避免"零散查询拼装"

# 3.4 Domain 领域模型

Domain 层是业务知识的纯净沉淀,不依赖任何框架:

// Order.java —— 富有行为的领域对象(不是 getter/setter 木偶)
public class Order {

    private OrderId id;
    private CustomerId customerId;
    private List<OrderItem> items;
    private Money totalAmount;        // ← 值对象
    private OrderStatus status;
    private Instant createdAt;

    private Order() {}                // ORM 反射需要

    /** 业务工厂方法 */
    public static Order create(Customer customer, List<OrderItemCmd> itemCmds) {
        if (itemCmds.isEmpty()) {
            throw new BizException(ErrorCode.EMPTY_ORDER);
        }
        Order order = new Order();
        order.customerId = customer.getId();
        order.items = itemCmds.stream().map(OrderItem::of).toList();
        order.totalAmount = order.calcTotal();           // ← 业务规则
        order.status = OrderStatus.CREATED;
        order.createdAt = Instant.now();
        return order;
    }

    /** 业务行为:取消 */
    public void cancel(String reason) {
        if (status != OrderStatus.CREATED) {
            throw new BizException(ErrorCode.CANNOT_CANCEL, status);
        }
        this.status = OrderStatus.CANCELLED;
    }

    private Money calcTotal() {
        return items.stream()
                .map(OrderItem::subtotal)
                .reduce(Money.ZERO, Money::add);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

Domain 的硬性约束:

  • 零框架依赖:不 import Spring、不 import MyBatis、不 import javax.servlet
  • 不可变优先:值对象(Money、Address)一律 final
  • 行为聚集:业务规则写成方法,而不是 getter+外部 if
  • 聚合根边界清晰:跨聚合用 ID 引用,不直接持有对方对象

💡 检验标准:Domain 包能不能被一个没有数据库、没有 HTTP 框架的纯 Java SE 工程直接编译通过?如果不行,说明你"分层不干净"。

# 3.5 DTO/VO/PO/BO 数据载体

跨层传递数据的载体常被滥用。本节给出严格定义:

类型 全称 所在层 生命周期 关注点
VO View Object Controller → 前端 一次响应 前端展示字段(含格式化)
DTO Data Transfer Object 跨进程/跨服务传输 一次调用 序列化友好、向后兼容
Req/Cmd Request / Command Controller → Service 一次调用 入参聚合
BO Business Object Service 内 一次用例 业务编排中间态
Domain Entity 实体 Domain 长期 业务规则与状态
PO Persistent Object Repository ↔ DB 与表行同寿 字段映射、索引

关键铁律:

前端 ──VO── Controller ──Cmd── Service ──Domain── Repository ──PO── DB
        ↑              ↑                  ↑                 ↑
        转换 1        转换 2             转换 3            转换 4
1
2
3

每跨一层都显式转换,不要图省事把 PO 直返前端(本篇主线事故的根因之一)。

疑惑:转换太繁琐,能不能合并?

论证:

  1. PO = VO 会出事——主线案例:DB 字段重命名直接打穿前端
  2. VO = DTO 会出事——开放 API 需要 DTO 稳定向后兼容,前端 VO 想加字段就加
  3. DTO = Domain 会出事——Domain 是富对象(带行为、带不变量),DTO 是贫数据载体(带 setter),序列化反序列化会破坏不变量

结论:四个不同生命周期、四种不同关注点,强行复用就是"为了少写 50 行映射,付出多次跨端事故"——用 MapStruct 自动生成转换代码,0 心智成本。

# 4. 依赖方向铁律

# 4.1 自上而下单向

分层架构最核心的约束:依赖只能从上往下,不能反向,不能跳层。

✅ 允许                       ❌ 禁止
┌──────────────┐             ┌──────────────┐
│ Controller   │             │ Controller   │
└──────┬───────┘             └──────┬───────┘
       │ ✅ 调用                    │      ╲
       ▼                            ▼       ╲ ❌ 反向
┌──────────────┐             ┌──────────────┐ │
│  Service     │             │  Service     │ │ ❌ 跳层
└──────┬───────┘             └──────┬───────┘ │
       │ ✅ 调用                    │         │
       ▼                            ▼         │
┌──────────────┐             ┌──────────────┐ │
│ Repository   │◄────❌──────│ Repository   │◄┘
└──────────────┘  反向调用    └──────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14

为什么这条铁律不能破?

  1. 可推理性:拿到任何一层的代码,只看本层和下层就能完全理解——不需要回头看"是谁调了我"
  2. 可测试性:上层依赖下层,Mock 下层就能单测上层;如果反向依赖,Mock 链会爆炸
  3. 可演进性:下层修改不影响上层——比如 Repository 实现从 MyBatis 换成 JOOQ,Service 一行不动

# 4.2 反向依赖的代价

看一段反向依赖的反例:

// ❌ Repository 调用 Service
@Repository
public class OrderRepositoryImpl implements OrderRepository {

    @Autowired private NotifyService notifyService;     // ← Repo 反向依赖 Service

    @Override
    public void save(Order order) {
        orderMapper.insert(toPO(order));
        notifyService.sendCreatedEvent(order);          // ← 在持久层发通知?
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

为什么这是错的?

  • 职责混乱:Repository 应该只关心"存",不关心"存完后做什么"
  • 事务陷阱:发通知如果失败,会让保存事务回滚——一个无关的副作用毁掉主流程
  • 测试困难:测 Repository 还要 Mock NotifyService 整条链
  • 循环风险:NotifyService 哪天也想 save 一条记录,循环就成了

正确做法:通知应该在 Service 编排:

@Service
public class OrderAppService {
    @Transactional
    public OrderDTO create(CreateOrderCmd cmd) {
        Order order = Order.create(...);
        orderRepository.save(order);
        notifyService.sendCreatedEvent(order);          // ← 在 Service 编排
        return toDTO(order);
    }
}
1
2
3
4
5
6
7
8
9
10

# 4.3 跨层调用的禁忌

主线案例的 Controller → Mapper 是经典跨层:

// ❌ Controller 直接调 Mapper(跳过 Service)
@RestController
public class OrderController {
    @Autowired private OrderMapper orderMapper;        // ← 跨过 Service
}
1
2
3
4
5

后果:

  1. 业务规则散落:Controller 里写"if order.status == ...",五个 Controller 五份逻辑
  2. 事务边界混乱:Controller 加 @Transactional 在 web 容器线程里挂事务,慢请求拖垮连接池
  3. 复用断裂:定时任务想复用"创建订单"逻辑,发现它在 Controller 里写着,复用不了
  4. 测试地狱:测一个 Controller 要起完整 Spring + DataSource

纪律:上层只能调相邻下层;跳层是技术债。

# 4.4 依赖倒置救场

疑惑:Domain 不能依赖框架,但 Domain 又要调用 Repository(数据库),这不矛盾吗?

论证:用依赖倒置原则(DIP)——让 Repository 接口属于 Domain 层,实现属于 Infrastructure 层:

       Domain 层 (纯净)               Infrastructure 层 (脏活)
┌────────────────────────┐         ┌──────────────────────────┐
│  Order  (实体)          │         │                          │
│                        │         │  OrderRepositoryImpl     │
│  interface             │ ◄───────│  implements              │
│  OrderRepository       │  实现   │  OrderRepository         │
└────────────────────────┘         │                          │
       ▲                            │  + MyBatis / JDBC        │
       │ 依赖                       └──────────────────────────┘
┌────────────────────────┐
│ OrderAppService        │
│ (调用接口)              │
└────────────────────────┘

依赖方向: Service → Repository接口 ← RepositoryImpl
                  (在 Domain 包)    (在 Infra 包)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

关键技巧:

  1. 接口放在 domain.repository 包下
  2. 实现放在 infrastructure.repository 包下
  3. Spring 启动时自动把实现注入到接口位置
  4. 编译期 Domain 包不依赖任何 ORM——你可以把 MyBatis 整个换成 JPA,Domain 一行不动

结论:依赖倒置不是为了"炫技",而是让"业务知识"获得对技术细节的免疫力——这是六边形架构的核心思想,在分层架构里就以"Repository 接口归 Domain"的形式体现。

# 5. 循环依赖破解

# 5.1 循环依赖现场

疑惑:明明都是 Service,A 调 B,B 调 A,怎么就不行了?

@Service
public class OrderService {
    @Autowired private UserService userService;

    public void createOrder(Long userId) {
        User user = userService.findById(userId);
        // ...
    }
}

@Service
public class UserService {
    @Autowired private OrderService orderService;    // ← 循环

    public UserVO getUserDetail(Long userId) {
        List<Order> orders = orderService.findByUserId(userId);
        // ...
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

Spring 启动时报:BeanCurrentlyInCreationException: Requested bean is currently in creation。

Spring 4.x+ 通过"三级缓存"能解决字段注入的循环;但构造器注入死活不行——而构造器注入恰恰是最佳实践。这等于强制你解决问题,不让你绕过。

# 5.2 三种破解手术

手术 1:提取共同抽象到 Domain 层

// 把双方都需要的能力抽到 Domain
public class UserOrderQuery {                        // Domain 服务
    public List<Order> findUserOrders(Long userId) { ... }
}

// OrderService 和 UserService 都依赖它,不再互相依赖
1
2
3
4
5
6

手术 2:领域事件解耦

@Service
public class OrderService {
    @Autowired private ApplicationEventPublisher publisher;

    public void createOrder(...) {
        // 不直接调 UserService,发事件
        publisher.publishEvent(new OrderCreatedEvent(...));
    }
}

@Service
public class UserService {
    @EventListener
    public void onOrderCreated(OrderCreatedEvent e) {
        // 异步处理,不形成调用环
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

手术 3:上提到 Application 层

// Service 层不互调,由更上层的 AppService 编排
@Service
public class UserOrderAppService {
    @Autowired private OrderService orderService;
    @Autowired private UserService userService;

    public UserOrderVO getDetail(Long userId) {
        User user = userService.findById(userId);
        List<Order> orders = orderService.findByUserId(userId);
        return assemble(user, orders);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

结论:循环依赖几乎总是"职责切分不清"的征兆,不要靠 @Lazy 绕过,要靠重构根除。

# 5.3 ArchUnit 守门

光靠人工 review 守不住分层。用 ArchUnit (opens new window) 在单测里强制:

@AnalyzeClasses(packages = "com.example.order")
public class LayerArchTest {

    @ArchTest
    static final ArchRule layer_rule = layeredArchitecture()
            .consideringAllDependencies()
            .layer("Controller").definedBy("..controller..")
            .layer("Service").definedBy("..service..")
            .layer("Domain").definedBy("..domain..")
            .layer("Repository").definedBy("..repository..")

            .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
            .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
            .whereLayer("Domain").mayOnlyBeAccessedByLayers("Service", "Repository")
            .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service");

    @ArchTest
    static final ArchRule no_controller_to_mapper =
            noClasses().that().resideInAPackage("..controller..")
                    .should().dependOnClassesThat().resideInAPackage("..mapper..");

    @ArchTest
    static final ArchRule no_cycles =
            slices().matching("com.example.order.(*)..").should().beFreeOfCycles();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

收益:CI 跑单测就能拦截"分层破坏",新人想偷懒直接编译失败——纪律从约定变成机制。

# 6. 分层不彻底反例

# 6.1 贫血模型陷阱

贫血模型(Anemic Domain Model) ——Martin Fowler 2003 年命名的反模式,特征:

// ❌ 贫血模型:纯数据 + getter/setter
@Data
public class Order {
    private Long id;
    private Long userId;
    private BigDecimal amount;
    private Integer status;
    // 没有任何业务方法!
}

// 所有业务逻辑都在 Service 里
@Service
public class OrderService {
    public void cancel(Order order, String reason) {
        if (order.getStatus() != 1) throw new RuntimeException("不能取消");
        order.setStatus(3);
        order.setCancelReason(reason);
        orderMapper.updateById(order);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

为什么是反模式?

  1. 业务规则散落:取消订单的条件可能在 5 个 Service 里各写一遍,不一致
  2. 不变量难维护:status == 3 必须 cancelReason 非空这种规则没人守
  3. 退化为过程编程:对象只是数据容器,Service 是函数集合——还叫什么面向对象?

反例反思:贫血模型不是不能用——小型 CRUD 项目用着没问题。但一旦业务复杂度上升(比如订单有 10+ 种状态机),贫血模型会立刻成为维护噩梦。

充血模型修复:

public class Order {
    private OrderStatus status;
    private String cancelReason;
    // ...

    public void cancel(String reason) {
        if (status != OrderStatus.CREATED && status != OrderStatus.PAID) {
            throw new BizException(ErrorCode.ORDER_CANNOT_CANCEL, status);
        }
        if (StringUtils.isBlank(reason)) {
            throw new BizException(ErrorCode.CANCEL_REASON_REQUIRED);
        }
        this.status = OrderStatus.CANCELLED;
        this.cancelReason = reason;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

业务规则 + 不变量集中在领域对象里,Service 只做编排。

# 6.2 Service 上帝类

// ❌ 上帝 Service:4000 行、80 个方法
@Service
public class OrderService {
    public void createOrder() { ... }
    public void cancelOrder() { ... }
    public void shipOrder() { ... }
    public void refundOrder() { ... }
    public void calcDiscount() { ... }
    public void sendNotify() { ... }
    public void exportExcel() { ... }
    // ... 80 个方法
}
1
2
3
4
5
6
7
8
9
10
11
12

症状:

  • IDE 打开就卡
  • 一改全炸
  • 多人协作冲突频繁
  • 单测时 Mock 链长达 20 行

拆分原则:

按"业务能力"拆,不按"技术分类"拆

✅ OrderCreateService    (单一业务能力:创建)
✅ OrderCancelService    (单一业务能力:取消)
✅ OrderQueryService     (单一业务能力:查询)

❌ OrderUtilsService      (技术分类)
❌ OrderHelperService     (技术分类)
1
2
3
4
5
6
7
8

经验阈值:单个 Service 类超过 500 行就要警惕;超过 1000 行必须拆。

# 6.3 Controller 漏业务

// ❌ Controller 里写业务判断
@PostMapping("/refund")
public Response refund(@RequestBody RefundReq req) {
    Order order = orderService.findById(req.getOrderId());
    if (order.getStatus() != 2) {                              // ← 业务规则
        return Response.fail("订单状态不对");
    }
    if (order.getCreatedAt().plusDays(7).isBefore(now())) {   // ← 业务规则
        return Response.fail("超过 7 天不能退款");
    }
    orderService.doRefund(req.getOrderId());
    return Response.ok();
}
1
2
3
4
5
6
7
8
9
10
11
12
13

问题:这些规则在"App 接口"、"管理后台"、"开放 API" 三个 Controller 里被各写一遍——一旦规则变化(比如 7 天改 14 天),三处都要改,必漏一处。

正确:规则只能写在 Service/Domain 里,Controller 只负责调用:

@PostMapping("/refund")
public Response refund(@RequestBody RefundReq req) {
    orderAppService.refund(req.getOrderId(), req.getReason());
    return Response.ok();
}
1
2
3
4
5

# 6.4 PO 直返前端

主线案例的核心反例。完整后果链:

数据库字段 user.name → user.nickname
        │
        ▼
UserPO.name 读出来是 null
        │
        ▼
Controller 直接返回 UserPO
        │
        ▼
H5 / Android / iOS / 小程序 / OpenAPI 同时炸
        │
        ▼
没有 VO 层做"缓冲",无法只动后端
1
2
3
4
5
6
7
8
9
10
11
12
13

修复:VO 与 PO 解耦,DB 改字段时用 MapStruct 在 Repository 出口处转换,前端层 VO 字段名不动。

@Mapper
public interface UserConverter {
    @Mapping(source = "nickname", target = "name")    // ← DB 改名,转换层兜住
    UserVO toVO(UserPO po);
}
1
2
3
4
5

# 7. 事务与异常边界

# 7.1 事务该放哪层

疑惑:@Transactional 加在哪一层?

论证:

候选位置 优点 缺点 结论
Controller 简单 Web 容器线程持锁,慢 SQL 拖垮连接池;嵌套调用难控 ❌
Service 业务一致性的天然边界 — ✅
Repository 单 SQL 自动事务,自然 跨多个 Repository 时无法编排 ⚠️ 仅单表
Domain 纯净不依赖框架 加注解就破纯净性 ❌

Service 是事务的天然边界——因为"一个用例"就是"一个业务一致性单元",正好对应一个事务。

@Service
public class OrderAppService {
    @Transactional(rollbackFor = Exception.class, timeout = 5)
    public OrderDTO create(CreateOrderCmd cmd) {
        // 这一整个方法是一个事务
        orderRepository.save(order);
        inventoryRepository.deduct(items);
        publisher.publish(event);
    }
}
1
2
3
4
5
6
7
8
9
10

避坑细则:

  • rollbackFor = Exception.class——默认只回滚 RuntimeException,checked 异常不回滚是大坑
  • 显式设置 timeout——避免长事务拖垮连接池
  • 不要嵌套 @Transactional——除非清楚 Propagation.REQUIRES_NEW 的语义
  • 事务方法内不要发 RPC 调用——分布式事务问题,要么用 Saga,要么把 RPC 移出事务

# 7.2 异常的翻译规则

不同层应抛不同类型的异常:

Repository 层    ─→ DataAccessException / SQLException        (技术异常)
       │
       ▼  翻译
Service 层      ─→ BizException(ErrorCode.XXX)                 (业务异常)
       │
       ▼  翻译
Controller 层   ─→ Response<T>(code, message)                  (协议异常)
1
2
3
4
5
6
7

统一异常处理器:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BizException.class)
    public Response<Void> handleBiz(BizException e) {
        log.warn("业务异常: {}", e.getMessage());
        return Response.fail(e.getCode(), e.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Response<Void> handleValid(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
                .map(f -> f.getField() + ":" + f.getDefaultMessage())
                .collect(joining(";"));
        return Response.fail(ErrorCode.PARAM_INVALID, msg);
    }

    @ExceptionHandler(Throwable.class)
    public Response<Void> handleUnknown(Throwable e) {
        log.error("系统异常", e);
        return Response.fail(ErrorCode.SYSTEM_ERROR, "系统繁忙,请稍后再试");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

关键纪律:

  • 业务异常用 BizException + ErrorCode,不要 throw new RuntimeException("xxx") 字符串异常
  • 技术异常(SQL/IO)由 Spring 自动包成 DataAccessException,Service 层翻译为业务异常或继续抛
  • 绝不能让 Throwable 漏到前端——必须有 @RestControllerAdvice 兜底

# 7.3 日志的分层策略

层 日志级别 关注点
Controller INFO(入口)+ WARN(参数错) 请求 ID、入参摘要、耗时、返回码
Service INFO(用例开始结束)+ ERROR(系统异常) 业务关键事件、跨服务调用
Domain 几乎不打日志 纯净,由调用方记录
Repository DEBUG(SQL)+ ERROR(DB 异常) SQL 性能监控

TraceId 全链路串联:

@Component
public class TraceFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) {
        String traceId = ((HttpServletRequest) req).getHeader("X-Trace-Id");
        if (traceId == null) traceId = UUID.randomUUID().toString();
        MDC.put("traceId", traceId);
        try {
            chain.doFilter(req, resp);
        } finally {
            MDC.clear();
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

logback 模板 [%X{traceId}] 自动带上——一条请求所有层的日志可串起来,这是排查分层调用问题的命脉工具。

# 8. 测试金字塔回扣

# 8.1 单元测试落 Service

Service 是单元测试的主战场——业务规则密集、易变、易错。

@ExtendWith(MockitoExtension.class)
class OrderAppServiceTest {

    @Mock private OrderRepository orderRepository;
    @Mock private InventoryService inventoryService;
    @InjectMocks private OrderAppService orderAppService;

    @Test
    void create_should_throw_when_inventory_insufficient() {
        // given
        CreateOrderCmd cmd = aValidCmd();
        doThrow(new BizException(ErrorCode.INSUFFICIENT_STOCK))
                .when(inventoryService).deduct(any());

        // when & then
        assertThatThrownBy(() -> orderAppService.create(cmd))
                .isInstanceOf(BizException.class)
                .hasFieldOrPropertyWithValue("code", ErrorCode.INSUFFICIENT_STOCK);

        verify(orderRepository, never()).save(any());   // 库存失败不应保存
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

特点:纯 Mock,毫秒级,CI 跑 1000 个测试 < 30 秒。

# 8.2 集成测试落 Repository

Repository 必须连真实数据库测——SQL 写错只能在真实 DB 暴露。

@DataJpaTest                                   // 或 @MybatisTest
@AutoConfigureTestDatabase(replace = NONE)     // 用 docker MySQL,不用 H2
class OrderRepositoryTest {

    @Autowired private OrderRepository repository;

    @Test
    void save_and_findById() {
        Order order = anOrder();
        repository.save(order);

        Order found = repository.findById(order.getId()).orElseThrow();
        assertThat(found.getTotalAmount()).isEqualTo(order.getTotalAmount());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

经验:H2 与 MySQL SQL 方言差异巨大(GROUP BY 严格模式、JSON 字段、窗口函数),用 Testcontainers 起 docker MySQL才靠谱。

# 8.3 接口测试落 Controller

Controller 测试聚焦"协议正确性",不深入业务:

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired private MockMvc mvc;
    @MockBean private OrderAppService appService;

    @Test
    void create_should_return_400_when_amount_is_negative() throws Exception {
        String body = "{\"amount\": -1}";
        mvc.perform(post("/api/v1/orders").contentType(APPLICATION_JSON).content(body))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("PARAM_INVALID"));
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

测试金字塔回扣:

        ▲
       ╱E2E╲        少量端到端:模拟真实用户场景
      ╱─────╲
     ╱ Web 集成 ╲   适量 Controller 测试:协议层
    ╱──────────╲
   ╱  Service   ╲  大量单元测试:业务规则
  ╱────────────╲
 ╱ Repository  ╲   集成测试:SQL 与映射
─────────────────
1
2
3
4
5
6
7
8
9

分层架构的回报:每层职责清晰 → 每层测试策略不同 → 整体测试用最少代价跑得最快。

# 9. 演进实战剖析

# 9.1 三层到四层的进化

最早的"三层架构"(Web/Service/DAO)在面对复杂业务时会暴露问题:

痛点:
- Service 层既要业务编排又要业务规则,越来越胖
- 跨多个 Service 编排时无处安放(比如订单+库存+支付)
- 业务规则散落在 Service 各方法里,看不到"领域"
1
2
3
4

四层进化:把"业务编排"和"业务规则"分开——

旧: Controller → Service(编排+规则) → DAO

新: Controller → ApplicationService(编排) → DomainService/Entity(规则) → Repository
                       ↑                              ↑
                   薄、无状态                   富、含业务知识
1
2
3
4
5

# 9.2 引入 Application 层

// Application Service —— 用例编排,薄而无状态
@Service
public class PlaceOrderAppService {

    @Transactional
    public OrderDTO placeOrder(PlaceOrderCmd cmd) {
        // 编排:取 Customer → 调用 Domain 创建 Order → 库存 → 持久化 → 发事件
        Customer c = customerRepository.findById(cmd.customerId()).orElseThrow();
        Order order = OrderFactory.create(c, cmd.items());     // ← 领域工厂
        inventoryService.deduct(order.items());                 // ← 领域服务
        orderRepository.save(order);
        publisher.publish(new OrderPlaced(order.getId()));
        return OrderAssembler.toDTO(order);
    }
}

// Domain Service —— 跨实体的业务规则
public class InventoryService {
    public void deduct(List<OrderItem> items) {
        for (OrderItem item : items) {
            Stock stock = stockRepository.lockById(item.skuId());
            stock.deduct(item.quantity());                      // ← 领域行为
            stockRepository.save(stock);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

区分要点:

层 关键词 例子
ApplicationService "怎么做"的编排 顺序调度、事务边界、事件发布
DomainService "是什么"的规则 库存扣减规则、价格计算策略

# 9.3 向六边形架构过渡

四层做到极致,下一步就是六边形架构(Ports & Adapters)——把"依赖倒置"贯彻到底:

     ┌─────────────────────────────────────────┐
     │                                         │
     │           Domain (核心)                  │
     │           Port (端口接口)                │
     │                                         │
     └─────────────────────────────────────────┘
       ▲       ▲       ▲       ▲       ▲
       │       │       │       │       │
  HTTP Adapter  RPC  Scheduled  MQ   CLI       ← 入站适配器
                                              (都翻译成调用 Domain Port)

       ▼       ▼       ▼       ▼
   MySQL    Redis    HTTP     S3              ← 出站适配器
                                              (实现 Domain 的 Repository 等端口)
1
2
3
4
5
6
7
8
9
10
11
12
13
14

四层架构的"Controller / Repository"已经隐含了"入站/出站适配器"概念,只需把它们彻底从 Domain 抽离,就成了六边形。这是下一篇 02.六边形架构设计 的主题。

💡 演进规律:从单体的"三层",到分清编排与规则的"四层",再到端口与适配器的"六边形",最后到限界上下文的 DDD——架构的演进永远是"切得更清楚、依赖更明确",从来不是"叠加新框架"。

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章的 OrderController,七个疑问现在能逐条作答:

疑问 答案
① 每层职责? 第 3:Controller 协议、Service 编排、Domain 规则、Repository 数据
② 为什么 Controller 不能调 Mapper? 第 4.3:跳层让业务散落+事务错位+复用断裂+测试地狱
③ PO/VO/DTO 为什么不能合? 第 3.5:四种生命周期与关注点,合并就是"省小钱付大代价"
④ 循环依赖怎么破? 第 5.2:抽公共抽象 / 事件解耦 / 上提编排
⑤ 事务放哪? 第 7.1:Service 是业务一致性边界,必落于此
⑥ 贫血模型问题? 第 6.1:规则散落、不变量无人守、退化为过程编程;用充血模型修
⑦ 怎么自动守住分层? 第 5.3:ArchUnit 写成单元测试,CI 强制拦截

修复方案(按代价从小到大):

方案 A:先修当前事故(治标)

// 1) 引入 VO,DB 改名不打穿前端
@Mapper
public interface UserConverter {
    @Mapping(source = "nickname", target = "name")
    UserVO toVO(UserPO po);
}

// 2) Controller 不再注入 Mapper,只调 Service
@RestController
@RequiredArgsConstructor
public class OrderController {
    private final OrderAppService orderAppService;

    @GetMapping("/{id}")
    public Response<OrderVO> get(@PathVariable Long id) {
        return Response.ok(orderAppService.queryDetail(id));
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

方案 B:上线 ArchUnit 守门(治本)

把 5.3 节的规则放进单测,新写的违规代码无法合入主干——把人治变法治。

方案 C:四层重构 + 引入 Domain(演进)

controller/        ← 只做协议
application/       ← 用例编排 + 事务
domain/
  ├── model/       ← 富有行为的实体
  ├── service/     ← 跨实体业务规则
  └── repository/  ← 接口(依赖倒置)
infrastructure/
  ├── persistence/ ← Repository 实现 + PO + Mapper
  ├── messaging/   ← MQ 适配
  └── http/        ← 外部 RPC 适配
1
2
3
4
5
6
7
8
9
10

生产建议:方案 A 先止血,方案 B 当周上线(成本极低),方案 C 按业务模块逐步迁移——重构永远是渐进的,不是革命。

# 10.2 一个请求的一生

把 POST /api/v1/orders 这一行的全过程串成一棵知识树:

HTTP POST /api/v1/orders {...}
        │
        ├─ 接入层
        │   ├─ Tomcat 解析 HTTP → HttpServletRequest
        │   ├─ Spring DispatcherServlet 路由到 OrderController.create
        │   ├─ Jackson 反序列化 body → CreateOrderReq
        │   ├─ JSR-303 @Valid 校验参数        ─── 第 3.1 节
        │   ├─ TraceFilter 注入 MDC traceId  ─── 第 7.3 节
        │   └─ MapStruct 把 Req → Cmd
        │
        ├─ 应用层 (Service)
        │   ├─ @Transactional 开启事务      ─── 第 7.1 节
        │   ├─ 加载 Customer (Repository)
        │   ├─ Order.create(customer, items)  ← 领域工厂  ─── 第 3.4 节
        │   ├─ inventoryService.deduct(...)   ← 领域服务
        │   ├─ orderRepository.save(order)    ← 持久化     ─── 第 3.3 节
        │   ├─ publisher.publish(event)       ← 领域事件
        │   ├─ 事务提交 / 异常回滚
        │   └─ OrderAssembler.toDTO(order)
        │
        ├─ 数据层 (Repository 实现)
        │   ├─ OrderConverter.toPO(order)
        │   ├─ MyBatis 执行 INSERT
        │   ├─ 回写自增 ID 到 Order
        │   └─ 子表批量插入
        │
        ├─ 出口
        │   ├─ Service 返回 OrderDTO
        │   ├─ Controller 调 toVO 转换
        │   ├─ Response.ok 统一包装
        │   ├─ Jackson 序列化为 JSON
        │   └─ HTTP 200 响应
        │
        └─ 异常路径
            ├─ BizException → GlobalHandler → 业务码     ─── 第 7.2 节
            ├─ ValidationException → 400 PARAM_INVALID
            └─ Throwable → 500 SYSTEM_ERROR (兜底)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

理解一个请求的一生,就是理解所有 Java 后端"在哪一层、为什么在这里"。这是整个架构系列的总入口。

# 10.3 设计哲学回扣

整理本篇的四条跨篇适用的设计哲学:

哲学 1:分层即隔离——把"变化频率不同的东西"切开

HTTP 协议三年一变,业务规则三天一变,DB 引擎五年一迁。分层让"业务变更不动协议"、"DB 迁移不动业务"。变化的隔离比代码的复用更重要——这是所有架构模式的共同祖先。

哲学 2:依赖即纪律——单向依赖是可维护性的底线

允许跨层、反向,写代码当时省 5 分钟,未来排查问题损失 5 小时。依赖方向是分层架构的"宪法",违反就让"分层"变成"一锅粥"。用 ArchUnit 把宪法写成机制,让"约定"无法被破坏。

哲学 3:模型即业务——Domain 是业务知识的纯净沉淀

贫血模型把"业务规则"散布到 Service,让对象退化为数据袋子。充血模型把规则集中在领域对象内,让"业务"成为代码的主角。Domain 不依赖任何框架是检验"分层干净"的金标准——能脱离 Spring/MyBatis 编译,才叫真的分层。

哲学 4:边界即契约——VO/DTO/PO 是跨层的合同

每跨一层显式转换,看起来"重复",实际是给变化留缓冲区。DB 改字段不打穿前端、对外 API 不被前端需求拉扯、Domain 不被序列化框架污染——四道边界四道保护。用 MapStruct 自动生成转换,零心智成本,全部红利。

# 10.4 分层速查清单

一张表保存以备查:

层 该做 不该做 测试策略
Controller 协议解析、参数校验、DTO 转换、统一响应 业务规则、事务、SQL @WebMvcTest + MockMvc
ApplicationService 用例编排、事务、跨聚合协作、事件发布 HTTP/SQL、业务规则 @Mock 纯单测
DomainService 跨实体业务规则 框架依赖、IO 纯 JUnit
Domain Entity 业务行为、不变量 getter/setter 木偶 纯 JUnit
Repository 接口 集合语义 实现细节 —
Repository 实现 ORM、SQL、Cache 业务规则 @DataJpaTest + Testcontainers
DTO/VO/PO 纯字段载体 互相替代 MapStruct 自带测试

60 秒诊断清单:

# 看依赖方向
grep -r "import.*\.mapper\." src/main/java/.../controller/   # 应为空
grep -r "import.*\.service\." src/main/java/.../repository/  # 应为空

# 看上帝类
find . -name "*.java" -exec wc -l {} \; | sort -nr | head -20

# 跑 ArchUnit
mvn test -Dtest=LayerArchTest

# 看 @Transactional 是否在 Service 层
grep -rn "@Transactional" src/main/java/ | grep -v service

# 看 PO 是否泄漏到 Controller
grep -rn "import.*\.PO" src/main/java/.../controller/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

演进路径速记:

单层(脚本)→ 三层(Web/Service/DAO)→ 四层(加 Domain)
            → 六边形(端口适配器)→ DDD 限界上下文
            → CQRS(读写分离)→ 事件驱动 → 微服务
1
2
3

每一步都不是"换框架",而是"切得更清楚、依赖更明确"。


下一篇:我们已经知道了"分层是把业务和技术分开",下一步进入 02.六边形架构设计——把"分层"升级为"端口与适配器",让 Domain 彻底独立于任何技术框架。

#架构#分层
上次更新: 2026/06/17, 11:43:57
README
六边形架构设计

← README 六边形架构设计→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式