Slide 1

Slide 1 text

Replacing ActiveRecord callbacks with Pub/Sub edenspiekermann_ March 2nd, 2017 Niall Burkley (Edenspiekermann_) @niallburkley | github.com/nburkley | niallburkley.com

Slide 2

Slide 2 text

Callbacks ACTIVE RECORD

Slide 3

Slide 3 text

What are callbacks?

Slide 4

Slide 4 text

Business logic that you want - before or after something happens - tied to the lifecycle of the model What are callbacks?

Slide 5

Slide 5 text

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?

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

Unrelated Business Logic in your model - Violation of Single Responsibility Principle #1

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

Tight Coupling & brittle tests #2

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

11 class FeedItemGenerator def self.create(subject) FeedItem.create( subject: subject, owner: subject.user ) end end

Slide 16

Slide 16 text

12 class FeedItemGenerator def self.create(subject, published) FeedItem.create( subject: subject, owner: subject.user, published: published ) end end

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

Your Models are going to grow #3

Slide 22

Slide 22 text

15 class Post < ActiveRecord::Base after_commit :notify_users, on: :create private def notify_users PostMailer.send_notifications(self).deliver_later end end

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

Callbacks are Synchronous #4

Slide 26

Slide 26 text

19 class Image < ActiveRecord::Base after_commit :generate_image_renditions, on: :create private def generate_image_renditions ImageService.create_renditions(self) end end

Slide 27

Slide 27 text

20 Browser Controller Image ImageService POST /images create(params) create_renditions(image) image renditions image success after_create callback

Slide 28

Slide 28 text

20 Browser Controller Image ImageService POST /images create(params) create_renditions(image) image renditions image success after_create callback

Slide 29

Slide 29 text

20 Browser Controller Image ImageService POST /images create(params) create_renditions(image) image renditions image success after_create callback

Slide 30

Slide 30 text

20 Browser Controller Image ImageService POST /images create(params) create_renditions(image) image renditions image success after_create callback

Slide 31

Slide 31 text

20 Browser Controller Image ImageService POST /images create(params) create_renditions(image) image renditions image success after_create callback

Slide 32

Slide 32 text

20 Browser Controller Image ImageService POST /images create(params) create_renditions(image) image renditions image success after_create callback

Slide 33

Slide 33 text

20 Browser Controller Image ImageService POST /images create(params) create_renditions(image) image renditions image success after_create callback

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

Building a Social Platform → User feeds → Project feeds → User Notifications → Project Notifications → User Karma

Slide 42

Slide 42 text

23 class Project < ActiveRecord::Base after_commit :add_user_points, on: :create private def add_user_points UserPointsService.recalculate_points(self.user) end end

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

Wisper

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

27 Project ProjectSubscriber UserSubscriber create User create Events project_created(self) user_created(self) user_created(user) project_ created(post)

Slide 48

Slide 48 text

28 class Project < ActiveRecord::Base include Wisper::Publisher after_commit :publish_creation, on: :create private def publish_creation broadcast(:project_created, self) end end

Slide 49

Slide 49 text

29 Project.subscribe(ProjectSubscriber.new)

Slide 50

Slide 50 text

30 class ProjectSubscriber def project_created(project) UserPointsService.recalculate_points(project) end end

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

31 Too much boilerplate

Slide 55

Slide 55 text

Wisper::ActiveRecord

Slide 56

Slide 56 text

Wisper::ActiveRecord Broadcast Lifecycle Events after_create after_destroy create__{successful, failed} update__{successful, failed} destroy__{successful, failed} _committed after_commit after_rollback

Slide 57

Slide 57 text

34 class Project < ActiveRecord::Base include Wisper::Publisher after_commit :publish_creation, on: :create private def publish_creation broadcast(:project_created, self) end end

Slide 58

Slide 58 text

35 class Project < ActiveRecord::Base include Wisper.model end

Slide 59

Slide 59 text

35 class Project < ActiveRecord::Base include Wisper.model end Project.subscribe(ProjectSubscriber.new)

Slide 60

Slide 60 text

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)

Slide 61

Slide 61 text

36 Make it Asynchronous

Slide 62

Slide 62 text

Wisper::ActiveJob

Slide 63

Slide 63 text

38 Post.subscribe(ProjectSubscriber, async: true)

Slide 64

Slide 64 text

38 Post.subscribe(ProjectSubscriber, async: true)

Slide 65

Slide 65 text

38 class ProjectSubscriber def self.post_created(post) UserPointsService.recalculate_points(post) end end Post.subscribe(ProjectSubscriber, async: true)

Slide 66

Slide 66 text

39 What about tests?

Slide 67

Slide 67 text

40 class Message < ActiveRecord::Base after_create :add_author_as_watcher after_create :reset_counters! after_create :send_notification end

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

Wisper::Rspec

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

46 What have we achieved?

Slide 74

Slide 74 text

46 What have we achieved? #1 - We’ve removed unrelated business logic from our classes

Slide 75

Slide 75 text

46 What have we achieved? #1 - We’ve removed unrelated business logic from our classes #2 - Decoupled our callbacks, making them easier to test

Slide 76

Slide 76 text

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

Slide 77

Slide 77 text

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

Slide 78

Slide 78 text

47 Alternatives?

Slide 79

Slide 79 text

48 Alternatives - Observers

Slide 80

Slide 80 text

48 Alternatives - Observers class CommentObserver < ActiveRecord::Observer def after_save(comment) EditorMailer.comment_notification(comment).deliver end end

Slide 81

Slide 81 text

49 Alternatives - Decorators

Slide 82

Slide 82 text

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

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

51 Alternatives - Trailblazer

Slide 85

Slide 85 text

51 Alternatives - Trailblazer class Comment::Create < Trailblazer::Operation callback :after_save, EditorNotificationCallback

Slide 86

Slide 86 text

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

Slide 87

Slide 87 text

52 Wisper

Slide 88

Slide 88 text

52 Wisper → Lightweight and clean integration

Slide 89

Slide 89 text

52 Wisper → Lightweight and clean integration → Well tested and maintained

Slide 90

Slide 90 text

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

Slide 91

Slide 91 text

52 Wisper → Lightweight and clean integration → Well tested and maintained → Plenty of integration options → Not just for Rails or ActiveRecord

Slide 92

Slide 92 text

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

Slide 93

Slide 93 text

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

Slide 94

Slide 94 text

( & Thanks) unless questions? Niall Burkley (Edenspiekermann_) @niallburkley | github.com/nburkley | niallburkley.com