Introduction: The Problem of "Wiring" an Application
In any non-trivial application, different parts need to talk to each other. A UserService might need a Database connection and an Emailer. A Database might need a Configuration object. This network of dependencies is often called "the application graph" or "the wiring."
How do we manage this?
- Manual new-ing? new UserService(new Database(new Config()), new Emailer()). This gets messy fast and is hard to change.
- A Framework like Spring? This uses reflection and can be "magical," but sometimes you want compile-time guarantees and a more functional approach. This is where ZIO shines. ZIO provides a powerful, purely functional, and compile-time safe way to manage dependencies using a feature called ZIO Layers. In this post, we'll build a simple application to demonstrate how it works.
**The Goal: **A Simple User Registration Flow
Our application will do one thing:
- Receive a User.
- "Insert" that user into a "database" (we'll just print to the console).
- Send a notification email to that user (again, just printing to the console). Let's look at the code and unpack the concepts piece by piece.
Part 1: The Building Blocks - Our Domain Models
First, we need some simple data structures. These are just plain Scala case classes with no ZIO-specific magic.
// In package com.dev24
case class User(name: String, email: String)
case class Config(tableName: String)
User: Represents a user in our system.
Config: Represents configuration data. Here, it just holds the name of our database table.
These are our pure data models. They are simple, immutable, and easy to understand.
Part 2: Defining Capabilities with traits (The Service Interfaces)
In ZIO, a "service" is defined by its public API, which is typically a trait. This trait describes what a service can do, not how it does it. This separation is key for modularity and testing.
The UserEmailer Service
// In package com.dev24
import zio.*
import java.io.IOException
trait UserEmailer:
def notify(user: User, message: String): ZIO[Console, IOException, Unit]
Let's dissect the notify method signature: ZIO[Console, IOException, Unit]. This is a ZIO effect, and its type parameters are its superpower:
-
R (Requirement Type): Console
- This is the dependency. It means that to execute this notify effect, we need an implementation of the Console service available in our environment. Console is a built-in ZIO service for interacting with... well, the console.
-
E (Error Type): IOException
- This is the type of error that can fail the effect. If something goes wrong while trying to print, it might result in an IOException.
-
A (Success Type): Unit
- This is the type of the value returned on success. We're just performing a side-effect (printing), so we don't need to return a meaningful value. Unit signifies "no value."
So, notify is a description of a computation that says: "To run me, you need to give me a Console. I might fail with an IOException, and if I succeed, I'll give you nothing (Unit)."
The UserDb Service
// In package com.dev24
import zio.*
import java.io.IOException
trait UserDb:
def insert(user: User): ZIO[Config & Console & UserEmailer, IOException, Unit]
This is where it gets more interesting. Look at the R type: Config & Console & UserEmailer.
- The & (Intersection Type): This means the insert method requires all three of these services to be present in the environment to run. It needs the Config to get the table name, the Console to print the "insert" message, and the UserEmailer to send the notification.
This is the heart of ZIO's dependency management. Dependencies are declared explicitly in the function signature, making them impossible to forget at compile time.
Part 3: Creating Implementations with ZLayer (The Blueprints)
A trait is just an interface. We need concrete implementations. In ZIO, an implementation of a service is provided by a ZLayer.
Think of a ZLayer as a recipe or a blueprint for building a service. It describes:
- What service it builds (the "output").
- What services it needs to build it (the "input" or dependencies).
The UserEmailer.live Layer
object UserEmailer:
lazy val live: ULayer[UserEmailer] =
ZLayer.succeed(
new:
override def notify(user: User, message: String): ZIO[Console, IOException, Unit] =
ZIO.serviceWithZIO(_.printLine(s"[User emailer] Sending '$message' to ${user.email}"))
)
object UserEmailer: We use a companion object to hold the implementations of our service. This is a common Scala pattern.
-
live: ULayer[UserEmailer]:
- live is a conventional name for the production implementation of a service.
- ULayer[UserEmailer] is the type. Layer[R, A] means "a layer that requires R to build A". ULayer[A] is just an alias for Layer[Any, A], meaning this layer has no dependencies. It can build a UserEmailer out of thin air.
ZLayer.succeed(...): This is the simplest way to create a layer. It takes a pre-built instance of your service and wraps it in a layer. Since our UserEmailer doesn't need any complex setup, this is perfect.
-
object UserEmailer: We use a companion object to hold the implementations of our service. This is a common Scala pattern.
- live: ULayer[UserEmailer]:
- live is a conventional name for the production implementation of a service.
ULayer[UserEmailer] is the type. Layer[R, A] means "a layer that requires R to build A". ULayer[A] is just an alias for Layer[Any, A], meaning this layer has no dependencies. It can build a UserEmailer out of thin air.
ZLayer.succeed(...): This is the simplest way to create a layer. It takes a pre-built instance of your service and wraps it in a layer. Since our UserEmailer doesn't need any complex setup, this is perfect.
new: ...: We are creating an anonymous class that implements the UserEmailer trait.
ZIO.serviceWithZIO(_.printLine(...)): This is a ZIO helper.
It's shorthand for : Get the Console service from the environment (ZIO.service[Console]).Use its printLine method.
The result of printLine is a ZIO[..., IOException, Unit], which is exactly what our notify method needs to return. We are creating an anonymous class that implements the UserEmailer trait.
- ZIO.serviceWithZIO(_.printLine(...)): This is a ZIO helper. It's shorthand for:
1.Get the Console service from the environment (ZIO.service[Console]).
2.Use its printLine method.
3.The result of printLine is a ZIO[..., IOException, Unit], which is exactly what our notify method needs to return.
The UserDb.live Layer
// In package com.dev24
object UserDb:
lazy val live: ULayer[UserDb] = // Wait, no dependencies? We'll see...
ZLayer.succeed(
new:
override def insert(user: User): ZIO[Config & Console & UserEmailer, IOException, Unit] =
for
tableName <- ZIO.environmentWith[Config](_.get.tableName)
_ <-
ZIO.serviceWithZIO[Console](_.printLine(s"[Database] insert into $tableName values ('${user.email}')"))
_ <- ZIO.serviceWithZIO[UserEmailer](_.notify(user, "Message"))
yield ()
)
This looks similar, but let's focus on the implementation of insert.
for { ... } yield (): This is ZIO's version of a for-comprehension, used to sequence effects.
tableName <- ZIO.environmentWithConfig: This is another way to access a service from the environment. It pulls out the entire Config object and lets us access its tableName field.
_ <- ZIO.serviceWithZIOUserEmailer): Here, we are using another service! We get the UserEmailer from the environment and call its notify method.
Crucial Point: Notice that UserDb.live is also a ULayer[UserDb]. This seems contradictory, right? The insert method requires dependencies, but the UserDb layer itself doesn't.
This is a key concept: A layer's dependencies are for its construction, not for its usage.
Creating a UserDb instance (the new part) doesn't require a Config or Console. But calling methods on that instance does. The dependencies are deferred to the method calls, which is exactly what we want.
Part 4: Assembling and Running the Application (Main)
Now we have all the pieces: our interfaces (traits) and our blueprints (ZLayers). The final step is to build the application and run it.
// In package com.dev24
import zio.*
object Main extends ZIOAppDefault:
// 1. Define the user we want to insert
lazy val kamil = User("d", "d@dev24.in")
// 2. Define the main logic of our application
val appLogic: ZIO[UserDb, IOException, Unit] =
ZIO.serviceWithZIO[UserDb](_.insert(kamil))
// 3. Assemble the environment (the wiring)
lazy val env =
ZLayer.succeed(ConsoleLive) ++ // Provide the live Console implementation
UserEmailer.live ++ // Provide our UserEmailer implementation
UserDb.live ++ // Provide our UserDb implementation
ZLayer.succeed(Config("table-x")) // Provide our Config instance
// 4. Run the logic with the provided environment
override def run: Task[Unit] =
appLogic.provideLayer(env)
Let's break down the Main object.
1.appLogic: This is the core business logic of our application, described as a ZIO effect. It says: "I need a UserDb service, and I will call its insert method with kamil." Its type is ZIO[UserDb, IOException, Unit], meaning it requires UserDb and can fail with IOException.
2.env: This is where the magic happens. We are building our complete application environment.
- ** ZLayer.succeed(ConsoleLive):** Console is a special ZIO service. We need to provide its "live" implementation, which actually interacts with the system console.
- ** ZLayer.succeed(Config("table-x")):** We create a layer from our simple Config case class instance.
- ** ++ (The Horizontal Composition Operator):** This operator combines layers. It's like stacking blueprints. It tells ZIO: "Here are all the services available in my application." ZIO is smart enough to analyze the dependencies and figure out the correct construction order. For example, it knows that UserDb needs Config, so it will ensure Config is built before UserDb.
3.run: Task[Unit]: This is the entry point for our ZIOAppDefault.
- appLogic.provideLayer(env): This is the final, crucial step. We take our appLogic (which needs a UserDb) and we give it a complete blueprint (env) for building one.
- What happens here? ZIO analyzes appLogic and sees it needs UserDb. It looks at env and finds the UserDb.live layer. It then builds the UserDb service. While building, it sees that the methods on UserDb need Config, Console, and UserEmailer. It finds those in the env as well. It builds everything, wires it all together, and finally executes the insert method.
When you run this, the output will be:
[Database] insert into table-x values ('d@dev24.in')
[User emailer] Sending 'Message' to d@dev24.in
Conclusion: Why This Approach is Powerful
Type-Safe Dependency Injection: The Scala compiler guarantees that all dependencies are met. If you forget to provide Config in your env, your code will not compile. No more runtime
NullPointerExceptions from missing dependencies.
Testability: This is a massive win. To test UserDb, you can provide a mock UserEmailer layer that doesn't actually send emails. You can provide a fake Config. Your UserDb code doesn't change at all; you just give it a different environment to run in.
Modularity and Reusability: Services are completely decoupled. UserEmailer has no idea UserDb exists. You can reuse UserEmailer in any other part of your application.
Explicitness: There is no magic. Every dependency a function needs is declared right in its type signature. The code is self-documenting.
This simple example demonstrates the foundational pattern for building robust, modular, and testable applications with ZIO. You define capabilities with traits, provide implementations with ZLayer, and wire them all together at the application's entry point.
full source code can be found below
Top comments (0)