Skip to Content
Course content

35: Introduction to ZIO or Cats Effect

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

Up until now, we've been writing Scala code that executes immediately. You call a method, it does something, it returns a value. But in high-scale production systems, "just doing it" is dangerous. We need a way to describe what should happen without actually triggering the side effect until the very last moment. That's where ZIO and Cats Effect come in.

I've used both, but for this lesson, I'm going to use ZIO. I find its type signature—ZIO[R, E, A]—to be a bit more intuitive when you're first starting out. It basically tells you: "I need these requirements (R), I might fail with this error (E), and if I succeed, I'll give you this value (A)."

Let's build a small "User Wallet Service" that fetches a balance from a database and updates it. We'll simulate the database to keep things focused.

Describing the balance fetch

First, I don't want to just return a Double. If I do that, the code executes the moment I call the function. Instead, I'll wrap the logic in ZIO.attempt. This tells ZIO: "Here is a piece of code that might throw an exception; please wrap it in an effect."

case class User(id: String, balance: Double)

def fetchBalance(userId: String): ZIO[Any, Throwable, User] = {
  ZIO.attempt {
    println(s"Fetching balance for $userId...") 
    // Simulating a DB hit
    if (userId == "unknown") throw new Exception("User not found!")
    User(userId, 100.0)
  }
}

Notice the return type. It's not a User; it's a ZIO effect that eventually produces a User. It's like a blueprint for a building rather than the building itself.

Chaining operations together

Now, let's say we want to deduct some money. In standard Scala, you'd just call one function after another. In ZIO, we use flatMap to sequence these blueprints.

def spendMoney(userId: String, amount: Double): ZIO[Any, Throwable, Double] = {
  fetchBalance(userId).flatMap { user =>
    if (user.balance < amount) {
      ZIO.fail(new Exception("Insufficient funds!"))
    } else {
      ZIO.attempt {
        val newBalance = user.balance - amount
        println(s"Updated balance to $newBalance")
        newBalance
      }
    }
  }
}

I'm using flatMap here because the second operation (spending) depends on the result of the first (fetching). If fetchBalance fails, ZIO is smart enough to skip the flatMap entirely and jump straight to the error handler.

The "Nothing is Happening" mistake

Here is where I almost tripped up when I first started with ZIO. I wrote my logic, I called my method, and then I ran the program... and nothing happened. No prints, no errors, just a blank console. Why?

// My mistake:
object WalletApp extends ZIOAppDefault {
  def run = {
    spendMoney("user123", 20.0) // I just called the method!
    ZIO.unit 
  }
}

I forgot that ZIO is lazy. Calling spendMoney doesn't execute the logic; it just creates the blueprint. Because I didn't "return" that blueprint to the ZIO runtime, the program finished immediately without ever running the effect. I fixed it by making sure the effect is the actual return value of the run method:

object WalletApp extends ZIOAppDefault {
  def run = {
    spendMoney("user123", 20.0) // Now the runtime knows to execute this
  }
}

Taming the error channel

Using Throwable as our error type is a bit lazy. In a real project, I want specific errors. I can change the E in ZIO[R, E, A] to a custom sealed trait. This makes the compiler force me to handle every possible failure case, which is a lifesaver in production.

sealed trait WalletError
case object UserNotFound extends WalletError
case object InsufficientFunds extends WalletError

def spendMoneySafe(userId: String, amount: Double): ZIO[Any, WalletError, Double] = {
  fetchBalance(userId)
    .mapError(_ => UserNotFound) // Convert Throwable to our domain error
    .flatMap { user =>
      if (user.balance < amount) ZIO.fail(InsufficientFunds)
      else ZIO.succeed(user.balance - amount)
    }
}

By switching to ZIO.succeed and ZIO.fail, we've moved away from throwing exceptions and into a world where errors are just part of the type system. It's much more predictable.




📋 Practical Task

Build a Simulated Temperature Alert System

Your task is to create a small ZIO program that simulates a temperature monitoring system. You need to build the following:

  • A readTemperature effect that returns a ZIO[Any, Throwable, Double]. It should randomly return a temperature between 0 and 100.
  • A checkAlert effect that takes the temperature. If the temperature is above 35.0, it should return a ZIO.fail with a custom OverheatingError. Otherwise, it should return ZIO.succeed with a "Temperature OK" message.
  • A ZIOAppDefault that chains these two effects together.
  • Implement error handling using .catchAll` or `.orElse` so that if an OverheatingError occurs, the program prints "ALERT: System overheating!" instead of crashing with a stack trace.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.