Skip to main content

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
lookupT = Map.findWithDefault True

lookupF :: FeatureFlag -> FeatureFlagMap -> Bool
lookupF = Map.findWithDefault False

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
globalFeatureFlagMap = unsafePerformIO MVar.newEmptyMVar
{-# 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 ()
initialize ffMap = liftIO $ do
    isSuccess <- MVar.tryPutMVar globalFeatureFlagMap ffMap
    unless isSuccess . void $ MVar.swapMVar globalFeatureFlagMap ffMap

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

  default readM :: MonadIO m => m FeatureFlagMap
  readM = liftIO $ MVar.readMVar globalFeatureFlagMap

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)
lookupM ff = lookup ff <$> readM

lookupTM :: MonadFeatureFlags m => FeatureFlag -> m Bool
lookupTM ff = lookupT ff <$> readM

lookupFM :: MonadFeatureFlags m => FeatureFlag -> m Bool
lookupFM ff = lookupF ff <$> readM

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
  getFeatureFlags = id

instance (HasFeatureFlags env, Monad m)
    => MonadFeatureFlags (ReaderT env m) where
  readM = asks getFeatureFlags

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
load = pure $ Map.fromList
    [ (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
fooBar = do
    ffSomeBug <- FF.lookupTM FF.Fix_202407_SomeBug_42
    ffSomeBusinessLogic <- FF.lookupTM FF.Ref_202407_SomeBusinessLogic
    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
run ffMap = do
    FF.initialize ffMap
    fooBar

Application

File app/demo1.hs simply loads the feature flag configuration, runs the program, and prints the result.

main :: IO ()
main = print =<< Demo1.IO.run =<< Demo1.FeatureFlag.DB.load

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