The Gnar Company
The Gnar Company

Tell Don't Ask: Benefits and Limitations

by Dan Frenette

TL;DR

"Tell Don't Ask" describes a style of programming that can significantly improve the quality of one's code, but much like any principle, it's possible to take this style too far.

When this happens, we need to know how to recognize it and refactor back to something that's more enjoyable for ourselves and our colleagues to work on.

Background

"Tell Don't Ask" was for me, and I think a lot of other developers, one of those mysterious phrases I heard people saying when discussing object-oriented programming, but could never quite parse what it meant through context alone. After working in Rails for a few years and seeing the pattern employed in several large codebases, I think I have a pretty solid working definition:

Instead of querying an object about its state, then making a decision on behalf of that object, it's better to send that object a message and let it make the decision. If that's not clear, don't worry, we're going to walk through all of that, and how it relates to "Tell Don't Ask."

Let's start with the literal phrase: "Tell Don't Ask." To "tell" is to give a command, to "ask" is to make a query. Take these two Rails models for example:

class JobApplicationSubmissionsController < ApplicationController
  def create
    if job_application.looks_good? && job_application.fits_job_description?
      job_application.submit!
      flash[:success] = "Good luck! Your application has been submitted!"
    else
      flash[:alert] = "Whoops! There was an error submitting your application!"
    end
  end

  private

  def job_application
    JobApplication.find(params[:job_application_id])
  end
end
class JobApplication < ApplicationRecord
  belongs_to :applicant
  belongs_to :job

  def submit!
    update!(status: :submitted)
    send_applicant_submission_confirmation_email
    send_hiring_manager_new_applicant_email
  end
end

We have a controller responsible for submitting job applications. Job applications belong to both an applicant, and a job. When an applicant tries to submit an application for a job, we ask the application a few things about itself to make sure it's in good shape to be submitted, and then we tell it to submit itself. While this code definitely works, it's also an example of some code that definitely does not follow "Tell Don't Ask."

The giveaway here is that on line 3 of the controller, we're performing several queries (or "Asks") on the given job application object.

As a developer, one might start to remember reading about those "God Object" things, and think "if the JobApplicationSubmissionsController doesn't know any of these things about a given job_application, then why is it the one handling these responsibilities?" and start to look for a way to refactor the code.

Enter "Tell Don't Ask." What if instead of asking a given job application a bunch of questions about its current state, and making a decision on its behalf, we just tell the application what we want it to do and let it figure everything out from there? If that were the case, our code might look something like this instead:

class JobApplicationSubmissionsController < ApplicationController
  def create
    if job_application.submit!
      flash[:success] = "Good luck! Your application has been submitted!"
    else
      flash[:alert] = "Whoops! Looks like there was an error submitting your application!"
    end
  end

  private

  def job_application
    JobApplication.find(params[:job_application_id])
  end
end
class JobApplication < ApplicationRecord
  belongs_to :applicant
  belongs_to :job

  def submit!
    return unless can_be_submitted?

    update_status_and_send_emails
  end

  private

  def can_be_submitted?
    looks_good? && fits_job_description?
  end

  def update_status_and_send_emails
    update!(status: :submitted)
    send_applicant_submission_confirmation_email
    send_hiring_manager_new_applicant_email
  end
end

The most noticeable improvement of this change is how much we've cleaned up our create action. But we've also significantly reduced the complexity created by this method. Now one object doesn't have to ask another object about its state, and then make a choice based on that state.

So there ya have it - "Tell Don't Ask" is perfect in literally every way and should be followed religiously. Because there are clearly no downsides to this, right? RIGHT?

Problem

Through my experience following "Tell Don't Ask" semi religiously for a few years as a Rails developer, I've found a problem with it, especially in the context of Rails.

That problem is that it's very easy to break the "Single Responsibility Principle" which states that classes should have, at most (you guessed it) a single responsibility.

Let's go back to our example of a job postings application. It's not hard to imagine that in a month or so of time, the complexity of our model can grow quite explosively.

When this happens, much to our chagrin, we realize we've just created a different "God Object." This kind of explosive growth is common in Rails models that inherit from ApplicationRecord, as they are responsible for many different aspects of data management including validation, scoping, enums, and associations.

In other words, sometimes classes already have a lot of responsibility, and adding methods onto them, even if it puts particular work closer to the data, might not be the best idea. So what can we do to fix it?

Well, if we have multiple responsibilities that need to get handled, and we want each class to only have one responsibility, let's make some more classes! In this context, classes like this are sometimes referred to as plain old Ruby objects or "POROs."

Solution

The solution that I've found to work best is basically a compromise between following, and not following, "Tell Don't Ask." Create a new class that is removed from the data, but is accessed through a public method added onto the class closest to the data.

class JobApplication < ApplicationRecord
  ...
  def submit!
    SubmitJobApplicationService.new(job_application: self).submit!
  end
  ...
end
class SubmitJobApplicationService
  def initialize(job_application:)
    @job_application = job_application
  end

  def submit!
    return unless application_can_be_submitted?

    update_status_and_send_emails
  end

  private

  attr_reader :job_application

  def application_can_be_submitted?
    job_application.looks_good? && job_application.fits_job_description?
  end

  def update_status_and_send_emails
    job_application.update!(status: :submitted)
    send_applicant_submission_confirmation_email
    send_hiring_manager_new_applicant_email
  end

  def send_applicant_submission_confirmation_email
    ...
  end

  def send_hiring_manager_new_applicant_email
    ...
  end
end

Yes, our new service object still has to "ask" our job application object about some things, but the value in this is that this is the only thing that service object will ever have to do.

You might also be asking, "why is the JobApplication#submit! method still there? Isn't that still keeping the responsibility tied to the model?" To which I'd say some of it, but not all of it. The responsibility of how a job application is submitted is still entirely confined to the service object; it's just that our job application has knowledge of that service object.

The most significant advantage I see in this is writing code that reads the same way as the language used by non-technical team members. In other words, if the non-technical people working on this application (think project managers, business analysts, etc.) use language like "a job application must be submitted to its job", then having a method on the JobApplication model called submit! is much easier to learn, remember, and develop with than the name of a service object stored somewhere completely separate from the model it's performing on. The service object is already tied to the model by way of what it does. Why not be explicit about that through our API?

Outcome/Takeaways

As your application grows in size, that's usually a sign that it's growing in responsibility. As developers, we can make our lives considerably easier by dividing that responsibility up into small separate classes all working together. By doing this, we effectively reach the goal of "Tell Don't Ask" - keeping objects that don't need to be responsible for certain information away from that information - by doing the exact opposite of what it prescribes.

A nice side effect of this is that we can create methods that delegate functionality to those small classes so that the public API of our classes closely reflects the language used to describe what our application does.