18 Comments

Stop Writing Rails Controllers

Writing Rails controllers can be a pain. They exist in a weird transitional point in your application, facilitating communication between the world of hypertext, headers, and response codes (Railsy code) and your application domain.

They are never the interesting portion of the code, often boring boiler-plate code with lots of different code paths to test. A simple PUT to update a model involves parsing parameters, checking if the user has permissions on that model, trying to update, rendering errors if there are any, rendering the model if there weren’t and deciding what format to render the results in. Phew! That’s a lot of responsibility for one little controller. Even skinny controllers have a lot to do, but what they are doing is repetitive and boring.

I decided to never write another controller if I could help it. That’s why I wrote DDC.

Data Driven Controllers

Data Driven Controllers (DDC) lets you declare via data how to convert back and forth from HTTP to your application’s domain without the need for code. By adhering to a couple of interfaces, you can avoid writing most controller code and tests. DDC breaks the process of handling a request into three parts.

1. Convert parameters.

This step is handled by some sort of context builder. It is mostly in charge of gathering parameters, but may need to pluck out additional information from the controller. The information is collected into a form that the domain code can digest (usually a data blob via a Hash or Struct).

2. Process the domain request / action.

The domain level service object takes the necessary information and processes it (update the database, send emails, external services). The service then returns a result that knows nothing about HTTP-land. It includes things like status (application, not HTTP), objects, errors, etc.

3. Glue.

DDC is the glue that holds it all together. It creates a controller class that does all the default things for you, but allows you to override and fill in the blanks where necessary. When defining your glue, you simply tell the action how to get the params from the context builder and what service object to send them off to.

DDC Example Code

Controllers

# controllers/monkeys_controller.rb
 
DDC::ControllerBuilder.build :monkeys
  before_actions: [:authenticate_user!],
  actions: {
    show: {
      context: 'context_builder#user_and_id',
      service: 'monkey_service#find'
    },
    index: {
      context: 'context_builder#user',
      service: 'monkey_service#find_all'
    },
    update: {
      context: 'context_builder#monkey',
      service: 'monkey_service#update'
    },
    create: {
      context: 'context_builder#monkey',
      service: 'monkey_service#create'
    }
  }

Context Builders

# lib/context_builder.rb
 
class ContextBuilder
  def user(context_params)
    HashWithIndifferentAccess.new current_user: context_params[:current_user] 
  end
 
  def user_and_id(context_params)
    user(context_params).merge(id: context_params[:params][:id])
  end
 
  def monkey(context_params)
    info = context_params[:params].permit(monkey: [:color, :poo])
    user_and_id(context_params).merge(info)
  end
end

Services

Service objects can themselves be boiler-plate and boring. DDC includes a simple CRUD service generator to get you started.

# lib/monkeys_service.rb
 
class MonkeyService
  def find(context)
    id, user = context.values_at :id, :current_user
    me = find_for_user user, id
    if me.present?
      ok(me)
    else
      not_found
    end
  end
 
  def update(context)
    id, user, updates = context.values_at :id, :current_user, :monkey
    george = find_for_user user, id
 
    if george.present?
      update_monkey(george, updates)
    else
      not_found
    end
  end
 
  private
 
  def update_monkey(george, updates)
    if george.update_attributes updates
      ok(george)
    else
      model_errors(george, george.full_messages)
    end
  end
 
  def not_found
    {status: :not_found}.freeze
  end
 
  def ok(obj)
    {status: :ok, object: obj}
  end
  # ...
 
end
 
# shortcut for default CRUD service
MonkeyService = DDC::ServiceBuilder.build(:monkey)

Going Forward

So far I love defining my controllers this way. My system tests make sure that things are glued together and individual pieces can be tested easily. By defining all the pieces in the Railsy locations, routes and class reloading all work flawlessly. In the immortal words of Rails creator, DHH: “Look at all the things I’m not doing”. What I am doing is declaring what needs to be glued together in my application instead of how to glue them. Stop writing Rails controllers today.