HonoでInversifyJSを使って本格的なDIを実現する - 力技からの脱却…😈
本記事のサマリ
前回の記事で、私はHonoにDIライブラリを使わずに依存性注入を実装してみました。動くことは動いたのですが、正直に言うと「これは現場では使いたくないな」と思いました。手動での依存関係管理は煩雑で、テストも書きづらく、何より新しいサービスを追加するたびに大量のボイラープレートコードを書く必要がありました。
そこで今回は、TypeScript製のDIライブラリ「InversifyJS」を導入して、同じ機能を実装し直してみました。結論から言うと、もう力技には戻れません。デコレータを使った宣言的な記述によって、コードの見通しが圧倒的に良くなり、テストも驚くほど簡単に書けるようになりました。
本記事では、前回の実装で感じた課題、InversifyJSの基本的な使い方、そしてHonoとの統合方法を、実際のコードを交えながら解説していきます。「DIって難しそう」と思っている方にこそ読んでいただきたい内容です。
今回の実装例は、すべてGitHubで公開しています。
前回の力技実装との差分も、以下のPull Requestで確認できます。
前回実装の振り返りと課題
前回の記事では、外部ライブラリを使わずにHonoでDI(依存性注入)を実装しました。動くことは動いたのですが、書いているうちに「これはさすがに辛いな」と感じる瞬間が何度もありました。
一番の問題は、依存関係の管理がすべて手動だったことです。新しいサービスを追加するたびに、コンテナへのバインディング、依存関係の解決、初期化処理を一つずつ手で書いていく必要がありました。コードを書きながら「これ、サービスが10個、20個と増えていったらどうなるんだろう...」と不安になったのを覚えています。
型安全性の面でも心配が残りました。依存関係の解決は実行時に行われるため、TypeScriptのコンパイラが助けてくれません。コードを書いている時は「これで大丈夫なはず」と思っても、実際に実行してみないと本当に正しく動くかわからない。そんな状態でした。
テストを書こうとしたときに、さらに問題に気づきました。モックオブジェクトを作って、手動でコンテナに登録し直して...という作業が、想像以上に面倒だったのです。「テストが書きづらい設計は良い設計ではない」という言葉を思い出しました。
そして何より、ボイラープレートコードの多さに辟易としました。新しいサービスを追加するたびに、同じようなコードを延々と書き続ける。プログラマーとして、もっと本質的な部分に時間を使いたいのに。
こうした経験から、「やはり適切なライブラリを使うべきだ」という結論に至りました。そこで選んだのが、InversifyJSです。
InversifyJSとの出会い
InversifyJSは、TypeScript向けに作られたDIコンテナライブラリです。2015年からあるライブラリで、今でも活発に開発が続いています。
初めてInversifyJSのドキュメントを読んだとき、「あ、これだ」と思いました。TypeScriptの型システムと本当に上手く統合されていて、デコレータを使った宣言的な記述が驚くほど読みやすかったのです。
特に気に入ったのは、リフレクション機能を使った自動的な依存関係解決です。私が前回手動で頑張っていた部分を、ライブラリが勝手にやってくれる。これだけでも導入する価値がありました。
ライブラリ自体は軽量で、必要最小限の機能に絞られています。でも、その裏にはちゃんとエンタープライズレベルの要求に応えられるだけの柔軟性があります。スコープ管理や条件付きバインディングなど、「あ、こういうのが欲しかった」という機能がしっかり揃っているんです。
そして何より良かったのは、フレームワーク非依存の設計です。Express、Hapi、React、Angularなど、どんなフレームワークとも組み合わせられる。だから、今回のようなHonoとの統合も、特に苦労することなく実現できました。
InversifyJSの基本的な使い方
それでは、InversifyJSの基本的な使い方を見ていきましょう。まずは必要なパッケージをインストールします。
npm install inversify reflect-metadata
npm install -D @types/reflect-metadata
reflect-metadataは、TypeScriptのデコレータ機能を使うために必要なpolyfillです。ちょっと不思議な名前ですが、これがないとInversifyJSのデコレータが動きません。
次に、TypeScriptの設定でデコレータを有効にします。これを忘れると、デコレータが使えないのでご注意ください。
// tsconfig.json
{
"compilerOptions": {
// ...
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"exclude": ["node_modules"]
}
基本的な使い方は、とてもシンプルです。まず、サービスクラスに @injectableデコレータを付けます。
import { injectable } from 'inversify'
@injectable()
export class UserService {
async getUsers() {
return [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }]
}
}
依存関係がある場合は、コンストラクタの引数に @injectデコレータを使います。
import { injectable, inject } from 'inversify'
@injectable()
export class PostService {
constructor(
@inject(TYPES.SlackService) private slackService: SlackService
) {}
async getPosts() {
return [{ id: 1, title: 'First Post' }]
}
async createPost(postData: any) {
const post = { id: Date.now(), ...postData }
// Slack通知を送信
await this.slackService.notifyPostCreated(post.id, post.title || 'Untitled')
return post
}
}
最後に、コンテナを作成してバインディングを設定します。
import { Container } from 'inversify'
const container = new Container()
container.bind(TYPES.UserService).to(UserService).inSingletonScope()
container.bind(TYPES.PostService).to(PostService).inSingletonScope()
container.bind(TYPES.SlackService).to(SlackService).inSingletonScope()
これだけです。本当にこれだけで、DIが実現できます!この基本的な流れを理解すれば、InversifyJSの8割は使いこなせると言っても過言ではありません。
Honoへの統合方法
さて、ここからが本題です。InversifyJSをHonoと統合する方法はいくつかありますが、私はシンプルなアプローチを選びました。コンテナを直接importして使う方法です。
(middlewareを使うやり方も考えましたが、今回は割愛します)
まず、依存関係の識別子を管理するためのSymbolを定義します。文字列でも良いのですが、Symbolを使うことで打ち間違いによるバグを防げます。
// src/core/types.ts
export const TYPES = {
UserService: Symbol.for('UserService'),
PostService: Symbol.for('PostService'),
SlackService: Symbol.for('SlackService'),
}
次に、コンテナを作成し、各サービスをバインディングします。
// src/core/container.ts
import 'reflect-metadata'
import { Container } from 'inversify'
import { TYPES } from './types.js'
import { UserService } from '../modules/users/userService.js'
import { PostService } from '../modules/posts/postService.js'
import { SlackService } from '../utilModules/slack/slackService.js'
const container = new Container()
// サービスをコンテナにバインド
container.bind(TYPES.UserService).to(UserService).inSingletonScope()
container.bind(TYPES.PostService).to(PostService).inSingletonScope()
container.bind(TYPES.SlackService).to(SlackService).inSingletonScope()
export { container }
このアプローチのおかげで、各コントローラーで直接コンテナをimportして、必要なサービスを取得できるようになります。シンプルですが、これで十分実用的です。
// src/index.ts
import 'reflect-metadata'
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import './core/container.js' // Inversifyコンテナを初期化
import { userController } from './modules/users/index.js'
import { postController } from './modules/posts/index.js'
import { loggingMiddleware } from './core/middleware/loggingMiddleware.js'
const app = new Hono()
// ロギングミドルウェアを適用
app.use('*', loggingMiddleware)
app.route('/api/users', userController)
app.route('/api/posts', postController)
serve({
fetch: app.fetch,
port: 3000
}, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})
実装例の詳細解説
ここからは、実際のコードを見ながら、InversifyJSを使った実装を詳しく見ていきましょう。
types.tsでのシンボル定義
依存関係を文字列ではなくSymbolで管理しています。これ、最初は「面倒だな」と思ったのですが、実際に使ってみると打ち間違いによるバグを防げるので重宝しています。
// src/core/types.ts
export const TYPES = {
UserService: Symbol.for('UserService'),
PostService: Symbol.for('PostService'),
SlackService: Symbol.for('SlackService'),
}
それに、IDEの補完も効くようになるので、開発効率も上がります。ちょっとした手間ですが、かける価値のある手間だと思います。
container.tsでのバインディング設定
コンテナの設定は一箇所にまとめました。こうすることで、「このプロジェクトにはどんなサービスがあるのか」が一目でわかります。
// src/core/container.ts
import 'reflect-metadata'
import { Container } from 'inversify'
import { TYPES } from './types.js'
import { UserService } from '../modules/users/userService.js'
import { PostService } from '../modules/posts/postService.js'
import { SlackService } from '../utilModules/slack/slackService.js'
const container = new Container()
// サービスをコンテナにバインド
container.bind(TYPES.UserService).to(UserService).inSingletonScope()
container.bind(TYPES.PostService).to(PostService).inSingletonScope()
container.bind(TYPES.SlackService).to(SlackService).inSingletonScope()
export { container }
ここで注目してほしいのは inSingletonScope()です。これを付けることで、アプリケーション全体で一つのインスタンスを共有するようになります。毎回新しいインスタンスを作るより、メモリ効率も良いですし、状態の管理もシンプルになります。
サービスクラスの実装
InversifyJSを使うと、サービスクラスのコードが驚くほどシンプルになります。前回の実装と比べてみてください。
PostService
// src/modules/posts/postService.ts
import { injectable, inject } from 'inversify'
import { TYPES } from '../../core/types.js'
import { SlackService } from '../../utilModules/slack/index.js'
@injectable()
export class PostService {
constructor(@inject(TYPES.SlackService) private slackService: SlackService) {}
async getPosts() {
return [{ id: 1, title: 'First Post' }]
}
async createPost(postData: any) {
const post = { id: Date.now(), ...postData }
// Slack通知を送信
await this.slackService.notifyPostCreated(post.id, post.title || 'Untitled')
return post
}
}
UserService
// src/modules/users/userService.ts
import { injectable } from 'inversify'
@injectable()
export class UserService {
async getUsers() {
// DBアクセスやビジネスロジック
return [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }]
}
async createUser(userData: any) {
// ユーザー作成ロジック
return { id: 3, ...userData }
}
async getUserById(id: string) {
// ユーザー取得ロジック
return { id, name: 'John' }
}
}
SlackService
// src/utilModules/slack/slackService.ts
import { injectable } from 'inversify'
import { Logger } from '../logger.js'
@injectable()
export class SlackService {
async sendNotification(message: string): Promise<void> {
// 実際のSlack APIを呼び出す代わりに、ログ出力でシミュレート
Logger.info('SlackService', `Notification sent: ${message}`)
}
async notifyPostCreated(postId: number, title: string): Promise<void> {
await this.sendNotification(`New post created: #${postId} - "${title}"`)
}
async notifyError(error: string): Promise<void> {
await this.sendNotification(`Error occurred: ${error}`)
}
}
見てわかる通り、@injectableと @injectデコレータだけで、依存関係が明確に宣言されています。コードを読む人は、「ああ、このサービスはSlackServiceに依存しているんだな」とすぐに理解できます。新しいメンバーがプロジェクトに参加したときの理解コストも、ぐっと下がるはずです。
コントローラでの利用
コントローラでは、コンテナから必要なサービスを取得して使います。とてもシンプルです。
// src/modules/posts/postController.ts
import { Hono } from 'hono'
import { container } from '../../core/container.js'
import { TYPES } from '../../core/types.js'
import { PostService } from './postService.js'
export const postController = new Hono()
postController.get('/', async (c) => {
const postService = container.get<PostService>(TYPES.PostService)
const posts = await postService.getPosts()
return c.json({ posts })
})
postController.post('/', async (c) => {
const postService = container.get<PostService>(TYPES.PostService)
const postData = await c.req.json()
const post = await postService.createPost(postData)
return c.json(post, 201)
})
同様に、UserControllerも実装します。
// src/modules/users/userController.ts
import { Hono } from 'hono'
import { container } from '../../core/container.js'
import { TYPES } from '../../core/types.js'
import { UserService } from './userService.js'
export const userController = new Hono()
userController.get('/', async (c) => {
const userService = container.get<UserService>(TYPES.UserService)
const users = await userService.getUsers()
return c.json({ users })
})
userController.post('/', async (c) => {
const userService = container.get<UserService>(TYPES.UserService)
const userData = await c.req.json()
const user = await userService.createUser(userData)
return c.json(user, 201)
})
userController.get('/:id', async (c) => {
const userService = container.get<UserService>(TYPES.UserService)
const id = c.req.param('id')
const user = await userService.getUserById(id)
return c.json(user)
})
コントローラは、ビジネスロジックの詳細を知る必要がありません。必要なサービスを取得して、メソッドを呼ぶ。ただそれだけです。この単純さが、保守性と拡張性を生み出します。
ロギングミドルウェア
共通的な機能は、ミドルウェアとして実装しています。すべてのリクエストでログを出力するようにしました。
// src/core/middleware/loggingMiddleware.ts
import type { Context, Next } from 'hono'
import { Logger } from '../../utilModules/logger'
export const loggingMiddleware = async (c: Context, next: Next) => {
const startTime = Date.now()
const method = c.req.method
const path = c.req.path
// 次の処理を実行
await next()
// レスポンスタイムを計算
const responseTime = Date.now() - startTime
const status = c.res.status
// HTTPログ出力
Logger.http(method, path, status, responseTime)
}
テスト実装のメリット
InversifyJSを導入して、一番感動したのはテストの書きやすさでした。依存関係をモックに置き換えるのが、本当に簡単なんです。
// src/modules/posts/postService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { PostService } from './postService.js'
import { SlackService } from '../../utilModules/slack/index.js'
describe('PostService', () => {
let postService: PostService
let mockSlackService: SlackService
beforeEach(() => {
// SlackServiceのモックを作成
mockSlackService = {
sendNotification: vi.fn().mockResolvedValue(undefined),
notifyPostCreated: vi.fn().mockResolvedValue(undefined),
notifyError: vi.fn().mockResolvedValue(undefined)
} as any
// DIを使ってPostServiceにモックを注入
postService = new PostService(mockSlackService)
})
describe('getPosts', () => {
it('投稿のリストを返す', async () => {
const posts = await postService.getPosts()
expect(posts).toBeDefined()
expect(Array.isArray(posts)).toBe(true)
expect(posts.length).toBeGreaterThan(0)
expect(posts[0]).toHaveProperty('id')
expect(posts[0]).toHaveProperty('title')
})
})
describe('createPost', () => {
it('指定されたデータで新しい投稿を作成する', async () => {
const postData = { title: 'Test Post', content: 'Test content' }
const post = await postService.createPost(postData)
expect(post).toBeDefined()
expect(post).toHaveProperty('id')
expect(post.title).toBe(postData.title)
expect(post.content).toBe(postData.content)
})
it('投稿作成時にSlack通知を送信する', async () => {
const postData = { title: 'Important Post' }
const post = await postService.createPost(postData)
expect(mockSlackService.notifyPostCreated).toHaveBeenCalledWith(
post.id,
'Important Post'
)
})
it('タイトルがない場合は"Untitled"を使用する', async () => {
const postData = { content: 'No title here' }
const post = await postService.createPost(postData)
expect(mockSlackService.notifyPostCreated).toHaveBeenCalledWith(
post.id,
'Untitled'
)
})
})
})
vitestのモック機能を使えば、依存関係を簡単にモック化できます。コンストラクタインジェクションのおかげで、テスト時にモックを渡すだけでOKです。前回の力技実装では、これがとても大変だったのを覚えています。
InversifyJSのその他の便利な機能
InversifyJSには、基本的なDI機能以外にも、便利な機能がたくさんあります。全部を紹介すると長くなってしまうので、特に役立つものをいくつか紹介しておきます。
スコープ管理
依存関係のライフサイクルを、柔軟にコントロールできます。これ、知っておくとかなり便利です。
// Singleton: アプリケーション全体で一つのインスタンス
container.bind(TYPES.UserService).to(UserService).inSingletonScope()
container.bind(TYPES.PostService).to(PostService).inSingletonScope()
container.bind(TYPES.SlackService).to(SlackService).inSingletonScope()
// Transient: 要求されるたびに新しいインスタンス(デフォルト)
container.bind(TYPES.PostService).to(PostService).inTransientScope()
// Request: リクエスト単位で一つのインスタンス
container.bind(TYPES.DatabaseConnection)
.to(DatabaseConnection).inRequestScope()
今回の実装では、すべてのサービスをSingletonスコープで管理しました。アプリケーション全体で同じインスタンスを共有するので、メモリ効率も良いです。
条件付きバインディング
実行環境に応じて、違う実装を注入することもできます。開発環境と本番環境で振る舞いを変えたいときに便利です。
// 開発環境では開発用のSlackService、本番環境では本番用のSlackServiceを使用
if (process.env.NODE_ENV === 'development') {
container.bind(TYPES.SlackService).to(DevSlackService).inSingletonScope()
} else {
container.bind(TYPES.SlackService).to(ProdSlackService).inSingletonScope()
}
ファクトリーパターン
複雑な初期化が必要な場合は、ファクトリー関数を使うこともできます。
container.bind(TYPES.DatabaseConnection)
.toFactory((context) => {
return (config: DatabaseConfig) => {
const connection = new DatabaseConnection()
connection.configure(config)
return connection
}
})
こういった機能があるおかげで、エンタープライズレベルの複雑な要求にも対応できます。最初から全部使う必要はありませんが、必要になったときに「そういえばInversifyJSにそんな機能があったな」と思い出してもらえれば幸いです。
まとめ
前回の力技実装から、今回のInversifyJS実装への移行を通じて、私は改めて「適切なツールを選ぶことの大切さ」を実感しました。
InversifyJSを導入して得られたものは、本当にたくさんあります。デコレータによる宣言的な記述で、コードが格段に読みやすくなりました。依存関係を手動で解決する必要がなくなって、ビジネスロジックに集中できるようになりました。型安全性が向上して、コンパイル時にエラーを検出できるようになったのも大きいです。
そして何より、テストが書きやすくなりました。モックの注入が簡単になったことで、テストを書くハードルがぐっと下がりました。「テストを書くのが面倒だからやめておこう」ということが減ったのは、コードの品質向上に直結していると思います。
もちろん、InversifyJSの導入にもコストはあります。デコレータの理解、リフレクションの仕組み、コンテナの設計。学ぶべきことは少なくありません。でも、これらの投資は、プロジェクトが大きくなるにつれて確実にリターンを生み出します。私自身、「最初からInversifyJS使っておけばよかった」と思っています!✨
HonoとInversifyJSの組み合わせは、モダンなTypeScript開発において、とても良い選択肢だと思います。軽量フレームワークの柔軟性と、成熟したDIライブラリの恩恵を、両方とも享受できます。このアプローチが、もっと多くのプロジェクトで使われるようになったら嬉しいです。
依存性注入は、最初は複雑に感じるかもしれません。私も最初はそうでした。でも、一度その価値を理解して、適切なツールを使えるようになると、もう元には戻れません。コードの保守性、テスタビリティ、拡張性。これらすべてが向上します。チーム開発の生産性も、きっと大きく改善されるはずです。
この記事が、「DIって難しそう」と思っている誰かの背中を押せたら、とても嬉しいです。
株式会社StellarCreate(stellar-create.co.jp)のエンジニアブログです。 プロダクト指向のフルスタックエンジニアを目指す方募集中です! カジュアル面談で気軽に雑談しましょう!→ recruit.stellar-create.co.jp/
Discussion