Twenty SDK 应用架构实践:twenty-partners 的垂直切片模块化设计
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-sdk 与 twenty-client-sdk 2.31.0、Node ^24.5.0、Yarn >= 4.0.2,并提供 seed、purge、rls:configure 等按路径直接调用 src/scripts/* 的 npm 脚本——这些脚本路径本身就是一个架构不变量(见下文"不变量")。
AGENTS.md 开篇点明该架构的来历:它改编自 twentyhq/twenty-eng(最大的 Twenty SDK 应用),后者正是在规模膨胀后将"扁平结构 → 模块化结构"迁移而成。文档声明迁移已完成:每个领域都位于 src/modules/ 之下,src/ 顶层仅保留 constants/universal-identifiers.ts、scripts/、roles/、skills/、workflows/、__tests__/ 以及两个根配置文件;新增代码必须进入某个领域模块,禁止再建扁平的顶层 src/<primitive>/ 目录。仓库级编码约定(kebab-case 文件名、named exports、禁用 any、types 优先于 interface、短 // 注释)沿用根目录 CLAUDE.md,该文档只补充 partners 特有的结构约定。
当前仓库的实际目录结构与文档声明一致:src/modules/ 下为 partner、opportunity、application、shared 四个模块,顶层另有 application-config.ts、default-role.ts、constants/、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/
核心理念是"一个领域是一个自包含切片":它的声明(objects、fields、views……)与它的逻辑(logic-functions → services → graphql/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 行:
- 通过共享工具
readSecretGuardedEvent(来自 shared/http/read-secret-guarded-event)校验并解析入参(zod schema),守卫失败直接返回{ ok: false, reason }; - 调用唯一的服务
submitPartnerApplication(位于同目录的services/); - 用
defineLogicFunction声明入口,携带universalIdentifier(固定 UUID7b1e2c5f-3a14-4f7d-8e91-0b5e2a3c4d76)、timeoutSeconds: 15,以及 HTTP 路由触发器配置POST /partner-applications(isAuthRequired: 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.ts、resolve-candidacy.service.ts、partner.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.ts 中 unit project 只匹配 src/**/*.test.ts,integration project 匹配 src/**/*.integration-test.ts,并配置了 120 秒超时、fileParallelism: false、globalSetup(global-setup.ts)以及限流重试 setup,通过 TWENTY_API_URL / TWENTY_API_KEY 环境变量指向目标 workspace。对应 package.json 中的脚本:test:unit 运行 vitest run --project unit,test:integration 运行 vitest run --project integration。共享测试设施统一放在 src/__tests__/helpers/。最后一条纪律是:不要给 mock 过的单元测试改名为 .integration-test.ts——那会让它被错误地拉进需要真实服务器的 project。
不变量:移动代码时绝不能破坏的东西
架构文档中最具 Twenty SDK 特色的部分是"不变量"清单,它解释了为什么"看似安全的 git mv 也可能造成线上事故":
universalIdentifierUUID 集合必须逐字节不变。SDK 按 identifier(而非路径)跟踪原始实体,所以纯git mv是安全的,但改动或丢失任何一个 UUID 都会导致对象被重新注册(重注册)或成为孤儿对象。- 不要移动
src/constants/universal-identifiers.ts——env-toolkit 会按路径逐 bundle 重写这个文件。 - 不要移动
src/scripts/*——package.json 中的seed、purge、rls: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-brief、twenty-partner-shortlist、twenty-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-partners 的 AGENTS.md 提供了一套可直接套用到任何 Twenty SDK 应用(乃至更广泛的插件/微服务代码库)的模块化方法:
- 以领域为边界的垂直切片——声明与逻辑同目录,功能增长体现为新增文件夹;
- 单向依赖链 + 唯一位置原则——原始 GraphQL 只在
graphql/、对外 API 只在connector/、业务逻辑只在services/,使"代码该放哪"不再有歧义; - 身份与路径解耦的不变量清单——在 SDK 按 UUID 注册元数据的前提下,明确哪些文件不可移动、哪些 UUID 不可变更,让重构有安全边界;
- 文档即代码——应用内 playbook 与常量、视图、工作流锁步更新,避免"手册与行为漂移"这一最常见的文档腐烂来源。
配合 vitest.config.ts 的 unit/integration 双 project 与 package.json 的按路径脚本约定,这份架构文档本身就是一个可执行的质量契约。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00