加个 CLI,为什么 core 也要改?¶
上一章结束时,AI 学习助手的核心函数已经有了精确的公开类型:build_plan() 接收
LearningGoal,返回 Plan | PlanFailure。调用方知道该准备什么、会拿回什么、
需要处理几个业务状态。
现在团队收到一个看起来很小的需求:除了 HTTP 接口,再加一个 CLI 入口,让开发者 在终端直接生成学习计划。
你打开代码,发现 build_plan() 的参数类型直接使用了 Web 框架的 Request 对象。
改动 CLI 时,你得让命令行参数伪装成 HTTP 请求——或者修改 core 函数接口。
问题来了:明明只是换了一种输入方式,为什么计划生成规则也要跟着改?
这不是文件太少或太多的问题。两个方案可以有完全相同的文件数量,区别只在 import 箭头指向哪里。本章就从这个反差开始,看看模块边界到底由什么决定。
运行环境与材料说明¶
本章代码使用 Python 3.12。示例只涉及 package 结构和 import 行为,不依赖外部 框架。你可以在任意 Python 3.12 环境中创建临时 package 复现本章的 import 关系。
Python 的 import 机制在此章中只需要一条核心事实:当 a.py 写了
from b import X,模块 a 就对模块 b 产生了依赖。b 的变化可能影响 a,
反过来则不一定。
1. 观察:增加 CLI 时谁需要改?¶
先看当前 HTTP-only 时的文件结构和 import 关系:
各文件的关键片段:
# contracts.py
from dataclasses import dataclass
@dataclass(frozen=True)
class LearningGoal:
topic: str
weekly_hours: int
@dataclass(frozen=True)
class Plan:
topic: str
daily_minutes: int
# core.py
from learning_planner.contracts import LearningGoal, Plan
def build_plan(goal: LearningGoal) -> Plan:
daily_minutes = goal.weekly_hours * 60 // 7
return Plan(topic=goal.topic, daily_minutes=daily_minutes)
# http_adapter.py
from learning_planner.contracts import LearningGoal
from learning_planner.core import build_plan
def handle_http(payload: dict) -> dict:
goal = LearningGoal(topic=payload["topic"], weekly_hours=payload["weekly_hours"])
result = build_plan(goal)
return {"topic": result.topic, "daily_minutes": result.daily_minutes}
import 箭头是:
箭头都指向同一方向:adapter 依赖 core,core 依赖 contracts。没有反向。
现在需求来了——增加 CLI adapter。暂停一下,预测:在这个结构下,你需要改动
core.py 或 contracts.py 吗?
不需要。你只要新增一个文件:
# cli_adapter.py
import sys
from learning_planner.contracts import LearningGoal
from learning_planner.core import build_plan
def main() -> None:
topic = sys.argv[1]
weekly_hours = int(sys.argv[2])
goal = LearningGoal(topic=topic, weekly_hours=weekly_hours)
result = build_plan(goal)
print(f"{result.topic}: 每天 {result.daily_minutes} 分钟")
新的 import 图:
两个 adapter 各管各的输入输出方式,共享同一套业务规则和数据契约。core 没有 感知到外面多了一种入口。
2. 反转箭头:当 core 依赖 adapter¶
现在看另一种写法。文件数量完全相同,但 core.py 直接使用了 HTTP adapter 的
请求对象:
# core.py(方案 B)
from learning_planner.http_adapter import HttpRequest
from learning_planner.contracts import Plan
def build_plan(request: HttpRequest) -> Plan:
topic = request.body["topic"]
weekly_hours = request.body["weekly_hours"]
daily_minutes = weekly_hours * 60 // 7
return Plan(topic=topic, daily_minutes=daily_minutes)
import 箭头变成了:
文件数量没变。命名也还算整齐。但箭头方向完全不同。
现在同样的需求来了:增加 CLI adapter。预测一下,你需要改什么?
CLI 没有 HttpRequest。你面临两个选择:
- 在 CLI 里凭空构造一个
HttpRequest,假装命令行参数是 HTTP body; - 修改
core.py的参数类型,让它不再依赖HttpRequest。
无论哪个选择,core 都要受到 adapter 变化的影响。第一种是把 Web 概念强加给 CLI;第二种是承认 core 一开始就不该依赖这个对象。
这就是"依赖方向"的后果:方案 A 中 adapter 的变化不会扩散到 core;方案 B 中 adapter 的变化会沿着反向箭头传播进 core。
两个方案的差异不是文件多少,而是谁依赖谁。
3. 从现象中提取三个判断维度¶
回过头看,方案 A 和方案 B 的对比已经暗示了三条可检查的线索。现在正式命名它们。
职责内聚:同一变化原因是否集中在同一模块?
方案 A 中,"怎样从 HTTP 读取输入"集中在 http_adapter.py,"怎样生成计划"
集中在 core.py。改 Web 框架只动 adapter,改计划规则只动 core。
方案 B 中,core 同时承担"理解 HTTP 请求结构"和"生成计划"两个变化原因。换 框架时 core 也要改。
检查问题:这个模块会因为几种不同原因被修改?
公开接口:跨模块协作暴露了什么?
方案 A 中,core 公开的函数签名是 build_plan(goal: LearningGoal) -> Plan。
调用方只需要提供一个 LearningGoal,不需要知道 core 内部怎样计算。
方案 B 中,core 公开接口要求传入 HttpRequest。这意味着 core 把 Web 协议
的细节暴露给了所有调用方——包括未来根本不走 HTTP 的 CLI。
检查问题:跨模块的协作面上,暴露的是业务数据契约,还是某个特定技术的内部结构?
依赖方向:稳定的规则是否反向依赖易变的 adapter?
学习计划的生成规则比 Web 框架选型更稳定。方案 A 让易变的 adapter 依赖稳定的 core;方案 B 让稳定的 core 反向依赖易变的 adapter。
检查问题:import 箭头是从易变指向稳定,还是从稳定指向易变?
这三个问题不要求你知道任何架构模式的名字。你只需要看 import、看函数签名、 想一想"如果某处变了,谁会跟着变"。
4. 边界的两个反例¶
掌握了三项判断后,另一些问题也能识别了。
反例一:common.py 汇总一切¶
有人觉得"共享的东西都放一起"很方便:
# common.py
from dataclasses import dataclass
@dataclass
class LearningGoal:
topic: str
weekly_hours: int
@dataclass
class Plan:
topic: str
daily_minutes: int
@dataclass
class HttpRequest:
body: dict
def parse_body(request: HttpRequest) -> LearningGoal:
return LearningGoal(
topic=request.body["topic"],
weekly_hours=request.body["weekly_hours"],
)
def format_plan(plan: Plan) -> str:
return f"{plan.topic}: {plan.daily_minutes}min/day"
三个模块都导入 common,同时 common 里也导入了三个模块各自的内部 helper。
结果:
- 职责内聚?不。
common.py同时包含数据契约、HTTP 解析和输出格式化,三种 完全不同的变化原因共存。 - 依赖方向?混乱。每个模块都依赖
common,common又依赖每个模块的内部, 形成事实上的循环。
Python 遇到循环导入时可能抛出 ImportError 或 AttributeError,具体取决于
导入顺序和模块加载时机。但即使用延迟导入或其他技巧绕过了报错,真实的耦合
并没有消失——它只是被语法技巧掩盖了。
反例二:六层空壳¶
另一个极端:为当前只有一个 adapter 和一个业务函数的项目,预建六个层级:
learning_planner/
├── adapters/
│ └── inbound/
│ └── http/
│ └── handler.py
├── application/
│ └── services/
│ └── plan_service.py # 只转发给 domain
├── domain/
│ └── services/
│ └── plan_domain_service.py # 只转发给 model
├── infrastructure/
│ └── ...
└── ...
plan_service.py 的全部内容:
from learning_planner.domain.services.plan_domain_service import build_plan_domain
def build_plan_service(topic: str, weekly_hours: int):
return build_plan_domain(topic, weekly_hours)
它不做任何事,只是把调用原样转发。
这违反了什么?职责内聚要求模块有自己的变化原因。一个"只转发"的层没有独立 职责;它的存在不是因为当前业务需要隔离,而是因为某个模板说"应该有这一层"。
文件数量不等于模块边界清晰。边界清晰的标志是:每个模块有自己的变化原因, 公开接口暴露的是业务契约而不是内部细节,import 箭头从易变指向稳定。
5. 综合验证:CLI 迁移¶
回到贯穿故事。现在你已经知道三项判断,让我们把 CLI adapter 的设计做完整。
需求:CLI 从命令行参数读取 topic 和 weekly_hours,调用 build_plan,
把结果打印到终端。
允许的 import 箭头:
cli_adapter → contracts (读取 LearningGoal 定义)
cli_adapter → core (调用 build_plan)
core → contracts (使用 LearningGoal 和 Plan)
http_adapter → contracts (读取 LearningGoal 定义)
http_adapter → core (调用 build_plan)
不允许的箭头:
core → cli_adapter ✗ (core 反向依赖 adapter)
core → http_adapter ✗ (同上)
cli_adapter → http_adapter ✗ (adapter 之间不应耦合)
验证三项判断:
-
职责内聚:CLI adapter 只负责"从命令行读取输入、向终端输出结果"这一种 变化原因。HTTP adapter 只负责"从 HTTP 请求读取、向 HTTP 响应输出"。core 只负责计划生成规则。改命令行参数格式不影响 core,改计划算法不影响 adapter。
-
公开接口:core 暴露的协作面是
build_plan(goal: LearningGoal) -> Plan。 这是业务数据契约,不是某个框架的技术对象。两个 adapter 都能直接使用。 -
依赖方向:adapter(易变)依赖 core 和 contracts(稳定)。core 不反向 依赖任何 adapter。新增第三个 adapter 时,core 无需修改。
这就是模块边界的工作方式:不是数文件、不是看命名,而是检查职责是否集中、 接口是否暴露正确的东西、箭头是否指向稳定的方向。
本章小结¶
回到开头的问题:"加个 CLI,为什么 core 也要改?"
答案是:如果 core 的 import 箭头反向依赖了 adapter 的技术对象,那么 adapter 的变化就会沿着箭头传播进 core。文件数量相同、命名整齐,都不能阻止这种扩散。
模块边界由三个维度决定:
- 职责内聚——同一变化原因是否集中在同一模块;
- 公开接口——跨模块协作面暴露的是业务契约还是内部细节;
- 依赖方向——import 箭头是否从易变指向稳定。
循环共享(common.py 汇总一切)违反职责内聚和依赖方向。过度拆分(为小职责
预建空层)违反职责内聚——没有独立变化原因的模块不构成有意义的边界。
如果只带走一句话:
不要数文件,要追箭头。边界清晰的标志是变化不扩散,不是目录看起来整齐。
练习¶
练习一:画出 import 箭头¶
以下是一个四文件项目的 import 关系:
# file: api.py
from project.service import process_order
# file: service.py
from project.api import ApiRequest
from project.models import Order
# file: models.py
# 无 import
# file: utils.py
from project.models import Order
from project.service import process_order
请回答:
- 画出所有 import 箭头。
service.py依赖api.py——这符合"从易变指向稳定"吗?- 如果需要增加一个消息队列入口,
service.py需要修改吗?为什么?
练习二:诊断模块方案¶
某团队将代码组织如下:
# adapters/http.py
from core.planner import build_plan
from contracts.goal import LearningGoal
# core/planner.py
from contracts.goal import LearningGoal, Plan
# contracts/goal.py
# 只有 dataclass 定义
另一个团队的组织:
# adapters/http.py
from core.planner import build_plan
# core/planner.py
from adapters.http import HttpPayload
from contracts.goal import Plan
# contracts/goal.py
# 只有 Plan 定义
请用三项判断(职责内聚、公开接口、依赖方向)分别评价两个方案。哪个方案在 增加 CLI adapter 时 core 不需要修改?
练习三:识别循环与过度拆分¶
方案 X:
# shared.py
from handlers.http_handler import get_default_headers
from handlers.cli_handler import get_default_args
from core.planner import DEFAULT_HOURS
# handlers/http_handler.py
from shared import LearningGoal
# handlers/cli_handler.py
from shared import LearningGoal
# core/planner.py
from shared import LearningGoal, Plan
方案 Y:
# layer1/entry.py → 只调用 layer2
# layer2/dispatch.py → 只调用 layer3
# layer3/validate.py → 只调用 layer4
# layer4/transform.py → 只调用 layer5
# layer5/execute.py → 只调用 layer6
# layer6/planner.py → 实际执行 build_plan
每层只有一行转发代码。
请指出:方案 X 违反了三项判断中的哪些?方案 Y 又违反了哪项?
练习四:为新 adapter 设计依赖¶
需求:为 AI 学习助手增加一个定时任务 adapter,每天凌晨自动为所有用户生成
学习计划。它从数据库读取用户列表,逐个调用 build_plan。
请回答:
- 这个 adapter 应该 import 哪些模块?
core.py是否需要知道"调用来自定时任务"?- 画出包含 HTTP、CLI 和定时任务三个 adapter 的 import 箭头图。
- 不看正文,复述判断模块边界是否清晰的三个问题。
练习解析¶
练习一解析¶
import 箭头:
service.py 依赖 api.py 是反向的。service 是业务逻辑,api 是外部入口。
稳定的业务逻辑不应该依赖易变的接入层。这里 service 使用了 ApiRequest,
意味着如果 API 协议变化,service 也要跟着修改。
增加消息队列入口时,service.py 需要修改。因为它的参数类型绑定了
ApiRequest,消息队列无法直接提供这个对象。解决方向是让 service 接收业务
数据契约(如 Order),而不是 api 层的技术对象。
练习二解析¶
第一个团队:
- 职责内聚:adapter 负责接入,core 负责规则,contracts 负责数据定义。各司其职。
- 公开接口:core 暴露的是
build_plan(LearningGoal) -> Plan,业务契约。 - 依赖方向:adapter → core → contracts。从易变指向稳定。
第二个团队:
- 职责内聚:core 同时承担"理解 HTTP 载荷"和"生成计划"两种变化原因。
- 公开接口:core 的函数签名要求传入
HttpPayload,暴露了 Web 技术细节。 - 依赖方向:core → adapters。稳定的规则反向依赖易变的接入层。
增加 CLI adapter 时,第一个方案的 core 不需要修改。第二个方案必须修改 core,
因为 CLI 无法提供 HttpPayload。
练习三解析¶
方案 X 违反了职责内聚和依赖方向:
shared.py汇总了数据契约、HTTP 默认头和 CLI 默认参数三种不同职责。shared.py导入了 handlers 和 core 的内部对象,而 handlers 和 core 又 反过来导入shared,形成循环依赖。任何一处修改都可能触发多模块变化。
方案 Y 违反了职责内聚:
- 中间四层(layer2-layer5)没有独立的变化原因,只做转发。它们的存在不是因为 业务需要隔离不同职责,而是因为"模板说要有这么多层"。
- 没有独立职责的模块不构成有意义的边界,只增加了调用链长度和阅读成本。
练习四解析¶
定时任务 adapter 应该 import:
contracts:获取LearningGoal和Plan的定义;core:调用build_plan。
core.py 不需要知道调用来自定时任务。它只关心接收到的 LearningGoal 是否
符合契约。调用来源是 HTTP、CLI 还是 cron job,属于 adapter 的职责。
三个 adapter 的 import 箭头图:
http_adapter → core → contracts
cli_adapter → core → contracts
cron_adapter → core → contracts
└────────────────────→ contracts
所有 adapter 都依赖 core 和 contracts;core 只依赖 contracts;没有任何箭头 从 core 或 contracts 指向 adapter。
三个判断问题:
- 这个模块会因为几种不同原因被修改?(职责内聚)
- 跨模块协作面上暴露的是业务契约还是特定技术的内部结构?(公开接口)
- import 箭头是从易变指向稳定,还是从稳定指向易变?(依赖方向)
参考资料¶
- Python 3.12 Import System
- Python 3.12 Packages
- Robert C. Martin, "The Principles of OOD" — Stable Dependencies Principle