#Technic 01/22/2020
A friend asked a code review of some Rails controller.
He has couple of actions that requires to be chained, one to the next.
All of those actions can, of course go wrong.
Let's see how we cleaned this code.
Here some bad bad code that you should no be writing:
class MyController < ApplicationController
def create
response = fetch_api
return unless response
save_into_db response
return unless response
load_from_db response
end
protected
def fetch_api
"...SOME CODE..."
end
def save_into_db
"...SOME CODE..."
end
def load_from_db
"...SOME CODE..."
end
end
First problem, the protected method should be extracted into services. This way they are reusable and easier to test.
But it’s not my friend code, instead he created something more like this.
Extraction into command class
class Command
def self.call(*params, &block)
new(*params, &block).call
end
def call
reset
run!
rescue CommandException => e
errors.push e
ensure
self
end
def reset
self.payload = nil
self.errors = []
end
def success?
errors.empty?
end
def result
payload
end
def merge(cmd)
self.payload = cmd.result
self.errors += cmd.errors
end
attr_reader :errors
protected
attr_accessor :payload
attr_writer :errors
end
class FetchApiCmd < Command
def run
response = SomeApi.fetch('something')
if response.status == 200
self.payload = response.data
else
self.errors.push "invalid response #{response.status}"
end
rescue StandardError => e
self.errors.push e.message
end
end
Allowing the following version of the controller :
class MyController < ApplicationController
def create
@response = FetchApiCmd.call params[:something]
return unless @response.success?
SaveIntoDBCmd.call @response, params[:something_else]
return unless response.success?
@response = LoadFromDBCmd.call @response
end
end
The methods are now separeted into *Cmd classes, stored in app/commands. And the view can deal with the @response. It’s a bit better, but we are not there yet.
Chaining command
I wrote some Gist similar to this :
class ChainCommands < Command
def initialize(&block)
self.cmds = [block]
end
def then(&block)
cmds.push &block
end
def run
run_subcmd cmds.first
return self unless success?
res[1..-2].each do |cmd|
run_subcmd cmd, result
break unless success?
end
self
end
protected
def run_subcmd(cmd, *arg)
merge cmd.call(*arg)
end
attr_accessor :cmds
end
Allowing the following refactor :
class MyController < ApplicationController
def create
@response = process.call
end
protected
def process
ChainCommands
.call { FetchApiCmd.call params[:something] }
.then {|last| SaveIntoDBCmd.call(last.result, params[:something_else]) }
.then {|_last| LoadFromDBCmd.call }
end
end
We have now a more explicite definition of the process, it’s a chain of commands, and the view handle a response object containing the errors or the final payload.
The protected method could be also be extracted as a separed command.
Challenging the result
We haven’t look at existing code that could help. It can be some gem that implement in a better and more tested way this command and chain system.
For example in the ruby toolbox
We could also have worked with Exception. Actually we talked about it, but it was not fitting the current implementation of the HTML view behind. But the question is still on, what if (and it will) an exception is triggered by one of the commands ?
Well some of them are handled by the commands (Timeout, NetworkError, Transaction …). I personnaly would prefer to handle on the top level the exception, I think the code would have been more simple. However, this command approach allow more flexibility down the road. For instance, if you want to branch on another process after a failure.
Happy code !