Haskell Feature Flag Demo (Part 3)
The previous entry of the Haskell Feature Flag Demo series presents a feature flag demo implementation using simple types. It suffers from “boolean blindness:” it is possible/easy to pass the wrong feature flag configuration as an argument. This entry presents a feature flag demo implementation that addresses this problem using tagged feature flag configuration.
Demo2.FeatureFlag
For simplicity, the feature flags and feature flag API are defined in a single module. In a production application, it might be worth developing the feature flag API in a separate module/package.
The previous demo represents a feature flag configuration using
Bool
(or Maybe Bool
). Function
foo
depends on feature flag
Fix_202407_SomeBug_42
but any Bool
value can
be passed to it. It is up to the developer to manually ensure that the
correct configuration is passed, and bugs occur when there is a
mistake.
We would instead like to use the Haskell type system to ensure that configuration for a specific feature flag is passed. By representing this information at the type level, there is a compile-time error when there is a mistake. This is an example of using the type system to make a whole class of bugs impossible.
In this demo, current feature flags are defined using a generalized
algebraic data type (GADT). Note that they specify the type of the
configuration value. Current configuration has Bool
values,
as they are feature flags, but it would be possible to create
other types of configuration as well.
data FeatureFlag :: Type -> Type where
Fix_202407_SomeBug_42 :: FeatureFlag Bool
Ref_202407_SomeBusinessLogic :: FeatureFlag Bool
Note that this requires the GADTs
and PolyKinds
GHC extensions.
It is not possible to derive Eq
, Ord
, or
Show
instances for such a type, but the some
package provides GEq
,
GCompare
,
and GShow
type classes that work with GADTs, and the dependent-sum-template
package provides Template Haskell functions to generate them.
'FeatureFlag
deriveGEq ''FeatureFlag
deriveGCompare ''FeatureFlag deriveGShow '
Note that this requires the TemplateHaskell
GHC extension.
While the previous demo stored feature flag configuration in a Map
,
this one stores it in a DMap
,
a “dependent map” provided the dependent-map
package. The above GADT determines the keys as well as the type of value
for each key in the map. This type is functor-parametric, and the Identity
functor is used to store concrete configuration values.
type FeatureFlagMap = DMap FeatureFlag Identity
Type FeatureFlagConfig
represents feature flag
configuration. It is tagged with a FeatureFlag
, and it
wraps a value of the type specified in the GADT. Note that
Maybe
is used to represent feature flags without
configuration.
newtype FeatureFlagConfig (tag :: FeatureFlag a)
= FeatureFlagConfig
config :: Maybe a
{
}deriving (Eq, Show)
Function config
is used to get the Maybe
value, while function configT
returns true if the feature
flag is not found and function configF
returns false if the
feature flag is not found.
configT :: FeatureFlagConfig (tag :: FeatureFlag Bool) -> Bool
= fromMaybe True . config
configT
configF :: FeatureFlagConfig (tag :: FeatureFlag Bool) -> Bool
= fromMaybe False . config configF
Function lookup
performs a lookup for the specified
feature flag in a FeatureFlagMap
and returns a
FeatureFlagConfig
, which is tagged with the feature flag.
There are no lookupT
or lookupF
functions, as
these are replaced by configT
and configF
.
lookup
:: FeatureFlag a
-> FeatureFlagMap
-> FeatureFlagConfig (tag :: FeatureFlag a)
lookup ff = FeatureFlagConfig . fmap runIdentity . DMap.lookup ff
The global storage for this demo is the same as that of the previous demo, just with the different types.
globalFeatureFlagMap :: MVar FeatureFlagMap
= unsafePerformIO MVar.newEmptyMVar
globalFeatureFlagMap {-# NOINLINE globalFeatureFlagMap #-}
initialize :: MonadIO m => FeatureFlagMap -> m ()
= liftIO $ do
initialize ffMap <- MVar.tryPutMVar globalFeatureFlagMap ffMap
isSuccess . void $ MVar.swapMVar globalFeatureFlagMap ffMap unless isSuccess
Type class MonadFeatureFlags
is the same as well, just
using the different types. Remember that it requires the DefaultSignatures
GHC extension.
class Monad m => MonadFeatureFlags m where
-- | Get the feature flag configuration
readM :: m FeatureFlagMap
readM :: MonadIO m => m FeatureFlagMap
default= liftIO $ MVar.readMVar globalFeatureFlagMap
readM
instance MonadFeatureFlags IO
Function lookupM
has the same implementation, just
different types.
lookupM :: MonadFeatureFlags m
=> FeatureFlag a
-> m (FeatureFlagConfig (tag :: FeatureFlag a))
= lookup ff <$> readM lookupM ff
Type class HasFeatureFlags
and the related instances are
the same as well, just using different types. Remember that it requires
the FlexibleInstances
GHC extension.
class HasFeatureFlags a where
getFeatureFlags :: a -> FeatureFlagMap
instance HasFeatureFlags FeatureFlagMap where
= id
getFeatureFlags
instance (HasFeatureFlags env, Monad m)
=> MonadFeatureFlags (ReaderT env m) where
= asks getFeatureFlags readM
Demo2.FeatureFlag.DB
The (mock) load
function in the new demo constructs a DMap
.
Note that operator (==>)
,
provided by package dependent-sum
,
uses pure
to lift into the functor, Identity
in this case.
load :: IO FF.FeatureFlagMap
= pure $ DMap.fromList
load FF.Fix_202407_SomeBug_42 ==> True
[ FF.Ref_202407_SomeBusinessLogic ==> True
, ]
Demo2.IO
The IO
demo application is the same as in the previous
demo, refactored to use the different types.
Functions foo
and bar
now take tagged
feature flag configuration. Attempting to pass different feature flag
configuration results in a compile-time error.
foo :: FF.FeatureFlagConfig FF.Fix_202407_SomeBug_42 -> Int
foo ffSomeBug| FF.configT ffSomeBug = 42
| otherwise = 13
bar :: FF.FeatureFlagConfig FF.Ref_202407_SomeBusinessLogic -> Int -> Int
bar ffSomeBusinessLogic n| FF.configT ffSomeBusinessLogic = n + n
| otherwise = 2 * n
Note that the DataKinds
GHC extension is required to use a type like
FF.FeatureFlagConfig FF.Fix_202407_SomeBug_42
.
Function fooBar
is just changed to use the different
types.
fooBar :: IO Int
= do
fooBar <- FF.lookupM FF.Fix_202407_SomeBug_42
ffSomeBug <- FF.lookupM FF.Ref_202407_SomeBusinessLogic
ffSomeBusinessLogic pure . bar ffSomeBusinessLogic $ foo ffSomeBug
Function run
is the same, just with the different
types.
run :: FF.FeatureFlagMap -> IO Int
= do
run ffMap
FF.initialize ffMap fooBar
Application
File app/demo2.hs
simply loads the feature flag
configuration, runs the program, and prints the result.
main :: IO ()
= print =<< Demo2.IO.run =<< Demo2.FeatureFlag.DB.load main
Code
The full source is available on GitHub.
This demo uses GHC 9.6.5 and can be run using the following command.
$ cabal run demo2
Alternatively, if you use Stack, run the demo using the following command.
$ stack run demo2