ストーリー
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分