28 Verwendung von JSON mit MongoDB: Die Developer Experience

MongoDB’s größte Stärke aus Developer-Perspektive ist seine JSON-ähnliche API. Wer mit modernen Web-Technologien arbeitet, kennt JSON bereits – es ist die lingua franca des Web. MongoDB erlaubt es, dieses Wissen direkt zu nutzen. Dokumente sehen aus wie JavaScript-Objekte, Queries wie JSON-Filter, Updates wie JSON-Patches. Die Lernkurve ist flach, die Produktivität hoch. Aber hinter dieser Einfachheit steckt Nuance – Operatoren mit subtilen Semantiken, Performance-Implikationen bei falscher Nutzung, und Type-Awareness, die JavaScript-Entwickler oft überrascht.

Dieses Kapitel durchläuft die praktische Verwendung von JSON in MongoDB, von simplen CRUD-Operationen bis zu komplexen Aggregationen. Der Fokus ist nicht auf Vollständigkeit (die offizielle Dokumentation ist dafür da), sondern auf das Verständnis der Patterns, häufige Fehler und Best Practices, die aus jahrelanger Produktion-Erfahrung stammen.

28.1 Die mongosh-Shell: JSON als Interface

mongosh, MongoDB’s moderne Shell, präsentiert eine JavaScript-Umgebung. Dokumente werden als JavaScript-Objekte manipuliert, Queries sind JavaScript-Expressions. Dies ist keine Emulation, sondern echtes JavaScript – mongosh läuft auf Node.js. Man kann beliebigen JavaScript-Code schreiben, Variablen definieren, Funktionen deklarieren, NPM-Packages laden.

Ein einfaches Insert zeigt die JSON-Nähe:

db.users.insertOne({
  username: "alice",
  email: "alice@example.com",
  age: 28,
  registeredAt: new Date(),
  roles: ["user", "moderator"],
  profile: {
    firstName: "Alice",
    lastName: "Smith",
    bio: "Coffee enthusiast and book lover"
  }
})

Dieses Objekt ist valides JavaScript und valides JSON (bis auf new Date(), das zu einem Date-Objekt evaluiert). Die Syntax ist intuitiv – keine XML-Verschachtelung, keine komplizierte Query Language, nur Objects und Arrays.

Die Response vom Server ist ebenfalls JSON-ähnlich:

{
  acknowledged: true,
  insertedId: ObjectId('65abc123def456789012345')
}

Das insertedId ist die generierte _id des neuen Dokuments. MongoDB generiert automatisch eine ObjectId, wenn keine _id explizit angegeben wird. Man kann eigene _id-Werte verwenden, aber sie müssen unique sein – duplikate führen zu Fehlern.

28.2 Create: Dokumente einfügen mit Nuancen

Das Einfügen von Dokumenten ist trivial, aber es gibt subtile Aspekte, die wichtig werden, wenn man über Hello-World-Beispiele hinausgeht.

insertOne vs. insertMany: insertOne fügt ein einzelnes Dokument ein und returned die insertedId. insertMany akzeptiert ein Array und fügt alle Dokumente in einer Operation ein:

db.products.insertMany([
  { name: "Laptop", price: 999.99, category: "electronics" },
  { name: "Mouse", price: 19.99, category: "electronics" },
  { name: "Desk", price: 299.99, category: "furniture" }
])

Die Response enthält ein insertedIds-Object mit den IDs aller eingefügten Dokumente. insertMany ist effizienter als multiple insertOne-Calls, weil es eine Batch-Operation ist – ein Netzwerk-Roundtrip statt mehrerer.

Aber: insertMany ist nicht transaktional per default. Schlägt eines der Dokumente fehl (etwa wegen Validation-Error oder duplicate _id), stoppt die Operation an diesem Punkt. Vorherige Dokumente sind bereits inserted, nachfolgende nicht. Das Verhalten ist konfigurierbar mit der ordered-Option:

db.products.insertMany([
  { _id: 1, name: "A" },
  { _id: 2, name: "B" },
  { _id: 1, name: "C" },  // Duplicate _id
  { _id: 3, name: "D" }
], { ordered: false })

Mit ordered: false versucht MongoDB, alle Dokumente einzufügen, selbst wenn manche fehlschlagen. Das Resultat ist partial success: Dokumente 1, 2 und 4 sind inserted, 3 ist failed. Das Default ordered: true würde nach Dokument 2 stoppen.

Write Concerns: Inserts (und alle Writes) können mit Write Concerns versehen werden, die bestimmen, wann die Operation als erfolgreich gilt:

db.users.insertOne(
  { username: "bob", email: "bob@example.com" },
  { writeConcern: { w: "majority", wtimeout: 5000 } }
)

Dies wartet, bis eine Mehrheit des Replica Sets den Write bestätigt hat, maximal 5 Sekunden. Ohne diese Option returned insertOne sofort, sobald der Primary bestätigt – die Secondaries könnten noch nicht repliziert haben. Für kritische Daten ist w: "majority" safer, aber langsamer.

Schema Flexibility vs. Discipline: MongoDB ist schemaless, aber das heißt nicht, dass man beliebige Strukturen mixen sollte. Eine Collection sollte eine konsistente Struktur haben, auch wenn sie nicht erzwungen ist. Dokumente in users sollten alle ähnliche Felder haben. Wildly unterschiedliche Strukturen machen Queries schwierig und Indizes ineffizient.

Schema Validation (JSON Schema) kann helfen, Konsistenz zu erzwingen:

db.createCollection("users", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["username", "email"],
      properties: {
        username: {
          bsonType: "string",
          minLength: 3,
          maxLength: 30
        },
        email: {
          bsonType: "string",
          pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
        },
        age: {
          bsonType: "int",
          minimum: 0,
          maximum: 150
        }
      }
    }
  }
})

Inserts, die die Validation nicht erfüllen, werden rejected. Dies verhindert Garbage-Data, kostet aber Flexibilität.

28.3 Read: Queries von simpel bis komplex

Das Lesen von Daten ist wo MongoDB’s Query Language glänzt – und wo die meisten Performance-Probleme entstehen. Ein simples Find ist trivial:

// Alle Dokumente
db.users.find()

// Dokumente mit spezifischem Feld
db.users.find({ username: "alice" })

// Multiple Kriterien (implicit AND)
db.users.find({ age: 28, "profile.city": "Berlin" })

Die Query ist ein Objekt, wo Keys Field-Namen sind und Values die erwarteten Werte. Nested Fields werden mit Dot-Notation adressiert. Implizit ist dies ein AND – alle Kriterien müssen matchen.

Comparison Operators: Für Ranges oder Inequalities nutzt man Operatoren:

// Alter zwischen 25 und 35
db.users.find({ age: { $gte: 25, $lte: 35 } })

// Username ist nicht "alice"
db.users.find({ username: { $ne: "alice" } })

// Email in bestimmter Liste
db.users.find({ email: { $in: ["alice@example.com", "bob@example.com"] } })

Operatoren beginnen mit $. Die Syntax { field: { $operator: value } } ist konsistent über alle Operatoren. Multiple Operatoren auf demselben Feld werden kombiniert:

// Alter >= 25 UND <= 35 UND != 30
db.users.find({ age: { $gte: 25, $lte: 35, $ne: 30 } })

Logical Operators: Für komplexere Logik gibt es $and, $or, $not, $nor:

// User ist entweder unter 25 ODER über 65
db.users.find({
  $or: [
    { age: { $lt: 25 } },
    { age: { $gt: 65 } }
  ]
})

// User ist in Berlin UND (role ist admin ODER moderator)
db.users.find({
  "profile.city": "Berlin",
  $or: [
    { role: "admin" },
    { role: "moderator" }
  ]
})

Die $or-Syntax nimmt ein Array von Conditions. Jede Condition ist selbst ein Query-Objekt. Dies erlaubt beliebig komplexe Logik, aber die Performance hängt stark von Indizes ab. Eine $or-Query ohne Index auf keinem der Felder ist ein Full-Collection-Scan – langsam bei großen Collections.

Array Queries: MongoDB hat spezielle Syntax für Array-Felder:

// User hat "moderator" in roles
db.users.find({ roles: "moderator" })

// Roles-Array hat mindestens 2 Elemente
db.users.find({ roles: { $size: 2 } })

// Roles enthält sowohl "user" als auch "moderator"
db.users.find({ roles: { $all: ["user", "moderator"] } })

// Mindestens ein Element in roles matched die Condition
db.users.find({ "roles.0": "admin" })  // Erstes Element ist "admin"

Für Arrays von Objekten gibt es $elemMatch:

// Orders mit mindestens einem item, das > $100 kostet
db.orders.find({
  items: {
    $elemMatch: {
      price: { $gt: 100 },
      quantity: { $gte: 2 }
    }
  }
})

Ohne $elemMatch würde MongoDB separate items matchen können – ein item mit price > 100 und ein anderes mit quantity >= 2. $elemMatch stellt sicher, dass dieselbe item beide Conditions erfüllt.

Projection: Queries returnen standardmäßig alle Felder. Für Performance kann man Felder limitieren:

// Nur username und email, keine _id
db.users.find(
  { age: { $gte: 25 } },
  { username: 1, email: 1, _id: 0 }
)

// Alle Felder außer profile
db.users.find(
  { age: { $gte: 25 } },
  { profile: 0 }
)

Projections mit 1 sind Inclusion (nur diese Felder), mit 0 sind Exclusion (alle außer diese). Man kann nicht mixen (außer _id, das man explizit excluden darf trotz Inclusion-Projection).

Projections reduzieren Netzwerk-Traffic und Memory-Nutzung. Wenn Dokumente 1 MB groß sind, aber man nur 10 KB braucht, ist Projection essentiell.

28.4 Update: Dokumente modifizieren mit Präzision

Updates in MongoDB sind mächtiger als einfaches “set field to value”. Die Update-Operatoren erlauben granulare Änderungen ohne das gesamte Dokument zu überschreiben.

updateOne vs. updateMany: updateOne updated das erste matchende Dokument, updateMany alle matchenden:

// Erhöhe Alters von alice um 1
db.users.updateOne(
  { username: "alice" },
  { $inc: { age: 1 } }
)

// Setze status auf "inactive" für alle ohne Login in 90 Tagen
db.users.updateMany(
  { lastLogin: { $lt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) } },
  { $set: { status: "inactive" } }
)

Der erste Parameter ist die Query (welche Dokumente), der zweite ist das Update (was ändern). Der Update-Teil nutzt Operatoren wie $set, $inc, $push, etc.

Update Operators: Die wichtigsten sind:

// Setze email, entferne temporaryToken
db.users.updateOne(
  { _id: userId },
  {
    $set: { email: "newemail@example.com" },
    $unset: { temporaryToken: "" }
  }
)

// Erhöhe loginCount, füge login-Timestamp zu history
db.users.updateOne(
  { username: "alice" },
  {
    $inc: { loginCount: 1 },
    $push: { loginHistory: new Date() }
  }
)

// Füge role zu roles, nur wenn nicht bereits vorhanden
db.users.updateOne(
  { username: "alice" },
  { $addToSet: { roles: "moderator" } }
)

Upserts: Manchmal will man “update wenn existiert, insert wenn nicht”. Dies ist ein Upsert:

db.counters.updateOne(
  { name: "pageViews" },
  { $inc: { count: 1 } },
  { upsert: true }
)

Wenn ein Dokument mit name: "pageViews" existiert, wird count inkrementiert. Wenn nicht, wird ein neues Dokument erstellt mit name: "pageViews" und count: 1. Upserts sind atomic – keine Race Conditions zwischen Check-if-exists und Insert.

replaceOne: Statt Update-Operatoren kann man das gesamte Dokument ersetzen:

db.users.replaceOne(
  { username: "alice" },
  {
    username: "alice",
    email: "alice@example.com",
    age: 29,
    // Komplett neues Dokument
  }
)

Dies überschreibt das alte Dokument komplett, behält nur die _id. Replace ist gefährlich – man kann versehentlich Felder verlieren. Update-Operatoren sind safer und expliziter.

28.5 Delete: Dokumente entfernen mit Vorsicht

Das Löschen ist permanent (ohne Backups). Die API ist simpel, aber Vorsicht ist geboten.

// Lösche ein Dokument
db.users.deleteOne({ username: "temporaryUser" })

// Lösche alle inaktiven Users
db.users.deleteMany({ status: "inactive" })

Ein häufiger Fehler: deleteMany ohne Query-Filter löscht ALLE Dokumente:

// GEFÄHRLICH: Löscht alle Dokumente in der Collection
db.users.deleteMany({})

Für Drop-Collection ist db.collection.drop() expliziter und auch schneller (Filesystem-Level-Delete statt Dokument-für-Dokument).

Soft Deletes: In vielen Anwendungen ist Hard-Delete unerwünscht. Statt Dokumente zu löschen, markiert man sie als deleted:

db.users.updateOne(
  { username: "alice" },
  {
    $set: { 
      deleted: true, 
      deletedAt: new Date() 
    }
  }
)

// Queries excluden deleted Dokumente
db.users.find({ deleted: { $ne: true } })

Dies erlaubt Audit-Trails und versehentliches Löschen rückgängig zu machen. Der Trade-off: Die Collection wächst unbegrenzt. Periodic Cleanup von alten soft-deleted Dokumenten ist nötig.

28.6 Aggregation Framework: Queries auf Steroiden

Die Aggregation-Pipeline ist MongoDB’s mächtigstes Query-Tool. Sie erlaubt komplexe Datenverarbeitung – Filtering, Grouping, Sorting, Reshaping, Joining – in einer Pipeline von Stages.

Eine Pipeline ist ein Array von Stages. Jede Stage transformiert die Dokumente und passed sie zur nächsten Stage:

db.orders.aggregate([
  // Stage 1: Filter
  { $match: { 
      orderDate: { 
        $gte: new Date("2024-01-01"),
        $lt: new Date("2024-02-01")
      }
  }},
  
  // Stage 2: Unwind items array
  { $unwind: "$items" },
  
  // Stage 3: Group by product, sum quantities
  { $group: {
      _id: "$items.productId",
      totalQuantity: { $sum: "$items.quantity" },
      totalRevenue: { $sum: { $multiply: ["$items.quantity", "$items.price"] } }
  }},
  
  // Stage 4: Sort by revenue descending
  { $sort: { totalRevenue: -1 } },
  
  // Stage 5: Limit to top 10
  { $limit: 10 }
])

Diese Pipeline: 1. Filtert Orders aus Januar 2024 2. “Entfaltet” das items-Array – ein Order mit 3 items wird zu 3 Dokumenten 3. Gruppiert nach productId, summiert Quantity und Revenue 4. Sortiert nach Revenue 5. Limitiert auf Top 10

Das Resultat ist die Top-10-Produkte nach Revenue im Januar.

Common Stages:

$lookup für Joins: MongoDB ist kein relationales System, aber manchmal braucht man Daten aus mehreren Collections:

db.orders.aggregate([
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customerInfo"
    }
  },
  {
    $unwind: "$customerInfo"
  },
  {
    $project: {
      orderId: 1,
      "customerInfo.name": 1,
      "customerInfo.email": 1,
      total: 1
    }
  }
])

Dies joined Orders mit Customers, embedded Customer-Info in jedes Order-Dokument. Für viele Queries ist dies langsamer als denormalized Data (Embedding), aber manchmal ist Normalisierung nötig.

Performance-Bewusstsein: Aggregation-Pipelines können teuer sein. $match früh in der Pipeline ist kritisch – es reduziert die Dokument-Menge, die durch teure Stages wie $lookup oder $group laufen muss. Indizes helfen nur bei initialen $match oder $sort – sobald Dokumente transformiert sind (etwa nach $project oder $unwind), sind Indizes nicht mehr anwendbar.

Die folgende Tabelle fasst CRUD und Aggregation zusammen:

Operation Method Use-Case Performance-Hinweis
Create insertOne / insertMany Neue Dokumente Batch-Inserts effizienter
Read find() / findOne() Queries Indizes kritisch
Update updateOne / updateMany Änderungen Update-Operatoren > Replace
Delete deleteOne / deleteMany Entfernen Soft-Delete oft besser
Aggregation aggregate() Komplexe Analysen $match früh, Indizes nutzen

MongoDB’s JSON-ähnliche API ist intuitiv für JavaScript-Entwickler, aber die Tiefe und Mächtigkeit zeigt sich erst mit Erfahrung. Operatoren wie $elemMatch, Upserts, Aggregation-Pipelines – diese sind nicht in einem Nachmittag gemeistert. Die Best Practice: Mit simplen Queries starten, Komplexität graduell hinzufügen, und immer Performance monitoren. Die JSON-Syntax ist elegant, aber schlechte Queries sind immer noch langsam, egal wie schön die Syntax ist.