You've probably heard of SOLID. Maybe in a job interview, maybe in a code review comment, maybe from that one senior developer who drops it into every conversation. But here's the honest truth — most explanations of SOLID use examples so abstract ("imagine a Shape class...") that you walk away nodding without actually understanding how to apply it.
This post is different. Every principle comes with a real Android example — the kind of code you actually write — showing what it looks like when you violate it and how to fix it. By the end, SOLID won't be interview vocabulary. It'll be something you actually use.
What is SOLID?
SOLID is an acronym for five object-oriented design principles introduced by Robert C. Martin (Uncle Bob). Each letter is a rule for writing code that's easier to maintain, extend, and test:
- S — Single Responsibility Principle
- O — Open/Closed Principle
- L — Liskov Substitution Principle
- I — Interface Segregation Principle
- D — Dependency Inversion Principle
In Android, these principles show up everywhere — in how you structure ViewModels, design repositories, set up dependency injection with Hilt, and build modular features. Let's go through each one with real examples.
S — Single Responsibility Principle
"A class should have one reason to change."
This is the one everyone violates first. You start with a clean Activity, and six months later it's 800 lines handling navigation, network calls, data parsing, analytics events, and a permissions check. Changing anything breaks something else. That's a class with too many responsibilities.
❌ Violation — Activity doing too much
class UserProfileActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// UI setup
setContentView(R.layout.activity_profile)
// Network call directly in Activity — wrong
val user = ApiClient.getUser(userId)
// Business logic directly in Activity — wrong
val formattedName = user.firstName + " " + user.lastName
// Analytics directly in Activity — wrong
FirebaseAnalytics.getInstance(this).logEvent("profile_viewed", null)
binding.tvName.text = formattedName
}
}
✅ Fix — Each class has one job
// Repository handles data fetching
class UserRepository(private val api: ApiService) {
suspend fun getUser(id: String): User = api.getUser(id)
}
// ViewModel handles business logic and state
class UserProfileViewModel(private val repo: UserRepository) : ViewModel() {
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user
fun loadUser(id: String) {
viewModelScope.launch {
_user.value = repo.getUser(id)
}
}
}
// Activity only handles UI
class UserProfileActivity : AppCompatActivity() {
private val viewModel: UserProfileViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.loadUser(userId)
lifecycleScope.launch {
viewModel.user.collect { user ->
binding.tvName.text = "${user?.firstName} ${user?.lastName}"
}
}
}
}
This is exactly why MVVM exists in Android — it enforces SRP by design. The Activity shows UI. The ViewModel holds logic. The Repository fetches data. Each class has one reason to change.
O — Open/Closed Principle
"Open for extension, closed for modification."
You should be able to add new behaviour without editing existing code. If every new feature requires you to add another if/else branch to an existing class, that class is a liability. It's fragile, hard to test, and will break in unexpected ways.
❌ Violation — Adding payment types by modifying existing code
class PaymentManager {
fun processPayment(type: String, amount: Double) {
if (type == "credit_card") {
// process credit card
} else if (type == "paypal") {
// process paypal
} else if (type == "upi") {
// Every new payment method = modify this class
}
}
}
✅ Fix — Extend without modifying
// Define the abstraction
interface PaymentProcessor {
fun processPayment(amount: Double)
}
// Each payment method is its own class
class CreditCardProcessor : PaymentProcessor {
override fun processPayment(amount: Double) { /* credit card logic */ }
}
class PayPalProcessor : PaymentProcessor {
override fun processPayment(amount: Double) { /* PayPal logic */ }
}
// New UPI payment? Add a new class, touch nothing else
class UpiProcessor : PaymentProcessor {
override fun processPayment(amount: Double) { /* UPI logic */ }
}
// PaymentManager never needs to change
class PaymentManager(private val processor: PaymentProcessor) {
fun pay(amount: Double) = processor.processPayment(amount)
}
L — Liskov Substitution Principle
"Subtypes must be substitutable for their base types."
If you have a function that accepts a Bird, it should work correctly with any subclass of Bird — without the caller needing to check what type it actually is. Violating this principle is sneaky — the code compiles and runs, but behaves incorrectly for certain subclasses.
❌ Violation — Subclass breaks expected behaviour
open class NetworkDataSource {
open fun getData(): String = fetchFromNetwork()
}
class CachedDataSource : NetworkDataSource() {
override fun getData(): String {
// Sometimes returns stale data, sometimes throws when offline
// Violates LSP — callers expect consistent behaviour
if (isOnline()) return fetchFromNetwork()
else throw IOException("No internet")
}
}
✅ Fix — Consistent contract across subclasses
interface DataSource {
fun getData(): Result<String> // Always returns Result, never throws
}
class NetworkDataSource : DataSource {
override fun getData(): Result<String> = runCatching { fetchFromNetwork() }
}
class CachedDataSource : DataSource {
override fun getData(): Result<String> = runCatching { fetchFromCache() }
}
// Any DataSource works here — both behave consistently
class Repository(private val source: DataSource) {
fun loadData() = source.getData()
}
I — Interface Segregation Principle
"No client should be forced to depend on methods it doesn't use."
Fat interfaces are a common Android mistake. You create one big interface, multiple classes implement it, and half of them are forced to implement methods they don't need — usually with empty bodies or throwing UnsupportedOperationException. That's a smell.
❌ Violation — One bloated interface
interface MediaPlayer {
fun play()
fun pause()
fun record() // AudioRecorder doesn't need play/pause
fun takePhoto() // Camera doesn't need play/pause/record
}
class AudioRecorder : MediaPlayer {
override fun play() { /* forced to implement, makes no sense */ }
override fun pause() { /* forced to implement, makes no sense */ }
override fun record() { /* actual logic */ }
override fun takePhoto() { /* forced to implement, makes no sense */ }
}
✅ Fix — Small, focused interfaces
interface Playable {
fun play()
fun pause()
}
interface Recordable {
fun record()
}
interface PhotoCapture {
fun takePhoto()
}
// Each class only implements what it actually needs
class MusicPlayer : Playable {
override fun play() { /* play music */ }
override fun pause() { /* pause music */ }
}
class AudioRecorder : Recordable {
override fun record() { /* record audio */ }
}
// A SmartPhone can implement multiple interfaces
class SmartPhone : Playable, Recordable, PhotoCapture {
override fun play() { /* play */ }
override fun pause() { /* pause */ }
override fun record() { /* record */ }
override fun takePhoto() { /* capture */ }
}
D - Dependency Inversion Principle
"Depend on abstractions, not on concrete implementations."
This is the one that makes your code actually testable. If your ViewModel directly creates a RetrofitClient instance, you can never unit test it without hitting the real network. Depend on an interface instead, and in tests you swap the real implementation for a fake one. This is the foundation of dependency injection with Hilt.
❌ Violation — ViewModel directly depends on concrete class
class UserViewModel : ViewModel() {
// Tightly coupled to SQLite — can't swap for Room, can't unit test
private val db = SQLiteDatabase()
fun getUser(id: String) = db.getData(id)
}
✅ Fix — Depend on an interface, inject the implementation
// Abstraction
interface UserRepository {
suspend fun getUser(id: String): User
}
// Real implementation
class UserRepositoryImpl(private val api: ApiService) : UserRepository {
override suspend fun getUser(id: String) = api.getUser(id)
}
// Fake for unit tests
class FakeUserRepository : UserRepository {
override suspend fun getUser(id: String) = User(id, "Test User")
}
// ViewModel depends on the interface, not the implementation
class UserViewModel(private val repo: UserRepository) : ViewModel() {
fun loadUser(id: String) {
viewModelScope.launch { _user.value = repo.getUser(id) }
}
}
In production, inject UserRepositoryImpl via Hilt. In tests, inject FakeUserRepository. The ViewModel doesn't care which one it gets — that's DIP working exactly as intended.
How SOLID Maps to Android Architecture
If you look at MVVM carefully, you'll notice SOLID is baked into it:
| SOLID Principle | Where it shows up in Android |
|---|---|
| SRP | Activity = UI only, ViewModel = logic, Repository = data |
| OCP | Add new features via new classes, not by editing existing ones |
| LSP | Any DataSource implementation works in Repository |
| ISP | Small, focused interfaces for each capability |
| DIP | Hilt injects interfaces — ViewModel never knows the concrete class |
When Not to Apply SOLID Blindly
Here's the thing nobody tells you — over-engineering is a real problem too. If you're building a small screen with one data source and no chance of it changing, creating five interfaces and three abstract classes is overkill. It makes the code harder to read, not easier.
Apply SOLID when:
- The class is likely to change or grow
- You need to unit test the class
- Multiple developers work on the same codebase
- You're building a feature that others will extend
Skip it (or apply lightly) when:
- It's a small, self-contained screen with no business logic
- You're prototyping and the design isn't settled yet
- The abstraction adds more complexity than it removes
Frequently Asked Questions
What are SOLID principles in Android development?
SOLID is an acronym for five object-oriented design principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. In Android, they guide how you structure ViewModels, repositories, and interfaces to write maintainable, testable code.
How does SOLID relate to MVVM in Android?
MVVM naturally enforces several SOLID principles. SRP is enforced by separating UI (Activity), logic (ViewModel), and data (Repository). DIP is enforced by injecting interfaces via Hilt. OCP is supported by adding new features as new classes without modifying existing ones.
What is the most important SOLID principle for Android?
The Dependency Inversion Principle (DIP) has the biggest impact because it's what makes unit testing possible. If your ViewModel depends on interfaces instead of concrete classes, you can inject fakes in tests without hitting the real network or database.
Should I always follow SOLID principles?
Not blindly. Over-engineering small features with unnecessary abstractions makes code harder to read. Apply SOLID when a class is likely to change, needs unit testing, or is shared across a team. For small self-contained screens, simpler code is often better.
- S — SRP: One class, one job. Activity = UI, ViewModel = logic, Repository = data
- O — OCP: Add new payment types/features as new classes, never edit existing ones
- L — LSP: Any subclass should work wherever the base class is expected
- I — ISP: Small focused interfaces > one fat interface everyone partially implements
- D — DIP: Depend on interfaces, inject implementations — this is what makes unit tests possible
- MVVM naturally enforces SOLID — use it and you're already most of the way there
- Don't over-engineer — SOLID is a guide, not a law