SOLID原則とクリーンアーキテクチャ入門
ソフトウェア設計の基本であるSOLID原則と、それを活かしたクリーンアーキテクチャの概念と実践方法を詳しく解説します。
🎯 この記事で学べること
- 1SOLID原則の5つの原則を理解し、実際のコードに適用できます
- 2各原則が解決する具体的な問題と適用方法を習得できます
- 3クリーンアーキテクチャの基本概念と実装方法を学べます
- 4TypeScriptでの実装パターンを身につけられます
- 5実際のプロジェクトでの段階的適用方法を理解できます
読了時間: 約5分
Instagramの13人が作った奇跡
2010年、フォトシェアアプリ「Instagram」。
開発チームは、わずか13人。しかも、サーバーサイドエンジニアは2人だけ。マイク・クリーガーとケビン・システロムが、このアプリを支えていた。
ローンチから2ヶ月で100万ユーザー。1年で1000万ユーザー。2年後、10億ドルでFacebookに買収された。
なぜ、こんなに小さなチームで、これほど巨大なサービスを支えられたのか?
秘密は、コードの構造にあった。
「我々のコードは、まるでレゴブロックのようだった」
後にUberのCTOとなったマイクは振り返る。「新しい機能を追加するとき、既存のコードを壊すことはなかった。すべてが独立したピースとして動作していた」
Instagramのコードベースには、一つの哲学が貫かれていた。SOLID原則。
Robert C. Martin(通称Uncle Bob)が提唱した、5つの設計原則。これが、13人という小チームで世界を変えるプロダクトを作る原動力となった。
WhatsAppの32人vs Facebook の25,000人
2014年2月19日。
FacebookがWhatsAppを190億ドルで買収した日。この金額は、当時史上最高の企業買収だった。
興味深いのは、両社の組織規模だ。
Facebook:25,000人の従業員、数百万行のコード WhatsApp:32人の従業員、数万行のコード
1人当たりの生産性を計算すると、WhatsAppの方が圧倒的に高い。なぜこんなことが可能だったのか?
WhatsAppの共同創設者、ブライアン・アクトンは答えた。「我々のコードは、非常にシンプルだった。一つの機能は、一つのことだけをする。新しい機能を追加するときも、既存のコードに手を加える必要がなかった」
これが、単一責任原則の威力だ。
Twitter の失われた週末
2008年10月、Twitter本社。
エンジニアのジャックは、絶望していた。金曜日の夕方、「ダイレクトメッセージの文字数制限を140文字から280文字に変更してほしい」という簡単な要求を受けた。
月曜の朝までに対応すれば十分だと思った。
しかし、週末をすべて費やしても、作業は終わらなかった。
なぜか?
// Twitterの問題があったコード(簡略化)
class TwitterService {
postTweet(userId, message) {
// ツイート投稿
if (message.length > 140) {
throw new Error('Too long');
}
this.saveToDatabase(message);
this.updateUserStats(userId);
this.sendNotifications(message);
this.updateTimeline(userId);
this.indexForSearch(message);
}
sendDirectMessage(fromUserId, toUserId, message) {
// DM送信
if (message.length > 140) { // ここも変更が必要
throw new Error('Too long');
}
this.saveDirectMessage(fromUserId, toUserId, message);
this.sendPushNotification(toUserId);
this.markAsUnread(toUserId);
}
// 他にも20個のメソッドが、同じ文字数制限ロジックを重複して持っていた
}
文字数制限のロジックが、20箇所に散在していた。一箇所変更すると、他の機能が壊れる。修正すると、また別の機能に影響する。
ジャックは、この週末の経験から重要なことを学んだ。一つのクラスが、複数の責任を持ってはいけない。
単一責任原則:一つのことを、完璧に
単一責任原則(SRP)は、SOLID原則の「S」。
「クラスを変更する理由は、一つでなければならない」
Twitterの問題を、この原則で解決してみよう。
// 改善後:責任を分離した設計
class MessageValidator {
constructor(maxLength) {
this.maxLength = maxLength;
}
validate(message) {
if (message.length > this.maxLength) {
throw new Error(`Message too long (max: ${this.maxLength})`);
}
return true;
}
// 文字数制限が変わったとき、このクラスだけ修正すれば済む
updateMaxLength(newMaxLength) {
this.maxLength = newMaxLength;
}
}
class TweetService {
constructor(validator, database, notificationService) {
this.validator = validator;
this.database = database;
this.notificationService = notificationService;
}
async postTweet(userId, message) {
this.validator.validate(message); // バリデーションは専門クラスに委任
const tweet = await this.database.saveTweet(userId, message);
await this.notificationService.notifyFollowers(tweet);
return tweet;
}
}
class DirectMessageService {
constructor(validator, database, notificationService) {
this.validator = validator; // 同じバリデーターを再利用
this.database = database;
this.notificationService = notificationService;
}
async sendDirectMessage(fromUserId, toUserId, message) {
this.validator.validate(message); // 同じロジック、別のクラス
const dm = await this.database.saveDirectMessage(fromUserId, toUserId, message);
await this.notificationService.notifyUser(toUserId, dm);
return dm;
}
}
この設計なら、文字数制限の変更はMessageValidator
だけを修正すれば済む。週末を失うこともない。
開放閉鎖原則:Airbnbの200種類の家
2015年、Airbnb。
物件の種類が爆発的に増えていた。アパート、一軒家、キャンプ場、城、樹上の家、イグルー。プロダクトマネージャーは、毎週新しいタイプの物件を追加したがった。
エンジニアのサラは、このコードを書いていた。
// 問題のあったコード
class PropertyPriceCalculator {
calculatePrice(property) {
if (property.type === 'apartment') {
return property.basePrice * 1.0;
} else if (property.type === 'house') {
return property.basePrice * 1.2;
} else if (property.type === 'castle') {
return property.basePrice * 5.0;
} else if (property.type === 'treehouse') {
return property.basePrice * 2.0;
}
// 新しいタイプを追加するたびに、このメソッドを修正する
}
}
新しい物件タイプが追加されるたびに、サラはcalculatePrice
メソッドを修正しなければならなかった。月に4回、火曜日の会議で新しいタイプが決まり、金曜日までに実装が必要だった。
「毎週コアロジックを触るのは危険すぎる」
サラは、開放閉鎖原則を適用した。
// 改善後:拡張に開いて、修正に閉じた設計
class Property {
constructor(type, basePrice) {
this.type = type;
this.basePrice = basePrice;
}
// 各物件タイプが独自の価格計算ロジックを持つ
calculatePrice() {
throw new Error('Subclass must implement calculatePrice');
}
}
class Apartment extends Property {
calculatePrice() {
return this.basePrice * 1.0;
}
}
class House extends Property {
calculatePrice() {
return this.basePrice * 1.2;
}
}
class Castle extends Property {
calculatePrice() {
return this.basePrice * 5.0;
}
}
class TreeHouse extends Property {
calculatePrice() {
return this.basePrice * 2.0;
}
}
// 新しいタイプ:既存コードに一切手を加えずに追加可能
class Igloo extends Property {
calculatePrice() {
// イグルーは寒いので安くする
return this.basePrice * 0.8;
}
}
// 価格計算エンジン:修正不要で新しいタイプに対応
class PropertyPriceCalculator {
calculatePrice(property) {
return property.calculatePrice(); // 各オブジェクトが自分で計算
}
}
この設計により、新しい物件タイプを追加するとき、既存のコードに触れる必要がなくなった。新しいクラスを追加するだけで済む。
サラの週末は平和になった。
リスコフの置換原則:Uberの5種類の車
2012年、Uber。
最初はタクシーだけだった。しかし、事業拡大とともに、様々な車種が追加された。UberX、UberXL、UberBLACK、UberSUV、UberPOOL。
エンジニアのマルコスは、こんなコードを書いていた。
// 問題のあったコード
class Vehicle {
startRide() {
console.log('Ride started');
}
}
class Taxi extends Vehicle {
startRide() {
console.log('Taxi ride started');
}
}
class UberPOOL extends Vehicle {
startRide() {
if (this.hasOtherPassengers()) {
throw new Error('Cannot start ride with other passengers boarding');
}
console.log('UberPOOL ride started');
}
hasOtherPassengers() {
// 他の乗客がまだ乗車していない場合の複雑なロジック
return Math.random() > 0.5;
}
}
// このコードは期待通りに動作しない
function startAllRides(vehicles) {
vehicles.forEach(vehicle => {
vehicle.startRide(); // UberPOOLで例外が発生する可能性
});
}
UberPOOLは、他の車種とは動作が異なる。同じstartRide()
メソッドを呼んでも、例外が発生することがある。
これはリスコフの置換原則違反だった。「派生クラスは、基底クラスと置換可能でなければならない」
マルコスは設計を見直した。
// 改善後:LSP準拠の設計
class Vehicle {
constructor(type) {
this.type = type;
}
// すべての車種で共通の動作
requestRide(passenger) {
console.log(`${this.type} ride requested for ${passenger.name}`);
}
// 各車種が独自に実装
canStartImmediately() {
return true; // デフォルトはすぐ開始可能
}
}
class Taxi extends Vehicle {
constructor() {
super('Taxi');
}
canStartImmediately() {
return true; // タクシーはすぐ開始可能
}
}
class UberPOOL extends Vehicle {
constructor() {
super('UberPOOL');
this.waitingPassengers = [];
}
canStartImmediately() {
return this.waitingPassengers.length === 0; // 他に待っている乗客がいない場合のみ
}
addWaitingPassenger(passenger) {
this.waitingPassengers.push(passenger);
}
}
// どの車種でも同じように処理できる
class RideService {
startRide(vehicle, passenger) {
if (vehicle.canStartImmediately()) {
console.log(`Starting ${vehicle.type} ride for ${passenger.name}`);
return true;
} else {
console.log(`${vehicle.type} ride delayed - waiting for other passengers`);
return false;
}
}
}
// すべての車種で期待通りに動作
const vehicles = [new Taxi(), new UberPOOL()];
const rideService = new RideService();
const passenger = { name: 'Alice' };
vehicles.forEach(vehicle => {
rideService.startRide(vehicle, passenger); // 例外なし、期待通りの動作
});
この設計なら、どの車種でも同じインターフェースで扱える。新しい車種を追加しても、既存のコードは動作し続ける。
インターフェース分離原則:Spotifyの音楽業界革命
2008年、スウェーデン・ストックホルム。
Spotify創業者のダニエル・エクは、音楽業界の複雑さに直面していた。
アーティスト、レコード会社、配信プラットフォーム、ユーザー。それぞれが異なるニーズを持っている。
初期の設計はこうだった。
// 問題のあったコード:すべてを一つのインターフェースで
class MusicPlatformUser {
// ユーザー機能
playMusic(track) { /* 音楽再生 */ }
createPlaylist(name) { /* プレイリスト作成 */ }
// アーティスト機能
uploadTrack(trackData) { throw new Error('Not an artist'); }
viewRoyalties() { throw new Error('Not an artist'); }
// レーベル機能
manageArtists() { throw new Error('Not a label'); }
distributeTracks() { throw new Error('Not a label'); }
// アドミン機能
moderateContent() { throw new Error('Not an admin'); }
viewAnalytics() { throw new Error('Not an admin'); }
}
一般ユーザーは、アーティスト向けの機能を使えない。アーティストは、レーベル向けの機能を使えない。しかし、すべてのメソッドを実装しなければならない。
ダニエルはインターフェース分離原則を適用した。「クライアントは、使用しないメソッドに依存すべきではない」
// 改善後:役割別に分離されたインターフェース
class MusicListener {
playMusic(track) {
console.log(`Playing: ${track.title}`);
}
createPlaylist(name) {
console.log(`Created playlist: ${name}`);
}
followArtist(artist) {
console.log(`Following: ${artist.name}`);
}
}
class MusicCreator extends MusicListener {
uploadTrack(trackData) {
console.log(`Uploaded: ${trackData.title}`);
}
viewRoyalties() {
console.log('Viewing royalty report');
}
updateProfile(profileData) {
console.log('Profile updated');
}
}
class RecordLabel {
manageArtists() {
console.log('Managing artist roster');
}
distributeTracks(tracks) {
console.log(`Distributing ${tracks.length} tracks`);
}
viewLabelAnalytics() {
console.log('Viewing label analytics');
}
}
class PlatformAdmin {
moderateContent(content) {
console.log(`Moderating: ${content.type}`);
}
viewPlatformAnalytics() {
console.log('Viewing platform-wide analytics');
}
manageLicenses() {
console.log('Managing music licenses');
}
}
// 各ユーザータイプが必要な機能だけを持つ
const listener = new MusicListener(); // リスナー向け機能のみ
const artist = new MusicCreator(); // 作成者向け機能も使用可能
const label = new RecordLabel(); // レーベル向け機能
const admin = new PlatformAdmin(); // 管理者向け機能
この分離により、各ユーザータイプは自分に関係ない機能に依存しなくて済む。リスナーは音楽を聞くことだけに集中できる。アーティストは楽曲制作とファンとの交流に集中できる。
結果は歴史が証明している。Spotifyは音楽業界を変革し、4億人のユーザーを獲得した。
依存性逆転原則:Netflix の技術革命
2007年、Netflix。
まだDVD郵送事業が主力だった時代。CTOのAdrian Cockcroftは、ストリーミングサービスへの転換を見据えていた。
しかし、既存のシステムには問題があった。
// 問題のあったコード:具象クラスに直接依存
class RecommendationEngine {
constructor() {
this.database = new MySQLDatabase(); // MySQL に強く依存
this.emailService = new SMTPEmailService(); // SMTP に強く依存
}
generateRecommendations(userId) {
const user = this.database.getUser(userId);
const recommendations = this.calculateRecommendations(user);
this.emailService.sendRecommendations(user.email, recommendations);
return recommendations;
}
}
この設計では、データベースをMySQLからCassandraに変更したい場合、RecommendationEngine
クラス自体を修正しなければならない。メール送信をSMTPからAmazon SESに変更する場合も同じだ。
Adrianは依存性逆転原則を適用した。「高レベルモジュールは、低レベルモジュールに依存してはならない。どちらも抽象に依存すべきだ」
// 改善後:抽象に依存する設計
class RecommendationEngine {
constructor(database, notificationService) {
this.database = database; // 抽象(インターフェース)に依存
this.notificationService = notificationService; // 抽象に依存
}
generateRecommendations(userId) {
const user = this.database.getUser(userId);
const recommendations = this.calculateRecommendations(user);
this.notificationService.send(user, recommendations);
return recommendations;
}
}
// 様々な実装を切り替え可能
class MySQLDatabase {
getUser(userId) {
console.log(`Getting user from MySQL: ${userId}`);
return { id: userId, preferences: ['action', 'comedy'] };
}
}
class CassandraDatabase {
getUser(userId) {
console.log(`Getting user from Cassandra: ${userId}`);
return { id: userId, preferences: ['drama', 'sci-fi'] };
}
}
class EmailNotificationService {
send(user, recommendations) {
console.log(`Sending email to ${user.id}: ${recommendations.length} recommendations`);
}
}
class PushNotificationService {
send(user, recommendations) {
console.log(`Sending push notification to ${user.id}: ${recommendations.length} recommendations`);
}
}
// 実行時に実装を選択可能
const mysqlDb = new MySQLDatabase();
const cassandraDb = new CassandraDatabase();
const emailService = new EmailNotificationService();
const pushService = new PushNotificationService();
// 様々な組み合わせが可能
const engine1 = new RecommendationEngine(mysqlDb, emailService);
const engine2 = new RecommendationEngine(cassandraDb, pushService);
この設計により、Netflixは技術スタックを柔軟に変更できるようになった。データベースを段階的にMySQLからCassandraに移行。通知システムをメールからプッシュ通知に変更。すべて、RecommendationEngine
のコードを変更することなく実現した。
結果?Netflix は世界最大のストリーミングプラットフォームになった。
クリーンアーキテクチャ:Instagram の秘密兵器
話をInstagramに戻そう。
なぜ13人という小さなチームで、これほど巨大なスケールに対応できたのか?
秘密は、クリーンアーキテクチャにあった。
Uncle Bobが提唱したこのアーキテクチャは、システムを同心円状の層に分ける。外側から内側への依存のみを許可し、逆方向の依存は禁止する。
Instagramのコードは、この構造に従っていた。
// Instagram の写真投稿機能(簡略化)
// Entities(一番内側):ビジネスルールの核心
class Photo {
constructor(imageData, caption, userId) {
this.id = this.generateId();
this.imageData = imageData;
this.caption = caption;
this.userId = userId;
this.timestamp = new Date();
this.filters = [];
this.validateImageSize(imageData);
}
applyFilter(filterName) {
if (!this.isSupportedFilter(filterName)) {
throw new Error('Unsupported filter');
}
this.filters.push(filterName);
}
validateImageSize(imageData) {
if (imageData.size > 10 * 1024 * 1024) { // 10MB制限
throw new Error('Image too large');
}
}
isSupportedFilter(filterName) {
const supportedFilters = ['valencia', 'x-pro-ii', 'lo-fi', 'earlybird'];
return supportedFilters.includes(filterName);
}
}
// Use Cases(アプリケーションロジック)
class PostPhotoUseCase {
constructor(photoRepository, notificationService, feedService) {
this.photoRepository = photoRepository;
this.notificationService = notificationService;
this.feedService = feedService;
}
async execute(imageData, caption, userId, filterName) {
// 1. ビジネスオブジェクトを作成
const photo = new Photo(imageData, caption, userId);
// 2. フィルターを適用
if (filterName) {
photo.applyFilter(filterName);
}
// 3. 保存
await this.photoRepository.save(photo);
// 4. フォロワーに通知
await this.notificationService.notifyFollowers(userId, photo);
// 5. フィードに追加
await this.feedService.addToFeeds(photo);
return photo;
}
}
// Interface Adapters(外部とのやりとり)
class PhotoController {
constructor(postPhotoUseCase) {
this.postPhotoUseCase = postPhotoUseCase;
}
async handlePhotoUpload(request, response) {
try {
const { image, caption, filter } = request.body;
const userId = request.user.id;
const photo = await this.postPhotoUseCase.execute(
image, caption, userId, filter
);
response.json({
success: true,
photo: {
id: photo.id,
caption: photo.caption,
timestamp: photo.timestamp
}
});
} catch (error) {
response.status(400).json({
success: false,
error: error.message
});
}
}
}
この構造の利点は何か?
1. テストが容易 各層を独立してテストできる。エンティティはビジネスロジックのみをテスト。ユースケースはアプリケーションロジックのみをテスト。
2. 技術変更が容易 データベースをMySQLからPostgreSQLに変更?Interface Adapters層だけを変更すれば済む。APIフォーマットを変更?Controller層だけの変更で対応可能。
3. ビジネスロジックが保護される フレームワークやライブラリの変更が、コアなビジネスロジックに影響しない。React.jsからVue.jsに変更しても、写真の投稿ロジックは変わらない。
これが、13人でFacebookに対抗できた理由だった。
Shopifyの成長を支えた段階的適用
Shopifyの創業者、トビアス・リュトケは、興味深い戦略を取った。
「最初から完璧なアーキテクチャを目指すな。必要になったときに、必要な分だけ適用しろ」
2006年のShopify:Ruby on Railsのシンプルな構成。SOLID原則もクリーンアーキテクチャも適用していない。
2008年:ユーザー数が1万を超えたとき、単一責任原則を適用開始。
2010年:ユーザー数が10万を超えたとき、開放閉鎖原則を適用。新しい支払い方法を追加しやすくした。
2012年:グローバル展開開始。インターフェース分離原則を適用。地域別の機能を分離した。
2015年:上場準備。依存性逆転原則を適用。レガシーシステムから新システムへの段階的移行を可能にした。
2018年:IPO後。完全なクリーンアーキテクチャを導入。
この段階的アプローチにより、Shopifyは継続的な成長を実現した。2024年現在、175万以上の店舗が利用する世界最大のeコマースプラットフォームとなっている。
実践への道筋
では、あなたのプロジェクトにSOLID原則とクリーンアーキテクチャを適用するには、どうすればいいか?
Phase 1: 単一責任原則から始める(1-2週間)
- 一つのクラスが複数の理由で変更される箇所を特定
- バリデーション、データアクセス、ビジネスロジックを分離
- すぐに効果を実感できる
Phase 2: インターフェースの導入(2-4週間)
- 具象クラスに依存している箇所を特定
- インターフェースまたは抽象クラスを導入
- テストが格段に容易になる
Phase 3: 開放閉鎖原則の適用(4-8週間)
- 頻繁に変更される箇所を特定
- Strategy パターンや Template Method パターンを適用
- 新機能追加時の影響範囲が激減する
Phase 4: 依存性逆転の完全適用(2-3ヶ月)
- 依存性注入コンテナの導入
- 設定ファイルによる実装の切り替えを実現
- 本番環境とテスト環境の完全分離が可能に
Phase 5: クリーンアーキテクチャの導入(3-6ヶ月)
- レイヤーの明確な分離
- ユースケース駆動の開発プロセス
- 長期的な保守性の確保
重要なのは、一度にすべてを適用しようとしないこと。Instagram、Netflix、Shopify、すべての会社が段階的に導入している。
あなたのプロジェクトも、小さな一歩から始めよう。まずは、一つのクラスの責任を分離することから。
その小さな変更が、やがて大きな変革をもたらす。13人で世界を変えたInstagramのように。
SOLID原則は「完璧」を目指すものではありません。「改善」を目指すものです。完璧な設計はありませんが、より良い設計は常に存在します。小さな改善を積み重ねることで、大きな成果を得られます。
おすすめコース
関連記事
システムアーキテクチャの基本パターン
システム設計における基本的なアーキテクチャパターンを分かりやすく解説。レイヤードアーキテクチャ、マイクロサービス、MVC、イベント駆動など、実践的なパターンとその適用場面を学びます。
ドメイン駆動設計(DDD)入門:ビジネスロジックを中心としたソフトウェア設計
ドメイン駆動設計(DDD)の基本概念から実装まで詳解。戦略的設計と戦術的設計、境界づけられたコンテキスト、アグリゲートパターンを実例で学びます。
デザインパターンの基礎知識
ソフトウェア開発における代表的なデザインパターンを、実例とコードを通じて分かりやすく解説。GoFパターンを中心に、実践的な使い方と適用場面を学びます。