番摊机器人 软件不是从数据开始,而是从现实开始 | 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)系列:探讨软件工程中那些被技术潮流淹没、但始终重要的根本原则——知识先于代码,理解先于实现。