😇

Ruby on Rails×クリーンアーキテクチャを1年半に渡って本番運用して得られた学び

2023/12/18に公開

この記事はPharmaXアドベントカレンダー2023の18日目の記事です。

https://qiita.com/advent-calendar/2023/pharma-x

こんにちは、エンジニアの加藤(@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では、積極的な発信が文化となっていて、下記のような勉強会もい行います。ご興味のある方は是非ご参加ください!

https://yojo.connpass.com/event/302642/

PharmaXテックブログ

Discussion