Kotlin Extension Functions & Clean Architecture — Android Best Practices
Kotlin's extension functions are a powerful feature that lets you add new functionality to existing classes without inheritance. When combined with Clean Architecture principles, they become essential tools for building maintainable, scalable Android applications.
Part 1: Kotlin Extension Functions Fundamentals
Extension functions allow you to call new functions on an object as if they were members of the original class, despite not owning that class's source code.
Basic Syntax
// Define an extension function
fun String.isValidEmail(): Boolean {
return this.contains("@") && this.contains(".")
}
// Use it like a member
val email = "user@example.com"
println(email.isValidEmail()) // true
Extension functions are resolved statically at compile time based on the type declared in the code, not the runtime type. This is important for architecture decisions.
Part 2: Context Extensions — The Foundation
The Context type is pervasive in Android. Extension functions on Context provide convenient utilities throughout your app.
Toast Extensions
// Extension on Context
fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(this, message, duration).show()
}
// In Activity or Fragment
showToast("Success!")
Density Extensions
fun Context.dpToPx(dp: Int): Int {
return (dp * resources.displayMetrics.density).toInt()
}
fun Context.pxToDp(px: Int): Int {
return (px / resources.displayMetrics.density).toInt()
}
// Usage
val padding = dpToPx(16) // 16 dp converted to pixels
Network Connectivity Check
fun Context.isNetworkAvailable(): Boolean {
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
return when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true
else -> false
}
}
// In ViewModel or UseCase
if (context.isNetworkAvailable()) {
fetchData()
}
Part 3: Modifier Extensions — Compose Conditional Logic
In Jetpack Compose, extension functions on Modifier enable clean conditional styling.
fun Modifier.conditional(condition: Boolean, modifier: @Composable Modifier.() -> Modifier): Modifier {
return if (condition) {
then(modifier(Modifier))
} else {
this
}
}
// Usage in composables
Button(
modifier = Modifier
.padding(16.dp)
.conditional(isSelected) {
background(Color.Blue).border(2.dp, Color.Black)
}
.size(100.dp)
) {
Text("Click me")
}
Part 4: Flow Extensions — Advanced Reactive Patterns
Extension functions on Flow provide reusable reactive operators.
fun <T> Flow<T>.retryWithDelay(
maxRetries: Int = 3,
delayMillis: Long = 1000,
backoffMultiplier: Float = 2f
): Flow<T> = retryWhen { cause, attempt ->
if (attempt < maxRetries) {
emit("Retry ${attempt + 1}/$maxRetries after ${delayMillis}ms")
delay((delayMillis * (backoffMultiplier.pow(attempt))).toLong())
true
} else {
false
}
}
// Usage in Repository or UseCase
fun fetchUserProfile(userId: String): Flow<User> {
return flow {
emit(api.getUser(userId))
}.retryWithDelay(maxRetries = 3, delayMillis = 2000)
}
Part 5: Date Formatting Extensions
Reusable date formatting keeps your codebase DRY (Don't Repeat Yourself).
fun LocalDateTime.formatForDisplay(): String {
val formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm")
return this.format(formatter)
}
fun Long.toFormattedDate(): String {
val instant = Instant.ofEpochMilli(this)
val localDateTime = instant.atZone(ZoneId.systemDefault()).toLocalDateTime()
return localDateTime.formatForDisplay()
}
// In ViewModel
val createdAt = 1708990800000L
val displayText = createdAt.toFormattedDate() // "Feb 26, 2024 10:00"
Part 6: Clean Architecture for Android
Clean Architecture separates your app into independent layers with clear dependency rules: outer layers depend on inner layers, never the reverse.
3-Layer Architecture
┌─────────────────────────────────────┐
│ Presentation Layer (UI) │
│ Activity, Fragment, ViewModel │
└────────────┬────────────────────────┘
│ depends on
┌────────────▼────────────────────────┐
│ Domain Layer (Business) │
│ UseCase, Entity, Repository Intf │
└────────────┬────────────────────────┘
│ depends on
┌────────────▼────────────────────────┐
│ Data Layer (Source) │
│ Repository Impl, DAO, API Client │
└─────────────────────────────────────┘
Rules:
- Presentation → Domain → Data (inner only)
- Domain never imports from Data or Presentation
- Each layer has DTOs for transformation at boundaries
Repository Pattern
The Repository abstracts data sources (API, database, cache).
// Domain Layer: abstraction
interface UserRepository {
fun getUserById(userId: String): Flow<Result<User>>
suspend fun updateUser(user: User): Result<Unit>
}
// Data Layer: implementation
class UserRepositoryImpl(
private val apiClient: ApiClient,
private val userDao: UserDao
) : UserRepository {
override fun getUserById(userId: String): Flow<Result<User>> = flow {
try {
// Try network first
val networkUser = apiClient.getUser(userId)
userDao.insert(networkUser.toEntity())
emit(Result.success(networkUser.toDomain()))
} catch (e: Exception) {
// Fallback to cache
val cachedUser = userDao.getUserById(userId)
if (cachedUser != null) {
emit(Result.success(cachedUser.toDomain()))
} else {
emit(Result.failure(e))
}
}
}.retryWithDelay(maxRetries = 2)
override suspend fun updateUser(user: User) = try {
apiClient.updateUser(user.toNetwork())
userDao.insert(user.toEntity())
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
UseCase with operator invoke()
UseCases encapsulate business logic and make code testable.
// Domain Layer
class GetUserProfileUseCase(
private val userRepository: UserRepository,
private val analyticsRepository: AnalyticsRepository
) {
operator fun invoke(userId: String): Flow<Result<User>> {
return userRepository.getUserById(userId)
.onEach { result ->
if (result.isSuccess) {
analyticsRepository.logEvent("user_profile_fetched")
}
}
}
}
// Usage in ViewModel (Presentation)
class UserProfileViewModel(
private val getUserProfileUseCase: GetUserProfileUseCase
) : ViewModel() {
val userProfile: StateFlow<Result<User>> = getUserProfileUseCase(userId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = Result.loading()
)
}
The operator fun invoke() allows calling the UseCase like a function: getUserProfileUseCase(userId).
ViewModel Integration
ViewModels are the bridge between Presentation and Domain layers.
class UserListViewModel(
private val getUsersUseCase: GetUsersUseCase,
private val deleteUserUseCase: DeleteUserUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
init {
loadUsers()
}
private fun loadUsers() {
viewModelScope.launch {
getUsersUseCase()
.catch { _uiState.value = UiState.Error(it.message ?: "Unknown error") }
.collect { users ->
_uiState.value = UiState.Success(users)
}
}
}
fun deleteUser(userId: String) {
viewModelScope.launch {
deleteUserUseCase(userId)
.onSuccess {
loadUsers() // Refresh
}
.onFailure { error ->
_uiState.value = UiState.Error(error.message ?: "Delete failed")
}
}
}
}
sealed class UiState {
object Loading : UiState()
data class Success(val users: List<User>) : UiState()
data class Error(val message: String) : UiState()
}
Typical File Structure
com.example.app/
├── presentation/
│ ├── screens/
│ │ ├── users/
│ │ │ ├── UserListScreen.kt
│ │ │ ├── UserDetailScreen.kt
│ │ │ └── UserListViewModel.kt
│ │ └── settings/
│ │ └── SettingsScreen.kt
│ └── components/
│ └── UserCard.kt
├── domain/
│ ├── usecase/
│ │ ├── GetUsersUseCase.kt
│ │ ├── DeleteUserUseCase.kt
│ │ └── UpdateUserUseCase.kt
│ ├── repository/
│ │ ├── UserRepository.kt
│ │ └── AnalyticsRepository.kt
│ └── model/
│ └── User.kt
└── data/
├── repository/
│ ├── UserRepositoryImpl.kt
│ └── AnalyticsRepositoryImpl.kt
├── api/
│ ├── ApiClient.kt
│ └── dto/
│ └── UserDto.kt
├── local/
│ ├── UserDao.kt
│ └── AppDatabase.kt
└── mapper/
└── UserMapper.kt
Dependency Direction
UserListScreen (Presentation)
↓ imports
UserListViewModel (Presentation)
↓ imports
GetUsersUseCase (Domain)
↓ imports (interface only)
UserRepository (Domain)
↓ implements
UserRepositoryImpl (Data)
↓ imports
UserDao, ApiClient (Data)
Never reverse: Domain never imports Presentation or Data classes.
When to Skip the Domain Layer
For simple apps, Domain layer can be minimal:
// Minimal: ViewModel directly uses Repository
class SimpleViewModel(
private val userRepository: UserRepository
) : ViewModel() {
val users = userRepository.getUsers().stateIn(...)
}
Skip Domain if:
- No shared business logic between screens
- 1-2 data sources per entity
- No complex transformations
- Single-developer projects with clear scope
Keep Domain if:
- Multiple screens share logic (e.g., validation, filtering)
- 3+ data sources (API, DB, cache, preferences)
- Team collaboration / long-term maintenance
- Testability is critical
Pro Tips
- Extension functions + sealed classes = powerful error handling
sealed class Result<T>
fun <T> Result<T>.getOrNull(): T? = (this as? Result.Success)?.data
- Scope extension functions to packages to avoid pollution
// In package com.example.ui.extensions
fun Context.uiShowToast(...) { }
// In package com.example.domain.extensions
fun <T> Flow<T>.businessRetry(...) { }
- Test extension functions independently
@Test
fun testDpToPx() {
val context = ApplicationProvider.getApplicationContext<Context>()
assertEquals(32, context.dpToPx(16))
}
Conclusion
Kotlin extension functions reduce boilerplate and improve readability when combined with Clean Architecture:
- Context extensions handle platform concerns
- Flow extensions encapsulate reactive patterns
- Clean Architecture ensures long-term maintainability
- UseCases with invoke() make business logic testable
The result: code that's concise, testable, and scales with your team.
Want ready-made Android app templates with Clean Architecture pre-configured? Check out our collection: 8 Android App Templates → https://myougatheaxo.gumroad.com
Start building better Android apps today! 🚀
Top comments (0)