3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ビジネスロジックとmodelを切り分けた話

Last updated at Posted at 2020-10-02

概要

運営しているサービスにおいて、ビジネスロジックから特定のデータリソースに対するアクセス、および加工処理を切り出して共通化した話です。

コレは果たしてmodelなのか?

わからん。Entityとかドメインオブジェクトの方がいいのか?
誰か教えてください。

model化前

例えばこんなコードでした。
下記は都道府県のページを管理するクラスで、その都道府県に紐づくイベントを取得しているとします。

area/pref.rb
module Area
  class Pref
    def event
      events = Api.call('/events', params)
      events.each_with_object([]) do |event, memo|
        memo << {
          name: event[:name],
          address: create_full_address(event)
        }
      end
    end

    def create_full_address(event)
      # いくつかの情報を組み合わせて住所のフル文字列を返す
    end
  end
end

次に都道府県の配下の市区町村のページができました。下記ではその市区町村に関するイベントを取得しています。

area/city.rb
module Area
  class City
    def event
      events = Api.call('/events', params)
      events.each_with_object([]) do |event, memo|
        memo << {
          name: event[:name],
          address: create_city_address(event)
        }
      end
    end

    def create_city_address(event)
      # 住所を短く表示したいので市区町村以降の住所文字列を返す
    end
  end
end

イベント情報を扱う処理が重複しています。これを下記のように切り出して共通化してみます。

area/common.rb
module Area
  class Common
    class << self
      def events(params)
        Api.call('/events', params)
      end

      def create_full_address(event)
        # いくつかの情報を組み合わせて住所のフル文字列を返す
      end

      def create_city_address(event)
        # 住所を短く表示したいので市区町村以降の住所文字列を返す
      end
    end
  end
end

呼び元は下記のような感じになります。

area/pref.rb
require_relative 'common'

module Area
  class Pref
    def event
      events = Common.events(params)
      events.each_with_object([]) do |event, memo|
        memo << {
          name: event[:name],
          address: Common.create_city_address(event)
        }
      end
    end
  end
end

よしよしと思っていましたが、次に駅のページを管理するクラスが新たにできました。
このページでは住所の情報は不要で、駅に関する情報のみが必要だったとします。

前回area/common.rbのような形でarea系のページで共通化を行なってしまいました。
また、area系のページで取得している情報は不要で、少し欲しい情報も違うため、イベント情報へのアクセスを独自で実装することにしました。

traffic/station.rb
module Traffic
  class Station
    def event(params)
      events = Api.call('/events', params)
      events.each_with_object([]) do |event, memo|
        memo << {
          name: event[:name],
          stations: create_stations(event)
        }
      end
    end

    def create_stations(event)
      # いくつかの駅情報を返す
    end
  end
end

以上がmodel化前の大体の状況です。
例が長い上にいまいちな気がしてきました。実際にはもう少し入り組んだ状況になっているという言い訳をしておきます。

model化前の問題点

問題点をまとめると、下記のような状況であったと言えます。

  • データリソースに対するアクセスをページごとのビジネスロジック内で行うので処理の共通化がしづらい
  • ページごとのビジネスロジックが肥大化する
  • ビジネスロジックが肥大化した影響でページごとにクラス構成が異なり、どこに何を書いたらよいかがわかりにくい
  • データリソースの加工処理が各所に散らばるため、データリソースに変更が行われた際の影響調査がしづらい

社内の便利APIがデータリソースである場合には、ある程度気を利かせたレスポンスが返ってくるため、かっちり切り分けずにビジネスロジック内で呼んでしまうケースはあるのでは?と思います。
最初はシンプルに実装できていても、様々な箇所で様々な利用が始まるとカオスになってゆきます。

model化後

解決策として、modelをちゃんと運用することにしました。
データリソースごとにmodelを切り、そのデータリソースに対するアクセスは必ずmodel経由で行うことにしました。
レスポンスの装飾もmodelに集約することで共通化を図り、どこに実装してよいか明確な状態をつくりました。

具体的には下記です。

1つ1つのイベントを管理するmodelを下記のように定義します。

model/event.rb
module Model
  class Event
    attr_accessor :name, :address

    def initialtize(event)
      {
        'name' => '@name',
        'address' => '@address',
      }.each do |key, prop|
        instance_variable_set(prop, event[key])
      end
    end

    def full_address
      # いくつかの情報を組み合わせて住所のフル文字列を返す
    end

    def city_address
      # 住所を短く表示したいので市区町村以降の住所文字列を返す
    end

    def stations
      # いくつかの駅情報を返す
    end
  end
end

イベントのリストを管理するmodelは下記です。

model/events.rb
require_relative 'event'

module Model
  module Events
    attr_accessor :events

    def initialize(events)
      @events = events.each_with_object([]) do |event, memo|
        memo << Model::Event.new(event)
      end
    end

    class << self
      def get(params)
        events = Api.call('/events', params)
        Model::Events.new(events)
      end
    end
  end
end

利用側はこんな感じです。

area/pref.rb
module Area
  class Pref
    def event
      events = Model::Events.get(params)
      events.each_with_object([]) do |event, memo|
        memo << {
          name: event.name,
          address: event.full_address
        }
      end
    end
  end
end

event情報を扱うmodelとして、model/event.rbを定義しました。
こちらはevent情報の保持と装飾を担当し、event情報へのアクセスは全てこのmodelを経由します。

eventを取得するAPIのレスポンスはリスト型で返るので、model/events.rbを定義し、APIのレスポンスはこちらで管理します。
このとき、個々のイベント情報はevent.rbに渡して装飾し、events.rbではその結果をリストとして保持します。

この結果、利用側は特に意識することなく、装飾された項目にアクセスできるようになります。

FATを回避する

以上のようにmodel化することで、データリソースへのアクセスや装飾を集約することができました。

一方でこの設計だとmodelが膨らんでいく懸念もあると思います。
FATなmodelを避けるための取り組みとして、下記のような対策を行っています。

  • 装飾メソッドを適切に分離する
  • delegateを利用して移譲する

delegateですが、例えばeventの他にfestivalの用な別のmodelがあり、それぞれで同様のメソッドを所持したい場合に有効です。

model/event.rb
module Model
  class Event
    # イベント開催を判定するhold?メソッドはModel::Conditionに移譲し、そちらの実装を使う
    delegate :hold?, to: Model::Condition
  end
end

下記のように呼べる

area/pref.rb
module Area
  class Pref
    def hold_event?
      Model::Events.hold?
    end
  end
end

SpecialThanks

原案提供してくださった @nazomikan さん、推進してくださったHK、SSさん、および部署のメンバーのみなさまありがとうございました!

PR

不動産売却の時は LIFULL HOME'S へ!

3
2
1

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
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?