我如何构建模块化的 Rails SaaS 应用程序
Source: Dev.to

在我之前的文章中,我写到了我 5 年前本希望拥有的 Rails SaaS 架构。
核心思想很简单:当一个 Rails 应用成长为真正的 SaaS 产品时,问题不再只是快速编写功能。
真正的挑战变成了 让系统易于理解。
这自然会引出下一个问题:
在实际操作中,这种结构到底是什么样子的?
这篇文章是我对这个问题的尝试性回答。它 并不是 组织 Rails 应用的唯一方式;它只是目前对我而言,在具备多业务能力、内部工具以及长期增长需求的产品中最合理的结构。
默认增长路径的问题
大多数 Rails 应用程序最初都有一个非常合理的结构:
app/
config/
db/
lib/在起步阶段这运作良好。
但随着产品的增长,许多业务功能开始并列存在于同一应用层:
- 身份验证
- 角色和权限
- 通知
- 仪表盘
- 审计
- 支持工单
- 文件管理
- 计费逻辑
- 管理工具
此时,问题不在于 Rails 本身,而是所有东西开始在同一个应用边界内部争夺空间。
- 模型了解的东西太多。
- 控制器开始协调不相关的关注点。
- 辅助方法向奇怪的方向发展。
- Concern 变得层出不穷。
最终,即使各个功能本身并不复杂,整个应用也会感觉庞大。
转变:按能力结构化
对我而言,更有效的做法是按 业务能力 而不是仅按技术层来结构化系统。
因此,我不再仅仅从以下角度思考:
- models
- controllers
- views
我从以下角度思考:
- 支持
- 审计
- 管理员
- 账户
- 用户
- 仪表盘
- 计费
每个能力都有自己的边界。在 Rails 中,我发现最简洁的实现方式是使用 engines。
简单的高级结构
一个模块化的 Rails SaaS 应用可能如下所示:
my_app/
├── app/
├── config/
├── db/
├── lib/
├── engines/
│ ├── lesli_core/
│ ├── lesli_admin/
│ ├── lesli_audit/
│ ├── lesli_billing/
│ ├── lesli_dashboard/
│ ├── lesli_shield/
│ └── lesli_support/
└── Gemfile主应用仍然存在,但它不再是把所有功能都塞进去的地方。
相反,主应用更像是 集成层,而各个引擎则包含实际的业务能力。
主应用程序中应包含什么?
这是最关键的部分。模块化结构只有在主应用保持纪律时才有效。就我而言,主 Rails 应用通常负责:
- environment configuration
- deployment configuration
- boot process
- mounting engines
- app‑specific branding and overrides
- product‑specific custom logic
- final composition of the system
因此,应用仍然重要——只是它不再假装直接拥有每个领域。
什么属于引擎?
每个引擎都有明确的职责。例如,一个 support(支持)引擎可能包含:
engines/lesli_support/
├── app/
│ ├── controllers/
│ ├── models/
│ ├── views/
│ └── components/
├── config/
│ └── routes.rb
├── db/
│ └── migrate/
├── lib/
│ └── lesli_support/
└── lesli_support.gemspec该引擎可以管理:
- 工单
- 评论或讨论
- 状态
- 优先级
- 分配流程
- 支持专用的仪表盘
- 与支持相关的通知
这会产生极其有价值的东西:本地推理。当我需要处理支持相关的功能时,我会直接进入 support 引擎,而不是在庞大的应用中四处搜寻,试图记起工单逻辑泄漏到了哪些层。
Source:
核心层的作用
我仍然喜欢拥有一个共享的核心层,但它应该保持 小而有目的。对我来说,核心引擎通常包含以下内容:
- 真正跨越多个模块的共享关注点
- 共享的 UI 基元
- 基类
- 常用辅助函数
- 平台配置辅助工具
- 引擎之间的通用接口
我尽量避免把核心层变成第二个单体。如果 core 成为每个引擎都倾倒共享快捷方式的地方,架构就会慢慢回到同样的问题。因此我不断自问:
这真的跨越多个模块,还是我只是在回避更清晰的边界?
路由与组合
我喜欢这种方法的一点是组合保持显式。主应用决定挂载什么。下面是一个简化的例子:
# config/routes.rb
Rails.application.routes.draw do
mount LesliAdmin::Engine , at: "/admin"
mount LesliSupport::Engine , at: "/support"
mount LesliAudit::Engine , at: "/audit"
end这使得最终应用的结构易于理解。产品并不是谜团;它是各种功能的组合。
边界比复用更重要
引擎的一个不错的副作用是可以复用,但这 并不是 我喜欢它们的主要原因。更大的好处是 强制性的边界。
- 没有边界时,每个特性最终都会渗透到其他所有地方。
- 有了边界,集成就必须是有意为之。
这会改变代码库的增长方式。你会开始提出更好的问题:
- 这段逻辑应该属于已有的引擎,还是应该拥有自己的能力?
- 引擎之间如何通信而不产生紧耦合?
- 什么真正属于核心层,什么属于特定的引擎?
把这些问题放在首位,架构就能保持模块化、可维护,并为长期增长做好准备。
这种依赖可能存在吗?
- 支持真的需要了解计费内部细节吗?
- 这个关注点是共享的,还是仅仅放错了位置?
- 这些逻辑应该放在应用层、引擎层,还是核心层?
这些问题比任何命名约定都更能提升架构。
这不是免费
公平地说,这种结构也会带来一些额外开销。你需要更多地考虑以下方面:
- 引擎命名
- 领域边界
- 依赖方向
- 跨引擎迁移
- 共享约定
- 本地开发工作流
因此,我 不会 在每个项目中都使用这种方式。
如果你在构建一个小型内部工具、MVP,或是需要非常快速交付的东西,这种结构可能会显得过早且繁琐。
但当产品开始在多个功能上扩展时,这额外的结构就不再显得沉重,反而变得非常有用。
我最喜欢这种方法的原因
我最喜欢的并不是它看起来更“高级”。
而是它让系统更易于使用。
- 当我添加新功能时,我能更清楚它应该放在哪里。
- 当我调试问题时,我不必在应用的无关部分之间跳来跳去。
- 当我重构时,我会更有信心不会把周围的一切都弄坏。
对我而言,这才是良好架构应有的作用。
- 不在于赢得抽象分数。
- 不在于在图表上显得惊艳。
- 只是在应用不断增长时,让它更易于理解。
How This Connects to Lesli
这与我在构建 Lesli 时所探索的总体方向相同。
我并不是想让 Rails 变得更复杂。
我主要是想为更大型的 SaaS‑style 应用提供一个更清晰的成长空间。
Lesli 仍在不断发展,但这种模块化方法是其背后的理念之一。
