🧠 If it extends an Android class — it probably shouldn’t contain your business logic.
A lot of Android developers unknowingly fall into the same architectural trap: they put too much logic inside framework classes like FirebaseMessagingService, BroadcastReceiver, Activity, or Service. At first, it feels fast and easy. But soon the code becomes brittle, hard to test, and almost impossible to maintain.
This article shows how to avoid that trap by applying one simple rule:
Keep Android components dumb. Reza!
We’ll use Firebase Cloud Messaging (FCM) as a case study, but this principle applies broadly across your app architecture.
📦 FCM Push Notifications: A Case Study:
Setting up Firebase Cloud Messaging (FCM) on Android is straightforward. You set up the AndroidManifest.xml, request permissions, and override two key methods:

- onNewToken(String token) — called when a new FCM token is generated
- onMessageReceived(RemoteMessage message) — triggered when a push message arrives
Then you’d have something like:
override fun onMessageReceived(remoteMessage: RemoteMessage) { | |
val title = remoteMessage.data["title"] | |
val body = remoteMessage.data["body"] | |
val deeplink = remoteMessage.data["deeplink"] | |
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deeplink)) | |
val notification = NotificationCompat.Builder(this, "channel_id") | |
.setContentTitle(title) | |
.setContentText(body) | |
.setContentIntent(PendingIntent.getActivity(this, 0, intent, 0)) | |
.build() | |
NotificationManagerCompat.from(this).notify(1, notification) | |
} |
⚠️ It works… until it doesn’t. Why?
- ❌ You can’t unit test it
- ❌ You can’t reuse the logic
- ❌ You need real pushes to test it
Let’s clean it up.
✅ The Better Way: Delegate Everything
🧩 1. Keep FirebaseMessagingService Thin
Your service shouldn’t contain logic. It should forward work to injected, testable components.
@AndroidEntryPoint | |
class MyFirebaseMessagingService : FirebaseMessagingService() { | |
@Inject lateinit var pushHandler: dagger.Lazy<PushHandler> | |
@Inject lateinit var pushMessageMapper: dagger.Lazy<PushMessageMapper> | |
override fun onNewToken(token: String) { | |
//Communicate with your server to update the token | |
} | |
override fun onMessageReceived(message: RemoteMessage) { | |
val mapped = pushMessageMapper.get().map(message) | |
pushHandler.get().handle(mapped) | |
} | |
} |
This gives you the power of dependency injection (e.g., Hilt or Koin), and isolates the logic for reuse and testing.
🧱 2. Map to a Clean Domain Model
Don’t pass around Firebase’s RemoteMessage. Instead, extract a platform-agnostic model that reflects your app’s needs
data class PushMessage( | |
val title: String?, | |
val body: String?, | |
val deeplink: String?, | |
val type: String? | |
) |
Map FCM data into this class:
class PushMessageMapper @Inject constructor() { | |
fun map(message: RemoteMessage): PushMessage { | |
val data = message.data | |
return PushMessage( | |
title = data["title"], | |
body = data["body"], | |
deeplink = data["deeplink"], | |
type = data["type"] | |
) | |
} | |
} |
By abstracting RemoteMessage, you decouple your core logic from Firebase.
🛠️ 3. Inject and Test Business Logic
Now create a PushHandler that takes your domain model and decides what to do.
interface PushHandler { | |
fun handle(message: PushMessage) | |
} | |
class DefaultPushHandler @Inject constructor( | |
private val notificationFactory: NotificationFactory, | |
private val deeplinkParser: DeeplinkParser, | |
@ApplicationContext private val context: Context | |
) : PushHandler { | |
override fun handle(message: PushMessage) { | |
val notification = notificationFactory.createNotification(context, message) | |
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) | |
== PackageManager.PERMISSION_GRANTED) { | |
NotificationManagerCompat.from(context).notify(1001, notification) | |
} | |
} | |
} |
Now your logic is testable, extendable, and easy to maintain.
🔁 Same Pattern for BroadcastReceivers
This mistake isn’t limited to push notifications. BroadcastReceiver is another classic offender.
Bad pattern — logic inside the receiver:
class BootCompletedReceiver : BroadcastReceiver() { | |
override fun onReceive(context: Context, intent: Intent?) { | |
val workManager = WorkManager.getInstance(context) | |
workManager.enqueue(...) // logic here | |
} | |
} |
Better pattern — delegate logic:
class BootCompletedReceiver : BroadcastReceiver() { | |
private val rescheduler: TaskRescheduler by inject() | |
override fun onReceive(context: Context, intent: Intent?) { | |
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) { | |
rescheduler.reschedule() | |
} | |
} | |
} | |
interface TaskRescheduler { | |
fun reschedule() | |
} | |
class DefaultTaskRescheduler @Inject constructor( | |
private val workManager: WorkManager | |
) : TaskRescheduler { | |
override fun reschedule() { | |
workManager.enqueue(...) // clean and testable | |
} | |
} |
This allows you to test rescheduling logic with unit tests — no need to simulate broadcasts.
🧪 Unit Testing with Robolectric and Fakes
This is where it gets fun. Because your core logic is no longer tied to Android components, testing is simple:
@RunWith(RobolectricTestRunner::class) | |
class DefaultPushHandlerTest { | |
private lateinit var context: Context | |
private val posted = mutableListOf<Notification>() | |
@Before | |
fun setup() { | |
context = ApplicationProvider.getApplicationContext() | |
} | |
@Test | |
fun `push handler posts notification`() { | |
val handler = DefaultPushHandler( | |
FakeNotificationFactory(context, posted), | |
FakeDeeplinkParser(), | |
context | |
) | |
handler.handle(PushMessage("Hello", "World", null, null)) | |
assertEquals("Hello", posted.first().extras.getString(Notification.EXTRA_TITLE)) | |
} | |
} |
You don’t need a device or emulator — just plain Kotlin and Robolectric.
📲 End-to-End Verification with UIAutomator
You can also test real system-level behavior with UIAutomator:
@RunWith(AndroidJUnit4::class) | |
class NotificationUITest { | |
@Test | |
fun notificationIsDisplayed() { | |
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) | |
val context = ApplicationProvider.getApplicationContext<Context>() | |
val handler = DefaultPushHandler( | |
FakeNotificationFactoryThatShowsRealNotif(context), | |
FakeDeeplinkParser(), | |
context | |
) | |
handler.handle(PushMessage("Test Title", "Test Body", null, null)) | |
device.openNotification() | |
device.wait(Until.hasObject(By.textContains("Test Title")), 5000) | |
val notif = device.findObject(UiSelector().textContains("Test Title")) | |
assertTrue("Notification not found", notif.exists()) | |
} | |
} |
class FakeNotificationFactoryThatShowsRealNotif( | |
private val context: Context | |
) : NotificationFactory { | |
init { | |
createNotificationChannel() | |
} | |
override fun createNotification(context: Context, message: PushMessage): Notification { | |
return NotificationCompat.Builder(context, TEST_CHANNEL_ID) | |
.setContentTitle(message.title ?: "Test Title") | |
.setContentText(message.body ?: "Test Body") | |
.setSmallIcon(android.R.drawable.ic_dialog_info) | |
.setPriority(NotificationCompat.PRIORITY_HIGH) | |
.build().also { | |
NotificationManagerCompat.from(context).notify(TEST_NOTIFICATION_ID, it) | |
} | |
} | |
private fun createNotificationChannel() { | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | |
val name = "Test Channel" | |
val importance = NotificationManager.IMPORTANCE_HIGH | |
val channel = NotificationChannel(TEST_CHANNEL_ID, name, importance) | |
NotificationManagerCompat.from(context).createNotificationChannel(channel) | |
} | |
} | |
companion object { | |
private const val TEST_CHANNEL_ID = "test_channel" | |
private const val TEST_NOTIFICATION_ID = 42 | |
} | |
} |
Perfect for verifying real device behavior on CI or pre-release tests.
Job Offers
💡 Final Advice
- 🤖 FirebaseMessagingService should not contain notification code
- 📡 BroadcastReceiver should not enqueue jobs
- 🎬 Activity should not contain business logic
@Inject
, @Binds
, and interfaces
to make every part of your system testable and modular.✅ TL;DR
- Android components should delegate, not do
- Move logic into injectable classes
- Create domain models to decouple from SDKs
- Unit test with fakes, verify UI with UIAutomator
🧼 Clean architecture starts with one rule: Keep Components Dumb.
- If you need help with your Android project, Book a 1:1 or a Pair-Programming meeting with me, Book a time now🧑💻🧑💻🧑💻
- Follow my YouTube channel for video tutorials and tips on Android development 📺📺
If you like this article please can you do me a little favour and hit the 👏clap button as many times! I really appreciate your kindness x❤️👊
This article was previously published on proandroiddev.com.