先设计模型,再写代码。

数据模型通常从 ORM 开始。但 ORM 描述的不是业务领域,而是建立在它之上的数据库;而对 NoSQL 来说,实体模型根本就不存在。AOA 按照业务领域本来的样子描述它——对 SQL 和 NoSQL 一视同仁。

数据库是为可靠存储和快速读取而建的,不是为了让人理解它:先规范化,再为性能反规范化——业务领域被扭曲了两次,方向还相反。而在这里,领域是按步骤搭建的:先是领域地图,然后是填充它的实体、实体之间的关系,以及每个实体的 Lifecycle。这一切最终被打包进 Resource——真实的数据库通过它被读入模型。

文档 ↗

领域地图

认识一套新系统,总是从一个问题开始:它是为了什么,它做什么。这是关于意义的问题,而不是关于构造的问题。但最先得到的答案往往是基础设施:控制器、服务、仓储、迁移脚本。业务领域藏在它们背后,看不见。

领域地图就是对第一个问题的回答。上面只有系统由哪些领域构成:StoreDomain——订单管理,BillingDomain——计费,MessagingDomain——消息推送。这样一个没有细节的回答覆盖了整个系统,又装得进脑子里——它是第一个支点。

领域地图
完整文档 ↗

实体表达领域含义

领域里主要有两样东西:Entity——订单、客户、订单行——以及作用在它们之上的具名操作 Action。没有控制器,没有表,也没有其他基础设施。这让人可以再往下走一层,而注意力仍然停留在系统的业务意图上,而不是技术实现上。

Entity 就是一个普通的类:一个名字、若干带类型的字段,别的什么都没有。它不知道 ORM,不知道表,也不知道数据将从哪里来——它唯一的任务是按人们谈论它的方式描述一个现实世界的对象:订单有金额和币种,客户有姓名和邮箱。字段分两种:简单的——字符串、数字、日期——以及指向其他 Entity 的引用,因为订单有客户,也有订单行。正是后者把一组彼此孤立的类变成了模型,下一节讲的就是它们。而行为在这里是没有的:Entity 只负责描述,对对象所做的一切都住在 Action 里。

什么是 Action →
CustomerEntityPKidstrnamestremailstrOrderEntityPKidstrtotalfloatcurrencystrstatusstr
完整文档 ↗

关系明确所有权

领域由什么构成,已经清楚了。那它靠什么维系,又是什么让这些关系不会各说各话?

关系由两件事来描述:对象之间抓得有多紧——组合、聚合还是关联——以及每一侧站着多少个对象,一个还是多个。每个实体都完整地声明这条关系:自己的一侧和对面的一侧,两边都带类型和基数。所以只看一个实体,就能一次看清它的全部关系。启动时系统逐对核对——两侧对不上,启动就停下。有时并没有第二侧:这条关系指向自己存储之外。这也可以,但必须明确声明。

OrderEntityPKidstrtotalfloatcurrencystrstatusstrFKcustomer→ CustomerEntityFKlines→ OrderLineEntityCustomerEntityPKidstrnamestremailstrFKorders→ OrderEntityOrderLineEntityPKidstrskustrFKorder→ OrderEntity
完整文档 ↗

Lifecycle 约束每次状态迁移

只要状态还是一个字符串,就没有什么能阻止一个订单从草稿直接跳到已送达。什么允许、什么不允许,活在团队的脑子里,或者活在一条早就过时的注释里。

在 AOA 里,这些规则不再只是口头的:订单的状态和它们之间的转换,作为一台状态机,就声明在实体旁边——草稿、已支付、已发货、已送达,而取消只能从前两个状态发生。这不是建议:清单上没有的转换就不存在,订单也不可能在未支付的情况下变成已送达。状态机本身会在启动时被检查——到不了的状态,或者通向虚空的转换,会在第一笔真实订单到来之前很久就让启动停下。

状态机单独声明:订单可以处于哪些状态,以及它们之间允许哪些转换。只写一次,与任何实体无关。

draftpaidshippeddeliveredcancelled

现在可以把它用上了:OrderEntity 的 status 字段不再是字符串,而是 OrderLifecycle 类型。字符串什么值都收,这个类型只收那些有路径可达的值。

OrderEntityPKidstrtotalfloatcurrencystrlifecycleOrderLifecycle
完整文档 ↗

Resources 定义存储边界

模型描述的是业务领域,但它并不直接跟数据库打交道:它不该依赖数据库是怎么实现的。那就得由别的东西去真实的库里把数据取出来——再放回去。

这正是 Resource 的活儿。它持有一切必须跨调用存活的东西:连接、连接池、客户端。它的职责是打开、执行、返回;里面没有任何业务规则。跨过这条边界的是实体。同一个模型可以同时由好几个 Resource 来承载:一个读 SQL,一个读 NoSQL,还有一个读别人的 HTTP 服务,而它们返回的都是同一个 OrderEntity。业务逻辑代码永远不知道是哪一个在干活:表名、方言、响应格式,一样都传不到它那里。所以更换存储,改的只是 Resource。

代码ResourceOrderEntityNoSQLSQLHTTP 服务一个接口 · 同一个 OrderEntity · 可替换的数据源
三种 Resource:Storage、Gateway、Controller ↗完整文档 ↗

投影塑造每次读取

同一个实体从数据库里读出来,有时是完整的,有时只是一部分,有时干脆只有一个标识符。通常的应对办法是一堆 DTO,一种情况一个类。

通常的做法是给资源的每个方法都配一个自己的 DTO。跟返回普通字典相比,这确实带来了静态保护,字段名写错在运行前就能发现。但这种做法还有另一面:原本完整的实体被拆成一大堆小 DTO,而领域模型只剩在文档和图纸里活着——那些东西在第一次发版之前就已经过时了。

AOA 的想法是不放弃完整的模型:资源方法返回的就是模型自己的对象,跟声明时一模一样。字段照样通过具名属性来读,跟 DTO 一样方便,但不会多出任何新的类——而且往模型里塞一个模型根本没有的字段,也变得不可能。模型是否还作数,由用它的那段代码来担保;ERD 也是直接从它画出来的。

于是引入了数据投影这个概念:当资源方法没有把实体读全时,它返回的仍是同一个实体,只是那些没从数据库读出来的字段被标记为不可用。一旦有人去用它们,系统会抛出一个清楚的异常,而不是默不作声。

Maxitor 中的 ERD 模型 →

get_order(order_id) → OrderEntity

最显而易见的情形——整个实体,每个字段都已加载;模型类被完全按照声明的样子使用。

OrderResourceget_order()find_orders()get_full_order_info()OrderEntityPKid"ORD-1024"total149.90currency"RUB"status"paid"返回

find_orders(query) → list[OrderEntity]

一个精简的集合——只有每个订单的 id 被加载;读取任何其他字段都会报错。

OrderResourceget_order()find_orders()get_full_order_info()OrderEntityPKid"ORD-1024"totalcurrencystatus返回

get_full_order_info(order_id) → OrderEntity

深层的情形——订单加上它嵌套的实体,每一个都加载到不同的部分深度(id 始终会被加载,它就是键)。

OrderResourceget_order()find_orders()get_full_order_info()OrderEntityPKid"ORD-1024"total149.90FKcustomer→ CustomerEntityFKlines→ OrderLineEntityCustomerEntityPKid"CUST-7"name"Ivan Petrov"emailOrderLineEntityPKid"LN-1"sku"SKU-55"quantity3unit_price返回
完整文档 ↗
先设计模型,再写代码。 · aoa.run