SPIDERPLUS Tech Blog

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

ソフトウェアアーキテクチャの本質と依存関係の整理、あるいはinterfaceの正しい使い所について

はじめに

こんにちは、EMの本田です。今回はソフトウェアアーキテクチャの話を自分なりの理解で整理してみようと思います。

ソフトウェアアーキテクチャを設計する上で大切なことの一つは、変更しやすく、テストも行いやすいシステムを目指すことではないでしょうか。その本質は、突き詰めていくと「モジュール間の依存関係をいかに整理整頓するか」という点にたどり着くように思います。

この記事では、「依存関係の整理」がソフトウェア開発においてなぜこれほど大切なのか、そして、その実現に役立つ interface を、どのような場面で、どのように活用していくと効果的なのか、といったポイントに焦点を当ててみたいと思います。

説明の際には、オニオンアーキテクチャのレイヤー構造をイメージした図や、TypeScriptを用いたサンプルコードを参考にしますが、ここで触れる考え方は、多くのプログラミング言語や設計の進め方にも応用できるはずです。

ソフトウェアの課題: 複雑に絡み合う依存関係

モジュール間の依存関係が整理されていなかったり、複雑に絡み合っていたりすると、ソフトウェアは次のような課題を抱えやすくなります。

  • 変更の影響範囲の広がり: 一つの変更が、思わぬ多くの箇所に影響を及ぼしてしまうことがあります。
  • テストの実施の難しさ: 特定のモジュールだけを切り離してテストすることが難しくなる傾向があります。

クリーンアーキテクチャやオニオンアーキテクチャといった設計の考え方は、まさにこの依存関係の問題に取り組むための、構造的なヒントを与えてくれます。これらのアーキテクチャで共通して大切にされているルールの一つに「依存性のルール」があります。

依存関係は常に内側(ビジネスロジックのコアやドメイン知識が配置されることが多い層)に向かうようにする。

これは、外側のレイヤー(UI、データベース、外部APIといった具体的な技術詳細を扱う部分)が内側のレイヤー(アプリケーションの核となるビジネスロジックを担う部分)に依存するのは許容されるものの、その逆、つまり内側のレイヤーが外側のレイヤーに依存するのは避けるべき、という考え方です。

オニオンアーキテクチャのレイヤーと理想的な依存方向の簡略図

この依存の方向性を意識することには、以下のようなメリットが期待できます。

  • 変更への対応しやすさの向上: ビジネスのコアロジックに手を加えることなく、UIフレームワークやデータベース技術といった、外部に依存する技術詳細部分の置き換えがしやすくなります。
  • テストのしやすさの向上: コアロジックを具体的なUIやDBの実装から切り離すことで、独立して、より速く信頼性の高いテストが行えるようになります。
  • 再利用性と独立性の向上: コアロジックが特定の外部要因に強く縛られなくなるため、異なる環境での再利用や、モジュールとして独立した形での開発・保守が進めやすくなります。

依存関係の逆転: interfaceの活用

「依存は内側へ」というルールは理想的ですが、実際の開発では、内側の層が外側の層の機能(例えば、データベースへのデータの保存)を使いたい場面が自然と出てきます。例として、アプリケーションのユースケースを扱う UserService(Application Service層に配置されることが多いでしょう)が、具体的なデータベースアクセスを担う PrismaUserRepository (Infrastructure層に配置されることが多い、特定のORMを使った実装)の機能を利用するケースを考えてみましょう。

もし、この連携を素直に実装してしまうと、内側の UserService が外側の PrismaUserRepository を直接参照する形になりがちです。これは、先に述べた「依存は内側へ」という依存性のルールとは少し異なる方向を向いてしまうことになります。具体的にコードで見てみると、以下のようになるかもしれません。

// --- これは依存性のルールに反する可能性がある例 ---
// src/application/services/user_service.ts
import { User } from '../../domain/models/user';
// ↓ 外側の Infrastructure レイヤーにある具体的なクラスを直接 import
import { PrismaUserRepository } from '../../infrastructure/repositories/prisma_user_repository';

export class UserService {
    // ↓ 具体的な実装クラスに依存している状態
    private userRepository: PrismaUserRepository;
    constructor() {
        // ↓ 自身で具体的なインスタンスを生成 (PrismaClientも必要になる想定)
        this.userRepository = new PrismaUserRepository(/* ... */);
    }

    async createUser(name: string): Promise<User> {
        const user = new User(name);
        // ↓ 具体的な実装のメソッドを呼び出し
        await this.userRepository.save(user);
        return user;
    }
}

このコードは、Application Serviceが本来外側にあるべきInfrastructure層の具象クラスに依存しているため、依存性のルールに違反しています。

依存性ルール違反の例。Application層がInfrastructure層の具象クラスに依存している様子

このような状況をうまく整理するために役立つのが、依存性逆転の原則 (Dependency Inversion Principle - DIP) という考え方です。DIPは、「上位モジュールは下位モジュールに直接依存するのではなく、両者は抽象に依存すべきである」と教えてくれます。 ここで言う「抽象」の役割を、多くの場合 interface が担ってくれます。interface は、具体的な実装の詳細ではなく、「どのような操作ができるか」という約束事だけを定義するものです。

interface を使用することで、依存関係の方向をいわば「逆転」させ、内側の層が外側の層の具体的な実装内容を知ることなく連携できるよう促すことができます。

interfaceを使った依存関係逆転のステップ

1. 抽象(interface)の用意 (ドメイン層に置くことを検討)

最初に、上位モジュール(例: UserService)が下位モジュール(例: Repository)に期待する操作の約束事を interface として定義します。ここで大切なポイントの一つは、この interfaceドメイン層(または、Application層とDomain層の間など、利用側である内側の層に近い場所)に配置することを検討する点です。 たまに実装と同じディレクトリに interface が置いてあるのを見かけますが、そうすると依存関係が不明瞭になり、DIPの意図から外れて実装されてしまうことがあるため注意が必要です。

// src/domain/repositories/user_repository.interface.ts
import { User } from '../models/user';
// Application ServiceがRepositoryに期待する操作を定義
export interface IUserRepository {
    findById(id: string): Promise<User | null>;
    save(user: User): Promise<void>;
}

2. 上位モジュールがinterfaceを利用

次に、上位モジュールであるUserServiceが、具体的な実装クラスを参照する代わりに、先ほど用意したIUserRepository インターフェースを利用するように変更します。実際のオブジェクトは、コンストラクタなどを通じて外部から渡してもらう(Dependency Injection - DI)形が多く見られます。

// src/application/services/user_service.ts
import { User } from '../../domain/models/user';
// ↓ ドメイン層などに置かれたInterfaceをimport
import { IUserRepository } from '../../domain/repositories/user_repository.interface';

export class UserService {
    // ↓ Interface型のプロパティとして依存を持つように
    private readonly userRepository: IUserRepository;
    // ↓ コンストラクタでInterfaceを満たすオブジェクトを受け取る (DI)
    constructor(userRepository: IUserRepository) {
        this.userRepository = userRepository;
    }

    async createUser(name: string): Promise<User> {
        const user = new User(name);
        // ↓ Interfaceで定義されたメソッドを呼び出す
        await this.userRepository.save(user);
        return user;
    }

    async findUser(id: string): Promise<User | null> {
        // ↓ Interfaceで定義されたメソッドを呼び出す
        return this.userRepository.findById(id);
    }
}

3. 下位モジュールがinterfaceを実装

最後に、Infrastructure層で、ドメイン層などで定義されたIUserRepository インターフェースの約束事を満たす、具体的なクラス(例: PrismaUserRepository)を作成します。

// src/infrastructure/repositories/prisma_user_repository.ts
import { PrismaClient } from '@prisma/client';
// ← 実装するInterfaceをimport (ドメイン層などから)
import { IUserRepository } from '../../domain/repositories/user_repository.interface';
import { User } from '../../domain/models/user';

export class PrismaUserRepository implements IUserRepository {
    private readonly prisma: PrismaClient;
    // ↓ DIコンテナなどからClientを受け取ることを想定
    constructor(prismaClient: PrismaClient) {
        this.prisma = prismaClient;
    }

    async findById(id: string): Promise<User | null> {
        const userRecord = await this.prisma.user.findUnique({ where: { id } });
        if (!userRecord) {
            return null;
        }
        // DBレコードからドメインオブジェクト(Entity)へ変換
        return new User(userRecord.id, userRecord.name);
    }

    async save(user: User): Promise<void> {
        // ドメインオブジェクト(Entity)からDBレコード形式へ変換して保存
        await this.prisma.user.create({
            data: {
                id: user.id,
                name: user.name,
            },
        });
    }
}

アプリケーションが動き出す際など(例えばmain.tsやDIコンテナの設定部分)で、PrismaUserRepositoryインスタンスを生成し、それをUserServiceに渡すようにします。

// src/main.ts (DIの例)
import { UserService } from './application/services/user_service';
import { PrismaUserRepository } from './infrastructure/repositories/prisma_user_repository';
import { PrismaClient } from '@prisma/client';
// ↓ Interfaceもimport (ドメイン層などから)
import { IUserRepository } from './domain/repositories/user_repository.interface';

const prisma = new PrismaClient();
const userRepository: IUserRepository = new PrismaUserRepository(prisma);
const userService = new UserService(userRepository);

async function runApplication() {
  try {
    const user1 = await userService.createUser('Alice');
    const foundUser = await userService.findUser(user1.id);
    // ...
  } finally {
    await prisma.$disconnect();
  }
}
runApplication();

これによって、依存関係は次のようなイメージで整理されることになります。

DIP適用後の依存関係。interfaceはドメイン層に配置されている様子

ここで注目したいのは、UserService (Application層) が IUserRepository (Domain層の抽象) を利用し、PrismaUserRepository (Infrastructure層) も同じく IUserRepository (Domain層の抽象) の実装をするという形で依存している点です。これにより、Application層からInfrastructure層への直接的な矢印がなくなり、依存の流れが内側(Domain層の interface )に向かうように制御されているのが見て取れます。

interfaceの適切な使い所

interfaceDIPは心強い味方ですが、どんな場合でも使えば良いというわけではなく、時には過剰な抽象化となり、コードを少し複雑にしてしまう可能性も考えられます。では、interface が特にその力を発揮しやすいのは、どのような場面なのでしょうか。

  • レイヤー境界をまたぐ依存関係の逆転を実現したい場合:
    • これが最も代表的で、効果を実感しやすいケースでしょう。Application層とInfrastructure層の間(Repositoryパターンなどがよく知られています)、UI層とApplication層の間など、関心事や変更の頻度が異なるレイヤー間のつながりを緩やかにするために役立ちます。安定させたいビジネスロジックを、副作用を含む要素(DB、外部API、UIフレームワークなど)から守るイメージです。
  • 外部ライブラリ・フレームワークへの橋渡し役 (アダプター):
    • アプリケーション内で使うロガーやHTTPクライアントといった機能を、アプリケーション独自の interface (例: ILogger)で一旦定義しておき、具体的なライブラリはその interface を実装するアダプタークラスとして包み込むようにします。こうすることで、特定のライブラリへの直接的な依存を減らし、将来ライブラリを変更したり差し替えたりする際の柔軟性を高めることにつながります。
  • テストダブル(Mock、Stub)への差し替えやすさ:
    • ユニットテストを行う際に、DBアクセスや外部API呼び出しといった、外部環境に左右される部分を、テスト用の代役オブジェクト(テストダブル)にスムーズに差し替えられるようにするのに有効です。 interface を利用していれば、本番用の実装とテスト用の実装を容易に入れ替えることができます。

反対に、次のような場合には、 interface の導入が必ずしも必要でなかったり、かえって複雑さを増したりすることもあるかもしれません。

  • 同じレイヤーの中で、互いに密接に関連し、一緒に変更されることが自然なモジュール同士。
  • 変更される可能性がとても低く、安定している内部的な実装の詳細部分。
  • 主にデータを運ぶ役割を持ち、様々な振る舞いを使い分ける必要がないような、シンプルなデータ転送オブジェクト(DTO)や値オブジェクトなど。

常に、「この interface (抽象化)を導入することで得られるつながりの緩やかさ、テストのしやすさ、変更への対応しやすさといったメリットは、導入によって生じるかもしれない複雑さというコストと比べてどうだろうか?」というバランスを考えることが大切です。

おまけ: 関数型アプローチにおける依存の分離

ここまで主にオブジェクト指向の文脈で interface について触れてきましたが、依存関係を疎にし、その方向を整理するという考え方は、関数型プログラミングのスタイルでも同じように大切で、実現可能です。クラスや interface の代わりに、高階関数(関数を引数として受け取ったり、関数を返したりする関数)と関数の型シグネチャ(関数の引数や戻り値の型を定義したもの)がその役割を担います。

中心となるロジックを担う関数は、具体的な処理を行う関数を直接呼び出すのではなく、必要とする操作を表す「関数の型」(これが interface のような役割を果たします)を定義し、その型の関数を引数として受け取るようにします(これがDIのような形になります)。

// src/domain/models/user.ts
export class User {
    constructor(public readonly id: string, public name: string) {}
}

// --- 関数の型を定義 (抽象、interfaceのような役割) ---
// src/domain/repositories/user_repository.types.ts
import { User } from '../models/user';
type SaveUserFn = (user: User) => Promise<void>;

// --- Application Serviceに相当する関数 (高階関数) ---
// src/application/services/user_service.fn.ts
import { User } from '../../domain/models/user';
import { SaveUserFn } from '../../domain/repositories/user_repository.types';

const createUserService = (saveUser: SaveUserFn) => {
    const createUser = async (name: string): Promise<User> => {
        const newUser = new User(name);
        await saveUser(newUser); // 渡された関数で保存
        return newUser;
    };
    return { createUser };
};

// --- Infrastructure層に相当する具体的な関数の実装例 (Prismaを使用) ---
// src/infrastructure/repositories/prisma_user_repository.fn.ts
import { PrismaClient } from '@prisma/client';
import { User } from '../../domain/models/user';
import { SaveUserFn } from '../../domain/repositories/user_repository.types';

const createPrismaUserFunctions = (prisma: PrismaClient) => {
    const saveUser: SaveUserFn = async (user) => {
        await prisma.user.create({ data: { id: user.id, name: user.name } });
    };
    return { saveUser };
};

// --- 実行部分 (main.tsなど) ---
// src/main.fn.ts
import { PrismaClient } from '@prisma/client';
import { createUserService } from './application/services/user_service.fn';
import { createPrismaUserFunctions } from './infrastructure/repositories/prisma_user_repository.fn';

async function mainFp() {
    const prisma = new PrismaClient();
    try {
        const { saveUser } = createPrismaUserFunctions(prisma);
        const userService = createUserService(saveUser); // 具体的なDB操作関数を渡す
        const user = await userService.createUser('Functional Example');
        // ...
    } finally {
        await prisma.$disconnect();
    }
}
mainFp();

このアプローチでも、createUserServiceはデータがどのように保存されるかという具体的な方法を知る必要はなく、ただ約束事(SaveUserFn型)を満たす関数が提供されることを期待しています。これにより、関心の分離が促されます。

まとめ

ソフトウェアアーキテクチャを考える上で特に大切なのは、変更のしやすさ、テストのしやすさ、そして再利用のしやすさを高めるための「依存関係の整理」ではないでしょうか。これを実現する上で、依存性逆転の原則(DIP)と、それを形にする interface(あるいは関数型における関数の型シグネチャなど)は、とても心強い道具となります。

interface は、特にレイヤーの境界や外部ライブラリとのつなぎ目、テストのしやすさを確保したい部分などで、その良さが活きてくるでしょう。しかし、どんな場面でも使える万能薬というわけではなく、どこで使うか、そして interface をどこに置くか(内側の層に置くのが基本です)といった点には、少し注意を払うとよさそうです。

最も大切なのは、これらの原則やパターンをそのまま鵜呑みにするのではなく、自分たちが作っているソフトウェアが置かれている状況や、これからどうしていきたいかといったことをよく考え、なぜその設計を選ぶのかを常に意識しながら、柔軟に向き合っていくことだと感じています。設計は常に様々な要素のバランスを取ることであり、その中でより良い選択を積み重ねていくことが、本当に価値あるソフトウェア作りに繋がっていくのではないでしょうか。

最後に、スパイダープラスでは仲間を募集中です。 スパイダープラスにちょっと興味が出てきたなという方がいらっしゃったらお気軽にご連絡ください。最後までご覧くださり、ありがとうございます。