6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Fizzy: Vanilla Rails Project

Last updated at Posted at 2025-12-24

1. なぜ今、Fizzyを読むべきなのか

147行のクラスが22個のConcernを統合し、プロダクションレベルの機能を実現している。

この一文を聞いて、あなたはどう感じますか? Concernを22個も使っているクラスなんてあるのとびっくりするかもしれません。あるいは、ひとつのクラスに色々なものを詰め込みすぎじゃないと眉を潜めるかもしれません。しかし、そのコードを実際に読んでみると、印象は一変すると思います。

Fizzyは、37signals(Basecamp社)が開発したカンバン式プロジェクト管理ツールです。ボード、カラム、カードという構成で、チームのタスクを視覚的に管理できます。機能としては特別なものがあるわけではありません。しかし、そのコードベースは特別です。

37signalsといえば、Ruby on Railsの生みの親であるDHH(David Heinemeier Hansson)が共同創業者として知られる会社です。彼らは長年「less is more」「Vanilla Railsで十分」という哲学を掲げてきました。BasecampやHEYといったプロダクトを通じて、その思想を実践してきました。そして今、Fizzyのコードが公開されたことで、私たちは彼らの設計思想を直接学べます。

本記事では、Fizzyのコードを読み解きながら、シンプルなのに強力な設計とは何かを探っていきます。特に、Cardクラスの設計を中心に、Vanilla Railsをどのように実現されているかを見ていきましょう。

2. Fizzyはどのように構築されているか

2.1 URLパスベースのマルチテナンシー

Fizzyの最も特徴的なアーキテクチャは、マルチテナンシーの実装方法です。多くのSaaSアプリケーションはサブドメイン(acme.example.com)でテナントを分離しますが、FizzyはURLパスを使います。

https://fizzy.example.com/0123456/boards/...
                          ^^^^^^^
                          アカウントID

この実装は、Rack Middlewareで行われています。

config/initializers/tenanting/account_slug.rb
module AccountSlug
  PATTERN = /(\d{7,})/
  FORMAT = "%07d"
  PATH_INFO_MATCH = /\A(\/#{AccountSlug::PATTERN})/

  class Extractor
    def initialize(app)
      @app = app
    end

    def call(env)
      request = ActionDispatch::Request.new(env)

      if request.path_info =~ PATH_INFO_MATCH
        # URLプレフィックスをPATH_INFOからSCRIPT_NAMEへ移動
        request.engine_script_name = request.script_name = $1
        request.path_info = $'.empty? ? "/" : $'
        env["fizzy.external_account_id"] = AccountSlug.decode($2)
      end

      if env["fizzy.external_account_id"]
        account = Account.find_by(external_account_id: env["fizzy.external_account_id"])
        Current.with_account(account) do
          @app.call env
        end
      else
        Current.without_account do
          @app.call env
        end
      end
    end
  end
end

このMiddlewareが行っていることはおもしろいです。/0123456/boards/1というURLが来たとき、/0123456SCRIPT_NAMEに移動し、PATH_INFO/boards/1に書き換える。Railsから見ると、まるでアプリケーションが/0123456配下にマウントされているかのように振る舞います。

これにより、ルーティング定義は通常のRailsアプリケーションと変わりません。resources :boardsと書けば、実際のURLは/{account_id}/boardsになります。そしてCurrent.accountで、どこからでも現在のアカウントにアクセスできます。

2.2 コアドメインモデル

Fizzyのドメインモデルはシンプルな階層構造になっています。

Account (テナント/組織)
  └── Board (ボード)
        ├── Column (カラム: 進行状況を表す)
        └── Card (カード: タスク/課題)
              └── Comment (コメント)

各モデルにはaccount_idが含まれ、データの完全な分離を実現しています。面白いのはデフォルト値の設定方法です。

app/models/card.rb
class Card < ApplicationRecord
  belongs_to :account, default: -> { board.account }
  belongs_to :board
  belongs_to :creator, class_name: "User", default: -> { Current.user }
end

default: -> { board.account }という記述により、Cardを作成する際にaccountを明示的に指定する必要はありません。boardから自動的に推論されます。この宣言的なデフォルト値の設定は、Fizzy全体で一貫して使われているパターンです。

2.3 Entropy システム

FizzyにはEntropyと呼ばれるユニークなシステムがあります。これは、非アクティブなカードを自動的に延期する仕組みです。

ToDoリストが肥大化し、古いタスクが埋もれていく現象は誰もが体験すると思います。Entropyシステムは、一定期間アクティビティがないカードを自動的に脇に寄せることで、この問題に対処します。アカウントレベルまたはボードレベルで延期期間を設定でき、定期的なバックグラウンドジョブが対象カードを処理します。

app/models/card/entropic.rb
scope :due_to_be_postponed, -> do
  active
    .joins(board: :account)
    .left_outer_joins(board: :entropy)
    .joins("LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id")
    .where("last_active_at <= #{connection.date_subtract('?', 'COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)')}", Time.now)
end

3. Cardクラス - 多機能なのに147行

3.1 22個のConcern

Fizzyで最も機能が集中しているのがCardクラスです。タスク管理アプリケーションの中心となるモデルであり、以下のような機能を持ちます。

  • ユーザーへのアサイン
  • クローズ/リオープン
  • 延期/再開
  • お気に入り(ゴールド)
  • ウォッチ
  • メンション
  • タグ付け
  • 添付ファイル
  • コメント
  • 検索インデックス
  • リアルタイム更新
  • イベント記録

これだけの機能を持ちながら、Card本体は147行に収まっている。その秘密は、22個のConcernによる責務の分離だ:

app/models/card.rb
class Card < ApplicationRecord
  include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
    Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
    Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable

  belongs_to :account, default: -> { board.account }
  belongs_to :board
  belongs_to :creator, class_name: "User", default: -> { Current.user }

  has_many :comments, dependent: :destroy
  has_one_attached :image, dependent: :purge_later

  has_rich_text :description

  before_save :set_default_title, if: :published?
  before_create :assign_number

  after_save   -> { board.touch }, if: :published?
  after_touch  -> { board.touch }, if: :published?
  after_update :handle_board_change, if: :saved_change_to_board_id?

  scope :reverse_chronologically, -> { order created_at:     :desc, id: :desc }
  scope :chronologically,         -> { order created_at:     :asc,  id: :asc  }
  scope :latest,                  -> { order last_active_at: :desc, id: :desc }
  scope :with_users,              -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) }
  scope :preloaded,               -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds }

  scope :indexed_by, ->(index) do
    case index
    when "stalled" then stalled
    when "postponing_soon" then postponing_soon
    when "closed" then closed
    when "not_now" then postponed.latest
    when "golden" then golden
    when "draft" then drafted
    else all
    end
  end

  scope :sorted_by, ->(sort) do
    case sort
    when "newest" then reverse_chronologically
    when "oldest" then chronologically
    when "latest" then latest
    else latest
    end
  end

  delegate :accessible_to?, to: :board

  def card
    self
  end

  def to_param
    number.to_s
  end

  def move_to(new_board)
    transaction do
      card.update!(board: new_board)
      card.events.update_all(board_id: new_board.id)
    end
  end

  def filled?
    title.present? || description.present?
  end

  private
    STORAGE_BATCH_SIZE = 1000

    # Override to include comments, but only load comments that have attachments.
    # Cards can have thousands of comments; most won't have attachments.
    # Streams in batches to avoid loading all IDs into memory at once.
    def storage_transfer_records
      comment_ids_with_attachments = storage_comment_ids_with_attachments

      if comment_ids_with_attachments.any?
        [ self, *comments.where(id: comment_ids_with_attachments).to_a ]
      else
        [ self ]
      end
    end

    def storage_comment_ids_with_attachments
      direct = []
      rich_text_map = {}

      # Stream comment IDs in batches to avoid loading all into memory
      comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
        batch_ids = batch.pluck(:id)

        direct.concat \
          ActiveStorage::Attachment
            .where(record_type: "Comment", record_id: batch_ids)
            .distinct
            .pluck(:record_id)

        ActionText::RichText
          .where(record_type: "Comment", record_id: batch_ids)
          .pluck(:id, :record_id)
          .each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
      end

      embed_comment_ids = if rich_text_map.any?
        rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
          ActiveStorage::Attachment
            .where(record_type: "ActionText::RichText", record_id: batch_ids)
            .distinct
            .pluck(:record_id)
        end.filter_map { |rt_id| rich_text_map[rt_id] }
      else
        []
      end

      (direct + embed_comment_ids).uniq
    end

    def set_default_title
      self.title = "Untitled" if title.blank?
    end

    def handle_board_change
      old_board = account.boards.find_by(id: board_id_before_last_save)

      transaction do
        update! column: nil
        track_board_change_event(old_board.name)
        grant_access_to_assignees unless board.all_access?
      end

      remove_inaccessible_notifications_later
    end

    def track_board_change_event(old_board_name)
      track_event "board_changed", particulars: { old_board: old_board_name, new_board: board.name }
    end

    def grant_access_to_assignees
      board.accesses.grant_to(assignees)
    end

    def assign_number
      self.number ||= account.increment!(:cards_count).cards_count
    end
end

22個のConcernをincludeしているにもかかわらず、コアのコードは驚くほどシンプルです。関連の定義、スコープ、そしていくつかのメソッドだけで済みます。

3.2 Concernの設計哲学

各Concernがどのように設計されているか、代表的なものを見てみましょう。

Card::Closeable(48行)- 状態管理

app/models/card/closeable.rb
module Card::Closeable
  extend ActiveSupport::Concern

  included do
    has_one :closure, dependent: :destroy

    scope :closed, -> { joins(:closure) }
    scope :open, -> { where.missing(:closure) }

    scope :recently_closed_first, -> { closed.order(closures: { created_at: :desc }) }
    scope :closed_at_window, ->(window) { closed.where(closures: { created_at: window }) }
    scope :closed_by, ->(users) { closed.where(closures: { user_id: Array(users) }) }
  end

  def closed?
    closure.present?
  end

  def open?
    !closed?
  end

  def closed_by
    closure&.user
  end

  def closed_at
    closure&.created_at
  end

  def close(user: Current.user)
    unless closed?
      transaction do
        create_closure! user: user
        track_event :closed, creator: user
      end
    end
  end

  def reopen(user: Current.user)
    if closed?
      transaction do
        closure&.destroy
        track_event :reopened, creator: user
      end
    end
  end
end

状態を別テーブルで管理しています。closedというboolean列を持つ代わりに、Closureという別モデルで状態を表現しています。closed?は単にclosure.present?を返すだけです。これにより「誰がいつクローズしたか」という情報も自然に持てます。

scope :closed, -> { joins(:closure) }scope :open, -> { where.missing(:closure) }の対比が良いです。Rails 6.1で追加されたwhere.missingを活用し、宣言的にクエリを表現しています。

Card::Golden(22行)- 最小限で完結する機能

app/models/card/golden.rb
module Card::Golden
  extend ActiveSupport::Concern

  included do
    has_one :goldness, dependent: :destroy, class_name: "Card::Goldness"

    scope :golden, -> { joins(:goldness) }
    scope :with_golden_first, -> {
      left_outer_joins(:goldness)
        .prepend_order("card_goldnesses.id IS NULL")
        .preload(:goldness)
    }
  end

  def golden?
    goldness.present?
  end

  def gild
    create_goldness! unless golden?
  end

  def ungild
    goldness&.destroy
  end
end

22行で機能が完結しています。

  • 状態確認:golden?
  • 操作:gild, ungild
  • スコープ:Card.golden, Card.with_golden_first

必要なものだけがあり、それ以上はありません。メソッド名は意図を明確に表現しています。gildは「金メッキを施す」という意味で、カードを特別扱いすることを表します。

3.3 Concernパターンの一貫性

Fizzyの各Concernは、驚くほど一貫したパターンに従っています。

Concern 行数 状態管理 主要メソッド
Closeable 48 has_one :closure close, reopen, closed?
Postponable 48 has_one :not_now postpone, resume, active?
Golden 22 has_one :goldness gild, ungild, golden?
Watchable 34 has_many :watches watch_by, unwatch_by

共通パターン

  1. has_one/has_manyで状態を別テーブルに保持:boolean列ではなく、関連レコードの存在で状態を表現しています
  2. ?メソッドで状態確認closed?, golden?, postponed?など
  3. 動詞メソッドで操作close, gild, postponeなど
  4. scopeでクエリを宣言的にscope :closed, -> { joins(:closure) }

この一貫性があるからこそ、22個のConcernがあっても混乱しません。どのConcernも同じ設計原則に従っています。

4. Vanilla Railsの美学

4.1 なぜServiceクラスを使わないのか

「Controller → Service → Model」というレイヤードアーキテクチャは、多くのRailsプロジェクトで採用されています。しかし、Fizzyはこのパターンを採用していません。STYLE.mdには、こう書かれています。

In general, we favor a vanilla Rails approach with thin controllers directly invoking a rich domain model. We don't use services or other artifacts to connect the two.

実際のコントローラーを見てみましょう。

app/controllers/cards/closures_controller.rb
class Cards::ClosuresController < ApplicationController
  include CardScoped

  def create
    capture_card_location
    @card.close           # ← モデルのメソッドを直接呼び出し
    refresh_stream_if_needed

    respond_to do |format|
      format.turbo_stream
      format.json { head :no_content }
    end
  end

  def destroy
    @card.reopen          # ← モデルのメソッドを直接呼び出し
    refresh_stream_after_reopen

    respond_to do |format|
      format.turbo_stream
      format.json { head :no_content }
    end
  end
end

@card.close@card.reopen。ただ、モデルのメソッドを直接呼び出してるだけです。Serviceクラスもなければ、Interactorもありません。コントローラーは「何をするか」を記述し、「どうやるか」はモデルが知っています。

STYLE.mdにはこんな例もあります。

class Cards::GoldnessesController < ApplicationController
  def create
    @card.gild
  end
end

たっだの1行です。これ以上シンプルにはできません。

4.2 RESTful リソース設計

Fizzyのルーティングを見ると、カスタムアクションがほとんどないことに気づきます。

config/routes.rb
resources :cards do
  scope module: :cards do
    resource :closure      # POST /cards/:id/closure (close)
    resource :goldness     # POST /cards/:id/goldness (gild)
    resource :not_now      # POST /cards/:id/not_now (postpone)
    resource :pin
    resource :watch
    # ...
  end
end

POST /cards/:id/closeというカスタムアクションではなく、POST /cards/:id/closureというリソース作成として設計されています。STYLE.mdにはこう書かれています。

# Bad
resources :cards do
  post :close
  post :reopen
end

# Good
resources :cards do
  resource :closure
end

すべてがCRUD操作として表現されています。closeは「Closureの作成」、reopenは「Closureの削除」。この設計により、コントローラーはcreate, destroyなどの標準のアクションだけで良くなります。

4.3 コードの読み心地を大切にする

STYLE.mdの冒頭には、こんな一節がある:

We aim to write code that is a pleasure to read, and we have a lot of opinions about how to do it well. We care about how code reads, how code looks, and how code makes you feel when you read it.

「コードを読んだときにどう感じるか」を大切にする。これは技術的な指標では測れない価値観です。

例えば、guard clauseについて。

# Bad(Guard clause)
def todos_for_new_group
  ids = params.require(:todolist)[:todo_ids]
  return [] unless ids
  @bucket.recordings.todos.find(ids.split(","))
end

# Good(Expanded conditional)
def todos_for_new_group
  if ids = params.require(:todolist)[:todo_ids]
    @bucket.recordings.todos.find(ids.split(","))
  else
    []
  end
end

多くのスタイルガイドはguard clauseを推奨していますが、Fizzyは拡張条件を好みます。理由はguard clauseはネストすると読みにくくなるからです。これは好みの問題かもしれないが、一貫性を持って適用されています。

メソッドの順序についても明確なルールがあります。

class SomeClass
  def some_method
    method_1
    method_2
  end

  private
    def method_1
      method_1_1
      method_1_2
    end

    def method_1_1
      # ...
    end

    def method_1_2
      # ...
    end

    def method_2
      # ...
    end
end

呼び出し順序に基づいてメソッドを配置します。これにより、コードを上から下へ読むだけで処理の流れが理解できます。

5. オープンソースであることの価値

5.1 学習リソースとしての価値

Fizzyのコードベースは、Railsの学習リソースとして非常に価値が高いです。

  • 8,685コミットの歴史
  • 194個のテストファイル
  • 詳細な設計ドキュメント(AGENTS.md, STYLE.md, CONTRIBUTING.md)

これは教科書やチュートリアルでは得られない、「実際に動いているプロダクションコード」です。しかも、世界でも最も洗練されたRails開発チームが書いたコードです。

特にAGENTS.mdは、AI開発支援ツールへのガイドとして書かれていますが、人間にとっても優れたアーキテクチャドキュメントになっています。マルチテナンシーの実装、認証・認可の仕組み、バックグラウンドジョブの設計など、システム全体を理解するための情報が詰まっています。

5.2 37signalsの設計思想を直接学べる

BasecampやHEYのコードは公開されていません。しかしFizzyを通じて、37signalsの設計思想を直接学ぶことができます。

2022年にVanilla Rails is plentyというブログ記事が公開されました。Fizzyはまさにその実践例です。

  • Serviceレイヤーなし
  • Form Objectも最小限
  • CRUDリソースベースの設計
  • Concernによる責務分離
  • シンプルなActive Record操作

「Rails Wayに従えば十分」という哲学が、具体的なコードとして示されています。

5.3 技術選定の参考に

Fizzyは、37signalsのツールチェーンを理解する上でも参考になります。

Solid Queue:Redisを使わない、データベースベースのジョブキュー。Fizzyではconfig/recurring.ymlで定期実行ジョブを宣言的に定義しています。

16シャードMySQL検索:Elasticsearchを使わず、MySQLのFULLTEXT検索を16シャードに分散しています。account_idのCRC32ハッシュでシャードを決定する独自実装しています。

Kamal:コンテナベースのデプロイツール。Fizzyのデプロイ設定も公開されています。

これらは大規模なツールを避け、シンプルな選択をするという37signalsの哲学を反映しています。

5.4 コミュニティへの貢献

FizzyはONCEシリーズの一部として公開されています。ONCEは「一度払って、永久に使える」というビジネスモデルを持つ製品群です。オープンソースとして公開されていることで、コードを読んで学ぶことができます。フォークして自分のプロジェクトに応用できます。バグを見つけたらIssueを報告できます。改善案をPRとして送れます。これは、Railsコミュニティへの大きな貢献です。

6. 結論 - 多機能性とシンプルさの両立

Fizzyのコードを読んで感じるのは、多機能性とシンプルさの両立です。

たった147行のCardクラスで、ものすごく多くのことができます。これは、責務を22個のConcernに分けることで実現しています。責務を分けずに、Cardクラスにそのままコードを書いていたら、147行では表現できなかったでしょう。ここまで洗練されたコードは何度も読みたくなります。何度も読みたくなるほどのシンプルさ。こんなコードであれば、日々の仕事は楽しいでしょう。

Concernを22個使うことは、悪いことではありません。各Concernが明確な責務を持ち、一貫したパターンに従っているなら、それは優れた設計です。重要なのは数ではなく、設計の一貫性と意図の明確さです。

Vanilla Railsの美学とは、「Railsの規約に従い、余計なものを足さない」ことです。Serviceレイヤーを追加する前に、モデルに適切なメソッドを追加できないか考える。カスタムアクションを作る前に、新しいリソースとして設計できないか考える。

Fizzyは、その思想を実践したコードベースです。そして、そのコードが公開されていることは、Railsを学ぶ私たちにとって幸運なことです。

優れたコードは読んでいて心地よいです。ぜひ一度、コードを読んでみてください。

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?