🫣

設計と実装を分離して、ReactComponentの保守性を高める

2024/09/10に公開

こんばんは。
株式会社CHILLNNという京都のスタートアップでフルサイクルエンジニアをしております大島と申します。

弊社では現在アプリケーションのリプレイスを行っているのですが、そのリプレイスと並行してフロントエンド側のリアーキテクチャを行っています。
リプレイス後のフロントエンドでは、React, TypeScriptで実装しており、特定の境界で「関数型ドメインモデリング」に影響を受け、DDDのパラダイムを導入しています。

導入するに至るまで、数多くの試行錯誤を重ねてきた中でいくつかの知見を獲得しました。
それらの知見を本記事にて具体例を用いて紹介出来ればと思っております。

何か少しでもみなさまのお役に立てれば幸いです。

はじめに

本記事で紹介する開発手法では、
設計と実装を分離し、保守性・可読性の高いReactComponentを目指しています。

背景

弊社では主に以下のような課題がありました。

  1. デザイン要求が変化するたびに、ビジネスロジックを表現するコードも同時に修正する必要が生じていた
  2. 1.に依存する形でレビューコストが増加してしまっていた

得られた主なメリット

  1. Componentが取りうる状態を端的に表現することで、自然言語でのレビューが容易になった
  2. 設計をコードで表現したことで、Componentが持つ責務が掴みやすくなった
  3. ビジネスロジックのみをモデリングによって端的に表現し、副作用を完全に外に追い出すことが可能になった
  4. コンパイラレベルでステートが取りうる値に制約を加えることで、条件分岐がシンプルになった

具体的な実装例

ここらからは具体的に実装例を紹介していきます。
例として、私たちは旅行業をドメインをとしていますので、宿泊日を選択するカレンダーとします。

コアドメインの定義

ユーザーイベントの洗い出し

まずは起こり得るユーザーのイベントを考えていきます。

  1. チェックイン日を選択する
  2. チェックイン日を選択した状態で、チェックアウト日を選択する
  3. 選択している日付をリセットする

取り得る状態をモデリングし、型として表現

洗い出したユーザーイベントを元にComponetが取り得る状態を考えていきます。
今回の場合、以下のケースが想定されます。

  1. 未選択
  2. チェックイン日のみ選択
  3. チェックイン/アウト日を選択

上記の取り得る状態を型として宣言していきます。

reservation-calendar-for-stay/model/state/index.ts
export namespace State {
  /**
   * チェックイン/アウト日が未選択状態
   */
  export type StayNotSelected = {
    kind: "StayNotSelected";
  };
  /**
   * チェックイン日が選択状態
   */
  export type StayCinSelected = {
    kind: "StayCinSelected";
    cinDt: string;
  };
  /**
   * チェックイン/アウト日が選択状態
   */
  export type StayCoutSelected = {
    kind: "StayCoutSelected";
    cinDt: string;
    coutDt: string;
  };

  export type State = StayNotSelected | StayCinSelected | StayCoutSelected;
};

次に状態が遷移するケースを型で表現していきます。
この時次の状態へ遷移する際に必要なプロパティを第2引数以降に宣言します。

reservation-calendar-for-stay/model/state-machine/index.ts
export namespace StateMachine {
  /**
   * 初期化
   */
  export type Initialize = (input: {
    stayStartDt: string | null;
    stayLastDt: string | null;
  }) => State.State;
  /**
   * 未選択の状態へ遷移する
   */
  export type CreateStayNotSelected = (input: State.StayCinSelected | State.StayCoutSelected) => State.StayNotSelected;
  /**
   * チェックイン日を選択中の状態へ遷移する
   */
  export type CreateStayCinSelected = (input: State.StayNotSelected | State.StayCoutSelected) => (cinDt: string) => State.StayCinSelected;
  /**
   * チェックイン/アウト日を選択中の状態へ遷移する
   */
  export type CreateStayCoutSelected = (input: State.StayCinSelected) => (coutDt: string) => State.StayCoutSelected;
};

これでモデリングは完了です。

表現した型を関数として実装

次に、状態の遷移をそれぞれ実装していきます。
状態を宣言するにあたっての必要なプロパティは引数でのみ受け取っているので、素直に実装することが可能です。

reservation-calendar-for-stay/model/service/initialize/index.ts
/**
 * 初期化
 */
export const initialize: StateMachine.Initialize = (input) => {
  if (!Method.isNullOrUndefined(input.stayStartDt) && !Shared.Method.isNullOrUndefined(input.stayLastDt)) {
    return {
      kind: "StayCoutSelected",
      cinDt: input.stayStartDt,
      coutDt: Shared.Lib.ChillnnDayjs.addDate({ date: input.stayLastDt, inc: 1 }),
    };
  }

  if (!Method.isNullOrUndefined(input.stayStartDt) && Shared.Method.isNullOrUndefined(input.stayLastDt)) {
   return {
      kind: "StayCinSelected",
      cinDt: input.stayStartDt,
      coutDt: null,
    };
  }

  return {
    kind: "StayNotSelected",
  };
};
reservation-calendar-for-stay/model/service/create-stay-not-selected/index.ts
/**
 * 未選択の状態へ遷移する
 */
export const createStayNotSelected: StateMachine.CreateStayNotSelected = () => {
  return {
    kind: "StayNotSelected",
  };
};
reservation-calendar-for-stay/model/service/create-stay-cin-selected/index.ts
/**
 * チェックイン日を選択中の状態へ遷移する
 */
export const createStayCinSelected: StateMachine.CreateStayCinSelected = () => (cinDt) => {
  return {
    kind: "StayCinSelected",
    cinDt,
  };
};

reservation-calendar-for-stay/model/service/create-stay-cout-selected/index.ts
/**
 * チェックイン/アウト日を選択中の状態へ遷移する
 */
export const createStayCoutSelected: StateMachine.CreateStayCoutSelected = (input) => (coutDt) => {
  return {
    kind: "StayCoutSelected",
    cinDt: input.cinDt,
    coutDt,
  };
};

次に、冒頭で洗い出したユーザーイベントを実装していきます。
この時に状態と状態の遷移を引き起こす関数、副作用をPropsとして注入します。

reservation-calendar-for-stay/use-case/use-select-stay-un-selectable-date/index.ts
type Props = {
  state: Model.State.State;
  onChangeState: (state: Model.State.State) => void;

  onRemoveReservationStayDt: () => Promise<void>;
};

/**
 * 未選択状態へ変更するユーザーイベント
 */
export const useSelectStayUnSelectableDate = ({
  state,
  onChangeState,

  onRemoveReservationStayDt,
}: Props) => {
    const onSelectStayUnSelectableDate = useCallback(async () => {
    if (state.kind === "StayNotSelected") {
      throw new Error();
    }

    const sideEffect = async () => {
     if (state.kind === "StayCoutSelected") {
        await onRemoveReservationStayDt();
      }
    };

    await sideEffect().finally(() => {
      const newState = Model.Service.createStayNotSelected(state);
      onChangeState(newState);
    });
  }, [onChangeState, onRemoveReservationStayDt, state]);

  return {
    onSelectStayUnSelectableDate,
  };
};

reservation-calendar-for-stay/use-case/use-select-stay-cin-date/index.ts
type Props = {
  state: Model.State.State;
  onChangeState: (state: Model.State.State) => void;

  onAvailableStayLastDateListQuery: (input: { stayStartDt: string; searchYearMonth: string }) => Promise<void>;
};

/**
 * チェックイン日を選択状態へ変更するユーザーイベント
 */
export const useSelectStayCinDate = ({
  state,
  onChangeState,

  onAvailableStayLastDateListQuery,
}: Props) => {
  const onSelectStayCinDate = useCallback((searchYearMonth) => async (cinDt) => {
    if (state.kind === "StayCinSelected") {
      throw new Error();
    }

    const sideEffect = async () => {
      await onAvailableStayLastDateListQuery({ stayStartDt: cinDt, searchYearMonth });
    };

    await sideEffect().finally(() => {
      const newState = Model.Service.createStayCinSelected(state)(cinDt);
      onChangeState(newState);
    });
  }, [onAvailableStayLastDateListQuery, onChangeState, state]);

  return {
    onSelectStayCinDate,
  };
};
reservation-calendar-for-stay/use-case/use-select-stay-cout-date/index.ts
type Props = {
  state: Model.State.State;
  onChangeState: (state: Model.State.State) => void;

  onUpdateReservationStayDt: (input: { stayStartDt: string; stayLastDt: string }) => Promise<void>;
  onAvailableStayStartDateListQuery: (input: { searchYearMonth: string }) => Promise<void>;
};

/**
 * チェックイン/アウト日を選択状態へ変更するユーザーイベント
 */
export const useSelectStayCoutDate = ({
  state,
  onChangeState,

  onUpdateReservationStayDt,
  onAvailableStayStartDateListQuery,
}: Props) => {
  const onSelectStayCoutDate = useCallback((searchYearMonth) => async (coutDt) => {
    if (state.kind !== "StayCinSelected") {
      throw new Error();
    }

    const sideEffect = async () => {
      await onUpdateReservationStayDt({
        stayStartDt: state.cinDt,
        stayLastDt: Shared.Lib.ChillnnDayjs.addDate({ date: coutDt, inc: -1 }),
      });
      await onAvailableStayStartDateListQuery({ searchYearMonth });
    };

    await sideEffect().finally(() => {
      const newState = Model.Service.createStayCoutSelected(state)(coutDt);
      onChangeState(newState);
    });
  }, [onAvailableStayStartDateListQuery, onChangeState, onUpdateReservationStayDt, state]);

  return {
    onSelectStayCoutDate,
  };
};

最後に前段で定義した関数を呼び出し、状態とユーザーイベントを宣言していきます。

reservation-calendar-for-stay/use-model-context/index.ts
type Props = {
  stayStartDt: string | null;
  stayLastDt: string | null;

  onRemoveReservationStayDt: () => Promise<void>;
  onUpdateReservationStayDt: (input: { stayStartDt: string; stayLastDt: string }) => Promise<void>;
  onAvailableStayStartDateListQuery: (input: { searchYearMonth: string }) => Promise<void>;
  onAvailableStayLastDateListQuery: (input: { stayStartDt: string; searchYearMonth: string }) => Promise<void>;
};

export const useModelContext = ({
  stayStartDt,
  stayLastDt,

  onRemoveReservationStayDt,
  onUpdateReservationStayDt,
  onAvailableStayStartDateListQuery,
  onAvailableStayLastDateListQuery,
}: Props) => {
  const [state, setState] = useState<Model.State.State>(
    Model.Service.initialize({ stayStartDt, stayLastDt }),
  );

  const onChangeState = useCallback((input: Model.State.State) => {
    setState(input);
  }, []);

  const { onSelectStayCinDate } = UseCase.useSelectStayCinDate({
    state,
    onChangeState,
    onAvailableStayLastDateListQuery,
  });
  const { onSelectStayCoutDate } = UseCase.useSelectStayCoutDate({
    state,
    onChangeState,
    onUpdateReservationStayDt,
    onAvailableStayStartDateListQuery,
  });
  const { onSelectStayUnSelectableDate } = UseCase.useSelectStayUnSelectableDate({
    state,
    onChangeState,
    onRemoveReservationStayDt,
  });

  return {
    state,

    onSelectStayCinDate,
    onSelectStayCoutDate,
    onSelectStayUnSelectableDate,
  };
};

サブドメインの定義

最後にサブドメインの定義を行います。
コアドメインで定義した状態ごとにそれぞれのComponentを定義していきます。
今回の例ではそのうち、コアドメインが「未選択状態」の状態を例にします。

ユーザーイベントの洗い出し

コアドメインと同様にまずは起こり得るユーザーイベントから考えていきます。

  1. チェックイン日を選択する

この時にコアドメインとは違い、ユーザーイベントの実装は行いません。
サブドメインとなるComponentはそれ自身で状態の遷移せず、フレームワークに則して再度計算が行われる為です。

取り得る状態をモデリングし、型として表現

同様にユーザーイベントを元にComponetが取り得る状態を考えていきます。

  1. 選択不可の日付
  2. チェックイン日として選択可能な日付

上記の取り得る状態を型として宣言していきます。

when-not-selected/model/state/index.ts
  export type StayAsCinDateNotSelectable = {
    kind: "StayAsCinDateNotSelectable";
    date: string;
  };
  export type StayAsCinDateSelectable = {
    kind: "StayAsCinDateSelectable";
    date: string;
    price: number;
    onSelect: () => void;
  };
  export type State = StayAsCinDateNotSelectable | StayAsCinDateSelectable;
};

次に状態が遷移するケースを型で表現していきます。
この時コアドメインで定義したユーザーイベントを渡します。

when-not-selected/model/state-machine/index.ts
export namespace StateMachine {
  export type Initialize = (input: {
    date: string;
    onGetAvailableStayStartDateList: () => Array<ReservationCalendar.TAvailableStayStartDate>;
    onSelectStayCinDate: (cinDt: string) => void;
  }) => State.State;
  export type InitializeForStayAsCinDateSelectable = (input: {
    date: string;
    price: number;
  }) => State.StayAsCinDateSelectable;
}

表現した型を関数として実装

今回のサブドメインではイベントハンドラーをオブジェクトに注入します。
これによって、Componentが受け取るイベントをコンパイラレベルで制限することができます。

when-not-selected/model/service/initialize/index.ts
/**
 * 初期化
 */
export const initialize: StateMachine.Initialize = (input) => {
  const matchedAvailableDate = onGetAvailableStayStartDateList().find((availableDate) => {
    return Shared.Lib.ChillnnDayjs.isSame({ targetDate: availableDate.date, compareDate: date }),
  });
  if (!Shared.Method.isNullOrUndefined(matchedAvailableDate) && matchedAvailableDate.selectable) {
    return {
      kind: "StayAsCinDateSelectable",
      date,
      price: matchedAvailableDate.roomMinimumPrice ?? 0,
      onSelect: () => onSelectStayCinDate(date),
    };
  } else {
    return {
      kind: "StayAsCinDateNotSelectable",
      date,
    };
  }
}

最後に前段で定義した関数を呼び出し、状態を宣言していきます。

when-not-selected/use-model-context/index.ts
type Props = {
  date: string;
  onGetAvailableStayStartDateList: () => Array<ReservationCalendar.TAvailableStayStartDate>;
  onSelectStayCinDate: (cinDt: string) => void;
};

export const useModelContext = (props: Props) => {
  const state = useMemo(() => {
    return Model.Service.initialize({
      date: props.date,
      onGetAvailableStayStartDateList: props.onGetAvailableStayStartDateList,
      onSelectStayCinDate: props.onSelectStayCinDate,
    });
  }, [date, onGetAvailableStayStartDateList, onSelectStayCinDate]);

  return {
    state,
  };
};

Tips

1. ステートフルかステーレスかで性質が異なる

コアドメインでuseStateを通して状態の遷移を可能にしていたように、ステートを持つステートフルComponentでは状態の遷移の契機となるユーザーイベントが定義されました。
一方で、サブドメインは状態の遷移が行われないステートレスなComponentになるため、自身にフレームワークへ再レンダリングを促すイベントを注入します。

2. 状態の命名は扱うドメインの抽象度によって表現方法が異なる

コアドメインは行った処理の完了状態を意識した命名が考えられ、サブドメインではユーザーアクションを意識した命名が考えられます。

3. 単一の状態で表現出来ない場合は、状態を分割する

状態の粒度が荒く設計されてしまうと、コンパイラレベルで縛れないドメイン知識が多く現れてしまい、主題としている「設計と実装の分離」が達成出来なくなってしまいます。

4. ライフサイクルはReactに合わせる

パラダイムというよりReactの思想部分にはなりますが、必要最小限の状態を宣言し、それ以外では都度再計算を行うメンタルモデルを持つことをオススメします。

終わりに

いかがでしたでしょうか?
設計と実装の分離」が一定実現出来ているのではないかと思います。
初めはモデリングに右往左往したり、記述量、ファイル数も増えてしまうので手戻りコストが高くなってしまったのですが、慣れてくればそこまで気にならなくなりました。

また、Componentが持つべきイベント・状態を中心に設計と実装がされていくので、純粋なドメインロジックが定義されるので可読性・保守性も高くなり、複雑にしがちであった副作用をドメインロジックから追い出すことが可能になったのが良いなと感じています。
一方で、まだまだ改善の余地は残されているので、様々なパラダイム等と組み合わせて継続して改善を行なっていく予定です。

最後になりますが、弊社ではエンジニア採用を積極的に行なっています!
ぜひ一緒にサービスを作っていければと思っておりますので、もし少しでもご興味をお持ち頂ければカジュアル面談をさせてください!

株式会社CHILLNN

Discussion