dev.toのServiceクラスについてDDDとPofEAAを読んで考察してみた

23 min read読了の目安(約21100字

RailsにおけるServiceクラスに関する設計的な話は、プロジェクトの性質や開発チームの状況によって色々な意見があり、これといった解りやすい結論が出ていない状況だと思っています。
この状況において、ある程度の規模のOSSのServiceクラスの活用事例を取り上げて、考察することは意義がありそうだと思いました。
また、ServiceクラスのアイデアはRailsやWeb開発に関わらず、幅広い分野のソフトウェア開発で適用できるものなので、そういった意味でも価値がありそうだと思い、記事にしてみました。

対象とするOSSプロジェクトは、爆速技術記事サービスで有名な dev.to です。
まず、dev.toにおけるServiceクラスの活用方法を見た後で、ソフトウェア設計に関する名著である、Patterns of Enterprise Application ArchitectureDomain-Driven Design のServiceに関連する記述を読みながら考察してみる形です。
(以降、それぞれ「PofEAA」、「DDD」と表記します。)

対象読者と得られること

Serviceクラスを雰囲気で使っている人

  • Serviceクラスがどのようなものかをメリット、デメリットを交えて理解できるようになる
  • OSSの実例も紹介するので、自分が実装しているものと比較ができる

Serviceクラスを使ったことがない・知らない人

  • Serviceクラスを開発時のアイデアとして選択肢に入れられる

PofEAA,DDDを読んでいない人

  • 本の雰囲気を知れるので、読むかどうかの判断材料を得られる

dev.toでのServiceクラスの活用方法

まず、dev.toでのServiceクラスの活用方法を見てみます。
dev.toのオープンソースプロジェクトはforemという名前です。

https://github.com/forem/forem

foremの開発者向けドキュメントにServiceクラスについて説明しているページがあります。

https://docs.forem.com/backend/service-objects/

上記ページからServiceクラスについて説明している箇所を引用:

Service objects are Plain Old Ruby Objects (POROs) which encapsulate a whole business process/user interaction.

(著者訳)サービスオブジェクトは、ビジネスプロセス、又はユーザーインタラクションをカプセル化した、Plain Old Ruby Objects (POROs)です。

Serviceクラスは「Service objects」と表現されていますね。
「Plain Old Ruby Objects (POROs)」というのは、ActiveRecordのような特殊なクラスを継承しない、ピュアなクラスという意味です。

「ビジネスプロセス」、「ユーザーインタラクション」をカプセル化したものというふわっとした定義をしてますね。
実例をいくつか見てみましょう。

ユーザー管理周りのビジネスプロセスの実装例

ユーザー管理はWebサービスにとって一般的な機能なのでイメージがしやすいと思い、また、実装的にも上記の定義のような典型的なServiceクラスの使い方でしたので、例としてピックアップしてみました。

ユーザー管理周りのビジネスプロセスは、具体的には次のようなものがあります。

  • ユーザーアカウントの作成・認証
  • ユーザーアカウントの削除
  • ユーザーアカウントのマージ

まず、ユーザー管理周りでどういったモデルがあるかを見てみましょう。

ユーザー管理周りの概要を把握するためのクラス図:

ユーザーアカウントのモデルはUserです。
dev.toでのユーザー認証はソーシャルログインで行うので、ソーシャルログイン用のサードパーティのアカウント情報を保持しています。
これがIdentityモデルです。

また、Userに紐づくモデルとして、ArticleCommentなどがあります。
説明のためにイメージしやすいモデルとして、ArticleCommentをピックアップしましたが、実際にはかなりの数のモデルがUserに紐づけられています。

実際にデータが入った状態を見てみるとイメージしやすいかもしれません。

クラス構成のイメージしやすくするためのオブジェクト図:

ユーザー"太郎"がGithubアカウントを使ってソーシャルログインをして、記事とコメントを投稿した状態を表しています。

ユーザー管理周りのビジネスプロセスは、このようなUserと関連のある複数のモデルを適切に処理しなくてはなりません。
では、実際にどのようなビジネスプロセスがServiceクラスとして実装されているか見てみましょう。

ユーザーアカウントの作成・認証 Authentication::Authenticator

app/services/authentication/authenticator.rb

ソーシャルログイン時に取得したサードパーティのアカウント情報を元に、ユーザーを認証するServiceクラス。
ソーシャルログインのコールバックが実装されている、OmniauthCallbacksControllerから使用される。
app/controllers/omniauth_callbacks_controller.rb から一部引用してコメントを付与:

@user = Authentication::Authenticator.call(
    # ソーシャルログイン用のgem OmniAuthから取得した、サードパーティのアカウント情報(emailやusernameなど)
    auth_payload,
    # deviseのcurrent_user: 現在ログインしているユーザー情報
    current_user: current_user,
    # どのアクションからユーザー認証が行われたかを記録するための情報
    cta_variant: cta_variant, 
)

dev.toでは、このソーシャルログイン時のコールバックのアクションで、次の3つの機能を賄っている。

  • ユーザーアカウントの作成
  • ログイン
  • ソーシャルアカウント連携(dev.toでは複数のソーシャルアカウントと紐づけられるので、アカウント作成後の設定画面から、作成時に連携したもの以外のソーシャルアカウントと連携できる)

3つの機能を実現するために、サードバーティのアカウント情報を元に、UserIdentityの細かい制御が必要になる。
このような細かい制御をAuthentication::Authenticatorクラスにカプセル化しているので、Controllerではauth_payloadを渡して、認証結果のUserを受けとるだけの簡潔なコードになっている。

ユーザーアカウントの削除 Users::Delete

app/services/users/delete.rb

ユーザーアカウントを削除するServiceクラス。
ユーザーアカウントを削除するWorkerクラスUsers/DeleteWorkerから呼び出されて、実際にユーザーアカウントの削除処理を実行する。
ユーザーアカウントの削除処理は、Userの削除だけでなく、関連したArticleCommentなどの削除や、関連するキャッシュの削除などを伴う複雑な処理。
こういった処理をカプセル化して、クライアントであるWorkerクラスUsers/DeleteWorkerから隠蔽している。

したがって、Users/DeleteWorkerのコードは次のような簡潔な形になる。
app/workers/users/delete_worker.rb から一部引用してコメントを付与:

def perform(user_id, admin_delete = false) # rubocop:disable Style/OptionalBooleanParameter
    user = User.find_by(id: user_id)
    return unless user

    Users::Delete.call(user)
    return if admin_delete || user.email.blank?
    # ...

ユーザーアカウントのマージ Moderator::MergeUser

app/services/moderator/merge_user.rb

2つのユーザーアカウントをマージするServiceクラス。
マージ処理は管理画面のユーザー管理から行える。

マージ処理のアクションはadmin/users#mergeで、ServiceクラスModerator::MergeUserは次ように呼び出される。
app/controllers/admin/users_controller.rb から一部引用してコメントを付与:

def merge
  @user = User.find(params[:id])
  begin
    Moderator::MergeUser.call(admin: current_user, keep_user: @user, delete_user_id: user_params["merge_user_id"])
    # ...

ユーザーアカウントの削除処理と同様に、マージ処理もUserに紐づく様々なモデルを適切に処理する必要がある。
この複雑な処理をServiceクラスModerator::MergeUserにカプセル化している。


ユーザー管理に関連する「ビジネスプロセス」を実装したServiceクラスを3つピックアップしてみました。
「ビジネスプロセス」をUserなどのモデルのメソッドとして実装するのではなく、専用のServiceクラスとして実装していますね。
共通して見て取れるのは、Serviceクラスのクライアントである、ControllerやWorkerクラスのコードが、Serviceクラスのメソッドを呼び出すだけの簡潔なコードになっている点でしょうか。

次は「ユーザーインタラクション」の実装例を見てみます。

ユーザーインタラクションの実装例

Exporter::Service

app/services/exporter/service.rb

自分が投稿した記事とコメントをエクスポートするServiceクラス。
ログイン後の設定ページからエクスポートできる。

設定ページの更新アクションであるusers#updateから非同期的に実行されるWorkerクラスExportContentWorkerから次のように使用される。
app/workers/export_content_worker.rb から一部引用:

def perform(user_id)
    user = User.find_by(id: user_id)
    Exporter::Service.new(user).export(send_email: true) if user
end

処理内容は、投稿記事とコメントをjson化したファイルをzipにまとめて、メールに添付して送信する形。
「どの情報をどのような形式でエクスポートするか」を、Exporter::Serviceクラスにカプセル化している。

Suggester::Users::Recent

app/services/suggester/users/recent.rb

フォローするユーザーを提案するServiceクラス。
アカウント作成時のオンボーディングでフォローするユーザー候補を出してくれる。

フォローするユーザー候補を取得するエンドポイント/users?state=follow_suggestionsにアクセスされた際に次のように使用される。

recent_suggestions = Suggester::Users::Recent.new(
    current_user,
    attributes_to_select: INDEX_ATTRIBUTES_FOR_SERIALIZATION, # %i[id name username summary profile_image].freeze
).suggest

インターフェースはUserと必要なattributesを指定する形。
Suggester::Users::Recentクラスは、ユーザーがフォローしているタグ情報や、候補となるユーザーの活動状況などを考慮した上で、ランダム性を加味したフォロー候補ユーザーのリストを作成してくれる。
これを実現するための複雑な条件式やqueryをカプセル化している。


以上、「ビジネスプロセス」と「ユーザーインタラクション」の実装例を見てみました。

実際にはこういったServiceクラス以外にも、Slack::AnnouncerPayments::Customerのような、前述した「Serviceクラスの定義」に当てはまらないものも数多く app/services 以下に配置されています。

app/services 以外にも app/libapp/labor のようなディレクトリもあるのですが、「クラスの置き場所に困ったら app/servicesに置く」という感じになってしまっているようです。
この現象は、app/servicesを設けた際に起こりがちで、現実的に対処すべき課題ではあると思いますが、記事の目的である「Serviceクラスの考察」とは少しずれてしまうので、本記事では触れないことにします。

考察

dev.toのServiceクラスの活用方法が把握できたので、PofEAA、DDDそれぞれのServiceに関する技術をピックアップしながら考察していきます。

用語について

考察の前に、使用する用語について簡単に説明します。
出来るだけ本と同じ表現で用語を使いますが、PofEAAとDDDで、同じ用語でも別の意味で使っていたりするので少し工夫が必要です。
工夫の1つとして、抽象的なものはカタカナ表記で、実装的なものはローマ字表記にしてみました。
例えば「ドメインモデル」は、DDDで頻繁に使われている、抽象的・本質的な意味での「ドメインモデル」と、PofEAAで紹介されいてる実装パターンの1つの「Domain Model」があり、これらは全く違う意味を表しています。
DDDの方は抽象的な意味なのでカタカナの「ドメインモデル」、PofEAAの方は実装的なものなのでローマ字の「Domain Model」と表記します。

以下、用語の定義です。
厳密なものではなく、考察の内容が最低限伝わるための、ふわっとした定義です。

用語 説明
ドメイン 問題領域
システム、アプリケーション ドメイン内の課題をコンピューターリソースを使って解決するもの
ドメインモデル アプリケーションを作るため(ドメイン内の課題解決のため)に、ドメイン内の情報の取捨選択や構造化をしたもの
ドメインモデリング ドメインモデルを創る作業
ドメインロジック、ビジネスロジック ドメイン内の知識や規則

実装的なものは簡単な図にしてみました。

ということで、本の内容に入っていきます。

Patterns of Enterprise Application Architecture (PofEAA)

PofEAAは題名の通り、エンタープライズアプリケーションを開発する際のアーキテクチャパターンを紹介しているものです。
まず、「エンタープライズアプリケーションとは何か」は、簡潔な定義は難しいとした上で、次にような特徴をもつと書かれています。

  • 永続化するデータがあり、データに対する様々な処理から構成される
  • ゲームやコンパイラー、エレベーターを制御するようなアプリケーションではない

いわゆる一般的なWebアプリケーションはエンタープライズアプリケーションに含まれていて、もちろんdev.toも対象内だと思います。

また、パターンを紹介する本ということで、「パターンを適用すること」に関しても冒頭で次のように書かれています。

開発するアプリケーションは、それぞれ多様であるので、どのような設計方法を用いれば良いかは、アプリケーションごとに異なる。
なので、この本で紹介するパターンも、書かれている通りにそのまま適用することは想定していなくて、紹介しているパターンのアイデアや考え方を使って、アプリケーションごとに適した形に調整して適用することを想定している。

上記のような記述をした上で、でも、アプリケーションの属性は大まかな分類はできるよね、という話をしています。

例えば、B2CのアプリケーションはB2Bのものと比べてドメインロジックがシンプルになる傾向がある。
B2Cは不特定多数のクライアントに利用されるものなので、各々のクライアントに向けた細かいロジックはなく、大勢に向けたシンプルなロジックになる。
B2Bは、クライアントが少ない代わりに、1つ1つのクライアントの重要性が大きく、それぞれの背景や事情があるから、各々のクライアントに応じたビジネスロジックを組む必要があり、複雑になる傾向がある。

紹介されているパターンごとに「シンプルなビジネスロジックに向いている」などの適性があるので、パターンを採用するときに開発対象のアプリケーションがどういった属性かを把握しておくのは大事そうですね。
dev.toは、B2Cのシンプルなビジネスロジックのアプリケーションと捉えることができそうです。

PofEAAは様々なジャンルのパターンを数多く扱っていて、パターンカタログとして公開もされています。

https://bliki-ja.github.io/pofeaa/CatalogOfPofEAA_Ja/

それでは、本記事の目的である「Serviceクラスの考察」と関係のある「Active Record」と「Service Layer」を取り上げてみます。
Active Recordは、Serviceクラスとは直接的には関係はありませんが、dev.toのDomain Layerは主にModelとServiceクラスで構成されているので、「Model = Active Record」がどういったものかを理解しておくことは無駄ではないと思い取り上げてみました。

Active Record

https://bliki-ja.github.io/pofeaa/ActiveRecord/

データベースのテーブルやビューの列をラップし、データベースアクセスをカプセル化し、ドメインロジックを追加するオブジェクト

RailsのModelでお馴染みの ActiveRecord もこのパターンを採用している実装の1つです。

PofEAAでは、Domain ModelとDBをマッピングするパターンの1つとして紹介されています。
このパターンは、シンプルなドメインモデルに適していて、メリットは「わかりやすさ」です。

なぜ、シンプルなドメインモデルに適しているのか。
Active RecordのオブジェクトはDBのテーブルに1レコードに対応していて、Active Recordのオブジェクトを通してDBの読み書きをする。
RailsのModelもそうですよね。

RailsのActiveRecordを通してDBの読み書きをする例:

person = Person.find(1) # DBからの読み取り
person.name = 'takashi'
person.save # DBへの書き込み

このパターンを適用するためには、クラスとテーブル構造が一致している必要があると書かれています。
具体的には、クラスのフィールドが、テーブルのカラムと対応している形です。

これは、「クラス設計の表現をDBの表現力に合わせて抑える必要がある」とも言えます。
クラスの実装場所であるオブジェクト指向のパラダイムでは、継承やCollectionなど、様々なデザインパターンが知見として蓄積されています。
このような表現力が高い実装方法を採ると、クラス構造とテーブル構造が乖離してしまうので、クラス設計は素朴なテーブル構造に合わせる必要があるという感じです。

表現力が高い、複雑なクラス設計を使いたい場合は、Active Recordパターンではなく、Data Mapperパターンを採用すると良いと書かれています。

https://bliki-ja.github.io/pofeaa/DataMapper/

ということで、なぜActive Recordがシンプルなドメインモデルに適しているかは、次のようにまとめられます。

  • クラスとDBのテーブルが密に結合しているので、クラス構造とテーブルの構造が一致している必要がある
  • テーブルの表現力は限られているので、クラス設計も自ずとシンプルにする必要がある
  • よって、複雑なクラス構造を必要としない、シンプルなドメインモデルに適している

次に、メリットの「わかりやすさ」について。
Active Recordを採用している場合、Domain Layerの開発者がDBの読み書きをするときは、Active Recordオブジェクトのfind, save, destroy といったシンプルなインターフェースを使うことになります。
ですので、開発者はSQLに熟知していなくても簡単にDBの操作をすることできます。
また、クラスとテーブルの構造が一致しているので、オブジェクトがどのようにテーブルに保存されるかを想像するのは簡単ですし、逆に、テーブルを見てどのようなクラス構造になっているかを想像するのも簡単です。
シンプルでわかりやすいというのは、システムをメンテナンスしていく上でとても大きなメリットですよね。

この「わかりやすさ」はActive Recordパターンを採用しているRailsにも通ずるところがありそうで、また、人気の理由にもなっていると思うので、個人的には納得感があります。
わかりやすさの反面、「複雑なドメインモデルを扱いにくい」という本質的な課題があると認識しておくと、フレームワークの採用時や、運用していく中でシステムが複雑になってきてしまったときの対応を考える際などに、役に立つかもしれません。

前述したように、B2Cのシステムはドメインモデルがシンプルになる傾向があり、dev.toもまたシンプルなドメインモデルと捉えると、
dev.toの実装はActive Recordパターンを採用しているRailsのActiveRecordが上手くはまっているケースだと捉えることができるかもしれません。
(実際に開発している方々がどのように感じているかはわかりませんが。。)

ということで、記事の本題からは少し外れてしまいましたが、Active Recordについて見てきました。
次は、本題のServiceクラスと関係がありそうな、Service Layerを見てみます。

Service Layer

https://bliki-ja.github.io/pofeaa/ServiceLayer/

アプリケーションの境界をサービス層を使って定義する。サービス層は利用可能な操作を定め、各操作へのアプリケーションレスポンスを取りまとめる。

PofEAAのService Layerは、dev.toのServiceクラスと、似ている部分と異なる部分があります。
似ている部分は、「Domain Layerとクライアントの間のインターフェースの役割」です。
異なる部分は、Service Layerは名前の通り、Layer(層)として、Domain Modelとクライアントの間に実装されるものですが、dev.toではこのような明確なLayer(層)はありません。
それぞれについて少し掘り下げていきます。

Domain Layerとクライアントの間のインターフェースの役割

Service Layerは、Domain Layerとクライアントに間に位置していて、クライアントから求められるDomain Layerに対するニーズに応える役割を担います。
Service Layerは、クライアントのユースケースに応じて、Domain Modelのオブジェクト群を制御する処理を行い、また、必要に応じてトランザクション処理やセキュリティ処理も行います。

このような役割の層を設けることで、クライアント側にドメインロジックが漏れることを抑制できます。
ドメインロジックがクライアントに漏れると、ドメインロジックの再利用性が下がってしまいます。
ドメインロジックが再利用しにくくなると、異なるクライアントや同クライアントの別のアクションにドメインロジックに関する処理が重複してしまいがちになり、メンテナンスしにくい形になってしまいます。

この、「クライアントのユースケースに応じた、再利用性の高いドメインロジックを提供する役割」はdev.toのServiceクラスにも通ずるところがありそうです。
例えば、前述したServiceクラスUsers::Deleteはユーザー退会時の処理が実装されていました。
このクラスは、クライアントから「ユーザーを削除する処理」に関するドメインロジックを隠蔽しています。
「ユーザーを削除する処理」は単にUserを削除するだけでなく、関連する様々なModelを適切に処理をする必要があり、なかなか大変な仕事です。
このような処理がクライアントに散らばってしまうと管理が大変ですが、Serviceクラスとして再利用性の高い形で実装されているので、管理しやすい形になっています。

再利用の具体例として、管理画面からユーザーを削除する機能があり、この処理も同様に「ユーザーを削除する処理」のドメインロジックが必要になります。
管理画面からユーザーを削除する処理は、ServiceクラスModerator::DeleteUserに実装されていますが、内部では、ServiceクラスUsers::Deleteに処理を委譲しています。
もし、「ユーザーを削除する処理」がクライアント(Controller)に直接書いていたら、このような処理の委譲は難しくなっていたでしょう。

以上、dev.toのServiceクラスとService Layerが似ている部分を掘り下げてみました。
次は、異なっている部分、層の有無について掘り下げてみます。

Layer(層)の有無

Service Layerは名前の通り、層(Layer)として、クライアントとDomain Modelの間に位置するものですが、dev.toではこのような明確な層はありません。
dev.toでは、クライアント、例えばControllerやViewから直接Modelにアクセスできます。
PofEAAではService Layerを設けた場合は、クライアントはService Layerを通してDomain Modelにアクセスする必要があると書かれています。
また、エンタープライズアプリケーションの実装は、CRUD系の退屈な処理が多くなる傾向があると書かれていて、当然このような処理も全てService Layerを通すことになるので、結果としてService Layerのコードの多くはこのような退屈なものになってしまいがちとも書かれています。

Railsでこのような明確な層を設ける実装は、私自身はみたことが無く、おそらくあまり一般的な設計ではないのかなとは思いますが、実現すること自体は可能だと思います。
このような明確な層はあった方がいいのでしょうか?無い方がいいのでしょうか?

PofEAAでは、Service Layerを設ける背景として、複数のクライアントが存在するケースの話をしています。

クライアントはそれぞれ別の目的を持っているけど、Domain Modelに対するデータアクセスや処理は共通するものも多くなる傾向がある。
このような共通する処理は、複数のリソースを制御するトランザクション処理を伴う複雑なものもある。
こういった複雑な処理がクライアント側のコードとして実装されてしまうと、クライアント間で多くの重複するコードができてしまう。

このような文脈で、複数のクライアントに対するインターフェースとして、Service Layerを設けることの意義が語られています。

一方で、dev.toのようなWebアプリケーションの場合、複数のクライアントを意識することがあまり無いように思いました。
クライアントとしてWebインターフェース(Controller)の存在感がとても大きいですよね。
クライアントとしてスマホアプリが存在する場合でも、Webのインターフェースを通して通信することが一般的だと思います。

ですので、Railsを含むWeb Application Frameworkでは、Controllerに相当する部分に、ドメインロジックが漏れてしまうことを問題視することが少ないように思います。
このように、明確な層としてのService Layerを設けることの恩恵が感じづらく、また、層を設けるコストは変わらず存在するので、結果として、層を設けることが魅力的に感じないのでは無いかと思いました。
逆に、Webインターフェース以外に存在感のあるクライアントがいるようなケースでは、このような層を設けるなどの工夫が必要になってくるのではないかと思いました。

Service Layerに関してまとめますと、

  • Domain Layerを、Domain Modelとクライアントのユースケースに応えるインターフェースとしての役割が強い部分の2つに分割するメリットは大きそう。dev.toでもDomain LayerをModelとServiceクラスに分割して管理していると捉えることもできる
  • dev.toを含む一般的なRails開発においては、Service Layerとして、Domain Modelとクライアントの間に明確な層を設けるメリットは感じにくい。複数のクライアントが存在する場合に層を設けることのメリットが感じやすいけど、多くのWeb開発では、クライアントとしてWebインターフェースの存在感が強いから

Domain-Driven Design (DDD)

次はDDDと照らし合わせて考察してみます。
DDDはドメインモデルを中心に添えた、実践的な開発手法について書かれている本です。
「ドメインとは何か」、「モデリングとは何か、なぜ重要なのか」から始まり、ドメインモデルの作り方、継続的な改善方法、コミュニケーションへの活用方法、実装への反映方法など、ドメインモデルに関する話題が多岐に渡って書かれています。

その中で、ドメインモデルをオブジェクト指向のパラダイムの実装で表現する際の1つのパーツとして、「Service」が紹介されています。
以下、Serviceの説明を簡単にまとめてみました。

  • ドメインモデルをオブジェクト指向のパラダイムで表現する上で、「もの」を表すオブジェクトとしてEntityとValue Objectがある。(この2つに関しては詳しく説明しません。検索するとわかりやすい記事が色々と見つかると思います)
  • ドメインモデル内の「こと」は、「もの」を表すオブジェクトの「振る舞い」として実装するのが自然で、一般的。
  • しかし、中には「もの」の「振る舞い」として表現するのには不自然な「こと」がある。
  • このような「もの」の「振る舞い」には適さないけど、ドメインモデル内では重要な「こと」はServiceというオブジェクトとして表現すると良い。

これは、dev.toのService Objectsの説明に似ていますね。
Service Objectsの説明ページから再度引用:

サービスオブジェクトは、ビジネスプロセス、又はユーザーインタラクションをカプセル化した、Plain Old Ruby Objects (POROs)です。

オブジェクト指向は名前の通り、オブジェクト(もの)を中心として考える思想なので、オブジェクト指向の側面が強い言語であるJavaやRubyでは、「もの」の「振る舞い」に関しては自然な形で表現できますよね。
一方で、このようなオブジェクト指向的な言語では、「もの」の「振る舞い」以外の「こと」を表現するぴったりとして方法が無いように思います。
なので、Plain Old Java ObjectやPlain Old Ruby Objectsのような、いわゆるオブジェクト指向のパラダイムでは少し違和感のある形で実装している、と捉えることができるように思いました。

また、Serviceを導入することで、「ドメインモデルと実装を一致させる」ことができると書かれています。
ドメインモデルをより本来の形でオブジェクト指向の実装へ落とし込むことができる
= ドメインモデルと実装を一致させることができる

ということですね。

「ドメインモデルと実装を一致させる」ことの重要性は、DDDの中で一貫しか書かれていることなのですが、端的に説明するのが難しいところでもあります。
ざっくりトライしてみますと、、

  • 実装とドメインモデルが一致していると、実装から「システムの本質」が読み取れるようになる。
  • 「システムの本質」とは、システムが「どのような課題」を「どのような方法」で解決しているか
  • 実装から「システムの本質」を読み取れるようになると、開発者はシステムの問題領域であるドメインに対する理解が深まり、ドメインエキスパートとより深いコミュニケーションが取れるようになる
  • 開発者とドメインエキスパートとの深いコミュニケーションは、より良いドメインモデルを構築していく上で大事なこと

うまく説明できているとは思えませんが、「ドメインモデルと実装を一致させる」ことの重要性の雰囲気が伝わっていてくれればと思います。。
というわけで、Serviceは「ドメインモデルと実装を一致させる」上で、重要なパーツである、という感じでした。

DDDのServiceがどのようなものかを把握でき、また、dev.toのServiceクラスにも通ずるものだと解ったところで、3点ほど補足してみようと思います。

1. いわゆるDDDを採用した開発以外でも活用できる

Serviceは、いわゆるDDDを採用しているプロジェクト以外でも活用できます。
DDD自体は、ドメインモデルを中心に添えたプロジェクトの体系的な知識が書かれている本ですが、紹介されている細かいテクニック等は単独で使うことができます。
特にServiceは特別なクラスを継承しないプレーンオブジェクトですし、導入する障壁は低いと思います。

2. 振る舞いを安直にServiceとして実装しない

Serviceの導入の障壁は低そうに思える一方で、本書に次のような記述があります:

ドメインモデル内の「こと」を安直にServiceとして実装してしまうと、EntityやValue Objectの振る舞いを引き剥がすことになってしまう

このようにしてしまうと、本来EntityやValue Objectの振る舞いとして実装すべきことが、Serviceの実装に散らばってしまい、再利用性の低い、管理しにくいコードが出来上がってしまいます。

オブジェクトの責務を考慮しながら、どのオブジェクトに振る舞いを割り当てるかを考えるのは簡単ではないですし、ドメインモデリングのスキルや経験を要することだと思います。
また、時には既存のクラス構成に手を入れる必要もあったり、なかなか根気のいる作業だとも思います。

一方で、「こと」をプレーンオブジェクトに実装することは、比較的簡単だと思います。
「こと」に関する処理を手続き的にザーッと書いてしまえば、動作するコードを簡単に実装できますし、ドメインモデリングに関するスキルも必要としません。

人間楽な方に流れがちなので、こういった注意点があることは意識しておいたほうが良さそうです。

3. クライアントへのインターフェースとしての側面

これまでの話はドメインモデル内に閉じた内容で、「ドメインモデルと実装を一致させるため」の役割としてのServiceについて話してきました。
ドメインモデルの実装は、当然ですが外部のクライアントから使われることで初めて価値を出すことができます。
したがって、ドメインモデルの実装とクライアントが接するインターフェースの設計はとても大事になってきます。

PofEAAのService Layerのところでも同じような話をしました。
Service Layerはまさに、クライアントとのインターフェースの層として存在していて、クライアントにとって扱いやすい形で、Domain Layerのインターフェースの役割を担うものでした。
DDDのServiceもこれと同じような特性を持ち、本書では「粒度(Granularity)」という言葉を使って説明しています。
本書の説明を簡単にまとめてみます。

  • ドメインモデル内の「こと」の表現としてのServiceについて強調してきたけど、Serviceには、Domain Layerのインターフェースの粒度を制御する、つまり、クライアントとEntities, Value Objectsをdecoupling(分離)させる意味でも価値がある
  • EntityとValue Objectは細かい粒度のオブジェクト。 Serviceはそれらの細かい粒度の制御して特定の処理を行う、中くらいの粒度のオブジェクト
  • 細かい粒度のドメインオブジェクトをクライアントから直接制御してしまうと、ドメインロジックがクライアントに漏れてしまう
  • 適切なServiceを導入して、クライアントとDomain Layerのやり取りを、Serviceを通した簡潔なものにすることで、Domain Layerとクライアントのインターフェースをいい感じに管理できるようになる

dev.toの実装はまさにこのような形になっていましたね。
細かい粒度のModelがあり、それらを制御する中くらいの粒度のServiceがあり、クライアントであるControllerやWorkerはServiceの簡潔なインターフェースを通してDomain Layerとやり取りをしていました。

以上、DDDのServiceについて見てみました。
簡単にまとめますと、、

  • ドメインモデルの表現には、「もの」の「振る舞い」には属さないけど重要な「こと」がある
  • Serviceはドメインモデル内の「こと」をオブジェクト指向のパラダイムで表現する手法
  • ServiceはいわゆるDDD開発を採用していないプロジェクトでも活用できるが、安易に活用するのは良くない
  • Serviceをドメインモデルの外から見ると、ドメインロジックを隠蔽するインターフェースとしての側面を持つ

考察まとめ

dev.toのServiceクラスの実装は、DDDで紹介されていた「ドメインモデル内の「こと」をオブジェクト指向のパラダイムで表現する」オブジェクトとしてのServiceと重なる部分がかなり大きかった。
この方法のメリットは大きく2つあった:

  1. ドメインモデル内の「こと」を自然に表現できる
    「こと」をEntityやValue Objectの振る舞いに強引に実装してしまうと、オブジェクトの役割、責務がぼやけてしまい、また、ドメインモデルの表現と実装が乖離してしまう。
    ドメインモデルと実装が一致していることは、長期的にシステムを開発していく上でとても大事なこと。

  2. Domain Layerとクライアントの間のインターフェースとしての役割を果たせる
    Serviceがないと、EntityやValue Objectのような細かいドメインオブジェクトを制御する処理がクライアント側に書かれがちになる。
    そうなると、ドメインロジックが再利用性が低い形でクライアント側に散らばってしまうので、メンテナンスが大変になる。


また、dev.toでは、PofEAAのService Layerのように、ServiceをLayer(層)の形では実装していなかった。
設計的な話なので、システムによって色々な意見がありそうですが、Railsの開発において、層を設けることの恩恵を感じづらい理由として2つありそう:

  1. Railsも含む、Web Application Frameworkでは、Domain Layerのクライアントとして、Webインターフェース(Controller)の存在感がかなり大きいため、Service Layerを設けることのメリットである「複数のクライアントに共通の処理を提供する」ことの恩恵が感じづらい。
  2. Railsをデフォルトのシンプルな構成にしておくと、エコシステムの恩恵が受けやすい(リッチなモデルをcontrollerから直接使う)。そしてエコシステムが強い。

層を設けることの恩恵が少ないと、層の導入することのコスト(管理するコード量が増えるなど)と見合わなくなってしまいそう。

おわりに

この記事を書くきっかけは、「OSSからRailsをゆったり学ぶ」というコンセプトの本を書いているときに、「Serviceクラスについて雰囲気で使っていて自分の言葉で説明できない。。」と思ったところからでした。
そこで、今まで気になっていたけど読んでいなかったPofEAAとDDDを読みながら、Serviceクラスについて考えてみた形ですが、本の内容に感動して当初の予定より時間をかけてしまったので、せっかくだからまとめて記事にしてしまおうと思いました。

結果として、自分の中でServiceクラスに対する理解も深まり、またそれ以外にも設計や開発への取り組み方、考え方など色々学ぶところが多く、開発に対するモチベーションもかなり高まりました。
ですので、PofEAA、DDDどちらも2002年ごろに出版された古い本ではありますが、まだ読んでいない方にはおすすめしておきます!
2002年の時点で、ここまで言語化して整理されていたことにシンプルに感動しました。
一方で、20年前に書かれた本でもあるので、現在の開発状況と色々変わってきている部分を多そうで、この辺りの差分が現状どのように認識されているか、詳しい人たちがどう考えているかが気になりました。

以上、長くなってしまいましたが、最後まで読んでいただいてありがとうございました!

「OSSからRailsをゆったり学ぶ」というコンセプトの本は、亀ペースですが引き続きコンテンツを作成中です。
試作品はすでに公開しているので気になる方は是非読んでみてください!

https://zenn.dev/kitabatake/books/learn-from-devto-data-update-script

この記事に贈られたバッジ