Dynamic Ruby And Hidden Maintenance Costs

Ruby makes it easy to write dynamic code, and Rails amplifies this with ActiveSupport conveniences like constantize and classify. When you discover these capabilities, it feels empowering, like you're writing less code that does more. But there's a hidden cost to elegant abstractions in application code, especially on projects that will be maintained by multiple developers over many years.
This post explores some code from a project I was maintaining, where a dynamic pattern made the codebase harder to understand. Class names and other details have been changed so I can share them publicly, but the patterns and trade-offs illustrate what I encountered.
Where Are the Callers?
I was investigating a Sidekiq job that accepts a class_name as an argument:
class DataSyncer
include Sidekiq::Worker
def perform(class_name, id, options = {})
model = class_name.constantize.find(id)
case class_name.constantize.model_name.singular.to_sym
when :product
# sync product data...
when :article
# sync article data...
else
raise ArgumentError.new("#{ self.class.name } does not support class_name: #{ class_name }")
end
end
end
The job handles two different model types: Product and Article. But when I searched the codebase for references to DataSyncer, I only found one explicit caller:
# app/models/product.rb
class Product < ApplicationRecord
has_many :line_items
has_many :orders, through: :line_items
after_update_commit do
if saved_change_to_tags?
DataSyncer.perform_async(self.class.name, id)
end
end
end
This raised an immediate question: Why does DataSyncer have a :article handler when only Product appears to call it? My first instinct was that this might be dead code left over from a refactoring. I was tempted to remove the unused :article branch.
But something made me pause. The code seemed too intentional to be simply forgotten. There had to be a reason for that flexibility.
Dynamic Job Dispatcher
After some deeper investigation (with a little help from my AI assistant scanning the codebase), I discovered the missing piece. The Article model had a callback that didn't directly reference DataSyncer, but was invoking it indirectly:
# app/models/article.rb
class Article < ApplicationRecord
after_create_commit do
BackgroundJobDispatcher.new(self.class.name, id).execute
end
end
This led me to the following service class:
# app/services/background_job_dispatcher.rb
class BackgroundJobDispatcher
def initialize(class_name, id)
@class_name = class_name
@id = id
end
def execute
raise NotImplementedError unless dispatchable?
sync_attributes.each do |attribute|
job_class_for(attribute).perform_async(class_name, id)
end
end
private
def dispatchable?
class_name == "Article"
end
def job_class_for(attribute)
[attribute, :syncer].join("_").classify.safe_constantize
end
def sync_attributes
%i[metadata data].freeze
end
attr_reader :class_name, :id
end
How it works:
The BackgroundJobDispatcher uses string manipulation to dynamically resolve job class names:
- For each attribute in
sync_attributes(:metadataand:data) - It constructs a string by joining the attribute with
:syncer:"metadata_syncer","data_syncer" - ActiveSupport's
classifyconverts these to class names:"MetadataSyncer","DataSyncer" - ActiveSupport's
safe_constantizelooks up these classes as constants, and.perform_asyncis called on them
So when an Article is created, it automatically triggers MetadataSyncer and DataSyncer jobs without the Article model ever explicitly naming those classes.
From a design perspective, this pattern has some appealing qualities:
- Extensible: Need to add another syncer? Just add it to
sync_attributes - Convention-driven: Job names follow a predictable pattern (
{attribute}_syncer) - Decoupled: The model doesn't need to know about specific job classes
The Cost of Flexibility
While this code works perfectly from a technical standpoint, it creates significant friction for long-term maintenance.
Discoverability
When I searched for DataSyncer in the codebase, the dynamic dispatch through BackgroundJobDispatcher didn't show up. The connection between Article and DataSyncer was invisible to standard search tools and static analysis.
This made it difficult to:
- Understand the full scope of where
DataSynceris called - Assess the impact of changes to
DataSyncer - Know whether code was safe to remove
Cognitive Load
Every future developer who encounters this code needs to:
- Discover that
BackgroundJobDispatcherexists - Understand the string manipulation logic
- Mentally map attributes to their corresponding job classes
- Remember this pattern exists when making future changes
These activities require additional mental energy that compounds over time as more developers join the project.
Limited Reuse
In this codebase, BackgroundJobDispatcher is only used by the Article model, which only had two sync operations. The flexibility to handle multiple operations and models exists, but it's never exercised. The abstraction was built for a level of generality that wasn't actually needed.
A Simpler Alternative
The same functionality could be achieved with two explicit lines in the Article model:
# app/models/article.rb
class Article < ApplicationRecord
after_create_commit do
MetadataSyncer.perform_async(self.class.name, id)
DataSyncer.perform_async(self.class.name, id)
end
end
This version:
- Is immediately understandable to any developer
- Shows up in static searches for
DataSyncer - Requires no additional service class
- Makes the relationship between
Articleand its syncers explicit
Yes, if you need to add a third syncer, you add a third line. To me, the increased clarity is well worth the extra line.
When Dynamic Patterns Make Sense
To be clear, there's nothing inherently wrong with the dynamic job dispatcher pattern. It could be useful as:
- A documented library or gem that handles job dispatching across multiple projects
- A framework-level abstraction where the benefits of the pattern justify the cognitive overhead
In those contexts, the investment in understanding the abstraction pays dividends because it's used widely and consistently. But in application code, where the primary goal is to model your specific business logic, explicit is often better than dynamic.
Lessons for Long-Term Projects
This experience reinforced a few principles for me:
Optimize for reading, not writing. Code is read far more often than it's written. The few extra seconds it takes to write explicit job calls is dwarfed by the minutes (or hours) future developers will spend understanding dynamic code.
Abstractions should pay for themselves. Before creating an abstraction, ask: "Will this be reused enough to justify the cognitive overhead?" If the answer is unclear, err on the side of explicitness.
Consider the maintenance context. On projects that will live for years with multiple developers coming and going, predictable patterns are more valuable than elegant ones. The boring code that future on-call you can understand at 2am is better than the clever code that present-you is proud of.
Conclusion
What feels like a productivity gain when writing code can become a maintenance burden when others inherit it. Ruby (and Rails) give us powerful tools for abstraction, but on long-lived projects, sometimes the best code is the code that solves the current problem as simply as possible, and doesn't try to be too flexible.
I nearly removed a working feature because I couldn't trace its callers through a dynamic abstraction. The next time you're tempted to write one, ask yourself whether the elegance is worth that risk.



