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で行われています。
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が来たとき、/0123456をSCRIPT_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が含まれ、データの完全な分離を実現しています。面白いのはデフォルト値の設定方法です。
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システムは、一定期間アクティビティがないカードを自動的に脇に寄せることで、この問題に対処します。アカウントレベルまたはボードレベルで延期期間を設定でき、定期的なバックグラウンドジョブが対象カードを処理します。
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による責務の分離だ:
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行)- 状態管理
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行)- 最小限で完結する機能
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
|
共通パターン
-
has_one/has_manyで状態を別テーブルに保持:boolean列ではなく、関連レコードの存在で状態を表現しています -
?メソッドで状態確認:closed?,golden?,postponed?など -
動詞メソッドで操作:
close,gild,postponeなど -
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.
実際のコントローラーを見てみましょう。
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のルーティングを見ると、カスタムアクションがほとんどないことに気づきます。
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を学ぶ私たちにとって幸運なことです。
優れたコードは読んでいて心地よいです。ぜひ一度、コードを読んでみてください。