Skip to main content
Replacing ActiveRecord callbacks
with Pub/Sub
edenspiekermann_
March 2nd, 2017
Niall Burkley (Edenspiekermann_)
@niallburkley | github.com/nburkley | niallburkley.com
Callbacks
ACTIVE RECORD
What are callbacks?
Business logic that you want
- before or after something happens
- tied to the lifecycle of the model
What are callbacks?
Business logic that you want
- before or after something happens
- tied to the lifecycle of the model
class Post < ActiveRecord::Base
after_commit :notify_editors, on: :create
private
def notify_editors
EditorMailer.send_notification(self).deliver_later
end
end
What are callbacks?
4
Creating a record Updating a record Deleting a record
BEFORE VALIDATION
before_validation before_validation
before_validation_on_create before_validation_on_update
AFTER VALIDATION
after_validation after_validation
before_save before_save
before_create before_update before_destroy
AFTER CRUD ACTION
after_create after_update after_destroy
after_save after_save
after_commit, on: :create after_commit, on: :update after_commit, on: :destroy
4
Creating a record Updating a record Deleting a record
BEFORE VALIDATION
before_validation before_validation
before_validation_on_create before_validation_on_update
AFTER VALIDATION
after_validation after_validation
before_save before_save
before_create before_update before_destroy
AFTER CRUD ACTION
after_create after_update after_destroy
after_save after_save
after_commit, on: :create after_commit, on: :update after_commit, on: :destroy
Unrelated Business Logic in your model
- Violation of Single Responsibility Principle
#1
6
class Message < ActiveRecord::Base
after_create :add_author_as_watcher
after_create :send_notification
private
def add_author_as_watcher
Watcher.create(watchable: self.root, user: author)
end
def send_notification
if Setting.notified_events.include?('message_posted')
Mailer.message_posted(self).deliver
end
end
end
7
class Message < ActiveRecord::Base
after_create :add_author_as_watcher
after_create :send_notification
private
def add_author_as_watcher
Watcher.create(watchable: self.root, user: author)
end
def send_notification
if Setting.notified_events.include?('message_posted')
Mailer.message_posted(self).deliver
end
end
end
8
class Message < ActiveRecord::Base
after_create :add_author_as_watcher
after_create :send_notification
private
def add_author_as_watcher
Watcher.create(watchable: self.root, user: author)
end
def send_notification
if Setting.notified_events.include?('message_posted')
Mailer.message_posted(self).deliver
end
end
end
Tight Coupling & brittle tests
#2
10
class Post < ActiveRecord::Base
after_commit :generate_feed_item, on: :create
private
def generate_feed_item
FeedItemGenerator.create(self)
end
end
10
class Post < ActiveRecord::Base
after_commit :generate_feed_item, on: :create
private
def generate_feed_item
FeedItemGenerator.create(self)
end
end
describe Post do
subject { Post.new(title: 'Hello RUG::B') }
describe 'create' do
it 'creates a new feed item' do
expect { subject.save }.to change { FeedItem.count }.by(1)
expect(FeedItem.last.title).to eq('Hello RUG::B')
end
end
end
11
class FeedItemGenerator
def self.create(subject)
FeedItem.create(
subject: subject,
owner: subject.user
)
end
end
12
class FeedItemGenerator
def self.create(subject, published)
FeedItem.create(
subject: subject,
owner: subject.user,
published: published
)
end
end
13
class Post < ActiveRecord::Base
after_commit :generate_feed_item, on: :create
private
def generate_feed_item
FeedItemGenerator.create(self)
end
end
13
class Post < ActiveRecord::Base
after_commit :generate_feed_item, on: :create
private
def generate_feed_item
FeedItemGenerator.create(self)
end
end
describe Post do
subject { Post.new(title: 'Hello RUG::B') }
describe 'create' do
it 'creates a new feed item' do
expect { subject.save }.to change { FeedItem.count }.by(1)
expect(FeedItem.last.title).to eq('Hello RUG::B')
end
end
end 13
class Post < ActiveRecord::Base
after_commit :generate_feed_item, on: :create
private
def generate_feed_item
FeedItemGenerator.create(self)
end
end
describe Post do
subject { Post.new(title: 'Hello RUG::B') }
describe 'create' do
it 'creates a new feed item' do
expect { subject.save }.to change { FeedItem.count }.by(1)
expect(FeedItem.last.title).to eq('Hello RUG::B')
end
end
end 13
class Post < ActiveRecord::Base
after_commit :generate_feed_item, on: :create
private
def generate_feed_item
FeedItemGenerator.create(self)
end
end
Your Models are going to grow
#3
15
class Post < ActiveRecord::Base
after_commit :notify_users, on: :create
private
def notify_users
PostMailer.send_notifications(self).deliver_later
end
end
16
class Post < ActiveRecord::Base
after_commit :notify_users, on: :create
after_commit :generate_feed_item, on: :create
after_commit :notify_editors, on: :create
private
def notify_users
PostMailer.send_notifications(self).deliver_later
end
def generate_feed_item
FeedItemGenerator.create(self)
end
def notify_editors
EditorMailer.send_notification(self).deliver_later
end
end
17
class Post < ActiveRecord::Base
after_commit :notify_users, on: :create
after_commit :generate_feed_item, on: :create
after_commit :notify_editors, on: :create
after_commit :add_user_points, on: :create
private
def notify_users
PostMailer.send_notifications(self).deliver_later
end
def generate_feed_item
FeedItemGenerator.create(self)
end
def notify_editors
EditorMailer.send_notification(self).deliver_later
end
def add_user_points
UserPointsService.recalculate_points(self.user)
end
end
Callbacks are Synchronous
#4
19
class Image < ActiveRecord::Base
after_commit :generate_image_renditions, on: :create
private
def generate_image_renditions
ImageService.create_renditions(self)
end
end
20
Browser Controller Image ImageService
POST /images
create(params)
create_renditions(image)
image renditions
image
success
after_create callback
20
Browser Controller Image ImageService
POST /images
create(params)
create_renditions(image)
image renditions
image
success
after_create callback
20
Browser Controller Image ImageService
POST /images
create(params)
create_renditions(image)
image renditions
image
success
after_create callback
20
Browser Controller Image ImageService
POST /images
create(params)
create_renditions(image)
image renditions
image
success
after_create callback
20
Browser Controller Image ImageService
POST /images
create(params)
create_renditions(image)
image renditions
image
success
after_create callback
20
Browser Controller Image ImageService
POST /images
create(params)
create_renditions(image)
image renditions
image
success
after_create callback
20
Browser Controller Image ImageService
POST /images
create(params)
create_renditions(image)
image renditions
image
success
after_create callback
21
Browser Controller Image Feed Generator
POST /images
create(params)
create_renditions(image)
image
success
ImageService
background process
image renditions
21
Browser Controller Image Feed Generator
POST /images
create(params)
create_renditions(image)
image
success
ImageService
background process
image renditions
21
Browser Controller Image Feed Generator
POST /images
create(params)
create_renditions(image)
image
success
ImageService
background process
image renditions
21
Browser Controller Image Feed Generator
POST /images
create(params)
create_renditions(image)
image
success
ImageService
background process
image renditions
21
Browser Controller Image Feed Generator
POST /images
create(params)
create_renditions(image)
image
success
ImageService
background process
image renditions
21
Browser Controller Image Feed Generator
POST /images
create(params)
create_renditions(image)
image
success
ImageService
background process
image renditions
21
Browser Controller Image Feed Generator
POST /images
create(params)
create_renditions(image)
image
success
ImageService
background process
image renditions
Building a
Social Platform
→ User feeds
→ Project feeds
→ User Notifications
→ Project Notifications
→ User Karma
23
class Project < ActiveRecord::Base
after_commit :add_user_points, on: :create
private
def add_user_points
UserPointsService.recalculate_points(self.user)
end
end
23
class Project < ActiveRecord::Base
after_commit :add_user_points, on: :create
private
def add_user_points
UserPointsService.recalculate_points(self.user)
end
end
class Comment < ActiveRecord::Base
after_commit :add_user_points, on: :create
private
def add_user_points
UserPointsService.recalculate_points(self.user)
end
end
24
module UpdatesUserPoints
extend ActiveSupport::Concern
included do
after_commit :add_user_points, on: :create
def add_user_points
UserPointsService.recalculate_points(self.user)
end
end
end
class Project < ActiveRecord::Base
include UpdatesUserPoints
end
class Comment < ActiveRecord::Base
include UpdatesUserPoints
end
Wisper
Wisper
A library providing Ruby objects with Publish-Subscribe capabilities
•Decouples core business logic from external concerns
•An alternative to ActiveRecord callbacks and Observers in Rails apps
•Connect objects based on context without permanence
•React to events synchronously or asynchronously
27
Project
ProjectSubscriber
UserSubscriber
create
User
create
Events
project_created(self)
user_created(self)
user_created(user)
project_ created(post)
<broadcast>
<broadcast>
28
class Project < ActiveRecord::Base
include Wisper::Publisher
after_commit :publish_creation, on: :create
private
def publish_creation
broadcast(:project_created, self)
end
end
29
Project.subscribe(ProjectSubscriber.new)
30
class ProjectSubscriber
def project_created(project)
UserPointsService.recalculate_points(project)
end
end
30
class ProjectSubscriber
def project_created(project)
UserPointsService.recalculate_points(project)
end
end
class Project < ActiveRecord::Base
include Wisper::Publisher
after_commit :publish_creation, on: :create
private
def publish_creation
broadcast(:project_created, self)
end
end
30
class ProjectSubscriber
def project_created(project)
UserPointsService.recalculate_points(project)
end
end
class Project < ActiveRecord::Base
include Wisper::Publisher
after_commit :publish_creation, on: :create
private
def publish_creation
broadcast(:project_created, self)
end
end
30
class ProjectSubscriber
def project_created(project)
UserPointsService.recalculate_points(project)
end
end
class Project < ActiveRecord::Base
include Wisper::Publisher
after_commit :publish_creation, on: :create
private
def publish_creation
broadcast(:project_created, self)
end
end
31
Too much boilerplate
Wisper::ActiveRecord
Wisper::ActiveRecord
Broadcast Lifecycle Events
after_create
after_destroy
create_<model_name>_{successful, failed}
update_<model_name>_{successful, failed}
destroy_<model_name>_{successful, failed}
<model_name>_committed
after_commit
after_rollback
34
class Project < ActiveRecord::Base
include Wisper::Publisher
after_commit :publish_creation, on: :create
private
def publish_creation
broadcast(:project_created, self)
end
end
35
class Project < ActiveRecord::Base
include Wisper.model
end
35
class Project < ActiveRecord::Base
include Wisper.model
end
Project.subscribe(ProjectSubscriber.new)
35
class Project < ActiveRecord::Base
include Wisper.model
end
class ProjectSubscriber
def after_create(project)
UserPointsService.recalculate_points(project)
end
end
Project.subscribe(ProjectSubscriber.new)
36
Make it Asynchronous
Wisper::ActiveJob
38
Post.subscribe(ProjectSubscriber, async: true)
38
Post.subscribe(ProjectSubscriber, async: true)
38
class ProjectSubscriber
def self.post_created(post)
UserPointsService.recalculate_points(post)
end
end
Post.subscribe(ProjectSubscriber, async: true)
39
What about tests?
40
class Message < ActiveRecord::Base
after_create :add_author_as_watcher
after_create :reset_counters!
after_create :send_notification
end
41
class MessageTest < ActiveSupport::TestCase
def test_create
topics_count = @board.topics_count
messages_count = @board.messages_count
message = Message.new(board: @board,
subject: 'Test message',
content: 'Test message content',
author: @user)
assert message.save
@board.reload
# topics count incremented
assert_equal topics_count + 1, @board[:topics_count]
# messages count incremented
assert_equal messages_count + 1, @board[:messages_count]
assert_equal message, @board.last_message
# author should be watching the message
assert message.watched_by?(@user)
end
end
42
class MessageTest < ActiveSupport::TestCase
def test_create
topics_count = @board.topics_count
messages_count = @board.messages_count
message = Message.new(board: @board,
subject: 'Test message',
content: 'Test message content',
author: @user)
assert message.save
@board.reload
# topics count incremented
assert_equal topics_count + 1, @board[:topics_count]
# messages count incremented
assert_equal messages_count + 1, @board[:messages_count]
assert_equal message, @board.last_message
# author should be watching the message
assert message.watched_by?(@user)
end
end
Wisper::Rspec
44
describe Message do
subject { Message.new(text: 'Hello RUG::B') }
describe 'create' do
it 'broadcasts message creation' do
expect { subject.save }.to broadcast(:after_create, subject)
end
end
end
45
describe MessageSubscriber do
let(:message) { Message.create(text: 'Hello RUG::B') }
describe 'after_create' do
it 'adds message author as watcher' do
MessageSubscriber.after_create(message)
expect(Watcher.last.user).to eq(message.author)
end
it 'adds updates the board counter' do
expect { MessageSubscriber.after_create(message) }
.to change { message.board.count }.by(1)
end
it 'sends a notification' do
MessageSubscriber.after_create(message)
expect(UserMailer).to receive(:send_notification).with(message.board.owner)
end
end
end
46
What have we achieved?
46
What have we achieved?
#1 - We’ve removed unrelated business logic from our classes
46
What have we achieved?
#1 - We’ve removed unrelated business logic from our classes
#2 - Decoupled our callbacks, making them easier to test
46
What have we achieved?
#1 - We’ve removed unrelated business logic from our classes
#2 - Decoupled our callbacks, making them easier to test
#3 -DRY’d up and slimmed down our model code
46
What have we achieved?
#1 - We’ve removed unrelated business logic from our classes
#2 - Decoupled our callbacks, making them easier to test
#3 -DRY’d up and slimmed down our model code
#4 - Moved our callback logic into background jobs
47
Alternatives?
48
Alternatives - Observers
48
Alternatives - Observers
class CommentObserver < ActiveRecord::Observer
def after_save(comment)
EditorMailer.comment_notification(comment).deliver
end
end
49
Alternatives - Decorators
49
Alternatives - Decorators
class CommentDecorator < ApplicationDecorator
decorates Comment
def create
save && send_notification
end
private
def send_notification
EditorMailer.comment_notification(comment).deliver
end
end
50
Alternatives - Decorators
class CommentController < ApplicationController
def create
@comment = CommentDecorator.new(Comment.new(comment_params))
if @comment.create
# handle the success
else
# handle the success
end
end
end
51
Alternatives - Trailblazer
51
Alternatives - Trailblazer
class Comment::Create < Trailblazer::Operation
callback :after_save, EditorNotificationCallback
51
Alternatives - Trailblazer
class EditorNotificationCallback
def initialize(comment)
@comment = comment
end
def call(options)
EditorMailer.comment_notification(@comment).deliver
end
end
class Comment::Create < Trailblazer::Operation
callback :after_save, EditorNotificationCallback
52
Wisper
52
Wisper
→ Lightweight and clean integration
52
Wisper
→ Lightweight and clean integration
→ Well tested and maintained
52
Wisper
→ Lightweight and clean integration
→ Well tested and maintained
→ Plenty of integration options
52
Wisper
→ Lightweight and clean integration
→ Well tested and maintained
→ Plenty of integration options
→ Not just for Rails or ActiveRecord
52
Wisper
→ Lightweight and clean integration
→ Well tested and maintained
→ Plenty of integration options
→ Not just for Rails or ActiveRecord
→ Great for small to medium scale, and potentially more
52
Wisper
→ Lightweight and clean integration
→ Well tested and maintained
→ Plenty of integration options
→ Not just for Rails or ActiveRecord
→ Great for small to medium scale, and potentially more
→ It’s the right tool for the job for us
(👋 & Thanks) unless questions?
Niall Burkley (Edenspiekermann_)
@niallburkley | github.com/nburkley | niallburkley.com