By Derek Neighbors, Published on January 13, 2025
When to Use Concerns vs Service Objects in Rails
In Rails, concerns are a popular way to encapsulate shared behavior across models or controllers. However, over time, their misuse can lead to tight coupling, bloated code, and hidden dependencies. Service objects, on the other hand, offer a clean, maintainable way to manage logic, especially when it grows in complexity or needs reusability.
How do you decide when to use concerns and when to reach for a service object? Let’s explore this decision-making process with practical examples and guidelines.
What Are Concerns?
Concerns in Rails are modules used to organize reusable code. They allow developers to extend models, controllers, or other classes with shared logic.
For example:
module Scorable
extend ActiveSupport::Concern
class_methods do
def levels_for_evaluator
[['Not Observed', 0], ['Developing', 1], ['Intermediate', 2], ['Proficient', 3], ['Advanced', 4]]
end
def levels_for_target
[['Not Required', 0], ['Developing', 1], ['Intermediate', 2], ['Proficient', 3], ['Advanced', 4]]
end
def level_name(level, type)
levels = case type
when 'evaluator'
levels_for_evaluator
when 'target'
levels_for_target
else
raise ArgumentError, "Unknown type: #{type}"
end
level_entry = levels.find { |_, lvl| lvl == level }
level_entry&.first
end
end
end
class Evaluation
include Scorable
end
Here, the Scorable
concern adds class methods to manage levels for evaluators and targets. This works fine initially, but as the logic grows or becomes more dynamic, problems arise.
The Problems with Concerns
While concerns are useful, they can cause issues when overused:
1. Tight Coupling
Concerns tightly couple shared logic to the classes they’re included in. This makes it harder to track where a specific piece of logic comes from, and changes to a concern can unintentionally affect all the classes that include it.
2. Responsibility Creep
It’s tempting to keep adding related logic to a concern, but this can lead to bloated, unfocused modules. Over time, a concern can turn into a “kitchen sink” of unrelated functionality.
3. Testing Challenges
Testing concerns often requires creating dummy classes or relying on the context of the including model or controller, adding unnecessary complexity to your test suite.
4. Limited Reusability
Concerns work well when the logic is specific to one model or context. However, if the logic needs to be reused across different domains, concerns fall short.
Why Service Objects Are Better for Certain Use Cases
Service objects encapsulate specific functionality into a standalone, reusable class. This approach solves many of the problems associated with concerns:
Benefits of Service Objects
-
Separation of Concerns: Each service object has a single, well-defined responsibility. This makes your code easier to understand and maintain.
-
Easier Testing: Service objects are independent of models or controllers, so you can test them directly without additional setup.
-
Reusability: Service objects can be used across multiple models, controllers, or contexts without creating hidden dependencies.
-
Scalability: As your logic grows in complexity, service objects provide a natural place to extend functionality without bloating your models or controllers.
When to Use Concerns vs. Service Objects
To help you decide between concerns and service objects, here’s a quick guideline:
Use Concerns When:
- The logic is simple and specific to one model or context.
- The methods are short and don’t involve external dependencies.
- The concern’s scope is small and focused (e.g.,
SoftDeletable
orTimestampable
).
Use Service Objects When:
- The logic is complex, involves multiple steps, or grows over time.
- The logic needs to be reused across different models, controllers, or contexts.
- The logic depends on external APIs, databases, or configurations.
- You want to simplify testing by isolating the logic into a standalone class.
Example: Refactoring a Concern to a Service Object
Original Concern
module Scorable
extend ActiveSupport::Concern
class_methods do
def levels_for_evaluator
[['Not Observed', 0], ['Developing', 1], ['Intermediate', 2], ['Proficient', 3], ['Advanced', 4]]
end
def levels_for_target
[['Not Required', 0], ['Developing', 1], ['Intermediate', 2], ['Proficient', 3], ['Advanced', 4]]
end
def level_name(level, type)
levels = case type
when 'evaluator'
levels_for_evaluator
when 'target'
levels_for_target
else
raise ArgumentError, "Unknown type: #{type}"
end
level_entry = levels.find { |_, lvl| lvl == level }
level_entry&.first
end
end
end
Refactored Service Object
class LevelService
LEVELS = {
evaluator: [['Not Observed', 0], ['Developing', 1], ['Intermediate', 2], ['Proficient', 3], ['Advanced', 4]],
target: [['Not Required', 0], ['Developing', 1], ['Intermediate', 2], ['Proficient', 3], ['Advanced', 4]]
}
def self.levels_for(type)
LEVELS[type.to_sym] || raise(ArgumentError, "Unknown type: #{type}")
end
def self.level_name(level, type)
levels = levels_for(type)
level_entry = levels.find { |_, lvl| lvl == level }
level_entry&.first
end
end
Usage
Instead of including the Scorable
concern, you call the LevelService
directly:
LevelService.level_name(1, :evaluator) # => "Developing"
LevelService.levels_for(:target) # => [['Not Required', 0], ...]
Conclusion
Concerns are great for encapsulating simple, shared behavior tied to a specific model or context. However, as logic grows in complexity or scope, service objects provide a cleaner, more maintainable solution.
By isolating logic into standalone classes, service objects improve testability, scalability, and reusability. Use the decision framework above to determine the right approach for your project and keep your codebase clean and manageable over time.
Happy coding!