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