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.
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 %>