Blog Infos
Author
Published
Topics
, , , ,
Published
Learn how to apply each SOLID principle in Android apps with 15 focused use cases and Kotlin code examples.

 

source: istockphoto.com

Introduction

As Android developers, we often hear about the SOLID principles, but let’s be real — it’s not always clear how to apply them specifically in our projects. SOLID is a set of five design principles that help make software more maintainable, flexible, and scalable. Originally coined by Uncle Bob (Robert C. Martin), these principles are especially powerful in Android when you’re working on large codebases or want better testability and separation of concerns.

In this article, we’ll explore three real-world Android use cases for each of the SOLID principles — complete with Kotlin code snippets and detailed explanations. Whether you’re building Jetpack Compose UIs, integrating with APIs, or setting up your ViewModels and UseCases, you’ll walk away with practical knowledge you can apply today.

🟠 1. Single Responsibility Principle (SRP)

A class should have one reason to change.

In Android development, classes tend to become bloated quickly — think of ActivityFragment, or ViewModel handling UI, navigation, API calls, error handling, and more. SRP encourages us to split responsibilities so each class does one thing well.

✅ Use Case 1: Split ViewModel UI and Business Logic

 

class LoginUseCase(private val repository: LoginRepository) {
    suspend fun login(username: String, password: String): Result<User> {
        if (username.isEmpty()) return Result.failure(Exception("Username empty"))
        return repository.login(username, password)
    }
}

class LoginViewModel(private val useCase: LoginUseCase) : ViewModel() {
    val uiState = MutableStateFlow<UiState>(UiState.Idle)
    fun login(username: String, password: String) {
        viewModelScope.launch {
            uiState.value = UiState.Loading
            val result = useCase.login(username, password)
            uiState.value = if (result.isSuccess) UiState.Success else UiState.Error
        }
    }
}

 

Why this matters:
Moving business logic out of the ViewModel and into a UseCase separates concerns and improves testability. The ViewModel focuses only on UI state management, while the UseCase is reusable and testable on its own.

✅ Use Case 2: Extract API Calls from Repository

 

interface RemoteDataSource {
    suspend fun login(username: String, password: String): Result<User>
}

class LoginRepository(private val remote: RemoteDataSource) {
    suspend fun login(username: String, password: String) = remote.login(username, password)
}

 

Why this matters:
You delegate responsibility for API calls to RemoteDataSource, keeping your repository focused on data coordination. This makes it easier to replace or mock the remote logic during testing.

✅ Use Case 3: Split Responsibilities in Fragment

 

class ProfileFragment : Fragment() {

private val viewModel: ProfileViewModel by viewModels()

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        setupUi()
        observeViewModel()
    }
    private fun setupUi() { /* setup views, listeners */ }
    private fun observeViewModel() { /* collect flows */ }
}

 

Why this matters:
Instead of crowding everything into onViewCreated, you give each method a specific job. This improves readability and ensures your Fragment follows SRP—one task per method, one reason to change.

🟡 2. Open/Closed Principle (OCP)

Software entities should be open for extension, but closed for modification.

You should be able to add new features without modifying existing code. This reduces the risk of bugs and makes your codebase future-proof.

✅ Use Case 1: Extend Sealed Classes for UI States

 

sealed class UiState {
    object Loading : UiState()
    data class Success(val data: String) : UiState()
    data class Error(val message: String) : UiState()
}

 

 

data class Empty(val reason: String) : UiState()

 

Why this matters:
You can introduce new screen states like Empty without altering existing logic. Using sealed classes lets your code evolve safely with clear intent.

✅ Use Case 2: Custom ViewModel Factory with DI

 

class MyViewModelFactory @Inject constructor(
    private val useCase: SomeUseCase
) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return MyViewModel(useCase) as T
    }
}

 

Why this matters:
You can inject different use cases into your ViewModel without rewriting or modifying the ViewModel itself — just extend the factory behavior.

✅ Use Case 3: Compose Modifier Extensions

 

fun Modifier.errorBorder(): Modifier = this
    .border(2.dp, Color.Red)

TextField(
    value = username,
    onValueChange = { username = it },
    modifier = Modifier
        .fillMaxWidth()
        .then(if (hasError) Modifier.errorBorder() else Modifier)
)

 

Why this matters:
Modifiers in Jetpack Compose allow you to extend UI behavior without modifying the UI component. This keeps your UI flexible and reusable.

🟢 3. Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types.

Any subclass or implementation should be usable wherever the base type is expected — without introducing errors.

✅ Use Case 1: Base Fragment with Template Methods

 

abstract class BaseFragment : Fragment() {
    abstract fun setupObservers()
    abstract fun setupUI()

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        setupUI()
        setupObservers()
    }
}

class LoginFragment : BaseFragment() {
    override fun setupUI() { /* Login UI */ }
    override fun setupObservers() { /* ViewModel observers */ }
}

 

Why this matters:
You can create multiple screens that plug into the same base fragment structure without rewriting lifecycle code. Each screen extends the base behavior safely.

✅ Use Case 2: Multi-Type RecyclerView

 

abstract class BaseViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    abstract fun bind(item: ListItem)
}

 

 

class TextViewHolder(view: View) : BaseViewHolder(view) {
    override fun bind(item: ListItem) { /* Bind text */ }
}

class ImageViewHolder(view: View) : BaseViewHolder(view) {
    override fun bind(item: ListItem) { /* Bind image */ }
}

 

Why this matters:
Each ViewHolder type can be used wherever BaseViewHolder is expected. Your adapter logic stays generic, but the behavior is customized.

✅ Use Case 3: Replace Dispatchers for Testing

 

open class CoroutineDispatcherProvider {
    open val io = Dispatchers.IO
    open val main = Dispatchers.Main
}

class TestDispatcherProvider : CoroutineDispatcherProvider() {
    override val io = UnconfinedTestDispatcher()
    override val main = UnconfinedTestDispatcher()
}

 

Why this matters:
In tests, you can inject a subclass without changing the production logic. Your classes work with any subclass of the base dispatcher provider.

🔵 4. Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they don’t use.

In Android, this means keeping interfaces clean and focused — no bloated method contracts.

✅ Use Case 1: Split Repository Contracts

 

interface AuthRepository {
    suspend fun login(username: String, password: String): Result<User>
}

interface ProfileRepository {
    suspend fun getProfile(): Result<UserProfile>
}

 

Why this matters:
The LoginViewModel shouldn’t care about getProfile()—so it doesn’t. Smaller interfaces reduce coupling and simplify testing.

✅ Use Case 2: RecyclerView Click Listeners

 

interface OnImageClickListener {
    fun onImageClick(url: String)
}

interface OnTextClickListener {
    fun onTextClick(text: String)
}

 

Why this matters:
Let each adapter implement only what it needs. This keeps components lightweight and avoids confusing method stubs.

✅ Use Case 3: Feature-Specific View Contracts

 

interface LoginContract {
    fun showLoginForm()
    fun showLoginSuccess()
}

interface SignupContract {
    fun showSignupForm()
    fun showSignupSuccess()
}

 

Why this matters:
Avoid forcing unrelated screens (e.g., Login vs Signup) to implement shared methods they don’t need. Focused interfaces = cleaner code.

🟣 5. Dependency Inversion Principle (DIP)

Depend on abstractions, not on concretions.

This is the foundation of scalable Android apps using Clean Architecture or MVVM with DI.

✅ Use Case 1: Inject UseCase into ViewModel

 

class LoginViewModel(private val loginUseCase: LoginUseCase) : ViewModel() {
    fun login() = viewModelScope.launch {
        loginUseCase.login()
    }
}

 

Why this matters:
You can mock LoginUseCase in tests. The ViewModel doesn’t know or care about the implementation—it just calls the abstraction.

✅ Use Case 2: Abstract Data Sources

 

interface UserDataSource {
    suspend fun fetchUser(): User
}

class RemoteUserDataSource : UserDataSource {
    override suspend fun fetchUser() = api.getUser()
}
class LocalUserDataSource : UserDataSource {
    override suspend fun fetchUser() = db.getUser()
}

 

Why this matters:
You can switch between sources at runtime — e.g., use remote when online, local when offline — without changing the rest of your code.

✅ Use Case 3: Abstract Auth Storage

 

interface AuthStorage {
    suspend fun saveToken(token: String)
    suspend fun getToken(): String?
}

class DataStoreAuthStorage(...) : AuthStorage {
    override suspend fun saveToken(token: String) { /* Save to DataStore */ }
    override suspend fun getToken(): String? { /* Load from DataStore */ }
}

 

Why this matters:
Whether you use DataStore, EncryptedSharedPrefs, or something else, your app logic stays the same. The rest of your app talks only to the interface.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

✅ Conclusion

The SOLID principles aren’t just abstract theory — they’re real tools you can use to improve your Android codebase today.

To recap:

  • SRP separates responsibilities, keeping classes focused.
  • OCP lets you add new features without changing old code.
  • LSP ensures safe inheritance and polymorphism.
  • ISP helps break down bloated interfaces.
  • DIP decouples logic, improving testability and flexibility.

Start small. Refactor one ViewModel. Break one big interface into two. Inject one abstraction. These small changes now will lead to a solid architecture later (pun absolutely intended 😉).

Dobri Kostadinov
Android Consultant | Trainer
Email me | Follow me on LinkedIn | Follow me on Medium | Buy me a coffee

This article was previously published on proandroiddev.com.

Menu