SUZURI WEBアプリケーションエンジニアのarumaです。昨年、SUZURIのデジタルコンテンツ販売の新機能「バリエーション販売機能」をリリースしました。この開発では既存のデータベース構造を大きく変更する必要がありましたが、サービスを停止することなく移行を完了させることができました。この記事では、その無停止移行をどのように実現したかをご紹介します。
バリエーション販売機能とは
バリエーション販売機能は、1つのデジタルコンテンツ商品ページで複数の選択肢(バリエーション)を設定できる機能です。

たとえば以下のような販売方法が可能になります。
- 内容別: 壁紙データをPC用・スマホ用で別々に販売
- グレード別: 特典やライセンスなどによって価格差を設定
- 投げ銭: 無料配布のデジタルコンテンツでも支援を受けられるよう、同じ内容の投げ銭バリエーションを用意
移行前後のデータ構造
移行前の構造
移行前は、以下のような構造でした。
products: デジタルコンテンツ商品(価格情報を持つ)contents: 商品に含まれるファイル(products に 1:N で紐づく)order_details: 購入履歴(products に直接紐づく)

移行後の構造
バリエーション販売を実現するにあたり、いくつかの設計案を検討しました。
- 案1:
productsに紐づくvariantsテーブルを新設し、価格情報と購入履歴の参照先をそちらに移す - 案2: 上位に
product_groupsを追加し、既存のproductsをバリエーションとして扱う - 案3:
products同士に親子関係を持たせる
案2と案3は購入履歴テーブル(order_details)の既存レコードに手を加えなくてよいというメリットがありましたが、最終的には「1つの商品が複数のバリエーションを持つ」という実態を素直に表現できる案1を採用しました。productsとorder_detailsの間にvariantsテーブルを追加する形です。
products: デジタルコンテンツ商品variants: バリエーション(価格情報を持つ、products に 1:N で紐づく)contents: 商品に含まれるファイル(products に 1:N で紐づく)variant_contents: バリエーションとファイルの紐づけ(N:M の交差テーブル)order_details: 購入履歴(variants に紐づく)

1つのデジタルコンテンツが複数のバリエーションを持ち、バリエーションごとに含めるファイルを選択できるようになりました。購入履歴もバリエーションに紐づくよう変更されました。
なお、バリエーション販売を利用しない場合でも、内部的にはすべての商品が1つ以上のバリエーションを持つ構造になっています。バリエーション販売の有無に関わらず一貫したデータ構造で管理できます。
無停止移行の全体戦略
今回の移行では、以下の制約がありました。
- サービス無停止: メンテナンス時間を設けずに移行を完了させる
- 既存データの整合性維持: 過去の購入履歴などを壊さない
- ロールバック可能性: 問題が発生した場合に戻せる状態を維持する
これらを満たすため、段階的に移行を進めるアプローチを取りました。新しいテーブルやカラムを先に追加し、新旧両方に書き込みながら、最終的に新しい構造に切り替えるという流れです。
移行のステップ
Step 1: 新テーブル・カラムの追加
最初のステップでは、アプリケーションの動作に影響を与えずに、新しいテーブルとカラムを追加しました。
追加したもの:
variantsテーブル: バリエーション情報を管理variant_contentsテーブル: バリエーションとコンテンツファイルの多対多関係を管理する交差テーブルorder_details.variant_idカラム: 購入履歴とバリエーションを紐づける
この段階では、追加したテーブルやカラムはまだ使用されません。既存の機能は従来通り動作し続けます。
Step 2: 二重書き込みの実装
次に、データの作成・更新時に新旧両方のテーブル・カラムに書き込むようにしました。
実装した二重書き込み:
Product作成・更新時に、対応するVariantも(無ければ)作成し、価格やコンテンツファイルの紐づけを同期OrderDetail作成時に、variant_idもセット
# Product作成時にVariantも作成するイメージ
class Product < ApplicationRecord
after_save :sync_variant_price
private
def sync_variant_price
variant = variants.first_or_initialize
variant.update!(price: price)
end
end
この時点から、新しく作成されるデータは新構造に対応した状態になります。
Step 3: 既存データのバッチ移行
二重書き込みの実装後、既存データを新構造に移行するバッチ処理を作成・実行しました。
実行したバッチ処理:
- 既存の
Productに対応するVariantレコードを(無ければ)作成 - 既存のコンテンツファイルとバリエーションの紐づけを同期
- 既存の
OrderDetailにvariant_idを(未設定なら)セット
バッチ処理では、以下の点に注意しました。
- 冪等性の確保: 同じバッチを複数回実行しても問題ないようにする
- 大量データへの対応:
find_eachを使い効率良く実行する - 処理結果の検証: 処理件数やエラー件数をログに出力し、結果を確認・検証可能にする
# バッチ処理の構造イメージ
namespace :oneshot do
task create_variant_for_products: :environment do
created_count = 0
skipped_count = 0
error_count = 0
Product.find_each do |product|
if product.variants.exists?
skipped_count += 1
next
end
product.variants.create!(price: product.price)
created_count += 1
rescue => e
error_count += 1
Rails.logger.error("Failed for product #{product.id}: #{e.message}")
end
puts "Created: #{created_count}, Skipped: #{skipped_count}, Errors: #{error_count}"
end
end
Step 4: APIの拡張(GraphQL)
SUZURIでは一部のAPIにGraphQLを採用しています。データ移行が完了した後、GraphQL APIを拡張してフロントエンドから新構造のデータを取得できるようにしました。
実装した変更:
Variant型をGraphQLスキーマに追加Product型にvariantsフィールドを追加OrderDetail型にvariantフィールドを追加
type Product {
id: ID!
title: String!
price: Int!
variants: [Variant!]! # 追加
}
type Variant { # 追加
id: ID!
title: String
price: Int!
published: Boolean!
}
Step 5: フロントエンドの読み取り対応
Step 4で拡張したGraphQL APIを利用して、フロントエンドで新しいデータ構造を読み取り、表示できるようにしました。
対応した画面:
- デジタルコンテンツ一覧画面: 価格の範囲表示(最低価格〜最高価格)
- 売れたもの一覧画面: 購入されたバリエーション名を表示
- 買ったもの一覧画面: 購入したバリエーション名を表示
この段階で、フロントエンドは複数のバリエーションを持つ商品も正しく表示できる状態になりました。しかし、まだバリエーション作成機能はリリースされていないため、本番環境に複数バリエーションを持つ商品は存在しません。そのため、ユーザーから見た動作は従来と変わりません。
Step 6: バックエンドの書き込み対応(Mutation拡張)
Step 4では読み取り用のAPI(Query)を拡張しましたが、このステップでは書き込み用のAPI(Mutation)を拡張しました。
実装した変更:
Product作成・更新用のMutationでバリエーション情報を受け取れるように拡張- 受け取ったバリエーション情報をもとに
Variantレコードを作成・更新する処理を実装
Step 7: フロントエンドの書き込み対応とリリース
最後に、バリエーションの作成・編集機能をフロントエンドに実装し、機能をリリースしました。
実装した機能:
- バリエーション編集フォーム: 複数のバリエーションを追加・編集・削除できるUI
- バリエーションごとのコンテンツ選択: どのファイルをどのバリエーションに含めるか選択

これにより、クリエイターが実際にバリエーション販売を利用できるようになりました。
おわりに
バリエーション販売機能の開発は、私がSUZURIに所属して初めて担当した大きなプロジェクトでした。また、計画から実施まで一貫して携わるのも初めての経験で、無事にリリースできたときは安堵しました。
段階的にリリースを進める中で、途中で想定外の問題に気がつくこともありました。たとえば、これまで1商品に1価格だったものが、バリエーション導入により1商品が価格の幅を持つようになり、UIで追加の対応が必要になったことがありました。細かくリリースし動作確認していたおかげで、問題を早期に発見し、都度対処していくことができました。
また、データベースの大規模な変更は不安がつきものですが、二重書き込みを経由したことで、データが失われていないか・不整合が起きていないかを都度確認しながら進めることができたのも良かったポイントです。
この記事が同様の課題に取り組む方の参考になれば幸いです。