Introduction
When a SaaS product grows, the codebase often becomes tangled, making new features risky and slow to ship. A modular Rails architecture can keep the codebase clean, improve testability, and reduce deployment friction. In this article we walk through a practical pattern that separates core business logic from feature-specific code, using concerns, service objects, and mountable engines.
Why Modularize?
Modularization isolates change. If a new billing model requires a different payment flow, you can add a new module without touching the existing order processing code. This reduces merge conflicts and makes automated testing more focused. It also aligns with the principle of single responsibility, which senior engineers appreciate when reviewing pull requests.
Core Concepts
Engines
Rails engines are mini-applications that mount their own routes, models, and controllers. Create an engine for each major domain, such as Payments, Notifications, or Analytics. The engine lives under engines/ and can be tested in isolation.
# engines/payments/lib/payments/engine.rb
module Payments
class Engine <::Rails::Engine
isolate_namespace Payments
end
end
Service Objects
Encapsulate business rules in plain Ruby objects. A service receives input, performs the operation, and returns a result object. This keeps controllers thin and makes the logic reusable across APIs and background jobs.
class CreateInvoice
def initialize(order)
@order = order
end
def call
Invoice.create!(order: @order, amount: calculate_amount)
end
private
def calculate_amount
@order.items.sum { |i| i.price * i.quantity }
end
end
Concerns
Use concerns for shared behavior across models, but keep them small. A concern for Auditable can add callbacks that log changes to a central audit table.
module Auditable
extend ActiveSupport::Concern
included do
after_update:log_audit
end
def log_audit
AuditLog.create!(record: self, changed_at: Time.current)
end
end
Putting It Together
Suppose we need to add a new “usage-based” pricing tier. We create a Pricing engine that contains its own models (Plan, UsageMetric) and a service CalculateUsageCharges. The existing Billing engine calls this service via a well-defined interface.
# engines/billing/services/billing/calculate_charge.rb
module Billing
class CalculateCharge
def initialize(customer)
@customer = customer
end
def call
usage = Pricing::CalculateUsageCharges.new(@customer).call
base = @customer.plan.base_price
base + usage
end
end
end
Because the Pricing engine is isolated, we can run its test suite independently:
bundle exec rspec engines/pricing
If the pricing model changes, only the Pricing engine and the Billing::CalculateCharge service need updates. The rest of the application remains untouched.
Deployment Considerations
When you mount engines, Rails generates separate routes. Keep the route namespace clear to avoid collisions.
# config/routes.rb
mount Payments::Engine, at: '/payments'
mount Billing::Engine, at: '/billing'
Use feature flags to roll out new engine functionality gradually. Tools like flipper or rollout let you enable a feature for a subset of customers while you monitor performance.
Testing Strategy
- Unit tests for service objects and concerns.
- Engine integration tests that hit the engine’s routes.
- Contract tests that verify the interface between engines.
This layered approach catches regressions early and gives confidence when deploying to production.
Conclusion
A modular Rails architecture using engines, service objects, and concerns helps senior engineers deliver SaaS features quickly without sacrificing stability. By isolating domains, you keep the codebase maintainable, improve test coverage, and make deployments predictable. The pattern scales from early-stage startups to larger teams that need clear ownership boundaries.
Author: senior engineer at developerz.ai
Top comments (0)