I was working on a messaging system earlier this week and noticed a pretty tight coupling between two classes.
class Message < ActiveRecord::Base
belongs_to :author, :class_name => 'User'
belongs_to :conversation, :touch => true
after_create :author_reads_conversation
after_save :update_answered
after_save :update_last_student_posted_at
after_save :update_last_post_at
after_save :update_participants
after_save :update_messages_count
# ...
private
def update_answered
if conversation.kind == Conversation::QUESTION || conversation.kind == Conversation::ASSIGNMENT
if author.instructor?
conversation.update_attribute(:answered, true)
else
conversation.update_attribute(:answered, false)
end
end
end
def update_last_post_at
conversation.update_attribute(:last_post_at, self.created_at)
end
def update_last_student_posted_at
if author.student? || conversation.last_student_posted_at.nil?
conversation.update_attribute(:last_student_posted_at, self.created_at)
end
end
def update_participants
participants = {}
conversation.contributors.each{|user| participants[user.id] = user.profile.full_name }
conversation.update_attribute(:participants, participants.to_json.to_s)
end
def update_messages_count
conversation.update_attribute(:messages_count, conversation.messages.count)
end
def author_reads_conversation
conversation.read_by!(author)
end
end
The first thing I noticed was that all of these callbacks are actually
interacting with the conversation, not the message. A message shouldn’t be
responsible for updating a handful of attributes on another model! The second
thing I noticed was that Message#update_messages_count
could actually be
handled by a counter_cache
.
My gut reaction was to move all of this onto Conversation
and call that one
method in a callback on Message
. Why? I want to call one method that handles
all the data caching from a conversation’s standpoint.
Based on the conditionals, it looked like I could get away with passing the author and be done with it. After pondering how to test that (it would’ve been pretty nasty), I decided to extract it into a separate class. With a seperate class, I can then stub methods on conversation (because stubbing the system under test is evil) as well as the message.
class ConversationCacher
def initialize(conversation, message)
@conversation = conversation
@message = message
end
def run
update_last_posted_at
update_last_student_posted_at if @message.author.student?
update_answered if @conversation.answerable?
cache_participants_as_json
save_conversation
author_reads_conversation
end
private
def update_last_posted_at
@conversation.last_post_at = @message.created_at
end
def update_last_student_posted_at
@conversation.last_student_posted_at = @message.created_at
end
def cache_participants_as_json
@conversation.participants = participants_as_json.to_s
end
def update_answered
@conversation.answered = @message.author.instructor?
end
def author_reads_conversation
@conversation.read_by!(@message.author)
end
def save_conversation
@conversation.save(:validate => false)
end
def participants_as_json
@conversation.contributors.inject({}) {|result, user| result[user.id] = user.profile.full_name; result }.to_json
end
end
This class deals with all attribute caching on the Conversation
. It’s
now incredibly easy to test:
describe ConversationCacher, "#run" do
let(:conversation) { create(:conversation) }
let(:message) { create(:message) }
let(:student) { create(:student) }
let(:instructor) { create(:instructor) }
subject { ConversationCacher.new(conversation, message) }
it "updates conversation#last_post_at" do
subject.run
conversation.last_post_at.should == message.created_at
end
context "when the message author is a student" do
before { message.author = student }
it "updates conversation#last_student_posted_at" do
subject.run
conversation.last_student_posted_at.should == message.created_at
end
end
context "when the message author is an instructor" do
before { message.author = instructor }
it "does not update conversation#last_student_posted_at" do
subject.run
conversation.last_student_posted_at.should be_nil
end
end
# ...
end
Here’s what Message
looks like now:
class Message < ActiveRecord::Base
cattr_writer :cacher
def self.cacher
@@cacher || ConversationCacher
end
belongs_to :author, :class_name => 'User'
belongs_to :conversation, :touch => true, :counter_cache => true
has_many :attachments, :dependent => :destroy
after_create :cache_data_on_conversation
private
def cache_data_on_conversation
self.class.cacher.new(conversation, self).run
end
end
Notice that I created a Message.cacher
class method. If unset, it’ll return my
ConversationCacher
. Now I won’t have to stub ConversationCacher.new
to
make sure that my cacher works properly.
describe Message, "after save" do
let(:author) { Factory(:user) }
let(:conversation) { Factory(:conversation) }
let(:cacher_class) { stub("cacher", :new => cacher) }
let(:cacher) { stub("cacher instance", :run => true) }
subject { Factory.build(:message, :author => author, :conversation => conversation) }
before { Message.cacher = cacher_class }
after { Message.cacher = nil }
it "triggers the conversation data be cached for query optimizations" do
subject.save
cacher_class.should have_received(:new).with(conversation, subject)
cacher.should have_received(:run)
end
it "caches only on create" do
2.times { subject.save }
cacher_class.should have_received(:new).with(conversation, subject)
cacher.should have_received(:run).once
end
end
describe Message, ".cacher" do
after { Message.cacher = nil }
context "when overridden" do
let(:cacher) { stub("my awesome cacher") }
before { Message.cacher = cacher }
it { Message.cacher.should == cacher }
end
context "when not set" do
before { Message.cacher = nil }
it { Message.cacher.should == ConversationCacher }
end
end
One-line methods, small contexts in our tests, and an easy understanding of
what’s happening after a message is created. I’d call this a successful
refactor. The Message
now doesn’t care whatsoever about what data is cached,
since that logic is on ConversationCacher
. If more data needs to be cached
on the conversation when a message is created, it’s immediately obvious where
that logic is handled.
In my experience, after_*
callbacks on Rails models seem to have some of the
most tightly-coupled code, especially when it comes to models with
associations. Do yourself a favor and decouple some code!