A Poor Man’s Guide to Handling Application Logic – Part 1

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:

  1. Enable classes to return values wrapped inside a Success or a Failure object (monads).
  2. Handle failure conditions gracefully without the need for explicit conditionals (error handling).
  3. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *