Overview
TecMeli implements Clean Architecture principles as defined by Robert C. Martin (Uncle Bob). This architecture divides the application into distinct layers with clear dependencies flowing inward toward the domain layer.
The Dependency Rule
Core Principle : Dependencies only point inward. Inner layers know nothing about outer layers.
Dependency Flow
UI Layer depends on Domain Layer
Data Layer depends on Domain Layer
Domain Layer has no dependencies (pure Kotlin)
Layer Details
Domain Layer (Core Business Logic)
Package : domain/
Characteristics :
Framework-independent
Contains pure Kotlin code
No Android dependencies
Defines interfaces (contracts)
Houses business entities
Components :
1. Domain Models
Pure Kotlin data classes representing business entities.
// domain/model/Product.kt
data class Product (
val id: String ,
val title: String ,
val thumbnail: String ? = null ,
val domainId: String ? = null ,
val lastUpdated: String ? = null ,
val attributes: List < ProductAttribute > = emptyList ()
)
Why it matters : Domain models are technology-agnostic. They don’t use @SerializedName or any framework-specific annotations.
2. Repository Interfaces
Contracts defining how to access data, implemented by the data layer.
// domain/repository/ProductRepository.kt
interface ProductRepository {
suspend fun searchProducts (query: String ): Result < List < Product >>
suspend fun getProductDetail (id: String ): Result < ProductDetail >
}
The domain layer defines what data operations exist, not how they’re implemented.
3. Use Cases
Encapsulate single business operations with specific logic.
// domain/usecase/GetProductsUseCase.kt
class GetProductsUseCase @Inject constructor (
private val repository: ProductRepository
) {
suspend operator fun invoke (query: String ): Result < List < Product >> {
// Business logic: validate query before searching
return if (query. isBlank ()) {
Result. success ( emptyList ())
} else {
repository. searchProducts (query)
}
}
}
Key aspects :
Single Responsibility: Each use case does one thing
Operator function invoke() allows calling like a function: useCase(query)
Contains validation and business rules
Depends only on repository interface, not implementation
Data Layer (Data Access Implementation)
Package : data/
Responsibilities :
Implement repository interfaces from domain
Interact with external data sources (APIs, databases)
Transform external data to domain models
Components :
1. Repository Implementations
// data/repository/ProductRepositoryImpl.kt
class ProductRepositoryImpl @Inject constructor (
private val meliApi: MeliApi
) : ProductRepository {
override suspend fun searchProducts (query: String ): Result < List < Product >> = safeApiCall (
call = { meliApi. searchProducts (query = query) },
transform = { body -> body.results. map { it. toDomain () } }
)
}
Notice :
Implements the interface from domain
Uses MeliApi (a detail of implementation)
Transforms DTOs to domain models
2. Data Transfer Objects (DTOs)
External data structures from the API.
// data/remote/dto/ResultsDto.kt
data class ResultsDto (
@SerializedName ( "id" ) var id: String ,
@SerializedName ( "name" ) var name: String ,
@SerializedName ( "pictures" ) var pictures: ArrayList < PicturesDto > = arrayListOf (),
// ... more API-specific fields
)
Why separate from domain models?
API structure can change without affecting business logic
Different naming conventions (snake_case vs camelCase)
API may have fields we don’t need in the domain
3. Mappers
Convert DTOs to domain models.
// data/mapper/ProductMapper.kt
fun ResultsDto . toDomain (): Product {
return Product (
id = this .id,
title = this .name, // API uses "name", domain uses "title"
thumbnail = this .pictures. firstOrNull ()?.url,
domainId = this .domainId,
lastUpdated = this .lastUpdated
)
}
Mappers are extension functions, making conversions clean and readable: dto.toDomain()
4. Remote Data Sources
Retrofit API interfaces.
// data/remote/api/MeliApi.kt
interface MeliApi {
@GET ( "products/search" )
suspend fun searchProducts (
@Query ( "status" ) status: String = "active" ,
@Query ( "site_id" ) siteId: String = "MCO" ,
@Query ( "q" ) query: String
): Response < SearchResponseDto >
}
UI Layer (Presentation)
Package : ui/
Responsibilities :
Display data to users
Handle user interactions
Observe ViewModel state
Components :
1. Screens (Composables)
// ui/screen/home/HomeScreen.kt
@Composable
fun HomeScreen (
navigateToDetail: ( String ) -> Unit ,
viewModel: HomeViewModel = hiltViewModel ()
) {
val uiState by viewModel.uiState. collectAsState ()
when ( val state = uiState) {
is UiState.Loading -> CircularProgressIndicator ()
is UiState.Success -> ProductList (products = state. data )
is UiState.Error -> ErrorState (error = state.exception)
is UiState.Empty -> Text ( "Start searching" )
}
}
2. ViewModels
// ui/screen/home/HomeViewModel.kt
@HiltViewModel
class HomeViewModel @Inject constructor (
private val getProductsUseCase: GetProductsUseCase
) : ViewModel () {
private val _uiState = MutableStateFlow < UiState < List < Product >>>(UiState.Empty)
val uiState: StateFlow < UiState < List < Product >>> = _uiState. asStateFlow ()
fun searchProducts (query: String ) {
viewModelScope. launch {
_uiState. value = UiState.Loading
getProductsUseCase (query)
. onSuccess { products -> _uiState. value = UiState. Success (products) }
. onFailure { error -> _uiState. value = UiState. Error ( .. .) }
}
}
}
Key points :
Depends on use cases, not repositories
Manages UI state through StateFlow
Launches coroutines in viewModelScope
Annotated with @HiltViewModel for dependency injection
Practical Example: Search Flow
Let’s trace a complete search operation through all layers:
User Types Query
User types “laptop” in the search bar
UI Calls ViewModel
// HomeScreen.kt
SearchBar (
onSearch = { query ->
viewModel. searchProducts (query)
}
)
ViewModel Invokes Use Case
// HomeViewModel.kt
fun searchProducts (query: String ) {
viewModelScope. launch {
_uiState. value = UiState.Loading
getProductsUseCase (query) // <- Use case
. onSuccess { .. . }
}
}
Use Case Validates & Calls Repository
// GetProductsUseCase.kt
suspend operator fun invoke (query: String ): Result < List < Product >> {
return if (query. isBlank ()) {
Result. success ( emptyList ())
} else {
repository. searchProducts (query) // <- Repository interface
}
}
Repository Fetches from API
// ProductRepositoryImpl.kt
override suspend fun searchProducts (query: String ): Result < List < Product >> = safeApiCall (
call = { meliApi. searchProducts (query = query) }, // <- API call
transform = { body -> body.results. map { it. toDomain () } } // <- Mapping
)
API Returns DTOs
// MeliApi.kt
@GET ( "products/search" )
suspend fun searchProducts (
@Query ( "q" ) query: String
): Response < SearchResponseDto > // <- DTO response
Mapper Converts to Domain
// ProductMapper.kt
fun ResultsDto . toDomain (): Product {
return Product (
id = this .id,
title = this .name,
thumbnail = this .pictures. firstOrNull ()?.url
)
}
Result Flows Back to ViewModel
ViewModel receives Result<List<Product>> (domain models)
ViewModel Updates State
. onSuccess { products ->
_uiState. value = UiState. Success (products)
}
UI Recomposes
when ( val state = uiState) {
is UiState.Success -> ProductList (products = state. data )
}
Benefits in TecMeli
Easy to Test Domain layer can be tested without Android framework. Use cases and repositories are easily mockable.
Framework Independence Business logic in domain layer works with any UI framework. Could switch from Compose to Views without changing domain.
API Changes Isolated If Mercado Libre API changes, only DTOs and mappers need updates. Domain models stay stable.
Clear Responsibilities Each layer has one job. Easy to know where to add new code or fix bugs.
Testing Strategy
Domain Layer Tests
// GetProductsUseCaseTest.kt
class GetProductsUseCaseTest {
@Test
fun `returns empty list when query is blank` () = runTest {
val mockRepository = mockk < ProductRepository >()
val useCase = GetProductsUseCase (mockRepository)
val result = useCase ( "" ) // Blank query
assertTrue (result.isSuccess)
assertEquals ( emptyList (), result. getOrNull ())
verify (exactly = 0 ) { mockRepository. searchProducts ( any ()) } // Never called
}
}
No Android dependencies needed!
Data Layer Tests
// ProductRepositoryImplTest.kt
class ProductRepositoryImplTest {
@Test
fun `searchProducts maps DTOs to domain models` () = runTest {
val mockApi = mockk < MeliApi >()
val repository = ProductRepositoryImpl (mockApi)
coEvery { mockApi. searchProducts ( any ()) } returns Response. success (
SearchResponseDto (results = listOf (mockResultDto))
)
val result = repository. searchProducts ( "laptop" )
assertTrue (result.isSuccess)
assertEquals ( "Product Title" , result. getOrNull ()?. first ()?.title)
}
}
Anti-Patterns Avoided
Don’t bypass layers : UI should never access repositories directly. Always go through use cases.
Don’t leak DTOs to domain : Domain models should never depend on @SerializedName or other API-specific annotations.
Don’t put business logic in ViewModels : ViewModels orchestrate, use cases contain logic.
Further Reading
MVVM Pattern Learn how MVVM complements Clean Architecture in TecMeli
Dependency Injection See how Hilt wires all layers together