Skip to content

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.md first.


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 THIS

2. 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:

AspectRequired
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>Ids naming
  • [ ] No createdBy: ObjectId (must be createdById)
  • [ ] 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

FitZalo Platform Documentation