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 ) }
)
}
API Call
The repository invokes the Retrofit interface method
Response Handling
HttpResponseHandler validates the HTTP response
Transformation
The DTO is transformed into domain models using mappers
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