10 KiB
MongoDB & Mongoose Best Practices — MeshiTrack
Instruction file for database design and Mongoose usage across the project.
Schema Design Principles
Embed when possible, reference when necessary
MongoDB favors denormalization. Use this decision tree:
-
Embed (subdocument) when:
- Data belongs exclusively to the parent (e.g.,
NutritionInfoinsideProduct) - Data is always read together with the parent
- The embedded array is bounded and small (< 100 items)
- Data belongs exclusively to the parent (e.g.,
-
Reference (ObjectId) when:
- Data is shared across multiple documents (e.g.,
Productreferenced byRecipe,PantryItem,ShoppingItem) - The referenced document is large or changes independently
- You need to query the referenced document on its own
- Data is shared across multiple documents (e.g.,
MeshiTrack schema strategy
| Schema | Embedded Data | Referenced Data |
|---|---|---|
| Product | nutrition: NutritionInfo (embed) |
— |
| Recipe | ingredients[], steps[] (embed) |
ingredients[].productId (ref) |
totalNutrition, perServingNutrition |
||
| PantryItem | freshnessEstimate (embed) |
productId (ref), storeId (ref) |
| ShoppingList | items[] (embed) |
items[].productId (ref) |
| MealPlan | days[].meals[] (embed) |
meals[].recipeId (ref) |
| PriceRecord | — | productId (ref), storeId (ref) |
Denormalize names for display
Store productName alongside productId so list views don't require joins:
@Prop({ type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true })
productId: mongoose.Types.ObjectId;
@Prop({ required: true })
productName: string; // Denormalized from Product.name
Update denormalized names when the source changes (background job).
Mongoose Schema Definitions
Use NestJS decorators for schema definitions
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';
export type ProductDocument = HydratedDocument<Product>;
@Schema({
timestamps: true, // Auto-manages createdAt, updatedAt
collection: 'products', // Explicit collection name
toJSON: { virtuals: true }, // Include virtuals in JSON output
})
export class Product {
@Prop({ required: true, index: true })
householdId: string;
@Prop({ required: true, trim: true })
name: string;
@Prop({ trim: true })
brand?: string;
@Prop({ unique: false, sparse: true })
barcode?: string;
@Prop({ required: true, enum: ProductCategory })
category: string;
@Prop({ type: NutritionInfoSchema })
nutrition: NutritionInfo;
@Prop([String])
tags: string[];
@Prop()
deletedAt?: Date; // Soft delete
@Prop({ required: true })
createdBy: string;
}
export const ProductSchema = SchemaFactory.createForClass(Product);
Define subdocument schemas separately
@Schema({ _id: false }) // No separate _id for embedded subdocuments
export class NutritionInfo {
@Prop({ required: true, min: 0 })
calories: number;
@Prop({ required: true, min: 0 })
protein: number;
@Prop({ required: true, min: 0 })
carbs: number;
@Prop({ required: true, min: 0 })
fat: number;
@Prop({ min: 0 })
fiber?: number;
@Prop({ min: 0 })
sugar?: number;
@Prop({ min: 0 })
sodium?: number;
}
export const NutritionInfoSchema = SchemaFactory.createForClass(NutritionInfo);
Indexing Strategy
Every query pattern needs an index
Design indexes based on the queries your app actually runs, not just the schema structure.
Compound indexes: put equality fields first, range/sort fields last
// Good: householdId (equality) + status (equality) + urgency (sort/filter)
{ householdId: 1, status: 1, 'freshnessEstimate.urgency': 1 }
// Bad: sorting field first
{ 'freshnessEstimate.urgency': 1, householdId: 1, status: 1 }
Text indexes for search
// Define after schema creation
ProductSchema.index(
{ name: 'text', brand: 'text', tags: 'text' },
{ weights: { name: 10, brand: 5, tags: 3 } }, // Name matches rank higher
);
Only one text index per collection. If you need multiple text search patterns, use Atlas Search or a separate search service.
Required indexes per collection
// Products
{ householdId: 1, category: 1 }
{ householdId: 1, barcode: 1 }
{ name: 'text', brand: 'text', tags: 'text' }
// Recipes
{ householdId: 1 }
{ householdId: 1, 'ingredients.productId': 1 }
{ name: 'text', tags: 'text', cuisine: 'text' }
// PantryItems
{ householdId: 1, status: 1, 'freshnessEstimate.estimatedExpiryDate': 1 }
{ householdId: 1, storageLocation: 1, status: 1 }
{ householdId: 1, productId: 1, status: 1 }
// PriceRecords
{ householdId: 1, productId: 1, storeId: 1, date: -1 }
{ householdId: 1, productId: 1, date: -1 }
// ShoppingLists
{ householdId: 1, status: 1 }
// FreshnessRules
{ category: 1, storageLocation: 1 }
Register indexes in schema files
// After schema class definition
ProductSchema.index({ householdId: 1, category: 1 });
ProductSchema.index({ householdId: 1, barcode: 1 }, { sparse: true });
ProductSchema.index(
{ name: 'text', brand: 'text', tags: 'text' },
{ weights: { name: 10, brand: 5, tags: 3 } },
);
Query Best Practices
Always filter by householdId first
Every single data query MUST include householdId. Enforce this in the repository layer:
// Every repository method takes householdId as the first parameter
async findAll(householdId: string, filter: any = {}): Promise<Product[]> {
return this.model
.find({ householdId, deletedAt: null, ...filter })
.lean()
.exec();
}
Use .lean() for read operations
// Returns plain JS objects — 2-5x faster than hydrated documents
const products = await this.model.find(filter).lean().exec();
Only skip .lean() when you need Mongoose document methods (.save(), virtuals, middleware).
Use .exec() on all queries
// Always end with .exec()
const product = await this.model.findById(id).lean().exec();
Cursor-based pagination (not offset)
async findPaginated(
householdId: string,
cursor: string | null,
limit: number = 20,
): Promise<{ data: Product[]; nextCursor: string | null }> {
const filter: any = { householdId, deletedAt: null };
if (cursor) {
filter._id = { $gt: new Types.ObjectId(cursor) };
}
const docs = await this.model
.find(filter)
.sort({ _id: 1 })
.limit(limit + 1) // Fetch one extra to determine hasMore
.lean()
.exec();
const hasMore = docs.length > limit;
const data = hasMore ? docs.slice(0, limit) : docs;
const nextCursor = hasMore ? data[data.length - 1]._id.toString() : null;
return { data, nextCursor };
}
Use aggregation pipelines for analytics
// Example: Waste stats
async getWasteStats(householdId: string, startDate: Date, endDate: Date) {
return this.model.aggregate([
{
$match: {
householdId,
updatedAt: { $gte: startDate, $lte: endDate },
status: { $in: ['consumed', 'discarded'] },
},
},
{
$group: {
_id: '$status',
count: { $sum: 1 },
},
},
]).exec();
}
Soft Deletes
Use deletedAt field, filter in repository
@Prop({ type: Date, default: null })
deletedAt: Date | null;
// Repository always filters
async findAll(householdId: string): Promise<Product[]> {
return this.model.find({ householdId, deletedAt: null }).lean().exec();
}
// Soft delete
async softDelete(id: string, householdId: string): Promise<void> {
await this.model.updateOne(
{ _id: id, householdId },
{ $set: { deletedAt: new Date() } },
).exec();
}
Transactions
Only use transactions when updating multiple documents that must be atomic:
async transferItem(fromPantry: string, toRecipe: string): Promise<void> {
const session = await this.connection.startSession();
try {
session.startTransaction();
// ... multiple operations with { session }
await session.commitTransaction();
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
}
Note: MongoDB transactions require a replica set. For local development, use a single-node replica set in Docker.
Connection Management
Configure connection in AppModule
MongooseModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
uri: config.get<string>('MONGODB_URI'),
maxPoolSize: 10, // Connection pool size
serverSelectionTimeoutMS: 5000, // Fail fast on connection issues
socketTimeoutMS: 45000,
retryWrites: true,
}),
inject: [ConfigService],
});
Monitor connection events
MongooseModule.forRootAsync({
useFactory: () => ({
uri: process.env.MONGODB_URI,
onConnectionCreate: (connection) => {
connection.on('connected', () => console.log('MongoDB connected'));
connection.on('disconnected', () => console.warn('MongoDB disconnected'));
connection.on('error', (err) => console.error('MongoDB error', err));
return connection;
},
}),
});
Data Validation
Schema-level validation for data integrity
@Prop({
required: true,
min: 0,
max: 99999,
validate: {
validator: (v: number) => v >= 0,
message: 'Calories cannot be negative',
},
})
calories: number;
Application-level validation for business rules
Don't rely solely on Mongoose validation. Validate in the service layer with meaningful error messages:
if (ingredient.quantity <= 0) {
throw new BadRequestException('Ingredient quantity must be positive');
}
Backup Strategy (Docker/Self-Hosted)
# Backup: run inside the mongodb container or from host
mongodump --uri="mongodb://meshitrack:password@localhost:27017/meshitrack?authSource=admin" --out=/backup/$(date +%Y%m%d)
# Restore
mongorestore --uri="mongodb://meshitrack:password@localhost:27017/meshitrack?authSource=admin" /backup/20260325
# Automate with cron on the host or a Docker sidecar