Skip to content

Repository files navigation

Superfeature

Features are simple boolean flags that say whether or not they're enabled, right? Not quite. Features can get quite complicated, as you'll read below in the use cases.

This gem makes reasoning through those complexities much more sane by isolating them all into the app/plans folder as plain 'ol Ruby objects (POROS), that way your team can reason through the features available in an app much better, test them, and do really complicated stuff when needed.

Use cases

Here's why you should use Superfeature:

Turbo app built by a solopreneur deployed to the Apple App Store

If you're deploying a simple Rails Turbo application to the web you might have 20 features that are available for purchase, but when deployed to the Apple App Store, you have to disable certain parts of your website to comply with their draconian app store policies. Superfeature could disable the features that upset Apple, like links to your support and pricing, so that your app can get approved and stay in compliance.

B2B Rails app built by a 50 person engineering team for multinational enterprises

Enterprise use-cases are even more complicated. If a package is sold to a multi-national customer with 200 features, they may want to disable 30 of those features for certain teams/groups within that organization for compliance reasons. You end up with a hierarchy that can get as complicated as, "The Zig Bang feature is available to MegaCorp on the Platimum plan, but only for their US entities if their team administrators turn that feature on because of weird compliance reasons".

Installation

Install the gem by executing the following from your Rails root:

$ bundle add superfeature

Then run

$ rails generate superfeature:install

Restart your server and it's off to the races!

Quick Start

Plans

A plan is a Ruby class that defines what features are available:

modulePlansclassFree < Superfeature::Plandefname="Free"defdescription="Get started for free"endend

Features

Features are methods that return enabled/disabled states:

modulePlansclassFree < Superfeature::Planfeaturedefpriority_support=disable("Priority support")featuredefapi_access=enable("API access")endend

Check features in your app:

plan=Plans::Free.new(current_user)plan.priority_support.enabled?# => falseplan.api_access.enabled?# => true

Limits

Features can also be limits with quantities:

modulePlansclassFree < Superfeature::Planfeaturedefprojects=hard_limit("Projects",quantity: user.projects_count,maximum: 5)featuredefstorage_gb=soft_limit("Storage",quantity: user.storage_gb,soft_limit: 1,hard_limit: 2)endend

Check limits:

plan.projects.exceeded?# => true if over 5plan.projects.remaining# => how many leftplan.storage_gb.warning?# => true if between soft and hard limit

Plan Inheritance

Plans inherit from each other. Override features to change them:

modulePlansclassPro < Freedefname="Pro"defdescription="For professionals"# Enable what was disabled in Freedefpriority_support=super.enable# Increase limitsdefprojects=hard_limit("Projects",quantity: user.projects_count,maximum: 100)endend

Navigation Between Plans

Link plans together with next and previous:

modulePlansclassFree < Superfeature::Plandefnext=planProendclassPro < Freedefprevious=planFreedefnext=planEnterpriseendclassEnterprise < Prodefprevious=planProendend

Pricing

The Price class handles monetary values with precision using BigDecimal internally:

price=Superfeature::Price.new(49.99)price.amount# => BigDecimal("49.99")price.to_f# => 49.99price.to_i# => 49

Adding Price to Plans

modulePlansclassFree < Superfeature::Plandefprice=Price(0)endclassPro < Freedefprice=Price(29)endclassEnterprise < Prodefprice=Price(99)endend

Formatting Prices

price=Price(29)price.to_formatted_s# => "29.00"price.to_formatted_s(decimals: 0)# => "29"

Discounts

Apply discounts to prices:

price=Price(100)# Fixed dollar amount offprice.discount_fixed(20).amount# => 80.0# Percentage off (25 = 25%)price.discount_percent(25).amount# => 75.0# Set a target price directlyprice.discount_to(79).amount# => 79.0

Discount Strings

Parse discount strings naturally:

price=Price(100)price.apply_discount("20%").amount# => 80.0price.apply_discount("$15").amount# => 85.0price.apply_discount(10).amount# => 90.0 (numeric = dollars off)

Reading Discount Info

After applying a discount, access the details:

price=Price(100).apply_discount("25%")price.amount# => 75.0price.discounted?# => trueprice.original.amount# => 100.0price.discount.fixed# => 25.0 (dollars saved this step)price.discount.percent# => 25.0 (percent of original this step)price.discount.to_fixed_s# => "25.00"price.discount.to_percent_s# => "25%"price.discount.to_formatted_s# => "25%" (natural format)

Cumulative Savings

When chaining multiple discounts, use savings to get the total discount from the original price:

price=Price(100).apply_discount("20%").apply_discount("$10")# 100 -> 80 -> 70price.discount.fixed# => 10.0 (last step only)price.discount.percent# => 10.0 (last step as % of original)price.savings.fixed# => 30.0 (total saved from original)price.savings.percent# => 30.0 (total % off original)price.savings.to_fixed_s# => "30.00"price.savings.to_percent_s# => "30%"

Discount Objects

For reusable discounts, create Discount objects:

includeSuperfeaturesummer_sale=Discount::Percent.new(20)loyalty=Discount::Fixed.new(10)Price(100).apply_discount(summer_sale).amount# => 80.0Price(100).apply_discount(loyalty).amount# => 90.0

Apply multiple discounts:

Price(100).apply_discount(Discount::Fixed.new(10),# $10 off firstDiscount::Percent.new(20)# then 20% off).amount# => 72.0 (100 - 10 = 90, then 90 * 0.8 = 72)

Rounding to Endings

Round prices to specific endings like $9.99 or $49:

price=Price(50)price.round(9)# => Price(49) - nearest ending in 9price.round_up(9)# => Price(59) - round up to ending in 9price.round_down(9)# => Price(49) - round down to ending in 9price.round(0.99)# => Price(49.99) - nearest ending in .99

Price Chains

Chain operations directly on prices:

Price(100).discount_percent(20)# => Price(80).discount_fixed(10)# => Price(70).round_up(9)# => Price(79)

Each step returns a new Price with a reference to the previous price, so you can walk back the chain:

final=Price(100).discount_percent(20).round_up(9)final.amount# => 89final.previous.amount# => 80final.original.amount# => 100final.discounted?# => true

Custom Discount Sources

Any object can be a discount if it implements to_discount:

classCoupon < ApplicationRecorddefto_discountSuperfeature::Discount::Percent.new(percent_off)endendcoupon=Coupon.find_by(code: "SAVE20")price=Price(100).apply_discount(coupon)price.amount# => 80.0

Building a Pricing Table

Here's how to put it all together for a pricing page.

Define Your Plans

modulePlansclassBase < Superfeature::Planattr_reader:userdefinitialize(user)@user=userendfeaturedefprojects=hard_limit("Projects",quantity: user.projects_count,maximum: 3)featuredefapi_access=disable("API access")featuredefpriority_support=disable("Priority support")endclassFree < Basedefname="Free"defprice=Price(0)defnext=planProendclassPro < Freedefname="Pro"defprice=Price(29)defprojects=hard_limit("Projects",quantity: user.projects_count,maximum: 100)defapi_access=super.enabledefprevious=planFreedefnext=planEnterpriseendclassEnterprise < Prodefname="Enterprise"defprice=Price(99)defprojects=unlimited("Projects",quantity: user.projects_count)defapi_access=super.enabledefpriority_support=super.enabledefprevious=planProendend

Add a Promotion

classPromotionattr_reader:name,:percent_offdefinitialize(name:,percent_off:)@name=name@percent_off=percent_offenddefto_discountSuperfeature::Discount::Percent.new(@percent_off)endend

Controller

classPricingController < ApplicationControllerdefindex@plans=Superfeature::Plan::Collection.new(Plans::Free.new(User.new)).to_a@promo=Promotion.new(name: "Launch Special",percent_off: 20)endend

View

<h1>Pricing</h1><%if@promo%><divclass="promo-banner"><%=@promo.name%>: Save <%=@promo.percent_off%>% on all plans!
</div><%end%><divclass="pricing-grid"><%@plans.eachdo |plan| %><divclass="plan-card"><h2><%=plan.name%></h2><%price=plan.price%><%if@promo && price.positive?%><%discounted=price.apply_discount(@promo)%><pclass="price"><spanclass="original">$<%=price.to_formatted_s(decimals: 0)%></span><spanclass="sale">$<%=discounted.to_formatted_s(decimals: 0)%></span><spanclass="savings">Save <%=discounted.discount.to_percent_s%></span></p><%else%><pclass="price"><%ifprice.free?%>
Free
<%else%>
$<%=price.to_formatted_s(decimals: 0)%>/mo
<%end%></p><%end%><ulclass="features"><%plan.features.eachdo |feature| %><li><%iffeature.enabled?%><spanclass="check"></span><%else%><spanclass="x"></span><%end%><%=feature.name%></li><%end%></ul><%=link_to"Choose #{plan.name}",subscribe_path(plan: plan.key),class: "button"%></div><%end%></div>

This renders a pricing table with:

  • Original and discounted prices when a promotion is active
  • Feature list with checkmarks
  • "Free" label for zero-price plans
  • Savings percentage from the discount

Generated Files

The generator creates the following structure:

app/plans/base.rb

The base plan defines all features with sensible defaults:

modulePlansclassBase < Superfeature::Planattr_reader:userdefinitialize(user)@user=userend# Boolean features - simple on/off flagsfeaturedefpriority_support=disable("Priority support",group: "Support")featuredefphone_support=disable("Phone support",group: "Support")# Hard limits - strict maximum that cannot be exceededfeaturedefapi_calls=hard_limit("API calls",group: "Limits",quantity: user.api_calls_count,maximum: 1000)# Soft limits - has a soft and hard boundary for overagesfeaturedefstorage_gb=soft_limit("Storage",group: "Limits",quantity: user.storage_used_gb,soft_limit: 100,hard_limit: 150)# Unlimited - no restrictionsfeaturedefprojects=unlimited("Projects",group: "Limits",quantity: user.projects_count)protecteddeffeature(name, **options)Features::Base.new(name, **options)endendend

app/plans/features/base.rb

Extends Superfeature::Feature with name and group for display purposes:

modulePlansmoduleFeaturesclassBase < Superfeature::Featureattr_reader:name,:groupdefinitialize(name=nil,group: nil, **)super(**)@name=name@group=groupendendendend

You can add whatever else you want to a feature class, including logic, calculation methods, new types of limits, and more.

app/plans/free.rb and app/plans/paid.rb

Plans are linked together using next and previous methods:

modulePlansclassFree < Basedefname="Free"defprice=0defdescription="Get started for free"defnext=planPaidendendmodulePlansclassPaid < Freedefname="Paid"defprice=9.99defdescription="Full access to all features"# Override features from Base to enable themdefpriority_support=super.enabledefnext=nildefprevious=planFreeendend

The next and previous methods create a linked list of plans that Superfeature::Plan::Collection can traverse.

Usage

Setting up User#plan

Add a plan column to your users table to track which plan they're on:

add_column:users,:plan,:string,default: "free"

Then add a plan method to your User model:

classUser < ApplicationRecorddefplan@plan ||= Superfeature::Plan::Collection.new(Plans::Free.new(self)).find(plan_key)enddefplan_keyself[:plan]&.to_sym || :freeendend

Now you can access features directly from the user:

current_user.plan# => Collection wrapping Plans::Free or Plans::Paidcurrent_user.plan.priority_support.enabled?# => falsecurrent_user.plan.upgrades.to_a# => available upgrade plans

Checking features in controllers

classModerationController < ApplicationControllerdefshowifcurrent_plan.moderation.enabled?render"moderation"elseredirect_toupgrade_pathendendprivatedefcurrent_plan@current_plan ||= current_user.planendhelper_method:current_planend

Checking features in views

<h1>Moderation</h1><%ifcurrent_plan.moderation.enabled?%><%=renderpartial: "moderation"%><%else%><p>Call sales to upgrade to moderation</p><%end%>

Working with Plan::Collection

The Collection class wraps a plan and provides navigation and enumeration:

# Create a collection starting from any plancollection=Superfeature::Plan::Collection.new(Plans::Free.new(current_user))# Find a specific plan by symbol keycollection.find(:paid)# => Paid plan instance# Find a specific plan by classcollection.find(Plans::Paid)# => Paid plan instance# Get multiple plans with slicecollection.slice(:free,:paid)# => Array of matching planscollection.slice(Plans::Free,Plans::Paid)# => Also works with classes# Iterate through all plans (includes Enumerable)collection.eachdo |plan|
puts"#{plan.name}: $#{plan.price}"endcollection.to_a# All plans as an array

Checking limits

plan=current_user.plan# Hard limitsifplan.api_calls.exceeded?render"api_limit_reached"endputsplan.api_calls.quantity# current usageputsplan.api_calls.maximum# max allowedputsplan.api_calls.remaining# how many left# Boolean featuresplan.priority_support.enabled?# => falseplan.priority_support.disabled?# => true

Preventing inheritance with exclusively

When plans inherit from each other, methods are inherited too. Sometimes you want a method to only apply to the exact class it's defined in, not subclasses. Use exclusively:

modulePlansclassPro < Basic# Only Pro gets this badge, not Enterprise which inherits from Proexclusivelydefbadge="Most Popular"endendmodulePlansclassEnterprise < Pro# badge returns nil here, not "Most Popular"endend

Adding new plans

Generate a new plan:

$ rails generate superfeature:plan Enterprise

This creates app/plans/enterprise.rb:

modulePlansclassEnterprise < Basedefname="Enterprise"defprice=0defdescription="Description for Enterprise plan"# Override features from Base to enable them# def priority_support = super.enable## Conditionally enable/disable based on a boolean:# def dark_mode = super.enable(user.premium?)# def legacy_feature = super.disable(user.migrated?)# Link to adjacent plans for navigation# def next = plan NextPlan# def previous = plan PreviousPlanendend

Then wire it into your plan chain by updating next and previous methods:

# In paid.rbdefnext=planEnterprise# In enterprise.rbdefprevious=planPaid

Price Reference

Creating Prices

price=Price(49.99)# convenience methodprice=Price.new(49.99)# standard constructor

In Rails, you can also use core extensions:

100.discounted_by(20.percent_off)# => Price(80)100.discounted_by(20)# => Price(80)100.to_price# => Price(100)"$49.99".to_price# => Price(49.99)

Outside of Rails, opt-in with require "superfeature/core_ext".

Conversions

price.to_f# => 49.99 (Float)price.to_i# => 49 (Integer)price.to_d# => BigDecimal("49.99")price.to_s# => "49" or "49.99" (display-friendly, omits .00)price.to_formatted_s(decimals: 2)# => "49.99" (consistent decimals)

Comparisons

Price(100) > Price(50)# => truePrice(100) == 100# => truePrice(100) < 200# => true

Math

Price(100) + 20# => Price(120)Price(100) - 20# => Price(80)Price(100) * 2# => Price(200)Price(100) / 4# => Price(25)10 + Price(5)# => Price(15)

Queries

Price(0).zero?# => truePrice(0).free?# => true (alias)Price(100).positive?# => truePrice(100).paid?# => true (alias)

Comparable libraries

There's a few pretty great feature flag libraries that are worth mentioning so you can better evaluate what's right for you.

Flipper

https://github.com/jnunemaker/flipper

Flipper is probably the most extensive and mature feature flag libraries. It even comes with its own cloud service. As a library, it concerns itself with:

  • Persisting feature flags to Redis, ActiveRecord, or any custom back-end.
  • UI for toggling features flags on/off
  • Controlling feature flags for everybody, specific people, groups of people, or a percentage of people.

Superfeature is different in that it:

  • Feature flags are testable.
  • Features are versioned and tracked as code, which makes it easier to sync between environments if that's a requirement.
  • Can handle reasoning about features beyond a simple true/false, including soft limits, app store limitations, or complex feature cascading required by some enterprises.

Rollout

https://github.com/FetLife/rollout

Roll-out is similar to Flipper, but is backed soley by Redis.

License

The gem is available as open source under the terms of the MIT License.

About

A feature flag Rails plugin gem that works for simple one-person Rails apps all the way up to large enterprise rails apps

Resources

Stars

90 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages