Building a Complete ZIO Application
You've mastered ZIO's fundamentals, error handling, dependency injection, concurrency, resources, streams, testing, advanced patterns, and production deployment. Now it's time to build something real.
In this lesson, you'll create a complete Task Management API—a production-ready system that demonstrates every ZIO concept in action.
What You'll Build
A RESTful API for managing tasks with:
- User authentication and authorization
- CRUD operations for tasks
- Task assignment and tracking
- Real-time notifications
- PostgreSQL database integration
- Comprehensive test coverage
- Production deployment configuration
Application Architecture
The Layered Approach
┌─────────────────────────────────────┐
│ HTTP Layer (Routes) │ ← User-facing endpoints
├─────────────────────────────────────┤
│ Service Layer (Business Logic) │ ← Core operations
├─────────────────────────────────────┤
│ Repository Layer (Data Access) │ ← Database operations
├─────────────────────────────────────┤
│ Infrastructure (DB, Config) │ ← External dependencies
└─────────────────────────────────────┘
Why this architecture? Each layer has a single responsibility, making code testable, maintainable, and easy to reason about.
Project Structure
src/main/scala/
├── api/
│ ├── TaskRoutes.scala
│ ├── UserRoutes.scala
│ └── HealthRoutes.scala
├── domain/
│ ├── Task.scala
│ ├── User.scala
│ └── AppError.scala
├── service/
│ ├── TaskService.scala
│ └── UserService.scala
├── repository/
│ ├── TaskRepository.scala
│ └── UserRepository.scala
├── config/
│ └── AppConfig.scala
└── Main.scala
Domain Modeling
Start with your core domain types:
package domain
import java.time.Instant
import java.util.UUID
// Task status
sealed trait TaskStatus
object TaskStatus {
case object Todo extends TaskStatus
case object InProgress extends TaskStatus
case object Done extends TaskStatus
}
// Core domain model
case class Task(
id: UUID,
title: String,
description: String,
status: TaskStatus,
assignedTo: Option[UUID],
createdBy: UUID,
createdAt: Instant,
updatedAt: Instant
)
case class User(
id: UUID,
email: String,
name: String,
passwordHash: String,
createdAt: Instant
)
// Application errors
sealed trait AppError extends Throwable
object AppError {
case class NotFound(resource: String, id: UUID) extends AppError {
override def getMessage: String = s"$resource not found: $id"
}
case class Unauthorized(message: String) extends AppError {
override def getMessage: String = message
}
case class ValidationError(field: String, message: String) extends AppError {
override def getMessage: String = s"Invalid $field: $message"
}
case class DatabaseError(cause: Throwable) extends AppError {
override def getMessage: String = s"Database error: ${cause.getMessage}"
}
}
Notice how errors are typed? Every operation declares exactly what can go wrong.
Configuration Management
package config
import zio._
import zio.config._
import zio.config.magnolia._
import zio.config.typesafe._
case class DatabaseConfig(
url: String,
user: String,
password: String,
poolSize: Int
)
case class ServerConfig(
host: String,
port: Int
)
case class AppConfig(
database: DatabaseConfig,
server: ServerConfig
)
object AppConfig {
val layer: ZLayer[Any, ReadError[String], AppConfig] =
ZLayer {
read {
descriptor[AppConfig].from(
TypesafeConfigSource.fromResourcePath
)
}
}
}
Configuration from application.conf:
database {
url = "jdbc:postgresql://localhost:5432/taskdb"
user = "taskuser"
password = "secret"
pool-size = 10
}
server {
host = "0.0.0.0"
port = 8080
}
Repository Layer: Data Access
package repository
import domain._
import zio._
import java.util.UUID
import javax.sql.DataSource
trait TaskRepository {
def create(task: Task): IO[AppError, Task]
def findById(id: UUID): IO[AppError, Option[Task]]
def findByUser(userId: UUID): IO[AppError, List[Task]]
def update(task: Task): IO[AppError, Task]
def delete(id: UUID): IO[AppError, Unit]
def findAll(): IO[AppError, List[Task]]
}
object TaskRepository {
// Live implementation using PostgreSQL
case class Live(dataSource: DataSource) extends TaskRepository {
override def create(task: Task): IO[AppError, Task] =
ZIO.attempt {
val conn = dataSource.getConnection
try {
val stmt = conn.prepareStatement(
"""INSERT INTO tasks (id, title, description, status,
assigned_to, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"""
)
stmt.setObject(1, task.id)
stmt.setString(2, task.title)
stmt.setString(3, task.description)
stmt.setString(4, task.status.toString)
stmt.setObject(5, task.assignedTo.orNull)
stmt.setObject(6, task.createdBy)
stmt.setTimestamp(7, java.sql.Timestamp.from(task.createdAt))
stmt.setTimestamp(8, java.sql.Timestamp.from(task.updatedAt))
stmt.executeUpdate()
task
} finally {
conn.close()
}
}.mapError(AppError.DatabaseError)
override def findById(id: UUID): IO[AppError, Option[Task]] =
ZIO.attempt {
val conn = dataSource.getConnection
try {
val stmt = conn.prepareStatement(
"SELECT * FROM tasks WHERE id = ?"
)
stmt.setObject(1, id)
val rs = stmt.executeQuery()
if (rs.next()) Some(rowToTask(rs))
else None
} finally {
conn.close()
}
}.mapError(AppError.DatabaseError)
override def findByUser(userId: UUID): IO[AppError, List[Task]] =
ZIO.attempt {
val conn = dataSource.getConnection
try {
val stmt = conn.prepareStatement(
"SELECT * FROM tasks WHERE assigned_to = ? OR created_by = ?"
)
stmt.setObject(1, userId)
stmt.setObject(2, userId)
val rs = stmt.executeQuery()
val tasks = scala.collection.mutable.ListBuffer[Task]()
while (rs.next()) {
tasks += rowToTask(rs)
}
tasks.toList
} finally {
conn.close()
}
}.mapError(AppError.DatabaseError)
override def update(task: Task): IO[AppError, Task] =
ZIO.attempt {
val conn = dataSource.getConnection
try {
val stmt = conn.prepareStatement(
"""UPDATE tasks SET title = ?, description = ?,
status = ?, assigned_to = ?, updated_at = ?
WHERE id = ?"""
)
stmt.setString(1, task.title)
stmt.setString(2, task.description)
stmt.setString(3, task.status.toString)
stmt.setObject(4, task.assignedTo.orNull)
stmt.setTimestamp(5, java.sql.Timestamp.from(task.updatedAt))
stmt.setObject(6, task.id)
stmt.executeUpdate()
task
} finally {
conn.close()
}
}.mapError(AppError.DatabaseError)
override def delete(id: UUID): IO[AppError, Unit] =
ZIO.attempt {
val conn = dataSource.getConnection
try {
val stmt = conn.prepareStatement("DELETE FROM tasks WHERE id = ?")
stmt.setObject(1, id)
stmt.executeUpdate()
} finally {
conn.close()
}
}.mapError(AppError.DatabaseError)
override def findAll(): IO[AppError, List[Task]] =
ZIO.attempt {
val conn = dataSource.getConnection
try {
val stmt = conn.prepareStatement("SELECT * FROM tasks")
val rs = stmt.executeQuery()
val tasks = scala.collection.mutable.ListBuffer[Task]()
while (rs.next()) {
tasks += rowToTask(rs)
}
tasks.toList
} finally {
conn.close()
}
}.mapError(AppError.DatabaseError)
private def rowToTask(rs: java.sql.ResultSet): Task = Task(
id = rs.getObject("id", classOf[UUID]),
title = rs.getString("title"),
description = rs.getString("description"),
status = TaskStatus.valueOf(rs.getString("status")),
assignedTo = Option(rs.getObject("assigned_to", classOf[UUID])),
createdBy = rs.getObject("created_by", classOf[UUID]),
createdAt = rs.getTimestamp("created_at").toInstant,
updatedAt = rs.getTimestamp("updated_at").toInstant
)
}
// Layer for dependency injection
val layer: ZLayer[DataSource, Nothing, TaskRepository] =
ZLayer.fromFunction(Live.apply _)
}
What's happening here? The repository abstracts database operations behind a trait, making it easy to swap implementations for testing.
Service Layer: Business Logic
package service
import domain._
import repository.TaskRepository
import zio._
import java.time.Instant
import java.util.UUID
trait TaskService {
def createTask(
title: String,
description: String,
createdBy: UUID
): IO[AppError, Task]
def getTask(id: UUID): IO[AppError, Task]
def getUserTasks(userId: UUID): IO[AppError, List[Task]]
def updateTask(id: UUID, updates: TaskUpdate): IO[AppError, Task]
def deleteTask(id: UUID): IO[AppError, Unit]
def assignTask(taskId: UUID, userId: UUID): IO[AppError, Task]
}
case class TaskUpdate(
title: Option[String],
description: Option[String],
status: Option[TaskStatus]
)
object TaskService {
case class Live(repository: TaskRepository) extends TaskService {
override def createTask(
title: String,
description: String,
createdBy: UUID
): IO[AppError, Task] =
for {
_ <- validateTitle(title)
_ <- validateDescription(description)
now = Instant.now()
task = Task(
id = UUID.randomUUID(),
title = title,
description = description,
status = TaskStatus.Todo,
assignedTo = None,
createdBy = createdBy,
createdAt = now,
updatedAt = now
)
created <- repository.create(task)
} yield created
override def getTask(id: UUID): IO[AppError, Task] =
repository.findById(id).flatMap {
case Some(task) => ZIO.succeed(task)
case None => ZIO.fail(AppError.NotFound("Task", id))
}
override def getUserTasks(userId: UUID): IO[AppError, List[Task]] =
repository.findByUser(userId)
override def updateTask(
id: UUID,
updates: TaskUpdate
): IO[AppError, Task] =
for {
existing <- getTask(id)
_ <- ZIO.foreach(updates.title)(validateTitle)
_ <- ZIO.foreach(updates.description)(validateDescription)
updated = existing.copy(
title = updates.title.getOrElse(existing.title),
description = updates.description.getOrElse(existing.description),
status = updates.status.getOrElse(existing.status),
updatedAt = Instant.now()
)
saved <- repository.update(updated)
} yield saved
override def deleteTask(id: UUID): IO[AppError, Unit] =
for {
_ <- getTask(id) // Verify exists
_ <- repository.delete(id)
} yield ()
override def assignTask(
taskId: UUID,
userId: UUID
): IO[AppError, Task] =
for {
task <- getTask(taskId)
updated = task.copy(
assignedTo = Some(userId),
updatedAt = Instant.now()
)
saved <- repository.update(updated)
} yield saved
private def validateTitle(title: String): IO[AppError, Unit] =
if (title.trim.isEmpty)
ZIO.fail(AppError.ValidationError("title", "cannot be empty"))
else if (title.length > 200)
ZIO.fail(AppError.ValidationError("title", "too long (max 200)"))
else
ZIO.unit
private def validateDescription(desc: String): IO[AppError, Unit] =
if (desc.length > 2000)
ZIO.fail(AppError.ValidationError("description", "too long (max 2000)"))
else
ZIO.unit
}
val layer: ZLayer[TaskRepository, Nothing, TaskService] =
ZLayer.fromFunction(Live.apply _)
}
Notice the validation? Business rules belong in the service layer, not the repository.
HTTP Routes with ZIO HTTP
package api
import domain._
import service.TaskService
import zio._
import zio.http._
import zio.json._
import java.util.UUID
// JSON codecs
case class CreateTaskRequest(
title: String,
description: String
)
case class UpdateTaskRequest(
title: Option[String],
description: Option[String],
status: Option[String]
)
case class TaskResponse(
id: String,
title: String,
description: String,
status: String,
assignedTo: Option[String],
createdBy: String,
createdAt: String,
updatedAt: String
)
object TaskResponse {
def from(task: Task): TaskResponse = TaskResponse(
id = task.id.toString,
title = task.title,
description = task.description,
status = task.status.toString,
assignedTo = task.assignedTo.map(_.toString),
createdBy = task.createdBy.toString,
createdAt = task.createdAt.toString,
updatedAt = task.updatedAt.toString
)
}
// JSON codecs (using zio-json)
implicit val createTaskDecoder: JsonDecoder[CreateTaskRequest] =
DeriveJsonDecoder.gen[CreateTaskRequest]
implicit val updateTaskDecoder: JsonDecoder[UpdateTaskRequest] =
DeriveJsonDecoder.gen[UpdateTaskRequest]
implicit val taskResponseEncoder: JsonEncoder[TaskResponse] =
DeriveJsonEncoder.gen[TaskResponse]
object TaskRoutes {
def routes: HttpApp[TaskService, Nothing] =
Http.collectZIO[Request] {
// GET /tasks - List all tasks
case Method.GET -> Root / "tasks" =>
for {
service <- ZIO.service[TaskService]
tasks <- service.getUserTasks(extractUserId(???))
.mapError(errorResponse)
response = tasks.map(TaskResponse.from)
} yield Response.json(response.toJson)
// GET /tasks/:id - Get specific task
case Method.GET -> Root / "tasks" / id =>
for {
taskId <- ZIO.attempt(UUID.fromString(id))
.mapError(_ => Response.badRequest("Invalid UUID"))
service <- ZIO.service[TaskService]
task <- service.getTask(taskId)
.mapError(errorResponse)
response = TaskResponse.from(task)
} yield Response.json(response.toJson)
// POST /tasks - Create task
case req @ Method.POST -> Root / "tasks" =>
for {
body <- req.body.asString
request <- ZIO.fromEither(body.fromJson[CreateTaskRequest])
.mapError(_ => Response.badRequest("Invalid JSON"))
userId = extractUserId(req) // Extract from auth token
service <- ZIO.service[TaskService]
task <- service.createTask(
request.title,
request.description,
userId
).mapError(errorResponse)
response = TaskResponse.from(task)
} yield Response.json(response.toJson).status(Status.Created)
// PUT /tasks/:id - Update task
case req @ Method.PUT -> Root / "tasks" / id =>
for {
taskId <- ZIO.attempt(UUID.fromString(id))
.mapError(_ => Response.badRequest("Invalid UUID"))
body <- req.body.asString
request <- ZIO.fromEither(body.fromJson[UpdateTaskRequest])
.mapError(_ => Response.badRequest("Invalid JSON"))
service <- ZIO.service[TaskService]
updates = TaskUpdate(
title = request.title,
description = request.description,
status = request.status.map(s => TaskStatus.valueOf(s))
)
task <- service.updateTask(taskId, updates)
.mapError(errorResponse)
response = TaskResponse.from(task)
} yield Response.json(response.toJson)
// DELETE /tasks/:id - Delete task
case Method.DELETE -> Root / "tasks" / id =>
for {
taskId <- ZIO.attempt(UUID.fromString(id))
.mapError(_ => Response.badRequest("Invalid UUID"))
service <- ZIO.service[TaskService]
_ <- service.deleteTask(taskId)
.mapError(errorResponse)
} yield Response.noContent
// POST /tasks/:id/assign - Assign task to user
case req @ Method.POST -> Root / "tasks" / taskId / "assign" =>
for {
tId <- ZIO.attempt(UUID.fromString(taskId))
.mapError(_ => Response.badRequest("Invalid UUID"))
body <- req.body.asString
userId <- ZIO.fromEither(body.fromJson[String])
.mapError(_ => Response.badRequest("Invalid JSON"))
uId <- ZIO.attempt(UUID.fromString(userId))
.mapError(_ => Response.badRequest("Invalid user UUID"))
service <- ZIO.service[TaskService]
task <- service.assignTask(tId, uId)
.mapError(errorResponse)
response = TaskResponse.from(task)
} yield Response.json(response.toJson)
}
private def errorResponse(error: AppError): Response =
error match {
case AppError.NotFound(resource, id) =>
Response.notFound(s"$resource not found: $id")
case AppError.ValidationError(field, msg) =>
Response.badRequest(s"Validation error - $field: $msg")
case AppError.Unauthorized(msg) =>
Response.unauthorized(msg)
case AppError.DatabaseError(cause) =>
Response.internalServerError(s"Database error: ${cause.getMessage}")
}
private def extractUserId(req: Request): UUID = {
// In real app: extract from JWT token
UUID.randomUUID()
}
}
How does error handling work? Every service error maps to an appropriate HTTP status code.
Main Application
package main
import api._
import service._
import repository._
import config._
import zio._
import zio.http._
object Main extends ZIOAppDefault {
// Combine all routes
val httpApp: HttpApp[TaskService, Nothing] =
TaskRoutes.routes ++ HealthRoutes.routes
// Application layers
val appLayer: ZLayer[Any, Throwable, TaskService] =
AppConfig.layer >+>
DataSourceLayer.layer >+>
TaskRepository.layer >+>
TaskService.layer
// Server configuration
val serverConfig: ZLayer[AppConfig, Nothing, Server.Config] =
ZLayer.fromFunction { (config: AppConfig) =>
Server.Config.default
.binding(config.server.host, config.server.port)
}
def run =
Server
.serve(httpApp)
.provide(
appLayer,
serverConfig,
Server.live
)
}
// Health check endpoint
object HealthRoutes {
def routes: HttpApp[Any, Nothing] =
Http.collect[Request] {
case Method.GET -> Root / "health" =>
Response.json("""{"status":"healthy"}""")
}
}
// DataSource layer
object DataSourceLayer {
import javax.sql.DataSource
import com.zaxxer.hikari.{HikariConfig, HikariDataSource}
val layer: ZLayer[AppConfig, Throwable, DataSource] =
ZLayer.scoped {
for {
config <- ZIO.service[AppConfig]
hikariConfig = new HikariConfig()
_ = hikariConfig.setJdbcUrl(config.database.url)
_ = hikariConfig.setUsername(config.database.user)
_ = hikariConfig.setPassword(config.database.password)
_ = hikariConfig.setMaximumPoolSize(config.database.poolSize)
dataSource <- ZIO.acquireRelease(
ZIO.attempt(new HikariDataSource(hikariConfig))
)(ds => ZIO.succeed(ds.close()))
} yield dataSource
}
}
See how layers compose? Each dependency builds on the previous one, and ZIO wires everything automatically.
Testing Strategy
Unit Tests
package service
import domain._
import repository.TaskRepository
import zio._
import zio.test._
import java.util.UUID
object TaskServiceSpec extends ZIOSpecDefault {
// Mock repository for testing
case class MockTaskRepository(
tasks: Ref[Map[UUID, Task]]
) extends TaskRepository {
def create(task: Task): IO[AppError, Task] =
tasks.update(_ + (task.id -> task)).as(task)
def findById(id: UUID): IO[AppError, Option[Task]] =
tasks.get.map(_.get(id))
def findByUser(userId: UUID): IO[AppError, List[Task]] =
tasks.get.map(_.values.filter { t =>
t.createdBy == userId || t.assignedTo.contains(userId)
}.toList)
def update(task: Task): IO[AppError, Task] =
tasks.update(_ + (task.id -> task)).as(task)
def delete(id: UUID): IO[AppError, Unit] =
tasks.update(_ - id)
def findAll(): IO[AppError, List[Task]] =
tasks.get.map(_.values.toList)
}
// Test layer
val testLayer: ZLayer[Any, Nothing, TaskService] =
ZLayer.fromZIO {
for {
ref <- Ref.make(Map.empty[UUID, Task])
repo = MockTaskRepository(ref)
} yield TaskService.Live(repo)
}
def spec = suite("TaskService")(
test("creates task with valid data") {
for {
service <- ZIO.service[TaskService]
userId = UUID.randomUUID()
task <- service.createTask(
"Test Task",
"Description",
userId
)
} yield assertTrue(
task.title == "Test Task",
task.status == TaskStatus.Todo,
task.createdBy == userId
)
},
test("fails to create task with empty title") {
for {
service <- ZIO.service[TaskService]
result <- service.createTask(
"",
"Description",
UUID.randomUUID()
).exit
} yield assertTrue(result.isFailure)
},
test("updates task successfully") {
for {
service <- ZIO.service[TaskService]
userId = UUID.randomUUID()
task <- service.createTask("Original", "Desc", userId)
updates = TaskUpdate(
title = Some("Updated"),
description = None,
status = Some(TaskStatus.InProgress)
)
updated <- service.updateTask(task.id, updates)
} yield assertTrue(
updated.title == "Updated",
updated.status == TaskStatus.InProgress
)
},
test("assigns task to user") {
for {
service <- ZIO.service[TaskService]
creatorId = UUID.randomUUID()
assigneeId = UUID.randomUUID()
task <- service.createTask("Task", "Desc", creatorId)
assigned <- service.assignTask(task.id, assigneeId)
} yield assertTrue(assigned.assignedTo == Some(assigneeId))
}
).provide(testLayer)
}
What makes this testable? The repository is an interface, so we swap in a mock implementation using Ref for in-memory storage.
Integration Tests
package api
import domain._
import service.TaskService
import zio._
import zio.test._
import zio.http._
object TaskRoutesSpec extends ZIOSpecDefault {
def spec = suite("TaskRoutes")(
test("GET /tasks returns list of tasks") {
for {
response <- TaskRoutes.routes(
Request.get(URL.root / "tasks")
)
status = response.status
body <- response.body.asString
} yield assertTrue(
status == Status.Ok,
body.contains("tasks")
)
},
test("POST /tasks creates new task") {
val json = """{"title":"New Task","description":"Details"}"""
for {
response <- TaskRoutes.routes(
Request.post(
URL.root / "tasks",
Body.fromString(json)
)
)
} yield assertTrue(response.status == Status.Created)
},
test("GET /tasks/:id returns task") {
for {
// Create task first
createRes <- TaskRoutes.routes(
Request.post(
URL.root / "tasks",
Body.fromString("""{"title":"Test","description":"Desc"}""")
)
)
taskId <- extractTaskId(createRes)
// Get task
getRes <- TaskRoutes.routes(
Request.get(URL.root / "tasks" / taskId)
)
} yield assertTrue(getRes.status == Status.Ok)
}
).provide(
testTaskServiceLayer,
testRepositoryLayer
)
private def extractTaskId(response: Response): Task[String] = ???
}
Database Migrations
Use Flyway for migrations:
-- V1__create_tasks_table.sql
CREATE TABLE tasks (
id UUID PRIMARY KEY,
title VARCHAR(200) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL,
assigned_to UUID,
created_by UUID NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_tasks_assigned_to ON tasks(assigned_to);
CREATE INDEX idx_tasks_created_by ON tasks(created_by);
Production Deployment
Docker Configuration
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/scala-3.3.0/task-api.jar /app/task-api.jar
COPY src/main/resources/application.conf /app/application.conf
EXPOSE 8080
CMD ["java", "-jar", "task-api.jar"]
Docker Compose
version: '3.8'
services:
postgres:
image: postgres:15
environment:
POSTGRES_DB: taskdb
POSTGRES_USER: taskuser
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
app:
build: .
ports:
- "8080:8080"
environment:
DATABASE_URL: jdbc:postgresql://postgres:5432/taskdb
depends_on:
- postgres
volumes:
postgres-data:
Graceful Shutdown
object Main extends ZIOAppDefault {
override def run = {
val app = Server
.serve(httpApp)
.provide(appLayer, serverConfig, Server.live)
// Handle SIGTERM for graceful shutdown
app.onInterrupt {
Console.printLine("Shutting down gracefully...") *>
ZIO.sleep(2.seconds) *>
Console.printLine("Shutdown complete")
}
}
}
Monitoring and Observability
Health Checks
object HealthRoutes {
def routes: HttpApp[TaskRepository, Nothing] =
Http.collectZIO[Request] {
case Method.GET -> Root / "health" =>
for {
repo <- ZIO.service[TaskRepository]
// Check database connectivity
_ <- repo.findAll().timeout(5.seconds)
.catchAll(_ => ZIO.fail(()))
} yield Response.json("""{"status":"healthy","database":"connected"}""")
.catchAll(_ =>
ZIO.succeed(
Response
.json("""{"status":"unhealthy","database":"disconnected"}""")
.status(Status.ServiceUnavailable)
)
)
case Method.GET -> Root / "ready" =>
// Readiness probe
ZIO.succeed(Response.ok)
}
}
Logging
import zio.logging._
val program = for {
_ <- ZIO.logInfo("Application starting")
_ <- Server.serve(httpApp)
_ <- ZIO.logInfo("Application stopped")
} yield ()
program.provide(
appLayer,
consoleLogger() // Add structured logging
)
Key Takeaways
- Architecture matters: Layered design makes code maintainable
- Type safety everywhere: Errors, dependencies, effects—all in types
- ZIO handles the complexity: Concurrency, resources, errors—automatic
- Testing is natural: Mock any layer, test in isolation
- Production-ready: Health checks, monitoring, graceful shutdown
- Composability wins: Small, focused components compose into complete systems
Real-World Considerations
Performance Optimization
// Cache frequently accessed data
val cachedTasks = service.getUserTasks(userId).cached(5.minutes)
// Batch database operations
val createMany = ZIO.foreachPar(tasks)(repo.create)
// Connection pooling (already in DataSource layer)
Security
// JWT authentication middleware
def authMiddleware: HttpApp[Any, Response] =
Http.collectZIO[Request] { req =>
for {
token <- extractToken(req)
userId <- validateToken(token)
// Pass userId to routes
} yield userId
}
// Rate limiting
def rateLimited[R, E](effect: ZIO[R, E, Response]): ZIO[R, E, Response] =
Semaphore.make(100).flatMap { sem =>
sem.withPermit(effect).timeoutFail(
Response.status(Status.TooManyRequests)
)(30.seconds)
}
Observability
// Metrics
import zio.metrics._
val createTaskMetric = Metric.counter("tasks_created")
def createTask(...): IO[AppError, Task] =
for {
task <- createTaskImpl(...)
_ <- createTaskMetric.increment
} yield task
What You've Accomplished
You've built a complete, production-ready ZIO application that:
- ✅ Follows clean architecture principles
- ✅ Uses ZIO's effect system throughout
- ✅ Handles errors with type safety
- ✅ Manages dependencies with ZLayers
- ✅ Integrates with PostgreSQL
- ✅ Exposes REST APIs with ZIO HTTP
- ✅ Includes comprehensive tests
- ✅ Deploys with Docker
- ✅ Implements monitoring and health checks
Where to Go From Here
You've completed the ZIO course! You're now equipped to:
- Build production ZIO applications
- Design systems with effect-based architecture
- Write testable, maintainable code
- Deploy and monitor ZIO services
Continue Learning
- Explore ZIO ecosystem: ZIO JSON, ZIO Config, ZIO Kafka
- Study open-source ZIO projects
- Join the ZIO community
- Build your own projects
- Contribute to ZIO
Additional Resources
Congratulations on completing the ZIO course! You're ready to build amazing things. 🎉
Comments
Be the first to comment on this lesson!