Android Architecture series · Part 2 of 9

Principles and Patterns: The Bricks Behind Every Architecture

Khairul Alam·October 2026·~10 min read
SOLIDKISSDRYDesign patternsKotlin

Before comparing MVP and MVVM, learn the bricks they are all made from. This part covers the design principles and four patterns (ViewProxy, GlideProxy, Scheduler, Repository) that keep reappearing.

In 60 seconds
  • Principles: SOLID, separation of concerns, KISS, DRY.
  • Patterns: Proxy (ViewProxy, GlideProxy), Scheduler, Repository.
  • Idea: depend on small interfaces so each box can be replaced.
  • Warning: use a pattern only when it removes real pain.
Contents
  1. SOLID in kitchen language
  2. Design patterns in every variant
  3. How they work together

1. SOLID, in kitchen language

SOLID is five habits that keep code easy to change. If you want the long version with more examples, read my SOLID principles explained article. Here is the short version, always with Android in mind. For the patterns themselves in Kotlin, with runnable exercises, see Design Patterns in Kotlin.

LetterPrincipleAndroid meaning
SSingle ResponsibilityA class has one reason to change. The Activity shows things; it does not parse JSON or run SQL.
OOpen / ClosedAdd behavior by adding new code, not by editing working code. A new payment method is a new class, not another if in a 200-line function.
LLiskov SubstitutionA fake or alternative implementation must behave like the real one. A FakeRepository can replace RealRepository and the screen still works.
IInterface SegregationSmall interfaces. A screen that only reads should not depend on an interface that also has delete() and sync().
DDependency InversionHigh-level code depends on an interface, not on a concrete library. The presenter knows ImageLoader, not Glide.

Three more ideas travel with SOLID:

KISS beats cleverness

SOLID and design patterns are tools, not trophies. Use a pattern when it removes a real pain (hard to test, hard to change). Skip it when it only adds files.

2. Design patterns you will meet in every variant

A design pattern is a named, reusable answer to a problem that keeps coming back. The nice thing about a name is that your team can say “put it behind a proxy” and everyone understands. The next four patterns appear in every architecture in this series.

Proxy pattern: ViewProxy

A proxy is a stand-in object with the same job description as the real thing, placed in front of it. A ViewProxy is a small class that wraps your real views (the Activity, XML views or Compose) and exposes simple commands like showLoading() or showProfile(profile).

LogicPresenter / ViewModelViewProxyshowLoading(), render()Real viewsActivity, XML, ComposeinterfacefindViewById / binding
The logic never touches the framework. It only knows the proxy’s small interface.
interface ProfileView {
    fun showLoading()
    fun showProfile(profile: Profile)
    fun showError(message: String)
}

// Lives next to the layout. The only class that knows about the ViewBinding.
class ProfileViewProxy(private val binding: ActivityProfileBinding) : ProfileView {
    override fun showLoading() { binding.progress.isVisible = true }
    override fun showProfile(profile: Profile) {
        binding.progress.isVisible = false
        binding.name.text = profile.name
    }
    override fun showError(message: String) {
        binding.progress.isVisible = false
        binding.error.text = message
    }
}

Why it helps: the logic can be tested on the plain JVM with a fake ProfileView, no emulator needed. And when the screen moves from XML to Jetpack Compose, only the proxy changes. ViewProxy works in every variant: in MVP the presenter calls it; in MVVM the view layer observes state and passes it to the proxy; in Redux it is the function render(state). Part 8 proves it.

Proxy again: GlideProxy

The same trick protects you from a library. Wrap Glide behind your own interface:

interface ImageLoader {
    fun load(url: String, target: ImageView)
}

class GlideProxy : ImageLoader {
    override fun load(url: String, target: ImageView) {
        Glide.with(target).load(url).centerCrop().into(target)
    }
}
// Later you can add CoilProxy : ImageLoader and change one line of wiring.

Every screen uses ImageLoader. If Glide changes its API or you move to Coil, you edit one class. That is Dependency Inversion and the Open/Closed principle working together.

Scheduler pattern: AssessmentSchedulers

Background work needs two answers: which thread does the work and which thread shows the result. If you hard-code Dispatchers.IO or Schedulers.io() everywhere, tests become slow and flaky. Hide that choice behind a small interface. In one project it was called AssessmentSchedulers; you can name it after your own feature.

// RxJava version
interface AssessmentSchedulers {
    fun io(): Scheduler
    fun ui(): Scheduler
}
class AppSchedulers : AssessmentSchedulers {
    override fun io() = Schedulers.io()
    override fun ui() = AndroidSchedulers.mainThread()
}
class TestSchedulers : AssessmentSchedulers {   // used in unit tests
    override fun io() = Schedulers.trampoline()
    override fun ui() = Schedulers.trampoline()
}

// Coroutines version
interface DispatcherProvider {
    val io: CoroutineDispatcher
    val main: CoroutineDispatcher
}

In production you inject the real threads. In tests you inject immediate ones (trampoline() for Rx, a test dispatcher for coroutines) and the async code runs in order, instantly.

Repository pattern: LocalRepository

A repository is the single door to some data. The logic asks “give me the profile”; it does not care whether the answer came from memory, the database or the network.

interface ProfileRepository {
    suspend fun getProfile(id: String): Profile
}

class LocalRepository(private val dao: ProfileDao) : ProfileRepository {
    override suspend fun getProfile(id: String) = dao.find(id).toDomain()
}

class RemoteRepository(private val api: ProfileApi) : ProfileRepository {
    override suspend fun getProfile(id: String) = api.profile(id).toDomain()
}

// The "real" repository the app injects. It chooses and combines sources.
class DefaultProfileRepository(
    private val local: LocalRepository,
    private val remote: RemoteRepository,
) : ProfileRepository {
    override suspend fun getProfile(id: String): Profile =
        runCatching { remote.getProfile(id).also { local.save(it) } }
            .getOrElse { local.getProfile(id) }   // offline fallback
}

A few more patterns worth a name

PatternAndroid example
ObserverLiveData, StateFlow, Rx streams: the view reacts when data changes.
FactoryViewModelProvider.Factory: builds objects that need constructor arguments.
AdapterRecyclerView.Adapter, and mapper functions that convert API models to domain models.
StrategySwap an algorithm: different sort orders, different payment providers.
BuilderAlertDialog.Builder, Retrofit.Builder.
SingletonOne shared instance. Use through dependency injection, not as a global variable.

3. How they work together

Remember this list. In Part 8 the claim is that MVP, MVVM, VIPER and Redux are all built from these same bricks.

← Back to all writing