I used to believe a powerful application was a single, solid block of marble. My early Rails apps were like Michelangelo's "David" – breathtaking from a distance, but a nightmare to change. Tweak a finger, and you risked shattering the whole statue.
This was the era of the Monolithic Sculptor. We worshipped the single, majestic codebase. Our app
directory was a quarry where every tool was readily available, but where the sound of one chisel affected all others.
Then I encountered my first major feature pivot. A simple request – "Let's allow login with both email and username" – sent tremors through the entire codebase. I found myself touching User models, session controllers, validation logic, and half a dozen view templates. The sculpture was solid, but it was also brittle.
The First Incision: Discovering the Seams
My journey toward modularity began not with a grand vision, but with pain. That pain taught me to see the seams in my application – the natural fracture points where one concern ended and another began.
I started with the obvious culprit: the Fat Model. My User
class had become a city-state within my application, governing:
- Authentication logic
- Authorization rules
- Notification preferences
- Profile management
- Search functionality
It was then I discovered the first principle of the modular sculptor:
"A class should have only one reason to change."
This Single Responsibility Principle wasn't just a programming guideline – it was a call to see my application not as a single block, but as a collection of specialized tools, each with its own purpose.
The Artisan's Workshop: Building Specialized Tools
Let me show you the transformation through code. Here's what authentication looked like in my monolithic approach:
# app/models/user.rb (The old way - a jack of all trades)
class User < ApplicationRecord
has_secure_password
validates :email, presence: true, uniqueness: true
validates :password, length: { minimum: 8 }
def self.authenticate(email, password)
user = find_by(email: email)
user if user&.authenticate(password)
end
def generate_auth_token
payload = { user_id: id, exp: 24.hours.from_now.to_i }
JWT.encode(payload, Rails.application.secrets.secret_key_base)
end
def admin?
role == 'admin'
end
# ... 150 more lines of unrelated functionality
end
This class knew about databases, password hashing, JWT tokens, and authorization. Changing any one of these concerns meant touching the User model and risking breakage elsewhere.
Here's the modular approach:
# app/models/user.rb (The core identity)
class User < ApplicationRecord
has_secure_password
validates :email, presence: true, uniqueness: true
# Delegate to specialized tools
delegate :authenticate, to: UserAuthenticator
delegate :generate_token, to: UserTokenGenerator
delegate :admin?, to: UserRoleChecker
end
# app/services/user_authenticator.rb
class UserAuthenticator
def self.authenticate(email, password)
user = User.find_by(email: email)
user if user&.authenticate(password)
end
end
# app/services/user_token_generator.rb
class UserTokenGenerator
def self.generate(user)
payload = { user_id: user.id, exp: 24.hours.from_now.to_i }
JWT.encode(payload, Rails.application.secrets.secret_key_base)
end
end
# app/services/user_role_checker.rb
class UserRoleChecker
def self.admin?(user)
user.role == 'admin'
end
end
Suddenly, each component had a single, clear purpose. I could change authentication logic without touching token generation, and modify role checking without affecting core user validation.
The Architecture of Loose Coupling: Designing the Joints
But creating specialized tools was only half the battle. The true artistry came in designing how these tools connected. Tight coupling is like welding pieces together – strong, but permanent. Loose coupling is like designing precise joints – secure, but allowing for independent movement.
I learned to embrace dependency injection and interfaces over implementations. Instead of hard-coding dependencies, I made them configurable:
# Tightly coupled (the old way)
class OrderProcessor
def process(order)
PaymentService.new.charge(order.total)
ShippingService.new.schedule_delivery(order)
InventoryService.new.update_stock(order.items)
# All dependencies are hard-coded
end
end
# Loosely coupled (the new way)
class OrderProcessor
def initialize(payment_service: PaymentService.new,
shipping_service: ShippingService.new,
inventory_service: InventoryService.new)
@payment_service = payment_service
@shipping_service = shipping_service
@inventory_service = inventory_service
end
def process(order)
@payment_service.charge(order.total)
@shipping_service.schedule_delivery(order)
@inventory_service.update_stock(order.items)
end
end
This simple change transformed my testing strategy and made the code infinitely more flexible. I could now test components in isolation by injecting mock dependencies, and swap implementations without rewriting core business logic.
The Composition: Building Complex Systems from Simple Parts
The real magic happened when I started composing these modular pieces. Like building with LEGO, I could create complex workflows from simple, reusable components:
class UserRegistrationFlow
def initialize(user_params,
authenticator: UserAuthenticator,
notifier: UserNotifier,
profile_builder: UserProfileBuilder)
@user_params = user_params
@authenticator = authenticator
@notifier = notifier
@profile_builder = profile_builder
end
def execute
user = User.new(@user_params)
return Failure(:invalid_user) unless user.valid?
ActiveRecord::Base.transaction do
user.save!
@authenticator.setup_credentials(user)
@profile_builder.create_default_profile(user)
@notifier.send_welcome_email(user)
end
Success(user)
end
end
Each component remained simple and focused, while the flow coordinated their interaction. Changing the registration process became a matter of recomposing the flow, not rewriting monolithic methods.
The Evolving Studio: Continuous Refinement
Modularity isn't a destination – it's a continuous practice. I've developed heuristics for spotting when my coupling is becoming too tight:
- The "Shotgun Surgery" test: Do simple changes require modifications in multiple files?
- The "Test Pain" indicator: Do my tests require extensive setup because of hidden dependencies?
- The "Understanding Barrier": Can a new developer understand one component without understanding the entire system?
When I notice these symptoms, I know it's time to re-examine my boundaries and introduce new seams.
The Masterpiece: An Ecosystem of Cooperation
What emerged from this journey wasn't just better code – it was a different way of thinking about application design. My Rails app transformed from a solid block of marble into an intricate mobile, where each piece moves independently yet contributes to a harmonious whole.
The principles I follow now:
- Define clear boundaries – Each component should have a single, well-defined purpose
- Design for testability – If it's hard to test in isolation, the coupling is too tight
- Embrace interfaces – Depend on contracts, not implementations
- Compose, don't inherit – Build complexity through combination rather than hierarchy
- Listen to the pain – Let friction guide your refactoring decisions
Modularity isn't about creating more files or more abstraction. It's about creating the right abstractions. It's the art of designing systems where change is localized, understanding is accessible, and complexity is managed through clear boundaries.
Your Rails application is your studio. Design it not as a single masterpiece, but as a collection of well-crafted tools that work together in harmony. The true artistry lies not in the individual pieces, but in the elegant way they connect.
Top comments (0)