HonoでDIを実現する実践入門 - Slack通知モジュールで学ぶ依存性注入
本記事のサマリ
軽量かつ高速なWebフレームワーク「Hono」を使ったAPI開発において、依存性注入(DI)を実装する実践的な方法を解説します。HonoのVariables機能を活用したDIコンテナの構築から、実際のSlack通知モジュールの作成まで、テスタビリティの向上を含めた包括的な内容となっています。Honoでガンガン開発されたい方にとって、少しでも寄与する記事になればと思います。
正直DIを自前実装するのもどうかと思いましたが、仕組みを知るために今回は「車輪の再発明」をしています。
(後日、npm packageでDIを実装する形で、別途記事を出します)
はじめに - なぜHonoでDIが必要なのか
Honoを使ってAPIを作り始めると、最初はシンプルで快適なんですよね。でも、機能が増えてくると「あれ、このコード、テストどうしよう...」「Slack通知のサービスをメール通知に変えたいけど、あちこち書き換えないと...」なんて壁にぶつかることがあります。
私も実際にHonoでプロジェクトを進める中で、こうした課題に直面しました。例えば、こんなコードを書いていたんです。
app.post('/posts', async (c) => {
const post = await createPost(data)
// 直接Slackサービスを呼び出し
const slack = new SlackNotificationService()
await slack.sendNotification(`新しい投稿: ${post.title}`)
return c.json(post)
})
一見問題なさそうですが、このコードには困ったポイントがいくつかあります。
まず、テストを書こうとすると、実際にSlack APIが呼ばれてしまいます。テストのたびにSlackに通知が飛ぶのは...ちょっと困りますよね。それに、「やっぱりSlackじゃなくてDiscordにしよう」となったとき、このコードを使っているすべての場所を書き換える必要が出てきます。
そこで登場するのが、依存性注入(DI)というパターンです。「難しそう...」と思うかもしれませんが、実は考え方はシンプル。必要なものは外から渡してもらう、ただそれだけです。
今回は、このDIをHonoで実装する方法を、実際にSlack通知機能を作りながら見ていきましょう。
DIって結局何なの?
「依存性注入」なんて言うと難しく聞こえますが、実際の開発シーンで考えてみると、その価値がよくわかります。
最初は、Slack通知だけを想定してこんなコードを書いたとしましょう。
// 最初の実装:Slack通知のみ
class PostService {
async createPost(data: any) {
const post = await saveToDatabase(data)
// Slack通知を直接実装
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
body: JSON.stringify({ text: `新しい投稿: ${post.title}` })
})
return post
}
}
最初はこれで問題ないように見えます。でも、プロジェクトが進んでいくと...
「Discord通知も追加したい」
「開発環境ではメール通知にしたい」
「エラー通知と成功通知で送信先を変えたい」
こんな要求が出てきますよね。そうなると、このコードはかなり厄介なことになります。
// 通知先が増えてきた...
type NotifyChannel = 'slack' | 'discord' | 'email'
class PostService {
async createPost(data: any, notifyChannels: NotifyChannel[] = []) {
const post = await saveToDatabase(data)
// Slackに通知
if (notifyChannels.includes('slack')) {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
body: JSON.stringify({ text: `新しい投稿: ${post.title}` })
})
}
// Discordに通知
if (notifyChannels.includes('discord')) {
await fetch(process.env.DISCORD_WEBHOOK_URL, {
method: 'POST',
body: JSON.stringify({ content: `新しい投稿: ${post.title}` })
})
}
// メール通知
if (notifyChannels.includes('email')) {
await sendEmail({
to: process.env.ADMIN_EMAIL,
subject: '新しい投稿',
body: `新しい投稿: ${post.title}`
})
}
return post
}
}
これでは、PostServiceが「投稿を作る」という本来の責務以外に、通知の詳細まで知りすぎています。新しい通知方法を追加するたびに、このクラスを修正しないといけません。テストを書こうとしても、実際の通知が飛んでしまいます...。
ここで登場するのが、依存性注入(DI)です。「通知する」という処理を外部から渡してもらう形に変えてみましょう。
// DIを使った実装:通知サービスを外から渡す
interface NotificationService {
notify(message: string): Promise<void>
}
class PostService {
constructor(private notificationServices: NotificationService[]) {}
async createPost(data: any) {
const post = await saveToDatabase(data)
// 渡された全ての通知サービスに通知
await Promise.all(
this.notificationServices.map(service =>
service.notify(`新しい投稿: ${post.title}`)
)
)
return post
}
}
// 使う側で通知方法を選択
const slackNotifier = new SlackNotificationService()
const postService = new PostService([slackNotifier])
// 複数の通知サービスを組み合わせられる
const discordNotifier = new DiscordNotificationService()
const emailNotifier = new EmailNotificationService()
const postService2 = new PostService([slackNotifier, discordNotifier, emailNotifier])
// テスト時はモックを渡せる
const mockNotifier = new MockNotificationService()
const postService3 = new PostService([mockNotifier])
これなら、PostServiceは「通知サービスに通知を依頼する」だけで済みます。Slack、Discord、メール、どの通知方法を使うかは外から決められます。しかも、テスト時にはモックを渡せば、実際の通知は飛びません。
この「必要なものは外から渡してもらう」という考え方が、依存性注入(DI)の本質です。今回は、このコンストラクタで渡してもらう方式(コンストラクタインジェクション)で実装していきます。TypeScriptとの相性もいいですし、NestJSなど他のフレームワークでもよく使われている方法です。
DIコンテナを作ろう
さて、ここからが本題です。DIを実現するには「DIコンテナ」というものを作ります。これは、サービスたちを管理する箱のようなものだと思ってください。
「このサービスが必要です」と言えば、「はい、どうぞ」と渡してくれる。しかも、そのサービスが他のサービスに依存していても、芋づる式に全部用意してくれる優れものです。
DIコンテナのコア実装
まず、このコンテナの実装を見てみましょう。ちょっと長いですが、やっていることはシンプルです。
// Constructorは「newできるクラス」の型定義
// 例: SlackService, PostServiceなどのクラスを表す
type Constructor<T = any> = new (...args: any[]) => T
export class DIContainer {
// シングルトンパターン:アプリ全体で1つのインスタンスだけを使う
private static instance: DIContainer
// providers: クラスの設計図を保存する箱
// 例: 'SlackService' → SlackServiceクラス
private providers = new Map<string, Constructor>()
// instances: 実際に作ったインスタンスを保存する箱(キャッシュ)
// 例: 'SlackService' → new SlackService() で作ったオブジェクト
private instances = new Map<string, any>()
// dependencies: 「誰が誰に依存してるか」の情報を保存
// 例: 'PostService' → ['SlackService'] (PostServiceはSlackServiceが必要)
private dependencies = new Map<string, string[]>()
// privateコンストラクタ:外から new DIContainer() できないようにする
private constructor() {}
// getInstance(): アプリ全体で共通のコンテナを取得する
// どこから呼んでも同じインスタンスが返ってくる(シングルトン)
static getInstance(): DIContainer {
if (!DIContainer.instance) {
DIContainer.instance = new DIContainer()
}
return DIContainer.instance
}
// register: サービスを登録する
// token: サービスの名前(例: 'SlackService')
// provider: サービスのクラス(例: SlackService)
// deps: このサービスが依存する(必要とする)他のサービスのリスト
register<T>(token: string, provider: Constructor<T>, deps: string[] = []): void {
this.providers.set(token, provider) // クラスを保存
this.dependencies.set(token, deps) // 依存関係を保存
}
// resolve: サービスを取り出す(必要なら作成する)
resolve<T>(token: string): T {
// ステップ1: すでに作ってあるインスタンスがあれば、それを返す
// 何度呼んでも同じインスタンスが返る(シングルトン)
// ステップ2: 登録されているクラスを取得
// ステップ3: 依存しているサービスを先に作る(再帰的に解決)
// 例: PostServiceが['SlackService']に依存している場合
// → まずSlackServiceを resolve() してから PostService を作る
if (this.instances.has(token)) {
return this.instances.get(token)
}
const provider = this.providers.get(token)
if (!provider) {
throw new Error(`Provider not found for token: ${token}`)
}
const deps = this.dependencies.get(token) || []
const resolvedDeps = deps.map(dep => this.resolve(dep))
// ステップ4: すべての依存が揃ったので、インスタンスを作成
const instance = new provider(...resolvedDeps)
// ステップ5: 作ったインスタンスをキャッシュに保存(次回から使い回す)
this.instances.set(token, instance)
return instance
}
clear(): void {
this.providers.clear()
this.instances.clear()
this.dependencies.clear()
}
}
// アプリ全体で使う共通のコンテナインスタンスをエクスポート
export const container = DIContainer.getInstance()
ポイントは3つです。
- シングルトン: 一度作ったインスタンスは使い回す(何度も作らない)
- 依存関係の解決: 「AはBに依存してる」という情報を保存しておいて、必要なときに順番に作る
- 遅延初期化: 実際に使われるまでインスタンスを作らない
Moduleパターンでもっと便利に
さて、DIコンテナができたので、次はもっと使いやすくしていきましょう。
今のままでは、こんな感じでサービスを登録することになります。
// Moduleパターンなしの場合
const container = DIContainer.getInstance()
// すべてを手動で登録する必要がある
container.register('SlackService', SlackService, [])
container.register('PostService', PostService, ['SlackService'])
container.register('UserService', UserService, ['PostService', 'SlackService'])
container.register('CommentService', CommentService, ['PostService', 'UserService'])
// ...サービスが増えるたびに延々と続く
これだと、いくつか困ったことが起きます。
- 登録順を間違えると動かない: 依存関係を手動で書くので、順番を間違えるとエラーに
- 関連するサービスが散らばる: 「このサービス、結局どこで登録してるんだっけ?」となりがち
- 依存関係の変更が大変: UserServiceに新しい依存を追加したら、登録コードも直さないと
- チーム開発で混乱: 複数人で開発すると、誰がどのサービスを登録したか分からなくなる
そこで登場するのがModuleパターンです。NestJSを触ったことがある方なら「あ、これ見たことある!」となるかもしれません。今回は @Module
デコレータを使って、関連するサービスをまとめて管理できるようにします。
import { container, type DIContainer } from './di-container'
export interface ModuleMetadata {
imports?: ModuleMetadata[]
providers?: {
token: string
useClass: any
deps: string[]
}[]
exports?: string[]
}
export function Module(metadata: ModuleMetadata) {
return function <T extends { new (...args: any[]): {} }>(target: T) {
// importsを先に処理
if (metadata.imports) {
metadata.imports.forEach(importedModule => {
if (importedModule.providers) {
importedModule.providers.forEach(provider => {
container.register(provider.token, provider.useClass, provider.deps)
})
}
})
}
// 自分のprovidersを登録
if (metadata.providers) {
metadata.providers.forEach(provider => {
container.register(provider.token, provider.useClass, provider.deps)
})
}
return target
}
}
export function Injectable() {
return function <T extends { new (...args: any[]): {} }>(target: T) {
return target
}
}
デコレータを使うことで、「このモジュールは何を提供して、何に依存しているか」が一目でわかるようになります。コードを書くときも、読むときも、すごく楽になるんです。
準備が整ったので、いよいよ実際にSlack通知機能を作っていきましょう!
実践:Slack通知を作ってみよう
Slackサービスの実装
まずは、Slack通知を送るサービスを作ります。今回はログ出力でシミュレートしていますが、本番では実際のWebhook URLを使うことになります。
import { Injectable } from '../../core/module'
import { Logger } from '../logger'
@Injectable()
export class SlackService {
async sendNotification(message: string): Promise<void> {
// 実際のSlack APIを呼び出す代わりに、ログ出力でシミュレート
Logger.info('SlackService', `Notification sent: ${message}`)
// 本番環境では以下のようなコードになります:
// const webhookUrl = process.env.SLACK_WEBHOOK_URL
// await fetch(webhookUrl, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ text: 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}`)
}
}
3つのメソッドを用意しました。汎用的な sendNotification
、投稿作成時の notifyPostCreated
、エラー時の notifyError
です。@Injectable()
デコレータをつけておくことで、DIコンテナで管理できるようになります。
Slackモジュールを定義する
次に、このSlackServiceを「モジュール」として登録します。モジュールは、関連するサービスをまとめたパッケージのようなものです。
import type { ModuleMetadata } from '../../core/module'
import { SlackService } from './slackService'
export const SlackModule: ModuleMetadata = {
providers: [
{
token: 'SlackService',
useClass: SlackService,
deps: []
}
],
exports: ['SlackService']
}
シンプルですよね。「SlackServiceを提供しますよ」というメタデータを定義しているだけです。deps: []
は「他に依存するものはないよ」という意味です。
PostServiceでSlack通知を行う
ここからが面白いところです。投稿を作成するサービスで、先ほど作ったSlackServiceを使ってみましょう。
import { Injectable } from '../../core/module'
import { SlackService } from '../../utilModules/slack/index'
@Injectable()
export class PostService {
constructor(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
}
}
コンストラクタで SlackService
を受け取っているだけです! new SlackService()
などは不要なんですね。これがDIの力です💪
投稿が作成されたら、自動的にSlack通知が飛ぶ。しかも、テストのときはモックのSlackServiceを渡せば、実際の通知は飛ばないのです。完璧ですね!
Postモジュールを定義する
PostServiceもモジュールとして登録します。ここでは、SlackModuleに依存していることを明示します。
import { Module } from '../../core/module'
import { PostService } from './postService'
import { SlackModule } from '../../utilModules/slack/index'
@Module({
imports: [SlackModule],
providers: [
{
token: 'PostService',
useClass: PostService,
deps: ['SlackService']
}
]
})
export class PostModule {}
コントローラーでの活用
最後に、コントローラーでDIコンテナからサービスを取得し、実際のAPIエンドポイントを実装します。
import { Hono } from 'hono'
import { container } from '../../core/di-container'
import { PostService } from './postService'
export const postController = new Hono()
postController.get('/', async (c) => {
const postService = container.resolve<PostService>('PostService')
const posts = await postService.getPosts()
return c.json({ posts })
})
postController.post('/', async (c) => {
const postService = container.resolve<PostService>('PostService')
const postData = await c.req.json()
const post = await postService.createPost(postData)
return c.json(post, 201)
})
アプリケーションの初期化
すべてのモジュールを登録し、アプリケーションを起動します。モジュールをインポートすることで、デコレータが実行され、自動的にDIコンテナに登録されます。
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { userController } from './modules/users/index'
import { postController } from './modules/posts/index'
import { loggingMiddleware } from './core/middleware/loggingMiddleware'
// モジュールを登録(デコレータが実行されてDIコンテナに登録される)
import './modules/posts/postModule'
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}`)
})
テスタビリティの向上
DIの大きな利点の一つは、テストが容易になることです。モックオブジェクトを使用して、外部サービスとの依存関係を切り離したテストが可能になります。
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('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'
)
})
})
})
このように、コンストラクタインジェクションを使うことで、実際のSlack APIを呼び出すことなく、モックを直接注入してテストが可能になります。
今回の実装の補足
Loggerの実装
今回の実装では、Loggerもユーティリティモジュールとして実装しています。静的メソッドとして提供し、context引数を受け取ることで、ログの発生源と日時を明確にしています。
export type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'DEBUG';
export class Logger {
private static formatDate(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
private static formatLog(level: LogLevel, context: string, message: string): string {
const timestamp = this.formatDate();
return `[${timestamp}] [${context}] ${level}: ${message}`;
}
static info(context: string, message: string): void {
console.log(this.formatLog('INFO', context, message));
}
static error(context: string, message: string, error?: Error): void {
console.error(this.formatLog('ERROR', context, message));
if (error && error.stack) {
console.error(error.stack);
}
}
// HTTPリクエスト用の便利メソッド
static http(method: string, path: string, status: number, responseTime: number): void {
const message = `${method} ${path} ${status} ${responseTime}ms`;
this.info('HTTP', message);
}
}
このLoggerは静的メソッドとして提供されているため、DIコンテナに登録せずにどこからでも利用できます。
モジュールパターンの利点
今回採用したModuleパターンには、いくつかの利点があります。
各機能が独立したモジュールとして管理されるため、コードの見通しが良くなります。新しい機能を追加する際も、既存のコードに影響を与えずに新しいモジュールを追加するだけで済みます。また、モジュール単位でのテストも容易になります。
// 新しいモジュールの追加例
// デコレータを使ったモジュールクラスを作成
@Module({
imports: [SlackModule],
providers: [
{
token: 'NewService',
useClass: NewService,
deps: ['SlackService']
}
]
})
export class NewModule {}
// index.tsでインポートするだけで自動登録される
import './modules/new/newModule'
まとめ
本記事では、HonoでDependency Injectionを実現する方法を、Slack通知モジュールを例に実践的に解説しました。
DIパターンとModuleパターンを組み合わせることで、コードの結合度が下がり、保守性が向上します。各機能がモジュールとして独立するため、新機能の追加や変更が容易になります。
今回の実装では、以下のような構造でアーキテクチャを構築しました。
- DIContainer: サービスの登録と解決を担当
- Module: サービスをまとめて管理する単位
- Service: 実際のビジネスロジックを実装
- Controller: HTTPリクエストを処理し、Serviceを呼び出す
この構造により、NestJSのような本格的なフレームワークに近い開発体験を、Honoの軽量さを保ったまま実現できています。
HonoのVariables機能を活用することで、軽量ながらも本格的なDIシステムを構築できることがお分かりいただけたと思います。今回実装したパターンは、小規模から中規模のアプリケーションに適していますが、より大規模なシステムでは、専用のDIライブラリ(InversifyJSなど)の導入も検討してみてください。
→ InversifyJSを使った実装は次回やってみましょう!
モダンなWeb開発において、適切なアーキテクチャの選択は非常に重要です。ぜひ実際のプロジェクトでDIパターンを活用し、より保守性の高いアプリケーションを構築してください。
完全な実装コードはGitHubで公開していますので、ぜひ実際に動かしてみてください。

株式会社StellarCreate(stellar-create.co.jp)のエンジニアブログです。 プロダクト指向のフルスタックエンジニアを目指す方募集中です! カジュアル面談で気軽に雑談しましょう!→ recruit.stellar-create.co.jp/
Discussion
とても参考になりました!ライブラリ使わなくても実装できるんですね!
r-sugiさん、コメントありがとうございます!🙌
私も自前実装してみて「シングルトンに注意するのと依存を明示的に注入してあげれば」できるものだな、とDIについて理解が深まりました!
ただ、プロダクション利用するにはかなり不安が残るところですね...
明後日Package (InversifyJS)を使うパターンの記事も上げるので、是非読んでいただけると嬉しいです!
触った感じ依存関係の指定も簡単になって良さそうです✨