オープンソースCRM「Twenty」のアーキテクチャを読む
前回の記事で、オープンソースCRM「Twenty」のローカル開発環境を構築しました。今回はコードを読んで、1.6GB・約26,000ファイルのモノレポ(twentyhq/twenty)の中身がどうなっているかを見ていきます。
読んでみると、Twentyは「CRMアプリ」というより「CRMを組み立てるフレームワーク」に近い設計でした。この記事では、その骨格にあたるモノレポ構成・マルチテナント設計・メタデータ駆動アーキテクチャの3つと、技術選定の面白かった点を紹介します。
packages/にフラットに並ぶモノレポ構成
Nx のモノレポというと、公式チュートリアルにもあるように apps/(デプロイ対象のアプリ)と packages/(共有ライブラリ)に分ける構成が定番ですが、Twentyは packages/ ひとつにフラットに並べるスタイルです。nx.jsonに次の設定があり、アプリもライブラリも置き場所を区別していません。
"workspaceLayout": {
"appsDir": "packages",
"libsDir": "packages"
}
実体はyarn workspacesによるpackage-basedなモノレポで、Nxはタスクランナー(実行順序の解決とビルドキャッシュ)として被さっています。
主要パッケージと依存関係
workspaceは19パッケージあり、主なものは次の通りです。
| パッケージ | 役割 |
|---|---|
| twenty-front | React SPA本体(Vite) |
| twenty-server | NestJSバックエンド |
| twenty-ui | 共有UIコンポーネント |
| twenty-shared | フロント/サーバー横断の型・ロジック |
| twenty-emails | React Email製メールテンプレート |
| twenty-website / twenty-docs | 公式サイト(Next.js)とドキュメント(Mintlify) |
| twenty-sdk / create-twenty-app | 拡張アプリ開発用のSDKとスキャフォールド |
| twenty-e2e-testing | Playwright E2E |
依存関係のハブは twenty-shared で、front・server・emails・website・docs のすべてがここに依存しています。ほかにSlack/Linear連携などの公式拡張アプリ集(twenty-apps)や、Twentyを操作するClaudeスキル集(twenty-claude-skills)といったパッケージもあり、拡張エコシステムを本体と同じリポジトリで育てているのがわかります。
起動はserver・front・workerの3プロセス
開発時の起動口はルートの yarn start で、実体は次のワンライナーです。
"start": "npx concurrently --kill-others 'npx nx run-many -t start -p twenty-server twenty-front' 'npx wait-on tcp:3000 && npx nx run twenty-server:worker'"
APIサーバーとフロントを並行起動しつつ、ポート3000が開くのを待ってからジョブワーカー(BullMQ)を立ち上げる、という依存順制御までnpm scripts 1行に収まっています。
マルチテナントはPostgreSQLのスキーマ分離
ワークスペース(テナント)ごとのデータ分離は、行レベルのフィルタではなくPostgreSQLスキーマの分離で実現しています。命名ロジックはシンプルで、ワークスペースごとに専用スキーマが作られます(get-workspace-schema-name.util.ts)。
export const getWorkspaceSchemaName = (workspaceId: string): string => {
return `workspace_${uuidToBase36(workspaceId)}`;
};
オブジェクト定義などのメタデータと認証・課金といったコアテーブルは core スキーマ、顧客の実データは各 workspace_* スキーマ、という分担です。あるテナントの変更が他テナントのテーブルに影響しない、テナント単位のバックアップや削除がしやすい、という分離の強さがこの方式の利点です。
では、各テナントのスキーマの中にどんなテーブルを作るかは何が決めているのか。それが次のメタデータの話です。
心臓部はメタデータ駆動アーキテクチャ
Twentyの設計でいちばん面白いのはここです。CompanyやPersonといったオブジェクト定義の「正」は、core スキーマにある objectMetadata テーブルです。標準オブジェクトの初期定義自体はコード(standard-objects/*.workspace-entity.ts のTypeScriptクラス。例: company.workspace-entity.ts)にあるのですが、ワークスペース作成時にこのテーブルへ同期され、以降ランタイムが参照するのはDB側のメタデータだけになります。
ユーザーが後から作るカスタムオブジェクトも同じテーブルに載ります。つまり同期が終わった後は、標準もカスタムも区別なく「メタデータの1行」として扱われます。
テーブルとGraphQL APIの二方向に導出される
このメタデータから、2つのものが自動導出されます。
1つ目は物理テーブルのDDLです。TypeORMを直接使わず、twenty-orm という独自ORM層(engine/twenty-orm/workspace-schema-manager/)がテーブル・カラム・インデックスの作成まで面倒を見ます。
2つ目はGraphQLスキーマです。workspace-schema.factory.ts がメタデータからスキーマを生成し、オブジェクトごとに findMany / createOne / updateMany / restoreOne といったCRUDリゾルバまで自動で生えます。といってもコード生成でファイルが書き出されるわけではなく、SDL文字列とリゾルバ関数を実行時にメモリ上で組み立てて makeExecutableSchema に渡す方式で、生成したSDLはワークスペースIDとメタデータバージョンをキーにRedisへキャッシュされます。

つまり、設定画面からオブジェクトを1つ追加すると、その場で「テーブル」と「型付きGraphQL API」の両方が手に入る構造です。管理画面のデータモデル設定を見ると、この設計がそのままUIに現れているのがわかります。

APIの入り口は4つ
APIの入り口も1つではなく、データ用の /graphql、オブジェクト定義を操作する /metadata、/rest、そしてAIエージェント向けの /mcp が最初から用意されています。ローカルで http://localhost:3000/graphql を開くと、Apollo SandboxでスキーマをそのままExploreできます。

カスタムオブジェクトは本物のテーブルになる
メタデータ駆動と聞くと、実データはEAVやJSONBのような汎用構造に入れているのでは、と想像するかもしれません。実際に確かめるため、シードデータに含まれるカスタムオブジェクト「Pets」のテーブルをpsqlで覗いてみました(抜粋・一部整形)。
default=# \d workspace_1wgv..."_pet"
Column | Type
------------------------+--------------------------------------
id | uuid
name | text
species | workspace_1wgv..."_pet_species_enum"
traits | workspace_1wgv..."_pet_traits_enum"[]
age | double precision
locationAddressStreet1 | text
locationAddressCity | text
searchVector | tsvector(generated column)
createdAt / deletedAt | timestamp with time zone
フィールド1つにつき実カラムが1つ切られた、完全な型付きテーブルでした。カスタムオブジェクトのテーブル名には、標準オブジェクトと区別するための _ プレフィックスが付きます。
セレクト型のフィールドはテナントスキーマ内にPostgreSQLのenum型ごと生成され、住所のような複合フィールドは複数の実カラムに展開されます。searchVector は全文検索用のgenerated columnです。つまり「設定画面でフィールドを足す」とは、文字通りその場で ALTER TABLE ADD COLUMN が走ることを意味します。普通のインデックスや全文検索がそのまま効くのはこの設計のおかげです。
スキーマ変更は全テナントに配って回る
Twenty本体のバージョンアップで標準オブジェクトの構造が変わると、全テナントの workspace_* スキーマに1つずつ変更を適用して回る必要があります。スキーマ分離方式の運用でいちばん重いところです。
それを担うコマンド基盤の基底クラス(WorkspaceCommandRunner)には --start-from-workspace-id(途中のテナントから再開)や --workspace-count-limit(処理件数の制限)、--dry-run といったオプションが用意されていて、多数のテナントを相手にするマイグレーションが「途中で失敗しうる長時間バッチ」として設計されているのが読み取れます。
TypeORMマイグレーションは凍結されている
TypeORMの標準的なマイグレーションは凍結されています。過去のマイグレーションは legacy-typeorm-migrations-do-not-add/ という強い名前のディレクトリに封印され、以降のスキーマ進化は “instance command” という独自の仕組み(#19356)に置き換えられています。テナントごとに動的にテーブルが生えるアーキテクチャだと、静的なマイグレーションファイルでは管理しきれない、という判断です。
この物騒なディレクトリ名の由来はリネームPR(#21295)に書かれています。凍結後も旧フォルダが現役のマイグレーション置き場に見えるため、新規コントリビューターやAIエージェントがファイルを追加し続けてしまい、「do-not-add という名前にするのが一番強いシグナル」としてリネームされたそうです。
技術選定の面白ポイント
コードを読んでいて目についた選定をいくつか挙げます。
状態管理はRecoilからJotaiへ
TwentyはRecoilの大規模採用例として知られていましたが、Recoil本体は開発が止まり、2025年1月にリポジトリがアーカイブされています。
これを受けてか、現在のコードでは jotai のimportが539ファイルある一方、recoilへの言及は0件でした。git履歴にも「Fully deprecate old recoil」というコミットがあり、Jotaiへの移行が完了しています。フロントはReact 19 + Apollo Client v4 + Vite + graphql-codegenという構成です。
lintと型チェックはRust系ツールチェーン
開発ツールチェーンはRust系に寄せています。lint/formatはESLint/Prettierではなくoxlintとoxfmt、型チェックも tsc ではなくtsgo(TypeScript Native Preview)です。26,000ファイル規模のモノレポでCIを回すための高速化として、思い切った選択だと感じました。
AIエージェント対応が標準装備
AI対応も本格的です。サーバーにはVercel AI SDK経由でAnthropic・OpenAI・Google・Bedrock・Azure・Mistral・xAIのプロバイダが組み込まれ、先述の通りMCPエンドポイントも標準装備です。CRMをAIエージェントから操作させる前提の設計になっています。
おわりに
「オブジェクト定義をメタデータとしてDBに置き、そこからテーブルもAPIも自動導出する」という骨格は、カスタマイズ性が本質的な価値になるCRMというドメインに、うまく噛み合った設計だと感じました。
一方で、この構造は「どこに何を足せばいいか」を掴むまでが少し大変です。今後も手を動かしながら、わかったことをこのブログで紹介していきます。
Anycloudでは一緒に働くメンバーを募集しています!
Anycloudは、ユーザーの心を動かす体験を届けることを大切にしています。フルリモート・フルフレックスの環境のもと、ライフスタイルに合わせた働き方を実現しながら挑戦したい方を歓迎します。詳細はこちらをご覧ください。