💉

HonoでDIを実現する実践入門 - Slack通知モジュールで学ぶ依存性注入

本記事のサマリ

軽量かつ高速なWebフレームワーク「Hono」を使ったAPI開発において、依存性注入(DI)を実装する実践的な方法を解説します。HonoのVariables機能を活用したDIコンテナの構築から、実際のSlack通知モジュールの作成まで、テスタビリティの向上を含めた包括的な内容となっています。Honoでガンガン開発されたい方にとって、少しでも寄与する記事になればと思います。

正直DIを自前実装するのもどうかと思いましたが、仕組みを知るために今回は「車輪の再発明」をしています。
(後日、npm packageでDIを実装する形で、別途記事を出します)

https://github.com/toto-inu/lab-202510-nestjs_vs_hono/tree/di_test

はじめに - なぜHonoでDIが必要なのか

Honoを使ってAPIを作り始めると、最初はシンプルで快適なんですよね。でも、機能が増えてくると「あれ、このコード、テストどうしよう...」「Slack通知のサービスをメール通知に変えたいけど、あちこち書き換えないと...」なんて壁にぶつかることがあります。

私も実際にHonoでプロジェクトを進める中で、こうした課題に直面しました。例えば、こんなコードを書いていたんです。

app.ts
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コンテナ」というものを作ります。これは、サービスたちを管理する箱のようなものだと思ってください。

「このサービスが必要です」と言えば、「はい、どうぞ」と渡してくれる。しかも、そのサービスが他のサービスに依存していても、芋づる式に全部用意してくれる優れものです。

https://hono.dev/docs/api/context

DIコンテナのコア実装

まず、このコンテナの実装を見てみましょう。ちょっと長いですが、やっていることはシンプルです。

core/di-container.ts
// 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つです。

  1. シングルトン: 一度作ったインスタンスは使い回す(何度も作らない)
  2. 依存関係の解決: 「AはBに依存してる」という情報を保存しておいて、必要なときに順番に作る
  3. 遅延初期化: 実際に使われるまでインスタンスを作らない

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 デコレータを使って、関連するサービスをまとめて管理できるようにします。

core/module.ts
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を使うことになります。

https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks

utilModules/slack/slackService.ts
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を「モジュール」として登録します。モジュールは、関連するサービスをまとめたパッケージのようなものです。

utilModules/slack/slackModule.ts
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を使ってみましょう。

modules/posts/postService.ts
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に依存していることを明示します。

modules/posts/postModule.ts
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エンドポイントを実装します。

modules/posts/postController.ts
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コンテナに登録されます。

src/index.ts
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の大きな利点の一つは、テストが容易になることです。モックオブジェクトを使用して、外部サービスとの依存関係を切り離したテストが可能になります。

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('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引数を受け取ることで、ログの発生源と日時を明確にしています。

utilModules/logger.ts
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で公開していますので、ぜひ実際に動かしてみてください。

https://github.com/toto-inu/lab-202510-nestjs_vs_hono/tree/di_test

株式会社StellarCreate | Tech blog📚

Discussion

r-sugir-sugi

とても参考になりました!ライブラリ使わなくても実装できるんですね!

佐々木将一 @株式会社StellarCreate佐々木将一 @株式会社StellarCreate

r-sugiさん、コメントありがとうございます!🙌
私も自前実装してみて「シングルトンに注意するのと依存を明示的に注入してあげれば」できるものだな、とDIについて理解が深まりました!
ただ、プロダクション利用するにはかなり不安が残るところですね...

明後日Package (InversifyJS)を使うパターンの記事も上げるので、是非読んでいただけると嬉しいです!
触った感じ依存関係の指定も簡単になって良さそうです✨