9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

The Rails Delegated Type Patternで紹介された実装をコードにするとこんな感じか?

Last updated at Posted at 2025-12-23

度々DHHがConcernの素晴らしさをアピールする時に貼っつけるコードのスクショにRecordingというのがあった

DHHファンの私は、この狂気じみたMixinに興奮しつつも「ところでRecordingってなんすか...?」っていう感情を募らせていた

何か記録するものなんだろうが、これに関連してるであろうRecordableというConcernがBasecamp(プロジェクト管理のWebサービス)のほとんどあらゆるモデルにmixinされてるという話を聞き、なんなんだそれは...となっていた

これに関するアンサー的な動画が37singalsのdevチャンネルで公開され、ファンは一同釘つけになった

私もその一人である

拙い英語力と翻訳機を駆使してここでいってることをある程度把握できたと思うのでメモ代わりに記事にしようと思う

何をするものなのか

やはり履歴管理が目的らしい

Basecampはそこで管理しているドキュメントなどの変更履歴を細かく追うことができ、さらにそのバージョンにいつでも戻せるといった機能があるらしい

スクリーンショット 2025-12-23 17.37.51.png

スクリーンショット 2025-12-23 17.40.07.png

これをあらゆるモデルに対して可能にしていて、その履歴管理のために長年苦心して編み出したのが現在のパターンだという話らしい

謎解き

まず動画内のキーワードを拾っていくと

  • delegated typeを用いる(1:43あたり)
  • 各記録可能なモデル(Documentとか)はimmutable (9:40あたり)
  • 実際の履歴の追跡はeventモデルが行う (11:45あたり)
  • Recordingはimmutableではない (12:49あたり)
  • Recordingは各記録可能なモデルのメタ情報が入っている (timestampやcreator_id) (03:53)
  • recordableなモデルに対応するURLのidは実際にはrecordingのid (14:08)

となっている

DocummentやCommentといった記録可能(recordable)なモデルは不変といっているのでUI上の削除は実際の削除ではないのであろうと読み解ける

また、動画内でちらっと

events.all? { |e| e.recording&.deleted? }

という実装が写ったのでrecordingモデルも変更されこそ削除はされないのであろう

動画中では明かされなかったがおそらくdeleted_atみたいなカラムがあることが推測される

またeventはrecordingにアクセスできてるっぽいので両者にはなんらかのassociationがあるのだろう

recordingには

delegated_type :recordable, types: Recording::Types, inverse_of: :recordings

みたいな処理も見れて動画内の発言との整合性がとれた

ざっくり聞いた感じとこの辺を考慮するとこんな感じかなというのが見えてくる

多分こんな感じ?

migration

recording

  • 各記録可能なモデルと関連づけなきゃいけないのでpolymorphicな感じになる
  • 削除されないが削除状態を扱う必要があるのでdeleted_atがある
  • (動画ではcreater_idみたいなのがありそうな雰囲気あったけど割愛)
bin/rails g model recording recordable:references{polymorphic} deleted_at:datetime

移譲型でrecordable_typeに入るモデルを定義、ここでは一旦話をシンプルにしたくてDocumentだけを対象にしている

クエリ削減目的でinvers_ofも指定していた
(動画内ではRecordingがコピーされることで一つのドキュメントが複数に紐づくこともあるよねってことで複数系だったがちょっとややこしくなるので1:1と考える)

class Recording < ApplicationRecord
  delegated_type :recordable, types: %w[Document], inverse_of: :recording
  has_many :events

  scope :active,  -> { where(deleted_at: nil) }
  scope :deleted, -> { where.not(deleted_at: nil) }

  def deleted?
    deleted_at.present?
  end
end

recordableなデータ(ex: Document, Comment, ...)

とくにテーブル構造を変にする必要はない

bin/rails g model document title:string body:text

recordable modelとrecordingの関連づけ
recordable modelは不変なのでrecording経由でdeletedの判定をしちゃう

concerns/recordable.rb
module Recordable
  extend ActiveSupport::Concern

  included do
    has_one :recording, as: :recordable, autosave: false
    scope :active, -> { joins(:recording).merge(Recording.active) }
  end
end
document.rb
class Document < ApplicationRecord
  include Recordable
end

event

  • recordingを引っ張れる
  • 対象となるrecordableなモデルを引っ張れる
  • recordableなモデルの変更履歴を持つため、create/update/destroyedみたいな操作内容を持つフィールド(action_type)が必要

というところを考えるとこれもpolymorphicな感じになるのかな

bin/rails g model event action_type:string recordable:references{polymorphic} recording:references
event.rb
class Event < ApplicationRecord
  belongs_to :recording
  belongs_to :recordable, polymorphic: true
  validates :action_type, presence: true
end

関係性まとめ

Recording (id, recordable_type, recordable_id, deleted_at)
  ├─ belongs_to :recordable (Document など)
  └─ has_many :events

Document (id, title, body)
  └─ has_one :recording, as: :recordable

Event (id, action_type, recording_id, recordable_type, recordable_id)
  ├─ belongs_to :recording
  └─ belongs_to :recordable (Document, Comment ...)

routing部分

recordableモデルのルーティングのidはrecordingになるという話なのでちょっと名前をいじる

resources :documents, param: :recording_id do
  # 変更履歴表示側
  resources :events, only: [:index, :show], module: :documents
end

controller部分

そしてcontroller部分、DocumentController /documents/:recording_id となるので基本はrecordingを一本釣りしてそこからrecordable経由でrecordable modelを取得する流れになる

そしてrecordable modelは不変なのでupdateは新規recordable modelの作成、削除はrecordingのdelete_atを記入と置き換わる
そして、都度その情報を記録したイベントを作成していくという形になるという想定でこんな感じだろうか

documents_controller.rb
class DocumentsController < ApplicationController
  before_action :set_recording, only: [:show, :edit, :update, :destroy]
  before_action :set_document, only: [:show, :edit]

  # GET /documents
  def index
    @recordings = Recording.active.documents.order(updated_at: :desc).limit(50)
  end
  
  # GET /documents/:recording_id
  def show
  end

  # GET /documents/new
  def new
    @document = Document.new
  end

  # POST /documents
  def create
    @document = Document.new(document_params)
    ApplicationRecord.transaction do
      @document.save!
      @recording = Recording.create!(recordable: @document)
      Event.create!(recording: @recording, action_type: :created, recordable: @document)
    end

    redirect_to document_path(@recording.id), notice: 'Document was successfully created.'
  rescue ActiveRecord::RecordInvalid
    render :new, status: :unprocessable_entity
  end

  # GET /documents/:recording_id/edit
  def edit
  end

  # PATCH/PUT /documents/:recording_id
  def update
    @document = Document.new(document_params)
    ApplicationRecord.transaction do
      @document.save!
      @recording.update!(recordable: @document)
      Event.create!(recording: @recording, recordable: @document, action_type: :updated)
    end

    redirect_to document_path(@recording.id), notice: 'Document was successfully updated.'
  rescue ActiveRecord::RecordInvalid
    render :edit, status: :unprocessable_entity
  end

  # DELETE /documents/:recording_id
  def destroy
    @document = @recording.recordable
    ApplicationRecord.transaction do
      Event.create!(recording: @recording, action_type: :destroyed, recordable: @document)
      @recording.update!(deleted_at: Time.current)
    end

    redirect_to documents_url, notice: "Document was successfully destroyed."
  rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed
    render :show, status: :unprocessable_entity
  end

  private

  def recording_id
    params[:recording_id]
  end

  def set_recording
    @recording = Recording.find(recording_id)
  end

  def set_document
    @document = @recording.recordable
  end

  def document_params
    params.expect(:document, [:title, :body])
  end
end

で、履歴側はこんな感じ

documents::events_controller.rb
class Documents::EventsController < ApplicationController
  before_action :set_recording
  before_action :set_event, only: [:show]

  # GET /documents/:document_recording_id/events
  def index
    # @recording に紐づく全てのイベントを取得
    # 最新の変更が上に来るように降順でソート
    @events = @recording.events.order(created_at: :desc)
  end

  # GET /documents/:document_recording_id/events/:id
  def show
    @document = @event.recordable
  end

  private

  def recording_id
    params[:document_recording_id]
  end

  def set_recording
    @recording = Recording.find(recording_id)
  end

  def set_event
    @event = @recording.events.find(id)
  end

実際こんな感じでつくって適当にview用意したら履歴管理はちゃんとできるようになった

適当にドキュメントを作って3回ほど更新をかけて動きをみてみる

documents#show

スクリーンショット 2025-12-23 22.01.14.png

documents::events#index

スクリーンショット 2025-12-23 21.56.32.png

documents::events#show 先頭(最新のもの)

スクリーンショット 2025-12-23 21.56.40.png

documents::events#show 2つめのもの

スクリーンショット 2025-12-23 21.56.47.png

documents::events#show 3つめのもの

スクリーンショット 2025-12-23 21.56.56.png

documents::events#show 末尾(作成時のもの)

スクリーンショット 2025-12-23 21.57.03.png

今はスナップショットを出してるが一つ前のeventからrecordable(つまりdocument)を取り出してdiffyとかで差分を出せば冒頭のbasecampのような差分表示も可能になると思う

きっとこんな感じなのかな〜〜

最後に

動画中でもちょくちょく話されてるが少し小難しい実装になっています
37signalsもこのパターンを常に使ってるわけではなくrecordingが重要なアプリケーションでだけで使っています

ただ、込み入った業務アプリケーションなどを作る際はこのような履歴管理、元のバージョンへの復帰機能、差分表示などこういう要件がでてくることは多いのでそういった時の参考にできればいいですね

9
0
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
9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?