如果一个项目标题只有三个字母,最容易想到的是“React 拼到一半”或者某个文件名缩写。但对我这种常年做企业应用和账务建模的人来说,“rea”这三个字母更自然指向一种经典的业务领域建模方式——资源-事件-代理,也就是 REA 模式。它不绑定任何开发框架,只是一套描述业务语义的思维模型,专门用来回答企业里“钱、货、单据、责任”四条线到底是怎么流动的。
我最近拿这个模式做了一个模拟项目:给某制造企业的采购域建立一套从采购申请、供应商发货、仓库验收到财务付款的领域模型。过程里最大的感受是,传统建表思路很容易一上来就讨论字段,而 REA 逼着你先想清楚:什么是有价值的资源?哪些动作是真正发生的事件?参与的人和组织分别是谁?想清楚这三个问题,后面建表、写代码、出报表都顺了。
这篇文章会从 REA 的核心元素开始,再用一个采购流程完整演示建模过程,最后给出代码结构、数据库表设计和一组踩坑清单。适合这几类读者:想用 DDD 但找不到业务语义抓手的开发者,正在做采购、库存、结算这类系统重构的中级工程师,以及需要为多套业务系统制定统一数据标准的架构师。
你先别急着搜代码。任何含糊的项目名背后都有一件事要想清楚:这个项目的核心业务价值到底是什么?REA 模式就是帮你想清楚这件事的。
1. “rea”到底指什么:从三个字母到一套业务框架
如果按字面拆,“rea”可以被还原成“Resource-Event-Agent”的首字母拼接。Resource 是资源,Event 是事件,Agent 是代理。这套模型最早出现在会计信息系统领域,1982 年由一位会计信息系统学者提出,目的是替代那种只按“科目”“凭证”“余额”机械建模的老路子,让系统里的事实能够反映企业真实经济过程。
打个比方你就明白了。传统账本记录的是“账户余额”,比如库存 100 件、现金余额 5000 元。但余额是怎么变成 100 件和 5000 元的?中间经历了哪些交易、谁经手的、对应哪张单据?传统模型不一定能回答。REA 则更像流水账,它要求把每一笔“事件”完整记录:某年某月某日入库 50 件原料,某年某月某日支付 5000 元货款。余额不是直接保存的字段,而是把事件流水相加得出的结果。这样一来,审计、追溯、对账全都变得可靠。
我选择用采购域做演示,是因为采购流程刚好包含了 REA 最典型的要素:原料入库是一个资源流入事件,付款是一个资源流出事件,采购员和供应商是代理,采购订单和收货单则是事件发生时的记录凭证。但要注意,单据本身不是资源,单据所代表的物料和资金才是资源。这个区分很多人一开始会搞混。
那么 REA 能解决什么问题?最核心的是跨部门数据一致性。制造业里,仓库、采购、财务各自的系统往往口径不统一,仓库说“到货了”,财务说“还没付款”,业务说“订单都下出去了”。大家都有数据,但串不到一起。REA 把大家关注的事实压成同一套原语,之后无论出库存报表、应付报表还是利润分析,都从同一套事件出发,口径自然一致。
我这里说句实在话:REA 不是银弹,小型 CRUD 系统并不需要这么重的建模。但凡是涉及对账、审计、跨系统协调的业务域,它几乎是最值得先试的建模工具。
2. REA 建模的三块基础积木:资源、事件、代理
2.1 资源:有经济价值的“存量”
资源就像一个容器里的东西,会被业务过程增加或减少。常见资源包括原材料、产成品、现金、应收账款、固定资产、无形资产。判断一个实体是不是资源的简单标准有两个:第一,它能用数量或金额计量;第二,业务过程中存在让它的数量发生增减的事件。
采购域里,原料库存是资源,现金账户余额是资源,供应商开给我们的发票对应的应付义务也是资源。采购订单号、合同编号这些字段不是资源,它们只是业务单据的唯一标识。订单上的“承诺”如果要对未来库存计划产生影响,可以建模成承诺型资源,但这点通常只在大型供应链系统里才需要深入设计。
这里最容易犯的错,是把“属性”当成“资源”。比如一台设备的状态是“运行中”,状态本身不是资源,设备才是资源。设备价值会因为“采购”“折旧”“处置”这些事件而增减,运行状态只是设备资源上的一段属性,不应被单独建成资源表。
2.2 事件:资源在时间轴上发生的“动作”
事件是 REA 最有价值的部分。它代表一次具体的资源流入或流出,必须带有发生时间、数量或金额,并且不能再向下拆分。比如“采购到货”是一个经济事件,“财务付款”是另一个经济事件。事件记录的是增量变化,不是某个时刻的存量结果。
事件分为两类。第一类是经济事件,也就是实际发生的资源增减,比如入库、出库、收款、付款、退货。第二类是承诺事件,代表未来可能会发生的资源增减,比如下达采购订单、接受销售订单。承诺事件不一定最终履约,但会影响之后的计划和分析。
我见过不少团队把事件表做成“操作日志”,往里面塞“用户点了一下保存”这种无业务语义的动作。这是典型的过度记录。REA 的事件必须能回答“资源发生了怎样的变化”,而不是“用户在界面上做了什么”。界面动作可以记审计日志,但那是另一条技术线,不能混进领域事件流。
2.3 代理:谁发起了事件,谁来负责
代理是事件的参与方,分为内部代理和外部代理。内部代理通常是企业内的某个角色,比如采购员、仓管员、财务审核人、部门;外部代理是交易对手,比如供应商、客户、物流承运公司。代理建模的核心不是简单的“操作人”字段,而是独立的代理实体,包含名称、编号、所属组织、角色类型。
经济事件至少要关联两类代理:内部代理回答“企业里谁负责这件事”,外部代理回答“和哪个外部组织发生关系”。例如“仓库收货”事件,内部代理是仓管员,外部代理是供应商。如果把外部代理漏掉了,日后对账就只能靠单据上的对手方名称硬匹配,字段一改就对不上。
代理表可以做得相对简单,但不要把它并进 event 表。独立成表意味着代理本身有主数据,可以被多个事件引用,也方便后面做权限控制和组织维度汇总。
2.4 关系:流入流出、二元性和责任链
三块积木之间靠关系串起来:
- 资源与事件之间是“流入/流出”关系,一个事件会让一个或多个资源增加或减少。
- 事件与代理之间是“执行/参与”关系,记录谁参与了这次变化。
- 两个经济事件之间应该成对出现,一方是资源流出,另一方是资源流入,构成“二元性”。典型例子是“销售出库”对应“收款”,“采购入库”对应“付款”。
二元性这条最容易被忽略,但它恰恰是 REA 能对账的根本原因。假设采购入库事件让原材料增加 100 件,价值 8500 元;付款事件让现金减少 8500 元。两个事件如果不配对,报表只能分别看到“入库 100 件”和“付款 8500 元”,无法证明这批货和这笔钱是同一笔交易。加了二元性关联之后,就能完整描述“一手交货、一手付钱”的价值闭环。
| 元素 | 通俗叫法 | 典型例子 | 判断方向 |
|---|---|---|---|
| 资源 | 变化的货或钱 | 原材料、库存现金、应收/应付 | 有唯一身份,能量化价值和数量 |
| 事件 | 发生的动作 | 到货、出库、付款、收款 | 有发生时间,有数量金额,不可再拆 |
| 代理 | 做事的人或组织 | 采购员、仓管、供应商、客户 | 参与事件并承担对应责任 |
| 关系 | 连接逻辑 | 入库事件与付款事件配对 | 能回答谁在何时改变了哪项资源 |
很多传统模型就直接存“当前状态字段”,例如在订单表里存一个“status = 已付款”。REA 不这么干,它会把“付款事件”单独落下来,状态只是付款事件发生后派生出的查询结果。这个思路很像事件溯源,只是一般事件溯源更多是从软件架构角度讲,REA 则是从业务语义角度讲,两者天然互补。
3. 实操篇:把制造企业的采购-付款流程改造成 REA 模型
3.1 先框定业务范围
我用一个模拟的制造企业采购流程来演示。流程大致是:生产部提出原材料需求,采购员向供应商下采购订单,供应商发货后仓库验收入库,财务根据发票安排付款。这是典型的“产、供、销”里供应一环,涉及库存资源、现金资源和供应商代理。
动手前先用一页纸把业务主干画出来,不画关系图,只画时间线:
- 采购申请被批准;
- 采购员创建采购订单;
- 供应商发货,仓库收货并入库;
- 财务核对供应商发票;
- 财务完成付款。
这条时间线里,真正值得作为 REA 事件的是 2、3、4、5。采购申请只是内部审批动作,不直接改变资源价值,一般可以当作前置流程处理,不进入核心 REA 事件流。如果需要严格跟踪“申请占用预算”,可以把“采购申请批准”当成承诺事件模型化,但大部分项目中它会成为系统工作流的一部分,而非领域事件。
3.2 第一步:拆出事件清单
不要一上来就建表,先写事件。把上面提到的时间线逐条转成事件描述,并标出它改变的是什么资源、资源是流入还是流出。我这里整理了一张表:
| 事件名称 | 事件类型 | 涉及资源 | 资源方向 | 关键代理 |
|---|---|---|---|---|
| 下达采购订单 | 承诺事件 | 对原材料 B 的采购承诺 | 承诺增加 | 采购员、供应商 |
| 仓库收货 | 经济事件 | 原材料 B 库存 | 流入 | 仓管员、供应商 |
| 发票核对确认 | 经济事件 | 应付义务 | 流入 | 财务审核人、供应商 |
| 支付货款 | 经济事件 | 现金 | 流出 | 出纳、供应商 |
这张表列完之后,你就有了第一版领域事件清单。事件清单的质量直接决定后续所有建模,所以要反复问一句:“把它拆成这样,业务人员能看懂吗?”如果业务人员说“我们还有质量检验环节”,你就要决定是否把“质检合格入库”作为一个独立经济事件。通常可以拆成“到货待检”和“检验合格入库”两个事件,但多数简化模型会把质检放进入库事件的属性里。
3.3 第二步:确认资源和代理
基于事件清单,资源表可以初步定为:
- 原材料 B:单位、标准成本、状态;
- 现金:按银行账户或资金池拆分;
- 应付义务:用来表达“已收货未付款”的负债。
代理表可以定为:
- 内部代理:采购员、仓管员、财务审核人、出纳;
- 外部代理:供应商、物流公司。
这里要提醒一点:现金不建议只建一个“现金”资源。制造企业通常有多家银行、多币种、多个资金账户,如果现金资源不区分账户,月末资金对账时你会被数据搞疯。至少按账户拆分,报销和付款事件都指向具体账户。
3.4 第三步:建立流入流出连接与二元性
事件和资源之间使用“流入/流出”连接。这种连接我通常叫 flow,里面有事件 ID、资源 ID、方向、数量、金额。比如“仓库收货”事件产生一条 flow,方向是流入,资源是原材料 B,数量是 100 件;“支付货款”事件产生另一条 flow,方向是流出,资源是现金账户,金额是 8500 元。
接着再把两个经济事件用二元性关联起来,形成“货进钱出”的闭环。仓库收货产生的“原材料流入”对应付款产生的“现金流出”,二元性记录两端事件的 ID。这样从任何一端出发,都能找到另一端对应的业务凭证。
这一步做完后,可以做一个自检:把每个资源的流入数量减去流出数量,是否等于你业务上认同的期末存量;把每个非现金资源的流入金额和流出金额配对,是否和资金流水吻合。如果对不上,说明事件清单仍有遗漏,不要急着写表结构。
3.5 对比传统订单表与 REA 模型
最后放一个传统建模和 REA 建模的对照,能帮你把思路彻底扭过来。
| 关注点 | 传统订单表设计 | REA 模型设计 |
|---|---|---|
| 订单状态 | 状态字段直接变更 | 用承诺事件与经济事件推导 |
| 库存数量 | 库存表存当前数量 | 由入库事件减去出库事件得出 |
| 应付余额 | 应付表存当前余额 | 由收货事件金额减去付款事件金额得出 |
| “谁做了操作” | 订单表操作人字段 | 独立的代理实体关联事件 |
| 审计口径 | 覆盖历史字段 | 事件流水永久保留 |
看到差异了吧。传统模型方便的是“快速取当前值”,代价是历史不可回溯、跨系统口径难统一。REA 模型最初写报表麻烦一点,但每张报表的出处都有据可查,这才是企业级系统真正需要的可靠。
4. 从模型到实现:代码结构和数据库落表
4.1 用类型安全代码固定领域规则
建模完成后可以先用代码把领域类型表达出来。这里我用 Java 的 record 类型做示例,只是为了表达语义,不代表只能这么写。关键点是事件里不保存余额,只保存本次变化量。
public record Resource( String id, String code, String name, String unit, BigDecimal unitPrice, boolean active ) {} public enum AgentType { INSIDER, OUTSIDER } public record Agent( String id, String code, String name, AgentType agentType, String orgScope ) {} public enum EventType { COMMITMENT, ECONOMIC } public record Event( String id, EventType type, Instant occurredAt, BigDecimal quantity, BigDecimal amount ) {} public enum FlowDirection { IN, OUT } public record Flow( String id, String eventId, String resourceId, FlowDirection direction, BigDecimal quantity, BigDecimal amount ) {} public record Duality( String id, String decrementEventId, String incrementEventId ) {}这段代码最核心的约束是:resource 和 event 通过 flow 产生关系,event 自身不维护“变化后的余额”。你可能会想在 Event 里加一个 resourceId 字段,这也可以,但一旦一个事件涉及多个资源,这个字段就会被迫冗余。我更推荐把“事件和资源的关系”单独拆成 flow 表,这样一对多、多对多都自然支持。
4.2 数据库表设计示例
数据库落表时,先建资源、代理、事件、流程、二元性五类核心表。依然用 PostgreSQL 风格示例。
CREATE TABLE resource ( id uuid PRIMARY KEY, code varchar(32) UNIQUE NOT NULL, name varchar(128) NOT NULL, unit varchar(16) NOT NULL, unit_price numeric(18,2) NOT NULL DEFAULT 0, active boolean NOT NULL DEFAULT true ); CREATE TABLE agent ( id uuid PRIMARY KEY, code varchar(32) UNIQUE NOT NULL, name varchar(128) NOT NULL, agent_type varchar(8) NOT NULL CHECK (agent_type IN ('INSIDER','OUTSIDER')), org_scope varchar(64) ); CREATE TABLE event ( id uuid PRIMARY KEY, event_type varchar(16) NOT NULL CHECK (event_type IN ('COMMITMENT','ECONOMIC')), event_time timestamptz NOT NULL, quantity numeric(18,4) NOT NULL DEFAULT 1, amount numeric(18,2) NOT NULL DEFAULT 0 ); CREATE TABLE flow ( id uuid PRIMARY KEY, event_id uuid NOT NULL REFERENCES event(id), resource_id uuid NOT NULL REFERENCES resource(id), direction varchar(8) NOT NULL CHECK (direction IN ('IN','OUT')), quantity numeric(18,4) NOT NULL DEFAULT 1, amount numeric(18,2) NOT NULL DEFAULT 0 ); CREATE TABLE duality ( id uuid PRIMARY KEY, decrement_event_id uuid NOT NULL REFERENCES event(id), increment_event_id uuid NOT NULL REFERENCES event(id) );事件和代理的关系可以用中间表表达:
CREATE TABLE event_agent ( event_id uuid NOT NULL REFERENCES event(id), agent_id uuid NOT NULL REFERENCES agent(id), role varchar(32) NOT NULL, PRIMARY KEY (event_id, agent_id, role) );写到这里特别强调一个投产经验:初期不要密集建外键约束。历史数据导入时,事件顺序不一定整齐,早期的大堆外键会让你不得不反复禁约束再重建。我的习惯是先建主键和非空约束跑通流程,等数据迁移稳定后再在核心关联列上补外键。用“先松后紧”的方式上线,比一开始追求完美约束能少踩很多坑。
4.3 从 REA 模型反推库存、应付和利润
REA 模型里的所有查询都围绕聚合事件展开。比如要查某个资源的当前库存,就把所有经济事件中该资源的流入量汇总,再减去流出量。
SELECT r.code AS resource_code, r.name AS resource_name, COALESCE(SUM(CASE WHEN f.direction = 'IN' THEN f.quantity ELSE 0 END), 0) AS total_in, COALESCE(SUM(CASE WHEN f.direction = 'OUT' THEN f.quantity ELSE 0 END), 0) AS total_out, COALESCE(SUM(CASE WHEN f.direction = 'IN' THEN f.quantity ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN f.direction = 'OUT' THEN f.quantity ELSE 0 END), 0) AS stock_qty FROM flow f JOIN resource r ON r.id = f.resource_id JOIN event e ON e.id = f.event_id WHERE e.event_type = 'ECONOMIC' GROUP BY r.code, r.name;这段 SQL 的思想是:库存不是一个存储字段,而是一个投影结果。同理,想要得到一段时间内的应付余额,就用收货事件的总金额减去付款事件的总金额;想要算毛利,就用销售出库资源金额减去对应采购资源金额。所有分析口径都从事件和 flow 推出来,这样各个报表之间不会出现“库存对不上、利润算错”的经典尴尬。
有一点必须提醒:事件表里的 amount 要明确币种口径。如果涉及多币种,至少要同时保留“原始币种金额”和“本币金额”两列,否则历史汇率一变动,回溯报表就会全部失真。不要偷懒只存一个本币金额,因为你永远无法判断历史上那个汇率是怎么折算的。
5. 真正落地时最容易踩的五个坑
5.1 把“状态”当成“事件”或“资源”来设计
最典型的问题是在资源表里直接存“库存余额”字段,或者用状态字段覆盖历史。这样做的好处是查询简单,坏处是历史被抹掉、并发更新锁容易兜不住。REA 的做法是永远保存事件增量,余额只是聚合计算的结果。
如果系统里已经有大量状态表,可以从分钟级快照开始迁移。先把当前状态作为基准快照,再向前补做一段事件流水,将今天的余额与事件流投影的结果对齐,差异逐笔排查。别试图从五年前开始回放,风险和工作量都失控。
5.2 忘记建立二元性,事件之间是孤岛
只记录“货出库了”“钱收到了”,但两条事件之间没有任何关联,月末对账时就得靠代码来回匹配。最初建表时,duality 表和 flow 表同等重要。如果实在不想加表,也一定要在 event 表里增加一个 linkage_key,把出库事件与收款事件挂在一起,保证链路可追踪。
5.3 没有区分承诺事件与经济事件
很多项目把订单状态简单存成字符串,状态一改就把之前的业务动作覆盖了。更合理的方式是把订单下达视为承诺事件,把到货和付款视为经济事件。承诺事件可以被修改、取消,经济事件一旦发生后就不应被直接修改,只能通过反向事件冲销。这个规则一旦在代码层落地,业务上的“追单”“退货”“冲销”就都有了对应的标准动作。
5.4 代理建模太薄弱,追责只能看日志
事件表加一个“操作人”字段,并不等于代理建模。代理是独立的主数据,拥有组织归属和角色信息。特别是外部代理,如果只是存一个字符串名称,供应商改名或重复时就会对不上账。把内部代理和外部代理都建成独立表,并为每次事件记录参与角色,责任链路才完整。
5.5 复杂度失控:什么都想装进 REA
REA 很适合描述价值链,但没必要把通知记录、页面点击、日志系统都装进来。如果一个域里没有“资源增减”,强行套 REA 只会得到一张张无意义的抽象表。建模时先划定事件边界:真正改变资源数量或金额的动作才进 REA,其他行为仍按其原本的技术或业务逻辑建模。
下面这张速查表,适合写设计文档时直接贴进附录。
| 现象 | 可能原因 | 对应做法 |
|---|---|---|
| 当月出库单找不到对应收款 | 未建 duality | 增加事件配对或 linkage_key |
| 库存与业务单据反复对不上 | 资源表冗余了余额字段 | 余额改为由事件聚合查询 |
| 审计追溯不到经办人 | 代理模型缺失 | 独立 agent 表并区分内外部 |
| 状态机越写越复杂 | 承诺事件被当状态字段 | 分离承诺事件与经济事件 |
| 数据模型没人敢改 | 外键约束过早过密 | 先建主键,后补约束 |
| 多币种报表金额错乱 | 只存了本币金额 | 保留原始币种与本币双口径 |
6. REA 的适用圈:什么项目适合,什么项目别硬套
不是所有项目都需要 REA。简单的内容管理、设备台账、站内通知这类模型,用普通表结构反而更高效。因为这类系统没有复杂的资源流动,也没有对账和审计需求,强行用事件流水建全套结构,只会让本来一小时能开发完的功能变成三天。
但以下情况,REA 几乎是首选:
- 核心会计与结算系统,需要记账和对账同时完成;
- 多套异构系统之间需要统一业务语义,REA 可以作为公共语义层;
- 供应链计划涉及订单承诺和实际履约,需要同时管理计划和事实;
- 大型组织做数字化转型,管理层要求业务全程可追溯。
在这些场景里,REA 的价值不是“用几个新名词”,而是把散落在各处的业务事实变成统一事件流。你可以先只做一个域,比如“采购到付款”或“订单到回款”,跑通后再逐步扩展到其他域。
从 REA 可以很自然地延伸出两条深水区路线。一条是事件溯源:既然业务事实已经表达为事件流,就让事件流作为系统真相源,数据库里的表和投影都从事件流派生。另一条是 DDD 落地:REA 可以直接映射到领域模型里的聚合根、领域事件和值对象,减少业务概念到代码概念的转译成本。
我在几个模拟项目里反复试过一种做法,先不写代码,拿一张白纸把资源、事件、代理之间的箭头画出来。画到能跟业务人员清晰解释“每一次付款对应哪一次收货”的时候,再往数据库模型转。这个习惯帮我避免了至少两次大规模返工。很多项目死掉不是因为技术不行,而是前期没有从业务事实上对齐口径。
所以回到标题本身,“rea”三个字母很短,但它背后代表的问题很长:你的企业里,哪些资源在流动?哪些事件改变了它们的价值?又有哪些代理该为这些变化负责?把这三个问题回答清楚,比任何代码框架都更能决定项目上限。