1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ドメイン駆動設計で巨大なコンポーネントを分割する

Last updated at Posted at 2025-08-24

はじめに

こんにちは!今回は、自分がやらかしたコードのリファクタリング体験談を共有したいと思います。

データ検証機能を持つeコマースチェックアウト拡張機能を開発していたとき、機能をどんどん追加していったら、気づけば1つのコンポーネントが500行超えの巨大モンスターになってしまいました...

API呼び出し、ビジネスロジック、UI状態管理が全部混ざって、「あれ?この処理どこにあったっけ?」「なんでここでエラーになるの?」みたいな状況に...

そこでドメイン駆動設計(DDD)の考え方を学んで大幅なリファクタリングを実施したところ、コードが読みやすく・保守しやすくなりました。

この経験を通じて、「責任を分離する」ことの大切さを痛感したので、同じような悩みを抱えている方に参考になればと思い、今回のアプローチを共有します!

やらかしたコードの問題点

🔥 とにかくファイルがでかい

  • 1つのファイルに500行以上... もはや何のファイルかわからない状態
  • API呼び出し、ビジネスロジック、UI状態管理が全部ごちゃ混ぜ
  • 複数の検証パターンが同じファイルに散らばり、どこに何があるのかさっぱり

🔥 「これどこにあるんだっけ?」問題

  • APIを呼び出してる場所を探すのに時間がかかる
  • ビジネスロジックとUI表示が混ざり、どっちを修正すればいいのかわからない
  • テスト書こうとしても、モックの設定が複雑すぎて心が折れる

🔥 使い回しできない

  • 検証ロジックが特定のコンポーネントにがっちり結合してて、他で使えない

救済リファクタリング作戦

ドメイン駆動設計の考え方を参考に、こんな感じで立て直しました:

1. API層をまとめる(/api ディレクトリ)

やること: 外部システムとの通信とデータ変換をここに集約

api/
├── validation/
│   ├── pattern-a/
│   │   ├── types.ts      # 型定義とAPI仕様
│   │   ├── mapper.ts     # ドメインモデル変換
│   │   └── api-hooks.ts  # API呼び出し用hooks
│   ├── pattern-b/
│   └── pattern-c/
└── external-service/
    ├── types.ts
    ├── mapper.ts
    └── api-hooks.ts

ポイント:

  • 検証パターンごとにフォルダを分ける
  • 各フォルダの中で types.ts(型定義)、mapper.ts(データ変換)、api-hooks.ts(API実行)をきっちり分離
  • 外部APIの複雑なレスポンスを、自分たちのアプリで使いやすい形に変換する

2. UI層を分ける(/app/feature/hooks ディレクトリ)

やること: 画面の表示状態管理とユーザーのアクション処理

app/feature/hooks/
└── use-XXX.ts      # UI固有の状態管理

ポイント:

  • APIから取得したデータを画面表示用に加工・管理
  • API層とは独立して動くので、それぞれ個別にテストできる
  • コンポーネントの表示状態だけに集中

実装のポイント

API層のhooks設計

// api/validation/pattern-a/api-hooks.ts の概念
export const usePatternAValidation = (inputData: InputData) => {
    const { getDataTrigger, validationTrigger } = usePatternAService(inputData);
    
    const validate = useCallback(async (
        token: string, 
        inputData: InputDataWithValidation
    ): Promise<ValidationResult> => {
        // 1. 外部APIの呼び出し
        const apiResponse = await getDataTrigger({ token });
        
        // 2. ドメインモデルへの変換
        const domainModel = mapToPatternAModel(apiResponse);
        
        // 3. ビジネスロジックの実行
        return patternAValidation(inputData, domainModel);
    }, [getDataTrigger]);

    return { validate };
};

// useSWRMutationを活用したAPI通信層
const usePatternAService = (inputData: InputData) => {
    const { trigger: getDataTrigger } = useSWRMutation(
        `${API_BASE_URL}/pattern-a/data`, 
        apiGet
    );
    
    const { trigger: validationTrigger } = useSWRMutation(
        `${API_BASE_URL}/pattern-a/validate`, 
        apiPost
    );
    
    return { getDataTrigger, validationTrigger };
};

ドメインモデル変換の詳細:

// api/validation/pattern-a/mapper.ts の概念
export const mapToPatternAModel = (apiResponse: ExternalApiResponse): PatternAModel => {
    return {
        // 外部住所APIレスポンスをアプリケーション固有のモデルに変換
        hasAddress: apiResponse.results && apiResponse.results.length > 0,
        addressList: apiResponse.results?.map(item => ({
            postalCode: item.zip_code,
            prefecture: item.pref_name,
            city: item.city_name,
            town: item.town_name || '',
        })) || []
    };
};

ビジネスロジック(検証処理)の詳細:

// api/validation/pattern-a/domain.ts の概念  
export const patternAValidation = (
    inputData: InputDataWithValidation, 
    model: PatternAModel
): ValidationResult => {
    // 1. 住所情報の存在チェック
    if (!model.hasAddress) {
        return {
            result: false,
            errorMessage: "入力された郵便番号に該当する住所が見つかりません",
            suggestFlag: false
        };
    }
    
    // 2. 入力住所と取得住所の整合性チェック
    const matchedAddress = model.addressList.find(addr => 
        addr.prefecture === inputData.prefecture && 
        addr.city === inputData.city
    );
    
    if (!matchedAddress) {
        return {
            result: false,
            errorMessage: "郵便番号と住所の組み合わせが正しくありません",
            suggestFlag: true,
            suggestedData: model.addressList[0]?.fullAddress
        };
    }
    
    // 3. 住所の文字数制限チェック(配送システムの制約)
    const fullAddress = `${inputData.prefecture}${inputData.city}${inputData.address}`;
    if (fullAddress.length > 100) {
        return {
            result: false,
            errorMessage: "住所は100文字以内で入力してください",
            suggestFlag: false
        };
    }

    return { result: true };
};

UI層のhooks設計

UI層のhooksが必要な理由:
住所検証では、API取得した住所情報をそのまま表示するのではなく、以下のようなUI固有の状態管理が必要になります

  • エラー状態の表示制御: 検証エラー時の表示・非表示
  • 住所候補の表示管理: 複数候補がある場合の選択UI
  • 修正提案の表示制御: 自動修正候補の受諾・拒否
  • 再検証のタイミング制御: ユーザーが修正後の再検証フロー
// app/feature/hooks/use-XXX.ts の概念
export const useAddressUIState = () => {
    const [addressDisplayInfo, setAddressDisplayInfo] = useState<AddressDisplayInfo>({
        showErrorMessage: false,
        showSuggestions: false,
        showRetryOption: false,
        currentError: null,
        suggestionList: [],
        userHasSeenSuggestion: false
    });

    // エラー表示の制御
    const showError = useCallback((errorMessage: string, showRetry: boolean = false) => {
        setAddressDisplayInfo(prev => ({
            ...prev,
            showErrorMessage: true,
            showRetryOption: showRetry,
            currentError: errorMessage
        }));
    }, []);

    // 住所候補の表示制御
    const showSuggestions = useCallback((suggestions: AddressSuggestion[]) => {
        setAddressDisplayInfo(prev => ({
            ...prev,
            showSuggestions: true,
            suggestionList: suggestions,
            userHasSeenSuggestion: true
        }));
    }, []);

    // UI状態のリセット
    const resetDisplayState = useCallback(() => {
        setAddressDisplayInfo({
            showErrorMessage: false,
            showSuggestions: false,
            showRetryOption: false,
            currentError: null,
            suggestionList: [],
            userHasSeenSuggestion: false
        });
    }, []);

    return {
        addressDisplayInfo,
        showError,
        showSuggestions,
        resetDisplayState
    };
};

ファイル構成と依存関係

  1. 📋 MainComponent.tsx (エントリーポイント)
  2. 🔗 api/external-platform/hooks.ts (外部データ取得)
  3. ✅ api/validation/pattern-a/api-hooks.ts (検証処理)
  4. 🎨 app/feature/hooks/use-XXX.ts (UI制御)

フォルダ構成はこんな感じ:

📋 メインコンポーネント (app/feature/MainComponent.tsx)
├─ 🔗 外部プラットフォームAPI (api/external-platform/hooks.ts)
├─ ✅ 住所検証API (api/validation/pattern-a/api-hooks.ts)
└─ 🎨 UI状態管理 (app/feature/hooks/use-XXX.ts)

各ファイルの役割:

ファイル 処理内容 読むべきポイント
📋 MainComponent.tsx 全体の処理フローを統合 どのhooksをいつ呼び出すか
🔗 external-platform/hooks.ts 外部システムから住所・認証情報を取得 生データを内部形式に変換する方法
✅ validation/pattern-a/api-hooks.ts 住所検証API呼び出しとビジネスロジック実行 検証の3ステップ(API→変換→検証)
🎨 feature/hooks/use-XXX.ts UI表示状態の管理 エラー・候補表示の制御方法

📋 1. メインコンポーネント(全体の司令塔)

何してる?: 各hooksを組み合わせて住所検証の流れを作ってる

// app/feature/MainComponent.tsx
// 【この処理の流れ】
// 1. 外部プラットフォームから住所データを取得
// 2. 住所検証APIで検証実行  
// 3. 結果に応じてUI状態を更新
// 4. UI状態に基づいて表示コンポーネントを切り替え

import { useCallback } from 'react';
import { useExternalPlatformAPI } from '../../api/external-platform/hooks';
import { usePatternAValidation } from '../../api/validation/pattern-a/api-hooks';
import { usePatternBValidation } from '../../api/validation/pattern-b/api-hooks';
import { useAddressUIState } from './hooks/use-XXX';

function MainComponent() {
    // 🔗 外部プラットフォームからデータ取得
    const { inputAddress, fetchAuthToken } = useExternalPlatformAPI();
    
    // ✅ 住所検証API
    const { validate: validatePatternA } = usePatternAValidation(inputAddress);
    const { validate: validatePatternB } = usePatternBValidation(inputAddress);
    
    // 🎨 UI状態管理
    const { addressDisplayInfo, showError, showSuggestions, resetDisplayState } = useAddressUIState();
    
    // 【核となる処理】検証実行とUI状態更新
    const handleValidation = useCallback(async () => {
        resetDisplayState();
        const authToken = await fetchAuthToken();
        
        // 地域別の検証処理
        let validationResult;
        switch (inputAddress.regionCode) {
            case 'PATTERN_A':
                validationResult = await validatePatternA(authToken, inputAddress);
                break;
            case 'PATTERN_B':
                validationResult = await validatePatternB(authToken, inputAddress);
                break;
        }
        
        // 結果に応じたUI状態更新
        if (!validationResult.result) {
            if (validationResult.suggestFlag) {
                showSuggestions([validationResult.suggestedData]);
            } else {
                showError(validationResult.errorMessage, validationResult.timeValidationFlag);
            }
        }
    }, [inputAddress, validatePatternA, validatePatternB, showError, showSuggestions, resetDisplayState, fetchAuthToken]);
    
    // 【UI表示の分岐】状態に応じてコンポーネントを切り替え
    if (addressDisplayInfo.showErrorMessage) {
        return <ErrorDisplay error={addressDisplayInfo.currentError} showRetry={addressDisplayInfo.showRetryOption} />;
    }
    
    if (addressDisplayInfo.showSuggestions) {
        return <SuggestionDisplay suggestions={addressDisplayInfo.suggestionList} />;
    }
    
    return <NormalCheckoutFlow />;
}

🔗 2. 外部プラットフォーム連携(データ取得係)

何してる?: 外部システム(Shopify等)から住所・認証情報をもらって、使いやすい形に変換

// api/external-platform/hooks.ts
import { useCallback } from 'react';
import { useApi, useShippingAddress } from '@external-platform/ui-extensions-react/checkout';
import { transformToInternalAddressFormat } from './mapper';

/**
 * 外部プラットフォーム(Shopify等)との連携hooks
 * 呼び出し元: app/feature/MainComponent.tsx
 * 依存先: @external-platform/ui-extensions-react/checkout
 */
export const useExternalPlatformAPI = () => {
    const { sessionToken, checkoutToken } = useApi(); // プラットフォーム提供のhooks
    const shippingAddress = useShippingAddress();      // 配送先住所の取得
    
    const getInputAddress = useCallback(() => {
        // プラットフォーム固有の住所形式を内部形式に変換
        return transformToInternalAddressFormat(shippingAddress);
    }, [shippingAddress]);
    
    const fetchAuthToken = useCallback(async () => {
        return await sessionToken.get();
    }, [sessionToken]);
    
    const getCurrentSession = useCallback(() => {
        return {
            sessionId: sessionToken.current,
            checkoutId: checkoutToken.current
        };
    }, [sessionToken, checkoutToken]);
    
    return {
        inputAddress: getInputAddress(),
        getInputAddress,
        fetchAuthToken,
        getCurrentSession
    };
};
// api/external-platform/mapper.ts
// 【データ変換の責任】外部形式 → 内部形式の変換ルール
/**
 * プラットフォーム固有の住所形式を内部形式に変換
 * 外部システムの項目名に依存しないよう、統一された形式に変換
 */
export const transformToInternalAddressFormat = (platformAddress: PlatformAddress): InternalAddress => {
    return {
        regionCode: platformAddress.countryCode,     // 国コード
        postalCode: platformAddress.zip,             // 郵便番号
        prefecture: platformAddress.provinceCode,    // 都道府県
        city: platformAddress.city,                  // 市区町村
        address: platformAddress.address1,           // 住所1
        addressDetail: platformAddress.address2 || '' // 住所2(任意)
    };
};

✅ 3. 住所検証API(検証の実行部隊)

何してる?: 住所検証APIを呼び出して、ビジネスルールに基づき住所をチェック

// api/validation/pattern-a/api-hooks.ts
// 【この処理の流れ】
// 1. useSWRMutationで住所検証APIを呼び出し
// 2. mapToPatternAModelでレスポンスを内部形式に変換
// 3. patternAValidationでビジネスロジック検証を実行

export const usePatternAValidation = (inputData: InputData) => {
    const { getDataTrigger, validationTrigger } = usePatternAService(inputData);
    
    const validate = useCallback(async (
        token: string, 
        inputData: InputDataWithValidation
    ): Promise<ValidationResult> => {
        // 【ステップ1】外部APIの呼び出し
        const apiResponse = await getDataTrigger({ token });
        
        // 【ステップ2】ドメインモデルへの変換
        const domainModel = mapToPatternAModel(apiResponse);
        
        // 【ステップ3】ビジネスロジックの実行
        return patternAValidation(inputData, domainModel);
    }, [getDataTrigger]);

    return { validate };
};

🎨 4. UI状態管理(画面の制御係)

何してる?: 検証結果に応じて画面の表示を切り替え
エラー・候補表示の出し分けロジックと状態リセットの仕組みを提供してます

// app/feature/hooks/use-XXX.ts
// 【この処理の役割】
// - エラー表示のON/OFF制御
// - 住所候補リストの表示管理
// - 再検証時の状態リセット

export const useAddressUIState = () => {
    const [addressDisplayInfo, setAddressDisplayInfo] = useState<AddressDisplayInfo>({
        showErrorMessage: false,
        showSuggestions: false,
        showRetryOption: false,
        currentError: null,
        suggestionList: [],
        userHasSeenSuggestion: false
    });

    // 【エラー表示制御】検証失敗時に呼び出される
    const showError = useCallback((errorMessage: string, showRetry: boolean = false) => {
        setAddressDisplayInfo(prev => ({
            ...prev,
            showErrorMessage: true,
            showRetryOption: showRetry,
            currentError: errorMessage
        }));
    }, []);

    // 【候補表示制御】修正候補がある場合に呼び出される
    const showSuggestions = useCallback((suggestions: AddressSuggestion[]) => {
        setAddressDisplayInfo(prev => ({
            ...prev,
            showSuggestions: true,
            suggestionList: suggestions,
            userHasSeenSuggestion: true
        }));
    }, []);

    // 【状態リセット】新しい検証開始時に呼び出される
    const resetDisplayState = useCallback(() => {
        setAddressDisplayInfo({
            showErrorMessage: false,
            showSuggestions: false,
            showRetryOption: false,
            currentError: null,
            suggestionList: [],
            userHasSeenSuggestion: false
        });
    }, []);

    return {
        addressDisplayInfo,
        showError,
        showSuggestions,
        resetDisplayState
    };
};

リファクタリングしてよかったこと

✅ メンテがめちゃくちゃ楽になった

  • 各検証ロジックが独立したフォルダに分かれてる
  • 新しい検証パターンを追加するときは、該当フォルダを作るだけでOK
  • バグが出ても「あー、あのフォルダね」って影響範囲がすぐわかる

✅ テストが書けるように!

  • API層とUI層を別々にテストできる
  • モックの設定がシンプル(各hooks単位でモック化すればいい)
  • ビジネスロジックのユニットテストがサクサク書ける

✅ 使い回せるようになった

  • 検証APIを他の機能でも簡単に使える
  • 同じ検証ロジックを違う画面でも使い回せる

✅ 開発が早くなった

  • 「あのコードどこだっけ?」がなくなった
  • 新しいメンバーも「あー、こういう構成ね」ってすぐ理解してくれる
  • API仕様が変わっても、修正箇所が限定的で済む

今回学んだこと

1. 「分割」より「責任分離」が大事

単純に「ファイルを分ける」んじゃなくて、「責任を分ける」ことが重要でした。API通信の責任、ビジネスロジックの責任、UI状態管理の責任をきっちり分けることで、それぞれが独立して成長できるようになったんです。

2. フォルダ構成で設計思想を表現

api/validation/pattern-a/
api/validation/pattern-b/

こんな感じで、フォルダ構成自体が「ここはこういうドメインだよ」って表現することで、誰が見ても「あー、こういう設計なのね」って直感的に理解できるようになりました。

3. hooksの役割をはっきり分ける

ReactのhooksをAPIアクセス用とUI状態管理用できっちり分離して、それぞれが独自のライフサイクルを持つように設計。これでコンポーネントがスッキリして、テストも書きやすくなりました。

まとめ

ドメイン駆動設計の考え方を使うことで:

  • API層: 外部システムとの通信とデータ変換
  • UI層: ユーザーの操作と画面の状態管理

この2つの責任をスパッと分離できました。

結果として、500行超えの巨大モンスターコンポーネントが、それぞれの責任に応じた小さくて理解しやすいモジュールに生まれ変わりました!

特に複雑な外部サービスと連携するようなアプリでは、こういう設計が長期的な保守性でめちゃくちゃ重要だなと実感しました。

同じような「うわー、このコードやばい...」って状況になってる方の参考になれば嬉しいです🙌


1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?