Skip to main content

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:
1

User Types Query

User types “laptop” in the search bar
2

UI Calls ViewModel

// HomeScreen.kt
SearchBar(
    onSearch = { query -> 
        viewModel.searchProducts(query) 
    }
)
3

ViewModel Invokes Use Case

// HomeViewModel.kt
fun searchProducts(query: String) {
    viewModelScope.launch {
        _uiState.value = UiState.Loading
        getProductsUseCase(query)  // <- Use case
            .onSuccess { ... }
    }
}
4

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

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

API Returns DTOs

// MeliApi.kt
@GET("products/search")
suspend fun searchProducts(
    @Query("q") query: String
): Response<SearchResponseDto>  // <- DTO response
7

Mapper Converts to Domain

// ProductMapper.kt
fun ResultsDto.toDomain(): Product {
    return Product(
        id = this.id,
        title = this.name,
        thumbnail = this.pictures.firstOrNull()?.url
    )
}
8

Result Flows Back to ViewModel

ViewModel receives Result<List<Product>> (domain models)
9

ViewModel Updates State

.onSuccess { products ->
    _uiState.value = UiState.Success(products)
}
10

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

Build docs developers (and LLMs) love