Sharing Business Logic
When writing web applications with rails, you commonly come across the question about fat-models and skinny-controllers vs skinny-models and fat-controllers. This is because we do not like to repeat ourselves. Too much logic into the controllers make it hard to reuse them. This is why adding the logic to the models seems much more convenient.
How about skinny models, skinny controllers and fat services?
These service objects can handle the complex business logics while models handle only their data and controllers handle only routing.
A simple example for this could be creation of a new job in our system:
class Job < ApplicationRecord; end
class JobsController
def create
result = Jobs::Post.call(
user: current_user,
attributes: job_attributes
)
result.success? ? : job_path(result[:job]) : error_path
end
def job_attributes
params[:job]
end
end
Its a minimal setup.
- A model that just holds data.
- A controller performing an action and routing based on the output of that action.
- A service object implementing the actual business logic to perform the action.
The service object’s implementation might look something like this:
module Jobs; end
class Jobs::Post
def self.call(**opts)
new(opts).call
end
attr_accessor :user, :attributes
def initialize(user:, attributes:)
self.user = user
self.attributes = attributes
end
def job
@job ||= Job.find_by(id: attributes[:id])
end
def call
return Result.new(false) unless user
return Result.new(false) unless valid_attributes?(attributes)
return Result.new(false) unless is_allowed_to(user, :post_jobs)
return Result.new(false) unless is_allowed_to_edit(user, job)
return Result.new(false) unless user.reached_job_limit?
title = strip_html_and_convert_to_markdown(attributes[:title])
description = strip_html_and_convert_to_markdown(attributes[:description])
company = find_company(attributes[:company])
location = find_location(description, attributes[:location])
salary = predict_salary(description)
job_data = {
user: user,
title: title,
description: description,
company: company,
location: location,
salary: salary
}
Result.new(true, job: (job ? job.update_attributes(job_data) : Job.create(job_data)))
rescue
Result.new(false)
end
def is_allowed_to(user, action); end
def is_allowed_to_edit(user, resource); end
def valid_attributes?(attributes);end
def strip_html_and_convert_to_markdown(text); end
def find_company(company_name); end
def find_location(description, given_location); end
def predict_salary(description); end
end
This would cover both updates and creates on the jobs.
The branching nightmare
In our case, we have multiple sources with slightly different input and special rules for certain kind of jobs. This quickly leads to a branch nightmare. For above example, to support job updates from a premium user, the call method might change to something like this:
def call
... # ignored code snippet
company = if user.is_premium?
user.company
else
find_company(attributes[:company])
end
location = if user.is_premium?
attributes[:location]
else
find_location(description, attributes[:location])
end
salary = user.is_premium? ? attributes[:salary] : predict_salary(description)
... # ignored code snippet
end
This is probably what happens in 90% of the cases when a new feature is added. An usual alternative is to write a new copy of this class but with the conditions for that of a Premium user. This would be against the all-so-holy principle of “do not repeat yourself”. In this case, I would argue that its bad to write actions in the above style and that you should not do it.
After looking at other approaches, we came across Trailblazer and dry-rb. These are collections of libraries that make it easier to write well structured ruby. They each have their own concepts similar to Services.
- in Trailblazer, they are operations
- in dry-ruby, they are transactions.
They both implement the same idea of writing code in a step-by-step manner, only moving to the next step if the previous one executed successfully. It is very similar to the template pattern but instead of using Inheritance they use Composition.
dry-ruby has much more to offer than just transactions and I would say it is at least worth a look to get inspired, but this would go beyond the scope of this post.
Composition over inheritance
Everybody is probably used to Inheritance to share and extend code. Inheritance is great when carefully planned and requirements for a specific domain do not change much. This is unfortunately not always the case and can create a big mess when used irresponsibly.
To counter this problem, the solutions from above mentioned frameworks follow the Composition principle: Instead of inheriting behaviour and passing it down the tree we always pick the exact behaviour we want for a specific action. This turns our actual service classes into a recipe with an attached grocery basket.
This way of programming complex systems is especially popular when it comes to game programming where requirements change often and Inheritance can lead to even bigger problems. Unity3d is a popular engine that uses the Composition principles. In our case we do not need a system quite as complex as that and can get away with a lot less complexity.
In case you are interested in game programming you should checkout godot and Unity3d. If you are on Linux you should choose
godot
as it has a native Linux version.
Lets take a look at our first business logic re-written in dry-ruby’s transactions:
class Jobs::Post
include Dry::Transaction(container: JobPostingContainer)
check :user_present
check :valid_attributes
check :user_allowed_to_post
check :user_allowed_to_edit
check :user_reached_posting_limit
step :extract_title
step :extract_description
step :extract_company
step :extract_location
step :extract_salary
try :save_or_update_job!
end
What just happened!
Our business logic class turned into a recipe with specific steps that are executed one after the other. Looking at the service class, one can immediately tell what the service class is doing. It also becomes very apparent which step is mutating state.
Each of the steps are simple service objects that respond to a call
method and are registered in the JobPostingContainer
:
class JobPostingContainer
extend Dry::Container::Mixin
class TitleExtract
include Dry::Transaction::Operation
def call(input)
# do stuff
end
end
register "extract_title" do
TitleExtract.new
end
end
Of course we can also write steps as simple functions right in the service object class. But we should keep in mind that we want to only do this rarely or for prototyping purposes.
Adding features with composition
This way our code becomes very modular, of course we have to be mindful. Making functions too fine grained is as well not desirable. We can compose new operations quickly and get tests already included.
As an example lets take the update for the premium user here:
class Jobs::PostPremium
include Dry::Transaction(container: JobPostingContainer)
check :user_present
check :user_is_premium
check :valid_attributes
check :user_allowed_to_post
check :user_allowed_to_edit
check :user_reached_posting_limit # added for premium users
step :extract_title
step :extract_description
step :extract_premium_company # added for premium users
step :use_given_location # added for premium users
step :use_given_salary # added for premium users
try :save_or_update_job!
end
We did not repeat ourselves, the command is doing exactly what we want. More importantly we do not have any branching in our business logic. This makes testing much easier because we only have a single path through the code. Composing service classes this way allows you to reuse code and be very specific when it comes to different implementations.
I hope you enjoyed this little foray into how we share our business logic and use composition for our service objects.