Skip to Content
Course content

119: ZIO Layers for Dependency Injection

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

I've been thinking about how we handle dependencies in our Scala apps. In the past, we probably just used constructor injection—passing an instance of a database client into a service, and that service into a controller. It works fine for a small project, but once you hit twenty or thirty services, you end up with a "main" method that is basically just a giant, fragile list of new ServiceA(new RepoA(new Client(config)), new Logger()). It's a nightmare to maintain.

The Manual Wiring Headache

Let's look at a simple example. I want to build a UserGreetingService that depends on a UserRepository to fetch a user's name. If I do this the "old way," it looks like this:

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

trait UserRepository {
  def findUser(id: Int): Task[User]
}

class LiveUserRepository extends UserRepository {
  def findUser(id: Int): Task[User] = ZIO.succeed(User(1, "Alice"))
}

class UserGreetingService(repo: UserRepository) {
  def greet(id: Int): Task[String] = 
    repo.findUser(id).map(u => s"Hello, ${u.name}!")
}

// Wiring it up manually
val repo = new LiveUserRepository()
val service = new UserGreetingService(repo)
val program = service.greet(1)

This is straightforward, right? But here is the problem: UserGreetingService isn't a ZIO effect; it's just a class. If I want to use ZIO's environment for things like configuration or logging later, I'm still stuck manually threading these dependencies through every single constructor. I want the environment to hold my services, not my constructors.

Letting ZIO Manage the Requirements

I'll try to shift the dependency into the ZIO environment. Instead of a class constructor, I'll use ZIO.service[UserRepository]. This tells ZIO: "I don't have the repo right now, but I expect whoever runs this effect to provide one."

case class GreetingService(repo: UserRepository) {
  def greet(id: Int): ZIO[Any, Throwable, String] = 
    ZIO.serviceWithZIO[UserRepository] { r => 
      r.findUser(id).map(u => s"Hello, ${u.name}!")
    }
}

// Wait, I can't just call this. I need a way to define the service as a requirement.
// Let's define the service itself as a trait so it can live in the environment.
trait GreetingService {
  def greet(id: Int): Task[String]
}

object GreetingService {
  def live(repo: UserRepository): GreetingService = new GreetingService {
    def greet(id: Int): Task[String] = 
      ZIO.serviceWithZIO[UserRepository](_.findUser(id))
        .map(u => s"Hello, ${u.name}!")
  }
}

Now I've hit a wall. I've defined how the service works, but if I try to run a program that requires GreetingService, ZIO will scream at me because the environment is empty. I can't just pass the service in the constructor anymore; I need to "provide" it to the ZIO runtime.

Filling the Gap with ZLayer

This is where ZLayer comes in. Think of a Layer as a blueprint for creating a service. It's not the service itself, but a description of how to build it and what it needs to get there.

I'll start by creating a simple layer for the repository. Since the repo doesn't have any dependencies, I can use ZLayer.succeed:

val repoLayer: ZLayer[Any, Throwable, UserRepository] = 
  ZLayer.succeed(new LiveUserRepository())

Now, the GreetingService is trickier because it needs a UserRepository. I can't use succeed here. I need to tell ZIO: "Take a UserRepository from the environment and use it to create a GreetingService."

val greetingLayer: ZLayer[UserRepository, Throwable, GreetingService] = 
  ZLayer.fromFunction(GreetingService.live(_))

Check out the types there. ZLayer[UserRepository, Throwable, GreetingService]. This is a dependency graph. It says: "If you give me a UserRepository, I can give you a GreetingService."

Handling the Dependency Chain

Now I have two layers, but my application needs both. If I try to provide just the greetingLayer, it will fail because the repository is missing. I need to compose them. ZIO provides the ++ operator for this, which effectively merges layers together.

val appLayer = repoLayer ++ greetingLayer

val program = ZIO.serviceWithZIO[GreetingService](_.greet(1))

// Now we provide the combined layer to the runtime
appLayer.provide(program).run

Wait, let's look at that ++ logic again. When I do repoLayer ++ greetingLayer, ZIO looks at the requirements of the second layer (the UserRepository) and sees that the first layer provides exactly that. It wires them together automatically. This is the "magic" of DI in ZIO: I've stopped thinking about when to instantiate classes and started thinking about what the system requires to function.

If I wanted to swap the LiveUserRepository for a MockUserRepository during testing, I don't have to touch the GreetingService or the program logic. I just swap the repoLayer at the very edge of the application.




📋 Practical Task

Build a Layered Order Processing System

You need to implement a small order system with three components. Your task is to define the traits, the live implementations, and the ZLayers to wire them together.

  • PaymentGateway: A service with a method processPayment(amount: Double): Task[Boolean].
  • OrderRepository: A service with a method saveOrder(orderId: String): Task[Unit].
  • OrderService: A service with a method placeOrder(id: String, amount: Double): Task[String]. This service should depend on both the PaymentGateway and the OrderRepository. It should return "Order Placed" only if the payment is successful and the order is saved.

Requirements:

  1. Define ZLayer instances for the PaymentGateway and OrderRepository using ZLayer.succeed.
  2. Define a ZLayer for the OrderService using ZLayer.fromFunction that takes both dependencies as arguments.
  3. Compose these layers using the ++ operator.
  4. Write a ZIO program that uses ZIO.serviceWithZIO[OrderService] to place an order and provide the composed layer to the program.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.