🛤️

Rubyではじめる関数型ドメインモデリング

2024/11/06に公開

こんにちは。世界から法律に関わる悩みをなくしたい高崎です。普段はココナラ法律相談という弁護士の先生方と相談したい悩みのある相談者のマッチングサービスをつくっています。

https://legal.coconala.com/

ココナラ法律相談はもうすぐリリース10年を迎える、それなりに歴史があるRuby on Rails(以後Rails)で実装されたWebサービスです。Railsは非常に洗練されたフレームワークで、迅速に機能を実装可能ですが、その反面自由度が高いがゆえに意図せず技術的負債を生み出しやすい傾向にあります。

この記事では、関数型ドメインモデリングという考え方を参考に、どのように普段のRails開発で一貫性を保った設計、実装を行うか検討します。またココナラ法律相談での実装例としてdry-monads を利用したワークフロー実装のコード例も紹介します。

関数型ドメインモデリングとは

関数型ドメインモデリングとは、「関数型ドメインモデリング ドメイン駆動設計とF#でソフトウェアの複雑さに立ち向かおう」(以下、本書)で提起されたコンセプトです。

https://amzn.asia/d/2r8jX5p

ドメイン駆動設計と関数型プログラミングを組み合わせることで、メンタルモデルを構築することを目指すものです。メンタルモデルとは「程よい抽象度で、システムとビジネス現場の間を取り持つような抽象概念」として捉えてください。

このメンタルモデルを開発チームやステークホルダー、コードと同期させることで、メンテナビリティの向上、サービスの市場投入までの時間短縮など、様々なメリットがあると本書では説いています。

この書籍の内容を集約すると、以下のようになります。

  • ドメインモデルを型に落とし込み、プログラム中のモデルが現実に起こり得ない状態を表すことを許さない
  • 副作用のないワークフローでドメインロジックを実装する

今回は動的型付け言語であるRubyでの実装が前提になるため、ドメインモデルを型に落とし込むことは言語特性上難しいです。なので中でも

  • 副作用のないワークフローでドメインロジックを実装する

こちらの観点を中心に考えたいと思います。

一般的にサービスの成長に伴い、ドメインロジックは複雑になる傾向にあります。初期リリースのときはシンプルだったロジックも、フィードバックを経て顧客の業務に詳しくなったり、市場の要望に応えたり、PMFを目指す中で複雑化することは正しいことであり、一定しょうがないことです。

ポイントはドメインロジックの複雑化とプロダクトのコードの複雑化を比例させないことだと考えます。

やや極端な例ですが、最悪の場合サービスの開発継続が不可能になるケースもあります。

https://www.itmedia.co.jp/news/articles/2408/27/news126.html

副作用のないワークフローとは

では、具体的に副作用のないワークフローとはなんでしょうか? それは、副作用のない小さな関数を組み合わせて作られる一つの関数を指します。

副作用のないワークフローを構築する上でのポイントは以下の3点になります。

  1. 関数の返り値を Result(Success or Failure) で表現すること
  2. Resultを返す複数の関数をパイプラインで合成すること
  3. ワークフローを純粋に保つこと(副作用を起こさないこと)

1. 関数の返り値を Result(Success or Failure) で表現すること

Resultは処理の 成功(Success)と失敗(Failure) を宣言的に表現する方法です。

典型的なユースケースとして、サイトに掲載されている記事(Article)を更新する処理を例として見てみましょう。

  1. 入力パラメータを受け付ける
  2. 入力パラメータが正常か検証する
  3. 入力された値で更新された記事オブジェクトを得る
  4. 上記の処理の結果を返す

実際の業務ロジックをコードに落とし込んでいく中で、単純な更新処理でも、上記のようなステップが必要になり、それらすべてのステップはなんらかの理由で失敗する可能性があります。

このような失敗をどのように表現すれば良いでしょうか?

ひとつの方法として各失敗に相当するエラークラスを用意し、失敗したケースに応じて、例外としてraiseする方法があります。

それでも一見問題はないですが、関数が例外を吐く場合、それは副作用として扱われるため、今回のように副作用のない関数を使いたいユースケースに合いません。

そこで上記のような失敗を Failure という Resultクラスで表現し、例外をraiseする代わりに Failureという値を返すことで、副作用を避けながら業務上発生する失敗(本書では、ドメインエラーと呼んでいます)を明示することが出来ます。

2. Resultを返す複数の関数をパイプラインで合成すること

ユースケースを表す関数全体の出力だけでなく、関数を構成する1つひとつの関数も、入力と出力をResultに限定することにより、再利用性が高まります。

つまり、今回のユースケースの処理ひとつを1ステップと定義し、それらをResultを返す関数として合成します。

こうすることで、すべてのステップがResultで繋がれた処理を作ることができます。このようにする利点は、処理の変更に柔軟に対応できることです。

例えば、ステップにパラメータの整形処理を追加する場合を考えてみましょう。その場合、1つステップが追加され、ワークフローは以下のようになります。

  1. 入力パラメータを受け付ける
  2. 入力パラメータが正常か検証する
  3. 入力パラメータを整形する <- [NEW]
  4. 入力された値で更新された記事オブジェクトを得る
  5. 上記の処理の結果を返す

ドメイン知識の深化や、仕様変更になり、途中でステップの追加が必要になったときでも、各ステップの入出力がResultで限定されていることで、ステップの追加が容易に行うことができます。

このように成功と失敗の2つのパスを持つステップを組み合わせ、1つのワークフローとして合成する手法は鉄道志向プログラミング(Railway oriented programming) と呼ばれています。

https://fsharpforfunandprofit.com/posts/recipe-part2/

3. ワークフローを純粋に保つこと(副作用を起こさないこと)

上記のワークフローの成功時の流れに注目してみると、以下のようなデータの変換が行われていることが分かります。

これはつまり、関数を通してデータがある状態から別の状態に値を写したと捉えることができます。

このように、ワークフローを関数を通した状態遷移と捉えることは、ドメインモデリングと相性が良いです。

なぜなら、引数(変更前の状態)と返り値(変更後の状態)を見ることで、状態変化を宣言的に表現できるからです。 また上記が成立するためには、引数が同じ場合、常に同じ返り値となる必要があります(参照透過性が担保されている状態)。

逆にこれを妨げるような処理を、ワークフローに持つべきではありません。具体的には外部システムとのやり取り(IO)などです。このように、関数を通して値を写していくことにより、ワークフローを純粋な計算として定義することができます。

そのような設計指針として本書で提示されているのが オニオンアーキテクチャ です。

onion_architecture.png
引用: https://www.slideshare.net/slideshow/reinventing-the-transaction-script-ndc-london-2020/227048595#117

これは副作用に当たる処理(データベースとのIOや、APIとのやり取りなど)を、処理の両端に配置することで、ドメインの中心に当たるワークフローを純粋に保つ設計です。

ところで、フロントエンドのデザインパターンに コンテナ・プレゼンテーションパターン というものがあります。これはレンダリングとロジックの責務をコンポーネントで分け、関心の分離を行います。

https://www.patterns.dev/react/presentational-container-pattern

プレゼンテーションコンポーネントは を通じてデータを受け取ります。その主な機能は、受け取ったデータを変更せずに、スタイルを含め、希望どおりに表示するpropsだけです。

つまりプレゼンテーションコンポーネントは、propsで受け取った値を、そのまま表示するのみの純粋な関数(コンポーネント)となっています。

バックエンド、フロントエンドという違いはありますが、副作用という観点に着目すると、オニオンアーキテクチャ同様、副作用を起こすレイヤーを設計として分離している点が共通点と捉えることもできそうです。

dry-monadsを使ったワークフローの実装

ここまで関数型ドメインモデリングにおけるワークフローの概念を紹介してきました。ここからは実際にRubyを使ってどのようにワークフローを実装するのか具体例を見ていきます。

実装にはResult型を使いたいですが、Rubyでは組み込みで存在しないため、 何らかのライブラリを使うか、自作する必要があります。

今回は dry-monads ライブラリを使って実装したいと思います。

https://dry-rb.org/gems/dry-monads/1.6/

dry-monadsはRuby用の一般的なモナドのセットです。 モナドは、エラーや例外を処理するエレガントな方法を提供し、関数を連結することで、コードがより理解しやすくなり、すべてのifやelseを使わずにすべてのエラー処理ができるようになります。

以下では、どのようにワークフローを実装するのか、記事 Article オブジェクトを更新するワークフロー実装の全体例を示します。

update_article_workflow.rb
require 'dry/monads'

class UpdateArticleWorkflow
  include Dry::Monads[:result]

  attr_reader :params, :article

  private_class_method :new, :allocate

  def self.call(**args)
    new(**args).send(:call)
  end

  private

  def initialize(params:, article:)
    @params = params
    @article = article
  end

  def call
    validate_params.bind do |validated_params|
      format_params(validated_params:).bind do |formatted_params|
        assign_attributes_params(formatted_params:).bind do |result|
          Success(result)
        end
      end
    end
  end

  def validate_params
    # 任意のバリデーション条件を指定
    some_conditions ? Success(params) : Failure('パラメータが不正です')
  end

  def format_params(validated_params:)
    # 任意の整形処理
    Success(validated_params)
  end

  def assign_attributes_params(formatted_params:)
    Success(article.tap { |a| a.assign_attributes(formatted_params) })
  end
end

実装のポイントを1つずつ見ていきましょう。

class UpdateArticleWorkflow
  include Dry::Monads[:result]

  attr_reader :params, :article

  private_class_method :new, :allocate

  def self.call(**args)
    new(**args).send(:call)
  end

  private

  def initialize(params:, article:)
    @params = params
    @article = article
  end
  ...以下略
end

initializeでワークフロー全体への入力を受け付けます。今回は

  • 更新する対象の記事オブジェクト
  • 更新したいパラメータ

の2つが入力として必要なので、それらを初期化時にインスタンス変数としてセットします。また、new allocateprivateにし、意図せぬ初期化がされないように、コンストラクタの可視性をコントロールします。

外部に露出するのはワークフローを呼び出すcallメソッドに限定させ、確実にワークフローに乗るようにします。

def call
  validate_params.bind do |validated_params|
    # validate_params関数のSuccessした場合の値がvalidated_paramsとして渡される
    format_params(validated_params:).bind do |formatted_params|
      # format_params関数のSuccessした場合の値がformatted_paramsとして渡される
      assign_attributes_params(formatted_params:).bind do |result|
        # assign_attributes_params関数のSuccessした場合の値がresultとして渡される
        Success(result)
      end
    end
  end
end

ワークフローのメインの処理です。注目してほしいのは、ワークフローの各ステップを1つの関数として定義し、その関数をbind関数で、連結させているところです。

先ほどの図のステップを Resultを返す関数として実装することで、成功した場合の Success を次のブロックに渡すことができます。

https://dry-rb.org/gems/dry-monads/1.6/result/#code-bind-code

Rubyのコードが、この状態遷移図と完全に一致していることが分かります。 このようにResultを返す関数を連携させることで、callの中身が成功時の処理の流れのみになり、ワークフロー全体が理解しやすくなります。

では失敗時の処理はどこに書けばいいのでしょうか? それはResultを返す各ステップ関数の中に記述します。

def validate_params
  # 任意のバリデーション条件を指定
  some_conditions ? Success(params) : Failure('パラメータが不正です')
end

各ドメインにおける任意の条件を指定して、パラメータ不正がある場合には Failureを返すようにします。

もし途中のステップでワークフローが失敗(Failureが発生)した場合、その時点でワークフローは失敗パスに入り、正常なパスに入ることはなく、残りのワークフローは失敗のフローをそのまま流れ、最終的にワークフローからレスポンスとして、呼び出し元に返されます。

このようにワークフローを実装することで、処理の流れとコードベースをシンプルに保ちやすくなります。

ただステップ数が増えてくると、関数のネストが増えてくるので、見通しが悪くなる可能性があります。

ステップごとにネストが深くなり、見通しの悪い関数
def call
  validate_params.bind do |validated_params|
    format_params(validated_params:).bind do |formatted_params|
      assign_attributes_params(formatted_params:).bind do |result|
        Success(result)
      end
    end
  end
end

その対策として、yieldを使用してステップを同じ階層に保つのをおすすめします。

https://dry-rb.org/gems/dry-monads/1.6/do-notation/

yieldを使うと先ほどのcallメソッドを以下の様に書き換えることができます。

yieldでリファクタリングした例
def call
  # 検証済のパラメータ
  validated_params = yield validate_params
  # 整形済のパラメータ
  formatted_params = yield format_params(validated_params:)
  # 更新適用済のオブジェクト
  result = yield assign_attributes_params(formatted_params:)
  Success(result)
end

Resultを返す関数に対して、yieldを適用すると渡れたブロックを実行し、Successが返された場合、その値をunpackします。

このように、yieldを利用することでより見通しの良いコードを保ちやすくなります。

また今回のワークフローでは、オニオンアーキテクチャの原則に従い、ワークフロー内でのIOを行わず、更新後のオブジェクトをそのまま Success(result)として返すようにしました。

しかし、場合によっては永続化の結果(DBへの保存など)までをワークフロー内で保証し、その結果を返したいケースもあると思います。

その場合は以下のように、適切にエラーハンドリングを行い、どのステップで失敗したのかを明示するようにしましょう。

def call
  # 検証済のパラメータ
  validated_params = yield validate_params
  # 整形済のパラメータ
  formatted_params = yield format_params(validated_params:)
  # 更新適用済のオブジェクト
  result = yield assign_attributes_params(formatted_params:)
  # DBへの保存(IO)
  result.save!
  Success(result)
rescue Dry::Monads::Do::Halt => e
  # ワークフロー上の何らかのステップで失敗しFailureが発生した場合、Dry::Monads::Do::Haltがraiseされる
  e.result
rescue StandardError => e
  # IOで失敗した場合
  Failure('記事の更新に失敗しました')
end

その場合でも、ワークフローの内部はなるべく純粋に保ち、副作用を伴う処理はなるべくワークフローの最初もしくは最後に寄せるようにして、分離することを推奨します。副作用を伴う処理が増えるほど、テストの難易度やコストを増大させてしまうためです。

io-in-the-middle.png
引用: https://www.slideshare.net/slideshow/reinventing-the-transaction-script-ndc-london-2020/227048595#124

特にRailsはActiveRecordの強力な機能で、意図せずDBとのIOを容易に行えてしまうため(それが強みでもありますが)、いつの間にか外部システムに依存したワークフローになってしまう恐れがあります。

メンテナビリティが高いワークフローを維持するためにも、副作用(IO)をなるべく外側に寄せるという設計指針は、重要であると考えます。

まとめ

本記事では、関数型ドメインモデリングというコンセプトの実装例として、dry-monadsライブラリを用いたRubyのワークフロー構築を紹介しました。RubyやRailsはそのエレガントな文法や柔軟性でさまざまな機能を実装できますが、柔軟であるがゆえに容易に副作用を起こしてしまうという側面をもっています。

Rubyの柔軟性を保ちつつ、堅牢な処理を実現する指針として、関数型のアプローチを採用することで、一定の秩序が保ちやすくなります。

関数型ドメインモデリングは本来的には、言語の型システムを利用してドメインモデルを忠実にコードに落とし込むことを目指しており、その用途においてはRubyは最適な選択肢ではないかもしれません。

しかしそのエッセンスを採用することで、既存のリソースを活かしつつ、コードの安全性を高める工夫ができると考えています。

それではみなさん、良いRubyライフを👋

We're hiring!

https://coconala.co.jp/recruit/engineer/

参考文献

https://amzn.asia/d/2r8jX5p

https://speakerdeck.com/naoya/guan-shu-xing-puroguramingutoxing-sisutemunomentarumoderu

https://fsharpforfunandprofit.com/posts/recipe-part2/

Discussion