Loading
FretLink / @clementd

Application design with monad stacks

$ whoami

dev haskell, former CTO, I've worked with several langages, I used scala a lot (not anymore). Nowadays, haskell, rust, JS (a bit)
mise en relation chargeurs et transporteurs. suivi plus transparent, aider les petits transporteurs environnement technique historique: node mongo. nouveaux services : haskell / pg

FP in the small, OOP in the large

today I’ll try to show you why I think it’s not a good motto

FP: what is it good for?

absolutely everything
properly model business code
but also, plumbing

today I’ll talk about plumbing

General web service design

push IO and associated tasks at the outside
no particular name given to it (as opposed to hex architecture)

typical request / response flow diagram
http, then persistence, and then (ideally) pure business logic

add to that external services and observability support

business logic is a solved problem, so let’s talk about
the nitty-gritty part

Push IO to the edge

it’s not about how can we do it, but about
how to make it natural

Model IO in types

because as soon as you start to make IO explicit, it’s the natural
thing to do.
getHost :: IO String
getHost = getEnv "HOSTNAME"

main :: IO ()
main = do
  host <- getHost
  putStrLn host
here, reading env vars is IO
this info shows up in the types
// Using cats IO
def putStrLn(s: String): IO[Unit] =
  IO.apply(println(s))

def getHost(): IO[String] = {
  IO.apply(System.getEnv("HOSTNAME"))
}
same in scala, with a bit more ceremony. By default
the type system does not track IO, we have to use a library

def main(): IO[Unit] = for {
  host <- getHost()
  putStrLn(host)
}
same as in haskell, we can pretend we’re doing imperative code
def runMain(): Unit =
  main().unsafeRunSync
since we have to use a lib, we need to execute the IO
Hopefully, this should show up only one per codebase.

Combining IO actions

Having IO explicitly in the type system is good and everything, but how can we compose theses actions?

Combining IO actions




main :: IO ()
main = do
  host <- getHost
  putStrLn host
as seen earlier, there is special syntax for IO

Combining IO actions




main :: IO ()
main = getHost >>= (\host ->
         putStrLn host)
it’s just syntactic sugar for regular function calls

Combining IO actions





(>>=) :: IO a -> (a -> IO b) -> IO b
the type shows that the IO has to be sequential
(else, a would not be available)

Combining IO actions




def main(): IO[Unit] = for {
  host <- getHost()
  putStrLn(host)
}
same in scala

Combining IO actions




def main(): IO[Unit] = {
  getHost().flatMap(host => {
    putStrLn(host)
  })
syntactic sugar as well

Combining IO actions





IO[A]#flatMap(f: A => IO[B]): IO[B]
same type (albeit a bit more noisy)

Not only IO

IO is of course a driving force, but it’s not the only thing

add to that external services and observability support

Modeling Effects




-- haskell
forall a. Effect a
// scala
Effect[A]
effects as a context (value + more information)

Modeling Errors




data Either e a =
    Left e
  | Right a

type Effect a = Either Error a
io is not the only thing we can model explicitly in the
type system. We can replace exceptions with a proper data structure

Combining Errors




(>>=) :: Either e a
      -> (a -> Either e b)
      -> Either e b
we can combine them the same way as IO. with exceptions,
each line only runs if the previous ones did not throw



Either[E,A]
  #flatMap(f: A => Either[E,B])
    : Either[E,B]
same in scala

Let’s talk DI

Super important, driving force for app design in oop world
you could even create a framework just for DI

DI is simple

even though it’s common to have runtime reflection, DI
containers and so on, DI at its core, is simple

You need something, but it has to be provided

DI is glorified functions

I can give you a value if you provide me with x is
precisely the definition of functions


cat :: Cheeseburger -> Nap
cat = \cheez ->
        -- whatever cats do
        )


cat :: CanIHaz Cheeseburger Nap
cat = CanIHaz (\cheez ->
        -- whatever cats do
        )

newtype CanIHaz env a =
  CanIHaz (env -> a)
newtype is a wrapper (and is erased during compilation)
This is the same as above, but a bit more expressive

Combining DI

combining functions like this would be tedious
usually, the real dependency is deep down, and
flows upwards, but you don’t want to apply functions everywhere

Combining DI




(>>=) :: CanIHaz e a
      -> (a -> CanIHaz e b)
      -> CanIHaz e b
same as above: you can only have a value if the needed deps
have been provided

Combining DI




myOperation :: CanIHaz AppConfig MyValue
myOperation = do
  value1 <- myOperation1
  value2 <- myOperation2 value1
  pure (value1 + value2)
no need to juggle with functions any more, everything
is easy to compose



(>>=) :: CanIHaz e a
      -> (a -> CanIHaz e b)
      -> CanIHaz e b



(>>=) :: Either e a
      -> (a -> Either e b)
      -> Either e b



(>>=) :: IO a
      -> (a -> IO b)
      -> IO b

Monads describe sequential composition




forall m a b.
Monad m => m a -> (a -> m b) -> m b
all those functions have the same name and similar signatures
that’s because they’re the same thing: sequential composition


( $ ) ::   (a ->   b) ->   a ->   b
(<$>) ::   (a ->   b) -> m a -> m b
(<*>) :: m (a ->   b) -> m a -> m b
(=<<) ::   (a -> m b) -> m a -> m b

pure  :: a -> m a
that’s the essence of what monads are. we need a few things more


( $ ) ::   (a ->   b) -> (  a ->   b)
(<$>) ::   (a ->   b) -> (m a -> m b)
(<*>) :: m (a ->   b) -> (m a -> m b)
(=<<) ::   (a -> m b) -> (m a -> m b)

pure  :: a -> m a
that’s the essence of what monads are. we need a few things more

Laws




fa >>= pure  === fa

pure a >>= f === f a

   (fa >>= f)         >>= g
=== fa >>= (\x -> f x >>= g)
as long as you don’t create monads, let the tooling
use them for you

Plumbing is all about sequential composition

Regular code is mostly regular composition. You only care about
business values. In plumbing code, the actual functions are usually
boring. What matters is the effects (error handling, etc)

Combining effectful values
(compose monadic values)

Combining effectful values
(compose monadic values

Combining effects
(compose monads)

Combining steps separately is nice, but usually I don’t have
only one effect
async + DI + http errors

Can I compose them willy-nilly?

Monads don’t compose
(in general)



type Compose m n a = m (n a)

(>>=) :: (Monad m, Monad n)
      => Compose m n a
      -> (a -> Compose m n b)
      -> Compose m n b
this function cannot be written. You can try to,
to try to get the intuition of why it’s not possible.
The key is to realize when you’re in this situation and
stop trying (I’ve lost a few hours to this)

Some monads compose
with all monads

We don’t really need to make all monads compose together.
As long as some monads compose with the rest of them

Maybe




(>>=') :: Monad m
       => m (Maybe a)
       -> (a -> m (Maybe b))
       -> m (Maybe b)
this, you can write (by pattern matching on maybe)

Either




(>>=') :: Monad m
       => m (Either e a)
       -> (a -> m (Either e b))
       -> m (Either e b)
this, you can write (by pattern matching on either)

CanIHaz (aka Reader)




(>>=') :: Monad m
       => Reader e (m a)
       -> (a -> Reader e (m b))
       -> Reader e (m b)
this, you can write (by applying the function)

Monad transformers

the key point in all the examples above is that we use specific
properties of the data types to implement bind.
things exposed by Monad are not sufficient.

take an arbitrary monad, and return a new
monad, extended with a specific effect
newtype MaybeT m a =
  MaybeT { runMaybeT :: m (Maybe a) }

instance (Monad m) =>
  Monad (MaybeT m) where
    (>>=) = -- fun exercise
For technical reasons, we need a wrapper to add new behaviours
to composition of values. It also makes the code easier to read


getSocket :: IO (Maybe String)
getSocket = runMaybeT $ do
  host <- MaybeT (lookupEnv "HOST")
  port <- MaybeT (lookupEnv "PORT")
  pure (host <> ":" <> port)
now we have this composition provided, we can compose steps
and effects at the same time
case class OptionT[F[_],A](

    value: F[Option[A]]

  ) {

    def flatMap[B](f: A => OptionT[F,B])
      : OptionT[F,B] = {
        // fun exercise
  }
}
same in scala, we use a wrapper
def lookupEnv(s: String):
  IO[Maybe[String] =
    // todo

def getSocket(): IO[Maybe[String]] = (
  for {
    host <- OptionT(lookupEnv("HOST"))
    port <- OptionT(lookupEnv("PORT"))
  } yield s"${host}:${port}"
).value
code looks the same as in haskell

Combining more than two effects

apply transformers one by one
monad stacks

Example




type Handler a =
  ExceptT Error IO a

type HandlerWithConf a =
  ReaderT Config Handler a
here we combine
- IO
- Http level errors
- Dependency Injection
findUserHandler :: UserId
                -> HandlerWithConf User

findUserHander userId = do
  results <- runDB (findUser userId)
  case results of
    Just u  -> pure u
    Nothing -> throwError err404 -- tbd
runDB requires the DI and IO
the pattern matching requires http error handling
I’ll talk about how we can write throw error later

Example




type Handler[A] =
  ExceptT[Error,IO,A]

type HandlerWithConf[A] =
  ReaderT[Config,Handler,A]
same in scala (with more brackets)
findUserHander(userId: UserId)
  : HandlerWithConf[A]

= for {
  results <- runT(findUser(userId))
  response <- results match {
    case Some(u) => pure(u) //tdb
    case None => throwError(err404) //tbd
  }
} yield response
scala needs a bit more help for pure because of its
poor type inference, but it’s essentially the same

Everything just needs to return HandlerWithConf

Writing handlers is simplified, sure, but everything is tied
to the whole monad stack

“Program to an interface”

Monad stacks are implementation, not interface
Not every function uses all the effects

Making things declarative

there is another way, called MTL-style that allows
to declare the effects you need without committing to
a specific monad stack

Transformer classes

-- for ReaderT
ask :: MonadReader e m => m e

-- for ExceptT
throwError :: MonadError e m => e -> m a

-- for IO
liftIO :: MonadIO m => IO a -> m a
here, it’s FORALL M, such as M behaves like…



-- provided by a library
runDbTransaction :: Transaction a
                 -> Pool
                 -> IO a
let’s say our DB library takes a transaction and runs it
against a connection pool
runDB :: ( Monad m
         , MonadReader Config m
         , MonadIO m)
      => Transaction a
      -> m a

runDB t = do
  pool <- asks dbPool
  liftIO (runDbTransaction t pool)
here we need DI (to get the pool), and IO, to actually
run the transaction

findUser :: ( Monad m
            , MonadIO m
            , MonadReader Config m
            , MonadError Error m
            )
         => UserId
         -> m User
for the whole handler, we also need HTTP error handling

findUser :: MonadEveryThing m
         => UserId
         -> m User

findUser uid = do
  results <- runTransaction $ findUser uid
  case results of
    Just u  -> pure u
    Nothing -> throwError err404
since these are all the effects we talk about,
we can put them in an alias instead of copying them over and over

Transformers as a free* implementation

* conditions may apply

Here the code only talks about interfaces. You have to provide
an implementation that satisfies this interface.

Chosing the interpreter

you can fuse everything in a single type for perf reasons
the way transformers implement the interface generates a lot of
calls, this can have a perf cost (it’s up to you to decide if
this is acceptable or not: tradeoffs, tradeoffs)

Going further

No need to stay constrained to monad transformers
just keep the typeclasses as interface (create your own), provide
the interpreter you want (maybe using monad transformers, maybe not)

Tagless final




class UserRepo m where
  findUser :: UserId -> m (Maybe User)
  listUsers :: m [User]
you can describe your own behaviours, not just standard ones,
and you can provide different interpreters as well
(prod, debug, mock, …)

Designing your stack

Don’t try to put every monad you have in the stack or as a class.

m (Either e a) is fine

MonadError is less powerful than having an explicit either
(by design).

Put in the stack (or in constraints) what really needs to be
common across your application. Trying to fit everything
in the stack will waste your time and over constrain
your application

Closing words

MTL is a powerful style.
If your app / HTTP lib is designed around monad stacks,
it’s a great fit. Pay attention to the perf (but don’t chase perf for the sake of it)
Monad transformers (without MTL) have their uses
inside business code
(but locally, and shouldn’t make it to the signatures)

Don’t panic

This may seem complex, but
- you can assemble stacks as you please
- it looks complex because we looked at the whole thing
(think about how it’s done in your favourite web framework)
- at least, we have types

Read list

Read list

Thanks!

(we're hiring)