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 Providers, which are like "data sources" associated to different Envs 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
SqliteHandlerto IO actions. -
Make
SqliteHandlerFan instance ofFunctorBackend. Most of this is covered by the previous point; you just have to specify a couple of type family instances (e.g. theContainerwould beVector) and lift the operations inSqliteFinto 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
LogHandlerthat the higher-level components (DB, email, etc.) can interpret to (i.e. instead of adding aLoggingTlayer toBaseM, they could work inFreeT LogHandlerF BaseMor something). This kind of structuring gives us something like ... an intermediate language, if you will.servantCore, 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.)