個人開発しているRails製のマイクロブログ「Mewst」の少し独特な実装方針 (バックエンド編)

最近個人でコツコツ作っていたMewst (ミュースト) というマイクロブログサービスを公開しました。

[マイクロブログ「Mewst \(ミュースト\)」のベータ版をリリースしました \| shimba\.co](https://shimba.co/2024-04-27-mewst-beta)

Railsを使って実装していて、ソースコードも公開しています。

https://github.com/mewstcom/mewst

個人的な好みにより一般的なRailsアプリとは異なる方針で実装しているところがいくつかあるので、この記事ではそれらについてご紹介したいと思います。
個人開発でやっているものなので好き勝手やっています。

この記事を書き始めたときはバックエンドとフロントエンドどちらの方針も書こうかと思っていましたが、思っていたよりも長くなったため2つに分けることとしました。
フロントエンド編はあとで書くかもしれません。

## ルーティング定義には基本的に `match` だけを使用する

[github.com/mewstcom/mewst/config/routes.rb](https://github.com/mewstcom/mewst/blob/bc51e38774cef3fc8b8ffd31d42e4ea95c7f7e22/config/routes.rb)

以下のような感じで定義しています。

```rb
# ...
match "/@:atname",                via: :get,    as: :profile,        to: "profiles/show#call"
match "/@:atname/atom",           via: :get,    as: :profile_atom,   to: "profiles/atom/show#call"
match "/@:atname/check",          via: :post,   as: :profile_check,  to: "checks/create#call"
match "/@:atname/follow",         via: :delete, as: :profile_follow, to: "follows/destroy#call"
match "/@:atname/follow",         via: :post,                        to: "follows/create#call"
match "/@:atname/posts/:post_id", via: :get,    as: :profile_post,   to: "posts/show#call"
match "/accounts",                via: :post,   as: :account_list,   to: "accounts/create#call"
# ...
```

以下の理由から `match` を使って定義しています。

1. どんなルーティングが定義されているのかパッと見でわかるようにするため
    - `bin/rails routes` の実行結果とほぼ同じ見た目なので、`bin/rails routes` を実行する必要がないです
1. どこにルーティング定義を差し込めば良いかがすぐわかるため
    - Mewstのルーティング定義はアルファベット順にソートしています
    - ルーティング定義を追加するとき、どこに追加すると良いか判断に迷わず済みます

## 1コントローラーにつき1アクション

ルーティング定義のサンプルコードで気づかれた方もいらっしゃるかもしれませんが、Mewstではアクションごとにコントローラーを定義しています。

例えばプロフィールページのコントローラーは以下のように定義しています。

[github.com/mewstcom/mewst/app/controllers/profiles/show_controller.rb](https://github.com/mewstcom/mewst/blob/bc51e38774cef3fc8b8ffd31d42e4ea95c7f7e22/app/controllers/profiles/show_controller.rb)

```rb:app/controllers/profiles/show_controller.rb
class Profiles::ShowController < ApplicationController
  # ...
  def call
    # ...
  end
  # ...
end
```

以下の理由からこうしています。

1. 定義されているコールバックがどのアクションで実行されるのかすぐわかるようになるため
    - `before_action` などを使ってコールバックを定義するとき、`only` や `except` オプションを指定する必要がなくなるため、どのコールバックが実行されるのかすぐわかるようになります
1. コントローラー内がすっきりするため

## フォームオブジェクトとUseCaseクラスを使用する

データの検証や永続化処理でActiveRecordのモデルを直接呼ぶことはせず、フォームオブジェクトとUseCaseクラスを使用しています。

例えばポストを新規作成するとき、コントローラーでは以下のようになっています。
(簡単のため[実際の実装](https://github.com/mewstcom/mewst/blob/bc51e38774cef3fc8b8ffd31d42e4ea95c7f7e22/app/controllers/posts/create_controller.rb)とは内容が少し異なります)

```rb
class Posts::CreateController < ApplicationController
  # ...
  def call
    @form = PostForm.new(form_params)

    if @form.invalid?
      return render("posts/new/call", status: :unprocessable_entity)
    end

    result = CreatePostUseCase.new.call(
      content: @form.content.not_nil!
    )

    redirect_to post_path(result.post.id)
  end
  # ...
end
```

まずフォームオブジェクトで値の検証を行い、問題なければUseCaseクラスを呼び出して保存します。

フォームオブジェクトは `ActiveModel::Model` と `ActiveModel::Attributes` を使用して定義しています。

```rb
class PostForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :content, :string

  validates :content, length: {maximum: 160}, presence: true
end
```

UseCaseクラスはトランザクションを管理するためのクラスにしており、以下のように定義しています。

```rb
class CreatePostUseCase
  Result = Struct.new(:post)

  def call(content:)
    post = Post.new(content:)

    ActiveRecord::Base.transaction do
      post.save!
      post.create_post_link!
      FanoutPostJob.perform_later(post_id: post.id)
    end

    Result.new(post)
  end
end
```

あらかじめフォームオブジェクトでデータの検証を行っているので、UseCaseクラスでは検証を行っていません。もし例外が起きたらそのままエラーとなって終了します。

また、UseCaseクラスの中でロジックを書くことはせず、モデルに定義したメソッドを呼び出すだけにしています。

以下のような理由からこうしています。

1. やることを別のクラスに分けることでコードの見通しを良くするため
    - 検証はフォームオブジェクトで、トランザクションを伴う永続化処理はUseCaseで、その他ロジックなどの処理はモデルで、という感じでクラスごとに役割分担しているので、クラスの責務が明確になります
1. プロジェクト内にどんな永続化処理があるのかパッと見でわかるようにするため
    - [app/use_cases/](https://github.com/mewstcom/mewst/tree/bc51e38774cef3fc8b8ffd31d42e4ea95c7f7e22/app/use_cases) ディレクトリを見ればどんなことをするアプリなのかがわかります
    - モデルに定義した永続化メソッドをコントローラーなどで直接呼び出すことは基本的にしていません
1. トランザクションを張る場所を統一するため
    - MewstではUseCaseクラス以外の場所でトランザクションを明示的に定義していません
1. コールバックをなるべく使わないようにするため
    - トランザクションの管理をUseCaseクラスで行っているため、コールバックで行いたいような事前・事後処理はUseCaseクラスの中で呼び出すだけで済むようになります

## Sorbetを使う

型は欲しいのですが、個人的に型定義は実装の近くに表示されているほうが好みなので、[Sorbet](https://sorbet.org/)を使って型定義しています。

上のUseCaseクラスなどのコード例では簡単のため型定義を書いていませんでしたが、実際はこんな感じで書いています。

[github.com/mewstcom/mewst/app/use_cases/create_post_use_case.rb](https://github.com/mewstcom/mewst/blob/bc51e38774cef3fc8b8ffd31d42e4ea95c7f7e22/app/use_cases/create_post_use_case.rb)

外部ライブラリなどの型定義の生成には[Tapioca](https://github.com/Shopify/tapioca)を使用していて、[lib/tasks/sorbet.rake](https://github.com/mewstcom/mewst/blob/bc51e38774cef3fc8b8ffd31d42e4ea95c7f7e22/lib/tasks/sorbet.rake) というRakeタスクを定義して各種コマンドを実行し型定義を更新しています。

型定義ファイルを更新し忘れることがあったりするので、GitHub Actionsで差分をチェックしていたりもします。
また、Dependabotがgemのアップデートをしたときは型定義ファイルを更新するようにもしています。

[github.com/mewstcom/mewst/.github/workflows/lint-and-test.yml#L18-L45](https://github.com/mewstcom/mewst/blob/77979e3f9fa77d2897e1d6610b50de1c8b710683/.github/workflows/lint-and-test.yml#L18-L45)

最初からSorbetを導入しているプロジェクトなので、型を書くつもりのクラスには `typed: strict` を設定しています。
逆に型を書く気がないクラスもあり、コントローラーやテストファイルなどは `typed: true` や `typed: false` としています。

## RSpecを愚直に書く

テストを書くときはRSpecを使っています。最近は以下のように書いています。

```rb
RSpec.describe DeletePostUseCase do
  context "正常系" do
    def setup_data
      post = FactoryBot.create(:post)

      {post:}
    end

    it "ポストが削除できること" do
      setup_data => {post:}

      # 実行前はポストが存在するはず
      expect(Post.count).to eq(1)

      # 実行
      DeletePostUseCase.new.call(post:)

      # 実行後はポストが存在しないはず
      expect(Post.count).to eq(0)
    end
  end
end
```

方針としては以下のようになります。

1. `describe`, `context`, `before`, `after`, `it` だけ使用する
    - `subject` や `let/let!` などは使わない
1. テストに必要なデータの用意などは `context` の中だけで行う
1. `context` をネストしない
1. `expect` にブロックを渡さない
    - `expect {}.to change()` みたいな書き方をしない

なるべく普通のRubyのコードっぽくなるように心がけています。

この方針で書くとテストコードがすごく冗長になりますが、あとからテストを見たときに何をやっているのかわかりやすく、テストケースを追加するときも楽になる気がしています。

---

以上がMewstを開発しているときの実装方針でした。

レールから外れまくりではありますが、個人的にはこの方針で実装するとわかりやすいRailsアプリになって好みです。
(仕事では普通にやっています)

何かの参考になれば幸いです :pray: