Comparing request handlers in Scotty, Yesod, and Servant

This post compares how to implement a non-trivial request handler in some popular Haskell web frameworks. We also consider integrating with a custom monad stack and doing things outside of IO.

Context

When someone asks on the internet "which Haskell web framework should I use?", popular answers are Scotty, Yesod, or Servant. Each answer gives different arguments and tradeoffs.

On one end of the spectrum, Scotty is easy to start with. It is similar to Ruby's Sinatra or Node.js' Express, which I've used extensively in previous jobs. Therefore, when learning Haskell, Scotty was a natural pick for my first small project. I wanted to learn the language Haskell, not a web framework and its specifics.

I had a positive experience using Scotty for this first project, so I was curious when reading some comment threads that pointed out some of its shortcomings. For example:

Sure, [Scotty] looks simple, but as soon as you try to do anything more flexible or powerful, you need to know how to dig into how monad transformers work. The documentation on extending it isn't great either.

[...]

ActionM and ScottyM are okay, but then you want a ReaderT or you want to forkIO or you want to do a bracket and suddenly you're thrown into a hell of monad transformer Weirdness.

r/haskell/comments/l7q1dx/using_scotty_in_production/

Also:

scotty has basically 0 features, which makes it seem simple, but it's actually rather complex under the hood, and that complexity leaks out as soon as you need to do anything non-trivial (eg make your handlers in something other than IO).

r/haskell/comments/v7ryqt/minimal_web_framework_ie_flask_for_haskell/

I don't mean this to be a criticism of Scotty. Users, including myself, agree it fills a specific space in the Haskell web framework ecosystem.

These fair remarks prompted me to try doing something "non-trivial" with Scotty and see how easy or complicated that would be. Then, I would compare it to doing the same with Yesod, which I've used a little, and Servant, which we use at work.

Of course, there are other Haskell web frameworks out there. But in the interest of time, I chose to limit myself to these three due to their popularity and my familiarity with them.

Some of the other frameworks, which I did not look into here, include:

Non-trivial request handler

To create a "non-trivial" web request handler to use as an example, I thought back at previous real-world web applications I had worked on and what similar things they needed to do.

While certainly not exhaustive, these are the features I decided my example web request handler would need to showcase:

I included the Haskell solution I chose for my test, but there are other options. Also, note that these are general web application features and not specific to Haskell.

Custom monad

The one Haskell-specific thing I wanted my example to demonstrate was using a custom monad. Each framework provides a built-in type (ex: Handler), which we can use to define web request handlers. Instead, we'll replace or combine it with our custom monad (ex: App).

I see this monad as a way to provide "dependency injection" to our handlers for logging, database connection pools, HTTP clients, etc. Accessing such dependencies is frequent in "non-trivial" web applications.

There are different flavors of custom monads used in Haskell applications. One is creating a monad transformer stack (ex: stacking ReaderT, LoggingT, DatabaseT, etc.). Another one that is gaining popularity is the so-called ReaderT design pattern. We'll use the latter in our example. We define our custom monad as:

newtype App a = App
  { unApp :: ReaderT AppEnv IO a
  }

AppEnv holds everything we'll need in our web handlers (configuration values, database connection pool, etc.):

data AppEnv = AppEnv
  { appEnvConfig :: Config,
    appEnvLogFunc :: LogFunc,
    appEnvHttpConfig :: HttpConfig,
    appEnvDbPool :: Pool Connection
  }

Fake cart purchase for a booking site

To illustrate a somewhat realistic request handler, imagine we're implementing the cart purchase functionality for an event booking site. We'll create a single endpoint:

POST /cart/:cartId/purchase

We'll assume the cart is already filled by the user and is stored in the database.

Below is what will happen when a request is made to the purchase endpoint. It covers all of the common web application features listed earlier:

This is what the complete handler looks like, using Servant:

postCartPurchaseHandler :: CartId -> App CartPurchaseResponse
postCartPurchaseHandler cartId = do
  cartStatusMaybe <- getCartStatus cartId
  case cartStatusMaybe of
    Nothing -> do
      logWarn $ "Cart does not exist" :# ["cart_id" .= cartId]
      throwIO $ jsonError err404 "Cart does not exist"
    Just CartStatusPurchased -> do
      logWarn $ "Cart already purchased" :# ["cart_id" .= cartId]
      throwIO $ jsonError err409 "Cart already purchased"
    Just CartStatusLocked -> do
      logWarn $ "Cart locked" :# ["cart_id" .= cartId]
      throwIO $ jsonError err409 "Cart locked"
    Just CartStatusOpen -> do
      withCart cartId $ do
        logInfo $ "Cart purchase starting" :# ["cart_id" .= cartId]
        let action :: App (Either Text (BookingId, PaymentId))
            action = Right <$> concurrently (processBooking cartId) (processPayment cartId)
            handleError :: CartException -> App (Either Text (BookingId, PaymentId))
            handleError (CartException msg) = pure $ Left msg
        result <- catch action handleError
        case result of
          Left msg -> do
            logWarn $ ("Cart purchase failed: " <> msg) :# ["cart_id" .= cartId]
            throwIO $ jsonError err500 ("Cart purchase failed: " <> msg)
          Right (bookingId, paymentId) -> do
            markCartAsPurchased cartId
            logInfo $ "Cart purchase successful" :# ["cart_id" .= cartId]
            pure $
              CartPurchaseResponse
                { cartPurchaseResponseCartId = cartId,
                  cartPurchaseResponseBookingId = bookingId,
                  cartPurchaseResponsePaymentId = paymentId
                }

Key takeaways

You can find the source code for the full example on GitHub. It implements the same web request handler and custom monad based on ReaderT IO in all three frameworks: Scotty, Yesod, and Servant.

I won't dive into the implementations' details and the differences between each framework. That could make for another blog post in itself. Instead, I'll highlight what I took away from this experiment.

Scotty:

Yesod:

Servant:

Further reading

If you'd like to dive deeper into the code of the example, here are a few places to get started:

Wrapping up

I had set out to explore how to implement a non-trivial request handler and integrate with a custom monad App based on ReaderT IO in three popular Haskell web frameworks: Scotty, Yesod, and Servant.

I would say that Servant makes this the easiest since you can write all of your handlers directly in the custom monad App. You then provide a transformation function from App to Servant's Handler. Servant's type-level DSL for defining routes means you get all the request parameters and body as arguments to the handler function. Sending responses is done by returning data from the handler function or throwing an error.

Yesod is a close second. Its Handler type is already equivalent to a ReaderT IO, so you don't need to define a custom App if that's what you want. It also already has useful instances such as MonadLogger, MonadReader, and MonadUnliftIO. This means you generally can use business logic functions directly without using lift or runApp.

Scotty makes it possible to use a custom monad thanks to ActionT. However, as the internet comments from the introduction pointed out, it has some shortcomings. You can't define an instance for MonadUnliftIO, so you must lift any action that uses it. Other instances might be tricky for beginners to write. Also, parsing request parameters and body is slightly more manual than the other two frameworks.

Nevertheless, Scotty is by far the easiest framework to get started with. It doesn't use advanced type system concepts or code generation with Template Haskell. This is why I would still recommend it for people starting out with Haskell.

On parting thoughts, there might be a place for another Haskell web framework in the spirit of Scotty. One that uses basic Haskell concepts but has a few more features out-of-the-box and is easier to integrate with a custom monad. Perhaps such a framework, or something close to it, already exists.

Continue reading