こんにちは。Tomoyuki(@tomoyuki65)です。
2026年現在、TypeScriptでAPIを開発するフレームワークとしては「Hono」が注目されています。
特に個人開発や小規模開発の本番環境としてCloudflare Workersを利用するとコストを抑えやすく、Honoはそのような環境を含め、さまざまな環境にデプロイできるため、有効な選択肢の一つになっています。
この記事では、そんなHono(TypeScript)でAPIを作成する方法をご紹介します。
Hono入門|TypeScriptでAPIを作成する方法【Node.js・Cloudflare対応】
Honoとは?
Hono(ホノ)は、TypeScript / JavaScript向けの軽量かつ高速なWebアプリケーションフレームワークです。
Web標準APIをベースとしており、マルチランタイムに対応しているため、Node.jsだけでなく、Deno・Bun・Cloudflare Workersなど、様々な環境で動作するのが特徴です。
Honoを使うメリット
Honoを使うメリットとしては、軽量かつシンプルなAPIを、様々な実行環境で構築できる点になります。
また、フロントエンド開発でTypeScriptを使用している場合、バックエンドにもTypeScriptを採用することでプログラミング言語を統一でき、エンジニア採用の面でも採用母数を広げられる可能性があります。
HonoでAPIを作る前の準備
今回はまずnode環境でローカル開発環境を構築するため、事前にnodeを使えるようにする必要があります。
私はnodeのバージョン管理ツールとしては「volta」を気に入っているため、今回はこれをご紹介しますが、インストールしたい場合はcurlコマンドを利用し、以下のコマンドを実行すると可能です。
$ curl https://get.volta.sh | bash
インストール後、以下のコマンドでパスが通っているか確認して下さい。
$ volta -v
次に以下のコマンドを実行し、nodeをインストールします。
$ volta install node@24.21.0
※ここでは2026年9月時点でLTS版のバージョン「24.21.0」を使います。尚、チーム開発などではnodeのバージョンは固定推薦です。
インストール後、以下のコマンドでパスが通っているか確認して下さい。
$ node -v
次にnodeのパッケージマネージャーとしては、デフォルトのnpmではなく、最近人気の「pnpm」を利用します。
MacOSでよく使われるパッケージ管理ツールのHomebrew経由でインストールするなら、以下のコマンドを実行すると可能です。
$ brew install pnpm
インストール後、以下のコマンドでパスが通っているか確認して下さい。
$ pnpm -v
Honoの新規プロジェクトを作成
次に以下のコマンドを実行し、Honoの新規プロジェクトを作成します。
$ mkdir hono-sample && cd hono-sample
$ pnpm create hono@latest .
実行後、テンプレートを選択できるので、今回は「nodejs」を選びます。
次にプロジェクトの依存関係をインストールするか聞かれるので、「y」を入力します。
次にパッケージマネージャーにどれを使うか聞かれるので、「pnpm」を選択します。
実行後、各種ファイルが作成され、以下のように完了メッセージが出力されます。
次にテキストエディタでプロジェクトを確認し、以下のように各種ファイルが作成されていればOKです。
nodeとpnpmのバージョン固定
次にnodeとpnpmのバージョン固定を行います。まずは以下のコマンドを実行し、ファイル「.npmrc」を作成します。
$ touch .npmrc
次に作成したファイル「.npmrc」は以下のように記述します。
engine-strict=true
次にファイル「package.json」に対して以下のように「engines」、「packageManager」を追加します。
{
・・・
"engines": {
"node": "24.21.0",
"pnpm": "12.6.0"
},
"packageManager": "pnpm@12.6.0",
・・・
※pnpmのバージョンはインストール時に「12.6.0」だったのでこれで固定しています。必要に応じて修正して下さい。
ローカルサーバーを起動して試す
次に以下のコマンドを実行し、ローカルサーバーの起動を試します。
$ pnpm run dev
実行後、以下のようにサーバーが起動します。
※サーバーを止めたい場合は、ターミナルをアクティブ状態にしてショートカットキー「control + c」を実行して下さい。
次にブラウザで「http://localhost:3000」を開き、以下のように表示されればOKです。
サンプルAPIを作成する(DDDを考慮したディレクトリ構成)
次にサンプルAPIを作成してみますが、DDD(ドメイン駆動設計)を考慮して、以下のようなディレクトリ構成で各種ファイルを作るようにしていきます。
/hono-sample
└─ /src
|
├─ /core(中核の業務領域)
| |
| └─ /[domainName](ドメインモジュール)
| |
| ├─ /domain(ドメイン)
| | |
| | ├─ [entityName].ts(エンティティ)
| | |
| | ├─ [valueObjectName].ts(値オブジェクト)
| | |
| | ├─ [domainServiceName]Service.ts(ドメインサービス)
| | |
| | ├─ [repositoryName]Repository.ts(リポジトリのインターフェース)
| | |
| | └─ [gatewayName]Gateway.ts(外部サービス用のインターフェース)
| |
| |
| ├─ /usecase(ユースケース層)
| |
| └─ /infrastructure(ドメイン用のインフラストラクチャ層)
| |
| ├─ /persistence(パーシステンス層)
| | |
| | └─ /pg(PostgreSQL用)
| | |
| | ├─ /command(書き込み)
| | |
| | └─ /query(読み込み)
| |
| └─ /external(外部サービスの実装)
|
├─ /supporting(補完的なの業務領域)
| |
| └── /[supportingName](サービス層)
| |
| └─ useCase.ts(ユースケース)
|
├─ /generic(一般的な業務領域)
|
├─ /shared(横断関心)
| |
| └─ /logger(共通ロガーの定義)
| |
| └─ logger.ts
|
├─ /di(DIコンテナ層)
| |
| └─ nodeContainer.ts(Node.js環境用)
|
├─ /infrastructure(共通のインフラストラクチャ層)
| |
| ├─ /logger
| | |
| | └─ consoleLogger.ts(consoleロガーの実装)
| |
| └─ /database
| |
| └─ /drizzle(Drizzle ORM用)
| |
| └─ /pg(PostgreSQL用)
| |
| ├─ /migration(マイグレーション関連)
| |
| ├─ /schema(スキーマ定義)
| |
| └─ client.ts(接続設定)
|
├─ /presentation(プレゼンテーション層)
| |
| └─ /http
| |
| ├─ /middleware(ミドルウェア)
| |
| ├─ /core
| | |
| | └─ /[domainName]
| | |
| | ├─ handler.ts(ハンドラー部分)
| | |
| | └─ routes.ts(ルーティング部分)
| |
| ├─ /supporting
| | |
| | └─ /[supportingName]
| | |
| | ├─ handler.ts(ハンドラー部分)
| | |
| | └─ routes.ts(ルーティング部分)
| |
| ├─ /generic
| |
| └─ routesNode.ts(Node.js環境用のルーティング設定)
|
├─ /runtime(ランタイム層)
| |
| └─ /node(Node.js環境用)
| |
| ├─ config.ts(コンフィグ設定)
| |
| ├─ app.ts(アプリ設定)
| |
| └─ server.ts(サーバー設定)
|
├─ /tests(結合テスト用)
|
└─ app.ts(アプリ全体の定義)
※ディレクトリ構成はDDD(ドメイン駆動設計)を前提としますが、アプリ全体に適用するのは本質的でないため、3つの業務領域(中核、補完的、一般的)に分類することを前提としています。また、Honoは異なるランタイム環境で実行できるメリットがあるため、アプリ全体の定義「app.ts」はランタイムの設定「runtime/node」に依存しないようにしています。
pnpmライブラリのインストール
まずは以下のコマンドを実行し、各種ライブラリを追加します。
$ pnpm add -D vitest @biomejs/biome
$ pnpm add zod @hono/zod-validator
※テスト用に「vitest」、フォーマッターとリンター用に「@biomejs/biome」、バリデーションチェック用に「zod」、「@hono/zod-validator」を使います。
次に以下のコマンドを実行し、biomeの初期化をします。
$ pnpm biome init
共通ロガーの作成
次に以下のコマンドを実行し、各種ファイルを追加します。
$ mkdir -p src/shared/logger && touch src/shared/logger/logger.ts
$ mkdir -p src/infrastructure/logger && touch src/infrastructure/logger/consoleLogger.ts
次に作成したファイルをそれぞれ以下のように記述します。
・「src/shared/logger/logger.ts」
// ログ用コンテキストの型定義
export type LogContext = {
requestId?: string;
[key: string]: unknown;
};
// ロガーのインターフェース定義
export interface Logger {
info(message: string, context?: LogContext): void;
warn(message: string, context?: LogContext): void;
error(message: string, context?: LogContext): void;
debug(message: string, context?: LogContext): void;
withRequestId(requestId: string): Logger;
};
※共通ロガーのインターフェース定義のみ行う。
・「src/infrastructure/logger/consoleLogger.ts」
import type { LogContext, Logger } from "../../shared/logger/logger.js";
// ロガーの作成用関数
const createLogger = (baseContext: LogContext = {}): Logger => {
// ログ出力用の関数定義
const log = (level: string, message: string, context: LogContext = {}) => {
console.log(
JSON.stringify({
level,
message,
...baseContext,
...context,
timestamp: new Date().toISOString(),
}),
);
};
return {
info(message, context) {
log("INFO", message, context);
},
warn(message, context) {
log("WARN", message, context);
},
error(message, context) {
log("ERROR", message, context);
},
debug(message, context) {
log("DEBUG", message, context);
},
withRequestId(requestId) {
return createLogger({
...baseContext,
requestId,
});
},
};
};
// ロガーのインスタンス
export const logger = createLogger();
※共通ロガーの実装はインフラストラクチャ層で行う。
GETとPOSTメソッドを利用するサンプルAPIを作成
次にGETとPOSTメソッドを利用するようなサンプルAPIを作成するため、以下のコマンドを実行して各種ファイルを作成します。
$ mkdir -p src/supporting/sample && touch src/supporting/sample/useCase.ts src/supporting/sample/useCase.test.ts
$ mkdir -p src/tests/integration/supporting/sample && touch src/tests/integration/supporting/sample/sample.test.ts
$ mkdir -p src/presentation/http/supporting/sample && touch src/presentation/http/supporting/sample/handler.ts
$ touch src/presentation/http/supporting/sample/routes.ts src/presentation/http/supporting/sample/routes.test.ts
$ mkdir -p src/presentation/http/middleware && touch src/presentation/http/middleware/requestLogger.ts
$ touch src/app.ts .env
$ rm src/index.ts
※既存の「src/index.ts」は不要になるので削除する。
次に作成したファイルをそれぞれ以下のように記述します。
・「src/supporting/sample/useCase.ts」
import type { Logger } from "../../shared/logger/logger.js";
type SampleTextInput = {
text: string;
};
export class SampleHelloUseCase {
execute(logger: Logger) {
// ログ出力
logger.info("SampleHelloUseCaseを実行!");
return {
message: "Hello World !!",
};
}
}
export class SampleTextUseCase {
execute(logger: Logger, input: SampleTextInput) {
// ログ出力
logger.info("SampleTextUseCaseを実行!", { text: input.text });
return {
message: input.text,
};
}
}
※ユースケースはクラスで定義する。
・「src/supporting/sample/useCase.test.ts」
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Logger } from "../../shared/logger/logger.js";
import { SampleHelloUseCase, SampleTextUseCase } from "./useCase.js";
// ロガーのモック化
const createMockLogger = (): Logger => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
withRequestId: vi.fn(),
});
let logger: Logger;
// テスト前の共通セットアップ
beforeEach(() => {
logger = createMockLogger();
});
describe("SampleHelloUseCase", () => {
it("Hello Worldを返す", () => {
// ユースケース作成
const useCase = new SampleHelloUseCase();
// ユースケース実行
const result = useCase.execute(logger);
// 検証
expect(result).toEqual({
message: "Hello World !!",
});
expect(logger.info).toHaveBeenCalledWith("SampleHelloUseCaseを実行!");
});
});
describe("SampleTextUseCase", () => {
it("渡されたtextを返す", () => {
// ユースケース作成
const useCase = new SampleTextUseCase();
// ユースケース実行
const result = useCase.execute(logger, {
text: "テストメッセージ",
});
// 検証
expect(result).toEqual({
message: "テストメッセージ",
});
expect(logger.info).toHaveBeenCalledWith("SampleTextUseCaseを実行!", {
text: "テストメッセージ",
});
});
});
※ユースケースのユニットテスト
・「src/tests/integration/supporting/sample/sample.test.ts」
import { testClient } from "hono/testing";
import { describe, expect, it, vi } from "vitest";
import { createNodeApp } from "../../../../runtime/node/app.js";
import type { NodeConfig } from "../../../../runtime/node/config.js";
import { config } from "../../../../runtime/node/config.js";
// ログ出力の無効化
vi.spyOn(console, "log").mockImplementation(() => {});
// テスト用コンフィグ変数
let testConfig: NodeConfig;
// テスト用クライアント作成関数
const createTestClient = () => {
testConfig = {
...config,
};
return testClient(createNodeApp(testConfig));
};
describe("GET /api/v1/sample", () => {
const client = createTestClient();
it("メッセージに「Hello World !!」を出力し、正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.sample.$get();
// 検証
expect(response.status).toBe(200);
// レスポンス結果の検証
const body = await response.json();
expect(body).toEqual({
message: "Hello World !!",
});
});
});
describe("POST /api/v1/sample", () => {
const client = createTestClient();
it("メッセージに「サンプルテキスト!」を出力し、正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.sample.$post({
json: {
text: "サンプルテキスト!",
},
});
// 検証
expect(response.status).toBe(200);
// レスポンス結果の検証
const body = await response.json();
expect(body).toEqual({
message: "サンプルテキスト!",
});
});
});
※インテグレーションテスト。Honoの型推論が効いていれば「testClient」を使ってテスト可能。
・「src/presentation/http/supporting/sample/handler.ts」
import type { Context } from "hono";
import type {
SampleHelloUseCase,
SampleTextInput,
SampleTextUseCase,
} from "../../../../supporting/sample/useCase.js";
export const createSampleHelloHandler =
(useCase: SampleHelloUseCase) => (c: Context) => {
// 共通ロガー取得
const logger = c.get("logger");
// ユースケース実行
const result = useCase.execute(logger);
return c.json(result);
};
export const createSampleTextHandler =
(useCase: SampleTextUseCase) => (c: Context, input: SampleTextInput) => {
// 共通ロガー取得
const logger = c.get("logger");
// ユースケース実行
const result = useCase.execute(logger, input);
return c.json(result);
};
※サンプルAPIのハンドラー部分(ユースケースの呼び出し)
・「src/presentation/http/supporting/sample/routes.ts」
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import type {
SampleHelloUseCase,
SampleTextInput,
SampleTextUseCase,
} from "../../../../supporting/sample/useCase.js";
import {
createSampleHelloHandler,
createSampleTextHandler,
} from "./handler.js";
// リクエストボディ用のスキーマ定義
const sampleTextRequestBodySchema = z.object({
text: z
.string()
.min(1, "textは必須です")
.max(10, "textは10文字以内で入力してください"),
});
export const createSampleRoutes = (
sampleHelloUseCase: SampleHelloUseCase,
sampleTextUseCase: SampleTextUseCase,
) => {
// Honoでルーティングを作る際は、メソッドチェーン推薦
return (
new Hono()
// GET /sample
.get("/", createSampleHelloHandler(sampleHelloUseCase))
// POST /sample
.post(
"/",
zValidator("json", sampleTextRequestBodySchema), // バリデーションチェック
(c) => {
// バリデーション済みのリクエストパラメータを取得
const { text } = c.req.valid("json");
// バリデーション済みのリクエストパラメータを取得
const input: SampleTextInput = { text };
return createSampleTextHandler(sampleTextUseCase)(c, input);
},
)
);
};
※サンプルAPIのルーティング部分(リクエストパラメータのバリデーションチェックを含む)、Honoでルーティングを作る際は、メソッドチェーン推薦
・「src/presentation/http/supporting/sample/routes.test.ts」
import { Hono } from "hono";
import { testClient } from "hono/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createApp } from "../../../../app.js";
import type { NodeConfig } from "../../../../runtime/node/config.js";
import { config } from "../../../../runtime/node/config.js";
import type {
SampleHelloUseCase,
SampleTextUseCase,
} from "../../../../supporting/sample/useCase.js";
import { createSampleRoutes } from "./routes.js";
// ログ出力の無効化
vi.spyOn(console, "log").mockImplementation(() => {});
// UseCaseのモック
let sampleHelloUseCase: {
execute: ReturnType<typeof vi.fn>;
};
let sampleTextUseCase: {
execute: ReturnType<typeof vi.fn>;
};
// テスト用クライアント作成関数
const createTestClient = (config: NodeConfig) => {
// ルーティング設定
const v1 = new Hono().route(
"/sample",
createSampleRoutes(
sampleHelloUseCase as SampleHelloUseCase,
sampleTextUseCase as SampleTextUseCase,
),
);
const routes = new Hono().route("/v1", v1);
const app = createApp(config).route("/api", routes);
return testClient(app);
};
// テスト用クライアント変数
let client: ReturnType<typeof createTestClient>;
// テスト用コンフィグ変数
let testConfig: NodeConfig;
// テスト前の共通セットアップ
beforeEach(() => {
// モックの初期化
sampleHelloUseCase = {
execute: vi.fn().mockReturnValue({
message: "Hello World !!",
}),
};
sampleTextUseCase = {
execute: vi.fn().mockReturnValue({
message: "テキスト!",
}),
};
testConfig = {
...config,
};
client = createTestClient(testConfig);
});
describe("GET /api/v1/sample", () => {
it("正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.sample.$get();
// 検証
expect(response.status).toBe(200);
expect(sampleHelloUseCase.execute).toHaveBeenCalledTimes(1);
});
});
describe("POST /api/v1/sample", () => {
it("正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.sample.$post({
json: {
text: "サンプル!",
},
});
// 検証
expect(response.status).toBe(200);
expect(sampleTextUseCase.execute).toHaveBeenCalledTimes(1);
expect(sampleTextUseCase.execute).toHaveBeenCalledWith(expect.anything(), {
text: "サンプル!",
});
});
it("textが未指定の場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.sample.$post({
json: {
text: "",
},
});
// 検証
expect(response.status).toBe(400);
expect(sampleTextUseCase.execute).not.toHaveBeenCalled();
});
it("textが全角10文字以内の場合に正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.sample.$post({
json: {
text: "あいうえおかきくけこ",
},
});
// 検証
expect(response.status).toBe(200);
expect(sampleTextUseCase.execute).toHaveBeenCalledTimes(1);
});
it("textが全角10文字を超えている場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.sample.$post({
json: {
text: "あいうえおかきくけこさ",
},
});
// 検証
expect(response.status).toBe(400);
expect(sampleTextUseCase.execute).not.toHaveBeenCalled();
});
it("textが半角10文字以内の場合に正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.sample.$post({
json: {
text: "abcdefghij",
},
});
// 検証
expect(response.status).toBe(200);
expect(sampleTextUseCase.execute).toHaveBeenCalledTimes(1);
});
it("textが半角10文字を超えている場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.sample.$post({
json: {
text: "abcdefghijk",
},
});
// 検証
expect(response.status).toBe(400);
expect(sampleTextUseCase.execute).not.toHaveBeenCalled();
});
});
※HTTPリクエスト部分のテストを行う。(主にバリデーションチェックの確認)
・「src/presentation/http/middleware/requestLogger.ts」
import { createMiddleware } from "hono/factory";
import { logger } from "../../../infrastructure/logger/consoleLogger.js";
export const requestLogger = createMiddleware(async (c, next) => {
// 処理の開始時刻を取得
const start = performance.now();
// Honoのミドルウェアで設定したリクエストIDを取得
const requestId = c.get("requestId");
// リクエストIDをセットしたロガーを取得
const requestLogger = logger.withRequestId(requestId);
// 共通ロガーを設定
c.set("logger", requestLogger);
// 処理実行
await next();
// 処理時間を取得
const duration = performance.now() - start;
// 処理結果をログ出力
requestLogger.info("HTTP request", {
method: c.req.method,
path: c.req.path,
durationMs: Math.round(duration),
status: c.res.status,
});
});
※リクエスト単位で一意のリクエストIDを付与し、処理結果をログ出力するミドルウェア。
・「src/app.ts」
import { Hono } from "hono";
import { cors } from "hono/cors";
import { requestId } from "hono/request-id";
import { requestLogger } from "./presentation/http/middleware/requestLogger.js";
import type { Logger } from "./shared/logger/logger.js";
// アプリのコンフィグ型
export type AppConfig = {
env: string;
corsOrigin: string;
};
// アプリの変数型
export type AppVariables = {
Variables: {
requestId: string;
logger: Logger;
};
};
export const createApp = (config: AppConfig) => {
// Honoでルーティングを作る際は、メソッドチェーン推薦
return (
new Hono<AppVariables>()
.get("/", (c) => {
return c.text("Hello Hono!");
})
// CORS設定
.use(
"*",
cors({
origin: config.corsOrigin,
allowHeaders: ["Content-Type", "Authorization"],
credentials: true,
}),
)
// 共通ミドルウェア設定
.use("*", requestId())
.use("*", requestLogger)
);
};
※アプリの共通定義。Honoでルーティングを作る際は、メソッドチェーン推薦。
・「.env」
ENV=local
PORT=3000
CORS_ORIGIN="http://localhost:3000"
※ローカル用の環境変数定義
Node.js環境用の各種ファイルを作成
次にNode.js環境用に各種ファイルを作成するため、以下のコマンドを実行して各種ファイルを作成します。
$ mkdir -p src/runtime/node
$ touch src/runtime/node/config.ts src/runtime/node/app.ts src/runtime/node/server.ts
$ mkdir -p src/di && touch src/di/nodeContainer.ts
$ touch src/presentation/http/routesNode.ts
次に作成したファイルをそれぞれ以下のように記述します。
・「src/runtime/node/config.ts」
import type { AppConfig } from "../../app.js";
// Node環境用のコンフィグ型
export type NodeConfig = AppConfig & {
port: number;
};
// Node環境用のコンフィグ設定
export const config: NodeConfig = {
env: process.env.ENV ?? "local",
corsOrigin: process.env.CORS_ORIGIN ?? "http://localhost:3000",
port: Number(process.env.PORT ?? "3000"),
};
・「src/runtime/node/app.ts」
import { createApp } from "../../app.js";
import { createNodeContainer } from "../../di/nodeContainer.js";
import { createRoutesNode } from "../../presentation/http/routesNode.js";
import type { NodeConfig } from "./config.js";
// Node.js環境用のapp作成関数
export const createNodeApp = (config: NodeConfig) => {
const container = createNodeContainer(config);
return createApp(config).route("/api", createRoutesNode(container));
};
export type NodeApp = ReturnType<typeof createNodeApp>;
※appだけを作成する
・「src/runtime/node/server.ts」
import { serve } from "@hono/node-server";
import { createNodeApp } from "./app.js";
import { config } from "./config.js";
// Node.js環境用のapp作成
const app = createNodeApp(config);
// サーバー起動処理
serve(
{
fetch: app.fetch,
port: config.port,
},
(info) => {
console.log(`Server is running on http://localhost:${info.port}`);
},
);
※サーバー起動処理を作成する
・「src/di/nodeContainer.ts」
import type { NodeConfig } from "../runtime/node/config.js";
import {
SampleHelloUseCase,
SampleTextUseCase,
} from "../supporting/sample/useCase.js";
export const createNodeContainer = (_config: NodeConfig) => {
const sampleHelloUseCase = new SampleHelloUseCase();
const sampleTextUseCase = new SampleTextUseCase();
return {
sampleHelloUseCase,
sampleTextUseCase,
};
};
export type NodeContainer = ReturnType<typeof createNodeContainer>;
※パラメータのconfigはまだ使わないので「_config」としておく
・「src/presentation/http/routesNode.ts」
import { Hono } from "hono";
import type { NodeContainer } from "../../di/nodeContainer.js";
import { createSampleRoutes } from "../http/supporting/sample/routes.js";
export const createRoutesNode = (container: NodeContainer) => {
/******************************
* v1用のルーティング
******************************/
// Honoでルーティングを作る際は、メソッドチェーン推薦
const v1 = new Hono()
.route(
"/sample",
createSampleRoutes(
container.sampleHelloUseCase,
container.sampleTextUseCase,
),
);
/******************************
* ルーティング設定
******************************/
return new Hono().route("/v1", v1);
};
※ランタイム毎にルーティングを設定できるようにしておく。また、 Honoでルーティングを作る際は、メソッドチェーン推薦。
package.jsonのscriptsを修正
次にpackage.jsonのscriptsを以下のように修正します。
・「package.json」
{
・・・
"scripts": {
"dev": "pnpm run dev:node",
"dev:node": "tsx watch src/runtime/node/server.ts",
"build:node": "tsc",
"start:node": "node dist/runtime/node/server.js",
"test": "vitest run",
"test:verbose": "vitest run --reporter=verbose",
"test:watch": "vitest",
"format": "biome format --write . && biome check --write --only=assist/source/organizeImports .",
"lint": "biome lint .",
"check": "biome check .",
"check:fix": "biome check --write ."
},
・・・
}
フォーマッターとリンターによるコードチェック
次に以下のコマンドを実行し、フォーマッターとリンターによるコードチェックを行います。
$ pnpm run format
$ pnpm run lint
実行後、以下のように警告やエラーがでなければOKです。
テストコードによるチェック
次にテストコードによるチェックを行うため、以下のコマンドを実行します。
$ pnpm run test
実行後、以下のように全てのテストがパスすればOKです。
サンプルAPIをPostmanで試す
次にサンプルAPIをPostmanで試してみます。
まずは以下のコマンドを実行し、ローカルサーバーを起動します。
$ pnpm run dev
次にGETメソッドで「http://localhost:3000/api/v1/sample」を実行し、以下のように正常終了して想定通りの結果になればOKです。
次にPOSTメソッドで「http://localhost:3000/api/v1/sample」を実行し、以下のように正常終了して想定通りの結果になればOKです。
尚、共通ロガーも設定しているため、以下のようにリクエスト単位でログ出力も確認できます。
データベースの導入・設定
次にデータベースを導入し、APIから操作するための各種準備を行います。
今回はまずNode.js環境用を想定し、データベースには「PostgreSQL」、ORMには「Drizzle ORM」を試してみます。
※Drizzle ORMは、強力な型安全性と軽量さを追求したSQLデータベース向けのモダンなTypeScript用ORMライブラリです。
また、データベースはDocker環境を利用して構築するため、後述の内容を試したい場合は、事前にDocker Desktopなどの準備を行なって下さい。
PostgreSQLのDockerコンテナ準備
まずはPostgreSQLのDockerコンテナを準備するため、以下のコマンドを実行し、各種ファイルを作成します。
$ mkdir -p deploy/docker/local/db/pg && touch deploy/docker/local/db/pg/Dockerfile
$ mkdir -p deploy/docker/local/db/pg/init && touch deploy/docker/local/db/pg/init/init.sql
$ touch compose.yaml
次に作成したファイルをそれぞれ以下のように記述します。
・「deploy/docker/local/db/pg/Dockerfile」
FROM postgres:18.6
ENV LANG=ja_JP.utf8
# PostgreSQLの日本語化で「ja_JP.utf8」を使うために必要
RUN apt-get update && \
apt-get install -y locales && \
rm -rf /var/lib/apt/lists/* && \
localedef -i ja_JP -c -f UTF-8 -A /usr/share/locale/locale.alias ja_JP.UTF-8
・「deploy/docker/local/db/pg/init/init.sql」
-- テスト用DB作成
CREATE DATABASE "testing-db-pg";
・「compose.yaml」
services:
pg:
container_name: hono-sample-pg
build:
context: .
dockerfile: ./deploy/docker/local/db/pg/Dockerfile
environment:
POSTGRES_DB: "local-db-pg"
POSTGRES_USER: "root"
POSTGRES_PASSWORD: "root-pass"
# ローカル環境でもパスワードを有効化する設定
POSTGRES_INITDB_ARGS: --auth-local=scram-sha-256 --auth-host=scram-sha-256
volumes:
- hono-sample-pg-data:/var/lib/postgresql
# 初回起動時にSQLを実行する設定
- ./deploy/docker/local/db/pg/init:/docker-entrypoint-initdb.d
ports:
- "5432:5432"
volumes:
hono-sample-pg-data:
次にファイル「.env」を以下のように修正します。
・「.env」
ENV=production
PORT=3000
CORS_ORIGIN="http://localhost:3000"
DB_PG_NAME=local-db-pg
DB_PG_USER=root
DB_PG_PASSWORD=root-pass
DB_PG_HOST=localhost
DB_PG_PORT=5432
DB_PG_MAX_OPEN_CONNS=2
DB_PG_CONN_MAX_LIFETIME=300
次に以下のコマンドを実行し、Dockerコンテナを起動します。
$ docker compose up -d
次に以下のコマンドを実行し、ステータスを確認します。
$ docker compose ps
実行後、以下のようにDockerコンテナが起動すればOKです。
Drizzle ORMを利用する準備
次にDrizzle ORMを利用する準備をするため、以下のコマンドを実行し、各種ライブラリをインストールします。
$ pnpm add drizzle-orm pg dotenv
$ pnpm add -D drizzle-kit @types/pg
次に以下のコマンドを実行し、各種ファイルを作成します。
$ touch drizzle.pg.config.ts
$ mkdir -p src/infrastructure/database/drizzle/pg && touch src/infrastructure/database/drizzle/pg/client.ts
次に作成したファイルをそれぞれ以下のように記述します。
・「drizzle.pg.config.ts」
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
// テスト用のDB名
const testingDbPgName = "testing-db-pg";
// 接続先DB名
const dbPgName =
process.env.ENV === "testing" ? testingDbPgName : process.env.DB_PG_NAME;
// 接続先URL
const url =
`postgresql://${process.env.DB_PG_USER}:${process.env.DB_PG_PASSWORD}` +
`@${process.env.DB_PG_HOST}:${process.env.DB_PG_PORT}` +
`/${dbPgName}`;
// drizzleのDB接続設定
export default defineConfig({
dialect: "postgresql",
schema: "./src/infrastructure/database/drizzle/pg/schema/*.ts",
out: "./src/infrastructure/database/drizzle/pg/migrations",
dbCredentials: {
url: url,
},
});
※drizzle-kit用の設定ファイル
・「src/infrastructure/database/drizzle/pg/client.ts」
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
// PostgreSQL用のコンフィグ型
export type PgConfig = {
name: string;
user: string;
password: string;
host: string;
port: number;
maxOpenConns: number;
connMaxLifetime: number;
};
// PostgreSQLのインスタンス型
export type PgDatabase = NodePgDatabase;
// PostgreSQLのトランザクションのインスタンス型
export type PgTransaction = Parameters<
Parameters<PgDatabase["transaction"]>[0]
>[0];
// PostgreSQLのDBクライアント型
export type PgDbClient = PgDatabase | PgTransaction;
// DBインスタンス生成用関数
export const createDbPg = (config: PgConfig) => {
const pool = new Pool({
database: config.name,
user: config.user,
password: config.password,
host: config.host,
port: config.port,
max: config.maxOpenConns,
maxLifetimeSeconds: config.connMaxLifetime,
});
const db = drizzle({
client: pool,
});
return {
pool,
db,
};
};
export type DbPg = ReturnType<typeof createDbPg>;
※テストコードなどでPostgreSQLインスタンスを止めたい場合は、「pool.end()」を利用するため、戻り値に「pool」も入れている。
次にファイル「tsconfig.json」を以下のように修正します。
{
・・・
"exclude": ["node_modules", "drizzle.pg.config.ts"]
}
※excludeに「drizzle.pg.config.ts」を追加
drizzle-kitを利用してusersテーブルを作ってみる
次に後述での利用も想定し、drizzle-kitを利用してusersテーブルを作ってみます。
まずは以下のコマンドを実行し、usersテーブル用のスキーマファイルを作成します。
$ mkdir -p src/infrastructure/database/drizzle/pg/schema && touch src/infrastructure/database/drizzle/pg/schema/users.ts
次にファイル「src/infrastructure/database/drizzle/pg/schema/users.ts」を以下のように記述します。
import {
bigint,
index,
integer,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
export const users = pgTable(
"users",
{
id: bigint("id", { mode: "number" })
.primaryKey()
.generatedAlwaysAsIdentity(),
uid: uuid().notNull().defaultRandom().unique(),
lastName: text("last_name").notNull(),
firstName: text("first_name").notNull(),
totalPurchaseAmount: integer("total_purchase_amount").notNull().default(0),
createdAt: timestamp("created_at", {
withTimezone: true,
})
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", {
withTimezone: true,
})
.notNull()
.defaultNow(),
},
(table) => [index("users_last_name_idx").on(table.lastName)],
);
次に以下のコマンドを実行し、マイグレーション用のファイルを作成します。
$ pnpm drizzle-kit generate --config=drizzle.pg.config.ts
実行後、ファイル「src/infrastructure/database/drizzle/pg/migrations/0000_lean_sabretooth.sql」が作成されればOKです。
次に以下のコマンドを実行し、マイグレーションを行います。
$ pnpm drizzle-kit migrate --config=drizzle.pg.config.ts
※テスト用DBへ実行したい場合は、「ENV=testing pnpm drizzle-kit migrate –config=drizzle.pg.config.ts」を利用して下さい。尚、ロールバック用のコマンドはないため、適用済みのマイグレーションを戻したい場合は、手動での対応(打ち消し用のSQLを作って実行する)が必要になります。
実行後、以下のように正常終了すればOKです。
次に以下のコマンドを実行し、DBに接続してテーブル一覧を確認してみます。
docker compose exec pg bash
PGPASSWORD=root-pass psql -U root -d local-db-pg
\dt
実行後、以下のようにusersテーブルが作成されていればOKです。
次に以下のコマンドを実行し、テーブル定義を確認してみます。
\d users
実行後、以下のようにusersテーブルの定義が確認できればOKです。
尚、DBから抜ける場合は、以下のコマンドを実行して下さい。
\q
exit
Cloudflare環境でD1を使う想定の場合について
上記では、Node.js環境かつデータベースに「PostgreSQL」を利用する想定で準備しましたが、Honoはマルチランタイムに対応しており、Cloudflare環境かつデータベースに「Cloudflare D1(SQLiteベース)」を利用したい方もいると思います。
その場合は、以下の手順にてCloudflare環境向けのローカル開発環境へ対応させることが可能です。
Cloudflare環境用のCLIツール「wrangler」をインストール
まずはCloudflare環境用のCLIツールをインストールするため、以下のコマンドを実行します。
$ pnpm add -D wrangler @cloudflare/workers-types
実行後、workerdに関する警告がでるため、コマンド「pnpm approve-builds」を実行し、スペースキーをクリックしてworkerdにチェックを付け、Enterで実行して承認して下さい。
承認後、ファイル「pnpm-workspace.yaml」のallowBuildsに「workerd: true」が追加されていればOKです。
次に以下のコマンドを実行し、wranglerコマンドが使えることを確認して下さい。
$ pnpm wrangler --version
ローカル環境用のDB「D1(SQLiteベース)」を作成
次にローカル環境用のデータベース「D1(SQLiteベース)」を作成するため、以下のコマンドを実行して、wrangler用の設定ファイルを作成します。
$ touch wrangler.jsonc
次にファイル「wrangler.jsonc」を以下のように記述します。
{
"name": "local-d1",
"compatibility_date": "2026-09-29",
"d1_databases": [
{
"binding": "DB",
"database_name": "local-db-d1",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"migrations_dir": "src/infrastructure/database/drizzle/d1/migrations"
}
]
}
次に以下のコマンドを実行し、ローカル環境用のD1を作成します。
$ pnpm wrangler d1 execute local-db-d1 --local --command="SELECT 1"
※ローカル環境用へ実行する場合はオプション「–local」必須です。
実行後、ディレクトリ「.wrangler」配下に拡張子「.sqlite」の以下のようなファイル「.wrangler/state/v3/d1/miniflare-D1DatabaseObject/423c60534c20823e3775c6e1de8b0d6f1fb85ef14b525413932d985d8dc6abf5.sqlite」が作成されるため、ファイルパスをメモします。
次にテスト用にもう一つ作りたい場合は、ファイル「wrangler.jsonc」を以下のように修正します。
{
"name": "local-d1",
"compatibility_date": "2026-09-29",
"d1_databases": [
{
"binding": "DB",
"database_name": "local-db-d1",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"migrations_dir": "src/infrastructure/database/drizzle/d1/migrations"
},
{
"binding": "TEST_DB",
"database_name": "testing-db-d1",
"database_id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
"migrations_dir": "src/infrastructure/database/drizzle/d1/migrations"
}
]
}
次に以下のコマンドを実行し、テスト用のD1も作成します。
$ pnpm wrangler d1 execute testing-db-d1 --local --command="SELECT 1"
※ローカル環境用へ実行する場合はオプション「–local」必須です。
実行後、先ほどとは違うファイル名の「.wrangler/state/v3/d1/miniflare-D1DatabaseObject/d5cace998a99b013073f98823ec85d6a6601f80941c4f243f809eef878b809e8.sqlite」が作成されるため、ファイルパスをメモします。
次にファイル「.env」を以下のように修正します。
ENV=production
PORT=3000
CORS_ORIGIN="http://localhost:3000"
DB_PG_NAME=local-db-pg
DB_PG_USER=root
DB_PG_PASSWORD=root-pass
DB_PG_HOST=localhost
DB_PG_PORT=5432
DB_PG_MAX_OPEN_CONNS=2
DB_PG_CONN_MAX_LIFETIME=300
DB_D1_PATH=".wrangler/state/v3/d1/miniflare-D1DatabaseObject/423c60534c20823e3775c6e1de8b0d6f1fb85ef14b525413932d985d8dc6abf5.sqlite"
DB_D1_TEST_PATH=".wrangler/state/v3/d1/miniflare-D1DatabaseObject/d5cace998a99b013073f98823ec85d6a6601f80941c4f243f809eef878b809e8.sqlite"
※DB_D1_PATHとDB_D1_TEST_PATHの値は各自メモした値を設定して下さい。
次にディレクトリ「.wrangle」はgit管理対象外のため、ファイル「.gitignore」を以下のように修正します。
・・・
# wrangler
.wrangler/
Drizzle ORMを利用する準備
次に上記と同様にDrizzle ORMを利用しますが、現在通常インストールされるDrizzle ORMのバージョンがv1未満(今回は「0.45.3」でした)の場合、別途sqlite用のライブラリを利用する必要があるようなので、以下のコマンドを実行し、追加で必要になるライブラリをインストールします。
$ pnpm add -D node-gyp @types/better-sqlite3
$ pnpm add better-sqlite3
※better-sqlite3を利用するには「node-gyp」も使うようです。
実行後、better-sqlite3に関する警告がでるため、コマンド「pnpm approve-builds」を実行し、スペースキーをクリックしてbetter-sqlite3にチェックを付け、Enterで実行して承認して下さい。
承認後、ファイル「pnpm-workspace.yaml」のallowBuildsに「better-sqlite3: true」が追加されていればOKです。
次に以下のコマンドを実行し、各種ファイルを作成します。
$ touch drizzle.d1.config.ts
$ mkdir -p src/infrastructure/database/drizzle/d1 && touch src/infrastructure/database/drizzle/d1/client.ts
次に作成したファイルをそれぞれ以下のように記述します。
・「drizzle.d1.config.ts」
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
// 接続先DBパス
const dbPath =
process.env.ENV === "testing"
? process.env.DB_D1_TEST_PATH
: process.env.DB_D1_PATH;
// 接続先URL
const url = `${dbPath}`;
// drizzleのDB接続設定
export default defineConfig({
dialect: "sqlite",
schema: "./src/infrastructure/database/drizzle/d1/schema/*.ts",
out: "./src/infrastructure/database/drizzle/d1/migrations",
dbCredentials: {
url: url,
},
});
・「src/infrastructure/database/drizzle/d1/client.ts」
import type { DrizzleD1Database } from "drizzle-orm/d1";
import { drizzle } from "drizzle-orm/d1";
// DBインスタンスの型
export type DatabaseD1 = DrizzleD1Database;
// DBクライアントの型
export type DbClient = DatabaseD1;
// DBインスタンス生成用関数
export const createDbD1 = (db: D1Database): DatabaseD1 => {
return drizzle(db);
};
次にファイル「tsconfig.json」を以下のように修正します。
{
"compilerOptions": {
・・・
"types": ["node", "@cloudflare/workers-types"],
・・・
},
"exclude": ["node_modules", "drizzle.pg.config.ts", "drizzle.d1.config.ts"]
}
※compilerOptionsのtypesに「@cloudflare/workers-types」を追加、excludeに「drizzle.d1.config.ts」を追加
drizzle-kitを利用してusersテーブルを作ってみる
次にdrizzle-kitを利用してusersテーブル作ってみます。
まずは以下のコマンドを実行し、usersテーブル用のスキーマファイルを作成します。
$ mkdir -p src/infrastructure/database/drizzle/d1/schema && touch src/infrastructure/database/drizzle/d1/schema/users.ts
次に作成したファイル「src/infrastructure/database/drizzle/d1/schema/users.ts」を以下のように記述します。
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const users = sqliteTable(
"users",
{
id: integer("id").primaryKey({ autoIncrement: true }),
uid: text("uid").notNull().unique(),
lastName: text("last_name").notNull(),
firstName: text("first_name").notNull(),
totalPurchaseAmount: integer("total_purchase_amount").notNull().default(0),
createdAt: integer("created_at", {
mode: "timestamp",
})
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", {
mode: "timestamp",
})
.notNull()
.$defaultFn(() => new Date()),
},
(table) => [index("users_last_name_idx").on(table.lastName)],
);
次に以下のコマンドを実行し、マイグレーション用のファイルを作成します。
$ pnpm drizzle-kit generate --config=drizzle.d1.config.ts
実行後、ファイル「src/infrastructure/database/drizzle/d1/migrations/0000_public_invisible_woman.sql」が作成されればOKです。
次に以下のコマンドを実行し、マイグレーションを行います。
$ pnpm drizzle-kit migrate --config=drizzle.d1.config.ts
※テスト用DBへ実行したい場合は、「ENV=testing pnpm drizzle-kit migrate –config=drizzle.d1.config.ts」を利用して下さい。
実行後、以下のように正常終了すればOKです。
次に以下のコマンドを実行し、ローカルのD1に対してクエリを実行し、テーブル一覧を確認してみます。
$ pnpm wrangler d1 execute local-db-d1 --local --command="SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"
実行後、以下のようにusersテーブルが作成されていればOKです。
次に以下のコマンドを実行し、テーブル定義を確認してみます。
$ pnpm wrangler d1 execute local-db-d1 --local --command="PRAGMA table_info(users);"
実行後、以下のようにusersテーブルの定義が確認できればOKです。
Cloudflare環境用に対応し、wranglerでサーバー起動を試す
次に上記で作成済みのAPIをCloudflare環境用に対応させてみます。
まずは以下のコマンドを実行し、TypeScriptの型定義を生成します。
$ pnpm wrangler types
実行後、ファイル「worker-configuration.d.ts」が作成されればOKです。
次にbiomeのチェックから外すため、ファイル「biome.json」を以下のように修正します。
{
・・・
"files": {
"ignoreUnknown": false,
"includes": ["**", "!**/worker-configuration.d.ts"]
},
・・・
}
※「”includes”: [“**”, “!**/worker-configuration.d.ts”]」を追加
次に以下のコマンドを実行し、ランタイム用の各種ファイルを作成します。
$ mkdir -p src/runtime/cloudflare
$ touch src/runtime/cloudflare/config.ts src/runtime/cloudflare/app.ts src/runtime/cloudflare/worker.ts
$ touch src/di/cloudflareContainer.ts
$ touch src/presentation/http/routesCloudflare.ts
次に作成したファイルをそれぞれ以下のように記述します。
・「src/runtime/cloudflare/config.ts」
import type { AppConfig } from "../../app.js";
// Cloudflare環境用のコンフィグ定義
export type CloudflareConfig = AppConfig & {
db: D1Database;
};
// Cloudflare環境用のコンフィグ設定関数
export const createConfig = (env: Env): CloudflareConfig => {
return {
env: env.ENV,
corsOrigin: env.CORS_ORIGIN,
db: env.ENV === "testing" ? env.TEST_DB : env.DB,
};
};
・「src/runtime/cloudflare/app.ts」
import { createApp } from "../../app.js";
import { createCloudflareContainer } from "../../di/cloudflareContainer.js";
import { createRoutesCloudflare } from "../../presentation/http/routesCloudflare.js";
import type { CloudflareConfig } from "./config.js";
// Cloudflare環境用のapp作成関数
export const createCloudflareApp = (config: CloudflareConfig) => {
const container = createCloudflareContainer(config);
return createApp(config).route("/api", createRoutesCloudflare(container));
};
export type CloudflareApp = ReturnType<typeof createCloudflareApp>;
・「src/runtime/cloudflare/worker.ts」
import { createCloudflareApp } from "./app.js";
import { createConfig } from "./config.js";
// サーバー起動処理
export default {
fetch: (request: Request, env: Env, ctx: ExecutionContext) => {
// コンフィグ作成
const config = createConfig(env);
// Cloudflare用のapp作成
const app = createCloudflareApp(config);
// 戻り値
return app.fetch(request, env, ctx);
},
};
※サーバー起動後、「env: Env」が使えるようになるため、これを「createConfig」に渡してコンフィグを作成しています。
・「src/di/cloudflareContainer.ts」
import type { CloudflareConfig } from "../runtime/cloudflare/config.js";
import {
SampleHelloUseCase,
SampleTextUseCase,
} from "../supporting/sample/useCase.js";
export const createCloudflareContainer = (_config: CloudflareConfig) => {
const sampleHelloUseCase = new SampleHelloUseCase();
const sampleTextUseCase = new SampleTextUseCase();
return {
sampleHelloUseCase,
sampleTextUseCase,
};
};
export type CloudflareContainer = ReturnType<typeof createCloudflareContainer>;
・「src/presentation/http/routesCloudflare.ts」
import { Hono } from "hono";
import type { CloudflareContainer } from "../../di/cloudflareContainer.js";
import { createSampleRoutes } from "../http/supporting/sample/routes.js";
export const createRoutesCloudflare = (container: CloudflareContainer) => {
/******************************
* v1用のルーティング
******************************/
// Honoでルーティングを作る際は、メソッドチェーン推薦
const v1 = new Hono().route(
"/sample",
createSampleRoutes(
container.sampleHelloUseCase,
container.sampleTextUseCase,
),
);
/******************************
* ルーティング設定
******************************/
return new Hono().route("/v1", v1);
};
次に以下のコマンドを実行し、ローカルサーバーを起動します。
$ pnpm wrangler dev
実行後、ローカルサーバー「http://localhost:8787/」が起動します。
Postmanで「GET http://localhost:8787/api/v1/sample」を実行し、以下のように想定通りの結果になればOKです。
尚、ログ出力も同様に出力されます。
usersテーブル用のCRUD APIを作成する
上記で準備したPostgreSQLのusersテーブルを利用し、CRUD APIを作成してみます。
単純なCRUD処理を想定し、一般的な業務領域として「generic」ディレクトリを作成して各種ファイルを作ります。
カスタムエラー用の設定
まずはカスタムエラーに対応できるようにするため、以下のコマンドを実行して各種ファイルを作成します。
$ mkdir -p src/shared/error && touch src/shared/error/appError.ts
$ mkdir -p src/presentation/http/error
$ touch src/presentation/http/error/errorHandler.ts src/presentation/http/error/errorStatusMap.ts
$ mkdir -p src/generic/user && touch src/generic/user/errors.ts
次に作成したファイルをそれぞれ以下のように記述します。
・「src/shared/error/appError.ts」
// ログレベル
export type LogLevel = "info" | "warn" | "error" | "debug";
// 共通エラークラス
export class AppError<TCode extends string = string> extends Error {
constructor(
message: string,
public readonly code: TCode,
public readonly logLevel: LogLevel = "info",
) {
super(message);
this.name = "AppError";
}
}
※ジェネリクスでカスタムエラーコード用の型を指定できるようにしている。
・「src/presentation/http/error/errorHandler.ts」
import type { ErrorHandler } from "hono";
import { AppError } from "../../../shared/error/appError.js";
import { errorStatusMap } from "./errorStatusMap.js";
export const errorHandler: ErrorHandler = (err, c) => {
const logger = c.get("logger");
// カスタムエラー用のエラーハンドリング
// 共通エラークラスを利用してカスタムエラークラスを作成し、
// エラーコードとHTTPステータスコードのマッピングを行う
if (err instanceof AppError) {
const status = errorStatusMap[err.code];
// エラーコードに対応するHTTPステータスコードが存在する場合は、エラーレスポンスを返す
if (status) {
// ログ出力
logger[err.logLevel]("AppError occurred", {
error: err,
requestId: c.get("requestId"),
});
return c.json(
{
code: err.code,
message: err.message,
},
status,
);
}
}
logger.error("Unexpected error", {
error: err,
});
return c.json(
{
message: "Internal Server Error",
},
500,
);
};
・「src/presentation/http/error/errorStatusMap.ts」
import type { ContentfulStatusCode } from "hono/utils/http-status";
import { userErrorStatusMap } from "../../../generic/user/errors.js";
// 共通エラーコードとHTTPステータスコードのマッピング
export const errorStatusMap: Record<string, ContentfulStatusCode> = {
...userErrorStatusMap,
};
※エラーコードを追加したらここでまとめる
・「src/generic/user/errors.ts」
import type { ContentfulStatusCode } from "hono/utils/http-status";
import { AppError } from "../../shared/error/appError.js";
// エラーコード(カスタムエラー用)
// 「|」で区切って追加可能
export type UserErrorCode = "USER_NOT_FOUND";
// エラーコードとHTTPステータスコードのマッピング
export const userErrorStatusMap: Record<UserErrorCode, ContentfulStatusCode> = {
USER_NOT_FOUND: 404,
};
export class UserNotFoundError extends AppError<UserErrorCode> {
constructor() {
super("User not found", "USER_NOT_FOUND", "warn");
}
}
※カスタムエラーを作る場合の例
次にファイル「src/app.ts」を以下のように修正します。
import { Hono } from "hono";
import { cors } from "hono/cors";
import { requestId } from "hono/request-id";
import { errorHandler } from "./presentation/http/error/errorHandler.js";
import { requestLogger } from "./presentation/http/middleware/requestLogger.js";
import type { Logger } from "./shared/logger/logger.js";
// アプリのコンフィグ型
export type AppConfig = {
env: string;
corsOrigin: string;
};
// アプリの変数型
export type AppVariables = {
Variables: {
requestId: string;
logger: Logger;
};
};
export const createApp = (config: AppConfig) => {
// Honoでルーティングを作る際は、メソッドチェーン推薦
return (
new Hono<AppVariables>()
.get("/", (c) => {
return c.text("Hello Hono!");
})
// エラーハンドラー設定
.onError(errorHandler)
// CORS設定
.use(
"*",
cors({
origin: config.corsOrigin,
allowHeaders: ["Content-Type", "Authorization"],
credentials: true,
}),
)
// 共通ミドルウェア設定
.use("*", requestId())
.use("*", requestLogger)
);
};
※「app.onError(errorHandler);」を追加してカスタムエラーを使えるようにする
DB接続用のコンフィグ設定を追加
次にDB接続用のコンフィグ設定を追加するため、ファイル「src/runtime/node/config.ts」を以下のように修正します。
import type { AppConfig } from "../../app.js";
import type { PgConfig } from "../../infrastructure/database/drizzle/pg/client.js";
// Node環境用のコンフィグ型
export type NodeConfig = AppConfig & {
port: number;
db: PgConfig;
};
// Node環境用のコンフィグ設定
export const config: NodeConfig = {
env: process.env.ENV ?? "local",
corsOrigin: process.env.CORS_ORIGIN ?? "http://localhost:3000",
port: Number(process.env.PORT ?? "3000"),
db: {
name: process.env.DB_PG_NAME ?? "local-db-pg",
user: process.env.DB_PG_USER ?? "root",
password: process.env.DB_PG_PASSWORD ?? "root-pass",
host: process.env.DB_PG_HOST ?? "localhost",
port: Number(process.env.DB_PG_PORT ?? "5432"),
maxOpenConns: Number(process.env.DB_PG_MAX_OPEN_CONNS ?? "2"),
connMaxLifetime: Number(process.env.DB_PG_CONN_MAX_LIFETIME ?? "300"),
},
};
※ランタイム環境に合わせた環境変数設定をする
ユーザー用のCRUD APIを追加
次にユーザー用のCRUD APIを追加するため、以下のコマンドを実行し、各種ファイルを作成します。
$ touch src/generic/user/useCase.ts src/generic/user/useCase.test.ts
$ mkdir -p src/presentation/http/generic/user
$ touch src/presentation/http/generic/user/handler.ts
$ touch src/presentation/http/generic/user/routes.ts src/presentation/http/generic/user/routes.test.ts
$ mkdir -p src/tests/integration/generic/user && touch src/tests/integration/generic/user/user.test.ts
次に作成したファイルをそれぞれ以下のように記述します。
・「src/generic/user/useCase.ts」
import { eq } from "drizzle-orm";
import type { PgDatabase } from "../../infrastructure/database/drizzle/pg/client.js";
import { users } from "../../infrastructure/database/drizzle/pg/schema/users.js";
import type { Logger } from "../../shared/logger/logger.js";
import { UserNotFoundError } from "./errors.js";
export type CreateUserInput = {
lastName: string;
firstName: string;
totalPurchaseAmount: number;
};
export type FindUserInput = {
uid: string;
};
export type UpdateUserInput = {
uid: string;
lastName: string;
firstName: string;
totalPurchaseAmount: number;
};
export type DeleteUserInput = {
uid: string;
};
// ユーザー作成
export class CreateUserUseCase {
constructor(private readonly db: PgDatabase) {}
async execute(logger: Logger, input: CreateUserInput) {
logger.info("Create user", input);
const [user] = await this.db
.insert(users)
.values({
lastName: input.lastName,
firstName: input.firstName,
totalPurchaseAmount: input.totalPurchaseAmount,
})
.returning();
return user;
}
}
// 全てのユーザー取得
export class FindUsersUseCase {
constructor(private readonly db: PgDatabase) {}
async execute(logger: Logger) {
logger.info("Find users");
const result = await this.db.select().from(users);
return result;
}
}
// 対象のユーザー取得
export class FindUserUseCase {
constructor(private readonly db: PgDatabase) {}
async execute(logger: Logger, input: FindUserInput) {
logger.info("Find user", input);
const [user] = await this.db
.select()
.from(users)
.where(eq(users.uid, input.uid))
.limit(1);
return user ?? {};
}
}
// 対象のユーザー更新
export class UpdateUserUseCase {
constructor(private readonly db: PgDatabase) {}
async execute(logger: Logger, input: UpdateUserInput) {
logger.info("Update user", input);
const user = await this.db.transaction(async (tx) => {
const [user] = await tx
.update(users)
.set({
lastName: input.lastName,
firstName: input.firstName,
totalPurchaseAmount: input.totalPurchaseAmount,
updatedAt: new Date(),
})
.where(eq(users.uid, input.uid))
.returning();
if (!user) {
throw new UserNotFoundError();
}
return user;
});
return user;
}
}
// 対象のユーザー削除
export class DeleteUserUseCase {
constructor(private readonly db: PgDatabase) {}
async execute(logger: Logger, input: DeleteUserInput) {
logger.info("Delete user", input);
const [user] = await this.db
.delete(users)
.where(eq(users.uid, input.uid))
.returning();
if (!user) {
throw new UserNotFoundError();
}
return user;
}
}
※トランザクションを使う場合は、ユースケース単位で使うことを想定としている
・「src/generic/user/useCase.test.ts」
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PgDatabase } from "../../infrastructure/database/drizzle/pg/client.js";
import type { Logger } from "../../shared/logger/logger.js";
import { UserNotFoundError } from "./errors.js";
import {
CreateUserUseCase,
DeleteUserUseCase,
FindUsersUseCase,
FindUserUseCase,
UpdateUserUseCase,
} from "./useCase.js";
// ロガーのモック化
const createMockLogger = (): Logger => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
withRequestId: vi.fn(),
});
// DBのモック化
const createMockDb = <T extends object>(mock: T): PgDatabase =>
mock as unknown as PgDatabase;
let logger: Logger;
// テスト前の共通セットアップ
beforeEach(() => {
logger = createMockLogger();
});
describe("CreateUserUseCase", () => {
it("ユーザーを作成できること", async () => {
// DBのモック化
const user = {
uid: "00000000-0000-0000-0000-000000000001",
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
};
const returning = vi.fn().mockResolvedValue([user]);
const values = vi.fn().mockReturnValue({ returning });
const insert = vi.fn().mockReturnValue({ values });
const db = createMockDb({
insert,
});
// ユースケース作成
const useCase = new CreateUserUseCase(db);
// ユースケース実行
const result = await useCase.execute(logger, {
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
});
// 検証
expect(result).toEqual(user);
expect(logger.info).toHaveBeenCalledWith("Create user", {
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
});
expect(values).toHaveBeenCalledWith({
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
});
});
});
describe("FindUsersUseCase", () => {
it("全てのユーザーを取得できること", async () => {
// DBのモック化
const users = [
{
uid: "00000000-0000-0000-0000-000000000001",
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
},
{
uid: "00000000-0000-0000-0000-000000000002",
lastName: "佐藤",
firstName: "花子",
totalPurchaseAmount: 2000,
},
];
const from = vi.fn().mockResolvedValue(users);
const select = vi.fn().mockReturnValue({ from });
const db = createMockDb({
select,
});
// ユースケース作成
const useCase = new FindUsersUseCase(db);
// ユースケース実行
const result = await useCase.execute(logger);
// 検証
expect(result).toEqual(users);
expect(logger.info).toHaveBeenCalledWith("Find users");
});
});
describe("FindUserUseCase", () => {
it("指定したユーザーを取得できること", async () => {
// DBのモック化
const user = {
uid: "00000000-0000-0000-0000-000000000001",
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
};
const limit = vi.fn().mockResolvedValue([user]);
const where = vi.fn().mockReturnValue({ limit });
const from = vi.fn().mockReturnValue({ where });
const select = vi.fn().mockReturnValue({ from });
const db = createMockDb({
select,
});
// ユースケース作成
const useCase = new FindUserUseCase(db);
// ユースケース実行
const result = await useCase.execute(logger, {
uid: user.uid,
});
// 検証
expect(result).toEqual(user);
expect(logger.info).toHaveBeenCalledWith("Find user", {
uid: user.uid,
});
expect(limit).toHaveBeenCalledWith(1);
});
it("ユーザーが存在しない場合は空オブジェクトを返すこと", async () => {
// DBのモック化
const limit = vi.fn().mockResolvedValue([]);
const where = vi.fn().mockReturnValue({ limit });
const from = vi.fn().mockReturnValue({ where });
const select = vi.fn().mockReturnValue({ from });
const db = createMockDb({
select,
});
// ユースケース作成
const useCase = new FindUserUseCase(db);
// ユースケース実行
const result = await useCase.execute(logger, {
uid: "00000000-0000-0000-0000-000000000099",
});
// 検証
expect(result).toEqual({});
expect(logger.info).toHaveBeenCalledWith("Find user", {
uid: "00000000-0000-0000-0000-000000000099",
});
});
});
describe("UpdateUserUseCase", () => {
it("ユーザーを更新できること", async () => {
// DBのモック化
const user = {
uid: "00000000-0000-0000-0000-000000000001",
lastName: "山田",
firstName: "次郎",
totalPurchaseAmount: 3000,
};
const returning = vi.fn().mockResolvedValue([user]);
const where = vi.fn().mockReturnValue({ returning });
const set = vi.fn().mockReturnValue({ where });
const update = vi.fn().mockReturnValue({ set });
const transaction = vi.fn(async (callback) => {
return callback({
update,
});
});
const db = createMockDb({
transaction,
});
// ユースケース作成
const useCase = new UpdateUserUseCase(db);
// ユースケース実行
const result = await useCase.execute(logger, {
uid: user.uid,
lastName: "山田",
firstName: "次郎",
totalPurchaseAmount: 3000,
});
// 検証
expect(result).toEqual(user);
expect(logger.info).toHaveBeenCalledWith("Update user", {
uid: user.uid,
lastName: "山田",
firstName: "次郎",
totalPurchaseAmount: 3000,
});
expect(set).toHaveBeenCalledWith({
lastName: "山田",
firstName: "次郎",
totalPurchaseAmount: 3000,
updatedAt: expect.any(Date),
});
});
it("ユーザーが存在しない場合はUserNotFoundErrorを投げること", async () => {
// DBのモック化
const returning = vi.fn().mockResolvedValue([]);
const where = vi.fn().mockReturnValue({ returning });
const set = vi.fn().mockReturnValue({ where });
const update = vi.fn().mockReturnValue({ set });
const transaction = vi.fn(async (callback) => {
return callback({
update,
});
});
const db = createMockDb({
transaction,
});
// ユースケース作成
const useCase = new UpdateUserUseCase(db);
// ユースケース実行・検証
await expect(
useCase.execute(logger, {
uid: "00000000-0000-0000-0000-000000000099",
lastName: "山田",
firstName: "次郎",
totalPurchaseAmount: 3000,
}),
).rejects.toBeInstanceOf(UserNotFoundError);
expect(logger.info).toHaveBeenCalledWith("Update user", {
uid: "00000000-0000-0000-0000-000000000099",
lastName: "山田",
firstName: "次郎",
totalPurchaseAmount: 3000,
});
});
});
describe("DeleteUserUseCase", () => {
it("ユーザーを削除できること", async () => {
// DBのモック化
const user = {
uid: "00000000-0000-0000-0000-000000000001",
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
};
const returning = vi.fn().mockResolvedValue([user]);
const where = vi.fn().mockReturnValue({ returning });
const deleteUser = vi.fn().mockReturnValue({ where });
const db = createMockDb({
delete: deleteUser,
});
// ユースケース作成
const useCase = new DeleteUserUseCase(db);
// ユースケース実行
const result = await useCase.execute(logger, {
uid: user.uid,
});
// 検証
expect(result).toEqual(user);
expect(logger.info).toHaveBeenCalledWith("Delete user", {
uid: user.uid,
});
});
it("ユーザーが存在しない場合はUserNotFoundErrorを投げること", async () => {
// DBのモック化
const returning = vi.fn().mockResolvedValue([]);
const where = vi.fn().mockReturnValue({ returning });
const deleteUser = vi.fn().mockReturnValue({ where });
const db = createMockDb({
delete: deleteUser,
});
// ユースケース作成
const useCase = new DeleteUserUseCase(db);
// ユースケース実行・検証
await expect(
useCase.execute(logger, {
uid: "00000000-0000-0000-0000-000000000099",
}),
).rejects.toBeInstanceOf(UserNotFoundError);
expect(logger.info).toHaveBeenCalledWith("Delete user", {
uid: "00000000-0000-0000-0000-000000000099",
});
});
});
・「src/presentation/http/generic/user/handler.ts」
import type { Context } from "hono";
import type {
CreateUserInput,
CreateUserUseCase,
DeleteUserInput,
DeleteUserUseCase,
FindUserInput,
FindUsersUseCase,
FindUserUseCase,
UpdateUserInput,
UpdateUserUseCase,
} from "../../../../generic/user/useCase.js";
export const createCreateUserHandler =
(useCase: CreateUserUseCase) =>
async (c: Context, input: CreateUserInput) => {
// 共通ロガー取得
const logger = c.get("logger");
// ユースケース実行
const result = await useCase.execute(logger, input);
return c.json(result, 201);
};
export const createFindUsersHandler =
(useCase: FindUsersUseCase) => async (c: Context) => {
// 共通ロガー取得
const logger = c.get("logger");
// ユースケース実行
const result = await useCase.execute(logger);
return c.json(result);
};
export const createFindUserHandler =
(useCase: FindUserUseCase) => async (c: Context, input: FindUserInput) => {
// 共通ロガー取得
const logger = c.get("logger");
// ユースケース実行
const result = await useCase.execute(logger, input);
return c.json(result);
};
export const createUpdateUserHandler =
(useCase: UpdateUserUseCase) =>
async (c: Context, input: UpdateUserInput) => {
// 共通ロガー取得
const logger = c.get("logger");
// ユースケース実行
const result = await useCase.execute(logger, input);
return c.json(result);
};
export const createDeleteUserHandler =
(useCase: DeleteUserUseCase) =>
async (c: Context, input: DeleteUserInput) => {
// 共通ロガー取得
const logger = c.get("logger");
// ユースケース実行
const _result = await useCase.execute(logger, input);
return c.body(null, 204);
};
※関数を2段階で実行するため、2回連続で「=>」を使っている。
・「src/presentation/http/generic/user/routes.ts」
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import type {
CreateUserInput,
CreateUserUseCase,
DeleteUserInput,
DeleteUserUseCase,
FindUserInput,
FindUsersUseCase,
FindUserUseCase,
UpdateUserInput,
UpdateUserUseCase,
} from "../../../../generic/user/useCase.js";
import {
createCreateUserHandler,
createDeleteUserHandler,
createFindUserHandler,
createFindUsersHandler,
createUpdateUserHandler,
} from "./handler.js";
// ユーザー作成用のリクエストボディスキーマ定義
const createUserRequestBodySchema = z.object({
lastName: z
.string()
.min(1, "lastNameは必須です")
.max(10, "lastNameは10文字以内で入力してください"),
firstName: z
.string()
.min(1, "firstNameは必須です")
.max(10, "firstNameは10文字以内で入力してください"),
totalPurchaseAmount: z
.number()
.min(0, "totalPurchaseAmountは0以上の数値で入力してください"),
});
// ユーザー取得用のリクエストパラメータのスキーマ定義
const findUserRequestParamSchema = z.object({
uid: z.uuid().min(1, "uidは必須です"),
});
// ユーザー更新用のリクエストパラメータのスキーマ定義
const updateUserRequestParamSchema = z.object({
uid: z.uuid().min(1, "uidは必須です"),
});
// ユーザー更新用のリクエストボディスキーマ定義
const updateUserRequestBodySchema = z.object({
lastName: z
.string()
.min(1, "lastNameは必須です")
.max(10, "lastNameは10文字以内で入力してください"),
firstName: z
.string()
.min(1, "firstNameは必須です")
.max(10, "firstNameは10文字以内で入力してください"),
totalPurchaseAmount: z
.number()
.min(0, "totalPurchaseAmountは0以上の数値で入力してください"),
});
// ユーザー削除用のリクエストパラメータのスキーマ定義
const deleteUserRequestParamSchema = z.object({
uid: z.uuid().min(1, "uidは必須です"),
});
export const createUserRoutes = (
createUserUseCase: CreateUserUseCase,
findUsersUseCase: FindUsersUseCase,
findUserUseCase: FindUserUseCase,
updateUserUseCase: UpdateUserUseCase,
deleteUserUseCase: DeleteUserUseCase,
) => {
// Honoでルーティングを作る際は、メソッドチェーン推薦
const routes = new Hono()
.post("/", zValidator("json", createUserRequestBodySchema), (c) => {
// バリデーション済みのリクエストパラメータを取得
const { lastName, firstName, totalPurchaseAmount } = c.req.valid("json");
// インプットパラメータ作成
const input: CreateUserInput = {
lastName,
firstName,
totalPurchaseAmount,
};
return createCreateUserHandler(createUserUseCase)(c, input);
})
.get("/", createFindUsersHandler(findUsersUseCase))
.get("/:uid", zValidator("param", findUserRequestParamSchema), (c) => {
// バリデーション済みのリクエストパラメータを取得
const { uid } = c.req.valid("param");
// インプットパラメータ作成
const input: FindUserInput = { uid };
return createFindUserHandler(findUserUseCase)(c, input);
})
.put(
"/:uid",
zValidator("param", updateUserRequestParamSchema),
zValidator("json", updateUserRequestBodySchema),
(c) => {
// バリデーション済みのリクエストパラメータを取得
const { uid } = c.req.valid("param");
const { lastName, firstName, totalPurchaseAmount } =
c.req.valid("json");
// インプットパラメータ作成
const input: UpdateUserInput = {
uid,
lastName,
firstName,
totalPurchaseAmount,
};
return createUpdateUserHandler(updateUserUseCase)(c, input);
},
)
.delete("/:uid", zValidator("param", deleteUserRequestParamSchema), (c) => {
// バリデーション済みのリクエストパラメータを取得
const { uid } = c.req.valid("param");
// インプットパラメータ作成
const input: DeleteUserInput = { uid };
return createDeleteUserHandler(deleteUserUseCase)(c, input);
});
return routes;
};
※ルーティングの方でバリデーションチェックを行い、チェック済みのリクエストパラメータをユースケースへ渡して使用する。また、Honoでルーティングを作る際は、メソッドチェーン推薦。
・「src/presentation/http/generic/user/routes.test.ts」
import { Hono } from "hono";
import { testClient } from "hono/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createApp } from "../../../../app.js";
import type {
CreateUserUseCase,
DeleteUserUseCase,
FindUsersUseCase,
FindUserUseCase,
UpdateUserUseCase,
} from "../../../../generic/user/useCase.js";
import type { NodeConfig } from "../../../../runtime/node/config.js";
import { config } from "../../../../runtime/node/config.js";
import { createUserRoutes } from "./routes.js";
// ログ出力の無効化
vi.spyOn(console, "log").mockImplementation(() => {});
// UseCaseのモック
type MockUseCase = {
execute: ReturnType<typeof vi.fn>;
};
let createUserUseCase: MockUseCase;
let findUsersUseCase: MockUseCase;
let findUserUseCase: MockUseCase;
let updateUserUseCase: MockUseCase;
let deleteUserUseCase: MockUseCase;
// テスト用クライアント作成関数
const createTestClient = (config: NodeConfig) => {
// ルーティング設定
const v1 = new Hono().route(
"/users",
createUserRoutes(
createUserUseCase as unknown as CreateUserUseCase,
findUsersUseCase as unknown as FindUsersUseCase,
findUserUseCase as unknown as FindUserUseCase,
updateUserUseCase as unknown as UpdateUserUseCase,
deleteUserUseCase as unknown as DeleteUserUseCase,
),
);
const routes = new Hono().route("/v1", v1);
const app = createApp(config).route("/api", routes);
return testClient(app);
};
// テスト用クライアント変数
let client: ReturnType<typeof createTestClient>;
// テスト用コンフィグ変数
let testConfig: NodeConfig;
// テストデータ用のUID
let uid: string;
// テスト前の共通セットアップ
beforeEach(() => {
// モックの初期化
createUserUseCase = {
execute: vi.fn().mockResolvedValue({
uid: "550e8400-e29b-41d4-a716-446655440000",
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
}),
};
findUsersUseCase = {
execute: vi.fn().mockResolvedValue([]),
};
findUserUseCase = {
execute: vi.fn().mockResolvedValue({
uid: "550e8400-e29b-41d4-a716-446655440000",
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
}),
};
updateUserUseCase = {
execute: vi.fn().mockResolvedValue({
uid: "550e8400-e29b-41d4-a716-446655440000",
lastName: "山田",
firstName: "次郎",
totalPurchaseAmount: 2000,
}),
};
deleteUserUseCase = {
execute: vi.fn().mockResolvedValue({
uid: "550e8400-e29b-41d4-a716-446655440000",
}),
};
testConfig = {
...config,
};
client = createTestClient(testConfig);
uid = "550e8400-e29b-41d4-a716-446655440000";
});
describe("POST /api/v1/users", () => {
it("正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.users.$post({
json: {
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
},
});
// 検証
expect(response.status).toBe(201);
expect(createUserUseCase.execute).toHaveBeenCalledTimes(1);
expect(createUserUseCase.execute).toHaveBeenCalledWith(expect.anything(), {
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
});
});
it("lastNameが未指定の場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users.$post({
json: {
firstName: "太郎",
totalPurchaseAmount: 1000,
} as never,
});
// 検証
expect(response.status).toBe(400);
expect(createUserUseCase.execute).not.toHaveBeenCalled();
});
it("firstNameが未指定の場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users.$post({
json: {
lastName: "山田",
totalPurchaseAmount: 1000,
} as never,
});
// 検証
expect(response.status).toBe(400);
expect(createUserUseCase.execute).not.toHaveBeenCalled();
});
it("totalPurchaseAmountが未指定の場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users.$post({
json: {
lastName: "山田",
firstName: "太郎",
} as never,
});
// 検証
expect(response.status).toBe(400);
expect(createUserUseCase.execute).not.toHaveBeenCalled();
});
it("lastNameが10文字以内の場合に正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.users.$post({
json: {
lastName: "あいうえおかきくけこ",
firstName: "太郎",
totalPurchaseAmount: 1000,
},
});
// 検証
expect(response.status).toBe(201);
expect(createUserUseCase.execute).toHaveBeenCalledTimes(1);
});
it("lastNameが10文字を超えている場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users.$post({
json: {
lastName: "あいうえおかきくけこさ",
firstName: "太郎",
totalPurchaseAmount: 1000,
},
});
// 検証
expect(response.status).toBe(400);
expect(createUserUseCase.execute).not.toHaveBeenCalled();
});
it("firstNameが10文字以内の場合に正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.users.$post({
json: {
lastName: "山田",
firstName: "あいうえおかきくけこ",
totalPurchaseAmount: 1000,
},
});
// 検証
expect(response.status).toBe(201);
expect(createUserUseCase.execute).toHaveBeenCalledTimes(1);
});
it("firstNameが10文字を超えている場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users.$post({
json: {
lastName: "山田",
firstName: "あいうえおかきくけこさ",
totalPurchaseAmount: 1000,
},
});
// 検証
expect(response.status).toBe(400);
expect(createUserUseCase.execute).not.toHaveBeenCalled();
});
it("totalPurchaseAmountが0の場合に正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.users.$post({
json: {
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 0,
},
});
// 検証
expect(response.status).toBe(201);
expect(createUserUseCase.execute).toHaveBeenCalledTimes(1);
});
it("totalPurchaseAmountがマイナスの場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users.$post({
json: {
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: -1,
},
});
// 検証
expect(response.status).toBe(400);
expect(createUserUseCase.execute).not.toHaveBeenCalled();
});
it("totalPurchaseAmountが文字列の場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users.$post({
json: {
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: "1000",
} as never,
});
// 検証
expect(response.status).toBe(400);
expect(createUserUseCase.execute).not.toHaveBeenCalled();
});
});
describe("GET /api/v1/users", () => {
it("正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.users.$get();
// 検証
expect(response.status).toBe(200);
expect(findUsersUseCase.execute).toHaveBeenCalledTimes(1);
});
});
describe("GET /api/v1/users/:uid", () => {
it("正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$get({
param: {
uid: uid,
},
});
// 検証
expect(response.status).toBe(200);
expect(findUserUseCase.execute).toHaveBeenCalledTimes(1);
expect(findUserUseCase.execute).toHaveBeenCalledWith(expect.anything(), {
uid,
});
});
it("uidがUUID形式ではない場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$get({
param: {
uid: "invalid-uid",
},
});
// 検証
expect(response.status).toBe(400);
expect(findUserUseCase.execute).not.toHaveBeenCalled();
});
});
describe("PUT /api/v1/users/:uid", () => {
it("正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$put({
param: {
uid: uid,
},
json: {
lastName: "山田",
firstName: "次郎",
totalPurchaseAmount: 2000,
},
});
// 検証
expect(response.status).toBe(200);
expect(updateUserUseCase.execute).toHaveBeenCalledTimes(1);
expect(updateUserUseCase.execute).toHaveBeenCalledWith(expect.anything(), {
uid,
lastName: "山田",
firstName: "次郎",
totalPurchaseAmount: 2000,
});
});
it("uidがUUID形式ではない場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$put({
param: {
uid: "invalid-uid",
},
json: {
lastName: "山田",
firstName: "次郎",
totalPurchaseAmount: 2000,
},
});
// 検証
expect(response.status).toBe(400);
expect(updateUserUseCase.execute).not.toHaveBeenCalled();
});
it("lastNameが未指定の場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$put({
param: {
uid: uid,
},
json: {
firstName: "次郎",
totalPurchaseAmount: 2000,
} as never,
});
// 検証
expect(response.status).toBe(400);
expect(updateUserUseCase.execute).not.toHaveBeenCalled();
});
it("firstNameが未指定の場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$put({
param: {
uid: uid,
},
json: {
lastName: "山田",
totalPurchaseAmount: 2000,
} as never,
});
// 検証
expect(response.status).toBe(400);
expect(updateUserUseCase.execute).not.toHaveBeenCalled();
});
it("totalPurchaseAmountが未指定の場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$put({
param: {
uid: uid,
},
json: {
lastName: "山田",
firstName: "次郎",
} as never,
});
// 検証
expect(response.status).toBe(400);
expect(updateUserUseCase.execute).not.toHaveBeenCalled();
});
it("lastNameが10文字以内の場合に正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$put({
param: {
uid: uid,
},
json: {
lastName: "あいうえおかきくけこ",
firstName: "次郎",
totalPurchaseAmount: 1000,
},
});
// 検証
expect(response.status).toBe(200);
expect(updateUserUseCase.execute).toHaveBeenCalledTimes(1);
});
it("lastNameが10文字を超えている場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$put({
param: {
uid: uid,
},
json: {
lastName: "あいうえおかきくけこさ",
firstName: "次郎",
totalPurchaseAmount: 1000,
},
});
// 検証
expect(response.status).toBe(400);
expect(updateUserUseCase.execute).not.toHaveBeenCalled();
});
it("firstNameが10文字以内の場合に正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$put({
param: {
uid: uid,
},
json: {
lastName: "山田",
firstName: "あいうえおかきくけこ",
totalPurchaseAmount: 1000,
},
});
// 検証
expect(response.status).toBe(200);
expect(updateUserUseCase.execute).toHaveBeenCalledTimes(1);
});
it("firstNameが10文字を超えている場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$put({
param: {
uid: uid,
},
json: {
lastName: "山田",
firstName: "あいうえおかきくけこさ",
totalPurchaseAmount: 1000,
},
});
// 検証
expect(response.status).toBe(400);
expect(updateUserUseCase.execute).not.toHaveBeenCalled();
});
it("totalPurchaseAmountが0の場合に正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$put({
param: {
uid: uid,
},
json: {
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 0,
},
});
// 検証
expect(response.status).toBe(200);
expect(updateUserUseCase.execute).toHaveBeenCalledTimes(1);
});
it("totalPurchaseAmountがマイナスの場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$put({
param: {
uid: uid,
},
json: {
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: -1,
},
});
// 検証
expect(response.status).toBe(400);
expect(updateUserUseCase.execute).not.toHaveBeenCalled();
});
});
describe("DELETE /api/v1/users/:uid", () => {
it("正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$delete({
param: {
uid: uid,
},
});
// 検証
expect(response.status).toBe(204);
expect(deleteUserUseCase.execute).toHaveBeenCalledTimes(1);
expect(deleteUserUseCase.execute).toHaveBeenCalledWith(expect.anything(), {
uid,
});
});
it("uidがUUID形式ではない場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.users[":uid"].$delete({
param: {
uid: "invalid-uid",
},
});
// 検証
expect(response.status).toBe(400);
expect(deleteUserUseCase.execute).not.toHaveBeenCalled();
});
});
※エラー系では「as never」を使う
・「src/tests/integration/generic/user/user.test.ts」
import { eq } from "drizzle-orm";
import { testClient } from "hono/testing";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DbPg } from "../../../../infrastructure/database/drizzle/pg/client.js";
import { createDbPg } from "../../../../infrastructure/database/drizzle/pg/client.js";
import { users } from "../../../../infrastructure/database/drizzle/pg/schema/users.js";
import { createNodeApp } from "../../../../runtime/node/app.js";
import type { NodeConfig } from "../../../../runtime/node/config.js";
import { config } from "../../../../runtime/node/config.js";
// ログ出力の無効化
vi.spyOn(console, "log").mockImplementation(() => {});
// テスト用クライアント作成関数
const createTestClient = (config: NodeConfig) => {
return testClient(createNodeApp(config));
};
// テスト用クライアント変数
let client: ReturnType<typeof createTestClient>;
let testDbPg: DbPg;
// テスト用コンフィグ変数
let testConfig: NodeConfig;
// テスト前の共通セットアップ
beforeEach(() => {
testConfig = {
...config,
db: {
...config.db,
name: "testing-db-pg",
},
};
client = createTestClient(testConfig);
testDbPg = createDbPg(testConfig.db);
});
// テスト後の共通クリーンアップ
afterEach(async () => {
await testDbPg.db.delete(users);
await testDbPg.pool.end();
});
describe("POST /api/v1/users", () => {
it("ユーザーが新規作成され、正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.users.$post({
json: {
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
},
});
// 検証
expect(response.status).toBe(201);
// レスポンス結果の検証
type CreateUserResponse = {
id: number;
uid: string;
lastName: string;
firstName: string;
totalPurchaseAmount: number;
createdAt: string;
updatedAt: string;
};
const body = (await response.json()) as CreateUserResponse;
expect(body).toEqual({
id: expect.any(Number),
uid: expect.any(String),
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
// DBに保存されていることを検証
const [user] = await testDbPg.db
.select()
.from(users)
.where(eq(users.uid, body.uid))
.limit(1);
expect(user).toEqual({
id: body.id,
uid: body.uid,
lastName: body.lastName,
firstName: body.firstName,
totalPurchaseAmount: body.totalPurchaseAmount,
createdAt: new Date(body.createdAt),
updatedAt: new Date(body.updatedAt),
});
});
});
describe("GET /api/v1/users", () => {
it("全てのユーザーを取得し、正常終了すること", async () => {
// テストデータ登録
const resultUsers = await testDbPg.db
.insert(users)
.values([
{
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
},
{
lastName: "佐藤",
firstName: "花子",
totalPurchaseAmount: 2000,
},
])
.returning();
const expectedUsers = resultUsers.map((user) => ({
...user,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
}));
// テスト実行
const response = await client.api.v1.users.$get();
// 検証
expect(response.status).toBe(200);
// レスポンス結果の検証
const body = await response.json();
expect(body).toHaveLength(2);
expect(body).toEqual(expectedUsers);
});
});
describe("GET /api/v1/users/:uid", () => {
it("対象ユーザーを取得し、正常終了すること", async () => {
// テストデータ登録
const [resultUser] = await testDbPg.db
.insert(users)
.values({
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
})
.returning();
const expectedUser = {
...resultUser,
createdAt: resultUser.createdAt.toISOString(),
updatedAt: resultUser.updatedAt.toISOString(),
};
// テスト実行
const response = await client.api.v1.users.$get();
// 検証
expect(response.status).toBe(200);
// レスポンス結果の検証
const [body] = await response.json();
expect(body).toEqual(expectedUser);
});
});
describe("PUT /api/v1/users/:uid", () => {
it("対象ユーザーを更新し、正常終了すること", async () => {
// テストデータ登録
const [resultUser] = await testDbPg.db
.insert(users)
.values({
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
})
.returning();
const expectedUser = {
...resultUser,
lastName: "佐藤",
firstName: "二郎",
totalPurchaseAmount: 2000,
createdAt: resultUser.createdAt.toISOString(),
updatedAt: expect.any(String),
};
// テスト実行
const response = await client.api.v1.users[":uid"].$put({
param: {
uid: expectedUser.uid,
},
json: {
lastName: expectedUser.lastName,
firstName: expectedUser.firstName,
totalPurchaseAmount: expectedUser.totalPurchaseAmount,
},
});
// 検証
expect(response.status).toBe(200);
// レスポンス結果の検証
const body = await response.json();
expect(body).toEqual(expectedUser);
if ("createdAt" in body) {
expect(body.createdAt).toBe(expectedUser.createdAt);
}
if ("updatedAt" in body) {
expect(body.updatedAt).not.toBe(expectedUser.createdAt);
}
});
});
describe("DELETE /api/v1/users/:uid", () => {
it("対象ユーザーを削除し、正常終了すること", async () => {
// テストデータ登録
const [resultUser] = await testDbPg.db
.insert(users)
.values({
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 1000,
})
.returning();
// テスト実行
const response = await client.api.v1.users[":uid"].$delete({
param: {
uid: resultUser.uid,
},
});
// 検証
expect(response.status).toBe(204);
// DBから削除されていることを確認
const deletedUsers = await testDbPg.db
.select()
.from(users)
.where(eq(users.uid, resultUser.uid));
expect(deletedUsers).toHaveLength(0);
});
});
※インテグレーションテスト
次にファイル「src/di/nodeContainer.ts」、「src/presentation/http/routesNode.ts」をそれぞれ以下のように修正します。
・「src/di/nodeContainer.ts」
import {
CreateUserUseCase,
DeleteUserUseCase,
FindUsersUseCase,
FindUserUseCase,
UpdateUserUseCase,
} from "../generic/user/useCase.js";
import { createDbPg } from "../infrastructure/database/drizzle/pg/client.js";
import type { NodeConfig } from "../runtime/node/config.js";
import {
SampleHelloUseCase,
SampleTextUseCase,
} from "../supporting/sample/useCase.js";
export const createNodeContainer = (config: NodeConfig) => {
// DBインスタンスの初期化
const dbPg = createDbPg(config.db);
// サンプル関連のユースケースを初期化
const sampleHelloUseCase = new SampleHelloUseCase();
const sampleTextUseCase = new SampleTextUseCase();
// ユーザー関連のユースケースを初期化
const createUserUseCase = new CreateUserUseCase(dbPg.db);
const findUsersUseCase = new FindUsersUseCase(dbPg.db);
const findUserUseCase = new FindUserUseCase(dbPg.db);
const updateUserUseCase = new UpdateUserUseCase(dbPg.db);
const deleteUserUseCase = new DeleteUserUseCase(dbPg.db);
return {
sampleHelloUseCase,
sampleTextUseCase,
createUserUseCase,
findUsersUseCase,
findUserUseCase,
updateUserUseCase,
deleteUserUseCase,
};
};
export type NodeContainer = ReturnType<typeof createNodeContainer>;
・「src/presentation/http/routesNode.ts」
import { Hono } from "hono";
import type { NodeContainer } from "../../di/nodeContainer.js";
import { createUserRoutes } from "../http/generic/user/routes.js";
import { createSampleRoutes } from "../http/supporting/sample/routes.js";
export const createRoutesNode = (container: NodeContainer) => {
/******************************
* v1用のルーティング
******************************/
// Honoでルーティングを作る際は、メソッドチェーン推薦
const v1 = new Hono()
.route(
"/sample",
createSampleRoutes(
container.sampleHelloUseCase,
container.sampleTextUseCase,
),
)
.route(
"/users",
createUserRoutes(
container.createUserUseCase,
container.findUsersUseCase,
container.findUserUseCase,
container.updateUserUseCase,
container.deleteUserUseCase,
),
);
/******************************
* ルーティング設定
******************************/
return new Hono().route("/v1", v1);
};
※Honoでルーティングを作る際は、メソッドチェーン推薦。
次に以下のコマンドを実行し、各種コードチェックします。
$ pnpm run format
$ pnpm run lint
$ pnpm run test
実行後、以下のように全てのテストがパスすればOKです。
次にPostmanで作成したAPIを試してみます。
まずはPOSTメソッドで「http://localhost:3000/api/v1/users」を実行後、以下のように正常終了し、想定通りの結果になればOKです。
次にGETメソッドで「http://localhost:3000/api/v1/users」を実行後、以下のように正常終了し、想定通りの結果になればOKです。
次にGETメソッドで「http://localhost:3000/api/v1/users/:uid」を実行後、以下のように正常終了し、想定通りの結果になればOKです。
次にPUTメソッドで「http://localhost:3000/api/v1/users/:uid」を実行後、以下のように正常終了し、想定通りの結果になればOKです。
次に再度GETメソッドで「http://localhost:3000/api/v1/users/:uid」を実行後、以下のように正常終了し、想定通りの結果になればOKです。
次にDELETEメソッドで「http://localhost:3000/api/v1/users/:uid」を実行後、以下のように正常終了し、想定通りの結果になればOKです。
次にGETメソッドで「http://localhost:3000/api/v1/users」を実行後、以下のように正常終了し、想定通りの結果になればOKです。
合計購入金額から会員ランクを出力するAPIを作成する
次は上記で作成したusersテーブルを利用し、memberドメインとして合計購入金額から会員ランクを出力するAPIを作成してみます。
Memberドメインを作る
まずはMemberドメインを作るため、以下のコマンドを実行し、各種ファイルを作成します。
$ mkdir -p src/core/member/domain
$ touch src/core/member/domain/memberUid.ts src/core/member/domain/memberUid.test.ts
$ touch src/core/member/domain/member.ts src/core/member/domain/member.test.ts
$ touch src/core/member/domain/memberRankService.ts src/core/member/domain/memberRankService.test.ts
$ touch src/core/member/domain/memberRepository.ts
$ mkdir -p src/core/member/infrastructure/persistence/pg/query
$ touch src/core/member/infrastructure/persistence/pg/query/drizzleMemberRepository.ts
次に作成したファイルをそれぞれ以下のように記述します。
・「src/core/member/domain/memberUid.ts」
export class MemberUid {
constructor(readonly value: string) {
if (!MemberUid.isValid(value)) {
throw new Error(`Invalid MemberUid: ${value}`);
}
}
// uuid形式チェック
private static isValid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value,
);
}
}
※値オブジェクト
・「src/core/member/domain/memberUid.test.ts」
import { describe, expect, it } from "vitest";
import { MemberUid } from "./memberUid.js";
describe("MemberUid", () => {
it("有効なUUIDをMemberUidとして生成できる", () => {
const value = "550e8400-e29b-41d4-a716-446655440000";
const memberUid = new MemberUid(value);
expect(memberUid.value).toBe(value);
});
it("不正なUUIDの場合はエラーになる", () => {
expect(() => {
new MemberUid("invalid-uuid");
}).toThrow("Invalid MemberUid: invalid-uuid");
});
it("空文字の場合はエラーになる", () => {
expect(() => {
new MemberUid("");
}).toThrow("Invalid MemberUid: ");
});
});
・「src/core/member/domain/member.ts」
import type { MemberUid } from "./memberUid.js";
export class Member {
constructor(
readonly uid: MemberUid,
readonly lastName: string,
readonly firstName: string,
readonly totalPurchaseAmount: number,
) {}
// フルネームを出力するメソッド
get fullName(): string {
return `${this.lastName} ${this.firstName}`;
}
}
※ドメイン
・「src/core/member/domain/member.test.ts」
import { describe, expect, it } from "vitest";
import { Member } from "./member.js";
import { MemberUid } from "./memberUid.js";
describe("Member", () => {
it("メンバー情報を保持する", () => {
const uid = new MemberUid("550e8400-e29b-41d4-a716-446655440000");
const member = new Member(uid, "山田", "太郎", 10000);
expect(member.uid).toBe(uid);
expect(member.lastName).toBe("山田");
expect(member.firstName).toBe("太郎");
expect(member.totalPurchaseAmount).toBe(10000);
});
it("fullNameに姓と名を結合した名前を返す", () => {
const member = new Member(
new MemberUid("550e8400-e29b-41d4-a716-446655440000"),
"山田",
"太郎",
10000,
);
expect(member.fullName).toBe("山田 太郎");
});
});
・「src/core/member/domain/memberRankService.ts」
import type { Member } from "./member.js";
export type MemberRank = "bronze" | "silver" | "gold" | "platinum";
export class MemberRankService {
calculate(member: Member): MemberRank {
// 100万円以上でプラチナ
if (member.totalPurchaseAmount >= 1_000_000) {
return "platinum";
}
// 10万円以上でゴールド
if (member.totalPurchaseAmount >= 100_000) {
return "gold";
}
// 1万円以上でシルバー
if (member.totalPurchaseAmount >= 10_000) {
return "silver";
}
return "bronze";
}
}
※ドメインサービス
・「src/core/member/domain/memberRankService.test.ts」
import { describe, expect, it } from "vitest";
import { Member } from "./member.js";
import { MemberRankService } from "./memberRankService.js";
import { MemberUid } from "./memberUid.js";
describe("MemberRankService", () => {
const service = new MemberRankService();
const createMember = (totalPurchaseAmount: number) => {
return new Member(
new MemberUid("550e8400-e29b-41d4-a716-446655440000"),
"山田",
"太郎",
totalPurchaseAmount,
);
};
it("合計購入金額が10,000円未満ならbronze", () => {
const member = createMember(9_999);
expect(service.calculate(member)).toBe("bronze");
});
it("合計購入金額が10,000円以上ならsilver", () => {
const member = createMember(10_000);
expect(service.calculate(member)).toBe("silver");
});
it("合計購入金額が100,000円以上ならgold", () => {
const member = createMember(100_000);
expect(service.calculate(member)).toBe("gold");
});
it("合計購入金額が1,000,000円以上ならplatinum", () => {
const member = createMember(1_000_000);
expect(service.calculate(member)).toBe("platinum");
});
});
・「src/core/member/domain/memberRepository.ts」
import type { Member } from "./member.js";
import type { MemberUid } from "./memberUid.js";
export interface MemberRepository {
findByUid(uid: MemberUid): Promise<Member | null>;
}
※リポジトリのインターフェース
・「src/core/member/infrastructure/persistence/pg/query/drizzleMemberRepository.ts」
import { eq } from "drizzle-orm";
import type { PgDbClient } from "../../../../../../infrastructure/database/drizzle/pg/client.js";
import { users } from "../../../../../../infrastructure/database/drizzle/pg/schema/users.js";
import { Member } from "../../../../domain/member.js";
import type { MemberRepository } from "../../../../domain/memberRepository.js";
import { MemberUid } from "../../../../domain/memberUid.js";
export class DrizzleMemberRepository implements MemberRepository {
constructor(private readonly db: PgDbClient) {}
async findByUid(uid: MemberUid): Promise<Member | null> {
// 対象ユーザー取得
const [user] = await this.db
.select()
.from(users)
.where(eq(users.uid, uid.value))
.limit(1);
// ユーザー取得チェック
if (!user) {
return null;
}
return new Member(
new MemberUid(user.uid),
user.lastName,
user.firstName,
user.totalPurchaseAmount,
);
}
}
※リポジトリの実装
ドメイン層とDB層を繋ぐ部分を作る
次にドメイン層がDB層に依存しないようにするため、ドメイン層とDB層を繋ぐ部分を作ります。
まずは以下のコマンドを実行し、各種ファイルを作成します。
$ touch src/infrastructure/database/database.ts
$ mkdir -p src/infrastructure/repository
$ touch src/infrastructure/repository/drizzleRepositoryFactory.ts
次に作成したファイルをそれぞれ以下のように記述します。
・「src/infrastructure/database/database.ts」
import type { PgDbClient } from "./drizzle/pg/client.js";
export class Database {
constructor(private readonly db: PgDbClient) {}
getClient(): PgDbClient {
return this.db;
}
}
※データベースクラスを作る
・「src/infrastructure/repository/drizzleRepositoryFactory.ts」
import { DrizzleMemberRepository } from "../../core/member/infrastructure/persistence/pg/query/drizzleMemberRepository.js";
import type { PgDbClient } from "../database/drizzle/pg/client.js";
export class DrizzleRepositoryFactory {
create(db: PgDbClient) {
return {
member: new DrizzleMemberRepository(db),
};
}
}
※リポジトリをインスタンス化するためのクラスを作る
ユースケース層を作る
次にユースケース層を作るため、以下のコマンドを実行し、各種ファイルを作成します。
$ mkdir -p src/core/member/usecase
$ touch src/core/member/usecase/calculateMemberRank.ts src/core/member/usecase/calculateMemberRank.test.ts
$ touch src/core/member/usecase/errors.ts
次に作成したファイルをそれぞれ以下のように記述します。
・「src/core/member/usecase/calculateMemberRank.ts」
import type { Database } from "../../../infrastructure/database/database.js";
import type { DrizzleRepositoryFactory } from "../../../infrastructure/repository/drizzleRepositoryFactory.js";
import type { Logger } from "../../../shared/logger/logger.js";
import { MemberRankService } from "../domain/memberRankService.js";
import { MemberUid } from "../domain/memberUid.js";
import { MemberNotFoundError } from "./errors.js";
export type CalculateMemberRankUseCaseInput = {
uid: string;
};
export class CalculateMemberRankUseCase {
constructor(
private readonly db: Database,
private readonly repositoryFactory: DrizzleRepositoryFactory,
) {}
async execute(_logger: Logger, input: CalculateMemberRankUseCaseInput) {
// リポジトリのインスタンス化
const repo = this.repositoryFactory.create(this.db.getClient());
// uidからメンバーを取得
const member = await repo.member.findByUid(new MemberUid(input.uid));
// 必須チェック
if (!member) {
throw new MemberNotFoundError();
}
// メンバーのランク取得
const rank = new MemberRankService().calculate(member);
return {
rank,
};
}
}
※ユースケース層を作る
・「src/core/member/usecase/calculateMemberRank.test.ts」
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Database } from "../../../infrastructure/database/database.js";
import type { PgDbClient } from "../../../infrastructure/database/drizzle/pg/client.js";
import type { DrizzleRepositoryFactory } from "../../../infrastructure/repository/drizzleRepositoryFactory.js";
import type { Logger } from "../../../shared/logger/logger.js";
import { Member } from "../domain/member.js";
import { MemberUid } from "../domain/memberUid.js";
import { CalculateMemberRankUseCase } from "./calculateMemberRank.js";
import { MemberNotFoundError } from "./errors.js";
// ロガーのモック化
const createMockLogger = (): Logger => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
withRequestId: vi.fn(),
});
// DBのモック化
const createMockDb = <T extends object>(mock: T): PgDbClient =>
mock as unknown as PgDbClient;
let logger: Logger;
beforeEach(() => {
logger = createMockLogger();
});
describe("CalculateMemberRankUseCase", () => {
it("メンバーのランクを取得できること", async () => {
// メンバーのモック
const uid = "550e8400-e29b-41d4-a716-446655440000";
const member = new Member(new MemberUid(uid), "山田", "太郎", 100_000);
// Repositoryのモック化
const findByUid = vi.fn().mockResolvedValue(member);
const repository = {
member: {
findByUid,
},
};
// DBのモック化
const dbClient = createMockDb({});
const getClient = vi.fn().mockReturnValue(dbClient);
const db = {
getClient,
} as unknown as Database;
// RepositoryFactoryのモック化
const create = vi.fn().mockReturnValue(repository);
const repositoryFactory = {
create,
} as unknown as DrizzleRepositoryFactory;
// ユースケース作成
const useCase = new CalculateMemberRankUseCase(db, repositoryFactory);
// ユースケース実行
const result = await useCase.execute(logger, {
uid,
});
// 検証
expect(result).toEqual({
rank: "gold",
});
expect(findByUid).toHaveBeenCalledOnce();
});
it("メンバーが存在しない場合はエラーになること", async () => {
// Repositoryのモック化
const findByUid = vi.fn().mockResolvedValue(null);
const repository = {
member: {
findByUid,
},
};
// DBのモック化
const dbClient = createMockDb({});
const getClient = vi.fn().mockReturnValue(dbClient);
const db = {
getClient,
} as unknown as Database;
// RepositoryFactoryのモック化
const create = vi.fn().mockReturnValue(repository);
const repositoryFactory = {
create,
} as unknown as DrizzleRepositoryFactory;
// ユースケース作成
const useCase = new CalculateMemberRankUseCase(db, repositoryFactory);
// ユースケース実行・検証
await expect(
useCase.execute(logger, {
uid: "550e8400-e29b-41d4-a716-446655440000",
}),
).rejects.toBeInstanceOf(MemberNotFoundError);
});
});
・「src/core/member/usecase/errors.ts」
import type { ContentfulStatusCode } from "hono/utils/http-status";
import { AppError } from "../../../shared/error/appError.js";
// エラーコード(カスタムエラー用)
// 「|」で区切って追加可能
export type MemberErrorCode = "MEMBER_NOT_FOUND";
// エラーコードとHTTPステータスコードのマッピング
export const memberErrorStatusMap: Record<
MemberErrorCode,
ContentfulStatusCode
> = {
MEMBER_NOT_FOUND: 404,
};
export class MemberNotFoundError extends AppError<MemberErrorCode> {
constructor() {
super("Member not found", "MEMBER_NOT_FOUND", "warn");
}
}
※カスタムエラーを作る
次にファイル「src/di/nodeContainer.ts」を以下のように修正します。
import { CalculateMemberRankUseCase } from "../core/member/usecase/calculateMemberRank.js";
import {
CreateUserUseCase,
DeleteUserUseCase,
FindUsersUseCase,
FindUserUseCase,
UpdateUserUseCase,
} from "../generic/user/useCase.js";
import { Database } from "../infrastructure/database/database.js";
import { createDbPg } from "../infrastructure/database/drizzle/pg/client.js";
import { DrizzleRepositoryFactory } from "../infrastructure/repository/drizzleRepositoryFactory.js";
import type { NodeConfig } from "../runtime/node/config.js";
import {
SampleHelloUseCase,
SampleTextUseCase,
} from "../supporting/sample/useCase.js";
export const createNodeContainer = (config: NodeConfig) => {
// DBインスタンスの初期化
const dbPg = createDbPg(config.db);
// サンプル関連のユースケースを初期化
const sampleHelloUseCase = new SampleHelloUseCase();
const sampleTextUseCase = new SampleTextUseCase();
// ユーザー関連のユースケースを初期化
const createUserUseCase = new CreateUserUseCase(dbPg.db);
const findUsersUseCase = new FindUsersUseCase(dbPg.db);
const findUserUseCase = new FindUserUseCase(dbPg.db);
const updateUserUseCase = new UpdateUserUseCase(dbPg.db);
const deleteUserUseCase = new DeleteUserUseCase(dbPg.db);
// メンバードメイン関連のユースケースを初期化
const database = new Database(dbPg.db);
const drizzleRepositoryFactory = new DrizzleRepositoryFactory();
const calculateMemberRankUseCase = new CalculateMemberRankUseCase(
database,
drizzleRepositoryFactory,
);
return {
sampleHelloUseCase,
sampleTextUseCase,
createUserUseCase,
findUsersUseCase,
findUserUseCase,
updateUserUseCase,
deleteUserUseCase,
calculateMemberRankUseCase,
};
};
export type NodeContainer = ReturnType<typeof createNodeContainer>;
※DIコンテナでユースケースのインスタンス化する
ルーティングを作る
次にルーティングを作成するため、
$ mkdir -p src/presentation/http/core/member
$ touch src/presentation/http/core/member/handler.ts
$ touch src/presentation/http/core/member/routes.ts src/presentation/http/core/member/routes.test.ts
次に作成したファイルをそれぞれ以下のように記述します。
・「src/presentation/http/core/member/handler.ts」
import type { Context } from "hono";
import type {
CalculateMemberRankUseCase,
CalculateMemberRankUseCaseInput,
} from "../../../../core/member/usecase/calculateMemberRank.js";
export const createCalculateMemberRankHandler =
(useCase: CalculateMemberRankUseCase) =>
async (c: Context, input: CalculateMemberRankUseCaseInput) => {
// 共通ロガー取得
const logger = c.get("logger");
// ユースケース実行
const result = await useCase.execute(logger, input);
return c.json(result);
};
・「src/presentation/http/core/member/routes.ts」
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import type {
CalculateMemberRankUseCase,
CalculateMemberRankUseCaseInput,
} from "../../../../core/member/usecase/calculateMemberRank.js";
import { createCalculateMemberRankHandler } from "./handler.js";
// リクエストパラメータのスキーマ定義
const calculateMemberRankRequestParamSchema = z.object({
uid: z.uuid().min(1, "uidは必須です"),
});
export const createMemberRoutes = (
calculateMemberRankUseCase: CalculateMemberRankUseCase,
) => {
const routes = new Hono().get(
"/:uid/rank",
zValidator("param", calculateMemberRankRequestParamSchema),
(c) => {
// バリデーション済みのリクエストパラメータを取得
const { uid } = c.req.valid("param");
// インプットパラメータ作成
const input: CalculateMemberRankUseCaseInput = { uid };
return createCalculateMemberRankHandler(calculateMemberRankUseCase)(
c,
input,
);
},
);
return routes;
};
・「src/presentation/http/core/member/routes.test.ts」
import { Hono } from "hono";
import { testClient } from "hono/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createApp } from "../../../../app.js";
import type { CalculateMemberRankUseCase } from "../../../../core/member/usecase/calculateMemberRank.js";
import type { NodeConfig } from "../../../../runtime/node/config.js";
import { config } from "../../../../runtime/node/config.js";
import { createMemberRoutes } from "./routes.js";
// ログ出力の無効化
vi.spyOn(console, "log").mockImplementation(() => {});
// UseCaseのモック
type MockUseCase = {
execute: ReturnType<typeof vi.fn>;
};
let calculateMemberRankUseCase: MockUseCase;
// テスト用クライアント作成関数
const createTestClient = (config: NodeConfig) => {
// ルーティング設定
const v1 = new Hono().route(
"/members",
createMemberRoutes(
calculateMemberRankUseCase as unknown as CalculateMemberRankUseCase,
),
);
const routes = new Hono().route("/v1", v1);
const app = createApp(config).route("/api", routes);
return testClient(app);
};
// テスト用クライアント変数
let client: ReturnType<typeof createTestClient>;
// テスト用コンフィグ変数
let testConfig: NodeConfig;
// テストデータ用のUID
let uid: string;
// テスト前の共通セットアップ
beforeEach(() => {
// モックの初期化
calculateMemberRankUseCase = {
execute: vi.fn().mockResolvedValue({
rank: "gold",
}),
};
testConfig = {
...config,
};
client = createTestClient(testConfig);
uid = "550e8400-e29b-41d4-a716-446655440000";
});
describe("GET /api/v1/members/:uid/rank", () => {
it("正常終了すること", async () => {
// テスト実行
const response = await client.api.v1.members[":uid"].rank.$get({
param: {
uid: uid,
},
});
// 検証
expect(response.status).toBe(200);
expect(calculateMemberRankUseCase.execute).toHaveBeenCalledTimes(1);
expect(calculateMemberRankUseCase.execute).toHaveBeenCalledWith(
expect.anything(),
{
uid,
},
);
});
it("uidがUUID形式ではない場合にバリデーションエラーになること", async () => {
// テスト実行
const response = await client.api.v1.members[":uid"].rank.$get({
param: {
uid: "invalid-uid",
},
});
// 検証
expect(response.status).toBe(400);
expect(calculateMemberRankUseCase.execute).not.toHaveBeenCalled();
});
});
次にファイル「src/presentation/http/routesNode.ts」を以下のように修正します。
import { Hono } from "hono";
import type { NodeContainer } from "../../di/nodeContainer.js";
import { createUserRoutes } from "../http/generic/user/routes.js";
import { createSampleRoutes } from "../http/supporting/sample/routes.js";
import { createMemberRoutes } from "./core/member/routes.js";
export const createRoutesNode = (container: NodeContainer) => {
/******************************
* v1用のルーティング
******************************/
// Honoでルーティングを作る際は、メソッドチェーン推薦
const v1 = new Hono()
.route(
"/sample",
createSampleRoutes(
container.sampleHelloUseCase,
container.sampleTextUseCase,
),
)
.route(
"/users",
createUserRoutes(
container.createUserUseCase,
container.findUsersUseCase,
container.findUserUseCase,
container.updateUserUseCase,
container.deleteUserUseCase,
),
)
.route(
"/members",
createMemberRoutes(container.calculateMemberRankUseCase),
);
/******************************
* ルーティング設定
******************************/
return new Hono().route("/v1", v1);
};
次にカスタムエラーを登録するため、「src/presentation/http/error/errorStatusMap.ts」を以下のように修正します。
import type { ContentfulStatusCode } from "hono/utils/http-status";
import { memberErrorStatusMap } from "../../../core/member/usecase/errors.js";
import { userErrorStatusMap } from "../../../generic/user/errors.js";
// 共通エラーコードとHTTPステータスコードのマッピング
export const errorStatusMap: Record<string, ContentfulStatusCode> = {
...userErrorStatusMap,
...memberErrorStatusMap,
};
インテグレーションテストを作る
次にインテグレーションを作るため、以下のコマンドを実行してファイルを作成します。
$ mkdir -p src/tests/integration/core/member
$ touch src/tests/integration/core/member/member.test.ts
次に作成したファイルを以下のように記述します。
・「src/tests/integration/core/member/member.test.ts」
import { testClient } from "hono/testing";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DbPg } from "../../../../infrastructure/database/drizzle/pg/client.js";
import { createDbPg } from "../../../../infrastructure/database/drizzle/pg/client.js";
import { users } from "../../../../infrastructure/database/drizzle/pg/schema/users.js";
import { createNodeApp } from "../../../../runtime/node/app.js";
import type { NodeConfig } from "../../../../runtime/node/config.js";
import { config } from "../../../../runtime/node/config.js";
// ログ出力の無効化
vi.spyOn(console, "log").mockImplementation(() => {});
// テスト用クライアント作成関数
const createTestClient = (config: NodeConfig) => {
return testClient(createNodeApp(config));
};
// テスト用クライアント変数
let client: ReturnType<typeof createTestClient>;
let testDbPg: DbPg;
// テスト用コンフィグ変数
let testConfig: NodeConfig;
// テスト前の共通セットアップ
beforeEach(() => {
testConfig = {
...config,
db: {
...config.db,
name: "testing-db-pg",
},
};
client = createTestClient(testConfig);
testDbPg = createDbPg(testConfig.db);
});
// テスト後の共通クリーンアップ
afterEach(async () => {
await testDbPg.db.delete(users);
await testDbPg.pool.end();
});
describe("GET /api/v1/members/:uid/rank", () => {
it("対象メンバーのランクを取得し、正常終了すること", async () => {
// テストデータ登録
const [resultUser] = await testDbPg.db
.insert(users)
.values([
{
lastName: "山田",
firstName: "太郎",
totalPurchaseAmount: 10_000,
},
])
.returning();
// テスト実行
const response = await client.api.v1.members[":uid"].rank.$get({
param: {
uid: resultUser.uid,
},
});
// 検証
expect(response.status).toBe(200);
// レスポンス結果の検証
const body = await response.json();
expect(body).toEqual({
rank: "silver",
});
});
});
各種コードチェック
次に以下のコマンドを実行し、各種コードチェックします。
$ pnpm run format
$ pnpm run lint
$ pnpm run test
実行後、以下のように全てのテストがパスすればOKです。
合計購入金額から会員ランクを出力するAPIを試す
次に作成したAPIを試すため、まずは上記で作成したユーザー作成APIを利用し、「totalPurchaseAmount」を「10000」にして新規ユーザー作成後、レスポンス結果の「uid」をメモします。
次にGETメソッドで「http://localhost:3000/api/v1/members/:uid/rank」を実行後、以下のように正常終了し、想定通りの結果になればOKです。
最後に
今回はHono(TypeScript)でAPIを作成する方法をご紹介しました。
最近はインフラコストを抑えるために「Cloudflare Workers」の利用が注目されたりしていますが、Honoはマルチランタイムに対応しており、Node.js環境だけでなく、Cloudflare Workersも含めた様々な環境にデプロイして利用できる点がメリットです。
加えて、バックエンド開発でもフロントエンド開発でよく利用されているTypeScriptに統一することで、エンジニア採用の間口を広げられるメリットもあるため、これからのバックエンド開発で利用するフレームワークとしては、第一候補になり得る可能性も高いです。
ただし、Honoはフルスタックフレームワークとは異なり、自由度が高いため、どのようなディレクトリ構成で作るのかなど、設計部分については難しい部分があります。
2026年以降で注目されているフレームワークには間違いないので、興味がある方はぜひ参考にしてみて下さい。
Tomoyuki
最新記事 by Tomoyuki (全て見る)
- Hono入門|TypeScriptでAPIを作成する方法【Node.js・Cloudflare対応】 - 2026年10月5日
- 【スト6】モダンヤスミンの立ち回り・攻め方まとめ|飛び道具対策・地上戦・起き攻め・リーサル - 2026年9月21日
- 【スト6】モダンヤスミンの初心者向けコンボまとめ|簡単で実戦向き - 2026年8月5日

















コメントを残す