Superfeature Plan

A Ruby DSL for defining SaaS pricing plans and features

After building pricing objects in last week’s post, the next challenge was figuring out how to organize features across different pricing tiers. Every SaaS product I’ve worked on eventually becomes a tangled mess of if current_user.plan == "pro" checks scattered throughout the codebase.

I decided to solve this problem in the Superfeature gem by creating a Plan class that makes defining SaaS pricing tiers declarative and maintainable.

Defining a plan

Plans are Ruby classes that inherit from Superfeature::Plan:

class Plans::Website::Free < Superfeature::Plan
  def name = "Free"
  def tagline = "Get started for free"
  def price = Price(0)
end

This gives you a clean interface for plan metadata. The Price() method is from Superfeature::Price, so pricing and plans work together naturally.

Plan inheritance

Here’s where things get interesting. Plans can inherit from each other:

class Plans::Website::Creator < Plans::Website::Free
  def name = "Creator"
  def tagline = "For content creators"
  def price = Price(19)
end

class Plans::Website::Business < Plans::Website::Creator
  def name = "Business"
  def tagline = "For growing businesses"
  def price = Price(99)
end

This inheritance isn’t just for code reuse—it represents the natural hierarchy of SaaS tiers where higher tiers include everything from lower tiers. I’m still learning if it makes sense to inherit from the previous plan or from a base plan.

Limits

The core of Superfeature is the Limit object. When you call enable or disable, you’re creating a Limit that can be checked throughout your application:

class Plans::Website::Free < Superfeature::Plan
  def screenshots
    disable "Automatic Open Graph images from screenshots"
  end

  def custom_fonts
    disable "Upload custom fonts"
  end
end

Then in the Creator plan, you enable the features you want to unlock:

class Plans::Website::Creator < Plans::Website::Free
  def screenshots
    enable "Automatic Open Graph images from screenshots"
  end
end

Because Creator inherits from Free, it gets all of Free’s limits by default. You only need to override the ones you want to change.

The Limit object gives you methods to check state and display information:

plan = Plans::Website::Free.new
plan.screenshots.enabled?    # => false
plan.screenshots.disabled?   # => true
plan.screenshots.description # => "Automatic Open Graph images from screenshots"

Tracking features for display

The feature keyword is separate from limits—it marks which methods should appear in pricing tables:

class Plans::Website::Free < Superfeature::Plan
  feature def screenshots
    disable "Automatic Open Graph images from screenshots",
      group: "Features"
  end

  feature def custom_fonts
    disable "Upload custom fonts",
      group: "Customization"
  end
end

The group option organizes features into sections. You can then iterate over plan.features to build comparison tables, filtering by group as needed.

Hard limits

Some features aren’t boolean—they’re quantities. Use hard_limit for these:

class Plans::Website::Free < Superfeature::Plan
  feature def link_credits
    hard_limit "Link credits",
      maximum: 100,
      group: "Limits"
  end
end

class Plans::Website::Creator < Plans::Website::Free
  feature def link_credits
    hard_limit "Link credits",
      maximum: 1000,
      group: "Limits"
  end
end

class Plans::Website::Business < Plans::Website::Creator
  feature def link_credits
    hard_limit "Link credits",
      maximum: 10000,
      group: "Limits"
  end
end

Plans form a linked list using next and previous methods. I use a helper to instantiate plans with the current context:

def plan(klass)
  klass.new(website)
end

Then define the navigation chain:

class Plans::Website::Free < Superfeature::Plan
  def next = plan Creator
end

class Plans::Website::Creator < Plans::Website::Free
  def next = plan Business
  def previous = plan Free
end

class Plans::Website::Business < Plans::Website::Creator
  def next = plan Platform
  def previous = plan Creator
end

This creates a doubly-linked list that’s useful for upgrade prompts and navigation.

Plan collections

When you need to work with groups of plans—like rendering a pricing page—use Superfeature::Plan::Collection:

collection = Superfeature::Plan::Collection.new(website.plan)

collection.upgrades   # => [Creator, Business, Platform]
collection.downgrades # => [Free]

You can also define class methods to get all plans:

class Plans::Website::Base < Superfeature::Plan
  class << self
    def all
      Superfeature::Plan::Collection.new Free.new
    end

    def pricing
      all.slice Free, Creator, Business, Enterprise
    end
  end
end

Using plans in views

Now the payoff. Instead of scattered conditionals, you can check features directly:

<%% unless website.plan.screenshots.enabled? %>
  <div class="upgrade-prompt">
    <p><%%= website.plan.screenshots.description %> is available on the <%%= website.plan.next.name %> plan.</p>
    <%%= link_to "Upgrade now", upgrade_path %>
  </div>
<%% end %>

And rendering a pricing page becomes straightforward:

<%% Plans::Website::Base.pricing.each do |plan| %>
  <div class="pricing-card">
    <h3><%%= plan.name %></h3>
    <p><%%= plan.tagline %></p>
    <p class="price"><%%= number_to_currency(plan.price.amount) %>/month</p>

    <ul>
      <%% plan.card_features.each do |feature| %>
        <li><%%= feature.description %></li>
      <%% end %>
    </ul>
  </div>
<%% end %>

The combination of Price and Plan from Superfeature has made building the pricing infrastructure for OpenGraph+ straightforward. Instead of scattered conditionals, I have a clear object model that represents how customers experience different tiers.