Android Architecture series · Part 2 of 9
Principles and Patterns: The Bricks Behind Every Architecture
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.
- 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.
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.
| Letter | Principle | Android meaning |
|---|---|---|
| S | Single Responsibility | A class has one reason to change. The Activity shows things; it does not parse JSON or run SQL. |
| O | Open / Closed | Add 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. |
| L | Liskov Substitution | A fake or alternative implementation must behave like the real one. A FakeRepository can replace RealRepository and the screen still works. |
| I | Interface Segregation | Small interfaces. A screen that only reads should not depend on an interface that also has delete() and sync(). |
| D | Dependency Inversion | High-level code depends on an interface, not on a concrete library. The presenter knows ImageLoader, not Glide. |
Three more ideas travel with SOLID:
- Separation of concerns: each part of the app deals with one concern (drawing, rules, storage). The three-box template from Part 1 is exactly this.
- KISS (Keep It Simple, Stupid): the simplest design that works is the best one. Do not add five layers to a screen that shows one label.
- DRY (Don’t Repeat Yourself): one rule lives in one place. If the date format is copy-pasted in ten screens, a change means ten edits and one forgotten bug.
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).
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
| Pattern | Android example |
|---|---|
| Observer | LiveData, StateFlow, Rx streams: the view reacts when data changes. |
| Factory | ViewModelProvider.Factory: builds objects that need constructor arguments. |
| Adapter | RecyclerView.Adapter, and mapper functions that convert API models to domain models. |
| Strategy | Swap an algorithm: different sort orders, different payment providers. |
| Builder | AlertDialog.Builder, Retrofit.Builder. |
| Singleton | One shared instance. Use through dependency injection, not as a global variable. |
3. How they work together
- Separation of concerns gives you the boxes (View, Logic, Data).
- Proxy keeps the View box replaceable. Repository keeps the Data box replaceable. Scheduler keeps threading replaceable.
- Dependency Inversion is what lets you replace them: depend on interfaces, get the real classes injected (Part 6).
- KISS and DRY stop you from overdoing any of it.
Remember this list. In Part 8 the claim is that MVP, MVVM, VIPER and Redux are all built from these same bricks.