MongoDB for Application Developers: Documents, Embedding, Indexes, Validation, and Transactions
MongoDB is a document database built around BSON documents rather than rows spread across normalized tables. Its main advantage is not that “you never need a schema.” The advantage is that you can shape documents around how the application reads and updates related data.
The most important MongoDB decisions are therefore practical ones: what belongs in one document, what should be referenced, which queries need indexes, where validation should be enforced, and when a multi-document transaction is genuinely necessary.
This article focuses on application data modeling and everyday development. MongoDB architecture and MongoDB scaling are covered separately on Lofingo.
Start from the workload, not from a relational ER diagram
MongoDB's current data-modeling guidance recommends designing around the application's read and write patterns.
Before creating collections, write down the important operations:
get product page
update product price
list recent orders for one customer
add an item to a cart
find account by email
show the latest five reviews
Then decide which data should live together so those operations stay simple and bounded.
A document model should answer the application's common requests directly—not force the application to reconstruct every object from many collections on every request.
BSON documents can contain nested structures
A MongoDB document can contain nested documents and arrays:
{
"_id": "p_42",
"name": "Mechanical Keyboard",
"price": 4999,
"seller": {
"id": "s_7",
"name": "Acme Store"
},
"attributes": {
"switch": "brown",
"layout": "75%"
}
}
That lets the stored shape resemble the object the application actually needs.
MongoDB collections are flexible: documents in the same collection do not have to contain exactly the same fields. That flexibility is useful for product catalogs and other polymorphic data, but it should not become an excuse for uncontrolled document shapes.
The main modeling decision: embed or reference?
MongoDB relationships are usually represented in one of two ways.
Embed related data
{
"_id": "u_42",
"name": "Asha",
"address": {
"city": "Agra",
"country": "IN"
}
}
Embedding is attractive when related data:
- is normally read with the parent
- belongs naturally to the parent
- is updated together
- is bounded in size
MongoDB's official guidance highlights two important benefits: related data can be returned in one read, and related embedded fields can be updated in one atomic document write.
Reference separate documents
orders.customer_id -> customers._id
References are better when related data:
- changes independently
- is shared by many parents
- can grow without a clear bound
- is queried independently
- would cause large duplicated updates if embedded everywhere
The right answer is workload-specific. “MongoDB means embed everything” is as wrong as “MongoDB should be normalized like SQL.”
Store together what is accessed together
A product page that always displays the product's small, stable attributes may benefit from embedding them.
A product with millions of reviews should not place every review into one ever-growing array.
A useful design might be:
products
product document
small embedded summary
latest few review previews if useful
reviews
one document per review
indexed by product_id + created_at
This preserves a fast product read without creating an unbounded parent document.
MongoDB documents have a 16 MiB BSON document size limit, so unbounded arrays are not merely a style problem—they can eventually make the model impossible to grow.
Single-document atomicity is a major design advantage
MongoDB guarantees atomicity at the single-document write level, even when one update changes several fields or nested values in that document.
For example, a cart document can update several related fields in one operation:
db.carts.updateOne(
{ _id: "cart_9", version: 7 },
{
$set: { updated_at: new Date() },
$push: { items: { sku: "p_42", qty: 1 } },
$inc: { version: 1 }
}
)
Including the expected current value (version: 7) in the filter is an optimistic-concurrency pattern: another writer that already changed the version prevents this update from silently overwriting that newer state.
A good document boundary often removes the need for a distributed transaction entirely.
Use references when independent growth matters
Suppose one user can have millions of activity events.
Embedding all events into the user document creates an ever-growing object and makes unrelated user updates compete with that large structure.
A cleaner design is:
users
_id = u_42
activity_events
user_id = u_42
created_at = ...
Then index the event collection for the exact query shape.
The rule is not “one-to-many must be referenced.” A bounded list of phone numbers can be embedded; an unbounded event timeline should normally be separate.
Indexes must follow real queries
MongoDB can only make a query fast if the storage and indexes match the query pattern.
Suppose the hot request is:
db.orders.find({
tenant_id: "t7",
status: "open"
}).sort({ created_at: -1 }).limit(50)
A compound index might be designed around those equality and sort fields:
db.orders.createIndex({
tenant_id: 1,
status: 1,
created_at: -1
})
Compound index field order matters. MongoDB's current documentation recommends using the Equality-Sort-Range (ESR) guideline as a starting point, while still validating the actual query plan.
Do not create indexes speculatively
Every index has costs:
- storage
- memory/cache pressure
- additional write work
- longer index-build/maintenance operations
Create indexes because a real query needs them.
Also look for redundant indexes. A compound index can sometimes serve queries that use its leftmost prefix, making a separate prefix index unnecessary depending on uniqueness/sparsity requirements.
The goal is not “index every field.” It is to make important queries targeted while keeping write amplification reasonable.
Arrays create multikey indexes automatically when indexed
MongoDB supports multikey indexes for array fields.
That is powerful for documents such as:
{
"tags": ["redis", "databases", "backend"]
}
but arrays also deserve bounded design. A giant array can create many index entries and large documents.
When an array represents an independently growing collection of entities, a separate collection is often easier to operate.
Flexible schema does not mean validation is useless
Production applications usually have fields that must obey rules:
- required identifiers
- enum-like statuses
- numeric ranges
- expected BSON types
- nested object shape
MongoDB supports collection validation, including $jsonSchema rules. Current documentation notes that MongoDB implements JSON Schema draft 4 with MongoDB-specific differences.
For example:
db.createCollection("orders", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["tenant_id", "status", "total"],
properties: {
tenant_id: { bsonType: "string" },
status: {
enum: ["pending", "paid", "cancelled"]
},
total: { bsonType: "decimal" }
}
}
}
})
Use application validation and database validation for invariants worth protecting from scripts, migrations, admin tools, and future code paths.
Evolve validation deliberately
Schema validation can be changed as the application evolves.
Be careful when tightening a rule: documents written before the new validation can become invalid under the new schema even though they remain stored. MongoDB's strict and moderate validation levels provide different behavior for existing invalid documents.
A safe migration often looks like:
- make application code compatible with old + new shapes
- backfill existing documents
- add/tighten validation
- remove old compatibility code later
Flexible schema makes evolution possible; it does not make schema migrations unnecessary.
Use transactions when one document cannot hold the invariant
MongoDB supports multi-document transactions on replica sets and sharded clusters.
They are appropriate when a business invariant genuinely spans documents, for example:
create payment record
mark invoice paid
write ledger entry
and partial success is unacceptable.
But distributed transactions cost more than single-document writes and should not substitute for effective schema design.
If two pieces of state always change together and remain bounded, embedding them into one document may be simpler and faster than creating a transaction for every request.
Use $lookup deliberately
MongoDB's aggregation framework supports $lookup for joining documents across collections.
That is useful, but a design that performs several large joins for every ordinary page load may be fighting the document model.
MongoDB's current modeling guidance recommends embedding related data when it is frequently queried and returned together, specifically to avoid repeated join work.
Use references and $lookup where independent ownership/growth makes sense—not as a reflexive recreation of a normalized relational schema.
Change Streams provide a change feed
MongoDB Change Streams let applications watch a collection, database, or deployment and receive change-event documents when data changes.
Useful consumers include:
- search-index synchronization
- cache invalidation
- notifications
- audit/integration pipelines
- materialized projections
Treat downstream consumers as distributed workers:
- store resume/checkpoint state
- make side effects idempotent
- tolerate reconnects/retries
- do not assume a consumer handler can never see a repeated business event
Change Streams are a safer abstraction than directly coupling application code to replication internals.
Avoid giant documents
Large documents affect more than the 16 MiB hard limit.
They can increase:
- network transfer
- serialization work
- memory/cache pressure
- update cost
- index size when many fields/arrays are indexed
Model independently changing, independently queried, or unbounded data separately.
A document should represent a useful atomic/read boundary, not become a miniature database inside one record.
Avoid unbounded arrays
Patterns such as this are dangerous when growth has no product bound:
{
"user_id": "u_42",
"all_messages_ever": [ ... ]
}
Even before reaching the document-size limit, updates and reads can become unnecessarily expensive.
Prefer separate message documents with an index such as:
conversation_id + created_at
so history can be paginated and retained independently.
Pagination should use stable indexed order
For large result sets, avoid returning an unbounded list.
Offset-style pagination with large skips can become increasingly expensive and can behave poorly while new documents are inserted.
For high-volume feeds, cursor/range pagination over an indexed stable ordering is often cleaner:
created_at before previous_cursor_time
with a unique tie-breaker such as _id when necessary.
The exact cursor should match the index and desired ordering.
Be explicit about duplicated data
Denormalization often means a value is copied into several documents so a hot read needs no join.
That can be correct—but you need to define:
- which copy is authoritative
- how derived copies are updated
- how stale they may become
- how inconsistencies are repaired
For example, an order may intentionally copy the customer's display name at purchase time because the order is historical state. That should not be “updated everywhere” when the customer changes their profile later.
Duplication is not automatically bad; undefined duplication semantics are bad.
Know when MongoDB is a strong fit
MongoDB works well when:
- object/document-shaped data matches the application
- related bounded state is often read together
- schema evolves over time
- single-document atomicity covers many operations
- query patterns can be supported by deliberate indexes
- replica-set/sharding capabilities fit future availability/scale needs
Know when a relational database may be simpler
PostgreSQL/MySQL-style systems can be a better default when the workload depends heavily on:
- many ad-hoc joins
- strong relational constraints across entities
- complex SQL reporting
- normalized shared data updated in one place
- transaction-heavy workflows spanning many entities
MongoDB supports transactions and joins, but supporting a capability is not the same as making it the natural center of the data model.
A production checklist
Before shipping a MongoDB collection, verify:
- The common read/write operations are documented.
- Embed vs reference choices follow those access patterns.
- No embedded array can grow without a practical bound.
- Important queries have intentional indexes.
- Compound-index field order matches real predicates and sorting.
- Schema validation protects important invariants.
- Single-document atomic updates are used where possible.
- Multi-document transactions are reserved for real cross-document invariants.
- Denormalized copies have an explicit source-of-truth/update policy.
- Change Stream consumers are resumable and idempotent where used.
- Pagination and large-result behavior are bounded.
- Document size/index growth is monitored under realistic data.
MongoDB is most effective when the schema is designed around application access patterns and atomic document boundaries, not when it is treated as “SQL without tables.”

Discussion (0)