Feature flags for Ruby objects.
Feature flags can be declared on modules or classes:
module Features
extend Featuring::Declarable
feature :some_feature
end
class ObjectWithFeatures
extend Featuring::Declarable
feature :some_feature
end
By default, a feature flag is disabled. It can be enabled by specifying a value:
module Features
extend Featuring::Declarable
feature :some_feature, true
end
Feature flags can also compute a value using a block:
module Features
extend Featuring::Declarable
feature :some_feature do
# perform some complex logic
end
end
The truthiness of the block's return value determines if the feature is enabled or disabled.
Each feature flag has a corresponding method to check its value:
module Features
extend Featuring::Declarable
feature :some_feature
end
Features.some_feature?
# => false
When using feature flags on an object, checks are available through the features
instance method:
class ObjectWithFeatures
extend Featuring::Declarable
feature :some_feature
end
instance = ObjectWithFeatures.new
instance.features.some_feature?
# => false
When using feature flag blocks, values can be passed through the check method:
module Features
extend Featuring::Declarable
feature :some_feature do |value|
value == :some_value
end
end
Features.some_feature?(:some_value)
# => true
Features.some_feature?(:some_other_value)
# => false
Check methods are guaranteed to only return true
or false
:
module Features
extend Featuring::Declarable
feature :some_feature do
:foo
end
end
Features.some_feature?
# => true
Check methods have access to their context:
class ObjectWithFeatures
extend Featuring::Declarable
feature :some_feature do
enabled?
end
def enabled?
true
end
end
instance = ObjectWithFeatures.new
instance.features.some_feature?
# => true
Note that this happens through delegators, which means that instance variables are not accessible to the feature flag. For cases like this, define an attr_accessor
.
Feature flag persistence can be added to any object with feature flags. Right now, persistence to an ActiveRecord model is supported. Postgres is currently the only supported database.
Enable persistence on an object by including the adapter:
class ObjectWithFeatures
include Featuring::Persistence::ActiveRecord
extend Featuring::Declarable
feature :some_feature
end
While persistence is anticipated to be used mostly for other ActiveRecord models, feature flags can be persisted for any object that exposes a deterministic value for id
.
Here's the example we'll use for the next few sections:
class User < ActiveRecord::Base
include Featuring::Persistence::ActiveRecord
extend Featuring::Declarable
feature :some_feature
end
Nothing is persisted by default. Instead, each feature flag must be persisted explicitly. This means that by default, checks fall back to the default value of a feature flag:
User.find(1).features.some_feature?
# => false
Use the persist
method to persist a feature flag with its default value:
User.find(1).features.persist :some_feature
User.find(1).features.some_feature?
# => false
This can be used to isolate objects from future changes to default values.
Use the set
method to persist a feature flag with a specific value:
User.find(1).features.set :some_feature, true
User.find(1).features.some_feature?
# => true
Enable a flag using the enable
method:
User.find(1).features.enable :some_feature
User.find(1).features.some_feature?
# => true
Disable a flag using the disable
method:
User.find(1).features.disable :some_feature
User.find(1).features.some_feature?
# => false
Reset a flag using the reset
method:
User.find(1).features.enable :some_feature
User.find(1).features.reset :some_feature
User.find(1).features.some_feature?
# => false
Multiple feature flags can be persisted using the transaction
method:
User.find(1).features.transaction |features|
features.enable :some_feature
features.disable :some_other_feature
end
User.find(1).features.some_feature?
# => true
User.find(1).features.some_other_feature?
# => false
Persistence happens in one step. Using the ActiveRecord adapter, all feature flag changes within the transaction block will be committed in a single INSERT
or UPDATE
query.
For performance, persisted feature flags are loaded only once for an instance. This means if a different value is persisted for a feature flag in another part of the system, the change won't be immediately available to other instances until they are reloaded:
user = User.find(1)
# enable somewhere else
User.find(1).features.enable :some_feature
# feature still appears disabled for existing instances
user.features.some_feature?
# => false
# reloading the features invalidates the cache:
user.features.reload
user.features.some_feature?
# => true
When used in an ActiveRecord model, feature flags are automatically reloaded with the object:
user = User.find(1)
# enable somewhere else
User.find(1).features.enable :some_feature
# feature still appears disabled for existing instances
user.features.some_feature?
# => false
# reloading the model invalidates the cache:
user.reload
user.features.some_feature?
# => true
The persisted status of a flag can be checked with the persisted?
method:
User.find(1).features.persisted?(:some_feature)
# => false
User.find(1).features.persist :some_feature
User.find(1).features.persisted?(:some_feature)
# => true
Checking if a specific value is persisted for a flag is also possible:
User.find(1).features.enable :some_feature
User.find(1).features.persisted?(:some_feature, true)
# => true
User.find(1).features.persisted?(:some_feature, false)
# => false
An example of where this is useful can be found in the next section.
In most cases, a feature flag's persisted value takes precedence over its default value. The single exception to this rule is when using feature flags defined with blocks. If the persisted value is false
, the persisted value is always given precedence. But if the persisted value is true
, the value returned from the block must also be truthy. This lets us do complex things like enable a feature 50% of the time for users that are given explicit access to a feature:
class User < ActiveRecord::Base
include Featuring::Persistence::ActiveRecord
extend Featuring::Declarable
feature :some_feature do
[true, false].sample && features.persisted?(:some_feature)
end
end
Feature flags are persisted to a database table with a polymorphic association to flaggable objects. By default, the ActiveRecord adapter expects a top-level FeatureFlag
model to be available, along with a feature_flags
database table. The table is expected to contain the following fields:
flaggable_id
:integer
column containing the flaggable object idflaggable_type
:string
column containing the flaggable object typemetadata
:jsonb
column containing the feature flag values
Feature flags can be defined in various modules and composed together:
module Features
extend Featuring::Declarable
feature :some_feature, true
end
module AllTheFeatures
extend Features
extend Featuring::Declarable
feature :another_feature, true
end
class ObjectWithFeatures
include AllTheFeatures
end
instance = ObjectWithFeatures.new
instance.some_feature?
# => true
instance.another_feature?
# => true
Super is fully supported! Here's an example of how it can be useful:
module Features
extend Featuring::Declarable
feature :some_feature do
[true, false].sample
end
end
class ObjectWithFeatures
include Features
extend Featuring::Declarable
feature :some_feature do
persisted?(:some_feature) || super()
end
end
User.find(1).features.some_feature?
# => true/false at random
User.find(1).features.enable :some_feature
User.find(1).features.some_feature?
# => true (always)
Feature flag values can be serialized using serialize
:
module Features
extend Featuring::Declarable
feature :some_enabled_feature, true
feature :some_disable_feature, false
end
Features.serialize
=> {
some_enabled_feature: true,
some_disabled_feature: false
}
All flags, persisted or not, will be included in the result.
Include only specific feature flags in the serialized result using include
:
module Features
extend Featuring::Declarable
feature :some_enabled_feature, true
feature :some_disable_feature, false
end
Features.serialize do |serializer|
serializer.include :some_enabled_feature
end
# => {
# some_enabled_feature: true
# }
Exclude specific feature flags in the serialized result using exclude
:
module Features
extend Featuring::Declarable
feature :some_enabled_feature, true
feature :some_disable_feature, false
end
Features.serialize do |serializer|
serializer.exclude :some_enabled_feature
end
# => {
# some_disabled_feature: false
# }
Serializing complex feature flags will fail if they require an argument:
module Features
extend Featuring::Declarable
feature :some_complex_feature do |value|
value == :some_value
end
end
Features.serialize
# => ArgumentError
Context can be provided for these feature flag using context
:
Features.serialize do |serializer|
serializer.context :some_complex_feature, :some_value
end
# => {
# some_complex_feature: true
# }