Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Replacing ActiveRecord callbacks with Pub-Sub

Replacing ActiveRecord callbacks with Pub-Sub

As your application grows ActiveRecord callbacks can easily get out of hand - creating a chain of dependencies between your models and mixing in business logic where it doesn't belong.

On some recent projects, I've been using the 'wisper' gem to replace some callbacks with a simple publish/subscribe pattern decoupling callbacks from models.

I'd like talk about the benefits of using this approach and give a brief run through of how to go about it.

Niall Burkley

March 02, 2017
Tweet

Other Decks in Technology

Transcript

  1. Replacing ActiveRecord callbacks with Pub/Sub edenspiekermann_ March 2nd, 2017 Niall

    Burkley (Edenspiekermann_) @niallburkley | github.com/nburkley | niallburkley.com
  2. Business logic that you want - before or after something

    happens - tied to the lifecycle of the model What are callbacks?
  3. 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. 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
  5. 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
  6. 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. 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. 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
  9. 10 class Post < ActiveRecord::Base after_commit :generate_feed_item, on: :create private

    def generate_feed_item FeedItemGenerator.create(self) end end
  10. 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. 13 class Post < ActiveRecord::Base after_commit :generate_feed_item, on: :create private

    def generate_feed_item FeedItemGenerator.create(self) end end
  12. 13 class Post < ActiveRecord::Base after_commit :generate_feed_item, on: :create private

    def generate_feed_item FeedItemGenerator.create(self) end end
  13. 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
  14. 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
  15. 15 class Post < ActiveRecord::Base after_commit :notify_users, on: :create private

    def notify_users PostMailer.send_notifications(self).deliver_later end end
  16. 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. 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
  18. 19 class Image < ActiveRecord::Base after_commit :generate_image_renditions, on: :create private

    def generate_image_renditions ImageService.create_renditions(self) end end
  19. 21 Browser Controller Image Feed Generator POST /images create(params) create_renditions(image)

    image success ImageService background process image renditions
  20. 21 Browser Controller Image Feed Generator POST /images create(params) create_renditions(image)

    image success ImageService background process image renditions
  21. 21 Browser Controller Image Feed Generator POST /images create(params) create_renditions(image)

    image success ImageService background process image renditions
  22. 21 Browser Controller Image Feed Generator POST /images create(params) create_renditions(image)

    image success ImageService background process image renditions
  23. 21 Browser Controller Image Feed Generator POST /images create(params) create_renditions(image)

    image success ImageService background process image renditions
  24. 21 Browser Controller Image Feed Generator POST /images create(params) create_renditions(image)

    image success ImageService background process image renditions
  25. 21 Browser Controller Image Feed Generator POST /images create(params) create_renditions(image)

    image success ImageService background process image renditions
  26. Building a Social Platform → User feeds → Project feeds

    → User Notifications → Project Notifications → User Karma
  27. 23 class Project < ActiveRecord::Base after_commit :add_user_points, on: :create private

    def add_user_points UserPointsService.recalculate_points(self.user) end end
  28. 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
  29. 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
  30. 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
  31. 28 class Project < ActiveRecord::Base include Wisper::Publisher after_commit :publish_creation, on:

    :create private def publish_creation broadcast(:project_created, self) end end
  32. 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
  33. 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
  34. 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
  35. 34 class Project < ActiveRecord::Base include Wisper::Publisher after_commit :publish_creation, on:

    :create private def publish_creation broadcast(:project_created, self) end end
  36. 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)
  37. 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
  38. 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
  39. 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
  40. 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
  41. 46 What have we achieved? #1 - We’ve removed unrelated

    business logic from our classes #2 - Decoupled our callbacks, making them easier to test
  42. 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
  43. 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
  44. 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
  45. 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
  46. 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
  47. 52 Wisper → Lightweight and clean integration → Well tested

    and maintained → Plenty of integration options
  48. 52 Wisper → Lightweight and clean integration → Well tested

    and maintained → Plenty of integration options → Not just for Rails or ActiveRecord
  49. 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
  50. 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