SOLID Principles in Android — Write Code Your Future Self Won't Hate

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.

SOLID design principles in Android

SOLID — five principles that separate maintainable code from the kind that haunts you at 2am

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.

📚 Continue Learning
📝 Summary
  • 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

Pragnesh Ghoda

A forward-thinking developer offering more than 8 years of experience building, integrating, and supporting android applications for mobile and tablet devices on the Android platform. Talks about #kotlin and #android

Post a Comment

Please let us know about any concerns or query.

Previous Post Next Post

Contact Form