Data Modeling

Personal Catalogue: React, Express & MongoDB

Chapter 2 · Data Modeling

Chapter 1 promised a real, working answer to the shared four-course spec's own genuinely heterogeneous item data — one MongoDB collection, discriminated by item type, built with Mongoose's own real discriminator mechanism rather than a vague "just store whatever JSON shows up" approach. This chapter builds it.

What the Relational Siblings Chose, and Why This One Diverges

The PHP & MySQL sibling's own Chapter 2 settles on a deliberately generic, shared items table — one row per item regardless of type, with columns like creator (Author/Artist/Director depending on type) and format_detail (Hardcover-paperback / Single-album / Region code) standing in for whatever that item type's own equivalent field actually is, plus a separate item_types lookup table and a tags/item_tags many-to-many join table. That design works specifically because relational storage forces one shared column set up front — genericizing the columns is the real workaround for a rigid schema, not a design goal in its own right.

MongoDB's document model doesn't have that constraint, so this course doesn't need that workaround either. Instead of one generic creator field standing in for four different real concepts, a Book document gets a real author field, a Cd document gets a real artist field, and a Dvd or Bluray document gets a real director field — each one named for what it actually is, not for what every other item type also happens to have.

The Base Item Schema

Every discriminator setup starts with a base schema holding the fields genuinely shared across all four item types — title, an optional releaseYear, and freeform notes — plus the discriminatorKey option that names the field Mongoose will use to record which subtype a given document actually is:

// models/Item.js const mongoose = require('mongoose'); const options = { discriminatorKey: 'itemType', collection: 'items' }; const itemSchema = new mongoose.Schema({ title: { type: String, required: true }, releaseYear: Number, notes: String, }, { ...options, timestamps: true }); const Item = mongoose.model('Item', itemSchema); module.exports = { Item, options };
Verified Against Mongoose's Own Documentation
A discriminator key exists on every Mongoose model whether it's explicitly named or not — if discriminatorKey is left unset, Mongoose falls back to a default field called __t. Naming it itemType here is a deliberate choice for readability, not a requirement — the mechanism works identically either way, it's just genuinely more obvious what a stored document's own itemType: "Book" field means than a bare __t: "Book" would be.

Building the Four Discriminators

Item.discriminator(name, schema) registers a subtype schema against the base model. Every field defined on itemSchema is still present on the resulting documents — a discriminator schema only adds fields, it doesn't replace anything. Book gets the fields no other item type needs, including the tags array this project's own spec restricts to books specifically:

// models/Book.js const mongoose = require('mongoose'); const { Item, options } = require('./Item'); const bookSchema = new mongoose.Schema({ author: { type: String, required: true }, isbn: String, publisher: String, tags: [String], }, options); const Book = Item.discriminator('Book', bookSchema); module.exports = Book;
// models/Cd.js const mongoose = require('mongoose'); const { Item, options } = require('./Item'); const cdSchema = new mongoose.Schema({ artist: { type: String, required: true }, tracklist: [String], }, options); const Cd = Item.discriminator('Cd', cdSchema); module.exports = Cd;

DVDs and Blu-rays share the most fields of any two types in this spec — both have a director and a runtime — but they're still registered as two genuinely separate discriminators rather than one, since a Blu-ray's own resolution and a DVD's own region code aren't the same real-world attribute wearing a different name:

// models/Dvd.js const dvdSchema = new mongoose.Schema({ director: String, runtimeMinutes: Number, regionCode: String, }, options); const Dvd = Item.discriminator('Dvd', dvdSchema); // models/Bluray.js const blurraySchema = new mongoose.Schema({ director: String, runtimeMinutes: Number, resolution: { type: String, enum: ['1080p', '4K'] }, }, options); const Bluray = Item.discriminator('Bluray', blurraySchema);

One Collection, Four Document Shapes

Every document created through Book, Cd, Dvd, or Bluray is written into the exact same underlying MongoDB collection — items, per the collection option set on the base schema — with the itemType field recording which discriminator produced it:

items collection (one physical MongoDB collection) ├── { itemType: "Book" } title, releaseYear, notes, author, isbn, publisher, tags[] ├── { itemType: "Cd" } title, releaseYear, notes, artist, tracklist[] ├── { itemType: "Dvd" } title, releaseYear, notes, director, runtimeMinutes, regionCode └── { itemType: "Bluray" } title, releaseYear, notes, director, runtimeMinutes, resolution

Querying through the base Item model returns documents of every type mixed together — exactly the "do I already own this?" search this project's own spec needs, resolved as a single query rather than a four-way union. Querying through a specific discriminator model like Book filters automatically to just that type, with no manual itemType: "Book" condition needed in the query itself.

A Base Item Instance Can't See Subtype Fields
A document fetched through the plain Item model doesn't expose author, artist, or any other discriminator-specific field, even though the raw MongoDB document actually has that data stored on it — Mongoose only attaches the extra schema fields when the document is loaded through the matching discriminator model. Chapter 6's own cross-type search route works around this directly by reading the raw document data rather than relying on every field being present on a base-model instance.

Trying It End to End

A quick script confirms all four discriminators write into, and can be read back out of, the same real collection:

const Book = require('./models/Book'); const { Item } = require('./models/Item'); async function seed() { await Book.create({ title: 'FastAPI: Modern Python Web Development', author: 'Bill Lubanovic', releaseYear: 2023, tags: ['Python', 'Web Development', 'Web Frameworks'], }); const everything = await Item.find(); console.log(everything.length, 'items across every type'); }

Running this against a real MongoDB instance and then inspecting the collection directly (via mongosh or a GUI like MongoDB Compass) shows exactly one document, in exactly one collection, carrying its own real itemType: "Book" field alongside the base schema's shared fields.

Why This Matters for This Project Specifically
Chapter 1's own tip-box promised discriminators would give "real, per-type schema validation" rather than unvalidated flexibility — this chapter delivers on that directly. Attempting Cd.create({ title: 'x' }) with no artist field fails with a real Mongoose validation error, the exact same way a missing NOT NULL column would fail on the PHP sibling's relational schema, even though every item type still lives in one shared, flexible collection.

Hands-On Exercises

Exercise 1

Define all four discriminator schemas (Book, Cd, Dvd, Bluray) against the shared Item base schema, create one real document of each type, then query through the base Item model and confirm all four documents come back from a single query against a single collection.

📄 View solution
Exercise 2

Explain why this course chose Mongoose discriminators over four separate MongoDB collections (one per item type), and name the one real feature that choice specifically simplifies.

📄 View solution
Exercise 3

Write a real Mongoose query that creates a Book document with at least two tags, then a second query demonstrating tag-based filtering — finding only Book documents that carry one specific tag.

📄 View solution

Chapter 2 Quick Reference

  • Base schema — title/releaseYear/notes, plus discriminatorKey: 'itemType'
  • Four discriminators — Item.discriminator('Book'|'Cd'|'Dvd'|'Bluray', schema), each adding genuinely type-specific fields
  • One collection — every discriminator writes into the same underlying items collection, distinguished by the stored itemType field
  • Tags — a plain [String] array, present only on the Book discriminator schema
  • Real validation — each discriminator still enforces its own required fields on write, even inside one shared collection
  • Known limitation — a document loaded through the base Item model doesn't expose subtype fields; load through the matching discriminator model instead
  • Next chapter: Building the Express API — real CRUD routes for every item type