Schema Design
MongoDB is schemaless on the wire but every real application has a schema — its just enforced in your code or at the DB. Modern Mongo gives you both: JSON-Schema validators at the collection level, and JSON-Schema-aware libraries (Mongoose, Zod adapters) at the app level. Decide once and write it down.
Collection validators + Mongoose schema side by side
EXAMPLE
// 1) MongoDB-level JSON Schema validator — checked on every insert/update
db.createCollection('orders', {
validator: {
$jsonSchema: {
bsonType: 'object',
required: ['_id', 'customer', 'total_cents', 'status', 'created_at'],
additionalProperties: false,
properties: {
_id: { bsonType: 'string', maxLength: 24 },
customer: { bsonType: 'string', minLength: 1, maxLength: 120 },
total_cents: { bsonType: 'long', minimum: 0 },
currency: { bsonType: 'string', enum: ['AUD', 'USD', 'NZD'] },
status: { bsonType: 'string',
enum: ['new', 'paid', 'shipped', 'cancelled', 'refunded'] },
items: {
bsonType: 'array',
minItems: 1,
items: {
bsonType: 'object',
required: ['sku', 'qty', 'price_cents'],
properties: {
sku: { bsonType: 'string' },
qty: { bsonType: 'int', minimum: 1 },
price_cents: { bsonType: 'long', minimum: 0 },
},
},
},
created_at: { bsonType: 'date' },
paid_at: { bsonType: ['date', 'null'] },
},
},
},
validationLevel: 'moderate', // existing docs that fail validator can still be updated
validationAction: 'error', // reject (vs 'warn' for soft launch)
});
// 2) Add or change validator on an EXISTING collection
db.runCommand({
collMod: 'orders',
validator: { /* updated schema here */ },
validationLevel: 'strict',
});
// 3) Indexes that go alongside the schema (defined in the same migration)
db.orders.createIndex({ customer: 1, created_at: -1 });
db.orders.createIndex({ status: 1, created_at: -1 });
db.orders.createIndex({ 'items.sku': 1 });
// 4) App-side schema with Mongoose — matched 1:1 with the DB validator
import { Schema, model } from 'mongoose';
const ItemSchema = new Schema({
sku: { type: String, required: true },
qty: { type: Number, min: 1, required: true },
price_cents: { type: Number, min: 0, required: true },
}, { _id: false });
const OrderSchema = new Schema({
_id: { type: String, required: true },
customer: { type: String, required: true, trim: true, maxlength: 120 },
total_cents: { type: Number, min: 0, required: true },
currency: { type: String, enum: ['AUD','USD','NZD'], default: 'AUD' },
status: { type: String, enum: ['new','paid','shipped','cancelled','refunded'], default: 'new' },
items: { type: [ItemSchema], validate: (v: any[]) => v.length > 0 },
paid_at: Date,
}, { timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' } });
OrderSchema.index({ customer: 1, created_at: -1 });
export const Order = model('Order', OrderSchema);
// 5) The two layers complement each other:
// - App-side schema: rich types, hooks, virtuals, fast feedback in tests
// - DB-side validator: catches drift from any other writer (mongo shell, scripts, other services)
// Run both; ship one without the other and a bad write sneaks in.
// 6) Versioning the schema — keep a 'schema_version' field on every doc and a migration plan
db.orders.updateMany({ schema_version: { $lt: 2 } }, [
{ $set: { tier: { $cond: [{ $gte: ['$total_cents', 50000] }, 'gold', 'silver'] },
schema_version: 2 } },
]);
Why it matters
Layered schema is the right answer: app-side for ergonomics, DB-side for safety. The app schema catches typos and types in tests; the DB validator stops a misbehaving migration script or admin console from writing garbage on a Friday afternoon. Run the same JSON Schema in both places where you can.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Schema is flexible but design matters. // One-to-few: embed. One-to-many: usually reference.Try it Yourself »
Discussion
Loading…