Teable v2 领域层(domain)架构解析:聚合、实体、值对象与规格模式的落地实践
2026/9/13 12:03:00 网站建设 项目流程

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):以值语义存在、不可变、靠内容判等的对象,如TableIdTableNameFieldIdSelectOption
  • 规格(Specs):把领域约束、查询条件与变更意图建模为显式对象,供仓库层与 visitor 消费;
  • 领域事件(Domain Events):聚合内状态变化产生的事件,如TableCreatedFieldUpdatedRecordCreated,供下游实时同步、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/领域公共基类与通用值对象/规格框架AggregateRootValueObjectEntityDomainEventDomainErrorIdGeneratorRehydratedValueObject,以及specification/规格框架(And/Or/Not Spec)、graph/拓扑排序、pagination/分页、sort/排序
base/Base(空间基)领域概念BaseIdBaseNameBase聚合、BaseBuilderBaseCreated事件
table/Table 聚合,含字段/视图/规格/事件TableTableBuilderTableMutatorfields/views/records/specs/events/
formula/供领域值对象使用的公式解析与类型推断visitor.ts(表达式返回类型推断)、functions/函数注册表、CellValueTypetyped-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 维度的查询与变更规格,例如:

  • 查询类:TableByIdSpecTableByBaseIdSpecTableByNameSpecTableByNameLikeSpecTableByIncomingReferenceToTableSpec
  • 变更类:TableAddFieldSpecTableRemoveFieldSpecTableRenameSpecTableDuplicateFieldSpecTableUpdateFieldNameSpecTableUpdateViewColumnMetaSpec等。

table/ARCHITECTURE.md特别指出:Table.update+TableMutator组合出的变更规格复用查询规格(如TableByNameSpec)与纯变更规格(如TableAddFieldSpec),但二者会分别传递给不同的消费方——查询规格交给仓库做筛选,变更规格交给 visitor 做事件生成与落库。

4.3 字段规格(fields/specs)

fields/specs 目录 是一组"谓词式"规格,用于描述字段的类型特征,例如:

  • FieldIsLinkSpecFieldIsFormulaSpecFieldIsLookupSpecFieldIsRollupSpec(计算/引用类);
  • FieldIsNumberSpecFieldIsDateSpecFieldIsUserSpecFieldIsAttachmentSpec(基础类型类);
  • FieldIsPrimarySpecFieldIsComputedSpecFieldIsNumberLikeSpec等交叉判定。

这些规格与FieldSpecBuilder结合,让"取所有链接字段""取主字段"这类语义以组合方式表达,例如TableBuilder.spec.tsbuildFieldSpec((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 通知。

它还区分了"顶层属性"与"选项支撑属性"两类语义:顶层属性(如namedbFieldNametypeaiConfig)直接映射到自身路径;而选项类属性(formattingdefaultValueshowAsoptions等)会归并到options根路径下,并用具体的 presence key(如preventAutoNewOptionsrelationship)标注。visitTableUpdateFieldConstraints甚至会根据previousNotNull/nextNotNullpreviousUnique/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
必须有 BaseIdBaseId 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 组合出变更规格集合并返回新的聚合状态。变更规格(如TableRenameSpecTableAddFieldSpec)与查询规格分离传递,正如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 层设计,可以提炼出几条可复用的工程原则:

  1. 依赖白名单是领域层的第一纪律。只用纯 TS 库(neverthrowzodnanoidts-pattern),领域逻辑才能在框架与存储之外独立演化;
  2. 聚合是事件源AggregateRoot.pullDomainEvents()取走即清空,事件既服务于下游实时同步,也服务于 adapter 的持久化后置步骤;
  3. 规格让约束可组合、可访问。查询规格与变更规格分离传递,visitor 旁挂解释逻辑(如FieldUpdateSemanticsVisitor),既保持聚合内聚,又让 realtime/presence 等横切关注点有干净的接入点;
  4. 构建与更新分离TableBuilder负责一次性构建并校验全部不变量,Table.update + TableMutator负责增量变更,二者共用同一套值对象与规格体系;
  5. 测试即规格文档TableBuilder.spec.tsDomainBasics.spec.ts等文件把每一条不变量写成可执行断言,既是回归保护,也是新开发者理解领域规则最快的入口。

对于想为 teable 贡献或扩展 v2 内核的开发者,最佳切入点就是packages/v2/core/src/domain:先读 domain/ARCHITECTURE.md 把握边界,再顺着本文提到的每个示例文件(TableBuilder.spec.tsDomainBasics.spec.tsFieldUpdateSemanticsVisitor.ts)深入,即可快速建立完整的领域心智模型。

【免费下载链接】teable✨ AI Spreadsheet for Business项目地址: https://gitcode.com/GitHub_Trending/te/teable

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询