LESSON 30分

ストーリー

高橋アーキテクト
コードの問題はメソッドだけにあるわけではない。データの構造自体が悪いと、すべてのロジックが複雑になる
高橋アーキテクト
今日は、データ構造を整理するリファクタリング技法を学ぼう。特に”Extract Class”と”Value Object の導入”は、コードの質を劇的に改善する

Extract Class(クラスの抽出)

1つのクラスが多すぎるデータと責任を持っている場合、関連するデータとメソッドを新しいクラスに抽出します。

Before

class Customer {
  name: string;
  // 住所関連(まとまっている)
  street: string;
  city: string;
  state: string;
  zipCode: string;
  country: string;
  // 電話関連(まとまっている)
  areaCode: string;
  phoneNumber: string;
  extension: string;

  getFullAddress(): string {
    return `${this.street}, ${this.city}, ${this.state} ${this.zipCode}, ${this.country}`;
  }

  getFullPhoneNumber(): string {
    const ext = this.extension ? ` ext.${this.extension}` : '';
    return `(${this.areaCode}) ${this.phoneNumber}${ext}`;
  }
}

After

class Address {
  constructor(
    readonly street: string,
    readonly city: string,
    readonly state: string,
    readonly zipCode: string,
    readonly country: string
  ) {}

  format(): string {
    return `${this.street}, ${this.city}, ${this.state} ${this.zipCode}, ${this.country}`;
  }

  isSameRegion(other: Address): boolean {
    return this.state === other.state && this.country === other.country;
  }
}

class PhoneNumber {
  constructor(
    readonly areaCode: string,
    readonly number: string,
    readonly extension?: string
  ) {}

  format(): string {
    const ext = this.extension ? ` ext.${this.extension}` : '';
    return `(${this.areaCode}) ${this.number}${ext}`;
  }

  isSameArea(other: PhoneNumber): boolean {
    return this.areaCode === other.areaCode;
  }
}

class Customer {
  constructor(
    readonly name: string,
    readonly address: Address,
    readonly phone: PhoneNumber
  ) {}
}

Value Object(値オブジェクトの導入)

プリミティブ型の代わりに、ビジネスの意味を持つ不変オブジェクトを導入します。

Before:プリミティブ執着

function createUser(
  email: string,
  amount: number,
  currency: string,
  dateOfBirth: string
): void {
  // email の形式チェックはどこで?
  // amount が負の場合は?
  // currency が有効かどうかは?
  // dateOfBirth のフォーマットは?
}

After:値オブジェクトの導入

class Email {
  private readonly value: string;

  constructor(value: string) {
    if (!Email.isValid(value)) {
      throw new Error(`Invalid email: ${value}`);
    }
    this.value = value.toLowerCase();
  }

  static isValid(value: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
  }

  toString(): string {
    return this.value;
  }

  getDomain(): string {
    return this.value.split('@')[1];
  }

  equals(other: Email): boolean {
    return this.value === other.value;
  }
}

class Money {
  constructor(
    readonly amount: number,
    readonly currency: string
  ) {
    if (amount < 0) throw new Error('Amount cannot be negative');
    if (!['JPY', 'USD', 'EUR'].includes(currency)) {
      throw new Error(`Invalid currency: ${currency}`);
    }
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error('Cannot add different currencies');
    }
    return new Money(this.amount + other.amount, this.currency);
  }

  multiply(factor: number): Money {
    return new Money(Math.round(this.amount * factor), this.currency);
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }

  format(): string {
    if (this.currency === 'JPY') return `${this.amount.toLocaleString()}円`;
    return `${this.currency} ${this.amount.toFixed(2)}`;
  }
}

class DateOfBirth {
  private readonly date: Date;

  constructor(value: string | Date) {
    this.date = value instanceof Date ? value : new Date(value);
    if (isNaN(this.date.getTime())) throw new Error('Invalid date');
    if (this.date > new Date()) throw new Error('Date of birth cannot be in the future');
  }

  getAge(): number {
    const today = new Date();
    let age = today.getFullYear() - this.date.getFullYear();
    const monthDiff = today.getMonth() - this.date.getMonth();
    if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < this.date.getDate())) {
      age--;
    }
    return age;
  }

  isAdult(): boolean {
    return this.getAge() >= 18;
  }
}

// 使い方
function createUser(
  email: Email,
  balance: Money,
  dateOfBirth: DateOfBirth
): void {
  // バリデーションは値オブジェクトの生成時に完了している
  console.log(`Email domain: ${email.getDomain()}`);
  console.log(`Balance: ${balance.format()}`);
  console.log(`Age: ${dateOfBirth.getAge()}`);
}

Replace Data Value with Object(データ値をオブジェクトに置換)

単純なデータが成長して振る舞いを持つべきときに使います。

// Before:注文ステータスが文字列
class Order {
  status: string; // 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled'

  canCancel(): boolean {
    return this.status === 'pending' || this.status === 'confirmed';
  }

  canShip(): boolean {
    return this.status === 'confirmed';
  }
}

// After:ステータスを値オブジェクトに
class OrderStatus {
  private static readonly TRANSITIONS: Record<string, string[]> = {
    pending: ['confirmed', 'cancelled'],
    confirmed: ['shipped', 'cancelled'],
    shipped: ['delivered'],
    delivered: [],
    cancelled: [],
  };

  constructor(private readonly value: string) {
    if (!OrderStatus.TRANSITIONS[value]) {
      throw new Error(`Invalid status: ${value}`);
    }
  }

  canTransitionTo(newStatus: string): boolean {
    return OrderStatus.TRANSITIONS[this.value]?.includes(newStatus) ?? false;
  }

  transitionTo(newStatus: string): OrderStatus {
    if (!this.canTransitionTo(newStatus)) {
      throw new Error(`Cannot transition from ${this.value} to ${newStatus}`);
    }
    return new OrderStatus(newStatus);
  }

  canCancel(): boolean {
    return this.canTransitionTo('cancelled');
  }

  canShip(): boolean {
    return this.canTransitionTo('shipped');
  }

  toString(): string {
    return this.value;
  }
}

class Order {
  private status: OrderStatus = new OrderStatus('pending');

  confirm(): void {
    this.status = this.status.transitionTo('confirmed');
  }

  ship(): void {
    this.status = this.status.transitionTo('shipped');
  }

  cancel(): void {
    this.status = this.status.transitionTo('cancelled');
  }
}

値オブジェクトの設計原則

原則説明
不変性生成後に値が変わらない(readonly)
自己検証コンストラクタでバリデーション
等価性値に基づく比較(equals メソッド)
副作用なし操作は新しいオブジェクトを返す

高橋アーキテクトのアドバイス:

「値オブジェクトは”小さくて退屈な”クラスだ。でも、この退屈さが大きな価値を持つ。バリデーションが1箇所に集約され、不正な値がシステムに入り込む余地がなくなる。地味だが、コード品質への貢献は計り知れない」


まとめ

ポイント内容
Extract Class関連するデータとロジックを新しいクラスに
Value Objectプリミティブ型を意味のあるオブジェクトに
不変性値オブジェクトは不変で安全
自己検証バリデーションをコンストラクタに集約

チェックリスト

  • Extract Class で大きなクラスを分割できる
  • 値オブジェクトを設計・実装できる
  • プリミティブ執着のコードスメルを解消できる

次のステップへ

次は「テスト駆動リファクタリング」です。テストを安全網にしてリファクタリングを進める方法を学びます。


推定読了時間: 30分