MongoDB with ZIO 2
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!