

这是「ERP 架构师笔记:定义驱动的框架设计」系列的 Day 1。
接下来三十天谈的不是「怎么用某个框架做一张表单」,而是怎么设计一套能驱动上百张表单的框架:它该替使用它的人决定哪些事、哪些留给对方、每个机制为什么长成那个样子。案例固定用 Northwind 示例数据库验证,但案例是佐证,不是主角。
本篇说明:
- ERP 开发成本的三层结构:单次变更、规模、定制交付
- 这三层收敛出的三个老问题,以及为何工具解不掉
- 范式的分歧点:唯一真相是代码还是定义
- 对照实验:同一个字段在两种范式下各改几处
先交代我是谁
我二十多年来一直在 ERP/HRM 套装软件业担任软件架构师。套装软件的产品周期非常长,动辄十年、二十年起跳,这段时间里系统不只是被维护,还得跟着前端技术的世代更替长出不同的样子,我经历过的就有桌面程序、Web、移动 App 三代,每一代来的时候问题都一样:那几百张界面,要不要整批重做一次?在这种产品线上,架构决策不会在三年后被谁重写掉,你会在十年后亲眼看到它长成什么样子,然后继续维护它。而我一直待在同一个职位上,没有转去管项目、管团队而离开代码,当年那些决定的后果,一次也没有错过。
这套框架与这个系列
这些年累积的想法,有些在项目里做过,更多是想过而没有动手,一个人要把它们全部写出来并不划算,而这个成本现在不一样了。我用 Claude Code 把没动手的那些做出来,成了一套开源框架 bee-library,后面的 Northwind 案例就跑在上面。
案例本身是另一个 repo,bee-northwind-avalonia:框架那一个装的是机制,案例这一个只有应用自己的定义文件与程序,框架以包引用进来。想知道套用这套框架做一个应用实际要准备哪些定义文件、还要写哪些代码,看案例这一个比看框架清楚。
框架还在持续发展,该收敛成机制的东西会陆续加进来,接口也会跟着改,破坏性变更会发生。现阶段要直接引用它的包得先把这件事算进去,也可以整份 clone 回去改成自己需要的样子。
这个系列不是它的使用教学,框架在这里只有一个角色:证明这些想法不是纸上谈兵。三十篇谈的是它已经做出来的那些机制,遇到什么问题、怎么解、代价是什么、为什么选这一种,实现细节不逐项展开。你完全可以不用它,只把「让定义当源头」这个想法带走,用你熟悉的语言和工具去实现。
整个系列的命题只有一句:
ERP 的复杂度不在单一功能有多难,而在同一件事被重述太多次、乘以太多张表、再乘以太多次定制。
一、单次变更成本:一个字段落在几个地方
需求:订单要多一个「预计到货日」。没有计算、没有联动、不影响既有逻辑。用户说这是「加一个字段而已,应该很简单吧」,而且他说得对。
在一套典型的 Code-First 堆栈(ORM + Web API + 前端)里,这个字段会落到十三个地方:
| # | 位置 | 内容 |
|---|---|---|
| 1 | Entity 类 | 加一个属性 |
| 2 | 类型对应设置 | 精度、允不允许 NULL、列名对应 |
| 3 | Migration | 产生、审阅、进版本控制、上线时运行 |
| 4 | 查询投影 | 清单查询的 Select、报表的字段清单 |
| 5 | DTO | Request 与 Response 各一次 |
| 6 | Mapper 设置 | Entity 与 DTO 的对应 |
| 7 | 验证 | 必填、范围、与订单日期的先后关系 |
| 8 | API 契约 | OpenAPI schema,并重新发布给消费端 |
| 9 | 前端类型 | TypeScript interface 或 C# model |
| 10 | 前端表单 | 控件、标签、布局位置 |
| 11 | 前端清单 | 字段、宽度、排序 |
| 12 | 多语言资源 | 每个语言一份 |
| 13 | 测试数据 | fixture、seed、假数据生成器 |
先说公道话:其中好几处可以自动生成(Migration 由工具产生、OpenAPI 由程序集扫描产生、前端类型可由 OpenAPI 产生),真正要手写的大约五、六处。
但重点不在手写几处:
「预计到货日是一个
Date、必填、显示在订单日期后面」这一个事实,在这套堆栈里被重述了十三次。
每一次重述都是一个可能不一致的点:
- DTO 忘了加 → 字段存进数据库,前端拿不到
- 验证忘了加 → 用户送出空值
- 清单忘了加 → 用户问「为什么查询界面看不到」
这些不是「难」的问题,是「多」的问题,而且每一次不一致都要靠一次测试或一次用户回报才会被发现。
这张表中间横着一条团队边界
第 1 到 8 项在后端,第 9 到 13 项在前端。前后端若是两个团队,那条线正好穿过表格中间,而跨过边界的每一处性质都会改变:从「编辑」变成「协调」。
一个加字段的需求于是长成这样:
- 先开会确认契约,再各自排期
- 顺序依赖:后端没上,前端没东西可接,要先动工就得自己刻 mock
- 上线窗口:两边谁先上,都得处理对方还没跟上的状态
- 联调时才发现,一方把「必填」理解成前端挡,另一方理解成后端挡
上面这几件事,没有一件是在写那个字段。它们吃掉的不是工时,是等待:用户算的是从开口到上线过了多久,工程师算的是自己动手多久,于是一边问「你们改一个字段要这么久?」,另一边觉得自己并没有偷懒。两边都对,因为成本是真的,只是不落在敲键盘那一段。
二、规模成本:把上面那张表乘以一千
一套完整的套装 ERP 涵盖财务、采购、库存、生产、销售、人力资源,业务数据表动辄数千张。
ORM 的核心前提是对象与数据表的对应,所以:
数千张业务表
→ 数千个 Entity 类
→ 加上 DTO(多半不只一个:List / Detail / Create / Update)
→ 加上 Mapper 设置
→ 加上 Repository
这些类绝大多数彼此雷同。Supplier 和 Shipper 的 Entity 差在字段名与数量,结构上是同一个模子。
这不是在说 ORM 不好。ORM 在它被设计的场景里运作良好,问题出在数量:当同一个模式要被拷贝数千次,拷贝这件事本身就变成主要成本。
而关联把这个数量再放大一次:
- ERP 的表本来就高度牵连:订单指向客户、业务员、承运商;出货单指向订单、仓库、批号
- 每一条关联都要有人决定怎么表达、怎么加载、谁维护。一条一条看都合理,数千张全部摊开就没有人装得下整张图。
把第一节那十三格乘以数千,连同那条团队边界一起乘。跨团队协调不是每张表付一次,是每次需求付一次,而需求数量本身就跟表数量成正比。
这不是「麻烦」,是管不动。这些症状,维护过大型系统的人都认得:
- 没有人敢动共用类,因为不确定谁在用
- 新人要很久才敢碰内核模块
- 同一个概念在不同模块有好几种写法,因为它们由不同时期、不同人各自长出来
三、定制成本:改完之后怎么交出去
前两层讲「改起来多费工」,第三层讲更根本的问题。这一层绕不开,因为:
ERP 几乎不存在「装了就用」这回事。
每次导入都伴随定制:这家的单号规则不同、那家的成本计算方式不同、这个部门要多两个字段、那张报表要换一种分组。差别只在定制通过什么路径交付。
Code-First 模式下,那十三处全部是代码,而代码只有一条交付路径:
改 → 编译 → 测试 → 打包 → 部署
同一条路径在三种部署形态下痛成三种样子:
| 部署形态 | 定制的痛点 |
|---|---|
| 单一组织内部系统 | 最单纯,排一次维护窗口即可。但「把小数位数从 2 改成 4」仍要走完整 build / test / deploy |
| 套装卖 N 家,各自部署 | 只有两条路:进主干用开关控制(开关越积越多,最后没人敢删任何一个),或开分支(N 条分支各自演进,主干修掉的问题要逐一同步) |
| 多租户 SaaS,共用程序集 | 租户 A 改一个计算式,却要协调所有租户的维护窗口、回归测试所有租户的功能,任何一个租户出事就整批 rollback |
三种形态,同一个结论:
需求的大小,与交付的成本,彻底脱钩。
不管需求是「把小数位数从 2 改成 4」还是动到整个模块,走的都是同一条交付路径、承担同一份部署风险。结果是小需求也得排进版本节奏,工程团队被迫把所有变更都当大事处理。
脱钩的根源是定制被表达成了代码。部署形态只决定这件事有多痛,不决定它痛不痛。
这一层才是定义驱动要解的问题:
定义是数据,不是代码。改数据不需要重新编译,也不需要重新部署程序集。
四、三个一直没解的老问题
前三节的成本收敛起来就是这三个:
| 老问题 | 症状 |
|---|---|
| 规格分散三层 | 同一个字段在 UI、DTO、DB Migration 各定义一次,极易不一致 |
| 业务逻辑分散 | 不同模块由不同工程师维护,风格不一、重复开发 |
| 定制化难标准化 | 定制逻辑无法反馈到标准版,累积成无法治理的技术债 |
过去二十年的解法多半是加工具:代码生成器、项目模板、更聪明的 mapper、更自动的 migration。这些工具确实让每一次重述变便宜。
但它们没有改变「同一件事被重述十三次」这个结构,只是让重述变快。
第三层的定制成本,工具更帮不上忙:产生出来的代码还是代码,还是要编译部署。第三个老问题拖了二十年没解,根源就在这里。
五、范式的分歧点
问题不在工具,在范式。只有一个问题要回答:
系统的唯一真相,是代码,还是定义?
- Code-First:Entity 类是源头,数据库由它衍生(Migration)、API 契约由它衍生(OpenAPI)、前端类型由它衍生。定义文件是附属产物。
- Definition-Driven:一份结构描述是源头,UI、数据库、CRUD SQL、验证规则全部由它衍生。代码只在真正需要业务判断的地方出现。
差别不只是文件格式从 .cs 换成 .xml,而在源头的性质:
| 代码是源头 | 定义是源头 | |
|---|---|---|
| 上千张表的表现形式 | 上千个类 | 上千份定义数据 |
| 要处理它们,你能用 | 编译器、IDE、重构工具 | 加上:批量脚本、生成器、程序化校验、AI |
| 变更的交付路径 | 编译、打包、部署 | 换一份定义 |
| 定制的表现形式 | 分支或开关,散在源代码里 | 叠加在标准定义之上的一层数据 |
| 前端怎么知道有哪些字段 | 另一份手写的类型,靠契约与人来同步 | 运行时取得同一份定义 |
| 换一代前端要付什么 | 每张界面重做一次 | 写一个新的渲染层 |
| 谁能修改 | 开发者 | 开发者、顾问、工具,甚至用户 |
其中两行要多说几句:
- 「上千个类」与「上千份定义数据」数量一样,管理成本不同量级。上千份同构的 XML 可以写一支脚本扫过去,检查「所有金额字段是否都设了正确小数位数」,几分钟的事;换成上千个类,同样的检查要复杂得多。
- 换代成本是我感受最深的一项。前端技术大概每十年换一代,界面若一张张手写,换代就是整批重做;界面若从定义长出来,换代是写一个新的渲染层。表单数量越多,差距越大。
这也是布局要独立成一层定义的理由:结构稳定,跟着业务走;布局易变,跟着前端世代走。分开之后,换代时重写的是渲染层,不是那上千份结构定义。
六、对照实验:同一个字段改几处
回到「订单加预计到货日」。我拿本系列的案例应用实际数了一次,答案是三处定义修改,零行代码。
数据表结构:
<DbField FieldName="required_date" Caption="Required Date" DbType="Date" />
表单结构:
<FormField FieldName="required_date" Caption="Required Date" DbType="Date" />
布局:
<LayoutField FieldName="required_date" Caption="Required Date" />
改完之后自动跟上的有:数据库字段、表单控件、清单字段、添加修改删除的 SQL、跨层传输。
- 没有 Entity 类要同步,因为 SQL 是依定义实时组出来的
- 没有 DTO 与 mapper 要同步,因为跨层传的数据本身就带着结构描述
这个数字视情况会多一处或少一处,但重点不在数字,是这几处全都是定义数据。
那条团队边界呢
前端不再自己声明一次字段,它在运行时向后端要那份定义,然后照着渲染:
前端启动 → 向后端要那份定义 → 依定义长出控件、清单列、验证
- 契约不需要「谈」,因为契约就是那份定义本身,没有第二份可以对不起来
- 不需要等对方上线,后端改完定义,前端下次取得就是新的
- 第一节那串协调、排期、顺序依赖、上线窗口,多半直接消失
剩下的只有第一步的一半:这个字段在业务上叫什么、放哪里、要不要必填。而这件事本来就该有人决定,它是业务判断,不是沟通成本。
但这条边界没有消失,只是移动了。
- 前端仍要有一个「懂得读定义」的渲染层,这一层要有人做。差别在它做一次,之后每张表单、每个字段都免费。
- 团队边界从「每个字段」下移到「渲染层」,对话频率从「每次需求」变成「框架能力有变动时」
- 真正不能声明成定义的东西仍然要沟通。一条「这张单据在什么状况下不准出货」的规则,该由谁挡、挡在哪一层、消息怎么写,换什么架构都逃不掉。定义驱动能消灭的是结构的重述,不是语义的协商。
改完之后
还有两个现实问题,框架都得处理掉:
- 物理数据库还没有那个字段 → 框架比对定义与实际结构,把字段补上,不必人手写 migration(Day 6 展开)
- 系统正在跑,怎么知道定义变了 → 改掉定义的那个节点要能通知其他节点,让它们手上的缓存失效(Day 12 展开)
重点只有一句:改定义到生效,中间没有编译、没有打包、没有部署程序集。
七、这三十天的地图
| 章 | 主题 |
|---|---|
| 一(Day 1–3) | 定义驱动的起点 |
| 二(Day 4–8) | 定义层设计 |
| 三(Day 9–12) | 数据访问与缓存 |
| 四(Day 13–18) | 业务逻辑与 API |
| 五(Day 19–21) | 多租户与定制化 |
| 六(Day 22–25) | 传输、安全与审计 |
| 七(Day 26–28) | 数据语义正确性 |
| 八(Day 29–30) | 案例回访与全图 |
每一篇的写法大致相同:先讲这件事要考量什么,再谈框架怎么设计、为什么这样设计,能拿案例验证的就用 Northwind 的实际定义片段收尾。
两件事先讲清楚:
- 重点在长期成本,不在开发速度。短期看 Code-First 几乎必胜,工具成熟、生态完整、上手快。定义驱动的成本集中在前期,回报要到第三年、第五年才显现。如果你的系统做完就结案、三年后整套重写,这个系列的价值有限;它是写给知道自己要维护很久的人。
- 取舍与代价会写得跟好处一样清楚。一套框架的价值不在它宣称能做什么,而在它明确知道自己不做什么、以及为什么。高度动态的 UI、仪表板、报表、非表单型功能,硬套这个模式只会更痛。
一个伏笔
明天谈框架该替用户决定哪些事,后天让案例正式登场。
案例用 Northwind 做成一套完整的应用:八张表单、主表明细、三处跨表关联、验证都在里面,而其中只有一张需要自己写程序。那一张里装了什么、以及有哪些地方我们选择不走定义,系列收尾前会把整套应用摊开来逐项对账。
ERP 的敌人不是复杂,是重述。而重述之所以无法用工具消灭,是因为它是范式的产物。只要代码还是源头,同一个事实就注定要被说很多次。
本文为 2026 iThome 铁人赛参赛文章,原文为繁体中文。
系列完整目录:ERP 架构师笔记:定义驱动的框架设计
文章摘自:https://www.cnblogs.com/jeff-tsai/p/23459252/day-01-why-definition-driven
