SRP(単一責任の原則)+ OCP(開放閉鎖の原則)
SRPは「変更する理由を1つに」、OCPは「変更せずに拡張する」。この2つの原則が、保守性の高い設計の土台を作る。
105 分で読めます52,150 文字
SRP(単一責任の原則)+ OCP(開放閉鎖の原則)
SRPは「変更する理由を1つに」、OCPは「変更せずに拡張する」。この2つの原則が、保守性の高い設計の土台を作る。
この章で学ぶこと
- SRP の「責任」の正しい定義を理解する
- OCP をポリモーフィズムで実現する方法を把握する
- 実践的なリファクタリング手法を学ぶ
- SRP と OCP の違反パターンを検出できるようになる
- 多言語での SRP/OCP 適用パターンを習得する
- 現実のプロジェクトでの段階的な適用方法を学ぶ
前提知識
このガイドを読む前に、以下の知識があると理解が深まります:
- 基本的なプログラミングの知識
- 関連する基礎概念の理解
- SOLID原則概要 の内容を理解していること
1. SRP: 単一責任の原則
定義(Robert C. Martin):
「クラスを変更する理由は1つだけであるべき」
より正確な定義:
「クラスは1つのアクター(利害関係者)に対してのみ責任を持つ」
例:
Employee クラスが以下を持つ場合:
- calculatePay() → CFO(経理部門)の責任
- reportHours() → COO(業務部門)の責任
- save() → CTO(技術部門)の責任
→ 3つのアクターに依存 = SRP 違反
→ 経理部門の要求変更が業務部門のコードに影響する可能性
1.1 SRP の「責任」とは何か
「責任」の誤解と正しい理解:
❌ 誤解: 「1つのメソッドだけ持つべき」
→ メソッド数で判断するのは間違い
→ 100メソッドでも「1つの責任」ならSRP準拠
❌ 誤解: 「1つのことだけする」
→ 抽象度によって「1つのこと」の粒度が変わる
→ 何をもって「1つ」とするかが曖昧
✅ 正しい理解: 「変更する理由が1つだけ」
→ 「このクラスを変更したい人(アクター)は誰か?」
→ アクターが1人だけなら SRP 準拠
✅ より実践的な理解: 「1つのアクターに対する責任」
→ アクター = ビジネス上の利害関係者
→ 経理部門、人事部門、技術部門など
→ 同じアクターの要求変更は1つのクラスに閉じるべき
責任の粒度の判断基準:
1. 「このクラスが変更される場面を3つ挙げてみる」
2. その3つが同じアクターの要求なら → SRP準拠
3. 異なるアクターの要求なら → SRP違反の可能性
1.2 SRP リファクタリング
// ❌ SRP違反: 複数の責任を持つクラス
class UserService {
// 責任1: ユーザーの作成ロジック
createUser(data: CreateUserDto): User {
// バリデーション
if (!data.email.includes("@")) throw new Error("Invalid email");
if (data.password.length < 8) throw new Error("Password too short");
// パスワードハッシュ化
const hashedPassword = bcrypt.hashSync(data.password, 10);
// DB保存
const user = db.users.create({ ...data, password: hashedPassword });
// メール送信
const html = `<h1>Welcome ${data.name}!</h1>`;
emailClient.send(data.email, "Welcome", html);
// ログ
logger.info(`User created: ${user.id}`);
return user;
}
}
// ✅ SRP適用: 各クラスが1つの責任を持つ
class UserValidator {
validate(data: CreateUserDto): void {
if (!data.email.includes("@")) throw new ValidationError("Invalid email");
if (data.password.length < 8) throw new ValidationError("Password too short");
}
}
class PasswordHasher {
hash(password: string): string {
return bcrypt.hashSync(password, 10);
}
}
class UserRepository {
create(data: CreateUserDto & { password: string }): User {
return db.users.create(data);
}
}
class WelcomeEmailSender {
send(user: User): void {
const html = `<h1>Welcome ${user.name}!</h1>`;
emailClient.send(user.email, "Welcome", html);
}
}
// オーケストレーター
class UserRegistrationService {
constructor(
private validator: UserValidator,
private hasher: PasswordHasher,
private repo: UserRepository,
private emailSender: WelcomeEmailSender,
) {}
async register(data: CreateUserDto): Promise<User> {
this.validator.validate(data);
const hashedPassword = this.hasher.hash(data.password);
const user = await this.repo.create({ ...data, password: hashedPassword });
this.emailSender.send(user);
return user;
}
}1.3 SRP の多言語実践例
# Python: SRP の実践例 - EC サイトの注文処理
# ❌ SRP違反: 1つのクラスが注文に関する全てを担当
class OrderManager:
def __init__(self):
self.db = psycopg2.connect("dbname=shop")
def create_order(self, customer_id: int, items: list[dict]) -> dict:
# バリデーション(責任1)
if not items:
raise ValueError("Order must have at least one item")
for item in items:
if item["quantity"] <= 0:
raise ValueError(f"Invalid quantity for {item['name']}")
# 価格計算(責任2)
subtotal = sum(i["price"] * i["quantity"] for i in items)
tax = subtotal * 0.10 # 消費税
shipping = 500 if subtotal < 5000 else 0
total = subtotal + tax + shipping
# 在庫チェック(責任3)
cursor = self.db.cursor()
for item in items:
cursor.execute(
"SELECT stock FROM products WHERE id = %s",
(item["product_id"],)
)
stock = cursor.fetchone()[0]
if stock < item["quantity"]:
raise ValueError(f"Insufficient stock for {item['name']}")
# DB保存(責任4)
cursor.execute(
"INSERT INTO orders (customer_id, total) VALUES (%s, %s) RETURNING id",
(customer_id, total)
)
order_id = cursor.fetchone()[0]
self.db.commit()
# メール送信(責任5)
import smtplib
server = smtplib.SMTP("smtp.example.com")
server.sendmail(
"shop@example.com",
f"customer_{customer_id}@example.com",
f"Your order #{order_id} has been placed. Total: ¥{total}"
)
return {"order_id": order_id, "total": total}
# ✅ SRP適用: 各クラスが1つの責任のみ持つ
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class OrderItem:
product_id: int
name: str
price: int
quantity: int
@dataclass
class Order:
id: int | None
customer_id: int
items: list[OrderItem]
subtotal: int
tax: int
shipping: int
total: int
class OrderValidator:
"""注文データのバリデーションのみ"""
def validate(self, customer_id: int, items: list[OrderItem]) -> None:
if not items:
raise ValueError("Order must have at least one item")
for item in items:
if item.quantity <= 0:
raise ValueError(f"Invalid quantity for {item.name}")
if item.price <= 0:
raise ValueError(f"Invalid price for {item.name}")
class PriceCalculator:
"""価格計算のみ"""
TAX_RATE = 0.10
FREE_SHIPPING_THRESHOLD = 5000
SHIPPING_FEE = 500
def calculate(self, items: list[OrderItem]) -> tuple[int, int, int, int]:
subtotal = sum(item.price * item.quantity for item in items)
tax = int(subtotal * self.TAX_RATE)
shipping = 0 if subtotal >= self.FREE_SHIPPING_THRESHOLD else self.SHIPPING_FEE
total = subtotal + tax + shipping
return subtotal, tax, shipping, total
class InventoryChecker:
"""在庫確認のみ"""
def __init__(self, db_connection):
self._db = db_connection
def check_availability(self, items: list[OrderItem]) -> None:
cursor = self._db.cursor()
for item in items:
cursor.execute(
"SELECT stock FROM products WHERE id = %s",
(item.product_id,)
)
row = cursor.fetchone()
if row is None:
raise ValueError(f"Product not found: {item.product_id}")
if row[0] < item.quantity:
raise ValueError(f"Insufficient stock for {item.name}")
class OrderRepository:
"""注文のDB永続化のみ"""
def __init__(self, db_connection):
self._db = db_connection
def save(self, order: Order) -> int:
cursor = self._db.cursor()
cursor.execute(
"INSERT INTO orders (customer_id, total) VALUES (%s, %s) RETURNING id",
(order.customer_id, order.total)
)
order_id = cursor.fetchone()[0]
for item in order.items:
cursor.execute(
"INSERT INTO order_items (order_id, product_id, quantity, price) "
"VALUES (%s, %s, %s, %s)",
(order_id, item.product_id, item.quantity, item.price)
)
self._db.commit()
return order_id
class OrderConfirmationNotifier:
"""注文確認通知のみ"""
def __init__(self, email_sender):
self._sender = email_sender
def notify(self, order: Order) -> None:
self._sender.send(
to=f"customer_{order.customer_id}@example.com",
subject=f"注文確認 #{order.id}",
body=f"ご注文ありがとうございます。合計: ¥{order.total}"
)
class CreateOrderUseCase:
"""オーケストレーション(各責任を組み合わせるだけ)"""
def __init__(
self,
validator: OrderValidator,
calculator: PriceCalculator,
inventory: InventoryChecker,
repository: OrderRepository,
notifier: OrderConfirmationNotifier,
):
self._validator = validator
self._calculator = calculator
self._inventory = inventory
self._repository = repository
self._notifier = notifier
def execute(self, customer_id: int, items: list[OrderItem]) -> Order:
# 1. バリデーション
self._validator.validate(customer_id, items)
# 2. 在庫確認
self._inventory.check_availability(items)
# 3. 価格計算
subtotal, tax, shipping, total = self._calculator.calculate(items)
# 4. 注文作成・保存
order = Order(
id=None,
customer_id=customer_id,
items=items,
subtotal=subtotal,
tax=tax,
shipping=shipping,
total=total,
)
order.id = self._repository.save(order)
# 5. 通知
self._notifier.notify(order)
return order// Java: SRP の実践例 - ログ処理
// ❌ SRP違反: ログの取得・整形・出力が1クラスに集約
public class Logger {
private final String logFile;
private final String dbUrl;
public Logger(String logFile, String dbUrl) {
this.logFile = logFile;
this.dbUrl = dbUrl;
}
public void log(String level, String message) {
// 責任1: メッセージのフォーマット
String timestamp = LocalDateTime.now()
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
String formatted = String.format("[%s] %s: %s", timestamp, level, message);
// 責任2: ファイルへの出力
try (FileWriter fw = new FileWriter(logFile, true)) {
fw.write(formatted + "\n");
} catch (IOException e) {
System.err.println("Failed to write log: " + e.getMessage());
}
// 責任3: DBへの出力
try (Connection conn = DriverManager.getConnection(dbUrl)) {
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO logs (level, message, created_at) VALUES (?, ?, ?)"
);
ps.setString(1, level);
ps.setString(2, message);
ps.setTimestamp(3, Timestamp.valueOf(LocalDateTime.now()));
ps.executeUpdate();
} catch (SQLException e) {
System.err.println("Failed to save log to DB: " + e.getMessage());
}
// 責任4: アラート送信(ERRORレベルの場合)
if ("ERROR".equals(level)) {
// Slack通知
HttpClient client = HttpClient.newHttpClient();
// ... Slack API呼び出し
}
}
}
// ✅ SRP適用: 各クラスが1つの責任
// フォーマット責任
public interface LogFormatter {
String format(String level, String message);
}
public class TimestampLogFormatter implements LogFormatter {
@Override
public String format(String level, String message) {
String timestamp = LocalDateTime.now()
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
return String.format("[%s] %s: %s", timestamp, level, message);
}
}
public class JsonLogFormatter implements LogFormatter {
@Override
public String format(String level, String message) {
return String.format(
"{\"timestamp\":\"%s\",\"level\":\"%s\",\"message\":\"%s\"}",
Instant.now(), level, message
);
}
}
// 出力先責任(インターフェースで抽象化 → OCPにもつながる)
public interface LogWriter {
void write(String formattedMessage);
}
public class FileLogWriter implements LogWriter {
private final String filePath;
public FileLogWriter(String filePath) {
this.filePath = filePath;
}
@Override
public void write(String formattedMessage) {
try (FileWriter fw = new FileWriter(filePath, true)) {
fw.write(formattedMessage + "\n");
} catch (IOException e) {
System.err.println("File write failed: " + e.getMessage());
}
}
}
public class DatabaseLogWriter implements LogWriter {
private final DataSource dataSource;
public DatabaseLogWriter(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void write(String formattedMessage) {
try (Connection conn = dataSource.getConnection()) {
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO logs (message) VALUES (?)"
);
ps.setString(1, formattedMessage);
ps.executeUpdate();
} catch (SQLException e) {
System.err.println("DB write failed: " + e.getMessage());
}
}
}
// アラート責任
public interface AlertNotifier {
void notify(String level, String message);
boolean shouldNotify(String level);
}
public class SlackAlertNotifier implements AlertNotifier {
@Override
public boolean shouldNotify(String level) {
return "ERROR".equals(level) || "FATAL".equals(level);
}
@Override
public void notify(String level, String message) {
// Slack API 呼び出し
}
}
// オーケストレーター
public class Logger {
private final LogFormatter formatter;
private final List<LogWriter> writers;
private final List<AlertNotifier> notifiers;
public Logger(
LogFormatter formatter,
List<LogWriter> writers,
List<AlertNotifier> notifiers
) {
this.formatter = formatter;
this.writers = writers;
this.notifiers = notifiers;
}
public void log(String level, String message) {
String formatted = formatter.format(level, message);
for (LogWriter writer : writers) {
writer.write(formatted);
}
for (AlertNotifier notifier : notifiers) {
if (notifier.shouldNotify(level)) {
notifier.notify(level, message);
}
}
}
}// Kotlin: SRP の実践例 - バリデーション
// ❌ SRP違反: バリデーションクラスが全ドメインのルールを知っている
class Validator {
fun validateUser(user: UserDto): List<String> {
val errors = mutableListOf<String>()
if (user.name.isBlank()) errors.add("Name is required")
if (!user.email.contains("@")) errors.add("Invalid email")
if (user.age !in 18..120) errors.add("Invalid age")
return errors
}
fun validateProduct(product: ProductDto): List<String> {
val errors = mutableListOf<String>()
if (product.name.isBlank()) errors.add("Name is required")
if (product.price <= 0) errors.add("Price must be positive")
if (product.stock < 0) errors.add("Stock cannot be negative")
return errors
}
fun validateOrder(order: OrderDto): List<String> {
val errors = mutableListOf<String>()
if (order.items.isEmpty()) errors.add("Order must have items")
if (order.total <= 0) errors.add("Total must be positive")
return errors
}
}
// ✅ SRP適用: ドメインごとにバリデーターを分離
// 汎用的なバリデーション結果
sealed class ValidationResult {
object Valid : ValidationResult()
data class Invalid(val errors: List<String>) : ValidationResult()
}
// バリデーターインターフェース
interface Validator<T> {
fun validate(target: T): ValidationResult
}
// ルールベースのバリデーション
interface ValidationRule<T> {
fun check(target: T): String? // null = 問題なし、非null = エラーメッセージ
}
// ユーザーバリデーション
class UserNameRule : ValidationRule<UserDto> {
override fun check(target: UserDto): String? =
if (target.name.isBlank()) "Name is required" else null
}
class UserEmailRule : ValidationRule<UserDto> {
override fun check(target: UserDto): String? =
if (!target.email.contains("@")) "Invalid email format" else null
}
class UserAgeRule : ValidationRule<UserDto> {
override fun check(target: UserDto): String? =
if (target.age !in 18..120) "Age must be between 18 and 120" else null
}
class UserValidator(
private val rules: List<ValidationRule<UserDto>> = listOf(
UserNameRule(),
UserEmailRule(),
UserAgeRule(),
)
) : Validator<UserDto> {
override fun validate(target: UserDto): ValidationResult {
val errors = rules.mapNotNull { it.check(target) }
return if (errors.isEmpty()) ValidationResult.Valid
else ValidationResult.Invalid(errors)
}
}
// 商品バリデーション(独立した責任)
class ProductValidator(
private val rules: List<ValidationRule<ProductDto>> = listOf(
ProductNameRule(),
ProductPriceRule(),
ProductStockRule(),
)
) : Validator<ProductDto> {
override fun validate(target: ProductDto): ValidationResult {
val errors = rules.mapNotNull { it.check(target) }
return if (errors.isEmpty()) ValidationResult.Valid
else ValidationResult.Invalid(errors)
}
}
// 新しいバリデーションルールは ValidationRule を追加するだけ
// → OCP にもつながる1.4 SRP 違反の検出方法
SRP違反を検出する5つのヒューリスティック:
1. クラス名テスト:
→ クラス名に「And」「Or」「Manager」「Handler」が含まれる
→ 例: UserAndOrderManager → SRP違反の疑い
→ 対策: 責任ごとに名前を分ける
2. 変更理由テスト:
→ 「このクラスを変更する理由を3つ挙げる」
→ 異なるビジネスドメインの理由が混在 → SRP違反
→ 例: "UIの変更" と "DBの変更" が同じクラス
3. 説明テスト:
→ クラスの目的を1文で説明できない → SRP違反の疑い
→ 「〜と〜と〜をする」→ 責任が3つ
→ 「〜をする」→ 責任が1つ(SRP準拠)
4. インポートテスト:
→ import文が多様なライブラリを参照 → SRP違反の疑い
→ 例: DB, HTTP, Email, ファイルシステム全てをimport
→ 各ライブラリの変更が影響する = 変更理由が複数
5. コンストラクタテスト:
→ 依存注入のパラメータが5つ以上 → SRP違反の疑い
→ 多くの依存 = 多くの責任を持っている可能性
→ ただし、オーケストレーターは例外
// SRP違反検出の具体例
// 🔍 クラス名テスト
class UserRegistrationAndNotificationService { } // ❌ And
class DataProcessingManager { } // ❌ Manager(曖昧すぎる)
class UserRegistrationService { } // ✅ 1つの責任
// 🔍 インポートテスト
// ❌ 多様すぎるインポート → SRP違反の兆候
import { Database } from './database';
import { SmtpClient } from './email';
import { S3Client } from 'aws-sdk';
import { RedisClient } from 'redis';
import { SlackWebhook } from './slack';
import { PdfGenerator } from './pdf';
class ReportService {
constructor(
private db: Database, // DB依存
private smtp: SmtpClient, // メール依存
private s3: S3Client, // ストレージ依存
private redis: RedisClient, // キャッシュ依存
private slack: SlackWebhook, // 通知依存
private pdf: PdfGenerator, // PDF生成依存
) {}
// → 6つの異なる関心事に依存 = 6つの変更理由
}
// ✅ SRP適用後
class ReportDataFetcher {
constructor(private db: Database, private redis: RedisClient) {}
}
class ReportGenerator {
constructor(private pdf: PdfGenerator) {}
}
class ReportStorage {
constructor(private s3: S3Client) {}
}
class ReportNotifier {
constructor(private smtp: SmtpClient, private slack: SlackWebhook) {}
}2. OCP: 開放閉鎖の原則
定義:
「ソフトウェアの構成要素は、拡張に対して開き(Open)、
修正に対して閉じている(Closed)べき」
つまり:
→ 新しい機能を追加するとき、既存のコードを変更しない
→ ポリモーフィズム(インターフェース + 実装クラス)で実現
なぜ重要か:
→ 既存コードを変更するとリグレッションのリスク
→ テスト済みのコードに触らずに済む
→ チーム開発でのコンフリクト減少
2.1 OCP の実現方法
OCP を実現する4つのパターン:
1. Strategy パターン(最も基本的):
→ インターフェースを定義し、実装クラスを追加
→ 利用側は switch/if を使わずインターフェースを呼ぶ
2. Template Method パターン:
→ 基底クラスでアルゴリズムの骨格を定義
→ サブクラスで詳細をオーバーライド
3. Decorator パターン:
→ 既存クラスをラップして機能を追加
→ 元のクラスのコードは一切変更しない
4. Plugin / Registry パターン:
→ 実行時に実装を動的に登録
→ 新しい実装はプラグインとして追加
適用基準:
変更が発生していない箇所 → まだOCPは不要(YAGNI)
同じ種類の変更が2-3回発生 → OCPを適用する時期
2.2 OCP リファクタリング
// ❌ OCP違反: 新しい通知手段を追加するたびに修正が必要
class NotificationService {
send(type: string, message: string, recipient: string): void {
if (type === "email") {
// メール送信処理
emailClient.send(recipient, message);
} else if (type === "sms") {
// SMS送信処理
smsClient.send(recipient, message);
} else if (type === "slack") {
// Slack送信処理(新規追加するたびにここを修正)
slackClient.post(recipient, message);
}
// LINE追加? Discord追加? → ここを修正し続ける...
}
}
// ✅ OCP適用: 新しい通知手段はクラスを追加するだけ
interface NotificationChannel {
send(message: string, recipient: string): Promise<void>;
}
class EmailChannel implements NotificationChannel {
async send(message: string, recipient: string): Promise<void> {
await emailClient.send(recipient, message);
}
}
class SmsChannel implements NotificationChannel {
async send(message: string, recipient: string): Promise<void> {
await smsClient.send(recipient, message);
}
}
class SlackChannel implements NotificationChannel {
async send(message: string, recipient: string): Promise<void> {
await slackClient.post(recipient, message);
}
}
// LINE追加 → LineChannel クラスを追加するだけ
// NotificationService は一切変更不要
class NotificationService {
constructor(private channels: NotificationChannel[]) {}
async sendAll(message: string, recipient: string): Promise<void> {
await Promise.all(
this.channels.map(ch => ch.send(message, recipient))
);
}
}2.3 OCP の多言語実践例
# Python: OCP の実践例 - レポートエンジン
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
# ❌ OCP違反: 新しいデータソースやフォーマットで修正が必要
class ReportEngine:
def generate(
self, source: str, format_type: str, filters: dict
) -> str:
# データ取得(ソースの種類で分岐)
if source == "mysql":
data = self._fetch_from_mysql(filters)
elif source == "mongodb":
data = self._fetch_from_mongodb(filters)
elif source == "api":
data = self._fetch_from_api(filters)
else:
raise ValueError(f"Unknown source: {source}")
# フォーマット(形式の種類で分岐)
if format_type == "pdf":
return self._format_as_pdf(data)
elif format_type == "excel":
return self._format_as_excel(data)
elif format_type == "html":
return self._format_as_html(data)
else:
raise ValueError(f"Unknown format: {format_type}")
# source ごとに private メソッドが増え続ける...
def _fetch_from_mysql(self, filters): ...
def _fetch_from_mongodb(self, filters): ...
def _fetch_from_api(self, filters): ...
def _format_as_pdf(self, data): ...
def _format_as_excel(self, data): ...
def _format_as_html(self, data): ...
# ✅ OCP適用: データソースとフォーマッターを拡張可能に
@dataclass
class ReportData:
"""レポートデータの共通表現"""
headers: list[str]
rows: list[list[Any]]
metadata: dict[str, Any]
class DataSource(ABC):
"""データソース抽象"""
@abstractmethod
def fetch(self, filters: dict) -> ReportData: ...
class MySQLDataSource(DataSource):
def __init__(self, connection_string: str):
self._conn_str = connection_string
def fetch(self, filters: dict) -> ReportData:
# MySQL からデータ取得
import mysql.connector
conn = mysql.connector.connect(self._conn_str)
cursor = conn.cursor()
query = self._build_query(filters)
cursor.execute(query)
headers = [desc[0] for desc in cursor.description]
rows = cursor.fetchall()
return ReportData(headers=headers, rows=rows, metadata={"source": "mysql"})
def _build_query(self, filters: dict) -> str:
# クエリ構築
return "SELECT * FROM reports"
class MongoDBDataSource(DataSource):
def __init__(self, uri: str, database: str):
self._uri = uri
self._database = database
def fetch(self, filters: dict) -> ReportData:
from pymongo import MongoClient
client = MongoClient(self._uri)
db = client[self._database]
documents = list(db.reports.find(filters))
if not documents:
return ReportData(headers=[], rows=[], metadata={})
headers = list(documents[0].keys())
rows = [[doc.get(h) for h in headers] for doc in documents]
return ReportData(headers=headers, rows=rows, metadata={"source": "mongodb"})
class RestApiDataSource(DataSource):
"""REST API からデータ取得 - 新規追加でも既存コード変更なし"""
def __init__(self, base_url: str, api_key: str):
self._base_url = base_url
self._api_key = api_key
def fetch(self, filters: dict) -> ReportData:
import requests
response = requests.get(
f"{self._base_url}/data",
headers={"Authorization": f"Bearer {self._api_key}"},
params=filters,
)
data = response.json()
headers = data.get("headers", [])
rows = data.get("rows", [])
return ReportData(headers=headers, rows=rows, metadata={"source": "api"})
class ReportFormatter(ABC):
"""フォーマッター抽象"""
@abstractmethod
def format(self, data: ReportData) -> bytes: ...
@abstractmethod
def content_type(self) -> str: ...
@abstractmethod
def file_extension(self) -> str: ...
class PdfFormatter(ReportFormatter):
def format(self, data: ReportData) -> bytes:
from reportlab.lib.pagesizes import A4
from reportlab.platypus import SimpleDocTemplate, Table
# PDF生成ロジック
return b"<pdf content>"
def content_type(self) -> str:
return "application/pdf"
def file_extension(self) -> str:
return ".pdf"
class ExcelFormatter(ReportFormatter):
def format(self, data: ReportData) -> bytes:
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.append(data.headers)
for row in data.rows:
ws.append(row)
# Excel生成ロジック
return b"<excel content>"
def content_type(self) -> str:
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
def file_extension(self) -> str:
return ".xlsx"
class HtmlFormatter(ReportFormatter):
def format(self, data: ReportData) -> bytes:
html = "<table>\n<tr>"
html += "".join(f"<th>{h}</th>" for h in data.headers)
html += "</tr>\n"
for row in data.rows:
html += "<tr>" + "".join(f"<td>{cell}</td>" for cell in row) + "</tr>\n"
html += "</table>"
return html.encode("utf-8")
def content_type(self) -> str:
return "text/html"
def file_extension(self) -> str:
return ".html"
class ReportEngine:
"""このクラスは新しいデータソースやフォーマットが追加されても変更不要"""
def __init__(self, source: DataSource, formatter: ReportFormatter):
self._source = source
self._formatter = formatter
def generate(self, filters: dict | None = None) -> bytes:
data = self._source.fetch(filters or {})
return self._formatter.format(data)
def generate_to_file(self, filename: str, filters: dict | None = None) -> str:
content = self.generate(filters)
filepath = f"{filename}{self._formatter.file_extension()}"
with open(filepath, "wb") as f:
f.write(content)
return filepath
# 使用例: 組み合わせ自由
engine = ReportEngine(
source=MySQLDataSource("mysql://localhost/mydb"),
formatter=PdfFormatter(),
)
engine.generate_to_file("monthly_report")
# 新しい組み合わせも既存コード変更なし
engine2 = ReportEngine(
source=RestApiDataSource("https://api.example.com", "key123"),
formatter=ExcelFormatter(),
)// Java: OCP の実践例 - 認証パイプライン
// ❌ OCP違反
public class AuthService {
public boolean authenticate(String method, String credentials) {
if ("password".equals(method)) {
// パスワード認証
String[] parts = credentials.split(":");
return checkPassword(parts[0], parts[1]);
} else if ("oauth".equals(method)) {
// OAuth認証
return verifyOAuthToken(credentials);
} else if ("api_key".equals(method)) {
// APIキー認証
return validateApiKey(credentials);
} else if ("certificate".equals(method)) {
// 証明書認証(追加のたびにここを修正)
return verifyCertificate(credentials);
}
throw new IllegalArgumentException("Unknown method: " + method);
}
}
// ✅ OCP適用: 認証方法はプラグインとして追加
public interface AuthenticationStrategy {
boolean authenticate(AuthRequest request);
boolean supports(String method);
}
public class PasswordAuthentication implements AuthenticationStrategy {
private final PasswordEncoder encoder;
private final UserRepository userRepo;
public PasswordAuthentication(PasswordEncoder encoder, UserRepository userRepo) {
this.encoder = encoder;
this.userRepo = userRepo;
}
@Override
public boolean supports(String method) {
return "password".equals(method);
}
@Override
public boolean authenticate(AuthRequest request) {
User user = userRepo.findByUsername(request.getUsername());
if (user == null) return false;
return encoder.matches(request.getCredentials(), user.getPasswordHash());
}
}
public class OAuthAuthentication implements AuthenticationStrategy {
private final OAuthTokenVerifier verifier;
public OAuthAuthentication(OAuthTokenVerifier verifier) {
this.verifier = verifier;
}
@Override
public boolean supports(String method) {
return "oauth".equals(method);
}
@Override
public boolean authenticate(AuthRequest request) {
return verifier.verify(request.getCredentials());
}
}
public class ApiKeyAuthentication implements AuthenticationStrategy {
private final ApiKeyRepository keyRepo;
public ApiKeyAuthentication(ApiKeyRepository keyRepo) {
this.keyRepo = keyRepo;
}
@Override
public boolean supports(String method) {
return "api_key".equals(method);
}
@Override
public boolean authenticate(AuthRequest request) {
return keyRepo.isValid(request.getCredentials());
}
}
// 認証サービス: 新しい認証方法が追加されても変更不要
public class AuthService {
private final List<AuthenticationStrategy> strategies;
public AuthService(List<AuthenticationStrategy> strategies) {
this.strategies = strategies;
}
public boolean authenticate(String method, AuthRequest request) {
return strategies.stream()
.filter(s -> s.supports(method))
.findFirst()
.map(s -> s.authenticate(request))
.orElseThrow(() ->
new IllegalArgumentException("Unsupported auth method: " + method)
);
}
}
// Spring Boot での設定例
@Configuration
public class AuthConfig {
@Bean
public AuthService authService(
PasswordAuthentication password,
OAuthAuthentication oauth,
ApiKeyAuthentication apiKey
) {
return new AuthService(List.of(password, oauth, apiKey));
}
}2.4 OCP のもう一つの実現方法: デコレータ
# Python: デコレータによるOCP
class Logger:
"""既存クラスを変更せずにログ機能を追加"""
def __init__(self, wrapped):
self._wrapped = wrapped
def __getattr__(self, name):
original = getattr(self._wrapped, name)
if callable(original):
def wrapper(*args, **kwargs):
print(f"[LOG] {name} called with {args}")
result = original(*args, **kwargs)
print(f"[LOG] {name} returned {result}")
return result
return wrapper
return original
class Calculator:
def add(self, a: int, b: int) -> int:
return a + b
# Calculator を変更せずにログ機能を追加
calc = Logger(Calculator())
calc.add(1, 2)
# [LOG] add called with (1, 2)
# [LOG] add returned 3// TypeScript: デコレータパターンによるOCP
// 基本インターフェース
interface HttpClient {
get(url: string): Promise<Response>;
post(url: string, body: any): Promise<Response>;
}
// 基本実装
class FetchHttpClient implements HttpClient {
async get(url: string): Promise<Response> {
return fetch(url);
}
async post(url: string, body: any): Promise<Response> {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
}
// デコレータ1: ログ追加(FetchHttpClient を変更しない)
class LoggingHttpClient implements HttpClient {
constructor(private inner: HttpClient) {}
async get(url: string): Promise<Response> {
console.log(`[GET] ${url}`);
const start = Date.now();
const response = await this.inner.get(url);
console.log(`[GET] ${url} → ${response.status} (${Date.now() - start}ms)`);
return response;
}
async post(url: string, body: any): Promise<Response> {
console.log(`[POST] ${url}`, body);
const start = Date.now();
const response = await this.inner.post(url, body);
console.log(`[POST] ${url} → ${response.status} (${Date.now() - start}ms)`);
return response;
}
}
// デコレータ2: リトライ追加(FetchHttpClient を変更しない)
class RetryHttpClient implements HttpClient {
constructor(
private inner: HttpClient,
private maxRetries: number = 3,
) {}
async get(url: string): Promise<Response> {
return this.withRetry(() => this.inner.get(url));
}
async post(url: string, body: any): Promise<Response> {
return this.withRetry(() => this.inner.post(url, body));
}
private async withRetry(fn: () => Promise<Response>): Promise<Response> {
let lastError: Error | null = null;
for (let i = 0; i <= this.maxRetries; i++) {
try {
const response = await fn();
if (response.ok) return response;
if (response.status >= 500) {
lastError = new Error(`Server error: ${response.status}`);
continue;
}
return response; // 4xx はリトライしない
} catch (error) {
lastError = error as Error;
}
}
throw lastError;
}
}
// デコレータ3: キャッシュ追加
class CachingHttpClient implements HttpClient {
private cache = new Map<string, { response: Response; expiry: number }>();
constructor(
private inner: HttpClient,
private ttlMs: number = 60_000,
) {}
async get(url: string): Promise<Response> {
const cached = this.cache.get(url);
if (cached && cached.expiry > Date.now()) {
return cached.response.clone();
}
const response = await this.inner.get(url);
this.cache.set(url, { response: response.clone(), expiry: Date.now() + this.ttlMs });
return response;
}
async post(url: string, body: any): Promise<Response> {
// POST はキャッシュしない
return this.inner.post(url, body);
}
}
// 使用例: デコレータを組み合わせて機能を追加
// 既存の FetchHttpClient は一切変更していない
const client: HttpClient = new CachingHttpClient(
new RetryHttpClient(
new LoggingHttpClient(
new FetchHttpClient()
),
3,
),
30_000,
);
// リクエスト → Caching → Retry → Logging → Fetch の順に処理
await client.get("https://api.example.com/data");# Python: デコレータパターン - ミドルウェアパイプライン
from abc import ABC, abstractmethod
from typing import Callable, Any
from dataclasses import dataclass, field
from datetime import datetime
import time
@dataclass
class Request:
method: str
path: str
headers: dict[str, str] = field(default_factory=dict)
body: Any = None
@dataclass
class Response:
status: int
body: Any
headers: dict[str, str] = field(default_factory=dict)
# ミドルウェアインターフェース
class Middleware(ABC):
@abstractmethod
def process(
self, request: Request, next_handler: Callable[[Request], Response]
) -> Response:
...
# 認証ミドルウェア
class AuthMiddleware(Middleware):
def __init__(self, token_verifier):
self._verifier = token_verifier
def process(self, request: Request, next_handler):
token = request.headers.get("Authorization", "").replace("Bearer ", "")
if not token or not self._verifier.verify(token):
return Response(status=401, body={"error": "Unauthorized"})
return next_handler(request)
# ログミドルウェア
class LoggingMiddleware(Middleware):
def process(self, request: Request, next_handler):
start = time.time()
print(f"→ {request.method} {request.path}")
response = next_handler(request)
elapsed = time.time() - start
print(f"← {response.status} ({elapsed:.3f}s)")
return response
# レート制限ミドルウェア
class RateLimitMiddleware(Middleware):
def __init__(self, max_requests: int, window_seconds: int):
self._max = max_requests
self._window = window_seconds
self._requests: dict[str, list[float]] = {}
def process(self, request: Request, next_handler):
client_ip = request.headers.get("X-Real-IP", "unknown")
now = time.time()
requests = self._requests.setdefault(client_ip, [])
requests = [t for t in requests if now - t < self._window]
self._requests[client_ip] = requests
if len(requests) >= self._max:
return Response(status=429, body={"error": "Too many requests"})
requests.append(now)
return next_handler(request)
# CORS ミドルウェア
class CorsMiddleware(Middleware):
def __init__(self, allowed_origins: list[str]):
self._origins = allowed_origins
def process(self, request: Request, next_handler):
response = next_handler(request)
origin = request.headers.get("Origin", "")
if origin in self._origins:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE"
return response
# パイプライン: ミドルウェアを組み合わせ(OCP達成)
class MiddlewarePipeline:
"""新しいミドルウェアは Middleware クラスを追加するだけ"""
def __init__(self, handler: Callable[[Request], Response]):
self._handler = handler
self._middlewares: list[Middleware] = []
def use(self, middleware: Middleware) -> "MiddlewarePipeline":
self._middlewares.append(middleware)
return self
def handle(self, request: Request) -> Response:
def build_chain(index: int) -> Callable[[Request], Response]:
if index >= len(self._middlewares):
return self._handler
middleware = self._middlewares[index]
return lambda req: middleware.process(req, build_chain(index + 1))
return build_chain(0)(request)
# 使用例
def app_handler(request: Request) -> Response:
return Response(status=200, body={"message": "Hello!"})
pipeline = (
MiddlewarePipeline(app_handler)
.use(LoggingMiddleware())
.use(CorsMiddleware(["https://example.com"]))
.use(RateLimitMiddleware(max_requests=100, window_seconds=60))
.use(AuthMiddleware(token_verifier))
)
response = pipeline.handle(Request(method="GET", path="/api/data"))2.5 OCP 違反の検出方法
OCP違反を検出する4つのヒューリスティック:
1. switch/if-else チェーン:
→ 同じ変数に対する type チェックが複数箇所に散在
→ 新しい type を追加するとき、全ての switch を修正する必要がある
→ Shotgun Surgery(散弾銃手術)の兆候
2. instanceof / type チェック:
→ if (obj instanceof SomeClass) が頻出
→ ポリモーフィズムで解決すべき箇所
3. 変更履歴の分析:
→ git log で「同じファイルが異なる機能追加で繰り返し修正」
→ → OCP適用の候補
4. コメント「// 新しい〜を追加する場合はここに追記」:
→ 修正箇所をコメントで示す必要がある = OCP違反
→ 本来は「クラスを追加するだけ」であるべき
検出コマンド例:
# 同じファイルが頻繁に変更されている箇所を特定
git log --format=format: --name-only --since="6 months ago" | \
sort | uniq -c | sort -rn | head -20
3. SRP と OCP の関係
SRP → クラスを小さく分割
↓
OCP → 小さなクラスをインターフェースで接続
↓
結果: 拡張が容易で変更の影響が局所的な設計
実践の流れ:
1. SRP で責任を分離
2. 変化しやすい部分を特定
3. OCP でインターフェースを設計
4. 新しい要件はクラスを追加して対応
3.1 SRP + OCP の連携パターン
// SRP と OCP が連携する実践例: 請求書システム
// Step 1: SRP で責任を分離する
// 請求書データ
interface Invoice {
id: string;
items: InvoiceItem[];
customer: Customer;
issuedAt: Date;
dueDate: Date;
}
// 税金計算(SRP: 税金計算のみ)
interface TaxCalculator {
calculate(items: InvoiceItem[]): number;
}
// 割引適用(SRP: 割引計算のみ)
interface DiscountPolicy {
apply(subtotal: number, customer: Customer): number;
}
// フォーマット(SRP: フォーマットのみ)
interface InvoiceFormatter {
format(invoice: Invoice, total: number): string;
}
// 送信(SRP: 送信のみ)
interface InvoiceSender {
send(invoice: Invoice, formatted: string): Promise<void>;
}
// Step 2: OCP で各責任を拡張可能にする
// 税金計算: 国ごとの税制に対応
class JapaneseTaxCalculator implements TaxCalculator {
calculate(items: InvoiceItem[]): number {
const subtotal = items.reduce((sum, i) => sum + i.amount, 0);
return Math.floor(subtotal * 0.10); // 消費税10%
}
}
class USStateTaxCalculator implements TaxCalculator {
constructor(private stateRate: number) {}
calculate(items: InvoiceItem[]): number {
const subtotal = items.reduce((sum, i) => sum + i.amount, 0);
return Math.floor(subtotal * this.stateRate);
}
}
// 割引: ビジネスルールに応じた割引
class VolumeDiscount implements DiscountPolicy {
apply(subtotal: number, customer: Customer): number {
if (subtotal > 100000) return subtotal * 0.05; // 5%割引
return 0;
}
}
class LoyaltyDiscount implements DiscountPolicy {
apply(subtotal: number, customer: Customer): number {
if (customer.memberSince.getFullYear() < 2020) return subtotal * 0.03;
return 0;
}
}
class CompositeDiscount implements DiscountPolicy {
constructor(private policies: DiscountPolicy[]) {}
apply(subtotal: number, customer: Customer): number {
return this.policies.reduce(
(total, policy) => total + policy.apply(subtotal, customer),
0
);
}
}
// フォーマット: 出力形式
class PdfInvoiceFormatter implements InvoiceFormatter {
format(invoice: Invoice, total: number): string {
// PDF生成ロジック
return `<pdf-data>Invoice ${invoice.id}: ¥${total}</pdf-data>`;
}
}
class HtmlInvoiceFormatter implements InvoiceFormatter {
format(invoice: Invoice, total: number): string {
return `<html><h1>Invoice ${invoice.id}</h1><p>Total: ¥${total}</p></html>`;
}
}
// 送信: 送信手段
class EmailInvoiceSender implements InvoiceSender {
async send(invoice: Invoice, formatted: string): Promise<void> {
await emailClient.send(invoice.customer.email, "Invoice", formatted);
}
}
class FaxInvoiceSender implements InvoiceSender {
async send(invoice: Invoice, formatted: string): Promise<void> {
await faxService.send(invoice.customer.faxNumber, formatted);
}
}
// Step 3: オーケストレーター(SRP: 調整のみ)
class InvoiceService {
constructor(
private taxCalc: TaxCalculator,
private discount: DiscountPolicy,
private formatter: InvoiceFormatter,
private sender: InvoiceSender,
) {}
async processInvoice(invoice: Invoice): Promise<void> {
const subtotal = invoice.items.reduce((sum, i) => sum + i.amount, 0);
const tax = this.taxCalc.calculate(invoice.items);
const discountAmount = this.discount.apply(subtotal, invoice.customer);
const total = subtotal + tax - discountAmount;
const formatted = this.formatter.format(invoice, total);
await this.sender.send(invoice, formatted);
}
}
// 使用例: 日本の顧客向け、PDF形式、メール送信
const jpService = new InvoiceService(
new JapaneseTaxCalculator(),
new CompositeDiscount([new VolumeDiscount(), new LoyaltyDiscount()]),
new PdfInvoiceFormatter(),
new EmailInvoiceSender(),
);
// 使用例: US顧客向け、HTML形式、FAX送信
const usService = new InvoiceService(
new USStateTaxCalculator(0.08),
new VolumeDiscount(),
new HtmlInvoiceFormatter(),
new FaxInvoiceSender(),
);
// 新しい税制・割引・フォーマット・送信手段 → クラスを追加するだけ
// InvoiceService は一切変更不要3.2 テスト容易性の向上
// SRP + OCP がテストを劇的に簡単にする
// テスト用モック
class MockTaxCalculator implements TaxCalculator {
calculate(items: InvoiceItem[]): number {
return 1000; // 固定値で予測可能に
}
}
class MockDiscountPolicy implements DiscountPolicy {
apply(subtotal: number, customer: Customer): number {
return 0; // 割引なし
}
}
class MockInvoiceFormatter implements InvoiceFormatter {
lastInvoice?: Invoice;
lastTotal?: number;
format(invoice: Invoice, total: number): string {
this.lastInvoice = invoice;
this.lastTotal = total;
return "formatted-invoice";
}
}
class MockInvoiceSender implements InvoiceSender {
sentInvoices: Array<{ invoice: Invoice; formatted: string }> = [];
async send(invoice: Invoice, formatted: string): Promise<void> {
this.sentInvoices.push({ invoice, formatted });
}
}
// テストコード
describe("InvoiceService", () => {
let service: InvoiceService;
let mockFormatter: MockInvoiceFormatter;
let mockSender: MockInvoiceSender;
beforeEach(() => {
mockFormatter = new MockInvoiceFormatter();
mockSender = new MockInvoiceSender();
service = new InvoiceService(
new MockTaxCalculator(),
new MockDiscountPolicy(),
mockFormatter,
mockSender,
);
});
it("should calculate total correctly", async () => {
const invoice = createTestInvoice([
{ name: "Item A", amount: 5000 },
{ name: "Item B", amount: 3000 },
]);
await service.processInvoice(invoice);
// subtotal(8000) + tax(1000) - discount(0) = 9000
expect(mockFormatter.lastTotal).toBe(9000);
});
it("should send formatted invoice", async () => {
const invoice = createTestInvoice([{ name: "Item A", amount: 5000 }]);
await service.processInvoice(invoice);
expect(mockSender.sentInvoices).toHaveLength(1);
expect(mockSender.sentInvoices[0].formatted).toBe("formatted-invoice");
});
});
// 各コンポーネントも独立してテスト可能
describe("JapaneseTaxCalculator", () => {
const calc = new JapaneseTaxCalculator();
it("should calculate 10% tax", () => {
const items = [{ name: "Item", amount: 10000 }];
expect(calc.calculate(items)).toBe(1000);
});
});
describe("VolumeDiscount", () => {
const discount = new VolumeDiscount();
it("should apply 5% discount for orders over 100000", () => {
const customer = createTestCustomer();
expect(discount.apply(200000, customer)).toBe(10000);
});
it("should not apply discount for small orders", () => {
const customer = createTestCustomer();
expect(discount.apply(50000, customer)).toBe(0);
});
});4. アンチパターンと注意点
SRP の過剰適用:
→ 1メソッドだけのクラスが大量発生
→ ファイル数が爆発してナビゲーション困難
→ 対策: 「変更する理由」で分割。メソッド数ではない
OCP の過剰適用:
→ 変更されない部分まで抽象化
→ 不要なインターフェースだらけ
→ 対策: 「実際に変更が発生してから」抽象化する
判断基準:
「このクラスが変更される理由は何か?」
→ 理由が複数ある → SRP で分割
「この部分は今後変更される可能性があるか?」
→ ある → OCP でインターフェースを導入
→ ない → そのままでよい(YAGNI)
4.1 SRP の過剰適用例
// ❌ SRP の過剰適用: 不必要な分割
// 1文字列の結合のためだけにクラスを作る必要はない
class StringConcatenator {
concatenate(a: string, b: string): string {
return a + b;
}
}
// 加算のためだけにクラスを作る必要はない
class NumberAdder {
add(a: number, b: number): number {
return a + b;
}
}
// nullチェックのためだけにクラスを作る必要はない
class NullChecker {
isNull(value: any): boolean {
return value === null || value === undefined;
}
}
// ✅ 適切な粒度: 関連する操作をまとめたクラス
class MathUtils {
static add(a: number, b: number): number { return a + b; }
static subtract(a: number, b: number): number { return a - b; }
static multiply(a: number, b: number): number { return a * b; }
static divide(a: number, b: number): number {
if (b === 0) throw new Error("Division by zero");
return a / b;
}
}
// → 変更理由: 「数学計算のルール変更」→ 1つのアクター
// → メソッドは4つだが責任は1つ = SRP準拠4.2 OCP の過剰適用例
// ❌ OCP の過剰適用: 変更が発生しない部分まで抽象化
// 環境設定の読み取り: 変更される可能性が低い
interface ConfigReader { read(): Config; }
interface ConfigParser { parse(raw: string): Config; }
interface ConfigValidator { validate(config: Config): void; }
interface ConfigMerger { merge(base: Config, override: Config): Config; }
// → 設定ファイルの読み取り方法が頻繁に変わることはない
// → 4つのインターフェースは過剰
// ✅ 適切な抽象化レベル
class ConfigLoader {
load(path: string): Config {
const raw = fs.readFileSync(path, "utf-8");
const config = JSON.parse(raw);
this.validate(config);
return config;
}
private validate(config: Config): void {
if (!config.port) throw new Error("port is required");
if (!config.dbUrl) throw new Error("dbUrl is required");
}
}
// → 設定の読み取りが変更される頻度は低い
// → シンプルなクラスで十分
// → 将来変更が必要になったら、その時に抽象化する4.3 実務での判断フローチャート
SRP 適用の判断フロー:
クラスの行数 > 300?
│
├── Yes → 変更理由を分析
│ │
│ ├── 変更理由が複数 → SRP適用(分割する)
│ └── 変更理由が1つ → 大きくてもOK(責任は1つ)
│
└── No → 複数のドメインを混在させていないか?
│
├── Yes → SRP適用(小さくても分割すべき)
└── No → 現状維持でOK
OCP 適用の判断フロー:
同じ種類の変更が2回以上発生した?
│
├── Yes → switch/if-elseの分岐が増えている?
│ │
│ ├── Yes → OCP適用(インターフェース導入)
│ └── No → もう1回変更が来たら適用を検討
│
└── No → 変更されていない
→ 現状維持(YAGNI)
→ 抽象化は「投機的」にしない
5. フレームワークにおけるSRP + OCP
主要フレームワークでの SRP + OCP の活用例:
NestJS (TypeScript):
SRP → Controller, Service, Repository の分離
OCP → @Injectable() による DI、Guard / Interceptor / Pipe
Spring Boot (Java):
SRP → @Controller, @Service, @Repository アノテーション
OCP → @Bean 定義、@Profile による環境切り替え
Django (Python):
SRP → views.py, models.py, serializers.py の分離
OCP → Middleware クラス、カスタム Backend
Rails (Ruby):
SRP → Model, Controller, Service Object パターン
OCP → Concern モジュール、ActiveSupport::Concern
// NestJS: SRP + OCP の実践例
// Controller(SRP: HTTPリクエスト処理のみ)
@Controller("orders")
class OrderController {
constructor(private readonly orderService: OrderService) {}
@Post()
async createOrder(@Body() dto: CreateOrderDto): Promise<OrderResponse> {
return this.orderService.create(dto);
}
@Get(":id")
async getOrder(@Param("id") id: string): Promise<OrderResponse> {
return this.orderService.findById(id);
}
}
// Service(SRP: ビジネスロジックのみ)
@Injectable()
class OrderService {
constructor(
private readonly repo: OrderRepository,
@Inject("PAYMENT_GATEWAY") private readonly payment: PaymentGateway,
@Inject("NOTIFIER") private readonly notifier: Notifier,
) {}
async create(dto: CreateOrderDto): Promise<OrderResponse> {
const order = Order.create(dto);
await this.payment.charge(order.total, order.id);
await this.repo.save(order);
await this.notifier.notify(order);
return OrderResponse.fromEntity(order);
}
}
// Module(OCP: 依存の差し替えが容易)
@Module({
providers: [
OrderService,
{
provide: "PAYMENT_GATEWAY",
useClass: process.env.NODE_ENV === "test"
? MockPaymentGateway
: StripePaymentGateway,
},
{
provide: "NOTIFIER",
useClass: process.env.NODE_ENV === "test"
? MockNotifier
: EmailNotifier,
},
],
})
class OrderModule {}
// Guard(OCP: 認証ロジックをプラグイン的に追加)
@Injectable()
class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return this.validateToken(request.headers.authorization);
}
}
// Interceptor(OCP: 横断的関心事をデコレータ的に追加)
@Injectable()
class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const now = Date.now();
return next.handle().pipe(
tap(() => console.log(`Response time: ${Date.now() - now}ms`)),
);
}
}
// Pipe(OCP: バリデーションをプラグイン的に追加)
@Injectable()
class OrderValidationPipe implements PipeTransform {
transform(value: any): CreateOrderDto {
const dto = plainToClass(CreateOrderDto, value);
const errors = validateSync(dto);
if (errors.length > 0) {
throw new BadRequestException(errors);
}
return dto;
}
}FAQ
Q1: このトピックを学ぶ上で最も重要なポイントは何ですか?
実践的な経験を積むことが最も重要です。理論だけでなく、実際にコードを書いて動作を確認することで理解が深まります。
Q2: 初心者がよく陥る間違いは何ですか?
基礎を飛ばして応用に進むことです。このガイドで説明している基本概念をしっかり理解してから、次のステップに進むことをお勧めします。
Q3: 実務ではどのように活用されていますか?
このトピックの知識は、日常的な開発業務で頻繁に活用されます。特にコードレビューやアーキテクチャ設計の際に重要になります。
まとめ
| 原則 | 核心 | 実現手段 | 注意 | 検出方法 |
|---|---|---|---|---|
| SRP | 1クラス1責任 | 責任の分離、委譲 | 過剰分割に注意 | クラス名・インポートテスト |
| OCP | 拡張は開、修正は閉 | インターフェース、ポリモーフィズム | 必要になってから抽象化 | switch/if-else連鎖の検出 |
SRP + OCP 適用チェックリスト
□ クラスの変更理由が1つだけか(SRP)
□ クラス名が1つの責任を表しているか(SRP)
□ import/依存が1つのドメインに限定されているか(SRP)
□ コンストラクタの引数が5つ以下か(SRP)
□ 同じ種類の分岐が複数箇所に散在していないか(OCP)
□ 新しい種類の追加でコメント「ここに追記」が必要ないか(OCP)
□ テスト時にモックに差し替え可能か(SRP + OCP)
□ 各クラスが独立してテスト可能か(SRP)
□ 変更が1クラスに閉じるか(SRP + OCP)
□ git log で同じファイルが頻繁に修正されていないか(OCP)
次に読むべきガイド
参考文献
- Martin, R. "Clean Architecture: A Craftsman's Guide to Software Structure and Design." Chapter 7-8, Prentice Hall, 2017.
- Martin, R. "The Single Responsibility Principle." The Clean Coder Blog, 2014.
- Martin, R. "Agile Software Development, Principles, Patterns, and Practices." Prentice Hall, 2003.
- Meyer, B. "Object-Oriented Software Construction." Prentice Hall, 2nd ed., 1997.
- Fowler, M. "Refactoring: Improving the Design of Existing Code." Addison-Wesley, 2nd ed., 2018.
- Gamma, E. et al. "Design Patterns: Elements of Reusable Object-Oriented Software." Addison-Wesley, 1994.
- Freeman, S. and Pryce, N. "Growing Object-Oriented Software, Guided by Tests." Addison-Wesley, 2009.