Deferring commitments: Tagless Final
When writing services in typed languages, we usually write an interface and an implementation for that interface. We refer to the implementation by its interface. In the services that we write, it’s common to see something like this:
There is yet another level of abstraction we can apply to this interface. This would be abstracting over the effect (Future) in the above example. Before we do that, I would like to show you some sample usage of this repository from a service
Notice here, we make use of the Future’s flatMap and map operation (explicitly and implicitly through for comprehensions) in order to sequence multiple asynchronous operations together. For example, for addForecastByLatLong, we first obtain the current weather at a particular latitude and longitude (which is an asynchronous operation) and use the result to run another asynchronous computation to populate the repository.
So remember how we were talking about abstracting over the effect in the repository. If we do this, we still need to be able to execute these flatMaps and maps in order to sequence effectful actions so the service can do its job. We’ll see how to do this in just a minute. Let’s create an Order Repository where we abstract over that effect.
F is a higher kinded type/function at the type level/type level constructor. Examples are Future, Option, List, etc. They are not proper types but if you provide them proper types as arguments, the result will be a proper type. Since that statement was a bit confusing, let’s look at an example. List is a type constructor and Int is a proper type. If I apply Int to List then I get List[Int] which is a proper type. So far, all we have done is used a generic higher kinded type (F) instead of a concrete one (Future).
Now, let’s say we have a service that wants to use this Order Repository, we would end up hitting a wall!
We don’t have the ability to treat F like a Future which has Future.successful, flatMap and map. At this point, you could argue and say, well Order Service could ask for a OrderRepository[Future] and call it a day. This would definitely work but you want to be able to write your functions without being tied to an implementation as much as possible. Doing this gives you the ability to swap out just the repository implementations without changing the service and that’s pretty powerful.
Using typeclasses, we can make demands of the F effect and effectively constrain it to types that have the ability to lift proper types into the F context (like Future.successful), be able to map and flatMap.
A brief introduction to typeclasses
Typeclasses are a form of ad-hoc polymorphism. They allow you to an enrich an existing type with new capabilities. For example, let’s say I wanted to add two Orders together. The source code for an Order isn’t in our control but we would like to extend it. Here’s one way we can do it.
The syntax is a bit clunky but with the use of Scala’s implicits, we can create a much slicker API.
This is an example of a typeclass method. Notice that in order to use the add method, your A’s need to have a typeclass implementation of Addable[A]. So for example, if we wanted add to add our two Orders then we must have an Addable[Order] implementation. The implicit keyword is used to pull that Addable[Order] implementation in automatically without you having to provide it explicitly. So in order to use this, we can do a little something like this
There’s a more succinct syntax called the Context Bound that can be used for the add function. That looks like this:
If you don’t plan to use the typeclass directly and want to pass it through, this is a good option.
Now that you have an idea of how to make demands to types that want to use your functions by means of typeclass constraints we are one step closer to solving the problem.
Using typeclasses to place constraints
Previously, we saw that Future made use of flatMap, map and successful (also known as pure). There just happens to be a formal typeclass that we can pull from Category Theory known as the Monad. If you look at the Monad typeclass in functional libraries like Cats, you’ll see that in order to have a typeclass implementation of Monad, you essentially need to implement flatMap, map and pure. Whilst our Addable typeclass abstracted over proper types like Int and Order, the Monad typeclass abstracts over higher kinded types like Future, List, Option, etc.
There are Monad laws that you need to adhere to in order for a higher kinded type to have a Monad implementation (left identity, right identity and associativity) but I won’t go into that here because that’s an entire topic in itself.
“Constraints Liberate, Liberties Constrain” — Runar Bjarnason
The main idea is that you can constrain the F effect to have a Monad typeclass implementation so we can make use of flatMap, map and pure.
We use the Monad typeclass from the functional library Cats. I just want to emphasize that we’re saying here that F has a Monad typeclass implementation and this is why we are able to use flatMap, map and pure. The code above is actually quite verbose. There is an alternate form known as interface syntax which augments the F effect so it appears as if flatMap and map can be invoked on instances of F[A] but under the hood it’s using implicit classes to make this happen. All you do is import cats.syntax.all._ and it brings a set of implicit classes into scope which enrich any instance of a type that has a Monad implementation and will allow you to invoke flatMap, map and pure. Since we no longer explicitly use the monad instance anymore, we use the more succinct context bound syntax to perform the typeclass constraint on F.
So far, we have written a Service that makes use of a Repository. The Service is abstract in that it expects that the Repository’s effect (F) has a Monad typeclass constraint which gives us the ability to sequence effectful operations in order to provide business functionality built atop the Repository. The real power of tagless final is that you can implement different OrderRepository’s with different Fs (as long as they have Monad implementations) and you can easily swap out different implementations and continue to have the business functionality you built atop the repository continue to work.
A simple in-memory implementation
For example, here’s a simple in-memory implementation of the Order Repository using the Id Monad.
Let’s see how we can wire this up to our service
Now you may be wondering where the Monad implementation for the Id effect is. I apologize for not showing this here. I have an import pulling that in. I’ll include the source code at the end so you can play around with the code yourself.
A DynamoDB Future based implementation
Feel like moving to a NoSQL database? How about an OrderRepository that uses DynamoDB that makes use of the Future Monad.
Here’s how you wire it up to the service
Again, I apologize for not showing the import. You need to pull in cats.instances.future._ and have an Execution Context in scope to bring the Monad[Future] implementation into scope to meet the constraints. As you can see, this is really similar to the in-memory implementation. The only slight difference is now you start to deal with some more intricacies behind the effect you decided to pick.
A SQL Monix Task based implementation
NoSQL, not your thing? How about a SQL implementation based on Slick with Monix Task for lazy operations
Here’s how you wire this implementation up
Again, just some database setup followed by the standard usage. Here I bring in monix.cats._ to get a Monad[Task] implementation.
We have to write a very minimal amount of code and we don’t need to make ripples through the entire codebase because of the level of abstraction we have selected. This makes it easy to contrast different implementations since we commit to an implementation right at the end of the program but we ensure that we stay abstract throughout the rest of the program. By placing typeclass constraints on the service, we are able to demand more capabilities from the effect (like flatMap, map and pure when we selected Monad).
A word on constraints and granularity
There are more powerful constraints available like MonadError where an effect can encapsulate errors (think Future and Task) or MonadFilter if you want to use flatMap, map and filter. You don’t need to place the constraint at the class level like we did above with OrderService. You can also place constraints at the function level.
Here fetchOrders can be done in parallel so we ask for an Applicative constraint but updateOrder requires sequencing so we ask for a Monad constraint. As you can see, you can pick and choose constraints at a granular level. Of course, the effect used by the repository needs to meet these constraints that are imposed by the service functions in order to be wired up correctly.
The approach we have taken to model our repository and service is known as ‘Tagless Final’ or ‘Finally Tagless’. The main purpose of this technique is to keep the implementation details away from your business logic. This makes evolutions smoother especially when you try and swap out your persistence mechanism or you want to contrast different implementations without tearing your codebase apart. It does require you to know about typeclasses but it brings a lot of benefits to the table. It helps you to get a deeper understanding of your system and makes you think about whether you really need to sequence your effectful operations or whether you can actually run them in parallel. One of the competitors to Tagless Final is the Free Monad. Both approaches have their own benefits and drawbacks and it really comes down to your use case and I would encourage you to read more about the two approaches if you plan to use them as there are plenty of great articles online. I hope you found this article useful :-)
tagless-final-example - An example of how to create services using tagless finalgithub.com
I also refer to source code from my other project which you can find here: