Railsでサービスとフォームを導入してみる話

この記事はRuby on Rails Advent Calendar 2013の6日目の記事です。 前日は @tkawa さんの「Favoriteの設計実装はパターンとして使える」でした。

Railsで適切に責務を分割するということ

RailsはいわゆるMVCと呼ばれるアーキテクチャパターンにのっとったフレームワークであり、プロジェクトを作成するとデフォルトでmodels/views/controllers/などのディレクトリが作成されます。 基本的にロジックを記述する場所はモデルであり、ビューには表示処理だけを、コントローラにはアプリケーション上必要な手続きだけを記述するべきであると一般的には言われています。*1 ただ、それを忠実に実践していった結果、モデルが肥大化しメンテナンシビリティやテスタビリティが低下するという問題も多く指摘されています。

これについては4日目に @joker1007 さんも指摘していて、それに対するアプローチについてまとめてくださっています。

この記事では少し違ったアプローチでモデルの肥大化と戦った話をしてみようと思います。

ドメイン駆動設計(DDD)という考え方

ソフトウェア設計の方法論のひとつにドメイン駆動設計(Domain-Driven Design: DDD)というものがあります。

Wikipediaには次のように紹介されています。

ドメイン駆動設計(英: Domain-driven design, DDD)とはソフトウェアの設計手法であり、'複雑なドメインの設計はモデルベースで行うべきであり'、'また大半のソフトウェアプロジェクトではシステムを実装するための特定の技術ではなくドメインそのものとドメインのロジックに焦点を置くべき'とする。

書籍 Domain-Driven Designでは、たとえば ubiquitous language といった高位の概念と実践について多数述べられている。これは、ドメインモデルがシステムの要求を記述するためにドメインの専門家が提供し、業務上のユーザーやスポンサー、開発者みなにとってうまく働くような common language(共通言語)を形成するべきである、という考えである。同書は多層アーキテクチャを持つオブジェクト指向システムにおいて、一般的なレイヤ構造におけるドメイン層を記述することに重点を置いている。

平たく言うとDDDとは、ユビキタス言語と呼ばれる共通の語彙をドメインエキスパートと開発者との間に確立することで仕様の決定の迅速化やその変更に対する堅牢性を維持し続けようとする方法論になります。

これはいくつかの具体的なデザインパターンやドメインモデルの実装方法を提供しますが、その中に「サービス」という考え方があり、それをRailsでどのように実現したかについてご紹介します。

サービスとは

ドメインの知識のうち、どのドメインモデルエンティティ*2にも属さない振る舞いあるいは複数のドメインモデルエンティティを操作するような振る舞いをDDDでは「サービス」と呼びます。 ドメインエキスパート等の仕様策定者が名前をつけて呼ぶ単語の中で動詞としてよく登場するものはサービスである可能性が高いです。(例: 会員登録)

以下のような特徴を持つ機能はサービスとして定義することを考えてみてください。

  • モデルAR継承クラスのクラスメソッドとして定義されている/しようとしている
  • メソッドの引数として別のモデルAR継承クラスのインスタンスを必要とする
  • 物理層(永続化層)における複数テーブルに対してINSERT/UPDATEを行う(トランザクション制御が必要)
  • DB登録、API通信、メール送信など外部システムや外部リソースとの連携を同時に行う

ただし、永続化層や外部リソースとの連携はそれがサービスかどうかの判断のためには気にしますが、それ自体はサービスの責務ではありませんので注意してください。サービスの先にある具体的なアーキテクチャRDBであろうとインメモリストレージであろうとRESTful APIであろうとそれは適切に抽象化されるべきであり、サービスはその抽象化した結果のオブジェクトに処理を委譲するべきです。

実装

実装は以下のようにしています。

config/application.rb

# サービスをオートロードする
config.autoload_paths += %W(#{config.root}/app/services)

app/services/member_registration_service/base.rb

module MemberRegistrationService
  class Base
    def initialize(params)
      @params = params
    end

    def do!
      Member.transaction do
        # TODO 会員登録処理を実行する
        # 具体的な処理は別のクラスへ委譲する
        # DDDではRepositoryパターンを使うのが普通?

        # ウェルカムメッセージ
        sub_service = MemberRegistrationService::SendingWelcomeMessage.new(member)
        sub_service.do!
      end
  end
end

app/services/member_registration_service/sending_welcome_message.rb

module MemberRegistrationService
  class SendingWelcomeMessage
    def initialize(member)
      @member = member
    end

    def do!
      # TODO ウェルカムメッセージを送る
    end
  end
end

これは会員登録サービスの概形になります。会員登録と同時にウェルカムメッセージを送るという仕様を想定していて、後者をサブサービスとして別サービスにしていることがおわかりいただけると思います。

このようにサブサービスとして別クラスにすることには以下のようなメリットがあります。

  • メインのサービスクラスが本来の責務に集中でき、コードの見通しがよくなる
  • サブサービスは単一の責務を持ったシンプルなクラスになるため、テストが書きやすい
  • サブサービスにも名前がつけられるので、ドメインエキスパートとの間で利用するユビキタス言語の語彙が増える
    • この場合は「ウェルカムメッセージ送信」がユビキタス言語として利用できる

サービスの生成

上述したサービスの実装を見ていただければわかる通り、これらのサービスクラスはコンストラクタで必要な情報をすべて渡し、実行する際に引数を必要としません。これは仕様変更によってサービスの実行に必要な情報が増減した場合*3に、このサービスクラスを利用する側への影響を極力小さくしたいという意図があります。

do!メソッドがパラメータを受け取る場合はこれを呼んでいる全ての箇所を修正する必要がありますが、コンストラクタで必要な情報をすべて渡していれば影響範囲をオブジェクト生成箇所に局所化できます。

ではサービスオブジェクトを生成するのは誰の責務になるでしょうか? 僕は最近は「フォーム」という層を作り、そこでサービス生成をさせています。

フォームとは

フォームはソフトウェアを利用しているユーザからの入力パラメータを受け取り、その検証/加工を責務とするクラスです。これ自体はドメインの知識ではなく、アプリケーションの関心事なのでDDDが提供しているデザインパターンというわけではありませんが、先に述べた通り、サービスを生成する層として非常に適しているのでご紹介します。

Rails4ではActiveModel::ModelというActiveRecordから永続化の機能を抜いたモジュールを利用できるようになったため、そちらをincludeしてフォームとして利用できます。

ActiveModel::Modelは永続化の機能以外はActiveRecordと同等ですのでform_forによってレンダリングすることが可能になります。

app/forms/member_registration_form.rb

class MemberRegistrationForm
  include ActiveModel::Model

  attr_reader :name, :email,

  validates :name,  presence: true
  validates :email, presence: true

  #
  # フォームからデータを受け取る
  #
  def receive(data)
    @name  = data[:name]
    @email = data[:email]
  end

  #
  # サービスオブジェクトを構築する
  #
  def build_service
    MemberRegistrationService::Base.new({ name: @name, email: @email })
  end
end

このようにフォームを導入することによりコントローラは以下のように非常にすっきりします。

app/controllers/member_controller.rb

class MemberController
  def create
    @form = MemberRegistrationForm.new
    @form.receive(params[:member_registration_form])

    if @form.valid?
      service = @form.build_service
      service.do!
      redirect_to member_path(service.registered_member), notice: 'some massage.'
    else
      render :new
    end
  end
end

サービスのコンストラクタがもっと複雑になってしまった場合にはフォームが複数(入力パラメータの検証とサービスの生成)の責務を負うことになり、単一責務の原則に反してしまいますので、その場合は別途Factoryパターンを適用する等の工夫は必要だと思いますが、サービスの生成が極端に複雑でない場合はこれで十分でしょう。

意見募集

DDDの考え方をRailsに導入するということにはDDDを学び始めたばかりの頃から意識はしていたのですが、ようやく最近実プロジェクトで導入するようになってきました。

ただ、これらはDDDで紹介されてる考え方やデザインパターンを参考にはしていますが、実装そのものは僕がほぼ我流で見いだしたところもあり、もっと効率的なやり方や不適切な箇所等があればご指摘/ご意見いただきたいと思います。

よろしくお願いします。

バトンタッチ

次は @pinzolo さんです。 よろしくお願いします!

12/9 追記

はてブで何人かの方にご意見いただきましたが、「モデル」という言葉が

  • DDDで言うところのドメインモデル
  • DDDで言うところのエンティティ(ドメインモデルの一部)
  • Railsが生成するActiveRecord::Baseを継承したクラス

の3つの意味で使ってしまっていて、多くの方を混乱させてしまったようです。

上から順に「ドメインモデル」「エンティティ」「AR継承クラス」と表記を変更しました。

みなさん、ご意見ありがとうございます。

id:ssig33 こういうのでいう「サービス」まで含めてモデルだしこういうの書くやつはなんもわかってないみたいの延々と言っていきたい

というわけで、記述を変更しました。 おっしゃってることはごもっともだと思いますが、まだ何か認識が違っているところはあるでしょうか?

id:justgg オートロードは勝手にされるはず。serviceもformもModelなわけだからapp/models/servicesに置いてService::MemberRegistrationとかにしたい。

確かにapp/modelsの下に置けばオートロードされるのですが、そこにはActiveRecord継承クラスが配置されてしまっている既存のアプリケーションを想定していて、そこにできるだけ影響を与えずに全く違うディレクトリにサービスを配置したいと思ったのです。説明が漏れていてすみません。。

あとフォームはモデル(=ドメインモデル)ではない認識なんですが違いますか?あくまでユーザーイターフェースの都合で導入したもので、受け取ったパラメータの加工だけを責務とするイメージです。ドメインロジックを持たない感じですね。

id:Beyondrailsにサービス」って言い出す人の99%は、「処理を逐次的に書かないと理解できない」の言い換えだと疑って掛かってる。サービスの次に言い出すのは、だいたい「複合キー」。

オブジェクト指向大好きですし、あまり手続き型じゃないと理解できないわけじゃないのですが、どのあたりでそう思われたのでしょうか?ご教示いただけると幸いです。それともここで言う「逐次的」の対義語は「オブジェクト指向」ではないのですかね?

また複合キーについても差し支えなければもう少し詳しく教えていただければ嬉しいです。

id:yojik DDDのサービスはドメインモデルの「中」にあって、特定のエンティティに属さない処理を実行するオブジェクトのことを主に指すので、多少意味のズレを感じる。(他のリソースとの連携とかはアプリケーション層の話)

id:ssig33 さんと同様の指摘ですね。言葉の使い方が曖昧ですいません。。

また後者のアプリケーション層の責務については理解しているつもりです。ただ、「ある振る舞いをエンティティに持たせるか、サービスにするか」を判断するときに、それがインフラストラクチャレベルでどれだけ多くのリソースにまたがるかを判断基準として使ったりするので書かせていただきました。多くのリソースにまたがっているほどそれだけ多くのことをアトミックにやる必要がある、ということなので。もちろん業務分析の結果としてエンティティの振るまいかサービスか判断するのが鉄則だとは思うのですが。。

*1:「Skinny Controller, Fat Model」と呼ばれていますね。

*2:ActiveRecordを継承したものと必ずしも一致しないので注意。ドメインモデルエンティティは論理レベルでちゃんと実装すること。

*3:会員登録時に住所も同時に登録するようにする等