DEV Community

Luis Donis
Luis Donis

Posted on

How I Structure a Modular Rails SaaS Application

How I Structure a Modular Rails SaaS Application

In my previous post, I wrote about the Rails SaaS architecture I wish I had 5 years ago.

The main idea was simple: once a Rails application grows into a real SaaS product, the problem is no longer just writing features quickly.

The real challenge becomes keeping the system understandable.

That naturally leads to the next question:

What does that structure actually look like in practice?

This post is my attempt to answer that.

It is not the only way to organize a Rails app.
It is just the structure that currently makes the most sense to me for products with multiple business capabilities, internal tooling, and long-term growth.


The Problem With the Default Growth Path

Most Rails applications begin with a very reasonable structure:

app/
config/
db/
lib/
Enter fullscreen mode Exit fullscreen mode

That works well at the beginning.

But as the product grows, many business capabilities start living side by side in the same application layer:

  • authentication
  • roles and permissions
  • notifications
  • dashboards
  • auditing
  • support tickets
  • file management
  • billing logic
  • admin tools

At that point, the issue is not that Rails is bad.

The issue is that everything starts competing for space inside the same app boundaries.

Models become aware of too much.
Controllers start coordinating unrelated concerns.
Helpers grow in weird directions.
Concerns multiply.
And eventually, the application feels large even when individual features are not that complex.


The Shift: Structure by Capability

What has worked better for me is structuring the system by business capability instead of only by technical layer.

So instead of thinking only in terms of:

  • models
  • controllers
  • views

I think in terms of:

  • support
  • audit
  • admin
  • accounts
  • users
  • dashboards
  • billing

Each of those capabilities gets its own boundary.

In Rails, the cleanest way I have found to do that is with engines.


A Simple High-Level Structure

A modular Rails SaaS application might look like this:

my_app/
├── app/
├── config/
├── db/
├── lib/
├── engines/
│   ├── lesli_core/
│   ├── lesli_admin/
│   ├── lesli_audit/
│   ├── lesli_billing/
│   ├── lesli_dashboard/
│   ├── lesli_shield/
│   └── lesli_support/
└── Gemfile
Enter fullscreen mode Exit fullscreen mode

The main application still exists.
But it is no longer the place where every feature gets dumped.

Instead, the main app acts more like the integration layer.

The engines contain the actual business capabilities.


What Belongs in the Main App?

This is the part that matters most.

A modular structure only works if the main app stays disciplined.

In my case, the main Rails app is usually responsible for:

  • environment configuration
  • deployment configuration
  • boot process
  • mounting engines
  • app-specific branding and overrides
  • product-specific custom logic
  • final composition of the system

So the app is still important.
It just stops pretending to own every domain directly.


What Belongs in an Engine?

Each engine owns a clear capability.

For example, a support engine might contain:

engines/lesli_support/
├── app/
│   ├── controllers/
│   ├── models/
│   ├── views/
│   └── components/
├── config/
│   └── routes.rb
├── db/
│   └── migrate/
├── lib/
│   └── lesli_support/
├── lesli_support.gemspec
Enter fullscreen mode Exit fullscreen mode

That engine can own:

  • tickets
  • comments or discussions
  • statuses
  • priorities
  • assignment flows
  • support-specific dashboards
  • notifications related to support

That creates something extremely valuable:

local reasoning.

When I need to work on support, I go to the support engine.
I am not hunting through a giant application trying to remember where ticket logic leaked into other layers.


The Role of a Core Layer

I still like having a shared core layer.

But I think it should stay small and intentional.

For me, a core engine usually contains things like:

  • shared concerns that are truly cross-cutting
  • shared UI primitives
  • base classes
  • common helpers
  • platform configuration helpers
  • common interfaces between engines

What I try to avoid is turning the core layer into a second monolith.

If core becomes the place where every engine dumps shared shortcuts, then the architecture slowly collapses back into the same problem.

So the question I ask is:

Is this really cross-cutting, or am I just avoiding a cleaner boundary?


Routing and Composition

One thing I like about this approach is that composition stays explicit.

The main application decides what gets mounted.

A simplified example might look like this:

Rails.application.routes.draw do
  mount LesliAdmin::Engine, at: "/admin"
  mount LesliSupport::Engine, at: "/support"
  mount LesliAudit::Engine, at: "/audit"
end
Enter fullscreen mode Exit fullscreen mode

That makes the final application shape easy to understand.

The product is not a mystery.
It is a composition of capabilities.


Boundaries Matter More Than Reuse

A nice side effect of engines is reuse.

But honestly, that is not the main reason I like them.

The bigger benefit is enforced boundaries.

Without boundaries, every feature eventually reaches into everything else.

With boundaries, integration has to be intentional.

That changes how the codebase grows.

You start asking better questions:

  • Should this dependency exist?
  • Does support really need to know about billing internals?
  • Is this concern shared, or just misplaced?
  • Should this logic live in the app, the engine, or the core layer?

Those questions improve architecture more than any naming convention ever will.


This Is Not Free

To be fair, this kind of structure also adds some overhead.

You have to think more about things like:

  • engine naming
  • domain boundaries
  • dependency direction
  • migrations across engines
  • shared conventions
  • local development workflow

So I would not use this for every project.

If you are building a small internal tool, an MVP, or something you need to ship very quickly, this can feel like too much structure too early.

But when the product starts growing across multiple capabilities, that extra structure stops feeling heavy and starts feeling useful.


What I Like Most About This Approach

What I like most is not that it feels more “advanced.”

It is that it makes the system easier to work with.

When I add a feature, I have a better idea of where it belongs.
When I debug something, I am not jumping across unrelated parts of the app.
When I refactor, I feel a little more confident that I am not breaking everything around it.

For me, that is what good architecture should do.

Not win abstraction points.
Not look impressive on a diagram.

Just make the app easier to understand as it grows.


How This Connects to Lesli

This is the same general direction I have been exploring while building Lesli.

I am not trying to make Rails more complicated.
I am mostly trying to give larger SaaS-style applications a cleaner place to grow.

Lesli is still evolving, but this modular approach is one of the ideas behind it.

Top comments (0)