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原則という考え方を知っていても、状況によって適切な実装方法は異なるはずです。

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

Reader Store開発チームでのAI活用についてご紹介

はじめに

こんにちは。株式会社ブックリスタのプロダクト開発部でエンジニアをしている長塚です。

私の所属する「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」開発チームでは半年前からClaude Codeの導入を始めましたが、今では既に多くの開発者が直接コードを書かなくなりました。

日々の開発はAIとの協働が前提で、コーディング以外のほぼ全ての業務でもAIを活用しています。例えばこのブログの執筆では、構成案の壁打ちや定期的に執筆具合に応じたアドバイスをもらっています。

(以下はその様子。即席で作った執筆コーチスキルですが、褒め上手でいい感じです)

執筆コーチの様子

さて、今回はこうしたAI活用が定着するまでに、Reader Store開発チームで進めてきた取り組みの一部を紹介します。チームのAIツールの活用状況の雰囲気を感じとっていただけたら幸いです。

まず、Reader Store開発チームでは昨年度、開発改善の時間(技術的負債の解消やプロセス改善に充てる時間)を週に1回程度取って良いことにしました。その中で私は、AI活用を中心とした開発改善に取り組みました。その際に意識してきたのは、属人的・1回限りではなく、チーム全員が再現性のある形でAIを活用できる状態を作ることでした。以下、その実現に向けて進めてきた取り組みの一部を順に振り返ります。

AI活用の土台作りから始めた

Reader Store開発チームでは数年前からGitHub Copilotを利用していました。昨年度、いくつかのAIツール(Cursor/Cline等)を試した結果、Claude Codeをチーム全体で導入することになりました。おおよそ半年前のことです。

ただ、チームとしての導入を決めたのは良いものの、最初はこのツールをどう使っていくかという手探りの状態でした。各々がCLAUDE.mdを作っていたり、カスタムコマンド等をリポジトリに自由に入れて良いか等のルールも決まっていませんでした。

そこでCLAUDE.mdの整備やAI関連コードのPRを出すときの運用ルールを策定しました。細かな話は省略しますが、運用ルールとしては運用の軽さや段階的な改善を意識したものにしました。CLAUDE.mdの内容は一般的なものですが、これらの整備によって、AI前提の開発を進めていくための第一歩を切ることができました。

チーム専用のプラグインを開発・共有する

次に課題になったのは、チームメンバーそれぞれがAIを有効に活用できる状態にできるかということでした。Claude Codeは賢いので大まかな指示でもある程度は動いてくれますが、より良い動作を再現性よく実現するにはプロンプトの書き方を考える必要があります。しかしその取り組みはメンバーごとにまちまちでした。

個々の取り組みだけに頼るのではなく、うまくいったワークフローやプロンプトの書き方を共有できるようにする必要があります。幸いClaude Codeではスキルやフック等、あるいはそれらをひとまとめにしたプラグインを共有できます。

メインのプロジェクトのリポジトリでも共有はできるのですが、Reader Storeではプラグイン用のリポジトリを作成しました。その方が他のプロジェクトでも再利用しやすいですし、心理的にも若干ハードルが低いです。

プラグイン用のリポジトリにある2つのプラグインを簡単に紹介します。

活用例1: テスト駆動開発支援プラグイン

使い方は色々考えられますが、Claude Codeはエージェント型コーディングツールなので、コーディングを中心としたワークフローの仕組み化としてテスト駆動開発の支援用プラグインを作成しました。

AIの利用を前提としたテスト駆動開発や仕様駆動開発を行うためのプラグインやツールは他にもありましたが、細かな調整がしやすいという点では自前で作成する利点があります。プラグイン自体もAIを使用して作成するので、手間もそれほど大きくありません。

このプラグインではテスト駆動開発の各フェーズに対応した3つのスキルがあります。

  1. テスト作成(Red): 要件からテストコードを作成し、失敗を確認する
  2. 実装(Green): テストが通る実装をする
  3. リファクタリング(Refactor): テストをパスした状態を保ちながら、コード構造を改善する

テスト駆動開発支援

これらの各スキルの中ではワークフローが定義されています。例えばテスト作成の場合、「要件の理解 → コードベース調査 → 不明点の確認 → テスト設計 → テスト実装 → テスト失敗の確認」といった決められた順序で進行します。

体感ベースですが、プラグインを使わない場合と比較したら出力の精度は上がり、都度プロンプトを考える必要もないので開発がスムーズになりました。

活用例2: テスト仕様書作成の支援プラグイン

テスト駆動開発支援プラグインを導入した後、Claude Code活用についてアンケートをとりました。その結果、半数以上が「テストケースの洗い出し・テスト仕様書の作成」を時間がかかる作業としてあげていました。

アンケート結果

それを受けてテスト仕様書作成の支援用プラグインを作成しました。

このプラグインでは主に次のことを行います。

  1. 要件からテストケースを列挙する
  2. テストケースを元に手順書を作成する

手順書の出力に関しては厳密にはテスト仕様書のフォーマットのスプレッドシートに貼り付けやすいようにTSVを出力する動作になっています。スプレッドシートでテスト管理するのが良いかはまた別の議論がありますが、一旦現状の運用に合わせた形です。

こちらのプラグインも要件の理解や不明点の確認を経てから観点の洗い出し、テストケースを作成する等の決められた順序で進行するようになっています。テスト仕様書のフォーマットやTSV出力の方法等もプラグインの中に組み込まれているので、利用者はテストケースの作成に集中できます。

ナレッジ共有の場を作る

上記で紹介した以外もいくつかのプラグインを作成していますが、利用されたフィードバックがあまり得られず、なかなかチーム内で使ってもらえていない感触がありました。

そこで実際にプラグインを利用したデモも兼ねてAI活用の共有会をすることになりました。

正直なところプラグインの利用がそこから一気に広がったという実感はありません。ただ、共有会では各メンバーが普段どういう使い方をしているかを知るきっかけになります。今後も共有の場を作ること自体は続けていきたいと思っています。

また、共有会とまではいかずとも、Reader Storeの開発チームではSlackで気軽に情報交換も行っています。

これから取り組みたいこと

Claude Codeを導入してから約半年、Claude Code自体の進化や各メンバーのスキル向上、そしてこれまで紹介したような取り組みなどが重なって、活用の幅は広がってきています。しかし、もっと磨いていきたい部分もあります。

その1つは、プラグインのメンテナンスが作成者主体で進んでいる点です。改善したい方向性もおそらく個々人で異なり、チームの資産としてどう継続的に管理していくかという部分は手探りの状態ですが、チームで形を整えていきたいと考えています。

そして、AI活用を前提とした開発には最適化の余地があると思っています。人間が行っていた作業の多くは、AIで代替できるようになったという実感があります。しかし、長時間のタスクの実行や並行稼働といった、AIだからこそできる使い方を実現するにはより安定したワークフローの設計が必要で、今後の研究課題だと考えています。

さらにReader Storeは昨年の12月で15周年を迎えた、電子書籍ストアとして歴史あるサービスです。15年分積み重ねてきたビジネスロジックや業務知識は厚く、AIに任せる際にも人間によるレビューやドメイン理解の補完が欠かせません。品質を担保しながらAIが担える領域を広げていきたいと考えています。

まとめ

以上が2025年下半期でReader StoreチームでのAI活用を目指した取り組みです。

AIによって開発は大きく変わりましたが、導入するだけで全員が使いこなせるわけではありません。AIを活用することはもちろんのこと、AIをチームで活用できる仕組みを作ること。それもエンジニアの大事な役割だと考えています。

業界全体でAI活用が加速していく中で、プロダクトの競争力を高めていけるよう、Reader Store開発チームでも引き続きその仕組みづくりに取り組んでいきます。

本記事が、同じような課題に取り組んでいる方の参考になれば幸いです。

Android、iOSネイティブからFlutter移行で得た知見

自己紹介

株式会社ブックリスタのプロダクト開発部でモバイルアプリエンジニアをしている城と申します。 現在、iOS/Androidネイティブで実装された電子書籍アプリのFlutterリプレイスを検討しており、課題を調査しFlutterでどこまで置き換え可能かの実証実験を行っています。

Flutter移行のメリット・デメリット

Flutterに移行すると具体的にどういうメリット・デメリットがあるのかおさらいします。

開発効率とスピードの向上

  • コードベースの統一: Android / iOS で同一コードを使用でき、開発・保守工数を大幅削減
  • Hot Reload: リアルタイムでのコード変更確認により、開発サイクルの大幅短縮
  • 豊富なライブラリエコシステム: pub.devの充実したパッケージにより機能実装の効率化
  • スキルセットの集約: Android / iOS の専門知識を Flutter に集中
  • 新メンバーのオンボーディング: 学習コストの削減
  • CI/CD効率化: 単一のビルドパイプラインでマルチプラットフォーム対応

デメリット・制約

  • ネイティブライブラリの制約: 既存のネイティブライブラリがFlutterに対応していない場合の移行が困難
  • プラットフォーム固有機能: カメラ、位置情報等の高度なネイティブ機能へのアクセス制限
  • アプリサイズ増加: Flutter Engineの組み込みによるアプリサイズの増大

Flutter移行戦略の検討

上記のメリット・デメリットを踏まえ、移行を推奨できるケース慎重な検討が必要なケースを整理します。

移行を推奨できるケース

  • 開発チームのリソースが限られている: 単一技術スタックでの効率化メリットが大きい
  • 長期的な開発・保守を重視: コードベース統一による保守性向上を優先
  • マルチプラットフォーム展開を計画: Web・Desktop対応の将来性を活用したい
  • 既存ネイティブライブラリへの依存が少ない: 技術的制約による移行困難が限定的

慎重な検討が必要なケース

  • ネイティブライブラリに強く依存: 既存資産の移行コストが高い
  • 高度なプラットフォーム固有機能が必要: カメラ・位置情報等の複雑な機能を多用
  • パフォーマンス要件が厳しい: アプリサイズやメモリ使用量に制限がある
  • Flutter経験者の確保が困難: 学習コストや採用コストを考慮する必要

しかし、実際のプロジェクトでは理想的な条件が揃わないことも多いです。弊社の電子書籍アプリでも技術的な制約に直面しました。次のセクションでは、具体的にどのような制約があったのか、そしてそれをどう解決したのかを詳しく説明します。


電子書籍アプリで直面したFlutter移行の制約

ビューワーライブラリの制約

電子書籍アプリの移行において、最も大きな技術的制約となったのがビューワーライブラリの問題でした。

  • 使用中のライブラリ: 高機能な電子書籍表示・操作ライブラリ
  • 技術的制限: Activity・ViewController上での使用が前提
  • Flutter非対応: ライブラリベンダーからFlutter版の提供予定なし
  • 継続使用の方針: ユーザーの読書体験を変えないため、同様のライブラリを使用する方針

なぜこの制約が重要なのか

ビューワー機能は電子書籍アプリの核心部分であり、以下の理由から既存ライブラリの継続使用が必要です。

  • ユーザー体験の継続性: 既存ユーザーの慣れ親しんだ操作感を維持
  • 読書体験の品質: 長年培われた最適化されたページめくりや表示性能
  • 機能の安定性: ページめくり、拡大縮小、しおり、検索等の複雑な機能群
  • 開発リスク回避: 新しいライブラリ導入による予期しない不具合を避ける

制約を踏まえた移行判断

この制約により、完全なFlutter移行は不可能となりましたが、ネイティブとのハイブリッドによる移行メリットが十分あると考えました。

求められる具体的なUI構成要件

電子書籍アプリでは、以下の画面構成が必要でした。

  1. 本棚画面: 書籍一覧・設定等の基本機能
  2. ビューワー: 本棚の上に重ねて表示(本棚状態は裏で維持・ネイティブ必須)
  3. ビューワーメニュー: ビューワーの上にメニューUI表示

この構成により、ユーザーは「本棚 → ビューワー → メニュー操作 → ビューワー → 本棚」へと、すべての状態を保持したままシームレスに遷移できます。

UI構成要件:3層アーキテクチャ

ここで疑問が生まれます: ビューワーを挟んで、本棚とビューワーメニューで異なるUIをFlutterで描画できるのでしょうか。それができれば、ビューワーメニューも一度の開発で済み、大幅な開発効率化が実現できるはずです。

複数のUIツリーを保持できるか

執筆時点のFlutter において、1つの FlutterEngine は1つの Widget ツリー(画面階層構造) しか管理できません。これはFlutterのアーキテクチャ制約です。

単一の FlutterEngine では、本棚UIのウィジェットツリーの状態を保持したままビューワーメニューをネイティブ画面の上にオーバーレイ表示できません。 単一のFlutterEngineでネイティブ画面の上にビューワーメニューを重ねようとすると、本棚が表示されてしまうという現象が起きました。 そこで取ったアプローチを次で説明します。

FlutterEngineとは何か

複数Engine構成の解決策を理解するために、まずFlutterEngineの基本概念について説明します。FlutterEngineの理解は、なぜ単一Engineでは制約があり、複数Engineが必要なのかを把握する上で重要です。

FlutterEngineの役割:

  • Dartコードの実行環境: Dart仮想マシン(VM)とアプリケーションの状態・ライフサイクルを管理
  • レンダリングエンジン: UIの描画とアニメーション処理を担当
  • プラットフォーム統合: ネイティブコードとの橋渡し機能

通常のFlutterアプリでは:

  • 1つのアプリケーションに対して1つのFlutterEngineが動作
  • すべてのWidget(UI部品)は単一のウィジェットツリー上で管理

複数FlutterEngine構成とは:

  • 同一アプリ内で複数のFlutterEngineを並行実行
  • 各Engineは独立したメモリ空間と状態を持つ
  • Engine間では直接的なデータ共有ができない

複数FlutterEngine構成による状態分離

  • メインEngine: 本棚画面の状態を継続維持
  • オーバーレイEngine: ネイティブビューワー上で独立してFlutterUIを表示

アーキテクチャ全体構成

複数FlutterEngineによる構成を下図に示します。

ハイブリッド構成アーキテクチャ

このアーキテクチャの特徴を説明します。

ネイティブプラットフォーム上でのFlutterEngine実行

  • 複数Engine管理: ネイティブレイヤーが2つのFlutterEngineを生成・管理
  • Engine間完全分離: 各EngineはDartVM、メモリ空間、状態管理がすべて独立しているため、状態は共有不可

Method Channelによる通信制御

  • Native経由の通信: FlutterEngine同士は直接通信不可、必ずネイティブを経由
  • 双方向インターフェース: Flutter → Native、Native → Flutter の両方向で関数呼び出し
  • Engine制御: ネイティブがビューワーイベントに応じてOverlay Engineを制御

OSレベルでの統一データアクセス

  • プラットフォーム標準ストレージ: SharedPreferences、Keychain、SQLiteなど
  • 全コンポーネント共通: すべてのEngineとネイティブコンポーネントが同一データソースにアクセス
  • データ一貫性: OSレベルでの排他制御により、データ競合を防止

Flutter と ネイティブの役割分担

機能 実装 理由
本棚・設定画面 Flutter UI開発効率とクロスプラットフォーム対応
ビューワー本体 ネイティブ ライブラリ制約
ビューワーメニュー・オーバーレイ Flutter UI統一性と開発効率

Flutter ↔ ネイティブ間の通信基盤

ハイブリッド構成では、Flutter実装とネイティブ実装の連携が不可欠です。 MethodChannel を使用することで、双方向の関数呼び出しとデータ受け渡しを実現します。詳細な実装については後述の「複数FlutterEngine実装の詳細」セクションで説明します。


複数FlutterEngine実装の詳細

前章で複数FlutterEngine構成のアーキテクチャを説明しましたが、実際の実装では「Flutterとネイティブ間の通信」が重要な要素となります。本章では、具体的な実装方法について詳しく解説していきます。

Flutter-ネイティブ間通信の必要性

ハイブリッド構成では、Flutterで実装された画面とネイティブで実装されたビューワーと連携する必要があります。具体的には以下のような通信が発生します。

  • Flutter → ネイティブ: 本棚画面からのビューワー起動
  • ネイティブ → Flutter: ビューワーからのメニュー表示要求
  • データ共有: 読書設定や書籍情報の同期

これらの通信を実現するために、FlutterではMethod Channelという仕組みを使用します。

Method Channelの概要

Method Channelは、FlutterとネイティブOS(Android/iOS)間で双方向の関数呼び出しを可能にする通信基盤です。

Method Channelの特徴

  • 双方向通信: Flutter → ネイティブ、ネイティブ → Flutter両方向の呼び出しが可能
  • 非同期処理: ネイティブ側の処理完了を待つことができる
  • 型安全: パラメータの型チェックと変換を自動処理
  • プラットフォーム統一: Android/iOS共通のAPIで実装可能

基本的な通信フロー

  1. Channel作成: Flutter側とネイティブ側で同じ名前のChannelを作成
  2. ハンドラー登録: 各側で受信した呼び出しを処理するハンドラーを登録
  3. 関数呼び出し: 一方から他方へ関数名とパラメータを送信
  4. 結果返却: 処理完了後、結果を呼び出し元に返却

Method Channelの実装方法

ハイブリッド構成の実装へ入る前に、Method Channelの具体的な実装方法について詳しく説明します。

Flutter側の実装

// Flutter側での基本的な実装
class NativeInterface {
  // MethodChannelに渡すnameパラメータはネイティブと合わせる必要がある
  static const _channel = MethodChannel('methodChannelName');
  
  // Flutter → ネイティブへの呼び出し
  static Future<void> openViewer(String bookId) async {
    await _channel.invokeMethod('openViewer', {'bookId': bookId});
  }
  
  // ネイティブ → Flutter からの呼び出し受信
  static void setupCallbacks() {
    _channel.setMethodCallHandler((call) async {
      switch (call.method) {
        case 'onMenuRequested':
          // メニュー表示要求の処理
          break;
      }
    });
  }
}

Android側の実装

class MainActivity : FlutterFragmentActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        // FlutterEngineにMethodChannelを登録し、Flutterからの呼び出しを受け取る
        // Flutterで定義したnameと同じ名称を指定
        val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "methodChannelName")
        
        // Flutter → ネイティブ からの呼び出し受信
        channel.setMethodCallHandler { call, result ->
            if (call.method == "openViewer") {
                // パラメータ取得
                val castedArgs = call.arguments as? Map<*, *>
                val bookId = castedArgs?.get("bookId") as? String?

                // 本を開く処理
                startActivity(ViewerActivity.newIntent(this, bookId))

                // 結果をFlutterに返却
                result.success(null)
                return@setMethodCallHandler
            }
            result.notImplemented()
        }

        // ネイティブ → Flutterへの呼び出し
        channel.invokeMethod("onMenuRequested")
    }
}

この通信基盤により、Flutterの本棚画面からネイティブビューワーの起動、そしてビューワー上でのFlutterメニュー表示制御が可能になります。

複数FlutterEngine構成の実装アプローチ

前章で説明した通り、単一のFlutterEngineでは本棚の状態を保持したままビューワー上にメニューを表示できません。

解決策:独立した複数Engine構成

  • メインEngine: 本棚画面専用のFlutterEngine(状態を継続維持)
  • オーバーレイEngine: ビューワーメニュー専用のFlutterEngine(独立したUI表示)

この構成により、各Engineが独立した状態を管理し、本棚の状態を保持したままビューワー上にメニュー表示が可能になります。

実装の全体的な流れ

  1. Flutter側エントリーポイント追加: オーバーレイEngine用の起動関数を定義
  2. Android側Engine管理: 複数のFlutterEngineを生成・キャッシュ・制御
  3. UI表示制御: ネイティブ画面上でのFlutterView表示/非表示切り替え

それでは、各ステップの詳細な実装を見ていきましょう。

Flutter側エントリーポイントの追加

main.dartトップレベルでVMのエントリーポイントを定義します。

// エントリーポイント追加
@pragma("vm:entry-point")
void showViewerMenu() {
  runApp(
    MaterialApp(
      navigatorKey: navigatorKey,
      color: Colors.transparent,
      home: ViewerMenu(),
    ),
  );
}
// ここまで

void main() {
  runApp(MyApp());
}

Android側での複数Engine管理

configureFlutterEngine()で渡されるEngineとは別のエンジンを生成し、他のActivityからも参照できるようにキャッシュしておきます。

class MainActivity : FlutterFragmentActivity() {

    // FlutterEngineをキャッシュする名称
    companion object {
        const val ENGINE_GROUP_NAME = "flutter_engine_group"
        const val MAIN_ENGINE_NAME = "flutter_main_engine"
        const val OVERLAY_ENGINE_NAME = "flutter_overlay_engine"
    }

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        // FlutterEngineにMethodChannelを登録し、Flutterからの呼び出しを受け取る
        val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "methodChannelName")
        channel.setMethodCallHandler { call, result ->
            if (call.method == "openViewer") {
                // パラメータ取得
                val castedArgs = call.arguments as? Map<*, *>
                val bookId = castedArgs?.get("bookId") as? String?

                // 本を開く処理
                startActivity(ViewerActivity.newIntent(this, bookId))

                // 結果をFlutterに返却
                result.success(null)
                return@setMethodCallHandler
            }
            result.notImplemented()
        }

        // ここから追加

        // FlutterEngineGroupを作成
        val engineGroup = FlutterEngineGroup(this)
        
        // オーバーレイ用Engineのエントリーポイント設定
        val overlayEntryPoint =
            DartExecutor.DartEntrypoint(
                FlutterInjector.instance().flutterLoader().findAppBundlePath(),
                "showViewerMenu",   // main.dartで追加したエントリーポイントの関数名と合わせる
            )
        
        // オーバーレイ用Engineを実行
        val overlayEngine =
            engineGroup.createAndRunEngine(
                applicationContext,
                overlayEntryPoint,
            )

        // EngineGroup をキャッシュに登録
        FlutterEngineGroupCache
            .getInstance()
            .put(ENGINE_GROUP_NAME, engineGroup)

        // メイン画面用Engine をキャッシュに登録
        FlutterEngineCache
            .getInstance()
            .put(MAIN_ENGINE_NAME, flutterEngine)

        // オーバーレイ用Engine をキャッシュに登録
        FlutterEngineCache
            .getInstance()
            .put(OVERLAY_ENGINE_NAME, overlayEngine)

        // ここまで
    }
}

Engine間でのデータ共有の重要な注意点

複数のFlutterEngineを使用する際の大きな制約として、各Engineは独立したメモリ空間を持つことになります。

問題点:

  • staticな変数やシングルトンインスタンスは各Engine間で共有されない
  • Providerや状態管理ライブラリ(Riverpod、BLoC等)のインスタンスも別物となる
  • メインEngine(本棚)とオーバーレイEngine(メニュー)間で直接的なデータ共有は不可能

解決策:

Engine間でデータを共有する場合は、物理的な永続化ストレージを介する必要があります。

具体的な実装方法については、後述の「ネイティブとFlutterでの共通Preference管理」セクションで詳しく説明します。

この制約により、複数Engine間での状態管理設計には特に注意が必要です。

実際の表示ロジック

Androidでの表示実装:

ビューワーの上へ重ねるようUI構成にFlutterViewを追加し、visibilityで表示/非表示の切り替えを行うようにしてみます。 まずはレイアウトファイルです。画面全体を占めるビューワーUIの上に、メニュー用のFlutterViewを重ねます。

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/viewer_activity"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ViewerView
        android:id="@+id/viewer_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

    <io.flutter.embedding.android.FlutterView
        android:id="@+id/menu_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</FrameLayout>

続いてActivityの実装です。 オーバーレイEngineを使用するように書き換えます。

class ViewerActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // レイアウト読み込み
        setContentView(R.layout.viewer)

        // オーバーレイ用Engineを使用するよう修正
        val overlayEngine = FlutterEngineCache
                .getInstance()
                .get(FlutterMainActivity.OVERLAY_ENGINE_NAME)
        overlayEngine?.let { engine ->
            val flutterView = findViewById<FlutterView>(R.id.menu_view)
            flutterView?.apply {
                setBackgroundColor(Color.TRANSPARENT)
                attachToFlutterEngine(engine)
                visibility = View.GONE
            }
        }
    }
}

2つの独立した Widget ツリーを並行管理することで、ネイティブビューワー上でのFlutterオーバーレイ表示を実現しています。


ネイティブとFlutterでの共通Preference管理

前章で複数FlutterEngineの実装について説明しましたが、Engine間で直接的なデータ共有ができないという制約があります。 この制約を解決するため、SharedPreferences(Android)、UserDefaults(iOS)をネイティブとFlutter双方からアクセスしてデータ共有するアプローチを説明します。

統一アプローチ

ネイティブとFlutterで統一データにアクセスする必要がある場合、Preferenceをどのように扱えば良いでしょうか。

Preferenceストレージの使い分け戦略

// Flutter側での実装例
class PreferenceManager {
  Future<void> saveSamplePreference() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('test_shared_key', 'abc123');
  }
}

Android実装例

key名称にflutter.プレフィックスをつけて呼び出すと、Flutter側でsetした値を取得できます。 iOSのUserDefaultsでも同様です。

class PreferenceManager {
    fun getSamplePreference(context: Context) {
        val pref = context.getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE);
        val refValue = pref.getString("flutter.test_shared_key", null);
        print($refValue);
    }
}

まとめ

Flutter移行を検討している方へのアドバイス

移行前の検討事項

  1. 制約の洗い出し: ネイティブライブラリやプラットフォーム固有機能の依存関係
  2. 段階的な移行戦略: 一括移行ではなく、機能単位での段階的アプローチ
  3. パフォーマンス要件: ユーザー体験を損なわない移行計画

成功のポイント

  • プロトタイプでの検証: 複雑な要件は事前に技術検証を実施
  • チーム内の知識共有: ネイティブとFlutter両方のスキル習得
  • 継続的な最適化: リリース後もパフォーマンスモニタリングを継続

複雑な制約がある場合でも、適切なアーキテクチャ設計により Flutter移行は十分可能です。 ただし今回の場合、FlutterとネイティブAndroid/iOSの知識がエンジニアに求められ、学習コストは高くなります。 本記事が同様の課題に取り組む方々の参考になれば幸いです。


読んでいただき、ありがとうございました。 ご質問やご感想がありましたら、お気軽にコメントください。

新規事業領域の開発におけるAI活用事例についてご紹介

はじめに

株式会社ブックリスタのプロダクト開発部でエンジニアとして開発に携わっている峯岸と申します。
主に新規事業開発室でiOSアプリの「Oshibana」とWebサービス「推しゲーム」などを開発しています。

新規事業開発室ではOshibanaや推しゲームなどのサービスを展開しており、そこでAIを積極的に活用しています。
今回は日々の開発において、また現在試行しているビジネスサイドとの協働において、どのようにAIを利用しているかご紹介します。

新規事業開発の課題

新規事業開発においては、市場の需要を迅速に検証し、リリースサイクルを短縮することが重要です。しかし少人数のチームでは、スピードを優先すると将来の拡張性・保守性が損なわれ、品質を重視すると市場機会を逃すリスクが高まるというジレンマに直面していました。

AIをパートナーとした解決アプローチ

そこで、AIを利用して今までとは異なるアプローチでリリースサイクルを短縮できないか模索していました。従来から活用している開発のAI利用に加え、ビジネスサイドは考えたアイディアの動作を確認するプロトタイプを作り、それをエンジニアに渡して実装する、という協働モデルです。

AIの力を借りることで、少人数でありながらも開発スピードを維持し、かつ一定の品質を保った効率的な開発を実現することを目指しています。また、ビジネスサイドがAIを活用して市場性を検証できるようになることで、エンジニアリソースをより確度の高い開発に集中できます。その結果、リリースサイクルの短縮と拡張性・保守性の向上を両立できるようになると考えています。

利用しているAI

新規事業の開発においては次のAIを主に利用しています。

  • VSCode Copilot
  • Cursor
  • Devin
  • Claude Code

開発現場でのAI活用事例

設計、実装、レビュー、バグ分析など、開発プロセス全体でAIを活用しています。
設計フェーズでは、AIに設計のリスクや簡略化の余地などを質問し、過度な作り込みを避けながら技術的負債を最小限に抑えた設計を短時間で検討しています。
実装段階では、Devinなどの非同期型AIに実装を依頼し、その後Cursorなどの対話型AIを使って品質を担保しながら仕上げを行っています。
レビュー工程では、Claude Codeでプルリクエストの要約を自動生成し、事前定義したチェック観点に基づいた一次レビューを依頼します。
人間のレビュアーはより本質的な設計やロジックの妥当性をレビューします。
バグ分析・修正では、Claude Codeでクラッシュレポートやエラーログを解析し、Cursorで原因調査を効率化し、修正案を短時間で検証できるようになりました。

ビジネスサイドとの協働

これまで開発チームでのAI活用から始まり、現在ではビジネスサイドでも企画段階での壁打ちやプロトタイプ生成にAIを活用するようになってきました。今後は、ビジネスと開発がより一体となって進んでいけるようなAI活用を模索しています。

企画から本番リリースまでの新しい協働モデル

新しい協働モデル

従来、ビジネスサイドが作りたいアイディアを形にするためには、エンジニアのリソースを使う必要がありました。しかしAIの進化により、ビジネスサイドが自らプロトタイプを作成できるようになりました。この変化により、ビジネスサイドが考えるイメージに近いものをエンジニアが実装できるようになり、プロトタイプがあることで「こういうものを作りたい」という意思疎通が格段に行いやすくなりました。その結果、開発の方向性が明確になり、リリースサイクルが短縮され、より迅速に市場に価値を届けられるようになりました。

具体的な協働の流れ

実際の開発では、以下のような流れでビジネスサイドとエンジニアが協働しています。ここでは、推しゲームの中で最近リリースした推し占いを実例として、この協働モデルがどのように機能したかをご紹介します。

推しゲームは各ゲームをマイクロサービスとして実装しており、それらを組み込む形でサービスが成り立っています。このアーキテクチャにより、各ゲームを独立して開発・デプロイでき、新規ゲームの追加や既存ゲームの更新が容易になっています。推し占いもこのマイクロサービスの1つとして実装されました。

ビジネスサイドによるプロトタイプ作成(推し占いの例)

推し占いの開発では、まずビジネスサイドがAIを活用してスタンドアロンで動くようなモック的なプロトタイプを作成しました。このプロトタイプにより、アイディアが実際にどう動くかを確認でき、動作イメージを明確にできました。この時点では既存のアプリ、サービスとの整合性はあまり考慮せず、とにかく動くものを作成することを優先しました。

ビジネスサイドは、AIに対して「推し占いのゲームを作りたい」というアイディアを、画面の構成や機能の要件として伝えました。AIはその要件を元に、HTMLやJavaScriptで動作するプロトタイプを生成します。このプロセスで、ビジネスサイドは自分のアイディアが実際に動く様子を確認でき、必要に応じて何度でも修正を繰り返して理想の形に近づけていきます。

ビジネス側が作成した、実際に作成された仕様書の一部

この作業を通じて、ビジネスサイドからAIに渡す仕様書が出来上がります。この仕様書には、画面の構成、機能の詳細、ユーザーの操作フローなどが含まれており、以降はエンジニアも参照しながら作業を進めていきます。プロトタイプがあることで、ビジネスサイドとエンジニアの間で「こういうものを作りたい」という共通認識が生まれ、意思疎通が格段にスムーズになります。

Before(プロトタイプ)

After(本番)

プロトタイプと本番実装を比較すると、基本的な画面構成や機能はプロトタイプのイメージをそのまま反映しています。一方で、既存の推しゲームのデザインシステムに合わせたUIの調整や、実際のデータベースとの連携、エラーハンドリングなど、本番環境で必要な要素が追加されています。プロトタイプがあることで、ビジネスサイドが求めている機能やUIのイメージが明確になっているため、実装の方向性がぶれることなく効率的に開発を進められました。

ビジネスサイドと開発側の取り決め

推し占いの開発でこの協働モデルをスムーズに進めるため、ビジネスサイドと開発側で以下の取り決めを行いました。これら取り決めはビジネスサイドでプロトタイプを作成する際AIに渡され、推し占いのプロトタイプ作成時も適用されました。

これらの取り決めの多くは開発を速やかに行うための制約です。AIのプロンプトへ組み込むことで、ビジネスサイドが意識せずとも自動的にこの制約を守れるような状況を作り出しています。これにより、ビジネスサイドはアイディアの実現に集中でき、開発側も効率的に実装を進められるという、双方にとってメリットのある協働が実現できています。

取り決め 説明
プロトタイプはスタンドアロンで動くこと プロトタイプは既存のシステムに依存せず、単独で動作するように作成する。これにより、ビジネスサイドは自分の環境で自由にプロトタイプを確認・修正でき、エンジニアもプロトタイプの動作を独立して検証できる。
サーバー連携部分はモックで実装を切り替え可能にすること データの永続化やGoogle Analytics(GA)などのサーバー連携が必要な部分については、モックを利用して実装を切り替えられるようにする。これにより、エンジニアはプロトタイプの動作確認を容易に行え、実環境への組み込み時もモックから本番実装への切り替えがスムーズに行える。
プロトタイプのアーキテクチャは統合先にできるだけ合わせること プロトタイプを作成する際、統合先のアーキテクチャ(推しゲームの場合はマイクロサービスアーキテクチャ)にできるだけ合わせるようにAIに指示を行う。これにより、エンジニアがプロトタイプを本番実装に変換する際の作業量が減り、既存システムへの組み込みがスムーズに行える。例えば、コンポーネントの構造やデータの流れを統合先のアーキテクチャに近づけることで、実装時の変換コストを最小限に抑えられる。

これらの取り決めにより、開発側のプロトタイプ動作確認と実環境への組み込みが行いやすくなり、ビジネスサイドとエンジニアの協働がより効率的になりました。

エンジニアによる本番実装(推し占いの例)

ビジネスサイドが作成した推し占いのプロトタイプと仕様書を受け取ったエンジニアは、これを基に本番環境での実装を進めました。

まず、エンジニアは、ビジネスサイドが作成したプロトタイプと仕様書を参考に、本番環境での実装に必要な技術的な仕様書を作成しました。エンジニア主導で必要に応じてビジネスサイドとコミュニケーションをとりながら、データモデルの設計、APIの仕様、既存システムとの統合方法などを検討できました。ビジネス要件と技術的な制約の両方を考慮した議論がしやすくなり、破綻のない構造を維持できました。

開発側が作成した、実際に作成された仕様書の一部

その後、エンジニアはこのプロトタイプと仕様書をベースに、バックエンドや既存機能とのマージを行いました。推しゲームのマイクロサービスアーキテクチャにより、推し占いゲームは独立したサービスとして実装され、既存の推しゲームプラットフォームに組み込まれました。

推し占いのバックエンドについてはデータモデルと、プロトタイプのモックを参考にAIを利用して実装しました。フロントについても同様にプロトタイプのモック実装を本実装に切り替え、バックエンドに接続するような指示をAIに与えて実装しました。ビジネスサイドの仕様書とエンジニアの仕様書を両方参照することで、ビジネス要件と技術要件の両方を満たした実装が可能になりました。

ビジネスサイドにとっての効果

この協働モデルにより、ビジネスサイドは以下のような効果を実感しています。

  • アイディアの検証が迅速に:エンジニアのリソースを待たずに、自分でプロトタイプを作成してアイディアを検証できるようになった
  • 意思疎通の質が向上:プロトタイプにより、ビジネスサイドとエンジニアの間で「こういうものを作りたい」という共通認識が生まれ、詳細な仕様のすり合わせが効率的になった
  • 市場への投入速度が向上:プロトタイプ段階で動作イメージが明確になっているため、実装の方向性がぶれることなく、迅速に市場に価値を届けられるようになった

今後の展望

企画から開発、運用まで、ビジネスとエンジニアリングが境界なくAIをパートナーとして活用し、より迅速で質の高い意思決定と実行ができる組織を作っていきたいと考えています。この「失敗前提で設計されたパイプライン」こそが、新規事業開発におけるAI活用の本質的な価値だと考えています。

また、ビジネスサイドが作成するプロトタイプのデザインについて、既存機能と統一感を取るために、Figmaで作成されたデザインコンポーネントからAIを使ってデザインを作れるような仕組みを考えています。これにより、プロトタイプ段階から既存サービスとの統一感を保ったデザインを生成でき、実装時のデザイン調整のコストを削減できるようになると期待しています。

“プロジェクトを前に進める役”をやってみて気づいたこと

1. はじめに

初めまして、コミック ROLLY サービスでサーバーサイドエンジニアをしている伊藤です。
今回私からは、「今までチームリードや PM の経験はなかった人間が、“プロジェクトを前に進める役”をやって気づいたこと」についてお伝えします。

コミック ROLLY の開発チームは、スクラム開発を導入しています。
PM という役職自体はないものの、メンバー各自が自律的に動くことをよしとしているチームです。

その中で今までチームリードや PM の経験がなかった私ですが、今回ご縁があり、現在のチームでリーディングの機会をいただきました。
このチームでリーディングのためにどういう動きをしたか、動いてみて気づいたことお話ししていきます。

2. コミック ROLLY のサービスと PM をしたプロジェクトについて

「コミック ROLLY」とは、株式会社ソニー・ミュージックエンタテインメント から配信している Android, iOS のスマートフォンアプリケーションです。

様々なコミックスを無料で読めたり、アプリを通して課金してコミックスを購入できるなどの機能があり、書店で売られているコミックスほか、先行配信するコミックスなどもそろっているサービスです。

今回のプロジェクトは、コミック ROLLY で一定期間課金せずに読むことができる「期間限定無料」というコミックス商品に対応するというものでした。

コミックROLLYの商品の流れ

コミック ROLLY が商品をアプリに出すまでが以下の流れになっています。

① 社内別チームが担当する商品管理システム(取次システムと呼んでいます)からコミック ROLLY の商品取り込みサーバーに商品情報を連携する。
② 商品取り込みサーバーが連携を受け取ったら、コミック ROLLY の DB に情報を登録する。
③ コミック ROLLY のコンテンツ(アプリに出す特集情報など)管理から、商品と特集などの紐付けを行う。
④ アプリと通信する webAPI を通して DB から情報を取得、整形する。
⑤API から情報を受け取ったアプリが商品情報を表示する。

このプロジェクトは新しい商品をアプリに出すことが目的になるので、①~⑤ に関わる開発が必要になります。
あわせて、商品管理システムのチーム側もコミック ROLLY に対応するための開発が必要になるので、足並みをそろえての開発・リリースをする必要もありました。

3. なぜ自分が PM 役をやることになったのか

コミック ROLLY チームでは スクラム開発という短期間で計画・作業・リリース・振り返りを繰り返す開発手法を採用しています。

プロジェクトを短期間で回す単位(スプリント)を 2 週間で回るように設定していて、タスクもその間に対応できる規模のものがほとんどになります。
ですが、まれに要望が複雑で複数システムの開発が必要なタスクもありました。

そのようなタスクが出てきた場合、要件整理・進捗の見通しなど「誰かが主導して動かす」必要がありました。

当時チーム内で一番所属経歴が長く、ドメイン知識や技術的な理解が深かったため、自然と自分がその役割を担うことになりました。

4. "PM 的な役割”でやったこと

プロジェクト推進役を実施するにあたり、主に以下の対応を重点的に行いました。

1.明確な方針の元の要件・仕様の整理とステークホルダー(PO や他チーム)とのコミュニケーション

中・大規模開発において重要なのは、要望を出すステークホルダー、ステークホルダーと開発メンバーの仲介であるプロダクトオーナーと一緒に要望の認識を合わせていくことです。

チームの設立時から「最低限の開発でユーザーへの提供スピードを優先する」という方針をもとに開発してきており、プロダクトオーナー、ステークホルダーとも方針を共有できていました。

この方針を元に要件・要望を整理し、最低限の機能開発で収めてリリースすることを確定できました。

2.仕様漏れ、変更に対しての柔軟に対応

今まで対応していたプロジェクトやチームでは仕様確定 → 開発 → テスト → リリースの流れが決まっていて、テスト時に仕様の考慮漏れがあった場合の修正やリリース時期の調整に時間がかかりました。

先に記載していますが、コミック ROLLY では 2 週間サイクルで開発を進めているので、中・長規模なプロジェクトも 2 週間で実施できる程度にタスクを細分化しています。そのなかで仕様の整理と開発を並行して行い、都度テストして確認していく方法を取りました。

この対応の結果テストで仕様の考慮漏れに気づけて次のスプリントで修正対応ができるので、大きな手戻りの発生がなく、リリースもリスケせずに対応できました。

3.定期的な振り返りで声を上げやすい環境を作る

スクラム開発ではレトロスペクティブというイベントがあり、プロジェクトに対しての振り返りを行います。私たちはプロジェクト以外にも開発プロセス内の振り返りも行っていました。

レトロスペクティブ中にプロセスの変えたいことをメンバー同士出し合い、アプローチ方法の検討し、次のスプリントにプロセス変更を取り入れる流れで改善案を迅速に取り入れる仕組みを導入しました。

この対応で改善の提案と実行のサイクルを早めたことで「自分の意見でチームが動いている」実感を得やすい形になりました。

その結果、プロジェクトの進行への取り組み、仕様や要件、開発プロセスに対してメンバーそれぞれが自主的に改善意見を言ってくれるようになるなどメンバーの自律性が格段に向上しました。

5. やってみての気づき・視野の変化

今回PM の役割を体験して、以下の気づいた点があります。

① チーム内で声を上げやすい環境にすることの大事さ。
チーム内のコミュニケーション方法を考慮したおかげで、チーム間で声を上げることのハードルが大幅に減りました。
それによってプロジェクト進行中に「リリース手順に考慮漏れがある」「仕様に矛盾点を発見した」などのさまざまな指摘をもらえる機会が多くありました。
おかげで慣れない進行でも進捗が早く、想定した時期に適切な機能をリリースできました。

この状況がないと、リリース直前で仕様の見落としや重大なバグが発見されリリースに大幅な遅れが出る可能性もありました。改めて意見を通しやすい環境があったことの大事さを実感しました。
この気づきとともに「頼れるところはもっと他メンバーにも頼ってよかった」という反省点もありました。

進行上エンジニアのメンバーには決定事項を共有することが多かったのですが、もっと早い過程の段階で意見を聞くことで、メンバーそれぞれの知識を活かせる場面がもっとあったのではと考えています。

② 物事を進めることの大変さ。
プロジェクトの推進役を経験することで、仕様に従うだけではなく、サービスとしてありたい方向、チームの状況に応じて柔軟に対応することがいかに大変かつ重要かも感じることができました。

③「リーダー」は役職じゃなく、チーム状況によって生まれる役割のひとつ。
PMの役割を体験したことで、要件定義や実装の流れを十分理解していれば誰でもチームリーダーとして対応できると考えを改めました。

今回はたまたま私がリーダーでしたが、プロジェクト周りの習熟度で他メンバーをアサインする可能性があったように誰でもリーダーになる機会はあります。

普段からこの感覚を意識すると、関わっているプロジェクトでも開発プロセスに対する積極的な提案や、仕様の曖昧な部分があったときにすぐに確認に入るなどを自主的に考えていけるようになりました。

6. 次にPMをやる時に気をつけること

先の気づきで「決定前の段階で他メンバーにも頼るところがあってよかった」という反省点があったのでそこに対してアプローチしていきたいです。
次にPMをやることがあったら、たとえば設計初期や技術選定の段階でメンバーに相談して考える段階でメンバーを巻き込んで色んな視点を取り入れる対応をすべきだと感じています。

7. 最後に:この経験を通じて

今までリード経験がなかった自分でも、プロジェクトの進行からトラブルの対応まで必要に応じて動くことができました。これは意見を言いやすい環境を作れた上で生じたチーム内での自律的な動きや、メンバー同士で協力できる信頼関係があったからこそだったなと感じています。

メンバー同士が積極的にプロジェクトに関心をもてる状況があれば、メンバー誰でも PM やリーダーの役割を担当できることも重要な気づきになりました。
これからもこの考えを元にエンジニアとして対応していければと思っています。

Reader StoreでAWSアカウント間でのデータ同期をしてみた

こんにちは。プロダクト開発部でクラウドインフラエンジニアとして業務を行っている饂飩です。

今回は、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」の新しい環境を構築した際に発生したAWSアカウント間でのデータ同期についてお伝えします。

新環境検討の背景

新環境検討のきっかけとなった背景としては以下があります。

  • 大きな開発案件では開発や事業部など全体の部署で成果物がどのようなものか認識を合わせるための環境が欲しい。という要望が上がった
  • 検証環境などでは開発者のテストデータや古い作品のデータが多くストアとしてのイメージがつきにくく認識を合わせることが難しくなっていた

今後システム改善をしていく上でも、事業部全体で認識合わせながら進めることが必須事項になってくることもあり、本番相当のデータで可能な環境を用意する運びとなった。

データ同期をどう行うか

弊社のAWSは商用と開発検証でアカウントが異なるため、アカウントを跨いでデータを同期する必要がありました。
大きく以下2つをどうにかする必要がありました。

1. RDS

候補として以下がありました。

export/importはデータ量がそれなりにありデータ同期するとなるとリードタイムがかかってしまうため不採用にしました。
バイナリログレプリケーションはお試し構築で実施していたため最有力候補でした。
ただ、テーブルを絞った上でデータ同期をするため、今後同期するテーブルが増えることを想定して容易に対応できるようにするためDMSを採用しました。

2. S3

候補として以下がありました。

S3レプリケーションは前提条件でバケットのバージョニングを有効にしなければならず、システム影響調査および環境差異が発生してしまうため不採用にしました。
awscliは処理時間がかかってしまうため不採用にしました。
1h程度でデータ同期が完了したのでDataSyncを採用しました。

構築の流れ

今回私が対応した流れとしては以下となります。

RDSおよびS3の同期設定以外

以前のブログで記載しましたが、Terraformコードがあるので、環境設定だけ追加をしてterraform applyを実施するだけなのですぐ完了しました。

RDS

RDSはDMSでレプリケートしますが、初期はソースDBのスナップショットから復元しました。
以下はDMSを利用したデータ同期を行うために実施した作業内容となります。

データ同期を行う環境構成としては以下のイメージのような形となります。
図のaccount-Aが商用のAWSアカウント、account-Bが開発検証用のAWSアカウントとなります。

AWS/DMS

ネットワーク周り

まずはソースDBが別アカウントに存在していたためVPC PeeringでVPC間通信できるようにしました。
その後Route TableやSecurityGroupの設定を実施しました。

DMS

次にDMS環境構築をしました。
実施した内容としては以下の通りとなります。

  • レプリケーションインスタンスの作成
    レプリケーションタスクが動作するインスタンスを作成しました。

  • ソース/ターゲットエンドポイントの作成
    ソースエンドポイント(移行元RDS接続情報)、ターゲットエンドポイント(移行先RDS接続情報)を作成しました。

  • 移行タスクの作成
    上記で作成したレプリケーションインスタンスとソース/ターゲットエンドポイントを紐付けてデータを移行するタスクを作成しました。

S3

S3は空のバケットを最初に作成しました。
DataSyncで実施した内容としては以下の通りです。

データ同期を行う環境構成としては以下のイメージのような形となります。
図のaccount-Aが商用のAWSアカウント、account-Bが開発検証用のAWSアカウントとなります。

AWS/DataSync

DataSync用のIAMロール作成

DataSyncからS3アクセスできるようにIAMロールを作成しました。
ロールに付与したポリシーは以下のような形になります。

ソース用ポリシーサンプル

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:GetBucketLocation",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads"
            ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::bucket-name"
        },
        {
            "Action": [
                "s3:GetObject",
                "s3:ListMultipartUploadParts",
                "s3:GetObjectTagging",
              ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::bucket-name/*"
        }
    ]
}

ターゲット用ポリシーサンプル

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:GetBucketLocation",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads"
            ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::bucket-name"
        },
        {
            "Action": [
                "s3:AbortMultipartUpload",
                "s3:DeleteObject",
                "s3:GetObject",
                "s3:ListMultipartUploadParts",
                "s3:GetObjectTagging",
                "s3:PutObjectTagging",
                "s3:PutObject"
              ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::bucket-name/*"
        }
    ]
}

信頼ポリシーサンプル

{
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Principal": {
            "Service": "datasync.amazonaws.com"
        },
        "Action": "sts:AssumeRole",
        "Condition": {
            "StringEquals": {
                "aws:SourceAccount": "account-id"
            },
            "StringLike": {
                "aws:SourceArn": "arn:aws:datasync:region:account-id:*"
            }
        }
    }]
}

S3バケットポリシー修正

先ほど作成したDataSync用のIAMロールでS3アクセスできるようにバケットポリシーを修正しました。

S3バケットポリシーサンプル

{
  "Version": "2008-10-17",
  "Statement": [
    {
      "Sid": "DataSyncCreateS3LocationAndTaskAccess",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::account-id:role/datasync-role"
      },
      "Action": [
        "s3:GetBucketLocation",
        "s3:ListBucket",
        "s3:ListBucketMultipartUploads",
        "s3:AbortMultipartUpload",
        "s3:DeleteObject",
        "s3:GetObject",
        "s3:ListMultipartUploadParts",
        "s3:PutObject",
        "s3:GetObjectTagging",
        "s3:PutObjectTagging"
      ],
      "Resource": [
        "arn:aws:s3:::bucket",
        "arn:aws:s3:::bucket/*"
      ]
    },
    {
      "Sid": "DataSyncCreateS3Location",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::account-id:role/datasync-role"
      },
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::bucket"
    }
  ]
}

DataSync

次にDataSync環境構築をしました。
実施した内容としては以下の通りとなります。

  • ソース/ターゲットロケーションの作成
    ソースロケーション(移行元S3)、ターゲットロケーション(移行先S3への接続情報)を作成しました。
    ソースロケーションは、アカウントが異なりコンソールからは設定できなかったので、aws cliコマンドを実行しました。
  #aws datasync create-location-s3 \
    --s3-bucket-arn arn:aws:s3:::bucket \
    --s3-config '{
      "BucketAccessRoleArn":"arn:aws:iam::account-id:role/datasync-role"
    }'
  • 移行タスクの作成
    上記で作成したソースロケーションからターゲットロケーションへデータを移行するタスクを作成しました。

データ同期

作成したDMSおよびDataSyncの移行タスクを実行しました。
どちらも初回フル同期して、フル同期後はソース側で変更があれば継続的にレプリケーションする設定となっています。

DMSはタスク実行の際に、ターゲットDB内のテーブルをtruncateして再度ソースDBからフルロードする設定にしているため初回同期は3hほどかかりました。

DataSyncの初回はソースS3の全てのオブジェクトをターゲットS3にコピーするため12hほどかかりました。

直面した課題・反省点

S3関連のコスト面の見積が甘かった

S3上のオブジェクト数からデータ同期する際のDataSync料金(想定データ転送量)を見積りして大したコストにならないとしていました。
だが、移行し始めた翌月のS3コスト(GET、SELECT、他のすべてのリクエスト)が前月差で3倍ほど跳ね上がりました。

原因は、S3同期を毎時やっていたことにありました。
毎時S3オブジェクト同期し、その都度オブジェクト比較するためオブジェクト数2431のリクエストが発生していました。
こちらのコストを下げるべく、同期の頻度を毎時から1日1回に変更することでオブジェクト数*31になり、当初想定していた試算コストくらいになりました。

GuardDutyのコストを見積もっていなかった

GuardDuty S3 Protection機能を有効にしていたのを失念していました。
こちらも移行し始めた翌月のコスト(S3データイベント分析)が前月差30倍ほど跳ね上がりました。

こちらもS3同期頻度を変更することでS3データイベント分析が減ったため前月差2倍程度になりました。

まとめ

今回Reader Storeで新しい環境を構築した件についてお話ししました。
DataSyncを継続的に移行する際にはS3コストにご注意下さい。

新しい環境はまだ未完な状態ではあるので、今後も環境の改善を進めて本番に近い確認ができるようにして、サービスの品質も高めていきたいです。

他にも実施した内容はあるのでどこかでまたお話しできたらと思っています。

以上、読んでいただきありがとうございました。

Reader StoreでSaaS型headlessCMSを使ってみて感じたこと

プロダクト開発部アプリケーションエンジニアの有末と申します。
現在、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」のシステム開発を主な業務として日々取り組んでいます。

Reader Storeでは段階的なサイトリニューアルを進行中です。一部のコンテンツ管理にCMSを利用していますが、リニューアルに伴いCMSの切り替えも行うこととなりました。そこで従来一般的だったフロントエンド一体型のCMS(以降、"従来型CMS"と呼びます。)ではなくheadlessCMSを導入することに。製品はSaaSとして提供されているmicroCMSを利用しました。

本記事では、headlessCMSの導入をした中で感じたことを中心にお伝えしていきます。現在CMSを利用している方や利用を検討している方の参考になれば幸いです。

headlessCMSとは

CMS(Contents Management System)とはWebサイトの構成要素(=コンテンツ。具体的には画像、テキストなど)を管理・配信するシステムを指します。headlessCMSはその中の一種です。バックエンドの機能のみ提供しAPIでコンテンツを配信するCMSをheadlessCMSと呼びます。
パッケージとして提供されている"従来型CMS"と"headlessCMS"、スクラッチ開発するCMSを比較すると以下のようになります。

従来型CMS headlessCMS スクラッチ
概要 コンテンツデータ、管理画面、Webサイトと総合的な機能を提供する。 コンテンツデータ管理といったバックエンドに特化。Webサイト自体の機能は持たない 0から開発をする
開発コスト 低い バックエンド:低い
フロントエンド:高い
高い
自由度 低い バックエンド:低い
フロントエンド:高い
高い
サービス例 WordPress
Movable Type
contentful
microCMS
-

バックエンド・フロントエンドともに提供される従来型では、提供される範囲内では開発に時間をかけずサイトを提供できます。ただし、パッケージの提供範囲外のことを実現しようとするのは難しいです。

スクラッチ開発は全て自分たちの要件に合わせてバックエンド・フロントエンドともに提供できますが、その全てを開発しなければならないため、開発コストは高くなリます。

バックエンドのみを提供しているheadlessCMSでは、バックエンドの開発は提供されている機能で作成をするため、自由度は低いですが開発コストも低いです。フロントエンドはスクラッチ同様、要件に合わせて自由に開発が可能です。裏を返すと、フロントエンドやビューワーは自分たちで用意をしなければなりません。

もちろん一長一短あるので手放しにheadlessCMSが良い、とは言えません。しかし、スクラッチと従来型CMSの良いとこ取りをしたCMSと言えます。Reader Storeでは、バックエンドで独自機能を提供する必要性は薄く、お客さまに提供するフロントエンドへ集中したい、という意図もありheadlessCMSを選択しました。

headlessCMSを使って開発してみた

リニューアルしたお知らせページ

実際にサイトの1機能であるお知らせ機能をリニューアルした時のおおまかな開発の流れが以下です。

  1. PM・デザイナーからの要件受領
  2. 要件をもとにフロントエンドへ渡す項目を決定
  3. headlessCMSで項目を設定
  4. APIが出るので想定通りかテストを実施
  5. APIを利用してフロントエンドを開発

この工程の中でheadlessCMSを導入したことで特筆したいのは2点です。

バックエンドの開発工数の圧縮

まず特筆すべきは項番3"headlessCMSで項目を設定"の工程です。
今回お知らせ機能を作る際に必要なものは"お知らせを入稿する管理画面"と"画面を出すために利用するAPI"でした。これらを作る際に、項番3で行ったことといえば、"2で決まった項目を、画面から設定していく"ことのみです。実際にはCMS上で設定をした後に、"運用は回るのか"、 "フロントで要件を満たしているのか"などの調整をしていたためやりとりや修正の時間は数日かかっています。しかし、その叩き台となるドラフト版を作るだけなら1~2時間もあれば作成は完了していました。

もしスクラッチで開発をしようとしていた場合はどうだったでしょうか。 headlessCMSで必要だったものは"お知らせを入稿する管理画面"と"画面を出すために利用するAPI"でした。スクラッチであればさらにデータを保管するDBの設計なども必要になります。これらを自前で開発しようとした場合、1~2時間ではとても収まらなかったはずです。

短期間で管理画面がすぐ見える形になると、運用部門と実際の画面を見ながらブラッシュアップできます。フロントエンドとの疎通も前倒しにでき、開発時にも実際のAPIで検証しながら開発を進めることができます。この提供スピードが一番のメリットであると感じました。

フロントエンド開発の柔軟性

項番5"APIを利用してフロントエンドを開発"の工程においてもメリットを発揮しました。
段階的にリニューアルを進めているReader Storeでは、既存のCMSで提供している箇所が多くあります。2023年時点でリニューアルを完了していないページでも、今回移行をした"お知らせ"を出している箇所がありました。

部分的に差し替えたページ

もしも従来型のCMSでリニューアルをしていた場合、従来型のCMSではフロントのページは丸々1ページ分、CMSから直接出されているものになります。部分的な対応をしようとするとiframeで埋め込むといった手法をとる形になります。後々リニューアルするページなので作った埋め込みページは丸々破棄になるか、拡張できるよう対処することになるか、いずれにせよのちのリニューアルに向けては検討が必要になっていそうです。

一方で、headlessCMSではAPIを出すのみなので、既存のページで出しているお知らせの箇所を従来の出し方からAPIの呼び先をheadlessCMSにすることで部分的な対応を実現しました。既存ページの改修は必要ですが、APIはリニューアル後も利用できますし、コストや無駄は従来型と比べ低いものになったはずです。

こうした段階的なリニューアルにおいても、headlessCMSではスムーズな移行に寄与しています。

使って感じた点

先述の通り、2点のメリットをheadlessCMSを用いることで実感できました。

  • バックエンドの提供スピード
  • フロントエンド開発の柔軟性

それ以外にもSaaS型のheadlessCMSを利用する中で実感したことがありましたのでご紹介します。

コンテンツ登録や公開日、バージョンなどの管理機能開発が基本不要

CMSにおいて必要な機能ですが、自前で開発をしようとすると大変な機能です。こちらが元から提供されていることで開発の必要がなかったのは非常に助かりました。

一方で、提供されている標準機能では足りない機能が要件としてある場合は実装に考慮が必要です。
Reader Storeでは未来に公開する記事を、プレビューの日時を指定し、その特定日時に想定した記事で公開されていることを確認したい、という要望がありました。ECサイトであるReader Storeにとって、未公開情報や商品の販売日などを考慮した日時指定プレビュー機能は重要なニーズです。microCMSには標準で下書きプレビュー機能がありますが、この要望を実現するには不十分でした。そのため、CMS側ではなくアプリケーション側の実装で対応することになりました。
このように、標準で提供されている機能から外れると何かしらの考慮が必要になってきますが、microCMSの標準として提供されている機能の範囲であれば、容易に利用ができています。プレビュー機能も標準的な導入は、各フレームワークごとにドキュメントが整理されており容易でした。

内部で管理するインフラを縮小

SaaS型のheadlessCMSを利用することで、インフラ側のOS、利用モジュールのEOLやセキュリティ対応がCMSサービス提供側の責務になります。これにより、自社でメンテナンス対象とするインフラの範囲を現在よりも縮小できました。
今回利用したmicroCMSは開発用ステージング環境を作成する機能を提供していました。本番環境をベースに迅速に別の環境を用意できます。各環境に対して接続IPなどを個別に設定できるため、本番用と開発用を簡単に作成できました。
しかし、サービス提供型のCMSであるため、提供側のインフラに関する制約も考慮する必要があります。例えば、秒間リクエスト数に制限があり、キャッシュからのリクエストには制限がないといった点です。この制限により、クエリに日時を指定した場合にmicroCMSのCDN(CloudFront)のキャッシュがヒットしにくいという事象が発生しました。キャッシュのヒット率を高めるために、指定する日時の秒・ミリ秒単位を切り捨てるといった実装上の工夫をしました。

管理画面上の細かいカスタマイズはフルカスタムに比べ劣る

提供されている機能で実現をする必要があったので、制限されることや別の箇所で回避しなければいけないことがありました。運用部門に強いこだわりがあり、管理画面のカスタマイズが必須要件の場合、フィットはしなかったです。 以下は一例です。

複数項目でのバリデーションができなかった

日時の開始日・終了日を設定するなど、複数項目間でのチェックをしたい場合、スクラッチであれば開始日<=終了日でなければ入力を弾くなどのバリデーションをかけるところでした。しかし、そういった機能の提供はなかったため、管理画面上に注釈を残して運用部門に注意を促し、フロントエンドの表示ロジックにて制御しました。

直接htmlを打ち込めない

テキストエリアにWYSIWYGエディターを提供されていたのですが、WYSIWYGエディター内でhtmlを直接打ち込む機能は提供されていませんでした。シンプルなページを作成する場合は問題ないのですが、特定のページをよりリッチに作成したい、といった要望が簡単には実現できないです。

さいごに

リニューアルに伴いSaaS型headlessCMSを使い始めることになりましたが、一般的に言われるメリット・デメリットは実感できました。それらを踏まえて、開発チームがフロント開発に注力できるチームであれば、CMSを導入する際の選択肢としてheadlessCMSは良い選択肢と言えます。本稿でそれらの感じたことをお伝えできていれば幸いです。
ここまで読んでいただきありがとうございました。