《架构整洁之道》浓缩笔记:从SOLID到组件边界的实战要点
简介《架构整洁之道》读书笔记将 Robert C. Martin 原著 324 页系统提炼为 13 页精读卡片面向软件开发者、架构师、技术负责人以及正在系统学习设计原则的读者帮助在短时间内建立整洁架构的整体认知与关键要点索引。资料为单文件 PDF共 1 个文件、1.29MB轻量易携带适合在工作间隙或评审前快速查阅。笔记按原著六大部分 33 章组织逐一覆盖设计与架构的区别和两个价值维度结构化、面向对象、函数式三种编程范式SRP/OCP/LSP/ISP/DIP 五项设计原则组件聚合与耦合、模块化与接口设计以及系统演进与常见架构风格每个模块都提炼了核心定义、实践侧重点和易混点。除了概念框架笔记还点明了对象封装继承多态的价值、函数式无副作用的优势、依赖倒置对测试与扩展的影响等细节能够帮助读者在写代码、做设计评审或备考时快速定位对应原则和方法。已有 3005 人学习下载是一份高信息密度的整洁架构速查地图。1. 架构整洁的核心指标为什么 13 页笔记比 324 页原书更常被翻起《架构整洁之道》最难读的部分往往不是那些架构图而是第一章里的这句话软件架构的终极目标是用最小的人力成本来满足构建和维护系统的需求。这句话听起来像废话但对照我自己的经历——一个订单后台三年时间里功能越加越多代码却越来越难改每次需求评审都要为“改哪里、会碰坏什么”吵半天——才意识到原书 324 页讲的不是“怎么选框架”而是“怎么让系统保持可改”。这份 13 页的浓缩笔记把原书六部分 33 章的核心结论按章节拆好保留了最值得反复看的原则和判断标准。适合两类人一类是被遗留系统拖住、想找改进切口的开发者另一类是读过原书但记不住要点、需要一份可查决策清单的架构师候选人。2. 编程范式与 SOLID 原则约束、接口与代码级落地2.1 三种范式都在“做减法”笔记里关于三种范式的描述容易让人误以为这是计算机史。实际这三段是架构决策的地基结构化编程限制 goto目的是让模块可以被“功能性降解拆解”拆到每一块都能被测试验证面向对象编程限制函数指针原因是多态提供的间接控制可以反转依赖方向函数式编程限制赋值是为了把可变状态逼到最小角落。把这三条连起来看结论很直白架构等于管理依赖加隔离状态。后面所有原则都是在回答“依赖往哪个方向走”和“状态放在哪一层”。原书在这一部分有个容易被忽略的判断强封装不是面向对象的必要条件。如果团队用 C 语言写出严格的头文件加实现文件分离同样能获得封装收益而真正不可替代的是多态这个“依赖反转开关”。所以当你看到某个设计说“我用继承做了复用”那其实没有抓住面向对象的要点。2.2 五条原则的代码级理解五条原则不是口号每条都能落到代码决策上。SRP 最容易背错。它不是说“一个类只干一件事”而是“一个模块只有一个被修改的理由”。判断方法是列出这个类可能的变更来源比如“报表字段调整”“提成规则变更”“渠道接入方式变化”如果超过一个并且来自不同业务方就可以拆。我见过大量把“上帝类”误认为“高内聚”的情况实际上它同时服务了三拨需求任何一方的修改都要回归测试全部功能这就是 SRP 被破坏的典型症状。OCP 的价值在于“新增代码而不是修改旧代码”。注意它说的是“允许新增代码来修改系统行为”不是“完全禁止修改”。实践中完全做到零修改几乎不可能但策略模式这类写法确实能把变化点从“调用方”转移到一个新增实现类。from abc import ABC, abstractmethod from dataclasses import dataclass dataclass class Order: amount: float discount: Discount None def final_amount(self) - float: return self.discount.apply(self.amount) if self.discount else self.amount class Discount(ABC): abstractmethod def apply(self, amount: float) - float: ... class NoDiscount(Discount): def apply(self, amount: float) - float: return amount class PercentDiscount(Discount): def __init__(self, rate: float): self.rate rate # 0.2 表示八折 def apply(self, amount: float) - float: return amount * (1 - self.rate)代码逻辑说明Order 不关心具体折扣策略只知道自己持有一个 Discount 对象并在结算时调用 apply。新增大促策略只需要新增一个 Discount 子类Order 和调用方都不用改。rate 的语义是折扣率0.2 表示打八折0 表示不打折。注意 Discount 接口返回的是实付金额而不是折扣额避免出现“负数折扣”这类 LSP 违约。LSP 不只在继承关系里起作用。笔记里说“必须遵守同一个约定”这个约定包括接口文档、异常类型、返回值边界。比如 PercentDiscount.rate 如果允许传 1.5返回负金额那么任何依赖“金额为正”的代码都会被破坏。所以实现类要验证参数范围class PercentDiscount(Discount): def __init__(self, rate: float): if not (0 rate 1): raise ValueError(rate must be between 0 and 1) self.rate rate这样写是让子类自己守住约定而不是让父类扛脏数据。实际项目里很多 LSP 问题来自“宽松的父类加随意的子类”而不是继承层级本身。ISP 的典型场景是“胖接口”。某个接口定义了十个方法调用方只需要两个那么依赖这个接口的代码就背负了八个不需要的变更来源。解法是把接口按调用方切分而不是按实体建模。DIP 的落地方式是“高层策略不 import 底层实现”依赖的方向指向抽象。class EmailSender(ABC): abstractmethod def send(self, recipient: str, content: str) - None: ... class SmtpSender(EmailSender): def send(self, recipient: str, content: str) - None: # 真实环境走 SMTP 协议 ... class NotificationService: def __init__(self, sender: EmailSender): self.sender sender def send_welcome(self, email: str) - None: self.sender.send(email, welcome)这段代码演示的是NotificationService 属于高层业务它不依赖 SmtpSender 这个底层细节只依赖 EmailSender 抽象。换短信、换推送都是新增实现类的事情。初始化时由组装根比如 main 函数或依赖注入容器决定实际传入哪个 sender。2.3 原则的适用边界与常见误用把原则当成教条是最常见的误用。一个内部报表系统生命周期六个月团队三个人把所有层都抽象一遍反而降低效率。原书笔记里的态度很务实保持开放但一定要务实同时考虑团队大小、技术水平和时间预算约束。架构决策不是数学证明是在约束条件下找拐点。另一个常见误用是用继承做复用。继承解决的是“类型关系”不是“代码复用”。一旦业务上出现“企鹅是鸟但企鹅不会飞”的模型继承体系就会扭曲。优先组合而不是继承——这句老话在整洁架构里同样适用。3. 组件原则从类设计升级到构建单元的三个平衡第 12 到 14 章讨论的“组件”指的是 jar、DLL、共享库这类可独立发布的构建单元。这一章的价值在于SOLID 解决的是类级别的依赖关系组件原则解决的是“哪些类应该打包在一起、它们之间依赖方向怎么控制”。实际工程里这是从“代码能跑”到“系统能持续交付”的分水岭。3.1 组件聚合三个原则互相拉扯笔记列出三个聚合原则。REP复用/发布等同原则说复用粒度等于发布粒度。也就是说如果你希望别人复用你写的某个类它必须跟着所在组件一起发布。CCP共同闭包原则说因为相同原因、在相同时间修改的类应该放进同一个组件。CRP共同复用原则说不要让一个组件的用户依赖他们不需要的东西。这三个原则不是同时满足的。REP 和 CCP 倾向于把更多类聚合在一起让发布更省事CRP 倾向于拆小避免用户被不需要的类拖累。真正落地时是权衡——把变更频率相似的类放在一起同时保证消费者只需要依赖他们用到的那部分。笔记里那句话值得圈出来组件结构不可能自上而下设计出来必须随着系统变化而变化和扩张。所以不要在项目第一天就定死分组先按业务域粗分然后根据实际变更记录调整。3.2 组件耦合依赖环与稳定性组件耦合部分的三个原则本质是给依赖图立规矩。无依赖环原则组件依赖图中不能出现环。一旦出现环本来可以独立部署的组件被迫同生共死改一处要全部回归。SDP稳定依赖原则依赖关系必须指向更稳定的方向。SAP稳定抽象原则组件的抽象程度要与稳定性一致。原子依赖环可以这样检测# 使用 jdeps 检查 jar 依赖Java 项目 jdeps -summary target/classes # 或者用 gradle 的 dependency graph 插件 ./gradlew dependencies --configuration runtimeClasspath逻辑说明jdeps 是 JDK 自带的分析工具会输出每个 jar/class 依赖了哪些包人工扫一眼就能发现 A 依赖 B、B 又依赖 A 的环。gradle dependencies 列出完整依赖树重点看是否有组件重复出现。如果发现环开一个代码评审专门讨论“谁该反转依赖”。我一般会在 CI 里放一个依赖检查脚本把“发现环”当成构建失败处理。常见做法是写一个简单的 Python 脚本扫描 import 图import ast import os from collections import defaultdict def scan_imports(src_dir): deps defaultdict(set) for root, _, files in os.walk(src_dir): for f in files: if not f.endswith(.py): continue path os.path.join(root, f) module os.path.relpath(path, src_dir).replace(os.sep, .)[:-3] with open(path, r, encodingutf-8) as fh: tree ast.parse(fh.read()) for node in ast.walk(tree): if isinstance(node, ast.ImportFrom): deps[module].add(node.module or ) elif isinstance(node, ast.Import): for alias in node.names: deps[module].add(alias.name) return deps def find_cycles(deps): visiting set() visited set() cycles [] def dfs(node, path): if node in visiting: cycles.append(path[path.index(node):] [node]) return if node in visited: return visiting.add(node) for nxt in deps.get(node, []): dfs(nxt, path [nxt]) visiting.remove(node) visited.add(node) for n in deps: dfs(n, [n]) return cycles这段脚本的逻辑说明用 Python 的 ast 模块解析每个文件的 import 语句构建模块之间的依赖有向图再用 DFS 找出环。参数说明src_dir 是源码根目录只处理 .py 文件node.module 保存 from x import y 的 x。find_cycles 返回所有环路径格式是 [a, b, a]。实际使用中我会把环的路径打印到 CI 日志里同时返回非零退出码让流水线失败。注意这个脚本只处理语法层面的依赖动态 import 和反射调用检测不到但对大多数项目已经够用。3.3 稳定性与抽象度的匹配SDP 和 SAP 合在一起说的意思是高层策略组件应该既稳定又抽象低层细节组件可以不稳定但必须具体。对应到代码就是依赖倒置在组件层面的样子——业务规则组件定义接口数据库组件实现接口并依赖业务组件。依赖方向从低层指向高层。刚才那个 Python 环形依赖扫描脚本本身也是组件原则的实践它把“检测”和“报告”分成两个函数让解析逻辑可以独立测试文件名和路径变化时不影响环路算法。这就是 CCP 的好处——解析细节变更只影响 scan_imports不会碰 find_cycles。4. 整洁架构与边界同心圆、依赖方向与 Main 组件从第 15 章开始笔记进入全书的中心软件架构的实质是切分组件、排列组件、安排通信方式。而“怎么切”的答案在第 17 章——边界线应该沿着系统的变更轴来画。4.1 同心圆模型的四层含义整洁架构的同心圆从内到外是业务实体企业级业务逻辑、用例应用级业务逻辑、接口适配器控制器、网关、展示器、框架与驱动程序数据库、UI、Web、外部设备。依赖规则只有一个方向源代码依赖只能指向内圈。内圈不 import 外圈的任何东西。很多人把注意力放在“分四层”上却忽略了两个细节。第一业务实体负责的是整个企业通用的业务规则用例才是某个具体应用场景的调度者。第二外圈变化不应该影响内圈——数据库从 MySQL 换到 Postgres用例层的代码应该一行都不用动。笔记里的那句话是这个模型的钥匙业务逻辑就是程序中真正用于赚钱或省钱的那部分代码它应该是系统中最独立、复用性最高的代码。4.2 边界怎么画沿着变更轴切原书第 17 章说得非常直白边界两侧的组件必须以不同原因、不同速率变化。比如订单计算逻辑和物流接口前者跟着营销策略变后者跟着第三方 API 变它们之间就该有边界。反过来如果两个模块总是因为同一个需求一起改就不该强行拆开。策略与层次给出一个判断标准一条策略距离系统输入输出越远层次越高。加密策略属于高层策略因为几乎所有内外交互都会经过它但它很少变打印格式属于低层策略变起来频繁。高层策略不应该依赖低层策略低层策略可以依赖高层策略。实际画边界时我习惯先列业务动词——下单、支付、发货、对账——再给每个动词找它的输入输出凡是输入输出长得不一样的就是潜在的边界线。4.3 Main 组件与不完全边界笔记第 26 章对 Main 组件的定位很多人会忽略Main 是系统中最低层的外围插件它的工作是加载配置、组装依赖、把控制权交给高层。我一般把依赖注入容器和装配逻辑全部放在 Main 里业务代码只通过接口拿依赖。这样测试环境里替换 Fake 实现不需要碰任何业务代码。第 24 章讨论了“不完全边界”。如果一开始就把系统拆成多个可独立部署的组件成本很高但完全不画边界未来又要返工。原书给了两种折中单向边界策略模式和门户模式。我常用的做法是先用一个明显的模块边界划分代码树部署上不拆等变更频率和团队规模证明某个边界值得拆再演进成独立组件。笔记里那句“组件结构不可能自上而下被设计出来”就是这个意思。边界的落地要看代码依赖方向举个例子# 用例层定义的存储接口内圈 class OrderRepository(ABC): abstractmethod def save(self, order) - None: ... abstractmethod def find_by_id(self, order_id: str): ... # 适配器层实现外圈它依赖内圈的接口和实体 class SqlOrderRepository(OrderRepository): def __init__(self, session): self.session session def save(self, order) - None: self.session.add(order) self.session.commit() def find_by_id(self, order_id: str): return self.session.query(Order).filter_by(idorder_id).one_or_none()这段代码的重点是依赖方向SqlOrderRepository import 了内圈的 OrderRepository 接口和 Order 实体但用例层的服务类只依赖 OrderRepository 抽象完全不知道 SqlOrderRepository 的存在。数据库换掉时服务类没有 import 变化这就是边界在起作用。如果哪天 ORM 本身泄漏到用例层比如用例代码里出现 query、session 等字眼说明边界破了。5. 数据库、Web 与框架实现细节定位与四类避坑记录原书第六部分有个非常反直觉的标题数据库只是实现细节。很多人听到这句话第一反应是“不可能”但笔记指出了真正的风险很多数据访问框架允许把数据行和数据表以对象形式在系统内部传递这在架构上会破坏用例、业务逻辑与数据关系模型的边界。5.1 数据库只是实现细节数据行对象不该穿过用例层把 ORM 实体当作业务实体的做法会在业务层和存储层之间建立不恰当绑定。表结构加一列业务代码要跟着改换了数据库类型序列化逻辑全部要动。我参与过一个系统订单实体直接从数据库映射传到前端后来改数据库字段名前端接口也变了整整返工两周。从那以后我强制在用例层定义自己的聚合结构存储层负责转换# 用例层只认这个结构不认 ORM 模型 class OrderData: def __init__(self, order_id: str, amount: float, items: list): self.order_id order_id self.amount amount self.items items # 存储层做转换 class SqlOrderRepository(OrderRepository): def find_by_id(self, order_id: str): row self.session.query(OrderModel).filter_by(idorder_id).one_or_none() if row is None: return None return OrderData( order_idrow.id, amountrow.total_amount, items[ItemData(item_iditem.id, nameitem.name) for item in row.items] )逻辑说明用例层看到的是 OrderData不是 OrderModel。这样表结构变化被限制在存储层用例层不会因为“total_amount 改名”而修改。5.2 Web 与框架把框架留在边界之外原书第 31 到 32 章说得很实际Web 是实现细节业务规则应该与 UI 解耦框架同理要尽可能把框架留在边界之外。框架风险有四类一是框架想把你耦合进最内圈二是与框架博弈的时间超过框架帮忙的时间三是框架演进方向与你的需求不一致四是你哪天想换框架。我见过的 Web 耦合典型病症Controller 里写满业务逻辑HTTP 请求参数直接进 Service返回结果又直接映射成 JSON。这样做短期很快长期只要路由规则变一下业务逻辑就跟着重写。正确做法是 Controller 只做参数解析和响应组装业务用例放在独立的用例类里。5.3 四类高频问题避坑记录避坑一改数据库字段业务代码跟着改 现象存储层表结构变更需要同步修改 Service 层代码测试用例全部回归。 原因ORM 实体穿透了用例层数据模型直接暴露给了业务代码。 解决用例层定义自己的数据结构存储层做模型转换依赖方向指向内圈。避坑二单元测试起不来必须先启动数据库 现象跑测试要连测试库CI 环境每次都要初始化数据库容器跑一次十几分钟。 原因业务逻辑与存储实现紧耦合测试代码被迫依赖基础设施。 解决用依赖注入把存储换成内存或 Fake 实现。用例层只需依赖接口测试时注入 InMemoryOrderRepository秒级可跑。避坑三升级框架版本等于重构系统 现象框架大版本升级所有 Controller、配置、启动类都改了。 原因框架与业务代码没有边界框架的演进直接传导到业务。 解决框架留在架构最外圈通过适配器与用例层通信。升级框架只动适配器业务层不感知。避坑四微服务化后一个业务变更要改四个服务 现象需求只改了一个计算规则却要同时发布订单、支付、库存三个服务。 原因边界沿技术层切分独立数据库、独立部署而不是沿业务变更轴切分。结果是每个服务都只含一部分完整业务逻辑的切片。 解决先保持单体把解耦做到“随时能转服务”的程度也就是业务逻辑按用例组织、不共享可变状态。真正需要独立部署的边界出现后再拆。最后一条对应原书第 16 章的独立性将系统的解耦推行到一旦有需要就可以随时转变为服务的程度即可让整个程序尽量长时间地保持单体结构。6. 把笔记变成自检清单架构决策与组件拆分演练最后一招把 324 页浓缩成一张“架构决策自检清单”每次需求评审和代码评审都过一遍。这不是抽象方法论是可抄的作业。6.1 架构决策自检清单六个问题够日常用了这次变更要改几个组件如果超过两个边界是不是画错了依赖方向是“指向抽象”还是“指向实现”新引入的 import 会不会创造环业务用例也就是赚钱逻辑是否被框架、数据库、UI 的技术细节污染测试能不能在不启动数据库、不启动 Web 容器的情况下跑完核心用例新增功能是新增代码还是修改旧代码修改旧代码的部分有没有办法用策略模式挪到新实现里这个决策有没有留出“推迟决定”的空间是不是非得今天定框架不可我把这六个问题贴在代码评审模板里每次评审先过一遍再谈代码细节。6.2 视频销售网站的组件拆分方式笔记第 33 章的例子很值得玩味视图、展示器、交互器、控制器、工具类可以打包成 5 个 jar也可以合并成 4 个、2 个 jar。真正有价值的是合并逻辑——视图和展示器合并因为它们变更原因相同交互器、控制器、工具类合并因为它们处于同一层次、变更频率接近。这个例子说明组件边界不一定要反映在部署上。同一个进程里可以有很多逻辑边界而部署边界只在变更频率和发布粒度相匹配的地方出现。我在做月度架构复盘时会专门看最近三个月的变更记录分布把“经常一起改”的模块标记为“应该合并”把“很少一起改但经常被不同需求驱动”的标记为“应该画边界”。从那以后我每次需求评审都会强制自己问一遍这行代码属于用例还是属于细节如果是细节它有没有越过边界这个问题问了三四年帮我把很多潜在的耦合问题在代码评审阶段就拦下来。希望帮到你。本文还有配套的精品资源点击获取