Teach your models to act, not just be

Matheus Richard in Brazil

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.

About thoughtbot

We've been helping engineering teams deliver exceptional products for over 20 years. Our designers, developers, and product managers work closely with teams to solve your toughest software challenges through collaborative design and development. Learn more about us.