Open/Closed Principle(開放閉鎖の原則)

原則の意味

「ソフトウェアエンティティは拡張に対して開いていて、修正に対して閉じているべきである」

つまり、新しい機能を追加する際に、既存のコードを変更せずに拡張できるように設計すべきということです。

なぜOCPが重要か

  • 既存のコードを変更するとバグを混入するリスクがある
  • 変更のたびに全体の再テストが必要になる
  • 並行開発時の競合を減らせる
  • システムの安定性を保ちながら成長できる

拡張可能な設計パターン

1. ストラテジーパターン

アルゴリズムを切り替え可能にする設計パターン

❌ 拡張に閉じた設計
class PriceCalculator {
    calculate(product, customerType) {
        let price = product.basePrice;
        
        // 新しい顧客タイプを追加するたびに
        // このメソッドを修正する必要がある
        if (customerType === 'regular') {
            return price;
        } else if (customerType === 'silver') {
            return price * 0.95; // 5%割引
        } else if (customerType === 'gold') {
            return price * 0.90; // 10%割引
        } else if (customerType === 'platinum') {
            return price * 0.85; // 15%割引
        }
        
        return price;
    }
}
✅ 拡張に開いた設計
// 価格戦略のインターフェース
interface PricingStrategy {
    calculatePrice(basePrice: number): number;
}

// 具体的な戦略の実装
class RegularPricing implements PricingStrategy {
    calculatePrice(basePrice: number): number {
        return basePrice;
    }
}

class SilverPricing implements PricingStrategy {
    calculatePrice(basePrice: number): number {
        return basePrice * 0.95;
    }
}

class GoldPricing implements PricingStrategy {
    calculatePrice(basePrice: number): number {
        return basePrice * 0.90;
    }
}

// 新しい戦略を追加(既存コードの変更不要)
class PlatinumPricing implements PricingStrategy {
    calculatePrice(basePrice: number): number {
        return basePrice * 0.85;
    }
}

// 戦略を使用するクラス
class PriceCalculator {
    constructor(private strategy: PricingStrategy) {}
    
    calculate(product): number {
        return this.strategy.calculatePrice(product.basePrice);
    }
}

2. デコレーターパターン

機能を動的に追加できる設計パターン

// 基本となるインターフェース
interface Coffee {
    getCost(): number;
    getDescription(): string;
}

// 基本実装
class SimpleCoffee implements Coffee {
    getCost(): number {
        return 100;
    }
    
    getDescription(): string {
        return "シンプルコーヒー";
    }
}

// デコレーターの基底クラス
abstract class CoffeeDecorator implements Coffee {
    constructor(protected coffee: Coffee) {}
    
    getCost(): number {
        return this.coffee.getCost();
    }
    
    getDescription(): string {
        return this.coffee.getDescription();
    }
}

// 具体的なデコレーター
class MilkDecorator extends CoffeeDecorator {
    getCost(): number {
        return this.coffee.getCost() + 50;
    }
    
    getDescription(): string {
        return this.coffee.getDescription() + "、ミルク";
    }
}

class SugarDecorator extends CoffeeDecorator {
    getCost(): number {
        return this.coffee.getCost() + 20;
    }
    
    getDescription(): string {
        return this.coffee.getDescription() + "、砂糖";
    }
}

// 使用例(機能を組み合わせる)
let coffee = new SimpleCoffee();
coffee = new MilkDecorator(coffee);
coffee = new SugarDecorator(coffee);

console.log(coffee.getDescription()); // "シンプルコーヒー、ミルク、砂糖"
console.log(coffee.getCost()); // 170

3. プラグインアーキテクチャ

システムの核となる部分を変更せずに機能を追加できる設計

// プラグインインターフェース
interface Plugin {
    name: string;
    version: string;
    execute(context: AppContext): void;
}

// アプリケーションのコア
class Application {
    private plugins: Plugin[] = [];
    
    registerPlugin(plugin: Plugin) {
        this.plugins.push(plugin);
        console.log(`Plugin registered: ${plugin.name} v${plugin.version}`);
    }
    
    executePlugins(context: AppContext) {
        for (const plugin of this.plugins) {
            try {
                plugin.execute(context);
            } catch (error) {
                console.error(`Plugin error in ${plugin.name}:`, error);
            }
        }
    }
}

// プラグインの実装例
class LoggingPlugin implements Plugin {
    name = "Logger";
    version = "1.0.0";
    
    execute(context: AppContext) {
        console.log(`[LOG] ${context.action} at ${new Date()}`);
    }
}

class MetricsPlugin implements Plugin {
    name = "Metrics";
    version = "1.0.0";
    
    execute(context: AppContext) {
        // メトリクスを収集
        metrics.record(context.action, context.duration);
    }
}

// 新しいプラグインの追加(コアの変更不要)
class SecurityPlugin implements Plugin {
    name = "Security";
    version = "1.0.0";
    
    execute(context: AppContext) {
        // セキュリティチェック
        if (!context.user.isAuthenticated) {
            throw new Error("Unauthorized");
        }
    }
}

インターフェース分離の原則(ISP)

原則の内容

「クライアントは、自分が使用しないメソッドに依存すべきではない」

❌ 肥大化したインターフェース
interface Worker {
    work(): void;
    eat(): void;
    sleep(): void;
    getPaid(): void;
    takeVacation(): void;
}

// ロボットはeat()やsleep()を実装できない
class Robot implements Worker {
    work() { /* 作業 */ }
    eat() { 
        throw new Error("Robots don't eat");
    }
    sleep() { 
        throw new Error("Robots don't sleep");
    }
    getPaid() { /* 支払い処理 */ }
    takeVacation() { 
        throw new Error("Robots don't take vacation");
    }
}
✅ 分離されたインターフェース
// 責任ごとにインターフェースを分離
interface Workable {
    work(): void;
}

interface Eatable {
    eat(): void;
}

interface Sleepable {
    sleep(): void;
}

interface Payable {
    getPaid(): void;
}

interface Vacationable {
    takeVacation(): void;
}

// 必要なインターフェースのみ実装
class Human implements Workable, Eatable, Sleepable, Payable, Vacationable {
    work() { /* 作業 */ }
    eat() { /* 食事 */ }
    sleep() { /* 睡眠 */ }
    getPaid() { /* 給料受取 */ }
    takeVacation() { /* 休暇 */ }
}

class Robot implements Workable, Payable {
    work() { /* 作業 */ }
    getPaid() { /* 支払い処理 */ }
}

拡張ポイントの設計

フックとテンプレートメソッド

// テンプレートメソッドパターン
abstract class DataProcessor {
    // テンプレートメソッド(処理の流れを定義)
    process(data: any[]): any[] {
        const validated = this.validate(data);
        const transformed = this.transform(validated);
        const result = this.aggregate(transformed);
        this.afterProcess(result);
        return result;
    }
    
    // 必須の抽象メソッド
    protected abstract validate(data: any[]): any[];
    protected abstract transform(data: any[]): any[];
    protected abstract aggregate(data: any[]): any[];
    
    // オプショナルなフック
    protected afterProcess(result: any[]): void {
        // デフォルトは何もしない
    }
}

// 具体的な実装
class SalesDataProcessor extends DataProcessor {
    protected validate(data: any[]): any[] {
        return data.filter(item => item.amount > 0);
    }
    
    protected transform(data: any[]): any[] {
        return data.map(item => ({
            ...item,
            tax: item.amount * 0.1
        }));
    }
    
    protected aggregate(data: any[]): any[] {
        // 月別に集計
        return Object.values(
            data.reduce((acc, item) => {
                const month = item.date.substring(0, 7);
                if (!acc[month]) {
                    acc[month] = { month, total: 0 };
                }
                acc[month].total += item.amount + item.tax;
                return acc;
            }, {})
        );
    }
    
    // フックをオーバーライド
    protected afterProcess(result: any[]): void {
        console.log(`Processed ${result.length} months of data`);
    }
}

実装のベストプラクティス

拡張性を高める設計指針

  • 具体的なクラスではなく、インターフェースに依存する
  • 設定より規約(Convention over Configuration)を活用
  • コンポジションを継承より優先する
  • 変更の可能性が高い部分を予測し、拡張ポイントを設ける
  • YAGNIの原則も忘れない(必要になるまで実装しない)