Why Not Callbacks
The Problem
You often want something to happen at a stage in an ActiveRecord object’s standard workflow - most often this is “After I save I want to do X.”
For Example:
class File < ActiveRecord::Base
validates :file_path, :presence => true
end
File.save # => Persist, if successful,
# create a thumbnail version for the web.
Why Callbacks
An often recommended way to deal with this is as an after_save callback.
class File < ActiveRecord::Base
validates :file_path, :presence => true
after_save :create_thumbnail
private
def create_thumbnail
ThumbnailCreator.call(file_path)
end
end
PROs:
- Simple, concise, all the logic is in one place you can look at.
- You can never accidentally save a file without it creating a thumbnail.
Before I get to the CONs, let’s look at some assumptions made:
Assumptions:
- We’re okay opening up the file class whenever we want to change when and how we create a thumbnail.
- We ALWAYS want to create a thumbnail.
- If we save a file, we’re okay with whatever overhead exists to create that thumbnail.
- We’re okay with not injecting dependencies (ThumbnailCreator), and thus when that changes we’re happy to change it in File.
CONs:
- State is mixed up with business logic. Suddenly my “File” is not just “attributes with persistence”, and is now “attributes with persistence and side effects too”
- NOT creating a thumbnail is hard. How do I avoid callbacks? Only this SPECIFIC callback?
- Doing something like pushing that thumbnail creation to the background is complicated logic that will sit in the model.
- ThumbnailCreator is hard-wired, its responsibility is inflexible.
Why Not Callbacks
It turns out the assumption that we ALWAYS want to create a thumbnail
is a big
one. It’s rare that you ALWAYS want something to happen, and building in that
assumption is the beginning to expensive backtracking or hacks later.
The big point is this: business logic changes at a different rate than object
representation
. Tying the logic of when and how to create a thumbnail to the
representation of how a File is stored means coupling changes in the File to
changes in how a thumbnail is created, unnecessarily.
Alternatives
Service Objects
We can encapsulate what to do when an object is saved by putting all of it in one object.
class FileSaver
attr_reader :file, :thumbnail_creator
def initialize(file, thumbnail_creator)
@file = file
@thumbnail_creator = thumbnail_creator
end
def run
if file.save
thumbnail_creator.call(file.file_path)
end
end
end
This works - you can inject ThumbnailCreator, you only use the FileSaver when you want, and you will never accidentally create a thumbnail.
However, what tends to happen is that ALL behavior gets stored here and the problem of “how do I ignore just ONE callback action” remains.
Decoration of Functionality
We can decorate JUST this functionality onto the object.
class CreatesThumbnails < SimpleDelegator
attr_reader :thumbnail_creator
def initialize(file, thumbnail_creator)
@thumbnail_creator = thumbnail_creator
super(file)
end
def save
if __getobj__.save
thumbnail_creator.call(file_path)
end
end
end
CreatesThumbnails.new(File.new(:file_path => "/file"), ThumbnailCreator).save
Now when you want to create a thumbnail on save you can decorate the object with this, call save, and you’re good. If when or how to run the thumbnail creator changes, you can inject a new thumbnail creator or change just the CreatesThumbnails object. Business logic is now separated from state.
Decorating all the necessary actions and dependencies for a transaction can get complicated, at which point the Factory Pattern can help. I may do a post on this at a later date.