Skip to content

Relationship & Population Rules

Precedence: TOP-LEVEL STANDARD — all modules MUST comply. Effective: 2026-02-23 | Version: 1.0 Decision: D-008 (Population Contract Standard)


1. Definitions

TermDefinition
Persisted FKA foreign key stored in the database schema (e.g., createdById: ObjectId). This is the source of truth.
Response RelationAn expanded/populated object returned in API responses (e.g., createdBySummary: { _id, name, avatar }). NEVER persisted into DB.
Populate / JoinThe act of resolving a FK into its related document at query time (Mongoose .populate(), MongoDB $lookup).
Include / ExpandClient-facing mechanism to request population of specific relations (REST: ?include=createdBy).
Projection / SelectLimiting which fields of a populated relation are returned (e.g., select: '_id name avatar').
Depth LimitMaximum nesting level for population. Hard limit: depth = 1 (no nested expansions).
Summary ShapeA minimal projection of a related entity designed for display (e.g., UserSummary, PartnerSummary).

2. Naming Rules (MANDATORY)

2.1 Persisted Foreign Keys

PatternExampleRule
<relation>IdcreatedById, categoryId, assigneeIdSingular FK — stores ObjectId
<relation>IdstagIds, roleIds, whitelistUserIdsArray FK — stores ObjectId[]

2.2 Response-Only Relations

PatternExampleRule
<relation>SummarycreatedBySummary, categorySummaryPreferred — projected relation object in API response
<relation>createdBy, categoryAllowed ONLY in API response if unambiguous; NEVER as DB field storing ID

2.3 Audit Fields (MANDATORY on all mutable entities)

FieldTypePersistedDescription
createdByIdObjectIdUser who created the record
updatedByIdObjectIdUser who last updated
deletedByIdObjectIdUser who soft-deleted (if applicable)
createdAtDateAuto (Mongoose timestamps)
updatedAtDateAuto (Mongoose timestamps)

2.4 FORBIDDEN Patterns

❌ FORBIDDENWhy✅ CORRECT
createdBy: ObjectId in schemaAmbiguous — looks like it could be a string name or populated objectcreatedById: ObjectId
createdBy: string (storing user ID)Same ambiguitycreatedById: ObjectId
Overwriting createdBy FK with populated objectMutates persisted field; breaks contractUse separate createdBySummary in response DTO
owner: ObjectId in schemaAmbiguousownerId: ObjectId
customer: ObjectId in schemaAmbiguouscustomerId: ObjectId

3. API Response Rules (MANDATORY)

3.1 Default: Minimal Response (No Relations)

All API endpoints MUST return only persisted fields by default. No relations are populated unless explicitly requested.

json
// DEFAULT response — no population
{
  "_id": "abc123",
  "name": "Product X",
  "categoryId": "cat456",
  "createdById": "user789",
  "createdAt": "2026-02-23T10:00:00Z"
}

3.2 Include/Expand Policy

Relations are populated only when:

  1. Client explicitly requests via ?include= query parameter
  2. The relation is on the server's whitelist for that endpoint
  3. The requesting user has RBAC permission to view the related entity

REST Pattern:

GET /catalog/products/:id?include=category,createdBy

Rules:

  • Comma-separated list of relation names
  • Each name must match a whitelisted relation for that endpoint
  • Server validates every include against whitelist BEFORE executing query
  • Unknown/forbidden includes return 400 INCLUDE_NOT_ALLOWED

3.3 Depth Limit: MAX = 1

HARD RULE: No nested expansions.

✅ include=category        → category is populated (depth 1)
✅ include=createdBy       → createdBy is populated (depth 1)
❌ include=category.parent → REJECTED (depth 2)
❌ include=createdBy.team  → REJECTED (depth 2)

Any request with depth > 1 returns 400 INCLUDE_DEPTH_EXCEEDED.

3.4 Projection: Summary Shapes Only

Populated relations MUST be projected to a minimal shape. Never return the full related document.

Standard Summary Shapes:

typescript
// UserSummary — for createdBy, updatedBy, assignee, etc.
interface UserSummary {
  _id: string;
  name: string;
  email?: string;    // only if RBAC allows
  avatar?: string;
}

// PartnerSummary — for customer, vendor references
interface PartnerSummary {
  _id: string;
  name: string;
  phone?: string;
  type: string;      // CUSTOMER | VENDOR
}

// CategorySummary — for product.category
interface CategorySummary {
  _id: string;
  name: string;
  slug: string;
}

// TenantSummary — for user.tenant
interface TenantSummary {
  _id: string;
  name: string;
  slug: string;
}

3.5 Prevent Loops

Mutual expansion is FORBIDDEN:

  • If Product includes Category, Category MUST NOT include Products in the same response
  • Server enforces: each entity type can appear at most once in the expansion tree

3.6 Error Codes

CodeHTTPMeaning
INCLUDE_NOT_ALLOWED400Requested include is not in the whitelist for this endpoint
INCLUDE_DEPTH_EXCEEDED400Nested include exceeds max depth (1)
INCLUDE_FORBIDDEN_FIELD403User lacks RBAC permission to view the related entity

4. Security / Privacy Rules

  1. RBAC before populate: Always check if requesting user has permission to view the related entity before returning it
  2. PII masking: Email, phone, address should NEVER appear in summary shapes unless the user has explicit permission (e.g., MEMBER_VIEW for email)
  3. Cross-tenant safety: Population MUST enforce tenantId filter on the related entity (no cross-tenant data leaks)
  4. Audit trail: Population requests are logged in audit (which user requested which includes)

5. Performance Rules

  1. Always use select(): Every populate() MUST have explicit field selection
  2. Always use .lean(): Populated queries must use lean for performance
  3. Batch N+1: If populating a list endpoint, use batch lookup (MongoDB $lookup or manual $in query) instead of per-item populate
  4. Cache summary shapes: Frequently-populated summaries (UserSummary, CategorySummary) should be cacheable (Redis, 5min TTL)
  5. Include budget: Maximum 3 includes per request

6. Examples

❌ BAD: Ambiguous createdBy Overwrite

typescript
// SCHEMA (BAD — createdBy stores ObjectId)
@Prop({ type: Types.ObjectId })
createdBy: Types.ObjectId;

// SERVICE (BAD — overwrites FK with populated object)
const product = await this.productModel.findById(id).populate('createdBy');
// product.createdBy is now a full User object, not the original ObjectId
// If saved back, the ObjectId is LOST

✅ GOOD: Separated FK + Response Summary

typescript
// SCHEMA (GOOD — explicit FK naming)
@Prop({ type: Types.ObjectId, ref: 'User' })
createdById: Types.ObjectId;

// SERVICE (GOOD — separate DTO)
const product = await this.productModel.findById(id).lean();
const response: ProductResponseDto = {
  ...product,
  // Only if ?include=createdBy was requested AND whitelisted:
  createdBySummary: includesCreatedBy
    ? await this.userService.getSummary(product.createdById)
    : undefined,
};

✅ REST Example with Include

http
GET /api/v2/catalog/products/abc123?include=category,createdBy
Authorization: Bearer <token>

Response 200:
{
  "_id": "abc123",
  "name": "Product X",
  "sku": "SKU-001",
  "categoryId": "cat456",
  "createdById": "user789",
  "categorySummary": {
    "_id": "cat456",
    "name": "Electronics",
    "slug": "electronics"
  },
  "createdBySummary": {
    "_id": "user789",
    "name": "Anh Nguyen",
    "avatar": "https://..."
  }
}

✅ REST Example — Include Rejected

http
GET /api/v2/catalog/products/abc123?include=createdBy.roles

Response 400:
{
  "statusCode": 400,
  "code": "INCLUDE_DEPTH_EXCEEDED",
  "message": "Nested includes are not allowed. Max depth is 1."
}

7. Migration Notes

V2 Schemas Requiring Rename

SchemaCurrent FieldTypeAction
product.schema.tscreatedBy: ObjectIdObjectIdRename to createdById
product.schema.tsupdatedBy: ObjectIdObjectIdRename to updatedById
category.schema.tscreatedBy: ObjectIdObjectIdRename to createdById
category.schema.tsupdatedBy: ObjectIdObjectIdRename to updatedById
voucher.schema.tscreatedBy: ObjectIdObjectIdRename to createdById
voucher.schema.tsupdatedBy: ObjectIdObjectIdRename to updatedById
user.schema.tscreatedBy: stringString(!)Rename to createdById, change to ObjectId
user.schema.tsupdatedBy: stringString(!)Rename to updatedById, change to ObjectId
wallet.schema.tsupdatedBy: ObjectIdObjectIdRename to updatedById

Migration Script Pattern

javascript
// Migration: rename createdBy → createdById
db.products.updateMany({}, { $rename: { "createdBy": "createdById" } });
db.categories.updateMany({}, { $rename: { "createdBy": "createdById" } });
db.vouchers.updateMany({}, { $rename: { "createdBy": "createdById" } });
// ... repeat for updatedBy → updatedById

8. Compatibility with Legacy Code

The OLD_CODE (tool_9106) already uses the correct pattern:

  • createdById: string as persisted FK
  • createdBy: IUserRes as response-only populated object

V2 MUST align with this established convention. The current V2 usage of createdBy: ObjectId is a regression from legacy best practice.


Changelog

DateChange
2026-02-23v1.0 — Initial standard created based on codebase audit

FitZalo Platform Documentation