Zum Inhalt springen

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 }
}

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

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

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

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

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

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

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

Support scenarios: Temporarily increase limits while investigating issues:

user.access.update(total_monthly_run_count: 100_000) # Temporary increase

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) }

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.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert