🌸

「RubyでDDDやるならHanami」という噂の真相

2022/04/30に公開

こんにちは。株式会社InnoScouter CTOの大西(Twitter: @monarisa_masa)です。

InnoScouterでは、Ruby製WebフレームワークであるHanamiを採用しており、DDDを用いて開発しています。
Hanamiについて言うと、私個人としては、前職も含めて4年ほど運用経験があります。

ここでは、Hanamiが出てくるとよく話題にされるRailsとの比較は取り扱いませんが、初めてHanamiについての記事を読まれる方にも分かるようなサンプルコードで説明したいと思います。

突然ですが、こちらが本日のメイントピックです。

今回は、「RubyでDDDやるならHanami」と言われてますが、本当にそうなの?ってところを掘り下げていきたいと思います。
ツイートが意味していることと、それに対する自分の考えを話していけたらと思います。

この記事の対象読者

  • Rubyを触っていて、DDDをやりたいと思っている方
  • Rubyを触っていて、ビジネスロジックと永続化の責務分離に悩まれている方
  • MVCを利用していて、Fat ModelやFat Controllerに悩まされている方
  • Hanamiを触ってみたけど、ドメインモデル貧血症になってしまった方

ドメインモデル貧血症の説明はこちらの記事がとてもわかりやすかったので、是非ご覧になられるとよいかと。
https://zenn.dev/dowanna6/articles/92c6494570a4dc

持ち帰ってほしいこと

そもそもHanami自体、レイヤリングについてはクリーンアーキテクチャをベースとしたディレクトリ構成を機能として提供してくれているため、DDDを取り入れやすい形になっていると言えます。
ただ一歩間違えると、①リポジトリとエンティティの責務が混ざってしまったり、②ドメインモデル貧血症に陥ってしまうという問題が起きてしまいます。
本記事ではそういった罠に焦点を当ててつつ、回避方法まで話します。

  • リポジトリとエンティティの責務を知る。
  • リポジトリとエンティティが混ざると何が悪いかを知る。
  • (Hanamiの公式通りに)実装していると、リポジトリとエンティティの責務が混ざる。
  • (Hanamiの公式通りに)実装していると、ドメインモデル貧血症が起きる。
  • それらの回避方法

リポジトリとエンティティの責務を知る。

エンティティ

  • エンティティとは、アプリケーションの中核であり、ドメインをまとめる場所です。
  • 永続化はビジネスロジックに含まれないと捉え、永続化責務は持ちません。
  • DDDにおけるエンティティと、クリーンアーキテクチャにおけるエンティティの意味は違いますが、ここでは深入りしません。(深入りしたい方はこちらを)
  • エンティティの中からビジネスロジックが漏れ出ることをドメインモデル貧血症と呼びます。(後で出てきます。)

リポジトリ

  • 作成されたエンティティを永続化する場所です。

リポジトリとエンティティが混ざると何が悪いかを知る。

[混ざり方1]リポジトリとエンティティの区別がなく、ビジネスロジックがまとまっていない場合

  • 概要

    • リポジトリとエンティティの区別をしていない。(たとえクラスとして分かれていても、呼び出し側で、別々に呼び出していない場合も含む。)
    • ビジネスロジックをまとめる場所が用意されていない。
    • 永続化の知識のみがまとめられている。
  • 具体例

    • Railsで言うと、ActiveRecordのモデルがビジネスロジックを何も持たない状態。
    • ※ 今日取り上げるHanamiのチュートリアル通りにやるとこうなります。
  • 問題

    • リポジトリの呼び出し元で、ビジネスロジックが至るところに重複してしまい、仕様変更が発生した場合に、複数箇所を一気に修正する必要がでてきて、影響範囲の調査に難航する。

[混ざり方2]リポジトリとエンティティの区別がなく、ビジネスロジックがまとまっている場合

  • 概要

    • リポジトリとエンティティの区別がない。(そもそも同じクラスとして定義されている。)
    • ビジネスロジックと永続化の知識が混在してまとめられている。
  • 具体例

    • Railsで言うと、ActiveRecordのモデルにビジネスロジックがまとめられている状態。
  • 問題

    • ビジネスロジックを書いている場所に、トランザクション、I/Oの失敗可能性、リトライなどの本質的ではない影響を考慮する必要があり、複雑化する。
      • 例えば、ActiveRecordのモデルに永続化メソッドが生えているおかげで、ピュアなビジネスロジックだけに集中できず、永続化の失敗時のケアなどを念頭に置きながら実装しないといけない。
    • エンティティのユニットテストを書くときにDB接続部分をモックすることができず、DBに繋がないとテストができない。
      • 例えば、ActiveRecordの中にモデルに、永続化のためのロジックとビジネスロジックの両方があり、DBに依存していないビジネスロジックの部分だけをテストしたいが、DB接続部分が邪魔でテストしづらい。

(Hanamiの公式通りに)実装していると、リポジトリとエンティティの責務が混ざる。

お待たせしました。
概念的な話はそれくらいにしておいて、コードを見たほうが早いのでコードを見てみましょう。
以下、Hanamiの公式を見つつ、実際に実装してみました。

もし、実際にコードを触れてみたい方はこちらから。(dockerでちゃっと立ち上げられます。)

コードの中身

  • bundle exec hanami generate model book とするだけで、これらのファイルが自動生成されます。
db/migrations/20xxxxxxxxxxxx_create_old_books.rb
Hanami::Model.migration do
  change do
    create_table :old_books do // ①
      primary_key :id

      column :title, String, null: false
      column :author, String, null: false
      column :created_at, DateTime, null: false
      column :updated_at, DateTime, null: false
    end
  end
end
lib/hanami_ddd/entities/old_book.rb
class OldBook < Hanami::Entity //end
lib/hanami_ddd/repositories/old_book_repository.rb
class OldBookRepository < Hanami::Repository //end
  • ①について、old_booksテーブルを生成するマイグレーションファイルが生成されます。
  • ②について、entitiesというフォルダ直下に、OldBookというエンティティクラスが生成されます。
  • ③について、repositoriesというフォルダ直下に OldBookRepositoryというリポジトリクラスが生成されます。

コードの実行

  • マイグレーションを実施の上、生成されたコードを実行してみます。
  • 実行コードは こちら(Hanamiの公式)記載のものになります。
repository = OldBookRepository.new // ④

old_book = repository.create(title: "hanami-ddd", author: "hanami") //[INFO] [2022-04-24 17:41:32 +0000] (0.005494s) INSERT INTO `old_books` (`title`, `author`, `created_at`, `updated_at`) VALUES ('hanami-ddd', 'hanami', '2022-04-24 17:41:32', '2022-04-24 17:41:32')
[INFO] [2022-04-24 17:41:32 +0000] (0.001632s) SELECT `id`, `title`, `author`, `created_at`, `updated_at` FROM `old_books` WHERE (`id` IN (1)) ORDER BY `old_books`.`id`
=> #<OldBook:0x00005582d1c18dc0 @attributes={:id=>1, :title=>"hanami-ddd", :author=>"hanami", :created_at=>2022-04-24 17:41:32 UTC, :updated_at=>2022-04-24 17:41:32 UTC}>
  • ④でOldBookRepoitoryがインスタンス化されます。
  • ⑤でいきなり、①のテーブル内のカラムに値を指定する形で、リポジトリに永続化依頼を飛ばしています。
  • その後、返り値としてOldBookのインスタンス(エンティティ)が返ってきています。

コードを見てのコメント

  • 自動生成したファイルを見てみると、エンティティとリポジトリが別クラスとして定義されているようです。
  • ただ、実行する際には、エンティティを呼び出すことなく、リポジトリだけを呼び出し、それがエンティティの生成・永続化の両方を行っているようです。
  • このツイートで松岡さんがイケてないとおっしゃっていたところですね。まさにその通りとしか言えません。

本来のあるべき実装は?

呼び出し側のコードで考えてみましょう。

old_book = OldBook.create(title: "hanami-ddd", author: "hanami") // ①

repository = OldBookRepository.new

repository.store(old_book) //
  • ①でエンティティを作成する。作成時には、OldBookのビジネスロジックにもとづき、渡されたパラメーターが適切なのか、validationが行われる。
  • ②でリポジトリが、責務通り、作成されたエンティティを永続化する。

こうすることで、エンティティで生成条件を満たしたもののみがインスタンス化され、それが永続化されるという理想的な書き方にできそうですね。

一旦、ここはこれくらいにしておいて、次の課題も見てみましょう。

(Hanamiの公式通りに)実装していると、ドメインモデル貧血症が起きる。

直前で書いたあるべき実装にせず、このまま追加機能の開発を進めると、もう少し悩ましいことが起きます。
では、追加機能を想定して、実際に体感してみましょう。

追加機能要件

  • 「titleは20文字以内にしてください。」
  • OldBookは管理画面からも追加されるし、ユーザーがアプリから追加される場合もあります。

少し要件にリアリティがないかもしれませんが(すみません...)、要は大事なビジネスロジックが追加されたと思ってもらえれば良いです。

コードの中身

lib/hanami_ddd/entities/old_book.rb
class OldBook < Hanami::Entity
end
lib/hanami_ddd/repositories/old_book_repository.rb
class OldBookRepository < Hanami::Repository
end
// ユーザーがアプリからOldBookを追加するユースケース
class UsecaseFromUser //def initialize(repository:)
    @repository = repository
  end

  def call(title:, authoer:)
    if title.size >= 20 //raise ArgumentError, "titleは20文字までです。"
    end
    
    @repository.create(title: title, author: authoer)
  end
end

repository = OldBookRepository.new
UsecaseFromUser(repository: repository).call(title: "aaaaaaaaaaaaaaaaaaaaa", author: "hanami") 
=> 失敗!(想定通り!👏👏👏)

// 管理画面からOldBookを追加するユースケース
class UsecaseFromAdminPanel
  def initialize(repository:)
    @repository = repository
  end

  def call(title:, authoer:)
    # 忘れてた.... // ③
    # if title.size >= 20
    #   raise ArgumentError, "titleは20文字までです。"
    # end
    
    @repository.create(title: title, author: authoer)
  end
end

repository = OldBookRepository.new
UsecaseFromAdminPanel(repository: repository).call(title: "aaaaaaaaaaaaaaaaaaaaa", author: "hanami")
=> 成功!(想定外💣💣💣)

  • 1つ前のコード例より、少しコード量が増えましたが、ユースケース層を追加してみただけです。
  • ①については、補足しておくと、Hanamiでは、ユースケースを書くことができる場所をオプションで提供してくれてています(詳細はこちら)。
  • そして、ここでは、リポジトリのインスタンスをコンストラクタから受け取り、callメソッドの中で、OldBookの生成処理を呼び出しています。
  • ②では、「titleは20文字以内にしてください。」というビジネスルールを守るための分岐を入れています。

コードを見てのコメント

  • このコード例では、③の部分にあるべき分岐を入れ忘れて、生成条件を満たしていないOldBookが生成され、永続化されてしまったという、事故が起きてしまったわけです。(今回はとてもシンプルなのでそこまで問題を感じないかも知れませんが、実際これが本番稼働しているアプリのソースコードだとしたら、分岐が各所に散らばっていて変更が非常に厄介になります。)
  • まさにドメインモデル貧血症に陥っていると言えます。
  • 「titleが20文字以下であってほしい」というのはOldBook(エンティティ)こそがもつべきビジネスロジックなわけです。もしOldBookの中にこのロジックがまとまっていれば、こういったことが起きることはなかったでしょう。

それらの回避方法

では、どうすればこれらの罠を回避できるのか。結論から言います。

  • エンティティにビジネスロジックを書く。
  • リポジトリにエンティティを渡せるようにする。

実際にコードを見てみましょう。

コードの中身

db/migrations/20xxxxxxxxxxxx_create_new_books.rb
Hanami::Model.migration do
  change do
    create_table :new_books do
      primary_key :id

      column :title, String, null: false
      column :author, String, null: false
      column :created_at, DateTime, null: false
      column :updated_at, DateTime, null: false
    end
  end
end
lib/hanami_ddd/entities/new_book.rb
class NewBook < Hanami::Entity
  MAX_TITLE_SIZE = 20
  def self.create(id:, title:, author:, created_at: Time.now, updated_at: Time.now) //if title.size >= MAX_TITLE_SIZE
      raise ArgumentError, "titleは#{MAX_TITLE_SIZE}文字までです。" //end

    self.new(
      id: id,
      title: title,
      author: author,
      created_at: created_at,
      updated_at: updated_at,
    )
  end
end

lib/hanami_ddd/repositories/new_book_repository.rb
class NewBookRepository < Hanami::Repository
  def store(book) // ③
    new_books.insert(
      id: book.id,
      title: book.title,
      author: book.author,
      created_at: book.created_at,
      updated_at: book.updated_at,
    )
  end
end
new_book = NewBook.create(title: "hanami-ddd", author: "hanami")

repository = NewBookRepository.new

repository.store(new_book)
  • ②の「titleは20文字以内にしてください。」はビジネスロジックと捉え、エンティティの中に実装しました。
  • また、①は細かいテクニックですが、エンティティにcreateメソッドを新設し、その中でコンストラクタを呼び出す構成にしました。(フレームワークの性質上、エンティティのコンストラクタはプリセットで用意されているので、生成条件の判定をするには、コンストラクタをオーバーライドする必要がありますが、ここでは別メソッドにその役割を担わせています。)
  • こうすることで、生成条件を満たしたもののみ、インスタンス化できるようになっています。
  • ③については、永続化のみの責務を持つstoreメソッドを別途定義しました。(Hanamiのリポジトリでは自由にsqlを書くことが可能です。) テーブル内のカラムに値を指定する形で渡すのではなく、new_book(エンティティ)が渡されることを前提に作っています。
  • テストの話になりますが、エンティティのユニットテストをする際には、永続化処理などは考慮せず、ビジネスロジックのみの検証に集中できます。

おまけ

ここまでの話を受けて、Hanamiは、(やり方さえ注意すれば) リポジトリとエンティティを分離して扱えるフレームワークだということをご理解いただけたかなと思います。

ただ、これで HanamiだとDDDでやりたいこと全部できてるの? というと、この構成だと私の答えはまだNoです。

というのも、現状の構成におけるエンティティは、アトリビュートを対応する1つテーブル(リポジトリ)のカラムから自動で割り振るような挙動になっています。
そうなると、「DBのカラム」=「エンティティのアトリビュート」となってしまい、1つのエンティティが複数のテーブルに対して関係を持つような(集約の範囲が広い)ケースにおいて、ドメインモデルを適切に表現できなくなることがあります。

こちらのツイートでも言及されているような内容です。

じゃあどうするの?って話は、書くとまた長くなるので、次回にしようかと思います。
(もし♡など反響があれば急いで書きます。)

まとめ

今回は、「RubyでDDDやるならHanami」という噂の真相と題して、記事を書かせていただきました。
確かにHanamiのチュートリアルどおりに実装すると、DDDにおけるアンチパターンを軽々と踏み抜くことが見て取れたと思います(動かせるまで最短距離のコードを手軽に自動生成できるというのは、素晴らしいことだとは思いますが)。ただ、Hanamiのフレームワークは良くも悪くも薄いので、無理せずカスタムしていける要素は揃っており、DDDをやる土台は十分にあると思います。

もしこの記事を読んで、Hanamiの実装について気になった方は是非気軽に見に来て下さい。今日話せなかったこともガンガン話します。
https://meety.net/matches/esiKfcSpwTVJ

僕のTwitter(DM解放されています)や、Facebook Messengerに直接ご連絡いただいても構いません!

InnoScouter(イノスカウター)Tech Blog

Discussion