System Design II: School/Productivity App with Local DB & Sync
Architecting Hazira Khata — attendance, reports, offline-first, multi-role
Open interactive version (quiz + challenge)Real-world analogy
What is it?
School management app system design combines multi-role access control, offline-first attendance marking, Firebase+Room hybrid sync, on-device report generation, push notification targeting, and export features. It is a common system design question in Bangladeshi and South Asian tech interviews, where edtech and school management apps are a significant market. The Hazira Khata framing adds local market context that interviewers find impressive.
Real-world relevance
Hazira Khata is a conceptual school management app for Bangladesh's 100,000+ schools. Similar real apps include Shikho (edtech), 10MS (Math Solution), and various government school management systems used by the Bangladesh Directorate of Primary Education. These apps must handle patchy 3G coverage in rural schools, extremely varied device hardware, and non-technical primary users.
Key points
- Requirements gathering for Hazira Khata context — Hazira Khata (Attendance Ledger in Bengali) is a school management app concept for Bangladeshi schools. Key requirements: 5K schools, 50K teachers, 500K students. Teachers mark attendance offline (no internet in classrooms). Admins generate monthly reports. Parents view attendance and results via read-only access. Multi-role: Super Admin, School Admin, Teacher, Parent, Student. Core features: attendance tracking, exam results, fee payment status, notification system, export to PDF/Excel.
- Multi-role data access — the permission layer — Design a role-based entity: Role (SUPER_ADMIN, SCHOOL_ADMIN, TEACHER, PARENT, STUDENT). Each role sees a filtered view: Teachers see only their class's students. Admins see all classes in their school. Parents see only their own children. Implement this at the ViewModel layer — query Room with workerId/classId scoped queries. Never return all students and filter in UI — that leaks data and wastes memory.
- Room schema for attendance and results — Key entities: School(id, name, district), Class(id, schoolId, grade, section, teacherId), Student(id, classId, name, rollNo, parentId), AttendanceRecord(id, studentId, classId, date, status[PRESENT/ABSENT/LATE/LEAVE], markedBy, syncStatus), ExamResult(id, studentId, examId, subject, marks, grade, syncStatus), Notification(id, targetRole, targetId, title, body, isRead, createdAt). Index on (studentId, date) for fast attendance queries.
- Firebase + Room hybrid architecture — Firebase Realtime Database or Firestore for live sync when connectivity exists. Room as the local cache that is always the source of truth for UI. Sync flow: UI reads from Room (always fast, always available). Background job syncs Room to Firebase when online. Firebase listeners push server changes to Room via a sync service. This hybrid gives the speed of local reads with the collaboration power of Firebase's real-time capabilities.
- Attendance marking — offline-first flow — Teacher opens class roster (loaded from Room — instant). Marks each student present/absent by tapping. Each tap: 1) Writes AttendanceRecord to Room with syncStatus=PENDING. 2) Inserts SyncQueueEntry. 3) Updates UI via Flow. No network call. When connectivity returns, WorkManager syncs the batch. Idempotency key: (studentId, classId, date) unique constraint prevents double-marking.
- Report generation — on-device vs server-side — Monthly attendance reports: on-device generation is feasible for a single class (100 students, 30 days = 3000 records). Use Room queries with aggregation: SELECT studentId, COUNT(*) FILTER (WHERE status='PRESENT') as presentCount FROM attendance WHERE classId=? AND date BETWEEN ? AND ?. Generate PDF using Android's PdfDocument API or iTextG library. Server-side generation for school-wide or district-wide reports — offload to a backend job and deliver via download link.
- Export features — PDF and Excel — PDF export: Android PdfDocument API for simple layouts; iTextG for rich formatted reports with tables and charts. Excel export: Apache POI (heavyweight) or a lightweight CSV export that Excel can open. Share via FileProvider + ACTION_SEND intent. For large exports, run in a coroutine on Dispatchers.IO and show progress notification. Never generate large exports on the main thread.
- Notification system design — Push notifications via Firebase Cloud Messaging (FCM). Notification types: attendance summary (daily to parents), exam result published, fee due reminder, school announcement. Targeting: FCM topic subscription per school (all teachers in a school subscribe to /topics/school_{id}). Individual notifications use FCM token stored server-side per user. On-device, store notifications in Room Notification table for in-app inbox — FCM delivers to notification tray, but app inbox requires local persistence.
- Offline capability analysis — Teachers: fully offline for attendance marking and viewing class roster. Admins: offline for viewing pre-loaded reports; report generation requires sync. Parents: offline for viewing last-synced attendance and results. Data pre-loading strategy: on app launch with connectivity, pre-load the next 30 days of schedule and current term's data into Room. This covers the most common offline scenarios.
- Firebase security rules — critical for multi-tenancy — Firestore security rules must enforce: a Teacher can only read/write attendance for classes where teacher_id == request.auth.uid. A Parent can only read students where parent_id == request.auth.uid. School Admin scoped to school_id. Badly written rules that allow any authenticated user to read any data are the most common security bug in Firebase-backed school apps. Test rules with the Firebase Rules Playground before shipping.
- Performance: report queries at scale — 500K students x 300 school days = 150M attendance records per year. Simple full-table Room queries will be slow without proper indexing. Required indices: (classId, date), (studentId, date). For school-wide monthly reports, do not compute in Android — push aggregation to the server. Use Paging 3 for long student lists in the UI — never load 500 students into memory at once.
- Interview narration: Hazira Khata reference — Mentioning a Bangladeshi-context app by name (Hazira Khata) immediately signals local market awareness, which is a differentiator in Bangladeshi tech interviews. Frame it as: 'I designed this system with constraints relevant to our market — intermittent connectivity, low-end Android devices (2GB RAM), and a user base that includes non-technical teachers who need zero-friction UX.' This shows product thinking alongside technical depth.
Code example
// Room schema — core entities
@Entity(tableName = "attendance_records",
indices = [
Index(value = ["student_id", "date"], unique = true),
Index(value = ["class_id", "date"])
])
data class AttendanceRecord(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val studentId: String,
val classId: String,
val date: String,
val status: AttendanceStatus,
val markedBy: String,
val syncStatus: SyncStatus = SyncStatus.PENDING
)
enum class AttendanceStatus { PRESENT, ABSENT, LATE, LEAVE }
// DAO with aggregation query for reports
@Dao
interface AttendanceDao {
@Query("""
SELECT studentId,
SUM(CASE WHEN status = 'PRESENT' THEN 1 ELSE 0 END) as presentDays,
SUM(CASE WHEN status = 'ABSENT' THEN 1 ELSE 0 END) as absentDays,
SUM(CASE WHEN status = 'LATE' THEN 1 ELSE 0 END) as lateDays,
COUNT(*) as totalDays
FROM attendance_records
WHERE classId = :classId AND date BETWEEN :from AND :to
GROUP BY studentId
""")
suspend fun getMonthlyReport(classId: String, from: String, to: String): List<AttendanceSummary>
@Query("SELECT * FROM attendance_records WHERE classId = :classId AND date = :date")
fun getTodayAttendance(classId: String, date: String): Flow<List<AttendanceRecord>>
@Upsert
suspend fun upsert(record: AttendanceRecord)
}
// Firebase + Room hybrid repository
class AttendanceRepository(
private val db: AppDatabase,
private val firestore: FirebaseFirestore,
private val prefs: SyncPrefs
) {
// Read always from Room (offline-safe)
fun observeTodayAttendance(classId: String, date: String): Flow<List<AttendanceRecord>> =
db.attendanceDao().getTodayAttendance(classId, date)
// Write to Room first, then sync to Firebase
suspend fun markAttendance(record: AttendanceRecord) {
db.attendanceDao().upsert(record)
// WorkManager will sync later
}
// Firebase -> Room sync (called when online)
suspend fun syncFromFirebase(classId: String) {
val lastSync = prefs.getLastSyncTime(classId)
firestore.collection("attendance")
.whereEqualTo("classId", classId)
.whereGreaterThan("updatedAt", lastSync)
.get()
.await()
.documents
.forEach { doc ->
val record = doc.toObject(AttendanceRecord::class.java) ?: return@forEach
db.attendanceDao().upsert(record)
}
prefs.setLastSyncTime(classId, System.currentTimeMillis())
}
}
// PDF report generation
class ReportGenerator(private val context: Context) {
suspend fun generateMonthlyPdf(
className: String,
summaries: List<AttendanceSummary>
): File = withContext(Dispatchers.IO) {
val pdfDocument = PdfDocument()
val pageInfo = PdfDocument.PageInfo.Builder(595, 842, 1).create() // A4
val page = pdfDocument.startPage(pageInfo)
val canvas = page.canvas
val paint = Paint().apply {
textSize = 16f
color = Color.BLACK
}
canvas.drawText("Monthly Attendance Report — $className", 40f, 60f, paint)
paint.textSize = 12f
var y = 100f
summaries.forEach { summary ->
canvas.drawText(
"${summary.studentName}: ${summary.presentDays}/${summary.totalDays} days present",
40f, y, paint
)
y += 25f
}
pdfDocument.finishPage(page)
val file = File(context.cacheDir, "report_${System.currentTimeMillis()}.pdf")
pdfDocument.writeTo(file.outputStream())
pdfDocument.close()
file
}
}
// FCM notification targeting
class NotificationService {
private val messaging = FirebaseMessaging.getInstance()
fun subscribeToSchool(schoolId: String) {
messaging.subscribeToTopic("school_$schoolId")
}
fun subscribeToClass(classId: String) {
messaging.subscribeToTopic("class_$classId")
}
// Server-side: send to topic (shown as pseudo-code)
// POST https://fcm.googleapis.com/fcm/send
// { "to": "/topics/school_123", "notification": { "title": "...", "body": "..." } }
}
// Paging 3 for large student lists
class StudentPagingSource(private val db: AppDatabase, private val classId: String) : PagingSource<Int, Student>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Student> {
val page = params.key ?: 0
return try {
val students = db.studentDao().getStudentsPaged(classId, params.loadSize, page * params.loadSize)
LoadResult.Page(
data = students,
prevKey = if (page == 0) null else page - 1,
nextKey = if (students.size < params.loadSize) null else page + 1
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Student>): Int? = state.anchorPosition
}Line-by-line walkthrough
- 1. The unique index on (student_id, date) in AttendanceRecord enforces one record per student per day at the database level — INSERT OR REPLACE semantics with @Upsert handles re-marking correctly.
- 2. The second index on (class_id, date) supports the daily class roster query efficiently — without it, retrieving all attendance for a class on a given day requires a full table scan.
- 3. The SQL aggregation query uses CASE WHEN inside SUM — this is a single-pass aggregation that computes present, absent, and late counts without multiple queries, efficient for report generation.
- 4. observeTodayAttendance returns Flow> — Room emits a new list on every change, driving the RecyclerView to update immediately when a teacher taps a student status.
- 5. syncFromFirebase uses whereGreaterThan('updatedAt', lastSync) — this is the delta sync pattern for Firestore, fetching only documents changed since last sync.
- 6. prefs.setLastSyncTime() uses server-returned timestamp, not device clock — prevents missed records from devices with clock drift.
- 7. withContext(Dispatchers.IO) in generateMonthlyPdf ensures PDF generation never blocks the main thread — PdfDocument.writeTo() is a disk write that can take 500ms+ for large reports.
- 8. PdfDocument requires explicit startPage/finishPage pairing — each page is rendered to a canvas then finalized before writing; multiple pages require looping this pattern.
- 9. FCM topic subscription (subscribeToTopic) happens on the client — the app subscribes the current device to the school's topic. Server sends to the topic, FCM fans out to all subscribed devices.
- 10. StudentPagingSource.load() computes offset as page * params.loadSize — this converts the page index to a SQL OFFSET value for the Room query, enabling efficient pagination without loading all records.
Spot the bug
class AttendanceViewModel(private val repo: AttendanceRepository) : ViewModel() {
fun markAllPresent(students: List<Student>, classId: String) {
val today = SimpleDateFormat("yyyy-MM-dd").format(Date())
students.forEach { student ->
viewModelScope.launch {
repo.markAttendance(
AttendanceRecord(
studentId = student.id,
classId = classId,
date = today,
status = AttendanceStatus.PRESENT,
markedBy = "current_teacher"
)
)
}
}
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Room with Flow and coroutines (Android Docs)
- Firebase Firestore Android quickstart (Firebase Docs)
- Paging 3 library overview (Android Docs)
- Firebase Cloud Messaging — Android (Firebase Docs)
- PdfDocument API — Android Developers (Android Docs)