Building a Task Board on the Event Bus
Events decouple side effects. But what if the side effect is work that needs doing? Here's how we turned domain events into an auto-managing task board.
In Part 3, the TodoSubscriber got one line: “auto-creates tasks.” That undersold it. The todo system is the most ambitious thing we built on the event bus, and the place where event-driven architecture stops being a decoupling technique and starts being a workflow engine.
The pitch: domain events don’t just trigger notifications. They create work, update work, and close work. A payroll run moves through draft, approval, and processing. At each stage, events fire. The todo system reacts - creating a task, updating its description, completing it, and spawning the next one. Nobody orchestrates this. Events carry the news. Todos react.
The Problem
Payroll has steps. An HR admin creates a draft. Someone reviews the numbers. A manager approves it. Finance handles statutory payments afterward. Each step is a task that needs tracking.
The naive approach: manually create todos in the payroll operation. Mark them done in the approval operation. Create new ones in the processing operation. Now the payroll domain knows about the todo domain. Every new step means editing payroll code. Testing payroll means setting up todos. The domains are coupled.
Events already solved this for notifications and analytics. The same principle applies to workflow.
The Subscriber
The TodoSubscriber does almost nothing:
class TodoSubscriber extend Events::Subscribable
subscribes_to "payroll.created", "payroll.pending_approval", "payroll.processed", "payroll.cancelled", "employee.onboarded", "leave.request.submitted", "leave.request.approved", "billing.payment.failed", "billing.invoice.paid" # ...
def self.consume(event) payload = event[:payload].merge( company_id: event[:context][:company_id], user_id: event[:context][:user_id] ).compact
Todos::EventRouter.call(event[:name], payload) endendMerge context into payload, forward to the router. That’s it. All intelligence lives in the next layer.
The Router
The event router is three hash maps and an execution order:
module Todos class EventRouter GENERATORS = { "payroll.created" => Generators::PayrollCycle, "payroll.processed" => Generators::StatutoryPayment, "employee.onboarded" => Generators::EmployeeSetup, "leave.request.submitted" => Generators::LeaveApproval, "billing.payment.failed" => Generators::Billing::PaymentFailed, "billing.invoice.overdue" => Generators::Billing::InvoiceOverdue, "billing.account.suspended" => Generators::Billing::AccountSuspended, # ... }.freeze
UPDATERS = { "payroll.pending_approval" => Updaters::PayrollCycle }.freeze
COMPLETERS = { "payroll.processed" => Completers::PayrollCycleProcessed, "payroll.cancelled" => Completers::PayrollCycleCancelled, "leave.request.approved" => Completers::LeaveApproval, "leave.request.rejected" => Completers::LeaveApproval, "billing.invoice.paid" => Completers::Billing::InvoicePaid, "billing.account.reactivated" => Completers::Billing::AccountReactivated }.freeze
def self.call(event_name, payload) COMPLETERS[event_name]&.call(payload) UPDATERS[event_name]&.call(payload) GENERATORS[event_name]&.call(payload) end endendThe execution order is deliberate: completers first, updaters second, generators last. Close old work before creating new work. This matters when one event triggers both - and it does.
"payroll.processed" hits Completers::PayrollCycleProcessed AND Generators::StatutoryPayment in the same call. The payroll todo closes. A statutory payments todo opens. One event, two actions, correct order.
Generators
Every generator extends a base class that handles idempotency:
class Base def create_task!(attributes) if attributes[:subject] scope = Todo.where(subject: attributes[:subject], status: [:pending, :blocked]) scope = attributes[:todo_type] ? scope.where(todo_type: attributes[:todo_type]) : scope return if scope.exists? end
attributes[:status] = :blocked if attributes[:follows] Todo.create!(attributes) endendBefore creating a todo, check if one already exists for the same subject and type. If Sidekiq retries the event, no duplicate todo. If follows: is set, the todo starts blocked - it can’t be worked until its predecessor completes.
A payroll cycle generator:
class Generators::PayrollCycle < Base def call payroll_run = PayrollRun.find(payload[:payroll_run_id])
create_task!( company:, todo_type: :payroll_processing, title: "Process Payroll - #{payroll_run.month_year}", description: build_description(payroll_run), due_date: payroll_run.payment_date, priority: :high, subject: payroll_run, metadata: { payroll_run_id: payroll_run.id, stage: "draft" } ) end
def build_description(payroll_run) count = payroll_run.selected_employees_count "Calculate payroll for #{count} #{"employee".pluralize(count)}" endendThe payroll run’s payment date becomes the todo’s due date. metadata carries stage: "draft" - this gets mutated by the updater later.
A more interesting generator creates two todos from one event:
class Generators::EmployeeSetup < Base def call employee = ::Employee.find(payload[:employee_id]) hire_date = employee.employment&.hire_date || Date.current
create_task!( company:, todo_type: :employee_onboarding, title: "Complete Onboarding: #{employee.full_name}", due_date: hire_date + 7.days, priority: :medium, subject: employee )
unless employee.payroll_ready? create_task!( company:, todo_type: :payroll_setup, title: "Complete Payroll Setup: #{employee.full_name}", description: payroll_checklist(employee), due_date: hire_date + 3.days, priority: :high, subject: employee ) end end
def payroll_checklist(employee) items = [] items << "- Set basic salary" unless employee.employment&.basic_salary_cents&.positive? items << "- Add KRA PIN" unless employee.personal_detail&.kra_pin.present? items << "- Add bank details" unless employee.personal_detail&.bank_account_number.present? items.join("\n") endendemployee.onboarded fires once. Two todos appear: a 7-day onboarding todo and, if the employee isn’t payroll-ready, a tighter 3-day setup todo with a checklist of what’s actually missing. The checklist is dynamic - if the employee already has a KRA PIN but no bank details, only bank details show up.
The Updater
Only one updater exists, and it’s the most revealing piece:
class Updaters::PayrollCycle def call payroll_run = PayrollRun.find(@payload[:payroll_run_id]) todo = Todo.find_by(subject: payroll_run, status: [:pending, :doing]) return unless todo
todo.update!( description: approval_description(payroll_run), progress: 100, status: :doing, started_at: Time.current, metadata: todo.metadata.merge(stage: "pending_approval") ) end
def approval_description(payroll_run) gross = MoneyFormatter.format(payroll_run.total_gross) net = MoneyFormatter.format(payroll_run.total_net_pay) "#{payroll_run.employee_count} payslips generated. Total: #{gross} gross, #{net} net" endendWhen payroll.pending_approval fires, the existing payroll todo mutates. The description changes from “Calculate payroll for 42 employees” to “42 payslips generated. Total: KES 3,200,000 gross, KES 2,450,000 net.” Status moves to :doing. Progress hits 100%. The metadata stamps stage: "pending_approval".
The todo is alive. It reflects the current state of the payroll run without anyone manually editing it.
Completers
Completers are uniform. Find the open todo, call the complete transition:
class Completers::PayrollCycleProcessed def self.call(payload) payroll_run = PayrollRun.find(payload[:payroll_run_id]) Todo.where(subject: payroll_run, status: [:pending, :doing]).find_each do |todo| todo.complete todo.save! end endendThe complete transition does the real work - it’s a LiteState declaration on the Todo model:
transition :complete, from: [:pending, :doing], to: :completed, column: :status do self.completed_at = Time.current self.completed_by = Current.user self.progress = 100 track_event(:completed) unblock_followersendTimestamp. Attribution. Audit event. And then unblock_followers - every todo that was waiting on this one moves from :blocked to :pending. Chain reaction.
Three completers point to the same class - leave.request.approved, leave.request.rejected, and leave.request.cancelled all close the leave approval todo. Any resolution of a leave request closes the task. The completer doesn’t care how it was resolved.
The Full Lifecycle
Here’s a payroll run’s complete todo history, driven entirely by events:
payroll.created - Generator creates “Process Payroll - March 2026.” Priority: high. Due date: March 28. Stage: draft.
payroll.pending_approval - Updater rewrites the description to “42 payslips generated. Total: KES 3,200,000 gross, KES 2,450,000 net.” Status: doing. Stage: pending_approval. The todo board now shows a finance manager exactly what they’re approving.
payroll.processed - Completer closes the payroll todo. Same event, generator creates “Statutory Payments Due - March 2026” with a due date of April 9th (Kenya’s statutory remittance deadline). The old work is done. The new work is visible.
Three events. One todo created, mutated, completed. A second todo spawned. The payroll domain emitted facts about what happened. The todo system decided what work that implied.
When Things Go Wrong
The router has a rescue clause:
def self.call(event_name, payload) COMPLETERS[event_name]&.call(payload) UPDATERS[event_name]&.call(payload) GENERATORS[event_name]&.call(payload)rescue => error Rails.logger.error "[Todos::EventRouter] Failed: #{error.message}" surface_failure!(event_name, payload, error)endsurface_failure! creates a todo:
def self.surface_failure!(event_name, payload, error) company = Company.find_by(id: payload[:company_id]) return unless company
Todo.create!( company:, todo_type: :system_error, title: "Todo handler failed: #{event_name}", description: "Error: #{error.message}\n\nPayload: #{payload.except(:company_id).to_json}", due_date: Date.current, priority: :urgent, audience: :support )rescue => inner_error Rails.logger.error "[Todos::EventRouter] Could not surface failure: #{inner_error.message}"endA generator crashes? An urgent, support-only todo appears on the internal dashboard with the error message and the full payload. The customer never sees it. The support team sees it immediately, in the same interface they use for everything else.
The double rescue prevents a failure-within-a-failure from crashing the subscriber pipeline. If even the error todo can’t be created, it logs and moves on. The event bus doesn’t care.
What This Gets Us
Adding a new todo trigger is a three-step process: write a generator (or completer, or updater), add the event mapping to the router hash, add the event name to the subscriber’s subscribes_to list. No operation code changes. No controller changes.
When we added statutory payment reminders, it was one generator and two lines in the router. The payroll processing operation - the code that actually runs payroll - was never opened.
The todo board reflects the state of the business in real time. Not because someone manually creates tasks, but because the system tells you what needs doing based on what just happened.
Part 7 of a series on event-driven development at Kazisafi. Start from Part 1 if you haven’t already.