Doing dependency injection using monad stacks
Asked Answered
I

1

8

I'm trying different approaches to do what is sometimes known as dependency injection. For this I've elaborated a simple example of a weather app, where we want to fetch the weather data (from a web-service or from a hardware device), store the weather data (could be a database or simply a file), and report it (either print it to screen, or speak the weather). The idea is to write a program that uses some fetch, store, and report functions, whose implementations can vary.

I've managed to separate concerns and abstract away from the implementations of retrieval, storage, and reporting using functions and free-monads, however the solution I reached with monad stacks looks bad:

{-# LANGUAGE GeneralizedNewtypeDeriving #-}

module WeatherReporterMTL where

import           Control.Monad.IO.Class
import           Control.Monad.Trans.Class

type WeatherData = String

class Monad m => WeatherService m where
    fetch :: m WeatherData

class Monad m => Storage m where
    store :: WeatherData -> m ()

class Monad m => Reporter m where
    report :: WeatherData -> m ()

-- | A dummy implementation of the @WeatherService@
newtype DummyService m a = DummyService { runDummyService :: m a }
    deriving (Functor, Applicative, Monad, MonadIO)

instance MonadIO m => WeatherService (DummyService m) where
    fetch = return "won't get any warmer in December."

-- | A dummy implementation of the @Storage@
newtype DummyStorage m a = DummyStorage { runDummyStorage :: m a }
    deriving (Functor, Applicative, Monad, MonadIO, WeatherService)

-- It seems wrong that the storage has to be an instance the weather service
-- (@WeatherService@) ...

instance MonadIO m => Storage (DummyStorage m) where
    store d = liftIO $ putStrLn $ "No room left for this report: " ++ d

-- | A dummy implementation of the @Reporter@
newtype DummyReporter m a = DummyReporter { runDummyReporter :: m a }
    deriving (Functor, Applicative, Monad, MonadIO, WeatherService, Storage)

-- Ok, now this seems even worse: we're putting information about
-- how we're gonna stack our monads :/

instance MonadIO m => Reporter (DummyReporter m) where
    report d = liftIO $ putStrLn $ "Here at the MTL side " ++ d

reportWeather :: (WeatherService m, Storage m, Reporter m) => m ()
reportWeather = do
    w <- fetch
    store w
    report w

dummyWeatherReport :: IO ()
dummyWeatherReport = runDummyService $ runDummyStorage $ runDummyReporter reportWeather

In the code above, both DummyStorage and DummyReporter have to have trivial instances for WeatherService, which seems plainly wrong. Moreover, these instances depend on the order monads are stacked in the end. Is there a way to avoid leaking information between the different stacks?

Irenics answered 30/11, 2017 at 17:24 Comment(10)
"In the code above, both DummyStorage and DummyReporter have to have trivial instances for WeatherService, which seems plainly wrong." No, that's how MTL works. All the transformers have instances for all the type classes (well, mostly) so that you can stack them in any order you want. Example from the MTL source code.Merovingian
Ok, so it seems unavoidable then. And now that you mention it, I don't use transformers at all in the code above (maybe thanks to the GeneralizedNewtypeDeriving magic?).Irenics
And I also wonder whether using MTL when you have cross-cutting concerns as in this example has any advantages over functions.Irenics
You are using transformers, you just didn't write the instances of MonadTrans. eg, instance MonadTrans DummyReporter where lift = DummyReporter. I'm afraid I don't understand your question about cross-cutting concernsMerovingian
Sorry for the confusion. What I meant by cross curring concerns is that we need implementations for a weather-service, a storage-service, and a reporting-service (all of which are orthogonal), that need to be combined in a single program reportWeather above. Maybe orthogonal-concerns is a better description.Irenics
I've been writing fairly extensively on this topic. In this case, I'd question whether you even need DI, or an alternative. Essentially, all you describe is impure, with little (if any) logic, so you should be able to just compose (impure) functions together: 1. Fetch the weather data (impure). 2. translate it (probably pure). 3. Report (side-effects, impure). Just chain the functions together. Why do you need mtl or transformers? What value does it provide?Shallot
It seems that you've already found at least one proper solution to this problem (the linked module you've entitled WeatherReporterFreeSeparated). It is a well known limitation of monad transformers that you need n*m instances (where n=the number of transformers and m=number of classes) - e.g. ReaderT has 'trivial' instances for MonadWriter, MonadState, etc. However, it isn't true that order matters (you can have instances for all 3 classes for all 3 types). Unless there is a compelling reason to use monad transformers, I would recommend you use the solution you've found already.Pane
@MarkSeemann I've went through the series of articles you referred, nice. For the problem I posed in this question, I think I do need what in the OO world is called DI: the core business logic should remain the same (fetch, store, report - all impure), but the implementation of these (impure) parts might change. I do agree that from the three approaches I tried using monadic functions is the one that seems simpler and better separates concerns.Irenics
fetch, store, report - is that really business logic? Is it something your business stakeholders care about, and that they may ask you to change in the future?Shallot
I would say the sequence fetch >> store >> report is business logic. And as I exemplified in the question, fetch might use an online service or some hardware, store might use a database or a file, report might print to screen or speak the weather. So I was wondering how these implementation specific details could be abstracted using MTL (instead of functions or free monads for instance).Irenics
S
3

Instead of tying implementations to specific newtypes, perhaps you could have "free-floating" implementation functions that required access to IO and to some necessary bookkeeping state, like

data WeatherState = WeatherState -- dummy
fetch' :: (MonadState WeatherState m,MonadIO m) => m WeatherData
fetch' = undefined 
data StorageState = StorageState -- dummy
store' :: (MonadState StorageState m,MonadIO m) => WeatherData -> m ()
store' = undefined 
data ReporterState = ReporterState -- dummy
report' :: (MonadState ReporterState m,MonadIO m) => WeatherData -> m ()
report' = undefined

"Injecting" would mean creating some newtype over a StateT carrying the required states, and then declaring instances like

newtype Injected a = 
    Injected { getInjected :: StateT (WeatherState,StorageState,ReportState) a } 
    deriving (Functor,Applicative,Monad)

instance WeatherService Injected where
    fetch = Injected $ zoom _1 fetch'

instance Storage Injected where
    store x = Injected $ zoom _2 $ store' x

instance Reporter Injected where
    report x = Injected $ zoom _3 $ report' x

(_1 is from microlens and zoom from microlens-mtl.)

Splitting answered 30/11, 2017 at 20:49 Comment(4)
Looks good. I think I've reached a similar solution using monadic functions.Irenics
@DamianNadales I expanded my answer in this gist gist.github.com/danidiaz/5fb8ac9ecf40875617cba503214dd3c2 My aim was to avoid the boilerplate of explicitly zooming every typeclass method when declaring the instances for the Injected newtype. Interestingly, I too ended up using MultiParamTypeClasses, but perhaps went a bit overboard with the type shenanigans.Splitting
@DamianNadales If we avoid StateT and limit ourselves to using ReaderT over IO and mutable references for carrying state, these patterns fit well with the "unliftio" approach hackage.haskell.org/package/unliftio youtube.com/watch?v=KZIN9f9rI34Splitting
Thanks for the links. I didn't know about unliftio. For the example I gave, I'd expect ReaderT and IO to be enough. In more concrete scenarios maybe these restrictions make sense as well.Irenics

© 2022 - 2024 — McMap. All rights reserved.