首页
/ Twenty SDK 应用架构实践:twenty-partners 的垂直切片模块化设计

Twenty SDK 应用架构实践:twenty-partners 的垂直切片模块化设计

2026-09-06 09:42:23作者:何举烈Damon

Twenty 是 Salesforce 的开源替代方案,其应用生态基于 Twenty SDK 构建。twenty-partners 是 Twenty 官方最大的内部 SDK 应用(合作伙伴市场与机会匹配平台),其架构文档 AGENTS.md 完整记录了一套"垂直切片 + 单向依赖"的模块化组织方式:以领域(domain)为单位的自包含目录、严格的分层调用规则、不可破坏的标识符不变量,以及应用内文档页(playbook)与真实行为保持同步的维护纪律。读完后,你可以掌握如何把一个从"扁平目录"长大的 Twenty SDK 应用迁移并演进为可维护的模块化结构,并复用其依赖规则、命名规范与测试拆分策略。

背景:一个从扁平结构迁移而来的 SDK 应用

twenty-partners 位于 packages/twenty-apps/internal/ 下,与 twenty-eng 风格的官方内部应用 并列。其 package.json 显示它依赖 twenty-sdktwenty-client-sdk 2.31.0、Node ^24.5.0、Yarn >= 4.0.2,并提供 seedpurgerls:configure 等按路径直接调用 src/scripts/* 的 npm 脚本——这些脚本路径本身就是一个架构不变量(见下文"不变量")。

AGENTS.md 开篇点明该架构的来历:它改编自 twentyhq/twenty-eng(最大的 Twenty SDK 应用),后者正是在规模膨胀后将"扁平结构 → 模块化结构"迁移而成。文档声明迁移已完成:每个领域都位于 src/modules/ 之下src/ 顶层仅保留 constants/universal-identifiers.tsscripts/roles/skills/workflows/__tests__/ 以及两个根配置文件;新增代码必须进入某个领域模块,禁止再建扁平的顶层 src/<primitive>/ 目录。仓库级编码约定(kebab-case 文件名、named exports、禁用 anytypes 优先于 interface、短 // 注释)沿用根目录 CLAUDE.md,该文档只补充 partners 特有的结构约定。

当前仓库的实际目录结构与文档声明一致:src/modules/ 下为 partner、opportunity、application、shared 四个模块,顶层另有 application-config.tsdefault-role.tsconstants/roles/scripts/skills/workflows/

目录布局:一个领域是一个自包含切片

文档给出的标准布局如下:

src/
  application-config.ts          # 应用 + 应用变量 — 保持不变
  default-role.ts                # 保持不变
  constants/universal-identifiers.ts   # ⚠️ 禁止移动(见不变量)
  scripts/                       # ⚠️ 禁止移动 — seed/purge/rls/slugify
  roles/                         # 应用级 RLS — 保留在根目录(跨所有对象)
  skills/                        # 捆绑的 Claude skills — 保持不变
  workflows/                     # 捆绑的工作流 — 保持不变
  __tests__/helpers/             # 共享测试设施(client、fixtures、cleanup)
  modules/
    shared/                      # 横切关注点:secret-guard (http/)、find-or-create、paginate、
                                 #   utils、front-components、跨域导航目录
    <domain>/                    # partner · application · opportunity · …(大领域可再加 <feature>/ 层级)
      <name>.logic-function.ts   # 薄 SDK 入口 — 放在领域/feature 根部,不进子目录
      objects/ fields/ view-fields/ views/ navigation-menu-items/ page-layouts/   # 声明
      constants/                 # 值列表 + 声明 UUID 映射(不是类型)
      services/                  # 业务逻辑(可测试)
      graphql/{queries,mutations}/   # 唯一存放原始 query/mutation 的位置
      connector/                 # 对外的第三方 API,每个 API 一个文件夹(<name>.connector.ts · config · types)
      mappers/  utils/  types/  front-components/

核心理念是"一个领域是一个自包含切片":它的声明(objectsfieldsviews……)与它的逻辑(logic-functionsservicesgraphql/connector)放在一起。添加一个功能意味着添加一个文件夹,而不是把文件散落进十个扁平目录。

对大型领域,文档规定了 <feature>/ 层级:当一个领域包含多个独立功能时,插入 modules/<domain>/<feature>/<primitive>/ 结构(twenty-eng 标准,例如 modules/code-build/build-task/services/),避免 graphql/services/ 退化为扁平大杂烩。实际仓库中可以看到这套规则的落地:partner 领域下分为 application-intake、directory、marketplace、self-service、onboarding 多个 feature;opportunity 领域下分为 intake、matching、how-to-process。而较小的单功能领域(如 application)允许直接使用 <domain>/<primitive>/

源码印证:一个"薄入口"长什么样

submit-partner-application.logic-function.ts 是依赖规则的理想样本,全文不到 40 行:

  1. 通过共享工具 readSecretGuardedEvent(来自 shared/http/read-secret-guarded-event)校验并解析入参(zod schema),守卫失败直接返回 { ok: false, reason }
  2. 调用唯一的服务 submitPartnerApplication(位于同目录的 services/);
  3. defineLogicFunction 声明入口,携带 universalIdentifier(固定 UUID 7b1e2c5f-3a14-4f7d-8e91-0b5e2a3c4d76)、timeoutSeconds: 15,以及 HTTP 路由触发器配置 POST /partner-applicationsisAuthRequired: false,仅转发 APPLICATION_SECRET_HEADER 请求头)。

入口文件内没有任何业务逻辑、没有原始 GraphQL、没有外部 HTTP 调用——这正是文档要求的"SDK 入口、解析输入、调用一个服务、返回"。文档还给出了经验阈值:超过约 40 行就属于"在做服务层的工作",应当抽取

依赖规则:唯一最重要的约束——永远不向上导入

文档将依赖方向压缩为一条链:

logic-functions → services → { graphql, connector, mappers } → shared · utils · types

各层职责与禁区:

  • 入口(*.logic-function.ts,位于领域/feature 根部)——仅作为 SDK 入口。解析输入、调用一个服务、返回。不允许业务逻辑、原始 GraphQL、外部 HTTP。
  • services/——所有业务逻辑所在,可测试、与 SDK/传输层解耦。禁止导入任何 logic-function
  • graphql/——唯一允许出现原始 query/mutation 的位置,且要求是"命名的、带类型的操作",禁止内联 client.query(...)
  • connector/——唯一允许调用对外第三方 API 的位置,每个 API 一个文件夹(<name>.connector.ts / config.ts / types.ts)。文档特别澄清:入站 webhook 不是 connector——应用接收的 webhook 只是普通 logic-functions/ 入口,用共享的 secret-guard 工具守卫。(目前真实的 connector 只有 Discord;TFT / client-brief / partner-application 都是入站。)仓库中确实只有 shared/connector/discord/discord.connector.ts 一个对外连接器,与文档陈述吻合。
  • 领域之间禁止横向导入对方的逻辑——跨域共享一律走 modules/shared/。唯一例外:关系字段引用目标对象的 ID 常量属于 schema 引用而非逻辑依赖,这类跨模块导入是允许的。

这条链的价值在于可测试性与可迁移性:services/ 不触碰 SDK 传输层,因此单元测试可以 mock 掉 client 快速运行;connector/ 单独成层,第三方 API 的凭证、超时、类型都集中在一个文件夹内。

命名规范:<name>.<primitive>.ts

文件名统一采用 <name>.<primitive>.ts 模式,例如 submit-application.logic-function.tsresolve-candidacy.service.tspartner.object.ts。全部文件使用 kebab-case——包括 React 组件也不允许 PascalCase(如 profile-picture-upload.tsx)。这与根 CLAUDE.md 中"kebab-case 文件、named exports、SCREAMING_SNAKE_CASE 常量、组件 props 类型以 Props 结尾"的仓库级约定一脉相承。

测试文件:按类型拆分,用 vitest project 区分

测试与被测对象同目录,并按类型分为两种命名:

  • <name>.test.ts——单元测试:mock 掉 client,运行快,不依赖任何基础设施;
  • <name>.integration-test.ts——集成测试:需要真实 workspace(运行中的服务器 + 全局 setup),被 tsconfig 排除在常规类型检查之外。

文档强调:这个拆分是可选的 vitest project(通过 --project unit 选择),而不是按文件数量强制。项目确实采用"一个 vitest.config.ts 定义两个 project"的推荐形态——vitest.config.tsunit project 只匹配 src/**/*.test.tsintegration project 匹配 src/**/*.integration-test.ts,并配置了 120 秒超时、fileParallelism: falseglobalSetupglobal-setup.ts)以及限流重试 setup,通过 TWENTY_API_URL / TWENTY_API_KEY 环境变量指向目标 workspace。对应 package.json 中的脚本:test:unit 运行 vitest run --project unittest:integration 运行 vitest run --project integration。共享测试设施统一放在 src/__tests__/helpers/。最后一条纪律是:不要给 mock 过的单元测试改名为 .integration-test.ts——那会让它被错误地拉进需要真实服务器的 project。

不变量:移动代码时绝不能破坏的东西

架构文档中最具 Twenty SDK 特色的部分是"不变量"清单,它解释了为什么"看似安全的 git mv 也可能造成线上事故":

  • universalIdentifier UUID 集合必须逐字节不变。SDK 按 identifier(而非路径)跟踪原始实体,所以纯 git mv 是安全的,但改动或丢失任何一个 UUID 都会导致对象被重新注册(重注册)或成为孤儿对象。
  • 不要移动 src/constants/universal-identifiers.ts——env-toolkit 会按路径逐 bundle 重写这个文件。
  • 不要移动 src/scripts/*——package.json 中的 seedpurgerls:configure 等脚本按路径直接调用它们。
  • 保持 universal-identifiers.ts 的导入方指向根文件——不要把它的 UUID 导出搬进某个模块。
  • 绝不允许把一个关系字段拆到两个领域并留下悬空 ID 导入——成对的关系字段互相导出/导入对方的 field-ID 常量,必须把关系两端放在同一个领域内,或者把共享 ID 提升到 shared/
  • 迁移时一个领域一个 commit(仅用 git mv,导入路径在同一 commit 内修好)。

这些不变量的本质是:Twenty SDK 的元数据注册体系把"路径"视为纯组织手段,把"UUID"视为身份。理解了这一点,就理解了整个目录迁移策略为什么可以做到"移动文件但不改变任何运行时行为"。

应用内文档页(Playbook)必须与真实行为锁步

twenty-partners 还规定了一个少见的纪律:应用内的操作手册页面本身也是一等公民,必须与真实行为同步更新。

合作伙伴申请评审 Playbook

Matching Admin Workspace 的 Playbook 是 src/modules/partner/application-intake/ 下的一个独立页面(front-component + 页面布局 + 导航项)。当以下任何一项发生变化时,必须在同一次变更中更新 Playbook:

  • 落到 Partner 上的网站表单字段;
  • Partner Applications 视图(过滤、列、排序);
  • 作为评审决策的 validationStage 取值;
  • 评审员需要知道的 intake 副作用(创建时发 Discord 通知;进入 VALIDATED 时发欢迎邮件,链接来自 WELCOME_EMAIL_WORKFLOW_URL);
  • 位于 completeness-score.ts 中的 Marketplace 排名加分规则(案例研究、封面、介绍、服务、图片、日历、报价、分类、ghost 规则)。

页面事实必须来自视图与 intake 使用的同一组常量,禁止留下过期步骤,也不允许在 twenty.com 或 Note 里保留第二份拷贝。

机会处理 Playbook

"How to process"(Matching Admin)与 "How to apply"(Partner Workspace)是 src/modules/opportunity/how-to-process/ 下的独立页面,同样要求与真实操作行为锁步。变更以下任一项时,需在同一变更中更新 matching 页面及其文案测试:

  • Intake(marketplace brief 已 Listed + Discord;Twenty Internal 未上架;/twenty-lead-brief 写入 Design Doc URL);
  • Listing(Listed 翻转 → Discord;每日摘要;Open Briefs 会员资格);
  • Intro(Intro Sent At 会把 Listed 关掉;Invited applications);
  • Win(设置 Partner → Won / Declined;Backup 保留;取消分配会把 Won 重开为 Applied;Deals 阶段 New → Screening → Meeting → Proposal → Customer);
  • Apply(Open Briefs 仅展示已 Listed;pitch 最低要求;一份 pitch;状态含义);
  • 捆绑 skills(twenty-lead-brieftwenty-partner-shortlisttwenty-partner-intro):本地运行方式、预期输出、SKILL.md 的仓库地址;
  • Match via MCP:操作员可以让 Agent 代写 CRM 变更。

页面语言保持面向操作员;How to process 页面上允许出现 skill 名称、slash 触发器和捆绑 skill 的仓库地址;How to apply 页面必须带 Open Briefs 与 My Applications 链接;并且不得从独立页面直接打开 Apply——Apply 固定(pinned)在 brief 记录上。

新代码放在哪里:一张决策速查表

文档最后给出一条极简的定位规则:

需求 放置位置
对外 API 调用 connector/
原始 query / mutation graphql/
任何业务逻辑 services/
新的 SDK 触发器 / 函数 领域/feature 根部的薄 *.logic-function.ts,内部调用一个 service
跨领域共享 modules/shared/

小结:这套架构的可迁移经验

twenty-partnersAGENTS.md 提供了一套可直接套用到任何 Twenty SDK 应用(乃至更广泛的插件/微服务代码库)的模块化方法:

  1. 以领域为边界的垂直切片——声明与逻辑同目录,功能增长体现为新增文件夹;
  2. 单向依赖链 + 唯一位置原则——原始 GraphQL 只在 graphql/、对外 API 只在 connector/、业务逻辑只在 services/,使"代码该放哪"不再有歧义;
  3. 身份与路径解耦的不变量清单——在 SDK 按 UUID 注册元数据的前提下,明确哪些文件不可移动、哪些 UUID 不可变更,让重构有安全边界;
  4. 文档即代码——应用内 playbook 与常量、视图、工作流锁步更新,避免"手册与行为漂移"这一最常见的文档腐烂来源。

配合 vitest.config.ts 的 unit/integration 双 project 与 package.json 的按路径脚本约定,这份架构文档本身就是一个可执行的质量契约。

登录后查看全文
热门项目推荐
相关项目推荐