データモデリング - 要求分析駆動設計

データモデリングの結果はDDDではEntityとAggregateに相当する。データモデリングといっても、物理データモデルではなく、論理データモデルのことである。 あるユースケースを実現するためのある機能が必要とする論理データモデルを考える。重要な点として強調したいのが、システムの全てをつなげた論理データモデルではなく、個別の機能における論理データモデルを考えるということである。 他の機能に類似の論理データモデルがあったとしても、機能が異なるなら偶然の一致と考え、その論理データモデルのクラスを参照するようなことはしない。 例えば、フリマアプリで出品者も購入者も物理データモデルとしてusersテーブルで管理しているとしても、論理データモデルとしてSeller(出品者)とBuyer(購入者)に分けて実装させる。さらに、同じSellerという名前でも購入者にとっては出品物一覧から購入画面までは出品中の出品者(market_feature/seller.rb)であり、購入済み一覧画面では取引相手の出品者(bought_items_feature/seller.rb)となる。 これによって、機能をサイロ化させ論理データモデルの単一責任の原則を達成させることができる。 機能(Feature)はDDDではModuleに相当する。

機能ごとに論理データモデルを構築する以上、機能の判別が重要になってくる。 画面が異なるからといって機能が異なるわけではない。ダッシュボード画面と統計情報画面、統計情報のCSVエクスポート機能は見せ方が異なるだけで本質的には同じ機能である。 論理データモデルが分かれているからと言って、機能も異なるというわけではない。一つの機能が複数の論理データモデルを束ねる場合もある。 機能の判別に迷うなら、フィーチャーツリーなどを用いて整理するのがよい。

なお、クラスの総称はER図(Entity-Relationship Diagram)に倣ってEntityとする。

モデリングの例

データモデリングの例

BoughtItemsFeature::MessageFeature::MutableMessageについては後述するが、永続化用のデータモデルと参照用のデータモデルは分ける方針をとる。保存と参照は異なる責務のため、分けることで単一責任の原則を達成する。 属性についても後述する。

コードとの対応

ファイルパスは以下の通りにする。

app
└── domains
    └── bought_items_feature
        ├── message_feature
        │   └── mutable_message.rb
        └── seller.rb

featureは入れ子にできる。RuleやValueObjectもfeatureディレクトリ内に格納する。

データの格納

なんらかの方法で、永続化済のデータ、あるいは入力データをインスタンス化のタイミングで渡さなければならない。DDDでいえばFactoryとRepositoryがその役割を担う。 FactoryやRepositoryは利用するフレームワークによってその実装方法は変わるだろうし、ここではActiveRecord(Rails)の機能を活かしつつ楽にインスタンス化させる方法を紹介する。

コード例

データモデリングのコード例のモデル図

コード例を示しつつ、コメントという形で解説をつける。

参照用のデータモデルのコード例

# 取引サブジェクト
module DealSubject
  module BuyerActor
    # 取引履歴閲覧のユースケース
    class BrowseDealHistoryUseCase
      attr_accessor :actor
      def initialize(actor)
        self.actor = actor
      end

      # 配送状況確認
      # 配送状況に関する情報を集約して返す
      def track_delivery_state(deal_id)
        # Deal#to_bought_items_featureを呼び出している
        deal = actor.deals.find(deal_id).to_bought_items_feature
        { deal: deal.raw, estimated_arrival_date: deal.estimated_arrival_date }
      end
    end
  end
end
class User < ApplicationRecord
  # ActiveRecordのインスタンスを論理データモデルにマッピングするメソッド
  # to_マッピング先のfeature名にする
  # マッピング先のfeature内に対応するクラスが複数ある場合も考えられる
  # メソッド名を適宜考えて分けるか、STIのようにデータに応じてインスタンス化するクラスを振り分ける方法がある
  # 引数のないメソッドしか例示していないが、引数を受け取ってはならないという制限はない
  def to_bought_items_feature
    BoughtItemsFeature::Seller.new(self)
  end

  has_many :deals
end
class Item < ApplicationRecord
  # 複数のActiveRecordのインスタンスを1つの論理データモデルクラスにまとめることもできる
  def to_bought_items_feature
    BoughtItemsFeature::Item.new(self, raw_seller: user)
  end

  belongs_to :user
end
class Deal < ApplicationRecord
  # DDDでいうところのAggregateは再起的にマッパーを呼び出すことで実現させる
  def to_bought_items_feature
    BoughtItemsFeature::Deal.new(self,
                                 item: item.to_bought_items_feature,
                                 buyer: user.to_bought_items_feature)
  end

  belongs_to :item
  belongs_to :user
end
module BoughtItemsFeature
  class Deal
    # ActiveRecordのインスタンスを保持する変数はrawをつけて区別する
    attr_accessor :raw, :item, :buyer

    # ActiveRecordのインスタンスは直接使わずにdelegateでカラムの値を参照する
    delegate :created_at, to: :raw
    # データソースが変わってrawの実体がActiveRecordのインスタンスからHashに変わったとしても、
    # def created_at
    #   raw[:created_at]
    # end
    # というメソッドを定義するだけで済む
    # 上記の理由から、後述する永続化の場合を除いて
    # 独自に定義したメソッドも含めActiveRecordのメソッドは呼び出してはならない

    def initialize(raw, item:, buyer:)
      self.raw = raw
      self.item = item
      self.buyer = buyer
    end

    # 到着予定日 Rangeを返す
    # DeliveryTimeCalcRuleにまとめてしまっても良いメソッド
    def estimated_arrival_date
      value_object = deal_date_value_object        # 取引確定日
      value_object += delivery_time_calc_rule.calc #  + 都道府県間の輸送日数
      value_object += item.shipment_time           #  + 商品の出荷時期
      value_object.to_range                        #  = XX/XX/XXからYY/YY/YYの間
    end

    private

    # 到着予定日の計算基点日
    # DateRangeValueObjectは日付期間計算のために独自に用意するValueObject
    # ((Date.today)..(Date.tomorrow)) + 1.day
    # ((Date.today)..(Date.tomorrow)) + (1.day..3.day)
    # のような計算が可能になるように実装されている
    # 後述のデータディクショナリの計算可能なValueObjectで実装例を記載しているので、そちらを参照
    def deal_date_value_object
      @deal_date_value_object ||= DateRangeValueObject.new(created_at)
    end

    # 配達日数に関するビジネスルール
    # 配達元都道府県から配達先都道府県までの輸送日数を計算する
    def delivery_time_calc_rule
      @delivery_time_calc_rule ||= DeliveryTimeCalcRule.new(
        from_prefecture: item.prefecture,
        to_prefecture: buyer.prefecture
      )
    end
  end
end
module BoughtItemsFeature
  class Item
    attr_accessor :raw, :raw_seller
    # 複数のActiveRecordのインスタンスを持つ場合は、カラム名が衝突する可能性があるので、
    # prefix: trueをつける
    delegate :prefecture, :shipment_time_from, :shipment_time_to, to: :raw, prefix: true
    delegate :prefecture, to: :raw_seller, prefix: true

    def initialize(raw, raw_seller:)
      self.raw = raw
      self.raw_seller = raw_seller
    end

    # 商品のある都道府県。商品に都道府県がなけれればSellerの都道府県とみなす
    def prefecture
      raw_prefecture.presence || raw_seller_prefecture
    end

    def shipment_time
      (raw_shipment_time_from.days)..(raw_shipment_time_to.days)
    end
  end
end

永続化用のデータモデルのコード例

# 取引サブジェクト
module DealSubject
  module BuyerActor
    # 取引成立後のやりとりのユースケース
    class ConversationUseCase
      attr_accessor :actor
      def initialize(actor)
        self.actor = actor
      end

      # Sellerにメッセージを送信する
      # paramsはPOSTされたデータをpermitしたもの
      def send_message(params)
        result = false
        deal = actor.deals.find_by(params[:deal_id])
        return { result: false, error_type: :deal_is_not_found } if deal.blank?

        message = Message.new(params.merge(user_id: actor.id)).to_bought_items_message_feature
        ApplicationRecord.transaction do
          return { result: result, data: message.raw } unless message.valid?

          message.save!
          # メール通知を行うならここでActionMailerを呼び出す
          result = true
        end

        { result: result, data: message.raw }
      rescue StandardError
        { result: false, data: message.raw }
      end
    end
  end
end
class Message < ApplicationRecord
  def to_bought_items_message_feature
    BoughtItemsFeature::MessageFeature::MutableMessage.new(self, raw_deal: deal)
  end

  belongs_to :deal
  belongs_to :user # 送信者
end
module BoughtItemsFeature
  module MessageFeature
    # Mutableという名前のつくEntityは永続化のための役割を担う
    # 永続化の処理(バリデーションなど)と参照系の処理はお互いに交わることがほとんどないため、
    # 参照用のEntityと永続化用のEntityを分ける方針をとる
    class MutableMessage
      # ActiveRecord(Rails)のインスタンスであるrawは
      # attr_accessorしか持たないPOROないしActiveModel::Modelをincludeしたクラスとみなす
      # rawは保存対象データのコンテナであり、Entityの内部処理でrawのデータを変更したいケースは、
      # delegate :status=, to: :raw
      # のように代入メソッドをdelegateする
      attr_accessor :raw, :raw_deal

      def initialize(raw, raw_deal)
        self.raw = raw
        self.raw_deal = raw_deal
      end

      # ActiveRecordのバリデーション以外に実行したいバリデーションがあればここに記述する
      # 例えばメッセージを下書き保存できる機能が必要な場合、
      # ActiveRecordのMessageには最大公約数的なバリデーションを定義、
      # BoughtItemsFeature::MessageFeature::MutableMessageと
      # BoughtItemsFeature::MessageFeature::MutableDraftMessageを作成し、
      # 各々の機能に適したバリデーションをvalid?メソッドに記述することができる
      #
      # raw.valid?、raw.errorsはActiveRecordのメソッドである
      # こうすることで、Viewではいつも通りの形でバリデーションエラー表示を実装できる
      def valid?
        raw.valid?
        raw.errors.add(:base, 'メッセージを送れません') unless sending_rule.sendable?

        # raw.valid?にするとerrorsがリセットされて'メッセージを送れません'が消えてしまう
        raw.errors.present?
      end

      # 「1ヶ月以上前の取引にメッセージは送れない」といったビジネスルールを実装しているとする
      # ビジネスルールがActiveRecordのインスタンスを利用することの是非は後述する
      def sending_rule
        @sending_rule ||= SendingRule.new(raw_deal)
      end
    end
  end
end

ActiveRecord(Rails)のインスタンスを利用していることについて

ActiveRecord(Rails)のインスタンスを利用せずに必要な項目だけ渡すという方法も取れるし、言語やフレームワークによってはそれが現実的な手段の場合もありえるだろう。 以下は、ActiveRecord(PofEAA)のインスタンスをEntityで利用するという選択肢をとった場合の話題であり、それ以外の選択肢をとるなら読み飛ばして良い。

ActiveRecord(Rails)のインスタンスを利用するのは、ドメインモデルがフレームワークに依存することになるためClean Architecuteに違反する。ただ、Rubyはダックタイピングな言語であるし、ドメインモデルだけ維持してRailsから別のフレームワークに変更することはまずないので、attr_accessorしか持たないPOROないしActiveModel::Modelをincludeしたクラスをインターフェースとしておけば問題は起きないはずである。 問題が起きないのであれば、コストのかからない方法を選択するべきであり、ActiveRecord(Rails)のインスタンスを利用するという方法をとっている。

ただ、実のところ、永続化用のデータモデルにおいては詰め切れていない部分もある。あまりないパターンと考えているが、ドメインモデル内の処理の結果としてアソシエーションのレコードが増減するケースは問題を抱えている。 問題は2つある。一つはアソシエーションにどうやって増減を反映させるか。もう一つはアソシエーションに追加する要素のインスタンス化方法である。

前者については、 アソシエーションに対応するEntityのリストが増減した場合、そのリストを持つ親Entityがその増減の詳細を保持し、UseCaseではその増減情報をもとに、永続化処理を行う。 後者については、 親Entityのインスタンス化時にアソシエーション要素のクラスを渡し、Entityのリストに追加する際はそのクラスからnewしたインスタンスをrawにしてEntityのインスタンス化をする。 という方法でとりあえずは目的が達成できるはずである。

以下のようにアソシエーションのラッパークラスを作り、ActiveRecordのbuildメソッドを使うが局所化する方法が今のところベストではないかと思っている。ただ、アソシエーションに対応するEntityのインスタンス化が必要なら、親Entityでケアするか、Entityを構築するlambdaをActiveRecordRelationのコンストラクタに追加しEntityのリストもActiveRecordRelationで管理するなど方法を考えなければいけない。

# relation = ActiveRecordRelation.new(blog.entries)
# のような形でRailsのActiveRecord_Relationのインスタンスを渡す
# relation.deleted_list.map(&:destroy)
# relation.active_list.map(&:save)
# のように増減を反映させる
# Entityのコンストラクタでインスタンス化し、
# Entity内でdeleteメソッドや<<メソッドを呼ぶ。
# UseCase内で
# deleted_list.map(&:destroy)
# などをする
class ActiveRecordRelation
  attr_accessor :deleted_list, :active_list, :relation

  def initialize(relation)
    self.relation = relation
    self.deleted_list = []
    self.active_list = relation.to_a
  end

  def delete(target)
    target = target.id if target.is_a?(ApplicationRecord)
    target = target.id if target.is_a?(ActiveModel::Model)
    target = active_list.find { |x| x.id == target }
    return nil if target.blank?

    self.active_list = active_list - [target]
    deleted_list << target

    target
  end

  def <<(target_hash)
    relation.build(target_hash).tap do |built|
      active_list << built
    end
  end

  def update(target_hash, find_key = :id)
    target = active_list.find { |x| x.send(find_key) == target_hash[find_key] }
    return nil if target.blank?

    target.attributes = target_hash
    target
  end

  def update_or_build(target_hash, find_key = :id)
    result = update(target_hash, find_key)
    return result if result.present?

    self << target_hash
  end
end

メソッドを見つける

経験とセンスが物を言うため、こうすれば良いとは残念ながら言えないので、参考程度の情報を書いておく。 データモデリングによって、「この機能に必要な論理データモデルはこれである」が明らかになる。メソッドは「この機能ではこの論理データモデルを使って、こういう計算結果あるいは処理が必要になる」という形で発見する。 「この機能ではAとBの論理データモデルを使って、こういう計算結果あるいは処理が必要になる」という風に異なる論理データモデルを必要とするなら、Serviceを用意するのがおそらく正解である。 ServiceはDDDにおけるDomainServiceに対応する。

依存関係

データモデリングの依存関係の図

EntityはEntity、Service、UseCase、Controllerから依存されうる。ServiceはUseCaseとControllerから依存されうる。ただし、Entityに依存されているEntityは、その親Entity以外から依存されてはならない(DDDのAggregateと同じルール)。 ServiceまたはUseCaseに依存されているEntityをControllerから依存してはならないという制限はない(Clean Architectureを厳格に適用して「緩いレイヤードアーキテクチャ」は許さないという設計方針をとっても良い)。 ViewやHelperからEntityとServiceに依存することは許されない。Entity内からデータベースなどにアクセスすることも許されない。

EntityはEntityとValueObject(データディクディクショナリに対応)、Rule(ビジネスルールに対応)、State(状態遷移図に対応)に依存できる。 ServiceもEntityとValueObject、Rule、Stateに依存できるが、通常は複数のEntityに依存するだけで、他のクラスに依存することは少ないと思われる。UseCaseと同じ理由でServiceは独立した存在として考え、他のServiceに依存するのは避ける。 EntityもServiceも依存の範囲は所属するFeature内に限られる。Featureが入れ子の場合は親Feature直下のValueObjectなどに依存できる。Feature無所属のValueObjectなどを定義するなら、そのValueObjectは全てのEntityとServiceから依存可能である。 親FeatureのEntityやServiceに依存するのは依存関係が複雑になるため避ける。親Featureに抽象クラスのEntityやServiceを定義して子Featureで継承する形で依存するなら依存関係は拗れないが、自己以外について関知しないというFeatureのサイロ性が弱くなるので基本的には避ける。

データモデリングするときしないとき、Entityを作るとき作らないとき

単純なCRUDであれば、UseCase、Service、Entityを作らずにフレームワークの標準的な方法で実装してしまえば良い。しかし、単純なCRUDであってもどういうデータを保存するのか、読み込みや削除はどの範囲で行うのかを考えねばならないため、データモデリングは必ず行う必要がある。

ドキュメンテーション

ER図(クラス図)は機能を追加するタイミングで必要なデータモデルが何か整理し確認するために作成するが、顧客(プロダクトオーナー、ドメインエキスパート)に確認してもらうという観点では適さないように思う。 画面サンプルや帳票サンプルを作成し、どこの項目がどのEntityに対応するのかわかる資料を用意した上で、ER図(クラス図)を確認してもらうという風に御膳立てした方が良い。 画面サンプルや帳票サンプルと言っても、データモデリングのための簡素なものでありPlantUMLのWireframeで作成したようなものでよい。正式なレイアウトの検討は別のタイミングでデザイナーなどによって行う。 なお、データモデリングでの成果物は正式なレイアウト検討のインプットの一つとなりうるし、逆方向のフィードバックが得られる可能性もある。

属性については、ERD内か画面サンプル内、あるいは表やリストによって必ずすべて列記する。

購入済み商品のメッセージ送信画面についてリストで列挙した例

  • メッセージ内容として以下の内容を登録できる
    • メッセージ.メッセージ本文(型:半角全角問わず1000文字まで、必須)
    • メッセージ.評価(型:データディクショナリの「取引評価」を参照、任意)
  • メッセージの作成が可能か判断するため以下の内容を参照する(詳細はビジネスルールの「メッセージ送信ルール」を参照)
    • 取引データ

入力内容の制約も表記する。データディクショナリで整理済の型が利用できるなら、その旨を明記する。ビジネスルールを利用する場合についても同様である。 複数の値をデータディクショナリの型やビジネスルールで扱っているなら、必要な値の説明をそちらに委譲してしまう方が良い。 ただ、委譲した結果、データモデルとして一意に意味が取れなくなったり誤読の可能性が生じるならば、必要な範囲でデータモデル側においても説明を付け加えなければならない。

データモデリングが完了したと言える状態は、必要な項目や制約の洗い出し、データディクショナリやビジネスルールとの関連づけなど確定可能な情報が明確にできている場合である。 確定可能な情報が明確になっていない場合、開発中に予定外かつ本来不要な顧客とのコミュニケーションが必要になるか、開発者の独断で仕様が決められてしまう。 確定できない箇所があるが、それ以外の部分は実装したい場合もある。そういう場合は、未確定箇所の存在を明記した上で、別途チケットを分けるなどの方法をとる。

テーブル定義書や物理データモデルのER図は開発者向けドキュメントなので、作成維持コストなどを考慮した上でメリットがあるなら作成すればよい。 大体の場合でデータディクショナリ、論理データモデル、コードから得られる情報で十分ではないかと思うが、フレームワークによっては辛いかもしれない。