In the Ruby programming language, there are many libraries that can be used to manage business or application logic. Some of the popular ones are dry-rb (framework-agnostic) and ActiveInteraction (used within the Rails framework). The strengths and weaknesses of these libraries are explored in more detail on another article.
While these are great libraries to use and provide enormous benefits to your code architecture, some consideration is needed before using them:
- Are you able to deal with the additional dependencies and complexity of dry-rb?
- What if your aim is not to couple your application too much with the Rails framework by using ActiveInteraction?
Most of the time it is not advised to reinvent the wheel and try to build your own solution as these libraries are built by people much smarter than you or me. But there are times when it makes sense. By building your own solution, you gain a lot of flexibility on how to integrate it with your application. It also lessens the risk of having the library becoming outdated and no longer compatible with other dependencies. But perhaps the most important reason to build your own solution is that you will learn a ton along the way, even if you stop using it in the end.
KantanLogic
With this goal in mind, we will try to build a simple library to handle application logic without using any popular gems or libraries. A library that is so simple to add, you don’t even need a Ruby gem for it!
Let’s call it KantanLogic.
Kantan (簡単) is Japanese for easy, simple, and uncomplicated.
So Kantan + Logic = a simple, easy way to handle application logic. This perfectly describes what we are trying to achieve. The library should have these features built in:
- Enable classes to return values wrapped inside a Success or a Failure object (monads).
- Handle failure conditions gracefully without the need for explicit conditionals (error handling).
- Ability to call other classes that also behave according to the first two points (nested services).
Success and Failure
To handle the first requirement, we get inspiration from the dry-monads library, where services should return the resulting value wrapped inside a Success or a Failure monad. To implement this, we can create a simple class that has a value and an errors attribute:
class KantanLogic::Success
attr_accessor :value
def initialize(value)
@value = value
end
def errors
nil
end
end
In our Success monad, we initialize it with a value, and can access this value by calling its getter method directly:
result = KantanLogic::Success.new({ sample_data: true })
result.value
=> { sample_data: true }
And since this is used to indicate a successful condition, the errors method should return nil to indicate no errors.
result = KantanLogic::Success.new({ sample_data: true })
result.errors
=> nil
The Failure monad is implemented in a similar manner, but this time, the wrapped value is errors instead of value:
class KantanLogic::Failure
attr_accessor :errors
def initialize(errors)
@errors = errors
end
def value
nil
end
end
Helper methods can also be defined to determine if an operation is successful or not. So we create the success? and a failure? methods, which can be implemented as simple Boolean values.
So for the Success result object, success? should return true, and failure? should return false.
class KantanLogic::Success
attr_accessor :value
def initialize(value)
@value = value
end
def success?
true
end
def failure?
!success?
end
def errors
nil
end
end
Same goes for the Failure object, but this time the Boolean values are inverted:
class KantanLogic::Failure
attr_accessor :errors
def initialize(errors)
@errors = errors
end
def success?
false
end
def failure?
!success?
end
def value
nil
end
end
Using Result Objects
Now that we defined our Success and Failure monads, how can we use it in our code? In Ruby, reusable components are usually created using modules and included (imported) from classes. For KantanLogic, we can create a separate module that includes both the Success and Failure result objects in any class.
As this module handles the result, we can name it KantanLogic::Result.
module KantanLogic::Result
def Success(*args)
KantanLogic::Success.new(*args)
end
def Failure(*args)
KantanLogic::Failure.new(*args)
end
end
By including this module in our class, we have access to the Success and Failure methods that create the result objects:
class CreateUser
include KantanLogic::Result
def call(user_params)
user = User.new(user_params)
if user.save
Success(user)
else
Failure(user.errors)
end
end
end
In the example above, instead of returning the User object directly, we wrap it inside a Success object. As we are now using result objects, we can do other things like:
outcome = CreateUser.new.call(user_params)
outcome.success?
=> true
outcome.failure?
=> false
outcome.errors
=> nil
outcome.success
=> <User object>
Railway-Oriented Programming
The use of result objects and its helper methods serve an important role. These objects help us manage the flow in our class by specifying the logic when the operation is successful or not. This is useful especially if we have classes that have other dependencies.
For instance, let’s say that our previous example class, CreateUser, is actually being called by another class, called CreateAccount:
class CreateAccount
include KantanLogic::Result
def call(user_params)
create_user = CreateUser.call(user_params)
if create_user.success?
user = create_user.value
Success(user)
else
Failure(create_user.errors)
end
end
end
By using the methods from the result objects, how we handle the happy path and the error condition is clearly defined:
- If creating the user record is successful (CreateUser), we return the user object inside the Success monad.
- When there are problems creating the user, we forward the errors from the CreateUser class to the caller (CreateAccount) and return the error inside a Failure monad.
What if when a user is created in our application, we also send an email to that user? Let’s extend our example by handling the sending of a welcome email when creating the user account:
class CreateAccount
include KantanLogic::Result
def call(user_params)
create_user = CreateUser.call(user_params)
if create_user.success?
user = create_user.value
send_email = SendWelcomeEmail.call(user)
if send_email.success?
Success(user)
else
Failure(send_email.errors)
end
else
Failure(create_user.errors)
end
end
end
This shows how errors are being handled on every step of the CreateAccount class. However, if our class contains other steps or operations, we need to repeat the success and failure checks all over again. And you can imagine that if there are many dependencies, our application logic quickly becomes a nested ball of conditionals.
Modified call method behavior
The issue here is that the logic that handles the success and failure conditions rely heavily on the calling class. What if we can improve the code such that the logic is also handled by the dependencies? In other words, we need a way for our classes to handle this behavior:
- If the dependency operation succeeds, return the unwrapped value then proceed to the next step of the execution
- If the dependency operation fails with an error, halt the execution and return with the error
The first condition is straightforward since we are already using the Success result object. We can use the success? and success methods to determine if the operation is successful and also to get the unwrapped value.
Handling the second one is more tricky though. If we want the class to return immediately, then we need to use a Ruby built-in concept: exceptions.
class CustomError < StandardError; end
class CreateUser
include KantanLogic::Result
def rop_call(user_params)
user = User.new(user_params)
if user.save
user
else
raise CustomError.new(user.errors)
end
end
end
Here we define a new method, rop_call, which satisfies the second behavior we need. We can use this method instead of call in the CreateUser and CreateAccount classes to return the unwrapped value when successful, and raise an exception if not.
class CreateAccount
include KantanLogic::Result
def call(user_params)
user = CreateUser.rop_call(user_params)
email_sent = SendWelcomeEmail.rop_call(user)
Success(user)
rescue CustomError => e
Failure(e)
end
end
Now this looks much better! If creating the user record succeeds, then it sends the email and returns a Success result with the User object wrapped inside. If at any point in the process, it fails, then it raises an exception which is rescued by CreateAccount. By handling this exception, the class was able to return a Failure result object with the errors.
Further improvements
With this, the three requirements we need to handle application logic has been satisfied (monads, error handling, and nested services). But there are still improvements that we can do with our library, such as:
- Eliminating the need to use a separate method, rop_call, instead of the default call method for our services
- Handle the the exception CustomError in our classes automatically without rescuing it explicitly
These improvements are further explored in Part 2 of this article, where we use Ruby metaprogramming to polish our library.