边界原则:隐藏的是上下文,而不是代码
边界原则是作用于深度的上下文算子:用语义契约让修改者停在边界上,无需再加载其背后的实现细节。
本文为中文版,根据英文原版改写,而非逐句直译。
设计原则是上下文算子
前面几篇文章建立了 CMP 的基本词汇:软件开发首先是一种修改活动;一次修改的成本不在于改了多少代码,而在于正确完成这次修改需要多少上下文;而这些上下文有不同的形态——深度(Depth)、广度(Breadth)、隐含假设、遗漏风险。
从这一篇开始,CMP 将成为一个透镜,用来重新审视那些我们熟悉的设计原则。
经典原则并非随意的约定。它们之所以流传至今,是因为确实解决了软件演化中的真实问题。信息隐藏、依赖倒置、接口隔离、可替换性、对扩展开放——都是有价值的思想。但它们常常被当作无条件的规则来教:要隐藏信息,要依赖抽象,接口要小,对扩展开放、对修改关闭。
真实系统没有这么干净。每个抽象都有成本,每条边界都要收租。每个原则都预设了未来某种变化模式,而这个预设可能是错的。
在 CMP 看来,设计原则不是风格偏好,而是一种上下文算子(context operator):通过组织上下文,让未来的修改更便宜。不同的原则作用于不同形态的上下文。
边界原则主要作用于深度,它试图让修改者不必继续深挖实现细节。局部性原则主要作用于广度,它把重复的逻辑合并起来,或者为相关的修改点建立索引,让分散的决策能被找到、比较并一起修改。本文聚焦第一类:边界原则。
边界隐藏的是上下文,而不是代码
人们很容易把边界看作一种结构性的东西:模块、类、接口、门面(facade)、服务、包、进程。
这些是边界可能出现的地方,但它们本身并不是边界。
一个模块可能只是把复杂性挪到了另一个目录;一个接口可能原样照搬提供方的每个方法;一个门面可能只是让读者在抵达真正实现之前多跳一次。这些结构也许隐藏了代码,却不一定减少修改所需的上下文。
真正的边界不是靠”分开”造出来的。只有当修改者不再需要继续深挖细节时,边界才真正成立。
一条边界要成立,需要同时满足三点:
- 它抽象出了一项职责。
- 它把这项职责表达为一份契约。
- 修改者可以基于这份契约进行推理,而不必越过它去看背后的实现。
名义上的边界隐藏代码,语义上的边界隐藏上下文。
这个区分很关键。名义边界可以让架构图更好看,却仍然逼着修改者去加载实现细节、历史假设和提供方特有的行为。语义边界则改变了上下文获取的形态:修改者走到边界处,手里的上下文就已经足以可靠地完成修改。
好名字是最短的契约
一项职责还需要一个好名字。
契约不只是方法签名或类型定义,职责的名字本身就是契约的一部分。事实上,它往往是契约中最短、被引用最频繁、传播最广的那部分。
好名字把一组假设压缩成一个可复用的符号。修改者看到这个名字,就能调动已有的领域知识和工程经验,然后顺着名字所代表的概念继续推理。
这和给 LLM 写提示词很像。一个精确的术语往往比一大段解释更有效,因为它能激活模型习得的一整片结构。人也是一样。看到 Cache,人们立刻会想到数据源(source of truth)、TTL、过期数据、未命中、失效与回退;看到 Transaction,会想到提交、回滚、隔离级别和失败边界。在业务领域里,Cart、Order、Invoice 各自承载着不同的规则和约束。
名字是一个上下文包。
但这也意味着,一个内涵丰富的名字同时带来了义务。
你不能把某个东西叫作 Cache,却要求调用方把它当作权威数据源;不能把一个没有回滚语义的流程叫作 Transaction;也不能把一个可以随意修改、却不涉及审计后果的对象叫作 Invoice。好名字之所以能省掉解释,是因为它借用了共同的理解。一旦实现违背了这种理解,名字就不再减少上下文,反而开始制造混乱。
如果一项职责很难命名,问题通常不在文笔,而是一个设计信号。它可能说明我们还没有找到一项稳定的职责,只是把当时恰好相邻的一堆实现细节捆在了一起。这样的边界通常需要更多注释、更多文档、更多示例、更多测试,以及更多口口相传,才能讲清它到底是什么意思。名字没能承载的上下文,会以别的形式回来。
一个叫 OrderHandler 的接口,可以有带类型的方法签名,也可以配上文档。但这个名字无法让调用方停在边界上。怎么 handle?校验?履约?取消?哪些操作是幂等的?这份契约里又折叠进了哪些提供方语义?
设计好的边界
到目前为止,我们讨论的是边界做什么。更难的问题是:怎样设计一条好边界。
SOLID 中的 L 和 I 回答了这个问题的前一半:好的边界长什么样?
- 里氏替换原则(Liskov Substitution Principle)问的是:不同的实现能否都满足这份契约,而不迫使调用方去加载每种实现的特殊行为?
- 接口隔离原则(Interface Segregation Principle)问的是:这个调用方拿到的,是否只是它这类修改所需的上下文,而不是一大片无关的方法、状态和失败模式?
如果一条边界通不过这些检查,它就还没有成为语义边界:要么提供方的细节仍然透过契约泄漏出来,要么契约暴露了调用方根本不需要的东西。
那么,怎样设计出能通过这些检查的边界?
答案在 SOLID 的 D:依赖倒置(Dependency Inversion)。
DIP 常被解释为”依赖抽象,而不是依赖具体实现”,或者被当作一种方便替换实现的手段。这些解释没错,但太浅。重要的不是具体类有没有被换成接口,而是:
接口是谁写的?
产品思维里有一句老话:客户永远是对的。
它的意思并不是客户在所有事实上都正确,而是产品的价值必须从付出代价的那一方来定义。供应商不应该仅仅因为自己能生产,就把东西摆上货架。货架应该围绕客户愿意为之付费的东西来组织。
软件边界也是如此。
在代码里,按提供方塑形的抽象就像供应商驱动的产品:把提供方能给的一切都包进一个接口。由于不知道将来会怎么被使用,人们很容易”以防万一”把所有东西都放进去。
这几乎必然同时破坏 LSP 和 ISP。一个照着 Stripe 建模的 PaymentProvider 接口,可能暴露一个带有 Stripe 专属参数和错误码的 charge() 方法。换成 PayPal,调用方才发现这份契约其实什么都没抽象:PayPal 的预授权模型套不进去,错误码也对不上。调用方必须知道接口背后是哪个提供方——这是 LSP 失效。同一个接口还可能暴露三十个方法:扣款、退款、争议、订阅、发票、webhook 管理……而客户端只需要扣款和退款,却仍得在整个接口面上摸索——这是 ISP 失效。
按客户端塑形的抽象问的是另一个问题:客户端为了完成自己的工作,到底需要什么?客户端愿意为哪些概念付出上下文成本?哪些失败模式必须进入客户端的推理?哪些提供方差异应该留在边界之后?这些问题会引出客户端能够据以推理的名字:不是 StripeChargeRequest,而是 PaymentIntent;不是 StripeChargeResult,而是 PaymentOutcome;不是提供方原始的错误码集合,而是客户端真正关心的修改语义:被拒、可重试、需要 3DS 验证。
这才是依赖倒置的真正价值:它把契约的撰写权还给客户端,更进一步,把命名权也还给客户端。一旦契约由客户端来写,LSP 和 ISP 就不再像外加的规则,而更像是自然的结果。
LSP 之所以成立,是因为契约描述的正是客户端需要的语义。任何满足这些语义的实现都可以替换进来,而不会迫使客户端去加载子类型特有的行为。
ISP 之所以成立,是因为契约里只包含客户端愿意为之付出上下文成本的能力。客户端不需要理解的提供方能力,根本不会出现在接口上。
OCP 同样是结果,而不是起点。当契约的轴与真实的修改轴一致时,新的提供方、新的实现策略和新的内部变化都可以在边界之后发生,而不必让现有客户端重写自己的推理。“对扩展开放、对修改关闭”不是靠提前加一层抽象换来的;只有当边界、DIP、LSP 和 ISP 全部对齐时,它才会自然浮现。
边界不只是接口
前面的讨论用了 SOLID、接口和子类型,因为它们是软件设计中大家熟悉的边界载体。但边界原则并不是面向对象的原则。
从 CMP 的视角看,任何能够限制上下文获取范围的东西,都可以是边界。事件载荷可以是边界:消费者依赖事件的语义,而不需要知道发布者内部经历了哪些状态转换。HTTP API 显然是边界:一份公开契约。状态机也可以是边界:它把一个工作流压缩为有限的状态和合法的转换,修改者只需关注单个状态及其出边,而不必去理清各个部分在整个系统中可能如何相互作用。
函数式编程同样提供了许多边界载体。Option / Maybe 把”值可能不存在”放进类型里,修改者不必在实现中搜寻哪里可能出现 null。Either / Result 把失败路径变成显式契约,调用方按成功与失败来推理,而不必追踪哪里可能抛出异常。代数数据类型和模式匹配把一组可能的状态压缩成一个封闭的分支集合,让需要处理的情形在边界上一目了然。
还有一些边界看起来更不像”代码边界”:SQL、Terraform、Kubernetes YAML、权限规则、校验规则、契约测试。它们把数据访问、基础设施变更、部署行为、授权决策、输入约束和跨服务约定,压缩成某种语言或可执行的检查。
它们的共同点不在形式,而在于对上下文的控制。
边界何时适得其反
边界是一种上下文变换,这意味着它不是免费的。
每条边界都在向未来征税。它增加了一个需要维护的名字、一份契约、一层概念表面,以及一种协作关系。问题不在于边界好不好,而在于这笔税是否换来了所需上下文的真实减少。
边界常见的失败方式有五种。
第一种:预想中的修改流始终没有到来。
许多抽象是围绕一个关于未来的故事搭建的:我们可能会更换支付提供方,可能要支持多种存储后端,可能会增加更多执行引擎。于是系统提前长出了适配器、接口、配置层和各种名字。可如果这些变化从未发生,边界就只剩成本。每个修改者都得先理解这层抽象,然后才发现背后只有一个实现。这条边界没有减少深度,只是让所有人都绕了一段远路。
第二种:变化沿着另一条轴到来。
假设一个支付系统是围绕”更换提供方”来设计的,但真正到来的变化却是先买后付(BNPL)、分账、订阅、预授权、区域合规或反欺诈流程。变化的维度不是提供方替换,而是支付语义。边界的形状错了,新的修改只能绕过它、刺穿它,或者拆掉它。这就是错误形状的深度:问题不在抽象太少,而在于抽象沿着错误的轴进行了压缩。
第三种:上下文泄漏。
如果一个支付接口仍然要求调用方理解 Stripe 的错误码、PayPal 的状态机或银行通道的超时行为,它就没有形成语义边界。它只是给提供方细节换了个名字,并没有把它们封装住。调用方既要理解 PaymentOutcome,又要理解藏在它下面的提供方特有行为;实现方既要满足契约,又要迁就调用方特有的假设。两边的上下文都没有被干净地切开,边界变成了一种承载性的深度。
第四种:误导性的命名。
好名字是一个上下文包,但包上的标签必须与内容相符。把权威数据源叫作 Cache,把无法回滚的流程叫作 Transaction,把可以随意修改的对象叫作 Invoice,都会让修改者从错误的共同理解出发进行推理。最危险的边界不是没有契约的边界,而是契约看起来可信、却在关键处违背了社区共识的边界。
第五种:隐藏了本应保持可见的信息。
并不是所有细节都该隐藏。某些失败模式、性能约束、一致性假设、安全边界和审计要求,本来就属于调用方的必要上下文。如果边界以”整洁”为名把这些事实藏起来,局部代码也许变简单了,但它所做的决策却更不可靠。表面复杂度下降了,遗漏风险却上升了。
结语
边界原则可以用一句话概括:
好的边界让局部代码无需了解全局,也能做对事情。
它并不规定必须用接口、模块、服务、schema、协议还是类型。它规定的是:用职责契约把实现细节、协作者的构造方式、提供方特有的行为和调用方特有的假设,排除在未来修改的必要上下文之外。
下一组原则将从深度转向广度。DRY、内聚和单一职责原则(SRP)关注的不是如何阻止修改者深挖细节,而是如何把相关的修改点连接起来:重复的判断如何合并,分散的上下文如何变得可发现,一组相关的修改如何获得索引。