On this page
SOLID is a set of five design principles, popularised by Robert C. Martin, for code that's easier to change. They're not rules to apply mechanically. They're questions to ask when code starts to hurt. Here's what each one looks like in an Android codebase.
S: Single Responsibility
A class should have one reason to change. On Android, the classic violation is a ViewModel or Activity that formats, validates, fetches, caches and tracks analytics.
// โ One class changes for UI tweaks, API changes, caching policy AND analytics events
class ProfileViewModel(/* api, dao, prefs, analytics */) : ViewModel() { /* 600 lines */ }
// โ
Each concern has an owner
class ProfileRepository(/* api, dao */) // data access + caching
class ProfileValidator // business rules for edits
class ProfileViewModel( // screen state + coordination
private val repository: ProfileRepository,
private val validator: ProfileValidator,
private val analytics: Analytics,
) : ViewModel()O: Open/Closed
Extend behaviour without modifying existing code. Example: adding a payment method shouldn't require editing a giant when in five places.
interface PaymentMethodHandler {
val id: String
suspend fun pay(orderId: String, amountPaise: Long): PaymentResult
}
class UpiHandler @Inject constructor(/* ... */) : PaymentMethodHandler { override val id = "upi" /* ... */ }
class CardHandler @Inject constructor(/* ... */) : PaymentMethodHandler { override val id = "card" /* ... */ }
class PaymentProcessor @Inject constructor(
private val handlers: Set<@JvmSuppressWildcards PaymentMethodHandler>, // Hilt multibinding
) {
suspend fun pay(methodId: String, orderId: String, amount: Long) =
handlers.first { it.id == methodId }.pay(orderId, amount)
}
// Adding Wallet = new class + @IntoSet binding; PaymentProcessor never changes.For a closed set of states, such as UI state, a sealed type with an exhaustive when is better, because the compiler forces you to handle new cases. Use open extension for plug-in style variation.
L: Liskov Substitution
Implementations must honour the contract of their abstraction. If ArticleRepository.observeArticles() promises cached data even when offline, a "network-only" implementation that throws offline breaks every caller that relied on that promise.
interface ArticleRepository {
/** Emits cached articles immediately (possibly empty); never throws for network failures. */
fun observeArticles(): Flow<List<Article>>
}Shared contract tests that run against every implementation, including fakes, catch Liskov violations early.
I: Interface Segregation
Clients shouldn't depend on methods they don't use.
// โ The bookmarks screen depends on admin operations it never calls
interface ArticleService {
fun observe(): Flow<List<Article>>
suspend fun bookmark(id: String)
suspend fun publish(article: Article)
suspend fun delete(id: String)
}
// โ
Focused interfaces; one class can implement several
interface ArticleReader { fun observe(): Flow<List<Article>> }
interface BookmarkWriter { suspend fun bookmark(id: String) }
interface ArticleAdmin { suspend fun publish(article: Article); suspend fun delete(id: String) }D: Dependency Inversion
High-level policy shouldn't depend on low-level details. Both depend on abstractions. Your ViewModel depends on WeatherRepository (an interface), not on Retrofit. Hilt supplies the implementation. This is what makes testing with fakes trivial.
class WeatherViewModel @Inject constructor(private val repo: WeatherRepository) : ViewModel()
// Production: DefaultWeatherRepository(Retrofit, Room). Tests: FakeWeatherRepository.When SOLID becomes over-engineering
- An interface for every class "just in case", which adds navigation cost and no flexibility.
- Splitting simple classes into many tiny ones that are always used together.
- Plugin architectures for variation that will never happen.
Apply SOLID where change actually happens: areas that change often, are tested heavily, or are shared across features.
Where it pays off on Android
Analytics
An Analytics interface (DIP) with a Firebase implementation, plus a no-op for tests and debug builds.
Feature flags
A small FeatureFlags reader interface (ISP), so features don't see the remote-config SDK.
Notifications
Handlers per notification type (OCP) registered in a set, with a dispatcher routing by type.
Frequently asked questions
Is SOLID still relevant with Kotlin and Compose?
Yes. The principles are language-agnostic. Kotlin features such as interfaces, sealed types and extension functions make them lighter to apply.
Which principle matters most in Android apps?
Single responsibility and dependency inversion usually give the largest practical benefit: smaller ViewModels and testable code.
How do I explain SOLID in interviews?
Use a concrete Android example for each principle, like the ones above, and mention when you'd choose not to apply one.
Conclusion
SOLID on Android means focused classes, extension points where variation is real, honest contracts, small interfaces and dependencies on abstractions, applied where code actually changes, not everywhere.
Next step: see recurring solutions in Design Patterns Every Android Developer Should Know.
Official references
๐ฑ Apps built by Yunok Tech
Real Android apps on Google Play, free to download.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
Ready to build real Android applications?
Architecture makes the most sense when you apply it to a real project and see why each boundary exists. Learn how maintainable Android applications are designed by building one.
Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.
Explore the Android course and take your next step as an Android developer.