ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

账本崩塌背后的真相:业务即代码与DDD边界思维

账本崩塌背后的真相:业务即代码与DDD边界思维 账本崩塌那天我看到了“业务即代码”的真相聊一个我琢磨了很久的事关于“业务即代码”里那个核心关键词——DDD领域驱动设计。这套东西听起来很“现代”UML图、限界上下文、聚合根、领域事件满屏的软件工程术语。但我最近看了一段老故事讲古代商帮的账房先生是如何在年底对账时发现账目崩掉的突然意识到——DDD这套模型压根不是计算机时代的发明它早就在人类最原始的“记账”里跑了几百年。先说清楚这个标题里的画面。古代商帮账本崩塌意味着什么意味着总号与分号之间的银钱往来出现了重大缺口掌柜的对不上帐东家开始疑心伙计开始内斗。表面上看是账目数字出了问题实际上是一个业务系统的最核心逻辑失守了。如果当时的商帮有一套“业务即代码”的思路把每一笔交易的流向、规则、边界都定义清楚崩塌其实是可以在几分钟内被定位并修复的。这期是“第1集”所以我不打算把整套DDD搬完就围绕“账本崩塌”这个场景把里面最关键的几个DDD概念用商帮的语境拆开业务规则的识别、核心逻辑的隔离、边界怎么划、数据怎么保证一致性。顺便说一句这是我第一次尝试用“古代商帮”这个壳来讲现代架构因为我自己觉得如果你能在一个没有数据库、没有事件总线、没有微服务的老旧世界把业务规则讲清楚那你在现代系统里画那些Domain Model的时候心里会踏实很多。本文写给那些已经被“贫血模型”“充血模型”“限界上下文”这些词绕晕的人也写给那些刚接触DDD、想知道“这套理论到底能解决什么问题”的开发者。没有高深代码没有框架选型通篇就是一个核心观点账本会崩通常不是因为计算错而是因为业务规则的边界画错了。1. 场景复盘账本为什么会在年末崩塌1.1 崩塌的是账不是计算先复盘故事里的账本事件。商帮的总号设在江南分号开在西北两地隔着上千里的商路往来靠镖局押运和书信传递。平时分号每天记账、月末寄账总号年底统一核算。听起来很合理对吧但崩溃就发生在年末。总号账房拿到各分号寄来的账册逐笔核对后发现西北分号记录的“已收货款”有1200两但江南总号记录的“已发货款”只有800两两者差了400两且这笔差额既找不到对应的汇兑记录也没有借据凭证。再往细里查发现西北分号的账册里有一笔200两的支出用途写着“马料修缮”但总号没有收到对应的请款单。那个瞬间账房先生满头大汗东家怒不可遏——但要注意他们当时没有算错任何一笔数。加总都是对的就是账目没对上。这个“对不上”不是技术问题而是组织问题。它暴露的是业务定义上的漏洞“货款”到底以谁的口径为准是发货单还是收款单“请款”是否需要先从总号获得批准还是分号有权动支“修缮”属于运营支出还是资本支出要不要单独列项审批在现代系统里这些问题的本质全是一个词含义的载体。你有一个字段叫“金额”但这笔金额代表的是应收账款、已收货款还是预付款只有业务规则说了算。而且规则在不同的角色手里必须是一致的——总号和分号对“货款”的理解必须完全相同否则就是两套模型在对话出问题只是时间问题。1.2 账本系统的隐性假设账房先生是唯一的“大脑”再往深处看传统商帮的记账体系里藏着一个致命的前提假设账房先生是那个唯一理解全局的人。他记得每笔交易的来龙去脉清楚哪个分号会赊账、哪个客户有旧账、哪条商路的运费存在季节性波动。因为他的大脑里有一个完整的“领域模型”所以短期的账目异常他能凭经验判断出个大概。可问题是这个模型不在纸上在人脑里。一旦账房先生生病、离职、或者被竞争对手挖走整套体系的“核心逻辑”就带走了。分号和总号之间关于业务规则的所有默契全崩了。我们做软件也是这个理。如果业务规则散落在各个开发者的脑子里没有沉淀到代码里、没有在统一的地方表达出来系统运行得再好也是积木搭的塔抽掉一块就塌。这里就引出了“业务即代码”的第一层含义不要把业务逻辑当作不可言说的默契要把它显式地、结构化地放到系统里成为可验证、可追溯、可演进的代码。1.3 账本崩塌和“系统崩溃”在架构上是同一件事你可能会说古代商帮和现代系统差别太大了哪里能类比但把问题抽象到信息流层面它们居然惊人地一致现代软件系统古代商帮账务对应DDD概念数据库主从同步延迟分号账册寄送延迟最终一致性接口字段未对齐总号与分号对“货款”定义不一致限界上下文缺失隐藏的业务逻辑在某个service里账房先生脑子里的“潜规则”领域知识未建模促销活动跨部门联动出错请款与发货流程脱节业务规则边界失守线上事故无法快速定位年末对账发现问题但追溯无门缺少事件溯源和审计这个对照表就是我想开篇传达的核心现代软件所遇到的问题在人类商业史里早就以不同的形式出现过。DDD不是在造新概念它是在帮我们把“业务规则”这一亘古不变的东西找到一套可靠的建模方法。2. 战略设计商帮里的限界上下文到底怎么画2.1 一个典型商帮的“业务域”地图要应用DDD第一件事不是画类图而是先画一张“领域地图”。先把商帮的业务按边界拆开看清楚每个环节的“术语体系”和“核心诉求”。一个典型商帮业务域差不多有这些采购域负责从产地收购药材、丝绸、茶叶核心数据是采购价、品质等级、供货商账期。运输域负责镖局押运、路线规划、运费结算核心数据是路线、里程、货物损耗率。仓储域负责入库、出库、盘点、损耗核心数据是库存数量、库位、保管条件。销售域负责客户接洽、订单签订、货款催收核心数据是商品报价、客户信用、回款周期。财务域负责记账、核算、银钱划拨、利润分配核心数据是科目、流水、凭证、余额。注意看这里有一个非常关键的细节——“库存”和“货物”在运输域、仓储域、销售域里反复出现但它们在不同域里的含义是不同的。在运输域里“货物”是一个被押运的物理对象核心状态是“在途”或“已送达”。在仓储域里“货物”是一个需要被保管和清点的对象核心状态是“入库”或“出库”。在销售域里“货物”是一个被买卖的商品核心状态是“已售”或“待售”。如果三个域共用一个叫“货物”的字段彼此混着用就会出现一个经典问题运输域认为货物在途中仓储域认为货物已入库销售域认为货物可售卖——三套认知在同一个字段里打架。这恰恰是商帮年末对账时“账面库存”和“实际在途货物”总是对不上的根源。放在DDD里这就是没有划清限界上下文各业务模块的术语混用。2.2 账房财务域的真正核心是什么回到我们的崩塌场景要搞清楚账房这个角色的核心职责。财务域的领域模型不是“记录流水”而是“保证账实相符”——这是一个典型的约束型业务规则域。账房要做的其实是三件事记录每一笔实际发生的银钱、货物往来都要有凭据。对账总号与分号的记录能相互印证。稽核可疑的出入项能追溯到原始凭据。这三件事合在一起本质上就是在做事件溯源。账房从来不是“直接改余额”他只是“追加一条账目记录”余额是后续从这些记录里算出来的。现代金融系统到今天仍然坚持这个原则**所有账户不得直接改余额只能追加流水余额由流水汇总而来。**这就是DDD里的“事件溯源”模式古人早就用明白了。那账本为什么还会崩因为在传统实践中虽然账房按流水记账但“流水”与“流水”之间的勾稽规则没有代码化。比如分号的“已收货款”到了总号那里该如何校验它与“发货单”的对应关系如果规则只靠人脑判断那就没有办法保证所有分号都执行同一套校验逻辑。有的分号按发货确认收入有的分号按收款确认收入——于是年末对不上账几乎是必然。2.3 画出限界上下文问题立刻暴露现在用DDD的语言整理一下商帮的限界上下文。我画一张简化的上下文映射图不用画图工具直接在脑内描述就行销售上下文分号负责与客户签订契约、下订单。核心术语订单、应付款、信用账期。它知道“客户应付我多少钱”。履约上下文总号负责调度货物、安排运输。核心术语发货单、在途、签收。它知道“我实际发出去多少货”。财务上下文账房负责记录银钱往来、核算利润。核心术语凭证、科目、流水。它知道“账面上有多少钱”。这三个上下文是互相协作的但它们不应该共享同一个“订单”模型。在销售上下文里订单是客户合同在履约上下文里订单变成了一张发货指令在财务上下文里订单又变成了一组借贷分录。如果整个系统只用一张表存“订单”三个部门往里写各自的口径那不需要等到年末三个月就该崩。把这个映射摆出来之后商帮账本崩塌的根源就很清晰了**销售上下文的“货款”被直接写进了财务上下文跳过了履约上下文的确认。**账房计算账目的时候引用了销售的数据两个上下文没有通过一个明确的翻译层防腐层进行通信导致总号在对账时无法判断这笔“已收货款”到底是哪个业务的。好到这里我们的战略设计已经画完。三句话总结术语不一致是因为没有画限界上下文。联动出错是因为上下文之间没有防腐层。账本崩塌是因为财务上下文接收了未经确认的销售数据。3. 战术落地“账本崩塌”时的几个关键模型3.1 聚合根谁负责校验“一借一贷”的平衡战略设计讲的是边界战术设计就要解决“具体的代码长什么样”的问题。现代DDD里最核心的战术概念是聚合根——它就是一组业务规则执行的入口所有的修改必须通过它来发起。对应到账务系统里聚合根是什么不可能是“账本”这个超大对象因为整本账如果是一个聚合根所有账目变动都串行走一个入口系统性能和灵活性就都完蛋。更合理的聚合根应该是“一笔完整交易”。设想一下分号收到一笔货款100两同时发了一批货给客户。在财务上下文里这不是两笔独立的记录而是一笔复合交易的多个分录借库存现金 100两贷预收货款 100两借营业成本 80两贷库存商品 80两这四行分录必须同时成功、同时失败余额校验也必须以这组分录为单位。如果你允许系统先记“收货款100两”再在另一个时间点记“结转成本80两”中间任何一个环节出错都会留下一个“半边账”——这正是传统账房避之不及的事情。而在代码层面这个“复合交易”就是聚合根对应一个事务边界。在设计聚合根时有一个极其重要的经验法则也是我经常在项目评审中反问团队的一句你能说出来“这个聚合根存在是为了保证哪条业务规则”吗如果答不上来那这个聚合根多半是在硬凑。在商帮的例子里财务上下文中的聚合根“账务凭证”存在的意义就是保证复式记账的平衡。它内部的不可变规则是一张凭证至少包含两行分录。借方总额必须等于贷方总额。凭证一经验证通过不可更改只能做红字冲销。这三条规则就是“业务即代码”的精华——它们不是代码风格不是性能优化是让业务能够自洽运行的底线。3.2 值对象与实体为什么“银两”不应该是一个整数再往细节走一层。我们常用最简单的int或BigDecimal表示金额这在很多系统里已经足够但遇到复杂业务就会出问题。在商帮场景里“银两”看似是一个数值可不同分号的银两标准并不一样。有足色纹银、有库平银、有漕平银兑换比率随行就市。如果把“银两”直接当作一个数字存进数据库你无法表达“这批货款以九八色纹银计折合足银980两”。这正是DDD里值对象的用武之地。值对象不是一个可以被随意赋值的标量而是一个带单位、带精度、带换算规则的结构体。它存在的理由是在业务规则里“50两库平银”和“50两漕平银”并不是同一个值。这里的实操建议是凡是涉及金额、重量、长度等计量概念不要裸用一个数字类型至少要封装成一个值对象。哪怕一开始只是包一层皮、内部只有一个BigDecimal也值得做。因为当业务说要增加汇率换算时你只需要在值对象内部加一个方法而不是去所有用到金额的地方改代码。这看起来是一个很细的点但说实话很多系统翻车就翻在这种地方看似是数字问题本质上是“单位与口径”问题。商帮账本崩塌的400两差额很可能就是两个分号使用了不同的银两成色折算标准记账时没有显式标注对账时算法不同最后账目差值成了悬案。3.3 领域服务商帮里的“票号汇兑”到底谁来处理有一些业务逻辑不属于任何一个聚合根但它确实属于领域的一部分。最典型的就是票据贴现、汇兑结算、跨分号之间的内部清算。这属于账房整个“核心域”里的关键能力但它不属于某一个具体账务凭证而是跨多个凭证的操作。在DDD里这类逻辑应该被建模成领域服务。注意领域服务不是那种“天下武功出少林”的Util工具类它必须满足一个条件它表达的动作是领域专家嘴里会说的那种话。商帮的账房先生会说“把西北分号的盈余调到江南总号平衡两地的银根。”这句话就是一个领域服务——调拨银两。实现上它需要做两件事在西北分号记录一笔调出借方减少现金贷方增加内部往来款。在江南总号记录一笔调入借方增加内部往来款贷方减少现金。这笔操作必须跨两个聚合根完成但又不应该由其中一个聚合根“代表”对方记账所以需要有一个领域服务来组织和协调整个过程。这个服务没有状态它的职责就是“协调”执行的还是不变量调出与调入的分录必须同时入账。在现代系统里这个场景对应的是跨库分布式事务或Saga模式。古代商帮的解法是一封调拨信函总号与分号各自记账在途期间双方账上都有一笔“在途资金”。这个“在途资金”概念放在现代系统里就是“分布式事务未完成时的中间状态”而事件驱动架构里这叫**“待确认事件”**。领域服务的引入有一个前提**避免让一个聚合根承担不该承担的职责。**如果强行让西北分号的账务凭证聚合根去“调用”江南总号的记账逻辑跨上下文的耦合就会冒出来时间长了扩展性必然受损。4. 事件驱动的复盘把“账本崩塌”变成可追溯的历史4.1 领域事件不是通知是“不可抹灭的事实”故事里的账本为什么难查因为没有事件记录只有结果记录。账房看到的是最终余额却看不到“每一步是怎么变成这样的”。如果一个系统只有快照当前余额而没有事件流每一笔变更那它就不可能回答一个问题账目到底在哪个环节开始不对的引入领域事件之后情况彻底不同。领域事件记录的不是数据变更而是“已经发生了的业务事实”。商帮的对应物就是流水账的凭据联。每笔交易都要有四联单存根、报账、回执、稽核。每一联记录同样的事实流向不同的上下文。在现代DDD实践里领域事件的命名必须用“过去时”OrderPlaced、PaymentConfirmed、GoodsShipped。再强调一遍领域事件的核心价值不是“通知别人做事情”而是“把一件事情已经发生了的事实告诉所有关心它的上下文”。通知只是它对下游的一个副作用它真正的身份是“业务记录”。你把它持久化到事件存储里就拥有了完整的历史。从商帮的角度这意味着账本不再只是余额表而是一叠完整的凭据档案。哪怕某个分号的账本被火烧了只要凭据联还在就能完整还原账目。很多企业现在的审计系统调用的就是这套逻辑。账本崩塌后能不能复原取决于你有没有留下事件日志。4.2 崩溃账本的正确修复姿势不靠记忆靠重放假设商帮的账本已经崩了如何修复如果你足够细心你会发现“修复”这个词本身就是个圈套。一个真正的审计流程里不该有“修复”只该有“重放”。因为账目不是一个可以随时修改的数字而是一串事实的累积结果。修复一个崩溃账本的正确步骤是冻结当前账本停止新的记账动作防止问题扩大。回溯事件流从上一期已经审计平的余额开始逐条重放每一笔领域事件。重放过程中校验每一步的不变量找到第一笔导致不平衡的操作。针对出错的业务规则打补丁而不是手动改正数字。要么是补做一笔红字冲销要么是把导致错误的流程本身改掉。继续重放剩余的事件直到期末。输出新的余额并在页面上盖章确认作为新的基准。这个过程中最反直觉的一点是**你永远不应该直接“纠正”一个错误数字你只能追加一条修正事件。**因为错误也是历史事实的一部分。删掉一个坏账会让你失去“为什么会坏账”的证据但保留坏账、追加一条冲正记录整个流水线仍然是完整可审计的。在代码层面这个思想对应的是不可变事件快照。性能上你可以定期做快照比如每天一个余额快照但每次状态计算都是“从最近快照重放增量事件”得到的。崩溃之后做追溯不用从上个世纪的第一笔账重放到今天只需要找到最后一个可信快照把之后的事件重放一遍即可。这就是为什么“业务即代码”的落地离不开事件溯源代码可以升级模型可以演进但事件一经写入就不可变。不可变才是审计能做到的唯一基础。4.3 用“事件风暴”复盘崩溃的账本如果你是一个团队负责人刚接手这个“账本已崩”的商帮系统怎么办我不会让你先去写代码我会建议你召集所有角色相关人员开一场“事件风暴”工作坊。把总号掌柜、分号账房、跑街销售、押运镖头全叫到一张大桌子上让他们把日常业务中发生过的事情逐条写到便签纸上。重点写“发生了什么事”比如“客户下了订单”“货物从仓库发出”“镖局在途中损毁货物”“分号收到了货款”“总号向分号拨付了银子”。全部写完之后把所有便签按时间顺序铺在墙上这就是一张原始事件流。接着让大家把关注点相同的便签归类圈成一个个“聚合”。你会发现讨论到后面就开始吵了——分号说“收到货款”和“发出货物”是一件事总号说它们必须分开记账。恭喜你你已经找到了限界上下文的边界。事件风暴最大的收获不是那张满墙的便签纸而是让每个角色都不得不把自己脑子里默认的规则说出来摆到台面上接受检验。古代账房先生靠记忆管理的那些“潜规则”被显式地结构化了——这本身就是DDD最朴素的落地方案不做代码先做共识。5. 避坑手册传统账本穿越成现代系统的五个细节5.1 不要一开始就做大而全的“统一账本”我看到很多团队接到这类需求第一反应是建一个“万能账本表”把所有分号的账目都塞进一张大表然后靠各种 type 字段去区分。结果就是这张表什么都能存什么都说不清。正确的做法是先按限界上下文拆小模型。销售上下文有自己的订单表履约上下文有自己的发货表财务上下文有自己独立的凭证表。表与表之间不直接共享外键通过领域事件异步同步。宁可前期多写几个类、多建几张表也不要把模型混在一起图省事。5.2 不要在“聚合内”贪多一次交易一个聚合一个常见的错误是为了省事把“一笔订单连带所有商品条目连带支付记录”塞进同一个聚合。这样做事务简单了但并发性能烂了而且业务上把“下单”和“支付”强行绑定不符合实际流程——客户可以先下单、后支付、再发货每个环节的规则都不相同。聚合的边界口诀是**一个聚合只保证一条业务不变量贪多必失。**一个商帮的账务凭证聚合只保证“借贷平衡”这一件事就够了别把“库存是否足够”也塞进来——那是仓储上下文的事。5.3 领域事件一定先持久化再发布事件驱动有个魔鬼细节事件是先存库还是先发消息如果先发消息后存库消息发出去了但库写失败了下游已经响应系统就出现幻觉事件。如果先存库后发消息存库成功但发送失败就需要重发机制兜底。实操建议很明确先持久化事件再异步发布发布失败要有本地重试表。在古代商帮这也是同样的道理。总号收到分号的账册后账房会先登记收到日期并归档然后再对照复核。归档在前、复核在后——万一复核中途出了意外凭据也已经留下了。连古人都在后半段做了幂等保障现在的代码更没理由跳过。5.4 不要用数据库事务模拟聚合间的“强一致”总号和分号的账目同步在现代实现中往往需要跨库操作。有人试图用分布式事务强行保证两边强一致结果锁表、死锁、性能下降。实际上从商帮的经验来看跨上下文的一致性本来就是最终一致性的。总号寄出调拨函后不需要立刻看到分号的账它只需要知道“这笔调拨在途”等分号回执到达后两边的账自然平了。对应到现代系统为每个跨上下文的操作设计一个“在途状态”不追求一股脑的全链路强一致而是用Saga或者事件溯源推进流程。这是“业务即代码”的深层含义之一——技术在响应业务的真实节律而不是反过来让业务迁就技术的一致性模型。5.5 领域术语表甚至比代码更早建立最后一条也是很多团队最容易忽视的一条。动手写任何代码之前先建立一个“领域术语表”把所有关键术语的定义和边界写清楚。比如术语定义属于哪个上下文货款客户因购买商品而应支付的款项销售上下文发货单由总号确认、作为运输依据的凭证履约上下文收讫分号确认收到现金在凭证中登记的动作财务上下文在途资金从一个分号向另一个分号调拨但尚未到达的款项财务上下文这份术语表就是团队内统一的“共同语言”。聊业务的时候用术语表写代码的时候用术语表测试用例的断言也用术语表。术语统一了大部分需求歧义已经消灭了一半。6. 一些不那么“技术”的实操心得说了这么多我想在结尾部分分享几点我在项目实战中的体会这些是最难在书本里学到的东西。第一点**DDD不是银弹它会拷问管理层的业务洞察力。**如果你所在的组织压根说不清“货款”的定义画多少张聚合图都没用。DDD落地之前必须先有业务决策者愿意把规则用白纸黑字写出来并接受规则的迭代。技术可以帮我们把规则落到代码里但规则本身必须来自业务高手。第二点**账本崩塌的修复本质上是为“数据事故”做复盘而不是为“代码缺陷”做优化。**你改了一个if分支修掉的只是当前这个Bug你追溯了事件流里的第一笔不平衡修掉的是整套规则里的漏洞。以后遇到生产事故我建议所有团队第一反应不是“哪里写错了”而是“哪个业务规则没有建模”。第三点**“业务即代码”不是把业务部门的PPT翻译成Java/C#而是让代码本身成为业务规则的权威载体。**当业务人员跟你争论某个逻辑时你能把代码指给他看“看规则在这里改它可以但改这里的影响是……”。代码和业务之间没有隔阂这才是“业务即代码”的真正状态。这个“古代商帮穿越系列”我打算继续写下去。第2集会深入讲“票号汇兑与现代事件溯源的异同”第3集讲“商帮内部的品级机制与领域服务的权限建模”。今天就先聊到这里——账本已经冻结事件流正在重放咱们下一集见。
返回列表