Skip to main content

Kotlin Android Architecture

Introduction

When building Android applications with Kotlin, having a solid architecture is essential for creating maintainable, testable, and scalable apps. Architecture refers to the organization of your code and the patterns you use to separate concerns, manage dependencies, and handle data flow.

In this guide, we'll explore modern Android architecture patterns using Kotlin, understanding why they're important, and how to implement them in your projects. By the end, you'll have a strong foundation in Android architecture principles that will help you build more robust applications.

Why Architecture Matters

Before diving into specific patterns, let's understand why architecture matters:

  • Separation of Concerns: Keeps different parts of your app isolated and focused on specific tasks
  • Testability: Makes writing unit tests easier by decoupling components
  • Maintainability: Makes code easier to understand, debug, and modify
  • Scalability: Allows your app to grow without becoming a tangled mess
  • Team Collaboration: Enables multiple developers to work on different parts simultaneously

Android Architecture Components

Google provides a set of libraries called Architecture Components that help implement robust, testable, and maintainable architecture. Let's look at the key components:

ViewModel

The ViewModel is responsible for preparing and managing data for the UI (Activity or Fragment).

kotlin
class UserProfileViewModel(
private val userRepository: UserRepository
) : ViewModel() {

// LiveData to observe user data
private val _userData = MutableLiveData<User>()
val userData: LiveData<User> = _userData

// Load user data
fun loadUserData(userId: String) {
viewModelScope.launch {
val user = userRepository.getUser(userId)
_userData.value = user
}
}
}

LiveData

LiveData is an observable data holder class that is lifecycle-aware.

kotlin
// In your ViewModel
private val _counter = MutableLiveData<Int>(0)
val counter: LiveData<Int> = _counter

fun incrementCounter() {
_counter.value = (_counter.value ?: 0) + 1
}

// In your Activity or Fragment
viewModel.counter.observe(viewLifecycleOwner) { count ->
counterTextView.text = "Count: $count"
}

Room Database

Room provides an abstraction layer over SQLite to allow for more robust database access.

kotlin
// Entity
@Entity(tableName = "users")
data class User(
@PrimaryKey val id: String,
val name: String,
val email: String,
val age: Int
)

// DAO (Data Access Object)
@Dao
interface UserDao {
@Query("SELECT * FROM users")
fun getAllUsers(): Flow<List<User>>

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: User)
}

// Database
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}

Common Architecture Patterns in Android

Now that we understand the building blocks, let's look at some common architecture patterns used in Android development with Kotlin.

1. MVVM (Model-View-ViewModel)

MVVM is one of the most popular architecture patterns in modern Android development.

Components:

  • Model: Represents the data and business logic
  • View: The UI components (Activities, Fragments)
  • ViewModel: Mediates between Model and View, prepares data for display

Implementation Example:

kotlin
// Model
data class Product(
val id: String,
val name: String,
val price: Double,
val description: String
)

// Repository
class ProductRepository(private val apiService: ApiService, private val productDao: ProductDao) {
suspend fun getProducts(): List<Product> {
return try {
val products = apiService.getProducts()
productDao.insertAll(products)
products
} catch (e: Exception) {
productDao.getAll() // Fallback to local data
}
}
}

// ViewModel
class ProductViewModel(private val repository: ProductRepository) : ViewModel() {
private val _products = MutableLiveData<List<Product>>()
val products: LiveData<List<Product>> = _products

private val _isLoading = MutableLiveData<Boolean>(false)
val isLoading: LiveData<Boolean> = _isLoading

fun fetchProducts() {
viewModelScope.launch {
_isLoading.value = true
_products.value = repository.getProducts()
_isLoading.value = false
}
}
}

// View (Fragment)
class ProductListFragment : Fragment() {
private lateinit var viewModel: ProductViewModel

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

viewModel = ViewModelProvider(this, viewModelFactory).get(ProductViewModel::class.java)

val adapter = ProductAdapter()
recyclerView.adapter = adapter

viewModel.products.observe(viewLifecycleOwner) { products ->
adapter.submitList(products)
}

viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
}

viewModel.fetchProducts()
}
}

2. Clean Architecture

Clean Architecture focuses on separating concerns into layers with explicit dependencies pointing inward.

Layers:

  • Presentation Layer: UI (Activities, Fragments) and ViewModels
  • Domain Layer: Business logic and use cases
  • Data Layer: Repositories and data sources

Directory Structure Example:

app/
├── data/
│ ├── api/
│ ├── db/
│ ├── models/
│ └── repositories/
├── domain/
│ ├── models/
│ └── usecases/
└── presentation/
├── ui/
└── viewmodels/

Implementation Example:

kotlin
// Domain Layer - Model
data class User(val id: String, val name: String, val email: String)

// Domain Layer - Use Case
class GetUserUseCase(private val userRepository: UserRepository) {
suspend operator fun invoke(userId: String): User {
return userRepository.getUser(userId)
}
}

// Data Layer - Repository Interface (in Domain Layer)
interface UserRepository {
suspend fun getUser(userId: String): User
}

// Data Layer - Repository Implementation
class UserRepositoryImpl(
private val apiService: ApiService,
private val userDao: UserDao
) : UserRepository {
override suspend fun getUser(userId: String): User {
return try {
val networkUser = apiService.getUser(userId)
userDao.insertUser(networkUser.toUserEntity())
networkUser.toDomainUser()
} catch (e: Exception) {
userDao.getUser(userId).toDomainUser()
}
}
}

// Presentation Layer - ViewModel
class UserDetailViewModel(
private val getUserUseCase: GetUserUseCase
) : ViewModel() {
private val _user = MutableLiveData<User>()
val user: LiveData<User> = _user

fun loadUser(userId: String) {
viewModelScope.launch {
try {
val user = getUserUseCase(userId)
_user.value = user
} catch (e: Exception) {
// Handle error
}
}
}
}

3. MVI (Model-View-Intent)

MVI is a newer pattern focusing on unidirectional data flow and immutable state.

Components:

  • Model: Represents the state of the UI
  • View: Renders the state and sends user actions as intents
  • Intent: Represents user actions that can change the state

Implementation Example with Flow:

kotlin
// State
data class TodoState(
val todos: List<Todo> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)

// Intent
sealed class TodoIntent {
object LoadTodos : TodoIntent()
data class AddTodo(val todo: Todo) : TodoIntent()
data class DeleteTodo(val todoId: Int) : TodoIntent()
}

// ViewModel
class TodoViewModel(private val repository: TodoRepository) : ViewModel() {
private val _state = MutableStateFlow(TodoState())
val state: StateFlow<TodoState> = _state.asStateFlow()

fun processIntent(intent: TodoIntent) {
when (intent) {
is TodoIntent.LoadTodos -> loadTodos()
is TodoIntent.AddTodo -> addTodo(intent.todo)
is TodoIntent.DeleteTodo -> deleteTodo(intent.todoId)
}
}

private fun loadTodos() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val todos = repository.getTodos()
_state.update { it.copy(todos = todos, isLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(error = e.message, isLoading = false) }
}
}
}

// Other methods for handling intents...
}

// View (Fragment)
class TodoFragment : Fragment() {
private lateinit var viewModel: TodoViewModel

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

viewModel = ViewModelProvider(this, viewModelFactory).get(TodoViewModel::class.java)

// Observe state
lifecycleScope.launch {
viewModel.state.collect { state ->
renderState(state)
}
}

// Send intents
addButton.setOnClickListener {
val todo = Todo(title = todoEditText.text.toString())
viewModel.processIntent(TodoIntent.AddTodo(todo))
}

// Load initial data
viewModel.processIntent(TodoIntent.LoadTodos)
}

private fun renderState(state: TodoState) {
progressBar.isVisible = state.isLoading
todoAdapter.submitList(state.todos)
state.error?.let { showErrorMessage(it) }
}
}

Implementing Repository Pattern

The Repository Pattern is a key element in many Android architectures. It abstracts the data sources from the rest of the app.

kotlin
// Repository interface
interface MovieRepository {
suspend fun getPopularMovies(): List<Movie>
suspend fun getMovieDetails(movieId: Int): MovieDetails
suspend fun searchMovies(query: String): List<Movie>
suspend fun saveMovieToFavorites(movie: Movie)
suspend fun getFavoriteMovies(): List<Movie>
}

// Repository implementation
class MovieRepositoryImpl(
private val movieApi: MovieApi,
private val movieDao: MovieDao
) : MovieRepository {

override suspend fun getPopularMovies(): List<Movie> {
return try {
// Try to fetch from network
val networkMovies = movieApi.getPopularMovies()

// Cache the results
movieDao.insertMovies(networkMovies.map { it.toMovieEntity() })

// Return domain models
networkMovies.map { it.toDomainMovie() }
} catch (e: Exception) {
// If network request fails, get from local database
movieDao.getAllMovies().map { it.toDomainMovie() }
}
}

// Other method implementations...
}

Dependency Injection with Hilt

Modern Android architecture often uses dependency injection with Hilt, which is built on top of Dagger.

kotlin
// Application class with Hilt
@HiltAndroidApp
class MovieApplication : Application()

// Module providing dependencies
@Module
@InstallIn(SingletonComponent::class)
object AppModule {

@Provides
@Singleton
fun provideApiService(): MovieApi {
return Retrofit.Builder()
.baseUrl("https://api.themoviedb.org/3/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(MovieApi::class.java)
}

@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"movie_database"
).build()
}

@Provides
fun provideMovieDao(database: AppDatabase): MovieDao {
return database.movieDao()
}

@Provides
@Singleton
fun provideMovieRepository(
api: MovieApi,
dao: MovieDao
): MovieRepository {
return MovieRepositoryImpl(api, dao)
}
}

// Using dependency injection in a ViewModel
@HiltViewModel
class MovieViewModel @Inject constructor(
private val movieRepository: MovieRepository
) : ViewModel() {
// ViewModel implementation
}

// Using in an Activity or Fragment
@AndroidEntryPoint
class MovieListFragment : Fragment() {
private val viewModel: MovieViewModel by viewModels()

// Fragment implementation
}

Best Practices for Android Architecture

  1. Single Source of Truth: Maintain a single source of truth for your data, typically in a repository.

  2. Separation of Concerns: Each component should have a single responsibility.

  3. Unidirectional Data Flow: Data should flow in one direction, making it easier to track and debug.

  4. Immutability: Use immutable data classes to prevent unexpected state changes.

  5. Dependency Injection: Use DI to make components more testable and reusable.

  6. Coroutines & Flow: Use Kotlin Coroutines and Flow for asynchronous operations and reactive programming.

  7. Testing: Write tests for each layer, especially the ViewModel and Repository layers.

Practical Example: Building a Weather App

Let's put everything together in a practical example of a weather app architecture:

kotlin
// Domain Models
data class WeatherInfo(
val location: String,
val temperature: Double,
val condition: String,
val humidity: Int,
val windSpeed: Double
)

// Repository Interface
interface WeatherRepository {
suspend fun getWeatherForCity(cityName: String): WeatherInfo
suspend fun getSavedLocations(): List<String>
suspend fun saveLocation(cityName: String)
}

// Repository Implementation
class WeatherRepositoryImpl(
private val weatherApi: WeatherApi,
private val weatherDao: WeatherDao
) : WeatherRepository {
override suspend fun getWeatherForCity(cityName: String): WeatherInfo {
return try {
val apiResponse = weatherApi.getWeather(cityName)

// Cache the results
weatherDao.insertWeatherData(apiResponse.toWeatherEntity())

// Return domain model
apiResponse.toWeatherInfo()
} catch (e: Exception) {
// Fallback to cached data
weatherDao.getWeatherForCity(cityName)?.toWeatherInfo()
?: throw Exception("No weather data available for $cityName")
}
}

// Other implementations...
}

// Use Case
class GetWeatherUseCase(private val repository: WeatherRepository) {
suspend operator fun invoke(cityName: String): WeatherInfo {
if (cityName.isBlank()) {
throw IllegalArgumentException("City name cannot be empty")
}
return repository.getWeatherForCity(cityName)
}
}

// ViewModel
@HiltViewModel
class WeatherViewModel @Inject constructor(
private val getWeatherUseCase: GetWeatherUseCase,
private val saveLocationUseCase: SaveLocationUseCase
) : ViewModel() {

private val _uiState = MutableStateFlow<WeatherUiState>(WeatherUiState.Loading)
val uiState: StateFlow<WeatherUiState> = _uiState.asStateFlow()

fun getWeather(cityName: String) {
viewModelScope.launch {
_uiState.value = WeatherUiState.Loading
try {
val weatherInfo = getWeatherUseCase(cityName)
_uiState.value = WeatherUiState.Success(weatherInfo)
saveLocationUseCase(cityName)
} catch (e: Exception) {
_uiState.value = WeatherUiState.Error(e.message ?: "Unknown error")
}
}
}
}

// UI State
sealed class WeatherUiState {
object Loading : WeatherUiState()
data class Success(val weatherInfo: WeatherInfo) : WeatherUiState()
data class Error(val message: String) : WeatherUiState()
}

// Fragment
@AndroidEntryPoint
class WeatherFragment : Fragment() {

private val viewModel: WeatherViewModel by viewModels()

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

searchButton.setOnClickListener {
val cityName = cityEditText.text.toString()
viewModel.getWeather(cityName)
}

lifecycleScope.launch {
viewModel.uiState.collect { state ->
when (state) {
is WeatherUiState.Loading -> {
progressBar.isVisible = true
weatherGroup.isVisible = false
errorText.isVisible = false
}
is WeatherUiState.Success -> {
progressBar.isVisible = false
weatherGroup.isVisible = true
errorText.isVisible = false

val weather = state.weatherInfo
locationText.text = weather.location
temperatureText.text = "${weather.temperature}°C"
conditionText.text = weather.condition
humidityText.text = "Humidity: ${weather.humidity}%"
windSpeedText.text = "Wind: ${weather.windSpeed} km/h"
}
is WeatherUiState.Error -> {
progressBar.isVisible = false
weatherGroup.isVisible = false
errorText.isVisible = true
errorText.text = state.message
}
}
}
}
}
}

Summary

Good architecture is essential for building maintainable and scalable Android applications with Kotlin. Throughout this guide, we've explored:

  • The importance of architecture in Android development
  • Key Android Architecture Components (ViewModel, LiveData, Room)
  • Common architecture patterns:
    • MVVM (Model-View-ViewModel)
    • Clean Architecture
    • MVI (Model-View-Intent)
  • The Repository Pattern for data management
  • Dependency Injection with Hilt
  • Best practices for Android architecture
  • A practical example of architecture in a weather app

Remember that architecture should serve your app's specific needs. Start with simple patterns and evolve as your app grows in complexity. The goal is to create maintainable code that your future self (and other developers) will thank you for.

Additional Resources

Exercise

Challenge: Build a simple to-do list app using MVVM architecture with the following features:

  1. Display a list of tasks
  2. Add new tasks
  3. Mark tasks as complete
  4. Delete tasks
  5. Persist tasks using Room database

Make sure to implement:

  • A clear separation between UI, ViewModel, and Repository layers
  • Proper error handling
  • Unit tests for at least one component
  • Use Kotlin Coroutines for asynchronous operations

Happy coding!



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)