DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

Flexible Feature Access in Rails SaaS Apps

This article was originally published on Build a SaaS with Rails from Rails Designer


When building a SaaS application, you'll inevitably need to manage different subscription plans with varying features and limitations. A common approach is to hard-code plan features directly in your models:

PLANS = { "starter" => { member_count: 1, workflows: 5, ai_enabled: false }, "pro" => { member_count: 10, workflows: 25, ai_enabled: true } } 
Enter fullscreen mode Exit fullscreen mode

While this works initially, it quickly becomes rigid and difficult to maintain. What happens when you need to give a customer a custom deal? Or when support needs to temporarily increase someone's limits? You're stuck modifying code or creating one-off exceptions.

I've found a much more flexible approach over the past decade: give each user their own individual access configuration that can be easily modified. Let me walk you through how I implement this system.

The Access Model

First, I create a dedicated model to store each user's access configuration using RailsVault:

# app/models/user/access.rb class User::Access < RailsVault::Base vault_attribute :member_count, :integer, default: Config::Plans.fallback.dig(:features, :member_count) vault_attribute :enabled_workflow_count, :integer, default: Config::Plans.fallback.dig(:features, :enabled_workflow_count) vault_attribute :enabled_endpoint_count, :integer, default: Config::Plans.fallback.dig(:features, :enabled_endpoint_count) vault_attribute :total_steps_per_workflow_count, :integer, default: Config::Plans.fallback.dig(:features, :total_steps_per_workflow_count) vault_attribute :total_monthly_run_count, :integer, default: Config::Plans.fallback.dig(:features, :total_monthly_run_count) vault_attribute :ai_enabled, :boolean, default: Config::Plans.fallback.dig(:features, :ai_enabled) end 
Enter fullscreen mode Exit fullscreen mode

Each attribute represents a specific feature or limitation in my application. I follow a personal convention of using _count suffixes for numeric limits and _enabled suffixes for boolean features (the booleans, can be called using a ? predicate, e.g. ai_enabled?).

The defaults come from your plan configuration (more on that below).

Adding Feature Access to Users

Next, I create a concern that handles the feature access logic:

# app/models/user/feature_access.rb module User::FeatureAccess extend ActiveSupport::Concern included do vault :access end def add_access(product_id) access.update Config::Plans[product_id.to_sym][:features] end end 
Enter fullscreen mode Exit fullscreen mode

The add_access method takes a Stripe product ID and applies the corresponding plan's features to the user. You'd typically call this from your Stripe webhook handler when a subscription is created or updated. Of course this is not limited to Stripe.

Include this concern in your User model:

# app/models/user.rb class User < ApplicationRecord include FeatureAccess # … end 
Enter fullscreen mode Exit fullscreen mode

Plan Configuration

Using the configuration system from my previous article, I define all plans in a YAML file:

# config/configurations/plans.yml shared: fallback: name: Free features: member_count: 1 enabled_workflow_count: 1 enabled_endpoint_count: 2 total_steps_per_workflow_count: 3 total_monthly_run_count: 100 ai_enabled: false development: starter_plan_id: name: Starter price_id: price_dev_starter amount: 19 features: member_count: 1 enabled_workflow_count: 5 enabled_endpoint_count: 10 total_steps_per_workflow_count: 5 total_monthly_run_count: 5_000 ai_enabled: false pro_plan_id: name: Pro price_id: price_dev_pro amount: 49 features: member_count: 5 enabled_workflow_count: 25 enabled_endpoint_count: 50 total_steps_per_workflow_count: 15 total_monthly_run_count: 25_000 ai_enabled: true production: starter_plan_id: name: Starter price_id: price_prod_starter amount: 19 features: member_count: 1 enabled_workflow_count: 5 enabled_endpoint_count: 10 total_steps_per_workflow_count: 5 total_monthly_run_count: 5_000 ai_enabled: false pro_plan_id: name: Pro price_id: price_prod_pro amount: 49 features: member_count: 5 enabled_workflow_count: 25 enabled_endpoint_count: 50 total_steps_per_workflow_count: 15 total_monthly_run_count: 25_000 ai_enabled: true 
Enter fullscreen mode Exit fullscreen mode

The fallback section defines the default free tier that new users get. Each plan's features hash corresponds exactly to the vault attributes defined in the Access model. Notice how the feature names match between the plans configuration and the Access model attributes.

Usage

Now you can easily check user access throughout your application:

# Check limits Current.user.access.member_count # => 5 Current.user.access.enabled_workflow_count # => 25 # Boolean checks with the ? suffix Current.user.ai_enabled? # => true # In controllers def create if Current.user.workflows.enabled.count >= Current.user.access.enabled_workflow_count redirect_to upgrade_path, alert: "Upgrade to create more workflows" return end # … end 
Enter fullscreen mode Exit fullscreen mode

When handling Stripe webhooks, applying access is nice and clean:

# In your Stripe webhook handler def checkout_session_completed(event) session = event.data.object user = User.find_by(stripe_customer_id: session.customer) # Extract the product ID from the line items product_id = session.line_items.data.first.price.product user.add_access(product_id) end 
Enter fullscreen mode Exit fullscreen mode

This system's real strength becomes apparent when you need flexibility:

Custom deals: Easily give a customer extra features without code changes:

user.access.update(member_count: 100) # Custom deal for enterprise prospect 
Enter fullscreen mode Exit fullscreen mode

Support scenarios: Temporarily increase limits while investigating issues:

user.access.update(total_monthly_run_count: 100_000) # Temporary increase 
Enter fullscreen mode Exit fullscreen mode

A/B testing: Test different feature combinations:

# Give beta users early access to AI features users.beta_enabled.each { it.access.update(ai_enabled: true) } 
Enter fullscreen mode Exit fullscreen mode

This approach has saved me many hours of custom development over the last decade and made customer support and sales much more pleasant. Instead of saying “that's not possible with your current plan”, I can often solve problems by adjusting their individual access settings.

And there you have it. The examples in above code are simplified to focus the core of this feature. If you have more advanced use cases, I am happy to help.

Top comments (0)