Skip to main content

Clean Architecture Guide

Every feature built with Miru SDK follows Clean Architecture with three layers. This guide shows how to structure your code.

Layer Rules

  1. Domain has zero dependencies — only pure Kotlin
  2. Data depends on Domain (implements interfaces)
  3. Presentation depends on Domain (consumes use cases)
  4. Data and Presentation never depend on each other
Presentation → Domain ← Data

Step-by-Step: Building a Feature

1. Domain Layer

Start with the domain — define what your feature does, not how.

Model — pure business object:

data class Product(
val id: Int,
val name: String,
val price: Double,
val inStock: Boolean
)

Repository interface — contract for data access:

interface ProductRepository {
suspend fun getProducts(): Resource<List<Product>>
suspend fun getProduct(id: Int): Resource<Product>
fun observeProducts(): Flow<Resource<List<Product>>>
}

Use case — single-responsibility business operation:

class GetProductsUseCase(private val repository: ProductRepository) {
suspend operator fun invoke(): Resource<List<Product>> =
repository.getProducts()
}

class ObserveProductsUseCase(private val repository: ProductRepository) {
operator fun invoke(): Flow<Resource<List<Product>>> =
repository.observeProducts()
}

2. Data Layer

Implement the domain contracts with real data sources.

DTO — maps 1:1 to the API response:

@Serializable
data class ProductDto(
val id: Int,
val name: String,
val price: Double,
@SerialName("in_stock") val inStock: Boolean
)

Mapper — converts DTO to domain model:

class ProductMapper : Mapper<ProductDto, Product> {
override fun map(from: ProductDto) = Product(
id = from.id,
name = from.name,
price = from.price,
inStock = from.inStock
)
}

Remote data source — API calls:

class ProductApi(httpClient: HttpClient) : ApiService(httpClient) {
suspend fun getProducts(): Resource<ApiResponse<List<ProductDto>>> =
get("products")
suspend fun getProduct(id: Int): Resource<ApiResponse<ProductDto>> =
get("products/$id")
}

Repository implementation:

class ProductRepositoryImpl(
private val api: ProductApi,
private val mapper: ProductMapper
) : ProductRepository {

override suspend fun getProducts(): Resource<List<Product>> =
api.getProducts().map { response ->
response.data?.map { mapper.map(it) } ?: emptyList()
}

override suspend fun getProduct(id: Int): Resource<Product> =
api.getProduct(id).map { response ->
mapper.map(response.data!!)
}

override fun observeProducts(): Flow<Resource<List<Product>>> =
flow {
emit(getProducts())
}
}

3. Presentation Layer

Consume domain use cases in a ViewModel, render in a Composable.

ViewModel:

class ProductListViewModel(
private val getProductsUseCase: GetProductsUseCase
) : BaseViewModel<Unit, ProductEvent>(Unit) {

private val _products = MutableStateFlow<Resource<List<Product>>>(Resource.Loading())
val products = _products.asStateFlow()

init { loadProducts() }

fun loadProducts() = collectResource(_products) { getProductsUseCase() }
}

Screen:

@Composable
fun ProductListScreen(viewModel: ProductListViewModel = koinViewModel()) {
val resource by viewModel.products.collectAsStateWithLifecycle()

MiruResourceView(
resource = resource,
onRetry = { viewModel.loadProducts() }
) { products ->
LazyColumn {
items(products, key = { it.id }) { product ->
ProductCard(product)
}
}
}
}

4. DI Wiring

val productModule = module {
single { ProductMapper() }
single { ProductApi(get()) }
single<ProductRepository> { ProductRepositoryImpl(get(), get()) }
factory { GetProductsUseCase(get()) }
viewModel { ProductListViewModel(get()) }
}

Folder Structure

your-feature/
├── data/
│ ├── model/ ProductDto.kt
│ ├── mapper/ ProductMapper.kt
│ ├── source/ ProductApi.kt
│ └── repository/ ProductRepositoryImpl.kt
├── domain/
│ ├── model/ Product.kt
│ ├── repository/ ProductRepository.kt
│ └── usecase/ GetProductsUseCase.kt
└── presentation/
├── viewmodel/ ProductListViewModel.kt
└── ui/ ProductListScreen.kt

Key Principles

  • Domain is king — all business rules live here, with no framework dependencies
  • Repository pattern — domain defines the interface, data provides the implementation
  • Use cases are optional — for simple CRUD, the ViewModel can call the repository directly. Use cases shine when there's actual business logic to encapsulate.
  • DTOs stay in data — never expose API models to the presentation layer
  • Mapper per entity — keep conversions explicit and testable