Ruby on Rails×クリーンアーキテクチャを1年半に渡って本番運用して得られた学び
この記事はPharmaXアドベントカレンダー2023の18日目の記事です。
こんにちは、エンジニアの加藤(@tomo_k09)です。PharmaXの薬局DX事業部でバックエンドの開発を担当をしています。
薬局DX事業部で開発しているサービスのバックエンドはRuby on Railsで書かれているのですが、Ruby on Railsで一般的なMVCアーキテクチャではなく、クリーンアーキテクチャをベースとしたアーキテクチャを採用し、1年半に渡って本番運用しています。
そこでこの記事では、あえてRails wayを離れてアーキテクチャを導入するに至った背景や、このアーキテクチャを本番運用してみて得られた学びについて、具体的なコード例を交えつつ紹介します。
Rails wayから離れてクリーンアーキテクチャベースのアーキテクチャへ移行した理由
クリーンアーキテクチャベースのアーキテクチャを採用する前は、私たちもMVC+Service層というRailsでは一般的なアーキテクチャを採用していました。業務ロジックは基本的にModelに書いて、複数モデルにまたがる処理はService層というようなアーキテクチャです。
しかし、以下のような課題が出てきました。
- 1000行越えのクラスが存在していて可読性が低い
- 同じレイヤーのクラスが他の同じレイヤーを呼んでいて明確なルールが存在しておらず、バグを生みやすい
いま思い返すと、Rails wayを離れる理由としては少し弱いのですが、これらの課題を解決するべく、クリーンアーキテクチャベースのアーキテクチャを採用するに至りました。
Rails wayを離れてクリーンアーキテクチャベースにしてどうだったか
実際のアーキテクチャを紹介する前に、クリーンアーキテクチャベースのアーキテクチャを導入した感想を述べたいと思います。
メリット
▪️可読性が高い
1ファイルあたりのコード量がかなり少なくなり、ファイル単位での可読性が上がりました。どの処理をどこに書けば良いかが分かりやすいレイヤーが細かく分かれているので「この処理はこのレイヤーに書けば良いんだな」ということが分かりやすくなりました。
▪️副作用を限定しやすい
リクエスト処理、ビジネス処理、DB処理の境界を明確につけることによって、副作用を限定しやすくなりました。
デメリット
▪️Railsの良さを活かしきれない
Railsとクリーンアーキテクチャは、そもそも目的が異なるため相性があまり良いとは言えません。というのも、思想的には真逆だからです。RailsはRails wayに乗って実装することにより開発コストが最小になることを目指しています。一方、クリーンアーキテクチャはフレームワークに関係なく適用できる普遍的な設計手法です。
たとえば、Active Recordを使えば2〜3行くらいで済むコードが、クリーンアーキテクチャベースに沿って書いた場合、10行以上書く必要になるなんてことが多々ありました。(なんなら別のファイルをまたいで処理を書くこともあるのでもっと多苦なっているかもしれない)
▪️キャッチアップに時間がかかる
各レイヤーごとに役割が明確に決められているのでルール違反は起こりにくいというメリットはあるものの、一般的なRailsのアーキテクチャではないため、各レイヤーのルールを覚えてそれに沿って実装できるようになるまでに少し時間がかかってしまいます。実際、業務委託の方に入ってもらった際、「ぜんぜんRailsっぽくないですね。。」とかなり戸惑っている様子でした。
薬局DX事業で採用しているアーキテクチャの紹介
では薬局DX事業で採用しているアーキテクチャを紹介します。
ディレクトリ構成
appディレクトリ配下以外は、至って普通のRailsプロジェクトなので、今回はappディレクトリの構成にフォーカスして紹介をします。
app/
│ ├ adapters/
│ │ ├ push_notify_adapter
│ │ │├ entities
│ │ │├ repositories
│ │ │├ factories
│ │ │├ usecases
│ │ ├ push_notify_adapter.rb
│ ├ context
│ │ ├ payment/
│ │ │ ├ entities
│ │ │ ├ errors
│ │ │ ├ repositories
│ │ │ ├ factories
│ │ │ ├ usecases
│ │ ├ shipment/
│ │ │ ├ entities
│ │ │ ├ errors
│ │ │ ├ repositories
│ │ │ ├ factories
│ │ │ ├ usecases
│ ├ controllers/
│ │ ├ requests
│ │ ├ responses
│ ├ models/
│ ├ transfer/
処理の流れ
処理の流れは以下のシーケンス図のようになっています。
今回は薬剤師が患者さんにメッセージを送る機能を例にして説明します。
このメッセージ送信機能では、メッセージが送信されるとpush通知も送られます。
controllers
APIのエンドポイントに対応したActionを定義します。
呼び出せるのは、
- 受け取ったリクエストのvalidation・castを行うRequestクラス
- フロントエンドへ返却する値に変換するResponseクラス
- 業務ロジックであるUseCaseクラス
です。
class MessageController < ApplicationController
def send_message
request = Message::Requests::SendMessageRequest.new(params:)
message_entity = Message::UseCases::SendMessageUseCase.execute(patient_id: request.patient_id, message: request.message)
response = Message::Responses::SendMessageResponse.new(entity: message_entity )
render json: response.covert_to_json, status: :ok
rescue Message::InvalidMessageError => e
raise BaseErrors::BadRequest.new(error_message: e.error_message, error_code: e.error_code)
end
end
requests
パラメータのValidation、Castする役割を担います。クライアント責のパラメータvalidationエラーは400系のレスポンスを返します。
module Messages
module Requests
class SendMessageRequest
extend T::Sig
sig { returns(Integer) }
attr_reader :patient_id
sig { returns(String) }
attr_reader :message
sig { params(params: ActionController::Parameters).void }
def initialize(params:)
params = params.permit(:message)
raise Message::InvalidMessageError if params[:message].size <= 0
@patient_id = params[:id].to_i
@message = params[:message]
end
end
end
end
responses
UseCase層で生成したオブジェクトをOpen APIで定義したResponseオブジェクトに変換する役割を担います。
# typed: strict
module Message
module Responses
class SendMessageResponse
extend T::Sig
sig { params(entity: Message::Entities::MessageEntity).void }
def initialize(entity:)
@message_entity = entity
end
sig { returns(T::Hash[T.untyped, T.untyped]) }
def convert_to_json
{
id: @message_entity.id,
message: @message_entity.message,
}
end
end
end
end
context
私たちのサービスでは配送・決済・メッセージ送信などの機能単位でcontext配下にディレクトリを切っています。たとえば、配送機能に関するコードはcontext配下のshipmentディレクトリに書くというイメージです。
配送コンテキスト内の処理は、決済コンテキストからは呼び出せないので、コードを改修した際の影響範囲を限定することができます。
use_cases
アプリケーションの特定の機能を実行する業務ロジックです。1ファイルに1つの業務ロジックだけを書いていて、publicなメソッドはexecuteメソッドのみです。
呼び出せるのは
- Repository
- Factory
- Adapter
です。
# sampleコード
module Message
module UseCases
class SendMessageUseCase
extend T::Sig
class << self
sig { params(patient_id: Integer, message: String).returns(Message::Entities::MessageEntity) }
def execute(patient_id:, message:)
message_entity = Message::Factories::MessageFactory.build_new_message_entity(patient_id:, message:)
Message::Repositories::MessageRepository.create(entity: message_entity)
PushNotificationAdapter.notify(patient_id:)
message_entity
end
end
end
end
entities
ビジネスルールを実現する役割を担います。たとえば、メッセージは1文字以上でなければならないというルールがある場合は、entityでそのルールを書きます。
レコードをcreateする場合はid-1でEntityを生成し、Repositoryで永続化した際にIDを振るようにしています。
# sampleコード
module Message
module Entities
class MessageEntity
extend T::Sig
sig { returns(Integer) }
attr_reader: id, patient_id
sig { returns(String) }
attr_reader: message
sig { params(id: Integer, patient_id: Integer, message: String).void }
def initialize(id:, patient_id:, message:)
@id = id
@patient_id = patient_id
@message = message
raise Message::InvalidMessageError if @message.size <= 0
end
end
end
end
factories
entityの生成をカプセル化する役割を担います。
# sampleコード
module Message
module Factories
class MessageFactory
extend T::Sig
class << self
sig { params(id: Integer, patient_id: Integer, message: String).returns(Message::Entities::MessageEntity) }
def build_new_message_entity(id:, patient_id:, message:)
Message::Entities::MessageEntity.new(
id: -1,
patient_id:,
message:
)
end
sig { params(model: Message).returns(Message::Entities::MessageEntity) }
def build_by_model(model:)
Message::Entities::MessageEntity.new(
id: model.id,
patient_id: model.patient_id,
message: model.message
)
end
end
end
end
end
repositories
entityの永続化と復元のためのインターフェースです。Repositoryの中で複数のテーブルにまたがる処理を書くことで、DBの関心事を分離します。
呼び出せるのは
- ActiveRecord
- Transfer
- Factory
です。
# sampleコード
module Message
module Repositories
class MessageRepository
extend T::Sig
class << self
sig { params(entity: Message:Entities::MessageEntity).returns(Message::Entities::MessageEntity) }
def create(entity:)
ActiveRecord::Base.transaction do
message = Message.create(patient_id: entity.patient_id, message: entity.message)
PushNotification.create(patient_id:)
Messages::Factories::MessageFactory.build_by_model(
id: message.id,
patient_id: message.patient_id,
message: message.message
)
end
end
end
end
end
adapter
外部サービスを使った機能を実現します。今回でいうと、薬剤師が患者さんにメッセージを送った際のLINEへのpush通知機能などがそれにあたります。
contextと同様に、adapterの直下にも
- entities
- factories
- use_cases
- repositories
というディレクトリを切っていて、それぞれの役割はcontextと同じです。
なぜcontextと同じディレクトリ構成なのにadapterはcontext内に配置されていないかというと、adapterはcontextをまたいで呼び出されることがあるためです。
もう1つadapterとcontextで違うことは、adapterのuse_caseはラップしてcontextのuse_caseから呼ばれるということです。下のサンプルコードは外部のインターフェースの提供、use_caseはビジネスロジックを担当という役割を担っています。
# sampleコード
module PushNotifyAdapter
class << self
extend T::Sig
sig { params(patient_id: Integer).void }
end
def notify(patient_id:)
PushNotifyAdapter::UseCases::NotifyMessageUseCase.execute(
patient_id:
)
end
end
end
transfer
外部へ疎通するための処理の複雑性を吸収するラッパーです。ラップすることによって、ライブラリのバージョンアップやAPIの仕様変更などの影響をTransferオブジェクト内に限定することができます。Repositoryを通してTransferを呼び出します。
# sampleコード
class LineTransfer
extend T::Sig
class << self
sig { params(line_id: String,line_message: String).void }
def send_line_message(line_id:, line_message:)
# LINE APIを叩く処理
# 引数にline_idと送りたいテキストメッセージを渡す
end
end
end
models
モデルはRailsのモデルのことです。
一般的にはmodelにロジックを書くかと思いますが、modelにロジックは何も書いていませんが、ORMとしてだけ使っています。
クリーンアーキテクチャベースのアーキテクチャを本番運用して得た学び
クリーンアーキテクチャベースのアーキテクチャで本番運用してみての反省点は、Railsを使うのであればRailsの良さを活かしつつ課題を解決できないかもっと考えるべきだったということに尽きます。
先ほども書いた通り、もともとクリーンアーキテクチャベースのアーキテクチャを採用したのは、
- 1000行越えのクラスが存在していて可読性が低い
- 同じレイヤーのクラスが他の同じレイヤーを呼んでいて明確なルールが存在していない。その結果、副作用を限定できず、バグを生みやすい
という理由からでした。
しかし、上記のような理由であればRails Wayから外れることでしかこれらの問題を解決できなかったかというとそうではないでしょう。むしろRailsの良さをもっと活かしつつ、解決できたはずです。
今からやり直すのであれば、Railsの特徴である開発スピードの速さをもう少し活かせるようなアーキテクチャを採用すると思います。
- 業務ロジック→UseCase
- 更新系ロジック→WriteLogic
- 参照系ロジック→ReadLogic
このような構成で、それぞれpublicなメソッドは1つだけにします。
そして、依存関係は以下のようなイメージです。
- Controller→UseCase or ReadLogic or AvticeRecord(idを渡してfindするくらいであれば直接呼び出せる)
- UseCase→WriteLogic
- WiteLogic、ReadLogic→Active Record
これくらいの構成であれば、Railsの良さをそこまで殺さずに、1000行越えのクラスが存在していて可読性が低いなどの課題も解決できるかつルールもシンプルでチーム内の認識も揃いやすい良い塩梅になるのではないでしょうか。
レイヤーを細かく分けるアーキテクチャも個人的には好きなのですが、キャッチアップコストや開発スピードなどを考慮すると少しやり過ぎな気がするというのが正直な感想です。仮に今回ご紹介したように細かく分けるのであれば、最初から分割するのではなく、アプリケーションが大きくなってきたタイミングで、段階的に分けていくかと思います。
終わりに
今回はRails×クリーンアーキテクチャで本番運用した学びについて書きました。
参考になれば幸いです。
PhramaXでは、積極的な発信が文化となっていて、下記のような勉強会もい行います。ご興味のある方は是非ご参加ください!
PharmaXエンジニアチームのテックブログです。エンジニアメンバーが、PharmaXの事業を通じて得た技術的な知見や、チームマネジメントについての知見を共有します。 PharmaXエンジニアチームやメンバーの雰囲気が分かるような記事は、note(note.com/pharmax)もご覧ください。
Discussion