Skip to Content
Course content

52: Tagless Final Pattern Overview

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I want to show you something that usually feels like a magic trick when you first see it in a professional codebase, but it's actually just a clever way of avoiding a very specific type of headache. We're talking about the Tagless Final pattern. Now, if you search for this online, you'll find a lot of academic papers and category theory. Let's ignore all that. Instead, let's just try to build a simple User Profile service and see where we get stuck.

The "Just Get It Done" Approach

Normally, if I need to fetch a user from a database and log the action, I'd just write something like this. I'll use Future because it's built-in and familiar.

case class User(id: Long, name: String)

class UserRepository {
  def findUser(id: Long): Future[User] = {
    // Imagine a real DB call here
    Future.successful(User(id, "Alice"))
  }
}

class UserService(repo: UserRepository) {
  def getUserGreeting(id: Long): Future[String] = {
    repo.findUser(id).map(user => s"Hello, ${user.name}!")
  }
}


This looks fine, right? It's simple. But here is where I usually start rubbing my temples. If I want to write a unit test for UserService, I'm now forced to deal with Future. I have to use Await.result or ScalaTest's async wrappers. Even worse, my business logic is now hard-coded to an asynchronous effect. What if I wanted to run this in a synchronous environment for a CLI tool? Or what if I wanted to switch from Future to ZIO or Cats Effect IO for better cancellation and error handling? I'd have to rewrite every single method signature in the entire app.

Trying a Basic Trait

My first instinct is usually to just hide the implementation behind a trait. Let's try that.

trait UserRepository {
  def findUser(id: Long): Future[User]
}

class LiveUserRepository extends UserRepository {
  def findUser(id: Long): Future[User] = Future.successful(User(1, "Alice"))
}


I've decoupled the implementation, but I haven't decoupled the effect. I'm still returning a Future. I'm still trapped in "Future-land." To truly be flexible, I need a way to say: "This service returns *some* kind of wrapper, and I don't care what it is, as long as I can map over it."

Introducing the Generic Effect

This is where we move toward Tagless Final. Instead of picking Future, I'm going to use a type parameter F[_]. I'm basically telling Scala, "I'm going to use a container F, and I'll tell you what F actually is later."

trait UserRepository[F[_]] {
  def findUser(id: Long): F[User]
}

// Now my service is also generic
class UserService[F[_]](repo: UserRepository[F]) {
  // Wait... I can't use .map here anymore!
  // def getUserGreeting(id: Long): F[String] = 
  //   repo.findUser(id).map(user => s"Hello, ${user.name}!")
}


I just hit a wall. Because F is just a generic type, the compiler has no idea that F supports map or flatMap. It could be List, it could be Option, or it could be some random class I wrote that does nothing. I need to constrain F to something that behaves like a Monad.

The Final Piece: The Monad Constraint

To fix this, I'll use cats.Monad. I don't want to inherit from it; I just want to require that there is an instance of a Monad available for whatever F ends up being. This is the "Tagless" part—we aren't using a specific "tag" or data type to represent our operations; we're using the capabilities of the effect.

import cats.Monad
import cats.implicits._

trait UserRepository[F[_]] {
  def findUser(id: Long): F[User]
}

class UserService[F[_]](repo: UserRepository[F])(implicit M: Monad[F]) {
  def getUserGreeting(id: Long): F[String] = {
    for {
      user <- repo.findUser(id)
    } yield s"Hello, ${user.name}!"
  }
}


Now look at how powerful this is. I can create a "Live" version and a "Test" version without changing a single line of business logic.

// The Production implementation (using Future)
class LiveUserRepository extends UserRepository[Future] {
  def findUser(id: Long): Future[User] = Future.successful(User(1, "Alice"))
}

// The Test implementation (using Id - a synchronous identity wrapper)
import cats.Id
class TestUserRepository extends UserRepository[Id] {
  def findUser(id: Long): Id[User] = User(1, "TestUser")
}


I've successfully separated what the program does (the algebra) from how it does it (the interpreter). The UserService is now completely agnostic. It just knows it's working with some F` that follows the Monad laws. I can swap Future for IO, ZIO, or Id just by changing which implementation I inject at the edge of my application.




📋 Practical Task

Build a Tagless Final Payment Processor

You need to implement a simple payment system that is decoupled from its execution effect. Follow these requirements:

  • Create a trait PaymentAlgebra[F[_]] with two methods:
    • processPayment(amount: Double): F[Boolean]
    • getBalance(userId: String): F[Double]
  • Create a class PaymentService[F[_]] that takes a PaymentAlgebra[F] and an implicit Monad[F].
  • Implement a method in PaymentService called securePayment(userId: String, amount: Double): F[String].
    • It should first check the balance.
    • If the balance is greater than or equal to the amount, it should call processPayment.
    • If payment is successful, return "Payment Successful".
    • Otherwise, return "Insufficient Funds or Processor Error".
  • Create a MockPaymentAlgebra that implements PaymentAlgebra[Id] (using cats.Id) for synchronous testing, returning a hardcoded balance of 100.0 and always returning true for payment processing.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.