MongoDB
Your second-hand equipment marketplace started with cameras. Then sellers asked to list bicycles, keyboards, and camping gear. Cameras have lens mounts, bicycles have frame sizes, and tents have sleeping capacities. The listing table has acquired a growing collection of mostly empty columns.
Every new category brings another migration, another backfill, and another discussion about what an old row's default should mean. A relational design could use category tables or a JSONB column. But the part you're building is a catalog: the app usually loads a listing and its category-specific details together. A document model looks worth trying.
MongoDB stores records as BSON documents inside collections. Documents can contain nested objects and arrays, and they don't all need the same fields. A camera can have a lens mount without giving every bicycle a lens_mount column. The useful change is how you model and retrieve a listing, rather than an exemption from thinking about schemas.
MongoDB began at 10gen, the company founded by Dwight Merriman, Eliot Horowitz, and Kevin Ryan in 2007. It was released in 2009 after work on a broader application platform. Their experience building large web systems shaped the interest in a flexible document model and distribution across machines.
Giving each category its own fields
You connect mongosh to a local development server. A remote deployment needs its own connection URI and credentials:
mongosh 'mongodb://127.0.0.1:27017/gear'Inside that shell, you create a collection with a small, stable contract. Listings must have an owner, title, category, integer price in cents, and a specifications object. The contents of that object can vary:
db.createCollection("listings", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["owner_id", "title", "category", "price_cents", "specs"],
properties: {
owner_id: { bsonType: "string" },
title: { bsonType: "string" },
category: { bsonType: "string" },
price_cents: { bsonType: "int", minimum: 0 },
specs: { bsonType: "object" },
},
},
},
});
db.listings.insertMany([
{
_id: "camera-42",
owner_id: "seller-7",
title: "Mirrorless camera body",
category: "cameras",
price_cents: Int32(45000),
specs: { lens_mount: "E", shutter_count: 12000 },
tags: ["used", "body-only"],
schema_version: 1,
},
{
_id: "bike-18",
owner_id: "seller-9",
title: "City bicycle",
category: "bicycles",
price_cents: Int32(18000),
specs: { frame_cm: 54, gears: 8 },
tags: ["used", "local-pickup"],
schema_version: 1,
},
]);This is flexible where the product needs flexibility, while still rejecting a price such as "cheap". BSON has types beyond JSON, including dates and integer types; the explicit Int32 matches this validator.
Finding and updating listings
The camera page reads one document. A category page can filter nested fields and return only what its cards need:
db.listings.find(
{ category: "cameras", "specs.lens_mount": "E" },
{ title: 1, price_cents: 1 },
);
db.listings.updateOne(
{ _id: "camera-42" },
{ $set: { "specs.includes_battery": true }, $addToSet: { tags: "tested" } },
);Dot notation reaches a nested field. $set changes the selected field without replacing the document, and $addToSet avoids adding another identical tag. An omitted field and an explicit null aren't the same thing: a filter { field: null } can match both null and missing fields. Use $exists when that distinction matters.
The first category page scans the collection. Once the catalog grows, you add an index for a real browsing pattern: one category, ordered by price.
db.listings.createIndex({ category: 1, price_cents: 1 });
db.listings
.find({ category: "cameras" })
.sort({ price_cents: 1 })
.limit(20)
.explain("executionStats");You inspect the winning plan and the number of documents examined. This index fits category-plus-price browsing; it doesn't automatically make every query inside specs fast. Indexes need to follow the filters and ordering people actually use, and every index adds work when sellers edit listings.
A seller asks for a category summary. The aggregation pipeline filters their records, groups them, and calculates totals on the server:
db.listings.aggregate([
{ $match: { owner_id: "seller-7" } },
{
$group: {
_id: "$category",
count: { $sum: 1 },
total_cents: { $sum: "$price_cents" },
},
},
{ $sort: { count: -1 } },
]);You embed a listing's small set of specifications because they travel with that listing. You keep seller accounts and an unbounded history of buyer messages elsewhere. Copying the entire seller profile into every listing would make a profile edit a multi-document maintenance job. MongoDB supports references and $lookup, but its model still benefits from deciding which data belongs together.
Claiming a listing once
Now two buyers click Buy. A conditional update can claim a listing atomically:
db.listings.updateOne(
{ _id: "camera-42", sold: { $ne: true } },
{ $set: { sold: true, buyer_id: "buyer-12" } },
);The application checks matchedCount. One matching document can be updated atomically; another request no longer matches after it is sold. This doesn't also charge a card or update a separate order collection. Multi-document transactions are available on replica sets and sharded clusters when you need them, with their own cost and retry handling.
Evolving the catalog
Adding a new optional camera field now leaves existing documents alone. Renaming frame_cm, changing a unit, or making a field mandatory still needs a compatibility plan. You use schema_version when readers must understand old and new shapes, migrate deliberately, and tighten validation after the transition.
Replica sets provide redundancy and elections; sharding distributes data using a shard key. Neither should be switched on as a substitute for measuring the catalog. For now, the next seller can list a tent without a bicycle migration, and the app still knows what a valid listing looks like.