Value Object (値オブジェクト) でリファクタリングしたら結構良かった

最近仕事でValue Object (以下、値オブジェクト) を使って一部の処理をリファクタリングした。

少し前には「値オブジェクトとは何であるか、どうあるべきか」がTwitterで物議を醸しており(Value Objectとは何であるか?)、世間的にも関心が高いテーマらしい。ここでは学術的な話には触れずに、実践的な話としてプロダクト開発の途中(リファクタリング過程)に取り入れた中で得た気づきを書く。

もし似たような境遇に遭っている方の参考になれば嬉しい。

値オブジェクトとは

端的に言うと、複雑性を低減するための実装戦術。

「戦術」というと固いので、言い換えるとテクニック、技術、具体的なやり方。

型で表現制約を設ける事でわかりやすくする手法 (言語機能で守る or 実装表現で守る) と理解しています。

詳細な概念説明は、既に多くの記事や書籍で行われているため割愛。

理解のためにオススメする文献

動機

「有名な書籍で提唱されているし、なんかイケてそうだから取り入れたい〜!ウェ〜イ!」といった気持ちで始まったわけではなかった。それよりも、「値オブジェクト的なアプローチを取らないと実装上苦しい… とりあえず試してみる…か…」という感じ。

この時に遭遇した実装上の苦しさは、プリミティブ型の限界

プリミティブ型だけでは複雑なドメインでの処理で認知負荷が上がり、コードを読み解くのが苦痛、変更を加えるのが苦痛、影響範囲を考慮するのが苦痛という3重苦を味わうことになっていた。

苦痛をもう少し丁寧に表現すると、「正しく読み解けているのか不安、それなのに変更を加えなきゃいけない任務、もう影響範囲がわからないから責任持てない…」といった感じ。 (※ わかりやすくナイーブな表現をしている)

特殊なシーンではなく、大きく成長したプロダクトの開発に携わる人なら誰もが経験する複雑さとの戦いそのもの。この認知負荷をどうにかして、従来よりも安心安全に変更できる状態を作り、末永く面倒を見続けていきたい、という気持ちから始まった。

なお、戦術を適用したドメインは将来的な変更可能性が高い領域でもあったため、リファクタリングすること自体が十分妥当だろうと判断している。

要件

※ 以下は実際に遭遇したケースに似せて、かなりわかりやすく表現した例です。

とあるECで注文金額に対してポイントによる割引を行う。

これをドメインではポイント利用と呼ぶ。

ポイント利用は単純な支払い合計金額に対しての割引ではない。

ポイント利用はお客様と店舗への公平な配慮として、ポイント獲得対象外金額 (税金, 配送料, 公共商品) → 通常ポイント獲得対象金額 → キャンペーンポイント獲得対象金額 という順番で割引を行う。

つまり、お客さまはポイントを利用した場合でも獲得ポイント (通常 & ボーナス) で損をすることはないということ。また、ポイント運営者側も合理的に利用されるため損が少ない。

この要件を満たして、獲得ポイント数を算出する処理を実装する。

[余談] そもそも獲得と利用を切り離してシンプルにすれば良いのでは?というツッコミが入れられますが、さまざまな事情からこの要件を守らざるを得なかったりするのです… それがビジネス。

具体例

前提

  • 通常は200円で1ポイント獲得とする。
  • キャンペーンは300円で5ポイント獲得とする。
  • 税金300は適当
定義
税金300
対象外金額200
対象内金額 (通常)700
対象内金額 (キャンペーン)300
注文合計金額1500
ポイント利用900
支払い合計金額600 (通常 300, キャンペーン 300)
獲得ポイント (通常)1
獲得ポイント (キャンペーン)5
獲得ポイント合計6

ドメイン分析とモデル化

ここで「モデル化」と呼ぶのは、実装者が理解しやすいように重要な側面に注目して、端的な形に抽象化する行為であると定義します。

また、実際に実務で行なっている自身のモデル化を行う時の書き振りを近しく再現(中身は変更)しているため、わかりづらいかもしれませんが、”実務ではこうやっている” というのを理解していただければ。

先の要件を整理すると、数という概念に金額とポイントという2つのドメインモデルが含まれる。

金額とポイントという異なる概念を計算して最終的に獲得ポイント数を導き出す必要がある。

存在する制約

  • 金額が負の数になることはありえない。
  • ポイントが負の数になることはありえない。
  • 金額は日本円のみを考慮し、外貨は存在しない。
  • ポイントは文脈によって呼び名が変わるが、単位は変わらない。
  • 支払い金額合計以上にポイント利用数が設定されることはない。
  • 金額に小数点は存在しない。
  • ポイントに小数点は存在しない。
  • ポイント利用の順序を守る。
  • 通常獲得ポイント数は200円につき1ポイント
  • キャンペーン獲得ポイント数は300円につき5ポイント

ポイント利用の順序

  • ポイント付与対象外金額 (税金, 配送料, 公共商品) → 通常ポイント獲得対象金額 → キャンペーンポイント獲得対象額

「順序を守って計算する」という要件とそこに存在する金額の種別 (対象外, 通常, キャンペーン) が複雑性の根源。

計算モデル

  • ポイント利用数 - 対象外金額 = 残ポイント利用数A
  • 残ポイント利用数A - 通常対象金額 = 残ポイント利用B
  • 通常対象金額 - 残ポイント利用数A = 通常獲得対象金額
  • キャンペーン対象金額 - 残ポイント利用数B = キャンペーン獲得対象金額
  • (通常獲得対象金額 / 200)切り捨て * 1 = 通常獲得ポイント
  • (キャンペーン獲得対象金額 / 300)切り捨て * 5 = キャンペーン獲得ポイント

このモデル化で明らかにできた複雑性の原因

  • 残ポイントの見積もり
  • 金額とポイントという異なる概念の数に対する計算
  • 再代入を行わない10個の変数

モデル化をした時に認識できた要点は、「順序を守って計算するために”見積もり”という概念を定義する必要があり、それは金額とポイントという異なる数値概念を演算することである」ということ。

ここまでの整理を踏まえて具体的にどう変わったのかをコードで確認する。

どう変えたのか

AsIs

import 'dart:math';

void main() {
  
  // 定数
  const regulerPointRatio = 200;
  const campaignPointRatio = 300;
  const campaignRate = 5;
  
  // 利用ポイント
  final usagePoint = 900;
  // 税金
  final tax = 300;
  // 対象外金額
  final notApplicablePrice = 200 + tax;
  // 対象内通常金額
  final regulerPrice = 700;
  // 対象内キャンペーン金額
  final campaignPrice = 300;
  

  final usagePointForReguler = max(usagePoint - notApplicablePrice, 0);
  final usagePointForCampaign = max(usagePointForReguler - regulerPrice, 0);
  
  
  final grantPriceForReguler = regulerPrice - usagePointForReguler;
  final grantPriceForCampaign = campaignPrice - usagePointForCampaign;
  
  final regulerPoint = (grantPriceForReguler / regulerPointRatio).floor();
  
  final campaignPointBase = (grantPriceForCampaign / campaignPrice).floor();
  final campaignPoint = campaignPointBase * campaignRate;
  
  // 合計ポイント
  final totalPoint = regulerPoint + campaignPoint;
  
  print(totalPoint); // print 6
}

※ サンプル用に単純化したせいで良さを理解しづらくなってしまったかもしれません。実態はAsIsをさらに複雑化した感じで、サンプル以上に minmax を多量に使っていました。例えば minmax で金額とポイントが比較され、さらにネストされるなど。。。

ToBe

import 'dart:math';

void main() {
  
  // 定数
  const regulerPointRatio = 200;
  const campaignPointRatio = 300;
  const campaignRate = 5;
  
  // 金とポイントという2つのドメインオブジェクトを計算するためのドメインサービス
  final pricePointCalculator = _PricePointCalculator();
  
  // 利用ポイント
  final usagePoint = _Point(900);
  // 税金
  final tax = _Price(300);
  // 対象外金額
  final notApplicablePrice = _Price(200) + tax;
  // 対象内通常金額
  final regulerPrice = _Price(700);
  // 対象内キャンペーン金額
  final campaignPrice = _Price(300);
  
  // 見積もり
  final usagePointForReguler = pricePointCalculator.estimatePriceFromPoint(notApplicablePrice, usagePoint);
  final usagePointForCampaign = pricePointCalculator.estimatePriceFromPoint(regulerPrice, usagePointForReguler);
  
  // ポイント獲得対象の金額
  final grantPriceForReguler = pricePointCalculator.subtractPointFromPrice(regulerPrice, usagePointForReguler);
  final grantPriceForCampaign = pricePointCalculator.subtractPointFromPrice(campaignPrice, usagePointForCampaign);
  
  // 通常ポイント
  final regulerPoint = _Point.fromPrice(grantPriceForReguler.value, regulerPointRatio);
  
  // キャンペーンポイント
  final campaignPointBase = _Point.fromPrice(grantPriceForCampaign.value, campaignPointRatio);
  final campaignPoint = campaignPointBase * campaignRate;
  
  // 合計ポイント
  final totalPoint = regulerPoint + campaignPoint;
  
  print(totalPoint.value); // print 6
}

class _PricePointCalculator {
  const _PricePointCalculator();

  // 金額からポイントを減算する, 最小値は0
  _Price subtractPointFromPrice(_Price price, _Point point) {
    final diff = max(price.value - point.value, 0);
    return _Price(diff);
  }

  // ポイントから金額を減算し、残るポイントを見積もる, 最小値は0
  _Point estimatePriceFromPoint(_Price price, _Point point) {
    final diff = max(point.value - price.value, 0);
    return _Point(diff);
  }
}

// 金額を表現する値オブジェクト
class _Price {
  final int value;

  _Price.zero() : value = 0;

  _Price(this.value) {
    if (isInvalid(value)) throw Exception('Invalid Value $value');
  }

  bool isInvalid(int value) {
    return 0 > value;
  }

  _Price operator +(_Price price) {
    return _Price(value + price.value);
  }

  _Price operator -(_Price price) {
    return _Price(value - price.value);
  }

  _Price operator *(int rate) {
    return _Price(value * rate);
  }
}

// ポイントを表現する値オブジェクト
class _Point {
  final int value;

  _Point(this.value) {
    if (isInvalid(value)) throw Exception('Invalid Value $value');
  }

  bool isInvalid(int value) {
    return 0 > value;
  }

  _Point operator +(_Point point) {
    return _Point(value + point.value);
  }

  _Point operator -(_Point point) {
    final diff = max(value - point.value, 0);
    return _Point(diff);
  }

  _Point operator *(int rate) {
    return _Point(value * rate);
  }

  // ポイント = (ポイント獲得対象金額 / ポイント基準金額)の切り捨て
  factory _Point.fromPrice(int price, int ratio) {
    return _Point((price / ratio).floor());
  }
}

結果どうなったのか

AsIsを最初に見た時の課題感

  • 金額とポイントという2つの概念が計算可能な状態なので、変更時にバグを生みやすい。
  • 金額とポイントという2つの概念を計算した結果がポイントになるのか金額になるのかわかりづらい。そもそも計算できて良いものなの…? という疑問が生まれる。
  • minやmaxといった演算子が使われているが、それを使う意図が不明。
  • 計算結果がポイントなのか金額なのかは変数名を読まないとわからない。

良くなったこと

  • 可読性が大幅に向上した。見積もり、金額算出、ポイント算出というのが手続き処理の中でもメソッド名から推論できるようになった。
  • 金額とポイントという概念が登場し、それらに負の数は存在し得ないことがコード上からわかるようになった。故に考えることが減った。
  • 金額とポイントという異なる概念の数を計算することが手続き処理上はできなくなった。2つの概念に対して操作する場合、必ず意味ある手続きになるように定義された。
  • ポイントは金額から生成できることが明文化された。
  • エディター上のサポートで型が確認できるのでわかりやすくなった。

悪くなったこと

  • コード量が大きく増えた。
image2

(VSCodeで見た場合の表示。カーソルを合わせるだけでTypeが何かわかる)

値オブジェクトを取り入れるのはいつ?

正直にわかっておらず、これといった基準が確立できていない。※ DDDなどの話を除く

ただし、活用できるシーンは多くあるはず。世間的には通貨, 住所, ポイント, 日付といった概念で扱う例が取り上げられている。

今回解決した異なる数値概念の計算を行うドメイン領域では、間違いなく活用した方が良いということだけは自信を持って言える。

最後に

値オブジェクトを使ってリファクタリングした内容を紹介してきた。

ドメインで解決したい課題が複雑であることは変わらないが、表現に規則的なルール(秩序)を持たせることで、実装の複雑さを一定抑えることができること。それを実装例を踏まえて、手触り感を持って理解していただけたら嬉しいです。

参考文献

追記 (7/2)

記事公開後に「"見積もり"という概念が見出されたのなら、その単位でclassを定義して振る舞えば良いのではないか」というフィードバックをもらいました。

確かにそうだと思いました。 PricePointCalculator はPriceとPointを計算することを明示化する意図などがありました。意図はあるものの、都合の良いクラス概念で、できればそういったものは減らし、洗練された抽象度の低い具体的な責務を表すクラスで表現するのが望ましいです。その方が変更を加えるときに加えやすくなりますし、変なGodクラスなどができづらくなるからです。

このフィードバックを受けて以下のように書き換えられました。


import 'dart:math';

void main() {
  // 定数
  const regulerPointRatio = 200;
  const campaignPointRatio = 300;
  const campaignRate = 5;

  // 利用ポイント
  final usagePoint = _Point(900);
  // 税金
  final tax = _Price(300);
  // 対象外金額
  final notApplicablePrice = _Price(200) + tax;
  // 対象内通常金額
  final regulerPrice = _Price(700);
  // 対象内キャンペーン金額
  final campaignPrice = _Price(300);

  // 見積もり
  final usagePointForReguler =
      _PointEstimate.create(notApplicablePrice, usagePoint).balance;
  final usagePointForCampaign =
      _PointEstimate.create(regulerPrice, usagePointForReguler).balance;

  // ポイント獲得対象の金額
  final grantPriceForReguler =
      regulerPrice.subtractPointFromPrice(usagePointForReguler);
  final grantPriceForCampaign =
      campaignPrice.subtractPointFromPrice(usagePointForCampaign);

  // 通常ポイント
  final regulerPoint =
      _Point.fromPrice(grantPriceForReguler.value, regulerPointRatio);

  // キャンペーンポイント
  final campaignPointBase =
      _Point.fromPrice(grantPriceForCampaign.value, campaignPointRatio);
  final campaignPoint = campaignPointBase * campaignRate;

  // 合計ポイント
  final totalPoint = regulerPoint + campaignPoint;

  print(totalPoint.value); // print 6
}

// ポイントの見積りを表現する
class _PointEstimate {
  final _Price price;
  final _Point point;

  _PointEstimate._(this.price, this.point);

  // ポイントから金額を減算し、残るポイントを見積もる, 最小値は0
  _Point get balance {
    final diff = max(point.value - price.value, 0);
    return _Point(diff);
  }

  factory _PointEstimate.create(_Price price, _Point point) {
    return _PointEstimate._(price, point);
  }
}

// 金額を表現する値オブジェクト
class _Price {
  final int value;

  _Price.zero() : value = 0;

  _Price(this.value) {
    if (isInvalid(value)) throw Exception('Invalid Value $value');
  }

  bool isInvalid(int value) {
    return 0 > value;
  }

  _Price operator +(_Price price) {
    return _Price(value + price.value);
  }

  _Price operator -(_Price price) {
    return _Price(value - price.value);
  }

  _Price operator *(int rate) {
    return _Price(value * rate);
  }

  _Price subtractPointFromPrice(_Point point) {
    final diff = max(value - point.value, 0);
    return _Price(diff);
  }
}

// ポイントを表現する値オブジェクト
class _Point {
  final int value;

  _Point(this.value) {
    if (isInvalid(value)) throw Exception('Invalid Value $value');
  }

  bool isInvalid(int value) {
    return 0 > value;
  }

  _Point operator +(_Point point) {
    return _Point(value + point.value);
  }

  _Point operator -(_Point point) {
    final diff = max(value - point.value, 0);
    return _Point(diff);
  }

  _Point operator *(int rate) {
    return _Point(value * rate);
  }

  // ポイント = (ポイント獲得対象金額 / ポイント基準金額)の切り捨て
  factory _Point.fromPrice(int price, int ratio) {
    return _Point((price / ratio).floor());
  }
}

ポイントの見積りをそのまま PointEstimate というクラスに定義し、見積りを行った残数を balance で取得できるようにしています。

金額に対するポイントの割引処理は Price クラスに移しました。引数に Point クラスを取りますが、 subtractFromPoint というメソッド名からポイントを引数に取り割引くことが自明であるのと、返り値が Price であるため、値オブジェクトとしての振る舞いとしては正しさを保っています。

このようにリファクタリングやモデリングはフィードバックをもらい、気づきを得ながらより良い表現にしていくプロセスだということを、この追記からも理解いただければ幸いです。

追記 (2023/01/27)

この記事での取り組みから、おおよそ半年ほどが経った。

最近型定義に精通した同僚が職場に加わってくれて、色々と学ばせてもらっている。

ちょうどこの領域の改善案をフィードバックとしてもらう機会があり、その内容を紹介する。

フィードバックの内容は、

金額とポイントは正の整数にしかならないため、そこに着目した共通の基底クラスで型宣言し、不正な値にならないようにする。 さらに同じ型を継承しているオブジェクト同士で安全に計算処理を行える形にし、中間操作クラスを消して凝集度を上げるアプローチを選択しても良いのではないか?

というもの。

前提として、金額とポイントが計算上 -100 といった負の数になるのを想定していないドメインであることがわかっている。

どうなるかを見てみる。


/// この規定クラスによって、必ずPositiveなInt型の値のみが扱われることが保証される
abstract class _TaggedPositiveInt<C> {
  final int _value;

  _TaggedPositiveInt._(this._value) {
    if (_isInvalid()) throw Exception('Invalid Value $_value');
  }

  bool _isInvalid() {
    return 0 > _value;
  }

  C _withNewValue(int value);

  C operator +(_TaggedPositiveInt<C> positiveInt) {
    return _withNewValue(_value + positiveInt._value);
  }

  C operator -(_TaggedPositiveInt<C> positiveInt) {
    return _withNewValue(max(_value - positiveInt._value, 0));
  }
}

class _Price extends _TaggedPositiveInt<Price> {

  _Price.zero() : super._(0);

  _Price(int value) : super._(value);

  
  Price _withNewValue(int value) => Price(value);

  Price subtract(Point point) {
    return _withNewValue(_value - point.value);
  }

    
  Price _withNewValue(int value) => Price(value);

  /// 金額からポイントを減算する, 最小値は0
  Price subtract(Point point) {
    return _withNewValue(_value - point._value);
  }
}


class Point extends TaggedPositiveInt<Point> {

  Point(int value) : super._(value);

  factory Point.fromPrice(Price price, int ratio) {
    return Point(price._value.safeDivide(ratio).floor());
  }

  Point operator *(int rate) {
    return _withNewValue(_value * rate);
  }

  
  Point _withNewValue(int value) => Point(value);

  Point estimateSubtractRemind(Price price) {
    return Point(_value - price._value);
  }
}

TaggedPositiveInt という型を定義して、それに沿ったクラスで扱うint型のフィールドは必ず正の値になることが保証されるもの。

この定義の追加により、_PricePointCalculator というポイントと金額を計算操作する存在がなくなり、ポイントと金額のオブジェクトが双方に型の仕組みに沿って安全に計算が可能になり(subtractメソッド)、両者の凝集性がより高まった状態を作ることができる。

なるほど、と思った。

この表現に行き着く際にモチベーションは何かを雑談で聞いたところ2つあるそう。

  1. プラスとマイナスのオペレータを利用するクラスの共通化
  2. 異なる文脈だが類似の性質を持つオブジェクト間で安全な操作を可能にする

今回扱ったポイントと金額という値オブジェクトは、計算を行うオブジェクトとして共通していた。 故にそれぞれのオブジェクトに計算操作を担うオペレータを定義しており、計算可能な形で定義。この操作の共通性を抽象な型でまとめ上げたいと考えた。 さらにどちらの値オブジェクトでも正の整数のみ扱われる (負の数は0に丸め込まれる) という性質と、異なる概念だが計算する関係でもあるという特徴もあったため、計算処理を持ち、正数であることを保証するという抽象型の定義に至ったという。

良い言語化で、特に着眼点が自分が見えていなかったところだったので新しい見方を得られた感覚があった。その角度で物事をとらえるのかと。

TaggedHogeHogeIntTagged という表現は Tagged type の考えを参考にしているようで、自分は全く知らない世界だった。これからキャッチアップしようと思う。

SHARES