
1. 从一锅乱炖的老项目说起SOLID 五大原则到底治什么病SOLID 五大原则这套东西我最早是在一本讲敏捷开发的书里看到的当时觉得就是五个拗口的英文缩写背下来应付面试用。真正让我对它改观是接手了一个跑了六年多的订单系统。那个项目里有一个叫OrderManager的类三千两百多行方法从下单、算价、扣库存、生成对账单、发短信到对接财务接口全塞在里面。产品经理提一句“满减规则改一下”我得先花半天时间把整个类的调用链摸清楚改完之后还得让测试同学把支付、退款、开票全跑一遍因为没人敢保证这一刀切下去会不会溅到别处。那种“改一处崩三处”的疼就是 SOLID 五大原则要解决的病。它不是什么高深的算法也不是某个框架的专属技巧而是一套关于代码该怎么分工、怎么留口子、怎么互相调用的工程约定。这五条原则分别是单一职责原则Single Responsibility Principle、开闭原则Open-Closed Principle、里氏替换原则Liskov Substitution Principle、接口隔离原则Interface Segregation Principle和依赖倒置原则Dependency Inversion Principle把首字母拎出来就是 SOLID。这套内容适合谁看我自己的判断是三类人。第一类是写了半年到三年代码、能跑通业务但一改需求就心慌的同学SOLID 能帮你把“能跑”升级成“好改”。第二类是要做代码评审、要带新人的技术负责人你需要一套能说得出口、能落地打分的话术而不是一句“这代码写得不行”拍过去。第三类是准备面试的SOLID 五大原则几乎是必考题但面试官真正想听的不是定义而是你有没有在真实项目里为了它纠结过、妥协过。我下面不会给你背定义。我会拿一个贯穿全文的订单场景把这五条原则一条一条拆开讲清楚每条原则保护的到底是什么、代码怎么写、什么时候不该用。这篇内容偏长建议你先收藏写代码卡住的时候翻出来对着改。2. 单一职责原则一个类只能有一个“被改动的理由”2.1 判断职责的标准不是“功能多少”而是“谁会来改它”大部分人对单一职责原则SRP的理解停留在“一个类只做一件事”这个说法太模糊了。一个订单类既能算价又能入库算几件事说不清。我更喜欢 Uncle Bob 给的那个判定口径一个类应该只有一个引起它变化的原因。换句话说你去看这个类问自己一句话——有哪些不同的角色会跑过来要求我改这个文件回到我那个OrderManager。它为什么难改因为有三拨人盯着它。运营要改优惠规则运维要改落库字段客服要改短信模板。这三拨人的关注点完全不重叠节奏也不一样可他们改的却是同一个文件。三个人同时提需求代码冲突能冲突到你想砸键盘。这就是“多个变更原因”压在同一个类上的典型症状。所以判断要不要拆我通常这么做把最近三个月这个文件的所有提交记录拉出来看提交信息里的关键词。如果提交集中在“优惠”“活动”“券”这类词说明它是营销维度的如果混着“数据库”“字段”“索引”说明它同时承担了持久化职责。提交信息的词越杂说明这个类的职责越脏。这里有个容易踩的坑职责的划分依据是业务变化的边界不是技术分层。有人把 SRP 理解成“Controller 干 Controller 的、Service 干 Service 的”然后在一个 Service 里同时做校验、计算、落库、发消息觉得自己分层很规范。分层只是横向切SRP 要求的是纵向把变化原因切开。一个订单服务里计算逻辑会随促销活动变落库逻辑会随表结构调整变这两件事变化的原因不同就该拆。2.2 实操把一个三千行的类拆成四个小角色我拆OrderManager的思路很朴素先按“变更原因”画线画完再动代码。具体三步第一步把所有方法列成一张表标注每个方法服务的是谁。比如calcDiscount服务运营saveOrder服务运维和 DBAsendSmsNotice服务客服buildMonthlyBill服务财务。标完之后你会发现方法天然聚成了几坨。第二步为每一坨定义一个角色接口。注意是接口不是实现类因为接口是你对外承诺的契约实现类随时可以换。第三步原类只保留编排逻辑也就是“先干什么、再干什么”把具体干活的部分委托出去。我给那段代码重构后的骨架大概长这样// 只负责编排不负责具体计算和持久化 public class OrderService { private final PriceCalculator priceCalculator; private final OrderRepository orderRepository; private final Notifier notifier; public OrderService(PriceCalculator priceCalculator, OrderRepository orderRepository, Notifier notifier) { this.priceCalculator priceCalculator; this.orderRepository orderRepository; this.notifier notifier; } public OrderResult submit(OrderCommand command) { Money payable priceCalculator.calculate(command); Order order Order.create(command, payable); orderRepository.save(order); notifier.notifyOrderCreated(order); return OrderResult.of(order); } }拆完之后最直观的变化是运营再提优惠规则我打开PriceCalculator就完事测试只需要覆盖计算相关用例压根不用碰支付和落库。变更半径从整个订单模块缩到了单个类这就是 SRP 带来的实际收益。2.3 注意事项别把 SRP 用成“一个方法一个类”我在代码评审里见过走极端的。有人把每个方法都抽成一个类OrderValidator、OrderValidatorHelper、OrderValidatorHelperUtil一个下单流程穿过了十四个类读代码像在玩跳房子。这不是 SRP这叫碎片化。我的经验判断标准是拆分的收益要大于跳转的成本。一个小工具方法只有三行抽出去反而让读者多点两次跳转那就不值得。真正的拆分信号是“变化原因不同”和“代码体积已经影响阅读”而不是“职责听起来不一样”。还有一个隐藏陷阱拆完之后这几个类如果共享了一大堆私有状态说明你的拆法有问题。比如你把计算逻辑抽走了但新类里还要回调原类的十几个 getter那本质上逻辑还是耦合的只是换了个文件而已。这时候要考虑的是把共享状态提出来做一个值对象而不是硬拆。3. 开闭原则需求变的时候老代码一行都别动3.1 扩展点该在哪儿预留看三个信号开闭原则OCP说的是软件实体应该对扩展开放、对修改关闭。很多人一听就皱眉需求天天变怎么可能不改老代码这话对但理解偏了。OCP 不是禁止你改代码而是说面对同一类变化时你应该只新增文件而不是去动那个稳定的核心逻辑。拿折扣计算举例最原始的写法通常是这样public Money calculate(Order order) { if (order.getType() OrderType.NORMAL) { return order.getAmount(); } else if (order.getType() OrderType.VIP) { return order.getAmount().multiply(0.9); } else if (order.getType() OrderType.PROMOTION) { return order.getAmount().multiply(0.7); } throw new IllegalArgumentException(unknown type); }这段代码的问题不是丑而是每加一种活动就要回来改一次 if-else改的时候还有可能手滑把上一行的分号删了。更要命的是这个方法的测试用例会越来越多每次改动都要重跑全量。什么时候该引入扩展点我一般看三个信号。第一这段逻辑在过去半年里改过三次以上第二分支数量超过四五个并且还在涨第三不同的分支由不同的人维护。三个信号命中两个我就动手改造了。3.2 用策略加注册表把 if-else 换掉改造的核心是定义一个稳定的抽象把变化的实现挡在外面。public interface DiscountPolicy { OrderType supportedType(); Money apply(Order order); } Component public class NormalDiscountPolicy implements DiscountPolicy { Override public OrderType supportedType() { return OrderType.NORMAL; } Override public Money apply(Order order) { return order.getAmount(); } } Component public class VipDiscountPolicy implements DiscountPolicy { Override public OrderType supportedType() { return OrderType.VIP; } Override public Money apply(Order order) { return order.getAmount().multiply(0.9); } }然后搞一个注册表启动的时候把所有策略按类型塞进MapComponent public class DiscountPolicyRegistry { private final MapOrderType, DiscountPolicy policyMap; public DiscountPolicyRegistry(ListDiscountPolicy policies) { this.policyMap policies.stream() .collect(Collectors.toMap(DiscountPolicy::supportedType, p - p)); } public Money calculate(Order order) { DiscountPolicy policy policyMap.get(order.getType()); if (policy null) { throw new IllegalArgumentException(no policy for order.getType()); } return policy.apply(order); } }改造完运营说“加一个拼团折扣”我要做的事情只有一件新建GroupBuyDiscountPolicy实现接口加注解重启。DiscountPolicyRegistry一行都不用动它的单元测试也永远不用重写。这就是对扩展开放、对修改关闭的实际样子。顺带说一句这种写法还有个附带好处策略类彼此独立新人接手的时候只要看懂一个策略就懂了全部策略的套路学习成本直线下降。3.3 什么时候不该硬套开闭原则OCP 是最容易被过度使用的一条。我见过有人给一个永远只有两种状态的枚举字段也搞了策略模式多写了三个类和一个注册表只为了“符合开闭”。这纯属自残。我的判断是变化频率低、分支稳定、逻辑极短的判断老老实实写 if-else。比如性别判断、订单是否已支付的布尔判断你抽成策略类只会让代码更难看懂。OCP 值得投入的前提是“变化还会继续来”如果它已经是终点了抽象就是纯成本。还有一点引入扩展点是有代价的它会打断阅读的连贯性。读者看calculate方法本来一行 if 就懂了现在要跳到接口、再跳到实现类。所以只有在“未来新增分支的收益 现在的跳转成本”时才划算这个账得你自己算。4. 里氏替换原则子类别给父类拆台4.1 契约式设计的四条硬要求里氏替换原则LSP听起来最学术其实说人话就是一句话凡是父类能用的地方换成子类之后程序的行为不能出问题。这里的“不能出问题”不是指不报错而是指不违背调用者的预期。具体拆成四条可检验的规则。前置条件不能加强父类要求参数大于零子类不能说必须大于一百那调用者按父类约定传五十就会炸。后置条件不能减弱父类承诺返回非空集合子类不能返回 null。不变式必须保持父类保证账户余额永不为负子类不能破坏它。历史约束要遵守父类规定方法是幂等的子类不能改成调一次扣一次钱。这四条听起来抽象落到代码上就是一个个具体的坑。我印象最深的一次事故是团队里有人给一个AccountService写了子类FrozenAccountService重写了withdraw方法遇到冻结账户直接抛异常。父类的调用方本来是按“返回失败结果”来处理的结果整个批处理任务因为一个异常全部中断第二天对账才发现少扣了一大笔。这就是典型的违背 LSP。4.2 经典反例正方形凭什么不能继承长方形教科书上的正方形继承长方形我一开始也觉得是抬杠后来自己写图形渲染代码才明白。父类长方形有setWidth和setHeight两个独立方法调用者写了一段逻辑设置宽为五、高为四然后断言面积是二十。这时候传进去一个正方形子类setWidth(5)顺带把高也改成了五最后面积变成二十五断言直接失败。代码没报错逻辑却错了这种 bug 最难查。正确的做法是让正方形和长方形都实现同一个Shape接口各自暴露area()方法谁也别继承谁。因为它们的行为契约根本不同长方形允许宽高独立变化正方形不允许。共用接口不共用实现。public interface Shape { double area(); } public class Rectangle implements Shape { private final double width; private final double height; public Rectangle(double width, double height) { this.width width; this.height height; } Override public double area() { return width * height; } } public class Square implements Shape { private final double side; public Square(double side) { this.side side; } Override public double area() { return side * side; } }4.3 用契约测试把 LSP 变成可执行的检查靠人眼盯 LSP 是不靠谱的我的做法是写父类契约测试然后让所有子类继承这套测试。父类测试里只依赖抽象接口覆盖前置条件、后置条件和不变量。任何子类只要跑不过这套测试就说明它违背了 LSP构建直接挂掉压根进不了主干。public abstract class NotifierContractTest { protected abstract Notifier createNotifier(); Test void 发送成功应返回成功结果() { Notifier notifier createNotifier(); Result result notifier.send(new Message(13800000000, hello)); assertNotNull(result); assertNotEquals(ResultStatus.UNKNOWN, result.getStatus()); } Test void 空接收人应返回失败而不是抛异常() { Notifier notifier createNotifier(); Result result notifier.send(new Message(, hello)); assertEquals(ResultStatus.INVALID_TARGET, result.getStatus()); } }这条经验我觉得比任何定义都值钱LSP 的落地手段是测试继承不是代码评审。人会有侥幸心理机器不会。5. 接口隔离原则别逼着别人实现他用不到的方法5.1 胖接口的三个典型症状接口隔离原则ISP说的是客户端不应该被强迫依赖它不使用的方法。这话翻译成现场语言就是别搞那种一个接口十几个方法的“胖接口”。胖接口有三个我一眼就能认出来的症状。第一个是空实现某个实现类里有一堆方法是throw new UnsupportedOperationException()或者干脆return null。第二个是命名带“多功能”比如CommonDeviceService、GeneralUserService名字里带“通用”的接口基本都有问题。第三个是新增方法引发连锁反应你往接口里加一个方法结果七八个实现类全编译不过挨个去补空实现。我遇到过一个典型的设备管理接口里面有print、scan、fax、copy、staple五个方法。老式打印机只能打印去实现这个接口就得给scan和fax写空实现。后来新来的同事看见空实现以为是历史遗留没删干净顺手把fax的实现补上了结果一调就崩。这种坑不怪人怪接口一开始就设计得太贪。5.2 按角色拆接口而不是按实现拆拆接口的正确姿势是按客户端的角色拆不是按实现类的数量拆。上面那个设备客户端其实分三类只需要打印的、需要扫描的、需要传真的。那就拆成三个接口public interface Printer { void print(Document doc); } public interface Scanner { Document scan(); } public interface FaxMachine { void fax(Document doc); } // 支持打印和扫描的一体机同时实现两个接口绝不实现它不会的 public class MultiFunctionDevice implements Printer, Scanner { Override public void print(Document doc) { /* ... */ } Override public Document scan() { /* ... */ } }拆完之后那个“空实现”的臭味就没了。调用方需要打印就声明Printer它自然拿不到scan方法也就不可能在运行时调错。这叫把错误从运行期提前到编译期是性价比极高的防御。拆到什么粒度算合适我的经验是看方法之间的使用相关性。如果每次调用 A 的人几乎都要调用 B那 A 和 B 放在同一个接口里是合理的如果 A 和 B 的调用者几乎不重叠就该拆。别只看方法本身像不像同一类东西。5.3 ISP 和 SRP 到底是不是一回事很多人搞混 ISP 和 SRP觉得都是从“拆”这个角度出发的。区别清楚对实际工作有用。SRP 约束的是实现类关注的是这个类有几个变更原因是站在开发者的角度防止代码腐化。ISP 约束的是接口关注的是客户端被迫依赖了多少无关方法是站在调用者的角度减少耦合。一个是从里往外看一个是从外往里看。举个具体例子。一个接口有十个方法全都属于同一个变更原因比如都是订单查询相关那它符合 SRP但只要存在“有的调用者只用其中一个方法”的情况它就违反了 ISP。反过来一个接口只有两个方法但属于两个变更原因那它违反 SRP 却可能不违反 ISP。所以这两条要分开检查不能互相替代。6. 依赖倒置原则高层模块不该认识数据库长什么样6.1 DIP、DI、IoC 三个词别搅在一起依赖倒置原则DIP经常被和依赖注入DI、控制反转IoC混为一谈面试的时候也常常被问懵。我用一句话把它们分开DIP 是设计原则说的是高层模块不应该依赖低层模块两者都应该依赖抽象抽象不应该依赖细节细节应该依赖抽象。DI 是实现手段说的是把依赖从内部 new 出来改成从外部传进来。IoC 是更宽的思路把控制权从你的代码交给框架DI 只是它的实现方式之一。一句话概括DIP 是目标DI 是工具IoC 是更大的容器。拿订单服务举例。原始写法长这样public class OrderService { private final EmailSender sender new EmailSender(); public void submit(Order order) { sender.send(order.getUserEmail(), 下单成功); } }这段代码的问题不是它不能跑而是OrderService把EmailSender钉死了。测试的时候你没法不真发邮件换成短信通知你得回来改OrderService的源码甚至要动构造函数。这就是高层模块依赖了低层细节。6.2 手工装配和容器装配先把原理搞明白改造的第一步是在中间插一个抽象public interface OrderNotifier { void onOrderCreated(Order order); } public class EmailNotifier implements OrderNotifier { private final EmailClient emailClient; public EmailNotifier(EmailClient emailClient) { this.emailClient emailClient; } Override public void onOrderCreated(Order order) { emailClient.send(order.getUserEmail(), 下单成功); } } public class OrderService { private final OrderNotifier notifier; public OrderService(OrderNotifier notifier) { this.notifier notifier; } public void submit(Order order) { // 省略计算与落库 notifier.onOrderCreated(order); } }这时候OrderService只知道有个东西能通知用户不知道是邮件还是短信。测试的时候你可以塞一个记录调用的假实现一行网络请求都不发public class RecordingNotifier implements OrderNotifier { public final ListOrder notified new ArrayList(); Override public void onOrderCreated(Order order) { notified.add(order); } }有些同学会问用了 Spring 之后这个构造函数的参数谁给我传是容器扫描到OrderNotifier的实现类自动注入的。但我建议你在学 DIP 的阶段先手工装配一遍也就是在 main 方法里自己 new 出来往里塞。只有手工装配过你才知道容器到底帮你做了什么出问题的时候才不会两眼一抹黑。我见过太多人只会写Autowired一旦出现循环依赖就完全不知道怎么下手。6.3 抽象泄漏是 DIP 最常见的翻车方式DIP 不是插个接口就完事了。我见过最典型的错误是接口里直接暴露了具体技术细节public interface OrderRepository { // 方法名里带 MySQL参数是 SQL 语句这就是抽象泄漏 ListMapString, Object queryBySql(String sql); }这个接口虽然叫Repository但它把 SQL 和表结构这两件最容易变的事情暴露给了高层。上层一旦按这个接口写代码将来从关系库换到别的存储改动量一点都没减少DIP 白做了。合格的抽象应该是findById、findByUserAndStatus这种业务语义的方法把技术细节关在实现类里面。另一个坑是抽象过度。所有东西都插一层接口UserService只有一个实现还非要搞UserService加UserServiceImpl代码里到处都是没意义的跳转。我的原则是只有当存在第二个实现、或者需要跨边界数据库、外部系统、时间、随机数的时候才抽接口。纯粹为了“规范”而抽的接口最后只会变成负担。7. 落地检查表五大原则怎么在评审里真正用起来7.1 代码评审时按这五条扫一遍原则这东西写在书上很虚落到评审里必须有可执行的动作。我整理了一份自己常用的速查表评审的时候按顺序过一遍效率比漫无目的地看代码高得多。原则评审时问自己的问题危险信号SRP这个类最近三个月的提交里出现了几类关键词文件超过八百行提交信息跨了三个业务域OCP加一个新类型需要改几个已有文件每加一种类型都要回来改 if-else 或 switchLSP子类有没有加强参数校验或抛出父类没声明的异常子类里有大量空实现和异常抛出ISP有没有实现类被迫写不用的方法出现UnsupportedOperationException的空壳方法DIP高层模块有没有直接 import 具体的技术类Service 里直接 new 数据库连接或者 HTTP 客户端这张表最大的价值是把主观感受变成客观提问。以前我说“这段代码耦合太重”对方会问“重在哪”现在我能指着OrderService里那个new EmailSender()说这里违反了 DIP改成构造函数注入测试就能脱离网络跑。7.2 常见问题排查速查实际改代码的时候症状和病因往往对不上我整理了一份对照关系出问题的时候可以顺着找。症状可能违反的原则排查方向改一个小需求要跑全量回归SRP、OCP看变更影响的类数量和测试范围单元测试必须连数据库才能跑DIP检查构造函数里有没有 new 具体实现加了新子类导致老功能异常LSP对比父子类的前置后置条件和异常声明引入新框架要改上百个文件DIP看业务代码里有没有直接依赖框架类型实现类里一堆空方法ISP检查接口的方法是否被所有实现类都用到7.3 什么时候该停下来过度设计的四个信号最后说点反面经验。SOLID 这五条用过头代码会变成另一种灾难。我见过一个项目一个 CRUD 接口搞了四层抽象从 Controller 到 Mapper 中间隔着六个类全是透传没有任何逻辑。新人进来第一周都在问“这个类到底干嘛的”。我自己总结的四个过度设计信号第一接口只有一个实现而且短期内确定不会有第二个第二为了符合原则引入的抽象让最简单的一个操作需要跨三个文件才能看懂第三代码行数比功能本身多出好几倍业务逻辑被淹没在样板里第四团队成员开始抱怨“看不懂”而不是“不好改”。任何一条命中我都会退回去简化。SOLID 是手段不是目的它服务的对象是未来的修改成本。如果为了遵守它反而增加了理解成本那这笔买卖就是亏的。我个人在这些年真正体会到的分寸感是先让它能跑再在第二次修改的时候重构。第一次写就想着抽象往往会抽错因为你还不知道变化会从哪里来等改到第二次、第三次变化的规律显现出来了这时候按 SOLID 动手抽象的位置才是准的。我最早学 SOLID 的时候特别着急恨不得把手上所有代码都拆一遍结果拆出一堆没人愿意维护的碎片。后来慢慢明白这五条原则更像是给有经验的开发者准备的收敛工具而不是给新手用的起手式——你得先写过足够多的烂代码才知道好代码到底好在哪里。