In Part 1 we created a simple library called KantanLogic that we can use to manage application logic in lieu of other popular libraries. Our library has these main features:
- Uses Success and Failure result objects for the class output
- Handles error conditions gracefully
- Has consistent behavior for the main class and all dependency classes
In our last example, we have a class called CreateAccount that creates a user account and sends a welcome email. When using KantanLogic, the service looks like this:
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
One problem with this approach is that the main class or service does all of the work in handling failure conditions by rescuing CustomError. If we want this behavior for all our services, then it means we have to do the same thing for all classes that uses KantanLogic.
Also, we are using a custom method to execute our class (rop_call) in order to raise a custom exception on failure. Ideally, we want to have a single public method for our services, which is call. Managing two public methods that behave almost the same is also a maintenance overhead that we need to eliminate.
Wrapping the call method
What if we can make all classes that use KantanLogic behave like what we described above, without explicitly defining the behavior in the class? That is, it will no longer matter how you intend to use the class — whether it serves as the main service or functions as a dependency for that service.
To achieve this, we need to turn to Ruby metaprogramming.
Defining methods in runtime
In Ruby, we can redefine an existing method using define_method, essentially overriding its behavior. We can use this technique to modify the way the call method operates, allowing the addition of extra behavior to that method. Specifically, adding the code to rescue a pre-defined exception and return a Failure result.
It is good practice to define your own error class in order to define custom behavior. For KantanLogic, this custom error is responsible for stopping the execution of a service in order to return a Failure result, so let’s call it KantanLogic::Halt.
class KantanLogic::Halt < StandardError; end
Using define_method to override the behavior of the call method looks like this:
define_method 'call' do |*args|
send('original_call_method', *args)
rescue KantanLogic::Halt => e
KantanLogic::Failure.new(e.message)
end
Notice that we are using send to execute the original call method. We cannot use send(‘call’) since we already redefined the call method. So how do we make sure we get to access the original method itself?
Aliasing methods
Ruby also provides a way to make a copy of any method and assign it a new name. This way, we can still invoke the original call method before the modifications were done.
Makes new_name a new copy of the method old_name. This can be used to retain access to methods that are overridden.
https://www.rubydoc.info/stdlib/core/Module:alias_method
Using method aliasing, our code now becomes this:
original_call = "_call".to_sym
alias_method original_call, 'call'
define_method 'call' do |*args|
send(original_call, *args)
rescue KantanLogic::Halt => e
KantanLogic::Failure.new(e.message)
end
We have now implemented the behavior to stop the execution and return a Failure result object. Now we need a way to use this behavior in our classes. For simplicity, we can also add this to the KantanLogic::Result module we used to define the result objects (but this can also be created as a new module).
# Define the exception when halting due to Failure result
class KantanLogic::Halt < StandardError; end
module KantanLogic::Result
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def wrap(method)
original_call = "_#{method}".to_sym
alias_method original_call, method
define_method method do |*args|
send(original_call, *args)
rescue KantanLogic::Halt => e
KantanLogic::Failure.new(e.message)
end
end
end
end
By including this module in a class, we can call the wrap method as a class method because of the use of base.extend:
def self.included(base)
base.extend ClassMethods
end
Now the CreateAccount service looks like this:
class CreateAccount
include KantanLogic::Result
wrap('call')
def call(user_params)
user = CreateUser.call(user_params)
email_sent = SendWelcomeEmail.call(user)
Success(user)
end
end
Finally, we can remove the rop_call method that we previously defined and we can always use call to execute our services.
It would even be better if we do not need to explicitly wrap the call method in our classes as this might be forgotten, especially when creating a new service. Is there a way to add this behavior automatically when we include the KantanLogic::Result module?
Method callbacks
In addition to having the ability to add or remove methods during runtime, Ruby also provides callbacks for method definitions. We can use the method_added callback to detect if a method has been defined, and method_removed to detect if a method has been deleted.
In our case, the method_added callback proves handy as we can then detect if the call method has been defined in the service.
module ClassMethods
def method_added(name)
wrap('call') if name == :call
super
end
end
This means that whenever we include KantanLogic to a class, and the class defines the call method, then the method_added callback will be invoked. As a result, the wrap method is automatically called for that method.
Avoiding Infinite Loops
If you look at what the module looks like currently, you may notice a problem:
module ClassMethods
def method_added(name)
wrap('call') if name == :call
super
end
def wrap(method)
...
define_method method do |*args|
...
end
end
end
Once the call method is defined, the method_added callback is invoked, which then subsequently calls wrap(‘call’). The wrap method then uses define_method that triggers the method_added callback again, resulting in a loop. Therefore, we must track whether the call method is already wrapped or not.
There are many ways of doing this, but one simple solution is to use class instance variables. Note that this is different from class variables, which will not work in this case. Using class variables will result in the tracking to be done on the entire process running our Ruby code, instead of for each class inheriting the KantanLogic module.
Using a class instance variable (call_is_wrapped) to check if the call method has been wrapped or not, our module now becomes:
module ClassMethods
@call_is_wrapped = false
def method_added(name)
wrap('call') if name == :call && !@call_is_wrapped
super
end
def wrap(method)
...
@call_is_wrapped = true
define_method method do |*args|
...
end
end
end
Homage to dry-monads
The reason why we went to such lengths to modify the behavior of the call method is so that we can easily implement Railway-Oriented Programming in our code. We found previously that defining rop_call is an incomplete implementation as it doesn’t handle nested service calls well.
The dry-rb library implements this through the yield method, overriding the default yield method in Ruby in order to manage failure conditions.
require 'dry/monads'
require 'dry/monads/do'
class CreateAccount
include Dry::Monads[:result]
include Dry::Monads::Do.for(:call)
def call(user_params)
user = yield CreateUser.call(user_params)
yield SendWelcomeEmail.call(user)
Success(user)
end
end
ActiveInteraction, another popular library for application logic management, implements this using the compose method.
class CreateAccount < ActiveInteraction::Base
hash :user_params
def execute
user = compose(CreateUser, user_params:)
compose(SendWelcomeEmail, user:)
user
end
end
For our KantanLogic module, we will create something similar and name it yield, just like in dry-rb. An important difference here from the yield (dry-rb) or compose methods are:
- Our yield method will be another public method of the class, but is defined through the KantanLogic module and not in the class itself
- When executing the service, both call and yield can be used interchangeably depending on the desired behavior
- This method can be called from the Ruby console to facilitate debugging
The last point is an underrated behavior in my opinion. Having the ability to copy and paste code into the terminal for debugging is useful, and this is not possible if you are using yield from dry-monads or compose from ActiveInteraction.
The implementation of our yield method is quite straightfoward:
def yield(*args)
result = call(*args)
raise KantanLogic::Halt, result.errors if result.failure?
result.value
end
- If the result is a failure, then raise a specific exception (KantanLogic::Halt) that gets rescued by our wrapped call method.
- If the result is successful, return the actual/unwrapped value.
Application Logic Usage
Combining all of the improvements we made to the module, we can now use it as such:
class CreateAccount
include KantanLogic::Result
def call(user_params)
user = CreateUser.yield(user_params)
SendWelcomeEmail.yield(user)
Success(user)
end
end
This also supports nested calls to other classes/services, and errors coming from dependency services (or its own dependencies), will be propagated upwards to the calling class. This allows us to break our code into separate services, improving the application logic architecture.
class CreateAccount
include KantanLogic::Result
def call(user_params)
user = CreateUser.yield(user_params)
Success(user)
end
end
class CreateUser
include KantanLogic::Result
def call(user_params)
user = User.create(user_params)
SendWelcomeEmail.yield(user)
Success(user)
end
end
In this example, the CreateUser service calls the SendWelcomeEmail service instead of everything being called in CreateAccount. For this scenario, error handling goes something like this:
- The SendWelcomeEmail service encounters an error. Since it is
executed using yield, it raises KantanLogic::Halt. - CreateUser‘s call method rescues this particular exception, and returns a KantanLogic::Failure result object.
- Inside CreateAccount, CreateUser is executed using yield. Since the result of CreateUser is a Failure object, this raises the KantanLogic::Halt exception again, but this time in CreateAccount.
- Since CreateAccount is using our KantanLogic module, the exception is rescued automatically. A Failure object is again returned with the error from the SendWelcomeEmail service.
Notice how the error payload gets passed on from SendWelcomeEmail to CreateUser and finally to CreateAccount. The error message was handled without explicitly coding how it gets passed between services. This way, we know immediately which service encountered the error and our classes can handle each case accordingly.
A working module
Combining all of the code and improvements, we arrive at the (mostly) functional module for handling our application logic:
class KantanLogic::Halt < StandardError; end
module KantanLogic::Result
def self.included(base)
base.extend ClassMethods
end
def yield(*args)
result = call(*args)
raise KantanLogic::Halt, result.errors if result.failure?
result.value
end
def Success(*args)
KantanLogic::Success.new(*args)
end
def Failure(*args)
KantanLogic::Failure.new(*args)
end
module ClassMethods
@call_is_wrapped = false
def method_added(name)
# puts "instance method '#{name}' added"
wrap('call') if name == :call && !@call_is_wrapped
super
end
def wrap(method)
old = "_#{method}".to_sym
alias_method old, method
@call_is_wrapped = true
define_method method do |*args|
send(old, *args)
rescue KantanLogic::Halt => e
KantanLogic::Failure.new(e.message)
end
end
end
end