A Ruby Service-Class Anti-Pattern
Ruby on Rails uses the Model / View / Controller (MVC) architecture, to separate the different components of web-based applications to promote modularity and maintainability.
- Model: The model represents the data of the application. It handles data storage, retrieval, and manipulation. In Rails, models are typically responsible for interacting with the database and defining the structure and behavior of the application’s data.
- View: The view is responsible for the presentation layer of the application. It represents how data is displayed to the user. For web-based applications, views are often written in HTML or other templating languages and are responsible for generating the user interface. For API-based applications, serialization of data into JSON or XML is handled by the view layer.
- Controller: The controller acts as an intermediary between the model and the view. It takes incoming requests, processes input parameters, interacts with the model to retrieve or manipulate data, and determines which view should be rendered as a response. In Rails, controllers handle the routing of incoming requests and control the flow of data between the model and the view.
This separation of components gives an initial structure to an application, but it is easy to contaminate these components with business logic. This can lead to fat models or fat controllers.
For non-trivial applications, an in-depth analysis of the domain can help to isolate logical components using Domain-Driven Design (DDD) and following the Single-Responsibility Principle (SRP). The resulting logical components can then model aspects of the business logic separately.
The Service Class Trap
When using Ruby, there is an important step that developers often forget, and instead jump straight to the Service Class pattern, resulting in code that looks similar to this:
module MyProject
class UserCreation
attr_accessor :first_name, :last_name, :email
def initialize(first_name:, last_name:, email:)
@first_name = first_name
@last_name = last_name
@email = email
end
def call
raise(StandardError, 'first name is required') if first_name.blank?
raise(StandardError, 'last name is required') if last_name.blank?
raise(StandardError, 'email is required') unless email_valid?
User.create!(
first_name: first_name,
last_name: last_name,
email: email,
)
end
private
def email_valid?
email && URI::MailTo::EMAIL_REGEXP =~ email
end
end
end
And which is called like:
MyProject::UserCreation.new(
first_name: "John", last_name: "Smith", email: "john.smith@example.com",
).call
In Ruby, the pattern Klass.new.call
is a big red flag. 🚩
Ruby has a more concise and idiomatic way of achieving the same result without using Klass.new.call
as commonly seen in languages like Java.
What’s wrong with Klass.new.call
in Ruby? 🚩
First of all, we are creating a single instance of the class, call a poorly named method on it, and then discard the instance immediately 🚩.
This is not performant, the naming is terrible, and we are abusing the concept of Ruby classes.
Looking at the code above, we notice that the input parameters are copied 1:1 to instance variables, which are then used everywhere by the methods in that file as if they were “global” variables. Obfuscating the instance variables through attr_accessor
adds that in large service classes, the reader has to scroll all the way up to understand that those are instance variables.
This coding style leads to code that is unnecessarily bloated, and is using “global” instance variables throughout the file. This makes the code harder to reason about, and makes testing more difficult.
Sometimes this coding style is also combined with Ruby gems like “interactor”, “service_actor” which leads to even more convoluted and hard to understand code. By the way: please don’t use these gems! 😉
Simple Does It
So, after we do our Domain-Driven Design, and identified the components we need to model, there are two important questions to ask:
Does the service need to keep state between calls?
Does the object need to survive between calls?
If the answer is “yes”, use a Ruby class. This means you’d be re-using an instance for multiple method calls..
But in the majority of times, the answer is “no”, and Ruby has a much more convenient way to model this type of code: using Ruby modules.
Ruby modules encapsulate methods and treat them as bag of code. This makes them a great choice for code that does not need to keep state.
Not everything in Ruby needs to be modeled as a class!
module MyProject
module UserService
extend self
def create_user(first_name:, last_name:, email:)
validate_names!(first_name, last_name)
validate_email!(email)
User.create!(first_name: first_name, last_name: last_name, email: email)
end
private
def validate_names!(first_name, last_name)
if first_name.blank? || last_name.blank?
raise ValidationError, "first and last name are required"
end
end
def validate_email!(email)
unless email && RI::MailTo::EMAIL_REGEXP =~ email
raise ValidationError, "email is required"
end
end
end
end
Our new code is called like this:
MyProject::UserService.create_user(
first_name: "John", last_name: "Smith", email: "john.smith@example.com",
)
The method names are now meaningful and are describing actions, parameters are named, dependency on parameters is easy to understand, and the code has less side-effects.
Because we don’t need to keep state, we don’t need to create an instance of a class, avoid initialization, as well as instance variables. This leads to simpler code, and also a small performance win.
The input parameters are now used directly and passed-on directly to other method calls.
This leads to a functional style of programming, where every method can be reasoned about and tested individually — each method only depends on its input parameters — no side effects. A huge win for testing, debugging, and maintainability.
When you express your solution in the simplest terms, you reduce the maintenance cost dramatically: easier to understand, reason, on-board, and to test. The simplest paradigm in Ruby is a method, and the easiest way to organize methods is in a module.
Conclusion
In conclusion, while the service “class” pattern is a common approach when dealing with complex business logic, using Ruby classes is not always the most efficient or idiomatic way to handle things.
When we don’t need to keep state, opting for a more functional approach with Ruby modules can result in simpler code that is easier to reason about, and much easier to test.
Understanding of the language, and the paradigms it supports, will always guide you in writing efficient and clean code. The approach described in this article is a testament to the flexibility of Ruby, which accommodates different design approaches to suit the specific requirements of the task at hand.
Remember that the power of Ruby allows for many different paths to a solution. The key lies in understanding the language nuances and making the best use of its features to write clean, efficient, and maintainable code.
Asking Chat-GPT
There are so many opinionated articles, so I was curious what Chat-GPT has to say about this..