I decided to refactor one of my Rails side projects with the Trailblazer architecture. Why? The short version is, I’m looking for some standards and conventions to refactor my day job’s Rails API application. After reading a bit about Trailblazer and what it offers, I decided to try it on one of my side projects. I would refactor it to learn about the architecture and then decide if Trailblazer is a good fit for my professional application.
As I was doing the first batch of refactoring, I thought that my experience in struggling to understand how it works could benefit others. That’s why I’m writing a blog post. As a matter of fact, as I was writing this blog post I discovered and learned some more stuff that did not occur to me when I was refactoring.
The App
The app I’m going to refactor is somewherexpress, which allows my friends and I to organize our own hitchhiking competitions and showcase the results. You can see the result here.
It’s a rather standard, small Rails 4.2 application: it has 8 ActiveRecord models + their relative controllers and views. Mostly CRUD actions with a few tricks, callbacks, nested forms with simple_form, devise, pundit, transactional emails. No externally open API.
It is open source, so you can follow my struggles in the trailblazer branch.
Trailblazer
The Trailblazer book can be bought on leanpub. In this post, I will make references to the pdf version of the book (for page numbers).
The two introduction chapters are very promising:
Trailblazer was created while reworking Rails code and does absolutely not require a green field with grazing unicorns and rainbows. It is designed to help you restructuring existing monolithic apps that are out of control.
— Trailblazer, p. 7
➡ This is essential
Conventional factories in tests create redundant code that never produces the exact same application state as in production - a source of many bugs. While the tests might run fine production crashes because production doesn’t use factories. In Trailblazer factories get superseded by using operations.
— Trailblazer, p. 7
➡ I don’t have too many problems with factories but that would be a good perk
Instead of leaving it up to the programmer how to design their “service object”, what interface to expose, how to structure the services, Trailblazer’s Operation gives you a well-defined abstraction layer for all kinds of business logic.
— Trailblazer, p. 17
➡ Great, I don’t want to make decisions, give me some conventions
While rendering documents is provided with fantastic implementations, Rails underestimates the complexity of deserializing documents manually. Many developers got burned when “quickly populating” a resource model from a hash. Representers make you think in documents, objects, and their transformations - which is what APIs are all about.
— Trailblazer, p. 30
➡ That could solve major struggles in my day job application
Refactoring the Create operation
The book recommends to start (or start refactor) with the most important business action. I’m not skeptical about this advice in the case of a refactor. In a sense, I get it, it’s your core business, you want it to work well. But it’s likely the most complex part of your code base. So starting by refactoring a huge method might prove challenging when you don’t know the new architecture. I’m going to carry on with this advice anyway. So let’s refactor The Competition creation process of somewherexpress. Here is the code I want to refactor:
# app/models/competition.rb
class Competition < ActiveRecord::Base
has_many :tracks, dependent: :destroy
# I want to get rid of this accepts_nested_attributes_for
accepts_nested_attributes_for :tracks, allow_destroy: true
belongs_to :start_city, class_name: "City", foreign_key: "start_city_id"
belongs_to :end_city, class_name: "City", foreign_key: "end_city_id"
has_many :tracks_start_cities, through: :tracks, source: :start_city
has_many :tracks_end_cities, through: :tracks, source: :end_city
# I want to get rid of these accepts_nested_attributes_for
accepts_nested_attributes_for :start_city
accepts_nested_attributes_for :end_city
belongs_to :author, class_name: "User"
# These validations should not be in the model
validates :name, presence: true
validates :start_registration, :start_city, :end_city,
:start_date, :end_date, presence: { if: :published? }
#[...]
end
# app/models/track.rb
class Track < ActiveRecord::Base
belongs_to :competition
belongs_to :start_city, class_name: "City", foreign_key: "start_city_id"
belongs_to :end_city, class_name: "City", foreign_key: "end_city_id"
# I want to get rid of everything below this
accepts_nested_attributes_for :start_city
accepts_nested_attributes_for :end_city
validates :start_city, :end_city, :start_time, presence: true
#[...]
end
# app/controllers/competitions_controller.rb
def create
@competition = current_user.creations.new
authorize @competition
# I want to get rid of Competitions::Update, which is a big mess.
# it's a kind of custom form object to map Cities based on their locality
# attribute and not their id. The strong parameters lie in this class
updater = Competitions::Update.new(@competition, params).call
@competition = updater.competition
@tracks = updater.updated_tracks
if @competition.valid? && @tracks.map(&:valid?).all?
send_new_competition_emails if @competition.published?
redirect_to @competition
else
# This is a hack I also want to get rid of: the rendered form requires an
# empty track that is removed on load with javascript
track = Track.new(end_city: City.new, start_city: City.new)
@tracks << track
render :new
end
end
private
# This method should not be in the controller
def send_new_competition_emails
User.want_email_for_new_competition.each do |user|
UserMailer.as_user_new_competition(user.id, @competition.id).deliver_later
end
end
This piece of code has all these usual suspects, plus some perks:
- conditional validations
accepts_nested_attributes_for
join model- callbacks
- a custom service (doing the job of a form object) because I don’t want the default Rails behavior on create/update a join model (City).
- some rubbish decoration to comply with my rendered form
I want to check if I can refactor only one part at a time. This is an important factor for me. So as a first step I want to refactor:
- the
create
method ofCompetitionsController
- use a form object to deserialize and validate the data to create a competition.
That’s it. I want to keep devise for authentication, pundit for authorization, keep my callbacks, and render my html views. And this is where “just following” the book made me struggle a lot. The book is structured to demonstrate how to create an application from scratch using all the Trailblazer classes. So if we want to refactor just one part, we will have to jump entire sections.
Let’s get started
Trailblazer::Operation & Reform::Form
The Operation is the core service object in Trailblazer. An operation orchestrates all business logic between the controller dispatch and the persistence layer.
If we put aside authorization for now, we want to modify CompetitionsController#create
to look like this:
# app/controllers/competitions_controller.rb
def create
run Competition::Create do |op|
return redirect_to op.model
end
render action: :new
end
As explained in the Trailblazer book p. 49, if there is no exception raised when the operation is ran, the given block will be executed, otherwise it won’t. I like this way of dealing with errors. It allows nesting services without the need of many nested conditions. Caution though, if you call your operation with the call syntax, exceptions won’t be rescued.
So the first thing we need is a new file for this Competition::Create
operation. I’m going to immediately integrate the Contract part - the form object layer that uses Reform, another Trailblazer gem - in the operation.
The form object is the most interesting part of this operation, so let’s set aside authorization and the callback (send emails) for now, and concentrate on the contract. If you don’t have nested forms, the operation + contract is quite straight forward following the Trailblazer book (chapter 3):
RSpec.describe Competition::Create do
it "creates an unpublished competition" do
competition = Competition::Create
.(competition: { name: "new competition" })
.model
expect(competition).to be_persisted
expect(competition.name).to eq "new competition"
end
it "does not create an unpublished competition without name" do
expect {
Competition::Create.(competition: { name: "" })
}.to raise_error Trailblazer::Operation::InvalidContract
end
end
# app/concepts/competition/operation.rb
class Competition < ActiveRecord::Base
class Create < Trailblazer::Operation
include Model
model Competition, :create
# The contract is extracted into a new file, it's quickly going to be big.
contract Contract::Create
def process(params)
validate(params[:competition]) do |form|
form.save
end
end
end
end
# app/concepts/competition/contract.rb
class Competition < ActiveRecord::Base
module Contract
class Create < Reform::Form
model :competition
property :name
# [...] more properties
validates :name, presence: true
end
end
end
Now, if I follow the Chapter 3 of the book, it wants me to refactor the new
method (and it’s view) and then it continues with the update part. But I don’t want to do this until I have my form object complete with nested models. That is where it gets tricky.
Nested forms: persisting belongs_to records
In the Trailblazer book, we need to jump to chapter 4 “Nested forms” that starts page 82.
First, I have a competition’s author
, which must be attributed to the current_user
. It makes sense to me to use the setup_model!
method (p. 85-86): the user is not part of the form, so dealing with the author relationship can be done at Operation level. This means, I need to pass the current user to my operation in the parameters:
RSpec.describe Competition::Create do
# [...]
let!(:user) { FactoryGirl.create(:user) }
it "creates a competition with author" do
competition = Competition::Create
.call(competition: { name: "new competition" },
current_user: user)
.model
expect(competition).to be_persisted
expect(competition.author).to eq user
end
end
# app/controllers/competitions_controller.rb
def create
run Competition::Create,
params: params.merge(current_user: current_user) do |op|
return redirect_to op.model
end
render action: :new
end
# app/concepts/competition/operation.rb
class Competition < ActiveRecord::Base
class Create < Trailblazer::Operation
include Model
model Competition, :create
contract Contract::Create
def process(params)
validate(params[:competition]) do |form|
form.save
end
end
private
def setup_model!(params)
model.author = params[:current_user]
end
end
end
A competition also belongs to 2 cities, start_city
and end_city
which params are passed in the request. That means, the setup_model!
hook is not appropriate. Continuing reading the chapter 4, we learn about the populate_if_empty
option for nested forms. We can pass a method to this option and this is going to be practical to setup our cities.
In somewherexpress app, cities are immutable objects. Whether the user creates or updates a City, it should look in the DB if there is an existing city with the same locality
attribute, and if yes, set it as the corresponding city (start_city
or end_city
). If no, create a new City with the passed params.
Here is the updated test:
RSpec.describe Competition::Create do
# [...]
let!(:user) { FactoryGirl.create(:user) }
let!(:existing_city) do
FactoryGirl.create(:city, locality: "Munich", name: "Munich, DE")
end
it "creates a published competition" do
competition = Competition::Create
.call(competition: {
name: "new competition",
start_city: { name: "Yverdon, CH",
locality: "Yverdon-Les-Bains",
country_short: "CH" },
end_city: { name: "Munich, DE",
locality: "Munich",
country_short: "DE" }
},
current_user: user)
.model
expect(competition).to be_persisted
expect(competition.author).to eq user
expect(competition.start_city.locality).to eq "Yverdon-Les-Bains"
expect(competition.end_city.id).to eq existing_city.id
end
end
This is how the contract evolves:
# app/concepts/competition/contract.rb
class Competition < ActiveRecord::Base
module Contract
class Create < Reform::Form
model :competition
property :name
# [...] more properties
validates :name, presence: true
property :start_city, populate_if_empty: :populate_city! do
property :name
property :locality
property :country_short
# [...] more properties
validates :name, :locality, :country_short, presence: true
end
property :end_city, populate_if_empty: :populate_city! do
property :name
property :locality
property :country_short
# [...] more properties
validates :name, :locality, :country_short, presence: true
end
private
def populate_city!(options)
# About this first return: it's a small hack. In my form view, I use
# google places autcomplete. That means, when a name is entered,
# a locality is set. But if the user erases the name, the locality
# stays. The validation being ran only after populating, this first
# return ensures that the form don't validate if the user erased the
# name (but the locality stayed).
return City.new unless options[:fragment].present? &&
options[:fragment][:name].present?
city = City.find_by(locality: options[:fragment][:locality])
return city if city
City.new(options[:fragment])
end
end
end
end
And it works. It’s as simple as this. I’m almost shocked. For you to understand, doing this with strong params and no deserializer nor form object was a huge pain: I wrote a dedicated service Competitions::Update
, 100 lines of code to setup the correct start_city
, end_city
(and, as we will see later, each competition’s tracks start_city
and end_city
). It was a complete hack, failing in edge cases.
It feels so nice and clean now. If the only gain was this part, it was worth using Reform!
As we can see, the 2 cities’ properties are the same. We can extract this code to it’s own form to make it cleaner:
# app/concepts/city/form.rb
class City < ActiveRecord::Base
class Form < Reform::Form
property :name
property :locality
property :country_short
# [...] more properties
validates :name, :locality, :country_short, presence: true
end
end
# app/concepts/competition/contract.rb
class Competition < ActiveRecord::Base
module Contract
class Create < Reform::Form
# [...]
property :start_city, populate_if_empty: :populate_city!,
form: City::Form
property :end_city, populate_if_empty: :populate_city!,
form: City::Form
# [...]
end
end
end
Finally, I can introduce my second validation on competition which reads like this:
validates :start_registration, :start_city, :end_city,
:start_date, :end_date, presence: { if: :published? }
RSpec.describe Competition::Create do
# [...]
let!(:user) { FactoryGirl.create(:user) }
let!(:existing_city) do
FactoryGirl.create(:city, locality: "Munich", name: "Munich, DE")
end
it "creates a published competition" do
competition = Competition::Create
.call(competition: {
name: "new competition",
published: "1",
start_date: 2.weeks.from_now.to_s,
end_date: 3.weeks.from_now.to_s,
start_registration: Time.current,
start_city: { name: "Yverdon, CH",
locality: "Yverdon-Les-Bains",
country_short: "CH" },
end_city: { name: "Munich, DE",
locality: "Munich",
country_short: "DE" }
},
current_user: user)
.model
expect(competition).to be_persisted
expect(competition.author).to eq user
expect(competition.start_city.locality).to eq "Yverdon-Les-Bains"
expect(competition.end_city.id).to eq existing_city.id
end
end
The trick is going to be the if: :published?
part. Reform classes don’t know about the Rails models magics, so we have to define published?
explicitly. I found this page of the Reform documentation to be very helpful in that regard:
# app/concepts/competition/contract.rb
class Competition < ActiveRecord::Base
module Contract
class Create < Reform::Form
# [...]
property :name
property :start_date
property :end_date
property :start_registration
property :published
property :start_city, populate_if_empty: :populate_city!,
form: City::Form
property :end_city, populate_if_empty: :populate_city!,
form: City::Form
validates :name, presence: true
validates :start_registration, :start_city, :end_city,
:start_date, :end_date, presence: { if: :published? }
# [...]
private
def published?
published && published != "0"
end
# [...]
end
end
end
And that’s it, we are good with competition level validations and belongs_to kind of nested models.
Nested forms: persisting has_many records
A Competition has many tracks, and tracks are created and updated in the competition’s form. A user can destroy a track from the form. This will make a delete request to TracksController#destroy
, so we don’t need to deal with removed tracks here. Tracks also have 2 nested cities, like Competition has. They are populated in the same way.
We now have to implement those nested tracks’ properties in our contract. The rest of chapter 4 of the Trailblazer book won’t help us much, it’s mostly about rendering the form. We need to move to chapter 5 “Mastering Forms”, that starts page 128.
RSpec.describe Competition::Create do
# [...]
let!(:user) { FactoryGirl.create(:user) }
let!(:existing_city) do
FactoryGirl.create(:city, locality: "Munich", name: "Munich, DE")
end
it "creates a published competition" do
competition = Competition::Create
.call(competition: {
name: "new competition",
published: "1",
start_date: 2.weeks.from_now.to_s,
end_date: 3.weeks.from_now.to_s,
start_registration: Time.current,
start_city: { name: "Yverdon, CH",
locality: "Yverdon-Les-Bains",
country_short: "CH" },
end_city: { name: "Munich, DE",
locality: "Munich",
country_short: "DE" },
tracks: [{ start_time: 16.days.from_now.to_s,
start_city: { name: "Yverdon, CH",
locality: "Yverdon-Les-Bains",
country_short: "CH" },
end_city: { name: "Munich, DE",
locality: "Munich",
country_short: "DE" } }]
},
current_user: user)
.model
expect(competition).to be_persisted
expect(competition.author).to eq user
expect(competition.start_city.locality).to eq "Yverdon-Les-Bains"
expect(competition.tracks.size).to eq 1
expect(competition.tracks.first.end_city.id).to eq existing_city.id
expect(competition.end_city.id).to eq existing_city.id
end
end
For has_many
kind of nested relationships, we can use a collection:
# app/concepts/competition/contract.rb
class Competition < ActiveRecord::Base
module Contract
class Create < Reform::Form
# [...]
collection :tracks, populate_if_empty: :populate_track! do
property :start_time
property :start_city, populate_if_empty: :populate_city!,
form: City::Form
property :end_city, populate_if_empty: :populate_city!,
form: City::Form
validates :start_city, :end_city, :start_time, presence: true
private
def populate_city!(options)
return City.new unless options[:fragment].present? &&
options[:fragment][:name].present?
city = City.find_by(locality: options[:fragment][:locality])
return city if city
City.new(options[:fragment])
end
end
private
def populate_track!(options)
Track.new(start_time: options[:fragment][:start_time])
end
# [...]
end
end
end
And that is pretty much everything we want in terms of persisting incoming data.
Put back authorization and callback
As I said earlier, I want to make the minimal refactor, that is, the form object. So let’s put back the authorization method (I use pundit) and the callback (sending email) as they were originally.
For authorization, pundit’s authorize
method expects either an instance of competition, or the Competition class itself. In CompetitionsController#create
, I don’t have any instance of competition anymore. My policy does not depend on a competition record, only on the current user. That means I can pass the Competition class to authorize:
# app/policies/competition_policy.rb
class CompetitionPolicy < ApplicationPolicy
# [....]
def create?
user && (user.organizer || user.admin)
end
end
# app/controllers/competitions_controller.rb
def create
authorize Competition
# [...]
end
About the callback, I can simply move everything to the operation:
class Competition < ActiveRecord::Base
class Create < Trailblazer::Operation
include Model
model Competition, :create
contract Contract::Create
def process(params)
validate(params[:competition]) do |form|
form.save
# This #published? will work: it's called on the AR model, which
# understands this method call (unlike the form object)
send_new_competition_emails if model.published?
end
end
private
def send_new_competition_emails
User.want_email_for_new_competition.each do |user|
UserMailer.as_user_new_competition(user.id, model.id).deliver_later
end
end
# [...]
end
The callback is still problematic: emails will be sent on each call of the Operation. But that’s a problem for another time.
We still have a problem in CompetitionsController#create
: if the form does not validate, we need to render new
. But new
requires a @competition
object which we don’t have anymore. We could hack it by passing the form as @competition
.
In somewherexpress app case, it would require a few modifications of the layout and helpers, but not so many. I will show those changes when we refactor the rendering of the form. But it’s just to say that it is doable without breaking the main structure of the view. This would be our final controller method:
def create
authorize Competition
operation = run Competition::Create,
params: params.merge(current_user: current_user) do |op|
return redirect_to op.model
end
@competition = operation.contract
render action: :new
end
That’s it! We have moved the persisting behavior of Competition to Trailblazer classes! And we did it without the need to refactor the entire application, or even the entire controller.
For this, I give you 👍👍 Trailblazer, you delivered on “you can refactor just some parts”.