module DomainEvents
class Registry
def initialize
@subscribers = Hash.new { |h, k| h[k] = [] }
end
def subscribe(event_class, subscriber)
@subscribers[event_class] << subscriber
end
def publish(event)
@subscribers[event.class].each do |subscriber|
subscriber.call(event)
end
end
end
def self.registry
@registry ||= Registry.new
end
def self.subscribe(event_class, subscriber)
registry.subscribe(event_class, subscriber)
end
def self.publish(event)
registry.publish(event.freeze)
end
end
module Events
OrderPlaced = Struct.new(:order_id, :total_cents, :customer_id, keyword_init: true) do
def total
total_cents / 100.0
end
end
end
class Order < ApplicationRecord
belongs_to :customer
has_many :line_items, dependent: :destroy
enum status: { pending: 0, placed: 1, shipped: 2 }
after_commit :announce_placed, on: :update, if: :saved_change_to_placed?
def total_cents
line_items.sum { |li| li.unit_price_cents * li.quantity }
end
private
def saved_change_to_placed?
saved_change_to_status? && placed?
end
def announce_placed
DomainEvents.publish(
Events::OrderPlaced.new(
order_id: id,
total_cents: total_cents,
customer_id: customer_id
)
)
end
end
module Subscribers
SendConfirmationEmail = lambda do |event|
OrderMailer.confirmation(event.order_id).deliver_later
end
AwardLoyaltyPoints = lambda do |event|
points = (event.total_cents / 100)
LoyaltyAccount.for(event.customer_id).credit(points)
end
ReserveInventory = lambda do |event|
ReserveInventoryJob.perform_later(event.order_id)
end
end
DomainEvents.subscribe(Events::OrderPlaced, Subscribers::SendConfirmationEmail)
DomainEvents.subscribe(Events::OrderPlaced, Subscribers::AwardLoyaltyPoints)
DomainEvents.subscribe(Events::OrderPlaced, Subscribers::ReserveInventory)
This snippet shows how an in-app domain-event bus replaces the tangle of after_commit callbacks that tends to grow inside ActiveRecord models. When every side effect of creating an order — sending mail, updating inventory, awarding loyalty points — is wired directly into the model as a callback, the model becomes a hidden orchestrator: it knows about mailers, jobs, and unrelated subsystems, and tests must stub half the application to save a record. Domain events invert that dependency. The model announces that something happened; interested subscribers decide what to do about it.
In DomainEvents bus, DomainEvents.publish walks a registry of subscribers keyed by event class and hands each a frozen event object. Subscription is explicit through DomainEvents.subscribe, so the wiring lives in one readable place rather than being scattered across model callbacks. The bus is deliberately tiny and synchronous; it is a routing table, not a message broker.
The event itself, OrderPlaced event, is an immutable value object built with Struct. It carries only primitives (order_id, total_cents, customer_id) rather than the live model, which keeps subscribers from reaching back into ActiveRecord state that may have changed and makes the payload safe to serialize onto a job later.
Order model keeps a single after_commit hook, but instead of doing work it only calls DomainEvents.publish. Publishing on after_commit matters: the event fires only once the transaction has durably committed, so a subscriber that enqueues a job will never reference a row that got rolled back — a classic pitfall of doing side effects in after_save.
Subscribers registration is the composition root where behavior is assembled. Each subscriber is a plain object responding to call; heavy work like email is pushed to a background job via perform_later so the publishing request stays fast, while cheap in-process updates run inline.
The trade-off is indirection — following control flow now means consulting the registry rather than reading the model top to bottom. In exchange the model stops depending on unrelated subsystems, subscribers become independently testable, and new reactions are added by subscribing rather than by editing a callback chain. This pattern fits best once a single event has three or more consequences.
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.