MongoDB with ZIO 2

Boris The Astronaut
5 min readAug 29, 2022

--

One of the most common and popular ways of working with MongoDB on JVM is through its native mongo-java-driver client, which is based on Reactive Streams API. On the surface, Reactive Streams initiative is a huge improvement over traditional ways of accessing data, as it defines a standard for asynchronous stream processing with non-blocking back pressure. However, when it comes to Scala (and functional programming in general), Reactive Streams have a lot of downsides as they are represented with mutable, unsafe and complicated data structures.

What would have been better instead is to capture our stream of data as a sequences of values that may have been produced by an effect (i.e. database query) with a pull-based semantics and where errors can be handled in the usual pure functional programming way. One of the ways of achieving this is through mongo4cats library.

Effectively, mongo4cats is a wrapper around native mongo-java-driver client which provides a functional way for constructing programs that use MongoDB. There are several variations of it available: mongo4cats-core achieves this through Cats Effect and FS2, whereas mongo4cats-zio does this with ZIO and ZIO-Streams.

In this article I will go through the basic requirements that are needed to start integrating MongoDB with ZIO and show you what you need to do to start working with the data in your MongoDB collection.

Dependencies

To start we only require a single dependency in the build.sbt file:

libraryDependencies += “io.github.kirill5k” %% “mongo4cats-zio” % “0.6.0”

On its own, mongo4cats-zio is based on ZIO (version 2.0.0) , mongo-java-driver (version 4.7.0) and Cats (version 2.8.0).

Creating a client

To make a connection to a running MongoDB instance, we need to create a ZMongoClient first. This can be done through several constructor methods available in ZMongoClient companion object:

import mongo4cats.models.client._
import mongo4cats.client._
import mongo4cats.zio._
import zio._
// From a connection string
val clientFromConnString: RIO[Scope, ZMongoClient] =
ZMongoClient.fromConnectionString(“mongodb://localhost:27017”)
// By providing ServerAddress
val clientFromServerAddress: RIO[Scope, ZMongoClient] =
ZMongoClient.fromServerAddress(ServerAddress(“localhost”, 27017))
// By providing Connection
val connection = MongoConnection(
“localhost”,
27017,
Some(MongoCredential(“username”, “password”)),
MongoConnectionType.Srv
)
val clientFromConnection: RIO[Scope, ZMongoClient] =
ZMongoClient.fromConnection(connection)
// By providing custom MongoClientSettings object
val settings = MongoClientSettings.builder.build()
val clientFromSettings: RIO[Scope, ZMongoClient] =
ZMongoClient.create(settings)

ZMongoClient represents a pool of connections to a MongoDB database and typically only one instance of this class is needed per application. To prevent leakage of resources, the connection to a MongoDB instance needs to be disposed at the end of its lifecycle, however you don’t have to worry about it as everything will be done for you by ZIO! ZMongoClient is built as a resource (hence the reason why we have Scope in our environment), meaning that connection will be closed when the scope is closed.

Furthermore, for a more convenient usage and dependency injection, ZMongoClient can be put into a ZLayer:

val clientLayer: TaskLayer[ZMongoClient] = 
ZLayer.scoped[Any](clientFromConnString)

Accessing a database

Once we have a ZMongoClient connected to a MongoDB instance, it can be further used for accessing a database:

val database: Task[ZMongoDatabase] =
clientFromConnString.getDatabase(“my-db”)

Or you can simply build it through layer:

val databaseLayer: RLayer[ZMongoClient, ZMongoDatabase] =
ZLayer.fromZIO(ZIO.serviceWithZIO[ZMongoClient](_.getDatabase(“my-db”)))

If the database associated with the provided name does not exist, it will be created by MongoDB when you’ll start storing data in there.

Working with collections

The last remaining element needed before we will be able to start putting data into our database is ZMongoCollection[T], where T is the data type of stored documents. By default, all documents are represented as Document from mongo4cats.bson package. However, this can be changed to any data type, given that there are necessary codecs provided (more on this later).

Collection can be accessed using getCollection method available in ZMongoDatabase:

val collection: RIO[ZMongoDatabase, ZMongoCollection[Document]] =
ZIO.serviceWithZIO[ZMongoDatabase](_.getCollection(“my-collection”))

Similarly to the getDatabase method in ZMongoClient, if upon calling the getCollection method the collection does not exist, it will be created later when we will start inserting data.

Furthermore, ZMongoDatabase has several other methods that can be used for creating new collections, listing the existing ones or dropping the entire database:

val options = CreateCollectionOptions(
maxDocuments = 100,
capped = true,
sizeInBytes = 1024
)
val newCollection: RIO[ZMongoDatabase, ZMongoCollection[Document]] =
ZIO.serviceWithZIO[ZMongoDatabase] { db =>
db.createCollection(“new-collection”, options) *>
db.getCollection(“new-collection”)
}

Inserting documents

Finally, we have everything needed to start working with the data.

Let’s create a document first:

import mongo4cats.bson.Document
import mongo4cats.bson.syntax._
val document = Document(
“firstName” := “John”,
“lastName” := “Doe”,
“hobbies” := List(“skating”, “coding”)
)

Documents are composed of bson key-value pairs (Tuple2[String, BsonValue]), however, using extension methods from mongo4cats.bson.syntax package, we can convert a regular tuple to bson key-value pair with := extension method.

To insert a single document into our collection, the insertOne method can be used:

val insertOneResult = collection.insertOne(document)

Or, if needed, multiple documents can be inserted at once:

val documents = (0 to 100).map(i => Document(“index”:= i))
val insertManyResult = collection.insertMany(documents)

Querying the collection

We have now inserted data into our collection, but how do we get it back? To query the collection, we can use the collection’s find method.

To get the very first document associated with your query, simply use first:

val maybeDocument = collection.find.first

Note: first actually returns Option[T] as theoretically such document might not exist in the collection.

Additionally, find provides several other options (filtering, sorting, projection, etc.) for narrowing the results of our query:

import mongo4cats.operations._val maybeDocument = collection.find
.filter(Filter.eq(“firstName”, “John”) && Filter.eq(“lastName”, “Doe”))
.sort(Sort.asc(“lastName”))
.projection(Projection.exclude(“hobbies”))
.first

If you want to get all document, then instead of first, we can use all:

val allDocuments = collection.find.all

The all method aggregates all documents returned by the query into a single list and returns them as Iterable[T]. If the returned result is expected to be too big to fit into the available memory, we can use stream instead:

val documentsStream = collection.find.stream

This will return us ZStream[Any, Throwable, T], enabling us to process documents one by one as they arrive from the database.

Final remarks

And that is it! Obviously, this is just a tip of the iceberg and there is a much wider functionality available (creating indexes, building aggregation pipelines, watching for database changes, managing transactions, getting distinct values, integrating with Circe, using embedded MongoDB, etc.), however with small tutorial should be more than enough to get you started integrating your ZIO-based application with MongoDB.

If you need to know more, feel free to browse through the documentation available on GitHub and/or explore the library on your own!

--

--

Boris The Astronaut
Boris The Astronaut

Written by Boris The Astronaut

Stackoverflow copy/paste developer

No responses yet