🚀

Hono APIをRepositoryパターンとUseCaseパターンで構築する

に公開

この記事は?

この記事では、Honoを使用してAPIを構築する際にコードの関心事を分離し、保守性やテスト容易性を高めるためにRepositoryパターンとUseCaseパターンを導入する手順です。

Hono の基本的なセットアップについては、以下の記事で完了していることとします。
React Router v7 + Hono + bun でモノレポ構成の初期構築

前提条件

Honoの基本的なプロジェクトセットアップ、Drizzle ORMとCloudflare D1の連携準備が済んでいる状態を想定します。

使用する技術スタック

database/schema.tsの作成

まず、Drizzle ORMを使用してデータベーススキーマを定義します。ここでは、usersテーブルを例にとります。

database/schema.ts
import {
    integer,
    sqliteTable,
    text
} from "drizzle-orm/sqlite-core";

export const usersTable = sqliteTable("users", {
    id: integer().primaryKey({ autoIncrement: true }),
    name: text().notNull(),
    email: text().notNull().unique(),
});
export type UserInferSelect = typeof usersTable.$inferSelect;

Repositoryの作成

Repositoryパターンは、データアクセスロジックをカプセル化するデザインパターンです。アプリケーションの他の部分(例えばUseCase)が、具体的なデータベースの実装詳細を意識することなくデータ操作を行えるようにします。

まず、ユーザーデータ (usersテーブル) を操作するためのRepositoryを作成します。

repositories/users.ts

このファイルでは、usersテーブルに対する具体的なCRUD操作を定義します。Drizzle ORMのクライアントを受け取り、それを利用してデータベースと接続する。また、Zodを使用して入力データやIDのスキーマ定義も行います。

repositories/users.ts
import {eq} from "drizzle-orm";
import {createInsertSchema} from "drizzle-zod";
import {z} from "zod";
import {type UserInferSelect, usersTable} from "../database/schema"; // database/schema.ts は別途定義済みとします
import type {Client} from "./index";

// usersTableからZodスキーマを生成
const userZodSchema = createInsertSchema(usersTable, {
    id: z.string(), // idは文字列型と仮定
    name: z.string().min(1, "user name min").max(100, "user name max"),
    email: z.string().email("email format")
});

// ユーザー作成時に使用するスキーマ (nameとemailのみ)
export const createUserSchema = userZodSchema.pick({ name: true, email: true });
// ユーザーIDのスキーマ
export const userIdSchema = userZodSchema.pick({ id: true });

// UserRepositoryのインターフェース定義
export type IUserRepository = {
    create(data: z.input<typeof createUserSchema>): Promise<UserInferSelect>;
    getById(id: number): Promise<UserInferSelect | undefined>; // idは数値型として受け取る
    getAll(): Promise<UserInferSelect[]>;
};

// UserRepositoryの実装
export const userRepository = (client: Client): IUserRepository => ({
    create: async (data) => {
        const parseData = await createUserSchema.parseAsync(data); // 入力データをバリデーション
        return await client
            .insert(usersTable)
            .values(parseData)
            .returning() // 挿入したレコードを返す
            .get(); // Drizzle D1特有の .get()
    },
    getById: async (id) => client
        .query
        .usersTable
        .findFirst({
            where: eq(usersTable.id, id) // eq(usersTable.id, String(id)) の可能性も検討 (スキーマ定義との整合性)
        }),
    getAll: async() => client
        .query
        .usersTable
        .findMany(),
});

repositories/index.ts

複数のRepositoryをまとめて管理しやすくするために、index.tsファイルを作成します。

repositories/index.ts
import type {DrizzleD1Database} from "drizzle-orm/d1";
import * as schema from "../database/schema"; // database/schema.ts をインポート
import {IUserRepository, userRepository} from "./users";

// Drizzle D1クライアントの型エイリアス
export type Client = DrizzleD1Database<typeof schema>;

// すべてのRepositoryをまとめるインターフェース
export interface IRepositories {
    user: IUserRepository;
    // 他のRepositoryが増えた場合はここに追加
}

export const repositories: (client: Client) => IRepositories = (
    client,
) => {
    return {
        user: userRepository(client),
        // 他のRepositoryも同様に初期化
    };
};

UseCaseの作成

UseCase(またはInteractor)は、アプリケーション固有のビジネスロジックをカプセル化します。Repositoryを介してデータ操作を行い、具体的なデータ永続化の方法からは独立しています。

usecases/user.ts

ユーザーに関連するビジネスロジック(ここではユーザー作成と取得)を定義します。

usecases/user.ts
import {z} from "zod";
import {createUserSchema} from "../repositories/users"; // repositories/users.ts からスキーマをインポート
import type {IRepositories} from "../repositories";
import type {UserInferSelect} from "../database/schema";

// UserUseCaseのインターフェース定義
export type UserUseCaseType = {
    create: (data: z.input<typeof createUserSchema>) => Promise<UserInferSelect>;
    getById: (id: number) => Promise<UserInferSelect | undefined>;
    getAll: () => Promise<UserInferSelect[]>;
}

// UserUseCaseの実装
export const userUseCase = (
    repo: IRepositories // IRepositoriesインターフェースに依存
): UserUseCaseType => {
    return {
        create: async (data) => repo.user.create(data),
        getById: async (id) => repo.user.getById(id),
        getAll: async () => repo.user.getAll(),
    };
};

usecases/index.ts

複数のUseCaseをまとめて管理しやすくするために、index.tsファイルを作成します。

usecases/index.ts
import {userUseCase, UserUseCaseType} from "./user";
import {IRepositories} from "../repositories";

// すべてのUseCaseをまとめるインターフェース
export interface UseCases {
    user: UserUseCaseType;
    // 他のUseCaseが増えた場合はここに追加
}

export const baseUseCases = (repo: IRepositories): UseCases => ({
    user: userUseCase(repo),
    // 他のUseCaseも同様に初期化
});

Middleware設定

Honoのミドルウェアを利用して、リクエストごとにRepositoryとUseCaseのインスタンスを生成し、Honoのコンテキスト (c.var) にセットします。これにより、各ルートハンドラでDI(Dependency Injection)のように依存関係を解決できます。

middlewares.ts

middlewares.ts
import {Context} from "hono";
import {createMiddleware} from "hono/factory";
import {drizzle} from "drizzle-orm/d1";
import {repositories, IRepositories} from "./repositories"; // repositories/index.ts からインポート
import * as schema from "./database/schema"; // database/schema.ts をインポート
import {baseUseCases, UseCases} from "./usecases"; // usecases/index.ts からインポート


// Repositoryインスタンスを生成する関数
const createRepository = (c: Context) => {
    // c.env.DB は wrangler.toml などで設定されたD1バインディングを指す
    const db = drizzle(c.env.DB, { schema });
    return repositories(db);
}

// Honoコンテキストにセットする変数の型定義
export type SetUpEnv = {
    Variables: {
        useCases: UseCases;
    }
}

// ミドルウェアの実装
export const setup = createMiddleware<SetUpEnv>(async (c, next) => {
    const repositories = createRepository(c); // リクエストごとにRepositoryを生成
    const useCases = baseUseCases(repositories); // Repositoryを元にUseCaseを生成
    c.set("useCases", useCases); // コンテキストにUseCaseをセット
    await next(); // 次の処理へ
});

このミドルウェアをHonoアプリケーションに適用することで、各ルートハンドラは c.var.useCases を通じてUseCaseのメソッドを呼び出すことができます。

APIエンドポイント作成

最後に、Honoアプリケーションのルート定義ファイル (index.tsなど) で、作成したミドルウェアとUseCaseを使ってAPIエンドポイントを構築します。

index.ts

index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import {zValidator} from "@hono/zod-validator";
import {userIdSchema, createUserSchema} from "./repositories/users"; // repositories/users.ts からスキーマをインポート
import {setup, SetUpEnv} from "./middlewares"; // 作成したミドルウェアをインポート

const app = new Hono<SetUpEnv>(); // ミドルウェアで設定した型をジェネリクスで指定

// CORSミドルウェアの設定
app.use("*", cors({
  origin: "*" // 必要に応じて適切なオリジンを指定
}));

const route = app
    // 作成したsetupミドルウェアを適用
    .use(setup)
    // ヘルスチェック用エンドポイント
    .get("/hello", (c) => {
        return c.json({ message: "Hello Hono!" })
    })
    // ユーザー一覧取得エンドポイント
    .get("/users", async (c) => {
        // ミドルウェアからuseCasesを取得し、userのgetAllメソッドを呼び出す
        const users = await c.var.useCases.user.getAll();
        return c.json(users, 200);
    })
    // ユーザー作成エンドポイント
    .post("/users", zValidator("json", createUserSchema), async (c) => { // Zodでリクエストボディをバリデーション
      const data = c.req.valid("json"); // バリデーション済みデータを取得
      // createUserSchema.parseAsync(data) はリポジトリのcreate内でも行われるため、
      // ここでのparseAsyncは必須ではないかもしれないが、型安全のため実施。
      const parseData = await createUserSchema.parseAsync(data);
      const newUser = await c.var.useCases.user.create(parseData);
      return c.json(newUser, 201);
    })
    // ユーザー取得エンドポイント (ID指定)
    .get("/users/:id", zValidator("param", userIdSchema), async (c) => { // Zodでパスパラメータをバリデーション
        const param = c.req.valid("param");
        // param.id は文字列なので、UseCase/Repositoryが数値を期待する場合は変換
        const user = await c.var.useCases.user.getById(Number(param.id));
        if (!user) {
          return c.json({ message: "User not found" }, 404);
        }
        return c.json(user, 200);
    })

export type AppType = typeof route; // hono/client で型推論を利用する場合にエクスポート
export default app;

動作確認

  • POST /users: リクエストボディに {"name": "John Doe", "email": "john.doe@example.com"} のようなJSONを送信し、ユーザーが作成され、作成されたユーザー情報がレスポンスとして返ってくることを確認します(ステータスコード 201)。
  • GET /users: 登録されているユーザーの一覧がJSON配列として返ってくることを確認します(ステータスコード 200)。
  • GET /users/:id: 存在するユーザーID(例: /users/1)を指定してアクセスし、該当ユーザーの情報が返ってくることを確認します(ステータスコード 200)。存在しないIDの場合は { "message": "User not found" } が返ってくることを確認します(ステータスコード 404)。

まとめ

この記事では、HonoでAPIを構築する際にRepositoryパターンとUseCaseパターンを導入する方法を解説しました。このアプローチにより、以下のメリットが期待できます。

  • 関心の分離: データアクセスロジック、ビジネスロジック、APIルーティングがそれぞれ異なるレイヤーに分離され、コードの見通しが良くなります。
  • 保守性の向上: 各レイヤーが疎結合になるため、一部の変更が他の部分に影響を与えにくくなり、機能追加や修正が容易になります。
  • テスト容易性の向上: RepositoryやUseCaseを個別にテストしやすくなります。特にUseCaseは、Repositoryをモックすることで、データベースに依存しない純粋なビジネスロジックのテストが可能です。

これらのパターンを導入することで、より堅牢でスケーラブルなHonoアプリケーションを構築するための一歩となるでしょう。今後の展望としては、エラーハンドリングの共通化、ロギング機構の導入、さらなるビジネスロジックの複雑化に対応するためのサービスレイヤーの導入などが考えられます。

GitHub

本記事で作成したコードは以下のリポジトリで公開しています。

参考 URL

Discussion