Back to Blog

How to Build a CLI Application, Step-by-Step, Using ZIO, Quill & ZIO CLI

December 10, 2024
How to Build a CLI Application, Step-by-Step, Using ZIO, Quill & ZIO CLI

What Are We Going to Build?

The application is called BiblioteK — a bibliographic resource management CLI tool. It manages resources (books, videos) and associated materials (PDFs, videos) on a user's computer.

The tool supports commands like:

  • Creating resources with metadata (title, authors, category, publication year)
  • Listing resources organized by category
  • Deleting resources
  • Managing materials associated with resources
  • Filtering and organizing displayed information

Models

The implementation uses the newtype pattern to prevent type confusion. The code uses Monix's Newtype library with either NewtypeWrapper or NewtypeValidated depending on whether validation is needed.

import monix.newtypes.NewtypeValidated

enum ResourceKind:
  case Book
  case Video

final case class Resource(
    id: Resource.Id,
    title: String,
    kind: ResourceKind,
    category: Option[Category],
    publicationYear: Option[Short],
    authors: Set[Author],
)

object Resource:
  type Id = Id.Type
  object Id extends NewtypeValidated[String]:
    def apply(value: String): Either[BuildFailure[Type], Type] =
      if value.length() == 8 then Right(unsafeCoerce(value))
      else Left(BuildFailure("String wasn't 8 characters long"))

    def make: UIO[Id] =
      makeShortUUID.map: s =>
        Id(s).getOrElse(throw new RuntimeException("unreachable"))
  end Resource

final case class ResourceRow(
    id: Resource.Id,
    title: String,
    kind: ResourceKind,
    publicationYear: Option[Short],
    categoryName: Option[Category.Name] = None,
)

Additional model definitions:

final case class Author(id: Author.Id, name: String)

object Author:
  type Id = Id.Type
  object Id extends NewtypeWrapped[UUID]

  final case class Category(name: Category.Name)

object Category:
  type Name = Name.Type
  object Name extends NewtypeWrapped[String]

final case class Material(id: Material.Id, name: Material.Name, resourceId: Resource.Id)

object Material:
  type Id = Id.Type
  object Id extends NewtypeWrapped[UUID]
  type Name = Name.Type
  object Name extends NewtypeWrapped[String]

Error Modeling

Domain errors inherit from a sealed DomainError trait. This approach leverages ZIO's covariant error type (+E) for better error handling.

package dev.zio.content.bibliotek.domain

import java.io.IOException
import java.sql.SQLException
import scala.reflect.runtime.universe.*
import scala.util.control.NoStackTrace

sealed trait DomainError(val message: String) extends NoStackTrace

object DomainError:
  final case class RepositoryError(exception: SQLException)
      extends DomainError(message = exception.getMessage())
  final case class IOError(exception: IOException)
      extends DomainError(message = exception.getMessage())
  case class NotFoundError[A](what: A) extends DomainError(message = s"$what was not found")

Configuration

Configuration uses environment variables with fallbacks. The BIBLIOTEK_CONFIG_DIR environment variable specifies storage location; otherwise, it defaults to $HOME/.bibliotek. The implementation uses ZIO's native configuration support.

given config: Config[AppConfig] =
  Config
    .string("BIBLIOTEK_CONFIG_DIR")
    .orElse(Config.string("user.home"))
    .map(Path(_) / ".bibliotek")
    .map(AppConfig.apply)

def layer = ZLayer.fromZIO(ZIO.config(config))

Data Access with SQLite & Quill

SQLite dependency:

"org.xerial" % "sqlite-jdbc" % sqliteVersion

DataSource creation:

def makeSqliteDataSource(dbFile: Path): UIO[SQLiteDataSource] =
  val ds = new SQLiteDataSource()
  ZIO.succeed(ds.setUrl(s"jdbc:sqlite:$dbFile")).as(ds)

DataSource layer:

def dataSourceLayer: RLayer[AppConfig, DataSource] =
  ZLayer.fromZIO:
    for
      cfg     <- ZIO.service[AppConfig]
      filePath = cfg.configDir / "bibliotek.db"
      ds      <- makeSqliteDataSource(filePath)
      _       <- ZIO.attemptBlocking(cfg.configDir.toFile.mkdir()).debug("Created database directory")
      _       <- ZIO.attemptBlocking(filePath.toFile.createNewFile()).debug("Created database file")
    yield ds

Database setup:

The application loads an init.sql resource file, splits statements by semicolons, and executes each using ZIO.foreach.

def setupDatabase: RIO[Quill.Sqlite[SnakeCase], Unit] =
  for
    quill     <- ZIO.service[Quill.Sqlite[SnakeCase]]
    sql       <- ZIO.attemptBlocking(Source.fromResource("init.sql").mkString)
    statements = sql.split(";").dropRight(1).map(_ ++ ";")
    _         <- ZIO.foreach(statements)(s => quill.executeAction(s)(io.getquill.context.ExecutionInfo.unknown, ()))
  yield ()

Quill layer:

val sqliteLayer: RIO[DataSource, Quill.Sqlite[SnakeCase]] = Quill.Sqlite.fromNamingStrategy(SnakeCase)

Quill Encoders and Decoders

Custom MappedEncoding instances handle newtype serialization.

import io.getquill.{MappedEncoding, SnakeCase}

trait ResourceSchema:
  given resourceIdEncoder: MappedEncoding[UUID, Resource.Id] = MappedEncoding[UUID, Resource.Id](Resource.Id(_))
  given resourceIdDecoder: MappedEncoding[Resource.Id, UUID] = MappedEncoding[Resource.Id, UUID](_.value)

  given resourceKindEncoder: MappedEncoding[ResourceKind, String] =
    MappedEncoding[ResourceKind, String](_.toString.toLowerCase)
  given resourceKindDecoder: MappedEncoding[String, ResourceKind] =
    MappedEncoding[String, ResourceKind](s => ResourceKind.valueOf(s.capitalize))
end ResourceSchema

Repositories

Repositories implement interfaces separated from implementations. ResourcesRepoLive extends both the interface and schema traits.

Interface and implementation:

// Interface
trait ResourcesRepo:
  def listResources: Stream[RepositoryError, Resource]

// Implementation
case class ResourcesRepoLive(protected val quill: Quill.Sqlite[SnakeCase])
    extends ResourcesRepo
    with ResourceSchema:
  def listResources: Stream[RepositoryError, Resource] = ???

Query schema:

class ResourcesRepoLive(protected val quill: Quill.Sqlite[SnakeCase])
    extends ResourcesRepo
    with ResourceSchema
    with CategorySchema
    with AuthorSchema:
  import quill.*

  inline def resources = quote:
    querySchema[ResourceRow]("resources")

List resources:

override def listResources: Stream[RepositoryError, ResourceRow] =
  val expr = quote(resources)
  stream(expr).refineOrDie:
      case e: SQLException => RepositoryError(e)

Add resource (with retry for ID collisions):

override def addResource(data: ResourceData): IO[RepositoryError, ResourceRow] =
  val effect =
    for
      resource <- ResourceRow.fromData(data)
      _        <- run(quote(resources.insertValue(lift(resource))))
    yield resource

  effect
    .retryN(2)
    .mapError(RepositoryError(_))
end addResource

Remove resource with transaction:

override def removeResource(resourceId: Resource.Id): IO[DomainError, Unit] =
  val expr = quote:
    resources.filter(_.id == lift(resourceId)).delete

  val deleteAuthorsAssocs = quote:
    resourceAuthors.filter(_.resourceId == lift(resourceId)).delete

  transaction(run(expr) <&> run(deleteAuthorsAssocs))
    .mapError:
      case e: SQLException => RepositoryError(e)
    .unit
end removeResource

Join example:

override def getResourceAuthors(resourceId: Resource.Id): Stream[RepositoryError, Author] =
  val expr = quote:
    for
      ra     <- resourceAuthors.filter(ra => ra.resourceId == lift(resourceId))
      author <- authors.join(a => ra.authorId == a.id)
    yield author

  stream(expr).refineOrDie:
    case e: SQLException => RepositoryError(e)
end getResourceAuthors

Material Store

Materials are stored as files in a dedicated directory within the configuration folder. File storage combines the material ID with its name: ${materialId.value}.${name.value}.

ZIO NIO dependency:

libraryDependencies += "dev.zio" %% "zio-nio" % zioVersion

Store implementation:

import zio.nio.file.Path

class MaterialStoreLive(storeDir: Path) extends MaterialStore

object MaterialStoreLive:
  val layer = ZLayer:
    for
      cfg        <- ZIO.service[AppConfig]
      materialDir = cfg.configDir / "material"
      _          <- ZIO.attemptBlocking(materialDir.toFile.mkdir())
    yield MaterialStoreLive(materialDir)

Store file:

override def store(
    materialId: Material.Id,
    name: Material.Name,
    file: Path,
): IO[IOError, MaterialSource] =
  val destFilename = s"${materialId.value}.${name.value}"
  val destPath     = storeDir / destFilename

  val materialSource = MaterialSource(materialId, destPath)
  Files
    .copy(file, destPath, StandardCopyOption.REPLACE_EXISTING)
    .as(materialSource)
    .mapError:
      case e: IOException => IOError(e)
end store

List materials files:

override def listMaterialsFiles: Stream[DomainError, MaterialSource] =
  Files
    .walk(storeDir)
    .drop(1)
    .map: filePath =>
      Material
        .Id(filePath.filename.toString.split('.').head)
        .map: id =>
          MaterialSource(id, filePath)
    .collect:
      case Right(material) => material
    .mapError:
      case e: IOException => IOError(e)

Delete material (with revert on failure):

private def deleteMaterial: MaterialSource => IO[DomainError, Unit] =
  case MaterialSource(_, source) =>
    Files.delete(source).mapError { case e: IOException =>
      IOError(e)
    }

override def delete(id: Material.Id): IO[DomainError, Unit] =
  getMaterialFile(id).flatMap(deleteMaterial)

The Librarian: Glueing Everything Together

The Librarian layer composes infrastructure components into user-facing functionality.

class LibrarianLive(
    materialStore: MaterialStore,
    materialsRepo: MaterialsRepo,
    resourcesRepo: ResourcesRepo,
    authorsRepo: AuthorsRepo,
    categoriesRepo: CategoriesRepo,
):
  // ...

Create resource helpers:

private def createCategoryIfDoesNotExist(name: Category.Name): IO[DomainError, Category] =
  categoriesRepo
    .getByName(name)
    .catchSome:
      case NotFoundError(_) =>
        categoriesRepo.insert(Category(name)).as(Category(name))

private def createAuthorIfDoesNotExist(name: String): IO[DomainError, Author] =
  authorsRepo
    .getByName(name)
    .catchSome:
      case NotFoundError(_) =>
        for
          id    <- Author.Id.make
          author = Author(id, name)
          _     <- authorsRepo.insert(author)
        yield author

Create resource:

def createResource(
    title: String,
    kind: ResourceKind,
    category: Option[Category.Name],
    publicationYear: Option[Short],
    authors: Set[String],
): IO[DomainError, ResourceRow] =
  val resourceData = ResourceData(title, kind, publicationYear, category)
  for
    authors  <- ZIO.foreach(authors)(createAuthorIfDoesNotExist(_))
    _        <- ZIO.fromOption(category).foldZIO(_ => ZIO.none, createCategoryIfDoesNotExist(_).asSome)
    resource <- resourcesRepo.addResource(resourceData)
    _        <- resourcesRepo.addAuthorsToResource(resource.id, authors.map(_.id).toSeq*)
  yield resource
end createResource

List resources helper:

private def resourceFromRow(row: ResourceRow): IO[DomainError, Resource] =
  for
    category <-
      ZIO.fromOption(row.categoryName).foldZIO(_ => ZIO.none, categoriesRepo.getByName(_).asSome)
    authors  <- resourcesRepo.getResourceAuthors(row.id).runCollect.map(_.toSet)
  yield Resource(row.id, row.title, row.kind, category, row.publicationYear, authors)

private def listResourcesFromRows(categoryOpt: Option[Category.Name] = None) =
  resourcesRepo.listResources(categoryOpt).mapZIO(resourceFromRow(_))

Stream extension methods:

import zio.stream.*
import zio.*

extension [R, E, O](s: ZStream[R, E, O])
  def simpleGroupByKey[K](f: O => K): ZStream[R, E, (K, O)] = s.groupByKey(f): (k, ss) =>
    ss.map(k -> _)

extension [R, E, K, O](s: ZStream[R, E, (K, O)])
  def runCollectMap: ZIO[R, E, Map[K, UStream[O]]] =
    s.runFold(Map.empty[K, UStream[O]]):
      case (s, (k, t)) =>
        s + (k -> (s.get(k).getOrElse(ZStream.empty) ++ ZStream.succeed(t)))

List resources:

def listResources(
    categoryOpt: Option[Category.Name],
): IO[DomainError, Map[Category.Name, UStream[Resource]]] =
  listResourcesFromRows(categoryOpt)
    .simpleGroupByKey(_.category.getOrElse(Category.root).name)
    .runCollectMap

Add material (with revert on file storage failure):

def addMaterial(
    name: Option[String],
    resourceId: Resource.Id,
    file: JPath,
): IO[DomainError, Material] =
  val filePath = Path.fromJava(file)

  for
    material <- materialsRepo.addMaterial(MaterialData(name, resourceId, filePath))
    _        <- materialStore
                  .store(material.id, material.name, filePath)
                  .tapError: _ =>
                    materialsRepo.deleteMaterial(material.id)
  yield material
end addMaterial

List materials:

def listMaterials(
    resourceIdOpt: Option[Resource.Id],
): IO[DomainError, Map[Category.Name, UStream[(Resource, List[Material])]]] =
  listResourcesFromRows()
    .mapZIO(r => materialsRepo.listMaterials(Some(r.id)).runCollect.map(r -> _.toList))
    .simpleGroupByKey(_._1.category.getOrElse(Category.root).name)
    .runCollectMap
end listMaterials

Remove material from material ID:

val fromMaterialId =
  for
    materialId <-
      ZIO.fromEither(Material.Id(resourceOrMaterialId)).mapError(e => NewtypeBuildError(e))
    material   <- materialsRepo.getMaterialById(materialId)
    _          <- materialsRepo.deleteMaterial(materialId)
    _          <- deleteFromStoreWithRevert(material)
  yield List(materialId)

Remove material from resource ID:

val fromResourceId =
  for
    resourceId <-
      ZIO.fromEither(Resource.Id(resourceOrMaterialId)).mapError(e => NewtypeBuildError(e))
    materials  <-
      materialsRepo
        .listMaterials(Some(resourceId))
        .filter(m => materialNameOpt.fold(true)(materialName => m.name == materialName))
        .runCollect
        .map(_.toList)
        .flatMap:
          case Nil  =>
            ZIO.fail:
              NotFoundError(
                s"$resourceOrMaterialId${materialNameOpt.fold("")(name => s" $name")}",
              )
          case list => ZIO.succeed(list)
    _          <- ZIO.foreachDiscard(materials)(material => materialsRepo.deleteMaterial(material.id))
    _          <- ZIO.foreachDiscard(materials)(material => deleteFromStoreWithRevert(material))
  yield materials.map(_.id)

CLI: The Entrypoint

Commands use Algebraic Data Types (ADTs) for nested subcommand structure.

sealed trait Subcommand extends Product with Serializable

object Subcommand:
  sealed trait Resources extends Subcommand
  object Resources:
    sealed trait ResourcesSubcommand extends Subcommand

  sealed trait Materials extends Subcommand

  object Materials:
    sealed trait MaterialsSubcommand extends Subcommand

Resources Commands

Create command ADT:

object Subcommand:
  object Resources:
    object ResourcesSubcommand:
      final case class Create(
          title: String,
          kind: ResourceKind,
          category: Option[Category.Name],
          publicationYear: Option[Short],
          authors: Set[String],
      ) extends ResourcesSubcommand

Create command definition:

import zio.cli.*

val resourcesCreate = Command(
  "create",
  Options.text("title") ++
    Options.text("kind").mapTry(s => ResourceKind.valueOf(s.toLowerCase.capitalize))
    ++ Options.text("category").map(Category.Name(_)).optional
    ++ Options.integer("publication-year").map(_.toShort).optional
    ++ Options.text("authors").??("author list separated by commas").alias("author").optional,
).map:
  case (title, kind, category, publicationYear, authorsOpt) =>
    Subcommand.Resources.ResourcesSubcommand.Create(
      title,
      kind,
      category,
      publicationYear,
      authorsOpt.map(authors => authors.split(",").map(_.trim()).toSet).getOrElse(Set()),
    )

Create handler:

case ResourcesSubcommand.Create(t, k, c, p, a) =>
  librarian
    .createResource(t, k, c, p, a)
    .fold(
      e => s"An error has occurred while creating the resource: ${e.message}",
      r => s"Resource with id ${r.id} was created",
    )
    .flatMap(zio.Console.printLine(_))

List command definition:

val resourcesList =
  Command("list", Args.text("category").map(Category.Name(_)).atMost(1))
    .map(category => Subcommand.Resources.ResourcesSubcommand.List(category.headOption))
    .withHelp("Lists all resources or under a single ")

List display helper:

private def showResource(r: Resource): String =
  def showMeta: Resource => String =
    r =>
      val authors         = r.authors.map(_.name).mkString(", ")
      val publicationYear = r.publicationYear.map(_.toString).getOrElse("")
      if !authors.isBlank() || !publicationYear.isBlank() then
        s"(${List(authors, publicationYear).filterNot(_.isBlank()).mkString(", ")})"
      else ""

  val meta = showMeta(r)
  List(s"[${r.id}]", r.title, meta).filterNot(_.isBlank()).mkString(" ") ++ "."
end showResource

private def showCategory(
    categoryName: Category.Name,
    resources: UStream[(Resource, List[Material])],
): UStream[String] =
  ZStream(categoryName.value.capitalize) ++ resources.flatMap: (r, ms) =>
    ZStream("\t- " ++ showResource(r)) ++ ZStream
      .fromIterable(ms)
      .map(m => "\t\t* " ++ showMaterial(m))

def showResources(resourcesMap: Map[Category.Name, UStream[Resource]]): UStream[String] =
  showResourcesWithMaterials(resourcesMap.map((k, v) => (k, v.map(_ -> List.empty))))

def showResourcesWithMaterials(
    resourcesMap: Map[Category.Name, UStream[(Resource, List[Material])]],
): UStream[String] =
  val rootOpt  = resourcesMap.get(Category.root.name)
  val noRoot   = resourcesMap - Category.root.name
  val rootShow =
    rootOpt.fold(ZStream.empty)(rootResources => showCategory(Category.root.name, rootResources))

  rootShow ++ ZStream.fromIterable(noRoot).flatMap(showCategory)
end showResourcesWithMaterials

List handler:

case ResourcesSubcommand.List(category) =>
  librarian
    .listResources(category)
    .map(CliDisplayer.showResources(_))
    .flatMap(s => s.runForeach(zio.Console.printLine(_)))

Remove command definition:

val resourcesRemove =
  Command(
    "remove",
    Args
      .text("resource-id")
      .mapOrFail(Resource.Id(_).left.map(e => HelpDoc.p(e.toReadableString))),
  ).map(id => Subcommand.Resources.ResourcesSubcommand.Remove(id))

Remove handler:

case ResourcesSubcommand.Remove(resourceId) =>
  librarian
    .deleteResource(resourceId)
    .map(_ => s"Resource $resourceId was successfully deleted.")
    .flatMap(zio.Console.printLine(_))

Materials Commands

Add command definition:

val materialsAdd =
  Command(
    "add",
    Options.text("name").optional,
    Args
      .text("resource-id")
      .mapOrFail(Resource.Id(_).left.map(e => HelpDoc.p(e.toReadableString)))
      ++ Args.file("file", Exists.Yes),
  ).map:
    case (name, (resourceId, file)) =>
      Subcommand.Materials.MaterialsSubcommand.Add(name, resourceId, file)

Add handler:

case MaterialsSubcommand.Add(name, resourceId, file) =>
  librarian.addMaterial(name, resourceId, file)

List command definition:

val materialsList = Command(
  "list",
  Args
    .text("resource-id")
    .mapOrFail(Resource.Id(_).left.map(e => HelpDoc.p(e.toReadableString)))
    .atMost(1),
).map: resourceId =>
  Subcommand.Materials.MaterialsSubcommand.List(resourceId.headOption)

Material display:

private def showMaterial(m: Material): String =
  s"[${m.id}] ${m.name}"

List handler:

case MaterialsSubcommand.List(resourceId) =>
  librarian
    .listMaterials(resourceId)
    .flatMap: resourcesMap =>
      CliDisplayer
        .showResourcesWithMaterials(resourcesMap)
        .runForeach(zio.Console.printLine(_))

Remove command definition:

def materialsRemove = Command(
  "remove",
  Args
    .text("resource-or-material-id") ++ Args.text("material-name").map(Material.Name(_)).atMost(1),
).map: (resourceOrMaterialId, materialName) =>
  Subcommand.Materials.MaterialsSubcommand.Remove(resourceOrMaterialId, materialName.headOption)

Remove handler:

case MaterialsSubcommand.Remove(resourceOrMaterialId, materialName) =>
  librarian
    .removeMaterial(resourceOrMaterialId, materialName)
    .map: mis =>
      "The following materials were deleted:\n" ++ mis
        .map(mi => s"\t* Material ${mi.value}\n")
        .mkString
    .flatMap(zio.Console.print(_))

Command Composition

val materials = Command("materials")
  .withHelp("help")
  .subcommands(materialsAdd, materialsList, materialsRemove)

val btek = Command("btek", Options.none, Args.none).subcommands(resources, materials)

CLI app:

override def cliApp = CliApp
  .make("btek", "0.1.0-SNAPSHOT", HelpDoc.Span.text("sample docs"), btek): cmd =>
    val program =
      ZIO
        .service[LibrarianLive]
        .flatMap: librarian =>
          cmd match
            case  // Subcommands =>

Dependency Injection with Layers

ZIO layers make the dependency tree explicit. There are two equivalent approaches:

Simple flat approach:

program.provide(
  AppConfig.layer,
  dataSourceLayer,
  sqliteLayer,
  MaterialStoreLive.layer,
  MaterialsRepoLive.layer,
  ResourcesRepoLive.layer,
  AuthorsRepoLive.layer,
  CategoriesRepoLive.layer,
  LibrarianLive.live,
)

Hierarchical approach:

val appLayer   = AppConfig.layer >+> dataSourceLayer >+> sqliteLayer
val infraLayer = appLayer >>>
  (MaterialStoreLive.layer ++
    MaterialsRepoLive.layer ++
    ResourcesRepoLive.layer ++
    AuthorsRepoLive.layer ++
    CategoriesRepoLive.layer)

val programLayer = infraLayer >>> (LibrarianLive.live ++ appLayer)

The >+> operator sequences layers while keeping their outputs available downstream. The ++ operator composes layers in parallel.

Suggested Improvements

The following extensions are recommended as next steps:

  • Add database/file cleanup commands to reconcile discrepancies between the database and filesystem
  • Implement material file opening with platform-specific support
  • Add comprehensive test coverage
  • Develop a Terminal User Interface (TUI) for interactive use