Population Implementation Guide — NestJS/Mongoose
Purpose: Code-level guidance for implementing the Relationship & Population Rules. Audience: Backend developers implementing modules. Prerequisite: Read
RELATIONSHIP_POPULATION_RULES.mdfirst.
1. Schema Layer: Persisted Model
Pattern: Always use <relation>Id for FKs
typescript
// ✅ CORRECT Schema
@Schema({ timestamps: true })
export class Product extends Document {
@Prop({ type: Types.ObjectId }) tenantId!: Types.ObjectId;
@Prop({ required: true }) name!: string;
@Prop({ required: true }) sku!: string;
// FK fields — named with Id suffix
@Prop({ type: Types.ObjectId, ref: 'Category' })
categoryId!: Types.ObjectId;
// Audit FKs — mandatory
@Prop({ type: Types.ObjectId, ref: 'User' })
createdById!: Types.ObjectId;
@Prop({ type: Types.ObjectId, ref: 'User' })
updatedById!: Types.ObjectId;
}Anti-Pattern: Never use bare createdBy
typescript
// ❌ FORBIDDEN — ambiguous field name
@Prop({ type: Types.ObjectId })
createdBy: Types.ObjectId; // DON'T DO THIS2. DTO Layer: Persisted vs Response
2.1 Create/Update DTOs (input — no relations)
typescript
export class CreateProductDto {
@IsString() name: string;
@IsString() sku: string;
@IsMongoId() categoryId: string;
// createdById is injected by service from JWT, not from client
}2.2 Response DTOs (output — may include summaries)
typescript
export class ProductResponseDto {
_id: string;
name: string;
sku: string;
categoryId: string;
createdById: string;
updatedById: string;
createdAt: Date;
updatedAt: Date;
// Response-only — populated only when requested via ?include
categorySummary?: CategorySummary;
createdBySummary?: UserSummary;
updatedBySummary?: UserSummary;
}2.3 Standard Summary Shapes
typescript
// Place these in a shared lib: libs/core/dto/summary-shapes.ts
export class UserSummary {
_id: string;
name: string;
avatar?: string;
}
export class PartnerSummary {
_id: string;
name: string;
phone?: string;
roles: string[];
}
export class CategorySummary {
_id: string;
name: string;
slug: string;
}
export class TenantSummary {
_id: string;
name: string;
slug: string;
}
export class RoleSummary {
_id: string;
name: string;
key: string;
}3. Include Parser (Central Infrastructure)
3.1 Include Parser Middleware
typescript
// libs/core/include/include.parser.ts
export interface IncludeConfig {
allowed: string[]; // whitelist of allowed relation names
projections: Record<string, string>; // field projection per relation
maxIncludes?: number; // default: 3
}
export function parseIncludes(
query: string | undefined,
config: IncludeConfig
): string[] {
if (!query) return [];
const requested = query.split(',').map(s => s.trim()).filter(Boolean);
// Validate no nested includes (depth > 1)
const nested = requested.filter(r => r.includes('.'));
if (nested.length > 0) {
throw new BadRequestException({
code: 'INCLUDE_DEPTH_EXCEEDED',
message: `Nested includes not allowed: ${nested.join(', ')}. Max depth is 1.`,
});
}
// Validate against whitelist
const notAllowed = requested.filter(r => !config.allowed.includes(r));
if (notAllowed.length > 0) {
throw new BadRequestException({
code: 'INCLUDE_NOT_ALLOWED',
message: `Includes not allowed: ${notAllowed.join(', ')}. Allowed: ${config.allowed.join(', ')}`,
});
}
// Enforce max includes
if (requested.length > (config.maxIncludes || 3)) {
throw new BadRequestException({
code: 'INCLUDE_NOT_ALLOWED',
message: `Max ${config.maxIncludes || 3} includes per request.`,
});
}
return requested;
}3.2 Per-Module Include Config
typescript
// libs/catalog/src/lib/include-config.ts
export const PRODUCT_INCLUDE_CONFIG: IncludeConfig = {
allowed: ['category', 'createdBy', 'updatedBy'],
projections: {
category: '_id name slug',
createdBy: '_id name avatar',
updatedBy: '_id name avatar',
},
};3.3 Controller Usage
typescript
@Get(':id')
async findOne(
@Param('id') id: string,
@Query('include') include?: string,
@Req() req: TenantRequest,
) {
const includes = parseIncludes(include, PRODUCT_INCLUDE_CONFIG);
return this.productService.findById(id, req.tenantId, includes);
}3.4 Service: Batch Population
typescript
// ✅ GOOD — batch populate with projection
async findById(id: string, tenantId: string, includes: string[]): Promise<ProductResponseDto> {
const product = await this.productModel
.findOne({ _id: id, tenantId })
.lean()
.exec();
if (!product) throw new NotFoundException();
const response: ProductResponseDto = { ...product };
// Populate only requested + allowed relations
if (includes.includes('category') && product.categoryId) {
response.categorySummary = await this.categoryModel
.findById(product.categoryId)
.select('_id name slug')
.lean()
.exec();
}
if (includes.includes('createdBy') && product.createdById) {
response.createdBySummary = await this.userModel
.findById(product.createdById)
.select('_id name avatar')
.lean()
.exec();
}
return response;
}3.5 List Endpoint: Batch N+1 Prevention
typescript
// ✅ GOOD — batch lookup for list endpoints
async findAll(tenantId: string, includes: string[]): Promise<ProductResponseDto[]> {
const products = await this.productModel
.find({ tenantId })
.lean()
.exec();
if (includes.includes('category')) {
const categoryIds = [...new Set(products.map(p => p.categoryId?.toString()).filter(Boolean))];
const categories = await this.categoryModel
.find({ _id: { $in: categoryIds } })
.select('_id name slug')
.lean()
.exec();
const categoryMap = new Map(categories.map(c => [c._id.toString(), c]));
products.forEach(p => {
if (p.categoryId) {
(p as any).categorySummary = categoryMap.get(p.categoryId.toString());
}
});
}
return products;
}4. GraphQL Patterns (If Applicable)
4.1 Field-Level Authorization
typescript
@Resolver(() => Product)
export class ProductResolver {
@ResolveField(() => UserSummary, { nullable: true })
@UseGuards(GqlPermissionsGuard)
async createdBySummary(@Parent() product: Product) {
return this.userService.getSummary(product.createdById);
}
}4.2 Depth Limiting (Query Complexity)
typescript
// Use graphql-query-complexity or graphql-depth-limit
app.use('/graphql', graphqlDepthLimit(2)); // max depth 2 (query→entity→summary)5. Anti-Overfetch Contract
Every new endpoint MUST document:
| Aspect | Required |
|---|---|
| Default response fields | ✅ |
| Allowed includes | ✅ |
| Projection per include | ✅ |
| Forbidden includes | ✅ |
| Max depth | ✅ (always 1) |
| Max includes per request | ✅ (default 3) |
6. "Never Overwrite Persisted Fields" Rule
typescript
// ❌ FORBIDDEN — populate overwrites the FK field
const product = await this.productModel.findById(id).populate('createdById');
// product.createdById is now a User object, not ObjectId
// If you do product.save(), the ObjectId is CORRUPTED
// ✅ CORRECT — use lean() + separate summary field
const product = await this.productModel.findById(id).lean();
const summary = await this.userModel.findById(product.createdById).select('_id name avatar').lean();
return { ...product, createdBySummary: summary };7. Checklist for Developers
Before merging any PR that touches relations:
- [ ] FK fields use
<name>Id/<name>Idsnaming - [ ] No
createdBy: ObjectId(must becreatedById) - [ ] Response DTOs separate persisted fields from summaries
- [ ]
?include=whitelist defined in IncludeConfig - [ ] Projections defined for each allowed include
- [ ]
.lean()used on all populated queries - [ ] List endpoints use batch lookup (no N+1)
- [ ] No nested includes (depth > 1)
- [ ] RBAC checked before population
- [ ] Unit test: default response has no populated relations
- [ ] Unit test: disallowed include returns 400