😇

途中からイミュータブルデータモデルにしたくなった!

に公開

はじめに

株式会社ウェイブCoolmicのエンジニアをしている布施です。
Coolmicは日本のコミックを海外向けに配信するWEBサービスです。

今回、サイト内で販売しているコンテンツの価格改定を行うためにシステムの修正を行いました。
スキーマ設計やコードの結合度などについて考える良い機会になったので、やったことの一部を記録します。

サンプルコードはRuby(Rails)です。

背景

データベースのCRUD操作のうちUpdateとDeleteはシステムを複雑にしてしまう、というような記述を時々見かけます。
コンテンツの価格改定を考える時に最も単純な方法はcontentsテーブルのレコードのpriceカラムをUpdateすることですが、この方法だと価格改定履歴が残りません。
また、改訂前にそのコンテンツを購入したユーザーの購入履歴を集計するときに、データに不整合があるように見える可能性があるなど、他にもよくないことがありそうです。

電子コミックというコンテンツの都合上、購入したコンテンツはユーザーと(ほぼ)永久的に関連づけられます。そのため、contentsの古いレコードをサイト内で非表示にして、新しいpricecontentsレコードを作り直すのもイマイチです。

このような背景から、コンテンツの価格改定履歴を管理するためのcontent_pricesテーブルを作成する方法を考えました。

やりたいこと

  • モデルの一カラムとして管理していたデータを、新しい専用のモデル(これがイミュータブルになる(はず))に抽出する。
  • 新モデルとのリレーションが存在しない場合、元のモデルのカラムを参照するようにする。
  • 元のモデルのテーブルのカラムを直接参照している箇所を修正する。☠️

イメージはこんな感じです。
contentsテーブル

id name price
1 Content01 1.00
2 Content02 2.00

content_pricesテーブル

id content_id price start_at
1 1 1.11 2025/01/01 00:00:00
2 1 1.22 2025/02/01 00:00:00
3 2 2.22 2025/03/01 00:00:00

Content01のpriceは、$1.00 -> $1.11 -> $1.22と改定されていきます。

やったこと

ContentPriceモデルの作成

新規作成しまーす
以上

ActiveRecordのGetterのオーバーライド

Railsのモデルのカラムは以下のように参照することが多いと思います。

content = Content.find(21)
content.price

このような参照ができるのは、ActiveRecord::Baseが自動で各カラムのgetter/setterを定義しているからです。
今回はこのgetterメソッドをオーバーライドすることで、変更箇所を最小にしました。
Contentpriceの参照箇所は結構多く、新規メソッドへの置換漏れのリスクもあるのでこの方法を採用しました。まあ結局全部チェックするんですが

変更イメージ

class Content < ActiveRecord::Base
  has_one :latest_content_price, ->{ order(id: :desc) }, class_name: "ContentPrice", dependent: :destroy

  def price
    return self[:price] if self.latest_content_price.blank?
    self.latest_content_price.price
  end
end

ContentPriceはコンテンツの価格情報を持つモデルです。
datetimeのstart_atカラムを持たせれば、価格改定の開始タイミングもコントロールしやすくなります。
ContentPriceとのリレーションが存在しない場合はContentpriceを参照します。

self[:price]

これで、全コンテンツのcontent_pricesテーブルのレコードを準備しなくてもよくなります。

参考

https://api.rubyonrails.org/v7.1/classes/ActiveRecord/Base.html#:~:text=Overwriting default accessors

テーブルのカラムを直接参照している箇所の修正

なんかcontent_pricescontentsLEFT OUTER JOINとかしていい感じにGROUP BYしてどうにかしました。
気合いです。

N+1問題の予防

コンテンツをリストとして扱う箇所もあるので、全ての該当箇所に.includes(:latest_content_price)を追加しました。

感想

今回はコンテンツの価格改定を扱うことになり、改めてデータモデリングの重要性について考えさせられました。
「価格」は「コンテンツ」の属性の一部とする考え方も自然に思えますが、独立して変更される可能性があると、別のモデルとして管理した方が良い場合もあるかもしれません。もちろんコンテキストによりますが。
「価格」というリソースではなく、「あるタイミングでこの価格が設定された」というイベントエンティティとも捉えられるということですかね。わかりません。

また、ECサイトである都合上、コード上にpriceという単語はかなり多く登場します。(それも異なるコンテキストで😇)
多少不自然になったとしても、コンテキストにあったユビキタス言語?を設定していけるとよいのかもしれません。

参考

https://scrapbox.io/kawasima/イミュータブルデータモデル

wwwave's Techblog

Discussion