Haskell Feature Flag Demo (Part 2)
This entry of the Haskell Feature Flag Demo series presents a feature flag demo implementation using simple types. This version suffers from “boolean blindness:” it is possible/easy to pass the wrong feature flag configuration as an argument.
Demo1.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.
Current feature flags are defined using a sum type as follows. Note
that the constructor names purposefully do not follow usual Haskell
conventions. Feature flag names encode the feature flag type, the month,
a concise description, and (optionally) an issue/ticket number, and
underscores make a feature flag name easier to read. In this demo, bug
fixes have a Fix
prefix while refactors have a
Ref
prefix.
data FeatureFlag
= Fix_202407_SomeBug_42
| Ref_202407_SomeBusinessLogic
deriving (Eq, Ord, Show)
Feature flag configuration is stored in a Map
.
This makes it possible to distinguish when a feature flag is not
configured, which would not be possible with a Set
.
type FeatureFlagMap = Map FeatureFlag Bool
Three functions are defined for querying feature flag configuration.
Function lookup
returns a Maybe
value, while
function lookupT
returns true if the feature flag is not
found and function lookupF
returns false if the feature
flag is not found.
lookup :: FeatureFlag -> FeatureFlagMap -> Maybe Bool
lookup = Map.lookup
lookupT :: FeatureFlag -> FeatureFlagMap -> Bool
= Map.findWithDefault True
lookupT
lookupF :: FeatureFlag -> FeatureFlagMap -> Bool
= Map.findWithDefault False lookupF
The feature flag configuration is stored in a global
variable. Use of global variables is generally considered bad
practice in high-level languages, and it is especially frowned upon in
Haskell, which distinguishes pure and effectful functions. It is used to
enable any IO
action to access it, with minimal refactoring
of an existing codebase.
globalFeatureFlagMap :: MVar FeatureFlagMap
= unsafePerformIO MVar.newEmptyMVar
globalFeatureFlagMap {-# NOINLINE globalFeatureFlagMap #-}
I chose to use an MVar
because it works well for the requirements. In particular, any reads
block until initialization is completed, which is a nice property that
one does not get from an IORef
.
There is no need for a TVar
because the configuration is written exactly once.
Note that unsafePerformIO
is what allows the creation of a “global variable” in Haskell, and the
NOINLINE
pragma is necessary to guarantee that a single
globalFeatureFlagMap
is used by all functions. (Indeed, the
“singleton”
design pattern implements global state in object-oriented
paradigms.)
Function initialize
initializes the global storage. This
function should be called exactly once in production. To support
testing, however, the implementation replaces any existing
configuration.
initialize :: MonadIO m => FeatureFlagMap -> m ()
= liftIO $ do
initialize ffMap <- MVar.tryPutMVar globalFeatureFlagMap ffMap
isSuccess . void $ MVar.swapMVar globalFeatureFlagMap ffMap unless isSuccess
Brandon’s solution uses function getFlagIO
to read from
the global storage and function getFlag
to read from a
monadic context. I prefer to use a type class to provide a unified API
for both of these cases.
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
The default implementation is to read from the global storage. By
defining this functionality as a default, it can trivially (and
consistently) be reused in any other MonadIO
monads that
should also read from the global storage. Note that this requires the DefaultSignatures
GHC extension.
As a convenience, I defined monadic versions of the three functions
used to query feature flag configuration, using readM
.
lookupM :: MonadFeatureFlags m => FeatureFlag -> m (Maybe Bool)
= lookup ff <$> readM
lookupM ff
lookupTM :: MonadFeatureFlags m => FeatureFlag -> m Bool
= lookupT ff <$> readM
lookupTM ff
lookupFM :: MonadFeatureFlags m => FeatureFlag -> m Bool
= lookupF ff <$> readM lookupFM ff
To support getting the feature flag configuration from a ReaderT
environment, a HasFeatureFlags
type class is provided.
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
Note that this requires the FlexibleInstances
GHC extension.
Demo1.FeatureFlag.DB
Loading configuration from a database is out of scope for this demo. The following implementation mocks it.
load :: IO FF.FeatureFlagMap
= pure $ Map.fromList
load FF.Fix_202407_SomeBug_42, True)
[ (FF.Ref_202407_SomeBusinessLogic, True)
, ( ]
Demo1.IO
This module defines a demo application in IO
.
Functions foo
and bar
are pure functions
that depend on feature flags. Since the flags are of type
Bool
, it is possible/easy to accidentally pass in an
incorrect value. Note that type Maybe Bool
could be use
instead, if we want to decide what to do when a feature flag is not
configured within the function.
foo :: Bool -> Int
foo ffSomeBug| ffSomeBug = 42
| otherwise = 13
bar :: Bool -> Int -> Int
bar ffSomeBusinessLogic n| ffSomeBusinessLogic = n + n
| otherwise = 2 * n
Function fooBar
just looks up the feature flag
configuration from the global storage and calls the above pure
functions. Note that this function blocks until global storage has been
initialized. This behavior is a trade-off; it prevents execution using
default values, but initialization must be done with care.
fooBar :: IO Int
= do
fooBar <- FF.lookupTM FF.Fix_202407_SomeBug_42
ffSomeBug <- FF.lookupTM FF.Ref_202407_SomeBusinessLogic
ffSomeBusinessLogic pure . bar ffSomeBusinessLogic $ foo ffSomeBug
Function run
is used to run the program. The feature
flag configuration is passed as a parameter so that the whole program
can be tested with various feature flag configurations (without loading
them from the database).
run :: FF.FeatureFlagMap -> IO Int
= do
run ffMap
FF.initialize ffMap fooBar
Application
File app/demo1.hs
simply loads the feature flag
configuration, runs the program, and prints the result.
main :: IO ()
= print =<< Demo1.IO.run =<< Demo1.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 demo1
Alternatively, if you use Stack, run the demo using the following command.
$ stack run demo1