Back to Blog
Scala

How to implement a REST API in Scala 3 with ZIO HTTP, Magnum and Iron

Jorge Vasquez·April 4, 2025
How to implement a REST API in Scala 3 with ZIO HTTP, Magnum and Iron

Introduction

REST APIs form the backbone of contemporary cloud applications, enabling communication across distributed systems. Given the critical importance of availability, scalability, and performance, selecting appropriate programming languages and libraries significantly impacts these metrics. The article explores leveraging three Scala 3 ecosystem tools:

  • ZIO HTTP for constructing scalable, performant web applications
  • Magnum, a Scala 3-exclusive database client
  • Iron, a Scala 3 library for refined types that prevents invalid states

Introduction to ZIO HTTP

ZIO HTTP is "a Scala library for building http apps. It is powered by ZIO and Netty and aims at being the defacto solution for writing, highly scalable and performant web applications using idiomatic Scala."

Built on ZIO, it provides:

  • Type-safety through Scala's type system
  • Resource-safety with proper cleanup
  • Composability and error handling
  • Concurrency, parallelism, and structured logging
  • Configuration management, metrics, and testability

Additional Features

  • Codecs: Integration with ZIO Schema enables encoding/decoding in JSON, Protobuf, Avro, and Thrift
  • Middlewares: Support for cross-cutting concerns like logging and authentication
  • WebSockets: Real-time application capabilities
  • Testkit: Integration testing without live servers
  • HTML Template DSL: Scala-based HTML templating

Routes vs. Endpoints APIs

ZIO HTTP offers two approaches:

Routes API (low-level, imperative): Developers map HTTP methods and paths directly to handlers, requiring manual request/response encoding and decoding.

Endpoints API (high-level, declarative): Separates endpoint descriptions from implementation logic, enabling:

  • OpenAPI documentation generation
  • CLI generation via ZIO CLI

Routes API Example

import zio.http.*

val routes =
   Routes(
     Method.POST / "cart" / uuid("userId") ->
       handler(handleInitializeCart _),
     Method.POST / "cart" / uuid("userId") / "item" ->
       handler(handleAddItem _)
   )

def handleAddItem(userId: UUID, req: Request): URIO[CartService, Response] =
 for
   _ <- ZIO.logInfo("Adding item to cart")
   body   <- req.body.asString.orDie
   item   <- ZIO.fromEither(body.fromJson[Item]).orDieWith(RuntimeException(_))
   items0 <- CartService.addItem(userId, item)
   allItems = req.headers.get("X-ALL-ITEMS")
   items <- allItems match
             case Some(allItems) =>
               ZIO.attempt(allItems.toBoolean).orDie.map {
                 case true  => items0
                 case false => Items.empty + item
               }
             case None => ZIO.succeed(Items.empty + item)
 yield Response.json(items.toJson)

Handlers receive the full Request object and must manually decode/encode data.

Endpoints API Example

val addItem =
   Endpoint(Method.POST / "cart" / uuid("userId") / "item")
     .in[Item](Doc.p("Item to add"))
     .header[Boolean]("X-ALL-ITEMS", Doc.p("Return all items or just new one"))
     .out[Items](Doc.p("The operation result")) 
     ?? Doc.p("Add an item to a user's cart")

def handleAddItem(
  userId: UserId,
  allItems: Option[Boolean],
  item: Item
): URIO[CartService, Items] =
  for
    _      <- ZIO.logInfo("Adding item to cart")
    items0 <- CartService.addItem(userId, item)
    items   = allItems match
                case Some(true) => items0
                case _          => Items.empty + item
  yield items

The Endpoints API automatically handles encoding/decoding, allowing handlers to work with domain models directly.

Introduction to Magnum

Magnum is a Scala 3 database client with these characteristics:

  • Zero external dependencies
  • Support for any JDBC database (PostgreSQL, MySQL, Oracle, H2, SQLite, etc.)
  • Auto-derives CRUD operations at compile-time (similar to Spring Data)
  • SQL string interpolator (like doobie)
  • Recent ZIO integration support

Introduction to Iron

Iron provides refined types in Scala 3, enabling type-level constraint attachment to enforce properties and forbid invalid values. Unlike the refined library, Iron uses a more elegant syntax.

Iron allows developers to:

  • Evaluate constraints at compile-time using Scala 3's type system
  • Evaluate constraints at runtime
  • Seamlessly integrate refined types (they're subtypes of unrefined versions)
  • Create custom constraints via typeclasses

Example Application: Employee Management REST API

Architecture Overview

The example implements CRUD operations for departments, employees, and phone numbers, storing data in PostgreSQL via Testcontainers.

Dependencies

lazy val root = (project in file("."))
 .settings(
   name := "zio-backend-example",
   libraryDependencies ++= Seq(
     "dev.zio"            %% "zio-http"               % "3.2.0",
     "com.augustnagro"    %% "magnumzio"              % "2.0.0-M1",
     "org.postgresql"      % "postgresql"             % "42.7.5",
     "org.testcontainers"  % "testcontainers"         % "1.20.4",
     "org.testcontainers"  % "postgresql"             % "1.20.4",
     "com.zaxxer"          % "HikariCP"               % "6.2.1",
     "io.github.iltotore" %% "iron"                   % "3.0.0",
     "dev.zio"            %% "zio-logging-jul-bridge" % "2.4.0"
   )
 )

Database Implementation

Entity Classes

package com.example.tables

import com.augustnagro.magnum.magzio.*

@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)
final case class Employee(
  @Id id: Int,
  name: String,
  age: Int,
  departmentId: Int
) derives DbCodec

The @Table annotation specifies:

  • Database type (PostgresDbType)
  • Name mapping strategy (CamelToSnakeCase)

The derives DbCodec automatically generates database codec for fields with available DbCodecs.

Domain Models

package com.example.domain

final case class Department(name: String)
final case class Employee(name: String, age: Int, departmentId: Int)
final case class Phone(number: String)

Domain models exclude auto-generated IDs since databases create them.

Repository Pattern

trait EmployeeRepository:
  def create(employee: Employee): UIO[Int]
  def retrieve(employeeId: Int): UIO[Option[Employee]]
  def retrieveAll: UIO[Vector[Employee]]
  def update(employeeId: Int, employee: Employee): UIO[Unit]
  def delete(employeeId: Int): UIO[Unit]

final case class EmployeeRepositoryLive(xa: Transactor)
   extends Repo[Employee, tables.Employee, Int]
   with EmployeeRepository:

 override def create(employee: Employee): UIO[Int] =
   xa.transact {
     insertReturning(employee).id
   }.orDie

 override def retrieve(employeeId: Int): UIO[Option[Employee]] =
   xa.transact {
     findById(employeeId).map(_.toDomain)
   }.orDie

The Repo[EC, E, ID] class provides:

  • EC: Entity Creator (domain model)
  • E: Entity (database entity)
  • ID: Identifier type

Operations like insertReturning and findById execute within xa.transact contexts providing implicit DbTx.

Custom Queries with TableInfo

object EmployeePhone:
  val table = TableInfo[EmployeePhone, EmployeePhone, Null]

override def retrieveEmployeePhones(employeeId: Int): UIO[Vector[Phone]] =
  xa.transact {
    val statement =
      sql"""
        SELECT ${tables.Phone.table.all}
        FROM ${tables.Phone.table}
        INNER JOIN ${tables.EmployeePhone.table}
          ON ${tables.EmployeePhone.table.phoneId} = ${tables.Phone.table.id}
        WHERE ${tables.EmployeePhone.table.employeeId} = $employeeId
      """

    statement.query[tables.Phone].run().map(_.toDomain)
  }.orDie

TableInfo enables future-proof queries by referencing table metadata rather than hardcoded column names.

REST API Implementation

Services Layer

trait EmployeePhoneService:
  def addPhoneToEmployee(phoneId: Int, employeeId: Int): IO[AppError, Unit]
  def retrieveEmployeePhones(employeeId: Int): IO[EmployeeNotFound, Vector[Phone]]

final case class EmployeePhoneServiceLive(
  employeePhoneRepository: EmployeePhoneRepository,
  employeeRepository: EmployeeRepository,
  phoneRepository: PhoneRepository
) extends EmployeePhoneService:
  override def addPhoneToEmployee(phoneId: Int, employeeId: Int): IO[AppError, Unit] =
    phoneRepository.retrieve(phoneId).someOrFail(PhoneNotFound)
      *> employeeRepository.retrieve(employeeId).someOrFail(EmployeeNotFound)
      *> employeePhoneRepository.addPhoneToEmployee(phoneId, employeeId)

Services orchestrate repositories and handle business logic.

Error Handling

package com.example.error

sealed trait AppError derives Schema
object AppError:
  case object DepartmentNotFound extends AppError derives Schema
  type DepartmentNotFound = DepartmentNotFound.type

  case object EmployeeNotFound extends AppError derives Schema
  type EmployeeNotFound = EmployeeNotFound.type

Using sealed traits with derives Schema enables OpenAPI documentation generation.

Endpoint Definitions

trait EmployeeEndpoints:
  val createEmployee =
    Endpoint(Method.POST / "employee")
      .in[Employee](Doc.p("Employee to be created"))
      .out[Int](Doc.p("ID of the created employee"))
      .outError[DepartmentNotFound](Status.NotFound, Doc.p("Department not found"))
      ?? Doc.p("Create a new employee")

  val updateEmployee =
    Endpoint(Method.PUT / "employee" / int("id"))
      .in[Employee](Doc.p("Employee to be updated"))
      .out[Unit]
      .outError[EmployeeNotFound](Status.NotFound, Doc.p("Employee not found"))
      ?? Doc.p("Update the employee with the given `id`")

Endpoints declare inputs, outputs, and potential errors with documentation.

Handler Implementation

trait EmployeeHandlers:
  def createEmployeeHandler(
    employee: Employee
  ): ZIO[EmployeeService, DepartmentNotFound, Int] =
    ZIO.serviceWithZIO[EmployeeService](_.create(employee))

  val getEmployeesHandler: URIO[EmployeeService, Vector[Employee]] =
    ZIO.serviceWithZIO[EmployeeService](_.retrieveAll)

Handlers receive already-decoded inputs and return domain models.

Routes Assembly

trait Router
   extends DepartmentEndpoints with DepartmentHandlers
   with EmployeeEndpoints with EmployeeHandlers:

 val routes: Routes[
   DepartmentService & EmployeeService,
   Nothing
 ] =
   Routes(
     createDepartment.implementHandler(handler(createDepartmentHandler)),
     getEmployees.implementHandler(handler(getEmployeesHandler)),
     updateEmployee.implementHandler(handler(updateEmployeeHandler)),
     deleteEmployee.implementHandler(handler(deleteEmployeeHandler))
   )

Routes combine endpoint definitions with handlers.

OpenAPI & Swagger

val swaggerRoutes =
  SwaggerUI.routes(
    "docs",
    OpenAPIGen.fromEndpoints(
      createDepartment,
      getEmployees,
      updateEmployee,
      deleteEmployee
    )
  )

OpenAPI documentation auto-generates from endpoint definitions and serves at /docs.

Database Setup with Testcontainers

val startPostgresContainer =
  ZIO.fromAutoCloseable {
    ZIO.attemptBlockingIO {
      val container = PostgreSQLContainer("postgres:13.18-alpine3.20")
      container.withDatabaseName("example")
      container.withUsername("sa")
      container.withPassword("sa")
      container.start()
      container
    }
  }

def createDataSource(jdbcUrl: String, username: String, password: String) =
  ZIO.fromAutoCloseable {
    ZIO.attemptBlockingIO {
      val config = HikariConfig()
      config.setJdbcUrl(jdbcUrl)
      config.setUsername(username)
      config.setPassword(password)
      HikariDataSource(config)
    }
  }

val dataSourceLayer =
  ZLayer.scoped {
    for
      postgresContainer <- startPostgresContainer
      dataSource        <- createDataSource(
                             postgresContainer.getJdbcUrl,
                             postgresContainer.getUsername,
                             postgresContainer.getPassword
                           )
    yield dataSource
  }

Table Creation

def createTables(xa: Transactor) =
 xa.transact {
   val departmentTable =
     sql"""
         CREATE TABLE ${tables.Department.table}(
           ${tables.Department.table.id}   SERIAL      NOT NULL,
           ${tables.Department.table.name} VARCHAR(50) NOT NULL,
           PRIMARY KEY(${tables.Department.table.id})
         )
     """

   departmentTable.update.run()
 }

val dbLayer =
 for
   dataSource <- dataSourceLayer
   xa         <- Transactor.layer(dataSource.get)
   _          <- ZLayer(createTables(xa.get))
 yield xa

Application Startup

object Main extends ZIOAppDefault with Router:
  val run =
    Server
      .serve(routes ++ swaggerRoutes)
      .provide(
        Server.default,
        DepartmentServiceLive.layer,
        DepartmentRepositoryLive.layer,
        EmployeeServiceLive.layer,
        EmployeeRepositoryLive.layer,
        PhoneServiceLive.layer,
        PhoneRepositoryLive.layer,
        EmployeePhoneServiceLive.layer,
        EmployeePhoneRepositoryLive.layer,
        dbLayer
      )

Logging Configuration

object Main extends ZIOAppDefault with Router:
  val logFormat =
    LogFormat.label("name", LoggerNameExtractor.loggerNameAnnotationOrTrace.toLogFormat())
      + LogFormat.space
      + LogFormat.default
      + LogFormat.space
      + LogFormat.allAnnotations

  val logFilterConfig =
    LogFilter.LogLevelByNameConfig(
      LogLevel.Info,
      "com.augustnagro.magnum" -> LogLevel.Debug
    )

  override val bootstrap =
    Runtime.removeDefaultLoggers
      ++ consoleLogger(ConsoleLoggerConfig(logFormat, logFilterConfig))
      ++ JULBridge.init(logFilterConfig.toFilter)

Request/response logging integrates via middleware:

val routes = Routes(...) @@ Middleware.requestLogging(logRequestBody = true, logResponseBody = true)

Enhanced Type Safety with Iron

Defining Refined Types

package com.example.domain

import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.all.*

type DepartmentIdDescription =
  DescribedAs[Greater[0], "Department's ID should be strictly positive"]

type DepartmentId = Int :| DepartmentIdDescription

object DepartmentId extends RefinedType[Int, DepartmentIdDescription]

Compile-time validation:

val validId = DepartmentId(100)  // Compiles
val invalidId = DepartmentId(0)   // Compilation error

Runtime validation:

val rawId: Int = ???
val departmentId = DepartmentId.either(rawId)  // Returns Either[String, DepartmentId]

Complex Constraints

type DepartmentNameDescription =
  DescribedAs[
    Alphanumeric & Not[Empty] & MaxLength[50],
    "Department's name should be alphanumeric, non-empty and have max length 50"
  ]

type DepartmentName = String :| DepartmentNameDescription

object DepartmentName extends RefinedType[String, DepartmentNameDescription]

type PhoneNumberDescription =
  DescribedAs[
    ForAll[Digit] & MinLength[6] & MaxLength[15],
    "Phone number should have length between 6 and 15"
  ]

type PhoneNumber = String :| PhoneNumberDescription

object PhoneNumber extends RefinedType[String, PhoneNumberDescription]

Schema Derivation for Iron Types

package com.example.util

import io.github.iltotore.iron.*
import zio.schema.*

inline given ironSchema[T, Description](
  using Schema[T], Constraint[T, Description]
): Schema[T :| Description] =
  Schema[T].transformOrFail(_.refineEither[Description], Right(_))

DbCodec Derivation for Iron Types

package com.example.util

import com.augustnagro.magnum.DbCodec
import io.github.iltotore.iron.*

inline given ironDbCodec[T, Description](
  using DbCodec[T], Constraint[T, Description]
): DbCodec[T :| Description] =
  DbCodec[T].biMap(_.refineUnsafe[Description], identity)

Updated Domain Models

final case class Department(name: DepartmentName) derives Schema
final case class Employee(name: EmployeeName, age: Age, departmentId: DepartmentId) derives Schema
final case class Phone(number: PhoneNumber) derives Schema

Entity Classes with Iron Types

@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)
final case class Employee(
  @Id id: EmployeeId,
  name: EmployeeName,
  age: Int,
  departmentId: DepartmentId
) derives DbCodec:
  val toDomain = domain.Employee(name, age, departmentId)

Endpoints with Iron Types

trait Codecs:
  inline def idCodec[Description](
    name: String = "id"
  )(using Constraint[Int, Description]): PathCodec[Int :| Description] =
    int(name).transformOrFail[Int :| Description](_.refineEither[Description])(Right(_))

trait EmployeePhoneEndpoints extends Codecs:
  val addPhoneToEmployee =
    Endpoint {
      Method.POST
        / "employee" / idCodec[EmployeeIdDescription]("employeeId")
        / "phone" / idCodec[PhoneIdDescription]("phoneId")
    }
      .out[Unit]
      .outError[AppError](Status.NotFound, Doc.p("Employee/phone not found"))

Handlers with Iron Types

trait EmployeePhoneHandlers:
  def addPhoneToEmployeeHandler(
    employeeId: EmployeeId,
    phoneId: PhoneId
  ): ZIO[EmployeePhoneService, AppError, Unit] =
    ZIO.serviceWithZIO[EmployeePhoneService](_.addPhoneToEmployee(phoneId, employeeId))

Iron catches parameter ordering issues at compile-time that would previously only surface at runtime.

Summary

The article demonstrates a complete REST API implementation in Scala 3 leveraging three complementary libraries:

ZIO HTTP provides the Endpoints API for declarative endpoint definitions that automatically generate OpenAPI documentation and Swagger UI, separating descriptions from implementations.

Magnum offers compile-time CRUD derivation for typical database operations while supporting custom SQL queries through a type-safe string interpolator, seamlessly integrating with ZIO for resource management.

Iron enables refined types that encode constraints at the type level, making illegal states unrepresentable. Automatic codec derivations integrate Iron types throughout the application stack—from endpoint definitions through repositories—ensuring invalid data never enters the system.

The combination creates a boundary where the application logic operates exclusively with valid data, with compile-time verification preventing entire categories of bugs from reaching runtime.

Additional Resources