Rails is an awesome web application framework and it used by a lot of companies to ship features fast and iterate quickly. Developers also love the ecosystem and the stability that the framework offers. But it is not without its criticisms. One of them, which I experienced in my professional work as well, is its lack of direction on how to structure business logic.
The introduction of Concerns provided another layer where this logic can be extracted from models and controllers, but I think that this is still lacking, especially as the complexity of the application grows over time.
Several libraries try to solve this by proposing a structure on how to create and invoke business logic in the application. Popular examples include Trailblazer, Interactor, ActiveInteraction, and dry-rb.
ActiveInteraction and dry-monads
In my professional career, I used two of them: dry-rb (through the dry-monads library) and ActiveInteraction. After years of using both, I determined aspects of these libraries that I prefer over the other, and I will explain the reasons behind it. Note that these all come from personal experience and your preference may not be the same as mine.
These libraries enable you to structure logic, actions and services in your application in a more manageable way. Not only does this make your codebase cleaner, it also allows for easier onboarding of new developers once they get the concepts behind these libraries.
As a starting example, let’s say that our application can create new user accounts. Instead of defining the logic that creates this account in the User model (or in the controller code), which looks something like:
# Definition in model or controller
def create_account(params)
Account.create(params)
AccountMailer.welcome_email.deliver_now
end
# Invocation
Account.create_account(params)
The same code can be implemented as its own class/service:
# dry-rb
Accounts::Create.(params)
# ActiveInteraction
Accounts::Create.run(params:)
Using this approach has these advantages:
- Reusable components in the code
- Group entities and features in code in a more meaningful way
- Easier to test as a unit and also within an operation sequence
- Define classes that follow the Single Responsibility Principle
- Allows for dependency injection and modularization of services
Both ActiveInteraction and dry-monads can be used to achieve what we want, but between these two libraries, which do I think is better? It is more nuanced than a simple winner (as I will explain in the end), but there are certain aspects where a particular library shines over the other.
Simplicity
Winner: ActiveInteraction
This might be an unfair comparison since dry-rb isn’t just a single library, it is a collection of gems for specific use-cases, such as:
- dry-validation
- dry-monads
- dry-transaction
- dry-schema
While these modularization of features is what makes dry-rb powerful and flexible, it also has a drawback. It is not apparent on the get-go which ones you need. On the other hand, ActiveInteraction is a single Ruby gem. To get started, all you have to do is peruse the README file and follow the basic use cases.
In dry-rb, you will need to determine first which gem you need in your application. Depending on how much structure you want in your application, you can use just a few or many of the gems under dry-rb. Using each gem requires inheriting or including the modules that you need in your classes:
class NewUserContract < Dry::Validation::Contract
params do
required(:name).filled(:string)
required(:age).filled(:integer)
end
end
class CreateArticle
include Dry::Monads[:result]
include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)
def call
...
end
end
While in ActiveInteraction, it works just by making your class inherit from ActiveInteraction::Base.
class CreateArticle < ActiveInteraction::Base
object :user
def execute
...
end
end
Service response
Winner: dry-monads
ActiveInteraction
ActiveInteraction is hooked deeply into the Rails framework, and under the hood, uses ActiveModel for validations as well. There are several ways that an error can be returned from a service:
Using ActiveModel validations:
class CreateUser < ActiveInteraction::Base
string :name
validates :name, presence: true
end
You can use the same model validations as in Rails. Similarly, the error message is also stored in an errors object on failure.
Adding directly to the errors object:
Within the service, you can also add custom errors directly to the errors object. While this adds some flexibility, the service does not immediately return when errors are added, so you will need to return manually in order to exit a failure condition.
def execute
if error_condition?
errors.add(:base, :invalid_input)
return
end
ProcessNextStep.run(input:)
end
In the example above, the ProcessNextStep service will still be called even though the error condition was hit, unless a return statement is declared after the error.
Raising exceptions:
As with normal classes, raising exceptions will immediately halt the execute method, and will need to be handled by the caller service.
dry-monads
In dry-rb, specifically the dry-monads gem, success and failure conditions are handled by monads. Success and Failure result objects are defined by dry-rb and they are used as return values by the class depending on the result of the execution.
def find_user(user_id)
user = User.find_by(id: user_id)
if user
Success(user)
else
Failure(:user_not_found)
end
end
The value or the payload of the service is extracted from the monad by calling either success or failure:
finder = FindUser.(user_id:)
finder.success # Returns the success payload
finder.failure # Returns the error payload
If the operation is successful, then the failure method will be nil, and vice versa.
Why bother using these additional classes as a return value rather than just the value? The use of monads makes it easier to make modular code as each service shares a common interface. Chaining methods for nested services are also easier as each service follows a consistent rule:
- All services have a single public method called call
- All services will return either a Success or Failure result object
Class Invocation
Winner: dry-monads
ActiveInteraction
When you define a service using ActiveInteraction, the service gets called using either the run or run! methods. Both of these call the execute method in the class, but each behaves differently:
- run calls the execute method and returns the service “outcome”. This outcome can either be successful (outcome.valid?) or not depending on whether there is an error object in the response.
- run! calls the execute method and returns the value of the result. If successful, the value is the return value of the class. On failure though, it raises an exception with the error object.
outcome = MyService.run(params:)
outcome.valid? # returns true if successful
value = MyService.run!(params:) # returns the value
dry-monads
Using the dry-monads library means that you use a consistent paradigm:
The return value of the class must always be a monad, which is either a Success or Failure object.
In addition, there is only one way to invoke a class, and this is via the call method.
result = MyService.new.call(params:)
result = MyService.new.(params:) # call is the default method
The result object is always either a Success or Failure object. Then to test if the result is a success or failure condition:
result.success? # Returns true if successful
result.failure? # Returns true if there is an error
And to get the value of the result:
result.success # Returns the value of the response
result.failure # Returns the error
Railway Oriented Programming
Winner: dry-monads
ROP is a concept developed by Scott Wlaschin that is based from functional programming. Railway Oriented Programming describes a way of structuring your code to execute sequentially (like a train running on tracks), while allowing for multiple exit points (like branch lines from a railroad). By structuring your code like this, your services are able to handle error conditions gracefully and effectively.
This is an important concept because execution paths are not always happy paths: there are conditions that can cause an error, throw an exception, or result in an unexpected value. Both dry-monads and ActiveInteraction provide a means of implementing this concept on your code.
ActiveInteraction
The compose method is used to handle success and failure conditions when the service is calling other services. When successful, the method returns the value of the success condition, and it returns an error if it is a failure condition. This is different from the run! method wherein the service raises an exception during a failure condition. The compose method handles errors gracefully by appending the errors to the main errors object of the class that called it.
To illustrate how this works, here is a simple example of a service that creates a new user account:
class CreateUserAccount < ActiveInteraction::Base
string :email
validates :email, presence: true
def execute
user = compose(CreateUser, email:)
compose(SendWelcomeEmail, user:)
user
end
end
In this example, the CreateUserAccount service calls the CreateUser and the SendWelcomeEmail services. Since we are using compose to call the CreateUser method:
- When CreateUser is successful, it returns the value of the success condition, which is a User object
- When CreateUser fails, the error object from the CreateUser service is merged to the CreateUserAccount service, and the CreateUserAccount service returns with a failure condition
Contrast this to using run (which returns the “outcome”, not the value), and run! (which returns the value, but also raises an exception on failure). The compose method allows the caller service to exit gracefully when one of its dependencies encounter a failure scenario, without explicitly handling it in code.
This is how ActiveInteraction implemented the compose method:
def compose(other, *args)
outcome = other.run(*args)
raise Interrupt, outcome.errors if outcome.invalid?
outcome.result
end
dry-monads
In dry-rb, this is achieved using the yield method. This behaves similarly to ActiveInteraction’s compose method and is referred to as “Do” notation:
- If the result is successful, it returns the (unwrapped) return value
- In case of a failure, it short-circuits the execution and the class that calls it exits, wrapping the error inside a Failure object
Instead of defining a “compose” method like in ActiveInteraction, dry-monads uses clever metaprogramming to achieve this behavior using Ruby’s yield method. Specifically, it prepends the class that is being executed with a module that passes a block to its call method. Then it uses exceptions to halt the execution and return the Failure result object.
Exceptions are used to make the code run safely: if your service handles multiple database operations, wrapping them inside a transaction will handle rollbacks automatically as exceptions are raised internally.
To use the previous example, this time using dry-monad’s Do notation:
require 'dry/monads'
require 'dry/monads/do'
class CreateUserAccount
include Dry::Monads[:result]
include Dry::Monads::Do.for(:call)
def call(email)
user = yield CreateUser.(email:)
yield SendWelcomeEmail.(user:)
Success(user)
end
end
The magic happens when we include Dry::Monads::Do.for(:call) in our class, which means that the call method will be handled by the Do notation logic and return the Failure result on error. Internally, it checks for the Dry::Monads::Do::Halt error class so it knows that the error is caused by an operation failure.
Conclusion
Even though I think the dry-monads library wins on most categories, it does not mean that this is the silver bullet that will solve all your application architecture problems. The suite of libraries that dry-rb offers is a more flexible and more powerful way to structure your code, but at the cost of more complexity in dependencies and coding paradigms.
The simplicity of ActiveInteraction is its most powerful advantage. You can already get a lot of mileage in your architecture just by using this library. And since it is closely tied to the Rails framework, any developer that is familiar with Rails will be able to absorb the concepts quickly and become productive at a short amount of time.
Once your application reaches a certain complexity threshold and you want to move further away from the opinionated defaults of Rails, then you will find that dry-rb is an excellent solution.