mongo4s

Effect-agnostic MongoDB client and repository layer for Scala 3 — any runtime, any BSON codec, AST-free by default.

CI status Maven Central version Scala 3 Apache 2.0 license

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.

mongo4s wraps the official mongodb-driver-reactivestreams directly — no mongo4cats underneath. 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 fake, so repositories are unit-testable without a running database.

Quick start

Pick a runtime and a codec bridge:

libraryDependencies ++= Seq(
  "org.mongo4s" %% "mongo4s-cats"        % "0.1.0",
  "org.mongo4s" %% "mongo4s-bson-medeia" % "0.1.0",
)
import cats.effect.{IO, IOApp}

import mongo4s.{Field, MongoClient, PrimaryKey}
import mongo4s.bson.BsonInstances.given
import mongo4s.bson.medeia.MedeiaDocumentCodec
import mongo4s.bson.medeia.MedeiaInstances.given
import mongo4s.cats.CatsInstances.given
import mongo4s.cats.CatsStream
import mongo4s.repositories.BaseMongoRepository

final case class User(id: String, name: String, age: Int) derives MedeiaDocumentCodec

object User:
  given PrimaryKey[User, String] = PrimaryKey.single("id")(_.id)

object Main extends IOApp.Simple:
  type S[A] = CatsStream[IO][A]

  def run: IO[Unit] =
    for
      client <- MongoClient.fromConnectionString[IO, S]("mongodb://localhost:27017")
      db     <- client.getDatabase("myapp")
      users  <- BaseMongoRepository.create[IO, S, User, String](db, "users")
      _      <- users.insertOne(User("1", "Alice", 30))
      alice  <- users.findOne("1")
      adults <- users.findByFilter(Field.of[User, Int](_.age).gte(18))
      _      <- client.close
    yield ()

Swap mongo4s-cats for mongo4s-zio / mongo4s-kyo / mongo4s-rapid and the matching *Instances.given import to change runtime — nothing else changes.

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) plus a repository example covering all three BaseMongoRepository construction styles against bson-direct.

Core concepts

MongoClient[F, S]MongoDatabase[F, S]MongoCollection[F, S, A] mirror the driver's own hierarchy, wrapped in your effect F[_] and stream type S[_].

Field.of[E, A](_.someField) is a macro that reads a field selector at compile time — no strings, no reflection — and gives you a typed path to build filters, updates, and sorts:

val adults = Field.of[User, Int](_.age).gte(18)
val named  = Field.of[User, String](_.name).equalTo("Alice") && adults
val setAge = Field.of[User, Int](_.age).set(31)
val city   = Field.of[Order, String](_.address.city).equalTo("Berlin")  // dotted paths from nested selectors

PrimaryKey[E, K] turns an entity into a key-based filter — single field, a native _id (ObjectId or your own encoder), or a compound key of up to four fields:

given PrimaryKey[User, String]         = PrimaryKey.single("id")(_.id)
given PrimaryKey[Note, ObjectId]       = PrimaryKey.objectId(_.id)          // entity has its own ObjectId field
given PrimaryKey[Order, (String, Int)] = PrimaryKey.make(o => (o.userId, o.seq), k => "user_id" -> k._1, k => "seq" -> k._2)

inFilter on a compound key produces an $or of $ands; on a single field it's a plain $in — and an empty key list always produces Filter.none, so findMany(Nil)/deleteMany(Nil) are safe no-ops instead of matching every document.

BSON codecs

Every codec ultimately produces a BsonDocumentCodec[A] (entity ⇄ org.bson.BsonDocument) or, for the AST-free path below, a WireCodec[A]. mongo4s never registers a global CodecProvider, so backends never collide inside one process.

ModuleBackendNotes
mongo4s-bson-medeiamedeiaderives BsonDocumentCodec
mongo4s-bson-ziozio-schema-bsonvia zio.schema.Schema
mongo4s-bson-calypsocalypsohand-written forProductN codecs
mongo4s-bson-directmongo4s itselfWireCodec[A], AST-free — see below

bson-direct — AST-free WireCodec

medeia/zio-bson/calypso all build an intermediate org.bson.BsonValue tree before it ever reaches the driver. WireCodec[A] skips that: derived via Mirror, it writes straight to the driver's own streaming BsonWriter/BsonReader — the same low-level SPI jsoniter-scala uses for JSON — no BsonDocument is ever built, on either side. No third-party codec dependency needed; bson-direct is transitively pulled in by mongo4s-core.

import mongo4s.bson.direct.WireCodec

final case class Address(city: String, zip: String) derives WireCodec
final case class Person(id: String, name: String, tags: List[String], address: Address) derives WireCodec

sealed trait Shape derives WireCodec
object Shape:
  final case class Circle(radius: Double)                   extends Shape derives WireCodec
  final case class Rectangle(width: Double, height: Double) extends Shape derives WireCodec

Products, Option, List, nested case classes, sealed traits/enums (via a _type discriminator field, written first), and self-/mutually-recursive types all derive directly — recursive derivation defers behind a lazy val internally so a type's own given never forces itself mid-construction. Anything with an existing BsonEncoder/BsonDecoder bridges automatically, at the cost of one BsonValue per field instead of zero.

getDirectCollection registers the derived codec with the driver via CodecRegistries.fromCodecs(...), so insert/find/replace/update/delete/bulkWrite decode straight to A — genuinely zero BsonDocument construction on the hot path, not just a thinner bridge:

final case class Person(id: String, name: String, age: Int) derives WireCodec
object Person:
  given PrimaryKey[Person, String] = PrimaryKey.single("id")(_.id)

for
  db         <- client.getDatabase("myapp")
  collection <- db.getDirectCollection[Person]("people")
  repo        = new BaseMongoRepository(collection)
  _          <- repo.insertOne(Person("1", "bob", 30))
yield ()

aggregate/distinct still go through BsonDocumentCodec/BsonDecoder on a direct collection (they're not the hot path); everything else — Filter/Update/ Field construction — is identical regardless of which codec backs the collection.

Repositories

BaseMongoRepository[F, S, E, K] implements count/find/insert/upsert/update/delete/bulkWrite, batched by batchSize (default 500), over any MongoCollection[F, S, E], from either codec path:

BaseMongoRepository.create[F, S, E, K](db, "collection")     // Projection.excludeId default
BaseMongoRepository.identified[F, S, E, K](db, "collection") // no projection — entities that store their own "_id"
BaseMongoRepository.objectId[F, S, E](db, "collection")      // WithId[ObjectId, E], auto _id round-trip

WithId[Id, E] wraps an entity with a separately-typed id (type Oid[E] = WithId[ObjectId, E]) and ships its own PrimaryKey/BsonDocumentCodec instances, for entities that don't carry their own id field.

For unit tests, FakeMongoCollection implements MongoCollection in memory — the exact same Filter/Update/Field AST the real driver interprets is interpreted against a TrieMap instead, so repository logic is testable without aggregate/ distinct/watch and without a running MongoDB.

Runtime backends

Each runtime module provides given Effect[F] and given RsBridge[F, S]:

ModuleEffectStreamNotes
mongo4s-catscats.effect.IOfs2.Streamvia fs2.interop.reactivestreams
mongo4s-ziozio.Taskzio.stream.ZStreamvia zio-interop-reactivestreams
mongo4s-kyokyo.IOkyo.Streamvia kyo-reactive-streams
mongo4s-rapidrapid.Taskrapid.Stream

Modules

Published for Scala 3 under org.mongo4s:

"org.mongo4s" %% "mongo4s-<module>" % "0.1.0"
KindModuleNotes
coremongo4s-coreclient/database/collection, Field/Filter/Update, PrimaryKey
bsonmongo4s-bson-corethe scalar + document codec seam
mongo4s-bson-directWireCodec — AST-free, no third-party dependency
mongo4s-bson-medeiabridges medeia
mongo4s-bson-ziobridges zio-schema-bson
mongo4s-bson-calypsobridges calypso
runtimemongo4s-catscats-effect 3 + fs2
mongo4s-zioZIO 2 + zio-streams
mongo4s-kyokyo 1.0.0-RC6
mongo4s-rapidrapid
repositoriesmongo4s-repositoriesBaseMongoRepository, Repository, WithId

Benchmarks

Three separate JMH harnesses, one developer machine — directional ballparks, not hardware-independent authorities. Run them on your own hardware before deciding on the numbers alone.

Codec backends — to org.bson.BsonDocument

CodecEncode ops/sDecode ops/s
calypso (forProductN)~3.36M~3.24M
medeia (derives)~1.40M~3.34M
zio-bson (zio-schema derived)~992k~2.98M
mongo4cats-zio-json~1.03M~920k
mongo4cats-circe~972k~665k

All three mongo4s codec bridges beat both mongo4cats codecs by 3–5× on decode — the cost of case class ↔ circe/zio-json ↔ mongo4cats.Bson ↔ org.Bson instead of straight to org.bson.

AST-free wire codec — all the way to real bytes

PathThroughputAlloc
direct encode~1.79M ops/s1792 B/op
medeiaFull encode~838k ops/s4712 B/op
direct decode~1.49M ops/s1088 B/op
medeiaFull decode~900k ops/s3200 B/op

WireCodec is ~2.1× the throughput on encode, ~1.7× on decode, and allocates 2.6–2.9× less — avoiding medeia's own intermediate AST and the driver's own BsonDocument. In typical CRUD the network round trip dwarfs this; it matters for bulk-decode-heavy paths — large cursor streams, ETL, big aggregations.

Runtime overhead — real MongoDB, every backend

Throughput — operations per second, higher is better
Operationcatsziorapidkyomongo4cats-catsmongo4cats-zio
insertOne227225622447250522772504
find(filter).all143215851551159513161325
find(filter).stream14041393155715483591147
updateOne200222222198221521382219
count(filter)180420141996197519051990

With a clean database on every trial, all six columns land within the same band — the TCP round trip to MongoDB dominates at this scale. The one real outlier is mongo4cats-cats' find(filter).stream (359 ops/s against its own .all's 1316) — it bridges through a hand-rolled Queue-backed Subscriber instead of fs2.interop.reactivestreams, which every mongo4s .stream() uses.

Codec choice under real MongoDB — mongo4s vs mongo4cats

The same harness isolates the codec dimension: four configs, all on cats-effect, against the same real MongoDB — mongo4s with bson-medeia vs bson-direct, and mongo4cats with circe vs zio-json. Two separate runs, same four stacks, two different questions.

Throughput — operations per second, higher is better
Operationmongo4s+medeiamongo4s+bson-directmongo4cats+circemongo4cats+zio-json
insertOne2321232323812405
insertMany (10 docs)2027201221732115
findOneById2047203121842122
findOneByFilter2117206622702194
findAll (~100 docs)1415147712991310
findStream (~100 docs)14261435386388
updateOne2026198821432159
deleteOne1063106211291108
count1852184719581916

Same finding as the runtime table above: all four land within the same band on every operation (differences within normal run-to-run noise, ~±5–10%) — the network round trip dominates regardless of codec. The one outlier is mongo4cats' find(filter).stream, ~6× slower than everything else and independent of codec (386 vs 388 ops/s for circe vs zio-json) — confirms it's the runtime's Queue-backed Subscriber bridge, not the codec.

Memory allocated per single call, in KB — lower is less garbage, not less work done
Operationmongo4s+medeiamongo4s+bson-directmongo4cats+circemongo4cats+zio-json
insertOne49.046.625.324.6
insertMany (10 docs)89.966.580.974.6
findOneById60.658.839.536.6
findOneByFilter60.558.739.336.5
findAll (~100 docs)419.4241.9951.7674.8
findStream (~100 docs)427.5240.33027.22746.2
updateOne47.046.922.422.4
deleteOne94.292.345.344.8
count56.556.531.531.5

bson-direct allocates less than bson-medeia on every single operation — the AST-free advantage measured in isolation above survives end-to-end through a real driver round trip, modestly on single-document ops (~4–6%) and much more on bulk reads (~42–44% less). mongo4cats allocates less than mongo4s on single-document ops, but far more on bulk reads (2–13× more on findAll/findStream) — its own mongo4cats.bson.BsonValue wrapper adds a full extra tree per document on top of org.bson's, and that cost multiplies with document count. Neither library is uniformly lighter — it depends on point-lookups vs bulk scans.

Design notes