androidengineers.Book a session

Code Organization

Exercise: Extract Cross-Cutting Concerns

exercise45 minHard

Cross-cutting concerns — logging, analytics, error handling, performance monitoring — tend to leak into every class if not deliberately structured. This exercise extracts them into focused, reusable components.

Starting State: The Problem

// ArticleViewModel.kt — cross-cutting code mixed with business logic
class ArticleViewModel(private val api: ArticleApi) : ViewModel() {
    fun loadArticle(id: String) {
        viewModelScope.launch {
            Log.d("ArticleViewModel", "loadArticle: id=$id")  // logging
            val startTime = System.currentTimeMillis()         // timing

            try {
                val article = api.getArticle(id)
                analytics.logEvent("article_viewed", id)       // analytics
                _state.value = UiState.Success(article)
            } catch (e: IOException) {
                Log.e("ArticleViewModel", "Failed to load article", e)  // logging
                crashlytics.recordException(e)                          // crash reporting
                analytics.logEvent("article_load_failed", id)          // analytics
                _state.value = UiState.Error(e.message ?: "Unknown error")
            } finally {
                val elapsed = System.currentTimeMillis() - startTime
                Log.d("ArticleViewModel", "loadArticle: ${elapsed}ms")  // timing
            }
        }
    }
}

Problems: Every new feature copy-pastes this pattern. Changes to logging format require touching every class. Testing is hard because concerns are interleaved.

Step 1: Extract Logging via OkHttp Interceptor

Move API-level logging to OkHttp — one place for all network logging:

class NetworkLoggingInterceptor(private val logger: Logger) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        logger.d("Network", "→ ${request.method} ${request.url}")
        val start = System.nanoTime()

        val response = chain.proceed(request)

        val elapsed = (System.nanoTime() - start) / 1_000_000
        logger.d("Network", "← ${response.code} ${request.url} (${elapsed}ms)")
        return response
    }
}

Step 2: Extract Performance Monitoring via Flow Operators

// Generic timing operator for any suspend operation
suspend fun <T> trackPerformance(
    name: String,
    tracker: PerformanceTracker,
    block: suspend () -> T
): T {
    val trace = tracker.startTrace(name)
    return try {
        block().also { trace.stop() }
    } catch (e: Exception) {
        trace.stop()
        throw e
    }
}

// Usage — clean and reusable
fun loadArticle(id: String) {
    viewModelScope.launch {
        val article = trackPerformance("article_load", performanceTracker) {
            api.getArticle(id)  // clean business logic
        }
        _state.value = UiState.Success(article)
    }
}

Step 3: Extract Error Handling via a Result Wrapper

sealed class Result<out T> {
    data class Success<T>(val data: T) : Result<T>()
    data class Error(val exception: Throwable, val message: String) : Result<Nothing>()
}

// Repository layer catches and wraps
class ArticleRepository(
    private val api: ArticleApi,
    private val crashReporter: CrashReporter
) {
    suspend fun getArticle(id: String): Result<Article> = try {
        Result.Success(api.getArticle(id))
    } catch (e: IOException) {
        crashReporter.record(e)  // cross-cutting concern handled HERE, not in ViewModel
        Result.Error(e, "Network error — check your connection")
    }
}

// ViewModel is clean
class ArticleViewModel(private val repository: ArticleRepository) : ViewModel() {
    fun loadArticle(id: String) {
        viewModelScope.launch {
            when (val result = repository.getArticle(id)) {
                is Result.Success -> _state.value = UiState.Success(result.data)
                is Result.Error -> _state.value = UiState.Error(result.message)
            }
        }
    }
}

Step 4: Extract Analytics via an Event Bus

// Define events as data classes — no analytics SDK coupling
sealed class AppEvent {
    data class ArticleViewed(val articleId: String) : AppEvent()
    data class ArticleLoadFailed(val articleId: String, val reason: String) : AppEvent()
    data class SearchPerformed(val query: String, val resultCount: Int) : AppEvent()
}

// Dispatcher — the only place that knows about Firebase/Mixpanel/etc.
class AnalyticsDispatcher(private val firebaseAnalytics: FirebaseAnalytics) {
    fun dispatch(event: AppEvent) {
        when (event) {
            is AppEvent.ArticleViewed -> firebaseAnalytics.logEvent("article_viewed", bundleOf("id" to event.articleId))
            is AppEvent.ArticleLoadFailed -> firebaseAnalytics.logEvent("article_load_failed", bundleOf("id" to event.articleId))
            is AppEvent.SearchPerformed -> firebaseAnalytics.logEvent("search", bundleOf("query" to event.query, "count" to event.resultCount))
        }
    }
}

// ViewModel dispatches events — doesn't know about Firebase
class ArticleViewModel(
    private val repository: ArticleRepository,
    private val analytics: AnalyticsDispatcher
) : ViewModel() {
    fun loadArticle(id: String) {
        viewModelScope.launch {
            when (val result = repository.getArticle(id)) {
                is Result.Success -> {
                    analytics.dispatch(AppEvent.ArticleViewed(id))
                    _state.value = UiState.Success(result.data)
                }
                is Result.Error -> {
                    analytics.dispatch(AppEvent.ArticleLoadFailed(id, result.message))
                    _state.value = UiState.Error(result.message)
                }
            }
        }
    }
}

Result: Clean ViewModel

Compare the before and after — the ViewModel now contains only business logic. All cross-cutting concerns are extracted:

// AFTER: ArticleViewModel has ONE concern — coordinating the UI flow
class ArticleViewModel(
    private val getArticle: GetArticleUseCase,
    private val analytics: AnalyticsDispatcher
) : ViewModel() {
    fun loadArticle(id: String) {
        viewModelScope.launch {
            when (val result = getArticle(id)) {
                is Result.Success -> {
                    analytics.dispatch(AppEvent.ArticleViewed(id))
                    _state.value = UiState.Success(result.data)
                }
                is Result.Error -> {
                    analytics.dispatch(AppEvent.ArticleLoadFailed(id, result.message))
                    _state.value = UiState.Error(result.message)
                }
            }
        }
    }
}

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: Extract Cross-Cutting Concerns | Android System Design | Android Engineers