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
テーブルを例にとります。
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のスキーマ定義も行います。
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
ファイルを作成します。
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
ユーザーに関連するビジネスロジック(ここではユーザー作成と取得)を定義します。
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
ファイルを作成します。
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
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
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
本記事で作成したコードは以下のリポジトリで公開しています。
Discussion