---
title: Teach your models to act, not just be
teaser: "…or end up hunting for logic in jobs and controllers."
tags: rails,good code,refactoring
author: Matheus Richard
published_on: 2026-02-06
---

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.

```ruby
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:

```ruby
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:

```ruby
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][anemic]. At this point, it was clear what
was bothering me. The system had behavior, but it didn’t belong to the thing it
described.

[anemic]: https://martinfowler.com/bliki/AnemicDomainModel.html

## 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:

[make the work easy]: https://x.com/KentBeck/status/250733358307500032?lang=en

```ruby
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:

```ruby
def create
  if @task.save
    @task.start!
    redirect_to @task
  end
end
```

Same for the job:

```ruby
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][thin]),
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.

[thin]: https://thoughtbot.com/blog/skinny-controllers-skinny-models
