Modern Android development has moved beyond the era of sprawling XML layouts and fragile AsyncTask calls. Kotlin, Jetpack libraries, and opinionated architecture patterns now promise faster iteration, fewer crashes, and more maintainable code. Yet many teams still struggle to adopt these tools effectively, often falling into the same traps that negate the benefits. This guide identifies the most common mistakes and shows how to avoid them, using a problem–solution approach that focuses on real-world constraints.
We will walk through a complete workflow: from setting up a modular project with Jetpack Compose and Navigation, to handling state, dependency injection, and testing. Along the way, we highlight the pitfalls that trip up both newcomers and experienced developers making the transition. By the end, you should have a clear, repeatable process for building Android apps that are robust, testable, and pleasant to maintain.
Why Modern Tooling Matters and What Goes Wrong Without It
Old-school Android development relied on Java, manual lifecycle management, and third-party libraries that often conflicted. The result was boilerplate-heavy code that was hard to test and easy to break. A typical Activity might contain network calls, database operations, and UI updates, all tangled together. Changing one piece risked breaking others, and debugging required tracing through layers of callbacks.
Modern tooling addresses these problems directly. Kotlin eliminates null-safety headaches and reduces boilerplate with features like data classes, sealed classes, and coroutines. Jetpack libraries provide lifecycle-aware components, a unified navigation system, and a declarative UI framework (Compose) that updates only what changes. Architecture guidelines from Google encourage separation of concerns through ViewModel, Repository, and UseCase layers.
But adopting these tools without understanding their intent can backfire. A common mistake is mixing Compose with traditional XML fragments, leading to inconsistent state management and unpredictable recompositions. Another is over-engineering with too many abstraction layers, making the codebase harder to navigate than the legacy version. Teams often jump into Compose without mastering the state hoisting pattern, resulting in UI that does not reflect the underlying data model.
The cost of these mistakes is real: slower development, increased bug rates, and developer frustration. In one typical scenario, a team migrating a large e-commerce app to Compose found that their custom state holders duplicated ViewModel logic, causing subtle race conditions. They had to refactor twice before settling on a unidirectional data flow. The lesson is that modern tools are not a silver bullet—they require a disciplined approach to architecture and a willingness to unlearn old habits.
This guide is for Android developers who have some experience with the platform but want to adopt modern practices systematically. It assumes you are familiar with basic Kotlin syntax and Android Studio, but not necessarily with Jetpack or Compose. We will focus on the decision points and trade-offs that make the difference between a smooth transition and a painful one.
Prerequisites: What You Need Before Starting
Before diving into the workflow, ensure your environment and team are ready. The following prerequisites are not optional—skipping them leads to the most common setup failures.
Kotlin Proficiency Beyond Basics
You need more than syntax knowledge. Understand coroutines and flows, because nearly every Jetpack library uses them. Know how to use StateFlow and SharedFlow for observable state, and be comfortable with structured concurrency. A team that treats coroutines as “just async” often leaks scopes and creates hard-to-reproduce bugs. Invest in training or pair programming before the migration starts.
Android Studio and Gradle Versions
Use the latest stable Android Studio (Hedgehog or later) and a compatible Gradle version. Many issues with Compose or Navigation stem from version mismatches. Check the official compatibility table for Kotlin, Compose compiler, and AGP. A typical mistake is updating Kotlin but forgetting the Compose compiler extension, leading to cryptic build errors. Automate version checks with a Gradle plugin like refreshVersions or a simple script.
Understanding of Jetpack Architecture Components
You should be comfortable with ViewModel, LiveData (or StateFlow), Room, and Navigation. If you are new to these, start with a small sample project that uses them together. The official Android Architecture Blueprints (now archived) still provide a good reference. Do not attempt to learn Compose and Navigation simultaneously—master the underlying concepts first.
Team Agreement on Conventions
Modern Android development requires consistent conventions for state management, dependency injection, and module structure. Without agreement, the codebase becomes a patchwork of competing patterns. Hold a design session to decide on a single approach for each concern: use Hilt or Koin for DI, decide whether to use sealed class or enum for UI states, and agree on a naming convention for ViewModels and repositories. Document these decisions in a short ADR (Architecture Decision Record).
Testing Infrastructure
Modern apps are testable by design, but only if you set up the testing framework early. Add dependencies for JUnit 5, MockK, and Compose UI testing. Configure test source sets for unit tests and instrumented tests. A common mistake is postponing tests until after the feature is built, then finding that the architecture makes mocking impossible. Write tests alongside production code, even if they are simple at first.
Core Workflow: Building a Modern Android App Step by Step
This section outlines a sequential workflow that minimizes rework. The steps are ordered to catch issues early and keep the codebase consistent.
Step 1: Define the Navigation Graph
Start with the user journey, not the data model. Use the Navigation Compose library to define routes and arguments. Each destination corresponds to a screen or dialog. Keep the graph flat unless you need nested navigation (e.g., for bottom navigation tabs). A common mistake is making the navigation graph too deep, which complicates argument passing and back-stack management.
Example: For a shopping app, define routes like productList, productDetail/{id}, cart, and checkout. Use sealed classes for route definitions to ensure type safety. Avoid using strings for destinations; instead, define a sealed class or object that holds the route pattern and arguments.
Step 2: Set Up State Management with ViewModel and StateFlow
Each screen should have a ViewModel that exposes a single StateFlow where UiState is a sealed class representing loading, success, error, and idle states. The ViewModel collects data from repositories and maps it to UI state. This unidirectional data flow makes the UI predictable and testable.
A frequent pitfall is exposing multiple StateFlows from a single ViewModel, forcing the UI to combine them with combine or nested collect. Instead, aggregate all relevant data into one state object. If the state grows large, split it into sub-states that are combined in the ViewModel, but still expose a single flow to the UI.
Step 3: Build UI with Compose and State Hoisting
Compose functions should be stateless where possible. Hoist state to the ViewModel or a higher-level composable that owns the state. Use remember only for UI-specific state like text field input or scroll position. A common mistake is putting business logic inside composables, making them hard to test and reuse.
For example, a ProductCard composable should receive the product data and a callback for user actions, not fetch data itself. This keeps the composable pure and reusable across screens.
Step 4: Implement Dependency Injection with Hilt
Use Hilt for dependency injection. Define modules for each layer (network, database, repository). Avoid manual DI or service locators in production code, as they make testing difficult. A typical mistake is annotating ViewModels with @HiltViewModel but forgetting to add the @AndroidEntryPoint annotation to the host Activity or Fragment, causing runtime crashes.
For unit tests, use Hilt’s testing support or MockK to inject mocks. Keep modules focused: one module for Room database, one for Retrofit, one for repositories. Do not create a single monolithic module that provides everything.
Step 5: Add Persistence with Room
Room simplifies local data storage. Define entities, DAOs, and the database class. Use Flow return types in DAOs to observe changes reactively. A common mistake is using LiveData instead of Flow, which ties you to the Android framework and makes testing harder. Also, avoid exposing raw DAO objects to the ViewModel; wrap them in repositories that handle data mapping and error handling.
Step 6: Write Tests for Each Layer
Unit test ViewModels by mocking repositories. Use Turbine library to test StateFlow emissions. For Compose UI, write createComposeRule tests that verify UI behavior. Integration tests with Room and Retrofit can use in-memory databases and MockWebServer. A common mistake is testing only the happy path; include tests for error states, loading states, and edge cases like empty lists or network timeouts.
Tools, Setup, and Environment Realities
Even with the right workflow, your environment can sabotage productivity. Here are the tools and configurations that matter most, along with common setup mistakes.
Gradle Configuration
Use Gradle version catalogs (libs.versions.toml) to centralize dependency versions. This avoids version conflicts and makes updates easier. A typical mistake is hardcoding versions in each module’s build.gradle.kts, leading to drift. Use the kotlin-android plugin and apply Compose compiler settings consistently across modules.
Enable Gradle build caching and configure parallel execution. For large projects, consider using the --scan option to identify bottlenecks. Many teams overlook the Compose compiler configuration, which must match the Kotlin version. Check the official Compose compiler compatibility map.
Coroutine Scopes and Lifecycle
Use viewModelScope for ViewModel coroutines and lifecycleScope for UI-related coroutines. Never use GlobalScope in production code. A common mistake is launching coroutines in composables without a scope, leading to leaks. Use LaunchedEffect or rememberCoroutineScope for side effects in Compose.
For background work, use WorkManager instead of foreground services. WorkManager handles constraints like network availability and device idle state, and survives process death.
Build Variants and Flavor Dimensions
Configure build types (debug, release) and product flavors if needed (e.g., demo, full). Use source sets to share code across flavors. A common mistake is duplicating code in flavor-specific directories when a simple configuration file would suffice. Use Gradle build config fields to toggle features.
CI/CD Integration
Set up continuous integration with GitHub Actions or Bitrise. Run lint, unit tests, and build checks on every pull request. Use ktlint or detekt to enforce code style. A common mistake is running tests only on the CI server without local pre-commit hooks, leading to repeated failures. Configure a pre-commit hook that runs lint and unit tests.
Variations for Different Constraints
Not every project is a greenfield app with a small team. Here are adjustments for common scenarios.
Large Team with Multiple Modules
For teams of 10+ developers, modularize the app by feature. Each feature module has its own navigation graph, ViewModel, and UI. Use a shared core module for common utilities, network, and database. A common mistake is creating too many modules, which increases build time and complexity. Aim for 5–10 feature modules plus core and app modules.
Use convention plugins to share build logic across modules. Define a android-common plugin that applies Kotlin, Compose, and Hilt configurations. This reduces duplication and ensures consistency.
Single Developer or Small Team
If you are working alone or in a pair, you can afford a flatter structure. Use a single module with packages for features. Skip Hilt in favor of manual DI or a simple service locator, but be prepared to refactor if the app grows. Focus on writing tests for the most critical paths rather than 100% coverage.
A common mistake for solo developers is skipping architecture entirely and writing everything in Activities or Fragments. Even a small app benefits from ViewModel and repository separation. Start with the minimal architecture that works and add layers only when needed.
Migrating an Existing App
Migration is riskier than greenfield development. Start by adding Kotlin to existing Java files incrementally. Introduce Compose in new screens, leaving existing XML screens untouched. Use AndroidView composable to embed legacy views if needed. A common mistake is trying to rewrite the entire app at once, which introduces bugs and delays releases.
Create a migration plan that prioritizes screens with the most user impact or the most complex logic. For each screen, write integration tests before refactoring to ensure behavior is preserved. Use feature flags to toggle between old and new implementations, allowing gradual rollout.
Pitfalls, Debugging, and What to Check When It Fails
Even with careful planning, things break. Here are the most common failures and how to diagnose them.
Compose Recompositions Not Updating UI
If the UI does not reflect state changes, check that you are collecting the StateFlow correctly. Use collectAsState() in composables and ensure the ViewModel is not creating a new StateFlow instance on each collection. A common mistake is using stateIn() with the wrong sharing strategy. Use WhileSubscribed(5000) to avoid restarting the flow on configuration changes.
Another cause is using mutable state inside the ViewModel that is not exposed as a StateFlow. Always expose state as read-only flows or state holders.
Navigation Errors: Screen Not Found or Back Stack Issues
If navigation fails, verify that the route string matches exactly, including arguments. Use a sealed class for routes to avoid typos. For deep links, check the manifest and the navigation graph. A common mistake is forgetting to add the navController to the composable hierarchy. Ensure each screen receives the NavController via parameter or LocalNavController.
Back-stack issues often arise from using popUpTo incorrectly. When navigating to a new screen, use navController.navigate(route) { popUpTo(startDestination) { inclusive = true } } to clear the back stack if needed. Test back button behavior on different devices.
Hilt Injection Failures at Runtime
If you get a HiltViewModel instantiation error, check that the Activity or Fragment is annotated with @AndroidEntryPoint. For ViewModels, use @HiltViewModel and inject dependencies via constructor. A common mistake is mixing Hilt with manual DI in the same module, causing duplicate bindings. Use @Binds for interfaces and @Provides for third-party classes.
For testing, use @HiltAndroidTest and @BindValue for mocks. If tests fail with missing bindings, ensure the test module provides all dependencies.
Room Database Migration Errors
If the app crashes after a schema change, provide a migration in the Room database builder. Use fallbackToDestructiveMigration() only during development. A common mistake is forgetting to increment the version number or writing a migration that does not handle all cases. Write and test migrations on a real device with existing data.
Use Room.inMemoryDatabaseBuilder for tests to avoid migration issues. For production, export the schema and review it in version control.
Performance Issues: Slow Compose or Jank
If Compose is laggy, check for unnecessary recompositions. Use the Compose layout inspector to see which composables are recomposing. Common causes: using derivedStateOf incorrectly, creating new lambdas in composable parameters, or having large lists without keys. Use key() in LazyColumn items and avoid allocating objects in composable bodies.
For network or database calls, ensure they are off the main thread. Use Dispatchers.IO in repositories and viewModelScope.launch for coroutines. Profile with Android Studio’s CPU profiler to identify bottlenecks.
If you encounter a problem not listed here, start by isolating the issue: create a minimal reproducible example in a separate project. Check official documentation and issue trackers for known bugs. The community is active, and many problems have been solved before.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!