#DevOps

NoSQL and Document Database Modeling

Developers accustomed to relational modeling often struggle with MongoDB. Its core ideas are query-driven schema design, embedding vs. referencing, and denormalization. This article explains document modeling, its trade-offs, and when to choose a document database over a relational one.

Key Points

  • Topic Focus: DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

NoSQL and Document Database Modeling

Many developers moving from MySQL/PostgreSQL to MongoDB initially think, "Isn't this just storing JSON? How hard can it be?" Once they actually start modeling, however, they encounter slow queries, scattered fields, and unwieldy aggregations. The root cause is that they are still applying a relational modeling mindset to a document database.

The modeling philosophy of document databases is almost the opposite of that of relational databases. Relational databases pursue normalization to eliminate redundancy and ensure consistency, while document databases aim to "keep aggregates together and read them in one operation." This article explains the core ideas behind MongoDB document modeling and the pitfalls that matter in real-world projects.

1. How Document Databases Differ from Relational Databases

The basic unit in a relational database is a "row," which is a flat record. To represent "a user has multiple addresses," you need two tables, users and addresses, linked through user_id. This flattens complex real-world objects into two-dimensional tables.

The basic unit in a document database is a "document" (BSON/JSON), which forms a tree. A user document can naturally look like this:

js
// users collection
{
  _id: ObjectId("..."),
  name: "Zhang San",
  emails: ["[email protected]", "[email protected]"],
  addresses: [
    { type: "home", city: "Beijing", zip: "100000" },
    { type: "work", city: "Shanghai", zip: "200000" }
  ],
  createdAt: ISODate("...")
}

The addresses are embedded directly in the user, so one query retrieves everything. This concept of an "aggregate root" comes from domain-driven design (DDD). A document usually corresponds to one aggregate root and serves as the consistency boundary for reads and writes.

The key differences can be summarized as follows:

DimensionRelational DatabaseDocument Database
ShapeFlat tables + foreign keysTree-shaped, nestable documents
RelationshipsJOINs assembled at runtimeEmbedding, or references resolved manually with $lookup
SchemaStrict constraints, defined in advanceFlexible, with fields added as needed
Consistency boundaryRow/tableSingle document
Design goalNormalize storage and eliminate redundancyAggregate around read paths for fast reads

Remember this: A relational database performs JOINs at query time, while a document database decides its "JOINs" during modeling. You either embed the data and avoid a JOIN, or reference it and query manually. If you get this decision wrong, changing it later is painful.

2. Core Principle: Design the Schema Around Queries

The most counterintuitive and important principle of document modeling is: First decide how the data will be read, then decide how it should be stored.

Relational modeling designs schemas around entities. You first map entities and relationships in an ER diagram, normalize everything to the third normal form, and leave query performance to indexes and the JOIN optimizer. That approach does not work in a document database. Document databases lack powerful JOINs, so query patterns determine how data should be organized.

Consider a real scenario: an article page in a blogging system. The frontend needs to display an article's title, body, author name, author avatar, tag list, and first 10 comments. Following a relational modeling approach, you might split this into four collections: articles, users, tags, and comments, then chain four $lookup operations while rendering the page. The result is slow, complicated, and difficult to optimize with indexes.

A query-driven schema instead tries to place everything required by this page in one document, or at least make it retrievable in one query.

js
// articles collection, optimized for the "article details page" query
{
  _id: ObjectId("..."),
  title: "MongoDB Modeling Guide",
  body: "...",
  author: {                    // Duplicated author data (denormalized)
    _id: ObjectId("..."),
    name: "Zhang San",
    avatar: "/u/zhangsan.png"
  },
  tags: ["nosql", "mongodb", "backend"],
  comments: [                  // Few comments with strong ownership, so embed them
    { user: "Li Si", text: "This was helpful", at: ISODate("...") }
  ],
  createdAt: ISODate("..."),
  updatedAt: ISODate("...")
}

A single findOne({_id}) can render the entire page. That is the power of a document database: you design storage around read paths.

Now consider a different scenario: the same system needs administrative analytics that aggregate article counts by tag and calculate page views by author. This schema may no longer be optimal, and you might need a separate statistics collection. Document databases often maintain collections with different shapes for different queries. That is not waste; it is the cost of read performance.

3. Embedding vs. Referencing: A Design Choice

When should data be embedded in a parent document, and when should it live in a separate collection connected by references? This is the central design choice in document modeling. The criteria are straightforward.

Embedding is suitable when:

  • The relationship is one-to-few, with a bounded number of children.
  • The child has strong ownership and no independent lifecycle.
  • The child is always read together with the parent.

A typical example is an article with a small number of comments. A blog post usually has only dozens of comments, and they are always displayed with the article, so embedding is natural:

js
// Embedding: comments are part of the article
{
  _id: articleId,
  title: "...",
  comments: [
    { _id: ObjectId("..."), user: "Li Si", text: "...", at: ISODate("...") }
    // Dozens of comments are fine
  ]
}

Referencing is suitable when:

  • The "many" side of a one-to-many relationship is large or unbounded.
  • The relationship is many-to-many.
  • The child has an independent lifecycle and is queried or updated separately.

A typical example is users and orders. A user may place thousands of orders, and every order must support independent queries, analytics, and pagination. Embedding orders in the user document could expand it to tens of megabytes and cause every update to rewrite a large block. That is an anti-pattern. The correct approach is a separate collection with references:

js
// users
{ _id: userId, name: "Zhang San", ... }

// orders — separate collection referencing the user
{ _id: orderId, userId: userId, amount: 99, items: [...], at: ISODate("...") }

// Query a user's orders with pagination
db.orders.find({ userId }).sort({ at: -1 }).limit(20)

A simple rule of thumb is to ask: "Can it grow without bound?" and "Does it need to be queried independently?" If the answer to either question is yes, split it out and use references.

4. Denormalization: Trade Space for Read Performance

Relational modeling treats redundancy as a serious threat because it causes update anomalies. Document modeling, however, treats moderate redundancy as a standard tool. This is denormalization.

The most common example is duplicating an author's name in an article. In a relational database, you would store only author_id and JOIN the users table to retrieve the name. In a document database, you store author.name directly in the article document, allowing the article page to load with one query and avoiding $lookup.

js
// Denormalization: duplicate author data needed during reads into the article
{
  _id: articleId,
  title: "...",
  authorId: ObjectId("..."),
  authorName: "Zhang San",      // Duplicated field
  authorAvatar: "/u/zhangsan.png"  // Duplicated field
}

What is the cost? When the author changes their name, you must update authorName in all of their articles. That is the essential trade-off of denormalization: exchange write complexity for read speed. In read-heavy, write-light scenarios, where an author may rarely change their name but their articles are read tens of thousands of times, this trade is highly favorable.

Several denormalization techniques are common in practice:

  1. Duplicated fields: As above, store a copy of related fields that are read frequently but updated infrequently.
  2. Snapshots: Store the product information, such as price and title, as it existed when an order was placed. Even if the product's price or title changes later, the historical order continues to show the original values. This form of redundancy is actually required for correctness.
  3. Materialized views: Maintain a pre-aggregated collection for frequent queries, such as separately stored hourly statistics.

More denormalization is not always better. Every duplicated value adds the obligation to synchronize multiple locations during updates. The rule is: Only duplicate fields whose read frequency is far higher than their write frequency.

5. Document Size and Unbounded Arrays: Anti-Pattern Warning

MongoDB has a hard limit of 16 MB per document. This is not a performance recommendation; it is a physical constraint. Documents exceeding the limit cannot be written. Any design that keeps adding data to one document without a bound is therefore an anti-pattern.

The most common anti-pattern is an unbounded array. Beginners often create designs like this:

js
// Anti-pattern: embed every comment in the article
{
  _id: articleId,
  comments: [
    // Tens of thousands of comments...
  ]
}

A popular article can easily accumulate tens of thousands of comments, quickly pushing the document beyond 16 MB. Even before reaching the limit, adding each comment can require rewriting the document, although MongoDB optimizes $push operations, and performance will deteriorate sharply.

When comment volume is large, the correct approach is to move comments into a separate collection and use references:

js
// Separate comments collection
{ _id: commentId, articleId: articleId, user: "...", text: "...", at: ISODate(...) }

// Query an article's comments with pagination
db.comments.find({ articleId }).sort({ at: -1 }).skip(20).limit(20)

The criterion is simple: If an array could theoretically grow without bound, never embed it. Even if it has only 10 entries today, business growth could turn that into tens of thousands, and migrating later will be painful.

Another related anti-pattern is forcing a relational many-to-many join table into an embedded array. For example, a user document might contain friendIds: [...] with thousands of ObjectIds. Although this may not exceed 16 MB, every friendship query must read the entire array, and indexing is inefficient. A dedicated relationship collection, similar to a relational join table, is more appropriate.

6. Transactions and Consistency: Available, but Do Not Overuse Them

MongoDB has supported multi-document transactions since 4.0, and the 4.x releases further enhanced distributed transactions. Many people hear that "MongoDB supports transactions" and begin using transactions everywhere as if they were working with MySQL. That is a misunderstanding.

MongoDB provides inherent single-document atomicity. Any update to one document, even if it modifies multiple nested fields, is atomic and requires no explicit transaction. If your data is modeled properly by aggregating data that requires strong consistency into one document, you may not need transactions at all.

Cross-document or cross-collection transactions have several drawbacks:

  • High performance cost: They can be more than an order of magnitude slower than single-document updates because of coordination overhead.
  • Lock contention: Long-running transactions can slow down the entire system.
  • Numerous limits: Transactions have size and duration limits and are unsuitable for heavy workloads.

The correct design approach is therefore to keep the consistency boundary within a single document whenever possible. Aggregate related data requiring strong consistency into one document during modeling and rely on single-document atomicity, instead of compensating with cross-document transactions.

Cross-document transactions are appropriate for unavoidable operations across aggregates, such as transferring funds by debiting one account and crediting another, or coordinating an order with inventory. Such operations have no natural aggregate root and therefore require explicit transactions. Routine CRUD usually does not.

js
// Typical scenario that requires a transaction: transferring funds
const session = db.getMongo().startSession()
session.startTransaction()
try {
  db.accounts.updateOne({ _id: fromId }, { $inc: { balance: -100 } }, { session })
  db.accounts.updateOne({ _id: toId },   { $inc: { balance:  100 } }, { session })
  session.commitTransaction()
} catch (e) {
  session.abortTransaction()
}

7. When to Use a Document Database and When to Use a Relational Database

Document databases are not a silver bullet, and relational databases are not relics. The choice depends on business characteristics.

Document databases such as MongoDB are suitable when:

  • The schema is flexible and frequently changes: Fields vary or new ones are added often. Examples include content management, product SKUs with completely different attributes across categories, logs, and configuration.
  • Reads dominate writes and data forms natural aggregates: Blogs, CMSs, and product detail pages where one query must retrieve a group of related data.
  • Horizontal scaling is important: MongoDB's sharding model is designed for horizontal scaling.
  • The product is iterating rapidly: The schema does not need to be fixed in advance, which suits the MVP stage.

Relational databases such as MySQL/PostgreSQL are suitable when:

  • Strong consistency is critical: For finance, transactions, and inventory, relational transactions and constraints are the more dependable choice.
  • Complex relational queries are common: Reporting, BI, and multi-table JOINs are routine. Decades of relational optimizer development cannot be matched by $lookup.
  • The data is naturally many-to-many and normalized: ERP and academic administration systems, for example, have complex networks of entities with no obvious aggregate roots.
  • Writes are intensive and require multi-table transactions: The system needs strict isolation levels and row-locking semantics.

A practical lesson is that many systems use both. Users, orders, and accounting may run on PostgreSQL for strong consistency, while content, comments, and activity feeds run on MongoDB for aggregate read performance. It does not have to be one or the other.

8. Do Not Use a Document Database Like a Relational Database

The most common and most damaging mistake is using a document database with a relational modeling mindset.

The typical symptoms are many collections containing only a few flat fields, with _id references connecting everything, followed by excessive $lookup operations and complaints that "MongoDB makes JOINs so difficult." That is not a MongoDB problem; it is incorrect usage.

Splitting every collection into the third normal form effectively turns the document database into "a relational database without foreign-key constraints and with weak JOINs." This loses the benefits of both sides: you get neither the strong consistency and optimizer of a relational database nor the aggregate read performance of a document database.

The correct approach is to recognize that document databases require a different mindset. They assume that data can be organized around aggregate roots, read paths are relatively stable, schemas evolve, and redundancy is acceptable. If your business does not have that shape, such as when it naturally forms a complex many-to-many network and strong consistency outweighs everything else, then a document database may be the wrong choice. If you choose one, use it according to its model.

A simple self-check is: How many of your recent MongoDB queries used $lookup? If most queries rely on $lookup to assemble data, your modeling is probably wrong. Either data that should be embedded is not embedded, or the workload belongs in a relational database.

Summary

  • Design the schema around queries: Understand the most frequent read paths first, then decide how documents should be organized, instead of normalizing entities before considering usage.
  • Choose embedding or referencing based on growth: Embed one-to-few data with strong ownership; use references when growth may be unbounded or independent queries are required.
  • Denormalization is a standard tool: Trade moderate redundancy for read performance, but duplicate only fields that are read often and written rarely, and accept the cost of synchronizing updates.
  • Respect the single-document consistency boundary: If single-document atomicity can solve the problem, avoid cross-document transactions. With sound data modeling, MongoDB transactions should rarely be necessary.
  • Do not force relational normalization onto documents: Treating a document database like a relational database is the most common mistake. If the data shape does not fit, switch decisively to a relational database.

Relational databases eliminate redundancy; document databases embrace aggregation. The first-principles modeling question is always: "What shape does this data have when it is read?"

Related

Related Content