Railsにおけるドメイン駆動設計の実践

「RailsでDDDをするのは難しい」とよく言われるが、こういう方法もあるというのを提示する。

ある程度のRailsの経験、エヴァンス本PofEAAを十分に理解していること、ソフトウェアアーキテクトとして弁えるべきことを弁えていればこのページに書かれていることは納得いただけると思う。

DDDを実践するということ

本題に入る前に断りを入れておきたいが、DDDをするということは要求分析やモデリングをきちんと行うということを意味する。「ドメイン」という言葉も「モデル」という言葉も要求分析の文脈の言葉であるし、どんな分析手法を取るにせよドメインエキスパートと会話しながら分析を行わなければドメインモデルを明らかにすることはできない。

無論、数百ページに及ぶ要件定義書を書けという話ではない。スクラムで言えばスプリントバックログに入れる前にフィーチャーのモデリングは終わらせておけというだけの話である。スプリントの中でモデリングする方法もあるが、大体の場合でスコープクリープを起こすはずである。

モデリングの結果をコードに反映させる(そして、コードとして表現できるモデルを構築する)というのがDDDの中心にあるアイディアの1つである以上、杜撰なモデリングをすればDDDの実践も難しくなる。

もし、「要求分析なんて古臭いもの。アジャイルでは不要」と考えているなら現代のアジャイルを適切に実践できていないので、More Effective Agileなどを読むことをおすすめする。

なお、以下に続く文章はRailsでDDDを実践するというテーマで書かれているためコードの話しかしないが、DDDを実践する上で本当に重要なのはドメインを意識して分析しモデリングするということである。やれEntityとValueObjectの違いはなんだとか、ValueObjectは不変だとか、ドメインロジックはどこに書くべきだだとか、そういうのは瑣末な話題でしかない。というのも、分析やモデリングを適切に行えば自然とそういう性質をもったクラスが作られていくからだ。逆に言えば、そういう瑣末な話題をいくら意識したところで、分析やモデリングができてないならカーゴカルトにしかならない。

単純な方法でRailsの世界観を壊さないこと

Qiitaなどを見ている限りだと、

  • ActiveRecordの上で頑張ろうとする
  • ActiveRecordの亜種をドメインモデルとして作ろうとする
  • Railsと対立するやり方で頑張ろうとする
  • 複雑な仕組みを導入しようとする

のいずれかの方法でどうにかしようとして辛い状況に陥っているように見える。

また、Laravelのエントリではあるが、ActiveRecordパターンのクラスからドメインモデルへの詰め替えが辛いという話もあった。

こういった話に対して思うのは、ソフトウェアアーキテクチャは目的を達成できる単純で拡張性のある方法でなければならないということだ。目的を達成できないのは論外だが、事前に考える必要があるという性質上、複雑だったり拡張性がなかったりするアーキテクチャはウォーターフォールプロジェクト的な失敗に陥りやすい。プロジェクトが進んでから軌道修正がしやすかったり、拡張できるアーキテクチャが良いアーキテクチャということになる。

なので、拡張性が損なわれないようにRailsの世界観は極力いじらず、単純な方法を考えなければならない。

私の考える方法

基本的なアイディアを図示すると以下の通りである。

RailsでDDDする方法の概略図

Railsの世界とDDDの世界を分け、その間は基本的なデータ型かActiveRecordのインスタンスしか行き来しない。Rails wayは全く破壊せず、必要ならDDDの世界に寄り道するだけである。

Entity

ActiveRecordのインスタンスを直接DDDの世界に流してしまうのは、ドメインモデルへの詰め替えが不要で楽だからだ。あくまで、データコンテナとして使い、ActiveRecordのメソッドは呼び出さない(後述するがバリデーションは例外的に呼び出す)。この方法はPofEAAに以下のように書かれている。

データをドメインオブジェクトにコピーする代わりに、行データゲートウェイをドメインオブジェクト用のデータホルダーとして使用することができる。

エンタープライズアプリケーションアーキテクチャパターン 第10章 データソースのアーキテクチャに関するパターン 10.2 行データゲートウェイ

これにならって、Entityは以下のようにする。

# app/domains/hgoe_module/user_entity.rb
module HogeModule
  class UserEntity
    attr_accessor :record

    delegate :first_name, :last_name, to: :record

    # recordはActiveRecordのインスタンス
    def initialize(record)
      self.record = record
    end

    def full_name
      "#{first_name} #{last_name}"
    end
  end
end

ActiveRecordのモデルクラスとEntityのクラスは1対多で紐づくため、ActiveRecordのモデルクラスに以下のようなマッパーメソッドを用意できる。

class User < ApplicationRecord
  # メソッド名は対応づけるドメインモデルのモジュール名などを使う
  def hoge_mapper
    HogeModule::UserEntity.new(self)
  end
end

集約なら、集約先のモデルクラスにもhoge_mapperメソッドを用意してHogeModule::UserEntity.new(self, blogs.map(&:hoge_mapper))という風にすれば良い。

バリデーション

下書き保存機能のようにバリデーションを分けたいケースは以下のようにすれば良い。

# タイトル(title)と本文(body)を持つレポートモデル
# 下書き保存ではタイトルだけ必須
# 公開保存ではタイトルと本文が必須
# という仕様だとする
class Report < ApplicationRecord
  validates :title, presence: true

  def mapper
    return DraftReportEntity.new(self) if draft?

    PublicReportEntity.new(self)
  end

  def draft?
    # ...
  end
end
# app/domains/draft_report_entity.rb
# 下書き保存レポートエンティティ
class DraftReportEntity
  attr_accessor :record
  def initialize(record)
    self.record = record
  end

  def valid?
    record.valid?
  end
end
# app/domains/public_report_entity.rb
# 公開保存レポートエンティティ
class PublicReportEntity
  attr_accessor :record
  delegate :body, to: :record

  def initialize(record)
    self.record = record
  end

  def valid?
    record.valid?
    record.errors.add(:body, :blank) if body.blank?

    # record.valid?にするとerrorsがリセットされてしまう
    record.errors.present?
  end
end

いちいちerrors.addすることに顔をしかめるかもしれないが、Railsの世界に戻った時には単なるReportモデルであるというのがポイントになる。

データの読み込み、永続化、トランザクション

データの読み込み、永続化、トランザクションはApplicationServiceで完結させる。Interactor gemを使っても良い。

# app/services/report_service.rb
class ReportService
  # permitはコントローラー側で行っておく。
  def create(params)
    entity = Report.new(params).mapper
    unless entity.valid?
      return { result: false, report: entity.record }
    end

    # トランザクションが必要ならここで呼び出す
    entity.record.save!

    { result: true, report: entity.record }
  rescue StandardError
    { result: false, report: entity.record }
  end
end

Repositoryについてはドメインモデルの永続化と復元を担うパターンであり、ActiveRecordのインスタンスをデータコンテナとしてとりまわすこのやり方では、大抵の場合で必要がないだろう。思考停止してRepositoryクラスを乱造するぐらいなら、ApplicationServiceにそのまま永続化処理を書いてしまう方がシンプルだろうと思う(復元については強いていえば、ActiveRecordに作るmapperメソッドがそれに当たる)。

「ApplicationServiceが肥大化する」と言われればその通りだが、特定のクラスの特定のメソッドから一度だけ使われる処理をわざわざクラスを分けてまで独立させる必要があるかといえば、冷静に考えればないはずである。Repositoryというパターンに引きずられたり、オブジェクト指向に偏執的に取り憑かれたりしていると「分けなければいけないんだ!」と思ってしまうかもしれないが。

もちろん、複数の場所から同じドメインモデルが呼ばれて同じ永続化が必要になるなら、永続化を共通化させるためにRepositoryを作るのはありだろう(ほとんどないだろうし、本当に必要だと思うなら多分設計を見直した方がいい)。あるいは、永続化処理が極端に複雑で独立させたい場合はRepositoryを作った方がいいかもしれない。

個人的には、ApplicationServiceには手続きが書かれる関係上、メソッドが長くなるのは必然であり40-50行ぐらいまでは許容範囲だと考えている。

2022/3/21追記:後になって気づいたことだが、私は「ApplicationServiceは結合テストだけ書けばよく単体テストは不要」という判断をしているようだ。単体テストを書くなら、Repositoryを使ってMockに差し替えることをしたくなるが、ApplicationServiceはドメインレイヤのクラスをまとめているだけであり、特別なロジックは書かれてない(書かれているなら、それはドメイン知識がドメインレイヤから漏れ出ていることになる)。であれば、ApplicationServiceの単体テストを書くよりもドメインロジックの実行から永続化までの結合テストを記述する方が費用対効果が大きいと判断している。なので、Repositoryをわざわざ作るようなことをしないという判断になっている。

Controllerからの呼び出し

ApplicationServiceの呼び出しが必要であるが、それ以外はActiveRecordのインスタンスやプリミティブな値がやりとりされるだけである。

class ReportController < ApplicationController
  def create
    result = ReportService.new.create(permit_params)
    @report = result[:report]

    if result[:result]
      redirect_to @report, notice: "Report was successfully created."
      return
    end
    render :new, status: :unprocessable_entity
  end
end

View

通常のRailsと変わらない。

まとめ

このように、ActiveRecordのインスタンスをドメインモデルのデータコンテナとして使い、ドメインモデルに対するマッパーを用意するだけでRailsの世界観を破壊せずにドメインレイヤを導入することができる。

仕組みが単純なため、DDDを理解していれば新規のプロジェクトメンバもすぐにアーキテクチャを理解できるはずである。

なお、この方法を突き詰めていくとActiveRecordのモデルからドメインロジックは一切なくなる。EntityやValueObjectにドメインロジックが移るため、最終的にvalidatesscopebelongs_toなどのアソシエーション、そしてmapperメソッドだけが残る。

要求分析駆動設計

このページは、要求分析駆動設計に書いてあることの抜粋です。RailsでDDDを実践することが可能であることが書いてあるんですがクソ長いし、抜粋してRailsでDDDが可能であることの説明を独立したページで用意した方が良かろうと思って書きました。

もし、ドメインモデルの中で集約のエンティティが増えるケースを考えなければならないなら要求分析駆動設計 ActiveRecord(Rails)のインスタンスを利用していることについてで考えているので参考にしてください。