拡張性の設計
Open/Closed Principle (OCP) とインターフェース設計で、変更に強いシステムを構築する
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の原則も忘れない(必要になるまで実装しない)