
クーポン機能をいい感じの設計で実装して、気持ちよくなった話。
はじめましての人ははじめまして!お久しぶりの方はお久しぶりです!
Buildサービスチームのソフトウェアエンジニア、佐々木です。
だいぶ暑くなってきて、そろそろ夏本番って感じですね!
さて、最近とあるECアプリの開発に参加させて頂き、その中でカート内の商品に対するクーポン機能を実装しました。
そこで体験した、一見複雑そうだったロジックを自分なりの設計でシンプルに実装できたときの気持ちよさを共有したくなったので、筆を執らせていただきます。
TL; DR:
現行のカートの実装に、カート内容の更新という形でクーポン機能を追加しようとしたら、思ったより複雑な感じになって大変でした。
方針を変えて、カート内容を毎回イミュータブルに再作成する形で全体的に実装しなおした結果、シンプルでわかりやすいコードかつ短期間で機能を実現できました。
イミュータブルな設計、最高!
以下、詳細
前に開発に携わっていたプロダクトがあり、ある日、その担当の方からもう少し力が欲しいというご要望をいただいたので、私は助っ人に行くことになりました。
だいたいECアプリに分類されそうなプロダクトです。
行ってみると、以下のようなミッションが与えられ、取り組むことになりました。
カート内の商品を対象にしたクーポン機能を実現してほしい
そこで、まずは現状のカートの実装を確認してみました。
実装開始!
カートの中身は、DB的には大体こんな感じでした。
(本番では商品オプションがあったり、商品マスタが履歴管理できるようになってたりしますが割愛。ほかのテーブルも同じ)

普通ですね。
そこにクーポン機能を追加するために、こんな感じでクーポン適用のテーブルを作りました。

クーポン周りのテーブルたちも作りました。
大体こんな感じで、いろいろ設定したり拡張したりできるように作りました。

納得のいく感じにテーブル設計ができたので、ロジックの実装に移りました。
とりあえず最初は何も考えずに、クーポン適用依頼リクエストが来たら、カート内の商品を上から順番に見て行って、適用できる商品があったら適用していけばいいっしょ!って感じで実装してみました。
思ったより複雑…
すると、1注文当たりの適用可能回数をオーバーした際にはカート内商品を分割する必要があったり、クーポンの適用されたカート内商品が削除されたり個数が減ったりしたときには、分割してたやつを再び結合する必要があったりして、思ったより複雑な処理を書かないといけなさそうなことに気が付きました。
また、価格の異なる複数の商品に適用可能なクーポンを適用する際にも、いい感じに適用されてくれません。
お客さん的には高い商品から順番に10%引きされてほしいはずですが、商品が追加されるたびに価格を比べて最適な商品にクーポンを付けたりそうでない商品からクーポンを外したりするのは結構大変です。
イミュータブルな設計への気づき
ここに至って、すでにクーポンが適用されている状態のカート内の商品たちを捏ね繰り回すと、複雑な感じになるのは回避不可能そうだと気づきました。
カートから一度商品を全部取り出して種類ごとに並び替え、1個ずつクーポンを適用しながら入れなおすようなイミュータブルな設計で実装し直すほうが、かなりシンプルそうです。
その場合、例えばカートに商品を追加する処理だと、
1. リクエストで追加を依頼された商品の、カート個別商品DTOを列挙する
2. 現在のカート内の中身をDBから取得し、カート個別商品DTOを列挙する
3. 1と2を合わせて、商品別 > 商品オプション別でグルーピングし、価格順でグループを並べ替える
4. グループに対して、クーポン適用情報を一度全部削除してから再度適切な順番で適用し、クーポン適用情報ごとに 4 の結果をさらに分割したグループを作る
5. 元のカートの並び順をできる範囲で復元する
6. カートのあるべき姿が完成したので、DBと同期する
という感じで、比較的簡潔にクーポンを適用できます。
DTOはグルーピングできればいいので、必要最低限の情報を持たせてこんな感じにしました。ICartLineの実装クラスには比較処理だけ実装して、それ以外はCartLineを集約するオブジェクトに処理させる感じにしました。
public interface IProductOption {
int ProductOptionId { get; set; }
int Count { get; set; }
}
public interface ICartLine {
int ProductId { get; set; }
IEnumerable<IProductOption> ProductOptions { get; set; }
IEnumerable<string> CouponCodes { get; set; }
}
実装しなおした結果
上述の方針でクーポンの適用という複雑なルールを持つ更新処理を、作成と削除のみのイミュータブルな処理に設計しなおした結果、わかりやすいコードで完璧な動作を実現することができました。
既に実装されていたカート内商品のCRUD処理と決別するには勇気が必要でしたが、再実装し始めると何も悩むことはなく半日くらいで完了できました。
もっと早くやっておけばよかったです。
ぼくのかんがえたさいきょうのロジックが綺麗に動いてる姿を見ると、気持ちいいですね。
おわりに
最後までお読みいただきありがとうございました。
複雑なロジックをシンプルに実装できた気持ちよさを、読者の皆様にも一緒に感じていただけたなら幸いです。
CTCの「Buildサービスチーム」では、現在積極採用中!
伴走型のプロダクト開発で日本のDXを牽引するベンチャー風土の組織の中で、あなたも一緒に成長してみませんか?
ご興味を持たれた方は、以下から各ポジションの詳細をぜひご確認下さい。
あなたのご応募を心よりお待ちしております!
・ソフトウェア開発エンジニア
・ソリューションオーナー
・ソリューションアーキテクト
・クオリティエンジニア
余談:Visioが思ったより便利
ところでこの記事を書くために初めてMicrosoft Visioを使ってみたところ、DB設計用の専用機能があってかなり使いやすかったです。
VisioがDB設計を理解してくれている中で図を書けるので、メンテナンスとかしやすそう。
PlantUMLより閲覧・編集のハードルが低く、非エンジニアにも見せ易そうなのもGoodです。
今後使っていきたいです。