DEV Community

developerz.ai
developerz.ai

Posted on

Building Scalable SaaS Features with Modular Rails Architecture

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Because the Pricing engine is isolated, we can run its test suite independently:

bundle exec rspec engines/pricing
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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

  1. Unit tests for service objects and concerns.
  2. Engine integration tests that hit the engine’s routes.
  3. 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)