mongo4s
Effect-agnostic MongoDB client and repository layer for Scala 3. No hardcoded cats-effect or fs2 — the runtime
(cats-effect / ZIO / Kyo / rapid) and the BSON codec (your own derivation, or medeia / zio-bson /
calypso) are
independent modules, each wired in through a given import. core depends on neither.
Install
libraryDependencies += "now.sendline" %% "mongo4s-cats" % "6.0.0"
import mongo4s.cats.CatsInstances.givenlibraryDependencies += "now.sendline" %% "mongo4s-zio" % "6.0.0"
import mongo4s.zio.ZioInstances.givenlibraryDependencies += "now.sendline" %% "mongo4s-kyo" % "6.0.0"
import mongo4s.kyo.KyoInstances.givenlibraryDependencies += "now.sendline" %% "mongo4s-rapid" % "6.0.0"
import mongo4s.rapid.RapidInstances.givenThe runtime you pick here is remembered on every page and in every library. mongo4s-kyo needs JDK 25 at runtime; every other module runs on JDK 17.
At a glance
mongo4s wraps the official mongodb-driver-reactivestreams directly. A type-safe
Field/Filter/Update builder replaces string-keyed queries, PrimaryKey turns an entity into single- or
compound-key lookups, and BaseMongoRepository gives you CRUD/batch operations over a collection for free. Every
piece is interpretable against a real MongoDB and an in-memory FakeMongoCollection, so repositories are
unit-testable without a running database.
flowchart LR
app["your application"] --> repositories
app --> core
subgraph core["mongo4s-core"]
CC["MongoClient · MongoDatabase · MongoCollection"]
QQ["Field · Filter · Update · PrimaryKey"]
end
repositories["mongo4s-repositories<br/>BaseMongoRepository"] --> core
RT["runtime<br/>cats-effect · ZIO · Kyo · rapid"] -->|given Effect, RsBridge| core
WC["bson-direct<br/>WireCodec (AST-free)"] -->|given WireCodec| core
BR["bson bridges<br/>medeia · zio-bson · calypso"] -->|given BsonDocumentCodec| core
Quick start
Pick a runtime. The default codec (bson-direct) derives straight from your case class — AST-free, no
third-party codec library, nothing beyond mongo4s-core, which brings it in:
libraryDependencies ++= Seq(
"now.sendline" %% "mongo4s-cats" % "6.0.0", // mongo4s-core + cats-effect integration
"now.sendline" %% "mongo4s-bson-direct" % "6.0.0", // ast-free bson codecs
"now.sendline" %% "mongo4s-bson-cats-data" % "6.0.0", // if you need NonEmptyList etc. codec instances
"now.sendline" %% "mongo4s-repositories" % "6.0.0", // if you need auto-generated CRUD repository ops for your model
)import cats.effect.{IO, IOApp}
import mongo4s.cats.{CatsStream, MongoClientResource}
import mongo4s.bson.direct.WireCodec
import mongo4s.{Field, PrimaryKey}
import mongo4s.repositories.BaseMongoRepository
import mongo4s.cats.CatsInstances.given
final case class User(id: String, name: String, age: Int) derives WireCodec
object User:
given PrimaryKey[User, String] = PrimaryKey.single("id")(_.id)
object Main extends IOApp.Simple:
def run: IO[Unit] =
MongoClientResource.fromConnectionString[IO]("mongodb://localhost:27017").use { client =>
for
db <- client.getDatabase("myapp")
collection <- db.getDirectCollection[User]("users")
users = BaseMongoRepository(collection)
_ <- users.insertOne(User("1", "Alice", 30))
alice <- users.findOne("1")
adults <- users.findByFilter(Field.of[User, Int](_.age).gte(18))
yield ()
}Swap mongo4s-cats for mongo4s-zio / mongo4s-kyo / mongo4s-rapid and the matching *Instances.given import to
change runtime — the body of the for is identical. What does change is how you take the client, since each runtime
keeps its own resource idiom: cats gives you a Resource to .use, ZIO a scoped ZIO and kyo a Scope effect (both
bind inside the for and are discharged by ZIO.scoped/Scope.run), and rapid takes the body as a function —
MongoClientResource.fromConnectionString(uri) { client => … }. See
examples/ for one app per runtime.
BsonEncoder/BsonDecoder for built-in types (String,
Int, Option, List, Vector, Set, Seq, …) resolve with no import at all — no
mongo4s.bson.BsonInstances.given needed unless you're summoning one directly.
Already have a model on medeia, zio-schema, or calypso? Swap derives WireCodec + getDirectCollection for
derives MedeiaDocumentCodec/etc. + getCollection (and BaseMongoRepository.create[F, S, User, String](db, "users")
instead of constructing it from a collection directly — E and K come from the type arguments, not from the
arguments, so they have to be written out) — see BSON codecs below for all four backends.
Already have a MongoClientSettings built elsewhere (connection pool tuning, read/write concerns, TLS, credentials,
…)? Use MongoClientResource.fromSettings instead of fromConnectionString. MongoClient.fromClient/fromSettings/
fromConnectionString give you the same thing unwrapped, if you'd rather own .close yourself.
MongoClientResource.fromClient(client) takes the driver client over: releasing the resource closes it, so hand it
a client nothing else is still using.
client.ping(timeout) asks the server for {ping: 1} and answers whether it replied in time — false rather than a
failure when it did not, the timeout applied by the driver itself. For a readiness check that should not wait on the
network, MongoClientResource.reachability(client, interval) pings in the background and hands back the last answer:
MongoClientResource.reachability(client, 5.seconds).use { reachable =>
reachable.flatMap(up => IO.println(s"mongo reachable: $up"))
}MongoClientSettings sets read/write concerns for the whole client. To narrow them, MongoDatabase and
MongoCollection both carry withReadConcern/withWriteConcern/withReadPreference, each returning a new
handle rather than mutating the one you have:
val durable = collection.withWriteConcern(WriteConcern.MAJORITY)
durable.insertOne(user) // majority-acknowledged
collection.insertOne(other) // still the client's defaultBecause the derived handle is an ordinary MongoCollection, per-operation control is just chaining:
collection.withWriteConcern(w).insertOne(x). Concerns inherit database → collection, as they do in the driver, and
a collection derived this way keeps the codec it was opened with — including the WireCodec that
getDirectCollection registers.
withTimeout(duration) does the same for the driver's client-side operation timeout (timeoutMS): every operation
through the handle — server selection, the command and reading its cursor — has that long in all, and one that runs
out fails as MongoError.ExecutionTimeout. Duration.Zero means no limit; set on a database, it is inherited by the
collections opened from it. The fake accepts it and ignores it, as it ignores maxTime.
val bounded = collection.withTimeout(500.millis)
bounded.find(adults).all // fails as ExecutionTimeout after half a second in allA driver CodecRegistry is not how you plug a codec into mongo4s, though — see
Codecs and the driver's registry.
For more examples see examples/src/main/scala/mongo4s/examples — a
shared domain model (opaque types, enums, nested case classes) run through every runtime/codec combination
(cats + medeia, ZIO + zio-bson, kyo + medeia, rapid + calypso), a repository example covering all four
BaseMongoRepository construction styles against bson-direct, and a sessions/transactions + typed aggregation
pipeline example on cats + medeia. Most of what this README shows is compiled there too —
ReadmeSnippets.scala walks the same ground section by
section, so if the API moves and these docs don't, CI fails.
Adopters
Using mongo4s? Open a PR to add your logo.