写了 4 年业务代码、踩过无数坑之后,回头再看这些「软件设计原则」,发现它们才是真正决定代码长期可维护性的东西——比任何具体框架、具体语法都重要。本篇把当时整理的笔记重新梳理一遍,每条原则都补上反例 → 正例的 Java 代码和真实框架的落地案例。
一、SOLID 原则
SOLID 是面向对象设计五大原则的首字母缩写——单一职责、开闭、里氏替换、接口隔离、依赖倒置。这五条原则共同回答了一个核心问题:如何让软件既能应对当下的需求变化,又不至于在迭代中腐化。
1.1 单一职责原则(SRP)
定义:不要存在多于一个导致类变更的原因。通俗讲——一个类 / 接口 / 方法只负责一项职责。
优点:降低类的复杂度,提高类的可读性、提高系统的可维护性、降低变更引起的风险。
反例:一个 UserService 干所有事
// ❌ 反例:混合了用户管理、权限校验、邮件通知、数据持久化四种职责
public class UserService {
public void register(User user) { /* 注册逻辑 */ }
public boolean checkPermission(Long userId, String perm) { /* 权限校验 */ }
public void sendWelcomeEmail(User user) { /* 邮件发送 */ }
public void saveToDb(User user) { /* 持久化 */ }
public void exportExcel(List<User> users) { /* 报表导出 */ }
}
正例:按职责拆分
// ✅ 正例:每个类只做一件事
public class UserService { /* 用户注册、查询、修改 */ }
public class PermissionService { /* 权限校验 */ }
public class EmailService { /* 邮件发送 */ }
public class UserRepository { /* 持久化 */ }
public class UserExporter { /* 报表导出 */ }
如何判断类的职责是否足够单一
这是当时笔记里记下的「经验信号」,比定义更实用:
- 类的代码行数、函数或者属性过多,影响代码可读性和可维护性
- 类依赖的其他类过多(构造函数注入了一长串)
- 类中私有方法过多——就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供给更多的类使用
- 比较难给类起一个适合的名字——很难用一个业务名词概括,或者只能起类似
Manager、Context、Helper之类的泛化词语,这就说明类的职责不够清晰 - 类中大量的方法都集中操作类中的某几个属性——说明这个类内部其实藏着两个职责
真实落地案例:Spring 的
BeanFactory只负责创建 Bean,ApplicationContext在它之上加了资源管理、事件发布、AOP 等增强能力——职责拆得很干净。对比之下,早期HttpSession在 Servlet 规范里既存数据又做监听又参与序列化,最后成了 Java EE 里最难演进的接口之一。
1.2 开闭原则(OCP)
定义:一个软件实体(类、模块、函数)应该对扩展开放,对修改关闭。用抽象构建框架,用实现扩展细节。
反例:每次加新支付方式都要改老代码
// ❌ 反例:加一种支付方式就要动这个方法
public class PaymentService {
public void pay(String type, BigDecimal amount) {
if ("alipay".equals(type)) {
// 支付宝逻辑
} else if ("wechat".equals(type)) {
// 微信逻辑
}
// 新增「银联」要在这里改 if-else,老方法被反复修改
}
}
正例:用抽象 + 实现扩展
// ✅ 正例:定义抽象,新增支付方式只加类不改老代码
public interface PaymentStrategy {
void pay(BigDecimal amount);
}
public class AlipayStrategy implements PaymentStrategy {
public void pay(BigDecimal amount) { /* 支付宝实现 */ }
}
public class WechatStrategy implements PaymentStrategy {
public void pay(BigDecimal amount) { /* 微信实现 */ }
}
// 新增银联:只加一个类
public class UnionPayStrategy implements PaymentStrategy {
public void pay(BigDecimal amount) { /* 银联实现 */ }
}
public class PaymentService {
private final List<PaymentStrategy> strategies;
public PaymentService(List<PaymentStrategy> strategies) {
this.strategies = strategies;
}
public void pay(String type, BigDecimal amount) {
strategies.stream()
.filter(s -> s.getClass().getSimpleName().startsWith(type))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("unsupported: " + type))
.pay(amount);
}
}
如何判断一次改动是否符合 OCP
务实标准:当一次改动
- 没有破坏原有代码的正常运行
- 没有破坏原有的单元测试
就可以认为这是一条合格的代码改动(例如向一个对象中加入新的属性以及对该属性操作的方法,而没有影响原有方法)。
关键认知:在添加新功能时,不可能任何模块、类、方法都不修改——我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。
真实落地案例:SLF4J 的
Logger接口、JDBC 的Driver接口——都是 OCP 的典范。新增日志实现 / 新增数据库,不需要改业务代码。
1.3 里氏替换原则(LSP)
定义:子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。
反例:子类破坏了父类的契约
// ❌ 反例:经典的长方形-正方形问题
public class Rectangle {
public void setWidth(int w) { /* ... */ }
public void setHeight(int h) { /* ... */ }
public int area() { return width * height; }
}
public class Square extends Rectangle {
@Override
public void setWidth(int w) {
super.setWidth(w);
super.setHeight(w); // 强制宽高一致——破坏了父类「宽高独立」的隐含契约
}
@Override
public void setHeight(int h) {
super.setWidth(h);
super.setHeight(h);
}
}
// 调用方按父类 Rectangle 写的代码:
void resize(Rectangle r) {
while (r.area() < 100) {
r.setHeight(r.getHeight() + 1); // Square 会让 width 也跟着涨,死循环!
}
}
LSP 与多态的区别
多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法,它是一种代码实现的思路。
里氏替换原则是一种设计原则,用来指导继承关系中子类如何设计——子类的设计要保证替换父类的时候,不改变原有程序的逻辑、不破坏程序原有的正确性。
LSP 的更有指导意义的描述:契约设计(Design By Contract)
子类在设计的时候要遵守父类的行为约定(协议)。父类定义函数的行为约定,子类可以改变函数的内部实现逻辑,但不能改变函数的行为约定——包括:
- 函数声明要实现的功能
- 对输入、输出、异常的约定
- 注释中所罗列的任何特殊说明
子类方法的契约规则(前置条件不能更严,后置条件不能更松):
| 契约维度 | 子类可以做的 | 子类不能做的 |
|---|---|---|
| 前置条件(入参) | 放宽(接受更多输入) | 加强(拒绝父类能接受的输入) |
| 后置条件(返回值) | 加强(返回更严格) | 放松(返回不符合父类约定) |
| 异常 | 抛父类声明异常的子类 | 抛父类未声明的新异常 |
真实落地案例:
Properties继承Hashtable——这是 Java 标准库里违反 LSP 的著名反面教材。Properties要求 key/value 都是 String,但父类Hashtable允许任意 Object,导致你可以通过父类引用塞进非 String 数据,调用方按Properties用时直接ClassCastException。
1.4 接口隔离原则(ISP)
定义:用多个专门的接口,而不使用单一的总接口。客户端不应该依赖它不需要的接口。
「接口」的广义理解
这里的「接口」可以理解为:
- 一组接口的集合(某个微服务的 API)
- 某个类库的接口
- 单个 API 接口或函数
- OOP 语言中的
interface关键字
反例:胖接口
// ❌ 反例:把所有定时任务相关的能力塞进一个接口
public interface ScheduledTask {
void runAtFixedRate();
void runWithDelay();
void cron();
void pause();
void resume();
void cancel();
void getProgress();
}
// 实现类 A:一次性延迟任务,根本不需要 cron / pause / resume,但被迫实现一堆空方法或抛异常
public class OneShotDelayTask implements ScheduledTask {
public void cron() { throw new UnsupportedOperationException(); }
public void pause() { throw new UnsupportedOperationException(); }
// ...
}
正例:按角色拆接口
// ✅ 正例:按调用方角色拆接口
public interface RunnableTask { void run(); }
public interface FixedRateTask extends RunnableTask { long period(); }
public interface CronTask extends RunnableTask { String cronExpression(); }
public interface PauseableTask { void pause(); void resume(); }
public interface ProgressAware { int getProgress(); }
// 每个类只实现它需要的接口
public class OneShotDelayTask implements RunnableTask {
public void run() { /* ... */ }
}
public class CronJobWithProgress implements CronTask, ProgressAware {
public void run() { /* ... */ }
public String cronExpression() { return "0 0 12 * * ?"; }
public int getProgress() { return 50; }
}
ISP 的判断标准
- 一个类对另一个类的依赖应建立在最小的接口上
- 建立单一接口,不要建立庞大臃肿的接口
- 尽量细化接口,接口中的方法尽量少
- 注意适度原则——一定要适度(过度拆分会产生海量小接口,反而增加心智负担)
优点:符合高内聚低耦合的设计思想,从而使类具有很好的可读性、可扩展性和可维护性。
真实落地案例:Java 集合框架的
Collection体系——List/Set/Queue各自有专门接口,而不是塞进一个SuperCollection;Spring 的BeanDefinitionReader也拆成了多个细粒度接口。
1.5 依赖倒置原则(DIP)
定义:高层模块不应该依赖低层模块,二者都应依赖其抽象。抽象不应该依赖细节,细节应该依赖抽象——针对接口编程,不要针对实现类编程。
反例:高层直接依赖低层实现
// ❌ 反例:业务类直接 new 一个 MySQL DAO,换 DB 就要改业务类
public class OrderService {
private final MySqlOrderDao dao = new MySqlOrderDao();
public void createOrder(Order o) { dao.save(o); }
}
正例:都依赖抽象
// ✅ 正例:业务和 DAO 都依赖 OrderRepository 抽象
public interface OrderRepository {
void save(Order o);
}
public class MySqlOrderRepository implements OrderRepository {
public void save(Order o) { /* MySQL 实现 */ }
}
public class OrderService {
private final OrderRepository repo;
public OrderService(OrderRepository repo) { // 构造函数注入
this.repo = repo;
}
public void createOrder(Order o) { repo.save(o); }
}
// 切换到 PostgreSQL:只加一个新实现,OrderService 一行不改
public class PostgresOrderRepository implements OrderRepository { /* ... */ }
DIP 与 IoC、DI 的关系
这是当时笔记里最容易混淆的三个概念,重新梳理一遍:
控制反转(Inversion of Control)
控制反转是一个比较笼统的设计思想,不是一种具体的实现方法,一般用来指导框架层面的设计。这里的「控制」指的是对程序执行流程的控制,而「反转」指的是:在没有使用框架前,程序员控制整个程序的执行;使用框架后,程序的执行流程由框架控制——流程的控制权从程序员「反转」给了框架。
例子:Servlet 规范。Tomcat 控制何时调用 doGet / doPost,开发者只填业务逻辑。
依赖注入(Dependency Injection)
依赖注入是控制反转的一种具体编码技巧。我们不通过
new的方式在类内部创建依赖类的对象,而是将依赖的类对象在外部创建,再通过构造函数、函数参数等方法传递(注入)给类使用。
三种注入方式:
| 方式 | 实现 | 优劣 |
|---|---|---|
| 构造函数注入 | new OrderService(repo) | ✅ 字段可 final、不可变、最推荐 |
| Setter 注入 | service.setRepo(repo) | ⚠️ 注入前对象处于不完整状态 |
字段注入(@Autowired) | 反射塞值 | ❌ 无法 final、隐藏依赖、Spring 官方不推荐 |
真实落地案例(DIP):在 Tomcat 环境下,我们编写的 Web 应用程序代码部署在 Tomcat 容器中就能被调用执行。Tomcat 是高层模块,我们编写的 Web 应用程序代码是低层模块,二者之间并没有直接的依赖关系——两者都依赖同一个「抽象」,也就是 Servlet 规范。Servlet 规范不依赖具体 Web 容器或者 Web 应用程序,而 Web 容器和 Web 应用程序依赖 Servlet 规范。
DIP 在框架中的体现:Spring 的
BeanFactory/ApplicationContext、SLF4J 的LoggerFactory、JDBC 的DriverManager——全部都是 DIP 的具体落地。
SOLID 五原则的关系
SOLID 不是孤立的五条——它们是一个整体:
┌─────────────────────────┐
│ OCP 开闭原则(目标) │
└────────────┬────────────┘
│ 怎么实现?
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ SRP 单一职责 │ │ LSP 里氏替换 │ │ DIP 依赖倒置 │
└─────────────┘ └──────────────┘ └─────────────┘
│ │ │
│ ISP 接口隔离 ───────┘ │
│ (保证抽象不会胖) │
▼ ▼
类的设计要小 高层只依赖抽象
| 原则 | 直接服务于 |
|---|---|
| SRP | OCP(职责单一才不会一处改动牵连多处) |
| LSP | OCP(子类能安全替换,多态扩展才成立) |
| ISP | DIP(抽象接口要细,否则依赖就臃肿) |
| DIP | OCP(依赖抽象,才能换实现不改调用方) |
核心论点:OCP 是 SOLID 的最终目标——其他四条原则都是在为 OCP 铺路。
二、KISS 原则
KISS 有多个英文展开版本:
- Keep It Simple and Stupid.
- Keep It Short and Simple.
- Keep It Simple and Straightforward.
2.1 如何实现 KISS
- 不要使用同事可能不懂的技术来实现代码。比如高级的正则表达式、某些编程语言中过于高级的语法(Java 的
var推断在某些场景反而降低可读性)、冷门的 API - 不要重复造轮子,要善于使用已有的工具类库。经验证明,自己去实现这些类库,出 bug 的概率更高,维护成本也更高
- 不要过度优化——不要过度使用一些奇技淫巧(比如位运算代替算术运算、复杂的条件语句代替 if-else、使用过于底层的函数等)来优化代码,牺牲代码的可读性
2.2 反例:炫技式代码
// ❌ 反例:用位运算交换两个数——快吗?不快。可读吗?极差。
public void swap(int[] arr, int i, int j) {
if (i != j) {
arr[i] ^= arr[j];
arr[j] ^= arr[i];
arr[i] ^= arr[j];
}
}
// ✅ 正例:经典临时变量——任何程序员都能秒读
public void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
2.3 如何判断代码是否「足够简单」
同样的代码,有的人觉得简单,有的人觉得不够简单——自己写的代码自己总觉得简单。
一个有效的间接方法:Code Review——如果同事对代码有很多疑问,那说明代码可能不够「简单」。
三、YAGNI 原则
YAGNI 的英文全称是 You Ain’t Gonna Need It——直译就是:「你不会需要它」。
用在软件开发中:不要去设计当前用不到的功能;不要去编写当前用不到的代码。
3.1 核心思想:不要过度设计
// ❌ YAGNI 违反:当前只有「按 id 查」一个需求,却提前预判「未来可能按姓名、邮箱、手机号查」
public interface UserRepository {
User findById(Long id);
User findByName(String name); // 当前没人调
User findByEmail(String email); // 当前没人调
User findByPhone(String phone); // 当前没人调
List<User> fuzzySearch(String kw); // 当前没人调
// ... 20 个方法,18 个没人用
}
// ✅ YAGNI 守住:当前需要什么就加什么
public interface UserRepository {
User findById(Long id);
}
// 来需求时再加 findByName,加一个方法而不是一开始就预测 5 个
3.2 KISS vs YAGNI
| 原则 | 关注点 | 区别 |
|---|---|---|
| KISS | 怎么实现——已经决定要做的事,用最简单的方式做 | 关于实现复杂度 |
| YAGNI | 要不要做——还没需求的功能不要提前做 | 关于范围复杂度 |
两者经常一起被违反——「过度设计」往往同时违反 YAGNI(做了不该做的)和 KISS(做得很复杂)。
四、迪米特法则(Law of Demeter,LOD)
迪米特法则,也叫最小知识原则(The Least Knowledge Principle)。
定义:每个模块只应该了解与它关系密切的模块的有限知识——或者说,每个模块只和自己的「朋友」说话,不和「陌生人」说话。
4.1 「朋友」的定义
一个对象的「朋友」包括:
- 对象本身(
this) - 方法的入参
- 方法内创建的对象
- 对象的直接成员变量(聚合 / 组合关系)
不算朋友的:通过朋友得到的对象(即「朋友的朋友」)。
4.2 反例:链式访问「陌生人」
// ❌ 反例:深度依赖「朋友的朋友的朋友」
public class OrderService {
public double calcDistance(Order order) {
// 拿到 Order 的 Customer 的 Address 的 Coordinate 的 location——4 层调用
return order.getCustomer()
.getAddress()
.getCoordinate()
.distanceTo(this.storeLocation);
}
}
任何中间一环(Customer 改 API、Address 字段重命名)都会波及这里。
4.3 正例:让朋友替你说话
// ✅ 正例:让直接依赖的对象提供该方法
public class OrderService {
public double calcDistance(Order order) {
return order.distanceTo(this.storeLocation); // 只跟 Order 这个朋友说话
}
}
// Order 自己知道怎么算(把责任下推给最该承担的对象)
public class Order {
public double distanceTo(Location store) {
return customer.distanceTo(store);
}
}
public class Customer {
public double distanceTo(Location store) {
return address.distanceTo(store);
}
}
public class Address {
public double distanceTo(Location store) {
return coordinate.distanceTo(store);
}
}
4.4 LOD 的适度性
LOD 也不能走极端——链式调用如 Stream、Builder 模式本身就是「违反 LOD」的,但它们可读性极好:
// 这种「链式」虽然是「陌生人」,但被广泛接受
List<String> names = users.stream()
.filter(u -> u.getAge() > 18)
.map(User::getName)
.collect(Collectors.toList());
务实判断:LOD 主要约束的是对象图导航(
a.getB().getC().getD()),不约束数据管道操作(Stream / Builder)。
五、八大原则的取舍矩阵
这些原则之间会冲突——成熟的工程师不是机械遵守,而是按业务场景做取舍。
| 冲突双方 | 冲突点 | 取舍建议 |
|---|---|---|
| SRP vs KISS | 拆分过细会让系统复杂度上升 | 拆到「单一变更原因」即可,不要为了拆而拆 |
| OCP vs KISS | 加抽象层是为了扩展,但抽象层本身就是复杂度 | 短期能预见的扩展点做 OCP,3 个月内的扩展点用 YAGNI 砍掉 |
| LSP vs 多态 | 子类要替换父类,限制子类表达力 | 父类约定要明确(契约文档化),子类突破契约是设计味道 |
| ISP vs 接口爆炸 | 拆接口过度会产生海量小接口 | 一个接口 3-5 个方法是健康区间,超过 10 就考虑拆,少于 2 就考虑合 |
| YAGNI vs OCP | OCP 要求预留扩展点,YAGNI 要求不做预留 | 以「可预见的需求」为分界——明确要做的留扩展,模糊的不留 |
| LOD vs 性能 | 不深挖对象图可能要加转发方法,增加代码量 | 业务对象深挖→守 LOD;数据流处理→豁免 |
一个常见的「过度遵守」反例
// ❌ 极端化遵守 SOLID:把简单的注册流程拆成 8 个类
RegisterController → RegisterService → RegisterValidator
→ RegisterPolicyManager → RegisterPolicy
→ RegisterEventPublisher → RegisterEventListener
→ RegisterRepository
这种「六边形架构 + DDD 战术模式 + SOLID 一起上」的写法,在一个注册接口要扩展到 50 种用户类型时是合理的;但只是一个临时活动注册接口,就是过度设计——违反 YAGNI 和 KISS。
六、原则的实战锚点
18-21 年做过的项目里,踩过的真实坑对应到的原则:
| 踩坑场景 | 违反的原则 | 后果 |
|---|---|---|
OrderService 4000 行,加一个字段改 8 处 | SRP | 任何改动都有 bug 风险 |
支付加新方式要改 PaymentService.pay() 的 switch | OCP | 每次合并冲突,回归测试 |
子类 @Override 后抛了 RuntimeException 而父类声明的是 IOException | LSP | 调用方 catch 不住,崩溃 |
JavaSE-标准库-合集接口 暴露了 30 个方法给所有实现 | ISP | 空实现遍地 |
业务直接 new MySqlDao(),迁移 PG 改了 80 处 | DIP | 大重构 |
getUser().getDept().getCompany().getName() | LOD | 任何一层 API 变更都波及 |
| 给内部工具加「未来可能用的国际化」 | YAGNI | 一年没用上,反而每次改都要绕过 |
| 自己写个 List 分页工具类 | KISS | bug 一堆,最后用 subList 一行解决 |
七、原则在框架中的体现
| 框架 / 库 | 主要体现的原则 | 怎么体现的 |
|---|---|---|
| Spring IoC | DIP | 业务类只声明依赖接口,容器注入实现 |
| Spring AOP | SRP + OCP | 切面把「日志、事务、权限」从业务代码剥离 |
| Servlet 规范 | DIP + OCP | Tomcat 和 Web 应用都依赖 Servlet 抽象 |
| SLF4J | OCP + DIP | 业务依赖 Logger 接口,底层可换 Logback / Log4j2 |
| JDBC | DIP + OCP | 业务依赖 java.sql.Connection,可换 MySQL / PG 驱动 |
| MyBatis | SRP | Mapper 只管 SQL,Service 只管业务,SqlSession 管会话 |
| 设计模式本身 | SOLID 全家桶 | 工厂 → OCP;策略 → OCP;装饰器 → SRP + OCP;适配器 → ISP |
→ 设计模式如何落地 SOLID,可参见我更早写的 原型模式:深拷贝与浅拷贝;后续 DDD 战术设计对 SOLID 的应用,见 DDD 复杂系统业务建模(总览)。
八、结语
这些原则的核心就一句话:让软件适应变化。
- 写代码时多问一句「如果需求变了,我要改几处」——这是 OCP 的灵魂
- 加新类时多问一句「这个类为什么会变」——这是 SRP 的灵魂
- 给抽象时多问一句「调用方真的需要这个方法吗」——这是 ISP 的灵魂
现代视角补一句(2026):这八大原则自 1980-2000 年代陆续提出以来,核心思想一字未改——它们是软件工程的「永恒真理」级别。
变化的是承载它们的语言和范式:
- 函数式编程把「OCP」从「继承 + 多态」演化成了「高阶函数 + 组合」
- Rust 用 trait 系统重新定义了 LSP(更严格,编译期保证)
- 微服务架构让 DIP 的「抽象」从「接口」升级到了「协议(OpenAPI / Protobuf)」
但你回头看每个新的范式,底层依然在回答 SOLID 那 5 个问题。
下一篇推荐:DDD 复杂系统业务建模(总览)——这八大原则在领域驱动设计中的完整应用,从战略设计到战术设计。