分层架构设计详解
# 01.分层架构设计详解
# 目录介绍
# 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();
}
}
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 节
2
3
4
5
6
7
# 1.3 我们要回答什么
这个事故就是本篇的主线案例。我们带着上面 7 个问号往下走,每讲完一段原理就解开一两个;最后在第 10 章把案例彻底剖开,并给出修复后的代码与对应的演进路径。
本篇路线:
架构总图 (第 2 章)
↓
四层职责边界 (第 3 章) ─→ 解开"每层该做什么不该做什么"
↓
依赖方向铁律 (第 4 章) ─→ 解开"为什么不能跨层、不能反向"
↓
循环依赖与反例 (第 5-6 章) ─→ 解开"分层不彻底的具体长相"
↓
事务/异常/测试 (第 7-8 章) ─→ 解开"分层的运行时与验证手段"
↓
演进路径 (第 9 章) ─→ 三层→四层→六边形
↓
综合案例 (第 10 章) ─→ 案例彻底剖开
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
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 把所有逻辑写完,简单直接不行吗?小项目不就这么干的?
论证:
- 变化的频率与方向不同——HTTP 协议三五年变一次(HTTP/2、HTTP/3、gRPC),业务规则三五天变一次,数据库引擎五年迁一次。把变化频率不同的东西切开,是为了让"业务变更不动协议","数据库迁移不动业务"。这是变化的隔离。
- 替换的代价不同——
MyBatis → JOOQ、MySQL → PostgreSQL、REST → GraphQL,这些都是技术选型的替换。良好分层让替换只影响一层;分层不彻底,则一次替换牵动全身(本篇主线案例就是这条)。 - 测试的难度不同——Service 是纯 Java 逻辑,单测应该秒级、零依赖;Repository 需要数据库,集成测试分钟级;Controller 需要起 HTTP 服务。测试金字塔的不同层对应不同分层(第 8 章)。
- 复用的颗粒度不同——同一个"扣减库存"业务,可能被 HTTP 接口、定时任务、MQ 消费者三个入口同时调用。Service 是复用单元,Controller 只是入口适配器——分层让复用成为可能。
- 依赖外部的程度不同——Domain 应该是"纯净的业务知识",Infrastructure 是"脏活累活"。把"业务知识"与"技术细节"切开,是 DDD 的核心思想,也是六边形架构的起点。
- 反向验证:如果不分层会怎样?参考"上千行的 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));
}
}
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
}
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);
}
}
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);
}
}
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);
}
}
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
2
3
每跨一层都显式转换,不要图省事把 PO 直返前端(本篇主线事故的根因之一)。
疑惑:转换太繁琐,能不能合并?
论证:
- PO = VO 会出事——主线案例:DB 字段重命名直接打穿前端
- VO = DTO 会出事——开放 API 需要 DTO 稳定向后兼容,前端 VO 想加字段就加
- DTO = Domain 会出事——Domain 是富对象(带行为、带不变量),DTO 是贫数据载体(带 setter),序列化反序列化会破坏不变量
结论:四个不同生命周期、四种不同关注点,强行复用就是"为了少写 50 行映射,付出多次跨端事故"——用 MapStruct 自动生成转换代码,0 心智成本。
# 4. 依赖方向铁律
# 4.1 自上而下单向
分层架构最核心的约束:依赖只能从上往下,不能反向,不能跳层。
✅ 允许 ❌ 禁止
┌──────────────┐ ┌──────────────┐
│ Controller │ │ Controller │
└──────┬───────┘ └──────┬───────┘
│ ✅ 调用 │ ╲
▼ ▼ ╲ ❌ 反向
┌──────────────┐ ┌──────────────┐ │
│ Service │ │ Service │ │ ❌ 跳层
└──────┬───────┘ └──────┬───────┘ │
│ ✅ 调用 │ │
▼ ▼ │
┌──────────────┐ ┌──────────────┐ │
│ Repository │◄────❌──────│ Repository │◄┘
└──────────────┘ 反向调用 └──────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
为什么这条铁律不能破?
- 可推理性:拿到任何一层的代码,只看本层和下层就能完全理解——不需要回头看"是谁调了我"
- 可测试性:上层依赖下层,Mock 下层就能单测上层;如果反向依赖,Mock 链会爆炸
- 可演进性:下层修改不影响上层——比如 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); // ← 在持久层发通知?
}
}
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);
}
}
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
}
2
3
4
5
后果:
- 业务规则散落:Controller 里写"if order.status == ...",五个 Controller 五份逻辑
- 事务边界混乱:Controller 加
@Transactional在 web 容器线程里挂事务,慢请求拖垮连接池 - 复用断裂:定时任务想复用"创建订单"逻辑,发现它在 Controller 里写着,复用不了
- 测试地狱:测一个 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 包)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
关键技巧:
- 接口放在
domain.repository包下 - 实现放在
infrastructure.repository包下 - Spring 启动时自动把实现注入到接口位置
- 编译期 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);
// ...
}
}
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 都依赖它,不再互相依赖
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) {
// 异步处理,不形成调用环
}
}
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);
}
}
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();
}
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);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
为什么是反模式?
- 业务规则散落:取消订单的条件可能在 5 个 Service 里各写一遍,不一致
- 不变量难维护:
status == 3 必须 cancelReason 非空这种规则没人守 - 退化为过程编程:对象只是数据容器,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;
}
}
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 个方法
}
2
3
4
5
6
7
8
9
10
11
12
症状:
- IDE 打开就卡
- 一改全炸
- 多人协作冲突频繁
- 单测时 Mock 链长达 20 行
拆分原则:
按"业务能力"拆,不按"技术分类"拆
✅ OrderCreateService (单一业务能力:创建)
✅ OrderCancelService (单一业务能力:取消)
✅ OrderQueryService (单一业务能力:查询)
❌ OrderUtilsService (技术分类)
❌ OrderHelperService (技术分类)
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();
}
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();
}
2
3
4
5
# 6.4 PO 直返前端
主线案例的核心反例。完整后果链:
数据库字段 user.name → user.nickname
│
▼
UserPO.name 读出来是 null
│
▼
Controller 直接返回 UserPO
│
▼
H5 / Android / iOS / 小程序 / OpenAPI 同时炸
│
▼
没有 VO 层做"缓冲",无法只动后端
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);
}
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);
}
}
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) (协议异常)
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, "系统繁忙,请稍后再试");
}
}
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();
}
}
}
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()); // 库存失败不应保存
}
}
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());
}
}
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"));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
测试金字塔回扣:
▲
╱E2E╲ 少量端到端:模拟真实用户场景
╱─────╲
╱ Web 集成 ╲ 适量 Controller 测试:协议层
╱──────────╲
╱ Service ╲ 大量单元测试:业务规则
╱────────────╲
╱ Repository ╲ 集成测试:SQL 与映射
─────────────────
2
3
4
5
6
7
8
9
分层架构的回报:每层职责清晰 → 每层测试策略不同 → 整体测试用最少代价跑得最快。
# 9. 演进实战剖析
# 9.1 三层到四层的进化
最早的"三层架构"(Web/Service/DAO)在面对复杂业务时会暴露问题:
痛点:
- Service 层既要业务编排又要业务规则,越来越胖
- 跨多个 Service 编排时无处安放(比如订单+库存+支付)
- 业务规则散落在 Service 各方法里,看不到"领域"
2
3
4
四层进化:把"业务编排"和"业务规则"分开——
旧: Controller → Service(编排+规则) → DAO
新: Controller → ApplicationService(编排) → DomainService/Entity(规则) → Repository
↑ ↑
薄、无状态 富、含业务知识
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);
}
}
}
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 等端口)
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));
}
}
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 适配
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 (兜底)
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/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
演进路径速记:
单层(脚本)→ 三层(Web/Service/DAO)→ 四层(加 Domain)
→ 六边形(端口适配器)→ DDD 限界上下文
→ CQRS(读写分离)→ 事件驱动 → 微服务
2
3
每一步都不是"换框架",而是"切得更清楚、依赖更明确"。
下一篇:我们已经知道了"分层是把业务和技术分开",下一步进入 02.六边形架构设计——把"分层"升级为"端口与适配器",让 Domain 彻底独立于任何技术框架。