Skip to content
← All insights
Functional Scala15 min read

Tagless-final in Scala: capabilities without the ceremony

A practical account of tagless-final programs, effect capabilities, interpreters and testability—including when a concrete IO or Future service is the better design.

Separate the program from the effect that runs it

Tagless-final code describes required operations in terms of abstract capabilities and an effect type F[_]. The program composes those capabilities without committing to Future, Cats Effect IO or another concrete runtime at the point where the business workflow is defined.

A small algebra—usually a trait parameterised by F—states what the program can do. An interpreter implements the algebra for a concrete effect and infrastructure. The application assembles the interpreter and runs the resulting effect at its boundary.

The value is not the unusual name. It is the ability to keep a workflow dependent on narrow operations while delaying a concrete execution choice. That only helps when the separation corresponds to a real boundary.

Describe the capability the workflow needsscala 2.13 / Cats
trait AccountRepository[F[_]] {
  def find(id: AccountId): F[Option[Account]]
  def save(account: Account): F[Unit]
}

trait Audit[F[_]] {
  def accountRenamed(id: AccountId): F[Unit]
}

final class RenameAccount[F[_]: Monad](
  accounts: AccountRepository[F],
  audit: Audit[F]
) {
  def apply(id: AccountId, name: AccountName): F[Either[RenameError, Account]] = ???
}

Algebras should describe application capabilities

An algebra that mirrors every method of a database driver does not create a useful abstraction; it relocates the driver API. Prefer operations meaningful to the application, such as find account or save payment, with typed inputs and results.

Keep capabilities cohesive. One enormous trait containing persistence, logging, time, configuration and HTTP calls makes every interpreter and test implement unrelated behaviour. Several tiny one-method traits can be equally noisy, so group operations by ownership and change cycle rather than following a rule mechanically.

The algebra is a contract. Returning raw JSON or throwing undocumented exceptions makes the supposedly abstract program depend on details the type does not disclose.

Model the application operation, not the database APIscala 2.13
trait AccountRepository[F[_]] {
  def find(id: AccountId): F[Option[Account]]
  def save(account: Account): F[Unit]
}

// Avoid leaking a generic document query language through
// the application merely to make the wrapper reusable.

Typeclass constraints state how the program composes

A workflow needs evidence for the operations it performs on F. Monad supplies pure, map and flatMap for dependent sequencing. A more specific Cats Effect capability may be required for errors, time, concurrency or resource management.

Ask for the weakest capability that accurately describes the program, but do not split a readable signature into a long list of constraints for theoretical purity. The chosen constraints are part of the API and should help a reader understand what execution features are available.

Business calculations that are already pure do not need F. Keep them as ordinary functions and lift only their results where composition requires it. This leaves more logic testable without an effect runtime.

Keep pure decisions outside the effectscala 2.13 / Cats
def rename(account: Account, name: AccountName): Either[RenameError, Account] =
  if (account.isClosed) Left(RenameError.AccountClosed)
  else Right(account.copy(name = name))

def program[F[_]: Monad](
  repository: AccountRepository[F],
  id: AccountId,
  name: AccountName
): F[Either[RenameError, Account]] =
  repository.find(id).map {
    case None          => Left(RenameError.NotFound(id))
    case Some(account) => rename(account, name)
  }

Interpreters own operational behaviour

An IO interpreter can use a database-backed repository and describe blocking work with IO.blocking. A test interpreter can use an in-memory state. Both satisfy the same algebra, but they are not assumed to have identical latency, consistency or concurrency behaviour.

Resource acquisition, retries, logging and transaction boundaries belong where the required operational context is available. Hiding them inside generic business functions makes the program look more portable than it really is.

Tests against an in-memory interpreter prove workflow decisions, not production database semantics. Keep integration tests for the real interpreter, including serialisation, queries, indexes and failure behaviour.

One concrete interpreter for IOscala / Cats Effect
final class MongoAccountRepository(
  collection: AccountCollection
) extends AccountRepository[IO] {
  def find(id: AccountId): IO[Option[Account]] =
    IO.fromFuture(IO(collection.find(id)))

  def save(account: Account): IO[Unit] =
    IO.fromFuture(IO(collection.save(account))).void
}

Testing can use values without mocking every call

A small stateful or deterministic interpreter can exercise a workflow across several operations without configuring an interaction script for every method. The test can assert the returned value and final state, which is usually closer to the behaviour that matters.

This is not a reason to create an interpreter solely to avoid Mockito. If a concrete service has one dependency and one effect type, a focused stub may be simpler. Tagless-final pays off when several interpreters or reusable effect-polymorphic programs are genuine requirements.

Keep laws or shared contract tests for interpreters when consistency matters. Otherwise two implementations can satisfy the same method signatures while disagreeing about missing records, duplicate writes or error handling.

A small deterministic test interpreterscala 2.13 / Cats
import cats.Id

final class InMemoryAccounts(
  initial: Map[AccountId, Account]
) extends AccountRepository[Id] {
  private var state = initial

  def find(id: AccountId): Option[Account] = state.get(id)
  def save(account: Account): Unit =
    state = state.updated(account.id, account)

  def current: Map[AccountId, Account] = state
}

Concrete effects are often the honest choice

A Play service returning Future may be entirely appropriate when it will never run under another effect and its team understands that model. A Cats Effect application written directly in IO can still be modular, testable and carefully designed.

Warning signs include F[_] threaded through every data type, many algebras with one production interpreter, difficult compiler errors and tests that exist mainly to prove wiring. Abstraction has a carrying cost in signatures, imports, inference and onboarding.

Adopt tagless-final at a boundary where capability abstraction produces leverage. Keep the rest concrete until a second execution model, reusable module or testing requirement demonstrates the need.

  • Does the algebra express an application capability?
  • Is effect polymorphism useful to more than a hypothetical caller?
  • Are pure decisions still plain functions?
  • Are production interpreter semantics integration-tested?
  • Would direct IO or Future make the module easier to own?
  • Can the team diagnose the resulting types in production code?