syncable-entity-integration について
NestJSに同期可能なエンティティを追加する開発者向けの統合手順ガイドです。モジュール登録、サービスとリゾルバーの分離、GraphQL例外処理を扱います。
主な機能
- 01ビルダー、バリデーター、アクションハンドラーの登録
- 02フラットエンティティを返すサービスの作成
- 03フラットエンティティからDTOへの変換
- 04インターセプターによるGraphQLエラーの整形
活用シーン
- 01ワークスペース移行モジュールへのエンティティ統合
- 02GraphQL APIへのエンティティサービスとリゾルバー追加
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ twentyhq/ twenty --skill syncable-entity-integrationインストール後、Claude Code で/syncable-entity-integrationを入力して読み込まれたか確認します。
詳細
同期可能エンティティ: 統合 (ステップ 5/6)
目的: すべてを連携させ、モジュールに登録し、サービスとリゾルバーを作成します。
使用するタイミング: ステップ 1〜4(すべての前のステップ)を完了した後。テストの前に必要です。
クイックスタート
このステップでは:
- 3 つの NestJS モジュールにサービスを登録します
- サービスレイヤーを作成します(フラットなエンティティを返します)
- リゾルバーレイヤーを作成します(フラット → DTO に変換します)
- GraphQL 用の例外インターセプターを使用します
重要な原則: サービスはフラットなエンティティを返し、リゾルバーはフラット → DTO に変換します。
ステップ 1: ビルダーモジュールに登録する
ファイル: src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts
import { WorkspaceMigrationMyEntityActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service';
@Module({
imports: [
// ... existing imports
],
providers: [
// ... existing providers
WorkspaceMigrationMyEntityActionsBuilderService,
],
exports: [
// ... existing exports
WorkspaceMigrationMyEntityActionsBuilderService,
],
})
export class WorkspaceMigrationBuilderModule {}
重要: providers と exports の両方に追加してください(オーケストレーターのためにビルダーをエクスポートする必要があります)。
ステップ 2: バリデーターズモジュールに登録する
ファイル: src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts
import { FlatMyEntityValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service';
@Module({
imports: [
// ... existing imports
],
providers: [
// ... existing providers
FlatMyEntityValidatorService,
],
exports: [
// ... existing exports
FlatMyEntityValidatorService,
],
})
export class WorkspaceMigrationBuilderValidatorsModule {}
ステップ 3: アクションハンドラーを登録する
ファイル: src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts
import { CreateMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/create-my-entity-action-handler.service';
import { UpdateMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/update-my-entity-action-handler.service';
import { DeleteMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/delete-my-entity-action-handler.service';
@Module({
imports: [
// ... existing imports
],
providers: [
// ... existing providers
CreateMyEntityActionHandlerService,
UpdateMyEntityActionHandlerService,
DeleteMyEntityActionHandlerService,
],
exports: [
// ... existing exports (action handlers typically not exported)
],
})
export class WorkspaceSchemaMigrationRunnerActionHandlersModule {}
注意: アクションハンドラーは通常 providers のみに含まれ、exports には含まれません。
ステップ 4: サービスレイヤーを作成する
ファイル: src/engine/metadata-modules/my-entity/my-entity.service.ts
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { fromCreateMyEntityInputToUniversalFlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/utils/from-create-my-entity-input-to-universal-flat-my-entity.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class MyEntityService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async create(input: CreateMyEntityInput, workspaceId: string): Promise<FlatMyEntity> {
// 1. Transform input to universal flat entity
const universalFlatMyEntityToCreate = fromCreateMyEntityInputToUniversalFlatMyEntity({
input,
workspaceId,
});
// 2. Validate, build, and run
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
myEntity: {
flatEntityToCreate: [universalFlatMyEntityToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
// 3. Throw if validation failed
if (isDefined(result)) {
throw new WorkspaceMigrationBuilderException(
result,
'Validation errors occurred while creating entity',
);
}
// 4. Return freshly cached flat entity
const { flatMyEntityMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatMyEntityMaps'],
},
);
return findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: universalFlatMyEntityToCreate.id,
flatEntityMaps: flatMyEntityMaps,
});
}
}
サービスのパターン:
- 入力を → ユニバーサルなフラットエンティティに変換します
validateBuildAndRunWorkspaceMigrationを呼び出します- 検証エラーがある場合はスローします
- フラットなエンティティを返します(DTO ではありません)
ステップ 5: リゾルバーレイヤーを作成する
ファイル: src/engine/metadata-modules/my-entity/my-entity.resolver.ts
import { UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { MyEntityService } from 'src/engine/metadata-modules/my-entity/my-entity.service';
import { fromFlatMyEntityToMyEntityDto } from 'src/engine/metadata-modules/my-entity/utils/from-flat-my-entity-to-my-entity-dto.util';
@Resolver(() => MyEntityDto)
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
export class MyEntityResolver {
constructor(private readonly myEntityService: MyEntityService) {}
@Mutation(() => MyEntityDto)
async createMyEntity(
@Args('input') input: CreateMyEntityInput,
@Workspace() { id: workspaceId }: Workspace,
): Promise<MyEntityDto> {
// Service returns flat entity
const flatMyEntity = await this.myEntityService.create(input, workspaceId);
// Resolver converts flat entity to DTO
return fromFlatMyEntityToMyEntityDto(flatMyEntity);
}
@Mutation(() => MyEntityDto)
async updateMyEntity(
@Args('id') id: string,
@Args('input') input: UpdateMyEntityInput,
@Workspace() { id: workspaceId }: Workspace,
): Promise<MyEntityDto> {
const flatMyEntity = await this.myEntityService.update(id, input, workspaceId);
return fromFlatMyEntityToMyEntityDto(flatMyEntity);
}
@Mutation(() => Boolean)
async deleteMyEntity(
@Args('id') id: string,
@Workspace() { id: workspaceId }: Workspace,
) {
await this.myEntityService.delete(id, workspaceId);
return true;
}
}
リゾルバーの責務:
- サービスからフラットなエンティティを受け取ります
- 変換ユーティリティを使用して フラット → DTO に変換します
- DTO を GraphQL API に返します
- エラーフォーマットのために例外インターセプターを使用します
ステップ 6: フラットから DTO への変換
ファイル: src/engine/metadata-modules/my-entity/utils/from-flat-my-entity-to-my-entity-dto.util.ts
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
import { type MyEntityDto } from 'src/engine/metadata-modules/my-entity/dtos/my-entity.dto';
export const fromFlatMyEntityToMyEntityDto = (
flatMyEntity: FlatMyEntity,
): MyEntityDto => {
return {
id: flatMyEntity.id,
name: flatMyEntity.name,
label: flatMyEntity.label,
description: flatMyEntity.description,
isCustom: flatMyEntity.isCustom,
createdAt: flatMyEntity.createdAt,
updatedAt: flatMyEntity.updatedAt,
// Convert foreign key IDs to relation objects if needed
// parentEntity: flatMyEntity.parentEntityId ? { id: flatMyEntity.parentEntityId } : null,
};
};
レイヤーの責務
| レイヤー | 入力 | 出力 | 責務 |
|---|---|---|---|
| サービス | 入力 DTO | フラットエンティティ | ビジネスロジック、検証のオーケストレーション |
| リゾルバー | サービスの結果 | DTO | フラット → DTO への変換、GraphQL への公開 |
サービスレイヤー:
- 内部的にフラットなエンティティを扱います
FlatMyEntity型を返します- DTO や GraphQL 型に関する知識は持ちません
リゾルバーレイヤー:
- サービスからフラットなエンティティを受け取ります
- フラットなエンティティを DTO に変換します
- DTO を GraphQL API に返します
例外インターセプター
WorkspaceMigrationGraphqlApiExceptionInterceptor は以下を自動的に処理します:
FlatEntityMapsException→ GraphQL エラー(NotFoundError など)に変換しますWorkspaceMigrationBuilderException→ 検証エラーを i18n でフォーマットしますWorkspaceMigrationRunnerException→ ランナーのエラーをフォーマットします
実行内容:
- 例外をキャッチし、API レスポンス用にフォーマットします
- ユーザーのロケールに基づいてエラーメッセージを翻訳します
- フロントエンド向けに一貫したエラー構造を保証します
チェックリスト
ステップ 6(テスト)に進む前に:
- ビルダーがビルダーモジュールに登録されている(providers + exports)
- バリデーターがバリデーターズモジュールに登録されている(providers + exports)
- 3 つのアクションハンドラーすべてがアクションハンドラーモジュールに登録されている(providers)
- サービスレイヤーが作成されている
- サービスがフラットなエンティティを返す(DTO ではない)
- リゾルバーレイヤーが作成されている
- リゾルバーが例外インターセプターを使用している
- リゾルバーがフラット → DTO に変換する
- フラットから DTO への変換ユーティリティが作成されている
次のステップ
統合が完了したら、(必須)に進んでください: 同期可能エンティティ: 統合テスト (ステップ 6/6)
完全なワークフローについては、@creating-syncable-entity ルールを参照してください。