はじめに
システム開発をしていて、こんな経験はないでしょうか。
- ちょっとした修正が、まったく想定していなかった箇所に波及してバグを生んでしまう
- 「簡単な機能追加のはず」なのに、影響範囲が広くて大量のコードを直すことになる
- 依存関係が複雑に絡み合っていて、コードを理解するだけで膨大な時間がかかる
- 既存コードが再利用しにくく、似たようなコードがあちこちに量産されている
- クラス同士の結びつきが強すぎて、単体テストが書けないコードが山ほどある
どれも、開発を続けるうえで「良い状態」とは言えません。こうした状況を改善する手がかりになるのが、この記事のテーマである SOLID原則 です。
SOLID原則は、オブジェクト指向で「変更しやすく・理解しやすく・再利用しやすい」コードを設計するための5つの指針をまとめたものです。この記事では、SOLIDの全体像から、5つの原則それぞれについて「概念 → 違反するとどうなるか → どう直すか」までを、TypeScriptのコード付きで1本にまとめました。
なぜ、AIがコードを書く時代にあえて設計原則なのか
正直なところ、この記事を書きながら「AIがコードを書いてくれる今、設計原則を学ぶ意味はあるのか?」と自問しました。実装をAIに任せれば、SOLIDなんて知らなくても動くものは作れてしまいます。それでもあえて書き残したいと思ったのは、AIを使うほどこの手の「土台」が効いてくると感じるようになったからです。
理由は3つあります。
- AIの出力を評価するには、良し悪しの基準が要る:AIは指示すれば大量のコードを一瞬で書いてくれますが、「そのコードが変更に強いか」「責務が混ざっていないか」を判断するのは、結局こちら側です。SOLIDは、その判断に使える共通の物差しになります。
- 良い指示は、良い設計語彙から生まれる:「この処理はアクターごとに分けて」「リポジトリはインターフェース越しに差し替えられるように」と指示できるかどうかで、返ってくるコードの質は変わります。原則を知っていることが、そのままプロンプトの解像度になります。
- 生成されたコードを保守するのは人間:AIが書いたコードでも、後から機能追加やレビューをするのは自分たちです。散らかった設計は、AI相棒との共同作業でも同じように足を引っ張ります。
もちろん、これは「AIに頼るな」という話ではありません。むしろ逆で、AIを頼りにするからこそ、こちらは「何が良い設計か」を語れるようになっておきたい、というのが今回の動機です。実装を速く出せることが当たり前になった分、設計を判断できることの価値が相対的に上がっている、と個人的には感じています。
ここは筆者の一つの見方です。「AI時代でも基礎は大事」という主張には異論もあり得ますし、プロジェクトの性質によって重みは変わります。あくまで「なぜ自分がこれを書こうと思ったか」の共有として読んでいただければと思います。
この記事で分かること
- SOLID原則が何のためにあり、どんな成り立ちなのかが分かる
- 5つの原則(SRP・OCP・LSP・ISP・DIP)を、たとえと図でイメージできる
- 各原則に「違反したコード」と「直したコード」の違いが、TypeScriptで具体的に分かる
- リスコフの置換原則を「契約による設計」の観点からロジカルに判断できるようになる
- 依存性逆転の原則と、DI(依存性注入)・DIコンテナの関係が整理できる
第0章:SOLID原則とは
SOLID原則は、ソフトウェアを組み立てるときに従うべきガイドラインです。オブジェクト指向プログラミングにおいて、変更に強く・読みやすく・使い回しやすいモジュールを設計するための5つの原則を、頭文字でまとめたものです。
成り立ちを整理しておくと、5つの原則そのものは Robert C. Martin(通称 "Uncle Bob")が2000年の論文『Design Principles and Design Patterns』で提唱したものが土台になっています。そして5つの頭文字が "SOLID" という覚えやすい単語になることに気づき、この呼び名を広めたのが Michael Feathers(2004年頃)です。もともとはオープンクローズドの原則が Bertrand Meyer、リスコフの置換原則が Barbara Liskov に由来するなど、複数の系譜が合流している点も、名前の由来を知るうえで面白いところです。
比較的古くからある原則ですが、20年以上たった今でもオブジェクト指向設計の重要な指針として広く参照されています。
SOLIDは、次の5つの原則の頭文字です。
| 頭文字 | 原則名(英語) | 日本語 | ひとことで言うと |
|---|---|---|---|
| S | Single Responsibility Principle | 単一責任の原則 | クラスは1つのアクターにだけ責務を負う |
| O | Open–Closed Principle | オープンクローズドの原則 | 拡張に開き、修正に閉じる |
| L | Liskov Substitution Principle | リスコフの置換原則 | サブタイプはスーパータイプと置換可能である |
| I | Interface Segregation Principle | インターフェース分離の原則 | 使わないメソッドへの依存を強制しない |
| D | Dependency Inversion Principle | 依存性逆転の原則 | 上位・下位のどちらも抽象に依存する |
この記事のコードはすべて TypeScript を前提にしています。考え方自体は言語に依存しないので、Java・C#・Python などでも同じように応用できます。
この記事の進め方も、各原則について「スライド的な概念解説 → 違反例 → 何が問題か → 解決策(コード)」という流れで統一しています。それでは、Sの単一責任の原則から始めましょう。
第1章:単一責任の原則(SRP)
概念:責務を負う相手を1つにする
単一責任の原則をひとことで言うと、「クラスはたった1つのアクターに対して責務を負うべき」という原則です。
名前の印象から「クラスは1つのことだけをやるべき」と誤解されがちですが、そうではありません。責務を負う対象(アクター)を1つにする、という意味です。
ここでのアクターとは、そのクラスを使うユーザーやステークホルダーのこと。つまり単一責任の原則は、「アクターが異なるなら、アクターごとにクラスを分けるべき」と言っています。
違反例:1つの Employee を複数部門が使う
従業員を表す Employee クラスに、次の3つが同居しているとします。
-
calculatePay()… 給与計算(アクター:経理部門) -
reportHours()… 労働時間のレポート(アクター:人事部門) -
save()… DBへの保存(アクター:DB担当エンジニア)
さらに、給与計算とレポートで所定労働時間を求めるロジックが同じだったので、getRegularHours() として共通化しています。
class Employee {
constructor(
public name: string,
public department: string,
) {}
// 経理部門(アクター)向け
calculatePay(): void {
this.getRegularHours();
console.log(`${this.name}の給与を計算しました`);
}
// 人事部門(アクター)向け
reportHours(): void {
this.getRegularHours();
console.log(`${this.name}の労働時間をレポートしました`);
}
// DB担当エンジニア(アクター)向け
save(): void {
console.log(`${this.name}を保存しました`);
}
// 3アクターで共通化してしまった計算ロジック
private getRegularHours(): number {
return 40; // 所定労働時間の計算
}
}
最初は問題なく動いていました。ところがある日、会社の規程が変わり、経理部門から「給与計算の所定労働時間の求め方を変えてほしい」という依頼が来ます。そこで getRegularHours() のロジックを変更し、テストして経理部門にも確認してもらった上でリリースしました。
しかし後日、人事部門から「レポートの結果が間違っている」と報告が入ります。人事部門は所定労働時間の計算を変える必要がなかったのに、経理部門のための変更が人事部門にも波及してしまったのが原因でした。
何が問題なのか
このように単一責任の原則に違反すると、あるアクターのための変更が、別のアクターに思わぬ影響を及ぼしてバグを生む可能性が高まります。
さらに、そうしたバグを防ごうとすると次のようなコストが積み上がります。
- 変更前に「どのアクターに影響するか」を調べる工数(アクターが増えるほどコードは複雑化し、調査時間も伸びる)
- 変更後に、直接関係のないアクターの分まで再テストする工数
- アクターごとに担当エンジニアが分かれている場合、共有部分の同時変更によるコンフリクト
Gitのようなツールを使えばコンフリクトは比較的安全に解消できますが、そもそも起こさないに越したことはありません。
解決策:アクターごとにクラスを分割する
解決策はシンプルで、アクターごとにクラスを分けます。データだけを EmployeeData に切り出し、残りのメソッドをアクター別のクラスに分けます。
上の図のように、経理部門に責務を負う PayCalculator と、人事部門に責務を負う HrReporter に分けます。それぞれが独立した getRegularHours() を持つので、片方の変更がもう片方に影響することはありません。
// データだけを持つクラス
class EmployeeData {
constructor(
public name: string,
public department: string,
) {}
}
// 経理部門に責務を負う
class PayCalculator {
private getRegularHours(): number {
return 40; // 給与計算用の所定労働時間ロジック
}
calculatePay(emp: EmployeeData): void {
this.getRegularHours();
console.log(`${emp.name}の給与を計算しました`);
}
}
// 人事部門に責務を負う
class HrReporter {
private getRegularHours(): number {
return 40; // レポート用の労働時間ロジック
}
reportHours(emp: EmployeeData): void {
this.getRegularHours();
console.log(`${emp.name}の労働時間をレポートしました`);
}
}
こうしておけば、経理部門から仕様変更が来ても、PayCalculator だけを直せば済みます。人事部門の HrReporter は無傷なので、安心して依頼を受けられます。
補足:DRY原則との関係
単一責任の話から少し脱線しますが、設計を学ぶと必ず出てくる DRY原則(Don't Repeat Yourself)にも触れておきます。「コードの重複を避けろ」という有名な原則です。
ここで大事なのは、DRYは「見た目が同じコードを何でもかんでも共通化しろ」とは言っていない、という点です。DRYにすべきは、そのコードが表している概念や知識の単位です。
似たようなロジックであっても、背後の概念が違えば、それは「たまたま同じ規則性だった」だけかもしれません。概念を無視して見た目だけで共通化すると、コード同士の結びつきが強くなり、かえって単一責任の原則に違反してしまいます。
先ほどの getRegularHours() も、最初は同じ概念に見えて共通化していたかもしれません。ですが仕様変更のタイミングで「経理用」と「人事用」は別の概念だと捉え直し、分割すればよいのです。最初から完璧なクラスを設計するのは難しいもの。ビジネス理解が深まった時点で分割できれば、設計をより良く育てられます。
第2章:オープンクローズドの原則(OCP)
概念:拡張に開き、修正に閉じる
オープンクローズドの原則は、「ソフトウェアの構成要素は、拡張に対して開かれ、修正に対して閉じていなければならない」という原則です。少し抽象的なので、分解して考えます。
- 拡張に対して開かれている:新しいコードを追加することで機能を拡張できる
- 修正に対して閉じている:拡張しても既存のコードは変更されない
つまり2つを合わせると、「ソフトウェアの振る舞いは、既存の成果物を変更することなく拡張できるようにすべき」という原則になります。
適用すべきなのは、種別によって振る舞いを変えたいケースです。たとえば会員ランク(ブロンズ・シルバー・ゴールドで付与ポイントが違う)や、データの保存先(RDB・NoSQL・CSVで保存処理が違う)などが典型です。こうしたものに種別の追加・削除があっても、既存コードを変えずに拡張できるようにするのが狙いです。
違反例:グレードで if 分岐するボーナス計算
社員のグレード(junior / middle / senior)によってボーナス額が変わる BonusCalculator を考えます。素直に書くと、こうなりがちです。
type Grade = "junior" | "middle" | "senior";
class Employee {
constructor(
public name: string,
public grade: Grade,
) {}
}
class BonusCalculator {
getBonus(emp: Employee, base: number): number {
if (emp.grade === "junior") {
return Math.floor(base * 1.1);
} else if (emp.grade === "middle") {
return Math.floor(base * 1.5);
} else {
// senior
return Math.floor(base * 2);
}
}
}
ここに新しく expert グレードを追加したくなったとします。一見、分岐を1つ足すだけで済みそうです。
} else if (emp.grade === "senior") {
return Math.floor(base * 2);
} else {
// expert を追加
return Math.floor(base * 3);
}
何が問題なのか
しかし getBonus() は、すでにシステム上で安定して動いているコードです。分岐を1つ足すという軽微な作業でも、人が手を入れる以上、条件の書き間違いなどのミスの可能性をゼロにはできません。修正に対して閉じておらず、オープンクローズドの原則に違反した状態です。
- 既存コードに手を入れることで、新たなバグを生むリスクが高まる
- バグを防ぐために、既存コードの再テスト工数がかかる(機能が増えるほど比例して増える)
動いているコードにはできるだけ手を加えず、追加だけで拡張できるほうが良い設計と言えます。
解決策:抽象化してポリモーフィズムで拡張する
拡張される可能性があるもの(=社員のグレード)を抽象化します。Employee をインターフェースにし、グレード別のクラスがそれを実装するようにします。
各グレードのクラスが Employee インターフェースを実装し、それぞれ独自の getBonus() を持ちます。
interface Employee {
name: string;
getBonus(base: number): number;
}
class JuniorEmployee implements Employee {
constructor(public name: string) {}
getBonus(base: number): number {
return Math.floor(base * 1.1);
}
}
class MiddleEmployee implements Employee {
constructor(public name: string) {}
getBonus(base: number): number {
return Math.floor(base * 1.5);
}
}
class SeniorEmployee implements Employee {
constructor(public name: string) {}
getBonus(base: number): number {
return Math.floor(base * 2);
}
}
利用側は、インターフェースの定義どおり getBonus() を呼ぶだけです。相手が Junior でも Middle でも Senior でも、同じ呼び方で正しく動きます。このように「同じインターフェースを操作しても、実装クラスによって振る舞いが変わる」仕組みが、オブジェクト指向の三大要素の1つ ポリモーフィズム です。
新しいグレードを追加したいときは、クラスを足してインターフェースを実装するだけで済みます。
// 追加は「新しいクラスを足すだけ」。既存クラスは無修正
class ExpertEmployee implements Employee {
constructor(public name: string) {}
getBonus(base: number): number {
return Math.floor(base * 3);
}
}
このとき、既存の Junior / Middle / Senior のクラスは何も修正していません。つまりバグ混入の余地がなく、安全かつ効率的に機能拡張できます。
第3章:リスコフの置換原則(LSP)
概念:サブタイプはスーパータイプと置換可能である
リスコフの置換原則は、継承に関する指針です。「サブタイプはそのスーパータイプと置換可能でなければならない」というもの。ここでスーパータイプは継承元のクラス、サブタイプはそれを継承したクラスを指します。
理解するには違反例を見るのが早いので、まず「正しくない継承」から見ていきます。
違反例:Rectangle を継承した Square
長方形 Rectangle を継承して、正方形 Square を作ってみます。「正方形は長方形の一種」なので、一見この継承は正しそうです。
class Rectangle {
protected width = 0;
protected height = 0;
setWidth(width: number): void {
this.width = width;
}
setHeight(height: number): void {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
class Square extends Rectangle {
// 正方形は幅と高さが常に等しい、というつもりでオーバーライド
setWidth(width: number): void {
this.width = width;
this.height = width;
}
setHeight(height: number): void {
this.width = height;
this.height = height;
}
}
これらを使う関数 f を用意します。幅と高さをセットし、面積を返すだけの関数です。
function f(shape: Rectangle, width: number, height: number): number {
shape.setWidth(width);
shape.setHeight(height);
return shape.getArea();
}
Square は Rectangle を継承しているので、f の引数として渡せます。ここで問題です。f(new Square(), 3, 4) の戻り値はいくつでしょうか。
答えは 16 です。setWidth(3) の時点で幅も高さも3。次の setHeight(4) で幅も高さも4に上書きされ、最後の getArea() は 4 × 4 = 16 を返します。
何が問題なのか
f を読んだ人は、「幅をセットして、高さをセットして、幅 × 高さを計算する」と想定するはずです。長方形なら 3 × 4 = 12。ところが Square を渡すと 16 になり、想定と食い違ってバグが生まれます。
構文エラーは出ません。しかしスーパータイプの Rectangle の代わりにサブタイプの Square を使うと、振る舞いが変わってしまう。これが「置換可能ではない」状態であり、リスコフの置換原則に違反しています。
原因は、継承関係そのものが間違っていることです。オブジェクト指向では継承は一般に「is-a(〜は〜である)」の関係と言われます。「正方形は長方形である」は成り立つように感じますが、コンパイルは通っても、振る舞いが意図と異なるなら正しい継承とは言えません。正しい継承は「is-a を満たす」だけでなく「振る舞いまで同じ」である必要があります。
この違反は連鎖も招きます。利用側で「もし Square だったら…」と型判定を入れて対処すると、今度はオープンクローズドの原則にも違反してしまいます。
置換可能かどうかは単体テストで確認できる
振る舞いが変わっていないかを確かめる最も簡単な方法は、単体テストを書くことです(ここではテストライブラリに Jest を使う想定です)。
test("Rectangle: 3 × 4 = 12", () => {
expect(f(new Rectangle(), 3, 4)).toBe(12);
});
test("Square も Rectangle として 12 を期待する", () => {
// f の中では Rectangle として扱われるので、幅 × 高さ = 12 を期待する
expect(f(new Square(), 3, 4)).toBe(12); // 実際は 16 になり失敗する
});
Square のテストだけが失敗します。これで「Rectangle と Square は振る舞いが違い、置換できない」とはっきり分かります。
解決策:継承関係を見直す
Rectangle と Square は、見た目こそ継承関係のようでも、振る舞いが異なるので継承すべきではありませんでした。そこで両者の継承をやめ、もう1段抽象度の高い Shape インターフェースを用意して、それぞれが Shape を実装するようにします。
Square は幅・高さではなく、1辺の長さ length だけを持つように直します。
interface Shape {
getArea(): number;
}
class Rectangle implements Shape {
private width = 0;
private height = 0;
setWidth(width: number): void {
this.width = width;
}
setHeight(height: number): void {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
class Square implements Shape {
private length = 0;
setLength(length: number): void {
this.length = length;
}
getArea(): number {
return this.length * this.length;
}
}
function printArea(shape: Shape): void {
console.log(shape.getArea());
}
これで、Rectangle は 3 × 4 = 12、Square は 3 × 3 = 9 と、それぞれ想定どおりの面積を返します。誤った継承による予期せぬバグの可能性を減らせました。
発展:契約による設計から見るリスコフの置換原則
少し発展的な内容として、リスコフの置換原則は「契約による設計(Design by Contract)」とあわせて語られることがあります。難しく感じる方は「そういう考え方がある」程度に読み流してください。
契約による設計は Bertrand Meyer が提唱した設計技法で、プログラムが満たすべき仕様をコード中に記述することで、正確で頑健なソフトウェアにする方法です。ここでの「契約」は、あるメソッドを使う側と使われる側の合意を指します。
- 事前条件(precondition):メソッド開始時に保証されるべき条件(引数や開始時のインスタンスの状態など)
- 事後条件(postcondition):メソッドが正常終了したときに保証される条件(戻り値や終了時の状態)
Meyer は、サブタイプの事前条件・事後条件について次のように述べています。
| ルール | 意味 | |
|---|---|---|
| 事前条件 | スーパータイプと同じか、それより弱く | サブタイプは、スーパータイプが許可することをすべて許可する必要がある |
| 事後条件 | スーパータイプと同じか、それより強く | サブタイプは、スーパータイプの事後条件をすべて含む必要がある |
たとえば数値 x を取るメソッドで、スーパータイプの事前条件が「x > 0」なのに、サブタイプが「x > 10」だと、x が1〜10の範囲でサブタイプは受け付けません。取りうる範囲が狭まった=より強い事前条件になっており、これは正しい継承ではなく違反です。
先ほどの setWidth の例で言えば、スーパータイプの事後条件は「幅が引数と等しい かつ 高さは変更されない」でした。Square では「高さは変更されない」がなくなり、事後条件が弱くなっています。これも違反というわけです。
契約の事前条件・事後条件で考えると、「なんとなく違う気がする」という感覚ではなく、ロジカルに継承関係の正しさを判断できるようになります。
第4章:インターフェース分離の原則(ISP)
概念:使わないメソッドへの依存を強制しない
インターフェース分離の原則は、「インターフェースのクライアントに対して、利用しないフィールドやメソッドへの依存を強制してはならない」という原則です。
インターフェースや抽象クラスを使うと、未実装のメソッドがあるとエラーになります。これは「サブクラスに実装を強制できる」というメリットである一方、「関係のないメソッドまで実装させられる」というデメリットにもなります。そこでこの原則は、インターフェースを適切に分割して、不要なメソッドへの依存が生じないようにすべきだと言っています。
違反例:飛べない Car に fly() を強制する
乗り物を表す Vehicle インターフェースに、start() / stop() / fly() が定義されているとします。これを Airplane と Car が実装します。
interface Vehicle {
name: string;
color: string;
start(): void;
stop(): void;
fly(): void;
}
class Airplane implements Vehicle {
constructor(public name: string, public color: string) {}
start(): void { /* ... */ }
stop(): void { /* ... */ }
fly(): void {
console.log("飛行します");
}
}
class Car implements Vehicle {
constructor(public name: string, public color: string) {}
start(): void { /* ... */ }
stop(): void { /* ... */ }
// 車は空を飛ばないのに、実装を強制される
fly(): void {
throw new Error("車は空を飛べません");
}
}
車は空を飛べないので、fly() は呼ばれたらエラーを投げる、という苦しい実装になっています。Car は fly() を使わないのに実装を強制されており、不要な依存関係が生まれています。
何が問題なのか
- インターフェースに変更があったとき、実装側で使っていないメソッドであっても、実装側を直さなければならなくなる。使っていない部分の変更で修正が発生し、そこで新たなバグを生んでは目も当てられません
- 不要なメソッドをエラーで実装することは、リスコフの置換原則にも違反します(
Vehicle型として扱うとfly()が使えると誤解され、実際には例外が飛ぶため) - インターフェースが多くのメソッドを含み、複数のアクターに使われると、単一責任の原則にも違反しかねません
実際、v1: Vehicle(Airplane)と v2: Vehicle(Car)を並べると、利用者は「どちらも Vehicle 型だから fly() を呼べる」と考えます。ところが v2.fly() を呼ぶと「車は空を飛べません」というエラーが発生してしまいます。
解決策:役割ごとにインターフェースを分割する
原則の名前どおり、インターフェースを役割ごとに分割します。TypeScript では多重継承はできませんが、多重実装(複数インターフェースの実装)はできるので、これを活かします。
-
Vehicle… 名前・色などのフィールド -
Movable… 動くことに関するメソッド(start/stop) -
Flyable… 飛ぶことに関するメソッド(fly)
そして各クラスは、必要なインターフェースだけを実装します。
interface Vehicle {
name: string;
color: string;
}
interface Movable {
start(): void;
stop(): void;
}
interface Flyable {
fly(): void;
}
class Airplane implements Vehicle, Movable, Flyable {
constructor(public name: string, public color: string) {}
start(): void { /* ... */ }
stop(): void { /* ... */ }
fly(): void {
console.log("飛行します");
}
}
class Car implements Vehicle, Movable {
constructor(public name: string, public color: string) {}
start(): void { /* ... */ }
stop(): void { /* ... */ }
// fly を実装する必要がなくなった
}
これで Car は fly() を実装しなくてよくなり、fly() の定義が変わっても Car を直す必要はなくなりました。不要な箇所に影響を与えずにインターフェースを変更できます。
どの粒度で分割するかは設計判断です。上の例では Vehicle と Movable を分けていますが、どちらも同じクラス群から実装されるので、1つにまとめても問題ありません。実際には今後の機能拡張の可能性などを踏まえて、分割の粒度を決めてください。細かく分けすぎると、今度は小さなインターフェースが乱立して逆に複雑になることもあります。
第5章:依存性逆転の原則(DIP)
概念:上位も下位も抽象に依存する
依存性逆転の原則は、2つの内容から構成されています。
- 上位のモジュールは下位のモジュールに依存してはならない。どちらも抽象に依存すべきである
- 抽象は実装の詳細に依存してはならない。実装の詳細が抽象に依存すべきである
モジュールとは、ソフトウェアの1まとまりの機能のことで、クラスもその一種です。あるモジュールが別のモジュールを使うとき、「使う側」が上位モジュール、「使われる側」が下位モジュールです。
普通に実装すると、上位モジュールが下位モジュールを直接使う形になります。依存性逆転の原則では、その間に抽象(インターフェース)を挟みます。すると、上位モジュールは抽象に依存し、下位モジュールも抽象を実装する形になります。結果として、下位モジュールから抽象へと依存の矢印が逆向きになる。これが「依存性逆転」と呼ばれる理由です。
違反例:Controller → Service → Repository の直接依存
Webアプリのバックエンドを模した例です。UserController が UserService を呼び、UserService が UserRdbRepository を呼び、DBからユーザー情報を取り出します。
class UserRdbRepository {
create(user: User): void { /* RDB に保存 */ }
findById(id: string): User { /* RDB から検索 */ }
}
class UserService {
private repository = new UserRdbRepository(); // 直接生成 = 強い依存
create(user: User): void {
this.repository.create(user);
}
findById(id: string): User {
return this.repository.findById(id);
}
}
class UserController {
private service = new UserService(); // 直接生成 = 強い依存
create(user: User): void {
this.service.create(user);
}
findById(id: string): User {
return this.service.findById(id);
}
}
UserController は UserService に、UserService は UserRdbRepository に直接依存しています。これは依存性逆転の原則に違反した状態です。
何が問題なのか
- 下位モジュールの変更が上位モジュールに波及する。リポジトリの変更がサービスに、サービスの変更がコントローラーに、と連鎖的に伝播してしまう
- 上位モジュールが下位モジュールに直接依存しているため、下位が完成するまで上位を開発できない
- 拡張性・再利用性が低い。
UserServiceにUserRdbRepositoryが直書きされているので、他のリポジトリ(たとえば NoSQL 用)に差し替えられない - モジュールの切り替えができないため、テスト用のリポジトリにも差し替えられず、単体テストが非常に難しい
解決策:間にインターフェースを挟み、DIで外から渡す
上位と下位の間にインターフェースを挟みます。下位モジュールを変更しても、インターフェースの定義さえ変わらなければ上位に影響しません。また、下位はインターフェースを実装していればよいので、差し替えが簡単になります。
右側の図のように、実装クラスから抽象へ矢印が向く(逆転する)のがポイントです。コードでは、依存するオブジェクトを new で内部生成するのをやめ、コンストラクタで外から受け取るようにします。
interface IUserRepository {
create(user: User): void;
findById(id: string): User;
}
interface IUserService {
create(user: User): void;
findById(id: string): User;
}
class UserRdbRepository implements IUserRepository {
create(user: User): void { /* RDB に保存 */ }
findById(id: string): User { /* RDB から検索 */ }
}
class UserService implements IUserService {
// 抽象(インターフェース)を外部から受け取る
constructor(private repository: IUserRepository) {}
create(user: User): void {
this.repository.create(user);
}
findById(id: string): User {
return this.repository.findById(id);
}
}
class UserController {
constructor(private service: IUserService) {}
create(user: User): void {
this.service.create(user);
}
findById(id: string): User {
return this.service.findById(id);
}
}
// 組み立て(ここで依存関係を注入する)
const repository = new UserRdbRepository();
const service = new UserService(repository);
const controller = new UserController(service);
これで、リポジトリをテスト用に差し替えても UserService や UserController は無修正で済みます。
// テスト用リポジトリに差し替えるだけ。上位モジュールは無修正
class UserTestRepository implements IUserRepository {
create(user: User): void { /* 何もしない */ }
findById(id: string): User {
console.log("テストID: 123 のユーザーを検索");
return {} as User;
}
}
const service = new UserService(new UserTestRepository());
モックを使った単体テストも実施できるようになりました。利用する側・される側の双方が抽象に依存し、外からオブジェクトを渡すことで、拡張性・再利用性が高くテスタブルな設計になります。
補足:DIとDIコンテナ
いま行った「依存するオブジェクトをコンストラクタで外から渡す」やり方が、DI(Dependency Injection:依存性注入) です。名前は難しそうですが、要は「コンストラクタやセッターでフィールドの値をセットするのと同じ感覚で、オブジェクトを外から渡すだけ」のものです。
DIのメリットは次のとおりです。
- クラス間の結びつきが弱まり、変更に強くなる
- インターフェースさえ定義すれば、実装の詳細がなくても開発を進められる
- 本番用・テスト用などのクラス切り替えが容易になり、テスタブルになる
一方で、生成方法が複雑なインスタンスの場合、外から渡すためのインスタンス生成が大変になる、というデメリットもあります。たとえば Sample を作るのに Hoge、Hoge を作るのに Foo と Bar… と芋づる式に事前生成が必要になると、毎回この手順を踏むのは骨が折れます。
これを楽にする仕組みが DIコンテナ です。インスタンス化したいクラスとその生成方法をコンテナに登録しておき、利用者は自分で new せずコンテナからインスタンスをもらう、という形にします。登録を設定ファイルで行えば、コードを直さずにクラスの切り替えができる、という利点もあります。
TypeScript の標準機能だけでは DIコンテナは使えないため、外部ライブラリが必要です。現時点でよく使われる主要なライブラリには InversifyJS や tsyringe(Microsoft が公開)などがあります(このほか TypeDI や awilix などもあります)。ライブラリの流行や仕様はバージョンで変わり得るので、導入前に公式ドキュメントで最新を確認してください。デコレータや reflect-metadata の設定が必要になる場合もあります。
まとめ:SOLID チェックリスト
最後に、各原則を「自分のコードは守れているか」の観点でチェックリストにまとめます。コピーして設計レビューに使ってみてください。
- S(単一責任):このクラスが責務を負うアクターは1つに絞れているか。異なるアクターのロジックが同居していないか
- O(オープンクローズド):種別の追加を、既存コードの修正ではなく「クラスの追加」で実現できる形になっているか
- L(リスコフの置換):サブタイプをスーパータイプとして使っても、振る舞いが変わらないか(is-a だけでなく振る舞いまで一致しているか)
- I(インターフェース分離):実装クラスが、使わないメソッドの実装を強制されていないか。インターフェースは役割ごとに分けられているか
- D(依存性逆転):上位・下位が互いに直接依存せず、抽象(インターフェース)に依存しているか。依存オブジェクトはDIで外から渡しているか
SOLID原則は、最初から完璧に満たそうとするものではありません。ビジネスや概念への理解が深まった時点で少しずつ設計を見直し、育てていくものです。まずは自分のコードを1つ選んで、上のチェックリストを当ててみるところから始めてみてください。