The idea is to use different functors to represent different noninteracting parts of the logic. We expose a user-facing API, transform computations targeting that API into internal APIs -- DB connectors, calls to remote APIs, etc. -- and then combine and handle the final computation at the end using separate, isolated handlers which can depend on what environment it's running in!
This code has a public API (AppF
and the AppEffect
types). It also has multiple internal APIs: a DbHandlerF
, an EmailHandlerF
, and a RemoteHandlerF
(for remote API calls). The APIs for working with these are all pure, and are separate from each other. Each of these APIs is also separated from its handler, which executes code written for it in IO
(and this need not even be an IO
action: one can write a different set of "pure" handlers easily).
We also want to be able to swap these pieces in and out. This example allows you to change Provider
s, which are like "data sources" associated to different Env
s the app can run in. Here, I've written a fake DB controller (DbHandlerF
) using a concurrently-accessed list of users, and associated it to a Prod
environment (although it should've been named Testing
: I realised this late enough that I didn't bother to do the replacing.)
If one writes a real DB controller with, say, sqlite-simple
(or, you know, opaleye
or other "real" libraries), it should be very easy to replace the current DbHandlerF
:
-
Write the type
data SqliteHandlerF next = AddSqliteRecord Thing1 Thing2 next | QuerySqliteRecord Thing1 Thing2 (Vector User -> next) | ... data SqliteHandler = Free SqliteF
-
Write a handler that converts operations in
SqliteHandler
to IO actions. -
Make
SqliteHandlerF
an instance ofFunctorBackend
. Most of this is covered by the previous point; you just have to specify a couple of type family instances (e.g. theContainer
would beVector
) and lift the operations inSqliteF
into the free monad:addRecord = liftF $ AddSqliteRecord ...
-
Change
instance HasHandler Prod where type ProviderF Prod = DbHandlerF
to
instance HasHandler Prod where type ProviderF Prod = SqliteHandlerF
Now, what I'd planned was that we could just add a new Env
instead of having to replace the behavior of Prod
, but we get overlapping instance/"enable IncoherentInstances
even though it won't help, lol" problems if the app
function is left polymorphic in the Env
(because there are multiple ways to deduce a HasServer
instance based on the env
variable). I think newtyping and adding a clever deriving instance
declaration ought to fix this.
You can try things like:
$ curl -X GET http://localhost:8080/users/
$ curl -X GET http://localhost:8080/users/add/Person/McPersonface
-
(somewhat silly) Write a
LogHandler
that the higher-level components (DB, email, etc.) can interpret to (i.e. instead of adding aLoggingT
layer toBaseM
, they could work inFreeT LogHandlerF BaseM
or something). This kind of structuring gives us something like ... an intermediate language, if you will.servant
Core, anyone? :)This allows us to transform and handle the log requests as we wish, and any changes here don't affect any other part of the code. More importantly, I think this will make it very easy to have a lot of context for each logging event (in case of an error, say).
This is influenced a lot by the well-known John De Goes article, but his approach of writing a separate interpreter for each functor's free monad isn't very useful here, because that forces you to convert each public API call into one set of DB actions, one set of logging actions, and so on, and then perform each set of actions separately one by one. Of course, not being able to interleave IO
actions dynamically is sort of limiting (free applicatives? wink), so I decided to try something else. (This code also reminds me of Halogen, now that I think about it. My entire experience with Halogen extends to a few buttons with state that I dimly remember having written once upon a time, so I don't know how conscious that could have been.)
The code is very hacky (e.g. the ContainerRef
nonsense (although that could, admittedly, be used to replace the TVar [User]
with an STM (Set User)
from stm-containers
for multithreaded access in test environments, or an IO DBConnection
for real-world usage)), has lots of cruft (comments, remnants of "can I remove this Proxy
?" experiments, "correct" type annotations) I've forgotten to delete, and is far from complete (for one, most of the MonadBackend
constraints aren't being used as well as they should: I should be using them to abstract over different final monads instead of restricting to BaseM
).
Also, I spent a long time wondering where Data.Functor.Sum
had run off to. I think it took me an hour. (It's in base
nowadays, not transformers
. On the plus side, I learned that transformers
uses Darcs. Huh.)