Skip to content
All posts
May 9, 20263 min read

Building 20+ Android Apps Solo — What I Learned About Architecture

After shipping 20+ Android apps as a solo developer, I settled on MVVM with Clean Architecture, Hilt, and StateFlow. Here's why, and what I'd tell myself if I were starting over.

AndroidArchitectureKotlinHilt
Share:

After shipping more than 20 Android apps solo — everything from habit trackers to sleep monitors to focus tools — I've made the same architectural mistakes enough times to know exactly what works and what quietly destroys you at 2am before a Play Store deadline.

Here's what I landed on, and why.


The Mistake Everyone Makes First

When you're building your first Android app, you put everything in the Activity. It works. Then you add a second screen, a third, a network call, a database — and suddenly you have a 1000-line Activity that nobody (including future-you) can read.

The second mistake is overreacting and building a giant framework before you have anything to show. I've done both.

The right level of structure is: just enough to not drown.


MVVM + Clean Architecture

Every app I ship now follows the same three-layer pattern:

Data layer — repositories, Room DAOs, DataStore, network calls. Nothing here knows about the UI.

Domain layer — one UseCase class per action.

code
GetHabitsUseCase
,
code
LogWaterUseCase
,
code
CompleteOnboardingUseCase
. Each does exactly one thing. This is the layer you can test without Android.

Presentation layer — ViewModels that hold

code
StateFlow<UiState>
, Composables that observe and render. No business logic here.

The rule I enforce: if a Composable contains an

code
if
statement about business state (not UI state), it belongs in the ViewModel. If the ViewModel contains a database call, it belongs in the Repository.


Why StateFlow Over LiveData

LiveData requires an Activity or Fragment lifecycle owner. StateFlow doesn't. When I moved to pure Compose, I stopped needing lifecycle owners for UI observation — StateFlow just works.

The other reason: StateFlow is explicit about initial state. You declare

code
MutableStateFlow(initialValue)
and the UI always has something to render. No null checks, no loading shimmer that flickers because the initial state wasn't handled.


Hilt Over Manual DI

I tried manual dependency injection once. You create a factory, pass it everywhere, update 12 files when a constructor changes. It's miserable.

Hilt adds two annotations to your module, one to your ViewModel, and everything gets wired automatically. The compile-time validation means you find missing dependencies at build time, not runtime.

The rule:

code
@HiltViewModel
on every ViewModel,
code
@Provides @Singleton
for app-scoped dependencies. Done.


KSP Over KAPT

KAPT (Kotlin Annotation Processing Tool) is deprecated. It compiles Kotlin to Java stubs, runs Java annotation processors, then compiles back. It's slow and occasionally broken.

KSP (Kotlin Symbol Processing) processes Kotlin directly. Build times dropped noticeably on my larger projects. Room, Hilt, and every major library support it. There's no reason to use KAPT in a new project today.


One Mistake That Cost Me Twice

I shipped two apps with bare

code
CoroutineScope(Dispatchers.IO).launch {}
inside a Hilt
code
@Singleton
module. It works — until the process keeps running longer than expected and you have a dangling scope with no lifecycle.

The fix is one extra class:

kotlin
@Qualifier @Retention(AnnotationRetention.BINARY)
annotation class ApplicationScope

@Provides @Singleton @ApplicationScope
fun provideApplicationScope(): CoroutineScope =
    CoroutineScope(SupervisorJob() + Dispatchers.IO)

Inject

code
@ApplicationScope CoroutineScope
wherever you need app-lifetime background work. The scope dies with the process, not mid-operation.


What I'd Tell Myself Starting Out

  1. Pick an architecture and stay consistent. The specific choices matter less than applying them uniformly. Mixed patterns across screens are harder to debug than a slightly imperfect pattern applied everywhere.

  2. UseCase classes feel over-engineered until they save you. When you need to reuse logic across two ViewModels, or test business logic without mocking Android, you'll be glad it's in its own class.

  3. StateFlow initial state is not optional. Every screen should render something on first observation. Define your sealed class or data class upfront.

  4. KSP from day one. Never KAPT.

  5. Hilt is not boilerplate — it's leverage. The annotations pay for themselves the first time you need to swap an implementation for tests.

The architecture isn't the goal. Shipping apps that don't embarrass you in six months is the goal. These choices just happen to make that easier.


All 20+ apps are live on Google Play under SudarshanTechLabs.

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