OCP Series · 4 of 5
The Pub-Sub Pattern
Where pub-sub comes from, why every team eventually wants to react to events without the publisher knowing, and how Rails' ActiveSupport::Notifications powers a stable extension point across the whole framework.
Where this rule comes from
The Publisher-Subscriber pattern (and its close cousin, Observer) is older than object-oriented programming. The 1994 Gang of Four book defined Observer as "a one-to-many dependency such that when one object changes state, all its dependents are notified and updated automatically." The pattern's core move: the publisher does not know who is listening. New subscribers attach themselves; old subscribers detach. The publisher fires the event.
In Rails, the pattern shows up in two places. The first is ActiveRecord callbacks: after_commit, after_create, after_destroy. These are a constrained form of pub-sub where the publisher (the model) and the subscriber (the callback) live in the same class. That works for small apps and breaks down quickly for larger ones, because the callbacks accumulate on the model and the model becomes a junk drawer of "everyone's reaction to this thing happening."
The second place is ActiveSupport::Notifications, introduced in Rails 3 in 2010. It is a process-wide pub-sub bus that is woven through Rails core: SQL queries publish "sql.active_record" events, controller actions publish "process_action.action_controller" events, mailers publish "deliver.action_mailer" events, cache reads and writes publish events, view rendering publishes events. The famous "Completed 200 OK in 45ms" line in your Rails logs is a subscriber on the controller event.
The OCP move pub-sub enforces is: the publisher publishes an event and forgets about it. Any number of subscribers can react, each in their own file, with their own concerns. Adding metrics, logging, audit trails, analytics, or webhooks does not require touching the publisher's code. The publisher stays closed; the listening side is open.
The anti-pattern
Picture a Rails app where the team uses ActiveRecord callbacks for everything. Orders trigger emails, metrics, audit logs, webhooks. The Order model collects callbacks over time:
class Order < ApplicationRecord
after_create :send_confirmation_email
after_create :record_revenue_metric
after_create :enqueue_inventory_update
after_create :emit_audit_log
after_create :notify_webhook_subscribers
after_create :update_user_lifetime_value
after_create :sync_to_data_warehouse
after_update :send_status_change_email, if: :status_changed?
after_destroy :emit_audit_log_for_destroy
# ... 12 more
private
def send_confirmation_email; OrderMailer.confirmation(self).deliver_later; end
def record_revenue_metric; StatsD.increment("orders.created", tags: ["currency:#{currency}"]); end
def enqueue_inventory_update; InventoryWorker.perform_async(id); end
# ... 17 more callback methods
end The Order model has become a coordination point for every team in the company. The marketing team owns send_confirmation_email. The analytics team owns record_revenue_metric and sync_to_data_warehouse. The compliance team owns emit_audit_log. The integrations team owns notify_webhook_subscribers. Five teams, one file, every team's change opens a Pull Request against order.rb.
Worse, the failure modes interact. A callback that raises rolls back the transaction, so a logging bug can prevent the order from saving. A callback that runs synchronously inside the transaction holds open the database connection while it calls Mailchimp. "What happens after an order is created" is now a 20-line list of side effects that nobody fully understands, because each addition was reviewed in isolation by the team that added it.
How Rails solves it (and how you can use the same shape)
ActiveSupport::Notifications gives you a clean pub-sub bus that lives entirely outside the model. The Order class publishes an event when something interesting happens, and that is its only responsibility:
class Order < ApplicationRecord
# No after_create callbacks. The model is back to being a model.
after_commit on: :create do
ActiveSupport::Notifications.instrument("order.created",
order_id: id,
amount_cents: total_cents,
currency: currency,
user_id: user_id
)
end
end One instrument call, with a name and a payload. The model publishes; it does not know who is listening. Subscribers each live in their own file:
# app/subscribers/order_confirmation_subscriber.rb
ActiveSupport::Notifications.subscribe("order.created") do |*, payload|
OrderMailer.confirmation(payload[:order_id]).deliver_later
end
# app/subscribers/order_metrics_subscriber.rb
ActiveSupport::Notifications.subscribe("order.created") do |*, payload|
StatsD.increment("orders.created", tags: ["currency:#{payload[:currency]}"])
StatsD.histogram("orders.amount_cents", payload[:amount_cents])
end
# app/subscribers/order_webhook_subscriber.rb
ActiveSupport::Notifications.subscribe("order.created") do |*, payload|
WebhookDispatchJob.perform_later("order.created", payload)
end
# app/subscribers/order_audit_subscriber.rb
ActiveSupport::Notifications.subscribe("order.created") do |*, payload|
AuditLog.create!(event: "order.created", payload: payload)
end
Four subscribers, four files, four teams. Each team owns its file and only its file. When the analytics team wants to add a new metric, the change is a one-line edit to order_metrics_subscriber.rb. The Order model is untouched. The other subscribers are untouched. The blast radius is the one team's own file.
Where Rails publishes events for you
ActiveSupport::Notifications is not only for your code. Rails core publishes events for almost every interesting operation it performs. You can subscribe to these to add behavior without modifying Rails:
"sql.active_record", every SQL query Rails issues. The Rails log line "User Load (1.2ms) SELECT…" is a subscriber on this event."process_action.action_controller", every controller action that completes (or fails). Payload includes status, duration, view runtime, DB runtime, allocations."deliver.action_mailer", every email sent. Payload includes recipient, subject, body size."cache_read.active_support","cache_write.active_support","cache_delete.active_support", every cache operation."render_template.action_view", every view template render.
This is the most under-used extension point in Rails. Want to log slow SQL queries to a separate file? Subscribe to "sql.active_record", filter on duration, write to a logger. Want to send your N+1 detector data to your APM? Subscribe and forward. Want to add request tracing? Subscribe to "process_action" and emit spans.
Tools like New Relic, Datadog, Skylight, and Honeycomb all work primarily by subscribing to these events. They never monkey-patch Rails; they listen.
Why this design holds up
Four benefits, each one a fix for a specific failure of the callback-stack version.
The publisher stays focused. Order is back to being a class about orders. It publishes one event when an order is created. The marketing, analytics, compliance, and integrations teams are no longer editing its file. The model's reasons to change collapse from many back down to one.
Subscribers fail in isolation. A bug in order_audit_subscriber.rb does not roll back the order. Each subscriber runs in its own block and its own error handling. The order is saved; the audit log is missing; the team gets paged. No customer sees a 500.
Subscribers can be added or removed without touching the publisher. A new compliance requirement to log to a different system is a new subscriber file. The Order model does not know it happened. When the requirement is removed, the file is deleted, and again the Order model is unaware.
Tests get cleaner. A test for the Order model asserts that the order was created. It does not need to stub the mailer, the metrics library, the webhook job, and the audit log. The test scopes itself to the publisher's responsibility, and the subscribers have their own tests that scope to their responsibility.
When you should not reach for pub-sub
Pub-sub has a real cost: indirection. When something goes wrong, finding "who else reacted to this event" requires grepping for the event name across the codebase. That cost is worth paying when many independent things react to the same event. It is not worth paying for:
- A single side effect. If the only thing that happens after an order is created is sending a confirmation email, an explicit method call from the controller or a service object is clearer than publishing an event with one listener.
- Synchronous workflows where order matters. Pub-sub does not guarantee subscriber order, and most subscribers do not expect to influence the publisher. If step B depends on step A having succeeded, that is a workflow, not pub-sub. A service object that calls A then B is the right shape.
- Anything that needs to roll back the publisher. Subscribers are fire-and-forget. If a subscriber failing should prevent the order from saving, that is not pub-sub; that is a service object that does both things atomically.
The right time to reach for pub-sub is when an event has three or more independent reactions, each owned by a different team, and the publisher does not need to know whether any of them succeeded. Order creation, user signup, payment captured, content published, all of these tend to grow into the multi-subscriber shape.
The principle at play
The Open/Closed Principle is doing the work here, but with a twist. The publisher is closed (it does not change when subscribers come and go), and the system is open in two directions at once. New events can be published (publisher-side extension), and new subscribers can attach (listener-side extension). The bus itself is the stable interface that lets both sides evolve independently.
The deeper move is one the Unix tradition encoded as fan-out: a single output can be consumed by many independent processes, each doing its own thing. ActiveSupport::Notifications is Rails' implementation of the same idea inside one Ruby process. The publisher emits the event; whoever is listening reacts. The publisher does not need to know who, how many, or in what order.
The pragmatic payoff is organizational. Pub-sub turns "we all need to do something when X happens" into "X publishes an event, and our team writes a subscriber." The shared file disappears. The cross-team coordination collapses to "agree on the event name and the payload shape," which is a one-time decision instead of a recurring negotiation.
Practice exercise
- Open your three largest models. Count the
after_*callbacks (after_create, after_update, after_commit, after_destroy). Each one is a place where the model is doing something for someone else's team. - For each callback, ask: does the model need to know whether this side effect succeeded? If yes, keep it as a callback (or move it into a service object). If no, it is a pub-sub candidate.
- Sketch the event name and payload for the strongest candidate. Event names follow the convention "{noun}.{past_tense_verb}":
order.created,user.signed_up,subscription.canceled. The payload is a hash of the data subscribers need. - Bonus: run this in a Rails console,
ActiveSupport::Notifications.subscribe("sql.active_record") { |*, payload| puts payload[:sql] }, then load a page in your dev app. Every query Rails ran will print. That is the extension point Rails itself uses, available to you for free.