module EventBus
@subscribers = Hash.new { |h, k| h[k] = [] }
class << self
def subscribe(event_class, handler = nil, &block)
callable = handler || block
raise ArgumentError, "handler must respond to #call" unless callable.respond_to?(:call)
@subscribers[event_class] << callable
end
def publish(event)
@subscribers[event.class].each do |handler|
begin
handler.call(event)
rescue => e
Rails.error.report(e, handled: true, context: { event: event.class.name })
end
end
event
end
def reset!
@subscribers = Hash.new { |h, k| h[k] = [] }
end
end
end
module Events
OrderPlaced = Struct.new(:order_id, :total_cents, :occurred_at) do
def self.from(order)
new(order.id, order.total_cents, Time.current)
end
def to_h
{ order_id: order_id, total_cents: total_cents, occurred_at: occurred_at.iso8601 }
end
end
end
class OrdersController < ApplicationController
def create
order = nil
ActiveRecord::Base.transaction do
order = Order.create!(order_params)
order.mark_placed!
end
# Publish only after the transaction has committed.
EventBus.publish(Events::OrderPlaced.from(order))
render json: { id: order.id, status: order.status }, status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end
private
def order_params
params.require(:order).permit(:total_cents, line_items: [:sku, :quantity])
end
end
Rails.application.config.to_prepare do
EventBus.reset!
EventBus.subscribe(Events::OrderPlaced) do |event|
OrderConfirmationMailerJob.perform_later(event.order_id)
end
EventBus.subscribe(Events::OrderPlaced) do |event|
AnalyticsTrackJob.perform_later(
name: "order.placed",
properties: event.to_h
)
end
end
This snippet shows a small in-process domain event bus that lets a Rails application publish domain events and dispatch them to interested handlers without coupling the code that raises the event to the code that reacts to it. The pattern is the classic publish/subscribe (observer) idea scoped to a single process: business logic announces that something happened, and any number of subscribers respond independently.
In EventBus, the bus is a singleton-style module holding a registry keyed by event class. subscribe appends a callable handler for a given event type, and publish looks up every handler registered for that event's exact class and invokes it. Handlers are stored as anything responding to call, so lambdas, method objects, or service classes all fit. Each handler runs inside a rescue so one failing subscriber never prevents the others from running; failures are reported to Rails.error rather than raised, which keeps event dispatch best-effort by design.
In OrderPlaced event, the event itself is a plain immutable value object built with Struct. It carries only the data a subscriber needs — order_id, total_cents, and occurred_at — and defaults the timestamp at construction. Keeping events as dumb data means subscribers never reach back into mutable application state and the event can be logged or serialized as-is.
In OrdersController, the controller performs the write inside a transaction and calls EventBus.publish only after the record is durably committed via after_commit-style ordering, avoiding the common pitfall of firing events for a transaction that later rolls back. The controller stays thin: it knows nothing about email, analytics, or inventory.
In subscribers initializer, the wiring lives in one boot-time file so the set of reactions is easy to audit. Each subscription maps OrderPlaced to a concrete responsibility — enqueuing a confirmation mailer job and tracking an analytics event — and the handlers push slow work onto background jobs rather than doing it inline.
The main trade-off is that this bus is synchronous and single-process: it is simple and transactional-friendly but does not survive restarts or cross service boundaries. It is ideal for decoupling modules inside one Rails app; for cross-service or durable delivery, an outbox plus a real broker is the next step.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.