# 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., `NutritionInfo` inside `Product`) - Data is always read together with the parent - The embedded array is bounded and small (< 100 items) - **Reference** (ObjectId) when: - Data is shared across multiple documents (e.g., `Product` referenced by `Recipe`, `PantryItem`, `ShoppingItem`) - The referenced document is large or changes independently - You need to query the referenced document on its own ### 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: ```typescript @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 ```typescript import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { HydratedDocument, Types } from 'mongoose'; export type ProductDocument = HydratedDocument; @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 ```typescript @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 ```javascript // 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 ```typescript // 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 ```javascript // 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 ```typescript // 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: ```typescript // Every repository method takes householdId as the first parameter async findAll(householdId: string, filter: any = {}): Promise { return this.model .find({ householdId, deletedAt: null, ...filter }) .lean() .exec(); } ``` ### Use `.lean()` for read operations ```typescript // 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 ```typescript // Always end with .exec() const product = await this.model.findById(id).lean().exec(); ``` ### Cursor-based pagination (not offset) ```typescript 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 ```typescript // 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 ```typescript @Prop({ type: Date, default: null }) deletedAt: Date | null; // Repository always filters async findAll(householdId: string): Promise { return this.model.find({ householdId, deletedAt: null }).lean().exec(); } // Soft delete async softDelete(id: string, householdId: string): Promise { await this.model.updateOne( { _id: id, householdId }, { $set: { deletedAt: new Date() } }, ).exec(); } ``` ## Transactions Only use transactions when updating multiple documents that must be atomic: ```typescript async transferItem(fromPantry: string, toRecipe: string): Promise { 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 ```typescript MongooseModule.forRootAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ uri: config.get('MONGODB_URI'), maxPoolSize: 10, // Connection pool size serverSelectionTimeoutMS: 5000, // Fail fast on connection issues socketTimeoutMS: 45000, retryWrites: true, }), inject: [ConfigService], }); ``` ### Monitor connection events ```typescript 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 ```typescript @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: ```typescript if (ingredient.quantity <= 0) { throw new BadRequestException('Ingredient quantity must be positive'); } ``` ## Backup Strategy (Docker/Self-Hosted) ```bash # 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 ```