The Notifier Pattern - Events Meet Email

How we decoupled notifications from operations with a subscribe_to DSL that routes events to the right mailer, the right recipients, automatically.

Every Rails app starts the same way. An operation does something, then calls a mailer:

EmployeeMailer.welcome(employee).deliver_later

Scattered across operations, controllers, jobs. Want to know every place that sends an email to an employee? grep for EmployeeMailer and hope you find them all. Want to add a new notification when an employee gets suspended? Find the suspension operation, figure out the right place, add a mailer call.

Notifications don’t belong in operations. They belong with the events that trigger them.

The Notifier

class EmployeeNotifier < Notifier::Base
subscribe_to "employee.onboarded" => :welcome,
"employee.activated" => :activated,
"employee.suspended" => :suspended,
"employee.terminated" => :terminated,
"employee.reactivated" => :reactivated
def recipients
[source]
end
def welcome(**)
email { EmployeeMailer.welcome(recipient) }
end
def suspended(reason: nil, actor: nil, **)
@reason = reason
@actor = actor
email { EmployeeMailer.status_suspended(recipient, @reason, @actor) }
end
end

subscribe_to is a hash: event name to method name. When employee.suspended fires, the suspended method runs. The notifier declares what it reacts to. The operation that suspended the employee has no idea this class exists.

recipients returns who gets notified. For the employee notifier, it’s the employee themselves. For other notifiers, it gets more interesting.

The Full Chain

How an event becomes an email:

  1. Operation emits: emit_async :employee_suspended, @employee, reason: "...", actor: @user
  2. Events::DispatchJob picks it up from Sidekiq
  3. The bus dispatches to all subscribers
  4. NotificationSubscriber checks: does any notifier handle "employee.suspended"?
  5. Notifier::Registry finds EmployeeNotifier
  6. The registry extracts the subject from the payload (finds the Employee by employee_id)
  7. Verifies tenant: employee.company_id == payload[:company_id]
  8. Instantiates: EmployeeNotifier.new(employee)
  9. Calls: .suspended(reason: "...", actor: user_object).deliver
  10. Iterates recipients, delivers the email

The operation never touched a mailer. The notifier never touched the operation. The event is the only thing connecting them.

Smart Recipients

The employee notifier is simple - send to the employee. The leave request notifier shows where this pattern really earns its keep:

class Leave::RequestNotifier < Notifier::Base
subscribe_to "leave.request.submitted" => :submitted,
"leave.request.approved" => :approved,
"leave.request.rejected" => :rejected,
"leave.request.escalated" => :escalated
def recipients
case current_action
when :submitted
pending_approvers
when :approved, :rejected, :cancelled
[source.employee]
when :escalated
hr_admins
end
end
end

Same notifier, different recipients depending on the event. A submitted request notifies the department manager (and their delegates, if they have active leave delegation). An approved request notifies the employee. An escalated request notifies HR admins.

The routing logic lives with the notification, not with the operation that triggered it. Want to change who gets notified when a request is escalated? Edit the notifier. The escalation operation doesn’t change.

Subject Extraction

Notifiers receive events as payloads - hashes with IDs, not live objects. The base class handles rehydration:

SUBJECT_MODELS = {
employee: "Employee",
leave_request: "Leave::Request",
payroll_run: "PayrollRun",
invoice: "Invoice"
}.freeze

When EmployeeNotifier receives an event with employee_id: 123, the base class finds the Employee, verifies it belongs to the right company (multi-tenant safety), and passes it as source to the notifier. No manual lookup, no tenant-scoping bugs.

The Registry

Notifiers register themselves at boot:

Notifier::Registry.register(EmployeeNotifier)
Notifier::Registry.register(Leave::RequestNotifier)
Notifier::Registry.register(PayrollRunNotifier)
Notifier::Registry.register(PayslipNotifier)
Notifier::Registry.register(BillingNotifier)

Each notifier declares its subscriptions. The registry answers one question: given an event name, which notifiers should handle it?

Adding a new notification means creating a new notifier (or adding a subscribe_to entry to an existing one) and registering it. No operation code changes. No controller changes. No mailer routing logic scattered across the codebase.

The Principle

Operations emit. Notifiers listen. Nobody coordinates.

The suspension operation says “an employee was suspended.” The notifier says “when an employee is suspended, email them.” Neither knows about the other. The event is the contract between them.

This is the pattern we’re most happy with. Every time a product requirement starts with “also notify the manager when…” the answer is the same: add a line to the notifier.

In the next post, we’ll step back and look at what we learned after building events across the whole platform.