はじめに
個人でタスク管理アプリを作ろうと思い、NestJS + Prisma + GraphQL の構成に挑戦していた時、こんな疑問が湧きました。
「なんで同じようなモデル定義を 2 回も書かないといけないの?」
// 1. Prismaモデル(schema.prisma)
model Task {
id Int @id @default(autoincrement())
name String
dueDate String
status Status?
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// 2. GraphQLモデル(TypeScript)
@ObjectType()
export class Task {
@Field(() => Int)
id: number;
@Field()
name: string;
@Field()
dueDate: string;
@Field({ nullable: true })
status?: Status;
@Field({ nullable: true })
description?: string;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
}
最初は「二度手間じゃん...」と思っていましたが、実際に開発を進めるうちになぜ分ける必要があるのかが分かってきました。
最初にハマったポイント
GraphQL モデル = DB の値をそのまま返すもの?
最初は「GraphQL モデルは DB から取得できる値のリスト」程度に思ってました。
でも実際は全然違いました。
それぞれの役割を理解した瞬間
Prisma モデル = データベース設計
// schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
password String // ← ハッシュ化されたパスワード
firstName String
lastName String
birthDate DateTime?
salary Int? // ← 給与情報
role String @default("USER")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
これは「データベースにどう保存するか」の設計図。
GraphQL モデル = API 設計
// user.model.ts
@ObjectType()
export class User {
@Field(() => Int)
id: number;
@Field()
email: string;
// ❌ password は意図的に除外(セキュリティ)
// ❌ salary も除外(機密情報)
@Field()
firstName: string;
@Field()
lastName: string;
// ✅ DBにはないけど、APIで提供したい計算フィールド
@Field()
fullName: string; // firstName + lastName
@Field(() => Int, { nullable: true })
age?: number; // birthDateから計算
@Field()
role: string;
@Field()
createdAt: Date;
}
これは「クライアントに何を提供するか」の設計図。
実際に遭遇したパターン
パターン 1: セキュリティによる除外
個人アプリでも、ユーザー機能を追加する時にこんなミスをしました:
// ❌ 最初にやらかした例
@ObjectType()
export class User {
@Field()
password: string; // パスワードハッシュがAPIに出ちゃう!
@Field()
email: string;
}
// GraphQL Playgroundで確認したら...
// パスワードハッシュが丸見え!😱
慌てて修正:
// ✅ 正しい例
@ObjectType()
export class User {
@Field(() => Int)
id: number;
@Field()
email: string;
// password フィールドは@Fieldを付けない
// → GraphQLスキーマに含まれない = APIに出ない
}
パターン 2: 計算フィールドの追加
タスク管理アプリを使いやすくするため、こんな機能を追加しました:
@ObjectType()
export class Task {
@Field()
name: string;
@Field()
dueDate: string; // "2025-07-01"
// DBにはないけど、フロントで欲しい情報
@Field(() => Boolean)
isOverdue: boolean; // 期限切れかどうか
@Field(() => Int)
daysUntilDue: number; // 期限まで何日か
@Field()
urgencyLevel: string; // 期限から自動計算した緊急度
}
パターン 3: 関連データの組み合わせ
タスクに関連する情報をまとめて取得できるように:
@ObjectType()
export class Task {
@Field(() => Int)
id: number;
@Field()
name: string;
// 関連するユーザー情報も一緒に取得可能
@Field(() => User, { nullable: true })
assignedUser?: User;
// コメント一覧も含められる
@Field(() => [Comment])
comments: Comment[];
// カテゴリ情報
@Field(() => Category, { nullable: true })
category?: Category;
}
パターン 4: 外部 API との組み合わせ
タスク管理アプリをもっと便利にするため、外部サービスとの連携も追加:
@ObjectType()
export class Task {
@Field()
name: string; // DBから
@Field()
location: string; // DBから
// 今後の拡張で追加予定
// GitHub APIから取得
@Field(() => Int, { nullable: true })
relatedIssues?: number;
// Notion APIから取得
@Field(() => String, { nullable: true })
notionPageUrl?: string;
}
タスク管理アプリでの実装例
実際にこんなコードを書きました:
// リゾルバーで計算ロジックを実装
@Resolver(() => Task)
export class TaskResolver {
@ResolveField(() => Boolean)
isOverdue(@Parent() task: Task): boolean {
return new Date(task.dueDate) < new Date();
}
@ResolveField(() => Int)
daysUntilDue(@Parent() task: Task): number {
const due = new Date(task.dueDate);
const now = new Date();
const diff = due.getTime() - now.getTime();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
}
@ResolveField(() => String)
urgencyLevel(@Parent() task: Task): string {
const days = this.calculateDaysUntilDue(task);
if (days < 0) return "OVERDUE";
if (days <= 1) return "URGENT";
if (days <= 3) return "HIGH";
return "NORMAL";
}
}
よくある間違いパターン
間違い 1: 全ての DB フィールドを GraphQL に公開
// ❌ これだとセキュリティリスク
@ObjectType()
export class User {
@Field() password: string; // 危険!
@Field() resetToken: string; // 危険!
@Field() internalNotes: string; // 危険!
}
間違い 2: GraphQL モデルに業務ロジックを詰め込み
// ❌ モデルが複雑になりすぎ
@ObjectType()
export class Task {
@Field()
calculatePriorityBasedOnComplexBusinessRule(): string {
// 100行の複雑なロジック...
}
}
// ✅ サービス層に切り出す
@Injectable()
export class TaskService {
calculatePriority(task: Task): string {
// 複雑なロジックはここに
}
}
自動生成ツールはどうなの?
GraphQL モデルを自動生成できるツールもあります:
// Prisma + TypeGraphQLの例
import { Task } from "@generated/type-graphql";
でも個人開発でも手動が良い理由
- 機能追加時の柔軟性
- 計算フィールドが必要になる
- 段階的な機能公開
- 学習効果が高い
自動生成は最初のプロトタイプには良いけど、本格的に作るなら手動設計がおすすめです。
個人開発での使い分けルール
Prisma モデル設計時の観点
- データの保存効率
- クエリのパフォーマンス
- データの整合性
- 将来の拡張性
GraphQL モデル設計時の観点
- フロントエンドの使いやすさ
- 必要な情報の過不足
- レスポンスの軽量化
- 機能の段階的な追加
実際の開発フロー
1. 要件定義
「タスクの緊急度を一目で分かるようにしたい」
↓
2. Prismaモデル設計
「dueDateとcreatedAtを保存しよう」
↓
3. GraphQLモデル設計
「緊急度はAPIで計算して返そう」
↓
4. リゾルバー実装
「計算ロジックを書こう」
まとめ
最初は「同じことを 2 回書くのは無駄」と思っていましたが、実際は:
- Prisma モデル: データの保存方法を決める
- GraphQL モデル: データの提供方法を決める
全く別の責任を持っていることが分かりました。
現場で使える考え方
- Prisma モデル: データベース管理者の視点
- GraphQL モデル: フロントエンド開発者の視点
この 2 つの視点で設計すると、自然と適切な分離ができます。
実践的なアドバイス
初期開発時
- まず Prisma モデルで DB 設計
- シンプルな GraphQL モデルから開始
- 必要に応じて計算フィールド・関連データを追加
運用時
- 機能追加時のパフォーマンス確認
- フロントエンドでの使い勝手チェック
- 新機能のテスト
2 つのモデルを適切に使い分けることで、安全で使いやすい APIが作れるようになります!
参考:よく使うパターン集
// 基本パターン
@ObjectType()
export class Task {
// 必須フィールド
@Field(() => Int)
id: number;
// オプショナルフィールド
@Field({ nullable: true })
description?: string;
// 計算フィールド
@Field(() => Boolean)
isCompleted: boolean;
// 関連フィールド
@Field(() => User)
assignedUser: User;
// 配列フィールド
@Field(() => [String])
tags: string[];
// カスタムスカラー
@Field(() => Date)
createdAt: Date;
}
この基本パターンを覚えておけば、大体のケースに対応できそうです!