Skip to main content

Overview

TecMeli’s network layer is built on Clean Architecture principles, providing a safe, testable, and maintainable approach to API communication. The architecture separates concerns into distinct components, each with a single responsibility.

Core Components

SafeApiCallExecutor

Orchestrates safe API calls with error handling

HttpResponseHandler

Validates and transforms HTTP responses

ErrorMapper

Maps exceptions to domain errors

SafeApiCallExecutor

The SafeApiCallExecutor is the core orchestrator for all network operations. It handles:
  • Executing API calls on the IO dispatcher
  • Catching and mapping exceptions
  • Logging errors for debugging
  • Wrapping results in Kotlin’s Result type
class SafeApiCallExecutor(
    private val responseHandler: HttpResponseHandler,
    private val errorMapper: ErrorMapper,
    private val logger: NetworkLogger
) {
    suspend fun <T, R> execute(
        call: suspend () -> Response<T>,
        transform: (T) -> R
    ): Result<R> = withContext(Dispatchers.IO) {
        try {
            val response = call()
            val result = responseHandler.handleResponse(response, transform)

            if (result.isFailure && response.isSuccessful.not()) {
                val error = result.exceptionOrNull()
                if (error is AppError.Server) {
                    logger.logServerError(error.code, error.message)
                }
            }

            result
        } catch (e: Exception) {
            val appError = errorMapper.mapException(e)
            logger.logUnexpectedError(appError.javaClass.simpleName, e)
            Result.failure(appError)
        }
    }
}
See core/network/executor/SafeApiCallExecutor.kt:24
All network operations automatically run on Dispatchers.IO to avoid blocking the main thread, even if called from a ViewModel coroutine.

HttpResponseHandler

The HttpResponseHandler interface defines a contract for validating and transforming HTTP responses:
interface HttpResponseHandler {
    fun <T, R> handleResponse(
        response: Response<T>,
        transform: (T) -> R
    ): Result<R>
}

DefaultHttpResponseHandler

The default implementation validates responses and handles edge cases:
class DefaultHttpResponseHandler : HttpResponseHandler {
    override fun <T, R> handleResponse(
        response: Response<T>,
        transform: (T) -> R
    ): Result<R> = when {
        response.isSuccessful -> {
            val body = response.body()
            if (body != null) {
                Result.success(transform(body))
            } else {
                Result.failure(
                    AppError.Server(response.code(), "Cuerpo de respuesta vacío")
                )
            }
        }
        else -> {
            Result.failure(
                AppError.Server(response.code(), response.message())
            )
        }
    }
}
See core/network/handler/DefaultHttpResponseHandler.kt:13
Even successful responses (2xx status codes) can have null bodies. The handler ensures this edge case is properly handled as an error.

Safe API Call Extension

For convenience, TecMeli provides a top-level extension function that simplifies API calls:
suspend fun <T, R> safeApiCall(
    call: suspend () -> Response<T>,
    transform: (T) -> R
): Result<R> {
    val executor = SafeApiCallExecutor(
        responseHandler = DefaultHttpResponseHandler(),
        errorMapper = DefaultErrorMapper(),
        logger = DefaultNetworkLogger()
    )
    return executor.execute(call, transform)
}
See core/network/NetworkExtensions.kt:24

Usage Example

Here’s how repositories use safeApiCall to communicate with the API:
class ProductRepositoryImpl @Inject constructor(
    private val api: MercadoLibreApi,
    private val mapper: ProductMapper
) : ProductRepository {

    override suspend fun searchProducts(query: String): Result<List<Product>> = 
        safeApiCall(
            call = { api.searchProducts(query) },
            transform = { dto -> dto.results.map(mapper::toDomain) }
        )
}
1

API Call

The repository invokes the Retrofit interface method
2

Response Handling

HttpResponseHandler validates the HTTP response
3

Transformation

The DTO is transformed into domain models using mappers
4

Result Wrapping

Success or failure is wrapped in Kotlin’s Result type

Error Mapping

The ErrorMapper translates low-level exceptions into domain-specific errors:
interface ErrorMapper {
    fun mapException(exception: Exception): AppError
}

class DefaultErrorMapper : ErrorMapper {
    override fun mapException(exception: Exception): AppError = when (exception) {
        is SocketTimeoutException -> AppError.Timeout()
        is IOException -> AppError.Network()
        else -> AppError.Unknown(exception)
    }
}
See:
  • core/network/mapper/ErrorMapper.kt:12
  • core/network/mapper/DefaultErrorMapper.kt:14

AppError Hierarchy

sealed class AppError : Exception() {
    class Network : AppError()
    class Timeout : AppError()
    data class Server(val code: Int, val msg: String) : AppError()
    data class Unknown(val throwable: Throwable) : AppError()
}
See core/network/AppError.kt:10
Triggered by IOException - indicates connectivity problems like no internet connection or DNS failures.
Triggered by SocketTimeoutException - indicates the server took too long to respond.
Triggered by non-2xx HTTP status codes - includes 4xx client errors and 5xx server errors.
Catch-all for unexpected exceptions - preserves the original throwable for debugging.

Network Logging

The NetworkLogger interface allows pluggable logging implementations:
interface NetworkLogger {
    fun logServerError(code: Int, message: String?)
    fun logUnexpectedError(errorType: String, exception: Exception)
}
This abstraction allows you to:
  • Log to Logcat in debug builds
  • Send to crash reporting tools (Firebase Crashlytics, Sentry) in production
  • Disable logging entirely for testing

Practical Example: Complete Flow

Let’s trace a complete API call from ViewModel to Repository:
// 1. ViewModel initiates the call
class HomeViewModel @Inject constructor(
    private val getProductsUseCase: GetProductsUseCase
) : ViewModel() {
    
    fun searchProducts(query: String) {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            
            getProductsUseCase(query)
                .onSuccess { products -> /* ... */ }
                .onFailure { error -> /* ... */ }
        }
    }
}

// 2. Use Case applies business logic
class GetProductsUseCase @Inject constructor(
    private val repository: ProductRepository
) {
    suspend operator fun invoke(query: String): Result<List<Product>> {
        return if (query.isBlank()) {
            Result.success(emptyList())
        } else {
            repository.searchProducts(query)
        }
    }
}

// 3. Repository executes the API call
class ProductRepositoryImpl @Inject constructor(
    private val api: MercadoLibreApi,
    private val mapper: ProductMapper
) : ProductRepository {
    
    override suspend fun searchProducts(query: String): Result<List<Product>> = 
        safeApiCall(
            call = { api.searchProducts(query) },
            transform = { dto -> dto.results.map(mapper::toDomain) }
        )
}

Benefits of This Architecture

Type Safety

Kotlin’s Result type provides compile-time safety for success/failure handling

Testability

All components are interfaces, making them easy to mock in tests

Separation of Concerns

Each class has a single, well-defined responsibility

Centralized Error Handling

All API errors are consistently mapped and logged

Next Steps

Error Handling

Deep dive into error mapping and user-facing messages

State Management

Learn how API results flow to the UI via UiState

Build docs developers (and LLMs) love