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_laterScattered 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) } endendsubscribe_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:
- Operation emits:
emit_async :employee_suspended, @employee, reason: "...", actor: @user Events::DispatchJobpicks it up from Sidekiq- The bus dispatches to all subscribers
NotificationSubscriberchecks: does any notifier handle"employee.suspended"?Notifier::RegistryfindsEmployeeNotifier- The registry extracts the subject from the payload (finds the
Employeebyemployee_id) - Verifies tenant:
employee.company_id == payload[:company_id] - Instantiates:
EmployeeNotifier.new(employee) - Calls:
.suspended(reason: "...", actor: user_object).deliver - 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 endendSame 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"}.freezeWhen 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.