registry
is a library offering an alternative to typeclasses for implicitly assembling functionalities in Haskell.
For example a typeclass for an Encoder
will implicitly retrieve other Encoder
s and use them to build a new one:
instance (Encoder Int) => Encode Age where
encode (Age n) = encode n
This can also be written "manually" as a function call:
newtype Encoder a = Encode { encode :: a -> Text }
ageEncoder :: Encoder Int -> Encoder Age
ageEncoder intEncoder = Encoder (\Age n -> encode intEncoder n)
registry
automates the call of such functions when it is necessary to build more complex structures like an application or data generators.
The benefits are:
- the construction of instance is still "type-directed" as with typeclasses, you can make an instance by specifying its type only, like
Encoder Age
- the construction can be altered by injecting some hand-crafted values in order to replace normal components with mocks for example
- it is also possible to inject values in a specific context only, for example a specific
Encoder Int
used by theEncoder Age
but not by theEncoder Year
If you want to get a good mental picture of what a registry is, you can visualize:
- an ordered list of values and functions
- an algorithm applying those functions to values in order to obtain other values based on their type
- some settings to "tweak" the algorithm (more on that later)
The algorithm goes like this:
- to get a value of type
a
check if there already is a value of that type in the registry and take the first available one - otherwise check if there is a function returning the type
a
(and take the first available one) - if there is one, create all the values required to call the function
- then call the function and get a value of type
a
This is pretty straightforward and we can see that "taking the first available value" allows us to "override" the registry by adding any value (or function actually) "on top" since this is an ordered list.
Let's see on a few examples what this means for wiring an application and generating data.
We are going to build, step by step, a very small application based on components being "records of functions". The purpose is to get
comfortable with the registry
API:
- how to create a registry holding our component definitions and values
- how to make a component with the registry
- how to "override" a component to replace it with a mock
- how to deal with components requiring some
IO
for their creation - how to "decorate" an existing component with some additional behavior
- how to memoize effectful components
- how to deal with typechecking for large registries
For this tutorial it is advised to create one file per exercise, possibly reusing code from previous exercises by using imports. You can then use the GHCi repl to make sure everything compiles and returns the values you expect.
We start with an application which does not do much, it asks the user if they want to know "the answer to life, the universe and everything" and accepts the following answers:
'Yes'
returns the answer by reading it from a file'No'
quitsMaybe
draws a random boolean and either quits or returns the answer
We use the following components for this application:
data App m = App {
userInput :: UserInput m
, secretReader :: SecretReader m
, rng :: Rng m
, console :: Console m
}
startApp :: App IO -> IO ()
startApp app@App{..} = do
userAnswer <- askQuestion userInput
quit <- case userAnswer of
Maybe -> randomBool rng
No -> pure True
Yes -> pure False
if quit then
write console "bye"
else do
secret <- readSecret secretReader
case secret of
Nothing -> write console "Sorry I actually don't know"
Just s -> do
write console ("the answer is " <> s)
startApp app
data Logger m = Logger {
info :: Text -> m ()
, error :: Text -> m ()
}
data UserAnswer = Yes | No | Maybe deriving (Eq, Show)
data UserInput m = UserInput {
askQuestion :: m UserAnswer
}
data Rng m = Rng {
randomBool :: m Bool
}
-- Get the secret answer and return Nothing if not found
data SecretReader m = SecretReader {
readSecret :: m (Maybe Text)
}
data Console m = Console {
write :: Text -> m ()
, read :: m Text
}
Those components with their implementations are available in test/Test/Tutorial/Application.hs
.
Implement the newApp
function to build the App
from the individual components by calling their respective constructors: newConsole
, newLogger
,...
Import Data.Registry
and:
- create a registry containing all constructors and configuration values using
<:
,fun
,val
registry =
...
<: fun newLogger
<: fun newRng
<: val (SecretReaderConfig ...)
Note the order in which you add elements to the registry matters! You will get a compilation error if you are trying to add a function whose inputs have not yet any way to be built by another function in the registry.
Tip
You can use the following pragmas to avoid typing the full signature of registry
:
{-# LANGUAGE PartialTypeSignatures #-}
{-# OPTIONS_GHC -fno-warn-partial-type-signatures #-}
Then:
- print the
registry
in the repl, what do you see? - implement the
newApp
function by callingmake @App registry
startApp newApp
in the repl!- comment out one of the constructors in the registry and observe the error you get on recompilation
- now use
makeUnsafe
instead ofmake
and recompile, the code should compile - what happens when you try to execute
startApp newApp
though?
Notes:
-
fun
is used to add a constructor function -
val
is used to add a configuration value, it must have aShow
instance -
the 2 lists of types
l1
andl2
inRegistry l1 l2
are:- the list of all the functions inputs
- the list of all the function outputs
They are used to statically check if you can
make
a value of a given type out of the registry
Now use the previous registry
and modify it to start the App
with a silentLogger
silentLogger = Logger (const (pure ())) (const (pure ()))
- create a
silentRegistry
by adding thesilentLogger
to theregistry
usingfun silentLogger +: ...
- run a
newSilentApp'
using this registry and observe that answeringMaybe
to the question should not display the random boolean used to determine what to do. - modify to the
registry
to set an incorrectSecretReaderConfig "missing"
value and run theApp
. When answeringYes
you should see an error with thisnewMisconfiguredApp
. - create a
newMisconfiguredSilentApp
which will be both misconfigured and will not output any logging - modify the
registry
to be both misconfigured and have a silent logger only for theRng IO
component withspecialize @(Rng IO) silentLogger
. You should still see error log messages but no info messages when selectingMaybe
- go back to
Application.hs
and add a newLogger
dependency to theUserInput
component. Observe that the code still compiles, you have done a local dependency modification without having to change any of the global code wiring the application
Take any registry and output the dot graph for the App
component:
putStrLn $ unDot $ makeDot @App registry
Copy the output and paste it at http://www.webgraphviz.com
Note: this could be extended to the Data.Registry.Rio
monad for dealing with resource allocation
Now we are going to introduce another implementation for the SecretReader
component.
We will now check right away if the secret file is missing or not, and emit an error right away
if it does not exist (don't bother trying to reuse code from newSecretReader
for now)
newCheckedSecretReader :: SecretReaderConfig -> Logger IO -> IO (SecretReader IO)
newCheckedSecretReader (SecretReaderConfig path) logger = do ...
- create a registry,
registryIO
, with this new constructor. Since it is now inIO
you must lift everything, all values and functions, toIO
usingfunTo @IO
andvalTo @IO
instead offun
andval
- start the application with
startApp =<< newAppIO
using the newregistryIO
to make theApp
- experiment with this new setup by using an incorrect configuration
Notes:
- in this exercise we introduce new combinators
funTo
,valTo
, which makes theregistry
API a bit more complex - however the main algorithm has not changed at all,
registry
is still applying functions to values regardless of these values being in a monadic context or not - this also means that each invocation of a value
IO a
will execute the effect each time. See Exercise 7 on how to fix this
It is quite annoying that we could not reuse the previous SecretReader
implementation to implement the checked one. Why does this not work?
newCheckedSecretReader :: SecretReaderConfig -> Logger IO -> SecretReader IO -> IO (SecretReader IO)
newCheckedSecretReader config logger original = -- use the original version
- use a "tagged" version of the
SecretReader
innewCheckedSecretReader
(unTag
will remove the tag fromTag "tag" a
to return justa
)
newCheckedSecretReader :: SecretReaderConfig -> Logger IO -> Tag "unchecked" SecretReader IO -> IO (SecretReader IO)
- add a "tagged" constructor to the
registry
using thetag
function
funTo @IO (tag "unchecked" newSecretReader)
<: ...
This will allow the registry algorithm to distinguish between an "unchecked" SecretReader
from a "checked" one since they have now different types.
- experiment with this new setup by using an incorrect configuration
Another issue related to having effectful components is that effects can be executed several times. This is clearly a problem for resources like connection pools where we don't want to create many times the same pool.
- to convince yourself that this is the case add a
newInitializedLogger
component to the registry
newInitializedLogger :: IO (Logger IO)
newInitializedLogger = do
print ("start the logger" :: Text)
pure (Logger putStrLn putStrLn)
Not great, when running the application we print start the logger
3 times because the Logger
is used by 3 other components.
- use the
Rio
monad and thecacheAt
to implement anewCachedLogger
function that will initialize theLogger
only once - run the application and observe that the
Logger
is only initialized once
By default a registry will collect all the types of values and functions that you register. The type-level lists tracked by a registry can grow quite large.
You can reduce those list by using:
eraseTypes
to return a registry with typeRegistry [ERASED_TYPES] [ERASED_TYPES]
normalize
to de-duplicate types in each list