SPIDERPLUS Tech Blog

建設SaaS「スパイダープラス」のエンジニアとデザイナーのブログ

TypeScriptとHonoで作るDI実践入門

Honoは軽快さとパフォーマンスで注目を集める最近注目されているWebフレームワークです。
TypeScriptとの相性も抜群で、一度使うとその虜になる開発者も多いのではないでしょうか。

しかし、アプリケーションが成長するにつれて、コンポーネント間の依存関係は複雑になりがちです。
「このサービスってどこでインスタンス化してるんだっけ…?」
「依存関係が複雑でテストが書きにくい!」そんな経験はないでしょうか?

そこで採用したいのが、Honoアプリケーションをより堅牢でメンテナンスしやすく進化させる方法が、DI(依存性注入)の導入です。
特に、インフラ層(データアクセスなど)とアプリケーション層(ビジネスロジック)を明確に分離することで、コードの見通しが良くなり、テストの容易性も向上します。

本記事では、シンプルながら強力なDIライブラリ hono-simple-di を活用し、HonoとTypeScriptで疎結合な構成を最小限のステップで実現する方法を徹底解説します。

「DIってなんだか難しそう…」と感じている方に対しても、わかりやすく解説できるようにサンプルコードと合わせて解説しますので、お付き合いいただければと思います。

なぜDIが必要になるのかは過去記事の「ソフトウェアアーキテクチャの本質と依存関係の整理、あるいはinterfaceの正しい使い所について」を参考にしていただければと思います。

techblog.spiderplus.co.jp

🎯 この記事で目指すゴール

  • Hono + TypeScript環境に、hono-simple-diを使ってDIを導入する。
  • 依存関係を疎結合に管理し、インフラ層とアプリケーション層を効果的に分離する。
  • 型安全性を維持しながら、テスタビリティとメンテナンス性の高いアプリケーションの土台を築く。

🛠 今回の使用技術

  • Hono
  • TypeScript
  • pnpm
  • hono-simple-di

初期セットアップ

まずは、プロジェクトの骨組みから作っていきましょう。
以下のコマンドで、必要なライブラリをインストールし、TypeScriptの設定を初期化します。

mkdir hono-di-app && cd hono-di-app
pnpm init
pnpm add hono hono-simple-di @hono/node-server
pnpm add -D typescript tsx @types/node
npx tsc --init

tsx を開発時の実行用に、@types/node をNode.jsの型定義のために追加しています。
これで、HonoとTypeScriptで開発を始める準備が整いました。

📁 プロジェクトのディレクトリ構成

今回は以下のような構成で進めます。

src/
├── index.ts # アプリケーションのエントリーポイント
├── app.ts # Honoアプリケーション本体の設定
├── interfaces/ 
│   ├── User.ts
│   └── CreateUserRepository.ts 
├── repositories/ # データ永続化層 (インフラ層)
│   └── userRepository.ts
├── services/ # ビジネスロジック層 (アプリケーション層)
│   └── userService.ts
├── dependencies/ # DIコンテナへの登録設定
│   ├── userRepositoryDependency.ts
│   └── userServiceDependency.ts
├── routes/ # ルーティング定義
│   └── user.ts
└── types/ # HonoのContext拡張など、プロジェクト固有の型定義
    └── hono.d.ts

この構成はあくまで今回のサンプルのための一例です。

DIが必要となるケースはオニオンアーキテクチャのアプリケーション層などで依存関係を調整したいケース等が想定されます。

詳しくは過去記事「ソフトウェアアーキテクチャの本質と依存関係の整理、あるいはinterfaceの正しい使い所についてを参考にしてください。

techblog.spiderplus.co.jp

このように、具体的な処理内容ではなく、「何をするか」だけを定義するのがインターフェースの役割です。
これにより、後述する UserService は UserRepository の具体的な実装を知ることなく、この CreateUserRepository インターフェースにのみ依存することで疎結合な実装とすることができます。

0. インターフェース定義 (型安全性の確保)

DIを導入する上で欠かせないのが、インターフェースによる抽象化です。
これにより、具体的な実装クラスに依存することなく、コンポーネントを連携させることができます。
TypeScriptの力を最大限に活かし、型安全な依存性注入の基盤を築きましょう。
まずは、ユーザー情報を表現する User インターフェースです。

// src/interfaces/User.ts
export interface User {
  id: number;
  name: string;
}

次に、ユーザーリポジトリが提供する機能のインターフェース CreateUserRepository を定義します。このインターフェースは、IDでユーザーを検索する findById メソッドを持つことを規定します。

// src/interfaces/CreateUserRepository.ts
import { User } from './User'; export interface CreateUserRepository { findById: (id: number) => Promise; // ID を受け取り、Userオブジェクトまたはnullを返すメソッド }

このように、具体的な処理内容ではなく、「何をするか」だけを定義するのがインターフェースの役割です。
これにより、後述する UserService は UserRepository の具体的な実装を知ることなく、この CreateUserRepository インターフェースにのみ依存することで疎結合な実装とすることができます。

1. UserRepository (インフラ層)

インフラ層は、データベースアクセスや外部APIとの通信など、アプリケーションの外部要因とのやり取りを担当します。
ここでは、その代表として UserRepository を定義しましょう。

今回はシンプルに、メモリ内に保持したユーザーデータから情報を取得する実装とします。
実際のアプリケーションでは、ここにPrismaやDrizzle ORMなどの処理が入ってくるイメージですね。

// src/repositories/userRepository.ts
import { User } from '../interfaces/User';
import { CreateUserRepository } from '../interfaces/CreateUserRepository';

// 本来はデータベースなどから取得するユーザーデータ
const users: User[] = [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
];

// UserRepository を作成するファクトリ関数
// CreateUserRepositoryインターフェースを実装したリポジトリを返す
export const createUserRepository = (): CreateUserRepository => { 
    const findById = (id: number): Promise =>
        Promise.resolve(users.find((user) => user.id === id) || null);
 // IDでユーザーを検索

    // findByIdメソッドを持つリポジトリオブジェクトを返す
    return {
        findById,
    };
};

createUserRepository 関数は、CreateUserRepository インターフェースを満たすオブジェクトを返します。
この関数が、後でDIコンテナによって呼び出され、リポジトリの実体が生成されるわけです。

ポイントは、ここでも CreateUserRepository インターフェースを意識している点です。
具体的な実装はここに閉じ込め、呼び出し側はインターフェースだけを知っていれば良い、という状態を目指します。

2. UserService (アプリケーション層)

アプリケーション層の UserService は、ビジネスロジックの主役です。
UserRepository を利用してユーザー情報を取得します。

UserService は UserRepository の具体的な実装に直接依存するのではなく、先ほど定義した CreateUserRepository インターフェースに依存します。

// src/services/userService.ts
import { CreateUserRepository } from '../interfaces/CreateUserRepository'; 

// UserService を作成するファクトリ関数
// 引数として、CreateUserRepositoryインターフェースを実装したuserRepositoryを受け取る
export const createUserService = (userRepository: CreateUserRepository) => ({ 
    getUserById: (id: number) => userRepository.findById(id),
});

// UserServiceの型定義。createUserServiceの返り値の型を利用
export type UserService = ReturnType;

createUserService は、CreateUserRepository 型のオブジェクト(つまり UserRepository のインスタンス)を引数に取ります。

これにより、UserService はインターフェースに依存しているだけで、それがメモリ上のデータなのか、データベースなのか、はたまた外部APIなのかを知る必要がありません。
この抽象化によって、例えば将来的にデータソースをMySQLからPostgreSQLへ変更したとしても、UserService のコードは一切変更せずに済むでしょう。

UserService 型は ReturnType を使って定義しています。
これにより、createUserService の実装が変われば型も自動的に追従するため、型定義のメンテナンスコストを削減できます。

3. Dependency 設定 (hono-simple-di)

hono-simple-di を使って、これまで定義してきた UserRepository と UserService を、DIコンテナに登録し、必要な場所で注入できるように設定します。

まずは UserRepository の依存関係定義です。

// src/dependencies/userRepositoryDependency.ts
import { Dependency } from 'hono-simple-di';
import { createUserRepository } from '../repositories/userRepository';

// 'userRepoDep' という名前で、createUserRepository関数をDIコンテナに登録
export const userRepoDep = new Dependency(() => createUserRepository());

Dependency クラスのコンストラクタに、インスタンスを生成するためのファクトリ関数 (createUserRepository) を渡すだで 、userRepoDep オブジェクトを通じて、UserRepository のインスタンスをどこからでも(正確にはHonoのコンテキスト経由で)取得できるようになります。

次に UserService の依存関係です。
UserService は UserRepository に依存しているため、少しだけ記述が増えます。

// src/dependencies/userServiceDependency.ts
import { Dependency } from 'hono-simple-di';
import { userRepoDep } from './userRepositoryDependency'; // UserRepositoryの依存関係定義をインポート
import { createUserService } from '../services/userService';

// 'userService' という名前で、UserServiceをDIコンテナに登録
export const userServiceDep = new Dependency(async (c) => { // HonoのContext(c)を受け取れる
  // まず UserRepository のインスタンスを解決(取得)
  const repo = await userRepoDep.resolve(c);
  // 解決したリポジトリを引数に UserService のインスタンスを生成
  return createUserService(repo);
});

userServiceDep のファクトリ関数は、引数にHonoの Context (ここでは c) を受け取ります。
userRepoDep.resolve(c) を呼び出すことで、DIコンテナから UserRepository のインスタンスを取得し、それを createUserService に渡しています。

このようにして、依存関係の連鎖をDIコンテナが解決してくれます。
resolve が async に対応している点も hono-simple-di の便利なところです。
非同期で初期化が必要な依存関係も扱えます。

4. 型安全なDIアクセス

せっかくTypeScriptを使っているのですから、DIで注入したサービスにも型が効いてほしいですよね。

Honoの Context オブジェクト (c) を拡張し、c.var.userService のようにアクセスした際に、UserService の型推論が正しく行われるように設定しましょう。

// src/types/hono.d.ts
import type { UserService } from '../services/userService'; // UserServiceの型をインポート

// 'hono' モジュールを拡張
declare module 'hono' {
  // Contextの `var` プロパティの型定義を拡張
  interface ContextVariableMap {
    userService: UserService; // userService プロパティは UserService 型であることを宣言
  }
}

declare module 記述により、Honoの Context に userService というキーで UserService 型のオブジェクトが格納されることをTypeScriptコンパイラに教えます。

これで、c.var.userService.getUserById() のようなコードを書く際に、型補完と型チェックの恩恵を受けられるようになります。

5. ユーザー取得ルートの作成

準備は整いましたので、実際にDIで注入された UserService を使って、ユーザー情報を取得するAPIエンドポイントを作成しましょう。

// src/routes/user.ts
import { Context } from 'hono'; // HonoのContextをインポート

export const getUserHandler = async (c: Context) => {
  const id = c.req.param('id'); // URLからIDパラメータを取得

  // IDの妥当性チェック (簡単な例)
  // 本来はもっと堅牢なバリデーションを推奨します
  if (!id || !/^\d+$/.test(id)) {
    return c.json({ error: 'Invalid user ID' }, 400);
  }

  // Dependencyから注入されたサービスを取得
  // c.var.userService のおかげで、userServiceは型安全にUserService型として扱える
  const userService = c.var.userService; // または c.get('userService') でも取得可能

  const user = userService.getUserById(Number(id)); // サービス経由でユーザー情報を取得

  // ユーザーが見つかれば情報を、見つからなければ404エラーを返す
  return user ? c.json(user) : c.json({ error: 'User not found' }, 404);
};

ハンドラー関数 getUserHandler は Context オブジェクト c を引数に取ります。

そして c.var.userService (または c.get('userService')) を使って、DIコンテナに登録・解決された UserService のインスタンスにアクセスしています。

先ほどの型拡張のおかげで、userService 変数は UserService 型として認識され、getUserById メソッドも適切に補完・チェックされます。

6. アプリケーションの起動

最後に、これまでのコンポーネントを組み合わせてHonoアプリケーションを起動します。
まずは、Honoアプリケーションインスタンスを作成し、ミドルウェアとして userServiceDep を登録します。

// src/app.ts
import { Hono } from 'hono';
import { userServiceDep } from './dependencies/userServiceDependency'; // UserServiceの依存関係定義
import { getUserHandler } from './routes/user'; // ユーザー取得ハンドラー

const app = new Hono()
  // UserServiceの依存関係をミドルウェアとして登録
  // これにより、以降のルートハンドラーで c.var.userService が利用可能になる
  .use(userServiceDep.middleware('userService'))

  // ルートエンドポイント (ウェルカムメッセージ)
  .get('/', (c) => {
    // ここでも注入されたサービスにアクセス可能
    const { userService } = c.var; // 分割代入で取得もスッキリ
    // または const userService = c.get('userService')

    return c.json({
      message: 'Hono DI Application へようこそ!',
      note: userService ? 'UserService is available here!' : 'UserService is NOT available here!',
      endpoints: {
        users: '/users/:id'
      }
    });
  })

  // ユーザー取得ルート
  .get('/users/:id', getUserHandler);

export default app;

app.use(userServiceDep.middleware('userService')) がポイントです。

ここで userServiceDep をミドルウェアとして登録し、第1引数に 'userService' を指定しています。
これにより、Honoの Context の var プロパティ(または get メソッド)を通じて、このキー名で UserService のインスタンスにアクセスできるようになります。

依存関係を注入したことによりc.var.userServiceのようにメソッドチェーンをつなげて表現できることが嬉しいです。

ルート '/' のハンドラー内でも c.var.userService を使ってサービスを取得できることを確認してみてください。

そして、アプリケーションのエントリーポイントとなる index.ts です。

// src/index.ts
import { serve } from '@hono/node-server'; // Node.jsでHonoを動かすためのアダプター
import app from './app'; // 作成したHonoアプリケーションインスタンス

const port = 3000;

console.log(`Server is running on http://localhost:${port}`);

// Honoアプリケーションを起動
serve({
  fetch: app.fetch, // Honoアプリのfetchメソッドを指定
  port
});

@hono/node-server を使って、作成したHonoアプリケーションをNode.js環境でサーブします。
これで、http://localhost:3000 でアクセス可能なAPIサーバーが立ち上がります。

🚀 いざ、起動!

ターミナルで以下のコマンドを実行して、開発サーバーを起動しましょう。

pnpm install # (もし未実行の場合)
pnpm dev # package.json の "scripts": { "dev": "tsx src/index.ts" } などを想定

📍 エンドポイント

サーバーが無事起動したら、ブラウザやお好みのAPIクライアントで以下のエンドポイントにアクセスしてみてください。

🗒️ まとめ

いかがでしょうか? DIによって注入されたサービスが正しく機能し、APIが動作していることが確認できたでしょうか。

TypeScriptとHonoを使ってDI(依存性注入)を実践する方法について解説しました。

`hono-simple-di`ライブラリを活用することで手軽にDIを実践することができたのではないでしょうか?

詳しいDIについて興味のある方は過去記事の「ソフトウェアアーキテクチャの本質と依存関係の整理、あるいはinterfaceの正しい使い所について」もぜひご一読いただけると幸いです。

techblog.spiderplus.co.jp

最後になりますが、スパイダープラスでは一緒に働ける仲間を募集しております。
スパイダープラスに少しでも興味を持たれた方は是非、カジュアル面談などでお話をしてみませんか?
ご連絡をお待ちしております。