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.

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.