Intro to Trailblazer - Part II: Operations
In a previous post, we talked about using Decorators
and introduced Trailblazer’s Disposable::Twin
class that gives us a super easy way to create and use decorators. But Decorators
aren’t the reason why you should look into Trailblazer, as we said in the previous post, draper would be the better option if that’s all you needed. The really interesting part comes in the way you get to organize your Rails application code so that you keep your controllers skinny and your models skinnier.
When I first started working with Rails, around 2008, the common principle that everyone was following was “Skinny Controllers, Fat Models”. What many applications at the time were suffering from was the never ending nesting of if/else
statements inside of controllers. The solution to that was to move business logic to classes that are responsible for handling the business requirements and limit the controller’s responsibility to merely directing traffic. So many devs, myself included, looked at the structure of a Rails app and figured that the place to move the business logic to was the models because those were the classes that represented the domain objects of the app and thus needed to encapsulate the business logic that governed the relationship between them. That was a trick that Rails played on us, and we fell for it. See, a typical Rails model looked like this:
class User < ActiveRecord::Base
end
or maybe something like this:
class User < ActiveRecord::Base
has_many :articles validates_uniqueness_of :email
end
what you noticed is that there wasn’t much code inside of a Rails model, so why not put a bunch of it in there, right?
In fact, there was a whole lot of functionality hidden inside each of these classes, courtesy of one tiny little character: <
. What we weren’t seeing was all of the logic hidden inside of ActiveRecord::Base
that was added to each model thanks to inheritance
. It turns out that AR
models plenty of responsibilities to uphold in terms of taking care of fetching and saving data from and to the database. And that is ALL an AR
model should be, a Data Object
. Which meant that a model was not the appropriate place to hold any business logic, we were doing it all wrong all this time!
So if not the controller, and not the model, where should I put my business logic inside of a Rails app, all I’ve got left are views?! If you look at the directory structure of a plain Rails app (what rails new
generates), this is how it looks like:
.
├── Gemfile
├── Gemfile.lock
├── README.md
├── Rakefile
├── app
│ ├── assets
│ ├── channels
│ ├── controllers
│ ├── helpers
│ ├── jobs
│ ├── mailers
│ ├── models
│ └── views
├── bin
│ ├── bundle
│ ├── rails
│ ├── rake
│ ├── setup
│ ├── spring
│ └── update
├── config
│ ├── application.rb
│ ├── application.yml
│ ├── boot.rb
│ ├── cable.yml
│ ├── environment.rb
│ ├── environments
│ ├── initializers
│ ├── locales
│ ├── puma.rb
│ ├── routes.rb
│ ├── secrets.yml
│ └── spring.rb
├── config.ru
├── db
│ └── seeds.rb
├── lib
│ ├── assets
│ └── tasks
├── log
│ ├── development.log
│ ├── production.log
│ └── test.log
├── public
│ ├── 404.html
│ ├── 422.html
│ ├── 500.html
│ ├── apple-touch-icon-precomposed.png
│ ├── apple-touch-icon.png
│ ├── favicon.ico
│ └── robots.txt
├── tmp
│ ├── cache
│ ├── pids
│ ├── restart.txt
│ └── sockets
└── vendor
└── assets
it is natural to assume from this structure that any code you introduce as a developer should go under the app
directory. And what you get out of the box is:
app
├── assets
├── channels
├── controllers
├── helpers
├── jobs
├── mailers
├── models
└── views
which means that your options for where to put business logic are pretty much the controllers
directory, the models
directory, and maybe the jobs
directory, but not much else. If you’re supposed to put app logic inside of any other directory, Rails would have generated it for you, wouldn’t it? “Convention over configuration” is the motto after all! The reality is that Rails is just another Ruby application, so you get to create as many directories as you want and organize your code however best it fits your needs. Rails does indeed automatically include any directory and subdirectory under the app
directory in the load path by default. What most Rails devs tend to forget and ignore is the lib
directory:
lib
├── assets
└── tasks
The lib
directory is not there by coincidence, and the fact that it just comes with two empty directories should be an indicator that it’s included for a purpose, despite how non evident that might be. All code that is not framework related, as in all code that is not tied to AR
(data), html
(views), or routing (controller + routes), is intended to go in the lib
directory. Which includes any business or domain logic.
It is counterintuitive to think of it this way. We mostly think as the entire app as domain logic, and easily fall into the trap of coupling our application logic with the framework, in this case Rails. But that need not be the case. In fact, structuring our code independently of the framework lends itself to cleaner and more maintainable code. Alas, if you are not ready to make the full jump into putting your code in the lib
directory, I feel you, I get it, and Trailblazer is here to help.
Trailblazer can act as the bridge between Rails, the framework, and code that represents our domain logic. This is where Trailblazer::Operation
s come in to play. If we think of a Rails application as a set of interactions a user can perform, then an operation is the set of side effects each interaction has on the application and the data that the application is responsible for.
Let’s take a look at a particular example. Assume we have a Dropbox clone where a user can upload documents and perform a set of actions on them (rename, delete, etc). The interaction of uploading a document is in fact the operation of creating a document record, uploading the file, setting the file path on the document record, setting document uploader to the current user, setting the timestamps, and triggering any notifications (let’s assume that we accept only images and pdfs, and all images are converted to pdfs and then the user is notified once the conversion is complete).
If you add all that logic on the model, that means each time you create a new record in the db, or perform any action on it, everything that was described above will be triggered. But you might not want that to happen. So you add a bunch of conditionals, and more logic. Soon enough just managing those rules becomes cumbersome and a nightmare. On the flip side, using Trailblazer, we can specify a Doc::Create
operation that is responsible for encapsulating the necessary logic for this particular interaction. It will look something like this:
require 'file/converter'class Doc::Create < Trailblazer::Operation
include Model
include Dispatch
model Doc, :create contract do
include Reform::Form::ModelReflections property :uploaded_at,
validates: { presence: true } property :file,
validates: { presence: true, file: true }
end callback :after_save, NotifyUserCallback def process params
validate params do |op|
file = params[:file]
if !file.is_a?(String)
converter = File::Converter.new original_file: file
op.file = converter.()
end op.save
callback!(:after_save)
end
endprivate def setup_params! params
@current_user = params[:current_user]
end def setup_model! params
model.uploader = @current_user
model.uploaded_at = Time.now
end
end
Let’s run through that line by line. The first line requires a custom class for converting images to pdf. We do not need to get into what that looks like, outside of the scope of this post. The first thing we do is define a class Doc::Create
which inherits from Trailblazer::Operation
. The reason we name the class Doc::Create
is because we want to namespace the action under the Doc
object, and Create
describes the operation we are performing. The next step is we include the Model
module provided by Trailblazer that gives us the ability to sync the object we create with a Rails model, we specify which model and which method to use with this piece of code: model Doc, :create
. This line include Dispatch
gives us access to callbacks, which we’ll use to send the notification. We use callbacks in this case so that we can chain a bunch of them and reuse them in other places, but for this example we could have just inlined the notification bit in this operation if we wanted to. The contract do ... end
part of the code indicates what params we expect to have passed to use from the form and we can add validations on these attributes the same way we would on a Rails model. The process
method is the method that gets called with this operation is run, it’s where we can add our custom logic. Because of what Trailblazer provides to us out of the box, like validations and synching to models, we don’t have to include a lot of logic to get our example working. We just run the validations, then call the converter, and save the object. Once that is done we call the callback to send the notification. What looked like complex logic became super simple when using Trailblazer::Operation
s. The only other piece of the puzzle is setup code that use to set the current user as the uploader
on the doc object. The reason we need that is because the current user is not a form field that we get, instead it’s an object that we pass along to the operation manually from the controller. Speaking of which, we should look at the controller code next:
class DocsController < ApplicationController
respond_to :json, :js def new
authorize Doc, :create?
form Doc::Create
end def create
authorize Doc create_params = doc_params.to_h.merge!(current_user: current_user) run Doc::Create, params: create_params do |op|
flash[:notice] = t(:notice, scope: [:flash, :docs, :create])
return redirect_to root_path
end
flash[:error] = t(:error, scope: [:flash, :docs, :create])
render :new
endprivate
def doc_params
params.require(:doc).permit(:file)
end
end
Because Trailblazer is awesome, when we define an operation, what we get for free is the ability to setup a form object that we can use in a form_for
helper just like we would a regular AR
model. So that’s all that the new
action is doing. Then in the create
action, we are fetching the permitted params for a doc, adding the current user object to it, then passing it to the operation we’ve defined and calling run
on it which will execute all the setup and then the process
method we talked about above. If the operation is successful and valid, then the code inside of the block is executed (which just sets the flash
message and redirects to the root_path
, you can obviously execute whatever fits your app. If the operation is not valid, then the code after the block is executed, which is very standard Rails controller code at this point, again, you’ll be able to include you’re own custom behavior there.
I would say that’s code that is organized, clean, and easy to follow. No magic, no hidden logic that will bite us in the ass. In fact, let’s see what we would do for the update
action:
require 'file/converter'class Doc::Update < Trailblazer::Operation
include Model
include Dispatch
model Doc, :create contract do
include Reform::Form::ModelReflections property :uploaded_at,
validates: { presence: true } property :file,
validates: { presence: true, file: true }
end callback :after_save, NotifyUserCallback def process params
validate params do |op|
file = params[:file]
if !file.is_a?(String)
converter = File::Converter.new original_file: file
op.file = converter.()
end op.status = :pending # <-- this is new op.save
callback!(:after_save)
end
endprivate def setup_params! params
@current_user = params[:current_user]
end def setup_model! params
model.uploader = @current_user
model.uploaded_at = Time.now
end
end
In the operation class, not much is different, we’ve only added a line to reset the status of the doc if a new file has been uploaded. You could, however, imagine scenarios where the logic can be vastly different and this setup lets us handle that kind of scenarios easily. On the controller side, the update
action will look exactly like the create
action, for the exception of where to redirect_to
on success or which action to render
on failure. It’s really that simple.
We’ve covered quite a lot so far, yet there’s still lots more that Trailblazer offers. You can check out the docs for more info. What I would say though, is that what we covered in this post should be enough to get you started and thinking about making sure your Rails code does not get messy and out of control.
Do you have experience with Trailblazer? Share it with us in the comments below…