21
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Ruby on Railsでドメイン駆動設計をやってもいいじゃないか。

Last updated at Posted at 2019-12-21

挨拶

松下と申します。みんなの株式を運営しています。

ドメイン駆動設計の導入した動機

最初にドメイン駆動設計を導入しようとしたのは2017年の末頃でした。

ドメイン駆動設計を導入しようとした最初の動機はフレームワークやgemに依存しないPORO(Plain Old Ruby Object)なコードを増やす事によって、保守性(テスト容易性、理解容易性、変更容易性)を高めようとした事だったと思います。

10年近くRuby on Railsのアプリを無計画に実装を繰り返した結果、肥大したコントローラやモデルやヘルパー、改修する事による影響範囲が読めないライブラリや自作gemなどが散見され、テストのカバレッジも酷い状況でした。

最初はデザインパターンを部分的に導入してみたりもしてみましたが、レイヤードアーキテクチャを元にしたドメイン駆動設計をしっかりやろうという事になるまではそれほど時間はかかりませんでした。

現在ではバッチ処理や複雑なドメインをマイクロサービスに移行する事で本体アプリのスリム化を平行で行いつつ、クリーンアーキテクチャのオニオンアーキテクチャを参考にした本体アプリケーションのリファクタリングを進めています。

ユビキタス言語とリファクタリング

ドメイン駆動を導入するに当たって大事にしようと決めた事は、チームの全員がユビキタス言語を意識して使う事と、ドメイン知識は納得いくまで話し合いながらモデリングした上でじっ層する事です。

ユビキタスは日本語名と英語名(クラスやメソッドとして利用する)の両方の視点からDomainやValueObjectまで細かく決め、スプレッドシートで管理しています。

一度決めたユビキタス言語でも、必要があって全員の同意が取れれば、必要に応じてアップデートします。(その場合はソースも全て書き換えるのでそれなりの作業にはなります)

それまで各技術者が自由に実装していたのですが、共通の理解を得るための話し合いをする時間が増えました。モデリングに対する設計レビューもしっかり行っていますが、一旦形にした後でも、納得がいかなければ何度も話し合いながらリファクタリングを行う事が当たり前になりました。

ディレクトリ構成と登場クラス

だいたい下記の様に配置しています。

app/
 ├─ assets/      # CSS
 ├─ controllers/ # コントローラ
 ├─ domains/     # ドメイン
 │   └─ users/
 │       ├─ user.rb       # ドメインモデル
 │       ├─ factory.rb    # ファクトリ
 │       └─ repository.rb # レポジトリ
 ├─ helpers/     # ヘルパー
 ├─ models/      
 │   └─ user_record.rb # データモデル
 ├─ services/    # アプリケーションサービス
 ├─ use_cases/   # ユースケース
 │   └─ users/
 │       ├─ show_interactor.rb # インタラクタ
 │       ├─ show_presenter.rb  # プレゼンタ
 │       └─ show_view_model.rb # ビューモデル
 └─ views/       # ビューテンプレート
     └─ users/
         └─ show.html.erb

Controller

class UsersController < ApplicationController
  def show
    @vm = Users::ShowInteractor.new.call(params[:id].to_i)
  end
end

コントローラからはユースケースに合わせたインタラクタを呼びます。
一つの画面に複数のユースケースが有る場合は複数のインタラクタを呼びます。

CleanArchitecture的にはインタラクタの返り値はvoidにしてオブザーバパターンなどでビューを描画した方が良いのかもしれませんが、
フレームワークの制約で描画処理(render)が一度しか呼べないといった事もあり、インスタンス変数にビューモデルをアサインしてテンプレート側で表示する様にしています。

Interactor

class Users::ShowInteractor
  include UseCaseInteractor

  def initialize(params = {})
    @repository = params.fetch(:repository, Users::Repository)
    @presenter  = params.fetch(:presenter, Users::ShowPresenter)
  end

  # @param [Integer] user_id
  # @return [Users::ShowViewModel] 
  def call(user_id)
    user = @repository.find(user_id)
    @presenter.new.call(user)
  end
end

コンストラクタで依存性の注入をできる様にしているのでテストが書きやすくなります。
同じドメインを取得しても表示側の都合が違う場合はプレゼンタを置き換える事で表示側の都合だけを切り替える事ができます。
複数のレポジトリからドメインモデルを集める必要が有る場合はDTO(OutputData)を組み立ててプレゼンタに渡す場合もあります。

Repository / Factory / User / UserRecord

class Users::Repository
  include DomainRepository

  def self.find(id)
    record = UserRecord.find_by(id: id)
    Users::Factory.build(record)
  end
end

class Users::Factory
  include DomainFactory

  def self.build(record)
    Users::User.new(
      nickname: Nickname.new(record.nickname),
      age: to_age(record.birthday),
    )
  end

  private def to_age(birthday)
    age = ((Time.zone.now - date_of_birth.to_time) / 1.year.seconds).floor
    Age.new(age)
  end
end

class Users::User
  include DomainEntity

  attr_reader :nickname, :age

  def initialize(params)
    @nickname = params.fetch(:nickname)
    @age = params.fetch(:age)
  end
end

class UserRecord < ApplicationRecord
  self.table_name = 'users'
end

レポジトリはドメインモデル(集約)を返します。
ActiveRecordはDAOとしてのみ利用していています。
複雑なテーブル取得の条件が必要な場合はQueryObjectクラスを作成する事もあります。
ValueObjectも必要に応じて作成しています。

Presenter / ViewModel

class Users::Presenter
  include UseCasePresenter

  def call(user)
    Users::ViewModel.new(
      nickname: with_nickname_title(user.nickname),
      age: with_age_unit(user.age)
    )
  end

  private def with_nickname_title(nickname)
    "#{nickname}さん"
  end

  private def with_age_unit(age)
    "#{age}歳"
  end
end

class Users::ViewModel
  include UseCaseViewModel

  # @return [String] ニックネーム
  attr_reader :nickname

  # @return [String] 年齢
  attr_reader :age

  def initialize(params)
    @nickname = params.fetch(:nickname)
    @age = params.fetch(:age)
  end
end

ヘルパーはViewのグローバルを汚染するのでなるべく利用しない様にしています。
UIの都合はプレゼンタに実装して、ビューモデルを組み立てます。
テンプレート側ではビューモデルの属性を表示するだけの実装になる様にしています。

Ruby on Rails と ドメイン駆動設計

Ruby on RailsのRailに乗る事こそが至上であるという方には、この様な取り組みを疑問視する方もいらっしゃると思います。

class UsersController < ApplicationController
  def show
    @user = UserRecord.find(params[:id])
  end
end 

確かに今回のソースの例では、上記の様にコントローラに一行書いて、年齢の処理はモデルに書けばそれだけで同じ動きが得られます。

ですが、同じサービスを長く続けていれば、データプロバイダが変わる事によってテーブルの定義は何度も見直されますし、意図してこなかった用途で貯めたデータを再利用したいという企画も出ますし、外部のDBやAPIから直接データを取得して作るサービスなどを作るという事もでてきます。

データプロバイダ側の都合や既存データの設計上の都合に合わせてコントローラやビューを一々組み直す事には大変な労力がかかります。
改修による影響範囲が読めなければそもそも改修する許可すら出す事が難しいという事になりかねません。
保守性の高い状態を保つ為にはRuby on Railsのレールを外す必要は有ると私は思いますし、長期的には保守性を保つ事こそが開発スピードを上げるという事にもなるのではないでしょうか。

ドメイン駆動設計というと難しいイメージがあるとよく言われますし、エヴァンス本を読むと眠くなると言う話も良く聞きます。ですが重要なのは丁寧なモデリング、それを実現するデザインパターン、チームのコミュニケーションと改善を繰り返すイテレーションです。

Rubyだってオブジェクト指向言語なんです。
静的型付け言語の良さが押される昨今の風潮も理解できますが、動的型付けだってしっかりチームでコミュニケーションを取って扱えば記述量を抑えた見通しのよいコードにできます。

スピード感重視でRuby on Railsでサービスを作って、
気がついたらコードが無秩序に肥大化してメンテナンスに困っているという事例はそれなりに有るのではないでしょうか。

それで幸せになれるなら、Ruby on Railsでドメイン駆動設計をやってもいいじゃないか。と思います。

21
7
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
21
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?