Teable v2 领域层(domain)架构解析:聚合、实体、值对象与规格模式的落地实践
【免费下载链接】teable✨ AI Spreadsheet for Business项目地址: https://gitcode.com/GitHub_Trending/te/teable
teable 是一个面向企业业务的 AI Spreadsheet 开源项目,其新一代架构中,packages/v2/core承担了与框架无关的领域建模职责。本文以 domain/ARCHITECTURE.md 为骨架,结合仓库内的领域源码与测试,完整剖析 domain 层的职责边界、依赖约束、核心抽象(聚合根/实体/值对象/规格/领域事件)以及 table 聚合的构建与更新流程。读完本文,你将掌握 teable v2 领域层的组织方式、规范与最佳实践,并能基于同样的模式在自己的业务代码中落地 DDD。
一、domain 层在 teable v2 中的定位
1.1 职责:领域模型的核心
packages/v2/core/src/domain是整个 v2 内核的领域模型核心,它只负责回答"业务规则是什么",不关心"数据怎么存、接口怎么暴露"。按架构笔记的声明,它的职责包括:
- 聚合(Aggregates):以
Table(数据表)为代表的聚合根,管理字段、视图、记录等内部模型; - 实体(Entities):拥有唯一标识、生命周期内状态可变的对象,如
Field(字段)、View(视图)、TableRecord(记录); - 值对象(Value Objects):以值语义存在、不可变、靠内容判等的对象,如
TableId、TableName、FieldId、SelectOption; - 规格(Specs):把领域约束、查询条件与变更意图建模为显式对象,供仓库层与 visitor 消费;
- 领域事件(Domain Events):聚合内状态变化产生的事件,如
TableCreated、FieldUpdated、RecordCreated,供下游实时同步、presence 等模块消费。
1.2 依赖约束:只依赖纯 TS 运行时库
架构笔记明确列出了 domain 层的依赖白名单:
Depends only on TS/JS, neverthrow, zod, nanoid, ts-pattern.
这意味着领域层不依赖任何 NestJS、Prisma、Express 等框架或基础设施,从而保证:
- 领域逻辑可以在纯 Node 环境、测试环境甚至浏览器端复用;
- 框架层(HTTP 控制器、应用服务)只是领域模型的"薄壳";
- 领域层可以被独立单测,无需数据库或网络。
其中neverthrow用于显式 Result 错误处理(如_unsafeUnwrap()/_unsafeUnwrapErr()贯穿所有领域测试),zod用于入参校验(如Table.createRecordInputSchema),nanoid风格的IdGenerator负责生成带前缀的随机 ID。
1.3 持久化边界:port/adapter 之外的世界
笔记中最关键的一条架构纪律是:
Repository-driven persistence lifecycle details belong behind ports/adapters; application code should observe aggregates and domain events, not drive adapter-private post-persist steps.
翻译过来就是:持久化生命周期细节(如事务提交后清理缓存、发布事件、重建物化视图等)属于端口/适配器(ports/adapters)的私有范畴。应用层与领域层只负责"观察聚合与领域事件",而不要去驱动 adapter 私有的后置步骤。这正是 adapter-repository-postgres 这类包存在的意义——领域模型完全不知道 SQL 的存在,却知道"哪些事件被产生、哪些规格需要落库"。
二、目录结构:shared / base / table / formula
domain 层按职责拆分为四个子目录,每个子目录都配有一份ARCHITECTURE.md架构笔记:
| 子目录 | 职责 | 关键内容 |
|---|---|---|
shared/ | 领域公共基类与通用值对象/规格框架 | AggregateRoot、ValueObject、Entity、DomainEvent、DomainError、IdGenerator、RehydratedValueObject,以及specification/规格框架(And/Or/Not Spec)、graph/拓扑排序、pagination/分页、sort/排序 |
base/ | Base(空间基)领域概念 | BaseId、BaseName、Base聚合、BaseBuilder、BaseCreated事件 |
table/ | Table 聚合,含字段/视图/规格/事件 | Table、TableBuilder、TableMutator、fields/、views/、records/、specs/、events/ |
formula/ | 供领域值对象使用的公式解析与类型推断 | visitor.ts(表达式返回类型推断)、functions/函数注册表、CellValueType、typed-value体系 |
从源码结构看(domain 目录),table/是体量最大的子域,其内部又细分为events/、fields/、methods/、records/、specs/、views/六个子模块,形成一个完整的高内聚聚合。
三、核心抽象:从 ValueObject 到 AggregateRoot
shared/子目录承载了整套 DDD 基础抽象,shared/ARCHITECTURE.md 对此有完整说明,下面结合源码逐一展开。
3.1 ValueObject:值语义与相等契约
ValueObject.ts 是一个极其精简的抽象基类:
export abstract class ValueObject { abstract equals(other: this): boolean; }它只强制子类实现equals,从而在整个领域层建立统一的"按值判等"契约。以 DomainBasics.spec.ts 中的测试为证:
class TestValueObject extends ValueObject { constructor(private readonly value: string) { super(); } equals(other: TestValueObject): boolean { return this.value === other.value; } } // expect(left.equals(right)).toBe(true); // 'a' == 'a' // expect(left.equals(other)).toBe(false); // 'a' != 'b'3.2 Entity:ID 访问器
Entity.ts 是所有实体与聚合根的公共基类,只做一件事——持有 ID 并提供访问器:
export abstract class Entity<Id> { protected constructor(private readonly idValue: Id) {} id(): Id { return this.idValue; } }3.3 AggregateRoot:收集与释放领域事件
AggregateRoot.ts 在Entity之上增加了领域事件收集机制:
export abstract class AggregateRoot<Id> extends Entity<Id> { private readonly domainEvents: IDomainEvent[] = []; addDomainEvent(event: IDomainEvent): void { this.domainEvents.push(event); } /** Records multiple domain events. Used by external event generators (like spec visitors). */ recordDomainEvents(events: ReadonlyArray<IDomainEvent>): void { ... } pullDomainEvents(): IDomainEvent[] { const events = [...this.domainEvents]; this.domainEvents.length = 0; return events; } }关键点是pullDomainEvents()取出即清空——事件被应用层/仓库层拉取发布后,聚合不会重复发布同一批事件。DomainBasics.spec.ts验证了这一点:
aggregate.record(event); const events = aggregate.pullDomainEvents(); expect(events).toEqual([event]); expect(aggregate.pullDomainEvents()).toEqual([]); // 第二次拉取为空注释还说明recordDomainEvents是供"外部事件生成器(如 spec visitors)"使用的——这正好呼应了后文FieldUpdateSemanticsVisitor的定位。
3.4 DomainEvent:统一事件形状与类型守卫
DomainEvent.ts 定义了领域事件的标准接口:
export interface IDomainEvent { readonly name: DomainEventName; readonly occurredAt: OccurredAt; requestId?: string; // 由 EventBus 在发布时从 ExecutionContext 注入,用于全链路追踪 }同时提供了两个工具函数:
hasDomainEventName(event, eventName):按事件名判等;createDomainEventGuard<TEvent>(eventName):生成类型守卫,把IDomainEvent收窄为具体事件类型。
事件名本身是强类型值对象DomainEventName(DomainEventName.ts),并内置了tableCreated()、computedActivityBatchChanged()等工厂方法。requestId字段的存在表明领域事件会携带全链路追踪标识,由 EventBus 在发布时注入。
3.5 RehydratedValueObject:延迟填充的值对象
RehydratedValueObject.ts 解决一个现实问题:聚合从仓库重建时,部分字段值可能尚未就绪。它允许先创建"空占位",在rehydrate之后才可访问值:
protected valueResult(typeName: string): Result<string, DomainError> { if (typeof this.rawValue !== 'string' || this.rawValue.length === 0) { return err(domainError.invariant({ message: `${typeName} is not available before rehydrate` })); } return ok(this.rawValue); }DomainBasics.spec.ts验证:empty()构造的占位对象isRehydrated()为false,此时调用value()返回错误;rehydrate()之后才返回正常值。笔记中的示例 DbTableName.ts(持久化表名与 schema 拆分)正是这一抽象的真实用例。
3.6 DomainError:结构化领域错误模型
DomainError.ts 提供结构化的错误码/标签与断言工具。领域层所有失败路径都以Result<T, DomainError>形式返回,配合neverthrow,把"业务校验失败"从异常机制中剥离出来——这也是TableBuilder.spec.ts中大量_unsafeUnwrapErr()断言能工作的前提。
四、规格(Spec)模式:领域约束的显式化
teable v2 的领域层对 DDD 的 Specification 模式应用得非常彻底,规格散落在三个层面:
4.1 规格框架(shared/specification)
specification 目录 提供规格的基础设施:
ISpecification.ts:规格接口,定义accept(visitor)协议;AndSpec/OrSpec/NotSpec:布尔组合规格;SpecBuilder.ts/composeAndSpecs.ts:规格的构建与 AND 组合工具;ISpecVisitor.ts/NoopSpecVisitor.ts/AbstractSpecFilterVisitor.ts:访客框架。
4.2 查询规格(table/specs)
table/specs 目录 存放 Table 维度的查询与变更规格,例如:
- 查询类:
TableByIdSpec、TableByBaseIdSpec、TableByNameSpec、TableByNameLikeSpec、TableByIncomingReferenceToTableSpec; - 变更类:
TableAddFieldSpec、TableRemoveFieldSpec、TableRenameSpec、TableDuplicateFieldSpec、TableUpdateFieldNameSpec、TableUpdateViewColumnMetaSpec等。
table/ARCHITECTURE.md特别指出:Table.update+TableMutator组合出的变更规格复用查询规格(如TableByNameSpec)与纯变更规格(如TableAddFieldSpec),但二者会分别传递给不同的消费方——查询规格交给仓库做筛选,变更规格交给 visitor 做事件生成与落库。
4.3 字段规格(fields/specs)
fields/specs 目录 是一组"谓词式"规格,用于描述字段的类型特征,例如:
FieldIsLinkSpec、FieldIsFormulaSpec、FieldIsLookupSpec、FieldIsRollupSpec(计算/引用类);FieldIsNumberSpec、FieldIsDateSpec、FieldIsUserSpec、FieldIsAttachmentSpec(基础类型类);FieldIsPrimarySpec、FieldIsComputedSpec、FieldIsNumberLikeSpec等交叉判定。
这些规格与FieldSpecBuilder结合,让"取所有链接字段""取主字段"这类语义以组合方式表达,例如TableBuilder.spec.ts中buildFieldSpec((builder) => builder.isLink())的用法。
五、FieldUpdateSemanticsVisitor:规格访客驱动实时语义分类
架构笔记专门点名的第三个示例是 FieldUpdateSemanticsVisitor.ts,它的用途是对字段更新事件做语义分类,供下游 realtime/presence 处理。
该 visitor 的核心挑战(源码注释直白地说明了):Table 的规格在accept()中会擦除 visitor 的返回值,所以它不再走标准的 visitor 分发,而是直接按具体规格类型instanceof分派:
visit(spec: object): FieldUpdateSpecSemantics | undefined { if (spec instanceof TableUpdateFieldNameSpec) return this.visitTableUpdateFieldName(spec); if (spec instanceof TableUpdateFieldDbFieldNameSpec) return this.visitTableUpdateFieldDbFieldName(spec); // ... 数十个分支 }每个分支产出FieldUpdateSpecSemantics,即"更新了哪些属性 + 每个属性在 realtime/presence 中的路径":
export type FieldUpdateSpecSemantics = { readonly updatedProperties: ReadonlyArray<string>; readonly propertySemantics: Readonly<Record<string, FieldUpdatedPropertySemantics>>; };其中FieldUpdatedPropertySemantics包含:
realtimePath:实时同步通道中的 JSON 路径(如['options']);presencePath:presence(在线协作状态)通道中的路径;mayRequirePresence:该属性更新是否可能需要 presence 通知。
它还区分了"顶层属性"与"选项支撑属性"两类语义:顶层属性(如name、dbFieldName、type、aiConfig)直接映射到自身路径;而选项类属性(formatting、defaultValue、showAs、options等)会归并到options根路径下,并用具体的 presence key(如preventAutoNewOptions、relationship)标注。visitTableUpdateFieldConstraints甚至会根据previousNotNull/nextNotNull、previousUnique/nextUnique是否真的变化,动态决定是否产出notNull/unique语义。
这个 visitor 是"规格 + visitor"模式的典型落地:领域层只负责声明"改了什么",语义解释(如何影响实时协作)以独立 visitor 的形式旁挂,避免污染规格本体。
六、Table 聚合:构建、更新与记录
6.1 TableBuilder:流式构建聚合
Table.ts 与 TableBuilder.ts 提供了聚合构建入口。TableBuilder.spec.ts 完整展示了流式构建范式:
const builder = Table.builder() .withBaseId(baseId) .withName(tableName); builder.field().singleLineText().withName(titleName).done(); builder.field().number().withName(amountName).primary().done(); builder.field().rating().withName(starsName).withMax(RatingMax.five()).done(); builder .field() .singleSelect() .withName(statusName) .withOptions([todoOption, doneOption]) .done(); builder.view().defaultGrid().done(); const buildResult = builder.build(); buildResult._unsafeUnwrap(); // 校验失败时在这里抛错从测试可以提炼出 TableBuilder 的完整约束集(这些就是领域不变量的直接证据):
| 不变量 | 错误信息(取自断言) |
|---|---|
| 至少需要一个字段 | at least one Field |
| 至少需要一个视图 | at least one View |
| 必须有 BaseId | BaseId is required |
| 必须有表名 | TableName is required |
| 主字段必须存在 | Primary Field must exist |
| 主字段唯一 | 错误消息包含primary |
| 字段名唯一 | Field names must be unique |
| 视图名唯一 | View names must be unique |
| 字段/视图名必填 | FieldName is required/ViewName is required(聚合为Table builder errors+details.errors) |
构建成功后的关键行为也被测试锁定:
- 支持
grid/kanban/calendar/gallery/form/plugin六种视图类型; - 支持
singleLineText/longText/number/rating/singleSelect/multipleSelect/checkbox/attachment/date/user/button基础字段类型; - 允许把非首个字段设为主字段(
primaryFieldId().equals(table.getFields()[1].id())); - 公式/汇总字段可以引用后声明的字段(builder 会事后解析依赖,
resolveFormulaFields.ts支撑); - 链接字段支持
oneOne/manyMany/oneMany/manyOne四种关系,其中manyMany会生成junction_<fieldId>中间表名,并创建对称字段(symmetricFieldId()); - 每种视图都会初始化列元数据(columnMeta),网格/插件视图的主字段列默认不显示
visible,而表单视图只对特定字段标记visible。
6.2 Table.update + TableMutator:不可变更新流
查询/构建之外,领域层还提供不可变更新入口:Table.update(...)+ TableMutator.ts 组合出变更规格集合并返回新的聚合状态。变更规格(如TableRenameSpec、TableAddFieldSpec)与查询规格分离传递,正如table/ARCHITECTURE.md所述,这保证了"筛选"与"变更"两种关注点在规格层面就被清晰隔离。
6.3 记录模型与公式字段
- records/TableRecord.ts 是记录实体,records/TableRecordFields.ts 以
FieldId为键组织字段值对; records/下还有 56 个条件规格文件(records/specs),用于记录查询条件的规格化表达;- resolveFormulaFields.ts 在构建期解析公式依赖与结果类型,其行为由 resolveFormulaFields.spec.ts 保障。
七、formula 子域:仅供类型推断,不做求值
domain/formula 与独立的 packages/formula 包分工不同:领域内的formula/只做解析与类型推断辅助,不做表达式求值。其visitor.ts推断表达式返回类型,functions/是带参数校验与返回类型的函数注册表,typed-value.ts/typed-value-converter.ts负责类型化值及其归一化。领域值对象 FormulaExpression.ts 在创建时即完成解析与类型推断(formula/ARCHITECTURE.md明确给出该示例),从而在聚合构建期就能发现引用不存在的字段等错误。
八、测试体系:领域不变量的事实来源
domain 层的测试密度非常高,且全部是纯 Vitest 单测(无需数据库)。除了前面反复引用的两个,值得继续深入的有:
- Table.spec.ts:聚合行为与不变量的主测试;
- TableSpecs.spec.ts 与 TableSpecBuilder.spec.ts:规格组合与构建;
- FieldSpecs.spec.ts:字段类型谓词规格;
- IdValueObjects.spec.ts:Table/Field/View ID 格式校验;
- graph/topologicalSort.spec.ts:公式依赖等场景的拓扑排序。
由于领域层不依赖任何框架与基础设施,这些测试可以直接在packages/v2/core包内运行(pnpm --filter @teable/v2-core test一类命令),快速反馈不变量是否被破坏。
九、小结:这套领域架构给我们的启示
回顾 teable v2 的 domain 层设计,可以提炼出几条可复用的工程原则:
- 依赖白名单是领域层的第一纪律。只用纯 TS 库(
neverthrow、zod、nanoid、ts-pattern),领域逻辑才能在框架与存储之外独立演化; - 聚合是事件源。
AggregateRoot.pullDomainEvents()取走即清空,事件既服务于下游实时同步,也服务于 adapter 的持久化后置步骤; - 规格让约束可组合、可访问。查询规格与变更规格分离传递,visitor 旁挂解释逻辑(如
FieldUpdateSemanticsVisitor),既保持聚合内聚,又让 realtime/presence 等横切关注点有干净的接入点; - 构建与更新分离。
TableBuilder负责一次性构建并校验全部不变量,Table.update + TableMutator负责增量变更,二者共用同一套值对象与规格体系; - 测试即规格文档。
TableBuilder.spec.ts、DomainBasics.spec.ts等文件把每一条不变量写成可执行断言,既是回归保护,也是新开发者理解领域规则最快的入口。
对于想为 teable 贡献或扩展 v2 内核的开发者,最佳切入点就是packages/v2/core/src/domain:先读 domain/ARCHITECTURE.md 把握边界,再顺着本文提到的每个示例文件(TableBuilder.spec.ts、DomainBasics.spec.ts、FieldUpdateSemanticsVisitor.ts)深入,即可快速建立完整的领域心智模型。
【免费下载链接】teable✨ AI Spreadsheet for Business项目地址: https://gitcode.com/GitHub_Trending/te/teable
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考