ZIO Environment and Dependency Injection
The Problem with Dependencies
Consider a typical application:
class UserService(database: Database, logger: Logger) {
def getUser(id: Int): User = {
logger.info(s"Fetching user $id")
database.findUser(id)
}
}
// Creating the service is painful
val database = new PostgresDatabase(url, user, password)
val logger = new ConsoleLogger()
val userService = new UserService(database, logger)
Problems:
- Manual wiring: You wire dependencies by hand
- Testing is hard: Need to create real instances or complex mocks
- Tight coupling: Services know concrete implementations
- Order matters: Must create dependencies in the right order
How does ZIO solve this? Through the Environment parameter (R in ZIO[R, E, A]).
What is the ZIO Environment?
The R in ZIO[R, E, A] represents what this effect needs to run:
// Needs nothing
ZIO[Any, Error, User]
// Needs a Database
ZIO[Database, Error, User]
// Needs both Database and Logger
ZIO[Database & Logger, Error, User]
Think of it as dependency injection in the type system. The compiler ensures you provide everything an effect needs before it can run.
Understanding the R Parameter
Any - Needs Nothing
val simple: ZIO[Any, Nothing, Int] = ZIO.succeed(42)
// Can run anywhere, requires no environment
Single Dependency
trait Database {
def findUser(id: Int): Task[User]
}
val effect: ZIO[Database, Throwable, User] =
ZIO.serviceWithZIO[Database](_.findUser(123))
// Requires a Database to run
Multiple Dependencies
val effect: ZIO[Database & Logger, Throwable, User] = for {
_ <- ZIO.serviceWithZIO[Logger](_.info("Fetching user"))
user <- ZIO.serviceWithZIO[Database](_.findUser(123))
} yield user
// Requires both Database and Logger
Accessing Services from the Environment
ZIO.service - Get the Service
trait Logger {
def info(message: String): UIO[Unit]
}
// Get the entire service
val getLogger: URIO[Logger, Logger] =
ZIO.service[Logger]
// Use it
val program = for {
logger <- ZIO.service[Logger]
_ <- logger.info("Hello!")
} yield ()
ZIO.serviceWith - Use the Service Directly
// Better: use the service immediately
val program: URIO[Logger, Unit] =
ZIO.serviceWith[Logger](logger => logger.info("Hello!"))
ZIO.serviceWithZIO - Chain Effects
// Best: when the service method returns an effect
val program: URIO[Logger, Unit] =
ZIO.serviceWithZIO[Logger](_.info("Hello!"))
Which accessor would you use to log a message and continue with other operations?
Creating Services
Define a Service Trait
trait UserRepository {
def getUser(id: Int): Task[User]
def saveUser(user: User): Task[Unit]
def listUsers(): Task[List[User]]
}
Implement the Service
case class UserRepositoryLive(database: Database) extends UserRepository {
def getUser(id: Int): Task[User] =
ZIO.attempt {
database.query(s"SELECT * FROM users WHERE id = $id")
}
def saveUser(user: User): Task[Unit] =
ZIO.attempt {
database.execute(s"INSERT INTO users VALUES (${user.id}, '${user.name}')")
}
def listUsers(): Task[List[User]] =
ZIO.attempt {
database.queryAll("SELECT * FROM users")
}
}
Service Companion Object Pattern
object UserRepository {
// Accessor methods for convenience
def getUser(id: Int): ZIO[UserRepository, Throwable, User] =
ZIO.serviceWithZIO[UserRepository](_.getUser(id))
def saveUser(user: User): ZIO[UserRepository, Throwable, Unit] =
ZIO.serviceWithZIO[UserRepository](_.saveUser(user))
def listUsers(): ZIO[UserRepository, Throwable, List[User]] =
ZIO.serviceWithZIO[UserRepository](_.listUsers())
}
Now you can use it elegantly:
val program = for {
users <- UserRepository.listUsers()
_ <- ZIO.foreach(users)(user => Console.printLine(user.name))
} yield ()
Introduction to ZLayer
ZLayer is how you construct and provide services to effects.
ZLayer[RIn, E, ROut]
- RIn: What this layer needs to be built
- E: How construction can fail
- ROut: What service this layer provides
Think of ZLayer as a recipe for creating a service.
Creating Simple Layers
trait Logger {
def info(message: String): UIO[Unit]
}
case class ConsoleLogger() extends Logger {
def info(message: String): UIO[Unit] =
Console.printLine(message).orDie
}
// Create a layer that provides Logger
val loggerLayer: ZLayer[Any, Nothing, Logger] =
ZLayer.succeed(ConsoleLogger())
Layers with Dependencies
trait Database {
def query(sql: String): Task[ResultSet]
}
trait UserRepository {
def getUser(id: Int): Task[User]
}
case class UserRepositoryLive(database: Database) extends UserRepository {
def getUser(id: Int): Task[User] =
database.query(s"SELECT * FROM users WHERE id = $id").map(parseUser)
}
// This layer needs a Database to provide UserRepository
val userRepoLayer: ZLayer[Database, Nothing, UserRepository] =
ZLayer.fromFunction(UserRepositoryLive.apply _)
ZLayer Constructors
// From a simple value
val layer1 = ZLayer.succeed(ConsoleLogger())
// From a function of dependencies
val layer2 = ZLayer.fromFunction((db: Database) => UserRepositoryLive(db))
// From an effect that creates the service
val layer3 = ZLayer.fromZIO {
for {
config <- loadConfig()
db <- connectDatabase(config)
} yield db
}
// With resource management (acquire/release)
val layer4 = ZLayer.scoped {
ZIO.acquireRelease(openConnection())(conn => closeConnection(conn))
}
Providing Dependencies
Using provide
val effect: ZIO[Logger, Nothing, Unit] =
ZIO.serviceWithZIO[Logger](_.info("Hello"))
val loggerLayer: ZLayer[Any, Nothing, Logger] =
ZLayer.succeed(ConsoleLogger())
// Provide the layer to the effect
val program: ZIO[Any, Nothing, Unit] =
effect.provide(loggerLayer)
// Now it can run!
Providing Multiple Dependencies
val effect: ZIO[Database & Logger, Throwable, User] = for {
_ <- ZIO.serviceWithZIO[Logger](_.info("Fetching user"))
user <- ZIO.serviceWithZIO[Database](_.findUser(123))
} yield user
val databaseLayer: ZLayer[Any, Nothing, Database] = ???
val loggerLayer: ZLayer[Any, Nothing, Logger] = ???
// Provide both layers
val program = effect.provide(
databaseLayer,
loggerLayer
)
Layer Composition
Layers compose with ++ (horizontal) and >>> (vertical):
// Horizontal: combine independent layers
val combined = databaseLayer ++ loggerLayer
// Vertical: chain dependent layers
val userRepoLayer = databaseLayer >>>
ZLayer.fromFunction((db: Database) => UserRepositoryLive(db))
Complete Example: Multi-Tier Application
import zio._
// Domain model
case class User(id: Int, name: String, email: String)
// Services
trait Database {
def query(sql: String): Task[List[User]]
}
trait Logger {
def info(message: String): UIO[Unit]
def error(message: String): UIO[Unit]
}
trait UserService {
def getUser(id: Int): Task[Option[User]]
def listUsers(): Task[List[User]]
}
// Implementations
case class InMemoryDatabase() extends Database {
private val users = Map(
1 -> User(1, "Alice", "alice@example.com"),
2 -> User(2, "Bob", "bob@example.com")
)
def query(sql: String): Task[List[User]] =
ZIO.succeed(users.values.toList)
}
case class ConsoleLogger() extends Logger {
def info(message: String): UIO[Unit] =
Console.printLine(s"[INFO] $message").orDie
def error(message: String): UIO[Unit] =
Console.printLine(s"[ERROR] $message").orDie
}
case class UserServiceLive(database: Database, logger: Logger) extends UserService {
def getUser(id: Int): Task[Option[User]] = for {
_ <- logger.info(s"Fetching user $id")
users <- database.query(s"SELECT * FROM users WHERE id = $id")
user = users.headOption
_ <- user match {
case Some(u) => logger.info(s"Found user: ${u.name}")
case None => logger.error(s"User $id not found")
}
} yield user
def listUsers(): Task[List[User]] = for {
_ <- logger.info("Fetching all users")
users <- database.query("SELECT * FROM users")
_ <- logger.info(s"Found ${users.length} users")
} yield users
}
// Layers
object Layers {
val database: ZLayer[Any, Nothing, Database] =
ZLayer.succeed(InMemoryDatabase())
val logger: ZLayer[Any, Nothing, Logger] =
ZLayer.succeed(ConsoleLogger())
val userService: ZLayer[Database & Logger, Nothing, UserService] =
ZLayer.fromFunction(UserServiceLive.apply _)
// Combine all layers
val live: ZLayer[Any, Nothing, UserService] =
database ++ logger >>> userService
}
// Companion object for convenience
object UserService {
def getUser(id: Int): ZIO[UserService, Throwable, Option[User]] =
ZIO.serviceWithZIO[UserService](_.getUser(id))
def listUsers(): ZIO[UserService, Throwable, List[User]] =
ZIO.serviceWithZIO[UserService](_.listUsers())
}
// Application
object UserApp extends ZIOAppDefault {
val program = for {
_ <- Console.printLine("=== User Management System ===")
users <- UserService.listUsers()
_ <- ZIO.foreach(users) { user =>
Console.printLine(s"${user.id}: ${user.name} (${user.email})")
}
_ <- Console.printLine("\nFetching user 1:")
user <- UserService.getUser(1)
_ <- user match {
case Some(u) => Console.printLine(s"Found: ${u.name}")
case None => Console.printLine("Not found")
}
} yield ()
def run = program.provide(Layers.live)
}
See how clean this is? No manual wiring, no constructors, just pure composition.
Testing with Layers
The power of ZLayer shines in testing:
// Test implementation
case class TestLogger() extends Logger {
private val messages = scala.collection.mutable.ListBuffer[String]()
def info(message: String): UIO[Unit] =
ZIO.succeed(messages += s"INFO: $message")
def error(message: String): UIO[Unit] =
ZIO.succeed(messages += s"ERROR: $message")
def getMessages: List[String] = messages.toList
}
case class TestDatabase() extends Database {
def query(sql: String): Task[List[User]] =
ZIO.succeed(List(User(1, "Test User", "test@test.com")))
}
// Test layers
val testDatabase: ZLayer[Any, Nothing, Database] =
ZLayer.succeed(TestDatabase())
val testLogger: ZLayer[Any, Nothing, Logger] =
ZLayer.succeed(TestLogger())
val testUserService: ZLayer[Any, Nothing, UserService] =
testDatabase ++ testLogger >>> Layers.userService
// Test
val test = (for {
user <- UserService.getUser(1)
_ <- ZIO.succeed(assert(user.isDefined))
} yield ()).provide(testUserService)
No mocking framework needed! Just provide different layer implementations.
Advanced Layer Patterns
Sharing Layers
// Create a layer once, use it multiple times
val sharedDatabase: ZLayer[Any, Nothing, Database] =
ZLayer.scoped {
ZIO.acquireRelease(
acquire = Database.connect()
)(
release = db => db.close().orDie
)
}.memoize
Conditional Layers
def makeLayer(useInMemory: Boolean): ZLayer[Any, Nothing, Database] =
if (useInMemory)
ZLayer.succeed(InMemoryDatabase())
else
ZLayer.fromZIO(PostgresDatabase.connect())
Layer Debugging
val debugLayer = databaseLayer.tap { db =>
Console.printLine("Database layer created").orDie
}
Common Patterns and Best Practices
Service Pattern
Always use this pattern:
// 1. Define trait
trait ServiceName {
def method1: ZIO[Any, Error, Result]
}
// 2. Implement
case class ServiceNameLive(deps: Deps) extends ServiceName {
def method1: ZIO[Any, Error, Result] = ???
}
// 3. Companion object with accessors
object ServiceName {
def method1: ZIO[ServiceName, Error, Result] =
ZIO.serviceWithZIO[ServiceName](_.method1)
}
// 4. Layer
val layer: ZLayer[Deps, Nothing, ServiceName] =
ZLayer.fromFunction(ServiceNameLive.apply _)
Organizing Layers
object AppLayers {
// Infrastructure
val database: ZLayer[Any, Throwable, Database] = ???
val cache: ZLayer[Any, Throwable, Cache] = ???
val logger: ZLayer[Any, Nothing, Logger] = ???
// Services
val userService: ZLayer[Database & Logger, Nothing, UserService] = ???
val authService: ZLayer[Database & Cache, Nothing, AuthService] = ???
// Complete app layer
val live: ZLayer[Any, Throwable, UserService & AuthService] =
database ++ cache ++ logger >>>
userService ++ authService
}
Error Handling in Layers
val dbLayer: ZLayer[Any, DbError, Database] =
ZLayer.fromZIO {
Database.connect().mapError(e => DbError.ConnectionFailed(e))
}
Real-World Example: REST API
// Services
trait UserRepository {
def findById(id: Int): Task[Option[User]]
def create(user: User): Task[User]
}
trait AuthService {
def authenticate(token: String): Task[Option[UserId]]
}
trait UserController {
def getUser(id: Int, token: String): Task[Response]
def createUser(request: CreateUserRequest): Task[Response]
}
// Implementation
case class UserControllerLive(
userRepo: UserRepository,
authService: AuthService
) extends UserController {
def getUser(id: Int, token: String): Task[Response] = for {
userId <- authService.authenticate(token).someOrFail(Unauthorized)
user <- userRepo.findById(id).someOrFail(NotFound)
_ <- ZIO.fail(Forbidden).when(user.id != userId)
} yield Response.ok(user)
def createUser(request: CreateUserRequest): Task[Response] = for {
user <- userRepo.create(request.toUser)
} yield Response.created(user)
}
// Layers
object ApiLayers {
val userRepo: ZLayer[Database, Nothing, UserRepository] = ???
val authService: ZLayer[Database & Cache, Nothing, AuthService] = ???
val userController: ZLayer[UserRepository & AuthService, Nothing, UserController] =
ZLayer.fromFunction(UserControllerLive.apply _)
val live: ZLayer[Database & Cache, Nothing, UserController] =
userRepo ++ authService >>> userController
}
Comparing with Other DI Approaches
Spring Framework (Java/Scala)
@Component
class UserService @Autowired()(database: Database, logger: Logger)
- Requires annotations
- Runtime wiring
- Hard to test
- Framework lock-in
Manual Constructor Injection
new UserService(
new Database(config),
new Logger()
)
- Verbose
- Order-dependent
- Hard to manage in large apps
ZIO Approach
UserService.getUser(123).provide(Layers.live)
- Type-safe
- Compile-time checking
- Easy testing (just swap layers)
- No framework needed
Which approach gives you more confidence that your app is wired correctly?
Key Takeaways
- Environment (R) tracks dependencies in the type system
- ZIO.service accesses services from the environment
- ZLayer is a recipe for constructing services
- provide injects dependencies into effects
- Layers compose with
++and>>> - Testing is trivial—just provide different layers
- No framework needed—it's built into ZIO
Common Questions
Q: When should I use ZLayer vs just passing parameters?
Use ZLayer when:
- Multiple services depend on the same resource
- You want different implementations for testing
- The dependency graph is complex
Use parameters for:
- Simple, local dependencies
- Configuration values
- Temporary, short-lived objects
Q: How do I handle circular dependencies?
Avoid circular dependencies by:
- Proper layer separation
- Using event-driven patterns
- Breaking circular logic into smaller services
Q: Can I use ZLayer with existing code?
Yes! Wrap existing services:
val legacyLayer = ZLayer.fromZIO {
ZIO.attempt(new LegacyService())
}
What's Next?
You now master dependency injection with ZIO! In Lesson 4: Concurrency with Fibers, you'll learn:
- ZIO's lightweight fiber-based concurrency model
- Running effects in parallel
- Racing and timeouts
- Handling interruption gracefully
Ready to unleash the power of concurrent programming? Let's continue!
Comments
Be the first to comment on this lesson!