0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

パーフェクトRuby on Rails 11章 メモ・雑感(値オブジェクト, サービスオブジェクト)

Posted at

11 複雑なドメインを表現する

雑感

この章は Rails のデザインパターンである値オブジェクトとサービスオブジェクトに関する内容でした。複数の属性やオブジェクトが絡み合う複雑なロジックをまとめる方法、そしてどれを導入する際の基準やルールについてです。しかし、次章で取り上げる concern との導入ケースの差異など、本書のみの記述では個人的に曖昧感じる部分が多かったので、この記事では本書の内容を超えて別のソース記事からの情報も交えてまとめています。(最終段参照)
特にサービスオブジェクトに関しては Rails 開発者の中でもネガティブな意見が多く、実際に導入する際にはもう一度深掘りして再検討する必要がある印象です。

ドメインとは

ドメインとはアプリケーションが対象とする問題領域のことを指す。

・ ドメインモデル

ドメインを分析して構成概念を抽出(モデリング)し、その結果得られた概念のこと。その概念に関連する属性と振る舞いを持ったオブジェクトとして定義される。

・ ビジネスロジック(ドメインロジック)

上述したドメインモデルの振る舞いのこと。

・ アクティブレコード

ドメインモデルとその状態を保存した DB のレコードを対応づけ、データの取得・保存処理とビジネスロジックを合わせてカプセル化するアーキテクチャパターン。

値オブジェクト

アプリケーションを構成するオブジェクトは、その同一性の捉え方によって以下の 2 種類に大別される。

  • エンティティ
    同一性を識別する情報(識別子)によって一意に識別されるオブジェクト。
    例えば、同姓同名かつ生年月日が同じユーザーオブジェクトは id という識別子によって別オブジェクトとして識別される

  • 値オブジェクト(Value Object)
    姓・名・生年月日など値が同じであれば、同じものとして扱うことができるため、値により一意に判別することができないオブジェクト

値オブジェクトを導入することで、複数モデルにおいて共通で存在する属性(name[名前]や phone_number[電話番号]など)に同じビジネスロジックを定義できる。

値オブジェクトの導入

User, Admin など複数のクラスにわたって以下のような属性とメソッドが設定されている場合、

  • 整形用のnameメソッドによって各モデルの記述が増えて fat になってしまう。別の整形方法が必要になった場合はさらにモデルの記述が増え、同じ属性を持つ全てのファイルに同様の編集が必要
  • 本来、意味を持つのはnameでありnameを一つの意味のある単位として扱いたいが、 nameメソッドによって返却されるのは単なる文字列
  • nameメソッドで取得しても、そこからfirst_nameなどの一部を取り出すのは容易ではない
# Adminクラスなどにも同様の属性とメソッドが定義されている場合
class User
  attr_accessor :first_name, :middle_name, :last_name
  def name
    first_name + middle_name + last_name
  end
end

上記の問題を解決するために、値オブジェクトを導入し「名前」に関するロジックをNameというクラスに切り出すことでnameメソッドでNameオブジェクトが返却されるようにする。これにより「名前」に関するロジックが元のモデルから切り出され、nameという一つのオブジェクトとして扱える。first_nameなどの一部を切り出すことも容易となる。

class User
  attr_accessor :first_name, :middle_name, :last_name
  def name
    Name.new(first_name, middle_name, last_name)
  end
end
class Name
  attr_reader :first_name, :middle_name, :last_name
  def initialize(first_name, middle_name, last_name)
    @first_name, @middle_name, @last_name = first_name, middle_name, last_name
  end

  # このクラスに名前に関するバリデーションや整形ロジックを記述する
end

Rails で composed_of によって値オブジェクトを導入する

composed_ofは Rails でモデルの属性を値オブジェクトとして表現するためのメソッド。

上記の「名前」に関する値オブジェクトは Rails way で以下のように実装できる。以下のように設定することでuser.first_nameuser.name.firstへマッピングされる。

class User
  attr_accessor :first_name, :middle_name, :last_name
  # 第一引数は値オブジェクトのクラス名
  # 第二引数以降はモデルと値オブジェクトの対応関係をマッピング
  # [モデルの属性, 値オブジェクトの属性]
  composed_of :name, mapping: [%w(first_name first), %w(middle_name middle), %w(last_name last)]
end
class Name
  attr_reader :first, :middle, :last
  def initialize(first, middle, last)
    @first, @middle, @last = first, middle, last
  end
end

以下のようにallow_nilオプションを true に設定することで属性を nil に指定しても値オブジェクトが作成できる

  composed_of :name,
              mapping: [%w(first_name first), %w(middle_name middle), %w(last_name last)]
+             allow_nill: true # デフォルトはfalse

上記のように composed_of で値オブジェクトを設定すると、 値オブジェクトを用いた検索も行えるようになる。例えば、上記の例の場合、

users = User.where(name: Name.new('Monkey', 'D', 'Luffy'))

いつ導入すべきか

  • その値オブジェクトを複数のクラスから利用することになった場合(User, Admin の「名前」など)
  • 複数の属性値を「一つの値」として扱いたい場合(first_name, middle_name, last_nameを一つのnameとして扱いたいなど)

値オブジェクト設計上のルール

  • ファイルは app/values 下に配置する
  • ファイル名は Value オブジェクトの名前にする。たとえば name.rb
  • クラス名はなんの値オブジェクトかがわかるようにする。たとえば Name や Address など。また NameValue のような接尾辞はつけない
  • 必要最低限のインタフェースのみを公開する。たとえば full_name など

サービスオブジェクト

例えば下記の口座残高を表す BankAccount モデルの二つのインスタンス間における送金処理ロジックを実装する場合、複数のオブジェクトの状態を更新するためモデルにインスタンスメソッドとして実装するのは不自然である。そのためクラスメソッドとして実装するのが自然であるが、このような複数のオブジェクトを操作するロジックを実装するとモデルが fat になりやすいためこのような場合はサービスオブジェクトを導入するとよい。

app/models/bank_account.rb
# 口座残高を表すモデル
class BankAccount < ApplicationRecord
  # balance(残高)
  # moneyオブジェクトをやり取りする
  composed_of :balance, class_name: "Money", mapping: [%w[balance amount], %w[currency currency]]

  def deposit(money)
    with_lock { update!(balance: balance + money) }
  end

  def withdraw(money)
    with_lock do
      raise "Withdrawal amount must not be greater than balance" if money > balance
      update!(balance: balance - money)
    end
  end
end
送金処理ロジック
# fromが送金元の口座残高オブジェクト
# toが送信先の口座残高オブジェクト
from.transaction do
  from.withdraw(money)
  to.deposit(money)
end

サービスオブジェクトの導入

上記の送金処理ロジックはサービスオブジェクトを導入すると以下のように実装できる。

app/services/transfer_money_between_bank_acounts_service.rb
# 送金処理ロジックを責務とするレコードを持たないクラスを定義する
class TransferMoneyBetweenBankAccountsService
  # インスタンス化を制限し、クラスメソッド .call 経由でのみ実行可能としている
  private_class_method :new

  # インスタンス生成(new)とメソッド呼び出し(call)がクラス名.callで一度で呼び出すクラスメソッド
  # **kwargsは渡された全ての任意のキーワード引数をハッシュとして受け取る
  def self.call(**kwargs)
    new.call(**kwargs)
  end

  def call(from:, to:, money:)
    from.transaction do
      from.withdraw(money)
      to.deposit(money)
    end
  end
end
# 使用例
TransferMoneyBetweenBankAccountsService.call(
  from: account1,
  to: account2,
  money: 10000
)

サービスオブジェクト設計上のルール

  • ファイルは app/services 下に配置する
  • ファイル名は「#(モデル名)Service」のような何を目的とした実装なのか曖昧なものとしてはならない。ある一つのビジネスロジックを指す名前にする(TransferMoneyBetweenBankAccountsService など)
  • 命名したロジックを実行するためのクラスメソッドを一つのみ公開する(上記の例では call メソッドのみ)
  • モデルに実装すべきビジネスロジックを実装しない
  • サービスクラスのインスタンスは状態をもたせない。そのためにサービスクラス外からはインスタンスを生成できないようにする

例えば、上記の送信処理ロジックの記録を残したいとなった場合、送信処理イベントそのものを表現するモデルを導入することでサービスオブジェクトを利用しなくてもより自然な形で実装することができる。
イベントの記録など、サービスオブジェクトを導入する前にそのイベントの対応についての見落としがないかを確認する必要がある。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?