スパイダープラス Tech Blog

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

TypeScript で DIP を関数型スタイルで実装する

DB・外部 API への直接依存が引き起こす問題

API 開発をしていると、こんな問題に直面することがあります。

  • ビジネスロジックのテストを書こうとしたが、DB や外部 API が必要で実行できない
  • DB や外部 API の実装を変更したら、ビジネスロジックのコードまで変更が必要になった

これらの問題の根本原因は、ビジネスロジックが DB・外部 API などの具体的な実装に直接依存していることです。

この依存関係を切り離し、変更に強い設計にするためのアプローチが「依存性逆転の原則(DIP)」です。

依存性逆転の原則(DIP)を TypeScript で実装した例を紹介します。クラスや DI コンテナは使わず、関数パラメーターによる手動 DI というシンプルな方法で実現します。


依存性逆転の原則(DIP)とは

SOLID 原則の「D」にあたる設計原則で、英語では Dependency Inversion Principle(DIP) と呼ばれます。

その考え方の核心は、「ソースコードの依存関係が抽象だけを参照しているシステムが最も柔軟である」という点にあります。

ここで言う「抽象」とは、TypeScript の typeinterface のような、具体的な処理を持たない型定義のことです。一方「具体的な実装」とは、実際に DB に接続したり外部 API を呼び出したりするコードのことです。

具体的な実装は変わりやすいものです。使用するライブラリが変わったり、外部 API の仕様が変わったりするたびに、それに依存しているコードも変更が必要になります。しかし抽象(型)は安定しています。抽象だけに依存していれば、具体的な実装がいくら変わっても依存元のコードは変わりません。

「逆転」とは何が逆転するのか

DIP という名前の「逆転(Inversion)」は、依存の向きが処理の流れと逆になることを指しています。

逆転前:処理の流れと依存の向きが一致しています。

処理の流れ:  上位モジュール ──▶ 下位モジュール(具体)
依存の向き:  上位モジュール ──▶ 下位モジュール(具体)// 同方向

逆転後:処理の流れはそのままで、依存の向きだけが変わります。

処理の流れ:  上位モジュール ──▶ 下位モジュール(具体)
依存の向き:  上位モジュール ──▶ 抽象 ◀── 下位モジュール(具体)// 逆方向

下位モジュール(具体実装)が上位モジュールの定めた抽象に従う方向になります。上位モジュールは抽象だけを参照しているため、下位モジュールの具体的な実装がどう変わっても影響を受けません。


依存性逆転のやり方

「上位モジュール(moduleA)が下位モジュール(moduleB)に依存している状態」を逆にする方法を説明します。

問題(逆転前)

上位モジュールが下位モジュールの具体実装を直接 import しています。

// moduleA.ts(上位モジュール)
import { ModuleB } from "./moduleB"; // 具体実装に直接依存

export const processA = (input: string): string => {
  const b = new ModuleB();
  return b.execute(input);
};

この状態では、下位モジュールの実装が変わると上位モジュールも変更が必要です。また上位モジュールのテストに下位モジュールの実体が必要になります。

例:ModuleB の戻り値の型を変更したケース

ModuleB.execute() の戻り値を string から { value: string } に変更したとします。

// moduleB.ts(変更後)
export class ModuleB {
  execute(input: string): { value: string } { // 戻り値の型が変わった
    return { value: input };
  }
}

ModuleB を直接 import している moduleA.ts も、この変更に合わせて修正が必要になります。

// moduleA.ts も変更が必要になる
export const processA = (input: string): { value: string } => { // 戻り値の型を合わせる
  const b = new ModuleB();
  return b.execute(input);
};

このように、変更の影響が下位モジュールにとどまらず、上位モジュールにまで波及します。

逆転のやり方(3 ステップ)

ステップ 1 — 上位モジュール側に抽象(型)を定義する

ここでは moduleA.types.tsmoduleA.ts の2ファイルで1つの「上位モジュール」を構成します(アーキテクチャ上の「モジュール」は論理的な単位で、複数ファイルになることがあります)。

「下位モジュールに何を要求するか」を、上位モジュール側のファイルに型として定義します。抽象を上位モジュール側が所有するのが DIP の核心です。

// moduleA.types.ts(上位モジュール側で定義)
export type ModuleBInterface = {
  execute: (input: string) => string;
};

ステップ 2 — 上位モジュールは抽象のみに依存させる

具体実装の import をなくし、抽象型を受け取る形にします。

// moduleA.ts(上位モジュール)
import type { ModuleBInterface } from "./moduleA.types"; // 抽象のみ

export const processA = (input: string, b: ModuleBInterface): string => {
  return b.execute(input); // 何の実装かを知らずに呼べる
};

ステップ 3 — 下位モジュールは抽象を満たす実装をする

下位モジュールは、上位モジュールが定義した型に従って実装します。

// moduleB.ts(下位モジュール)
import type { ModuleBInterface } from "./moduleA.types"; // 上位モジュールの型に従う

export const moduleB: ModuleBInterface = {
  execute: (input) => `processed: ${input}`,
};

結果

依存の向きが逆転しました。

処理の流れ:  上位モジュール ──▶ 下位モジュール
依存の向き:  上位モジュール ──▶ ModuleBInterface(抽象)◀── 下位モジュール

依存性を逆転するためのテクニック:依存性の注入(DI)

前のセクションで抽象を介した設計にしましたが、「誰が具体実装を上位モジュールに渡すのか」という問題が残ります。

その答えが DI(Dependency Injection) です。依存オブジェクトを自分で生成せず、外部から注入してもらうパターンです。

DI の手段

代表的な手段が 3 つあります。

手段 方法
コンストラクター注入 クラスのコンストラクターで受け取る
プロパティ注入 プロパティに外部からセットする
関数パラメーター注入 関数の引数で受け取る(本記事で採用)

関数パラメーターによる手動 DI

クラスや DI コンテナを使わず、関数の引数として渡すシンプルな方法です。この方法を選んだ理由は3つあります。

  • プロジェクトがクラスを使わない関数スタイルのため、コンストラクター注入が前提と合わない
  • 依存が関数シグネチャに現れるため、何に依存しているか一目でわかる
  • DI コンテナ(InversifyJS 等)が不要で、追加ライブラリなしに実現できる
// caller.ts(コンポジションルート)
import { moduleB } from "./moduleB"; // 具体実装を知っているのはここだけ
import { processA } from "./moduleA";

const result = processA("hello", moduleB); // 具体実装を外から渡す(注入)

具体実装を注入する場所を コンポジションルート と呼びます。アプリケーションのエントリポイント付近(Routes 層など)に配置するのが一般的で、ここが唯一「具体を知る場所」となり、それ以外の場所(上位モジュール)は抽象だけを知っている状態になります。


実装してみた

ここでは実際のプロジェクト(TypeScript + Hono)のコードを紹介します。前のセクションの対応関係は次のとおりです。

なお、Usecase・Adapter はいずれも Clean Architecture で使われる用語です。Usecase はビジネスロジックを実装する層、Adapter は DB や外部 API などの具体的な実装を担う層を指します。

一般的な説明 プロジェクトでの対応
上位モジュール Usecase(ビジネスロジック)
下位モジュール Adapter(DB)
コンポジションルート Routes

アーキテクチャ概要

┌─────────────────────────────────┐
│         Routes 層               │ ← HTTP処理・コンポジションルート
├─────────────────────────────────┤
│         Adapter 層              │ ← DB・外部 API などの具体的な実装
├─────────────────────────────────┤
│         Usecase 層              │ ← ビジネスロジック
└─────────────────────────────────┘

本節では ユーザー作成処理(POST /users) を例に DIP の実装を紹介します。この処理では DB にユーザー情報を保存します。

注意:以下のコードは説明のために実際の実装を簡略化したものです。エラーハンドリングや詳細な型定義などは省略しています。

ステップ 1 — Usecase 側に UserAdapter 型を定義する

ここでは users.types.tsusers.usecase.ts の2ファイルで1つの「上位モジュール」を構成します。

// users.types.ts(上位モジュールと下位モジュールの境界を定義)

export type UserCreateInput = {
  email: string;
  fullName: string;
  password: string;
};

export type CreateUserInDbFn = (
  input: UserCreateInput,
) => Promise<{ id: string }>;

// Usecase が必要とする操作をまとめた型(= 抽象)
export type UserAdapter = {
  createUserInDb: CreateUserInDbFn;
};

ステップ 2 — Usecase は UserAdapter 型のみに依存させる

// users.usecase.ts(Usecase = 上位モジュール)
import type { UserAdapter, UserCreateInput } from "./users.types"; // 抽象のみ

export const createUser = async (
  input: UserCreateInput,
  adapter: UserAdapter, // 何の実装かを知らない
): Promise<{ id: string }> => {
  return adapter.createUserInDb(input); // DB が何かを知らずに呼べる
};

ステップ 3 — Adapter は UserAdapter 型を満たす実装をする

// adapters/db.adapter.ts(Adapter = 下位モジュール)
import type { CreateUserInDbFn } from "../users.types"; // 上位モジュールの型に従う
import { db } from "../db"; // Drizzle の DB クライアント
import { users } from "../db/schema"; // Drizzle のテーブルスキーマ

const createUserInDb: CreateUserInDbFn = async (input) => {
  // DB にユーザー情報を保存する
  const id = crypto.randomUUID();
  await db.insert(users).values({ id, email: input.email });
  return { id };
};

export const dbAdapter = { createUserInDb };

// adapters/index.ts — UserAdapter 型に合わせて統合
export const userAdapter: UserAdapter = {
  ...dbAdapter,
};

DI — Routes がコンポジションルートとして注入する

// users.routes.ts(コンポジションルート)
import { userAdapter } from "./adapters"; // 具体実装を知っているのはここだけ
import { createUser } from "./users.usecase";

export const usersRoutes = new Hono()
  .post("/", async (c) => {
    const input = await c.req.json<UserCreateInput>();
    const result = await createUser(input, userAdapter); // 注入
    return c.json(result, 201);
  });

テストへの効果

Usecase が抽象にしか依存していないため、モックアダプターを差し込むだけでユニットテストが書けます。

const createMockAdapter = (overrides?: Partial<UserAdapter>): UserAdapter => ({
  createUserInDb: vi.fn().mockResolvedValue({ id: "user-123" }),
  ...overrides,
});

it("DB 登録失敗時はエラーになる", async () => {
  const adapter = createMockAdapter({
    createUserInDb: vi.fn().mockRejectedValue(new Error("DB 登録失敗")),
  });

  await expect(createUser(validInput, adapter)).rejects.toThrow();
  expect(adapter.createUserInDb).toHaveBeenCalledWith(validInput);
});

DB を一切起動せずに、ビジネスロジックを単体でテストできます。外部サービスへの接続設定も不要なため、テストの所要時間も大幅に短縮されます。


まとめ:DIP を関数パラメーター注入で実現する

このアーキテクチャを実際のプロジェクトに導入してみて、特にテストの書きやすさが大きく変わりました。以前は DB の準備に時間を取られていたユニットテストが、モックアダプターを渡すだけで書けるようになったのは大きな収穫でした。

DIP の実装は3ステップ+DI です。

ステップ やること
ステップ 1 上位モジュール側に抽象(型)を定義する
ステップ 2 上位モジュールは抽象のみに依存させる
ステップ 3 下位モジュールは抽象を満たす実装をする
DI コンポジションルートで具体実装を注入する
問題(DIPなし) 解決(DIPあり)
DB・外部 API が必要でユニットテスト不可 モックアダプターを差し込むだけでテスト可能
DB・外部 API の変更が Usecase まで波及 Adapter 実装だけ差し替え。Usecase は無変更
依存が処理の流れと同方向 依存が逆転。Adapter が Usecase の型に従う

TypeScript の構造的部分型を活用することで、クラスや DI コンテナを使わずにシンプルかつ型安全な DIP を実現できます。


この記事は、社内で実際に採用しているアーキテクチャパターンをベースに構成しました。

終わりに

スパイダープラスでは仲間を募集中です。

スパイダープラスにちょっと興味が出てきたなという方がいらっしゃったらお気軽にご連絡ください。

https://hrmos.co/pages/spiderplus/jobs/0000067/apply