Ensuring maintainability in large Rails apps. Fat controllers done right.

The Ruby on Rails community was right to step away from fat controllers but we did it in the wrong way. I want to revisit fat controller architecture with more experience and solve the problem in a better way

Photo by Jeffrey Hamilton on Unsplash

We’ve been through a few eras in the rails community.

The Beginning, Fat Controllers

When it all began most code was written in the controllers. Controllers grew in size to the point it was impossible to navigate the code. We started moving that logic into the models to keep the controllers smaller. 

Skinny Controllers, Fat Models

We moved on to the era of skinny controllers. This left us with fat models, the same problem occurred but worse, the code was no longer isolated to an individual requests like it was with fat controllers so we had to start being careful to manage side-effects correctly. A model callback might be desirable in one request but not in another, same for validations etc. Our models also began to bloat, full of methods only relevant to a subset of requests that model would interact with.

Service Objects

Somewhere along the road after the shift to fat models we started building service objects, PORO classes and modules to run abstracted logic. This helped us to reduce the size of our models to something more manageable but didn’t really solve the core issue. 

In hindsight, I believe if the community started using service objects sooner we would never have moved to fat models. At that point in time the community was trying it's best to stick to the controller, model, or view mentality, anything outside of that was slightly taboo.

Looking for a new challenge? Join Our Team



A Better Future

There are solutions to architecture out there but are often much more complex than the traditional rails architecture. IMO the key problem was introduced when we moved away from fat controllers.

Fat controllers allowed us to have isolated logic specific to each request, the entry point into our applications. The end-user always starts from a controller action so why do we write our code as if they start in the model?

Why not start in the controller and build outwards, that’s what I’ve started doing.

Form Objects and Service Objects

Form objects have been around for as long as ActiveModel has been around. When people started touting the idea of form objects they were  primarily discussed as a case for virtual models, like contact forms or other non-persisted data sources.

class ContactForm
  include ActiveModel::Model

  attr_accessor :name, :email, :message

  validates :name, presence: true
  validates :email, presence: true
  validates :message, presence: true

  def send_mail
    return false unless valid?
    
    ContactFormMailer.incoming_message(
      name: name, email: email, message: message
    ).deliver_later
  end
end

We can use this same design to pass the params to models for persistence.
This allows us to do some interesting things that are otherwise much more complicated.

Perhaps you have a page that saves data to more than one model:

class UpdateProfileForm
  include ActiveModel::Model

  attr_accessor :user_id, :name, :contact_number

  def save
    user = User.find(user_id)
    user.update(name: name)
    user.device.update(contact_number: contact_number)
  end
end

More importantly we can move all of those validations and callbacks from the models into the correct places. The main reason I wanted to pursue a solution to this problem was actually due to the nature of modern app UX design, often signup isn’t a simple single page form but a bunch of steps. Validations don’t always apply to each of those steps.

For example let’s imagine a situation where we have a KYC process that has 50 fields. 

The first page has 10 fields, including an image uploader for an ID card and a selfie holding that ID card. This is fairly common in KYC (Know Your Customer) processes and in my experience they often are not allowed to be stored for a long period. In more than one application I’ve worked on those files should be validated during the first step of the process but will be sent off for processing before we validate the second step. With the traditional Rails MVC architecture this is a real mess. You end up with something like this

class User
  attr_accessor :kyc_step, :updating_profile
  validates :name, on: :create
  validates :id_image, if: -> { kyc_step == '1' }
  validates :document, if: -> { kyc_step == '2' }
  validates :avatar, if: -> { kyc_step == '3' || updating_profile }
end

With form objects each action has its own set of validations so there’s no need for these virtual attributes and complex conditionals. The code becomes much easier to read and understand. All of the logic for the controller action is contained inside a single class (except calls to abstracted service objects) and it’s isolated from the rest of the codebase. 

I’ve been fortunate enough to write a couple of apps from scratch using form objects and both times I feel that it benefited the app design and will prove to be a simpler system to maintain in the long run. I’ve also developed a small lib called ActionForm for this purpose which I’ll go into more details in another post. 

Models become ORM models again. They define associations and scopes, that's it. The logic is abstracted to the correct places. Each controller action has its own form, super simple to read and debug. You can even render them in views. 

The Result

class User < ApplicationRecord
  belongs_to :account
  has_many :devices
  
  scope :active, -> { where.not(:activated_at: nil) }
end
 
# app/forms/user/registration_form.rb
class User < ApplicationRecord
  class RegistrationForm
    # more about this in another post,
    # basically a collection of active support
    # modules and some helpers
    include ActiveForm::Form 
    
    attr_accessor :user
  
    attribute :email
    attribute :password
  
    with_options presence: true do
      validates :email, format: { with: Devise.EMAIL_REGEX }
      validates :password, length: { minimum: 8, maximum: 128 }
    end
 
    def save
      return false unless valid?
  
      self.user = User.create(email: email, password: password)
    end
  end
end
 
# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
  def new
    @form = User::RegistrationForm.new
  end
 
  def create
    @form = User::RegistrationForm.new(params: params[:form])
 
    if @form.save
      redirect_to welcome_path
    else
      render :new
    end
  end
end
 
# app/views/registrations/new.erb.html
<%= simple_form_for @form, url: registrations_path, method: :post do |f| %>
  <%= f.input :email %>
  <%= f.input :password %>
  <%= f.submit %>
<% end %>
Like 17 likes
Joe Woodward
I'm Joe Woodward, a Ruby on Rails fanatic working with OOZOU in Bangkok, Thailand. I love Web Development, Software Design, Hardware Hacking.
Share:

Join the conversation

This will be shown public
All comments are moderated

Comments

Rav
June 5th, 2020
I think the whole idea of SO came when we started building APIs, so we needed reuse code. Form Object pattern is one of my favorites, works perfectly for decoupling complex validations from models. I still think that SO is the best possible approach, even it has a few drawbacks. But that probably depends on the project scale.

Get our stories delivered

From us to your inbox weekly.