设计原则:怎么判断违反了,以及什么时候不必遵守
「单一职责」「开闭原则」这些定义很好背,难的是回答两个问题:怎么判断当前代码违反了它,以及什么时候刻意不遵守是对的。这篇给每条原则配一个可检查的判断标准和一个反向条件。
本文是设计系列的第五篇。前几篇讨论了设计的对象(软件设计到底在设计什么)、关注点分离(关注点分离与可测试性)以及面向对象的三个手段(封装、组合与多态)。原则是在这些基础上判断取舍的尺子。
一、先说结论
- 所有原则服务于同一个目标:让变化的影响范围可控。 判断是否该遵守,先问「这部分会不会变、变的时候要改几个地方」。
- 每条原则都有对应的坏味道:一个类有多个修改理由、加功能要改老代码、子类抛
UnsupportedOperationException、实现类被迫实现空方法、业务代码里出现具体的技术类名。 - 原则之间会打架:单一职责与 KISS、DRY 与解耦经常冲突。冲突时按「变化频率」而不是「谁更正统」来决定。
- 最容易被误用的是 DRY:把「长得像」当成「是同一件事」,会把两个本应独立演化的功能绑死。
- 没有证据的扩展需求不应该驱动抽象:需求还没出现时,抽象层通常是负债。但已经发生过的变化、以及出错代价很高的边界(支付、权限、对外接口),应该主动设计。
- 可测试性是反馈,不是第六条原则:一段代码难以测试,通常说明依赖、副作用或职责没有被隔离开。
二、SOLID 的五条
2.1 单一职责(SRP)
定义:一个类应该只有一个引起它变化的原因。Robert C. Martin 后来把它表述得更具体:一个模块应该只对一类行为者(actor)负责,也就是只对一类会提出修改要求的人负责。
怎么判断违反了:说出这个类的职责时,如果需要用「并且」,通常就违反了。或者问:哪些角色会要求修改这个类? 如果产品、风控、运维三方都会来改它,它就有三个变化原因。
// 违反:三个变化原因挤在一起
class OrderService {
void placeOrder(Order order) {
if (order.amount().compareTo(LIMIT) > 0) { ... } // 风控规则,风控要改
orderMapper.insert(order); // 持久化,DBA 要改
mailClient.send(order.userEmail(), render(order)); // 通知模板,运营要改
}
}
// 改进:各自独立变化
class OrderService {
private final RiskChecker riskChecker;
private final OrderRepository repository;
private final OrderNotifier notifier;
void placeOrder(Order order) {
riskChecker.check(order);
repository.save(order);
notifier.notifyCreated(order);
}
}什么时候不必遵守:逻辑很短、只有一个调用方、并且从没改过的代码。为一个二十行的工具类拆出三个接口,增加的理解成本大于收益。
单一职责也不是「粒度越小越好」。拆分的收益是变化被隔离,代价是协作变多:读一个流程要跳更多文件,改一个功能要同步修改更多类。当拆开的两部分总是一起修改时,就说明拆过头了,应该合回去。
2.2 开闭原则(OCP)
定义:对扩展开放,对修改关闭——增加新行为时不应该修改已有代码。
要关闭的是已经稳定的核心,而不是所有代码。扩展点应该来自已经观察到的第二个变化,而不是想象中的未来需求。
怎么判断违反了:每次加一种新类型,都要回去改同一个 if-else 或 switch。
// 违反:每加一种支付方式就要改这里
BigDecimal fee(String channel, BigDecimal amount) {
if ("ALIPAY".equals(channel)) return amount.multiply(new BigDecimal("0.006"));
if ("WECHAT".equals(channel)) return amount.multiply(new BigDecimal("0.006"));
if ("UNIONPAY".equals(channel)) return amount.multiply(new BigDecimal("0.005"));
throw new IllegalArgumentException(channel);
}
// 改进:新增渠道只需要新增一个实现类
interface FeeCalculator {
boolean supports(String channel);
BigDecimal fee(BigDecimal amount);
}
class FeeCalculators {
private final List<FeeCalculator> calculators; // Spring 自动注入所有实现
BigDecimal fee(String channel, BigDecimal amount) {
return calculators.stream()
.filter(c -> c.supports(channel))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(channel))
.fee(amount);
}
}什么时候不必遵守:分支数量固定且很少变(比如只有「成功 / 失败」两种),或者暂时还看不出扩展方向。过早地为开闭原则铺路,往往会抽象出错误的接口——等真正的第二种情况出现时,会发现接口定义得不对。经验做法是「第二次出现时再抽象」。
类型集合由自己掌控时,Java 21 的密封类型加穷举 switch 是另一种选择:新增类型时,编译器会指出所有没有处理它的 switch,修改点不会遗漏。见 封装、组合与多态。
2.3 里氏替换(LSP)
定义:子类对象能够替换父类对象出现在任何地方,而不破坏程序的正确性。
怎么判断违反了:子类里出现 throw new UnsupportedOperationException(),或者调用方需要 instanceof 判断具体子类后区别对待。
// 违反:正方形不是矩形的合法子类型,改宽度会连带改高度
class Rectangle { void setWidth(int w); void setHeight(int h); }
class Square extends Rectangle { /* setWidth 同时改 height */ }
// 依赖父类契约的代码会失败
void resize(Rectangle r) {
r.setWidth(5); r.setHeight(4);
assert r.area() == 20; // Square 传进来时不成立
}详细的分析与修复方式见 里氏替换原则。
什么时候不必遵守:没有「不必遵守」的情况。违反 LSP 意味着继承关系建错了,正确做法是改用组合或重新划分类型,而不是接受它。
2.4 接口隔离(ISP)
定义:不应该强迫客户端依赖它不需要的方法。
怎么判断违反了:实现类里有一堆空方法,或者 return null;、throw new UnsupportedOperationException()。从调用方看,则是一个调用方只用到接口里的一小部分方法。
接口应该按调用者的角色来定义,而不是把实现类的方法列表原样搬出来。同一个实现类可以实现多个角色接口,不同的调用方各自依赖自己需要的那一个。
// 违反:只读的数据源被迫实现写方法
interface DataSource {
Data read(String key);
void write(String key, Data data);
void delete(String key);
}
// 改进:按能力拆分,实现类各取所需
interface ReadableSource { Data read(String key); }
interface WritableSource { void write(String key, Data data); void delete(String key); }JDK 里常被提到的例子是 java.sql.Connection。在 JDK 21 上用反射统计,它有 60 个公共方法,其中 54 个抽象方法、6 个 default 方法。每个驱动都要实现全部抽象方法,而大多数业务代码只用到其中的获取语句、事务控制和关闭。
default 方法让接口可以新增能力而不破坏已有的实现,但代价是能力是否可用变成了运行时才知道的事。实测 Connection 上与分片相关的两个 default 方法(setShardingKey、setShardingKeyIfValid),在驱动没有重写时直接抛出 SQLFeatureNotSupportedException,编译期看不出来。
JDK 集合框架则是有意的取舍:List 把 add、remove 标为「可选操作」,Arrays.asList 返回的列表可以 set 但 add 会抛 UnsupportedOperationException。如果严格按接口隔离,只读、定长、可变列表需要分成多个接口,类型数量会成倍增加。JDK 选择用文档约定换取更少的类型,这是一个明确记录在接口契约里的例外。
什么时候不必遵守:接口只有一个实现、而且短期内不会有第二个。拆得太细会让调用方需要同时注入好几个接口。
2.5 依赖倒置(DIP)
定义:高层模块不应该依赖低层模块,两者都应该依赖抽象;抽象不应该依赖细节。
怎么判断违反了:业务代码里出现了具体的技术类名(RedisTemplate、XxxHttpClient、MysqlXxxMapper),或者「换一个实现要改业务代码」。
// 违反:业务逻辑直接持有具体实现
class OrderService {
private final MysqlOrderRepository repository = new MysqlOrderRepository();
}
// 改进:接口由业务层定义(注意接口归属),实现放在外层
package com.acme.order.domain;
public interface OrderRepository { // 业务层定义它需要什么
void save(Order order);
Optional<Order> findById(long id);
}
package com.acme.order.infrastructure;
class MysqlOrderRepository implements OrderRepository { ... } // 实现依赖接口关键不在于「用了接口」,而在于接口属于谁。如果接口是按数据库能力定义的(selectByExample、updateSelective),那只是给实现套了层壳,依赖方向并没有倒置。
什么时候不必遵守:稳定的标准库和语言设施(String、List、LocalDate)不需要抽象;只有一个实现且永远不会换的技术组件,加接口只是增加跳转层级。
控制反转、依赖注入、依赖倒置不是同一件事:
| 概念 | 回答的问题 | 例子 |
|---|---|---|
| 控制反转(IoC) | 谁掌握流程的控制权 | 框架调用你的代码,而不是你调用框架 |
| 依赖注入(DI) | 依赖对象由谁创建、怎样交给使用方 | Spring 容器通过构造器把 OrderRepository 传给 OrderService |
| 依赖倒置(DIP) | 依赖的方向指向哪里 | 业务层定义 OrderRepository,数据库实现去依赖它 |
用了 Spring 的依赖注入,不代表依赖已经倒置:如果注入的是 MysqlOrderMapper,业务代码依然依赖着具体技术。反过来,不用任何容器、手动 new 出实现再传进去,也可以做到依赖倒置。Spring 容器的模型见 读懂一个系统的设计。
三、DRY、KISS、YAGNI 与可测试性
3.1 DRY:重复的是「知识」,不是「代码行」
DRY 的原始定义是「系统中每一项知识都必须有单一、明确、权威的表示」。重点是知识,不是字符。
// 看起来重复,但其实是两件事:一个是下单风控,一个是提现风控
if (amount.compareTo(new BigDecimal("50000")) > 0) { manualReview(); } // 下单
if (amount.compareTo(new BigDecimal("50000")) > 0) { manualReview(); } // 提现强行抽成 checkLimit(amount) 之后,风控要求「下单阈值调到 10 万、提现保持 5 万」时就会很难受——两个本应独立变化的规则被绑在了一起。
判断标准:如果两处代码会因为不同的原因而修改,它们就不是重复。
反过来,真正的重复(同一个业务规则在三个地方各写一遍)必须消除,否则改的时候一定会漏。
3.2 KISS 与 YAGNI
- KISS:能用简单方式解决就不要引入复杂机制。一个
if能解决的判断,不需要策略模式加工厂。 - YAGNI:不为「以后可能需要」的功能写代码。预留的扩展点在真需求到来时,十有八九方向是错的。
这两条是对前面所有原则的制动器。SOLID 倾向于「多拆分、多抽象」,KISS 与 YAGNI 负责踩刹车。
YAGNI 针对的是没有证据的需求。下面两类情况要主动设计,而不是等问题出现:
- 已经发生过的变化:同一个地方第二次因为同一类需求被修改,就是抽象的信号。
- 出错代价很高的边界:对外 API、消息格式、数据库表结构、权限和资金相关的逻辑,改错一次的代价远高于提前设计的成本。
Kent Beck 的四条简单设计规则给出了「简单」的可检查定义,按优先级排列:
- 通过所有测试;
- 清楚地表达意图;
- 没有重复的知识;
- 在满足前三条的前提下,元素(类、方法)尽可能少。
第四条正是对过度抽象的约束:多出来的接口和类,要能用前三条中的某一条来证明。
3.3 可测试性是反馈
可测试性不是和 SOLID 并列的第六条原则,而是检验它们的反馈。一段代码难以测试,通常是下面几种情况之一:
- 必须启动容器或连接外部服务才能测,说明规则和 I/O 混在一起(依赖倒置没做到);
- 结果依赖当前时间或随机数,说明不可控的输入没有被隔离;
- 一个测试要准备大量无关的数据,说明这个类承担了太多职责(单一职责)。
这些信号的实测对比见 关注点分离与可测试性。
四、原则冲突时怎么选
真实的代码评审里,冲突比遵守更常见:
| 冲突 | 判断依据 |
|---|---|
| 单一职责 vs KISS | 这两部分会不会因为不同原因修改?不会就先合着 |
| DRY vs 解耦 | 两处代码会不会因为不同原因修改?会就允许重复 |
| 开闭原则 vs YAGNI | 第二种情况出现了吗?没出现就先写 if |
| 接口隔离 vs 调用方便利 | 实现类里有没有空方法?没有就不用拆 |
统一的判断标准只有一个:变化。会独立变化的东西要分开,会一起变化的东西要放在一起,还看不出会不会变的东西先别动。
五、常见误区
- 「原则是规范,必须全部遵守」:它们是经验总结,适用条件是「代码会变化」。
- 「用了接口就是依赖倒置」:接口按实现的能力定义时,依赖方向并没有改变。
- 「代码一样就要抽出来」:DRY 针对的是知识重复,不是文本重复。
- 「先把扩展点留好」:没有第二个用例时,抽象往往是错的。
- 「用了 Spring 注入就是依赖倒置」:依赖注入解决对象由谁创建,依赖倒置解决依赖指向哪里,两者可以独立存在。
- 「类拆得越小越符合单一职责」:总是一起修改的两个类,应该合回去。
- 「设计模式用得越多设计越好」:模式是解决特定问题的手段,见 设计模式决策图。
小结
把五条 SOLID 原则翻译成可检查的问题,比记住定义有用得多:这个类有几个修改理由?加新类型要不要改老代码?子类能不能替换父类?调用方是不是只用到接口的一小部分?业务代码里有没有技术类名?然后用 KISS 和 YAGNI 做制动:**在变化真正出现之前,简单就是最好的设计;变化已经出现、或者边界出错代价很高时,就该主动设计。**难以测试的代码,是提醒你回头检查这几个问题的信号。
配套实验
- codesphere-labs/design/jdk-api-facts:
Connection接口规模与 default 方法行为(验证记录)
可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。
参考资料
- Robert C. Martin,《Clean Architecture》第 3 部分「设计原则」
- Robert C. Martin:The Single Responsibility Principle
- Martin Fowler:Inversion of Control Containers and the Dependency Injection pattern
- Martin Fowler:DIP in the Wild
- Andy Hunt、Dave Thomas,《The Pragmatic Programmer》中关于 DRY 的论述
- Martin Fowler:Yagni
- Martin Fowler:Beck Design Rules