Android Architecture Patterns: A Comprehensive Pre-Interview Guide
Introduction
Purpose of this guide
During my recent mobile system design interview prep, I realized I've compiled valuable notes spanning a decade of experience. These notes serve as my go-to refresher the week before interviews. That's why I've created this structured repository of posts - to help mobile developers quickly refresh their knowledge on key architectural patterns before heading into interviews.
Why architecture patterns matter in modern Android development?
Architectural patterns in modern mobile development provide structured approaches to managing complexity, improving testability, and enabling team collaboration. They separate concerns, enhance maintainability, and create resilient applications that can adapt to Android's evolving ecosystem and changing requirements.
How understanding different patterns demonstrates technical depth?
Understanding multiple architecture patterns shows you can evaluate trade-offs between approaches, adapt to different project requirements, and make informed technical decisions rather than following trends blindly—qualities that distinguish senior engineers from code implementers.
MVC (Model-View-Controller)
Overview and History
- One of the earliest patterns adopted in Android development
- Derived from desktop and web development paradigms
- Predates Android's architecture components and Jetpack libraries
- Initially popular due to simplicity and familiarity
Core Components
- Model: Data and business logic (POJOs, database operations)
- View: UI elements (Activities, Fragments, XML layouts)
- Controller: Mediates between Model and View (often embedded in Activities/Fragments)
Limitations
- Tends to create massive Activity/Fragment classes ("God Activities")
- Poor separation of concerns in practice
- Difficult to unit test due to Android dependencies
- No clear handling of Android lifecycle events
- UI logic and business logic become tightly coupled
When Interviewers Expect You to Know MVC
- When discussing architectural evolution in Android
- For maintaining or refactoring legacy applications
- As a baseline to explain why other patterns were developed
- To demonstrate you understand fundamental architectural concepts
MVP (Model-View-Presenter)
Evolution from MVC
- Emerged as a direct response to MVC's "God Activity" problem
- Extracts UI logic from Android components into testable Presenter class
- Creates clearer boundaries between responsibilities
- Addresses Android-specific lifecycle challenges by delegating to Presenter
Component Responsibilities
- Model: Business logic and data management (unchanged from MVC)
- View: Passive interface implemented by Activity/Fragment (displays data, forwards user actions)
- Presenter: Mediator that handles UI logic, processes user inputs, updates View
Interaction Flow
- User interacts with View
- View forwards events to Presenter
- Presenter manipulates Model and determines View updates
- Presenter instructs View to update itself
- View renders with new data
Testing Advantages
- Presenter contains no Android dependencies, enabling JUnit testing
- View interfaces can be mocked for testing Presenter logic
- Clear contracts between components via interfaces
- Easier to test UI logic without instrumentation tests
Common Variations
- Passive View: View has minimal logic, Presenter handles everything
// Passive View - View has minimal logic
public class NotesActivity extends AppCompatActivity implements NotesContract.View {
private NotesContract.Presenter presenter;
private ListView listView;
private ArrayAdapter<Note> adapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
// View binding logic ...
// Presenter handles everything
presenter = new NotesPresenter(this, new NotesRepository());
presenter.loadNotes();
}
@Override
public void showNotes(List<Note> notes) {
// View just renders what the presenter tells it to
adapter.clear();
adapter.addAll(notes);
}
@Override
public void showError(String message) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
}
}
- Supervising Controller: View handles simple UI logic, Presenter handles complex logic
// Supervising Controller - View handles simple UI logic
public class NotesActivity extends AppCompatActivity implements NotesContract.View {
private NotesContract.Presenter presenter;
private ListView listView;
private NotesAdapter adapter; // Custom adapter with filtering capability
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_notes);
// View handles some UI logic like filtering
EditText searchInput = findViewById(R.id.search_input);
searchInput.addTextChangedListener(new TextWatcher() {
@Override
public void afterTextChanged(Editable s) {
// View handles simple filtering directly
adapter.filter(s.toString());
}
// Other methods omitted
});
presenter = new NotesPresenter(this, new NotesRepository());
presenter.loadNotes();
}
@Override
public void showNotes(List<Note> notes) {
adapter.setNotes(notes);
}
}
- Contract-based MVP: Interfaces define communication between View and Presenter
// Contract defining View and Presenter responsibilities
public interface NotesContract {
interface View {
void showNotes(List<Note> notes);
void showEmpty();
void showLoading(boolean show);
void showError(String message);
}
interface Presenter {
void attachView(View view);
void detachView();
void loadNotes();
void addNote(String text);
void deleteNote(Note note);
}
}
// Implementation
public class NotesPresenter implements NotesContract.Presenter {
private NotesContract.View view;
private NotesRepository repository;
public NotesPresenter(NotesRepository repository) {
this.repository = repository;
}
@Override
public void attachView(NotesContract.View view) {
this.view = view;
}
@Override
public void detachView() {
this.view = null; // Prevent memory leaks
}
@Override
public void loadNotes() {
view.showLoading(true);
repository.getNotes(new Callback<List<Note>>() {
@Override
public void onSuccess(List<Note> notes) {
view.showLoading(false);
if (notes.isEmpty()) {
view.showEmpty();
} else {
view.showNotes(notes);
}
}
@Override
public void onError(Exception e) {
view.showLoading(false);
view.showError(e.getMessage());
}
});
}
// Other methods omitted
}
Real-World Examples
- Square's Mortar framework
- Google's deprecated android-architecture samples
- Still common in enterprise apps starting pre-2017
- Often found in apps emphasizing unit testing coverage
// Contract defining View and Presenter responsibilities
public interface NotesContract {
interface View {
void showNotes(List<Note> notes);
void showError(String message);
}
interface Presenter {
void loadNotes();
void addNote(String text);
void detachView();
}
}
MVVM (Model-View-ViewModel)
Google's Adoption via Architecture Components
- Officially endorsed by Google with the 2017 Architecture Components release
- Part of Android Jetpack library suite
- Built-in ViewModel class handles configuration changes elegantly
- Designed to work with LiveData, Room, and Navigation components
Data Binding and LiveData Concepts
- LiveData: Lifecycle-aware observable data holder
- Data Binding: Declaratively binds UI elements to data sources
- Together they create reactive UI updates without manual synchronization
- Eliminates boilerplate code for updating views
ViewModel Lifecycle Awareness
- Survives configuration changes (rotation, dark mode switch)
- Automatically cleared when associated fragment/activity is destroyed
- Prevents memory leaks through proper lifecycle management
- Separates UI data from UI controllers
Implementation Approaches
With Data Binding:
// Layout file: activity_notes.xml
<layout>
<data>
<variable name="viewModel" type="com.example.NotesViewModel" />
</data>
<androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:items="@{viewModel.notes}" />
</layout>
// Activity
class NotesActivity : AppCompatActivity() {
private lateinit var viewModel: NotesViewModel
private lateinit var binding: ActivityNotesBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_notes)
viewModel = ViewModelProvider(this).get(NotesViewModel::class.java)
binding.viewModel = viewModel
binding.lifecycleOwner = this
}
}
Without Data Binding
class NotesActivity : AppCompatActivity() {
private lateinit var viewModel: NotesViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_notes)
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
val adapter = NotesAdapter()
recyclerView.adapter = adapter
viewModel = ViewModelProvider(this).get(NotesViewModel::class.java)
viewModel.notes.observe(this) { notes ->
adapter.submitList(notes)
}
}
}
Testing Strategies
- ViewModels are easily testable with JUnit (no Android dependencies)
- Use
InstantTaskExecutorRule
to make LiveData emit synchronously in tests - Mock Repository dependencies with Mockito or fake implementations
- Use
TestCoroutineDispatcher
for testing coroutines in ViewModels
Why It Became the Recommended Pattern
- Solves lifecycle management problems elegantly
- Highly testable architecture
- Reduces boilerplate with data binding
- Strong integration with other Jetpack components
- Scales well from simple to complex apps
- Clear separation of concerns
- Official Google support and documentation
MVI (Model-View-Intent)
Reactive and Unidirectional Data Flow
- Inspired by functional and reactive programming paradigms
- Single direction of data flow: Intent → Model → View → Intent
- All UI changes come from state updates, never direct view manipulation
- Based on immutable state objects that represent the entire UI
State Management Approach
- Intent: User actions or events translated into "intentions"
- Model: Pure business logic that transforms current state based on intents
- State: Immutable object representing the entire UI at a given moment
- Reducer: Pure function that creates new state based on previous state and intent
- Current state is the single source of truth for the UI
Benefits for Predictability and Debugging
- Highly predictable UI behavior (same state always produces same UI)
- Easy to track state changes and reproduce bugs
- Time-travel debugging possible (rewind through previous states)
- Great for complex UIs with many interactive elements
- Simplifies handling of side effects
Implementation Example
// State class representing entire UI
data class NotesState(
val notes: List<Note> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
// Intent sealed class representing all possible user actions
sealed class NotesIntent {
object LoadNotes : NotesIntent()
data class AddNote(val text: String) : NotesIntent()
data class DeleteNote(val id: Long) : NotesIntent()
}
// ViewModel implementing MVI pattern
class NotesViewModel(private val repository: NotesRepository) : ViewModel() {
private val _state = MutableStateFlow(NotesState())
val state: StateFlow<NotesState> = _state.asStateFlow()
fun processIntent(intent: NotesIntent) {
when (intent) {
is NotesIntent.LoadNotes -> loadNotes()
is NotesIntent.AddNote -> addNote(intent.text)
is NotesIntent.DeleteNote -> deleteNote(intent.id)
}
}
private fun loadNotes() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val notes = repository.getNotes()
_state.update { it.copy(notes = notes, isLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(error = e.message, isLoading = false) }
}
}
}
// Other methods omitted
}
Implementation Complexity Considerations
- Steeper learning curve than MVVM
- More verbose code with explicit state management
- May be overkill for simple UIs
- Requires disciplined approach to state updates
- Benefits increase with UI complexity
Redux/Flux-inspired Patterns
Core Concepts Adapted to Android
- Unidirectional Data Flow: Single source of truth for app state
- Predictable State Management: State changes only through explicit actions
- Immutable State: Each action produces a new state rather than modifying existing one
- Separation of Concerns: Clear boundaries between UI, state logic, and business logic
State Management and Reducers
- Centralized state container holds entire application state
- Pure reducer functions handle state transitions
- Reducers take current state and action, return new state
data class AppState(
val user: User? = null,
val isLoading: Boolean = false,
val error: String? = null
)
sealed class Action {
data class LoadUser(val userId: String) : Action()
data class UserLoaded(val user: User) : Action()
data class Error(val message: String) : Action()
}
fun reducer(state: AppState, action: Action): AppState {
return when (action) {
is Action.LoadUser -> state.copy(isLoading = true)
is Action.UserLoaded -> state.copy(
user = action.user,
isLoading = false
)
is Action.Error -> state.copy(
isLoading = false,
error = action.message
)
}
}
Action Dispatching Flow
- UI events trigger actions
- Actions flow through middleware (optional)
- Reducers compute new state
- UI observes state changes and updates accordingly
// Dispatching an action
store.dispatch(Action.LoadUser("user123"))
// Observing state in a ViewModel
class ProfileViewModel(private val store: Store) : ViewModel() {
val userState: LiveData<User?> = store.state
.map { it.user }
.asLiveData()
fun loadUser(userId: String) {
store.dispatch(Action.LoadUser(userId))
}
}
MvRx (Mavericks): Airbnb's Reactive Architecture
Airbnb's Architecture Approach
- Model-View-ViewModel (MVVM) with reactive programming principles
- Built on top of AAC ViewModel for lifecycle awareness
- Predictable unidirectional data flow using immutable state
- Designed to solve complex UI challenges in large-scale applications
- Renamed from MvRx to Mavericks to avoid copyright issues
Integration with Kotlin and Coroutines
- First-class Kotlin support with immutable data classes
- Seamless coroutines integration for async operations
- Extension functions for viewModelScope
- Flow support for reactive data streams
- Structured concurrency for managing async tasks
class ProfileViewModel(
initialState: ProfileState,
private val userRepository: UserRepository
) : MavericksViewModel<ProfileState>(initialState) {
init {
// Launch coroutine in viewModelScope
viewModelScope.launch {
// Load user data and update state
val user = userRepository.getUserById(initialState.userId)
setState { copy(user = user, isLoading = false) }
}
}
fun refreshProfile() = withState { state ->
viewModelScope.launch {
setState { copy(isLoading = true) }
val updatedUser = userRepository.getUserById(state.userId, forceRefresh = true)
setState { copy(user = updatedUser, isLoading = false) }
}
}
}
State Management Strategy
- Single source of truth with immutable state classes
- Reducer pattern with
setState { }
blocks - Automatic state diffing to minimize UI updates
- Async support with
Async<T>
wrapper (Loading, Success, Fail) - State persistence across configuration changes
// Define immutable state
data class ProfileState(
val userId: String,
val user: Async<User> = Loading(),
val posts: Async<List<Post>> = Uninitialized
) : MavericksState
// Access state in Fragment
class ProfileFragment : Fragment(), MavericksView {
private val viewModel: ProfileViewModel by fragmentViewModel()
override fun invalidate() = withState(viewModel) { state ->
when (state.user) {
is Loading -> showLoadingIndicator()
is Success -> {
hideLoadingIndicator()
displayUserInfo(state.user())
}
is Fail -> showError(state.user.error)
is Uninitialized -> Unit // Not yet loaded
}
}
}
When to Consider This Approach
- For medium to large-scale Android applications
- When dealing with complex UI states and async operations
- If you prefer a structured, opinionated architecture
- For teams that embrace Kotlin, coroutines and immutability
- When you need reliability and predictability in your UI layer
- If you want to avoid common Android lifecycle issues
Key Benefits
- Reduced boilerplate code compared to traditional MVVM
- Built-in support for handling loading, success, and error states
- Automatic state diffing minimizes unnecessary UI updates
- Robust lifecycle management
- Improved testability with pure functions and immutable data
Clean Architecture in Android
Layers and Separation of Concerns
- Clean Architecture divides your app into concentric layers, with dependencies pointing inward
- Domain Layer (innermost): Contains business logic and use cases, independent of Android
- Data Layer: Handles data operations via repositories, with interfaces defined in the domain layer
- Presentation Layer (outermost): Manages UI components, ViewModels, and user interactions
- Each layer has clear responsibilities, making code more testable, maintainable, and adaptable
How Other Patterns Fit Within Clean Architecture
- MVVM works well in the presentation layer for managing UI state and business logic
- Repository Pattern bridges domain and data layers, abstracting data sources
- Dependency Injection helps maintain layer boundaries by providing dependencies
- Use Case Pattern encapsulates business rules in the domain layer
- Mapper Pattern converts data between layer-specific models to maintain separation
Domain, Data, and Presentation Layers
Domain Layer
- Contains business logic independent of any framework
- Defines entities, use cases, and repository interfaces
- Has no dependencies on other layers or Android framework
// Domain Entity
data class User(val id: String, val name: String, val email: String)
// Repository Interface
interface UserRepository {
suspend fun getUser(id: String): Result<User>
suspend fun saveUser(user: User): Result<Unit>
}
// Use Case
class GetUserUseCase(private val userRepository: UserRepository) {
suspend operator fun invoke(id: String): Result<User> {
return userRepository.getUser(id)
}
}
Data Layer
- Implements repository interfaces defined in domain layer
- Manages data sources (API, database, preferences)
- Handles data mapping between domain and external models
// Data source
class UserRemoteDataSource(private val api: UserApi) {
suspend fun getUser(id: String): UserDto {
return api.getUser(id)
}
}
// Repository Implementation
class UserRepositoryImpl(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource,
private val mapper: UserMapper
) : UserRepository {
override suspend fun getUser(id: String): Result<User> {
return try {
val userDto = remoteDataSource.getUser(id)
val user = mapper.mapToDomain(userDto)
Result.success(user)
} catch (e: Exception) {
Result.failure(e)
}
}
}
Presentation Layer
- Contains UI components (Activities, Fragments)
- Implements MVVM with ViewModels
- Observes data from domain layer and updates UI
class UserViewModel(private val getUserUseCase: GetUserUseCase) : ViewModel() {
private val _userState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val userState = _userState.asStateFlow()
fun loadUser(id: String) {
viewModelScope.launch {
_userState.value = UserUiState.Loading
getUserUseCase(id)
.onSuccess { user ->
_userState.value = UserUiState.Success(user)
}
.onFailure { error ->
_userState.value = UserUiState.Error(error.message ?: "Unknown error")
}
}
}
}
Implementation Approaches and Trade-offs
Benefits
- Testability: Isolated layers are easier to unit test
- Maintainability: Clear separation reduces unexpected side effects
- Flexibility: Swap implementations without affecting other layers
- Independent development: Teams can work on different layers simultaneously
Challenges
- Boilerplate code: Multiple models and mappers across layers
- Learning curve: Requires understanding of architectural concepts
- Overhead: May be excessive for small applications
- Performance: Extra mapping between layers can impact performance
Pragmatic Approaches
- Start simple: Implement a lite version for smaller projects
- Use Kotlin features: Sealed classes for UI states, coroutines for async work
- Modularize gradually: Begin with package-level separation before moving to modules
- Focus on domain first: Design your business logic before implementation details
Jetpack Compose and Architecture Patterns
How Compose Changes the Architecture Landscape
- Declarative UI paradigm fundamentally changes view implementation
- Built with state-driven design principles from the ground up
- Blurs traditional boundaries between View and ViewModel layers
- Naturally aligns with unidirectional data flow patterns
Compose with MVVM
- Ideal pairing as both are state-centric
- ViewModel exposes state via StateFlow
- Compose UI consumes and renders this state reactively
- No need for data binding library as Compose handles binding natively
@Composable
fun NotesScreen(viewModel: NotesViewModel = viewModelProvider()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when {
uiState.isLoading -> LoadingIndicator()
uiState.error != null -> ErrorMessage(uiState.error)
else -> NotesList(
notes = uiState.notes,
onNoteClick = { viewModel.selectNote(it) },
onAddClick = { viewModel.showAddNoteDialog() }
)
}
}
Compose with MVI
- Natural fit with Compose's state-driven approach
- Compose UI acts as the View layer in MVI
- Side effects can be handled via Flow/Channel and LaunchedEffect
- Reduced boilerplate compared to XML-based MVI implementations
State Hoisting in Compose
- Pattern specific to Compose architecture
- Separates stateful and stateless composables
- Enables better reusability and testability
- Aligns with component-based architecture approaches
Testing Advantages with Compose
- Easier UI testing without complex view hierarchies
- Preview annotations for visual component testing
- State-based testing rather than view-based assertions
- Reduced need for UI instrumentation tests
Architecture Recommendation for Compose Apps
- Use MVVM or MVI for overall architecture
- Apply state hoisting within the Compose UI layer
- Consider UI state reducers for complex screens
- Maintain clear boundaries between UI and business logic
Preparing for a Mobile Interview?
Join my community to get all the interview prep materials delivered to you as soon as they are published - completely FREE, always!
No spam. Unsubscribe anytime.