Architecting Alarms & Notifications in Android: The Clean Architecture Way

Table of Contents

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.

  1. Business logic must not depend on Android
  2. Scheduling is a domain concern, execution is an infrastructure concern
  3. Alarms wake the app, Workers do the work
  4. Repeating alarms are not repeating system alarms
  5. 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:

Kotlin
UI → ViewModel → UseCase → Domain → Scheduler abstraction

                               Android implementation

                          AlarmManager → Receiver → Worker

                                Notification

Each 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

Kotlin
:app

:core:domain
:core:data
:core:infra
:core:common

:feature:reminder
:feature:notification

Why 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)

Kotlin
feature → domain
data → domain
infra → domain + data
app → everything

If 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

Kotlin
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.

Kotlin
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.

Kotlin
interface ReminderScheduler {
    fun schedule(reminder: Reminder)
    fun cancel(reminderId: String)
}

This single interface is what keeps Android chaos contained.

Use Case Example

Kotlin
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:

  1. Persist
  2. 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.

Kotlin
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

Kotlin
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)

Kotlin
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

Kotlin
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.

Kotlin
@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

Kotlin
@Module
@InstallIn(SingletonComponent::class)
object SchedulerModule {

    @Provides
    fun provideReminderScheduler(
        alarmManager: AlarmManager,
        factory: ReminderPendingIntentFactory
    ): ReminderScheduler =
        AndroidReminderScheduler(alarmManager, factory)
}

AlarmManager Binding

Kotlin
@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

RequirementAlarmManagerWorkManager
Exact timingProvides precise, exact-time execution and is suitable for alarms and reminders that must fire at a specific momentDoes not guarantee exact timing; execution can be delayed based on system conditions
Doze compatibilityCan fire during Doze mode only when using exact alarms with allow-while-idle, and even then execution is constrainedDoes not run during Doze at an exact time and may be deferred until the system exits idle mode
Long background workNot suitable for long-running background tasks; execution time must be very shortDesigned specifically for longer background work with proper lifecycle handling
Survives device rebootDoes not automatically survive a device reboot and requires manual reschedulingAutomatically survives device reboot and restores scheduled work
Battery efficiencyLess battery efficient because it bypasses system optimizations for exact timingMore battery efficient because it respects system scheduling and optimization policies
User-visible alertsWell suited for user-visible actions such as alarms, reminders, and time-critical notificationsLess 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

  1. AlarmManager wakes the app at the exact time
  2. WorkManager executes background work
  3. 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.

Skill Up: Software & AI Updates!

Receive our latest insights and updates directly to your inbox

Related Posts

error: Content is protected !!