Every Rails developer has had that moment. You open a controller, and there it is: hundreds of lines of tangled business logic staring back at you. In my case, it was a reports controller that had grown to over 300 lines, mixing date parsing, filtering, business logic, and export handling into an incomprehensible mess. It didn't have tests, and the former developer had left the company — and I, the new hire, was being asked to change the functionality.
Let me show you something that resembles it without actually showing the old code:
def index
# 300 lines of mixed concerns:
@date = params[:date] ? Date.parse(params[:date]) : Date.current
@report_type = params[:type] || default_report_type
# Complex date logic
if @view_type == "Day"
# date calculations
elsif @view_type == "Week"
# more date calculations
# ... several more conditions
end
# Business logic mixed with filtering
@records = current_account.records
@records = @records.where(complex_conditions)
# Complex business rules
if @report_type == "Summary"
# 50 lines of summary logic
elsif @report_type == "Detail"
# 50 more lines of detail logic
end
# Export logic mixed in
respond_to do |format|
format.html
format.csv { # complex CSV generation }
end
end
To say I was afraid to change it was an understatement. To boot, the method was deeply nested, and writing tests for it as it was with it's endlessly branching logic would be a feat that I wasn't up to. I was in need of another pattern.
The Traditional Approaches
So, I tried the standard Rails patterns:
Fat Models
class Report < ApplicationRecord
def self.generate_summary
# Move logic to model
# Now model is huge instead of controller - not a solution
end
end
Concerns
module ReportGeneration
extend ActiveSupport::Concern
# Move logic to concern
# Now complexity is just hidden - not a solution
end
Service Objects
class GenerateReportService
def call
# Move logic to service
# End up with explosion of services,
# and functional programming methods in an OO language/app
# just never felt right
end
end
Each of these approaches helped a bit, but they didn't quite solve the fundamental problem: the code wasn't organized around the actual business concepts it represented.
Enter Elemental Ruby
Elemental Ruby is a pattern that emerged from the intersection of several powerful ideas.
I wanted a lot in a solution - it had to be extensible, follow good design principles, be easily testable, maintainable, and to the extent possible, follow Rails conventions.
So I drew from:
- Brad Frost's Atomic Design (breaking complex systems into fundamental units)
- Sandi Metz's teaching about small, focused objects
- Domain-Driven Design's bounded contexts
- Rails' convention over configuration
- SOLID design principles
The core idea is simple: organize your code around the fundamental elements of your business domain, using Ruby's natural namespacing capabilities.
Let's see how our reports controller evolves:
First Evolution: Service Objects
def index
@date = DateParser.new(params[:date]).parse
@report_type = ReportTypeSelector.new(current_account, params[:type]).select
@date_range = DateRangeCalculator.new(@date, params[:view_type]).calculate
@records = RecordFilter.new(current_account.records, params).filter
@data = ReportGenerator.new(
type: @report_type,
records: @records,
date_range: @date_range
).generate
respond_with_report(@data)
end
Better, but our business concepts are still scattered across multiple service objects. We've traded one form of complexity for another.
Final Evolution: Elemental Ruby
def index
@date_range = Reports::DateRange.new(
date: params[:date],
view_type: params[:view_type]
)
@report = Reports::Type.new(
account: current_account,
type: params[:type]
).build
@data = @report.generate(
records: Reports::Records.new(current_account, params),
date_range: @date_range
)
respond_with_report(@data)
end
The business concepts are now clear and organized:
# app/models/reports/date_range.rb
class Reports::DateRange
def initialize(date:, view_type:)
@date = parse_date(date)
@view_type = view_type
end
def start_date
case @view_type
when "Day"
@date
when "Week"
@date.beginning_of_week
# etc
end
end
def header
case @view_type
when "Day"
I18n.l(@date, format: :long)
when "Week"
"#{start_date.strftime('%B %e')} - #{end_date.strftime('%B %e')}"
# etc
end
end
private
def parse_date(date)
Date.parse(date)
rescue
Date.current
end
end
Why This Works Better
-
Clear Organization
- Each element represents a clear business concept
- Related functionality stays together
- Easy to find code by thinking about the domain
-
Better Testing
- Elements have clear responsibilities
- Dependencies are explicit
- Tests follow business concepts
-
Easier to Change
- Changes tend to affect single elements
- New features have clear homes
- Less risk of unintended consequences
-
Better for Teams
- New developers can understand the domain
- Clear boundaries between different parts
- Natural organization for dividing work
How This Aligns with SOLID
-
Single Responsibility Principle
- Each element handles one aspect of the domain
- Reports::DateRange only handles date-related concepts
-
Open/Closed Principle
- New behavior can be added by creating new elements
- Existing elements remain unchanged
-
Liskov Substitution Principle
- Elements use composition over inheritance
- Avoid inheritance hierarchies entirely
-
Interface Segregation
- Elements expose only related methods
- Clients depend only on what they need
-
Dependency Inversion
- Elements depend on abstractions
- Implementation details stay private
When to Use Elements
Good candidates for elements:
- Complex models (User, Account, Project)
- Code that clusters around business concepts
- Functionality that grows together
Don't create elements for:
- Simple models (Tag, Category)
- Single methods
- Technical concerns
Getting Started
- Look for clusters of related methods in your models
- Identify business concepts in your controllers
- Create namespaced classes for each concept
- Move logic gradually
- Let your tests guide you
Conclusion
Elemental Ruby isn't a silver bullet, but it provides a clear path for organizing Rails applications around business concepts. It builds on Rails conventions while adding structure that helps applications grow sustainably.
The next time you open a controller and feel that familiar dread, remember: there's a better way to organize your code. Break it down into its elements, and let your business domain guide you.
This is the first in a series exploring Elemental Ruby. Next time we'll look at identifying elements in legacy code.
What patterns have you found for organizing Rails applications as they grow? Have you tried similar approaches? Let me know in the comments!
Top comments (0)