🛠️

Convexの設計思想と実装パターンの解説

に公開

この記事 is 何

Convex が非凡なサービスであることは以前の記事で紹介したが,実運用する場合にどのような構成で実装を進めればよいのだろうか.

今回,実装を通して Convex の設計思想を把握して筆者なりの解釈がまとまったので共有する.3 行でまとめると以下の通り.特に関数ベースの実装が筆者の価値観と合致していて良い.推せる.

  1. 6 種類の責務を理解せよ.
  2. Schema に従え.
  3. 関数しか勝たん.

設計思想は理に適っていると感じる部分が多く,思想に従って実装すると非常に保守性の高いコードになると感じた.

本記事のもととなったプロジェクトは Next.js で実装しており,convex-auth と convex-test を用いた.

なお,Convex のドキュメントを参照しながらの実装ではあるがいわゆる「完全に理解した」状態であり,完全に理解した状態であることを完全に理解したうえで参考にしていただける幸いである.

今回の内容で実装すると下記のような構成になる.

convex/
├── schema.ts                      // スキーマ定義
├── actions/
│   ├── articles/
│   │   ├── publishArticle.ts      // 外部連携
│   │   └── ...                    // その他のAction
│   └── ...
├── api/
│   ├── articles/
│   │   ├── getUserArticles.ts     // フロントから呼び出すAPI
│   │   └── ...                    // その他のAPI
│   └── ...
├── queries/
│   ├── articles/
│   │   ├── getArticle.ts          // Read (single)
│   │   ├── getArticles.ts         // Read (list)
│   │   └── searchArticles.ts      // Read (search)
│   └── ...
├── mutations/
│   ├── articles/
│   │   ├── createArticle.ts       // Create
│   │   ├── updateArticle.ts       // Update
│   │   └── deleteArticle.ts       // Delete
│   └── ...
├── services/
│   ├── articles/
│   │   ├── formatArticleForSEO.ts // 純粋関数
│   │   └── ...                    // その他のサービス関数
│   └── ...
└── ...                            // その他のディレクトリ

1. Convex の設計思想

Convex を採用する場合はまず設計思想を知ることが極めて重要である.実装する際に「設計思想に沿った状態」にしないと目も当てられない状態になる.

コアコンセプト

  • リアルタイム・ファースト: WebSocket による自動同期とリアクティブ更新
  • 型安全性の徹底: Schema-driven アプローチと TypeScript 型生成
  • トランザクション整合性: ACID 保証による信頼性の高いデータ操作
  • 関数型アプローチ: Immutable データと副作用の分離

純粋関数と副作用の分離パターン

Convex では純粋関数(Pure 関数)と副作用を伴う関数(Mutation や Action)を明確に分離することが推奨されている.ロジック=純粋関数,副作用=Mutation/Action と分けることでテスタビリティと再利用性が向上する.混ぜるな危険.

// 純粋関数(計算ロジック)
export const calculateArticleStats = (
  article: Article,
  comments: Comment[]
): ArticleStatistics => {
  // 決定論的な変換ロジック
  return {
    commentCount: comments.length,
    avgRating: comments.reduce((sum, c) => sum + c.rating, 0) / comments.length,
    readTime: Math.ceil(article.content.length / 200), // words per minute
  };
};

// Mutation(DB書き込み / 副作用処理)
export const updateArticleStats = internalMutation({
  handler: async (ctx, args) => {
    const article = await ctx.db.get(args.articleId);
    const comments = await ctx.db
      .query("comments")
      .withIndex("by_article", (q) => q.eq("articleId", args.articleId))
      .collect();

    const stats = calculateArticleStats(article, comments); // 純粋関数呼び出し
    await ctx.db.insert("articleStats", {
      articleId: args.articleId,
      ...stats,
    }); // 副作用
  },
});

2. Convex の基本機能

Convex では以下の 3 つの関数タイプを提供している.これは明確な違いがあり,違いのわかるエンジニアになっておかないとコードが悲惨なことになる.

3 つの関数タイプの特徴

Convex では以下の 3 つの関数タイプでアプリケーションロジックを構築する.特に DB 関連の処理でも読み取りと書き込みを分離する点が非常に重要である.

Query - 読み取り専用関数

特徴:

  • 読み取り専用: データベースの変更不可
  • リアクティブ: データ変更時に自動再実行
  • キャッシュ可能: パフォーマンス最適化
  • 並列実行: 複数クエリの同時実行可能

用途: データ表示,検索,集計処理など

Mutation - 書き込み関数

特徴:

  • 書き込み専用: データベースの変更が可能
  • トランザクション: ACID 保証による一貫性
  • 順次実行: 競合状態の回避
  • 冪等性: 同じ操作の重複実行に対する保護

用途: データ作成,更新,削除など

Action - 外部連携・長時間処理

特徴:

  • 外部 API 連携: HTTP リクエスト,メール送信等
  • 長時間実行: 時間のかかる処理に最適
  • 間接的 DB 操作: ctx.runQuery/ctx.runMutation経由
  • Node.js 環境: 豊富なライブラリが使用可能

用途: メール送信,画像処理,外部 API 連携,バッチ処理など

3. 公開関数 vs Internal 関数の分離

先の 3 種類とは別軸で,Convex の関数は「公開関数」と「Internal 関数」に分けることが推奨されている.

雑に説明すると公開関数はフロントエンドから直接呼び出される関数で,Internal 関数はサーバー内部でのみ使用される関数である.

基本的にロジック自体はすべて Internal 関数に実装し,公開関数は認証・認可や入力検証などの API 層として振る舞う.実装した Internal 関数は公開関数から呼び出して使用される形にする.

バックエンド Saas を使用する場合にセキュリティ境界が問題となることが多いが,Convex ではこの分離によりセキュリティ境界を明確にできる.「バックエンドのコードはサーバで動かす」が原則であることを忘れてはならない.

2 つの軸による関数分類

先の 3 種類の分類と合わせて,Convex では 6 種類の関数タイプ がある.

           │ 公開関数         │ Internal関数
───────────┼─────────────────┼──────────────────
Query      │ query           │ internalQuery
Mutation   │ mutation        │ internalMutation
Action     │ action          │ internalAction

なぜ関数を分離するのか

1. セキュリティ境界の明確化

// ❌ 危険: すべてのロジックを公開関数に入れる
export const adminDeleteUser = mutation({
  args: { userId: v.id("users"), isAdmin: v.boolean() },
  handler: async (ctx, args) => {
    // 🚫 フロントエンドから「isAdmin: true」を送信可能
    if (!args.isAdmin) {
      throw new Error("管理者権限が必要です");
    }
    await ctx.db.delete(args.userId);
  },
});

// ✅ 安全: 認証は公開層,ロジックはInternal層
export const adminDeleteUser = mutation({
  args: { userId: v.id("users") },
  handler: async (ctx, args) => {
    const currentUserId = await getAuthUserId(ctx);
    const isAdmin = await checkAdminRole(currentUserId); // サーバー側で検証

    if (!isAdmin) throw new Error("管理者権限が必要です");

    return await ctx.runMutation(internal.users.deleteUser, {
      userId: args.userId,
      adminUserId: currentUserId,
    });
  },
});

2. 再利用性の向上

Internal 関数は複数の場所から呼び出し可能.

// 再利用可能なInternal関数
export const incrementArticleViews = internalMutation({
  args: { articleId: v.id("articles") },
  handler: async (ctx, args) => {
    const article = await ctx.db.get(args.articleId);
    await ctx.db.patch(args.articleId, {
      views: (article.views || 0) + 1,
    });
  },
});

// 複数の場所から呼び出し
export const getArticleWithViewIncrement = mutation({
  handler: async (ctx, args) => {
    await ctx.runMutation(internal.articles.incrementArticleViews, args);
    return await ctx.db.get(args.articleId);
  },
});

export const trackArticleView = action({
  handler: async (ctx, args) => {
    await ctx.runMutation(internal.articles.incrementArticleViews, args);
    await logAnalytics("article_view", args);
  },
});

API 層の「薄いラッパー」原則

公開関数(API 層)は可能な限り薄いラッパーにするとよいらしい.

❌ 厚い API 層 - 避けるべきパターン

export const createArticle = mutation({
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("認証が必要です");

    // 🚫 API層にビジネスロジックを詰め込み
    const wordCount = args.content.split(" ").length;
    const readTime = Math.ceil(wordCount / 200);
    const tags = extractHashtags(args.content);
    const seoData = generateSEOMetadata(args.title, args.content);

    // 重複チェック
    const existing = await ctx.db
      .query("articles")
      .withIndex("by_title", (q) => q.eq("title", args.title))
      .first();
    if (existing) throw new Error("同じタイトルの記事が存在します");

    // 複雑な作成ロジック...
    const articleId = await ctx.db.insert("articles", {
      ...args,
      authorId: userId,
      wordCount,
      readTime,
      tags,
      seoData,
    });

    // 関連データの作成
    await ctx.db.insert("articleStats", { articleId, views: 0 });

    return { success: true, articleId };
  },
});

✅ 薄い API 層 - 推奨パターン

// 公開API: 認証・認可のみ担当
export const createArticle = mutation({
  args: { title: v.string(), content: v.string() },
  handler: async (ctx, args) => {
    // API層の責務:認証・認可のみ
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("認証が必要です");

    // ビジネスロジックはすべてInternal層に委譲
    return await ctx.runMutation(internal.articles.createArticle, {
      ...args,
      authorId: userId,
    });
  },
});

// Internal: ビジネスロジック
export const createArticle = internalMutation({
  args: {
    title: v.string(),
    content: v.string(),
    authorId: v.id("users"),
  },
  handler: async (ctx, args) => {
    // ビジネスロジックの実装
    const wordCount = args.content.split(" ").length;
    const readTime = Math.ceil(wordCount / 200);
    const tags = extractHashtags(args.content);

    // 重複チェック
    const existing = await ctx.db
      .query("articles")
      .withIndex("by_title", (q) => q.eq("title", args.title))
      .first();
    if (existing) throw new Error("同じタイトルの記事が存在します");

    const articleId = await ctx.db.insert("articles", {
      ...args,
      wordCount,
      readTime,
      tags,
      status: "draft",
      createdAt: Date.now(),
    });

    await ctx.db.insert("articleStats", { articleId, views: 0 });

    return { articleId, status: "created" };
  },
});

薄いラッパー原則の利点

  1. セキュリティ: 認証・認可ロジックが一箇所に集約
  2. テスタビリティ: ビジネスロジックを独立してテスト可能
  3. 再利用性: Internal 関数を複数の公開関数から利用
  4. 保守性: ビジネスロジックの変更時に API 層への影響を最小化

4. 実装例とフロントエンド連携

フロントから呼び出すときは useQuery / useMutation / useAction を使う.これは先に説明した公開関数を呼び出す記述である.Internal 関数はフロントから直接呼び出せない.

6 種類の関数タイプ実装例

Query 系関数

公開 Query - フロントエンドから直接呼び出し

export const getPublishedArticles = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("articles")
      .filter((q) => q.eq(q.field("status"), "published"))
      .order("desc")
      .take(args.limit ?? 10);
  },
});

Internal Query - サーバー内部で使用

export const getArticleWithStats = internalQuery({
  args: { articleId: v.id("articles") },
  handler: async (ctx, args) => {
    const article = await ctx.db.get(args.articleId);
    const stats = await ctx.db
      .query("articleStats")
      .withIndex("by_article", (q) => q.eq("articleId", args.articleId))
      .first();

    return { ...article, stats };
  },
});

Mutation 系関数

公開 Mutation - 認証・認可層

export const updateArticle = mutation({
  args: { articleId: v.id("articles"), title: v.string() },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("認証が必要です");

    return await ctx.runMutation(internal.articles.updateArticle, {
      ...args,
      requestingUserId: userId,
    });
  },
});

Internal Mutation - ビジネスロジック層

export const updateArticle = internalMutation({
  args: {
    articleId: v.id("articles"),
    title: v.string(),
    requestingUserId: v.id("users"),
  },
  handler: async (ctx, args) => {
    // 権限チェック
    const article = await ctx.db.get(args.articleId);
    if (article.authorId !== args.requestingUserId) {
      throw new Error("編集権限がありません");
    }

    // 更新実行
    await ctx.db.patch(args.articleId, {
      title: args.title,
      updatedAt: Date.now(),
    });

    return { success: true };
  },
});

Action 系関数

公開 Action - 外部処理のエントリーポイント

export const publishArticleWithNotification = action({
  args: { articleId: v.id("articles") },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("認証が必要です");

    return await ctx.runAction(
      internal.articles.publishArticleWithNotification,
      {
        ...args,
        requestingUserId: userId,
      }
    );
  },
});

Internal Action - 外部連携とオーケストレーション

export const publishArticleWithNotification = internalAction({
  args: {
    articleId: v.id("articles"),
    requestingUserId: v.id("users"),
  },
  handler: async (ctx, args) => {
    // 記事公開
    await ctx.runMutation(internal.articles.publishArticle, args);

    // 記事データ取得
    const article = await ctx.runQuery(
      internal.articles.getArticleWithStats,
      args
    );

    // 外部メール送信
    await sendNotificationEmail({
      to: article.authorEmail,
      subject: `記事「${article.title}」が公開されました`,
      articleData: article,
    });

    // SNS自動投稿
    await postToSocialMedia({
      title: article.title,
      url: `https://example.com/articles/${args.articleId}`,
    });

    return { success: true, notifications: ["email", "social"] };
  },
});

フロントエンドでの使い方

// React Component例
function ArticleDashboard() {
  // Query: リアクティブなデータ取得
  const articles = useQuery(api.queries.articles.getPublishedArticles, {
    limit: 20,
  });

  // Mutation: データ更新操作
  const updateArticle = useMutation(api.mutations.articles.updateArticle);
  const createArticle = useMutation(api.mutations.articles.createArticle);

  // Action: 外部連携処理
  const publishWithNotification = useAction(
    api.actions.articles.publishArticleWithNotification
  );

  const handleCreate = async () => {
    try {
      const result = await createArticle({
        title: "新しい記事",
        content: "記事の内容",
      });
      console.log("記事作成完了:", result.articleId);
    } catch (error) {
      console.error("作成エラー:", error);
    }
  };

  const handlePublish = async (articleId: Id<"articles">) => {
    try {
      await publishWithNotification({ articleId });
      console.log("記事公開とメール送信完了");
    } catch (error) {
      console.error("公開エラー:", error);
    }
  };

  const handleTitleUpdate = async (
    articleId: Id<"articles">,
    newTitle: string
  ) => {
    try {
      await updateArticle({ articleId, title: newTitle });
      console.log("タイトル更新完了");
    } catch (error) {
      console.error("更新エラー:", error);
    }
  };

  return (
    <div>
      <button onClick={handleCreate}>記事作成</button>

      {articles?.map((article) => (
        <ArticleItem
          key={article._id}
          article={article}
          onPublish={() => handlePublish(article._id)}
          onTitleUpdate={(title) => handleTitleUpdate(article._id, title)}
        />
      ))}
    </div>
  );
}

使い分けの指針

用途 公開関数 Internal 関数
Query UI 表示用データ サーバー内部での参照
Mutation フロントエンド操作 ビジネスロジック実装
Action 外部処理の起点 複雑なオーケストレーション

選択の基準:

  • フロントエンドから呼ぶ → 公開関数
  • サーバー内部のみ → Internal 関数
  • 認証が必要 → 公開関数で認証,Internal 関数でロジック
  • 再利用したい → Internal 関数

5. Query vs Mutation - なぜ分離するのか

Convex において読み取り操作と書き込み操作は分離されており,この分離を理解して実装することが重要である.

Query と Mutation は渡されるコンテキスト自体が異なり,同一のクラスで実装するとコードが大変なことになる.後ほど述べるが,分離して実装するほかに関数で実装することも重要である.

従来の Repository Pattern との違い

一般的な Repository Pattern

// 従来: Repository層でCRUDを一元管理
class ArticleRepository {
  async find(id: string): Promise<Article> {}
  async findAll(): Promise<Article[]> {}
  async create(data: CreateArticleDto): Promise<Article> {}
  async update(id: string, data: UpdateArticleDto): Promise<Article> {}
  async delete(id: string): Promise<void> {}
}

Convex の Query/Mutation 分離

// Convex: 読み取りと書き込みを明確に分離

// queries/articles/getArticle.ts - 読み取り専用
export const getArticle = query({
  args: { articleId: v.id("articles") },
  handler: async (ctx, args) => {
    return await ctx.db.get(args.articleId);
  },
});

// mutations/articles/createArticle.ts - 書き込み専用
export const createArticle = mutation({
  args: { title: v.string(), content: v.string(), authorId: v.id("users") },
  handler: async (ctx, args) => {
    return await ctx.db.insert("articles", args);
  },
});

分離の理由と利点

1. リアクティビティの最適化

// Query: 自動的にリアクティブ(データ変更時に自動再実行)
const articles = useQuery(api.queries.articles.getArticles);
// articlesテーブルが更新されると自動的に再取得

// Mutation: 明示的な実行タイミング
const createArticle = useMutation(api.mutations.articles.createArticle);
await createArticle({ title: "新しい記事", content: "記事の内容" }); // 手動実行

2. パフォーマンス最適化

  • Query: キャッシュ可能,並列実行可能,自動リトライ
  • Mutation: トランザクション保証,順次実行,冪等性管理

3. セキュリティと権限管理

// Query: 読み取り権限のみチェック
export const getPublishedArticles = query({
  handler: async (ctx) => {
    // 公開済み記事は認証不要
    return await ctx.db
      .query("articles")
      .filter((q) => q.eq(q.field("status"), "published"))
      .collect();
  },
});

// Mutation: 書き込み権限の厳密なチェック
export const deleteArticle = mutation({
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("認証が必要です");

    const article = await ctx.db.get(args.articleId);
    if (article.authorId !== userId) {
      throw new Error("削除権限がありません");
    }

    await ctx.db.delete(args.articleId);
  },
});

実装上の注意点

他のフレームワークでも同様だが,責務を明確にして実装することが特に重要.

❌ アンチパターン: Query での副作用

// 絶対NG: Queryで書き込み操作
export const getArticleAndIncrement = query({
  handler: async (ctx, args) => {
    const article = await ctx.db.get(args.articleId);
    // ❌ Queryでは書き込み不可
    // await ctx.db.patch(args.articleId, { views: article.views + 1 });
    return article;
  },
});

✅ 正しいパターン: 責務の分離

// 読み取り用Query
export const getArticle = query({
  handler: async (ctx, args) => {
    return await ctx.db.get(args.articleId);
  },
});

// ビュー数更新用Mutation
export const incrementArticleViews = mutation({
  handler: async (ctx, args) => {
    const article = await ctx.db.get(args.articleId);
    await ctx.db.patch(args.articleId, {
      views: (article.views || 0) + 1,
    });
  },
});

// フロントエンドで組み合わせる
function ArticleDetail({ articleId }) {
  const article = useQuery(api.queries.articles.getArticle, { articleId });
  const incrementViews = useMutation(
    api.mutations.articles.incrementArticleViews
  );

  useEffect(() => {
    incrementViews({ articleId }); // ページビュー時に実行
  }, [articleId]);

  return <div>{article?.title}</div>;
}

CRUD マッピング例

articles/
├── queries/
│   ├── getArticle.ts       // Read (single)
│   ├── getArticles.ts      // Read (list)
│   └── searchArticles.ts   // Read (search)
├── mutations/
│   ├── createArticle.ts    // Create
│   ├── updateArticle.ts    // Update
│   └── deleteArticle.ts    // Delete
└── index.ts               // エクスポート集約

6. Schema を唯一のソースとする重要性

Convex の大きな特徴の一つとして,Schema を唯一の真実の源(single source of truth)とする設計思想がある.

プロジェクト内には Schema ファイルが存在し,これを基にテーブルが作成され,対応するデータ型が自動生成される.

Schema の定義や型を有効活用することでデータ型をシンプルに保ちつつ.型安全性を確保できる.できるだけ不要な型定義を避け,Schema を唯一のソースとすることが重要である.

型が合わない場合は設計がよろしくない場合が多く,手動で追加の型を定義したり強引に型変換を行ったりするとコードが複雑になり保守したくなくなる.

Schema 設計の原則

// convex/schema.ts - 単一のtruth source
export default defineSchema({
  ...authTables,
  users: extendedUsersTable,
  articles: articlesTable,
  comments: commentsTable,
  articleStats: articleStatsTable,
  // すべてのテーブル定義を一元管理
});

メリット

  • 型自動生成: _generated/dataModelによる完全な型安全性
  • 一貫性: すべての層で同じ型定義を使用
  • マイグレーション: スキーマ変更の追跡と管理が容易

7. レイヤーの責務分離

実装時にはここまでに説明してきた内容を把握して適切なレイヤーを分離することが重要である.

なぜレイヤー分離が必要なのか

Convex アプリケーションでは,以下の理由からレイヤー分離が重要になる.

1. セキュリティの確保

// ❌ 問題: フロントエンドから直接すべての操作が可能
export const deleteUser = mutation({
  handler: async (ctx, args) => {
    // 認証・認可チェックなし
    await ctx.db.delete(args.userId);
  },
});

// ✅ 改善: レイヤーを分けてセキュリティを確保
export const deleteUser = mutation({
  handler: async (ctx, args) => {
    const currentUser = await getAuthUserId(ctx);
    if (!currentUser || !isAdmin(currentUser)) {
      throw new Error("管理者権限が必要です");
    }

    return await ctx.runMutation(internal.users.deleteUser, {
      userId: args.userId,
      requestingUserId: currentUser,
    });
  },
});

2. 関心の分離

  • 認証・認可 vs ビジネスロジック の分離
  • 外部 API 呼び出し vs データベース操作 の分離
  • UI 固有のロジック vs 再利用可能なロジック の分離

3. テスタビリティの向上

各層を独立してテストすることで,より信頼性の高いアプリケーションを構築できる.

Convex におけるアーキテクチャレイヤー

Convex のドキュメントでは Internal 層で呼び出す純粋関数は model ディレクトリに配置されていることが推奨されているが,筆者は他プロジェクトとの兼ね合いも含めて Services 層として分離している.名前が違うが構造は同じである.

┌─────────────┐
│  Frontend   │ → useQuery/useMutation
└──────┬──────┘
       ↓
┌─────────────┐
│   API層     │ → 認証・認可・入力検証,内容によっては直接Internal層を呼ぶ
└──────┬──────┘
       ↓
┌─────────────┐
│  Actions層  │ → 外部API・非同期処理
└──────┬──────┘
       ↓
┌─────────────┐
│ Internal層  │ → ビジネスロジック
└──────┬──────┘
       ↓
┌─────────────┐
│ Services層  │ → 純粋関数・計算ロジック
└─────────────┘

各層の責務と実装例

API 層 - 公開インターフェース

// 認証・認可・入力検証を担当
export const updateArticle = mutation({
  args: { articleId: v.id("articles"), title: v.string() },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("認証が必要です");

    return await ctx.runMutation(internal.articles.updateArticle, {
      ...args,
      requestingUserId: userId,
    });
  },
});

Actions 層 - 外部連携・オーケストレーション

export const publishArticleWithNotification = action({
  handler: async (ctx, args) => {
    // 記事公開
    await ctx.runMutation(internal.articles.publishArticle, args);

    // 外部メール送信
    await sendNotificationEmail(args.articleId);

    // SNS投稿
    await postToSocialMedia(args.articleId);
  },
});

Internal 層 - ビジネスロジック

export const updateArticle = internalMutation({
  handler: async (ctx, args) => {
    // 権限チェック
    const article = await ctx.db.get(args.articleId);
    if (article.authorId !== args.requestingUserId) {
      throw new Error("編集権限がありません");
    }

    // 更新実行
    await ctx.db.patch(args.articleId, {
      title: args.title,
      updatedAt: Date.now(),
    });
  },
});

Services 層 - 純粋関数

export const formatArticleForSEO = (article: Article): SEOData => {
  return {
    title: article.title,
    description: article.content.substring(0, 160),
    keywords: extractKeywords(article.content),
    readTime: calculateReadTime(article.content),
  };
};

8. アクセス制御パターン

上記のレイヤーごとにアクセス可能なレイヤが決まっている.これを守ることで,セキュリティと保守性が向上する.

Convex におけるアクセス制御の理由

Convex では,アプリケーションの安全性と保守性を確保するために,各層間でのアクセス制御が厳密に設計されています:

1. セキュリティ境界の明確化

// ❌ 危険: フロントエンドから内部関数への直接アクセスが可能だとしたら
const result = useQuery(api.internal.users.getAllUserPasswords); // 大問題

// ✅ 安全: フロントエンドは公開APIのみアクセス可能
const profile = useQuery(api.queries.users.getMyProfile); // 認証済みユーザーの情報のみ

2. 責務の強制

  • Actions: データベース直接アクセス不可 → トランザクション整合性の保護
  • Internal: 外部 API アクセス不可 → 純粋なビジネスロジックの維持
  • Services: 副作用なし → テスタビリティの確保

3. アーキテクチャの一貫性

各層が決められた役割のみを担うことで,コードの予測可能性が向上します.

呼び出し可能性マトリックス

From/To API Internal Actions Services DB
Frontend
API ✅ ctx.run* ✅ ctx.run* ✅ import
Actions ✅ ctx.run* ✅ ctx.run* ✅ ctx.run* ✅ import
Internal ✅ import ✅ ctx.db
Services ✅ import

制約の実装例

ActionCtx 制約 - データベース直接アクセス不可

// ❌ 不可: Actions内でのDB直接アクセス
export const syncArticleWithExternalAPI = action({
  handler: async (ctx, args) => {
    // ctx.db.get() // コンパイルエラー: ActionCtxにはdbプロパティがない

    // 外部API呼び出し
    const externalData = await fetchFromExternalAPI(args.articleId);

    // ❌ これもできない
    // await ctx.db.patch(args.articleId, externalData);
  },
});

// ✅ 正しい: Internal経由でのアクセス
export const syncArticleWithExternalAPI = action({
  handler: async (ctx, args) => {
    // 1. 現在のデータを取得
    const currentArticle = await ctx.runQuery(
      internal.queries.getArticle,
      args
    );

    // 2. 外部APIから最新データを取得
    const externalData = await fetchFromExternalAPI(args.articleId);

    // 3. データをマージして更新
    await ctx.runMutation(internal.mutations.updateArticleFromExternal, {
      articleId: args.articleId,
      externalData,
      currentData: currentArticle,
    });
  },
});

Internal 制約 - 外部 API アクセス制御

// ❌ 推奨されない: Internal内での外部API呼び出し
export const createArticleWithValidation = internalMutation({
  handler: async (ctx, args) => {
    // 外部スペルチェックAPI(副作用)
    const isValid = await checkSpelling(args.content); // 🚫 副作用

    if (!isValid) throw new Error("スペルエラー");

    return await ctx.db.insert("articles", args);
  },
});

// ✅ 改善: Action→Internalの分離
export const createArticleWithValidation = action({
  handler: async (ctx, args) => {
    // 外部APIチェック
    const isValid = await checkSpelling(args.content);

    if (!isValid) throw new Error("スペルエラー");

    // Internal関数呼び出し
    return await ctx.runMutation(internal.articles.createArticle, args);
  },
});

なぜこの制約が重要なのか

1. トランザクション整合性の保護

Internal 関数内でのみデータベースアクセスを許可することで,ACID 特性を保証

2. テスタビリティ

各層を独立してテストでき,外部依存関係を制御しやすい

3. パフォーマンス最適化

Convex が適切にクエリをキャッシュし,リアクティブ更新を最適化できる

9. 関数型アプローチの採用

明言されているわけではないが,Convex ではクラスベースの設計よりも関数ベースの設計が推奨されている.ドキュメントのコードも関数ベースで書かれている.

Query と Mutation 分離の項でも述べたが,Convex は責務の分離が重要であり,細かく分けるという点でも関数ベースの方が適している.Query と Mutation では渡されるコンテキストが異なるため,この 2 者を同一クラスで実装すると地獄のようなコードが爆誕する.

関数しか勝たん.

なぜ関数構文を選ぶのか

Convex ではクラスベースの設計よりも関数ベースの設計.

1. 純粋関数と副作用の明確な分離

// ❌ クラス中心の設計
class ArticleService {
  constructor(private db: DatabaseConnection) {}

  async createArticle(data: ArticleData) {
    // 状態を持つオブジェクト,副作用の場所が不明確
    const processed = this.processContent(data.content);
    return await this.db.insert("articles", processed);
  }

  private processContent(content: string) {
    // メソッド内で状態変更の可能性
    this.lastProcessedLength = content.length;
    return { content, wordCount: content.split(" ").length };
  }
}

// ✅ 関数中心の設計
export const createArticle = internalMutation({
  handler: async (ctx, args) => {
    // 純粋関数で処理
    const processed = processArticleContent(args.content);
    // 副作用は明確に分離
    return await ctx.db.insert("articles", { ...args, ...processed });
  },
});

// 純粋関数(計算ロジック)
const processArticleContent = (content: string) => ({
  wordCount: content.split(" ").length,
  readTime: Math.ceil(content.length / 200),
  excerpt: content.substring(0, 100),
});

2. テスタビリティの向上

// 関数は独立してテスト可能
describe("processArticleContent", () => {
  it("正しい統計を計算する", () => {
    const result = processArticleContent("Hello world test");
    expect(result.wordCount).toBe(3);
    expect(result.readTime).toBe(1);
  });
});

// クラスの場合,依存関係の設定が複雑になりがち

3. Convex ランタイムとの整合性

  • リアクティビティ: 関数は入力に対する純粋な変換として扱われる
  • トランザクション: 関数単位で自動的に ACID 特性が保証される
  • 型生成: 関数シグネチャから型が自動生成される

実装上の指針

  1. 状態管理は Convex に委譲: クラスで状態を管理せず,データベースに委ねる
  2. 純粋関数の活用: 各ロジックは副作用のない関数として実装
  3. 組み合わせ可能性: 小さな関数を組み合わせて複雑な機能を構築

この方針により,予測可能で保守性の高いアプリケーションを構築できる.

10. トランザクション設計パターン

Convex はトランザクション処理の実装に優れており,handler 内でのすべての DB 操作は単一トランザクションとして扱われる.

実装する際は単一の Mutation 内で完結させることが推奨されている.

単一 Mutation 原則

// ✅ 推奨: 単一トランザクション内で完結
export const deleteArticle = internalMutation({
  handler: async (ctx, args) => {
    // すべての削除を1つのトランザクション内で実行
    const comments = await ctx.db
      .query("comments")
      .withIndex("by_article", (q) => q.eq("articleId", args.articleId))
      .collect();
    for (const comment of comments) {
      await ctx.db.delete(comment._id);
    }

    const articleStats = await ctx.db
      .query("articleStats")
      .withIndex("by_article", (q) => q.eq("articleId", args.articleId))
      .collect();
    for (const stat of articleStats) {
      await ctx.db.delete(stat._id);
    }

    // ... 他の関連データ削除

    await ctx.db.delete(args.articleId);
    // すべて成功 or すべて失敗(ACID保証)
  },
});

バッチ処理の最適化

// 大量データ用の分割処理
export const insertLargeCommentBatch = internalMutation({
  handler: async (ctx, args) => {
    const BATCH_SIZE = 500;
    for (let i = 0; i < args.comments.length; i += BATCH_SIZE) {
      const batch = args.comments.slice(i, i + BATCH_SIZE);
      await Promise.all(
        batch.map((comment) => ctx.db.insert("comments", comment))
      );

      // 進捗ログ
      console.log(
        `進捗: ${Math.min(i + BATCH_SIZE, args.comments.length)}/${
          args.comments.length
        }`
      );
    }
  },
});

11. テスト戦略

Convex におけるテストは convex-test パッケージを利用して行う.データ型や Schema 定義を活用するために,Schema をインポートしてテスト環境を構築することが推奨されている.

Schema 一貫性テスト

import { convexTest } from "convex-test";
import schema from "../schema";

describe("deleteArticle", () => {
  it("関連データもすべて削除される", async () => {
    const t = convexTest(schema); // スキーマ必須

    // テストデータ作成
    const articleId = await t.run(async (ctx) => {
      return await ctx.db.insert("articles", {
        title: "テスト記事",
        content: "記事の内容",
        authorId: "user123",
        status: "published",
      });
    });

    // 実行と検証
    const result = await t.run(deleteArticle, { articleId });
    expect(result.success).toBe(true);
  });
});

認証モック

// getAuthUserIdのモック化
const t = convexTest(schema, modules);
t.withIdentity({ subject: "user_123" }); // 認証ユーザー設定

まとめ

Convex アプリケーション開発において重要なのは以下の通り.

  1. Query/Mutation 分離の理解 - 読み取りと書き込みの明確な分離
  2. レイヤー分離の徹底 - 各層の責務を明確にし,適切な境界を設ける
  3. 型安全性の活用 - Schema を中心とした型生成と一貫性
  4. トランザクション設計 - ACID 保証を活用した信頼性の高い実装
  5. 純粋関数の活用 - テスタブルで予測可能なビジネスロジック
  6. 適切なアクセス制御 - Internal/Public の使い分けとセキュリティ

これらの原則に従うことで,保守性・拡張性・信頼性の高い Convex アプリケーションを構築できるだろう.

一例として,全体のディレクトリ構成例を示す.実際の運用では各層のディレクトリ内で articles, users などのリソースごとにさらに細分化するとわかりやすいだろう.

convex/
├── schema.ts                      // スキーマ定義
├── actions/
│   ├── articles/
│   │   ├── publishArticle.ts      // 外部連携
│   │   └── ...                    // その他のAction
│   └── ...
├── api/
│   ├── articles/
│   │   ├── getUserArticles.ts     // フロントから呼び出すAPI
│   │   └── ...                    // その他のAPI
│   └── ...
├── queries/
│   ├── articles/
│   │   ├── getArticle.ts          // Read (single)
│   │   ├── getArticles.ts         // Read (list)
│   │   └── searchArticles.ts      // Read (search)
│   └── ...
├── mutations/
│   ├── articles/
│   │   ├── createArticle.ts       // Create
│   │   ├── updateArticle.ts       // Update
│   │   └── deleteArticle.ts       // Delete
│   └── ...
├── services/
│   ├── articles/
│   │   ├── formatArticleForSEO.ts // 純粋関数
│   │   └── ...                    // その他のサービス関数
│   └── ...
└── ...                            // その他のディレクトリ

以上だ( `・ω・)b

GitHubで編集を提案

Discussion