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
Navigating between plans
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.