データディクショナリ - 要求分析駆動設計
データディクショナリはDDDではValueObjectに相当する。ただ、すべてがValueObjectのクラスになるわけではない。データモデリングの起点になるものもあれば、バリデーションを定義するだけで済むものなどもありうる。
分析の例
項目名 | 説明 | 構成 | 制限 | 値 |
---|---|---|---|---|
シリアルコード | 商品に一意にふられるコード | 商品コード、カラーコード | ||
カラーコード | 商品の色 | 大文字アルファベット | 長さ2文字まで | W(白)、B(黒)、R(赤) |
到着予定日 | 注文した商品が届く予定日 | 最短到着予定日(日付)、最長到着予定日(日付) | 最長到着予定日は最短到着予定日以降(<=)でなければならない | |
承認ステータス | 稟議書の承認ステータス | 整数 | 承認なし、課長差し戻し、課長承認済み、部長差し戻し、部長承認済み |
到着予定日はデータモデリングのコード例に出てきたが、抽象化したValueObjectとしてDateRangeValueObject
を定義し、到着予定日の計算は別途ビジネスルールとして定義させることができる。
承認ステータスは状態遷移のモデリング候補になる。
このように、データディクショナリはビジネスルール分析と状態遷移モデリングの起点になりうる。
商品コードのように、他のデータディクショナリの項目を参照することもできる。そのまま実装すれば、ProductCodeValueObject
がColorCodeValueObject
のインスタンスを持つ形になるが、カラーコードの制限や値が重要でない場合、クラスを用意せずに文字列として扱ってしまう場合もあり得るし、商品コード全体が文字列でよい場合さえあり得る。
コードとの対応
ファイルパスは以下の通りにする。
app
└── domains
├── bought_items_feature
│ └── xxx_value_object.rb
└── value_objects
└── date_range_value_object.rb
ValueObjectクラスもEntity同様、関連する機能のfeatureディレクトリ内に入れる。特定の機能に依存しない普遍的なValueObjectがあるのであれば、domains
ディレクトリ直下か、domains/value_objects
ディレクトリにまとめて格納する。
コード例
ビジネスルールと同じで、CQS(コマンドクエリ分離)でいえばクエリのみにする。
ValueObjectを簡単に分類すると4つある。なお、排他的に適用されるわけではない。
- ラッパーValueObject
- 計算可能なValueObject
- センシティブなValueObject
- インセンシティブ(鈍感)なValueObject
ラッパーValueObject
コード値などをラップして、判定メソッドと紐付ける。
# ブログの公開状況
class BlogStatusValueObject
attr_accessor :status
PUBLIC = 0
PRIVATE = 1
MEMBERS_ONLY = 2
def initialize(status)
self.status = status
end
# 全体公開
def public?
status == PUBLIC
end
# 非公開
def private?
status == PRIVATE
end
# 指定メンバのみ公開
def members_only?
status == MEMBERS_ONLY
end
end
計算可能なValueObject
演算可能な新しい型を追加する感覚に近い。演算の結果は閉じている方が望ましい。 「閉じている」とは平たくいうと、自身のクラスと同じインスタンスを返すということ。 自然数と自然数の加法は自然数になるので閉じているが、減法はマイナスにもなる(整数になる)ので閉じていない。
個人的な感覚で申し訳ないが、閉じていない演算については、複雑なメタプログラミングに手を出す時と同じぐらいの危険度を持つだろうと感じている。 実装の段階でその演算のポテンシャルを読み切るのが難しく、健全な状態を維持し続けるのも骨が折れる気がしている。
# 以下のような計算が可能になる
# from = Date.parse('2020-10-1')
# to = Date.parse('2020-10-31')
# puts (DateRangeValueObject.new(from) + (1..3)).to_range
# 結果: 2020-10-02..2020-10-04
# puts (DateRangeValueObject.new(from, to) + 1).to_range
# 結果: 2020-10-02..2020-11-01
class DateRangeValueObject
attr_accessor :from, :to
def initialize(from, to = nil)
self.from = from
self.to = to || from
end
# 他の演算子は不要なので加法のみ定義
def +(other)
other_from = other
other_to = other
if other.is_a?(Range)
other_from = other.first
other_to = other.last
end
self.class.new(from + other_from, to + other_to)
end
# プリミティブな値にエクスポートする
def to_range
from..to
end
end
センシティブなValueObjectとインセンシティブ(鈍感)なValueObject
センシティブなValueObjectとインセンシティブ(鈍感)なValueObjectは双子のような関係にある。
ValueObjectはインスタンス化時に与えられた値が許容する値かどうかチェックすることもできる。 Moneyパターン(PofEAA)のようなValueObjectでは、不正な通貨が指定されたらインスタンス化自体を防ぎたい。これをセンシティブなValueObjectとする。 ラッパーValueObjectのような場合では、不正な値を指定されても全てのメソッドでfalse相当の振る舞いをすれば良い場合もある。これをインセンシティブ(鈍感)なValueObjectとする。
センシティブなValueObjectはコンストラクタでバリデーションして不正ならTypeErrorを投げれば良いのでわかりやすい。
class MoneyValueObject
CURRENCIES = ['JPY', 'USD']
def initialize(amount, currency)
raise TypeError.new('不正な通貨が指定された') unless CURRENCIES.include?(currency)
# ...
end
end
インセンシティブ(鈍感)なValueObjectは浮動小数点演算におけるNaN
やSQLにおけるNULL
をイメージすればわかりやすい。
インセンシティブ(鈍感)かつ計算可能なValueObjectを実装するなら、演算しても不正であることが伝播するように実装する必要がある。
IS NULL
のように不正な値かどうかをチェックするvalid?
メソッドを定義することで、ユーザー入力のバリデーションチェックに利用できる。
class CurrencyValueObject
CURRENCIES = ['JPY', 'USD']
attr_accessor :value
def initialize(currency)
self.value = currency
end
def valid?
CURRENCIES.include?(value)
end
end
メンバのValueObjectのメソッドを外部から参照したい場合
シリアルコードから色名を取得したいとする。
class SerialCodeValueObject
# color_codeはColorCodeValueObjectのインスタンス
attr_accessor :product_code, :color_code
# ...
end
class ColorCodeValueObject
def name
# 色名を返す
end
end
上記の状況だと、serial_code.color_code.name
とすれば色名を取得できる。
しかし、product_code
によって同じ色でも色名が変わる要求が増えた場合に困る(ハロウィン商品なら「赤」ではなく「血の色」になるかもしれない)。
そのような要求が発生した場合、serial_code.color_code.name
という風に書いていた箇所をすべて変更しなければならない。
つまり、単品のオブジェクトと、別のオブジェクトに所有されているオブジェクトは同じクラスであっても同じように扱えるとは限らないということを意味する。
なので、メソッドチェインしたくなった時はcolor_code.name
を呼び出すだけのSerialCodeValueObject#color_name
メソッドを追加した方が安全である。
これは、RuleやEntityにおいても同じである。
依存関係
Ruleと基本的に同じなので、いくらか省略する。 ValueObjectはView、Helper、Controller、UseCase、Service、Entity、Rule、他のValueObjectから依存されうる。他のValueObjectに依存されていても、他から依存されてよい。 ValueObjectが依存できるのは他のValueObjectだけである。 Entity同様、Featureを跨いでの依存はしてはならない。親FeatureのValueObjectに依存することは許される。 親FeatureのValueObjectを継承することについては、Featureのサイロ性に比べて実装上のメリットがあるかを考えて判断しなければならない。個人的には継承を許した方がメリットがあると感じている。
データディクショナリをまとめるときまとめないとき、ValueObjectを作るとき作らないとき
通常の要求プラクティスとしてのデータモデリングとデータディクショナリであれば、制約はデータディクショナリにまとめる。 しかし、データモデリングのドキュメンテーションにおいて、制約についてもまとめ、必要であればデータディクショナリを参照すると書いた。 これは、データディクショナリをドメイン固有の用語集として機能させるため、ドメイン固有の用語とは言い難い単なる入力制限などはデータディクショナリから取り除きたいという意図からこうしている。 なので、ドメイン固有の用語といえるデータがあるならデータディクショナリとしてまとめるべき、ということになる。 Moneyパターンのような一見してドメイン固有のものと認識できないが、ValueObjectとして作成する価値のあるデータもある。データディクショナリをまとめる時は見逃しがないか、よく見直した方が良い。 また、似たものを同じ項目として扱ってしまったり、同じものを複数の項目として扱ってしまうミスにも注意しなければならない。 見逃したValueObjectを後から組み込もうとしたり、1つのValueObjectに統合しようとすると広範囲に影響するリファクタリングが必要となり、余計なコスト増とエンバグのリスクにさらされる。
ValueObjectとしてクラスを定義する価値があるかは、まずはコード例であげた4つの分類のいずれかとしてデータをラップすると有用かどうかで判断するのが良い。 単なる文字列データとしてしか扱わず、ORMのバリデーション機能で十分なら、インセンシティブ(鈍感)なValueObjectを作成してバリデーションさせる価値は薄い。 4つの分類も私が思いついたものであり、4つのいずれでもないが作る価値のあるValueObjectというのは当然ありうる。 データディクショナリに出てきて、そのデータをレシーバになんらかのメソッドを呼び出せるようにしたいなら、おそらくValueObjectを作る価値がある。
ドキュメンテーション
データディクショナリは、ドメイン固有のデータに関する用語集として機能する。 データディクショナリを参照したとき、あるデータがどういう名前や略語で呼ばれ、どういう特性や意味を持っていて、どういったデータ構成をしており、どういった値を持ちうるのかがわかるようにしなければならない。
最低限以下の項目をまとめる必要がある。
- 名称および略語
- 説明
- データの構成
- 値の持ちうる範囲や制限(通貨のような場合は定数を列挙する)
用語集なので、一つのエクセルファイルなりwikiのページなりにシステム全体の用語をまとめるべきである。 アジャイル開発においては、最初からその用語集に追記するのではなく、注目しているストーリーに出てくるデータのみをストーリーのチケット内でまとめて、実装が終わったあとで転記する形がよいだろう。