需求和架构 (1)

上一讲讨论了意图、规约和实现之间的差距:客户心里想要的东西,需要变成明确的要求,再变成软件。需求分析、系统设计、代码实现各有自己的方法和工具,UML 则试图用一套共同语言,让不同环节对同一件事的理解对齐。

今天,能够调用工具、修改代码、执行测试的 AI Agent,让其中一些步骤变得前所未有地便宜。目标足够明确时,我们甚至可以让多个 Agent 同时工作,生成实现、测试、断言乃至证明。但“写得出来”之后,问题也就更加直接地摆到了面前:我们究竟要做什么?应该怎样组织这个系统,才能承受下一次需求变化?

代码便宜以后,什么变得更重要

传统软件工程面对几个朴素而顽固的事实。客户用自然语言描述自己的工作,开发者却必须把它翻译成精确的行为;满足同一组要求的实现方法有很多,各有优劣;产品真正好不好用,往往要做出来才知道。而代码生产和返工都很贵,一个前面的误解,可能在后面变成整个团队数周的无效劳动。“软件不软”,说的就是这种修改起来一点也不轻松的处境。

于是有了各种软件工程方法:怎样访谈客户,怎样建模,怎样划分模块,怎样安排开发过程。不过,需求、产品和架构又特别难做定量研究。一个架构今天写起来很顺手,三年后是否仍然合适?一种需求分析方法让产品成功了,究竟是方法有效,还是参与者本来就更有经验?这些问题很难像比较算法运行时间一样,放在一个统一的实验里得到答案。

课堂上“学校里的软工人都不在做软工”的调侃,指向的正是这种张力:容易量化的研究对象,并不一定就是软件生产中最关键的困难。能够预测一些缺陷,并不自动意味着知道客户需要什么、产品应该长什么样。

当生产目标明确的代码变得便宜,需求、产品设计和架构就成为撬动软件生产力的杠杆。一个正确的抽象,可能让后续大量代码都变得容易生成和检查;一个含混的决定,也可能被 AI 迅速扩散到整个系统。如果我们只追求生成速度,最后得到的就可能是 AI Slop:代码和功能不断堆积,却越来越难说明它们为什么正确、彼此怎样配合。

教务系统为什么会陷入需求泥潭

需求工程在学校里曾经很难讲。学生通常不会去见真正的客户,课程作业也很少大到需要认真处理需求冲突。现在,做出一个可以使用的原型容易多了,人人都有机会体验一次产品经理的工作。身边的教务系统,就是一个很好的起点。

从入学到毕业,学生会经历学籍登记、培养方案选择、选课、考试、成绩认定和毕业审核。教师要开课、管理名单、登记成绩;教务员要排课、处理学籍变化和各类申请;教务处要执行政策、汇总统计;财务和辅导员又有各自的业务。表面上是几张表、几个页面,背后却连着许多不同的参与者。

按照经典做法,把需求方逐一访谈一遍,可能期末考试都结束了。更麻烦的是,客户常常说不清楚完整需求。一个人熟悉自己每天处理的普通情况,却未必能立刻列出所有例外;不同人即使都说清楚了,也未必能够达成一致。学生希望选上课,教师希望控制班额,教务员希望学生按时毕业,这几个要求遇到同一门满员的必修课时,就需要有人作出业务决定。

我们可以构造一个虚拟的培养方案,感受一下这种复杂性。假设一位 2024 级学生在 2026 年转入计算机专业,适用目标专业的 2024 版培养方案:

  • 毕业至少需要 160 学分,其中通识 40、专业必修 72、专业选修 28、实践 20,每一类都要分别达标。
  • 数学要求可以用 5 学分的高数 A 满足,也可以用 4 学分的高数 B 加 1 学分的桥接课满足。
  • 算法课要求先通过数据结构;缓考学生可以暂时选课,但开课前仍未通过就要撤销。
  • 两门交换课程经过批准,可以合抵一门 4 学分的算法课,但不能再把同一份学分计入专业选修。
  • 重修只计一次学分,成绩单保留历次记录,绩点按这个假想方案规定的较高成绩计算。

这里每一条单独看都不难。真正的困难在于它们会相遇:转专业学生恰好参加过交换,交换课程还没有完成认定,先修课正在等待缓考成绩,此时选课窗口却马上关闭。更现实的是,总有某种经过线下批准的特殊情况,没有来得及变成系统里的规则。最后只能有一个“视为满足”的兜底入口,也就是大家调侃的“暗箱操作”。

这个入口也有需求:谁有权批准,豁免哪一项要求,适用于哪个学生和哪一版方案,理由是什么。若系统不能表达这些内容,工程师就只能直接改数据库,把当前问题先救过去。

课堂中有一个很有冲击力的经历:上课第一天,绕过前端校验,把一位同学的缺课次数改成了 -1。结果,教务系统中所有能选出这位同学选课记录的部分都瘫痪了。我们没有据此定位某一处具体代码错误,但可以看到,某个看似局部的数据假设,已经被许多功能共同依赖。一个特殊值进入系统,问题就沿着这些依赖扩散。

功能勉强做全,例外靠补丁维持,改一个地方又牵动其他地方,软件就逐渐进入了“焦油坑”。

MVP 帮助发现需求,也会掩盖困难

AI 让最小可行产品,也就是 MVP,变得非常容易。先实现最重要的功能,把一个可操作的产品交给用户,往往比继续讨论抽象需求更有用。看到课表、选课按钮和毕业审核结果,用户马上就能指出:“这门课我在交换时已经修过了,为什么系统还要求我再修一次?”试用本身就在帮助我们发现没有说出口的需求。

但第一次顺利运行,也容易让人产生“我已经驾驭了 AI”的错觉。原型走通了正常路径,那些转专业、课程替代、规则追溯和人工批准的角落,可能还没有露出獠牙。更大的考验是需求变了以后怎么办。

例如,把“一门交换课程替代一门本校课程”改为“允许两门交换课程合抵一门必修课”,受到影响的就不只是一个下拉框。课程对应关系、学分计算、毕业审核、成绩单显示以及相关测试,都可能需要改变。已经认定过的学分是否重新计算,已经批准的特例是否继续有效,也要给出答案。

上一讲的数据依赖图在这里再次出现:一个结论以另一个结论为前提,需求变化就可能沿着依赖关系穿透整个系统。软件增长得越快,未管理的依赖也可能积累得越快。

“没有银弹”中的银弹,借用了西方传说里能够杀死狼人的特殊武器,形容人们期待的一招制胜的办法。快速生成原型很有价值,但需求冲突、历史包袱和持续变化仍然要有人处理。接下来的问题就是:怎样让这些复杂性有合适的位置?

几行代码也有架构

软件架构决定我们怎样分解问题、安排职责和组织依赖。它让一些事情变得容易,也决定另一些事情将来需要付出多大的修改成本。一个系统的品味,以及它能走多远,很大程度上取决于这些选择。

不必等到有数据库、微服务和流式数据处理,才开始谈架构。一个小程序也可以先读取输入,再解析、处理,最后输出。这几个阶段一旦成为明确的边界,我们就有机会分别考虑:换一种输入格式,需要改到哪里?核心算法是否必须知道结果最终写到终端还是文件?错误应该由谁处理?

当然,把代码切成四个函数,还不能保证边界真的存在。如果它们随意修改同一批全局变量,每一步都依赖其他步骤的内部细节,变化仍然会四处传播。架构的价值要落实到数据怎样流动、谁拥有状态、谁可以依赖谁。

因此,学习编程语言时,每一种构件都可能与架构有关。函数帮助表达边界,类型帮助约束表示,作用域限制信息可见的范围,调用栈保存尚未完成的工作。只以“语言律师”的方式记忆语法细节,很容易错过更有意思的问题:这些机制怎样帮助我们掌控一个程序?

十行汉诺塔背后的三种看法

汉诺塔要求把一叠盘子从 A 柱移动到 C 柱,借助 B 柱,每次只能移动最上面一个盘子,而且大盘不能压在小盘上。递归解法很自然:先搬走上面的 n - 1 个盘子,再搬最大的盘子,最后把那 n - 1 个盘子搬回来。要求“不使用递归”时,许多人却突然不知道该怎么写了。

第一种看法,是直接寻找移动序列的数学规律。把盘子从小到大编号为 1 到 n,把最少步数解中的步骤从 1 开始编号,第 step 步移动的盘号,就是 step 的二进制表示末尾连续零的个数加一。例如,三层汉诺塔的步号从 1 到 7,对应盘号恰好是 1, 2, 1, 3, 1, 2, 1。每个盘子沿 A、B、C 构成的环移动时,方向也是固定的,由总盘数和盘号的奇偶关系决定。

于是,只需要记录每个盘子的位置,就可以得到下面的核心循环。这里把 A、B、C 编为 0、1、2,pegs 保存这三个字符,position 初始全为 0。限定 1 <= n < 64,用 uint64_t total = (UINT64_C(1) << n) - 1 保存总步数,并为位置数组预留足够空间。

for (uint64_t step = 1; step <= total; step++) {
    int disk = 1;
    for (uint64_t x = step; (x & 1) == 0; x >>= 1) {
        disk++;
    }
    int direction = (n - disk) % 2 == 0 ? -1 : 1;
    int from = position[disk];
    int to = (from + direction + 3) % 3;
    printf("Move disk %d: %c -> %c\n", disk, pegs[from], pegs[to]);
    position[disk] = to;
}

这段程序之所以短,是因为对问题的理解已经承担了原来需要代码处理的复杂性。代价则是必须解释和验证这些规律;若游戏规则变了,原来的数学结构未必还能使用。短代码也没有消除指数级的移动次数。

第二种看法,是把“还没有做完的任务”存起来。一个任务记录盘数、起点、终点和辅助柱。只有一个盘子时直接移动;否则把它分成三个子任务:先把上面的盘子移到辅助柱,再移动最大的盘子,最后把辅助柱上的盘子移到终点。用栈保存任务时,因为后放入的先取出,必须按执行顺序的反方向压栈。

这样得到的是一个不断取出、分解和执行任务的循环。递归所表达的任务结构还在,只是等待执行的工作由我们显式保存了。它不依赖前一种解法的步号规律,只需要把熟悉的递归分解说清楚。

第三种看法更进一步:直接模拟函数调用的执行过程。每一层调用用一个栈帧表示,保存 n、三根柱子的角色,以及程序计数器 pc。程序计数器表示“这层函数执行到了哪一步”。遇到调用,就压入新的栈帧;遇到返回,就弹出栈帧;重新轮到上一层时,根据它保存的 pc 接着执行。

这时,我们其实构造了一台很小的抽象机器。函数调用和返回都变成了状态转移,“非递归汉诺塔”也就成了理解程序执行语义的机会。类似的办法可以解释更一般的递归过程,而不只服务于这一道题。

三种写法分别把复杂性放在数学规律、任务分解和执行机器里。课堂用不同模型给出的代码作了对照,但有价值的部分在这些表示方法本身。若只盯着眼前该写哪个 if,很容易陷入越来越多的分支;换一种表示,问题可能突然变得简单。架构关乎的,正是我们对问题掌控到了什么程度。

两千行二维码程序:原来我们需要一个编译器

从汉诺塔向外看,代码之外有客户工作的规律,客户之外有社会运行的规律,还有数学和物理世界的规律。学校里的学习、生活与工作的经验,共同帮助我们跳出当前代码,寻找更合适的解释。至于这种跨层次选择表示的能力,未来能否也通过强化学习不断改进,是值得继续观察的问题,不能把今天的能力边界当成永久边界。

再看一个具体需求:在 Excel 中输入字符串,由工作表公式计算并显示二维码,修改输入后二维码随之变化。直接让 Agent 写出所有公式,很快就会碰到长公式的脆弱性。括号、引用、数组运算和算法步骤缠在一起,模型可能花大量时间与不配对的括号搏斗。

即使公式终于能运行,下一次需求变化仍然很痛苦:要兼容更老的 Excel,或者利用新版本的能力;把二维码移动到工作表的另一个位置;换一种计算方法;再增加一个字符串输入单元格。原本看似局部的修改,都可能穿过整片公式。

跳出公式来看,二维码算法用普通编程语言并不难描述,困难在于怎样把这些计算映射到工作表。我们真正需要的,其实是一个小型编译器:用一种便于表达算法的形式描述计算,再把它翻译到目标环境。

一种可行的设计是引入中间表示,也就是 IR(Intermediate Representation)。它保存输入、常量、异或、查表、条件选择等计算节点,节点之间的边记录数据依赖。高级语言负责构造这张计算图,后端再负责把它翻译成 Python 程序或 Excel 公式。

这里要分清生成时与运行时。Python 中的循环可以按照选定容量,展开二维码计算所需的有限步骤;用户将来输入的字符串则要保留为符号输入。由字符串内容决定的分支,也必须变成图里的条件选择节点,留给 Excel 在重算时判断。否则生成器只是提前算出了一张固定二维码,并没有实现原来的需求。

有了中间表示,几类变化就可以找到各自的位置。算法只知道名为 text 的输入,输入绑定模块才知道它位于哪个单元格;布局模块分配中间结果和最终图案的位置,生成公式时再解析地址;兼容不同 Excel 版本,由目标能力和公式生成部分处理;增加一个输入,则先明确它怎样参与拼接、编码和容量计算,再修改相应的输入与算法构造。

中间表示也需要自己的语义。字节的取值范围、整数除法、字符串编码、数组形状,都不能交给两个后端各自猜测。构图时可以检查类型和容量,节点还可以保存来源,使错误能够追溯到某一步计算。括号和单元格引用由生成器统一处理,算法作者就不必在每个地方重新处理这些细节。

性能优化也有了明确的位置。与输入无关的内容可以在生成时算好,这叫常量折叠;重复的纯计算可以共享一个节点,避免把同一段长公式到处粘贴。真正生成工作表时,再决定哪些计算内联、哪些中间结果放到辅助单元格中。Microsoft 的 Excel 计算性能指南也建议减少重复计算、拆解巨型公式,并通过测量检查修改的效果。

两个后端还提供了一种验证办法:给定相同输入,比较 Python 的结果与目标 Excel 实际重算后的结果,必要时逐个比较中间值。不过,它们可能共享同一个算法错误,因此还应通过独立实现或二维码解码器核验内容。若要逐格比较两张二维码,则需要固定版本、纠错等级、编码分段和掩码等选择,否则外观不同也可能都正确。

这套设计的价值在于,一次需求变化可以主要落到负责它的模块。我们仍然需要验证整个结果,但不必每次都重新与所有公式搏斗。

五十万行教务系统:当前状态里藏着什么

把规模继续放大,架构仍然是怎样看待问题的选择。一个很自然的办法,是把系统理解成当前状态的快照:现在有哪些学生、教师、课程和选课记录。每出现一个需求,就增加一段查询或修改这些记录的业务逻辑。

这就是常说的 CRUD,即创建、读取、更新、删除。数据库课程中的范式,帮助我们减少冗余以及由此产生的更新异常。对于简单的数据维护,这种组织方式十分直接。只要为每个按钮找到对应的状态变化,一个功能似乎就完成了。

但教务系统里的许多字段,已经是推理和决策的结果。一个 can_graduate = true,可能依赖学生的课程成绩、课程替代审批、培养方案版本和人工豁免。它把历史事实、适用规则以及根据规则作出的决定,压缩成了一个当前值。

这又是数据依赖。成绩更正以后,哪些学分认定需要重新计算?哪些毕业审核需要复核?某个决定如果已经正式发布,还能不能直接覆盖?即使数据库表没有重复字段,如果没有保存这些依赖,我们仍然不知道一个结论从哪里来,也不知道前面的变化会破坏哪些后续结论。

更麻烦的是,软件只是现实过程在信息世界中的有损投影。现实中政策已经更新、领导已经批准,系统却还没来得及表达它。眼看今天不处理,学生就不能毕业了,工程师只能先把数据库改成“满足毕业条件”。如果最后只留下一个布尔值,几个月以后就难以分辨:这是正常审核、正式特批,还是一次救火时的误操作?

程序还能运行,数据却逐渐变成无法解释的黑洞。继续让 AI 修修补补,可以消除眼前的一些报错,却未必能补回已经丢失的业务含义。

计算机系变成计算机学院,ID 要不要变

需求的语义本身还会变化。课堂提到“南大没系啦”:计算机系变成了计算机学院。对师生来说可能是喜大普奔,对教务系统来说却立即出现了一个问题:dept_id 沿用旧的,还是新建一个?

沿用旧 ID,直接覆盖名称,学生、课程和权限的关联都可以继续工作。但查询历史成绩单或报表时,只要连接到现在的组织表,过去的“计算机系”也会被显示成“计算机学院”。如果层级、职责和管理范围一并改变,一个 ID 背后就混入了不同时间的含义。

新建 ID,旧组织确实保留下来了,但新学院可能变成一个没有学生、没有课程、没有权限关联的空壳。把所有旧关联迁过去,又会改写学生和课程过去的归属。跨年统计、权限继承、培养方案适用范围,仍然需要解释两个组织之间是什么关系。

问题的关键在于区分组织身份、组织属性,以及某个时刻的隶属关系。如果业务认为组织身份延续,可以保留稳定的组织 ID,让名称、层级等属性按有效期保存版本。如果业务认为产生了新的组织,就建立新 ID,并记录带日期的承继关系,按业务决定转移相应关联,同时保留历史归属。

查询也要说清楚口径:是查看当时的组织归属,还是按今天的组织结构重新汇总?培养方案应有自己的版本,不能因为组织换了名称,就让学生自动适用另一套毕业要求。

系统一旦运行起来,这类修改就很贵。代码可以重写,历史依据却不能凭空补出来。旧系统中同一个“已毕业”,可能来自几种完全不同的过程;迁移时如果不知道它们原来的含义,就很难保证新系统继续作出正确解释。理解并承接这些积累,是企业资源计划系统,也就是 ERP,以及类似复杂业务系统的一部分长期价值。

把历史保存下来:事件溯源

既然依赖和历史如此重要,为什么不把它们变成程序可以明确表示、保存和处理的对象,也就是所谓的 first-class citizens?

数据结构有两种常见的观察角度。我们可以只保存“张三目前选了《生成式软件工程》”,也可以保存“张三的选课申请已被接受”这件事,以及后来是否退选、是否再次选入。把被系统接受的事件依次应用到初始状态上,就可以得到当前状态;改变查询方式,还可以回答过去发生了什么。

这就是事件溯源,Event Sourcing。它把状态变化保存在事件序列里,并让状态能够由这些记录重建。账本是一个容易理解的类比:余额告诉我们现在有多少钱,逐笔收支还能解释钱为什么变成了这么多。Martin Fowler 对事件溯源的介绍讨论了这种表示带来的历史查询和重放能力。

回到教务系统,我们可以记录选课已接受、成绩已登记、课程替代已批准、毕业审核已完成等事件,再从中构造学生课表、教师点名册或审核视图。把事件转换成某种状态或查询结果的过程,称为投影;重新应用已有事件的过程,称为重放,也就是 replay。

记录历史也不要求每次查询都从学生入学开始重新计算。日常可以维护当前结果,在新事件到来时增量更新;需要重建时再重放相应历史,或者从一个有明确位置的快照继续处理后续事件。

不过,事件序列只告诉我们发生了哪些变化。若要沿着依赖找到受影响的结论,还应记录一次学分认定或毕业审核具体使用了哪些输入、哪个规则版本、什么审批依据。把日志保存下来,是为可解释性准备材料;还需要设计这些材料之间的关系。

恢复历史与按新规则重算

保存事件,让我们在语义改变时有了更多余地,但不能把它理解成“以后规则随便改,历史自然都能正确恢复”。至少要区分两个问题。

第一个问题是:当时究竟发生了什么,为什么作出那个决定?要回答它,就需要当时记录的事实、适用规则和有关输入。第二个问题是:如果用今天的新方案重新审核,会得到什么结果?这可以通过新的投影或计算得到,但它并不自动改写已经发生的事实。

例如,一位学生已经获得学位。今天换一套规则重算,结果不符合要求,不能因此让“当年已经授予学位”这件事从历史里消失。若业务上需要复核或撤销,应经过相应流程,再记录新的决定。

事件格式和业务规则也会各自演化,需要分别管理版本。事实录错时,可以追加更正事件及其原因,保留原始记录与更正之间的关系。Microsoft 的事件溯源说明讨论了事件版本、补偿事件和历史格式演进需要付出的成本。

重放还要隔离对外部世界的副作用。重建一次选课视图,不能再给所有学生发送一遍短信,更不能重复扣费。若历史决定依赖一个外部查询的结果,也需要保留当时的输入,不能拿今天查到的值冒充过去的依据。这些都是事件重放与外部系统交互时必须处理的问题。

历史保存得越完整,我们就越有机会解释旧决定、比较新方案和修正错误;哪些信息值得保存,仍然是一项架构决定。

CQRS:怎样做决定,怎样看结果

有了这些观察,就容易理解大语言模型出现之前的软件工程实践。CQRS 是 Command Query Responsibility Segregation,命令与查询职责分离。它把负责接受业务操作的写模型,与负责提供查询结果的读模型分别设计。

命令表达一个业务意图,例如“申请选课”。写模型检查先修资格、课程容量和时间冲突,再接受或拒绝申请。查询则服务于不同使用者:学生要看课表,教师要看点名册,教务员要看开课统计。这些结果没有必要被迫使用同一种展示结构。

在这样的设计中,写模型集中维护业务约束,读模型可以为查询准备专门的数据结构,适当冗余或预计算。CQRS 的重点在两类模型的职责,可以在同一个进程、同一个数据库里实现;是否再采用事件溯源,是另一个选择。参见 Microsoft 的 CQRS 说明。

若读模型通过异步处理事件更新,就可能出现短暂的间隙:选课已经确认,课表还没刷新。界面需要能解释这个状态,系统也要明确“选课成功”究竟由哪个环节确认。不能因为查询视图还没显示这条记录,就让用户误以为没有成功而不断重复提交。

还有一个更硬的约束:只剩最后一个名额,两位学生同时申请,不能让两人都占位成功。检查容量与确认占位需要作为一个不可被其他申请穿插破坏的操作完成。使用事件存储时,也仍然需要并发控制,例如追加前检查所读取的事件流版本,发现已经变化就重新读取和判断。事件溯源的并发处理并不会因为数据采用追加方式就自动消失。

由此,系统中哪些部分必须立即保持一致,哪些查询结果允许稍后更新,就成为可以明确讨论的决定。

DDD:让业务概念决定边界

DDD 是 Domain-Driven Design,领域驱动设计。这里的领域就是软件试图处理的业务世界。开发者与业务人员共同建立模型,让“选课”“学分认定”“毕业审批”这些概念进入软件的结构和语言,而不是只围绕数据库表名讨论系统。

在一个明确边界内,同一个词应当具有一致的含义,这就是统一语言。但一个庞大的系统不必强迫所有参与者共享一份无所不包的模型。DDD 用限界上下文,Bounded Context,明确模型适用的范围,再描述不同范围之间如何协作。可以参考 Eric Evans 的 DDD Reference。

例如,选课业务关心学生有没有资格、课程还有没有名额;财务业务关心应收和到账;毕业审核关心认定学分与适用方案。它们都谈到“学生”,但需要维护的状态和规则并不相同。

边界之间传递信息时,也要明确含义。“课程成绩已经通过”可以成为学分认定的输入,但能否计入某个专业的毕业学分,应该由学分认定的规则决定。如果直接把两个概念压成同一个 passed 字段,以后就很难解释“通过了这门课,却仍然不满足这项毕业要求”的情况。

DDD 帮助安排业务语义和模型边界,CQRS 分开接受操作与提供查询的职责,事件溯源则提供保存和重建历史的方式。这些想法可以组合,也可以按具体问题分别采用。

让下一次需求变化有地方可去

把这些想法落实到教务系统,可以先从一个结论开始。一次毕业审核除了保存“通过”或“不通过”,还可以记录输入记录的标识与版本,或者当时的快照,以及规则版本、结论和审批依据。成绩被更正时,系统就能够沿着依赖找到受影响的审核;已经发布的正式决定,则进入复核流程。

时间也值得明确表示。假设一项豁免在 6 月 1 日生效,6 月 3 日才录入系统,那么“6 月 2 日豁免是否有效”和“6 月 2 日系统为什么拒绝毕业”是两个问题。只保存一个时间,可能无法同时解释它们。业务发生或生效的时间,与系统得知这件事的时间,可以分别保存。

模块边界则应围绕可能变化的决定来安排。培养方案、课程替代、组织身份映射,各自有负责的模块和明确的接口。起初完全可以把这些模块放在一个程序里,先把数据归属和调用关系理清;需要独立发布或扩容时,再考虑拆成服务。把一个大文件拆成许多服务,并不会自动补上原来没有说清楚的业务边界。

对于已经运行的系统,也可以从一条业务链逐步改进。例如,先整理毕业审核的事实来源、规则版本和审批流程,让新旧审核并行计算,重点解释转专业、补考、课程替代与人工豁免案例中的差异,再逐步切换权威写入入口。旧系统提供切换时的基线快照,明确其时间和来源;已经丢失的历史,不能伪装成我们亲眼记录过的事件。

追溯与重算有成本,可以优先用于确实需要解释依据的业务;简单的信息维护仍然可以采用 CRUD。每增加一层结构,都应能说明它让哪一种变化更容易处理,以及带来了什么新的维护责任。

从十行汉诺塔、两千行二维码程序,到几十万行的教务系统,规模不同,选择表示方式的影响始终存在。给 AI 增加“采用 CQRS”这样几个字,就可能让它生成一个完全不同的系统;这几个字背后需要我们理解的,是谁维护业务约束、哪些信息应当保留、变化沿什么关系传播。代码越容易生成,越值得在这些少量但影响深远的决定上花时间。