Over the years of working with large, mature, monolithical Rails applications, I realized a subtle problem. Many, even experienced teams put their constants in suboptimal namespaces. On one hand such placement is logical, but on the other it leads cluttered code that mixes low-level and high-level concepts. It does not help with keeping our monolith modularized either.
Consider a common scenario in a Rails application: the Team
class.
As your application evolves, you might find yourself adding constants like this:
class Team < ApplicationRecord
SMB_SALES = :smb
ENTERPRISE_SALES = :ent
US_SUPPORT = :us_support
EU_SUPPORT = :eu_support
has_many :memberships
has_many :members, through: :memberships
end
Initially, this approach seems harmless. Other parts of your codebase might use these constants like so:
Team.find_by(identifier: Team::SMB_SALES)
Over time, you might even add convenience methods like Team.smb
or User.smb_sales
. However,
as your company grows and adds more divisions, teams, and units, the Team class and team.rb
file keep expanding.
The core issue here is that the Team
class itself doesn’t typically need to know about these specific
team identifiers. It’s a low-level class that should remain generic and unaware
of the higher-level domains built on top of it.
Do you know which classes/modules care about those constants? The ones that use them. Sales logic cares about the Sales team. Accounting logic cares about the accountants team. Contracting logic cares about the legal team.
The high-level domains of your app (payments, sales) need to use
the low level functionalities (authentication, teams, notifications).
The low level code and functionalities should remain unaware of what’s
build on top of them. Their code and terminology should be generic.
There is no need for the Team
class to be aware of sales or
marketing. Instead the dependency is in the other direction.
Understanding the Impact
This approach leads to several problems:
- Cluttered Code: The
Team
class becomes bloated with constants that it doesn’t directly use. - Mixed Concerns: Low-level concepts (business units) get mixed with high-level business logic (sales, support divisions).
- Reduced Modularity: It becomes harder to separate concerns and modularize your application.
The alternative solution
Instead of adding constants to the Team
class, consider
moving them to more appropriate namespaces or engines. Here’s an improved approach:
module Sales
SMB_SALES_TEAM_IDENTIFIER = :smb
ENTERPRISE_SALES_TEAM_IDENTIFIER = :ent
end
module Support
US_TEAM_IDENTIFIER = :us_support
EU_TEAM_IDENTIFIER = :eu_support
end
This approach ensures that constants related to various domains don’t accumulate in the low-level Team
class.
For even more flexibility and encapsulation, consider creating domain-specific classes:
module Sales
class Smb
TEAM_IDENTIFIER = :smb
def self.team
Team.find_by(identifier: TEAM_IDENTIFIER)
end
# other common methods
end
end
What’s the benefit
The benefits are mostly visible in enterprise Rails applications which use certain approach for development scalability such as:
- code ownership and mandatory code reviews when external teams change your team’s codebase.
- dependency tracking between domains/engines, i.e. by using packwerk
Under such constraints, we can assume:
- Reduced Code Review Overhead: engineering teams owning low-level classes like
Team
aren’t burdened with a stream of reviews for pull requests that keep adding toteam.rb
file. - Improved Code Organization: Constants are placed closer to where they’re actually used.
- Clearer Dependencies: Tools like packwerk can track dependencies between domains more accurately.
Code that uses
Sales::SMB_SALES_TEAM_IDENTIFIER
will be tracked as having a dependency on theSales
engine. This info can be used to either track valid dependencies or to discover unwanted coupling between certain domains.
By adopting this approach, we can create more maintainable and scalable Rails apps, especially as they grow into enterprise-level system.