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.

⚠️
This is NOT an in-depth guide about any Android Architecture or Design Pattern

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

  1. User interacts with View
  2. View forwards events to Presenter
  3. Presenter manipulates Model and determines View updates
  4. Presenter instructs View to update itself
  5. 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
  • 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