View source on Github

With all of the talk I've had about breaking changes in my libraries, I definitely didn't want the Yesod world to feel left out. We've been stable at yesod-core version 1.4 since 2014. But the changes going through my package ecosystem towards MonadUnliftIO are going to affect Yesod as well. The question is: how significantly?

For those not aware, MonadUnliftIO is an alternative typeclass to both MonadBaseControl and the MonadCatch / MonadMask classes in monad-control and exceptions , respectively. I've mentioned the advantages of this new approach in a number of places, but the best resource is probably the release announcement blog post.

At the simplest level, the breaking change in Yesod would consist of:

Modifying WidgetT 's internal representation. This is necessary since, currently, it's implemented as a WriterT . Instead, to match with MonadUnliftIO , it needs to be a ReaderT holding an IORef . This is just about as minor a breaking change as I can imagine, since it only affects internal modules. (Said another way: it could even be argued to be a non-breaking change.)

's internal representation. This is necessary since, currently, it's implemented as a . Instead, to match with , it needs to be a holding an . This is just about as minor a breaking change as I can imagine, since it only affects internal modules. (Said another way: it could even be argued to be a non-breaking change.) Drop the MonadBaseControl and MonadCatch / MonadMask instances. This isn't strictly necessary, but has two advantages: it allows reduces the dependency footprint, and further encourages avoiding dangerous behavior, like using concurrently with a StateT on top of HandlerT .

and / instances. This isn't strictly necessary, but has two advantages: it allows reduces the dependency footprint, and further encourages avoiding dangerous behavior, like using with a on top of . Switch over to the new versions of the dependent libraries that are changing, in particular conduit and resourcet. (That's not technically a breaking change, but I typically consider dropping support for a major version of a dependency a semi-breaking change.)

A number of minor cleanups that have been waiting for a breaking changes. This includes things like adding strictness annotations in a few places, and removing the defunct GoogleEmail and BrowserId modules.

This is a perfectly reasonable set of changes to make, and we can easily call this Yesod 1.5 (or 2.0) and ship it. I'm going to share one more slightly larger change I've experimented with, and I'd appreciated feedback on whether it's worth the breakage to users of Yesod.

Away with transformers!

NOTE All comments here, as is usually the case in these discussions, refer to code that must be in IO anyway. Pure code gets a pass.

You can check out the changes (which appear larger than they actually are) in the no-transformers branch. You'll see shortly that that's a lie, but it does accurately indicate intent. If you look at the pattern of the blog posts and recommended best practices I've been discussing for the past year, it ultimately comes down to a simple claim: we massively overuse monad transformers in modern Haskell.

The most extreme response to this claim is that we should get rid of all transformers, and just have our code live in IO . I've made a slight compromise to this for ergonomics, and decided it's worth keeping reader capabilities, because it's a major pain (or at least perceived major pain) to pass extra stuff around for, e.g., simple functions like logInfo .

The core data type for Yesod is HandlerT , with code that looks like getHomeR :: HandlerT App IO Html . Under the surface, HandlerT looks something like:

newtype HandlerT site m a = HandlerT (HandlerData site -> m a)

Let's ask a simple question: do we really need HandlerT to be a transformer? Why not simply rewrite it to be:

newtype HandlerFor site a = HandlerFor (HandlerData site -> IO a)

All we've done is replaced the m type parameter with a concrete selection of IO . There are already assumptions all over the place that your handlers will necessarily have IO as the base monad, so we're not really losing any generality. But what we gain is:

Slightly clearer error messages

Less type constraints, such as MonadUnliftIO m , floating around

, floating around Internally, this actually simplifies quite a few ugly things around weird type families

We can also regain a lot of backwards compatibility with a helper type synonym:

type HandlerT site m = HandlerFor site

Plus, if you're using the Handler type synonym generated by the Template Haskell code, the new version of Yesod would just generate the right thing. Overall, this is a slight improvement, and we need to weigh the benefit of it versus the cost of breakage. But let me throw one other thing into the mix.

Handling subsite (yes, transformers)

I lied, twice: the new branch does use transformers, and HandlerT is more general than HandlerFor . In both cases, this has to do with subsites, which have historically been a real pain to write (using them hasn't been too bad). In fact, the entire reason we have HandlerT today is to try and make subsites work in a nicely layered way (which I think I failed at). Those who have been using Yesod long enough likely remember GHandler as a previous approach for this. And anyone who has played with writing a subsite, and the hell which ensues when trying to use defaultLayout , will agree that the situation today is not great.

So cutting through all of the crap: when writing a subsite, almost everything is the same as writing normal handler code. The following differences pop up:

When you call getYesod , you get the master site's app data (e.g. App in a scaffolded site). You need some way to get the subsite's data as well (e.g., the Static value in yesod-static ).

, you get the master site's app data (e.g. in a scaffolded site). You need some way to get the subsite's data as well (e.g., the value in ). When you call getCurrentRoute , it will give you a route in the master site. If you're inside yesod-auth , for instance, you don't want to deal with all of the possible routes in the parent, but instead get a route for the subsite itself.

, it will give you a route in the master site. If you're inside , for instance, you don't want to deal with all of the possible routes in the parent, but instead get a route for the subsite itself. If I'm generated URLs, I need some way to convert the routes for a subsite into the parent site.

In today's Yesod, we provide these differences inside the HandlerT type itself. This ends up adding some weird complications around special-casing the base (and common) case where m is IO . Instead, in the new branch, we have just one layer of ReaderT sitting on top of HandlerFor , providing these three pieces of functionality. And if you want to get a better view of this, check out the code.

What to do?

Overall, I think this design is more elegant, easier to understand, and simplifies the codebase. In reality, I don't think it's either a major departure from the past, or a major improvement, which is what leaves me on the fence about the no transformer changes.

We're almost certainly going to have a breaking change in Yesod in the near future, but it need not include this change. If it doesn't, the breaking change will be the very minor one mentioned above. If the general consensus is in favor of this change, then we may as well throw it in at the same time.