Skip to main content

SOLID principles with Android scenarios

SOLID is a diagnostic vocabulary, not a command to create an interface for every class.

S — Single Responsibility​

A class should have one cohesive reason to change.

❌ ProfileViewModel fetches HTTP, parses JSON, validates names, uploads photos, formats strings, logs analytics, and navigates.

✅ ViewModel coordinates screen state; repository owns profile data; validator owns name rules; image uploader owns transfer policy; UI formats localized resources.

Do not turn every three-line function into a class. Extract distinct policy or volatility.

O — Open/Closed​

Add behavior through a stable extension point when variants are expected:

fun interface ShippingPricePolicy {
fun price(order: Order): Money
}

class Checkout(private val policies: Map<ShippingMethod, ShippingPricePolicy>) {
fun shipping(order: Order, method: ShippingMethod) =
requireNotNull(policies[method]) { "Unsupported method: $method" }.price(order)
}

This helps when shipping methods are independently owned. A when is clearer for two fixed sealed variants; do not pre-build plugin systems without evidence.

L — Liskov Substitution​

Every implementation must honor the contract.

interface UserRepository {
/** Emits current cached state, then later updates; never performs work on Main. */
fun observe(id: UserId): Flow<User?>
}

If the fake emits once and completes while production emits continuously, tests use a different contract. If one implementation throws for a valid ID despite the contract promising null, substitution fails.

I — Interface Segregation​

❌ A read-only widget depends on UserRepository with delete, upload, sync, admin ban, and export methods.

✅ Depend on a focused capability:

fun interface ObserveUser { operator fun invoke(id: UserId): Flow<User?> }

But many tiny interfaces can hide navigation and complicate DI. Split around consumer capability or ownership, not method count.

D — Dependency Inversion​

High-level policy should not depend directly on low-level mechanism:

class SubmitOrder(
private val orders: OrderRepository,
private val clock: Clock,
private val idGenerator: IdGenerator,
)

The domain policy chooses abstractions it needs. Retrofit/Room adapters implement them. Dependency injection supplies implementations; DI itself is not dependency inversion.

Practical smell table​

SmellLikely principleFirst question
ViewModel changes for API and UI redesignSRPwhich policies are mixed?
Adding payment type edits 12 whensOCPis a stable variation point real?
Fake behaves unlike productionLSPwhat is the missing contract?
Consumer mocks 20 unused methodsISPwhat capability does it need?
Business rule imports Android/RetrofitDIPcan mechanism point inward?
Interface inflation

An interface with one implementation is justified by boundary ownership, testing strategy, or expected variability—not automatically by SOLID.

Exercises​

  1. Refactor a sync worker that owns HTTP, SQL, retry policy, and notification UI.
  2. Write a behavioral contract shared by real and fake repositories.
  3. Explain DI versus dependency inversion in one example.