tsyringe で迷わない:Clean Architecture の DI 実装

はじめに

CADDi Tech/Product Advent Calendar 2025 12日目の記事です。 こんにちは、DataFabric部の松本です。 私たちのチームでは、Clean Architectureを採用したTypeScriptプロジェクトで開発を進めています。

取り組んでいるプロジェクトでは、依存関係を管理するために、Microsoftが開発するDIライブラリ tsyringe を採用することにしました。Clean Architectureの依存関係逆転の原則を実現するには、DIコンテナが必須であるからです。tsyringeは軽量で使いやすく、枯れておりDIコンテナに必要な機能が一通りそろっていることが魅力的でした。

この記事では、tsyringeを使った 適切な実装方法を、具体的なコード例とともに解説します。よくある間違いと注意点も紹介するので、Clean Architectureに必須のDIコンテナを迷わずに実装できるようになることを目的としています。

Clean Architecture における依存性注入の必要性

レイヤードアーキテクチャと依存関係逆転の原則

Clean Architectureでは、システムを複数の層に分割します。中心には Domain層(エンティティ、Repositoryインタフェース)があります。その外側に Use Case層(アプリケーションロジック)、Infrastructure層(Repository実装、DBアクセス)、Presentation層(Controller、Handler)が配置されます。

重要なのは、依存関係の方向です。Clean Architectureでは「依存関係逆転の原則(DIP)」に従い、外側の層が内側の層に依存するように設計します。

Presentation → Use Case → Domain ← Infrastructure

具体的には、Use Caseは IRepository インタフェース(Domain層)に依存し、PrismaRepository などの具象実装(Infrastructure層)が IRepository を実装します。Use Case層は具体的な実装を知る必要がありません。

この設計により、データベースをPostgreSQLからMySQLに変更しても1、Use Caseのコードは一切変更不要になりますし、各層のテストコードを記述する際に容易にMockに差し替えることができるようになります。

Repository パターンとDI コンテナの必要性

しかし、この設計を実現するには「インスタンス生成をどこで行うか」が課題です。Use Caseはインタフェースに依存したいが、実際に動かすには Prisma などのO/Rマッパを使用する具象クラスが必要です。DIコンテナは具象クラスの生成と注入を自動化してくれるため、インスタンス生成そのものにも依存しない設計が可能になります。

テスト時には任意のモックに差し替えることができる点、ライフサイクル管理(シングルトン、スコープなど)もライブラリに任せられる点も良いでしょう。

tsyringe とは

概要と特徴

tsyringe は、Microsoft社が開発するTypeScript向けの軽量なDIコンテナです。

tsyringeは、@injectable()@inject() といったデコレータベースのシンプルなAPI2を提供しています。reflect-metadataを利用することでTypeScriptの型情報を実行時に活用でき、singleton、transient、scopedといったライフサイクル管理もサポートしています。他のDIライブラリと比較して軽量で学習コストが低いのも特徴です。

別の選択肢としてinversify.jsがありますが、筆者らのプロジェクトでは、 tsyringeが提供してくれている機能で十分であるという見立てがあったのでtsyringeを採用しました。DIライブラリでメジャーなものは提供してくれる機能に大差はないため、軽量で学習コストの低いtsyringeが適していると判断しました。

インストール

npm install tsyringe reflect-metadata

動作確認環境:

  • Node.js: 22.x
  • TypeScript: 5.7.x
  • tsyringe: 4.10.0
  • reflect-metadata: 0.2.2

tsyringeはデコレータ(@injectable()@inject() など)を使って依存性注入を実現します。これらのデコレータはTypeScriptの型情報を実行時に利用するため、reflect-metadata が必要です3

// tsconfig.json に以下を追加
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

推奨実装パターン

ここからは、架空のBlogエンティティを例に、Clean Architectureでの推奨実装パターンを解説します。

Clean Architectureでは、UseCaseはインタフェース(IBlogRepository)に依存すべきで、具象クラス(PrismaBlogRepository)に依存してはいけません。

TypeScriptの interface はコンパイル後に消えてしまうため、DIコンテナが実行時に「どの実装を注入すべきか」を判断できません。この問題を解決するために、Symbolトークンをinterfaceの識別子として使います。

// Domain 層で interface と Symbol トークンをセットで定義
export interface IBlogRepository {
  save(blog: Blog): Promise<void>;
}
export const IBlogRepositoryToken = Symbol("IBlogRepository");

Symbolは実行時にも存在し、一意性が保証されるため、interfaceと実装を安全に紐付けられます。

実装例:Blog エンティティでの実践

それでは、Blogエンティティを例に、具体的な実装を見ていきましょう。

Domain 層:Repository インタフェースと Symbol トークン

まず、Domain層でRepositoryのインタフェースとSymbolトークンを定義します。

// domain/repository/blog-repository.interface.ts
import { Blog, BlogId } from "../entity/blog.js";

export interface IBlogRepository {
  save(blog: Blog): Promise<void>;
  findById(id: BlogId): Promise<Blog | null>;
  findAll(): Promise<Blog[]>;
}

// Symbol token for DI
export const IBlogRepositoryToken = Symbol("IBlogRepository");

Infrastructure 層:Repository 実装

次に、Infrastructure層でRepositoryの具象実装を作ります。

// infrastructure/repository/in-memory-blog-repository.ts
import { singleton } from "tsyringe";
import { Blog, BlogId } from "../../domain/entity/blog.js";
import { IBlogRepository } from "../../domain/repository/blog-repository.interface.js";

@singleton()
export class InMemoryBlogRepository implements IBlogRepository {
  private blogs: Map<BlogId, Blog> = new Map();

  async save(blog: Blog): Promise<void> {
    this.blogs.set(blog.id, blog);
  }

  async findById(id: BlogId): Promise<Blog | null> {
    return this.blogs.get(id) ?? null;
  }

  async findAll(): Promise<Blog[]> {
    return Array.from(this.blogs.values());
  }
}

@singleton() デコレータを付け、IBlogRepository をimplementsします。実装の詳細(今回はInMemory)はDomain層に影響しない構造になっているのが分かると思います。

Use Case 層:依存性の注入

Use Caseでは、@inject() デコレータでSymbolトークンを指定します。また、UseCase自体もinterfaceとTokenを定義することで、Handler層からの依存を抽象化できます。

// use-case/create-blog.use-case.ts
import { inject, singleton } from "tsyringe";
import { Blog, BlogId } from "../domain/entity/blog.js";
import {
  IBlogRepository,
  IBlogRepositoryToken,
} from "../domain/repository/blog-repository.interface.js";

export interface CreateBlogCommand {
  id: BlogId;
  title: string;
  content: string;
  authorId: string;
}

// UseCase interface and token
export interface ICreateBlogUseCase {
  execute(command: CreateBlogCommand): Promise<Blog>;
}

export const ICreateBlogUseCaseToken = Symbol("ICreateBlogUseCase");

@singleton()
export class CreateBlogUseCase implements ICreateBlogUseCase {
  constructor(
    @inject(IBlogRepositoryToken) private blogRepository: IBlogRepository
  ) {}

  async execute(command: CreateBlogCommand): Promise<Blog> {
    const blog = Blog.create({
      id: command.id,
      title: command.title,
      content: command.content,
      authorId: command.authorId,
    });

    await this.blogRepository.save(blog);

    return blog;
  }
}

@inject(IBlogRepositoryToken) でSymbolトークンを指定し、型は IBlogRepository(インタフェース)とします。@singleton() でライフサイクルをシングルトンに設定しています。

UseCaseにもinterfaceとTokenを定義することで、Handler層もUseCaseの具象実装に依存せず、interfaceに依存するようになります。

DI コンテナの設定

DIコンテナで、Symbolトークンと具象クラスを紐付けます。

// dependency-injection.ts
import "reflect-metadata";
import { container } from "tsyringe";
import { IBlogRepositoryToken } from "./domain/repository/blog-repository.interface.js";
import { InMemoryBlogRepository } from "./infrastructure/repository/in-memory-blog-repository.js";
import {
  CreateBlogUseCase,
  ICreateBlogUseCaseToken,
} from "./use-case/create-blog.use-case.js";

// 実際はここにRepositoryやUseCaseの登録がずらっと並ぶ

// Register repository with Symbol token
container.registerSingleton(IBlogRepositoryToken, InMemoryBlogRepository);

// Register UseCases with Symbol tokens
container.registerSingleton(ICreateBlogUseCaseToken, CreateBlogUseCase);

// Export container for use in other modules
export { container };

import "reflect-metadata"; は、アプリケーションのエントリーポイント(またはDIコンテナの設定ファイル)で最初にインポートする必要があります。複数ファイルでimportしても問題ありませんが、エントリーポイントで一度importすれば十分です。これにより、デコレータが型情報を実行時に利用できるようになります。

container.registerSingleton() でSymbolトークンと実装を紐付けます。RepositoryだけでなくUseCaseも明示的に登録することで、Handler層がUseCaseのinterfaceに依存できるようになります。

Handler での利用

Handler(Presentation層)でUseCaseを解決して実行します。

// example.ts
import "./dependency-injection.js";
import { container } from "./dependency-injection.js";
import {
  ICreateBlogUseCase,
  ICreateBlogUseCaseToken,
} from "./use-case/create-blog.use-case.js";

// Resolve use case from container using Symbol token
const createBlogUseCase = container.resolve<ICreateBlogUseCase>(
  ICreateBlogUseCaseToken
);

// Execute
const createdBlog = await createBlogUseCase.execute({
  id: "blog-001",
  title: "Clean Architecture with tsyringe",
  content: "...",
  authorId: "author-001",
});

container.resolve() はHandler層でのみ使い、UseCase内部では使いません。これは後述するServiceLocatorアンチパターンを避けるためです。Tokenを使うことで、Handler層もUseCaseの具象実装に依存せず、interfaceに依存します。

これで、インタフェースに依存しながら、実行時に具象クラスを注入できるようになりました。

Webフレームワーク(Hono)での利用例

実際のWebアプリケーションでは、HandlerでUseCaseを解決して実行します。以下は Hono を使った例です。実際はもっと細やかなエラーハンドリングなどが必要になりますが、基本的な流れは同じです。

// presentation/handler/blog/get.ts
import { createFactory } from "hono/factory";
import { container } from "../../dependency-injection.js";
import {
  IGetBlogUseCase,
  IGetBlogUseCaseToken,
} from "../../use-case/get-blog.use-case.js";

const factory = createFactory();

export const getBlogHandler = factory.createHandlers(async (c) => {
  const { id } = await c.req.json();

  // container.resolve() でUseCaseをSymbol tokenで取得
  const getBlogUseCase = container.resolve<IGetBlogUseCase>(
    IGetBlogUseCaseToken
  );
  const blog = await getBlogUseCase.execute({ id });

  if (!blog) {
    return c.json({ error: "Blog not found" }, 404);
  }

  return c.json({
    id: blog.id,
    title: blog.title,
    content: blog.content,
  }, 200);
});

ポイントは、Handler層で container.resolve() を呼び出してUseCaseをSymbol tokenで取得することです。これにより、Handler層もUseCaseの具象実装に依存せず、interfaceに依存します。ここまでの設定により、tsyringeが自動的にUseCaseとRepositoryの依存関係を解決し、指定した具象クラスを注入してくれます。この依存関係解決の階層は何階層でも可能です。

テストでのモック差し替え

DIの大きなメリットの1つは、テスト時にモックへの差し替えが容易なことです。 コンストラクタインジェクションなので、containerを使わずにコンストラクタに直接モックを渡す方法が最もシンプルです。

Mock クラスを作成してテストする

以下はvitestでのテスト例です。

import "reflect-metadata";
import { describe, it, expect, vi } from "vitest";
import { CreateBlogUseCase } from "./create-blog.use-case.js";
import { IBlogRepository } from "../domain/repository/blog-repository.interface.js";
import { Blog, BlogId } from "../domain/entity/blog.js";

// Mock class for IBlogRepository
class MockBlogRepository implements IBlogRepository {
  public savedBlogs: Blog[] = [];

  async save(blog: Blog): Promise<void> {
    this.savedBlogs.push(blog);
  }

  async findById(id: BlogId): Promise<Blog | null> {
    return this.savedBlogs.find((b) => b.id === id) ?? null;
  }

  async findAll(): Promise<Blog[]> {
    return this.savedBlogs;
  }
}

describe("CreateBlogUseCase", () => {
  it("should create and save a blog", async () => {
    // Arrange: Create mock repository
    const mockRepository = new MockBlogRepository();

    // Create UseCase with mock (without DI container)
    const useCase = new CreateBlogUseCase(mockRepository);

    // Act: Execute use case
    const result = await useCase.execute({
      id: "test-001",
      title: "Test Blog",
      content: "Test Content",
      authorId: "author-001",
    });

    // Assert: Verify the result
    expect(result.id).toBe("test-001");
    expect(result.title).toBe("Test Blog");

    // Assert: Verify the blog was saved
    expect(mockRepository.savedBlogs).toHaveLength(1);
    expect(mockRepository.savedBlogs[0].title).toBe("Test Blog");
  });
});

Mockクラスを作成して IBlogRepository をimplementsし、UseCaseのコンストラクタに直接mockを渡します。containerを使わないため、シンプルで高速です。また、Mockの内部状態を直接検証できます。

なお、テストでcontainerを使わない場合でも import "reflect-metadata"; は必要です。UseCaseクラスに @singleton()@inject() デコレータが付いているため、クラス定義をインポートする時点でデコレータが評価され、reflect-metadataが必要になります。

vi.fn() を使ったスパイ

より簡易に検証する場合は、vi.fn() を使ってスパイを作成できます。

it("should use vi.fn() for spying", async () => {
  // Arrange: Create mock with spy functions
  const mockRepository: IBlogRepository = {
    save: vi.fn(),
    findById: vi.fn(),
    findAll: vi.fn(),
  };

  const useCase = new CreateBlogUseCase(mockRepository);

  // Act
  await useCase.execute({
    id: "test-002",
    title: "Another Test",
    content: "Another Content",
    authorId: "author-002",
  });

  // Assert: Verify save was called
  expect(mockRepository.save).toHaveBeenCalledTimes(1);
  expect(mockRepository.save).toHaveBeenCalledWith(
    expect.objectContaining({
      id: "test-002",
      title: "Another Test",
    })
  );
});

vi.fn() でスパイ関数を作成すると、呼び出し回数や引数を詳細に検証できます。Mockクラスよりも軽量ですが、内部状態の検証はできません。

container を使う方法(オプション)

containerを使ってモックの登録もできますが、テストでは通常不要です。

import { container } from "tsyringe";

beforeEach(() => {
  container.clearInstances();
});

it("should work with container", async () => {
  const mockRepository = new MockBlogRepository();

  container.register(IBlogRepositoryToken, {
    useValue: mockRepository,
  });

  const useCase = container.resolve(CreateBlogUseCase);
  // ... テスト実行
});

この方法は、Handler層のテストなど、実際のcontainerの動作を検証したい場合にのみ使います。UseCase単体のテストでは、Mockをコンストラクタに直接渡す方法を基本使用するようにします。

応用:ライフサイクル管理(@singleton と @injectable)

tsyringe のライフサイクル管理

tsyringeは3つのライフサイクルをサポートしています。

デコレータ インスタンスの生存期間 用途
@singleton() アプリケーション全体で1つ ステートレスなクラス(UseCase、Repository)
@injectable() resolve() のたびに新規作成 ステートフルなクラス
@scoped() リクエストスコープごとに1つ ステートフルなクラス

推奨:UseCase と Repository は @singleton()

筆者らのプロジェクトでは、すべてのUseCaseとRepositoryを @singleton() にしています

この判断には理由があります。まず、UseCaseとRepositoryはステートレスに設計すべきというCleanArchitectureの原則があります。インスタンスに状態を持たなければ、複数のリクエストで同じインスタンスを共有しても問題ありません。

また、PrismaClientについては Prisma 公式ドキュメント でsingletonパターンが推奨されています。複数のPrismaClientインスタンスを作成すると、それぞれが独自のコネクションプールを持ちます。そのため、データベースの接続上限に達してしまうリスクがあります("FATAL: sorry, too many clients already" エラーが発生します)。

また、singletonパターンは不要なインスタンス生成を避けられるため、パフォーマンスとメモリ効率の面でもメリットがあります。

実装例:

// PrismaClient: singleton で登録
container.register(PrismaClientToken, {
  useFactory: () => {
    if (!prismaClientInstance) {
      prismaClientInstance = new PrismaClient(...).$extends(extension);
    }
    return prismaClientInstance;
  },
});

// Repository: @singleton() デコレータ
@singleton()
export class InMemoryBlogRepository implements IBlogRepository {
  private blogs: Map<BlogId, Blog> = new Map();
  // 注意: この Map はアプリケーション全体で共有される
  // 本番環境では DB を使うため、この例では問題ない
}

// UseCase: @singleton() デコレータ
@singleton()
export class CreateBlogUseCase {
  constructor(
    @inject(IBlogRepositoryToken) private blogRepository: IBlogRepository
  ) {}
  // インスタンス変数は依存関係の参照のみ(状態を持たない)
}

応用:useFactory による Lazy initialization

これまで useClass を使ってきましたが、tsyringeには useFactory という登録方法もあります。実際のプロジェクトでの使い分けを実例とともに解説します。

useClass と useFactory の違い

両方とも初回の resolve() 時に初期化されますが、useFactoryは初期化ロジックをカスタマイズできる点が特徴です。

実装例:PrismaClient の Lazy initialization

実際のプロジェクトでは、PrismaClientの初期化時にログを出力するために useFactory を使いました。

// dependency-injection.ts
type ExtendedPrismaClient = ReturnType<PrismaClient["$extends"]>;
let prismaClientInstance: ExtendedPrismaClient | null = null;

// Register PrismaClient
container.register(PrismaClientToken, {
  useFactory: () => {
    if (!prismaClientInstance) {
      ApplicationLogger.info("Initializing PrismaClient with configuration", {
        attributes: {
          database_config: PRISMA_DATABASE_CONFIG,
          environment: process.env.NODE_ENV,
        },
      });

      prismaClientInstance = new PrismaClient({
        datasources: { db: { url: DATABASE_URL } },
      }).$extends(prismaExtension());
    }
    return prismaClientInstance;
  },
});

// Register Repository
container.register(IBlogRepositoryToken, {
  useClass: PrismaBlogRepository,
});

useFactory自体はsingletonにならないため、 prismaClientInstance 変数でsingletonを実現しています。ほとんどの場合は useClass で十分ですが、初期化時にカスタムロジックが必要な場合は useFactory を使うことを検討しましょう。

よくある間違いと注意点

抽象クラスはトークンとして使えない

abstractクラスはinterfaceと違ってコンパイル後も残るため、Symbolトークンなしで注入できると期待するかもしれません。しかし、tsyringeでは現時点(2025年12月)では動作しません。そのため、抽象クラスをトークンとして使う場合もSymbolトークンを定義して利用する必要があります。

ServiceLocator アンチパターンを避ける

実装パターンでも述べましたが、UseCase内部で container.resolve() を呼ぶのは避けましょう。依存関係がコンストラクタに表出しないためテストが困難になり、DIのメリットが大きく損なわれます。

// NG: UseCase 内で container.resolve() を使う
class CreateBlogUseCase {
  async execute(command: CreateBlogCommand) {
    const repository = container.resolve(IBlogRepositoryToken);  // NG
    // ...
  }
}

// OK: コンストラクタで依存関係を明示
class CreateBlogUseCase {
  constructor(
    @inject(IBlogRepositoryToken) private repository: IBlogRepository
  ) {}

  async execute(command: CreateBlogCommand) {
    // this.repository を使う
  }
}

コードレビューではDIの基本に立ち返り、依存はコンストラクタで注入しDIライブラリで解決させることを徹底しましょう。

さいごに

いかがでしたでしょうか。実際のアプリケーションに近い構造で示したので、実際に利用するときのイメージが湧いたのではないかと思います。

tsyringeを使ったClean Architectureの実装は、Symbolトークンパターンと正しいDIの使い方を理解すれば、シンプルで保守性の高いコードを書けます。この記事が、皆さんのプロジェクトでtsyringeを導入する際の参考になれば幸いです。

最後に、私たちCADDiでは一緒に働く仲間を募集しています。興味がある方はぜひ以下のリンクからご応募ください! https://recruit.caddi.tech/

参考資料


  1. 筆者はこのようなDB変更を経験したことはありませんが、将来起こりうるかもしれない変更に備える意味でClean Architectureの採用は有効です。
  2. tsyringeはDIの形態としてコンストラクタインジェクションのみをサポートしています。プロパティインジェクションやメソッドインジェクションはサポートしていません。が、筆者の知る限りコンストラクタインジェクションでほとんどのユースケースをカバーできるため、問題になることは稀です。
  3. 実は、 reflect-metadata のインポート忘れにより1時間ハマったりしました。