Bootcamp

From idea to product, one lesson at a time. To submit your story: https://tinyurl.com/bootspub1

Modern Android App Architecture with Clean Code Principles (2025 Edition)

--

✨ Introduction

In 2025, building an Android app isn’t just about making it work — it’s about making it scalable, testable, and maintainable. With apps getting more complex and teams becoming more distributed, having a clean and modular architecture isn’t a luxury — it’s a necessity.

Yet many developers still struggle with questions like:

  • “Where should my business logic go?”
  • “How do I keep my UI layer clean?”
  • “What’s the right way to use ViewModels, UseCases, and Repositories?”

In this article, we’ll walk you through modern Android app architecture, combining:

  • Jetpack libraries (Compose, ViewModel, Navigation)
  • Kotlin Coroutines & Flow
  • Dependency injection with Hilt
  • And most importantly — Clean Code principles by Robert C. Martin (Uncle Bob)

By the end, you’ll understand how to structure your Android app like a pro — one that’s clean, modular, testable, and future-proof.

🧠 Why Clean Architecture Matters in 2025

In the early days of Android development, most apps started as a bunch of Activities and Fragments with business logic sprinkled everywhere. Fast forward to 2025, and that spaghetti architecture simply doesn’t scale.

Clean Architecture — originally proposed by Uncle Bob — encourages a separation of concerns. Each layer of the app should have a single responsibility, and no layer should know more than it needs to.

This leads to:

  • Easier testing
  • Better modularity
  • Faster debugging
  • Less technical debt over time

The core idea?

Keep your business logic independent of frameworks, UI, and databases.

🏗️ Overview of Modern Android Layers

Here’s how a clean architecture looks in a typical Android app today:

Presentation Layer  →  ViewModel, UI, Jetpack Compose
Domain Layer → UseCases, Business Logic
Data Layer → Repository, API, Database

🔁 Flow of data:

  1. UI triggers a user action (e.g., button click)
  2. ViewModel calls a UseCase
  3. UseCase calls the Repository
  4. Repository fetches data (from API or local DB)
  5. Data flows back up via Kotlin Flow or suspend functions

Each layer has its own clear responsibility. Dependencies only point inward.

🧱 Project Structure — A Real-World Example

Let’s look at a sample package structure:

com.example.app
├── di → Hilt modules
├── presentation
│ ├── ui → Composables, Screens
│ └── viewmodel → UI logic
├── domain
│ ├── model → Pure Kotlin data classes
│ └── usecase → Business logic
├── data
│ ├── repository → Data handling
│ ├── api → Retrofit/Ktor
│ └── local → Room database

Notice how everything flows from the presentation layer down to domain and data, but domain never knows about the UI or framework.

⚙️ UseCase, Repository, and ViewModel in Practice

🧩 UseCase (Domain Layer)

class GetUserProfileUseCase(
private val repository: UserRepository
) {
suspend operator fun invoke(userId: String): User {
return repository.getUser(userId)
}
}

🗃️ Repository Interface

interface UserRepository {
suspend fun getUser(id: String): User
}

You can have multiple implementations of this interface, such as:

  • RemoteUserRepository
  • LocalUserRepository

👀 ViewModel (Presentation Layer)

@HiltViewModel
class ProfileViewModel @Inject constructor(
private val getUserProfile: GetUserProfileUseCase
) : ViewModel() {

private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
val uiState: StateFlow<ProfileUiState> = _uiState

fun loadUser(userId: String) {
viewModelScope.launch {
try {
val user = getUserProfile(userId)
_uiState.value = ProfileUiState.Success(user)
} catch (e: Exception) {
_uiState.value = ProfileUiState.Error("Failed to load user.")
}
}
}
}

🧾 Handling UI State in Jetpack Compose

Compose makes it easier than ever to reflect UI state reactively.

@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
val uiState by viewModel.uiState.collectAsState()

when (uiState) {
is ProfileUiState.Loading -> CircularProgressIndicator()
is ProfileUiState.Success -> ShowUser((uiState as ProfileUiState.Success).user)
is ProfileUiState.Error -> ErrorText((uiState as ProfileUiState.Error).message)
}
}

🧪 Unit Testing and Scalability

With clean architecture, testing becomes straightforward:

  • UseCase tests: no framework dependency
  • Repository tests: mock data sources
  • ViewModel tests: using Turbine or MockK

Example:

@Test
fun `get user returns success`() = runTest {
val fakeRepo = FakeUserRepository()
val useCase = GetUserProfileUseCase(fakeRepo)

val user = useCase("123")
assertEquals("John", user.name)
}

Scalability comes naturally: Want to migrate to GraphQL? Just swap out the API module. Want to support offline mode? Add a local DB layer. No need to touch your business logic.

✅ Best Practices for Clean Android Architecture

  • Use interfaces for abstraction (especially in repositories)
  • Inject dependencies using Hilt
  • Make UI logic UI-only — no API/database calls in ViewModels!
  • Avoid business logic in Composables
  • Write unit tests for UseCases and ViewModels

🧠 Conclusion

Clean architecture isn’t just a buzzword — it’s a mindset. When you follow these principles in 2025, your Android app becomes:

✅ Easier to test

✅ Easier to scale

✅ Easier to onboard new team members

✅ And far less frustrating to maintain

You don’t have to implement everything perfectly from day one. But start small — add a UseCase here, move logic out of your UI there — and you’ll be surprised how much better your codebase becomes.

Buy a coffee for me ☕️

buy me a coffee ☕️

Connect with me👇🏻

LinkedInGithub

Listen to Our Podcast 🎧

Spotify

--

--

Bootcamp
Bootcamp

Published in Bootcamp

From idea to product, one lesson at a time. To submit your story: https://tinyurl.com/bootspub1

Reza Ramesh
Reza Ramesh

Written by Reza Ramesh

I am an Android developer and UI/UX designer with 5 years of experience in creating engaging and user-friendly mobile applications

Responses (4)