AI懂设计原则,为何仍写出难维护的代码
文章以结账折扣逻辑为例,指出模型能解释 SOLID 和设计模式,却未必在生成业务代码时主动遵守。代码即使通过测试,也可能混杂数据库访问、领域规则与其他职责。
AI 深度解读
AI 能把 SOLID 和 Strategy 模式解释得头头是道,写业务时却照样把折扣、数据库查询、支付和邮件塞进同一个函数。作者的核心判断是:懂原则,不等于默认执行原则;一句「遵守 Clean Code」,既可能被忽略,也可能催生过度设计。
这篇文章值得读的地方,是把问题落到了工程流程:怎样写可检查的规则,怎样分配规则的作用范围,以及怎样用 ESLint、依赖检查和 CI 补上提示词管不到的部分。它提供的是一套经验方法,没有证明效果的对照实验。
为什么「遵守 SOLID」还不够
原文从一个结账需求开始:给 VIP 客户打九折。常见的 AI 实现是在 checkout(cartId: string, userId: string) 里查询购物车和用户,遇到 VIP 就乘 0.9,遇到员工就乘 0.8,后面还接着约 150 行支付、邮件和库存逻辑。
功能可以工作,测试也能通过,但定价规则和数据库操作已经混在一起。下一次增加折扣,又要修改这个不断膨胀的函数。
作者认为,模型会跟随上下文里最强的信号:任务只要求功能,没有要求设计约束,设计质量就容易让位于尽快完成。这是作者的实践观察,不是对模型内部机制的解释。
反过来,笼统要求「遵守 SOLID,使用设计模式」也可能出问题:只有一个实现的接口、只创建一个对象的工厂、二十行能解决的需求拆成五个文件。形式完整,阅读成本却更高。
真正有用的规则,要能让人对着 diff 判断有没有遵守。
例如,「依赖通过构造函数注入,不要在用例内部实例化 repository」,比「遵守依赖倒置」更容易执行。不过,作者也明确承认:每个用例一个类、参数最好不超过 3 个、不使用 else,有些来自 Object Calisthenics 和个人偏好,并不是 SOLID 的统一要求。
这些建议面向领域逻辑较丰富的面向对象项目,不能直接当成所有前端项目的标准。
规则放在哪里,才不会互相打架
作者把个人规范放在 ~/.claude/CLAUDE.md:函数保持简短、命名表达意图、注释解释原因、业务数字使用具名常量,同时说明 Domain、Application、Infrastructure、Presentation 的依赖方向。
其中最有价值的,其实是三条边界。
首先,尊重项目已有架构,只把规范应用于新代码和本次重构。修复一个 bug,不应该顺便把遗留模块改造成另一套架构。
其次,设计模式必须解决具体问题。使用时写出模式名称,方便审查,但名称本身不能证明抽象合理。
最后,允许为了可读性或框架惯例放宽规则,并说明取舍。没有这个出口,规范很容易变成制造样板代码的机器。
项目架构放在项目文件,领域规则则按路径加载。例如,匹配 paths: ["src/domain/**/*.ts"] 的规则,可以要求金额使用 Money,定价逻辑没有 I/O,也不导入 Prisma、Express 或基础设施代码。
按原文转述的文档说明,发现的 CLAUDE.md 会从宽到窄拼接,并非简单覆盖;相互矛盾时,Claude 可能任意选择。作者建议每个文件控制在约 200 行以内,@path 导入能整理文件,却不会减少启动时的上下文成本。
多工具团队可以把项目规范集中到 AGENTS.md,再由工具专用文件引用。原文还称,较新的 Claude Code 在没有项目 CLAUDE.md 时会读取 AGENTS.md,也可以使用 @AGENTS.md 导入,但没有给出具体版本,落地前需要核对所用版本。
规则回答「代码应该怎样写」,ADR 和功能规格回答「当初为什么这样决定」。下一轮 agent 会话没有完整聊天记录,清楚的代码和决策记录同样是在为它准备上下文。
提示词之外,哪些约束应该交给 CI
指令文件是上下文,不是强制配置。会话变长、改动变大或指令发生冲突,都可能让规则失效。
作者给出的 ESLint 配置,用来捕捉嵌套过深、参数过多和函数过长等可测量的迹象:
// eslint.config.js
import tseslint from "typescript-eslint";
export default [
...tseslint.configs.recommended,
{
files: ["src/**/*.ts"],
rules: {
"max-depth": ["error", 2], // 对「一层缩进」的宽松近似
"no-else-return": "error", // 检查 return 后的 else,而非所有 else
"max-params": ["error", 3],
"max-lines-per-function": ["error", 40],
complexity: ["error", 8],
"no-magic-numbers": ["warn", { ignore: [0, 1] }],
},
},
];
要留意 no-else-return 的实际覆盖范围,以及 no-magic-numbers 只是 warn。这些阈值能筛出症状,不能证明设计合理。
架构边界则交给 dependency-cruiser。示例通过 domain-is-pure 规则,禁止 ^src/domain 导入 ^src/(infrastructure|presentation)|node_modules/(express|@prisma)。但领域代码仍可能读取 process.env 或调用 Date.now(),所以「禁止这些导入」并不等于「证明领域纯净」。
为了让 agent 及时修复问题,作者还配置了 Claude Code 的 PostToolUse hook,匹配 Edit|Write。它用 jq -r '.tool_input.file_path' 获取刚编辑的路径,再运行 npx eslint --no-warn-ignored --max-warnings=0 "$f";错误输出送到标准错误,退出码 2 把反馈交回 agent。
这个 hook 依赖 jq,文件路径也需要引号保护。它发生在编辑之后,不会撤销改动;真正阻止不合格代码合入的,仍是失败时拒绝合并的 CI。
我的判断
我赞同作者把要求从「使用某种模式」改成「纯函数、可独立测试、保持行为」。这些性质直接影响维护成本,也更容易审查。
原文的折扣改写就体现了这个取舍。只有两个固定规则时,先用函数:
// src/domain/pricing/customer-discount.ts
const VIP_DISCOUNT = Percentage.of(10);
const EMPLOYEE_DISCOUNT = Percentage.of(20);
// 保持原有规则:先判断 VIP,再判断员工,两者不叠加。
export function applyCustomerDiscount(customer: Customer, subtotal: Money): Money {
if (customer.isVip()) return subtotal.minus(VIP_DISCOUNT.of(subtotal));
if (customer.isEmployee()) return subtotal.minus(EMPLOYEE_DISCOUNT.of(subtotal));
return subtotal;
}
重点是两个判断的顺序:VIP 优先于员工,只应用一个折扣。改成「取最大优惠」或「优惠叠加」已经是业务变更,不能混进重构。这里的 Money、Percentage 和 Customer 没有完整实现,因此示例也不是可直接运行的交付代码。
等到营销活动每季度增加、各自条件独立变化,再考虑 DiscountPolicy 和 Strategy。用例 CalculateTotal 通过构造函数接收 OrderedDiscountPolicies,调用 applyFirstMatch,明确表达「按顺序,首次匹配即生效」。这个行为名称比模式名称更有信息量。
局限也很清楚:作者没有提供规则启用前后的缺陷率、返工量或审查耗时。自审能减少多少间接层,同样只是经验之谈。相较于把所有设计要求写进提示词,机械规则交给工具更可靠;相较于全面引入分层架构,小型脚本和框架胶水代码通常更需要克制。
能拿来做什么
- 给项目的
AGENTS.md写少量可核查约束,明确适用目录、已有约定和允许例外的条件,先解决团队反复遇到的问题。 - 修改 Node 定价或结账逻辑前,先写清优先级、叠加方式和保持行为的测试;固定规则先用函数,独立变化出现后再讨论 Strategy。
- 把 ESLint 和依赖检查接入会阻止合并的 CI,再用编辑后 hook 缩短反馈时间,让 agent 在任务上下文还完整时修复问题。
- 要求 agent 完成前审查自己的 diff,列出主动放宽的规则及原因,并检查陌生开发者能否快速找到相关契约和设计决定。