Untangle your code with MORE than service objects! Meet dry-monads!

Seb WilgoszSeb Wilgosz
15 min read

Recently I wrote an article, where I've told about the decision process behind my attempt to replace Rails with Hanami in our microservices ecosystem and why I decided not to do it just yet.

โ—
Please keep in mind that this statement was made before even Hanami 2-alpha versions had been officially released.

The important part was, that rewriting my Hanami application to Rails took me less than a day - and I was able to do so, because of how I and my team tend to structure code in our web applications in recent years.

Then I went through this great article about using Service Objects in Rails applications and It plucked my heartstrings. At first, I thought: Another article about Service objects, which I personally HATE.

But almost instantly, a second thought came to my mind, revealing, that I am actually a super fan of service objects. I just don't name them this way, I use more abstractions and extract dependencies for better clarity.

So in this episode, I'll show you my version of service objects that allowed me to replace one ruby web framework with another in a complete service in less than a day.

โ—
Disclaimer. This was a very small API-only service. Since then though, I've gained a lot of experience thanks to Hanami Mastery and working with Hanami daily.

In the next couple of minutes, I'll go through refactoring the onboarding endpoint to the structure we use in our microservices. Maybe it'll inspire you to improve your codebases and If it will, please send me your ideas so I can learn from them!

Enjoy!

Why do we need service objects?

I have here a simple Rails API application, with a single endpoint, that allows me to register only a single user. I did simplify it for this video but wanted to implement a few hidden functionalities, so it is enough to visualize the benefits of the refactoring.

List of onboarding error responses

๐Ÿ‘‰
Just please keep in mind, that the more complex the problem your application solves is, and the bigger your project grows, this technique will give you more and more benefits.

I've tested it in small microservices but also in big monolithic applications with hundreds of endpoints available.

Rails controllers do too much

So I have this single endpoint that creates a user and you may think it's the only thing it does but is it really?

It returns an error where another user had already been registered, a different error when the request body is wrongly formatted, one more when there is no authorization header, and finally the validation errors when user validation fails.

If I'll visit the controller, you will see, that even for this single action there is quite a lot happening.

# app/controllers/onboarding/registrations_controller.rb

module Onboarding
  class RegistrationsController < ApplicationController
    before_action :authorize!

    AuthorizationError = Class.new(StandardError)
    rescue_from AuthorizationError, with: -> { head :forbidden }
    rescue_from ActionController::ParameterMissing, with: -> { head :bad_request }

    def create
      user = User.new(user_params)

      if user.valid?
        if User.count > 0
          message = 'too many subscriptions! Try again later'
          return render json: { errors: message }, status: 418
        end

        user.save
        head :created
      else
        render json: { errors: user.errors }, status: :unprocessable_entity
      end
    end

    private

    def user_params
      params.require(:user).permit(:name, :email)
    end

    def authorize!
      token = request.headers['Authorization'].to_s.gsub('Bearer ', '')
      raise AuthorizationError if token.blank?
    end
  end
end

At the very top, you have the authorization check, which I've just implemented as a placeholder for this showcase, checking if the authorization header is present. I've written a complete tests coverage for this project to refactor safely, and it had been caught by one test example.

Then when there are specific errors risen, I'm rescuing from them calling proper error rendering methods.

Still, this single controller action does several things and there are some issues hard to be seen and tough to debug.

And this is a super simple one.

My experience from big rails projects shows that one can never underestimate, how big rails controllers may become.

Hidden issues in standard Rails applications

Let me go through some of the issues hidden here.

Unnecessary DB requests

You can see, that it first authorizes the request. It's not ideal, because it happens before params deserialization. This usually means, fetching objects from the database, like the OAuth application, or current user, which may result in unnecessary DB queries.

Imagine big CanCanCan Ability class, and You'll immediately get an idea what I'm talking about.

Conditional validations

Then we have the validation check, rendering validation errors in case of failure. Here is another common problem hidden. The user may be valid for creation, but to update the user, you could need different validation rules.

In this case, you'll end up with conditional validations which are very hard to be tracked.

API versioning and backward compatibility

Also when your application supports multiple API versions, it may be possible, that in the older API version your user was valid, but then you had added more validation rules to the user and the new API is not backward compatible.

API versioning, when we have validations stored in global Active Record models, is very hard to maintain, and this is the main reason I'm avoiding storing validation logic inside of the models.

Business logic in the controller

Then, finally, we have a business validation rule that prevents our action to succeed. It's not about validating the input.

The request body is completely valid and the client has an access to perform the request, but at the current state of the application, this action is not allowed due to the business condition.

These kinds of checks are something I often see in rails controllers or models, but I love the approach coming from Domain Driven Development, where the Business logic is aggregated in a completely separated object.

So what our controller does?

If we take all above and sum them up, it'll be clear that our controller

  1. Deserializes the input

  2. Authorizes the request

  3. Validates the params

  4. Checks business conditions

  5. Performs the action (updates the application state)

  6. Serializes the response

Each of these steps can potentially be a bit complicated, like validating the request parameters, or checking if the business rules allow for given action to be performed in the current application state.

It makes TOTAL SENSE then to not keep it all in the controllers, right?

However, in most Rails applications all those steps tend to be squeezed between the Model and Controller without too much thought behind managing business processes.

If you'll add 10 additional actions to the single user model, you'll easily end up with a big mess.

So, writing service objects for each and every action in your controller is pretty useful.

Refactoring the controller action

Now let me refactor this code to make it more straightforward, more scalable, and more reliable.

I'll use tree gems to do that.

1. Dry-Monads

To easily create objects which list several steps to perform, and better handle errors, I'll use Dry-Monads.

With this gem, I'll be able to ensure, that All my objects always return the same type of objects, either Success or Failure, which can be easily caught, compared, and processed later.

2. Dry-Matcher

Dry-Matcher is a pattern matching for Ruby that puts error handling to the next level. It has built-in integration with dry-monads, so It's a natural candidate to be used as a comparison engine for different failure objects.

3. Dry-Validation

Finally, Dry::Validation is the best validation engine I know. I have used it in all my projects for years already, to extract my validation rules out of Active Record objects.

While I'll go through the implementation pretty fast in this episode if you're interested in a deeper dive into any of those gems, let me know using #suggestion - and you can consider sponsoring me on Github to get a bigger impact on future episodes content.

Action object

Going back to the refactoring. First of all, I'll not start from the ServiceObject.

A service is an object that performs a single Business Process, so it should not be concerned about any of this authorization or validation stuff. It makes sense then to not call it directly from the controller.

Service Object responsibility

This is why when I implement my Rails endpoints, I always start by creating the endpoint action object, where I list all the steps that are required to perform the single action.

It accepts a rack request and returns the serializable response. Writing your controller actions in the way they're Rack-Compatible is the first step to creating truly framework-agnostic web applications.

# /lib/onboarding/endpoints/create_user/action.rb

require 'dry/monads'
require 'dry/matcher/result_matcher'

module Onboarding
  module Endpoints
    module CreateUser
      class Action
        include Dry::Monads[:do, :result]
        include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)

        def call(request)
          # Steps to be listed here
          Success(status: :created)
        end
      end
    end
  end
end

It'll contain a single call method as the only interaction point. I try to make all my projects callable, so it's easier to maintain communication between them.

Then I'll include the result monad so I can access Success and Failure objects directly, without prepending them with Dry::Monads - and enable the do notation.

Now let's list the steps.

# /lib/onboarding/endpoints/create_user/action.rb

def call(request)
  model, auth = yield deserializer.call(request)
  yield authorizer.call(model, auth)
  res = yield validator.call(model.to_h)
  yield create_user.call(res.to_h)

  Success(status: :created)
end

First I need to deserialize the request, getting the parameters and the authorization data. In this case, authorization will be just a token for simplicity.

Then I want to check the authorization logic - only after validating the format of the request body.

If this one succeeds, I call the validator to run validation rules.

Only in case the validation passes, I call the CreateUser service object, to actually try performing the business process action.

This way, my CreateUser service can be easily called from other places of the system, like background workers, or the developer console, where I don't need for example authorization check.

Each of those steps is an endpoint dependency. The action object is only concerned about what to call, and in which order, but the detailed logic is hidden in separate step classes.

# /lib/onboarding/endpoints/create_user/action.rb

require 'dry/monads'
require 'dry/matcher/result_matcher'

module Onboarding
  module Endpoints
    module CreateUser
      class Action
        include Dry::Monads[:do, :result]
        include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)

        def call(request)
          model, auth = yield deserializer.call(request)
          yield authorizer.call(model, auth)
          res = yield validator.call(model.to_h)
          yield create_user.call(res.to_h)

          Success(status: :created)
        end

        private

        attr_reader :deserializer, :authorizer, :validator, :create_user

        def initialize
          @authorizer = Authorizer.new
          @deserializer = Deserializer.new
          @validator = Validator.new
          @create_user = Operations::CreateUser.new
        end
      end
    end
  end
end

Let me implement them quickly.

Deserializer

The first step is a deserializer. It is supposed to ensure that params and headers are in a valid format.

The call method also accepts the rack request and returns either Success or Failure value.

First I need to get the params, then validate the format using deserialize method. It does pretty much the same that the controller did, but it catches the ParameterMissing error and returns the Failure object instead.

Then I get the authorization data - just for simplicity I just extract the token from headers. You may replace it with JWT token decoding, or whatever else you use in your application.

And at the end, return the Success monad.

# /lib/onboarding/endpoints/create_user/deserializer.rb

require 'dry/monads/result'

module Onboarding
  module Endpoints
    module CreateUser
      class Deserializer
        include Dry::Monads[:do, :result, :try]

        def call(request)
          params = ActionController::Parameters.new(request.params)

          model = yield deserialize(params)
          auth = yield fetch_token(request)

          Success([model, auth])
        end

        private

        def deserialize(params)
          res = Try[ActionController::ParameterMissing] do
            params.require(:user).permit(:name, :email)
          end

          res.error? ? Failure(:deserialize) : res
        end

        def fetch_token(request)
          token = request.headers['Authorization'].to_s
          Success(token.gsub('Bearer ', ''))
        end
      end
    end
  end
end

Authorizer

The second step is to authorize the action using the given parameters and authorization data. It may happen, that in-between-step will be required to fetch additional data for the authorizer to proceed, and you can imagine, how easy it would be to add such here.

My simple authorizer will just check if the token extracted from the header is present - but you may imagine that quite complex authorization rules may be listed here.

Again, It returns either Success or failure.

# /lib/onboarding/endpoints/create_user/authorizer.rb

require 'dry/monads/result'

module Onboarding
  module Endpoints
    module CreateUser
      class Authorizer
        include Dry::Monads[:result]

        def call(_model, auth)
          auth.length.zero? ? Failure(:authorize) : Success()
        end
      end
    end
  end
end

I hope you start seeing the pattern here. Because of the fact that all my objects return always the Result monad, I am free to handle all of them in the exactly same way.

Validator

The third step is the actual validation. In the user object, I have the presence validation for name and email, and also the uniqueness check for the email field.

# /app/models/user.rb

class User < ApplicationRecord
  validates :email, uniqueness: true
  validates :name, :email, presence: { message: 'must be filled' }

  # after_create :send_notification email
end

Let's replicate that using dry-validation here.

At the very top of my validator, I'm loading the monads extensions, to make my validators compatible with the Success and Failure objects I return in my other steps.

# /lib/onboarding/endpoints/create_user/validator.rb

require 'dry/validation'

Dry::Validation.load_extensions(:monads)

module Onboarding
  module Endpoints
    module CreateUser
      class Validator < Dry::Validation::Contract
        # rules go here
      end
    end
  end
end

First, let's define the basic validation rules. They will be already more powerful due to the built-in type checking.

First I need the name to be required and present, with the type of string. Then I repeat the same for email...

... and I wrap those rules into the params coercion block, which does the basic value transformation at the beginning. If you're interested in details about it, I strongly recommend you to visit the dry-validation's documentation page where this is explained deeply.

# /lib/onboarding/endpoints/create_user/validator.rb

...
class Validator < Dry::Validation::Contract
  params do
    required(:name).filled(:string)
    required(:email).filled(:string)
  end
end
...

Now Let's write the uniqueness validation for email. This will only be checked if the basic validations passed.

I add a custom rule for email, which returns a failure for this attribute key with the given message if the user with this email already exists in our database.

However, I don't want to make my validator coupled to User class, so I inject it as a repository using the option feature.

# /lib/onboarding/endpoints/create_user/validator.rb

...
class Validator < Dry::Validation::Contract
  option :repository

  params do
    required(:name).filled(:string)
    required(:email).filled(:string)
  end

  rule(:email) do
    key.failure('must be unique') if repository.exists?(email: value)
  end
end
...

Then let me go back to the action, and during the initialization of the validator specify that the repository that should be used by the validator is a User class.

# /lib/onboarding/endpoints/create_user/action.rb

@validator = Validator.new(repository: User)

This makes it extremely easy to test in encapsulation, as I can just inject anything to my validator that responds to the exists? method - so there is no coupling to rails or active record objects.

Service Object

Finally, I call the CreateUser service object (or interactor) with the value that is returned from my validator. At this point, I am hundred percent sure that I'm always working with the valid input parameters, correct types, and values so I can easily skip validation, or raise unexpected errors if such a situation happens.

โ—
Different names for service objects

You may notice that I tend to call my service objects - operations, the same I did for validators instead of contracts - but that's irrelevant. Call them however you wish, just be consistent.

๐Ÿ’ก
Other names you may see on the web
  • interactors

  • operations

  • service objects

Let me create the service object quickly.

Again, the object has a single call method. It fails if there is already a user registered, then creates a user and returns success otherwise.

You can easily extend it to schedule some notification emails or do other fancy updates, but the core thing here is that none of this stuff is application-related, but rather performs the business process.

# /lib/onboarding/operations/create_user.rb

require 'dry-monads'

module Onboarding
  module Operations
    class CreateUser
      include Dry::Monads[:result, :try]

      def call(args)
        failure_message = 'too many subscriptions! Try again later'
        return fail!(failure_message) if User.count > 0

        User.create!(args)
        # schedule_email(args)

        Success()
      end

      private

      def fail!(message)
        Failure[:teapot, message: message]
      end
    end
  end
end

With this, our endpoint is pretty much done.

The call_action method

Finally, let's go back to our controller and clean it up.

I can pretty much remove everything from here, replacing the method with only a single line, calling the given action object.

# /app/controllers/onboarding/registrations_controller.rb

module Onboarding
  class RegistrationsController < ApplicationController
    def create
      call_action(create_user)
    end

    private

    def create_user
      Endpoints::CreateUser::Action.new
    end
  end
end

The call_action method takes care of the error handling and this is where we make use of dry-matcher integration.

it accepts the given action and calls it with the rack request. Then in case, the action is successful, it renders an empty body with the status got from the result.

# /app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  private

  def call_action(action)
    action.call(request) do |result|
      result.success { |status:| head status }

      result.failure(:deserialize) { head :bad_request }

      result.failure(:authorize) { head :forbidden }

      result.failure(Dry::Validation::Result) do |res|
        render json: { errors: res.errors.to_h }, status: :unprocessable_entity
      end

      result.failure(:teapot) do |message:|
        render json: { errors: message }, status: 418
      end

      result.failure { head :server_error }
    end
  end
end

in case it's a deserialization issue, it returns the bad request HTTP status, and respectively for authorization failure, the forbidden error is returned.

Where a failure is a validation object then we can render the validation errors with unprocessable_entity status code,

and when business logic fails, we can return something else, like a teapot with a detailed message.

Finally, if there is another type of failure returned, we can return the 500 HTTP status code.

The refactoring completed!

Summary

There are of course pros and cons of this approach.

Is this code easier to read? I would say so, but it requires more jumping between files.

It allows me to update rails projects easily, or even replace one web framework with another in no time.

However, the drawback is that more actual code needs to be written.

I've designed this years ago for our Rails applications, and I was AMAZED, when I've discovered, that Hanami actually evangelizes a very similar programming style and conventions.

If you consider trying Hanami after years of working with Rails, you'll meet more such programming styles, where dependencies are injected from outside, and the single responsibility is encouraged for your objects.

To summarize

don't be afraid of putting more abstractions to your systems. If service objects are supposed to only handle business processes, maybe calling them directly from the controller is not the best approach.

Instead of naming everything service, I name objects based on what they actually are, and this reduced the amount of code that needed to be refactored when I needed to change frameworks, or more often when I need to update the Rails or Hanami in our projects.

Here are some ideas for naming ruby objects in web applications.

Different abstraction names examples

I hope you've enjoyed this episode and as always I'd like to say thanks to my Github sponsors, I appreciate the support as it allows me to create better content and it speeds up the development of the Hanami web framework.

If you enjoyed this episode and want to see more content in this fashion, Subscribe to this YT channel** and follow me on twitter! As always, all links you can find the description of the video or in the https://hanamimastery.com.

Thanks

Special thanks to:

0
Subscribe to my newsletter

Read articles from Seb Wilgosz directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Seb Wilgosz
Seb Wilgosz