If you are building Android apps professionally in 2025, you have likely noticed that the surface area of what "modern" means keeps expanding. Kotlin Multiplatform, Compose for foldables, and the ever-present pressure to ship faster have turned what was once a straightforward SDK into a sprawling ecosystem. The problem? Many teams adopt the latest tools without rethinking their underlying strategies, ending up with fragmented architectures and slow builds. This guide is for developers and tech leads who want to move beyond tutorials and understand how to structure real-world projects that scale. We focus on practical decisions—modularization, state management, build configuration, and testing—and highlight common mistakes that even experienced teams make.
Why This Topic Matters Now
The pace of Android change has not slowed. In 2025, Google is pushing Compose as the default UI toolkit, but many production apps still rely on Views. The tension between adopting new paradigms and maintaining legacy code creates a unique set of challenges. Teams that jump too quickly often end up with a mix of old and new that is harder to maintain than either alone. Meanwhile, the Android build system has evolved significantly: Gradle 8.x, version catalogs, and convention plugins promise faster builds, but misconfiguration can easily double compile times.
Consider a typical mid-sized team of five to eight developers. They have a single-module app with a few screens, and they decide to modularize because "that is what modern architecture looks like." Six months later, they have 15 modules, a build that takes 12 minutes, and a tangled dependency graph. The mistake is not modularization itself—it is doing it without a clear strategy. We see this pattern repeatedly in industry discussions: teams adopt patterns without understanding the trade-offs, then blame the tools.
The stakes are practical. Build speed directly affects developer productivity and morale. A ten-minute build that happens 20 times a day wastes over three hours per developer per week. For a team of six, that is 18 hours of lost effort. Configuration management, state handling, and testing strategy have similar leverage. Getting them right early in a project pays dividends, but fixing them later is expensive.
The Shift to Convention-Driven Development
One of the most effective strategies for 2025 is to move away from per-module configuration and toward convention-driven setups. Instead of each module defining its own dependencies and build logic, a central convention plugin (or a set of plugins) defines standard behavior. This reduces duplication and ensures consistency. Many teams still resist this because it feels like "magic," but once implemented, onboarding new developers becomes faster and build logic changes propagate automatically.
Core Idea in Plain Language
At its heart, advanced Android development in 2025 is about managing complexity through intentional constraints. The core idea is simple: define explicit boundaries for data flow, UI state, and dependencies, then enforce those boundaries with tooling. This is not new—it is the essence of clean architecture—but the specific implementations have matured. The mistake most teams make is treating architecture as a checklist of layers (data, domain, presentation) without considering how those layers interact in practice.
Let us break down what this means for a typical feature. You have a screen that displays a list of items from a remote API. A naive approach puts network calls directly in the ViewModel, uses LiveData for state, and updates the UI imperatively. That works for one screen, but as the app grows, you end up with duplicate logic, inconsistent error handling, and ViewModels that know too much. The alternative is to separate concerns: a repository handles data sourcing, a use case (or interactor) orchestrates business logic, and the ViewModel exposes a single state object that the UI observes.
The key insight is that the state object should be a sealed class or a sealed interface that represents every possible UI state: loading, success, error, and empty. This pattern, often called "unidirectional data flow" (UDF), makes state changes predictable and testable. In 2025, Compose makes this pattern even more natural because the UI is a function of state. But even with Views, UDF works well.
Common Mistake: Overusing Shared ViewModels
A frequent error is sharing a ViewModel between screens to avoid passing data around. While convenient, this creates hidden dependencies and makes state changes hard to trace. Instead, use a navigation argument or a shared repository that each ViewModel accesses independently. If you need to share live data across screens, consider a scoped ViewModel tied to a navigation graph, but limit its scope to the relevant flow.
How It Works Under the Hood
To understand why these strategies work, we need to look at the Android framework's lifecycle and the Kotlin type system. The core challenge is that Android components (Activities, Fragments) are not in control of their own lifecycle—the system can destroy and recreate them at any time. This makes state management critical. ViewModels survive configuration changes, but they are not immortal; they are cleared when the scope finishes. If you store data that outlives the ViewModel (like in a singleton), you risk memory leaks or stale data.
Under the hood, ViewModel uses a ViewModelProvider and a factory pattern. The key is that the ViewModel is scoped to a lifecycle owner (like an Activity or Navigation graph). When you use Hilt or Koin for dependency injection, the ViewModel gets its dependencies from the container. This is where convention plugins help: you can define a standard way to provide ViewModels, repositories, and use cases across modules without repeating boilerplate.
Another under-the-hood detail is how Gradle resolves dependencies. In a multi-module project, each module declares its own dependencies, and Gradle creates a dependency tree. If two modules depend on different versions of the same library, Gradle uses dependency resolution to pick one version. This can lead to subtle runtime errors if the library's API changes between versions. Version catalogs (introduced in Gradle 7.0, now standard) centralize dependency declarations, ensuring all modules use the same version. This is a small change that prevents hours of debugging.
How Compose Recomposition Works
Compose skips recomposition for components whose inputs have not changed. This is efficient, but it means you must be careful with object references. If you create a new list in the ViewModel every time you update state, Compose will recompose the entire list even if the content is the same. The solution is to use immutable data structures (like Kotlin's List, Set, Map) and avoid creating new objects unless the data actually changes. This sounds obvious, but in practice, many developers accidentally create new objects in getters or computed properties.
Worked Example or Walkthrough
Let us walk through a concrete example: migrating a legacy single-module app to a modular, convention-driven architecture. The app we are imagining has three main features: a product catalog, a shopping cart, and user authentication. The current codebase has one module with 50+ files, a mix of Activities and Fragments, and no consistent state management.
Step one: Define the module structure. We create a top-level module called :core that contains shared utilities, networking, and database access. Then we create feature modules: :feature:catalog, :feature:cart, :feature:auth. Each feature module is independent and can be built and tested separately. We also add a :app module that depends on all features and provides the application class and navigation.
Step two: Set up a convention plugin. We create a build-logic module with a plugin that applies the Android library plugin, sets up Kotlin, Compose, and Hilt, and adds common dependencies from a version catalog. Each feature module applies this plugin instead of declaring its own configuration. This reduces the build file for each feature to a few lines.
Step three: Move the existing code into the new modules. We start with the auth feature because it has the fewest dependencies. We extract the login screen's ViewModel, repository, and network calls into the auth module. We use a sealed class for the UI state: sealed class AuthUiState { object Loading : AuthUiState(); data class Error(val message: String) : AuthUiState(); object Authenticated : AuthUiState(); object Unauthenticated : AuthUiState() }. The ViewModel exposes a StateFlow<AuthUiState>, and the Compose UI observes it.
Step four: Handle navigation between modules. We avoid making modules depend on each other. Instead, each module defines its own navigation routes as constants. The :app module assembles the navigation graph using Compose Navigation, mapping routes to composables from each feature. This keeps modules decoupled.
Step five: Test the approach. We write unit tests for the ViewModel and repository, and a UI test for the login screen. Because the module is independent, we can run these tests without building the entire app. The build time drops significantly because changes in the auth module only recompile that module and its dependencies.
One team I read about followed a similar approach and reduced their full build time from 14 minutes to 4 minutes after modularizing with convention plugins. The key was not just splitting modules, but also using Gradle's build cache and parallel execution effectively.
Edge Cases and Exceptions
Not every app benefits from full modularization. If your app is a single-purpose tool with fewer than 10 screens, modularization adds overhead without much gain. In that case, a well-structured single module with clear package boundaries is sufficient. The trade-off is build speed versus simplicity. For small projects, a single module builds quickly anyway.
Another edge case is dynamic feature modules (on-demand delivery). These modules must be separate from the base APK and have their own dependencies. The convention plugin approach still works, but you need to handle the fact that dynamic features cannot reference code from other dynamic features. You might need to move shared code into a base module that all dynamic features depend on. Also, testing dynamic features is trickier because they are not always installed. You can use the Play Core library to simulate on-demand installs in tests, but it adds complexity.
What about using Compose for large lists? Compose's LazyColumn is efficient for most cases, but if you have a list with thousands of items that update frequently, you may hit performance issues. The exception is when each item has complex layout or animations. In those cases, consider using a RecyclerView with a DiffUtil, or optimize the Compose list by using key and contentType parameters. Another approach is to paginate the data so the list never holds too many items at once.
State handling also has edge cases. If you use a sealed class for UI state, you must handle all cases in the UI. If you forget to handle the loading state, the UI might show an empty screen when data is being fetched. A common mistake is to use nullable state and check for null, but that is less explicit. Sealed classes force you to handle every state, which is safer.
Limits of the Approach
The strategies we have discussed are not silver bullets. Modularization with convention plugins requires upfront investment in build-logic code. If your team is not familiar with Gradle plugin development, the learning curve can be steep. There is also the risk of over-engineering: creating too many modules can lead to dependency hell and slow down the build if modules are not properly isolated.
Another limit is that convention plugins can become a bottleneck. If the plugin is too opinionated, it may not fit every module's needs. You might end up with workarounds that defeat the purpose. The solution is to keep the convention plugin minimal—only configure what is truly common—and allow modules to override specific settings using Gradle's afterEvaluate or extension properties.
Unidirectional data flow is not always the best choice for simple screens. If a screen has only one piece of state that changes rarely, a simple MutableState in the composable might be fine. The overhead of creating a ViewModel, repository, and use case for that screen is not justified. Use UDF for screens with multiple data sources, user input validation, or complex business logic.
Finally, these approaches assume you have a team that can enforce conventions. In a solo project or a small team, you can be more flexible. But as the team grows, consistency becomes critical. Without enforcement, the architecture degrades over time. Code reviews and automated checks (like lint rules) can help maintain the structure.
Reader FAQ
When should I use sealed classes vs. enums for UI state?
Sealed classes are better when different states carry different data (e.g., success carries a list, error carries a message). Enums are sufficient when the state has no associated data, like loading vs. idle. Use sealed interfaces if you need to share a common type across features.
How do I handle navigation without tight coupling?
Use a navigation graph that maps route strings to composables. Each module exposes its composable as a function that takes a navController and any required arguments. The app module assembles the graph. Do not import ViewModels across modules; pass only primitive arguments.
Is it worth migrating to Compose in 2025?
For new projects, yes. For existing apps, consider migrating feature by feature. Start with a low-risk screen that does not use custom views. Compose improves development speed and reduces boilerplate, but the learning curve and recomposition pitfalls are real. Allocate time for training.
How do I test a ViewModel that uses StateFlow?
Use runTest from kotlinx-coroutines-test. Collect the flow in a test scope and assert the emitted values. Remember to handle the initial state. For time-dependent flows, use TestDispatcher to control virtual time.
What is the best way to handle configuration changes?
ViewModels already survive configuration changes. For UI state that should not be restored (like a one-time event), use a Channel or SharedFlow with replay=0. For persistent state, use SavedStateHandle in the ViewModel.
Practical Takeaways
- Audit your module graph. If you have more than 20 modules, check whether the dependencies are acyclic. Use Gradle's
dependenciestask to visualize the graph. Remove modules that are not independent. - Adopt a Gradle version catalog. Move all dependency declarations into a
libs.versions.tomlfile. This centralizes versions and prevents conflicts. It takes an afternoon to set up and saves weeks of debugging. - Implement a state machine for complex flows. For screens with multiple states and transitions, model the logic as a state machine using sealed classes and a reducer. This makes the code testable and prevents illegal states.
- Write one integration test per feature. Instead of relying solely on unit tests, write a single integration test that exercises the main user flow through the feature module. This catches wiring issues that unit tests miss.
- Enforce conventions with lint. Create custom lint checks for common anti-patterns, like accessing a ViewModel's state outside the UI layer. This automates code review and keeps the architecture consistent.
These steps are not exhaustive, but they provide a starting point for teams that want to move from "working code" to "maintainable system." The goal is not perfection—it is reducing friction so you can focus on delivering value to users.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!