番摊机器人 软件不是从数据开始,而是从现实开始 | KDC 系列 01


开篇:一个被颠倒的顺序


大多数软件项目的启动会议是这样的:产品经理拿出一份需求文档,技术负责人开始画 ER 图,数据库表结构在第一周就定下来了。字段名、外键、索引、范式——一切围绕「数据该怎么存」展开。


然后,业务逻辑被塞进数据的缝隙里。订单状态用一个 int 字段编码,配送流程靠状态机表驱动,用户权限用角色-权限映射表表达。软件的骨架是表结构,业务只是挂在骨架上的肉。


这个顺序是反的。


软件的本质不是对数据的增删改查,而是对现实世界中某个问题的数字化回应。数据只是这个回应留下的痕迹。先有现实中的业务流程、领域规则、边界约束,然后才有对这些东西的建模,最后才有数据存储方案。


把数据放在第一步,等于在还没理解问题之前就锁死了答案的形状。


---

一、现实优先:软件的真正起点

1.1 软件在回应什么


每一个软件系统都在回应一个现实世界中的具体困境:

电商系统回应的是「商品如何从生产者到达消费者手中」这一供应链现实

医院信息系统回应的是「患者从就诊到康复的全流程协作」这一医疗现实

飞行控制系统回应的是「如何让一架物理飞机在空气动力学约束下安全飞行」这一工程现实


这些现实有自己的结构:实体之间的关系、流程的先后顺序、规则的约束条件、异常的发生路径。软件的第一步工作,是去理解这个结构,而不是去设计数据库。

1.2 现实 ≠ 数据


一个常见的误解是「现实就是数据,数据就是现实的数字化」。


但现实远比数据丰富。考虑一个简单的例子——订单取消:


| 维度 | 数据视角 | 现实视角 |

|------|---------|---------|

| 取消是什么 | status 字段从 paid 改为 cancelled | 一个已经发生的交易行为被撤销,涉及退款、库存回滚、物流拦截、通知触发的完整链条 |

| 谁能取消 | user_id 有权限的记录 | 买家在发货前可自助取消;发货后需联系客服;商家缺货可主动取消但需赔偿 |

| 什么时候能取消 | 一个时间戳判断 | 不同商品类目有不同取消窗口(生鲜 1 小时,数码 24 小时),大促期间规则还会临时调整 |

| 取消的后果 | 更新关联表 | 优惠券是否返还?积分是否扣回?推荐算法的画像是否更新?这些是业务决策,不是外键约束 |


数据视角看到的是一个字段变更,现实视角看到的是一整套业务规则、时间约束、关联影响和异常处理路径。如果从数据开始设计,你会在 orders 表加一个 status 字段就以为搞定了;从现实开始设计,你会先理清取消的完整业务语义,再决定用什么结构来表达它。

1.3 为什么先设计数据会出问题


先定数据结构,再往里塞业务逻辑,会导致三类典型问题:


问题一:业务规则散落在代码各处,无法追溯


订单取消的规则可能出现在 Controller 的参数校验里、Service 的 if-else 分支里、数据库的 trigger 里、前端的按钮禁用逻辑里。没有一处能看到完整的取消语义。当产品经理问「到底什么情况下能取消订单」时,没有人能给出完整答案。


问题二:数据结构锁死了业务演进


早期把用户地址设计成 users 表的三个字段(address、city、zip),后来业务需要支持多收货地址、地址标签、默认地址切换。因为数据结构先入为主,改造变成了伤筋动骨的迁移工程。如果先理解「一个用户可以有多个配送目的地,每个目的地有标签和优先级」这一现实,一开始就会用独立的地址实体。


问题三:隐含约束丢失


现实中,「一个订单只能关联一个有效的支付记录」是一条业务不变量。但如果数据层只是 payments 表有个 order_id 外键,数据库本身并不阻止多条 status=success 的记录指向同一个订单。这条约束要么靠应用层代码保证(容易遗漏),要么靠唯一索引(但语义不清晰)。从现实出发的设计会显式识别这条不变量,并在架构层面保护它。


---

二、从现实到软件:正确的建模路径

2.1 先理解领域,再动手


软件设计的第一步不是打开 IDE,而是走进业务现场。


对于电商系统,这意味着:去看仓库怎么打包,去听客服怎么处理退款,去跟运营聊大促期间的价格策略怎么变。对于医疗系统,这意味着:去门诊观察医生怎么开处方,去药房看发药流程,去病案室理解病历归档的规则。


这些观察回答的核心问题是:

这个领域里,有哪些实体?它们之间是什么关系?有哪些流程?流程中有哪些规则和约束?哪些事情不能同时发生?哪些状态转换是合法的?


这就是 领域驱动设计(DDD) 中「通用语言」和「领域模型」的来源——不是从数据库反推出来的,而是从对现实的深入理解中提炼出来的。

2.2 一个建模的实例


以「外卖配送」为例,展示从现实到软件的正确路径。


第一步:观察现实


骑手接到一个订单后,去商家取餐,再送到用户地址。这个过程中:

骑手同一时间只能配送有限数量的订单(通常 3~5 单)

订单有取餐时间窗口和送达时间窗口

骑手的位置在持续变化

如果商家出餐慢,后续订单的送达时间会受影响

用户可以取消,但骑手已经取餐后取消需要赔偿


第二步:识别领域概念


实体(Entity):

骑手(Rider):有位置、运力上限、当前负载

订单(Order):有取餐点、送达点、时间窗口、状态

配送任务(DeliveryTask):骑手与一组订单的绑定关系,含路线


值对象(Value Object):

位置(Location):经纬度

时间窗口(TimeWindow):开始时间 + 结束时间

路线(Route):有序的位置序列


领域事件(Domain Event):

骑手接单(RiderAssigned)

到达商家(ArrivedAtMerchant)

取餐完成(PickedUp)

送达完成(Delivered)

订单超时(OrderTimedOut)


业务规则(Invariant):

一个骑手的活跃订单数 ≤ 运力上限

订单状态只能沿合法路径流转:待分配→已接单→取餐中→配送中→已送达

已取餐的订单取消时,必须触发赔偿流程


第三步:设计领域模型


class DeliveryTask:

 """配送任务:一个骑手在一段时间内负责的一组订单"""


 def init(self, rider: Rider, orders: list[Order]):

 self.ensurecapacity(rider, orders) # 不变量:不超过运力上限

 self.rider = rider

 self.orders = self.sortby_route(orders) # 按路线排序

 self.status = TaskStatus.ASSIGNED


 def markpickedup(self, order_id: str):

 """标记某订单取餐完成"""

 order = self.findorder(order_id)

 self.ensurevalidtransition(order.status, OrderStatus.PICKEDUP)

 order.status = OrderStatus.PICKED_UP

 DomainEvents.publish(OrderPickedUp(order_id, datetime.now()))


 def cancelorder(self, orderid: str):

 """取消订单——已取餐需触发赔偿"""

 order = self.findorder(order_id)

 if order.status >= OrderStatus.PICKED_UP:

 self.triggercompensation(order) # 业务规则:已取餐取消需赔偿

 order.status = OrderStatus.CANCELLED

 DomainEvents.publish(OrderCancelled(orderid, reason="usercancel"))


注意:这里没有一行 SQL,没有一张表。模型表达的是业务语义,不是存储结构。 取消的赔偿规则、状态转换的合法性、运力上限的约束,都显式地存在于模型中,而不是散落在 Service 层的 if-else 里。


第四步:才是数据持久化


CREATE TABLE delivery_tasks (

 id VARCHAR(36) PRIMARY KEY,

 rider_id VARCHAR(36) NOT NULL,

 status VARCHAR(20) NOT NULL,

 createdat TIMESTAMP DEFAULT CURRENTTIMESTAMP,

 CHECK (status IN ('ASSIGNED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'))

);


CREATE TABLE task_orders (

 task_id VARCHAR(36) NOT NULL,

 order_id VARCHAR(36) NOT NULL,

 sequence INT NOT NULL, -- 路线顺序

 status VARCHAR(20) NOT NULL,

 PRIMARY KEY (taskid, orderid),

 FOREIGN KEY (taskid) REFERENCES deliverytasks(id)

);


-- 不变量保护:一个骑手的活跃任务数有上限

-- 这条约束在应用层用 DeliveryTask 聚合保证,

-- 数据库层用唯一索引防止重复分配

CREATE UNIQUE INDEX idxactivetaskperrider

 ON taskorders(riderid)

 WHERE status = 'ASSIGNED';


数据层是模型的投影,不是模型的源头。表结构是从领域模型推导出来的,而不是反过来。


---

三、数据驱动思维的陷阱

3.1 「数据是资产」的误导


「数据是新时代的石油」这句话被过度引用后,产生了一个副作用:让人以为软件的核心价值在于积累数据,而非解决现实问题。


于是出现了大量「先建表、后想业务」的系统:用户注册表先建好,但注册后用户要做什么没人想清楚;日志表先建好,但日志要回答什么业务问题没人定义。数据在堆积,但系统对现实的回应能力并没有增强。


真正有价值的不是数据本身,而是数据所承载的、对现实的准确建模。 同样是订单数据,一个精确表达了取消规则、配送约束、状态流转的系统,和一个只有 status 字段的系统,数据量可能一样,但业务价值天差地别。

3.2 CRUD 思维的局限


数据驱动思维天然导向 CRUD(Create / Read / Update / Delete)设计:每个实体对应一组增删改查接口。但现实中的业务操作很少是简单的 CRUD。


以「银行转账」为例:

CRUD 思维:UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2;——两个 UPDATE 搞定

现实思维:转账是一个完整的业务事务,包含:扣款、入账、记录流水、风控检查、到账通知。它不是对 account 表的修改,而是一个 Transfer 领域操作的执行。金额、时间、对手方、渠道、状态构成了一个转账记录实体,有自己的生命周期


CRUD 思维把丰富的业务操作压扁成了表字段的修改,丢失了操作的语义、约束和历史轨迹。

3.3 报表驱动设计的陷阱


另一个常见模式是:先想好老板要看什么报表,反推出需要哪些数据字段,再建表。


这比纯粹的 CRUD 好一点——至少它从需求出发。但问题是:报表描述的是结果,不是过程。 如果只为报表设计数据结构,你得到的系统只能产出数字,无法支撑业务流程的运转和演进。


报表说「上月退款率 15%」,但系统无法回答「为什么这个订单被退款了」「退款流程走了哪条路径」「骑手取餐后取消和送达前取消各占多少」。因为这些业务语义在设计时就被压扁成了一个 refund_count 字段。


---

四、实践方法:如何从现实出发

4.1 事件风暴(Event Storming)


一种高效的领域探索方法:把业务专家和开发拉到一面白板前,用便利贴把领域事件(「发生了什么」)按时间线贴出来。


[用户下单] → [支付成功] → [商家接单] → [商家出餐] → [骑手取餐] → [骑手送达] → [用户确认收货]

 ↓

 [用户取消] → [退款处理]


事件风暴的产出不是数据库表,而是对业务流程的共享理解。从这个理解出发,再识别实体、规则和约束,最后才推导数据结构。

4.2 用领域语言写代码


代码应该用业务术语说话,而不是用技术术语。

❌ 数据驱动写法:看不懂在做什么

def updateorderstatus(orderid, statuscode):

 db.execute("UPDATE orders SET status = ? WHERE id = ?", [statuscode, orderid])


updateorderstatus("12345", 5) # 5 是什么意思?

✅ 现实驱动写法:业务语义清晰

def cancelorder(orderid: OrderId, reason: CancelReason) -> CancelResult:

 order = orderrepo.find(orderid)

 if order.ispickablefor_cancel():

 order.cancel(reason)

 if order.waspickedup():

 compensation_service.trigger(order)

 order_repo.save(order)

 notificationservice.notifyuser(order)

 return CancelResult.SUCCESS

 return CancelResult.NOT_CANCELLABLE


第二种写法中,取消的业务规则(能否取消、已取餐是否赔偿、是否通知用户)全部显式表达在代码里。任何人读这段代码都能理解「订单取消」在现实中意味着什么。

4.3 保护业务不变量


业务不变量是现实中「永远成立」的规则。软件设计要显式识别并保护它们:


| 现实不变量 | 数据层保护 | 模型层保护 |

|-----------|-----------|-----------|

| 一个订单同一时间只有一个有效支付 | 唯一索引 + 状态过滤 | Order 聚合根内控制支付状态转换 |

| 库存不能为负 | CHECK 约束 / 应用层校验 | Inventory 实体的 deduct() 方法抛出异常 |

| 骑手活跃订单不超过运力上限 | 难以在 SQL 层表达 | DeliveryTask 聚合根在构造时校验 |

| 转账必须借贷平衡 | 难以在 SQL 层表达 | Transfer 事务对象内保证双记平衡 |


注意:有些不变量在数据层根本无法表达(如骑手运力上限涉及跨行聚合)。这恰恰说明——数据层不足以承载完整的业务约束,必须由领域模型来保护。


---

五、从现实出发的架构分层


理解了「现实优先」后,系统的架构分层应该是这样的:


┌─────────────────────────────────────────────┐

│ 接口层(Interface) │

│ REST API / GraphQL / gRPC / 消息消费者 │

│ 职责:协议转换,输入校验,结果序列化 │

├─────────────────────────────────────────────┤

│ 应用层(Application) │

│ 用例编排:调用领域服务、事务边界、事件分发 │

│ 职责:编排业务流程,不含业务规则 │

├─────────────────────────────────────────────┤

│ 领域层(Domain) ← 核心层 │

│ 实体、值对象、聚合根、领域服务、领域事件 │

│ 职责:表达业务语义,保护业务不变量 │

│ 特点:不依赖任何框架、不依赖数据库 │

├─────────────────────────────────────────────┤

│ 基础设施层(Infrastructure) │

│ 数据库、消息队列、缓存、外部 API 适配器 │

│ 职责:技术实现细节,为领域层提供持久化与通信能力 │

└─────────────────────────────────────────────┘


关键原则:领域层不依赖基础设施层。 领域模型用纯面向对象的方式表达业务,不知道也不关心数据存在 MySQL 还是 MongoDB、消息走 Kafka 还是 RabbitMQ。基础设施层实现领域层定义的接口(Repository 接口等),是可替换的技术细节。


这就是「依赖倒置」——业务定义接口,技术实现接口,而不是技术框架决定业务怎么写。


---

六、常见质疑与回应

「小项目也需要这么重吗?」


不需要全套 DDD。但「先理解现实再写代码」的原则适用于任何规模。即使是一个 CRUD 后台,花 30 分钟理清「这个实体的状态有哪些、什么条件下能转换、哪些字段是业务不变的」也比直接建表强得多。


小项目的做法可以是轻量的:

用一张纸画出业务流程的关键步骤

列出 3~5 条核心业务规则

确认哪些字段有约束、约束是什么

然后再建表


###「敏捷开发不是应该快速迭代吗?先建表跑起来再说」


敏捷不等于无设计。敏捷的「快速迭代」建立在「每次迭代有清晰目标」的基础上。如果第一版的数据结构完全偏离业务现实,后续每次迭代都在打补丁,迭代速度反而越来越慢。


正确做法:第一版做最小但正确的领域模型——只覆盖核心流程,但模型表达是准确的。后续迭代在正确的骨架上扩展,而不是在错误的骨架上修补。

「AI 时代还需要手动建模吗?」


大模型可以帮你生成代码、生成 SQL、甚至生成领域模型草稿。但判断模型是否正确反映现实这件事,仍然需要人来完成。AI 不知道你的业务有哪些隐含规则、哪些约束是法律要求的、哪些状态转换在特定行业是禁止的。这些知识来自对现实的理解,不是来自训练语料。


AI 是加速器,不是替代品。它加速的是从模型到代码的转换,而不是从现实到模型的理解。


---

结语:回到起点


软件不是从数据开始,不是从框架开始,不是从架构图开始。


软件从对现实的理解开始。


数据是理解的投影,框架是理解的载体,架构是理解的组织方式。当你把现实理解透了——实体是什么、关系是什么、规则是什么、流程是什么——数据结构、接口设计、架构分层都会自然浮现。


反过来,如果跳过对现实的理解直接设计数据,你得到的不是软件,而是一个存了一堆字段却无法回应业务的空壳。


先理解世界,再写代码。


这是 KDC 系列的第一篇。后续文章将深入领域建模、聚合设计、事件驱动架构等具体实践,把「从现实出发」的原则一步步落到工程细节中。


---

KDC(Knowledge-Driven Craft)系列:探讨软件工程中那些被技术潮流淹没、但始终重要的根本原则——知识先于代码,理解先于实现。