Demystifying Android Architecture: From Spaghetti Code to Solid Systems
When building native Android applications in Java or Kotlin, it is dangerously easy to fall into the trap of writing "Spaghetti Code" inside a single Activity. This comprehensive engineering guide breaks down the evolution of mobile system design from the ground up. We explore the fatal flaws of legacy MVC and MVP, dive deep into Google’s recommended MVVM baseline using Kotlin StateFlow, scale up to enterprise-grade Clean Architecture, and peek into the declarative future of Jetpack Compose with MVI. Complete with practical code snippets, layered diagrams, and a definitive comparison matrix to help you choose the exact right pattern for your next project.
Salman Iyad
Full-Stack Product Engineer
In this article
- The Fundamental Problem: Separation of Concerns
- 1. MVC (Model-View-Controller): The Android Default
- The Core Problem with Android MVC
- 2. MVP (Model-View-Presenter)
- How it Works
- A Concrete Example (Kotlin)
- When to Use MVP
- 3. MVVM (Model-View-ViewModel): The Modern Baseline
- A Concrete Example (Kotlin with StateFlow)
- When to Use MVVM
- 4. Clean Architecture (Layered Architecture)
- What a Use Case Looks Like
- The Data Layer in Practice: The Repository Pattern
- Wiring It All Together: Dependency Injection
- A Deeper Cut: Volatility-Based Decomposition
- When to Use Clean Architecture
- Common Pitfalls in MVVM and Clean Architecture
- 5. MVI (Model-View-Intent): The Declarative Era
- How it Works
- When to Use MVI
- Handling Side Effects: State vs. One-Off Events
- Real-World Case Study: Building an Offline-First Feature
- Why "Testability" Actually Matters
- Summary Comparison Matrix
- Architectural Roadmap for Beginners
- From Legacy to Modern: A Practical Migration Path
- Conclusion
Demystifying Android Architecture: From Spaghetti Code to Solid Systems
When you build your first few Android applications, you typically write all your code inside an Activity or a Fragment. It handles the layout buttons, makes the API calls, updates the text on screen, and processes database operations.
It works. But as soon as your app grows, adding a single feature feels like playing Jenga—touching one line of code causes three other unrelated parts of the app to collapse.
This guide is designed to take you from a developer who just writes code that works to a software architect who designs systems that scale. We will break down the most dominant architectural patterns in native Android development (Java & Kotlin), look at real-world code structures, walk through the pitfalls that trip up most teams the first time they adopt these patterns, and help you decide exactly when to use each one.
The Fundamental Problem: Separation of Concerns
Before looking at specific architectures, we must understand the core principle guiding all of them: Separation of Concerns (SoC).
Think of a restaurant. If one single person had to greet the guests, take orders, cook the steak, wash the dishes, and manage the accounting, the restaurant would fail during the first lunch rush. Instead, responsibilities are split: a host greets, a waiter takes orders, a chef cooks, and an accountant manages finances.
In software, architecture does the exact same thing. It separates your code into distinct modules or classes based on what they do.
1. MVC (Model-View-Controller): The Android Default
Historically, MVC is the oldest architectural pattern. In native Android development, the implementation usually looks like this:
- Model: Your data layer (Data classes, Retrofit API endpoints, Room database tracking).
- View: Your XML layout files or UI components.
- Controller: The
ActivityorFragment.
The Core Problem with Android MVC
In theory, the Activity is supposed to act as the Controller (the brains). In reality, because of how Android was designed, the Activity is also deeply tied to the View (inflating layouts, finding views by ID).
This leads to the Massive View Controller anti-pattern. The Activity becomes a thousands-of-lines-of-code monolith handling network responses, UI animations, database operations, and user clicks all at once. It is virtually impossible to unit-test.
2. MVP (Model-View-Presenter)
To rescue developers from the chaotic mess of MVC, the industry shifted toward MVP. This pattern cuts the direct tie between the UI lifecycle and the business logic by introducing a pure Java/Kotlin class called a Presenter.
How it Works
- View: The Activity/Fragment. It is completely "dumb". It does not know why data changes; it just knows how to show it.
- Presenter: The brains. It fetches data from the Model, formats it, and explicitly tells the View what to display using an interface.
- Model: The data repository.
A Concrete Example (Kotlin)
Imagine we are building a user profile display. First, we define a strict contract via an interface:
The Presenter handles the logic and talks directly to the view contract:
When to Use MVP
- Status: Legacy.
- Verdict: Avoid it for new projects. It requires writing dozens of boilerplate interfaces, and the Presenter still holds a direct reference to the View, which can easily cause memory leaks if the Activity is destroyed during a background network request.
3. MVVM (Model-View-ViewModel): The Modern Baseline
MVVM is the official standard recommended by Google for modern Android development. It solves the biggest issue of MVP: the hard reference to the View. Instead of a Presenter explicitly telling a View what to do, a ViewModel simply exposes observable streams of data. The View (Activity/Fragment) "subscribes" or "observes" these streams. The ViewModel doesn't know or care which View is listening to it.
Rendering diagram...
A Concrete Example (Kotlin with StateFlow)
In your Activity or Jetpack Compose UI, you simply observe uiState. If a screen rotation happens, the ViewModel survives in memory, and the new Activity instance immediately hooks back up to the exact same data stream without re-fetching from the network.
When to Use MVVM
- Status: Industry Standard.
- Verdict: Use this for 80% of apps. It offers excellent separation of concerns, integrates perfectly with native lifecycle components, and makes unit-testing logic straightforward since the ViewModel doesn't depend on Android OS classes.
4. Clean Architecture (Layered Architecture)
Clean Architecture (popularized by Robert C. Martin / Uncle Bob) isn't a replacement for MVVM; instead, it expands upon it. As codebases grow to hundreds of screens with complex enterprise logic, MVVM alone can result in giant ViewModels containing too much business logic.
Clean Architecture solves this by splitting your entire application into three strict, decoupled layers:
Rendering diagram...
- Presentation Layer: Your MVVM setup (Views and ViewModels). Its only job is formatting and showing data.
- Domain Layer: The absolute core of your app. It contains Use Cases (sometimes called Interactors). A Use Case executes one single business operation (e.g.,
ValidatePasswordUseCase,GetFormattedUserProfileUseCase). It contains absolutely no Android dependencies—just pure Kotlin or Java. - Data Layer: Handles all database caching, network requests, and file I/O operations.
What a Use Case Looks Like
Instead of the ViewModel interacting with a repository directly and sorting data, it invokes a domain Use Case:
The Data Layer in Practice: The Repository Pattern
The Data Layer isn't just "a class that calls Retrofit." Its real job is to hide where data comes from behind a single, boring interface, so nothing above it ever has to know whether it's reading from a network call, a Room table, or an in-memory cache.
Notice that GetFormattedUserProfileUseCase above never had to change to support this offline fallback. It just calls repository.getUser(userId) and trusts the Repository to sort out the details. That's the entire point of the pattern — it's a shock absorber between your business logic and the messy reality of networks and disks.
Wiring It All Together: Dependency Injection
Once you have Use Cases and Repositories as separate classes, something has to actually construct them and hand them to the ViewModel — and doing that by hand in every screen gets unmanageable fast. This is what Dependency Injection frameworks like Hilt or Koin solve.
With Hilt, a ViewModel simply declares what it needs and never builds it itself:
If you prefer a lighter footprint (common in smaller apps or Kotlin Multiplatform projects), Koin achieves the same result without annotation processing:
Either way, the rule is the same: your Domain and Data layers should never construct their own dependencies. Something outside the architecture wires it all together — that's DI's entire job.
A Deeper Cut: Volatility-Based Decomposition
There's a more formal engineering principle hiding behind all of this layering: components should be separated based on how likely they are to change, not just what they conceptually "do."
Your UI is the most volatile part of any app — design trends shift, a PM asks for a new screen flow, or your team migrates the entire Presentation layer from XML Views to Jetpack Compose. Your business rules (how a discount is calculated, what makes a password valid) are comparatively stable — they rarely change just because the UI toolkit did.
That's exactly why GetFormattedUserProfileUseCase above doesn't import a single Android class. When your team eventually rewrites every screen in Compose, the Domain layer doesn't move — not one line of it — because it was never coupled to the volatile part in the first place. That resilience isn't an accident; it's the entire architecture doing its job correctly.
When to Use Clean Architecture
- Status: Enterprise standard.
- Verdict: Essential for large-scale production applications, apps built by multi-engineer squads, or platforms requiring absolute modularization and flawless unit test coverage. It prevents merge conflicts and isolates changing data structures from business logic.
Common Pitfalls in MVVM and Clean Architecture
Most of the pain teams hit with these patterns isn't from the theory — it's from a handful of repeated mistakes when applying them for the first time.
In MVVM:
- Business logic leaking into the ViewModel. It's tempting to just write the validation or formatting logic directly inside the ViewModel since it's "right there." The problem is this logic quietly becomes untestable-without-Android and unreusable the moment you need it in a second screen. If a rule about what the data means is more than a couple of lines, it belongs in the Domain layer, not the Presentation layer.
- Collecting
StateFlowincorrectly. Collecting a flow the naive way keeps it active even while the screen is stopped, wasting resources and occasionally causing crashes when the UI tries to update after it's gone:
In Compose, collectAsStateWithLifecycle() handles this for you automatically — prefer it over a plain collectAsState().
In Clean Architecture:
- "Useless Use Cases." It's common to see a Use Case that does nothing but forward a single call to a Repository, with zero actual logic inside:
A Use Case earns its existence when it does something a Repository call alone can't — orchestrating multiple repositories, applying a business rule, or making a caching decision. For plain pass-through CRUD, calling the Repository directly from the ViewModel is often the more honest choice. Some larger teams still enforce a Use Case for every operation purely for consistency across the codebase — that's a defensible trade-off, but it's a team convention, not a hard architectural requirement.
5. MVI (Model-View-Intent): The Declarative Era
With the shift toward modern, declarative UI frameworks like Jetpack Compose (and Flutter/React Native conceptually), MVI has surged in popularity.
In MVVM, a ViewModel might expose multiple data streams (nameFlow, loadingFlow, errorFlow). If updated incorrectly, you can end up in an invalid state where both the loading spinner and the error screen show up simultaneously. MVI fixes this by enforcing Unidirectional Data Flow (UDF) and a Single Source of Truth for the UI state.
How it Works
- Intent: An action initiated by the user or system (e.g.,
LoadProfileIntent,RefreshClickIntent). - Model (State): A single, completely immutable object representing exactly what the screen looks like at this precise millisecond.
- View: A pure function of the State. It receives the State and draws the UI.
Rendering diagram...
When to Use MVI
- Status: Highly Advanced / Modern.
- Verdict: Ideal for modern apps utilizing Jetpack Compose. Because declarative UI components redraw themselves entirely based on a state change, feeding them a single, immutable state object minimizes rendering bugs and state race-conditions.
If you don't want to hand-roll the plumbing yourself, libraries like Orbit MVI or Mavericks implement this pattern for you with far less boilerplate.
Handling Side Effects: State vs. One-Off Events
Both MVVM and MVI are excellent at describing what the screen looks like. Neither one, on its own, answers a question that trips up almost every team adopting them: what do you do with something that should happen exactly once — showing a Snackbar, firing a one-time navigation, triggering a haptic buzz?
The mistake is putting these inside the State object. State is meant to be redrawn safely, over and over — a loading flag or a list of items is harmless to re-emit on every recomposition or after a screen rotation. An error message or a navigation command is not: if it lives in State, it fires again every time a new subscriber attaches, which is exactly how you get a Toast that reappears after you rotate the phone.
The fix is to keep State and Events on two separate channels:
And on the Compose side, you collect events separately from state, inside a LaunchedEffect that only runs once per composition:
Rendering diagram...
This single distinction — State that's safe to repeat versus Events that must fire exactly once — is one of the most common sources of confusion when teams move to Compose, and getting it right early saves a lot of debugging later.
Real-World Case Study: Building an Offline-First Feature
The theory above is easiest to appreciate end-to-end with a concrete scenario: a content library screen — think an educational app's list of downloaded lessons — that has to stay fully usable with zero connectivity, and quietly catch up once the network comes back.
The Repository becomes the single source of truth, and Room's Flow support does most of the heavy lifting:
Rendering diagram...
Walking through the flow: the View subscribes once. It immediately gets whatever is already cached in Room — no spinner, no waiting on the network. In the background, the Repository quietly tries to refresh from the API; if that succeeds, it writes the new data into Room, and because the ViewModel is observing Room's Flow (not a one-shot network response), the UI updates itself automatically. No manual "refresh the state" call needed anywhere.
And if the refresh fails because the device is offline, this is exactly where the Events channel from the previous section earns its keep: the Repository can surface a ProfileEvent-style one-off notice — "You're offline, showing your last downloaded lessons" — without touching the State stream that's already correctly rendering the cached list.
Why "Testability" Actually Matters
The comparison table below rates every pattern's testability, but the reason MVVM, Clean Architecture, and MVI score so highly is worth spelling out: a class like GetFormattedUserProfileUseCase imports nothing from the Android SDK. That means it runs as a plain JUnit test directly on your local JVM — no emulator, no Context, no Robolectric shims, no multi-second boot time per test run.
Because the Use Case only depends on an interface (UserRepository), the test above never touches a real network or database — it substitutes a fake in milliseconds. This is the practical payoff of everything covered above: the further your business logic lives from Android-specific classes, the cheaper and faster it is to prove it's correct.
Summary Comparison Matrix
| Architectural Pattern | Boilerplate Level | Testability | Ideal Project Size | Main Advantage |
|---|---|---|---|---|
| MVC | Low | Low | Small / Prototype | Simple structure and fast initial development. |
| MVP | Medium to High | High | Small to Medium | Clear separation between View and logic via Presenter interfaces. |
| MVVM | Medium | High | Medium to Large | Lifecycle-aware architecture with strong ecosystem support (especially Android). |
| Clean Architecture | High | Very High | Large / Enterprise | Strong separation of concerns with fully independent business logic. |
| MVI | Medium to High | Very High | Medium to Large | Unidirectional data flow with predictable and immutable state. |
Architectural Roadmap for Beginners
- If you are building an independent side project, a prototype, or a tool app to learn development basics: Choose standard MVVM.
- If you are writing an app with a local database cache that needs to sync flawlessly with a server even when offline: Implement Layered Clean Architecture with MVVM.
- If you are diving deep into Jetpack Compose and want state management that prevents any rendering anomalies: Explore MVI.
From Legacy to Modern: A Practical Migration Path
None of this matters much if you're staring at a real, three-thousand-line Activity today. Rewriting it from scratch is rarely realistic — here's a path that lets you migrate one screen at a time without freezing feature work:
- Pick one screen, not the whole app. Trying to migrate everything at once is how migrations stall out permanently.
- Extract logic into plain Kotlin classes first — before touching the UI. Pull validation and business logic out of the Activity into standalone classes with zero Android imports. Nothing about the screen's behavior changes yet; you're only proving the logic can live outside the Activity.
- Introduce a ViewModel and move state ownership into it gradually. Wrap one field at a time in a
MutableStateFlow, replacing direct field reads with.valuereads and direct UI-setter calls with an observer. The screen keeps working at every step. - Switch the UI to observe the stream instead of reading fields off the Activity. This is the point where the View officially becomes "dumb."
- Only then, consider introducing the Domain layer. Once the ViewModel is clean and testable, pull out Use Cases if the codebase's size and team actually justify Clean Architecture's overhead — refer back to the Verdict sections above.
- Wire up Dependency Injection last. Retrofitting Hilt or Koin into a codebase whose class boundaries haven't settled yet usually creates more churn than it saves. Add it once the Presentation / Domain / Data split is stable.
Conclusion
Architecture isn't a checklist you tick once at the start of a project — it's a series of trade-offs you keep renegotiating as your team and your app grow. MVC will get a weekend prototype shipped faster than any other pattern on this list, and that's a perfectly valid reason to reach for it. The goal here was never to convince you that Clean Architecture plus MVI is objectively "the best" — it's to give you the vocabulary and the trade-off map so the choice is deliberate instead of accidental.
I'm putting together a small companion repository with a working sample of each pattern side by side — I'll link it here once it's ready. In the meantime, if you're mid-refactor and stuck on one of the pitfalls above, I'd genuinely like to hear what broke in practice.
Recommended Reads
AI-Powered Web Development: A New Era
Explore how AI is transforming web development, from code generation to performance optimization and testing.
Advanced React Performance Optimization Techniques
Master the art of React performance optimization with practical techniques, code examples, and best practices for building lightning-fast applications.
Modern Authentication Strategies for Web Applications
A comprehensive guide to implementing secure authentication in modern web applications using JWT, OAuth, and biometrics.
Read the original article online at Salman's Blog:
https://salman.is-a.dev/blog/demystifying-android-architecture