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)
}
}
}
}
}