ruby 82 lines · 4 tabs

Avoid Callback Chains: Use Domain Events (In-App)

Shared by codesnips Jan 2026
4 tabs
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
4 files · ruby Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Avoid Callback Chains: Use Domain Events (In-App) — share card
Link copied