"这段逻辑写在哪里"是日常设计里出现频率最高的决策,也是最容易被随手决定的一个。写在控制器里能跑,写在 SQL 里也能跑,写在实体里同样能跑——但三种写法的可测试性、变更成本、失效边界完全不同。分层的目的不是让代码符合某种图示,而是让每类逻辑落在变更频率与验证手段都最匹配的位置。
1. 四个落位候选
| 候选位置 |
擅长 |
不擅长 |
适用判据 |
| 数据库(SQL / 约束 / 存储过程) |
集合运算、聚合、原子性约束 |
可变业务策略、跨系统计算 |
一次处理大量行且结果只与库内数据相关 |
| 应用服务 |
编排、事务边界、权限校验 |
领域不变量、算法细节 |
需要协调多个协作者并决定提交时机 |
| 领域对象 / 领域服务 |
不变量、状态转换、计算归属 |
大范围集合运算、IO |
逻辑与某个实体状态强相关且需要长期维护 |
| 前端 |
交互即时反馈、展示派生 |
权威校验、跨用户一致性 |
只影响呈现、错误代价可回滚 |
判断顺序:先看错误代价,再看变更频率,最后看数据位置。错误代价高的逻辑必须靠后(服务端),变更频率高的逻辑必须集中,数据位置只影响通信成本。
2. 什么该进数据库
| 应进数据库 |
理由 |
| 大范围过滤与聚合 |
数据在库内,搬出来算等于放大网络与内存 |
| 集合去重、窗口函数、分组统计 |
SQL 表达力强且有优化器 |
| 唯一性、外键、非空等结构约束 |
约束在所有写入路径上生效 |
| 不该进数据库 |
后果 |
| 业务规则(折扣、审批条件) |
规则与代码分裂,测试手段缺失 |
| 可变策略与阈值 |
改规则要发 DDL,无法灰度 |
| 依赖外部系统的判断 |
数据库内无法可靠获取外部状态 |
| 复杂的条件分支 |
难以调试、难以覆盖测试 |
-- 该进库的:一次算出汇总,避免把行搬到应用层
SELECT user_id, COUNT(*) AS cnt, SUM(amount) AS total
FROM order_item
WHERE created_at >= ? AND status = 'PAID'
GROUP BY user_id
HAVING SUM(amount) > ?;
存储过程的真实成本是不可移植与不可单测:逻辑进了数据库,单测要连库,版本管理与发布流程也与代码分家。只在"必须原子完成且数据全在库内"时才值得。
3. 应用服务该薄到什么程度
// 应用服务:只做编排、事务与权限,不写规则
@Transactional
public Long placeOrder(PlaceOrderCmd cmd, CurrentUser user) {
user.assertHasPermission("order:create"); // 权限校验
var order = orderFactory.create(cmd, clock); // 委托领域对象
orderRepo.save(order); // 持久化
eventPublisher.publish(order.pulledEvents()); // 事务后发事件
return order.id();
}
| 应用服务该有 |
应用服务不该有 |
| 用例编排(调谁、按什么顺序) |
具体的金额计算、折扣规则 |
事务边界(@Transactional 位置) |
大段 if/else 业务分支 |
| 权限与租户隔离校验 |
拼装 SQL 或直接拼 DTO 字段 |
| 事件发布时机 |
调用第三方后的重试细节 |
贫血模型的真实代价:规则散落在各个服务方法里,同一规则被复制到多个用例,改一处漏一处。代价不在"不够面向对象",而在于缺少唯一的不变量持有者——没有人能回答"订单在什么条件下是合法的"。
4. 领域对象与领域服务
| 归属 |
内容 |
例子 |
| 实体自身 |
不变量、状态转换、与自身状态相关的计算 |
order.pay() 校验状态与金额 |
| 值对象 |
无标识的规则封装 |
Money.add() 校验币种一致 |
| 领域服务 |
需要多个实体协作、或规则本身无自然归属 |
PricingService 跨商品与优惠券 |
| 应用服务 |
只编排,不实现 |
用例入口 |
引入领域服务的信号是规则需要多个聚合的数据、且不属于其中任何一个。如果没有这个特征而引入领域服务,通常只是把贫血模型的逻辑换了个类名。
public void pay(Money paid) {
if (status != Status.CREATED) throw new IllegalStateException("状态不可支付");
if (!paid.currency().equals(total.currency())) throw new IllegalArgumentException("币种不符");
if (paid.isLessThan(total)) throw new IllegalArgumentException("金额不足");
this.status = Status.PAID;
this.paidAt = clock.instant();
}
5. 算法代码与业务代码分离的三个信号
| 信号 |
判据 |
分离后的收益 |
| 可单测 |
不依赖库、网络、时钟即可断言 |
用例覆盖从集成级降到毫秒级 |
| 可替换 |
存在(或预计存在)第二个实现 |
换算法不动业务 |
| 参数化 |
行为由一组参数决定而非硬编码 |
同一份代码服务多个场景 |
// 分离形态:纯函数 + 参数对象,边界清晰
public interface FeeCalculator {
Fee calc(FeeContext ctx, FeePolicy policy);
}
public record FeePolicy(BigDecimal baseRate, BigDecimal minFee, boolean vipExempt) {}
三条信号中任意两条成立,就值得抽成独立的类或包,而不是留在服务方法内部。
6. 策略模式与规则引擎
| 维度 |
策略模式 |
规则引擎 |
| 规则数量 |
少(数十以内) |
多(上百,且持续增加) |
| 变更频率 |
低,随版本发布 |
高,需业务方自行调整 |
| 变更者 |
开发 |
业务 / 运营 |
| 可解释性 |
靠代码阅读 |
需要规则命中追踪 |
| 调试成本 |
低 |
中高,需执行轨迹 |
规则引擎的隐藏成本是可解释性与可测试性同时下降:规则写在外部文件里,参数与上下文耦合不显式,出问题要重建执行轨迹。规则数量不到一定规模时,策略模式加配置表更划算。
# 策略参数化:规则留在代码,参数外置
fee_policy:
default:
base_rate: 0.006
min_fee: 1.00
vip:
base_rate: 0.003
min_fee: 0.00
7. 计算密集型逻辑的放置
| 放置 |
延迟预算 |
适用 |
代价 |
| 在线同步 |
主路径预算内(数十毫秒级) |
结果必须实时 |
阻塞主路径,需限流与降级 |
| 异步任务 |
秒到分钟级 |
可容忍延迟、可重试 |
需要任务表 / 队列与状态机 |
| 预计算 |
离线算、在线读 |
输入变化可预测 |
一致性窗口与重算成本 |
延迟预算的算法是从 SLO 反推:主路径 P99 目标减去上下游占用,剩下的才是这段计算能花的钱。算不清就不该放在同步路径上。
8. 缓存与预计算的位置
| 位置 |
谁负责失效 |
一致性 |
风险 |
| 请求内局部计算 |
无需失效 |
强 |
重复计算 |
| 进程内缓存 |
本进程(TTL / 事件) |
弱,多实例不一致 |
各节点视图不同 |
| 集中缓存 |
写方主动失效 |
中 |
缓存与库双写不一致 |
| 预计算表 |
定时任务 / 变更触发 |
弱 |
重算窗口内读到旧值 |
原则:谁改变数据,谁负责失效。让读方负责失效的缓存,最终一定会读到过期值。预计算表必须在表结构里带可见的时间水位,否则读方无法判断新鲜度。
9. 跨层数据传递的形态
| 形态 |
成本 |
风险 |
| DTO 逐层转换 |
显式、可控,代码量大 |
转换遗漏字段 |
| 领域对象直接外传 |
省事 |
序列化耦合,暴露内部结构 |
| 共享实体模型跨层复用 |
最省事 |
一处改动波及全链路 |
DTO 转换的成本是样板代码,收益是每层可独立演化。领域对象直接外传,会把内部字段名、懒加载行为甚至序列化循环引用带进 API 契约。
// 显式转换,边界清楚
public record OrderView(Long id, String status, BigDecimal total) {
public static OrderView of(Order o) {
return new OrderView(o.id(), o.status().name(), o.total().amount());
}
}
10. 分层过度与分层不足的症状
| 症状 |
表现 |
修法 |
| 分层过度 |
三层转发无逻辑、为每张表建一套 CRUD |
合并为单一用例方法 |
| 分层过度 |
每个方法都只有一个调用方 |
删除中间层 |
| 分层过度 |
DTO / VO / BO / PO 四套一模一样 |
保留接口边界处一套 |
| 分层不足 |
控制器里写算法与循环 |
抽领域服务或纯函数 |
| 分层不足 |
SQL 里写业务分支与阈值 |
规则上移到代码 |
| 分层不足 |
事务边界在控制器上 |
收到应用服务 |
判断方法:如果删掉某一层,代码量减少而调用方无需改动,这一层就是转发层。
11. 可测试性与分层的关系
| 逻辑位置 |
测试手段 |
单次运行量级 |
| 纯函数 / 领域对象 |
单元测试 |
毫秒 |
| 应用服务(含事务) |
集成测试(内存库 / 容器) |
百毫秒到秒 |
| SQL 中的逻辑 |
连真实库的集成测试 |
秒 |
| 规则引擎 |
场景化用例 + 轨迹断言 |
秒 |
逻辑落位决定了它能否被便宜地验证。把可单测的逻辑写进不可单测的位置,等于永久放弃回归保护——这比"职责单一"具体得多。
12. 排错
| 现象 |
常见原因 |
处理 |
| 改一个规则要改五个地方 |
规则散落在应用服务 |
收敛到领域对象 |
| 单元测试必须起数据库 |
逻辑写在 SQL 或实体与库耦合处 |
抽纯函数 |
| 同一份逻辑前后端重复 |
前端做了权威校验 |
前端只做提示,服务端唯一权威 |
| 缓存数据长期不一致 |
读方负责失效 |
改为写方失效 |
| 接口字段一改全线崩溃 |
领域对象直接外传 |
加 DTO 边界 |
13. 边界
| 场景 |
建议 |
| 一次性脚本 / 数据修复任务 |
扁平写法,不必分层 |
| 极简单的 CRUD |
一层到底,别造四层 |
| 报表导出 |
让数据库算,应用只搬结果 |
| 试验性功能(预计两周内下线) |
先扁平,等确定要长期保留再分层 |
| 多人长期维护的核心域 |
该分,且要有依赖方向约束 |
结论:分层的成本与收益都随维护周期长度放大。短期代码分层是纯成本,长期代码不分层是纯债务。
14. 自测
- 判断逻辑落位时,先看错误代价还是先看数据位置?
- 贫血模型的真实代价是什么,它为什么不是"不够面向对象"?
- 引入领域服务的信号是什么?
- 规则引擎相对于策略模式,隐藏成本体现在哪两点?
- 如何快速判断某一层是纯粹的转发层?
- 从 SLO 出发,如何确定一段计算能否放在同步路径上?