MongoDB #

MongoDB is a NoSQL document database that stores data in BSON (Binary JSON) format — flexible documents that don’t require a fixed schema and can nest naturally. Instead of tables with rows and columns, MongoDB uses collections containing documents, each of which can have a different structure. This is very suitable for data with changing structures, product catalogs with different attributes per category, event logs, or naturally hierarchical data. In Kotlin, there are two main ways: MongoDB Driver for JVM (the official, complete but verbose driver) and KMongo (a more idiomatic Kotlin wrapper with kotlinx.serialization support). This article covers both in depth.

When to Use MongoDB vs SQL #

Before choosing MongoDB, understand when it’s appropriate:

CHOOSE MongoDB if:
  ✓ Data has a varied structure (products with different attributes per category)
  ✓ Naturally hierarchical data often accessed together (order + items + address)
  ✓ The schema needs to change often without complex migrations
  ✓ Very high write volume and horizontal scaling needs
  ✓ Geospatial or time-series data
  ✓ Fast prototyping without database schema overhead

CHOOSE SQL (PostgreSQL, MySQL) if:
  ✓ Relational data needing complex JOINs between entities
  ✓ Very strict ACID transactions (finance, inventory)
  ✓ Complex reporting and analytics (GROUP BY, window functions, CTEs)
  ✓ The team is already familiar with SQL
  ✓ Referential integrity matters (foreign keys)

Setup and Dependencies #

// build.gradle.kts
dependencies {
    // MongoDB Driver for JVM (official)
    implementation("org.mongodb:mongodb-driver-sync:5.0.1")

    // KMongo — idiomatic Kotlin wrapper (optional, an alternative to the official driver)
    implementation("org.litote.kmongo:kmongo:4.11.0")
    implementation("org.litote.kmongo:kmongo-serialization:4.11.0")

    // kotlinx.serialization for data class serialization
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}

// Add to plugins if using KMongo with serialization
plugins {
    kotlin("plugin.serialization") version "2.0.0"
}

Connecting to MongoDB #

import com.mongodb.client.MongoClient
import com.mongodb.client.MongoClients
import com.mongodb.client.MongoDatabase
import com.mongodb.MongoClientSettings
import com.mongodb.ServerAddress
import com.mongodb.ConnectionString

object MongoConnection {
    private val client: MongoClient by lazy {
        // Simple connection
        MongoClients.create("mongodb://localhost:27017")

        // Or a connection with all options
        // MongoClients.create(
        //     MongoClientSettings.builder()
        //         .applyConnectionString(ConnectionString(
        //             "mongodb://user:***@localhost:27017/myapp?authSource=admin"
        //         ))
        //         .applyToConnectionPoolSettings { builder ->
        //             builder.maxSize(10).minSize(2)
        //         }
        //         .applyToSocketSettings { builder ->
        //             builder.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
        //         }
        //         .build()
        // )
    }

    // Connection to Atlas (MongoDB Cloud)
    private fun createAtlasClient(): MongoClient {
        val uri = System.getenv("MONGODB_URI")
            ?: "mongodb+srv://user:***@cluster0.abcde.mongodb.net/?retryWrites=true&w=majority"
        return MongoClients.create(uri)
    }

    fun database(name: String = "myapp"): MongoDatabase {
        return client.getDatabase(name)
    }

    fun close() = client.close()
}

Data Models and Serialization #

MongoDB stores BSON documents. To work with Kotlin data classes, there are two approaches:

Using Document (the Flexible Approach) #

import org.bson.Document
import org.bson.types.ObjectId

// Create a document manually
val document = Document()
    .append("name", "Gaming Laptop")
    .append("price", 15_000_000.0)
    .append("stock", 10)
    .append("category", "Electronics")
    .append("tags", listOf("gaming", "laptop", "premium"))
    .append("specs", Document()
        .append("ram", "32GB")
        .append("storage", "1TB SSD")
        .append("gpu", "RTX 4070")
    )

// Access values from the document
val name = document.getString("name")
val price = document.getDouble("price")
@Suppress("UNCHECKED_CAST")
val tags = document.get("tags") as List<String>
val specs = document.get("specs", Document::class.java)
println(specs?.getString("ram"))  // 32GB

Using Data Classes with KMongo and Serialization #

import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
import org.bson.codecs.pojo.annotations.BsonId
import org.bson.types.ObjectId

@Serializable
data class Specs(
    val ram: String? = null,
    val storage: String? = null,
    val gpu: String? = null,
    val cpu: String? = null
)

@Serializable
data class Product(
    @SerialName("_id")
    val id: String = ObjectId().toHexString(),  // MongoDB uses _id
    val name: String,
    val price: Double,
    val stock: Int = 0,
    val category: String? = null,
    val tags: List<String> = emptyList(),
    val specs: Specs? = null,
    val active: Boolean = true
)

CRUD with the Official Driver #

import com.mongodb.client.MongoCollection
import com.mongodb.client.model.*
import com.mongodb.client.result.*
import org.bson.Document
import org.bson.conversions.Bson
import org.bson.types.ObjectId

class MongoProductRepository {

    private val collection: MongoCollection<Document> =
        MongoConnection.database().getCollection("produk")

    // CREATE — insertOne
    fun save(product: Product): String {
        val document = Document()
            .append("nama", product.name)
            .append("harga", product.price)
            .append("stok", product.stock)
            .append("kategori", product.category)
            .append("tag", product.tags)
            .append("aktif", product.active)

        product.specs?.let { specs ->
            document.append("spesifikasi", Document()
                .append("ram", specs.ram)
                .append("storage", specs.storage)
                .append("gpu", specs.gpu)
            )
        }

        val result = collection.insertOne(document)
        return result.insertedId?.asObjectId()?.value?.toHexString()
            ?: throw RuntimeException("Failed to save product")
    }

    // CREATE — insertMany (batch)
    fun saveMany(list: List<Product>): Int {
        val documents = list.map { p ->
            Document("nama", p.name)
                .append("harga", p.price)
                .append("stok", p.stock)
                .append("kategori", p.category)
                .append("aktif", p.active)
        }
        val result = collection.insertMany(documents)
        return result.insertedIds.size
    }

    // READ — findOne
    fun findById(id: String): Document? {
        return collection.find(Filters.eq("_id", ObjectId(id))).firstOrNull()
    }

    // READ — find with filters
    fun findAll(
        category: String? = null,
        maxPrice: Double? = null,
        activeOnly: Boolean = true,
        limit: Int = 20,
        skip: Int = 0
    ): List<Document> {
        val filters = mutableListOf<Bson>()

        if (activeOnly) filters.add(Filters.eq("aktif", true))
        if (category != null) filters.add(Filters.eq("kategori", category))
        if (maxPrice != null) filters.add(Filters.lte("harga", maxPrice))

        val query = if (filters.isEmpty()) Document() else Filters.and(filters)

        return collection.find(query)
            .sort(Sorts.ascending("nama"))
            .skip(skip)
            .limit(limit)
            .into(mutableListOf())
    }

    // READ — search by name with regex (case-insensitive)
    fun findByName(keyword: String): List<Document> {
        val filter = Filters.regex("nama", keyword, "i")  // "i" = case-insensitive
        return collection.find(filter).into(mutableListOf())
    }

    // READ — with projection (only fetch certain fields)
    fun findNameAndPrice(): List<Document> {
        val projection = Projections.fields(
            Projections.include("nama", "harga", "kategori"),
            Projections.excludeId()  // hide _id
        )
        return collection.find()
            .projection(projection)
            .into(mutableListOf())
    }

    // UPDATE — updateOne
    fun updatePrice(id: String, newPrice: Double): Boolean {
        val filter = Filters.eq("_id", ObjectId(id))
        val update = Updates.set("harga", newPrice)
        val result = collection.updateOne(filter, update)
        return result.modifiedCount > 0
    }

    // UPDATE — update many fields at once
    fun update(id: String, name: String, price: Double, stock: Int): Boolean {
        val filter = Filters.eq("_id", ObjectId(id))
        val update = Updates.combine(
            Updates.set("nama", name),
            Updates.set("harga", price),
            Updates.set("stok", stock),
            Updates.currentDate("diperbarui_pada")  // set the timestamp automatically
        )
        return collection.updateOne(filter, update).modifiedCount > 0
    }

    // UPDATE — add to an array (push)
    fun addTag(id: String, tag: String): Boolean {
        val filter = Filters.eq("_id", ObjectId(id))
        val update = Updates.addToSet("tag", tag)  // addToSet avoids duplicates
        return collection.updateOne(filter, update).modifiedCount > 0
    }

    // UPDATE — increment a numeric value
    fun addStock(id: String, quantity: Int): Boolean {
        val filter = Filters.eq("_id", ObjectId(id))
        val update = Updates.inc("stok", quantity)  // atomic increment
        return collection.updateOne(filter, update).modifiedCount > 0
    }

    // UPSERT — insert if not present, update if present
    fun upsert(name: String, price: Double, stock: Int): String {
        val filter = Filters.eq("nama", name)
        val update = Updates.combine(
            Updates.setOnInsert("nama", name),
            Updates.set("harga", price),
            Updates.set("stok", stock),
            Updates.setOnInsert("aktif", true),
            Updates.currentDate("diperbarui_pada")
        )
        val options = UpdateOptions().upsert(true)
        val result = collection.updateOne(filter, update, options)

        return result.upsertedId?.asObjectId()?.value?.toHexString()
            ?: filter.toBsonDocument().getString("nama").value
    }

    // DELETE — deleteOne
    fun delete(id: String): Boolean {
        val result = collection.deleteOne(Filters.eq("_id", ObjectId(id)))
        return result.deletedCount > 0
    }

    // Soft delete
    fun deactivate(id: String): Boolean {
        return collection.updateOne(
            Filters.eq("_id", ObjectId(id)),
            Updates.set("aktif", false)
        ).modifiedCount > 0
    }
}

Aggregation Pipelines #

Aggregation is how MongoDB processes documents through a series of stages — equivalent to GROUP BY, JOIN, and window functions in SQL:

import com.mongodb.client.model.Accumulators
import com.mongodb.client.model.Aggregates

fun statisticsByCategory(): List<Document> {
    val pipeline = listOf(
        // Stage 1: filter only active products
        Aggregates.match(Filters.eq("aktif", true)),

        // Stage 2: group by category
        Aggregates.group(
            "\$kategori",  // group key
            Accumulators.sum("totalProduk", 1),
            Accumulators.sum("totalStok", "\$stok"),
            Accumulators.avg("rataHarga", "\$harga"),
            Accumulators.min("hargaMin", "\$harga"),
            Accumulators.max("hargaMax", "\$harga")
        ),

        // Stage 3: sort by product count
        Aggregates.sort(Sorts.descending("totalProduk")),

        // Stage 4: format the output with $project
        Aggregates.project(
            Document("_id", 0)
                .append("kategori", "\$_id")
                .append("totalProduk", 1)
                .append("totalStok", 1)
                .append("rataHarga", Document("\$round", listOf("\$rataHarga", 0)))
                .append("hargaMin", 1)
                .append("hargaMax", 1)
        )
    )

    return MongoConnection.database()
        .getCollection("produk")
        .aggregate(pipeline)
        .into(mutableListOf())
}

// Aggregation example with unwind (expand arrays)
fun statisticsPerTag(): List<Document> {
    val pipeline = listOf(
        Aggregates.match(Filters.eq("aktif", true)),
        Aggregates.unwind("\$tag"),           // split the tag array into separate documents
        Aggregates.group(
            "\$tag",
            Accumulators.sum("jumlah", 1),
            Accumulators.push("produk", "\$nama")
        ),
        Aggregates.sort(Sorts.descending("jumlah")),
        Aggregates.limit(10)                   // top 10 tags
    )

    return MongoConnection.database()
        .getCollection("produk")
        .aggregate(pipeline)
        .into(mutableListOf())
}

Creating Indexes #

import com.mongodb.client.model.IndexOptions
import com.mongodb.client.model.Indexes

fun createIndexes() {
    val collection = MongoConnection.database().getCollection("produk")

    // Single index
    collection.createIndex(Indexes.ascending("kategori"))
    collection.createIndex(Indexes.descending("harga"))

    // Compound index
    collection.createIndex(
        Indexes.compoundIndex(
            Indexes.ascending("kategori"),
            Indexes.descending("harga")
        )
    )

    // Unique index
    collection.createIndex(
        Indexes.ascending("nama"),
        IndexOptions().unique(true)
    )

    // Text index (for full-text search)
    collection.createIndex(
        Indexes.compoundIndex(
            Indexes.text("nama"),
            Indexes.text("deskripsi")
        )
    )

    // Index on a nested field
    collection.createIndex(Indexes.ascending("spesifikasi.ram"))

    // TTL index — documents are automatically deleted after N seconds
    collection.createIndex(
        Indexes.ascending("dibuat_pada"),
        IndexOptions().expireAfter(30L, java.util.concurrent.TimeUnit.DAYS)
    )
}

// Full-text search using a text index
fun searchText(keyword: String): List<Document> {
    return MongoConnection.database()
        .getCollection("produk")
        .find(Filters.text(keyword))
        .projection(Projections.metaTextScore("score"))
        .sort(Sorts.metaTextScore("score"))
        .into(mutableListOf())
}

KMongo — The Idiomatic Kotlin Approach #

KMongo is a wrapper that makes MongoDB interaction more Kotlin-idiomatic, with full support for data classes and kotlinx.serialization:

// build.gradle.kts
// implementation("org.litote.kmongo:kmongo-serialization:4.11.0")

import org.litote.kmongo.*
import org.litote.kmongo.serialization.registerSerializer

@Serializable
data class Product(
    val id: String = newId<Product>().toString(),
    val name: String,
    val price: Double,
    val stock: Int = 0,
    val category: String? = null,
    val active: Boolean = true
)

fun kmongoExample() {
    // Setup KMongo with serialization
    val client = KMongo.createClient("mongodb://localhost:27017")
    val db = client.getDatabase("myapp")
    val collection = db.getCollection<Product>("produk")

    // INSERT
    val product = Product(name = "Laptop", price = 15_000_000.0, stock = 5)
    collection.insertOne(product)

    // FIND — type-safe with a data class
    val laptop = collection.findOne(Product::name eq "Laptop")
    println(laptop?.price)  // 15000000.0

    // FIND with idiomatic filters
    val expensive = collection.find(Product::price gt 10_000_000.0).toList()
    println(expensive.size)

    // FIND with multiple filters
    val activeExpensive = collection.find(
        and(
            Product::active eq true,
            Product::price gt 5_000_000.0
        )
    ).sort(ascending(Product::name)).toList()

    // UPDATE
    collection.updateOne(
        Product::name eq "Laptop",
        setValue(Product::price, 14_000_000.0)
    )

    // DELETE
    collection.deleteOne(Product::name eq "Laptop")

    // UPSERT
    collection.updateOne(
        Product::name eq "Keyboard",
        product.copy(name = "Keyboard"),
        upsert()
    )

    client.close()
}

Multi-Document Transactions #

MongoDB has supported ACID transactions since version 4.0 (requires a replica set or sharded cluster):

import com.mongodb.client.ClientSession
import com.mongodb.TransactionOptions
import com.mongodb.ReadConcern
import com.mongodb.WriteConcern

fun transferStock(fromId: String, toId: String, quantity: Int) {
    val client = MongoClients.create("mongodb://localhost:27017")
    val db = client.getDatabase("myapp")
    val collection = db.getCollection("produk")

    val session: ClientSession = client.startSession()

    val txOptions = TransactionOptions.builder()
        .readConcern(ReadConcern.SNAPSHOT)
        .writeConcern(WriteConcern.MAJORITY)
        .build()

    try {
        session.startTransaction(txOptions)

        // Decrease the source product's stock
        val decreaseResult = collection.updateOne(
            session,
            Filters.and(
                Filters.eq("_id", ObjectId(fromId)),
                Filters.gte("stok", quantity)  // make sure the stock is sufficient
            ),
            Updates.inc("stok", -quantity)
        )

        if (decreaseResult.modifiedCount == 0L) {
            throw IllegalStateException("Insufficient stock or product not found")
        }

        // Increase the destination product's stock
        collection.updateOne(
            session,
            Filters.eq("_id", ObjectId(toId)),
            Updates.inc("stok", quantity)
        )

        session.commitTransaction()
        println("Transfer successful: $quantity units from $fromId to $toId")

    } catch (e: Exception) {
        session.abortTransaction()
        println("Transfer aborted: ${e.message}")
        throw e
    } finally {
        session.close()
        client.close()
    }
}

Tips and Design Patterns #

Embedding vs Referencing #

EMBED documents if:
  ✓ Data is always accessed together (order + order items)
  ✓ One-to-few relationships (user + addresses, at most a few)
  ✓ Data doesn't change independently

REFERENCE (store IDs) if:
  ✓ Data can be accessed independently (products, users)
  ✓ Many-to-many relationships
  ✓ Documents would be too large if embedded (>16MB limit)
  ✓ Data is frequently updated independently
// Embedding pattern — orders with items directly inside
data class OrderItem(val productId: String, val name: String, val price: Double, val quantity: Int)
data class Order(
    val id: String = ObjectId().toHexString(),
    val userId: String,
    val items: List<OrderItem>,  // embedded — no JOIN needed
    val total: Double,
    val status: String = "PENDING"
)

// Referencing pattern — products as references (IDs)
data class CartItem(
    val productId: String,   // only store the ID
    val quantity: Int
)

Summary #

  • MongoDB for hierarchical and flexible data — if your entities naturally nest (order → items → product) and are often accessed together, MongoDB avoids expensive SQL JOINs.
  • _id is mandatory and unique — MongoDB automatically creates the _id field with an ObjectId if not provided. Always use _id as the primary identifier, not a custom field.
  • Use Filters, Updates, Sorts — these builder classes make queries safer from injection and easier to read than manual query strings.
  • Updates.inc() for atomic counters — to increase or decrease numeric values (stock, view counts), use inc rather than read-modify-write which is prone to race conditions.
  • addToSet not push for unique arraysaddToSet only adds an element if it doesn’t exist, avoiding duplicates. push always adds.
  • Aggregation pipelines for analyticsmatch → group → sort → project is a very common pattern for aggregation reports. More efficient than loading all data into Kotlin and processing it there.
  • Index before large queries — MongoDB without an index performs a collection scan that’s very slow. Always create indexes on frequently queried fields, especially those in filters and sorts.
  • KMongo for more idiomatic codeProduct::name eq "Laptop" is far safer and easier to read than Filters.eq("name", "Laptop"). Type-safe with great IDE auto-complete.

← Previous: PostgreSQL   Next: Elasticsearch →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact