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
| Term | Definition |
|---|---|
| Persisted FK | A foreign key stored in the database schema (e.g., createdById: ObjectId). This is the source of truth. |
| Response Relation | An expanded/populated object returned in API responses (e.g., createdBySummary: { _id, name, avatar }). NEVER persisted into DB. |
| Populate / Join | The act of resolving a FK into its related document at query time (Mongoose .populate(), MongoDB $lookup). |
| Include / Expand | Client-facing mechanism to request population of specific relations (REST: ?include=createdBy). |
| Projection / Select | Limiting which fields of a populated relation are returned (e.g., select: '_id name avatar'). |
| Depth Limit | Maximum nesting level for population. Hard limit: depth = 1 (no nested expansions). |
| Summary Shape | A minimal projection of a related entity designed for display (e.g., UserSummary, PartnerSummary). |
2. Naming Rules (MANDATORY)
2.1 Persisted Foreign Keys
| Pattern | Example | Rule |
|---|---|---|
<relation>Id | createdById, categoryId, assigneeId | Singular FK — stores ObjectId |
<relation>Ids | tagIds, roleIds, whitelistUserIds | Array FK — stores ObjectId[] |
2.2 Response-Only Relations
| Pattern | Example | Rule |
|---|---|---|
<relation>Summary | createdBySummary, categorySummary | Preferred — projected relation object in API response |
<relation> | createdBy, category | Allowed ONLY in API response if unambiguous; NEVER as DB field storing ID |
2.3 Audit Fields (MANDATORY on all mutable entities)
| Field | Type | Persisted | Description |
|---|---|---|---|
createdById | ObjectId | ✅ | User who created the record |
updatedById | ObjectId | ✅ | User who last updated |
deletedById | ObjectId | ✅ | User who soft-deleted (if applicable) |
createdAt | Date | ✅ | Auto (Mongoose timestamps) |
updatedAt | Date | ✅ | Auto (Mongoose timestamps) |
2.4 FORBIDDEN Patterns
| ❌ FORBIDDEN | Why | ✅ CORRECT |
|---|---|---|
createdBy: ObjectId in schema | Ambiguous — looks like it could be a string name or populated object | createdById: ObjectId |
createdBy: string (storing user ID) | Same ambiguity | createdById: ObjectId |
Overwriting createdBy FK with populated object | Mutates persisted field; breaks contract | Use separate createdBySummary in response DTO |
owner: ObjectId in schema | Ambiguous | ownerId: ObjectId |
customer: ObjectId in schema | Ambiguous | customerId: 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.
// 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:
- Client explicitly requests via
?include=query parameter - The relation is on the server's whitelist for that endpoint
- The requesting user has RBAC permission to view the related entity
REST Pattern:
GET /catalog/products/:id?include=category,createdByRules:
- 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:
// 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
| Code | HTTP | Meaning |
|---|---|---|
INCLUDE_NOT_ALLOWED | 400 | Requested include is not in the whitelist for this endpoint |
INCLUDE_DEPTH_EXCEEDED | 400 | Nested include exceeds max depth (1) |
INCLUDE_FORBIDDEN_FIELD | 403 | User lacks RBAC permission to view the related entity |
4. Security / Privacy Rules
- RBAC before populate: Always check if requesting user has permission to view the related entity before returning it
- PII masking: Email, phone, address should NEVER appear in summary shapes unless the user has explicit permission (e.g.,
MEMBER_VIEWfor email) - Cross-tenant safety: Population MUST enforce
tenantIdfilter on the related entity (no cross-tenant data leaks) - Audit trail: Population requests are logged in audit (which user requested which includes)
5. Performance Rules
- Always use
select(): Everypopulate()MUST have explicit field selection - Always use
.lean(): Populated queries must use lean for performance - Batch N+1: If populating a list endpoint, use batch lookup (MongoDB
$lookupor manual$inquery) instead of per-item populate - Cache summary shapes: Frequently-populated summaries (UserSummary, CategorySummary) should be cacheable (Redis, 5min TTL)
- Include budget: Maximum 3 includes per request
6. Examples
❌ BAD: Ambiguous createdBy Overwrite
// 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
// 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
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
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
| Schema | Current Field | Type | Action |
|---|---|---|---|
product.schema.ts | createdBy: ObjectId | ObjectId | Rename to createdById |
product.schema.ts | updatedBy: ObjectId | ObjectId | Rename to updatedById |
category.schema.ts | createdBy: ObjectId | ObjectId | Rename to createdById |
category.schema.ts | updatedBy: ObjectId | ObjectId | Rename to updatedById |
voucher.schema.ts | createdBy: ObjectId | ObjectId | Rename to createdById |
voucher.schema.ts | updatedBy: ObjectId | ObjectId | Rename to updatedById |
user.schema.ts | createdBy: string | String(!) | Rename to createdById, change to ObjectId |
user.schema.ts | updatedBy: string | String(!) | Rename to updatedById, change to ObjectId |
wallet.schema.ts | updatedBy: ObjectId | ObjectId | Rename to updatedById |
Migration Script Pattern
// 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 → updatedById8. Compatibility with Legacy Code
The OLD_CODE (tool_9106) already uses the correct pattern:
createdById: stringas persisted FKcreatedBy: IUserResas 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
| Date | Change |
|---|---|
| 2026-02-23 | v1.0 — Initial standard created based on codebase audit |