Alarms, reminders, and notifications are some of the most deceptively complex features in Android development.
On the surface, they look simple: “Schedule something at a time and show a notification.”
In reality, you’re fighting with:
- Doze mode
- OEM background restrictions
- Process death
- App restarts
- Device reboots
- Android 12+ background execution limits
- Android 13+ notification permissions
- Android 14+ stricter alarms policies
After more than a decade building Android apps at scale, one thing is clear:
Most alarm and notification bugs are architectural bugs, not API bugs.
This article walks through how to architect alarms and notifications in a real production app using MVVM + Clean Architecture, feature modules, and a hybrid AlarmManager + WorkManager approach that survives modern Android constraints.
This is not theory. This is how you ship reliable reminder systems.
Core Architectural Principles (Hard-Earned Lessons)
Before talking about code, we need to align on principles. These are non-negotiable.
- Business logic must not depend on Android
- Scheduling is a domain concern, execution is an infrastructure concern
- Alarms wake the app, Workers do the work
- Repeating alarms are not repeating system alarms
- Everything must survive process death and reboot
If your current implementation violates any of these, instability is inevitable.
High-Level Architecture Overview
At a conceptual level, the flow looks like this:
UI → ViewModel → UseCase → Domain → Scheduler abstraction
↓
Android implementation
↓
AlarmManager → Receiver → Worker
↓
NotificationEach arrow represents a boundary. Boundaries are what give us control.
Feature-Based Modular Architecture
In real apps, packages are not enough. Gradle modules matter.
Recommended Module Layout
:app
:core:domain
:core:data
:core:infra
:core:common
:feature:reminder
:feature:notificationWhy This Matters
- Domain becomes completely platform-independent
- Feature modules own UI and presentation logic
- Infra absorbs Android volatility
- Teams can work independently without merge hell
Dependency Rules (Strict)
feature → domain
data → domain
infra → domain + data
app → everythingIf a feature imports AlarmManager, your architecture is already compromised.
Domain Layer: Where Truth Lives
The domain layer is pure Kotlin. No Context. No Android imports.
Reminder Model
data class Reminder(
val id: String,
val triggerAtMillis: Long,
val title: String,
val message: String,
val repeat: RepeatRule?
)This model expresses intent, not implementation.
Repeat Rule Modeling (Critical Design)
Repeating reminders are the most common source of bugs. The fix starts here.
sealed class RepeatRule {
data class Daily(val intervalDays: Int = 1) : RepeatRule()
data class Weekly(val daysOfWeek: Set<DayOfWeek>) : RepeatRule()
data class Interval(val millis: Long) : RepeatRule()
}This gives us:
- Explicit behavior
- Full control
- Deterministic scheduling
Scheduler Abstraction
The domain does not care how scheduling happens.
interface ReminderScheduler {
fun schedule(reminder: Reminder)
fun cancel(reminderId: String)
}This single interface is what keeps Android chaos contained.
Use Case Example
class ScheduleReminderUseCase(
private val repository: ReminderRepository,
private val scheduler: ReminderScheduler
) {
suspend operator fun invoke(reminder: Reminder) {
repository.save(reminder)
scheduler.schedule(reminder)
}
}Notice the order:
- Persist
- Schedule
That one decision is the difference between recoverable and broken reminders.
Presentation Layer (MVVM Done Properly)
The ViewModel knows nothing about alarms or notifications.
class ReminderViewModel(
private val scheduleReminder: ScheduleReminderUseCase
) : ViewModel() {
fun schedule(time: Long, title: String) {
viewModelScope.launch {
scheduleReminder(
Reminder(
id = UUID.randomUUID().toString(),
triggerAtMillis = time,
title = title,
message = "Reminder",
repeat = null
)
)
}
}
}This is clean, testable, and boring — exactly how ViewModels should be.
Infrastructure Layer: Where Android Lives
This is the layer that absorbs:
- AlarmManager
- PendingIntent
- BroadcastReceiver
- WorkManager
- Notifications
And keeps them out of your business logic.
Alarm Scheduling Implementation
class AndroidReminderScheduler(
private val alarmManager: AlarmManager,
private val pendingIntentFactory: ReminderPendingIntentFactory
) : ReminderScheduler {
override fun schedule(reminder: Reminder) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
reminder.triggerAtMillis,
pendingIntentFactory.create(reminder.id)
)
}
override fun cancel(reminderId: String) {
alarmManager.cancel(
pendingIntentFactory.create(reminderId)
)
}
}We use:
setExactAndAllowWhileIdle- Explicit PendingIntent
- One alarm per reminder
Anything else is unreliable.
PendingIntent Factory (Often Overlooked)
class ReminderPendingIntentFactory(
private val context: Context
) {
fun create(id: String): PendingIntent {
val intent = Intent(context, ReminderAlarmReceiver::class.java)
.putExtra("REMINDER_ID", id)
return PendingIntent.getBroadcast(
context,
id.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
}Correct requestCode usage is essential for cancellation and updates.
Alarm Receiver: Do Almost Nothing
class ReminderAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val work = OneTimeWorkRequestBuilder<ReminderWorker>()
.setInputData(workDataOf(
"REMINDER_ID" to intent.getStringExtra("REMINDER_ID")
))
.build()
WorkManager.getInstance(context).enqueue(work)
}
}Receivers should delegate immediately. Anything else risks ANRs.
Worker: The Real Execution Engine
This is where notifications are shown and repeats are handled.
@HiltWorker
class ReminderWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val repository: ReminderRepository,
private val scheduler: ReminderScheduler,
private val calculator: NextTriggerCalculator
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val id = inputData.getString("REMINDER_ID") ?: return Result.failure()
val reminder = repository.getById(id) ?: return Result.failure()
showNotification(reminder)
reminder.repeat?.let {
val next = calculator.calculate(reminder.triggerAtMillis, it)
val updated = reminder.copy(triggerAtMillis = next)
repository.save(updated)
scheduler.schedule(updated)
}
return Result.success()
}
}This explicit rescheduling is what makes repeating reminders reliable.
Why Repeating Alarms Must Be Manual
Using setRepeating():
- Is inexact
- Breaks under Doze
- Is ignored by OEMs
- Cannot adapt dynamically
One alarm → one execution → reschedule next
This model survives every Android version.
Hilt Dependency Injection (Exact Bindings)
Scheduler Binding
@Module
@InstallIn(SingletonComponent::class)
object SchedulerModule {
@Provides
fun provideReminderScheduler(
alarmManager: AlarmManager,
factory: ReminderPendingIntentFactory
): ReminderScheduler =
AndroidReminderScheduler(alarmManager, factory)
}AlarmManager Binding
@Module
@InstallIn(SingletonComponent::class)
object SystemServiceModule {
@Provides
fun provideAlarmManager(
@ApplicationContext context: Context
): AlarmManager =
context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
}Domain remains DI-agnostic.
AlarmManager vs WorkManager: The Real Decision Matrix
| Requirement | AlarmManager | WorkManager |
|---|---|---|
| Exact timing | Provides precise, exact-time execution and is suitable for alarms and reminders that must fire at a specific moment | Does not guarantee exact timing; execution can be delayed based on system conditions |
| Doze compatibility | Can fire during Doze mode only when using exact alarms with allow-while-idle, and even then execution is constrained | Does not run during Doze at an exact time and may be deferred until the system exits idle mode |
| Long background work | Not suitable for long-running background tasks; execution time must be very short | Designed specifically for longer background work with proper lifecycle handling |
| Survives device reboot | Does not automatically survive a device reboot and requires manual rescheduling | Automatically survives device reboot and restores scheduled work |
| Battery efficiency | Less battery efficient because it bypasses system optimizations for exact timing | More battery efficient because it respects system scheduling and optimization policies |
| User-visible alerts | Well suited for user-visible actions such as alarms, reminders, and time-critical notifications | Less reliable for user-visible alerts that must appear at an exact time |
- Use AlarmManager when the user expects something to happen at an exact moment, such as reminders, alarms, or medication alerts.
- Use WorkManager when the task involves background processing, network calls, database work, or any operation that does not require exact timing.
- Combine both for production-grade systems: let AlarmManager wake the app, and let WorkManager perform the actual work.
The Correct Hybrid Approach
- AlarmManager wakes the app at the exact time
- WorkManager executes background work
- Worker shows notification and reschedules
This combination works across:
- Android 8 → 15
- OEM restrictions
- Play Store policies
Conclusion
Reliable alarms and notifications are not about clever tricks or undocumented APIs. They are about respecting architectural boundaries and accepting Android’s reality.
If you:
- Keep domain pure
- Treat scheduling as a business concern
- Let infra absorb platform volatility
- Reschedule explicitly
Your reminder system will survive every Android release.
And you’ll sleep better knowing your users won’t miss their alarms.
