Skip to content
All posts
May 1, 20265 min read

Android Architecture Decisions That Actually Matter

Not every architectural choice is equally important. After 22 apps, here are the decisions that compound positively — and the debates that waste your time.

AndroidArchitectureKotlinSolo Dev
Share:

Android architecture discussions are full of noise. Developers argue about repository pattern variations, use case granularity, and module boundaries while shipping apps where the real problems are unstructured ViewModels and no error state handling.

Here are the architectural choices that actually make a difference over time.


The Decisions That Compound

1. Sealed UI State — Non-Negotiable

Every screen has three states: Loading, Success, Error. If you're not modeling all three explicitly, you're accumulating debt.

kotlin
sealed interface HomeUiState {
    data object Loading : HomeUiState
    data class Success(val items: List<Item>) : HomeUiState
    data class Error(val message: String) : HomeUiState
}

In your ViewModel:

kotlin
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

fun loadItems() {
    viewModelScope.launch {
        _uiState.value = HomeUiState.Loading
        repository.getItems()
            .onSuccess { items -> _uiState.value = HomeUiState.Success(items) }
            .onFailure { e -> _uiState.value = HomeUiState.Error(e.message ?: "Unknown error") }
    }
}

In Compose, the

code
when
expression is exhaustive — the compiler forces you to handle every state:

kotlin
when (val state = uiState) {
    is HomeUiState.Loading -> LoadingScreen()
    is HomeUiState.Success -> ItemList(state.items)
    is HomeUiState.Error -> ErrorScreen(state.message)
}

Apps without explicit error states ship with invisible failure modes. Users see blank screens, infinite spinners, or silent failures. This is the single highest-ROI architectural decision you can make.

2. Repository as the Only Truth Source

Every piece of data your app uses comes from a repository. ViewModels don't call network APIs directly. ViewModels don't query Room directly. They call the repository.

kotlin
class ItemRepository @Inject constructor(
    private val localDataSource: ItemLocalDataSource,
    private val remoteDataSource: ItemRemoteDataSource
) {
    fun getItems(): Flow<List<Item>> = localDataSource.getItems()
        .onStart { syncIfStale() }

    private suspend fun syncIfStale() {
        if (isDataStale()) {
            val items = remoteDataSource.fetchItems()
            localDataSource.upsertItems(items)
        }
    }
}

This gives you:

  • A single place to implement caching strategy
  • A single place to add error handling and retry logic
  • ViewModels that are trivially testable with a fake repository

If your ViewModel has

code
@Inject constructor(private val api: ApiService)
anywhere, that's the debt accumulating.

3. One-Way Data Flow

State flows down. Events flow up. No exceptions.

kotlin
// ✅ Correct
@Composable
fun ItemScreen(
    uiState: HomeUiState,
    onRetry: () -> Unit,
    onItemClick: (String) -> Unit
) { ... }

// ❌ Wrong — ViewModel reference in Composable
@Composable
fun ItemScreen(viewModel: HomeViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // calling viewModel directly in composables breaks testability
}

Pass state and lambdas. Let the caller (the screen-level Composable or NavHost entry point) own the ViewModel reference.

This makes every Composable previewable in isolation and independently testable.

4. Use Cases for Business Logic — But Don't Over-engineer

Use cases (interactors) belong in the domain layer. They encapsulate a single piece of business logic. But they're only worth creating when:

  • The logic is complex enough to warrant isolation
  • The logic is reused across multiple ViewModels
  • The logic needs to be independently tested
kotlin
// Worth creating — complex, reusable
class CalculateSubscriptionStatusUseCase @Inject constructor(
    private val subscriptionRepository: SubscriptionRepository,
    private val clock: Clock
) {
    suspend operator fun invoke(userId: String): SubscriptionStatus {
        val subscription = subscriptionRepository.getSubscription(userId)
        return when {
            subscription == null -> SubscriptionStatus.None
            subscription.expiresAt < clock.now() -> SubscriptionStatus.Expired
            subscription.autoRenews -> SubscriptionStatus.Active
            else -> SubscriptionStatus.ActiveCancelled
        }
    }
}
kotlin
// Not worth creating — trivial passthrough
class GetItemsUseCase @Inject constructor(
    private val repository: ItemRepository
) {
    fun invoke() = repository.getItems() // just call the repository directly
}

The use case that just delegates to a repository is ceremony. It adds a file, adds a Hilt binding, adds an injection point — for zero architectural value. Put the call in the ViewModel directly.

[!TIP] Create a use case when you can write a meaningful test for it. If the only test is "it calls the repository," delete the use case.

The Debates That Don't Matter as Much

Module count

Single-module vs multi-module: for solo projects under 10 engineers, the build time savings from multi-module rarely justify the maintenance overhead. Start with a well-organized single module with clear package structure:

code
com.example.app
├── data/
│   ├── local/
│   ├── remote/
│   └── repository/
├── domain/
│   ├── model/
│   └── usecase/
└── presentation/
    ├── home/
    ├── detail/
    └── settings/

Migrate to modules when build times exceed 3 minutes or team size exceeds 5 engineers.

Repository vs DataSource layering

Both work. The key principle is that each layer depends only on the layer below it. Whether you call it DataSource or DAO doesn't matter. What matters is that your ViewModel doesn't know whether data came from the network or the database.

ViewModel factory boilerplate

With Hilt,

code
@HiltViewModel
handles this. Don't write custom factory boilerplate in 2026.

The One Architectural Test

When in doubt about an architectural decision, ask:

"Can I test this without a device or emulator?"

If yes, the architecture is sound. The answer being "no" is a smell:

  • ViewModel depends directly on
    code
    Context
    → refactor to extract business logic
  • Repository depends on
    code
    SharedPreferences
    directly → inject via interface, mock in tests
  • Network call triggered in Composable → move to ViewModel

Testability and good architecture are the same thing. They're not separate concerns.

What This Looks Like in Practice

Across 22 apps, the architecture that holds up:

code
Composable ──(events)──→ ViewModel ──(calls)──→ Repository
    ↑                        │                       │
    └────────(state)─────────┘         Local SourceSource + Remote Source

Each layer:

  • Composable: no business logic. Displays state, emits events.
  • ViewModel: no data fetching. Orchestrates use cases and repositories. Exposes sealed state.
  • Repository: no UI knowledge. Manages data sources, caching, sync.
  • Data sources: no business logic. Pure CRUD or network calls.

This isn't revolutionary. It's the pattern that survives real-world maintenance.

[!NOTE] The best architecture is the one your future self can understand at 9pm on a Friday when something is broken in production. Optimize for clarity, not cleverness.


Takeaways

  • Sealed UI state for Loading/Success/Error is the highest-ROI architectural choice.
  • Repository pattern enforces clean data flow. ViewModels never touch APIs or DAOs directly.
  • One-way data flow: state down, events up. Composables receive state and lambdas — nothing else.
  • Create use cases for complex, reusable business logic. Skip them for trivial delegation.
  • The testability test: if you can't test it without a device, the architecture needs work.
  • Don't over-modularize. A clean package structure in a single module beats a complex multi-module setup you'll fight daily.
  • Architecture debates are only worth having when they affect these outcomes: testability, maintainability, error handling coverage.
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this