# オープンソースCRM「Twenty」のアーキテクチャを読む

> OSS CRM「Twenty」のコードリーディング記録です。packages/フラットのモノレポ構成、PostgreSQLスキーマ分離によるマルチテナント、オブジェクト定義からDBスキーマとGraphQL APIを自動生成するメタデータ駆動設計などを解説します。

- 公開日: 2026-07-10
- 著者: 村井 謙太
- タグ: Twenty, CRM, 新しい技術で検証
- URL: https://tech.anycloud.co.jp/articles/twenty-architecture

---

[前回の記事](/articles/twenty-local-setup)で、オープンソースCRM「Twenty」のローカル開発環境を構築しました。今回はコードを読んで、1.6GB・約26,000ファイルのモノレポ（[twentyhq/twenty](https://github.com/twentyhq/twenty)）の中身がどうなっているかを見ていきます。

読んでみると、Twentyは「CRMアプリ」というより「CRMを組み立てるフレームワーク」に近い設計でした。この記事では、その骨格にあたるモノレポ構成・マルチテナント設計・メタデータ駆動アーキテクチャの3つと、技術選定の面白かった点を紹介します。

## packages/にフラットに並ぶモノレポ構成

[Nx](https://nx.dev/) のモノレポというと、[公式チュートリアル](https://nx.dev/docs/getting-started/tutorials/crafting-your-workspace)にもあるように `apps/`（デプロイ対象のアプリ）と `packages/`（共有ライブラリ）に分ける構成が定番ですが、Twentyは `packages/` ひとつにフラットに並べるスタイルです。[nx.json](https://github.com/twentyhq/twenty/blob/main/nx.json)に次の設定があり、アプリもライブラリも置き場所を区別していません。

```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` で、実体は次のワンライナーです。

```json
"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](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/workspace-datasource/utils/get-workspace-schema-name.util.ts)）。

```typescript
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](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts)）にあるのですが、ワークスペース作成時にこのテーブルへ同期され、以降ランタイムが参照するのはDB側のメタデータだけになります。

ユーザーが後から作るカスタムオブジェクトも同じテーブルに載ります。つまり同期が終わった後は、標準もカスタムも区別なく「メタデータの1行」として扱われます。

### テーブルとGraphQL APIの二方向に導出される

このメタデータから、2つのものが自動導出されます。

1つ目は**物理テーブルのDDL**です。TypeORMを直接使わず、`twenty-orm` という独自ORM層（[engine/twenty-orm/workspace-schema-manager/](https://github.com/twentyhq/twenty/tree/main/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager)）がテーブル・カラム・インデックスの作成まで面倒を見ます。

2つ目は**GraphQLスキーマ**です。[workspace-schema.factory.ts](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts) がメタデータからスキーマを生成し、オブジェクトごとに `findMany` / `createOne` / `updateMany` / `restoreOne` といったCRUDリゾルバまで自動で生えます。といってもコード生成でファイルが書き出されるわけではなく、SDL文字列とリゾルバ関数を実行時にメモリ上で組み立てて `makeExecutableSchema` に渡す方式で、生成したSDLはワークスペースIDとメタデータバージョンをキーにRedisへキャッシュされます。

![Twentyのメタデータ駆動の流れ。コードの標準オブジェクト定義と設定画面のカスタムオブジェクトが core.objectMetadata に集約され、そこから物理テーブルとGraphQLスキーマが導出される](./metadata-flow.webp)

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

![TwentyのData model設定画面。Standard/Customオブジェクトの一覧とER図が表示されている](./data-model.webp)

### APIの入り口は4つ

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

![localhost:3000/graphqlで開くApollo Sandbox](./graphql-playground.webp)

## カスタムオブジェクトは本物のテーブルになる

メタデータ駆動と聞くと、実データはEAVやJSONBのような汎用構造に入れているのでは、と想像するかもしれません。実際に確かめるため、シードデータに含まれるカスタムオブジェクト「Pets」のテーブルをpsqlで覗いてみました（抜粋・一部整形）。

```text
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](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/database/commands/command-runners/workspace.command-runner.ts)）には `--start-from-workspace-id`（途中のテナントから再開）や `--workspace-count-limit`（処理件数の制限）、`--dry-run` といったオプションが用意されていて、多数のテナントを相手にするマイグレーションが「途中で失敗しうる長時間バッチ」として設計されているのが読み取れます。

### TypeORMマイグレーションは凍結されている

TypeORMの標準的なマイグレーションは凍結されています。過去のマイグレーションは [legacy-typeorm-migrations-do-not-add/](https://github.com/twentyhq/twenty/tree/main/packages/twenty-server/src/database/typeorm/core/legacy-typeorm-migrations-do-not-add) という強い名前のディレクトリに封印され、以降のスキーマ進化は "instance command" という独自の仕組み（[#19356](https://github.com/twentyhq/twenty/pull/19356)）に置き換えられています。テナントごとに動的にテーブルが生えるアーキテクチャだと、静的なマイグレーションファイルでは管理しきれない、という判断です。

この物騒なディレクトリ名の由来は[リネームPR（#21295）](https://github.com/twentyhq/twenty/pull/21295)に書かれています。凍結後も旧フォルダが現役のマイグレーション置き場に見えるため、新規コントリビューターやAIエージェントがファイルを追加し続けてしまい、「`do-not-add` という名前にするのが一番強いシグナル」としてリネームされたそうです。

## 技術選定の面白ポイント

コードを読んでいて目についた選定をいくつか挙げます。

### 状態管理はRecoilからJotaiへ

TwentyはRecoilの大規模採用例として知られていましたが、Recoil本体は開発が止まり、[2025年1月にリポジトリがアーカイブされています](https://github.com/facebookexperimental/Recoil)。

これを受けてか、現在のコードでは `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というドメインに、うまく噛み合った設計だと感じました。

一方で、この構造は「どこに何を足せばいいか」を掴むまでが少し大変です。今後も手を動かしながら、わかったことをこのブログで紹介していきます。
