Rails 7.0 - Decoupling with Events (or Commands / Service Objects)
Decoupling for slim Models and slim Controllers
 
    
  NOTE: the concepts work (we use them at work) - but this particular code hasn’t yet been tested. (just recording these ideas for me, but feel free to let me know of any suggestions)
Organizing Code
In Rails its all too easy to accidentally tightly couple controller activities with follow-up actions - resulting in bloated controllers and / or tight coupling with models.
A relatively simple fix to help with this is to use Events.
Observer & Pub/Sub differences
Ruby and Rails Environment
Using Rails 7 & Ruby 3.1.2 - I found that it is important to update my ruby environment - so before we start this is what I didn’t remove errors:
# I've had the error several times without updating:
# /Users/btihen/.rbenv/versions/3.1.0/lib/ruby/gems/3.1.0/gems/bundler-2.3.8/lib/bundler/rubygems_ext.rb:18:in `source': uninitialized constant Gem::Source (NameError)
#
#       (defined?(@source) && @source) || Gem::Source::Installed.new
#                                            ^^^^^^^^
# Did you mean?  Gem::SourceList
# this seems to fix it:
# https://bundler.io/guides/bundler_2_upgrade.html
# https://stackoverflow.com/questions/4859600/bundler-throws-uninitialized-constant-gemsilentui-nameerror-error-after-upgr
rbenv local 3.1.2
gem update --system
gem install bundler
gem install rails
rbenv rehash
Rails Project - Simple Blog
Since my other projects are using esbuild I use that here too
rails new rails_events -T --database=postgresql --css=bootstrap --javascript=esbuild
cd rails_events
bin/rails db:create
# add the helper gem
bundle add wisper_next
NOTE: wisper_next works very similar to the other gems (wisper, event_bg_bus, ma).
Idea
Lets do some activities after a user changes:
- on create: A confirmation email, an invoice email and some setup for the service.
- on change: A confirmation email of change & setup change as needed
Fundamentally, events should be a past tense fact: *
Code
First lets create the users model
bin/rails g scaffold user name config email
User Hooks
Lets do the simplest possible thing - we can use after commits and use them to start activities.
This is ok when very simple, but is highly coupled and requires changes to the user model instead of classes designed to handle the after creation processes.
class User < ApplicationRecord
  after_commit :user_created_event, on: :create
  after_commit :user_updated_event, on: :update
  def user_created_event
    # lots of complicated business logic
    puts "UserCreatedConfirmJob: #{email} - send creation confirmation email"
    puts "UserCreatedConfigJob: #{self.changes} - create user setup"
  end
  def user_updated_event
    # lots of complicated business logic
    puts "UserUpdatedConfirmJob: #{email} - send change confirmation email"
    puts "UserUpdatedConfirmJob: #{self.changes} - updated user account config"
  end
end
Controller Alternative
In reality the user isn’t responsible for the app so lets try moving the business logic into the Controller where the change is made. Lets move our Event methods to the controlle, change the create & update actions & leave the model empty.
# app/models/user.rb
class User < ApplicationRecord
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  # ...
  def create
    @user = User.new(user_params)
    respond_to do |format|
      if @user.save
        # add event call here on success
        user_created_event(@user, @user.changes)
        format.html { redirect_to user_url(@user), notice: "User was successfully created." }
        format.json { render :show, status: :created, location: @user }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end
  def update
    respond_to do |format|
      if @user.update(user_params)
        # add event call here on success
        user_updated_event(@user, @user.changes)
        format.html { redirect_to user_url(@user), notice: "User was successfully updated." }
        format.json { render :show, status: :ok, location: @user }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end
  private
  def user_created_event(user, changes)
    changed_attributes = changes
    # lots of complicated business logic
    puts "UserCreatedConfirmJob: #{user.email} - send creation confirmation email"
    puts "UserCreatedConfigJob: #{changed_attributes} - create user setup"
  end
  def user_updated_event(user, changes)
    changed_attributes = changes
    # lots of complicated business logic
    puts "UserUpdatedConfirmJob #{user.email} - send change confirmation email"
    puts "UserUpdatedConfigJob: #{changed_attributes} - updated user account config"
  end
end
this is a bit better, now the business logic isn’t embedded in the models.
Using Commands to Decouple
Ideally, I like having Business Logic in its own class and decoupled from Rails. Lets move our methods into classes.
# app/commands/user_created_command.rb
class UserCreatedCommand
  attr_reader :user, :changes
  def initialize(user:, changes: nil)
    @user = user
    @changes = changes
  end
  def self.call(user:, changes: nil)
    new(user: user, changes: changes).run
  end
  def run
    # lots of complicated business logic
    puts "UserCreatedConfirmJob: #{user.email} - send creation confirmation email"
    puts "UserCreatedConfigJob: #{changes} - create user setup"
  end
end
# app/commands/user_updated_command.rb
class UserUpdatedCommand
  attr_reader :user, :changes
  def initialize(user:, changes:)
    @user = user
    @changes = changes
  end
  def self.call(user:, changes:)
    new(user: user, changes: changes).run
  end
  def run
    puts "UserUpdatedConfirmJob: #{user.email} - send change confirmation email"
    puts "UserUpdatedConfigJob: #{changes} - updated user account config"
  end
end
so now we can adjust the controller
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  # ...
  def create
    @user = User.new(user_params)
    respond_to do |format|
      if @user.save
        # add event call here on success
        UserCreatedCommand.call(@user, @user.changes)
        format.html { redirect_to user_url(@user), notice: "User was successfully created." }
        format.json { render :show, status: :created, location: @user }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end
  def update
    respond_to do |format|
      if @user.update(user_params)
        # add event call here on success
        UserUpdatedCommand.call(@user, @user.changes)
        format.html { redirect_to user_url(@user), notice: "User was successfully updated." }
        format.json { render :show, status: :ok, location: @user }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end
end
Commands allow lots of decoupling
Events
Commands are great, but what if we want well partitioned code, but various parts of the code need to be notified and act on events (if activated)?
This is where events are great!
Lets build our listeners (notice the prefix and async):
# app/listeners/user_created_listener.rb
class UserCreatedListener
  include Wisper.subscriber(prefix: true, async: true)
  def on_user_created(user, changes)
    UserCreatedCommand.call(user: user, changes: user.changes)
  end
end
# app/listeners/user_updated_listener.rb
class UserUpdatedListener
  include Wisper.subscriber(prefix: true, async: true)
  def on_user_updated(user, changes)
    UserUpdatedCommand.call(user: user, changes: user.changes)
  end
end
We need to define an event_bus and register our listeners (they can be registered on the fly too) - not just at start-up
# config/initializers/event_bus.rb
EVENT_BUS = WisperNext::Events.new
EVENT_BUS.subscribe(UserCreatedListener.new)
EVENT_BUS.subscribe(UserUpdatedListener.new)
Now we can update the controller again (we need to add the include and broadcast)
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  include WisperNext.publisher
  # ...
  def create
    @user = User.new(user_params)
    respond_to do |format|
      if @user.save
        # add event call here on success - all listers for this event must have :on_user_created
        EVENT_BUS.broadcast(:user_created, user: @user, changes: @user.changes)
        # UserCreatedCommand.call(@user, @user.changes)
        # user_created_event(@user, @user.changes)
        format.html { redirect_to user_url(@user), notice: "User was successfully created." }
        format.json { render :show, status: :created, location: @user }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end
  def update
    respond_to do |format|
      if @user.update(user_params)
        # add event call here on success - all listers for this event must have :on_user_updated
        EVENT_BUS.broadcast(:user_updated, user: user, changes: @user.changes)
        # UserUpdatedCommand.call(@user, @user.changes)
        # user_updated_event(@user, @user.changes)
        format.html { redirect_to user_url(@user), notice: "User was successfully updated." }
        format.json { render :show, status: :ok, location: @user }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end
end
Now we are quite flexible - but debugging fully async and decoupled behaviors is difficult - so I generally stay with calling commands directly until I need the configuration flexibility
summary
Commands are the sweet spot - until config flexibility is needed
Gems
- ma (active) - https://gitlab.com/kris.leech/ma - objects & async
- wisper_next (active) - https://gitlab.com/kris.leech/wisper_next - (messages with async)
- eventmachine (active) - https://github.com/eventmachine/eventmachine/
- event_bg_bus (active) - https://github.com/indiereign/event_bg_bus (with backgrounding options)
- rails_event_store (active) - https://github.com/RailsEventStore/rails_event_store - (focus on DDD - event bus and event storage)
- wisper (inactive 3 years) - https://github.com/krisleech/wisper
- resugan (inactive 5 years) - https://github.com/jedld/resugan
- event_bus (inactive 8 years) - https://github.com/kevinrutherford/event_bus
Resources
- https://www.youtube.com/watch?v=q0LtzYTrmMY&t=270s
- https://www.toptal.com/ruby-on-rails/the-publish-subscribe-pattern-on-rails
- https://www.igvita.com/2008/05/27/ruby-eventmachine-the-speed-demon/
- https://www.mikeperham.com/2010/03/30/using-activerecord-with-eventmachine/
- https://www.mikeperham.com/2010/01/27/scalable-ruby-processing-with-eventmachine/
Going Further with Events & DDD
- https://github.com/RailsEventStore/rails_event_store
- https://medium.com/kontenainc/event-driven-microservices-with-rabbitmq-and-ruby-7a54ae01b285
- https://www.globalapptesting.com/engineering/design-the-unknown-with-the-help-of-event-storming
- Event-Driven Architecture and Messaging Patterns for Ruby Microservices - Kirill Shevchenko - https://www.youtube.com/watch?v=e9AAUy4kkek
