いまさらながらSOLID原則に入門
はじめに
「変更に強く、理解しやすい設計」を実現するための指針として、SOLID原則は頻出します。
私自身もよく目にするものの、なぜ必要なのか、どのような場合にどうやって使うのかしっかり理解しているわけではなかったのでAIの力を借りて以下のような観点でまとめてみます。
SOLID原則の理解の一助になれば幸いです。
- 概要
- なぜその原則が必要なのか
- どのような場合に使用されるか
- 原則に反しているコード例
- 原則が適用されたコード例
なお、コード例についてはTypeScriptで記載しています。
S — Single Responsibility Principle (単一責任の原則)
概要
- 定義:あるクラス(またはモジュール/関数)は “一つの責務(responsibility)” のみを持つべきで、その責務に変更理由が一つだけであること。
- 責務 = 変更の理由 と捉えます。複数の理由で変更される可能性があるなら、責務が混ざっている可能性が高い。
なぜその原則が必要なのか
- 可読性の向上:各クラスがやることが明確になり、コードの意図が読みやすくなる。
- 保守性の向上:ある責務に関する変更が、別の責務に影響を及ぼしにくくなる(変更の局所化)。
- 再利用性:責務が分かれていると、個々の部品を別の文脈で再利用しやすい。
- テストしやすさ:小さく責務が限定されたコンポーネントはユニットテストが書きやすい。
- 結合度の低下:不必要な依存や副作用が減るため、安全にリファクタ可能。
どのような場合に使用されるか(適用タイミング)
- クラスやモジュールが複数の役割(例:ビジネスロジック + データベースアクセス + ロギング)を持っていると気付いたとき。
- メソッドが増えてきて「〜を変更する理由が複数あるな」と感じたとき。
- 単体テストが書きづらい、またはテストが複雑になっているとき。
- 新機能追加で既存のクラスを壊しがち・バグが出やすいとき。
(※“過剰分割”にも注意:細分化し過ぎて理解コストが上がるケースもある。実用的バランスが大事。)
原則に反しているコード例(アンチパターン)
以下は TypeScript の簡単な例。ReportManager
が データ取得、フォーマット、ファイル保存、ロギング を全部やっている — 責務が混在している。
// アンチパターン:単一責任を無視したクラス
class ReportManager {
constructor(private db: any, private logger: any) {}
async generateAndSaveReport(userId: string) {
// 1) データ取得(DBに問い合わせ)
const rows = await this.db.query(`SELECT * FROM sales WHERE user_id = $1`, [userId]);
// 2) レポート生成(フォーマット)
const csv = "date,amount\n" + rows.map((r: any) => `${r.date},${r.amount}`).join("\n");
// 3) ファイル保存(I/O)
const filename = `/reports/report_${userId}.csv`;
require('fs').writeFileSync(filename, csv);
// 4) ロギング
this.logger.info(`Report saved: ${filename}`);
return filename;
}
}
問題点:
- データアクセスが変わる(SQL → NoSQL)と
ReportManager
を変更する必要がある。 - フォーマット(CSV → JSON)でまた変更、保存先を変えるならさらに変更……変更理由が複数。
- テストで DB や I/O をモックする必要がありテストが重くなる。
原則が適用されたコード例(リファクタ後)
責務ごとにクラス(または関数)を分ける。各コンポーネントは一つの責務だけ持つ。
// ① データ取得責務(Repository)
class SalesRepository {
constructor(private db: any) {}
async getSalesByUser(userId: string) {
return this.db.query(`SELECT * FROM sales WHERE user_id = $1`, [userId]);
}
}
// ② フォーマット責務(Formatter)
class CsvFormatter {
format(rows: Array<{date: string, amount: number}>) {
return "date,amount\n" + rows.map(r => `${r.date},${r.amount}`).join("\n");
}
}
// ③ 保存責務(Storage)
class FileStorage {
save(path: string, content: string) {
const fs = require('fs');
fs.writeFileSync(path, content);
return path;
}
}
// ④ ロギング責務(Logger) — 既存のロガーをそのまま利用できる
// ⑤ Orchestrator:各責務を協調するが、自身は「調整(orchestration)」だけを行う
class ReportService {
constructor(
private repo: SalesRepository,
private formatter: CsvFormatter,
private storage: FileStorage,
private logger: any
) {}
async generateAndSave(userId: string) {
const rows = await this.repo.getSalesByUser(userId);
const csv = this.formatter.format(rows);
const filename = `/reports/report_${userId}.csv`;
this.storage.save(filename, csv);
this.logger.info(`Report saved: ${filename}`);
return filename;
}
}
利点:
-
SalesRepository
の実装を差し替え(例:SQL→API)してもReportService
は変わらない。 -
CsvFormatter
をJsonFormatter
に置換しても保存・取得ロジックは変えない。 - 各コンポーネントを容易にユニットテスト可能(DBやFSはモックできる)。
- 再利用性向上(
FileStorage
はレポート以外でも使える)。
O — Open–Closed Principle(開放・閉鎖の原則)
概要
-
定義:
ソフトウェアのクラスやモジュールは、新しい振る舞いを追加するときは“拡張”で対応でき、既存コードを“変更”しなくても済むように設計すべき、という原則。 - 一言で言えば:
「新しい機能を追加しても、既存コードを壊さない」。
なぜその原則が必要なのか
-
バグのリスクを減らす:
安定して動いている既存クラスを変更すると、新たなバグを生みやすい。
拡張で対応すれば既存の安定部分を壊さずに済む。 -
保守性が高まる:
変更が他のモジュールに波及しにくく、安心して新機能を追加できる。 -
拡張性・柔軟性が高まる:
新しい要件に対応しやすくなる(例:新しいタイプの処理を追加したい場合など)。 -
ポリモーフィズム(多態性)と相性が良い:
インターフェースや抽象クラスを利用し、振る舞いを差し替えやすくすることで達成できる。
どのような場合に使用されるか
- 新しい種類の処理(例:新しい支払い方法、新しいファイル形式など)を追加したいとき。
- 既存コードに
if
やswitch
で「型や種別」を判定している箇所が増えてきたとき。 - 「このクラスを編集しないと新しい機能を追加できない」と感じたとき。
原則に反しているコード例(アンチパターン)
「支払い処理」を行うクラスが、if
文で支払いタイプごとに条件分岐している例。
→ 新しい支払い方法を追加するたびに processPayment
を編集しなければならない。
// アンチパターン:Open–Closedに反している
class PaymentProcessor {
processPayment(type: string, amount: number) {
if (type === "credit") {
console.log(`Processing credit card payment: $${amount}`);
} else if (type === "paypal") {
console.log(`Processing PayPal payment: $${amount}`);
} else {
throw new Error("Unknown payment type");
}
}
}
// 使用例
const processor = new PaymentProcessor();
processor.processPayment("credit", 100);
processor.processPayment("paypal", 200);
問題点:
- 新しい支払い方法(例:
applepay
)を追加するたびにPaymentProcessor
を変更する必要がある。 -
processPayment
のテストが肥大化する。 - OCP 違反:「変更に閉じていない」。
原則が適用されたコード例(リファクタ後)
新しい支払い方法を追加しても、既存コードを触らなくて済むようにする。
→ ポリモーフィズム(継承 or インターフェース)で拡張可能な設計に。
// ① 支払い共通インターフェース
interface PaymentMethod {
pay(amount: number): void;
}
// ② 具象クラス(それぞれが拡張ポイント)
class CreditCardPayment implements PaymentMethod {
pay(amount: number): void {
console.log(`Processing credit card payment: $${amount}`);
}
}
class PayPalPayment implements PaymentMethod {
pay(amount: number): void {
console.log(`Processing PayPal payment: $${amount}`);
}
}
// ③ 新しい支払い方法を追加しても OK
class ApplePayPayment implements PaymentMethod {
pay(amount: number): void {
console.log(`Processing Apple Pay payment: $${amount}`);
}
}
// ④ 支払い処理クラスは、抽象型に依存する
class PaymentProcessor {
constructor(private method: PaymentMethod) {}
process(amount: number): void {
this.method.pay(amount);
}
}
// 使用例
const processor1 = new PaymentProcessor(new CreditCardPayment());
processor1.process(100);
const processor2 = new PaymentProcessor(new PayPalPayment());
processor2.process(200);
const processor3 = new PaymentProcessor(new ApplePayPayment());
processor3.process(300);
利点:
-
PaymentProcessor
は 変更せずに新しい支払い方法を追加できる。 - つまり、OCP の「拡張には開いている」「変更には閉じている」を達成。
- 既存コードの安全性を保ちつつ、柔軟な拡張が可能。
L — Liskov Substitution Principle(リスコフの置換原則)
概要
-
定義:
「プログラム中のオブジェクトがそのサブタイプに置き換えられても、
プログラムの正しさが保たれなければならない。」 -
つまり、継承関係を使うなら、“is-a” 関係が成立している必要がある。
-
Rectangle
はShape
の一種(is-a) -
Square
はRectangle
の一種ではない(実は LSP に違反しやすい)
-
なぜその原則が必要なのか
-
安全なポリモーフィズムを実現するため:
継承先(サブクラス)を使っても、呼び出し元のコードが破綻しないようにする。 -
予測可能な挙動を保つため:
親クラスとして使っていたコードの契約(Contract)を、
子クラスが裏切らないようにする(“契約に従う”)。 -
保守性と再利用性の向上:
サブクラスを差し替えても正しく動く設計なら、
新しい拡張を安全に導入できる。
どのような場合に使用されるか
- クラス継承を利用しているとき(特に「上位型の代わりに下位型を使う」場面)。
- 継承したクラスでメソッドの仕様(前提条件・出力)を変更してしまうとき。
- 「子クラスを渡したら動作が変わった/壊れた」といった現象が起きたとき。
原則に反しているコード例(アンチパターン)
以下は「長方形(Rectangle)と正方形(Square)」の有名な例。
一見、正方形は長方形の一種に見えるが、実装上は LSP に違反します。
// アンチパターン:LSPに違反する例
class Rectangle {
protected _width: number;
protected _height: number;
constructor(width: number, height: number) {
this._width = width;
this._height = height;
}
set width(value: number) {
this._width = value;
}
set height(value: number) {
this._height = value;
}
get area(): number {
return this._width * this._height;
}
}
// 正方形クラス:見た目上はRectangleのサブクラス
class Square extends Rectangle {
set width(value: number) {
this._width = value;
this._height = value; // 正方形なので両方を同じにする
}
set height(value: number) {
this._width = value;
this._height = value;
}
}
// 使用側コード
function printArea(rect: Rectangle) {
rect.width = 5;
rect.height = 10;
console.log(rect.area); // 期待値: 50
}
printArea(new Rectangle(2, 3)); // ✅ 50
printArea(new Square(2, 3)); // ❌ 実際は 100 になる(LSP違反)
問題点:
-
Square
は「Rectangleとして扱える」ように見えるが、
振る舞い(幅や高さの独立性)を変えてしまっている。 - 呼び出し元(
printArea
)はRectangle
の仕様を信じて動作しているのに、
その前提をSquare
が壊している。 - つまり、置き換えた瞬間に期待通り動かなくなる。
原則が適用されたコード例(リファクタ後)
共通の抽象(Shape)を定義し、それぞれが独自のルールで面積を返すように設計。
→ 「正方形は長方形の一種」ではなく、「どちらも図形(Shape)の一種」として扱う。
// ① 抽象クラスまたはインターフェース
interface Shape {
area(): number;
}
// ② 各クラスは独自に責務を実装
class Rectangle implements Shape {
constructor(private width: number, private height: number) {}
area(): number {
return this.width * this.height;
}
}
class Square implements Shape {
constructor(private size: number) {}
area(): number {
return this.size * this.size;
}
}
// ③ 呼び出し側は共通の抽象に依存(LSPが成立)
function printArea(shape: Shape) {
console.log(shape.area());
}
printArea(new Rectangle(5, 10)); // ✅ 50
printArea(new Square(5)); // ✅ 25
利点:
- 呼び出し側 (
printArea
) は “Shape” という契約 にだけ依存。 - どのサブクラス(Rectangle/Square)を渡しても動作は破綻しない。
- 継承を“実装の共有”ではなく、“契約の共有”として使う。
I — Interface Segregation Principle(インターフェース分離の原則)
概要
-
定義:
クラスは、自分が必要としないメソッドを持つ大きなインターフェースに依存してはいけない。
→ インターフェースは小さく分割し、特定の目的に特化させるべき。 - 一言でいえば:
「デカいインターフェースを小さく分けよう」
なぜその原則が必要なのか
-
不要な依存を減らすため:
クライアント(利用側)が使わないメソッドまで実装・依存することになると、
修正時の影響範囲が広がってしまう。 -
変更に強くするため:
不要なメソッドを持つインターフェースを変更すると、
関係ない実装クラスまで修正が必要になる。 -
保守性・再利用性の向上:
各インターフェースが目的に沿って分かれていれば、
小さく安全に変更できる。 -
テストが簡単になる:
必要な機能だけモックすれば良い。
どのような場合に使用されるか
- 1つのインターフェースに多数のメソッドが詰め込まれているとき。
- 実装クラスが「このメソッドは必要ないけど空実装してる」などと感じるとき。
- インターフェースを変更すると、関係ないクラスまで再コンパイル・修正が必要になるとき。
原則に反しているコード例(アンチパターン)
以下は 「1つの大きなインターフェース」 にいろいろ詰め込んでしまった例です。
すべてのプリンタが「FAX」や「スキャン」機能を持っているわけではないのに、
それを強制してしまっています。
// アンチパターン:1つの巨大なインターフェース
interface MultiFunctionDevice {
print(document: string): void;
scan(document: string): void;
fax(document: string): void;
}
// 単機能プリンタ
class SimplePrinter implements MultiFunctionDevice {
print(document: string): void {
console.log("Printing:", document);
}
// 不要なのに実装を強制される
scan(document: string): void {
throw new Error("Scan not supported");
}
fax(document: string): void {
throw new Error("Fax not supported");
}
}
問題点:
- SimplePrinter はスキャンもFAXも使わないのに、その責務を「空実装」または「例外」で対応している。
- もし
MultiFunctionDevice
にメソッドを追加したら、すべての実装クラスに影響が出る。 - クライアントコードも「使わない機能」を持つ型に依存してしまう。
原則が適用されたコード例(リファクタ後)
各機能ごとに 小さいインターフェースに分離。
クラスは必要な機能だけを実装すればよいようにします。
// ① 小さなインターフェースに分割
interface Printer {
print(document: string): void;
}
interface Scanner {
scan(document: string): void;
}
interface Fax {
fax(document: string): void;
}
// ② 単機能デバイスは必要な機能だけ実装
class SimplePrinter implements Printer {
print(document: string): void {
console.log("Printing:", document);
}
}
// ③ 多機能デバイスは複数インターフェースを実装
class AllInOnePrinter implements Printer, Scanner, Fax {
print(document: string): void {
console.log("Printing:", document);
}
scan(document: string): void {
console.log("Scanning:", document);
}
fax(document: string): void {
console.log("Faxing:", document);
}
}
利点:
-
SimplePrinter
は 必要な機能だけ依存。 -
AllInOnePrinter
は複数の機能を組み合わせられる。 - インターフェースを変更しても、不要なクラスに影響しない。
- クライアントは自分が必要とする最小の契約だけ知ればよい。
D — Dependency Inversion Principle(依存性逆転の原則)
概要
-
定義:
- 高水準モジュール(ビジネスロジック)は、低水準モジュール(実装の詳細)に依存してはいけない。両者は「抽象(インターフェースや抽象クラス)」に依存すべきである。
-
一言で言えば:
「依存の方向を反転させ、抽象に依存する」
なぜその原則が必要なのか
-
柔軟性の向上:
実装(例:DB、外部API、ログ出力など)を差し替えても上位ロジックを変更せずに済む。 -
保守性の向上:
実装変更の影響が上位層に波及しない。 -
テスト容易性の向上:
実際のDBやAPIの代わりにモックを注入できる。 -
モジュール間の結合度を下げる:
ビジネスロジックが具象クラス(具体実装)に強く依存しない構造を作れる。
どのような場合に使用されるか
- 上位の業務ロジック層が、具体的な実装(DB・HTTP・ファイルI/Oなど)を直接呼んでいるとき。
- 実装を差し替えようとしたら上位コードまで書き換えが必要なとき。
- 単体テストを書く際、依存をモックに差し替えにくいとき。
原則に反しているコード例(アンチパターン)
以下の例では、高水準モジュール(OrderService) が
低水準モジュール(MySQLDatabase) に直接依存しています。
// アンチパターン:依存方向が逆転していない
class MySQLDatabase {
save(order: string) {
console.log(`Saving order "${order}" to MySQL database.`);
}
}
class OrderService {
private db = new MySQLDatabase(); // ← 具体クラスに依存!
createOrder(order: string) {
this.db.save(order);
}
}
// 使用
const service = new OrderService();
service.createOrder("Coffee");
問題点:
-
OrderService
はMySQLDatabase
に強く結合している。 - DBを
PostgreSQL
やInMemoryDB
に変更したくても、OrderService
を編集しなければならない。 - 単体テストでモックDBを使いたくても差し替えが難しい。
原則が適用されたコード例(リファクタ後)
依存を 抽象(インターフェース) に向ける。
→ 上位クラス (OrderService
) は「DBの使い方(契約)」だけを知り、
実装の詳細には依存しない。
// ① 抽象インターフェース(抽象に依存)
interface Database {
save(order: string): void;
}
// ② 具体的な実装(低水準モジュール)
class MySQLDatabase implements Database {
save(order: string): void {
console.log(`Saving "${order}" to MySQL database.`);
}
}
class InMemoryDatabase implements Database {
save(order: string): void {
console.log(`Saving "${order}" to in-memory DB.`);
}
}
// ③ 高水準モジュールは抽象に依存する
class OrderService {
constructor(private db: Database) {} // ← 抽象を注入
createOrder(order: string): void {
this.db.save(order);
}
}
// 使用例
const mysqlService = new OrderService(new MySQLDatabase());
mysqlService.createOrder("Coffee");
const testService = new OrderService(new InMemoryDatabase());
testService.createOrder("Mocked order for test");
利点:
-
OrderService
はどのDB実装にも依存しない。 - 実行時に 依存性注入(DI) で差し替え可能。
- テストでは簡単にモック実装を使える。
- 高水準・低水準の両者が共通の抽象(
Database
)を介して疎結合になる。
Discussion