Replacing ActiveRecord callbacks with Pub/Sub

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

Transcript of Replacing ActiveRecord callbacks with Pub/Sub

Page 1: Replacing ActiveRecord callbacks with Pub/Sub

Replacing ActiveRecord callbacks with Pub/Sub

edenspiekermann_

March 2nd, 2017

Niall Burkley (Edenspiekermann_) @niallburkley | github.com/nburkley | niallburkley.com

Page 2: Replacing ActiveRecord callbacks with Pub/Sub

CallbacksACTIVE RECORD

Page 3: Replacing ActiveRecord callbacks with Pub/Sub

What are callbacks?

Page 4: Replacing ActiveRecord callbacks with Pub/Sub

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

What are callbacks?

Page 5: Replacing ActiveRecord callbacks with Pub/Sub

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?

Page 6: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 7: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 8: Replacing ActiveRecord callbacks with Pub/Sub

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

#1

Page 9: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 10: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 11: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 12: Replacing ActiveRecord callbacks with Pub/Sub

Tight Coupling & brittle tests

#2

Page 13: Replacing ActiveRecord callbacks with Pub/Sub

10

class Post < ActiveRecord::Base

after_commit :generate_feed_item, on: :create private

def generate_feed_item FeedItemGenerator.create(self) end

end

Page 14: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 15: Replacing ActiveRecord callbacks with Pub/Sub

11

class FeedItemGenerator

def self.create(subject) FeedItem.create( subject: subject, owner: subject.user

) end

end

Page 16: Replacing ActiveRecord callbacks with Pub/Sub

12

class FeedItemGenerator

def self.create(subject, published) FeedItem.create( subject: subject, owner: subject.user, published: published

) end

end

Page 17: Replacing ActiveRecord callbacks with Pub/Sub

13

class Post < ActiveRecord::Base

after_commit :generate_feed_item, on: :create private

def generate_feed_item FeedItemGenerator.create(self) end

end

Page 18: Replacing ActiveRecord callbacks with Pub/Sub

13

class Post < ActiveRecord::Base

after_commit :generate_feed_item, on: :create private

def generate_feed_item FeedItemGenerator.create(self) end

end

Page 19: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 20: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 21: Replacing ActiveRecord callbacks with Pub/Sub

Your Models are going to grow

#3

Page 22: Replacing ActiveRecord callbacks with Pub/Sub

15

class Post < ActiveRecord::Base

after_commit :notify_users, on: :create private

def notify_users PostMailer.send_notifications(self).deliver_later

end

end

Page 23: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 24: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 25: Replacing ActiveRecord callbacks with Pub/Sub

Callbacks are Synchronous

#4

Page 26: Replacing ActiveRecord callbacks with Pub/Sub

19

class Image < ActiveRecord::Base

after_commit :generate_image_renditions, on: :create private

def generate_image_renditions ImageService.create_renditions(self) end

end

Page 27: Replacing ActiveRecord callbacks with Pub/Sub

20

Browser Controller Image ImageService

POST /images

create(params)

create_renditions(image)

image renditionsimage

success

after_create callback

Page 28: Replacing ActiveRecord callbacks with Pub/Sub

20

Browser Controller Image ImageService

POST /images

create(params)

create_renditions(image)

image renditionsimage

success

after_create callback

Page 29: Replacing ActiveRecord callbacks with Pub/Sub

20

Browser Controller Image ImageService

POST /images

create(params)

create_renditions(image)

image renditionsimage

success

after_create callback

Page 30: Replacing ActiveRecord callbacks with Pub/Sub

20

Browser Controller Image ImageService

POST /images

create(params)

create_renditions(image)

image renditionsimage

success

after_create callback

Page 31: Replacing ActiveRecord callbacks with Pub/Sub

20

Browser Controller Image ImageService

POST /images

create(params)

create_renditions(image)

image renditionsimage

success

after_create callback

Page 32: Replacing ActiveRecord callbacks with Pub/Sub

20

Browser Controller Image ImageService

POST /images

create(params)

create_renditions(image)

image renditionsimage

success

after_create callback

Page 33: Replacing ActiveRecord callbacks with Pub/Sub

20

Browser Controller Image ImageService

POST /images

create(params)

create_renditions(image)

image renditionsimage

success

after_create callback

Page 34: Replacing ActiveRecord callbacks with Pub/Sub

21

Browser Controller Image Feed Generator

POST /images

create(params)

create_renditions(image)image

success

ImageService

background process

image renditions

Page 35: Replacing ActiveRecord callbacks with Pub/Sub

21

Browser Controller Image Feed Generator

POST /images

create(params)

create_renditions(image)image

success

ImageService

background process

image renditions

Page 36: Replacing ActiveRecord callbacks with Pub/Sub

21

Browser Controller Image Feed Generator

POST /images

create(params)

create_renditions(image)image

success

ImageService

background process

image renditions

Page 37: Replacing ActiveRecord callbacks with Pub/Sub

21

Browser Controller Image Feed Generator

POST /images

create(params)

create_renditions(image)image

success

ImageService

background process

image renditions

Page 38: Replacing ActiveRecord callbacks with Pub/Sub

21

Browser Controller Image Feed Generator

POST /images

create(params)

create_renditions(image)image

success

ImageService

background process

image renditions

Page 39: Replacing ActiveRecord callbacks with Pub/Sub

21

Browser Controller Image Feed Generator

POST /images

create(params)

create_renditions(image)image

success

ImageService

background process

image renditions

Page 40: Replacing ActiveRecord callbacks with Pub/Sub

21

Browser Controller Image Feed Generator

POST /images

create(params)

create_renditions(image)image

success

ImageService

background process

image renditions

Page 41: Replacing ActiveRecord callbacks with Pub/Sub

Building a Social Platform

→ User feeds → Project feeds → User Notifications → Project Notifications → User Karma

Page 42: Replacing ActiveRecord callbacks with Pub/Sub

23

class Project < ActiveRecord::Base

after_commit :add_user_points, on: :create private

def add_user_points UserPointsService.recalculate_points(self.user) end

end

Page 43: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 44: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 45: Replacing ActiveRecord callbacks with Pub/Sub

Wisper

Page 46: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 47: Replacing ActiveRecord callbacks with Pub/Sub

27

Project ProjectSubscriber

UserSubscriber

create

Usercreate

Events

project_created(self)

user_created(self)

user_created(user)

project_ created(post)

<broadcast>

<broadcast>

Page 48: Replacing ActiveRecord callbacks with Pub/Sub

28

class Project < ActiveRecord::Base

include Wisper::Publisher after_commit :publish_creation, on: :create

private

def publish_creation broadcast(:project_created, self)

end end

Page 49: Replacing ActiveRecord callbacks with Pub/Sub

29

Project.subscribe(ProjectSubscriber.new)

Page 50: Replacing ActiveRecord callbacks with Pub/Sub

30

class ProjectSubscriber

def project_created(project) UserPointsService.recalculate_points(project)

end

end

Page 51: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 52: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 53: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 54: Replacing ActiveRecord callbacks with Pub/Sub

31

Too much boilerplate

Page 55: Replacing ActiveRecord callbacks with Pub/Sub

Wisper::ActiveRecord

Page 56: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 57: Replacing ActiveRecord callbacks with Pub/Sub

34

class Project < ActiveRecord::Base

include Wisper::Publisher after_commit :publish_creation, on: :create

private

def publish_creation broadcast(:project_created, self)

end end

Page 58: Replacing ActiveRecord callbacks with Pub/Sub

35

class Project < ActiveRecord::Base

include Wisper.model end

Page 59: Replacing ActiveRecord callbacks with Pub/Sub

35

class Project < ActiveRecord::Base

include Wisper.model end

Project.subscribe(ProjectSubscriber.new)

Page 60: Replacing ActiveRecord callbacks with Pub/Sub

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)

Page 61: Replacing ActiveRecord callbacks with Pub/Sub

36

Make it Asynchronous

Page 62: Replacing ActiveRecord callbacks with Pub/Sub

Wisper::ActiveJob

Page 63: Replacing ActiveRecord callbacks with Pub/Sub

38

Post.subscribe(ProjectSubscriber, async: true)

Page 64: Replacing ActiveRecord callbacks with Pub/Sub

38

Post.subscribe(ProjectSubscriber, async: true)

Page 65: Replacing ActiveRecord callbacks with Pub/Sub

38

class ProjectSubscriber

def self.post_created(post) UserPointsService.recalculate_points(post)

end

end

Post.subscribe(ProjectSubscriber, async: true)

Page 66: Replacing ActiveRecord callbacks with Pub/Sub

39

What about tests?

Page 67: Replacing ActiveRecord callbacks with Pub/Sub

40

class Message < ActiveRecord::Base

after_create :add_author_as_watcher after_create :reset_counters! after_create :send_notification

end

Page 68: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 69: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 70: Replacing ActiveRecord callbacks with Pub/Sub

Wisper::Rspec

Page 71: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 72: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 73: Replacing ActiveRecord callbacks with Pub/Sub

46

What have we achieved?

Page 74: Replacing ActiveRecord callbacks with Pub/Sub

46

What have we achieved?

#1 - We’ve removed unrelated business logic from our classes

Page 75: Replacing ActiveRecord callbacks with Pub/Sub

46

What have we achieved?

#1 - We’ve removed unrelated business logic from our classes

#2 - Decoupled our callbacks, making them easier to test

Page 76: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 77: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 78: Replacing ActiveRecord callbacks with Pub/Sub

47

Alternatives?

Page 79: Replacing ActiveRecord callbacks with Pub/Sub

48

Alternatives - Observers

Page 80: Replacing ActiveRecord callbacks with Pub/Sub

48

Alternatives - Observers

class CommentObserver < ActiveRecord::Observer

def after_save(comment) EditorMailer.comment_notification(comment).deliver end

end

Page 81: Replacing ActiveRecord callbacks with Pub/Sub

49

Alternatives - Decorators

Page 82: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 83: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 84: Replacing ActiveRecord callbacks with Pub/Sub

51

Alternatives - Trailblazer

Page 85: Replacing ActiveRecord callbacks with Pub/Sub

51

Alternatives - Trailblazer

class Comment::Create < Trailblazer::Operation callback :after_save, EditorNotificationCallback

Page 86: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 87: Replacing ActiveRecord callbacks with Pub/Sub

52

Wisper

Page 88: Replacing ActiveRecord callbacks with Pub/Sub

52

Wisper→ Lightweight and clean integration

Page 89: Replacing ActiveRecord callbacks with Pub/Sub

52

Wisper→ Lightweight and clean integration

→ Well tested and maintained

Page 90: Replacing ActiveRecord callbacks with Pub/Sub

52

Wisper→ Lightweight and clean integration

→ Well tested and maintained

→ Plenty of integration options

Page 91: Replacing ActiveRecord callbacks with Pub/Sub

52

Wisper→ Lightweight and clean integration

→ Well tested and maintained

→ Plenty of integration options

→ Not just for Rails or ActiveRecord

Page 92: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 93: Replacing ActiveRecord callbacks with Pub/Sub

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

Page 94: Replacing ActiveRecord callbacks with Pub/Sub

(👋 & Thanks) unless questions?

Niall Burkley (Edenspiekermann_) @niallburkley | github.com/nburkley | niallburkley.com