Let's Make SOLID Work: The Principles and Patterns Behind Android Architecture

AsyncTask died, Fragments were rewritten twice, XML gave way to Compose. What survived every migration was the shape of the code underneath. Here are the five principles and nine patterns that decide it.

Let's Make SOLID Work: The Principles and Patterns Behind Android Architecture

Every Android developer has inherited the same codebase. A ViewModel with eleven hundred lines. A when block that grew a new branch every sprint for two years. A Utils.kt that imports half the SDK. It compiles, it ships, and nobody wants to touch it.

The instinct is to blame the framework. It's rarely the framework.

AsyncTask was deprecated. Fragments were rewritten twice. XML layouts gave way to Compose. Every one of those migrations was survivable — or not — based on something the framework had no opinion about: whether the code underneath had a shape.

This post is about that shape. Five principles, then nine patterns that fall out of them.

The Foundation: SOLID#

SOLID isn't interview trivia. Each letter names a specific failure mode you've already met.

S — Single Responsibility#

The usual phrasing is "a class should have one reason to change," which sounds obvious and helps nobody. The sharper version, from Uncle Bob's later writing: a class should be answerable to one actor.

An actor is a source of change requests. Design asks for a different date format. Backend changes the DTO. Product wants retry logic. Three actors, and in most codebases all three edits land in the same file:

Kotlin
class ProfileViewModel(private val api: ProfileApi) : ViewModel() {

    private val _state = MutableStateFlow(ProfileState())
    val state = _state.asStateFlow()

    fun load(id: String) {
        viewModelScope.launch {
            val dto = api.fetch(id)                              // backend's actor
            val joined = SimpleDateFormat("dd/MM/yyyy")          // design's actor
                .format(Date(dto.joinedAtMillis))
            _state.update {
                it.copy(                                          // product's actor
                    name = "${dto.firstName} ${dto.lastName}",
                    joinedLabel = "Member since $joined"
                )
            }
        }
    }
}

Three actors, one file, and a merge conflict every time two of them move in the same sprint.

The fix isn't more classes for their own sake. It's giving each actor its own address:

Kotlin
// data — answers to the backend
class ProfileRepository(private val api: ProfileApi) {
    suspend fun profile(id: String): Profile = api.fetch(id).toDomain()
}

// presentation — answers to design
class ProfileFormatter(private val clock: Clock) {
    fun joinedLabel(joinedAt: Instant): String = /* ... */
}

// orchestration — answers to product
class ProfileViewModel(
    private val repository: ProfileRepository,
    private val formatter: ProfileFormatter,
) : ViewModel() { /* ... */ }

The test for SRP isn't "how many lines is this class?" It's "who asks me to change it?" If you can name two different people, you have two responsibilities.

O — Open/Closed#

Open for extension, closed for modification. New behaviour should arrive as new code, not as edits to code that already works.

The tell is a when that keeps growing:

Kotlin
fun charge(method: String, cents: Long) = when (method) {
    "card" -> chargeCard(cents)
    "pix"  -> chargePix(cents)
    "boleto" -> chargeBoleto(cents)
    // every new method reopens this file and risks the three above
    else -> throw IllegalArgumentException("unknown: $method")
}

Each new payment type forces you back into a file whose existing branches are already in production. The compiler can't help you, and neither can your reviewer.

Push the variation into types:

Kotlin
sealed interface PaymentMethod {
    suspend fun charge(cents: Long): Result<Receipt>
}

class CardPayment(private val gateway: Gateway) : PaymentMethod {
    override suspend fun charge(cents: Long) = gateway.authorize(cents)
}

class PixPayment(private val pix: PixClient) : PaymentMethod {
    override suspend fun charge(cents: Long) = pix.createCharge(cents)
}

// adding Boleto: one new file. Nothing above it changes.

Note what sealed buys you: exhaustive when at the edges where you genuinely must branch (rendering an icon per method, say), while the behaviour itself stays polymorphic.

L — Liskov Substitution#

Any implementation must be usable through its interface without the caller knowing which one it got.

This one is violated quietly. The code compiles; the contract doesn't hold:

Kotlin
interface Cart {
    fun add(item: Item)
    fun items(): List<Item>
}

class ReadOnlyCart(private val snapshot: List<Item>) : Cart {
    override fun add(item: Item) = throw UnsupportedOperationException()  // 💥
    override fun items() = snapshot
}

Every caller now needs to know whether its Cart is the real one. The moment you write if (cart is ReadOnlyCart), the abstraction has stopped paying for itself.

Usually the hierarchy is just wrong. Split the capability instead:

Kotlin
interface CartSource { fun items(): List<Item> }
interface MutableCart : CartSource { fun add(item: Item) }

Now a read-only cart is a CartSource, and the type system enforces what the exception used to.

I — Interface Segregation#

No client should be forced to depend on methods it doesn't use.

Fat interfaces feel efficient right up to the day you write a test:

Kotlin
interface UserRepository {
    suspend fun user(id: String): User
    suspend fun save(user: User)
    suspend fun delete(id: String)
    suspend fun search(query: String): List<User>
    suspend fun syncAll()
    fun observeAll(): Flow<List<User>>
}

Your ProfileViewModel calls exactly one of those. But your fake has to implement all six, five of them as TODO(), and every method you add to the interface later breaks every fake in the suite.

Kotlin
fun interface GetUser { suspend operator fun invoke(id: String): User }
fun interface SaveUser { suspend operator fun invoke(user: User) }

class ProfileViewModel(private val getUser: GetUser) : ViewModel()

// the test fake is now a lambda
val vm = ProfileViewModel(GetUser { User.fake(id = it) })

That last line is the whole argument. When a dependency is small enough to fake in one line, people write tests. When it takes forty lines of TODO(), they don't.

D — Dependency Inversion#

High-level policy should not depend on low-level detail. Both depend on abstractions.

Concretely: your domain layer must not know that Retrofit exists.

Kotlin
// ❌ domain importing the data layer
class GetProfile(private val api: RetrofitProfileApi) { /* ... */ }

Now your business rules can't be tested without a web server, and swapping Retrofit for Ktor means touching domain code that has nothing to do with HTTP.

Invert it — the domain owns the interface, the data layer implements it:

Kotlin
// domain/ProfileRepository.kt — no Android, no Retrofit, no imports you don't control
interface ProfileRepository {
    suspend fun profile(id: String): Profile
}

// domain/GetProfile.kt
class GetProfile(private val repository: ProfileRepository) {
    suspend operator fun invoke(id: String) = repository.profile(id)
}

// data/ProfileRepositoryImpl.kt — depends inward, on the domain
class ProfileRepositoryImpl(
    private val api: ProfileApi,
    private val dao: ProfileDao,
) : ProfileRepository {
    override suspend fun profile(id: String) =
        dao.find(id)?.toDomain() ?: api.fetch(id).toDomain().also { dao.insert(it.toEntity()) }
}

The arrow of dependency now points inward, toward the code that changes least. This is the principle that makes the other four practical — and the one that turns a six-minute instrumented test suite into a six-second JVM one.

If you only internalise one letter, make it D. Every clean architecture diagram you've squinted at is just this rule drawn as concentric circles.

The Vocabulary: Gang of Four Patterns#

The 1994 book catalogues 23 patterns. You'll use maybe nine regularly, and you already use most of them without naming them.

That naming is the actual value. "Let's put a Facade over it" ends a design discussion that "maybe we wrap it in something?" would have dragged out for twenty minutes.

Creational — how objects get built#

Builder — construct something complicated step by step, when a constructor with nine optional parameters would be unreadable. NotificationCompat.Builder, OkHttpClient.Builder, Retrofit.Builder. In Kotlin, named and default arguments cover most of what Builder was invented for — reach for it when construction is genuinely multi-step or conditional.

Factory — let something else decide the concrete type. ViewModelProvider.Factory exists precisely because the framework must instantiate your ViewModel without knowing its constructor.

Kotlin
class ProfileViewModelFactory(
    private val getProfile: GetProfile,
) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T =
        ProfileViewModel(getProfile) as T
}

Singleton — exactly one instance for the process lifetime. Also the most abused pattern in the catalogue.

Kotlin
// ❌ untestable, invisible in signatures, alive until the process dies
object AnalyticsManager {
    fun track(event: String) { /* ... */ }
}

The problem isn't uniqueness — a single OkHttpClient is correct and good. The problem is global access. A dependency reached through object never appears in a constructor, so nothing tells you it's there and nothing lets you replace it in a test.

Kotlin
// ✅ still one instance, but injected and swappable
@Provides @Singleton
fun analytics(client: OkHttpClient): Analytics = FirebaseAnalytics(client)

Same lifetime. Honest signature.

Structural — how objects compose#

Adapter — make an incompatible interface fit. RecyclerView.Adapter is the canonical example: RecyclerView knows nothing about your data, and your data knows nothing about view recycling.

Facade — one simple entry point over a messy subsystem. Every Repository you've written is a Facade: callers ask for a Profile and never learn whether it came from Room, Retrofit, or a memory cache.

Decorator — add behaviour by wrapping, not subclassing. OkHttp Interceptors are Decorators; so is this:

Kotlin
class LoggingProfileRepository(
    private val delegate: ProfileRepository,
) : ProfileRepository {
    override suspend fun profile(id: String): Profile {
        Log.d("Repo", "profile($id)")
        return delegate.profile(id).also { Log.d("Repo", "-> $it") }
    }
}

Kotlin's by keyword makes this nearly free when you only want to override one member of a wide interface.

Behavioural — how objects talk#

Observer — notify dependents when state changes. Flow, StateFlow, LiveData, Compose's State. The entire reactive UI model is one pattern from 1994.

Command — package an action as an object so it can be passed, queued, logged, or replayed. Your MVI Intents are Commands:

Kotlin
sealed interface ProfileAction {
    data class Load(val id: String) : ProfileAction
    data object Refresh : ProfileAction
}

fun onAction(action: ProfileAction) = when (action) {
    is ProfileAction.Load -> load(action.id)
    ProfileAction.Refresh -> refresh()
}

Strategy — swap an algorithm behind a stable interface. Different sort orders, different validation rules per country, different image loaders per build flavour.

Look at that list and MVI stops being a framework you adopt: it's Command for input, Observer for output, and a state machine in the middle. Nothing new — just well named.

How to actually use this#

Patterns are a vocabulary, not a checklist. The failure mode of learning them is applying all nine to a screen that shows a list.

Three rules that have held up for me:

Let the pain pick the pattern. Don't introduce Strategy because you might need a second algorithm. Introduce it when the second one arrives. Speculative abstraction costs more than the duplication it prevents.

Name what you already built. Most codebases contain a dozen unnamed patterns. Renaming DataHelper to ProfileRepository teaches the next reader more than any comment.

When principles conflict, D wins. SRP and ISP push you toward more, smaller types; at some point that's just noise. Dependency Inversion is the one whose absence you'll pay for every single day, in test runtime and in coupling.

Wrapping up#

None of this is about being clever. It's about the diff size of your next migration.

The codebase that survived Compose wasn't the one with the smartest abstractions. It was the one where the UI layer was thin enough to replace, because the rules lived somewhere the framework couldn't reach.

Next up, we'll take one screen and refactor it end to end against these principles — the eleven-hundred-line ViewModel, broken apart with the tests to prove nothing shifted.

Until then, let's make this work.

Keep reading

More breakdowns

Kotlin & Core Concepts

Let's Make Coroutines Work: What is a Coroutine?

Threads block, and blocked threads freeze apps. Here's what a coroutine actually is, what a suspension point does, and why you can run 100,000 of them on a single thread.

· 2 min read