Composing doobie programs using Cats

type classes to the rescue

Arjun Dhawan
Feb 24 · 3 min read

TL;DR

If you want to combine ConnectionIO programs using |+| syntax:

you need bring an implicit Semigroup in scope through

Introduction: doobie

Doobie is a functional library for Scala/Cats that allows us to write programs to interact with a database using the JDBC API.

ConnectionIO

All such programs are described in the form of ConnectionIO. An example of such a program:

Nice thing about ConnectionIO is that it forms a Monad. It has flatMap which enables us to sequence different ConnectionIO programs.

Transaction

A ConnectionIO has no interpretation in the outside world (it’s a construct having only significance to Doobie) and can therefore not directly be run. To interpret it to a meaningful effect (let’s say, to a ZIO Task) we need a Transactor:

Here the transaction boundary is put around insertProgram1 and insertProgram2. Meaning that if something goes wrong at the database level for either insertProgram1 or insertProgram2, the entire transaction will be rolled back thus guaranteeing consistency in our database.

The problem

The construction of a ConnectionIO program itself might be modeled using another effect. Take the case where a UserService needs to make an http call before knowing what to insert:

See this other example showing how such nesting can arise. If we are dealing with only one such call, there is no issue transforming this into a Task:

But what if we need to perform multiple calls from different services, and want to keep the transaction boundary around the resulting ConnectionIO’s?

A nested for-comprehension? Yikes!

The solution

We seek to achieve an easy way to combine ConnectionIO programs. Semigroup is just the right abstraction for that:

But how do we create a Semigroup for ConnectionIO? Doobie provides us out of the box with a type class instance for cats-effect for ConnectionIO: Async[ConnectionIO]. Async is a Monad, and is actually (being related through the type class hierarchy) also an Apply which is a less powerful Applicative. And Apply has defined a function to give us a Semigroup 😊:

So we can bring any implicit Semigroup[ConnectionIO[A]] in scope by defining

In this case we are not interested in the return values of our ConnectionIO programs. Since ConnectionIO is also a Functor, we can ignore the result value through .void, meaning we can write:

and gone is the nested for-comprehension 😊.
If our ConnectionIO programs return ADT’s which we would like to keep, we can use .widen on Functor to cast to the most common subtype:

Of course we would need a Semigroup instance for ADT.

Closing thoughts

We could also have defined a Semigroup for Free (what ConnectionIO is) instead of Apply. But since Apply is less strict than Free (it has less laws to obey) it is preferable to define the Semigroup for Apply so we can model more behaviors.

Having interpreted ConnectionIO as a Semigroup also better conveys our intent: when ‘smashing’ together ConnectionIO’s we don’t care about the power to control computations based on the previous result (which is the power that flatMap gives). Instead, we want to merely combine ConnectionIO programs, which exactly fits the semantics of Semigroup.

Acknowledgements

Special thanks to Mark de Jong for his insights on the subject.

Arjun Dhawan

Written by

Software Engineer @TomTom

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade