依存性逆転の原則に従う抽象と実装のディレクトリ構成を考える
はじめに
こんにちは。
株式会社CHILLNNという京都のスタートアップでCTOを務めております永田と申します。
ソフトウェアを構築するベストプラクティスの一つとして、依存性逆転の原則というものがあります。この原則は特定のコード実装を他の責務をもつ実装の詳細から切り離す手段として用いられます。
依存性逆転の原則は強力で有用な原則ですが、自分の中で抽象を定義したファイルと実装を行なったファイルをどのように配置するべきかについて課題がありました。試行錯誤を繰り返す中で、最終的になかなかいい感じに落ち着かせることができたので、知見をシェアすべく記事にまとめました。
本記事では、依存性逆転の原則に従ったコードを書く際、抽象とその実装をそれぞれどこに書くべきかについて、一つの実装例を思考プロセスを辿りながらご紹介します。
依存性逆転の原則とは
依存性逆転とは「抽象に依存させることで依存関係を逆転させること」です。
具体的には、
import db from './db'
class UserService {
async getUserName(userId: string): string {
const user = await db.get(userId);
return user.name;
}
}
こんな感じで書かれていたコードを
interface UserRepository {
get(userId: string): Promise<User>;
}
class UserService {
constructor(
private readonly userRepository: UserRepository,
) {}
async getUserName(userId: string): string {
const user = await this.userRepository.get(userId);
return user.name;
}
}
こんな感じにすることなんかが、よく例として挙げられていると思います。
上記の例では、データアクセスのインターフェースをデータアクセスレイヤーの詳細から切り離し、データアクセスレイヤーとビジネスロジックレイヤーの間に境界を引くことができています。
依存性逆転の原則に従って、異なる責務を持つ層の間に境界を設けることで、システムになんらかの変更が必要になった際に、差分を吸収する余地を残すことができ、将来の変更に対してシステムの柔軟性が上がります。
ちなみに、依存性逆転の原則は全ての依存関係があるコードで従うべきものではありません。
依存性を逆転させるということは、逆転と表現されていることからもわかるように、基本的には人間の直感に反しており、開発者がシステムを理解するコストが上がります。
この原則は、肥大化したコードを小さなパーツに分解する際など、コードの抽象度のスコープがが変わるタイミングではなく、責務の異なる層を跨ぐ必要があるタイミングで用いるべき原則だと認識しておくと良いと思います。
また、別の観点で、依存性の逆転といえば、データアクセスレイヤーの抽象化だと捉えている方もいますが、より広く異なる抽象度を持つデータモデルを扱う必要がある際に導入すべき概念だと考えたほうが適切に適用することができます。
現在の弊社のバックエンドのアーキテクチャでは、将来的な変更が必要になった際に、どの変更をどのレイヤーで吸収するかをあらかじめ厳密に定義しているため、あえて実装に依存させているレイヤーも存在しています。
(この全体設計に関してはまた機会があれば記事にしようとおもいます。弊社ではフラクタルアーキテクチャと呼んでいます。)
抽象の定義はどこで行うべきか
ソフトウェアアーキテクチャが担うべき重要な責務の一つは、システムを理解するコストを下げることです。
特に、エンジニアの人材流動性が高まっている現代においては、オンボーディングコストを下げるために最も重要な側面といっても過言ではないと思っています。
自分は、ソフトウェアアーキテクチャについてなんらかの検討が必要になった場合、その時点で活発に開発されている大規模なOSSでの議論を参照することが多いのですが、その中でかなり有用な情報が見つかることがあります。
どこで抽象を定義するべきかという本記事のテーマに対しては、Next.js
のApp Router
で用いられているコロケーションパターンが優れていると感じています。
コロケーションパターンを用いる一つの利点は、関連コードを一箇所に束ねることで、ディレクトリ構成に抽象度や関係といった意味を持たせることができる点にあります。
コロケーションパターンをうまく用いることができれば、コードリーディングの際に、読んでいるコードに期待されている抽象度が直感的にわかるようになり、システムを理解するために必要なコードリーディングのコストが減少します。
一つ、実装の具体例を挙げておきます。
弊社のサーバー実装ではプログラミングパラダイムとしてデータ指向プログラミングを採用しているのですが、データエンティティを定義するエンティティレイヤーでEntityのRepositoryも同時に定義するようにしています。
具体的には、以下のような形です。
// entity/user.ts
interface UserEntityRepository {
getById(id: string): Promise<UserEntity | null>;
}
type UserEntity {
id: string;
name: string;
}
経験上、Entityのフィールドを参照するタイミングと、Entityへのアクセス方法を確認するタイミングは似通っていることが多く、コードリーディングのコストを下げることに役立っています。
コードが肥大化して参照性が落ちてきた場合はファイル分割を行いますが、その場合は新規にディレクトリを作成し、それぞれのファイルを同一ディレクトリに並列に置くようにしています。
ちなみにコロケーションパターンと言っても、クソデカ木構造を作っているというわけではなく、同一の責務を持つレイヤーでも適宜境界を設けることで木構造を分割しています。木構造のネストが深くなった場合には途中からでも木構造のルートを定義し直すことができるような設計にしておくとよいでしょう。(フラクタル!!)
コロケーションパターンで実装した場合に陥る事態
コロケーションパターンを用いることの一つの利点は、ディレクトリ構造に抽象度という意味づけを行うことができることにあると説明しました。一方難点として同一の責務を持つファイルが散らばってしまうという点が挙げられます。
コードを利用する側としては、関連するコードが一箇所にまとまっていることはコードの参照性が高まるので利点でしかありませんが、一方で、定義された抽象を実装するレイヤーではどうでしょうか?
実装レイヤーでは同一の責務を持つ実装を並列に配置する
自分は当初、実装を行うレイヤーでも、ディレクトリ構成に意味を持たせ、抽象が定義されたレイヤーのディレクトリ構成と似たディレクトリ構成でファイルを配置しようと考えていました。
しかしながら、よく考えてみると、ディレクトリ構成が意味を持つのは、抽象を定義したモジュールにとってのみであり、抽象を実装するレイヤーからしてみれば、前述のモジュールの詳細は秘匿化されておりそこまで重要な意味を持ちません。
抽象を実装するレイヤーでは、むしろ、対称性を持つように並列にファイルを配置することで、同一責務のファイルの参照性を上げることができます。
難点を上げるとすれば、このようにファイルを配置をすると、依存性の注入を行う際に、ディレクトリ分割による意味のサポートを受けることはできません。しかし、うまく設計されたアプリケーションであれば、依存性の注入を行う部分のコードを参照する頻度は、他の部分のコードに比べかなり少ないはずです。
弊社の例では、実装レイヤーで数百ファイル程度が並列に並んでいますが特に不都合は起きていません。
まとめ
本記事のテーマである、抽象とその実装の配置に関する結論としては以下です。
- 抽象の定義はコロケーションパターンで関連コードの近くに配置する
- その実装は、同一の責務を持つファイル群で並列に配置する
上記のようにファイルを配置することで、
参照性を損なわずに依存性逆転の原則を適用することができました。
また、補足ですが、
アーキテクチャを考える際には、システムの依存関係や機能要件だけでなく、
- 開発の際に各コンポーネントを参照する頻度
- 特定のコンポーネントを参照するタイミング
- 同時に参照する可能性が高いファイル
- エンジニアのオンボーディングにかけられるコスト
なども考慮に入れるとより良い設計に近づいていくかと思います。
まあ、正直どんな設計だろうと気合か資金力があればなんとでもなりますけどね。
参考になれば幸いです。
Discussion