Improve Your Services Using Dry-rb Stack
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:
- 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.
- Create the actual reservation object in our database.
- 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 otherwisevalue_or(arg)
- returns the value if the service ended successfully or the givenarg
otherwisesuccess
- returns the value if the service ended successfully ornil
otherwisefailure
- contrariwise to the mentioned above, returns value if the service ended with failure ornil
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:
- Dry-rb web page
- Railway Oriented Programming by Scott Wlaschin
- Dry-monads documentation
- Dry-types documentation
- Dry-initializer documentation
Photo by Émile Perron on Unsplash