Recording pattern (Basecamp 3)
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.
https://dev.37signals.com/globals-callbacks-and-other-sacrileges/
There is now a blogpost about it