跳至正文
来两杯美式
返回

软件设计原则:SOLID + KISS / YAGNI / LOD 全景

By 来两杯美式
发布于更新于

写了 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 { /* 报表导出 */ }

如何判断类的职责是否足够单一

这是当时笔记里记下的「经验信号」,比定义更实用:

真实落地案例: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 接口隔离 ───────┘                        │
              │  (保证抽象不会胖)                              │
              ▼                                                 ▼
        类的设计要小                                    高层只依赖抽象
原则直接服务于
SRPOCP(职责单一才不会一处改动牵连多处)
LSPOCP(子类能安全替换,多态扩展才成立)
ISPDIP(抽象接口要细,否则依赖就臃肿)
DIPOCP(依赖抽象,才能换实现不改调用方)

核心论点OCP 是 SOLID 的最终目标——其他四条原则都是在为 OCP 铺路。

二、KISS 原则

KISS 有多个英文展开版本:

  • Keep It Simple and Stupid.
  • Keep It Short and Simple.
  • Keep It Simple and Straightforward.

2.1 如何实现 KISS

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 「朋友」的定义

一个对象的「朋友」包括:

  1. 对象本身this
  2. 方法的入参
  3. 方法内创建的对象
  4. 对象的直接成员变量(聚合 / 组合关系)

不算朋友的:通过朋友得到的对象(即「朋友的朋友」)。

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 OCPOCP 要求预留扩展点,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() 的 switchOCP每次合并冲突,回归测试
子类 @Override 后抛了 RuntimeException 而父类声明的是 IOExceptionLSP调用方 catch 不住,崩溃
JavaSE-标准库-合集接口 暴露了 30 个方法给所有实现ISP空实现遍地
业务直接 new MySqlDao(),迁移 PG 改了 80 处DIP大重构
getUser().getDept().getCompany().getName()LOD任何一层 API 变更都波及
给内部工具加「未来可能用的国际化」YAGNI一年没用上,反而每次改都要绕过
自己写个 List 分页工具类KISSbug 一堆,最后用 subList 一行解决

七、原则在框架中的体现

框架 / 库主要体现的原则怎么体现的
Spring IoCDIP业务类只声明依赖接口,容器注入实现
Spring AOPSRP + OCP切面把「日志、事务、权限」从业务代码剥离
Servlet 规范DIP + OCPTomcat 和 Web 应用都依赖 Servlet 抽象
SLF4JOCP + DIP业务依赖 Logger 接口,底层可换 Logback / Log4j2
JDBCDIP + OCP业务依赖 java.sql.Connection,可换 MySQL / PG 驱动
MyBatisSRPMapper 只管 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 复杂系统业务建模(总览)——这八大原则在领域驱动设计中的完整应用,从战略设计到战术设计。


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
查看系列全部文章
  1. 01.软件设计原则:SOLID + KISS / YAGNI / LOD 全景
  2. 02.设计模式之原型模式,及深浅拷贝
  3. 03.设计模式之工厂模式:简单工厂、工厂方法与抽象工厂
  4. 04.设计模式之建造者模式:复杂对象的组装艺术
  5. 05.设计模式之适配器模式:让不兼容的接口协同工作
  6. 06.设计模式之桥接模式:抽象与实现分离,各自独立变化
  7. 07.设计模式之装饰者模式:动态增强,比继承更有弹性
  8. 08.设计模式之代理模式:静态代理与 JDK 动态代理
  9. 09.设计模式之外观模式:统一门面,简化子系统调用
  10. 10.设计模式之享元模式:共享细粒度对象,降低内存占用
  11. 11.设计模式之策略模式:算法家族,自由切换
  12. 12.设计模式之模板方法模式:固定骨架,可变步骤
  13. 13.设计模式之观察者模式:一对多依赖,状态变更自动通知
  14. 14.设计模式之责任链模式:请求逐级传递,动态组合处理者
  15. 15.设计模式之状态模式:状态变,行为变
  16. 16.设计模式之迭代器模式:不暴露内部实现,顺序访问集合

上一篇
Linux 命令大全:SSH 免登录、查找、防火墙与运维必会命令
下一篇
DDD 复杂业务系统设计(三):限界上下文,为什么“课程”在教务和市场是两种东西