Skip to Content
Course content

74: Resource Management with Cats Effect

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

We've all been there: you're debugging a production leak, and you realize some developer—maybe even you, six months ago—forgot to close a database connection or a file handle in one specific error path. In a simple synchronous app, a try-finally block is usually enough. But once we move into the world of Cats Effect and asynchronous fibers, managing the lifecycle of a resource becomes a lot trickier than just wrapping a block of code.

The Try-Finally Trap

Let's look at a typical scenario. Imagine you're building a UserRepository that needs a database connection. In a naive implementation, you might do something like this:

def getUser(id: UserId): IO[User] = {
  for {
    conn <- IO(Database.connect())
    user <- IO(conn.queryUser(id))
    _    <- IO(conn.close()).handleErrorWith(_ => IO.unit)
  } yield user
}

At first glance, this looks fine. But there's a massive hole here: if conn.queryUser(id) fails, the execution jumps straight to the error handler, and conn.close() is never called. You've just leaked a connection. You could wrap it in a try-finally, but once you start composing multiple resources—say, a database connection and a Redis client—you end up with a "pyramid of doom" of nested blocks that are a nightmare to read and even harder to get right.

Furthermore, in a concurrent environment, a fiber might be canceled. If your fiber is killed while it's waiting for a database response, a standard try-finally inside an IO doesn't always guarantee the cleanup happens exactly when and how you expect across different execution contexts.

Lifting Lifecycles into Resource

This is where Resource comes in. Instead of thinking about "opening and closing," I want you to think about the lifecycle of the object as a first-class value. A Resource is essentially a description of how to acquire something and how to release it, regardless of whether the operation succeeded, failed, or was canceled.

Here is how I'd rewrite that repository logic. We define the resource separately from the logic that uses it:

import cats.effect._

def dbConnection: Resource[IO, Connection] = 
  Resource.make(
    IO(Database.connect()) // Acquisition
  )(conn => IO(conn.close()).println("Connection closed!")) // Release

def getUser(id: UserId): IO[User] = {
  dbConnection.use { conn => 
    IO(conn.queryUser(id))
  }
}

The magic here is in Resource.make. It guarantees that the release block runs no matter what happens inside the use block. If the fiber is canceled, if the database throws an exception, or if everything goes perfectly, the connection is closed. I don't have to remember to call close(); the type system and the runtime handle it for me.

Why Composition Beats Nesting

The real power of this approach shows up when your application grows. Suppose your UserRepository now needs both a Connection and a CacheClient. If you were using try-finally, you'd be nesting blocks again. With Resource, you just compose them using a for-comprehension.

def appResources: Resource[IO, (Connection, CacheClient)] = 
  for {
    conn  <- dbConnection
    cache <- redisCache
  } yield (conn, cache)

// Now you can use both safely
appResources.use { case (conn, cache) =>
  // Your business logic here
}

I love this pattern because it separates dependency management from business logic. The appResources block describes the "world" your application needs to exist in, and use is where the actual work happens. If the redisCache fails to initialize, Cats Effect is smart enough to shut down the dbConnection that was already opened, ensuring you don't leave dangling resources during a partial startup failure. That's a level of robustness that is nearly impossible to implement manually without writing a mountain of boilerplate.




📋 Practical Task

Implement a Safe File-Based Audit Logger

You need to build a simple audit logger that writes events to a file. To ensure the file is properly flushed and closed, you must use Resource.

Requirements:

  • Create a Resource[IO, BufferedWriter] that opens a file named audit.log for writing.
  • Ensure the BufferedWriter is closed correctly, even if an error occurs during writing.
  • Implement a function logEvent(event: String): IO[Unit] that uses this resource to append a line to the file.
  • Test the implementation by attempting to write three lines, but intentionally trigger a failure (e.g., using IO.raiseError) after the second line to verify that the resource is still released.

Hint: You can use java.io.FileWriter wrapped in a java.io.BufferedWriter for the acquisition phase.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.