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. 🎉