Scaling Android: The Pragmatic Architecture Lessons Learned in Production
Hello, I’m Ali Mansour, a Senior Android Engineer, and I’m passionate about clean architecture, automation, and developer excellence. Over the years, I’ve seen countless teams, including my own, fall into the same architectural pitfalls when an application starts to grow. This isn’t just about theory; this is a distillation of lessons learned in production on medium and large-scale Android applications with multiple contributors.
If your build times are getting slower, refactoring feels risky, or you hear phrases like “Don’t touch this module,” this article is for you. The simple truth is this: architecture doesn’t fail; scale exposes its weaknesses. The clean, elegant structure you started with quickly becomes a crippling burden when features, developers, and time pressure increase.
Common Symptoms of Scaling Sickness
When an app starts to crash under its own weight, the warning signs are clear:
- God ViewModels: These massive classes become the central hub for too much business logic, making them impossible to test or modify.
- Endless Abstraction Layers: Layers of classes that add complexity without providing true business value, a classic sign of over-engineering.
- Slow Gradle Builds: A direct tax on developer productivity, often an architectural problem disguised as a build-tool problem.
- Fear of Refactoring: When engineers are hesitant to make changes because the impact is unclear, the architecture has failed to reduce fear.
A Reality Check on Clean Architecture
Clean Architecture is a powerful tool, but it is not a goal, a religion, or a checklist. It’s just a method to achieve a goal at the end. Implementing it blindly can result in an application that is difficult to understand and even harder to change.
Clean Architecture works when:
- Business rules are genuinely complex.
- Multiple data sources must be managed.
- Teams are large and require clear boundaries.
- Component ownership is unambiguous.
The key is to ask: Does this layer add value, or is it just another class? If there’s no real business logic to abstract, you are simply over-engineering.
Scaling Starts with Modularization: Feature Over Layer
The first and most critical architectural decision that makes a real difference at scale is Modularization. This is non-optional.
However, many teams make a common mistake: Layer-based modules. This setup separates the code into :ui, :domain, and :data modules. While it looks clean on paper, it creates massive coupling. A small change in the :data module can force a rebuild and retest of the entire application.
Note – The components of each layer:
:ui⇨ Activities / Fragments / Compose Screens, ViewModels, UI state models (UiState, UiEvent), Navigation logic, UI mappers (Domain → UI models):domain⇨ Use cases / interactors, Business models, Repository interfaces, Domain-specific validation logic:data⇨ Repository implementations, Remote data sources (API, Retrofit), Local data sources (Room, DataStore), DTOs and database entities,
Mappers (DTO ↔ Domain)
What works better is Feature-based modularization:
:feature-login:feature-profile:feature-payment:feature-settings
Benefits of Feature Modules:
- Clear Ownership: Teams own their feature modules completely.
- Parallel Work: Different teams can work on different features simultaneously with minimal conflict.
- Reduced Impact: Changes are localized, minimizing the application-wide impact.
- Faster Builds: Gradle can utilize incremental builds far more effectively when modules are isolated.
Note: In this approach, you may need to create some core modules that contain the core functionalities needed for these feature modules. That will help reducing code duplication and encreasing code reusability. but take care of the shared core trap.
Shared Core is a Trap
The most dangerous phrase in a growing codebase is, “Let’s put it in core.” The shared :core module quickly transforms into a garbage dump for everything that doesn’t fit cleanly elsewhere. If a utility or component is only used by one feature, it should live within that feature module, not in the shared core. This prevents the core from becoming a massive dependency that touches everything and is constantly changing.
State Management Gets Hard Fast
While UI complexity might remain simple, State complexity grows exponentially. You have loading, error, partial success, and retries. Over time, a ViewModel that manages all these cases turns into a monster.
The solution is to manage state clearly: the UI should only interact with a single, unambiguous state. This drastically reduces the surface area for bugs and makes the UI logic straightforward.
Testing at Scale: Strategy over Count
The number of tests you have is less important than what you cover with them. Test coverage can often be deceptive. The true focus of your test strategy should be:
- Business Logic: The critical, high-value operations.
- Contracts between Modules: Ensure components interact correctly at their boundaries.
- Critical User Flows: End-to-end paths that must never break.
If you find yourself testing implementation details, you are penalizing yourself, creating flaky tests that will break every time you refactor.
The Build Time is an Architectural Problem
A slow build is not always Gradle’s fault — it’s often a symptom of poor architecture.
What truly slows builds down:
- Huge Modules: The larger a module, the longer it takes to compile, and the less effective incremental builds become.
- Cyclic Dependencies: When modules depend on each other in a loop, it forces the build system to process them together, negating the benefits of modularization.
- Unnecessary Public APIs: Every extra public API prevents the build system from using incremental compilation effectively. Keep module interfaces tight and internal logic hidden.
The Most Important Lessons
If I could go back and tell myself what not to repeat, these would be the top three mistakes:
- Over-abstracting early: Premature abstraction adds complexity that rarely pays off. Keep it simple until the complexity is genuinely necessary.
- Big shared core modules: Allowing the core to grow uncontrolled guarantees slow builds and massive coupling.
- Ignoring build performance: Treat build time as a critical product metric. It’s a direct measure of developer friction.
What truly paid off in the long run:
- Feature Ownership: Setting clear boundaries empowers teams.
- Clear Boundaries: Explicit contracts between modules.
- Fewer Abstractions: Simplicity wins.
- Faster Feedback Loops: The quicker you can test and build, the quicker you can ship.
Architecture is always a series of trade-offs. There is no perfect solution. The most successful architecture is the one that is optimized for the team, not for the pattern. It must reduce the fear of change. If your team is afraid to modify the code, your architecture has failed.
Note: This article is adapted from my talk "Modern Android at scale: What actually works in production", which I recantly delivered at Davfest Ismailia 2025
Resources
Guide to App Architecture (Jetpack): https://developer.android.com/topic/architecture/guide
Android Architecture Guide: https://developer.android.com/topic/architecture
Android Modularization Guide: https://developer.android.com/topic/modularization
Manage UI State: https://developer.android.com/topic/architecture/ui-layer/state
Now in Android (Open Source Sample App): https://github.com/android/nowinandroid
Icare (Open Source Sample App): https://github.com/dev-ali-mansour/Icare
Gradle Performance Best Practices: https://docs.gradle.org/current/userguide/performance.html
Testing Apps on Android: https://developer.android.com/training/testing