Skip to content

NoSQL Patterns and Strategies

Your MongoDB schema looked clean in the demo: one fat user document with embedded orders, addresses, and a cart. Six months later that array has 4,000 entries, you are bumping the 16MB document limit, and every read drags the whole thing over the wire. The agent that generated the schema never saw your access patterns or your read/write ratio — so it optimized for the demo, not for production.

NoSQL gives you no schema migrations to hide behind. The data model is the design, and a bad embed-vs-reference call is expensive to unwind once you have traffic. The good news: AI coding agents are excellent at this when you give them the constraints up front and let them validate against a live database via an MCP server.

  • A decision prompt that forces the agent to justify embed vs. reference from your actual access patterns and read/write ratio
  • An aggregation-pipeline prompt that demands an explain() plan and a covering index, not just a query that “works”
  • A single-table DynamoDB prompt that lists exact access patterns and asks for GSI projections
  • The before/after of wiring up the MongoDB and Neo4j MCP servers so the agent introspects your real schema instead of guessing
  • Runnable AWS SDK v3 and Neo4j 5 snippets you can paste into a real project today

The pattern is the same for every store: give the agent the access patterns and the constraint, make it produce the design as an artifact, then have it validate against a live instance via MCP. Without MCP the agent hallucinates collection names and index shapes; with it, the agent reads your actual schema and proves its queries.

Step 1 — Wire up the database MCP server

Section titled “Step 1 — Wire up the database MCP server”

This is the single biggest upgrade to NoSQL work with an agent. The MongoDB MCP server (mongodb-mcp-server, on npm) lets the agent list collections, sample documents, run explain(), and create indexes against your real cluster. The Neo4j Cypher MCP server (mcp-neo4j-cypher, Python — run with uvx) does the same for a graph. MCP setup is identical across all three tools apart from where the config lives.

Add to .cursor/mcp.json (project) or ~/.cursor/mcp.json (global), then toggle the server on in Settings → MCP:

{
"mcpServers": {
"mongodb": {
"command": "npx",
"args": ["-y", "mongodb-mcp-server", "--connectionString", "mongodb://localhost:27017/shop"]
}
}
}

Step 2 — Make the embed-vs-reference decision explicit

Section titled “Step 2 — Make the embed-vs-reference decision explicit”

The most consequential document-store decision is whether related data is embedded or referenced. The agent gets this right when you hand it the access patterns and the read/write ratio instead of asking it to “design a good schema.”

A good answer embeds line items (bounded, immutable, always read together), stores a denormalized customer: { id, name } snapshot rather than $lookup-ing on every read, and keeps a separate orderItems collection only if items can grow without bound. The design output is the artifact you review — the agent should produce it as a labeled schema, not bury it in prose.

Step 3 — Generate aggregation pipelines that prove themselves

Section titled “Step 3 — Generate aggregation pipelines that prove themselves”

Aggregation pipelines are where AI saves the most time and also where it most often produces a query that returns correct results while doing a full collection scan. The fix is to demand the execution plan as part of the deliverable.

With the MongoDB MCP server connected, the agent can actually run that explain() and iterate until the stage hits an IXSCAN — closing the loop instead of leaving you to discover the collection scan in production.

The same loop covers real-time work. A MongoDB change stream is a few lines and is identical across all three tools — the value is the prompt, not the boilerplate:

import { MongoClient } from 'mongodb';
const client = new MongoClient(process.env.MONGODB_URI);
await client.connect();
const products = client.db('shop').collection('products');
// Resume tokens survive restarts; store the last one durably in prod.
const changeStream = products.watch(
[{ $match: { 'fullDocument.inventory.available': { $lt: 10 } } }],
{ fullDocument: 'updateLookup' }
);
for await (const change of changeStream) {
await notifyLowStock(change.fullDocument);
}

DynamoDB punishes vague prompts hardest: get the partition key wrong and you create hot partitions that throttle under load. List every access pattern up front and make the agent map each one to a key condition.

Treat the resulting key map and GSI table as the design artifact. The executable part is small — and it must use AWS SDK v3, since v2 is end-of-support. Here is a real, runnable query against the getUserTasks access pattern:

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
// GSI1: PK = USER#<id>, SK = TASK#<dueDate>#<taskId>
export async function getUserTasks(userId, fromDate) {
const { Items } = await ddb.send(
new QueryCommand({
TableName: 'SaaSPlatform',
IndexName: 'GSI1',
KeyConditionExpression: 'GSI1PK = :pk AND GSI1SK >= :start',
ExpressionAttributeValues: {
':pk': `USER#${userId}`,
':start': `TASK#${fromDate}`,
},
ScanIndexForward: true, // ascending by due date
})
);
return Items;
}

Note await ddb.send(new QueryCommand(...)) — the v2 .query(params).promise() chain no longer applies. Batch writes follow the same shape with BatchWriteCommand (still capped at 25 items per request).

For highly connected data (social graphs, recommendations, fraud rings), a graph beats joins. Ask the agent for the node/relationship model first, then the Cypher — and make sure it emits Neo4j 5 syntax, because the agent’s training data is full of the removed v4 constraint form.

Correct Neo4j 5 constraints and a recommendation query look like this:

CREATE CONSTRAINT user_id IF NOT EXISTS
FOR (u:User) REQUIRE u.id IS UNIQUE;
CREATE CONSTRAINT post_id IF NOT EXISTS
FOR (p:Post) REQUIRE p.id IS UNIQUE;
// Posts friends liked in the last 7 days that this user hasn't viewed
MATCH (user:User {id: $userId})-[:FRIENDS]-(friend:User)
MATCH (friend)-[:LIKED]->(post:Post)
WHERE NOT (user)-[:VIEWED]->(post)
AND post.createdAt > datetime() - duration('P7D')
WITH post, count(DISTINCT friend) AS friendLikes
RETURN post.id, post.content, friendLikes
ORDER BY friendLikes DESC
LIMIT 10;

With the Neo4j MCP server connected, the agent runs each query before handing it back, catching label typos and v4-isms that would otherwise blow up at runtime.

Storing embeddings alongside documents lets you do semantic search without a separate vector database. On MongoDB Atlas the modern API is the vectorSearch index type with a fields array and numDimensions — the older knnVector mapping is deprecated and is incompatible with the $vectorSearch aggregation stage you actually query with.

from pymongo.operations import SearchIndexModel
# A vectorSearch index requires SearchIndexModel — the plain-dict form of
# create_search_index() only creates Atlas Search indexes and ignores `type`.
search_index_model = SearchIndexModel(
definition={
"fields": [
{
"type": "vector",
"path": "text_embedding",
"numDimensions": 384, # must match your embedding model exactly
"similarity": "cosine",
},
{"type": "filter", "path": "price"},
{"type": "filter", "path": "categories"},
]
},
name="product_embeddings",
type="vectorSearch",
)
db.products.create_search_index(model=search_index_model)
# Query it with the $vectorSearch stage, pre-filtering on the indexed filter fields
pipeline = [
{
"$vectorSearch": {
"index": "product_embeddings",
"path": "text_embedding",
"queryVector": query_embedding, # 384-dim list from your model
"numCandidates": 200,
"limit": 10,
"filter": {"price": {"$lte": 100}},
}
},
{"$addFields": {"score": {"$meta": "vectorSearchScore"}}},
{"$project": {"text_embedding": 0}},
]
  • Unbounded array growth / 16MB limit. Embedding events, comments, or lineItems that grow forever eventually hits MongoDB’s 16MB document cap and slows every read. Reference into a child collection once an array can grow without bound; ask the agent to flag this in the schema-design step.
  • DynamoDB hot partitions. A partition key with low cardinality (e.g. TENANT#<id> for a few huge tenants) throttles under load no matter your provisioned capacity. Add a write-sharding suffix (TENANT#<id>#<shard>) and scatter-gather on read.
  • Neo4j supernodes. A celebrity user with millions of FRIENDS edges turns traversals into table scans. Cap fan-out in queries, or model the hot relationship as its own node.
  • Stale embeddings. Vector results silently degrade when you change embedding models but don’t re-index existing documents, or when numDimensions no longer matches the model. Re-embed on model change and assert the dimension count.
  • The agent invents schema. Without an MCP connection it guesses collection and field names. If queries reference fields you don’t have, stop and connect the MongoDB or Neo4j MCP server so it reads the real schema.