ストーリー
ミッション概要
| ミッション | テーマ | 対象原則 | 目安時間 |
|---|---|---|---|
| Mission 1 | 神クラスを分割せよ | SRP | 15分 |
| Mission 2 | 条件分岐を拡張可能にせよ | OCP | 15分 |
| Mission 3 | 壊れた継承を直せ | LSP | 15分 |
| Mission 4 | 太ったインターフェースを整理せよ | ISP | 15分 |
| Mission 5 | 具体への依存を断ち切れ | DIP | 15分 |
| Mission 6 | 総合:全原則を適用せよ | 全SOLID | 15分 |
Mission 1: 神クラスを分割せよ(15分)
要件
以下の OrderManager クラスは SRP に違反しています。適切に分割してください。
class OrderManager {
createOrder(userId: string, items: CartItem[]): Order {
// バリデーション
if (items.length === 0) throw new Error('Cart is empty');
for (const item of items) {
if (item.quantity <= 0) throw new Error('Invalid quantity');
}
// 価格計算
let total = 0;
for (const item of items) {
total += item.price * item.quantity;
if (item.category === 'food') total -= item.price * item.quantity * 0.08;
}
// DB保存
const order: Order = { id: generateId(), userId, items, total, status: 'pending' };
db.query(`INSERT INTO orders VALUES (...)`, order);
// メール送信
const user = db.query(`SELECT * FROM users WHERE id = ?`, [userId]);
smtp.send(user.email, 'Order Confirmed', `Your order #${order.id} has been placed.`);
// ログ記録
fs.appendFileSync('orders.log', `${new Date().toISOString()} Order ${order.id} created\n`);
return order;
}
}
期待される動作
- 少なくとも4つのクラスに分割する
- それぞれのクラスが1つの変更理由だけを持つ
- 元の
createOrderと同等の処理が実行される
解答
// 責任1: 注文のバリデーション
class OrderValidator {
validate(items: CartItem[]): void {
if (items.length === 0) throw new Error('Cart is empty');
for (const item of items) {
if (item.quantity <= 0) throw new Error('Invalid quantity');
}
}
}
// 責任2: 価格計算
class PriceCalculator {
calculate(items: CartItem[]): number {
return items.reduce((total, item) => {
const subtotal = item.price * item.quantity;
const discount = item.category === 'food' ? subtotal * 0.08 : 0;
return total + subtotal - discount;
}, 0);
}
}
// 責任3: 注文の永続化
class OrderRepository {
save(order: Order): void {
db.query(`INSERT INTO orders VALUES (...)`, order);
}
}
// 責任4: 通知
class OrderNotifier {
constructor(private userRepository: UserRepository) {}
notifyOrderCreated(order: Order): void {
const user = this.userRepository.findById(order.userId);
smtp.send(user.email, 'Order Confirmed', `Your order #${order.id} has been placed.`);
}
}
// 責任5: ログ記録
class OrderLogger {
logCreation(order: Order): void {
fs.appendFileSync('orders.log', `${new Date().toISOString()} Order ${order.id} created\n`);
}
}
// オーケストレーション
class OrderService {
constructor(
private validator: OrderValidator,
private calculator: PriceCalculator,
private repository: OrderRepository,
private notifier: OrderNotifier,
private logger: OrderLogger
) {}
createOrder(userId: string, items: CartItem[]): Order {
this.validator.validate(items);
const total = this.calculator.calculate(items);
const order: Order = { id: generateId(), userId, items, total, status: 'pending' };
this.repository.save(order);
this.notifier.notifyOrderCreated(order);
this.logger.logCreation(order);
return order;
}
}
Mission 2: 条件分岐を拡張可能にせよ(15分)
要件
以下の通知システムはOCPに違反しています。新しい通知チャネルを追加しやすい設計にリファクタリングしてください。
class NotificationService {
send(userId: string, message: string, channel: string): void {
if (channel === 'email') {
const user = this.getUser(userId);
smtp.send(user.email, 'Notification', message);
} else if (channel === 'sms') {
const user = this.getUser(userId);
smsGateway.send(user.phone, message);
} else if (channel === 'slack') {
slackApi.postMessage(userId, message);
}
// push通知を追加したい場合、ここを修正する必要がある...
}
}
期待される動作
- インターフェースを定義し、各チャネルを個別のクラスにする
- 新しいチャネルの追加で既存コードの変更が不要
解答
interface NotificationChannel {
send(userId: string, message: string): void;
}
class EmailChannel implements NotificationChannel {
constructor(private userRepository: UserRepository) {}
send(userId: string, message: string): void {
const user = this.userRepository.findById(userId);
smtp.send(user.email, 'Notification', message);
}
}
class SmsChannel implements NotificationChannel {
constructor(private userRepository: UserRepository) {}
send(userId: string, message: string): void {
const user = this.userRepository.findById(userId);
smsGateway.send(user.phone, message);
}
}
class SlackChannel implements NotificationChannel {
send(userId: string, message: string): void {
slackApi.postMessage(userId, message);
}
}
// 新しいチャネルの追加:クラスを追加するだけ
class PushChannel implements NotificationChannel {
send(userId: string, message: string): void {
pushService.send(userId, { title: 'Notification', body: message });
}
}
class NotificationService {
private channels: Map<string, NotificationChannel> = new Map();
registerChannel(name: string, channel: NotificationChannel): void {
this.channels.set(name, channel);
}
send(userId: string, message: string, channelName: string): void {
const channel = this.channels.get(channelName);
if (!channel) throw new Error(`Unknown channel: ${channelName}`);
channel.send(userId, message);
}
}
Mission 3: 壊れた継承を直せ(15分)
要件
以下のコードはLSPに違反しています。ReadOnlyConfig を使ったときにエラーが発生しないよう設計を修正してください。
class Config {
protected data: Map<string, string> = new Map();
get(key: string): string | undefined {
return this.data.get(key);
}
set(key: string, value: string): void {
this.data.set(key, value);
}
delete(key: string): void {
this.data.delete(key);
}
}
class ReadOnlyConfig extends Config {
set(key: string, value: string): void {
throw new Error('Cannot modify read-only config');
}
delete(key: string): void {
throw new Error('Cannot modify read-only config');
}
}
期待される動作
ReadOnlyConfigの利用者が予期しないエラーに遭わない- 読み取り専用と読み書き可能の両方を安全に扱える
解答
interface ConfigReader {
get(key: string): string | undefined;
has(key: string): boolean;
}
interface ConfigWriter {
set(key: string, value: string): void;
delete(key: string): void;
}
class ReadOnlyConfig implements ConfigReader {
constructor(private data: Map<string, string>) {}
get(key: string): string | undefined {
return this.data.get(key);
}
has(key: string): boolean {
return this.data.has(key);
}
}
class MutableConfig implements ConfigReader, ConfigWriter {
private data: Map<string, string> = new Map();
get(key: string): string | undefined {
return this.data.get(key);
}
has(key: string): boolean {
return this.data.has(key);
}
set(key: string, value: string): void {
this.data.set(key, value);
}
delete(key: string): void {
this.data.delete(key);
}
}
// 読み取りだけ必要な場所
function displayConfig(config: ConfigReader): void {
// set/delete は呼べない = エラーが起きない
console.log(config.get('app.name'));
}
// 書き込みも必要な場所
function updateConfig(config: ConfigWriter, key: string, value: string): void {
config.set(key, value);
}
Mission 4: 太ったインターフェースを整理せよ(15分)
要件
以下のインターフェースはISPに違反しています。適切に分離してください。
interface DataStore {
read(id: string): any;
write(id: string, data: any): void;
delete(id: string): void;
search(query: string): any[];
backup(): void;
restore(backupId: string): void;
getStats(): StoreStats;
}
// 検索だけしたいクライアント
class SearchService {
constructor(private store: DataStore) {} // 7メソッド中1つしか使わない
find(query: string): any[] {
return this.store.search(query);
}
}
期待される動作
- 役割ごとに3-4個のインターフェースに分離する
- 各クライアントが必要なインターフェースだけに依存する
解答
interface DataReader {
read(id: string): any;
}
interface DataWriter {
write(id: string, data: any): void;
delete(id: string): void;
}
interface DataSearcher {
search(query: string): any[];
}
interface DataBackup {
backup(): void;
restore(backupId: string): void;
}
interface DataMonitor {
getStats(): StoreStats;
}
// 全機能を持つ実装
class FullDataStore implements DataReader, DataWriter, DataSearcher, DataBackup, DataMonitor {
read(id: string): any { /* ... */ }
write(id: string, data: any): void { /* ... */ }
delete(id: string): void { /* ... */ }
search(query: string): any[] { return []; }
backup(): void { /* ... */ }
restore(backupId: string): void { /* ... */ }
getStats(): StoreStats { return {} as StoreStats; }
}
// 各クライアントは必要なインターフェースだけに依存
class SearchService {
constructor(private store: DataSearcher) {}
find(query: string): any[] {
return this.store.search(query);
}
}
class BackupService {
constructor(private store: DataBackup) {}
performBackup(): void {
this.store.backup();
}
}
class CrudService {
constructor(
private reader: DataReader,
private writer: DataWriter
) {}
// ...
}
Mission 5: 具体への依存を断ち切れ(15分)
要件
以下のコードはDIPに違反しています。抽象に依存するように修正してください。
class StripePaymentGateway {
charge(amount: number, cardToken: string): string {
// Stripe固有のAPI呼び出し
return `stripe-txn-${Date.now()}`;
}
}
class OrderService {
private gateway = new StripePaymentGateway(); // 具体クラスに直接依存
placeOrder(order: Order, cardToken: string): string {
const txnId = this.gateway.charge(order.total, cardToken);
return txnId;
}
}
期待される動作
- 決済ゲートウェイのインターフェースを定義する
OrderServiceはインターフェースに依存する- テスト用のモック実装を作成する
解答
interface PaymentGateway {
charge(amount: number, paymentToken: string): string;
}
class StripePaymentGateway implements PaymentGateway {
charge(amount: number, paymentToken: string): string {
// Stripe固有の実装
return `stripe-txn-${Date.now()}`;
}
}
class PayPalPaymentGateway implements PaymentGateway {
charge(amount: number, paymentToken: string): string {
// PayPal固有の実装
return `paypal-txn-${Date.now()}`;
}
}
// テスト用モック
class MockPaymentGateway implements PaymentGateway {
public lastCharge: { amount: number; token: string } | null = null;
charge(amount: number, paymentToken: string): string {
this.lastCharge = { amount, token: paymentToken };
return 'mock-txn-123';
}
}
class OrderService {
constructor(private gateway: PaymentGateway) {} // 抽象に依存
placeOrder(order: Order, paymentToken: string): string {
return this.gateway.charge(order.total, paymentToken);
}
}
// 使い方
const service = new OrderService(new StripePaymentGateway());
// テスト時
const mockGateway = new MockPaymentGateway();
const testService = new OrderService(mockGateway);
Mission 6: 総合:全原則を適用せよ(15分)
要件
以下のコードに含まれるすべてのSOLID原則違反を特定し、修正してください。
class ReportSystem {
generateSalesReport(startDate: Date, endDate: Date): string {
// DB直接アクセス(DIP違反)
const db = new PostgresDB();
const sales = db.query(`SELECT * FROM sales WHERE date BETWEEN ...`);
// 集計処理とフォーマットが混在(SRP違反)
let total = 0;
let report = 'Sales Report\n';
for (const sale of sales) {
total += sale.amount;
if (sale.type === 'online') {
report += `[Online] ${sale.amount}\n`;
} else if (sale.type === 'store') {
report += `[Store] ${sale.amount}\n`;
}
// 新しい種類を追加するたびに else if が増える(OCP違反)
}
report += `Total: ${total}`;
return report;
}
}
解答
// DIP: 抽象に依存
interface SalesRepository {
findByDateRange(start: Date, end: Date): Sale[];
}
// OCP: 販売タイプごとのフォーマッター
interface SaleFormatter {
readonly type: string;
format(sale: Sale): string;
}
class OnlineSaleFormatter implements SaleFormatter {
readonly type = 'online';
format(sale: Sale): string { return `[Online] ${sale.amount}`; }
}
class StoreSaleFormatter implements SaleFormatter {
readonly type = 'store';
format(sale: Sale): string { return `[Store] ${sale.amount}`; }
}
// SRP: 集計ロジック
class SalesAggregator {
calculateTotal(sales: Sale[]): number {
return sales.reduce((sum, sale) => sum + sale.amount, 0);
}
}
// SRP: レポート組み立て
class SalesReportGenerator {
private formatters: Map<string, SaleFormatter> = new Map();
constructor(
private repository: SalesRepository,
private aggregator: SalesAggregator,
formatters: SaleFormatter[]
) {
for (const f of formatters) {
this.formatters.set(f.type, f);
}
}
generate(startDate: Date, endDate: Date): string {
const sales = this.repository.findByDateRange(startDate, endDate);
const total = this.aggregator.calculateTotal(sales);
const lines = sales.map(sale => {
const formatter = this.formatters.get(sale.type);
return formatter ? formatter.format(sale) : `[Unknown] ${sale.amount}`;
});
return `Sales Report\n${lines.join('\n')}\nTotal: ${total}`;
}
}
達成度チェック
| ミッション | テーマ | 完了 |
|---|---|---|
| Mission 1 | SRP — 神クラスの分割 | |
| Mission 2 | OCP — 条件分岐の拡張可能化 | |
| Mission 3 | LSP — 壊れた継承の修正 | |
| Mission 4 | ISP — 太ったインターフェースの分離 | |
| Mission 5 | DIP — 具体依存の排除 | |
| Mission 6 | 全SOLID原則の総合適用 |
まとめ
| ポイント | 内容 |
|---|---|
| SRP | クラスの変更理由を1つにする |
| OCP | インターフェースで拡張ポイントを作る |
| LSP | 基底型と派生型の契約を守る |
| ISP | 必要な操作だけのインターフェースを定義する |
| DIP | 具体ではなく抽象に依存する |
チェックリスト
- 各原則の違反パターンを認識できるようになった
- 各原則に基づくリファクタリングを実行できた
- 複数の原則を組み合わせた設計ができた
次のステップへ
次はチェックポイントクイズです。SOLID原則の理解度を確認しましょう。
推定読了時間: 90分