Save 36% for Black Friday! Learn more

All threads / Recording pattern (Basecamp 3)
Ask A Question

Notifications

You’re not receiving notifications from this thread.

Recording pattern (Basecamp 3)

Arnaud asked in Rails

Has anyone tried to unravel the recording pattern used in Basecamp 3 ?

You can find breadcrumbs in the "On Writing Software Well" video series (https://www.youtube.com/playlist?list=PL9wALaIpe0Py6E_oHCgTrD6FvFETwJLlx) and a bit more details in this presentation (https://youtu.be/tmWlm0M6JSk?t=3060).

It relies on a subpattern that is now part of rails, delegated types: https://github.com/rails/rails/pull/39341, but there's also a notion of tree structure (recordings belong to other recordings) and of versioning/activity (there's an Event class involved as you can see here https://youtu.be/D7zUOtlpUPw?t=653).

The core looks something like this, but you can see a glimpse of it here https://youtu.be/5hN6OZDyQtk?t=419 (lots of things are hidden within the concerns, such as the parent/children that must live in the Tree concern).

class Recording < ApplicationRecord
  belongs_to :bucket
  belongs_to :creator
  belongs_to :parent, class_name: "Recording", optional: true
  has_many :children, class_name: 'Recording', foreign_key: :parent_id
  delegated_type : recordable, types: %w[ Todo Todolist Todoset Dock ]
end

So instead of having multiple models sharing the same concerns and attributes, you have the Recording model that takes care of all of that (and that's a lot of something as you saw above, in https://youtu.be/5hN6OZDyQtk?t=419).
Any content user-created (Document, Todo, Todolist, etc.) is immutable: instead of updating it, they create a new version, and the recording points to that new version while you get to keep a track of the changes thanks to the Event class.

Now in this video, here https://youtu.be/tmWlm0M6JSk?t=3364 there's a mention that copying is a lot faster thanks to this pattern.
They don't need a background job anymore to copy everything, but I don't really get why it's so much faster.
My understanding is that they don't have to copy the immutable objects, but they still have to duplicate the whole tree of recordings (the recording that points to the copied object as well as any of its descendants - other recordings that point to immutable objects related to the copied object), and they also need to duplicate things associated to the recording such as events or subscribers. It doesn't feel like there's a lot less to do.

Another thing I don't get is the children association part in the record method that you see here: https://youtu.be/tmWlm0M6JSk?t=3310. I'm not sure what the children are supposed to be. They should be recordables but children is used for recording descendants. Also there's no recursion happening.

So that's where I left things so far, I'll have another look later but I thought you guys my have some interesting insights!

Hello, you can find a repo that puts these bits and pieces together here: https://github.com/dixpac/camp
Hope it helps!

Thanks Ciprian, I appreciate your contribution :)
Unfortunately, it seems that this repo covers only the basics. For instance, the Bucket#record method is a simplified version of the one used by Basecamp.

Yes, Basecamp has something along the lines of:

  def record(recordable, children: nil, parent: nil, creator: Current.person, **options)
    transaction do
      recordable.save!
      options.merge!(recordable: recordable, parent: parent, creator: creator)

      recordings.create!(options).tap do |recording|
        Array(children).each do |child|
          record child, parent: recording, status: status, creator: creator
        end
      end
    end
  end

What I cannot yet figure is how to create a relationship from a Recordable, which can be anything, to a Recording.

module Recordable
  extend ActiveSupport::Concern

  TYPES = %w[ Conversation Message Device ]

  included do
    has_one :recording, as: :recordable, inverse_of: :recordable, touch: true
  end
end


class Recording < ApplicationRecord
  include Tree

  include Accountable, Readable, Recordables

  belongs_to :mobile_application, touch: true
  belongs_to :creator, class_name: "Person", default: -> { Current.person }

  delegated_type :recordable, types: Recordable::TYPES, inverse_of: :recording
end


module Recordables
  extend ActiveSupport::Concern

  included do
    after_commit :update_recordable, on: [:create, :update]
  end

  def recordables
    recordable_type.constantize.where(recording_id: id)
  end

  private
    def update_recordable
      recordable.update! recording_id: self.id
    end
end

The reason I need this is to be able to delete the recordables along with the recording.
I will probably have to do it in a after_commit on destory, but I will probably have to do it in a job.

Hope this helps.

As far as I know (knowledge gathered from what I could find + personal experience trying to implement it - I have an app using this pattern):

  • a recordable has_many :recordings, as: :recordable, not one
  • Recordables are immutable. You don't update a recordable, you create a new one.
  • you will have a hard time building the same architecture without the Event model

There's also this video, where they go through the Buckets & Recordings patterns.
https://www.youtube.com/watch?v=tmWlm0M6JSk&t=3309s

Indeed, it seems that I will have to use Events to keep track of changes and present them.
I thought that I will be able to do that using just recordings and recordables.

I guess the only way to properly clean up the database when a recording is deleted and delete any associated recordables is to have a trashed property that will trigger a job. And I guess that here: https://www.youtube.com/watch?v=AoxoPfilKqE&t=1185s the @recordable.recordings is just a Recording.where(recordable: self), therefore, a delegated_type is sufficient and a Recordable does not need to point to a Recording, even though I am not 100% sure of that because, in the previous class, Recording::Incineratable::Incineration, the incinerate_recordables method is hidden and it has 13 lines of code, so it's not just another simple call.

Yep, you are right, I kind of misses this detail. https://www.youtube.com/watch?v=lEUkarkROv0
The Event class also has a previous_recordable which means that the recording accesses its recordables through events, leaving Recordables be completely separated from the business logic.

Join the discussion

Want to stay up-to-date with Ruby on Rails?

Join 67,819+ developers who get early access to new tutorials, screencasts, articles, and more.

    We care about the protection of your data. Read our Privacy Policy.

    logo Created with Sketch.

    Screencast tutorials to help you learn Ruby on Rails, Javascript, Hotwire, Turbo, Stimulus.js, PostgreSQL, MySQL, Ubuntu, and more. Icons by Icons8

    © 2022 GoRails, LLC. All rights reserved.