I was working on a feature to notify users when a Task completed. This model
was related to an external service that processes tasks asynchronously. It
tracked that external state locally, so I expected to find the polling logic
there. Should be easy, right? Just open the model.
Unfortunately, no. The model had no answers for me. It just had some querying methods and a callback.
class Task < ApplicationRecord
after_destroy :delete_from_external
def running? = !done?
def done? = complete? || failed?
def complete? = completed_at.present?
def failed? = failed_at.present?
private
def delete_from_external
external_id = external_data['id']
ExternalService.delete(external_id) if external_id.present?
end
end
Turns out that logic was in a background job. The transition to “finished” was
almost a side effect—buried in an else branch:
class ProcessTaskJob < ApplicationJob
def perform(task, external_task_id)
data = ExternalService.get(external_task_id)
task.update(external_data: data)
task.broadcast_replace_to(task, partial: 'tasks/details', ...)
if task.running?
ProcessTaskJob.set(wait: 5.seconds).perform_later(task, external_task_id)
else
# this is the "finished" state
task.broadcast_replace_to(task, partial: 'tasks/complete', ...)
end
end
end
Looking closer, the Task model was just holding data. It didn’t even know how
to #start itself. That code was in the controller:
class TasksController < ApplicationController
def create
if @task.save
response = ExternalService.create(@task.as_json)
@task.update(external_data: response)
ProcessTaskJob.perform_later(@task, response[:id])
end
end
end
The Task model couldn’t #start or #finish itself (not to mention knowing
about polling). It just… existed. At this point, it was clear what
was bothering me. The system had behavior, but it didn’t belong to the thing it
described.
Teaching the model to act
Before doing my actual work, I decided to make the work easy by pulling a refactoring first. I wanted to teach this model how to act, but how do you find the right verbs?
I looked at what the code was already doing and named each behavior: the
controller started a task, the job polled for updates, and the
else branch finished it. Paying attention to how the team talked about
tasks helped too. They’d say “start the task” or “it finished”, never “create
an external service request and enqueue a process job”. Once I had start!,
poll!, and finish!, the refactoring almost wrote itself:
class Task < ApplicationRecord
include ActionView::RecordIdentifier
after_destroy :delete_from_external
def start!
response = ExternalService.create(as_json)
update!(external_data: response)
poll_later(response[:id])
end
def poll!(external_id)
refresh_from_external!(external_id)
broadcast_progress
if running?
poll_later(external_id, wait: 5.seconds)
else
finish!
end
end
def running? = !done?
def done? = complete? || failed?
def complete? = completed_at.present?
def failed? = failed_at.present?
private
def finish!
broadcast_completion
end
def broadcast_progress
broadcast_replace_to(
self,
partial: 'tasks/details',
locals: { task: self },
target: dom_id(self, :details)
)
end
def broadcast_completion
broadcast_replace_to(
self,
partial: 'tasks/complete',
locals: { task: self },
target: dom_id(self, :status)
)
end
def refresh_from_external!(external_id)
data = ExternalService.get(external_id)
update!(external_data: data)
end
def poll_later(external_id, wait: nil)
ProcessTaskJob.set(wait:).perform_later(self, external_id)
end
def delete_from_external
external_id = external_data['id']
ExternalService.delete(external_id) if external_id.present?
end
end
Now the controller just says what to do:
def create
if @task.save
@task.start!
redirect_to @task
end
end
Same for the job:
class ProcessTaskJob < ApplicationJob
queue_as :default
def perform(task, external_id)
task.poll!(external_id)
end
end
What changed?
Once the model knew how to start, poll, and finish itself, the system became easier to read and safer to change. The lifecycle lived in one place, and the code read like the business language the team used.
When the model owns its behavior, controllers and background jobs have nothing left to coordinate. They naturally become thin (as they should), simply telling the model what to do.
And my original feature? Notifying users on completion was now just a matter of
adding one line to finish!. The model already knew when it was done.
Rich domain models help us actually practice OOP: letting behavior live close to the data it operates on.