Mert Özen Further With Every Line
Database MongoDB — #05

Document Modeling 2: Schema Patterns, Schema Validation and Schema Evolution

Mert Özen Nov 14, 2026 19 min 11 views
Document Modeling 2: Schema Patterns, Schema Validation and Schema Evolution

MongoDB is not schemaless but flexible: patterns such as computed, subset, bucket, polymorphic and archive shape the model around what is read, and $jsonSchema validation protects data quality in the database; the constraint level in 9.0 guarantees that every document follows the rules. With real output: a review summary in the book, the latest 3 reviews, hourly buckets, three edition types in one collection, migration with schemaVersion, moderate versus strict and moving to constraint.

Version note: The examples in this post were run with MongoDB 9.0.8 Community Server and mongosh 2.6. The pattern examples gave the same output on MongoDB 8.0.38; the 8.0 differences on the validation side are in the relevant section.

Guess First

In the review collection of Bookstore, the online bookstore used as the shared example across this site's series, there is a review written years ago by another application, and its rating is not a number but the string "5". The team adds a validator to reviews: rating must be an int between 1 and 5. The validator is set up first at the moderate level and then at strict, and each time some text is added to the old review:

for (const validationLevel of ["moderate", "strict"]) {
    bookstore.runCommand({ collMod: "reviews", validator: { $jsonSchema: reviewSchema }, validationLevel, validationAction: "error" });
    tryWrite(`${validationLevel.padEnd(8)} update legacy review`, () =>
        bookstore.reviews.updateOne({ "customer.customerId": 39, bookId: 26 }, { $set: { body: `A classic (${validationLevel}).` } }));
}

Can a validator be added while a document breaks the rule? If it can, does this small update on the old review pass at both levels?

moderate update legacy review: ok (modified 1)
strict   update legacy review: MongoServerError: Plan executor error during update :: caused by :: Document failed validation

There was no error when the validator was added; MongoDB does not check existing documents at that moment. At the moderate level, updates to documents that already break the rule are not checked, so the text was added. At the strict level every insert and update is checked, and the same update was rejected because the document after the update would still carry rating as a string. The review that breaks the rule is still in the collection and will stay there until someone touches it. That is why calling MongoDB "schemaless" is wrong; "flexible schema" is the right term: the schema is as strict as the rules you put in place. In this post we look first at schema patterns that shape the model around what is read, then at how the schema changes over time and finally at validation, including the new constraint level in 9.0.

The scripts are in the repo's scripts folder. The pattern examples run as the application user and the validation examples as admin, because, as we saw in post 2, the readWrite role does not include the collMod command that changes a validator. Some scripts change the data permanently (summary fields on books, a new schema on customers); 02-load.sh is enough to clean up the lab.

The Computed Pattern: a Review Summary in the Book

The book card shows the average rating and the number of reviews. Computing them with an aggregation over reviews on every read puts the most expensive work on the most frequently read page. The computed pattern moves the calculation to write time: the result lives in the book and is updated when a new review arrives.

// 05 - computed pattern: a review summary in the book
db.reviews.deleteMany({ "customer.customerId": 40, bookId: 26 });
db.books.updateMany({}, { $unset: { rating: "" } });

// Initial fill: compute once from reviews
db.reviews.aggregate([
    { $group: { _id: "$bookId", count: { $sum: 1 }, sum: { $sum: "$rating" } } }
]).forEach(r => db.books.updateOne(
    { _id: r._id },
    { $set: { rating: { count: r.count, sum: r.sum, average: Math.round(r.sum / r.count * 100) / 100 } } }
));
printjson(db.books.findOne({ _id: 26 }, { _id: 0, title: 1, rating: 1 }));

// New review: the review goes to its own collection, the summary to the book (pipeline update)
db.reviews.insertOne({ bookId: 26, customer: { customerId: 40, name: "Linus Torvalds" }, rating: 5,
    body: "Re-read it after twenty years.", reviewedAt: ISODate("2026-06-30T10:00:00Z") });
db.books.updateOne({ _id: 26 }, [
    { $set: { "rating.count": { $add: ["$rating.count", 1] }, "rating.sum": { $add: ["$rating.sum", 5] } } },
    { $set: { "rating.average": { $round: [{ $divide: ["$rating.sum", "$rating.count"] }, 2] } } }
]);
printjson(db.books.findOne({ _id: 26 }, { _id: 0, rating: 1 }));
print("books with rating:", db.books.countDocuments({ rating: { $exists: true } }));
{
  title: 'Dune',
  rating: {
    count: 5,
    sum: 22,
    average: 4.4
  }
}
{
  rating: {
    count: 6,
    sum: 27,
    average: 4.5
  }
}
books with rating: 58

The summary keeps the total and the count next to the average; you cannot update an average from the average alone. The update is an aggregation pipeline update: the stages inside the square brackets can read the document's current values ($rating.count), so the count, the total and the new average are computed in one atomic update. The cost is that the review and the summary are written to two different documents. If the review is written but the summary update fails, the summary falls one review behind. For an average on a book card this is usually acceptable, and the summary is corrected by recomputing it from reviews periodically; if it is not acceptable, the two writes go into a transaction (post 10). For values that change often and do not need to be exact, such as ratings, the MongoDB documentation describes a further step as well: the approximation pattern, which updates the summary not on every write but, for example, on every tenth.

Subset and Bucket: Keeping What Is Read Small

In post 4 we measured why embedding reviews in the book is a bad idea. But the book page shows the latest few reviews every time it opens, and we do not want a second query to reviews for that. The subset pattern sits between the two: all reviews live in reviews, and only a small copy of the latest three lives in the book.

// 05 - subset pattern: a copy of the latest 3 reviews for the book page
db.books.updateOne({ _id: 26 }, { $unset: { recentReviews: "" } });
const latest = db.reviews.find({ bookId: 26 }, { _id: 1, rating: 1, body: 1, "customer.name": 1, reviewedAt: 1 })
    .sort({ reviewedAt: -1 }).limit(3).toArray();
db.books.updateOne({ _id: 26 }, { $set: { recentReviews: latest } });

// New review: add it, sort, keep the first 3
const review = { _id: new ObjectId(), rating: 4, body: "Slow start, great ending.",
    customer: { name: "Ada Lovelace" }, reviewedAt: ISODate("2026-06-30T12:00:00Z") };
db.books.updateOne({ _id: 26 }, {
    $push: { recentReviews: { $each: [review], $sort: { reviewedAt: -1 }, $slice: 3 } }
});
printjson(db.books.findOne({ _id: 26 }).recentReviews.map(r => ({ by: r.customer.name, at: r.reviewedAt })));
print("book size:", bsonsize(db.books.findOne({ _id: 26 })), "bytes");
[
  {
    by: 'Ada Lovelace',
    at: ISODate('2026-06-30T12:00:00.000Z')
  },
  {
    by: 'Linus Torvalds',
    at: ISODate('2026-06-30T10:00:00.000Z')
  },
  {
    by: 'Adele Goldberg',
    at: ISODate('2026-05-04T01:49:15.000Z')
  }
]
book size: 808 bytes

With $push combined with $each, $sort and $slice, the new review is added, the array is sorted by date and only the first three elements remain; the array never grows beyond three. The book, with the summary and three reviews, is 808 bytes. In a real application the new review is first written to reviews and then copied into the book with the same _id; the script only shows the second step. The "show all reviews" link is a paged query on reviews. The cost of the copy is as in post 4: when a review is deleted or edited, its copy in the book must be updated as well.

Page views are the one-to-squillions case from post 4: writing every view as a separate document means millions of small documents and as many index entries. The bucket pattern groups records by a time range; here, one document per book per hour:

// 05 - bucket pattern: book views in hourly documents
db.book_views.drop();
const start = ISODate("2026-06-30T09:00:00Z").getTime();
for (let i = 0; i < 1000; i++) {
    const at = new Date(start + i * 11 * 1000);              // one view every 11 seconds
    const hour = new Date(Math.floor(at.getTime() / 3600000) * 3600000);
    db.book_views.updateOne(
        { bookId: 26, hour },
        { $inc: { count: 1, [`byCountry.${["TR", "GB", "DE"][i % 3]}`]: 1 } },
        { upsert: true }
    );
}
print("documents:", db.book_views.countDocuments());
db.book_views.find().sort({ hour: 1 }).forEach(b =>
    print(b.hour.toISOString(), "count", b.count, "TR", b.byCountry.TR, "GB", b.byCountry.GB, "DE", b.byCountry.DE));
documents: 4
2026-06-30T09:00:00.000Z count 328 TR 110 GB 109 DE 109
2026-06-30T10:00:00.000Z count 327 TR 109 GB 109 DE 109
2026-06-30T11:00:00.000Z count 327 TR 109 GB 109 DE 109
2026-06-30T12:00:00.000Z count 18 TR 6 GB 6 DE 6

A thousand views came down to four documents. Each view is an upsert: if the document for that hour does not exist it is created, otherwise the counters go up with $inc. "A chart of the last 24 hours" is now a read of 24 documents. The bucket size follows the workload: per minute if hourly is too coarse, or, if you also want to keep raw events in each bucket, a structure that opens a new bucket at an upper bound (for example 1,000 events per bucket). For measurement and sensor data, MongoDB's time series collections do this grouping themselves; their changes in 9.0 are in post 15. Aggregations over these buckets for reporting are in posts 7 and 8.

Polymorphic and Attribute Patterns: Print, Ebook and Audiobook

The editions of a book share some fields (book, format, price) and have format-specific ones: the binding and weight of a printed edition, the file formats and DRM status of an ebook, the narrator and duration of an audiobook. In a relational model this means a table full of empty columns or a separate table per format. With the polymorphic pattern they all live in the same collection, each document with its own fields:

// 05 - polymorphic pattern: print, ebook and audiobook editions in one collection
db.editions.drop();
db.editions.insertMany([
    { _id: "26-print", bookId: 26, format: "print", price: Decimal128("390.50"),
      binding: "paperback", pageCount: 513, weightGrams: 520 },
    { _id: "26-ebook", bookId: 26, format: "ebook", price: Decimal128("210.00"),
      fileFormats: ["epub", "pdf"], drm: false },
    { _id: "26-audio", bookId: 26, format: "audiobook", price: Decimal128("320.00"),
      narrator: "Grace Hopper", durationMinutes: 1263 }
]);

// One query on the shared fields; format-specific fields only where they apply
printjson(db.editions.find({ bookId: 26 }, { _id: 0, format: 1, price: 1 }).sort({ price: 1 }).toArray());
print("ebooks without DRM:", db.editions.countDocuments({ format: "ebook", drm: false }));
[
  {
    format: 'ebook',
    price: Decimal128('210.00')
  },
  {
    format: 'audiobook',
    price: Decimal128('320.00')
  },
  {
    format: 'print',
    price: Decimal128('390.50')
  }
]
ebooks without DRM: 1

"This book's editions, cheapest first" is one query and one index. The format field is a discriminator; the application knows from it which fields to expect. On the .NET side the counterpart is the driver's polymorphic serialization with the _t field, which we see in post 9. A problem appears when you want to search on format-specific fields: each field needs its own index, and a new format means new indexes. The attribute pattern moves these fields into a single array of k/v pairs and makes all of them searchable with one compound index:

// Attribute pattern: searchable attributes in one k/v array, one index
db.editions.updateMany({}, [{ $set: { attributes: {
    $filter: {
        input: { $objectToArray: "$$ROOT" },
        cond: { $in: ["$$this.k", ["binding", "pageCount", "drm", "narrator", "durationMinutes"]] }
    }
} } }]);
db.editions.createIndex({ "attributes.k": 1, "attributes.v": 1 });
const stats = db.editions.find({ attributes: { $elemMatch: { k: "narrator", v: "Grace Hopper" } } })
    .explain("executionStats");
print("narrator query:", stats.queryPlanner.winningPlan.inputStage?.stage ?? stats.queryPlanner.winningPlan.stage,
      "keysExamined", stats.executionStats.totalKeysExamined, "returned", stats.executionStats.nReturned);
printjson(db.editions.findOne({ _id: "26-audio" }, { _id: 0, attributes: 1 }));
narrator query: IXSCAN keysExamined 1 returned 1
{
  attributes: [
    {
      k: 'narrator',
      v: 'Grace Hopper'
    },
    {
      k: 'durationMinutes',
      v: 1263
    }
  ]
}

The query read a single entry from the index and found the result (IXSCAN, an index scan). The $elemMatch rule from post 3 is vital here: the k and v conditions must hold on the same element, otherwise you search for "any attribute named narrator, any value Grace Hopper" instead of "narrated by Grace Hopper". This structure resembles EAV in the relational world and partly carries its problems: the type of v varies from attribute to attribute, and range queries are exposed to mixed types. The relational-side problems of EAV are in the Data Modeling and Normalization series post on modeling anti-patterns. Use the attribute pattern only for attributes that are really searched and whose number cannot be predicted; fixed, few fields should stay ordinary fields.

Archive and Single Collection

Old closed orders are rarely read, yet they take space in the same collection, the same indexes and the same cache as active orders. The archive pattern moves them to a separate collection (or cheaper storage). Let's move orders delivered or cancelled before 2025-07-01 to orders_archive:

// 05 - archive pattern: closed orders before 2025-07-01 move to orders_archive
db.orders_archive.drop();
const cutoff = ISODate("2025-07-01T00:00:00Z");
const filter = { status: { $in: ["Delivered", "Cancelled"] }, orderedAt: { $lt: cutoff } };

db.orders.aggregate([
    { $match: filter },
    { $merge: { into: "orders_archive", whenMatched: "keepExisting", whenNotMatched: "insert" } }
]);
const archivedIds = db.orders_archive.distinct("_id");
const r = db.orders.deleteMany({ _id: { $in: archivedIds }, ...filter });
print("archived:", db.orders_archive.countDocuments(), " deleted:", r.deletedCount, " orders left:", db.orders.countDocuments());

// Undo: move them back for the next posts
db.orders_archive.aggregate([{ $merge: { into: "orders", whenMatched: "keepExisting", whenNotMatched: "insert" } }]);
db.orders_archive.drop();
print("orders after undo:", db.orders.countDocuments());
archived: 109  deleted: 109  orders left: 270
orders after undo: 379

The two steps are deliberately in this order: copy first, then delete only what is really in the archive. If the operation is interrupted, at worst an order exists in both places, and when the script runs again keepExisting prevents a duplicate copy; no order is lost. $merge is the stage that writes an aggregation's result to a collection, covered in post 8. The script moves the orders back for the next posts; that is the second output line. Reading the archive is a separate path: the "my orders" screen reads the last year from orders and older orders from orders_archive on request.

The single collection pattern goes the other way: it keeps documents of different types that are read together in one collection, with a shared key and a type field. For example, an order and its shipping events can live in the same collection with orderId and type fields; one index scan returns the order and all its events in order. We did not use it in Bookstore; it is worth considering when the read need really is "read different types together and in order" and embedding hits its limits.

Schema Versioning: Changing the Schema over Time

Today a customer's address lives at the root of the document: city and country. A new requirement brings several addresses and postal codes; the first step is moving these fields into an address subdocument. In a relational database this is a migration and a single moment of deployment. In MongoDB both shapes can live together for a while; the schema versioning pattern makes that explicit: each document states its schema version in a field.

// New code writes v2: new and updated customers
db.customers.updateOne({ _id: 3 }, [
    { $set: { schemaVersion: 2, address: { city: "$city", country: "$country" } } },
    { $unset: ["city", "country"] }
]);

// Reads support both shapes
printjson(db.customers.aggregate([
    { $match: { _id: { $in: [2, 3] } } },
    { $project: {
        _id: 1,
        schemaVersion: { $ifNull: ["$schemaVersion", 1] },
        city: { $ifNull: ["$address.city", "$city"] }
    } }
]).toArray());
[
  {
    _id: 2,
    schemaVersion: 1,
    city: 'Berlin'
  },
  {
    _id: 3,
    schemaVersion: 2,
    city: 'Istanbul'
  }
]

A document without the field counts as v1; new code writes v2 when it updates a customer and understands both shapes when reading. On the .NET side that means a read model that handles both shapes, or a small converter in the serialization layer. That way the schema change needs no maintenance window. Old documents move either on their own as they are updated, or in the background in small batches:

// Background migration: small batches, safe to resume
let migrated = 0;
while (true) {
    const ids = db.customers.find({ schemaVersion: { $exists: false } }, { _id: 1 }).limit(15).toArray().map(c => c._id);
    if (ids.length === 0) break;
    const r = db.customers.updateMany({ _id: { $in: ids }, schemaVersion: { $exists: false } }, [
        { $set: { schemaVersion: 2, address: { city: "$city", country: "$country" } } },
        { $unset: ["city", "country"] }
    ]);
    migrated += r.modifiedCount;
    print("batch:", r.modifiedCount);
}
print("migrated:", migrated, " v1 left:", db.customers.countDocuments({ schemaVersion: { $exists: false } }));
printjson(db.customers.findOne({ _id: 2 }, { _id: 0, firstName: 1, schemaVersion: 1, address: 1 }));
batch: 15
batch: 15
batch: 9
migrated: 39  v1 left: 0
{
  firstName: 'Alan',
  schemaVersion: 2,
  address: {
    city: 'Berlin',
    country: 'DE'
  }
}

One of the forty customers was already v2; the remaining 39 moved in three batches. Each batch's filter repeats the schemaVersion: { $exists: false } condition, so if the migration is interrupted it resumes where it stopped and the same document is never moved twice. In production batch sizes are counted in thousands, with a short pause between batches so that the migration does not choke live traffic and replication. Once every document is v2, v1 support in the read code is removed in the next release; carrying the v1 branch forever means an $ifNull chain nobody understands years later.

Schema Validation: $jsonSchema, validationLevel and validationAction

A validation rule is a query expression added to a collection with collMod (or createCollection); its most common form is $jsonSchema, based on draft 4 of JSON Schema. For types, MongoDB adds the bsonType keyword, which knows BSON types, instead of JSON Schema's type. The validator from "Guess First" is this:

const reviewSchema = {
    bsonType: "object",
    required: ["bookId", "customer", "rating", "reviewedAt"],
    properties: {
        bookId: { bsonType: "int" },
        customer: {
            bsonType: "object",
            required: ["customerId", "name"],
            properties: { customerId: { bsonType: "int" }, name: { bsonType: "string", minLength: 1 } }
        },
        rating: { bsonType: "int", minimum: 1, maximum: 5 },
        body: { bsonType: "string", maxLength: 1000 },
        reviewedAt: { bsonType: "date" },
        helpfulCount: { bsonType: "int", minimum: 0 },
        helpfulVoters: { bsonType: "array", items: { bsonType: "int" } }
    }
};

What does the server say when a review that breaks the rule arrives:

const e = tryWrite("insert rating 6", () => bookstore.reviews.insertOne({
    bookId: 26, customer: { customerId: 38, name: "Margaret Hamilton" }, rating: 6, reviewedAt: new Date() }));
printjson(e.errInfo.details.schemaRulesNotSatisfied);

print("invalid documents:", bookstore.reviews.countDocuments({ $nor: [{ $jsonSchema: reviewSchema }] }));
insert rating 6: MongoServerError: Document failed validation
[
  {
    operatorName: 'properties',
    propertiesNotSatisfied: [
      {
        propertyName: 'rating',
        details: [
          {
            operatorName: 'maximum',
            specifiedAs: {
              maximum: 5
            },
            reason: 'comparison failed',
            consideredValue: 6
          }
        ]
      }
    ]
  }
]
invalid documents: 1

Inside errInfo.details the error says which field broke which rule; the application should log it, but the documentation notes that this text is meant for humans and scripts should not rely on it. The last line is the way to measure current data quality: { $nor: [{ $jsonSchema: reviewSchema }] } finds the documents that break the rule; the only result is the old review from "Guess First". Running this query before adding a validator shows up front how many documents you need to clean up.

Two settings define how a validator behaves. validationLevel: strict (the default) checks every insert and update, moderate checks inserts and only updates to documents that currently follow the rule, and off turns validation off. validationAction: error (the default) rejects the write, warn allows the write and logs the violation, and errorAndLog, added in 8.1, both rejects and logs. moderate and warn are transition settings for adding a validator to existing dirty data: first you see in the log how many writes break the rule with warn, clean up the data, then move to strict and error.

9.0: the constraint Level

strict checks new writes but knows nothing about old violations in the collection; the review from "Guess First" stayed in the collection under strict too. MongoDB 9.0 added the constraint level for this: when moving to the level, MongoDB scans the whole collection and rejects the change if even one document breaks the rule; once it is in place, every document in the collection is guaranteed to follow the rule. For an existing collection, you first prepare with prepareConstraintValidationLevel and then change the level:

// 05 - MongoDB 9.0: constraint validation level
const bookstore = db.getSiblingDB("bookstore");

tryCommand("prepare", () => bookstore.runCommand({ collMod: "reviews", prepareConstraintValidationLevel: true }));
tryCommand("constraint (1)", () => bookstore.runCommand({ collMod: "reviews", validationLevel: "constraint", validationAction: "error" }));

// Fix the old document that breaks the rule (string -> int)
const fixed = bookstore.reviews.updateMany({ rating: { $type: "string" } }, [{ $set: { rating: { $toInt: "$rating" } } }]);
print("fixed:", fixed.modifiedCount);
tryCommand("constraint (2)", () => bookstore.runCommand({ collMod: "reviews", validationLevel: "constraint", validationAction: "error" }));

tryCommand("bypassDocumentValidation", () => bookstore.runCommand({
    insert: "reviews", bypassDocumentValidation: true,
    documents: [{ bookId: 26, customer: { customerId: 38, name: "Margaret Hamilton" }, rating: 6, reviewedAt: new Date() }]
}));
tryCommand("change validator", () => bookstore.runCommand({ collMod: "reviews", validator: { $jsonSchema: { bsonType: "object" } } }));
const options = bookstore.getCollectionInfos({ name: "reviews" })[0].options;
print("level:", options.validationLevel, " action:", options.validationAction);
prepare: ok
constraint (1): MongoServerError: Cannot upgrade validationLevel to 'constraint': the collection contains documents that do not satisfy the validator. First offending document _id: ObjectId('6af85e4c5a7edf5858284d0c') Run db.reviews.find({"$nor": [{ $jsonSchema: { ... } }]}) to find non-compliant documents.
fixed: 1
constraint (2): ok
bypassDocumentValidation: inserted 0, writeError code 2: bypassDocumentValidation is not permitted with 'constraint' validationLevel
change validator: MongoServerError: Failed to set validation options :: caused by :: Validator cannot be changed when Validation level is 'constraint'
level: constraint  action: error

The first attempt was rejected, and the error gave the _id of the first offending document and the query to find them (shortened here). After a pipeline update that fixed the string rating with $toInt, the change succeeded. The constraint level has two more consequences: bypassDocumentValidation, which normally lets an authorized user skip validation, is now rejected, and the validator cannot be changed; to change the rule you first go down to strict, change the rule, then go back up to constraint. The documentation also notes that the scan during the change can be expensive on large collections. At the end of the script reviews stays at the constraint level; when 05-validation.js runs again it first goes down to strict.

The difference on MongoDB 8.0: there is no constraint level and no prepareConstraintValidationLevel, and since errorAndLog came with 8.1, it is not in 8.0 either. On 8.0.38 all three were rejected with these errors: Enumeration value 'constraint' for field 'collMod.validationLevel' is not a valid value, the same for errorAndLog, and BSON field 'collMod.prepareConstraintValidationLevel' is an unknown field. The moderate and strict examples gave the same result on 8.0; only the strict error lacks the Plan executor error during update :: caused by :: prefix it has on 9.0.

Anti-Patterns

We have already seen most of the schema design anti-patterns in MongoDB's documentation in these two posts; here they are briefly in one place:

  • Unbounded arrays: in post 4 we hit the 16 MB limit at 110,000 reviews. The fix is a reference, plus a subset where needed.
  • Too many collections: opening a separate collection for every category, customer or day. Each collection and each index means separate files, separate cache space and management overhead; they should be one collection split by a field.
  • Bloated documents: large, rarely read fields in a frequently read document (a long description, old price history). Every time the book card opens these fields are carried into the cache too; move them to a separate document.
  • Case-insensitive search without an index: the $regex with the i option in post 3 scanned every document. The right way is an index with a collation, in post 6.
  • Unnecessary indexes: every index is updated on every write and takes memory. Indexes that are never used or are a prefix of another index should be dropped; we see how to find them in post 6.
  • Separating data that is read together: the relational copy at the end of post 4 that runs $lookup on every read.

In Production

Create the validator together with the collection. When validation is added later, existing violations stay, and finding and cleaning them becomes a separate job. Opening a new collection with createCollection together with its validator, directly at the constraint level on 9.0 if needed, is the cheapest path. Keep validators in version control, in the same place as the migrations in your application code.

Know what validation costs. Every insert and update evaluates the rule; for a reasonable $jsonSchema the cost is small, but for very large, deeply nested schemas it should be measured. The full scan when moving to constraint means heavy I/O on large collections; do it at a quiet hour.

Keep computed and subset fields reproducible. The review summary and latest reviews in the book must be recomputable from reviews at any time. With such a script, a bug or a half-finished write stops being a permanent inconsistency and becomes a deviation you can correct.

Common Pitfalls

Using no validation at all and leaving data quality to application code. It works as long as the application is the only writer. Then an import script, an admin tool or another service writes to the same collection, and a string rating like the one in "Guess First" appears. A validator is the one checkpoint that does not depend on who writes.

Assuming old data is clean because you added a validator. No level, strict included, checks existing documents retroactively; except constraint. Do not consider the job done before running the $nor with $jsonSchema query and seeing the number of violations.

Leaving schema versioning half done. Adding a schemaVersion field and never finishing the migration means carrying two branches in the read code forever. Every schema version needs an end plan: once the migration is complete, the old branch is removed, and so is any looseness in the validator that accepted the old schema.

A Small Experiment

★☆☆ Write a check script that recomputes the review summary of every book from reviews and compares it with the rating field in the books; list any book that differs.

★★☆ Write a $jsonSchema for editions that uses oneOf: the shared fields are required in every format, plus pageCount for print, fileFormats for ebook and narrator for audiobook. Insert an audiobook without a narrator and examine the details part of the error.

★★★ Turn the v2 schema for customers into a validator, and while the migration runs set it up with anyOf so that it accepts both shapes and the application can write either. When the migration is finished, narrow the validator to v2 only and move to constraint; explain why the change only succeeds once the migration is complete.

The model is now in place, with its decisions, patterns and rules. In the next post we move on to reading it fast: compound indexes and the ESR rule, multikey, partial, TTL and collation indexes, and reading explain output.