| name | kotlin-schema-first-entities |
| description | Use when creating Room entities for Kotlin/Android apps that mirror Django/PostgreSQL backend schemas. Enforces exact field alignment, prevents schema drift, validates type mappings. Critical for maintaining zero impedance mismatch between backend and mobile. |
Kotlin Schema-First Room Entity Implementation
When to Use This Skill
MANDATORY when:
- Creating ANY
@Entityclass for Room database - Android app mirrors a Django/PostgreSQL backend
- Backend has Pydantic type-safe contracts
- Documentation exists for PostgreSQL → SQLite schema mapping
Triggers:
- About to write
@Entityannotation - Creating data models for offline-first mobile app
- Implementing sync between backend database and SQLite
Core Principle
Android SQLite is a REPLICA of PostgreSQL, not an independent schema.
Schema Authority Hierarchy:
1. PostgreSQL (Django Models) ← SOURCE OF TRUTH
2. Pydantic Validation ← Contract enforcement
3. OpenAPI Schema ← API contract
4. Kotlin DTOs ← Generated from OpenAPI
5. Room Entities ← Hand-coded to match DTOs ± denormalization
MANDATORY VERIFICATION PROTOCOL
Before Creating ANY Room Entity
STOP and complete this checklist:
Step 1: Locate schema mapping documentation
- Find:
docs/api-contracts/POSTGRESQL_TO_SQLITE_SCHEMA_MAPPING.md(or equivalent) - If missing: FAIL - Cannot proceed without schema reference
- Find:
Step 2: Find the corresponding section
- Example: Section 1.2 for People domain, Section 1.1 for Attendance, etc.
- Read the COMPLETE field list for that Django model
Step 3: Copy EXACT field list
- Do NOT add fields not in Django model
- Do NOT remove fields from Django model
- Do NOT assume field types
- Use the field list EXACTLY as documented
Step 4: Verify type mappings
- Check Section 1 of schema mapping doc for type conversion rules
- DateTimeField → Long (epoch ms), NOT String
- DecimalField → String (for precision), NOT Double/Float
- BooleanField → Boolean (stored as INTEGER 0/1)
- JSONField with known structure → Typed data class, NOT Map<String, Any>
- ForeignKey → Int/Long (store ID only)
- PointField (PostGIS) → Separate lat/lng Double columns OR WKT String
Step 5: Document denormalization
- If flattening multiple Django tables → Document in KDoc
- Justify with performance rationale
- List which Django tables are merged
Step 6: Add KDoc with schema authority
/** * ⚠️ SCHEMA AUTHORITY: Django model apps/domain/models/model_name.py * * FIELD MAPPING: [list Django fields → Kotlin properties] * * DENORMALIZATION: [if applicable, explain what's merged and why] * * Last Verified Against Django: YYYY-MM-DD */
CRITICAL PATTERNS
Pattern 1: Hybrid Naming Convention (REQUIRED)
@Entity(tableName = "table_name") // ← PostgreSQL table name
data class EntityName(
@ColumnInfo(name = "django_field") // ← MUST be PostgreSQL column name
val kotlinProperty: Type // ← CAN be idiomatic Kotlin name
)
Example:
@Entity(tableName = "peopleeventlog")
data class AttendanceEntity(
@ColumnInfo(name = "punchintime") // ← Django field (PostgreSQL column)
val checkInTime: Long, // ← Kotlin property (camelCase, descriptive)
@ColumnInfo(name = "people") // ← Django FK field
val personId: Int, // ← Kotlin property (explicit it's ID)
@ColumnInfo(name = "peoplename") // ← Django field
val fullName: String // ← Kotlin property (more descriptive)
)
Rule:
@ColumnInfo(name)= Django field name (becomes SQLite column)- Kotlin property = Idiomatic camelCase name
Pattern 2: Indexes MUST Match PostgreSQL (REQUIRED)
@Entity(
tableName = "peopleeventlog",
indices = [
Index(value = ["tenant", "cdtz"]), // ← From Django Meta.indexes
Index(value = ["tenant", "people"]), // ← Column names are Django fields
Index(value = ["tenant", "datefor"]),
Index(value = ["tenant", "people", "shift", "datefor"],
name = "pel_validation_lookup_idx") // ← Composite index
]
)
Rule: Copy PostgreSQL indexes exactly (use Django field names in value arrays).
Pattern 3: Foreign Keys with Cascade Behavior (REQUIRED)
@Entity(
foreignKeys = [
ForeignKey(
entity = PersonEntity::class,
parentColumns = ["id"],
childColumns = ["people"], // ← Django field name
onDelete = ForeignKey.RESTRICT // ← Matches Django on_delete
)
]
)
data class AttendanceEntity(
@ColumnInfo(name = "people") val personId: Int // ← Store FK ID
)
Django on_delete mapping:
models.CASCADE→ForeignKey.CASCADEmodels.RESTRICT→ForeignKey.RESTRICTmodels.SET_NULL→ForeignKey.SET_NULL
CRITICAL: Enable FK constraints in database:
override fun init(configuration: DatabaseConfiguration) {
super.init(configuration)
openHelper.writableDatabase.execSQL("PRAGMA foreign_keys=ON;")
}
Pattern 4: TypeConverters for Complex Types (REQUIRED)
For DateTime (MANDATORY):
@TypeConverter
fun fromTimestamp(value: Long?): Instant? =
value?.let { Instant.fromEpochMilliseconds(it) }
@TypeConverter
fun toTimestamp(instant: Instant?): Long? =
instant?.toEpochMilliseconds()
For Typed JSON (REQUIRED for known structures):
// ❌ WRONG: Untyped Map
@ColumnInfo(name = "capabilities") val capabilities: Map<String, Any>
// ✅ CORRECT: Typed data class
@Serializable
data class UserCapabilities(
@SerialName("can_use_ai_query") val canUseAiQuery: Boolean = false,
// ... match Django JSONField structure EXACTLY
)
@TypeConverter
fun fromCapabilities(value: UserCapabilities): String =
Json.encodeToString(value)
@TypeConverter
fun toCapabilities(value: String): UserCapabilities =
Json.decodeFromString(value)
FORBIDDEN ANTI-PATTERNS
❌ Anti-Pattern 1: Inventing Fields
// ❌ WRONG: Field not in Django model
@Entity
data class UserEntity(
val firstName: String, // ← Django doesn't have this!
val lastName: String, // ← Django has "peoplename" only!
val isFavorite: Boolean // ← Not in Django model!
)
Consequence: Sync failures, data loss, schema drift.
How to detect: Missing field in schema mapping doc = FORBIDDEN
❌ Anti-Pattern 2: Wrong Type Mappings
// ❌ WRONG: Type mismatch
@ColumnInfo(name = "department") val department: String // ← Django has FK (Int)
@ColumnInfo(name = "punchintime") val checkInTime: String // ← Django DateTimeField → Long
@ColumnInfo(name = "hours_worked") val hoursWorked: Double // ← Django DecimalField → String
Consequence: Type errors, precision loss, sync failures.
How to fix: Check Section 1 type mapping table.
❌ Anti-Pattern 3: Client-Side Timestamps
// ❌ WRONG: Generating server-authoritative timestamps
@Entity
data class AttendanceEntity(
val createdAt: Long = Instant.now().toEpochMilliseconds() // ← Django auto_now_add
)
Consequence: Timestamp mismatch between client and server, audit trail corruption.
How to fix: Server-authoritative timestamps come from API response.
❌ Anti-Pattern 4: Untyped JSON
// ❌ WRONG: Known structure as untyped map
@ColumnInfo(name = "capabilities") val capabilities: Map<String, Boolean>
Consequence: Runtime errors, no compile-time safety, typos in keys.
How to fix: Create typed data class matching Django JSONField default.
REQUIRED VALIDATION STEPS
Step 1: Field Count Verification
/**
* Django People model has 20+ fields.
* This entity must include ALL exposed via API.
*
* Verified field count: 30 (People: 15, Profile: 8, Organizational: 7)
*/
@Entity
data class UserEntity(
// List all 30 fields here...
)
Test: Count fields in Django model = Count fields in Room entity (± documented exclusions).
Step 2: Enum Value Verification
// Django TextChoices
class AssignmentStatus(models.TextChoices):
SCHEDULED = 'SCHEDULED', 'Scheduled'
CONFIRMED = 'CONFIRMED', 'Confirmed'
IN_PROGRESS = 'IN_PROGRESS', 'In Progress'
// Kotlin enum MUST match EXACTLY (case-sensitive)
enum class AssignmentStatus {
@SerialName("SCHEDULED") SCHEDULED,
@SerialName("CONFIRMED") CONFIRMED,
@SerialName("IN_PROGRESS") IN_PROGRESS
}
// ❌ WRONG: Different values
enum class AssignmentStatus {
SCHEDULED, // Missing @SerialName - won't deserialize correctly
Confirmed, // Wrong case - won't match Django
InProgress // Wrong format - won't match Django
}
Write test:
@Test
fun `enum values match Django choices`() {
val expected = setOf("SCHEDULED", "CONFIRMED", "IN_PROGRESS")
val actual = AssignmentStatus.values().map { it.name }.toSet()
assertEquals(expected, actual)
}
Step 3: Type Conversion Round-Trip Test
@Test
fun `DateTime conversion is lossless`() {
val original = Instant.parse("2025-11-08T14:30:00Z")
val epoch = original.toEpochMilliseconds()
val restored = Instant.fromEpochMilliseconds(epoch)
assertEquals(original, restored)
}
@Test
fun `Decimal conversion preserves precision`() {
val original = BigDecimal("8.123456")
val text = original.toPlainString()
val restored = text.toBigDecimal()
assertEquals(original, restored)
}
ENFORCEMENT CHECKLIST
Before marking Room entity complete:
- Schema mapping doc read for this domain
- ALL Django fields accounted for (or exclusion documented)
- @ColumnInfo names match Django field names exactly
- Type mappings verified against mapping table
- Indexes copied from Django Meta.indexes
- Foreign keys have correct cascade behavior
- TypeConverters defined for complex types
- KDoc includes Django model file path
- Denormalization documented (if applicable)
- Verification date added to KDoc
If ANY checkbox is unchecked → Entity is NOT complete.
SUCCESS CRITERIA
✅ Room entity compiles without errors ✅ KSP generates DAO implementation successfully ✅ Type conversion tests pass (round-trip lossless) ✅ Enum validation tests pass (match Django choices) ✅ SQLite column names match PostgreSQL exactly ✅ No fields invented (all from Django model) ✅ No type mismatches (all per mapping table) ✅ All complex types have TypeConverters
REFERENCE DOCUMENTS
Primary: docs/api-contracts/POSTGRESQL_TO_SQLITE_SCHEMA_MAPPING.md
- Section 1: Type mapping table (18 Django types)
- Section 1.1: Attendance domain models
- Section 1.2: People domain models
- Section 1.3: Operations domain models
- Section 1.4: Journal domain models
- Section 1.5: Helpdesk domain models
Secondary: docs/api-contracts/SCHEMA_FIRST_DATA_CONTRACT_VALIDATION.md
- Forbidden patterns
- Required patterns
- 8-point validation pipeline
Skill Guides: docs/api-contracts/skills/ROOM_IMPLEMENTATION_GUIDE.md
- Schema modification workflow
- TypeConverter patterns
- Migration strategies
INTEGRATION WITH OTHER SKILLS
Use with:
room-database-implementation- For Room-specific errors (missing TypeConverters, FK issues)kotlin-coroutines-safety- For DAO suspend functions and Flowoffline-first-architecture- For sync status fields and pending operations
This skill adds: Schema alignment verification on TOP of Room implementation best practices.
EXAMPLE: Correct Implementation
/**
* Room Entity for Attendance records (Check-in/Check-out).
*
* ⚠️ SCHEMA AUTHORITY: Django model
* - File: apps/attendance/models/people_eventlog.py
* - Model: PeopleEventlog
*
* DENORMALIZATION: None (exact replica of Django model)
*
* FIELD MAPPING (per POSTGRESQL_TO_SQLITE_SCHEMA_MAPPING.md Section 1.1):
* - Django punchintime (DateTimeField) → Kotlin Long (epoch ms)
* - Django people (ForeignKey) → Kotlin Int (person ID)
* - Django startlocation (PointField) → Kotlin Double lat/lng (denormalized)
* - Django peventlogextras (EncryptedJSONField) → Kotlin String (encrypted JSON)
*
* Last Verified: 2025-11-08
*/
@Entity(
tableName = "peopleeventlog", // ← PostgreSQL table name
foreignKeys = [
ForeignKey(
entity = PersonEntity::class,
parentColumns = ["id"],
childColumns = ["people"],
onDelete = ForeignKey.RESTRICT // ← Matches Django on_delete
)
],
indices = [
Index(value = ["tenant", "cdtz"]), // ← From Django Meta.indexes
Index(value = ["tenant", "people"]),
Index(value = ["tenant", "datefor"])
]
)
data class AttendanceEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Int? = null,
@ColumnInfo(name = "uuid")
val uuid: String = UUID.randomUUID().toString(),
@ColumnInfo(name = "people") // ← Django FK field name
val personId: Int?, // ← Kotlin property (explicit)
@ColumnInfo(name = "client")
val clientId: Int?,
@ColumnInfo(name = "bu")
val buId: Int?,
@ColumnInfo(name = "punchintime") // ← Django DateTimeField
val checkInTime: Long?, // ← Long epoch (per mapping table)
@ColumnInfo(name = "punchouttime")
val checkOutTime: Long?,
@ColumnInfo(name = "datefor") // ← Django DateField
val attendanceDate: String?, // ← ISO8601 date string
@ColumnInfo(name = "start_latitude") // ← Denormalized from PointField
val startLatitude: Double?,
@ColumnInfo(name = "start_longitude")
val startLongitude: Double?,
@ColumnInfo(name = "peventlogextras") // ← EncryptedJSONField
val eventExtras: String = "{}", // ← Store encrypted JSON
@ColumnInfo(name = "version") // ← VersionField
val version: Int = 1, // ← Optimistic locking
@ColumnInfo(name = "tenant") // ← Multi-tenant FK
val tenantId: Int?,
@ColumnInfo(name = "cdtz") // ← auto_now_add
val createdAt: Long, // ← Server-authoritative
@ColumnInfo(name = "mdtz") // ← auto_now
val updatedAt: Long, // ← Server-authoritative
// Sync metadata (client-managed)
@ColumnInfo(name = "mobile_id")
val mobileId: String = UUID.randomUUID().toString(),
@ColumnInfo(name = "sync_status")
val syncStatus: String = "pending"
)
VERIFICATION TESTS (MANDATORY)
Test 1: Schema Alignment
@Test
fun `UserEntity has all fields from schema mapping doc`() {
// Reference field list from schema doc
val expectedFields = setOf(
"id", "peoplecode", "peoplename", "loginid", "email",
"isadmin", "is_staff", "enable", "gender", "dateofbirth",
"dateofjoin", "department", "designation", "client", "bu",
"capabilities", "tenant", "cdtz", "mdtz"
// ... all 30 fields
)
val actualFields = UserEntity::class.memberProperties
.mapNotNull { it.findAnnotation<ColumnInfo>()?.name }
.toSet()
val missing = expectedFields - actualFields
val extra = actualFields - expectedFields
assertTrue(missing.isEmpty(), "Missing Django fields: $missing")
assertTrue(extra.isEmpty(), "Extra fields not in Django: $extra")
}
Test 2: Capabilities Match Django
@Test
fun `UserCapabilities has exact fields from Django default_capabilities`() {
val expectedKeys = setOf(
"can_use_ai_query",
"can_use_nlp_search",
"can_use_voice_commands",
"can_use_advanced_analytics",
"can_use_predictive_insights",
"can_use_automated_scheduling",
"can_use_smart_recommendations"
)
val json = Json.encodeToString(UserCapabilities())
val jsonObject = Json.parseToJsonElement(json).jsonObject
assertEquals(expectedKeys, jsonObject.keys)
}
COMMON ERRORS & FIXES
Error 1: "Field not in schema mapping doc"
Symptom: You want to add field notes: String to entity.
Check: Is notes in the Django model section of schema mapping doc?
- ✅ Yes → Safe to add
- ❌ No → FORBIDDEN - Do not add
Fix: If field is needed, add to Django model FIRST, then update mobile.
Error 2: "Capabilities structure doesn't match"
Symptom: Capabilities test fails - JSON keys don't match.
Check: Schema mapping doc has exact capabilities structure.
Fix:
// Read schema doc Section 1.2
// Copy EXACT field list for capabilities
// Do NOT invent permission fields
Error 3: "Type mismatch in DTO → Entity mapping"
Symptom:
// DTO has: departmentName: String (from API)
// Entity has: @ColumnInfo(name = "department") val departmentId: Int
This is CORRECT if:
- API enriches response with department name
- PostgreSQL stores department ID (FK)
- Entity stores ID (matches PostgreSQL)
Mapper handles enrichment:
fun UserDto.toEntity() = UserEntity(
departmentId = this.departmentId // Use ID from DTO, ignore name
)
SUCCESS INDICATORS
Green flags (entity is correct):
- ✅ KDoc references Django model file path
- ✅ All @ColumnInfo names are Django field names
- ✅ Field count matches schema doc (± documented denormalization)
- ✅ Type mappings match Section 1 table
- ✅ Indexes copied from Django Meta
- ✅ TypeConverters for all complex types
- ✅ Tests validate schema alignment
Red flags (entity is WRONG):
- 🚩 No KDoc referencing Django model
- 🚩 @ColumnInfo names are camelCase or different from Django
- 🚩 Field count doesn't match schema doc
- 🚩 Type mappings don't match (String instead of Int, Double instead of String)
- 🚩 No indexes defined (Django has indexes)
- 🚩 Map<String, Any> for known JSON structure
- 🚩 Invented fields (firstName, isFavorite, etc.)
SUMMARY
This skill enforces:
- Schema mapping doc is authoritative source
- Django field names in @ColumnInfo
- Exact type mappings per documentation
- No invented/assumed fields
- Typed JSON for known structures
- Server-authoritative timestamp handling
- FK IDs (not resolved objects)
Use this skill BEFORE room-database-implementation skill - it ensures schema alignment, then room-database-implementation ensures Room-specific correctness.
Prevents: Schema drift, sync failures, type mismatches, data loss.
Ensures: Perfect PostgreSQL → SQLite replica with zero impedance mismatch.