一个项目最先崩掉的往往不是功能而是接手的人脑子里的那张图。我带的几个项目都撞过同一堵墙最初只有三个文件半年后变成四十个包、六层调用、一堆没人敢删的兼容分支。改一行日志要翻五个仓库新人上手第一周全在问“这个到底走哪条链路”。后来我们内部把一套被反复验证过的收敛手法固化成约定取名叫SIMPL——名字不是缩写炫技而是把“简单”当成一条可以被度量、被评审、被强制执行的工程约束。它说的是用五条硬性约束管住项目复杂度让一个中等规模的系统在迭代十几次之后仍然能被一个人完整读懂。这篇文章面向已经写过几个项目、也开始被维护成本反噬的开发者也面向刚入行、想一开始就把地基打正的新人。我会把 SIMPL 的五条约束拆开讲透再给出一套可直接照抄的目录结构、代码骨架和评审清单。1. SIMPL 到底是什么先把概念钉死1.1 五个字母对应的五条硬约束网上叫“极简”的方法论太多了多数停在口号层面——“保持简单”“避免过度设计”。这类话在评审会上毫无杀伤力因为没人能证明自己违反了它。SIMPL 的做法是把“简单”拆成五条可以当场判定对错的约束每条对应一个字母。字母约束判定方式违反后的典型症状SSimple Surface接口面收敛单个模块对外公开的入口不超过 7 个调用方不知道该用哪个函数IIsolated Boundary边界隔离跨层调用必须经过显式接口禁止越级改底层要连带改上层 UIMMinimal Dependency依赖最小化新增第三方依赖需说明“不用它会多写多少行”装了两百个包构建要八分钟PPredictable Flow数据流可预测同一个状态只有一处写入点出现“改了这个值那个页面自己变了”LLayered Depth分层深度受限从入口到最底层的调用深度不超过 4 层断点要打八个栈帧看不完这套东西的价值不在“新”而在“可执行”。我见过太多团队把架构图画得漂漂亮亮落地时全靠自觉。SIMPL 的每一条都能在代码评审时被指出来这里公开了 11 个方法超了这条调用链穿了 5 层砍掉一层这个依赖只为了格式化一个日期自己写 15 行不行注意SIMPL 是约束不是银弹。它的前提是你的项目还有救——如果代码已经烂到没人愿意碰先别谈约束先做止血。1.2 为什么“简”比“全”难得多写代码的时候“多写一点”几乎总是更安全的选项。多留一个兼容分支多包一层抽象多开一个配置项——每个决定单独看都合理甚至有“防御性编程”的光环。问题在于这些东西不会自己消失。三个月后那个“万一以后要支持多租户”的抽象层还空着但每个新人都得花两小时搞懂它为什么存在。复杂度是有复利的。我统计过一个模拟项目 X 的演化第一版 1200 行接口 6 个半年后 9800 行接口 47 个。代码量涨了 8 倍但真正处理业务的分支只涨了 2 倍出头剩下的全是抽象层、适配层、兼容层。这中间的差额就是“为了将来可能的需求”付出的利息。SIMPL 的第一原则是任何抽象都必须由一个已经发生的、具体的重复来支撑而不是由一个想象中的未来。这条听起来保守实际执行起来非常反直觉。因为删掉一个“可能有用”的抽象层心理成本远高于留着它。我自己的做法是设一条硬线同一段逻辑出现第三次才允许抽取只出现两次宁可复制。有人会说复制粘贴是技术债。是但它是一种看得见、改得动的债而过早抽象是一种看不见、改不动的债。前者的利息是未来某次改动要改两处后者的利息是每次改动都要先理解一层没人说得清的东西。孰轻孰重做过两年维护的人心里都有数。1.3 什么场景该用什么场景别硬套SIMPL 明显不适合两类项目。第一类是探索型原型需求本身还没定这时候任何结构都是浪费直接一个文件写到底能跑就行跑通了再谈重构。第二类是强合规或强集成场景比如必须对接多个外部系统的场景接口数量由对方定这时候“接口不超过 7 个”这条约束就是自欺欺人。它最适合的场景我总结成一句话有明确业务边界、需要长期维护、团队规模在 1 到 8 人之间的系统。这个区间很关键。一个人做约束靠自律就够超过八个人沟通成本会压过结构收益你需要的可能是更重的流程而不是更轻的结构。判断标准可以量化一下。拿你手上的项目算三个数新人从拉代码到跑通第一个改动需要几小时超过 4 小时结构有问题。改一个字段平均要动几个文件超过 5 个边界没隔开。删掉一个功能你敢不敢直接删不敢说明依赖关系已经糊成一团。这三个数不用工具拿秒表和一双手就能测出来。测完你就知道自己该不该往下读了。2. 整体设计思路把复杂度关进笼子2.1 先算复杂度预算再动手写代码大部分人做设计是从“要实现什么”出发的SIMPL 建议反过来先算“允许多复杂”再往里填功能。这套算法我用了两年粗糙但有效。复杂度预算的核心指标是有效交互面把系统里所有可以被外部触发的东西数出来包括 HTTP 接口、消息队列的消费点、定时任务、CLI 命令、对外暴露的函数。这个数字乘以平均调用深度就是你系统的心智负担近似值。我通常这样估单模块公开入口数上限7这个数字来自人短期记忆的容量经验值 7±2超过之后调用方基本靠翻文档调用深度上限4入口层 → 编排层 → 领域层 → 基础设施层再多一层就说明职责没切干净单文件行数软上限400超过之后滚动条就成了主要导航工具单函数行数上限50超过之后基本没法在屏幕上完整看完拿一个模拟项目 X 举例它的业务只有三件事接收上报、聚合统计、生成报表。按上面的约束接口面算下来是 3 个业务入口 × 平均 2 个变体 6 个刚好在 7 以内。这就意味着它不该有独立的鉴权服务、独立的配置中心、独立的消息总线。不是这些技术不好是这个体量的项目用不上装上就是纯负担。提示复杂度预算要在项目启动时算一次写进 README 的第一屏。它最大的作用是给后来的人一个“反驳的靶子”——当有人提议加一层缓存中间件时你可以指着那个数字问加完之后入口数变成多少2.2 分层与依赖方向只允许单向流动SIMPL 的 I边界隔离和 L分层深度落地成一条规则依赖只能单向下行且最多四层。我把四层定义得很直白不玩概念入口层HTTP 路由、CLI 入口、定时任务触发点。只做参数校验和转发不写业务。编排层把多个领域操作串成一个用例。负责事务边界和错误转换。领域层纯业务规则不依赖任何框架输入输出都是普通数据结构。基础设施层数据库、缓存、外部调用、文件读写。只被领域层通过接口调用。关键在方向上入口层可以调编排层编排层可以调领域层领域层通过接口调基础设施层。反过来一律禁止。领域层不许 import 框架的 ORM入口层不许直接连数据库。为什么卡这么死因为依赖方向决定了改动的影响半径。如果领域层直接 import 了 ORM那你想换存储方案时业务代码里全是 ORM 的查询构造器等于重写。反过来领域层只认一个Repository接口换存储时只需要实现一个新类业务代码一行不动。这套隔离在项目小的时候看起来像“为了一个可能永远不会发生的迁移写样板代码”。我的建议是接口只写一次但写法要极简。不要去定义IRepositoryFactoryProvider这种层级的接口一个模块一个接口类三五个方法够了。2.3 技术选型够用即止的三问法每次要引入新东西我让提需求的人先回答三个问题。回答不上来就不加。第一问不用它我要多写多少行如果答案是“不到 200 行”自己写。为 200 行代码引入一个长期维护的外部依赖性价比是负的——你不仅要管它还要管它的版本升级、它的安全通告、它和别的库的冲突。第二问它会不会成为我唯一的退出路径有些库一旦用上就拔不掉比如深度侵入数据模型的 ORM、绑定特定框架的认证方案。这类依赖要格外谨慎因为退出成本可能是重写。第三问它的心智负担有多大一个库如果需要在项目里写 80 行配置才能跑起来那它的实际成本是 80 行配置 未来每个新人都要读这 80 行。相比之下一个零配置的库哪怕功能少一半长期看也划算。我做过一个对比两个模拟项目做同一件事——解析一批结构化文本并入库方案依赖数量首次可用耗时半年后修改成本引入一个全功能解析框架7 个传递依赖2 天含学习改字段要读框架文档手写 180 行解析 标准库01 天直接改自己代码第二个方案的首次耗时不比第一个短太多但半年后修改成本差了三倍以上。这个结论我在至少四个项目里反复验证过。3. 核心环节落地从零搭一个 SIMPL 结构3.1 目录划分让结构自己说话目录结构是 SIMPL 最容易被抄走也最容易被抄错的部分。常见错误是照着“标准分层”建了十几个空目录结果每个目录里只有一两个文件。我的原则是目录数量跟实际模块数量走不预留空位。一个中等规模的模拟项目 X三到五个业务模块我一般这样布project/ app/ entry/ # 入口层路由、CLI、定时任务 http.py jobs.py flow/ # 编排层用例串接 report_flow.py ingest_flow.py domain/ # 领域层纯业务规则 models.py rules.py ports.py # 领域层对外的接口定义 infra/ # 基础设施层具体实现 db.py storage.py config.py tests/ unit/ integration/ pyproject.toml README.md四个目录对应四层一一对齐不搞花活。ports.py是这套结构的关键——它定义了领域层需要基础设施提供什么能力但只有接口没有实现。基础设施层的类去实现这些接口在入口处组装。注意不要建utils/这种目录。它是一切的垃圾桶入口一旦存在两周内就会塞满不知道该放哪的东西而且这些东西之间毫无关系。真需要公用函数放到使用它的那一层里或者单独开一个语义明确的模块。3.2 配置与环境一个文件三种来源配置管理的复杂度经常被低估。我见过一个项目有config.py、settings.py、env.py、constants.py四个配置相关的文件谁也说不清哪个生效。SIMPL 的做法是只有一个配置入口值来自三个优先级明确的来源。优先级从高到低环境变量 本地配置文件 代码内置默认值。这样本地开发不用配环境变量部署时环境变量覆盖一切代码里的默认值保证“不配也能跑起来”。具体实现上我用一个不可变的配置对象启动时一次性读齐之后全程只读。这解决了一个非常隐蔽的问题配置在运行中被修改。有些人为了方便会把配置对象到处传某个中间件顺手改了一个值后面所有逻辑都受影响排查时根本想不到。# infra/config.py from dataclasses import dataclass import os import json dataclass(frozenTrue) class Config: db_url: str batch_size: int report_dir: str classmethod def load(cls, path: str config.local.json) - Config: file_values {} if os.path.exists(path): with open(path, r, encodingutf-8) as f: file_values json.load(f) def pick(key, default, caststr): raw os.environ.get(key.upper(), file_values.get(key, default)) return cast(raw) return cls( db_urlpick(db_url, sqlite:///local.db), batch_sizepick(batch_size, 200, int), report_dirpick(report_dir, ./out), )frozenTrue这一行是整个设计里最便宜也最有效的保险。它让运行时被悄悄改配置这种事从一开始就报错而不是等到线上出问题才发现。参数怎么定batch_size我按单次处理耗时来反推目标单批不超过 2 秒实测每条记录 5 毫秒那 200 是安全值800 就有超时风险。这种“先定目标再倒推数值”的习惯比拍脑袋写个 1000 靠谱得多。3.3 数据流单一写入点P可预测的数据流是最容易在评审时被忽略、出问题时最难排查的一条。它的判定标准只有一句任何一个状态全项目只能有一个地方写它。做到这一点靠的是把“状态”和“变更”分开。状态本身是只读的数据结构变更通过一个明确的函数完成这个函数在项目里只出现一次。举个具体例子。模拟项目 X 里有一个“任务状态”取值是待处理、处理中、完成、失败。糟糕的写法是在入口层读数据库、判断、写回在定时任务里也读数据库、判断、写回。两个地方各写一遍规则稍有出入就出 bug——比如入口层允许“失败”重试定时任务却不允许两边打架。正确的做法是把状态变更收进领域层的一个函数# domain/rules.py from dataclasses import replace from domain.models import Task, TaskStatus ALLOWED { TaskStatus.PENDING: {TaskStatus.RUNNING}, TaskStatus.RUNNING: {TaskStatus.DONE, TaskStatus.FAILED}, TaskStatus.FAILED: {TaskStatus.RUNNING}, TaskStatus.DONE: set(), } def transit(task: Task, target: TaskStatus) - Task: if target not in ALLOWED[task.status]: raise ValueError(f非法状态迁移: {task.status} - {target}) return replace(task, statustarget)这个小函数只有十几行但它是整个系统的“规则唯一真相”。入口层要用调它定时任务要用也调它。想加一条新规则改这一个地方不用满项目搜status 。提示replace用的是不可变数据类返回新对象而不是就地修改。这一点看起来多写了几个字符但它让“谁改了状态”这个问题彻底消失——状态只能被新对象替代不可能被暗中修改。3.4 构建与部署一条命令跑完构建流程复杂度是 SIMPL 里最容易被放过的一块因为它不影响代码质量只影响人的耐心。但当“部署一次要执行六个脚本、顺序还不能错”的时候就没人愿意频繁部署了而没人愿意频繁部署的团队发布节奏一定慢。我的标准是从干净环境到服务可用一条命令。./run.sh prepare # 装依赖、建目录、初始化数据库 ./run.sh test # 跑测试 ./run.sh start # 启动服务run.sh内部不超过 60 行每一步都打印在做什么。我坚持不引入复杂的构建工具链理由很实际这套东西是给值班的人用的半夜出问题时他要能在三十秒内看懂这条命令在干什么。依赖锁定必须做。不是“写上版本号”那种锁定而是真正的锁文件——所有传递依赖的版本都被固定下来。这解决的是“上个月还能构建这个月突然失败”的经典问题某个下游库发了个小版本行为变了。4. 代码与配置细节可以直接抄的部分4.1 一个最小可运行骨架把前面几节的内容串起来一个最小可运行的服务大概长这样。它没有任何框架纯标准库但四层结构齐全。# app/domain/models.py from dataclasses import dataclass from enum import Enum class TaskStatus(str, Enum): PENDING pending RUNNING running DONE done FAILED failed dataclass(frozenTrue) class Task: id: str status: TaskStatus payload: str# app/domain/ports.py from typing import Protocol from domain.models import Task class TaskRepo(Protocol): def save(self, task: Task) - None: ... def get(self, task_id: str) - Task | None: ...# app/infra/db.py from domain.models import Task from infra.config import Config import sqlite3 class SqliteTaskRepo: def __init__(self, cfg: Config): self._cfg cfg def save(self, task: Task) - None: with sqlite3.connect(self._cfg.db_url) as conn: conn.execute( INSERT OR REPLACE INTO tasks(id, status, payload) VALUES (?, ?, ?), (task.id, task.status.value, task.payload), ) def get(self, task_id: str) - Task | None: with sqlite3.connect(self._cfg.db_url) as conn: row conn.execute( SELECT id, status, payload FROM tasks WHERE id ?, (task_id,) ).fetchone() if row is None: return None return Task(idrow[0], statusTaskStatus(row[1]), payloadrow[2])# app/entry/http.py from http.server import BaseHTTPRequestHandler, HTTPServer import json from domain.rules import transit from domain.models import TaskStatus class Handler(BaseHTTPRequestHandler): repo None # 由组装处注入 def do_POST(self): length int(self.headers.get(Content-Length, 0)) body json.loads(self.rfile.read(length) or b{}) task self.repo.get(body[id]) if task is None: self._reply(404, {error: not found}) return try: updated transit(task, TaskStatus(body[to])) except ValueError as e: self._reply(400, {error: str(e)}) return self.repo.save(updated) self._reply(200, {id: updated.id, status: updated.status.value}) def _reply(self, code: int, data: dict): raw json.dumps(data).encode() self.send_response(code) self.send_header(Content-Type, application/json) self.send_header(Content-Length, str(len(raw))) self.end_headers() self.wfile.write(raw)这段代码不到 120 行但四层结构、依赖方向、单一写入点、不可变配置全部体现出来了。Handler.repo是注入的入口层不知道具体是 SQLite 还是别的什么它只认save和get两个方法——这就是接口面收敛。注意Protocol是结构化类型不需要显式继承。这意味着基础设施层的类不必 import 领域层的接口名避免了反向依赖的风险。这一点在跨包的项目里特别重要。4.2 依赖注入手写别上容器依赖注入容器是个典型的过度设计高发区。我见过一个不到两万行的服务用了三个注解、两个配置类来管理依赖最后没人说得清某个对象的生命周期到底是单例还是每次新建。SIMPL 的做法是在进程启动的地方一个函数里组装所有对象手工传参。# app/main.py from infra.config import Config from infra.db import SqliteTaskRepo from entry.http import Handler def build(): cfg Config.load() repo SqliteTaskRepo(cfg) Handler.repo repo return cfg if __name__ __main__: cfg build() Handler.repo.init(cfg) HTTPServer((0.0.0.0, 8000), Handler).serve_forever()整个组装逻辑不到十行一眼看完出问题直接在这一处断点。依赖关系从“框架帮我管”变成“我明确知道谁依赖谁”。项目规模在几十个类以内时手写组装的成本几乎为零只有到几百个类的规模容器才有意义——而那时候你大概率该拆服务了而不是该上容器。4.3 日志与可观测性三个必打字段日志这块最常见的浪费是“什么都打”。日志量上去了排查时反而找不到关键信息。SIMPL 的做法是规定三个必打字段其他按需。必打的三项是请求标识、当前层、耗时。请求标识让一次调用链的所有日志能串起来当前层让你一眼看出慢在哪一层耗时帮你做性能归因。import logging import time from contextlib import contextmanager contextmanager def span(name: str, trace_id: str): start time.monotonic() try: yield finally: cost (time.monotonic() - start) * 1000 logging.info(trace%s layer%s cost%.1fms, trace_id, name, cost)用法很简单在每个层的入口包一下with span(flow.ingest, trace_id): with span(domain.rule, trace_id): ...这样一次请求打出来的日志是几行带层级的记录慢在哪一层一目了然。我实测过一个批次处理慢的问题靠这个五行的上下文管理器十分钟就定位到是基础设施层单次连接建立开销太大改成复用连接后耗时降了六成。要是没有分层耗时大概率会先去优化业务逻辑白忙半天。4.4 测试策略三层比例别搞反测试的复杂度同样会失控。我见过测试代码比业务代码多三倍的项目改一个字段要修二十个测试最后大家都开始跳过测试。SIMPL 的测试策略是按层分配比例大概 7:2:1。单元测试占七成只测领域层。因为领域层不依赖任何框架测试写起来极快一个测试几毫秒。这部分是真正能防回归的业务规则变了测试立刻红。集成测试占两成测编排层串起来的用例用真实的数据库内存版。这部分测的是“各层拼起来还能不能跑”重点在事务边界和错误传播。端到端测试占一成只覆盖两三条最关键的主路径。这部分慢、脆、维护成本高所以数量必须少。比例的判断依据很直接测试的价值随它的运行速度下降而下降。一个跑 3 秒的测试开发时不会愿意频繁跑一个跑 3 毫秒的测试可以挂在保存即运行的钩子上。所以把大部分测试压在最快的单元层是最划算的投入。提示不要把测试覆盖率当目标。我见过为了冲覆盖率写的测试全是“调用一下不报错就算过”一点用处没有。真正该测的是分支和边界非法状态迁移有没有被拦住、空输入会不会炸、并发写同一行会怎样。5. 常见问题与排查实录5.1 过度抽象的五个危险信号抽象失控是 SIMPL 要解决的头号问题但它往往是在“良好实践”的名义下发生的。我总结了五个信号出现任意两个就该停下来审一遍。第一个信号只有一个实现者的接口。如果你定义了Storage接口但全项目只有LocalStorage一个实现那这个接口大概率是多余的。除非你有明确的、近期就要做的第二个实现否则直接删掉接口领域层直接依赖具体类等真的需要第二个实现时再抽。第二个信号参数透传超过两层。一个函数收了a、b、c自己不用原样传给下一层。这说明调用链的层级设计有问题或者这个函数根本不该存在。第三个信号配置文件里的开关从未被关闭过。项目里有个enable_new_flow的配置上线三年一直是true。那就是纯粹的历史包袱删掉。第四个信号同名的类出现在三个包里。比如UserService、UserManager、UserHandler职责区别说不清。这通常是多人协作没对齐边界的结果。第五个信号单元测试需要 mock 五个以上的对象。这说明被测单元依赖太多边界没切干净。正常的领域层单元测试零 mock 才是常态。5.2 依赖膨胀的排查方法依赖膨胀是慢性病等发现时构建已经很慢了。我一般三个月做一次依赖审计方法很土但有效把依赖列表拉出来逐个问“删掉它要改几行”。删掉改动量小于 50 行的直接删50 到 200 行之间评估要不要自己实现200 行以上的留着但记录下来。这套方法的依据是依赖成本 引入成本 长期维护成本。引入时省下的 50 行代码在长期里会被版本升级、安全通告、冲突排查的成本反超。实测下来一个中等项目能从 60 个直接依赖砍到 25 个左右构建时间从五分钟降到一分半。砍完要做回归重点测三类地方序列化边界日期、数字、空值、异常处理路径、并发相关逻辑。这三类是最容易被“看起来差不多”的替代品埋坑的地方。5.3 问题速查表实际操作里反复出现的问题我整理成一张表出问题时先按这张表过一遍能省不少时间。症状最可能原因排查动作处理方式改一个字段要动很多文件边界没隔开数据模型被透传搜字段名看出现分布在编排层加转换阻断透传某接口偶发超时基础设施层连接未复用看分层耗时日志引入连接池设上限状态出现意料外的值存在多处写入点搜赋值语句收敛到唯一函数单元测试要 mock 一堆对象领域层依赖了外部资源看领域层 import抽接口反转依赖上线后行为与本地不一致配置来源不明确打印最终生效配置启动时打一行配置快照构建偶发失败依赖未真正锁定检查锁文件固定传递依赖版本日志里找不到关联缺请求标识看日志格式入口生成标识逐层透传这张表里我个人踩得最深的是“状态出现意料外的值”。当时排查了两天最后发现是一个后台任务在做数据修复时直接写了数据库绕过了所有规则。从那以后我在项目里加了一条硬规矩任何写数据库的地方如果是修复类脚本必须在代码里注释说明绕过了哪条规则、为什么。这条规矩后来救过好几次。5.4 一个真实踩坑的复盘说个具体的。模拟项目 X 上线三个月后出现一个奇怪现象报表里的数据偶发对不上重跑一次又对了。没有报错没有超时就是数据差那么几条。排查路径是这样的。先看分层耗时日志正常。看数据库记录数正常。看报表生成逻辑发现它是“先查总数再分页查明细最后汇总”。问题在这儿总数和明细是两次独立查询中间如果有新数据写入两次结果就不一致。根因是分页查询没有在同一个一致性视图下完成。修复方式很简单改成一次性查出所有需要的行再内存分页或者在同一个事务里完成两次查询。改动不到十行。但这件事真正的教训不在技术上而在设计上报表这类“需要一致快照”的操作在编排层就该声明成事务而不是让领域层自己去猜。我当时把事务边界画在了基础设施层结果编排层的两次独立调用根本没被包住。后来调整成编排层开事务、基础设施层接受事务句柄这类问题就再没出现过。6. 团队协作与长期维护6.1 评审清单把约束变成可执行的动作约束要落地必须变成评审时能一条条对着看的东西。我用的清单只有七条多了没人认真看。检查项通过标准常见驳回理由新增公开接口模块入口总数仍不超过 7已超需要合并或下沉新增第三方依赖说明不用它的替代成本替代成本低于 200 行跨层调用依赖方向为下行深度不超过 4入口层直接连了数据库状态写入全项目唯一写入点在任务里又写了一遍新增配置项说明何时会取不同值永远是同一个值新增抽象层有至少三处重复作为依据只有一处调用新增目录有明确职责边界又是 utils这七条我贴在评审模板里每一条对应一个勾选框。刚开始推行时有人觉得繁琐两周后就习惯了——因为它挡掉的问题本来会在三个月后以 bug 的形式回来。6.2 文档的最小集文档的复杂度比代码更容易失控因为它没有编译器帮你检查。SIMPL 的文档策略是“三份一份都不能多”。第一份是 README 的第一屏必须回答四个问题这是什么、怎么跑起来、目录怎么读、出问题找谁。这四行答不上来的项目新人上手成本至少翻倍。第二份是架构决策记录只记“为什么这么选”不记“怎么用”。比如“为什么领域层不直接用 ORM”写清当时的备选方案和被否掉的理由。这种东西半年后价值极高因为它防止新来的人把已经论证过的问题重新论证一遍。第三份是排障手册就是前面那张速查表的项目定制版。每次线上出问题处理完往里加一行。一年下来它就是最有价值的文档。除此之外的文档比如详细的接口文档、设计说明书我倾向于让它跟着代码走——接口文档从类型注解生成设计说明写进代码注释的顶部。手写文档和代码分离的那天起就开始腐化了。6.3 演进路径从约束到习惯SIMPL 落地有个顺序问题一步到位基本会失败。我建议的路径是分三步每步间隔一到两个月。第一步先做减法删空目录、删没实现者的接口、删永远为真的开关、删没用上的依赖。这一步不改任何逻辑风险最低收益最直接——通常能砍掉一到两成的代码量。第二步做边界把领域层从框架里剥出来让它不依赖任何外部库。这一步工作量最大也最容易出 bug建议配合测试一起做每剥一个模块跑一次全量测试。第三步做约束把评审清单挂上去把复杂度预算写进 README把排障表建起来。这一步是让前面两步的成果不被后来的改动侵蚀。三步走完大概需要三到六个月。我带的项目走到第二步时新人的上手时间从一周降到了两天走到第三步后线上事故的定位时间从平均四小时降到四十分钟以内。这些数字不是靠什么高深技术实现的只是把“简单”当成了需要维护的东西而已。最后一个我自己反复用的判断方法当你犹豫某个东西该不该加的时候先想它被删掉时的场景。如果想象不出什么情况下会删它那它大概率不该加——因为这意味着它会一直留着而所有“一直留着”的东西最终都会变成别人读代码时的障碍。