DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Kotlin Extension Functions & Clean Architecture — Android Best Practices

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
Enter fullscreen mode Exit fullscreen mode

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!")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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   │
└─────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
        )
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Dependency Direction

UserListScreen (Presentation)
    ↓ imports
UserListViewModel (Presentation)
    ↓ imports
GetUsersUseCase (Domain)
    ↓ imports (interface only)
UserRepository (Domain)
    ↓ implements
UserRepositoryImpl (Data)
    ↓ imports
UserDao, ApiClient (Data)
Enter fullscreen mode Exit fullscreen mode

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(...)
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Extension functions + sealed classes = powerful error handling
   sealed class Result<T>
   fun <T> Result<T>.getOrNull(): T? = (this as? Result.Success)?.data
Enter fullscreen mode Exit fullscreen mode
  1. 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(...) { }
Enter fullscreen mode Exit fullscreen mode
  1. Test extension functions independently
   @Test
   fun testDpToPx() {
       val context = ApplicationProvider.getApplicationContext<Context>()
       assertEquals(32, context.dpToPx(16))
   }
Enter fullscreen mode Exit fullscreen mode

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 Templateshttps://myougatheaxo.gumroad.com

Start building better Android apps today! 🚀

Top comments (0)