booklista tech blog

booklista のエンジニアリングに関する情報を公開しています。

SOLID原則の勉強会を行いました

こんにちは。株式会社ブックリスタの葉山です。私が担当するReader Store(運営:株式会社ソニー・ミュージックエンタテインメント)の開発グループでは、不定期ですがナレッジ共有会を実施しています。 今回はSOLID原則について勉強会を実施したので、その内容をご紹介します。

背景

現在のReader Storeのソースコードは長年の開発が積み重なり、いわゆる技術的負債がたまっている状態となっています。「このコードが何をしたいのか」「なぜそうしているのか」が読み取りづらく、デグレードが起きやすい状態を改善していくために、業務ルールを集約し、ルール間を疎結合にしていく必要性があると感じています。

またAIの活用においても「生成するコードの品質」、「テストの書きやすさ」、「レビューのしやすさ」など、ボトルネックを生みにくいコードにしていかないと開発速度向上に頭打ち感が出てきてしまいます。

勉強会では実際のソースコードを題材にSOLID原則をどのように適用できるのか、改善案を出しながら説明していきました。ブログでは分かりやすいように実際のソースコードを一部改変しています。

題材:カートに入れる

題材はReactで実装されているカートに入れる処理です。適度に業務ルールがあり、ソースコードも改善しがいがありそうでした。

現状のカートに入れる処理

カートに入れる処理は1つのhookにまとまっています。

const useCartDispatcher = () => {
  const [isUpdating, setIsUpdating] = useState(false);
  const handleError = (isBundle: boolean) => (e: unknown) => {
    if (isApiError(e)) {
      // カートイン失敗のユーザー通知
    }
    return Promise.reject(e);  // ← 呼び元にもエラーを伝える
  };

  return {
    isUpdating,
    addCart: (id) => {
      setIsUpdating(true);
      return addCart({ ids: [id] })  // カートに入れるAPIを実行
        .then(() => {
          // 分析用データ連携
          // カートイン成功のユーザー通知
          // 最新のカート情報再取得
        })
        .catch(handleError(false))
        .finally(() => setIsUpdating(false));
    },
  };
};

現状のカートに入れる処理の呼び元

カートに入れる処理は複数の箇所から呼ばれているのですが、カートに入れる対象の商品が1つしかない単一商品ページのケースと複数商品ページのケースで呼び方が大きく異なっています。

単一商品ページ

const CartButton = () => {
  const { contents, dispatch } = useContents();
  const { addCart, isUpdating } = useCartDispatcher();  // カートに入れる処理

  const onClick = () => {
    addCart(contents.id)
      .then(() => {
        // ページ内のコンテキストを更新
      })
      .catch(          
        // 未ログイン時のリトライ処理(未ログイン時はカート利用不可)
      );
  };
  // ...
};

複数商品ページ

複数商品が並ぶページの場合、商品ごとに購入済、カート登録済など複雑な状態管理をするとページ表示パフォーマンスに影響するため単一商品ページとは異なる処理を行っています。 画面が商品ごとのカート状態を持たず単一商品ページではほぼ発生しないカート内に登録済、購入済のときにカートボタンが押せるという状態が生じるため、APIのエラーを正常ケースにフォールバックしています。

export const useAddCartForMultiBook = () => {
  const { addCart, isUpdating } = useCartDispatcher();  // カートに入れる処理
  const dispatcher = useContext();

  const addCartFunction = (id) => {
    addCart(id)
      .then(()  => {
        // ページ内のコンテキストを更新
      })
      .catch((e) => {
        if (isStorebackApiError(e)) {
          const error = e.data?.errors.at(0);
          // ★ "100"=すでにカート内に登録済。API上はエラーだがカート処理上は成功扱い。
          if (error?.errorCode === "100") {
            // ページ内のコンテキストを更新
          }
          // ★ "200"=すでに購入済。API上はエラーだがカート処理上は成功扱い。
          if (error?.errorCode === "200") {
            // ページ内のコンテキストを更新
          }
        }
        if (isAuthError(e)) {
          // 未ログイン時のリトライ処理(未ログイン時はカート利用不可)
        }
        return Promise.reject(e);
      });
  };

  return { addCart: addCartFunction, isUpdating };
};

現状の処理の気になるところ

  • APIサーバーのエラーコードが呼び元まで露出しているので変更影響が広い
  • APIサーバーの処理を知らないとカートに入れる処理が呼べない
  • リトライ処理が呼び元に露出している

変更時の影響範囲が広いことと、呼び元が呼び先の詳細を知らないと実装できないことは表裏一体の問題です。現状のソースコードは適切に責務が分離できていない状態といえます。SOLID原則を念頭に置くと、責務が分離できていない状態に気づきやすいです。

SOLID原則に当てはめた場合

原則 このコードのどこが違反しているか
SRP (単一責任) 「カートに入れる」の全貌が1箇所では把握できない
OCP (拡張に開き修正に閉じる) エラーコードの追加などカートの仕様変更のたびに、全呼び元を修正する必要がある
DIP (依存逆転) 呼び元が API のエラーコード文字列に直接依存しているので API の実装を知らないとハンドリングできない

今回の勉強会では、 LSP(置換可能性)と ISP(インターフェース分離) には触れませんでした。SOLID はクラスベースの OOP から生まれた原則ですが、Next.js + React ではクラス継承を使いません(React 自体が「継承より合成」を推奨)。TypeScript の interface を「実装を強制する契約」として使う場面も少ないです。

改善案A:エラーハンドリングを hook に閉じ込める

コンセプト

APIの実装詳細を呼び元まで露出しないようにすることで、影響範囲を限定します。
APIの処理結果にどういう状態があるのかが結果オブジェクトを見るだけで分かるようにします。

ポイントはこの2点です。

  • エラーコードはカートに入れる処理から外には伝播させない
  • エラーコードの代わりに状態をオブジェクトで返す

改善案A:カートに入れる処理

export type AddToCartResult = {
  successIds: string[];          // カートに追加された
  alreadyInCartIds: string[];    // すでにカートにあった(エラーコード"100"に相当)
  alreadyPurchasedIds: string[]; // すでに購入済みだった(エラーコード"200"に相当)
  authRequired: boolean;         // 認証エラー(ログイン必要)
};

const useAddToCart = () => {
  const [isUpdating, setIsUpdating] = useState(false);

  const addToCart = async (
    id: string,
  ): Promise<AddToCartResult> => {
    setIsUpdating(true);
    try {
      await addCart({ ids: [id] });  // カートに入れるAPIを実行
      // 分析用データ連携
      // カートイン成功のユーザー通知
      // 最新のカート情報再取得
      return {
        successIds: [id],
        alreadyInCartIds: [],
        alreadyPurchasedIds: [],
        authRequired: false,
      };
    } catch (e) {
      // エラーコードの解釈はここに集約
      const result = classifyError(e, [id]);
      // 通知の出し分けもここで完結("すでにカートにある" なら通知しない等)
      return result;
    } finally {
      setIsUpdating(false);
    }
  };

  return { addToCart, isUpdating };
};

// 内部ヘルパー(hook 内に閉じる)
const classifyError = (e: unknown, ids: string[]): AddToCartResult => {
  if (isStorebackApiError(e)) {
    const code = e.data?.errors.at(0)?.errorCode;
    if (code === "100") return { successIds: [], alreadyInCartIds: ids, alreadyPurchasedIds: [], authRequired: false };
    if (code === "200") return { successIds: [], alreadyInCartIds: [], alreadyPurchasedIds: ids, authRequired: false };
  }
  if (isAuthError(e)) {
    // 未ログイン
    return { successIds: [], alreadyInCartIds: [], alreadyPurchasedIds: [], authRequired: true };
  }
  // その他のエラーは全滅扱いで返す
  return { successIds: [], alreadyInCartIds: [], alreadyPurchasedIds: [], authRequired: false };
};

改善案Aでの呼び元

単一商品ページ

const CartButton = () => {
  const { contents, dispatch } = useContents();
  const { addToCart, isUpdating } = useAddToCart();  // カートに入れる処理

  const onClick = async () => {
    const result = await addToCart(contents.id);

    if (result.successIds.includes(contents.id) ||
        result.alreadyInCartIds.includes(contents.id)) {
      // ページ内のコンテキストを更新
    }
    if (result.authRequired) {
      // 未ログイン時のリトライ処理(未ログイン時はカート利用不可)
    }
  };
  // ...
};

複数商品ページ

エラーコードでのハンドリングがなくなったので、処理もすっきりしました。

export const useAddCartForMultiBook = () => {
  const { addToCart, isUpdating } = useAddToCart();  // カートに入れる処理
  const dispatcher = useContext();

  const addCartFunction = async (id: string) => {
    const result = await addToCart(id);

    // 「カート状態に追加すべき id」= 成功 + すでにカート内
    const cartIds = [...result.successIds, ...result.alreadyInCartIds];
    if (cartIds.length > 0) {
      // ページ内のコンテキストを更新
    }

    if (result.alreadyPurchasedIds.length > 0) {
      // ページ内のコンテキストを更新
    }

    if (result.authRequired) {
      // 未ログイン時のリトライ処理(未ログイン時はカート利用不可)
    }
  };

  return { addCart: addCartFunction, isUpdating };
};

改善案B:宣言的な関数にする

コンセプト

改善案Aでは、結果の状態に合わせて呼び元で処理の分岐を考える必要があります。本当の意味での呼び元の関心事は成功した時にどうするかに集約されているはずです。
カートに入れる処理が成功した際に何をするかをコールバックで渡す方式にします。

改善案B:カートに入れる処理

const useAddToCart = (handlers: {
  onCartUpdated?: (ids: string[]) => void;        // 成功 + すでにカート
  onPurchasedDetected?: (ids: string[]) => void;  // すでに購入済み
  onAuthRequired?: () => void;                    // ログインが必要
}) => {
  // ...
  const addToCart = async (id) => {
    let result: AddToCartResult;
    try {
      await addCart({ ids: [id] });  // カートに入れるAPIを実行
      // 分析用データ連携
      // カートイン成功のユーザー通知
      // 最新のカート情報再取得
      result = { successIds: [id], alreadyInCartIds: [], alreadyPurchasedIds: [], authRequired: false };
    } catch (e) {
      result = classifyError(e, [id]);
    }

    const cartIds = [...result.successIds, ...result.alreadyInCartIds];
    if (cartIds.length > 0) handlers.onCartUpdated?.(cartIds);
    if (result.alreadyPurchasedIds.length > 0) handlers.onPurchasedDetected?.(result.alreadyPurchasedIds);
    if (result.authRequired) handlers.onAuthRequired?.();
  };
  return { addToCart, isUpdating };
};

// 内部ヘルパー(hook 内に閉じる)
const classifyError = (e: unknown, ids: string[]): AddToCartResult => {
  // 案Aと同じ
};

改善案B:呼び元の処理

単一商品ページ、複数商品ページで処理の差異はほとんどなくなります。

const { addToCart } = useAddToCart({
  onCartUpdated: (ids) => {
    // ページ内のコンテキストを更新
  },
  onPurchasedDetected: (ids) => {
    // ページ内のコンテキストを更新
  },
  onAuthRequired: () => {
    // 未ログイン時のリトライ処理(未ログイン時はカート利用不可)
  },
});

// 結果のハンドリングが不要になる
await addToCart(id);

改善案C:横断的関心事は「合成」で対応する

コンセプト

案Bまでで業務ルールの集約が進み、呼び元はカートに入れる処理の詳細を気にせず画面の実装に集中できるコードとなりました。最後に残っているのが未ログイン時のリトライ処理です。これは案A・Bでは解消されなかった SRP(単一責任)違反の部分です。
ログインが必要な処理はログインしてからリトライするという流れは、カート固有の関心事ではありません。カートでもなく、呼び元の画面でもない横断的な関心事となる業務ルールを適切に配置するために合成を使います。

改善案C:リトライ処理

// fn を「未ログインなら、再実行アクションを保存してログインへ誘導する」で包んで返す
export const withAuthRetry = <A extends unknown[], R>(
  fn: (...args: A) => Promise<R>,
  describe: (...args: A) => RetryAction, // ログイン復帰後に何を再実行するか
  redirectUrl?: string,  // ログイン後に遷移するページ
) =>
  (...args: A): Promise<R> =>
    fn(...args).catch((e) => {
      if (isAuthError(e)) saveActionAndLogin(describe(...args), redirectUrl);
      return Promise.reject(e);
    });

改善案C:カートに入れる処理

const useAddToCart = (config: {
  describeRetry: (id: string) => RetryAction;      // 何を再実行するか
  redirectUrl?: string;                            // ログイン後に遷移するページ
  onCartUpdated?: (ids: string[]) => void;         // 成功 + すでにカート
  onPurchasedDetected?: (ids: string[]) => void;   // すでに購入済み
  // ★ onAuthRequired は消えた。未ログイン対応はもう呼び元の関心事ではない
}) => {
  const [isUpdating, setIsUpdating] = useState(false);

  // ★ 未ログイン時リトライをここで合成。カートに入れる処理も呼び元も詳細を知らなくてよい
  const addCartWithAuth = withAuthRetry(
    (id: string) => addCart({ ids: [id] }),  // カートに入れるAPIを実行
    config.describeRetry,
    config.redirectUrl,
  );

  const addToCart = async (id: string) => {
    setIsUpdating(true);
    let result: AddToCartResult;
    try {
      await addCartWithAuth(id);
      // 分析用データ連携
      // カートイン成功のユーザー通知
      // 最新のカート情報再取得
      result = { successIds: [id], alreadyInCartIds: [], alreadyPurchasedIds: [], authRequired: false };
    } catch (e) {
      // 認証エラーは withAuthRetry が「保存してログインへ誘導」まで処理済み。
      // ここに残るのは「結果の意味づけ」だけ(案A/Bと同じ classifyError)
      result = classifyError(e, [id]);
    } finally {
      setIsUpdating(false);
    }

    const cartIds = [...result.successIds, ...result.alreadyInCartIds];
    if (cartIds.length > 0) config.onCartUpdated?.(cartIds);
    if (result.alreadyPurchasedIds.length > 0) config.onPurchasedDetected?.(result.alreadyPurchasedIds);
    // result.authRequired はもう呼び元に届けない(hook の外に出る前に処理が完結している)
  };

  return { addToCart, isUpdating };
};

改善案C:呼び元の処理

const { addToCart } = useAddToCart({
  describeRetry: (id) => ({ action: "cart", id}), // ← 宣言だけ
  onCartUpdated: (ids) => {
    // ページ内のコンテキストを更新
  },
  onPurchasedDetected: (ids) => {
    // ページ内のコンテキストを更新
  },
});

await addToCart(id);

技術的負債とAI

今回の勉強会の資料作成にあたっては、AIと一緒に改善案の検討をしました。現在のReader Storeの新規コードの大部分はAIを使って書かれています。しかし、技術的負債の解消のような複雑なタスクを進めようとすると、その品質はAIへ指示するエンジニアのスキル、意思に大きく依存しそうです。
改善案AからCへというステップは、私からの改善指示に大きく依存しています。つまり、今回のコードに関していえば現状のAIでは自主的に改善するのは難しかったという結論です。

改善案が出来上がるまで

私の頭の中には、最初から改善案Cに近いものがありました。しかし、完成形を細かく伝えるのが面倒だったのとAIと一緒に技術的負債の解消に取り組むとどの程度まで考えてくれるのかを知りたかったので大雑把な指示を出して進めることにしました。

指示1. カートに入れる処理は改善の余地が大きそうなのでSOLID原則の勉強会の題材となるようにリファクタリングを検討してください。
応答1. 十分に処理が集約されており題材として不適切です。

指示2. 処理の呼び元にAPIのエラーコードが流出しているのはDIP違反です。
応答2. 案Aを作ってくれました。

指示3. リトライ処理は呼び元の関心事ではないです。
応答3. 案Bを作ってくれました。
リトライ処理は切り出せてないのですが、呼び元側で十分に共通化されている。代わりにコンテキスト更新部分を宣言的にすることで分かりやすくしたという回答でした。

指示4. 未ログイン時のリトライはカートだけではなく、お気に入りなど複数の業務の共通ルール、合成関数で実装すれば責務を独立させられます。
応答4. 案Cを作ってくれました。

まとめ

SOLID原則は保守性、拡張性が高いコードを実現するための考え方ですが、実現手段はさまざまな方法があります。案Cが一番業務ルールの凝集度が高く、ルール間も疎結合にできていますが、稼働中のコードをいきなり案Cで書き換えると影響が大きそうです。

今回はReactを題材にしたので合成関数のアプローチを採用しましたが、JavaだったらDIコンテナを使ったAOPを採用した可能性もあります。SOLID原則という考え方を知っていても、状況によって適切な実装方法は異なるはずです。

勉強会に参加してくれたメンバーに実装の引き出しが増えたと思ってもらえた点が良かったです。