← All writing
Databases

Designing Mongoose Schemas That Survive the Second Feature Request

MongoDB will accept whatever shape you throw at it, which is exactly why schema design matters more, not less. Lessons from job portals, stores, and LMS builds.

The most dangerous sentence in MongoDB development is "it's schemaless, we can decide later." You can — and later arrives about two weeks in, when the client asks for the feature that your document shape quietly forbids.

This post expands the working rules I use on client builds — a job portal (Nookri.pk), an e-commerce store, and an LMS — into full schemas you can lift, with the reasoning attached to each decision.

Embed or reference: the only question that matters

Nearly every Mongoose design decision reduces to whether related data lives inside the document or in another collection. The working rule:

  • Embed when the child data is bounded, owned by the parent, and always read together with it — order line items, a product's image URLs.
  • Reference when the child grows without bound or is queried on its own — job applications, course enrollments, comments.

Embedding done right: an order and its line items

An order's line items are the textbook embed case: there will be a handful of them, they mean nothing without the order, and you never load an order without them. Note that the items are a snapshot — they copy the price and name at purchase time, because the product document will change and your order history must not:

const OrderItemSchema = new mongoose.Schema({
  product:  { type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true },
  name:     { type: String, required: true },   // snapshot, not a ref lookup
  price:    { type: Number, required: true, min: 0 }, // price at purchase time
  quantity: { type: Number, required: true, min: 1 },
}, { _id: false });

const OrderSchema = new mongoose.Schema({
  user:   { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  items:  {
    type: [OrderItemSchema],
    validate: [(arr) => arr.length > 0, 'Order must contain at least one item'],
  },
  total:  { type: Number, required: true, min: 0 },
  status: {
    type: String,
    enum: ['pending', 'paid', 'shipped', 'delivered', 'cancelled'],
    default: 'pending',
  },
}, { timestamps: true });

One read returns the whole order. No joins, no populate, no partial states.

Referencing done right: jobs and applications

On a job portal like Nookri.pk, applications reference both the job and the applicant. Embedding them inside the job document sounds tidy until a popular listing collects three thousand applications and every read of the job hauls all of them along — and you drift toward MongoDB's 16 MB document ceiling while you're at it.

const JobSchema = new mongoose.Schema({
  title:    { type: String, required: true, maxlength: 120 },
  slug:     { type: String, required: true, unique: true },
  company:  { type: mongoose.Schema.Types.ObjectId, ref: 'Company', required: true },
  status:   { type: String, enum: ['open', 'closed'], default: 'open' },
  // Denormalized counter — cheap to display, updated on write
  applicationCount: { type: Number, default: 0 },
}, { timestamps: true });

const ApplicationSchema = new mongoose.Schema({
  job:       { type: mongoose.Schema.Types.ObjectId, ref: 'Job', required: true },
  applicant: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  cvUrl:     { type: String, required: true },
  status:    {
    type: String,
    enum: ['submitted', 'shortlisted', 'rejected', 'hired'],
    default: 'submitted',
  },
}, { timestamps: true });

// One application per user per job — enforced by the database, not the UI
ApplicationSchema.index({ job: 1, applicant: 1 }, { unique: true });

Now the job document stays small forever, applications are queryable on their own ("show me everything this user applied to"), and the compound unique index makes double-applying impossible even if two requests race.

Notice applicationCount on the job. That's deliberate denormalization: the listing page wants to show "37 applicants" without running a countDocuments per job. Keep it honest with an atomic increment when an application is created:

await Application.create({ job: jobId, applicant: userId, cvUrl });
await Job.updateOne({ _id: jobId }, { $inc: { applicationCount: 1 } });

The gray zone: bounded-but-growing lists

Course enrollments in an LMS look embeddable on day one (a course has twenty students) and become a reference problem by month three (a course has four thousand). The tiebreaker question: will anyone ever query the child without the parent? "Show this student all their courses" means enrollments must be their own collection — an embedded array can't answer that query without scanning every course document.

const EnrollmentSchema = new mongoose.Schema({
  course:   { type: mongoose.Schema.Types.ObjectId, ref: 'Course', required: true },
  student:  { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  progress: { type: Number, min: 0, max: 100, default: 0 },
}, { timestamps: true });

EnrollmentSchema.index({ student: 1 });            // "my courses" page
EnrollmentSchema.index({ course: 1, student: 1 }, { unique: true });

Validate at the schema, not just the form

Frontend validation is a courtesy to users; schema validation is a contract with your future self. Every field that has rules should carry them here, because the admin panel you build next month will talk to this model through a different code path than the form you validated today.

const PostSchema = new mongoose.Schema({
  title: {
    type: String,
    required: [true, 'Title is required'],
    trim: true,
    maxlength: [160, 'Title cannot exceed 160 characters'],
  },
  slug: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    match: [/^[a-z0-9-]+$/, 'Slug may only contain lowercase letters, numbers, and hyphens'],
  },
  status: {
    type: String,
    enum: { values: ['draft', 'published'], message: '{VALUE} is not a valid status' },
    default: 'draft',
  },
  tags: {
    type: [String],
    validate: [(arr) => arr.length <= 10, 'A post can have at most 10 tags'],
  },
}, { timestamps: true });

Two traps worth naming:

  • unique is an index, not a validator. Mongoose won't produce a friendly validation error for duplicates — MongoDB throws an E11000 error at write time. Catch it explicitly:
try {
  await Post.create(data);
} catch (err) {
  if (err.code === 11000) {
    return res.status(409).json({ error: 'A post with this slug already exists' });
  }
  throw err;
}
  • Updates skip validation by default. findByIdAndUpdate happily writes an invalid document unless you tell it otherwise:
await Post.findByIdAndUpdate(id, updates, {
  new: true,
  runValidators: true, // without this, your enum and maxlength rules are decoration
});

That second one bites almost everyone once. The create form validates, the admin edit endpoint doesn't, and three weeks later there's a post with a 900-character title and a status of "pubished".

Design for the query, then index it

"Index what you query" is easy to say and easy to skip. Make it concrete: open your route handlers, list every find() filter and every sort(), and give each hot path an index.

// The blog listing page runs:
// Post.find({ status: 'published', category }).sort({ createdAt: -1 })

PostSchema.index({ status: 1, category: 1, createdAt: -1 });

// The detail page runs:
// Post.findOne({ slug })  — already covered by the unique index on slug

The compound index matches the query shape: equality fields first (status, category), sort field last (createdAt). One index serves the whole listing page. When in doubt, ask the database instead of guessing:

const explain = await Post.find({ status: 'published' })
  .sort({ createdAt: -1 })
  .explain('executionStats');

// Look for 'COLLSCAN' in explain.queryPlanner.winningPlan — that means
// no index was used and every document in the collection was read.

Use .lean() on every read-only query

By default, Mongoose wraps every result in a full Document instance — change tracking, getters, save methods, the works. A list endpoint returning fifty posts pays that cost fifty times for objects it will immediately serialize to JSON and discard.

// List endpoint: plain objects, roughly 5–10x lighter
const posts = await Post.find({ status: 'published' })
  .select('title slug excerpt readMinutes createdAt') // only what the card needs
  .sort({ createdAt: -1 })
  .limit(20)
  .lean();

// Edit endpoint: full document, because we're going to mutate and save it
const post = await Post.findById(id);
post.status = 'published';
await post.save(); // runs validators and pre-save hooks

The rule is mechanical: if the code path ends in res.json(), use .lean(). If it ends in .save(), don't.

Three habits that pay rent

  • Always set timestamps: true. You will want createdAt for sorting before the week is out, and updatedAt the first time a client asks "when was this last edited?"
  • Index what you query — slugs, foreign keys, and any field in a hot find(). Verify with .explain(), not vibes.
  • Use .lean() for read-only queries. Hydrated documents cost real memory on list endpoints.

And one habit for the schemas themselves: when the second feature request forces a shape change, don't mutate old documents in a panic at midnight. Add the new field with a default, let new writes use it, and backfill the old documents with a one-off script:

// Backfill script — run once, then delete
await Post.updateMany(
  { contentFormat: { $exists: false } },
  { $set: { contentFormat: 'html' } }
);

MongoDB gives you flexibility so you can evolve deliberately — not so you can skip the thinking.

#mongodb#mongoose#schema-design#nodejs
AA
Written byAmanat Ali

A full-stack developer who builds websites end-to-end — Next.js, Node.js, and MongoDB — and writes buildlog in the margins.