Improve Your Services Using Dry-rb Stack

Photo of Kamil Walkowiak

Kamil Walkowiak

Updated Jan 9, 2023 • 17 min read
photo-1484417894907-623942c8ee29-1

The service objects layer is a crucial part of the Ruby on Rails backend. This is the place where most of the business logic is stored. This is the place that we want to keep in the best possible shape.

For that purpose, I have started using some of the gems from the Dry-rb stack. In this blog post, I will try to share with you some solutions based on the Dry-rb gems that can make your services even better.

Dry-rb

Dry-rb is a collection of gems that allows you to solve more efficiently many different problems. It provides a set of complex solutions for the most common problems as e.g. input validation or even some more complex ones like e.g. dependency injection. Currently, the stack consists of 18 separate gems (some of them may depend on others to work properly). I would like to show you an example usage of a few of them.

Example case

For the purpose of this blog post, let's consider a simple case: we need to implement a single endpoint that books a conference room. The endpoint accepts parameters listed below:

{
   room_id: Integer,  # database id of the room to book
   start_date: Time,  # date and time of the reservation start
   end_date: Time,    # date and time of the reservation end
   notes: String,     # some optional notes
}

Additionally, when the booking process ends successfully, we need to send an email notification the the user.

Obviously, we will need to validate the input params (e.g. whether the room with provided room_id exists) as well as do some additional checks (e.g. check if the room is available in a desired time period) before we can actually create the reservation.

To implement such endpoint, we would need to add a service responsible for creating our reservation and define a new controller action that will validate input params and call the service. First, let's start with the service.

Railway Oriented Programming concept

While writing services, we very often need to control its execution flow. We cannot afford ourselves the luxury to focus only on the happy path execution flow. Instead, we need to be sure that all possible failing paths are covered too. For that purpose, we can, of course, use a bunch of conditional statements. It would do its job but it will also make the code a lot more complicated and less readable. This is a moment when the Railway Oriented Programming may be useful.

Railway Oriented Programming is a concept emerged from the functional programming paradigm. It assumes that our code consists of a chain of independent black-box steps (functions) that can either execute successfully and return a success response or end with some error and return a failure response. If the function ends with success, the next function is called. If one of the functions in the chain ends with failure, next functions are not executed and the failure response is returned for the whole call. To understand the concept better, we can use analogy to two-railway track. We have one track for a successful execution and another one for a failing one. Each function includes a "switch" that can move the execution from a successful rail to a failing one. If you are more interested in the Railway Oriented Programming, go to Scott Wlaschin's presentation about this concept.

Make your services Railway Oriented using Dry-monads

Now, you may say:

OK, Railway Oriented Programming is a cool concept but what has it to do with the original problem and Dry-rb?

The answer is simple: to solve our problem, we will build a Railway Oriented service and to do so we will use one of Dry-rb gems called Dry-monads. If you look closely at the list of things our service needs to do in order to create a room reservation, you can see a few independent steps and almost each of them can end with success or failure:

  1. Check if the room is free for given time period - We could check it during the data validation phase in the controller but this is not the responsibility of the input data validator. The responsibility of the input data validator is only to make sure that the input is correct, and in this case, the data is correct but there are some other conditions that are not fulfilled.
  2. Create the actual reservation object in our database.
  3. If everything went well, send an email notification to the user.

Dry-monads is a gem that provides monads support for Ruby programming language. Monads can be used e.g. for more clear errors, nil values or exceptions handling. One type of monads, provided by the gem, can be very useful in our case: it is a Result monad. This monad may have one of two values: Success or Failure. Result allows us to chain a few Ruby functions together so that if a previous one had returned a Success the next one would be called and when it had returned a Failure the execution would stop. Basically, it gives us the opportunity to build service in a Railway Oriented way. Let's look at an example service, written using the Result monad, that solves our example problem:

class Reservation::Create
  include Dry::Monads[:result]

  def initialize(user:, room:, start_date:, end_date:, notes: nil)
    @user = user
    @room = room
    @start_date = start_date
    @end_date = end_date
    @notes = notes
  end

  def call
    check_if_room_available
      .bind { create_reservation }
      .bind(method(:send_notification))
  end

  private

  attr_reader :user, :room, :start_date, :end_date, :notes

  def check_if_room_available
    Try(ActiveRecord::ActiveRecordError) { existing_reservations.exists? }.to_result.bind do |result|
      if result
        Failure('The room is not available in requested time range')
      else
        Success(nil)
      end
    end
  end

  def create_reservation
    reservation = Reservation.new(
      user: user, room: room, start_date: start_date, end_date: end_date, notes: notes
    )

    if reservation.save
      Success(reservation: reservation)
    else
      Failure('The reservation could not be created')
    end
  end

  def send_notification(reservation:)
    NotificationMailer
      .notify_room_booked(user: user, reservation: reservation)
      .deliver_later

    Success(reservation)
  end
  
  def existing_reservations
    Reservation.where(room: room).in_time_range(start_date: start_date, end_date: end_date)
  end
end

Looking at the call method, you can see that we are chaining three methods using the bind method. To better understand the whole structure of the call method, you can think that it is equal to such code:

send_notification(create_reservation(check_if_room_available))

First, we call the check_if_room_available method. After that, only if this method returns a Success monad, the execution moves to the create_reservation method. Then again when the method reports success, we call send_notification but this time it gets some input params included in the body of the Success monad returned by the previous method. If all calls end successfully, the last method in the chain returns the whole service response. If any method in the chain returns Failure monad, the whole execution stops and the response from the last called method (that reported failure) is returned.

When you look at the call method again, you can see that while binding methods sometimes we are using block and sometimes we are using the method method. You can use block when you don't need to pass any value from previous method to the next one (as when moving from check_if_room_available method to create_reservation). You can use the method method when you have argument(s) that needs to be passed to the next method in the chain (as when moving from the create_reservation method to the send_notification as you need to pass the newly created reservation object).

Try monad

You may noticed the strange Try(ActiveRecord::ActiveRecordError) { existing_reservations.exists? } line in the code above. What is it actually doing? It uses another type of monad provided by the Dry-monads gem which is Try monad. Its purpose is similar to the Ruby rescue, it rescues a block from an exception in its own "monadic" way. When the block raises an exception, the Error type (wrapping the raised exception) is returned, otherwise it returns the Value type (value of the block execution). In our example, we are using Try monad to catch all ActiveRecord::ActiveRecordError errors that can be potentially raised while connecting to the database. It is worth mentioning that any exception of type different than ActiveRecord::ActiveRecordError will still be raised (it is a good practice to rescue only expected exceptions).

Later on, we are calling .to_result on the monad returned by the block. This method converts the Try monad to a Result monad. The result of such call could be then a Success monad (including the block result) or a Failure monad (including raised exception). We are doing it to be able to then bind on the Result monad and do the actual check on the data fetched from the database.

Working with the response

To get the final status of the call of a service returning Result monad type, you can use the success? or failure? method on the service output. To get the value(s) returned from the service, use one of those methods:

  • value! - returns the value if the service ended successfully or rises an exception otherwise
  • value_or(arg) - returns the value if the service ended successfully or the given arg otherwise
  • success - returns the value if the service ended successfully or nil otherwise
  • failure - contrariwise to the mentioned above, returns value if the service ended with failure or nil otherwise

Do notation

The way of composing methods presented above (let's call it the bind notation) is the original one introduced from the beginnings of the Dry-monads gem. Since the release of 1.0 version, you can also use the so called Do notation. Let's see how the service call method would look like if we used the Do notation:

# include Do notation for #call method only
include Dry::Monads::Do.for(:call)
# OR include Do notation to all methods by default alongside the Result monad
include Dry::Monads[:result, :do]

def call
  yield check_if_room_available
  reservation_data = yield create_reservation
  send_notification(reservation_data)
end

There are few important things to notice:

  • With the Do notation, you must use yield for all methods that may possibly return a Failure monad.
  • If a method will not return a Failure monad, you don't need to use yield and you (with some exceptions, see point below) don't even need to return a Result monad at all. This is contradictory to the original bind notation where you always must return a Result monad from a method.
  • The last method in the chain doesn't need to be yield but it is the only method in the chain that must return a Result monad.
  • In the bind notation, the arguments are passed automatically between next methods in the chain while in the Do notation, you need to pass them explicitly.
  • It is also worth mentioning that the Do notation uses exceptions to handle failures (what may be useful when you want to wrap the whole service execution around a single database transaction).

Both, the bind and Do notations, are doing the same thing and allowing us to build a Railway Oriented services. Which notation you choose is up to you.

Achieve strict typed and better documented service arguments using Dry-types and Dry-initializer

If you are often using constructor to pass the data to the service, Dry-initializer may be an interesting proposition for you. The idea of the gem is simple: it allows you to define params (plain method arguments) and options (hash arguments) for Ruby class instances using provided mixin. As in standard constructors, you can define the arguments as optional and provide some default values for them. Additionally for each arguments, the gem defines a reader (with defined level of protection). Dry-initializer provides some other features like defining type coercers or detecting if optional parameter was omitted but there is one feature that I want to elaborate more: it is the integration with the Dry-types gem.

Dry-types is a gem that introduces an extensible type system for Ruby. It is a successor of the Virtus gem. It allows us to define more complex structs, value coercers and (most importantly in our case) type constraints. Combining the power of Dry-initializer and Dry-types, we can define the type constraints on the input parameters of our services. This way, we achieve two things:

  • Our input parameters are even better documented. You will know what type is expected for each argument by just looking at the code without any additional documentation or code analysis.
  • We get some kind of "strict typing" prosthesis. As we know, Ruby is a duck typed language. Using the Dry-types strict type constraints, we can enforce the strict type checking at least while initializing the service.

Dry-types allows to define few kinds of type constraints. I find myself to use mostly the strict (for primitives) and instance (for object instances) types constraints which raise an exception if a given parameter has a wrong type. This is very useful while developing the app as you will get clear information that this is not a parameter that the service was expecting to get. To see all possible type constraints heads up to the Dry-types gem documentation.

Now, let's see how the service from point above would look like if we replace the constructor with parameters definition using Dry-initializer and Dry-types:

class Reservation::Create
  include Dry::Monads[:result]

# Two additional module includes include Dry.Types extend Dry::Initializer option :user, type: Instance(User), reader: :private option :room, type: Instance(Room), reader: :private option :start_date, type: Strict::Time, reader: :private option :end_date, type: Strict::Time, reader: :private option :notes, type: Strict::String, reader: :private, optional: true def call # No changes end private # The attr_reader definition was removed # No changes later # ... end

As you can see, we have defined a user as an instance of a User class and a room param as and an instance of a Room class. Then we require start_date and end_date params will strictly (without coercion) have a Time type. Finally, we defined notes param as an optional with String type strictly required.

We achieved a clean definition of input parameters and their types. Now, let's see what will happen when for example we will pass a String instead of a timestamp for an end_date argument:


irb(main):001:0> Reservation::Create.new(user: User.last, room: Room.last, start_date: Time.current, end_date: 'asdf')

Dry::Types::ConstraintError: 1 violates constraints (type?(Time, 1) failed)
...

It's beautiful, isn't it?

What's next?

We took a look at how to improve control flow using the concept of Railway Oriented Programming. We have learned how the Dry-monads, Dry-initializer and Dry-types become useful while writing services in a Railway Oriented way. We made our services more clear and easier to maintain. But, this is only the beginning of our journey with the Dry stack. As mentioned earlier, Dry stack consists of many more gems than the ones mentioned in this blog post. This gives us much more possibilities to improve our code even more. Soon in my next post, we will take a look at how to improve the process of validating the input data in the controllers. So, stay tuned 🖐️.

Further reading:

Photo by Émile Perron on Unsplash

Photo of Kamil Walkowiak

More posts by this author

Kamil Walkowiak

Kamil has obtained a Master’s degree in Computer Science at Poznań University of Technology. He is...
Lost with AI?  Get the most important news weekly, straight to your inbox, curated by our CEO  Subscribe to AI'm Informed

Read more on our Blog

Check out the knowledge base collected and distilled by experienced professionals.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business