Clean Architecture, proposta por Robert C. Martin, e uma abordagem de organizacao de software que prioriza a separacao de responsabilidades e a independencia de frameworks, banco de dados e interfaces externas. Quando aplicada com Kotlin, a Clean Architecture se torna especialmente elegante gracas a recursos como sealed classes, extension functions, data classes e coroutines. Neste guia, vamos implementar Clean Architecture tanto para Android quanto para backend, com exemplos concretos que voce pode aplicar diretamente nos seus projetos.

Principios Fundamentais

A Clean Architecture se baseia na Regra de Dependencia: as camadas internas nao devem conhecer nada sobre as camadas externas. O codigo-fonte so pode apontar para dentro, nunca para fora. Isso significa que a logica de negocio nao depende de frameworks, banco de dados ou APIs externas.

As camadas tipicas sao, de dentro para fora: Domain (entidades e use cases), Data (repositorios e fontes de dados) e Presentation (UI e ViewModels). Cada camada comunica-se com a adjacente atraves de interfaces definidas na camada mais interna.

Estrutura de Modulos

Em projetos maiores, cada camada pode ser um modulo Gradle separado:

// settings.gradle.kts
include(":app")         // Presentation + DI
include(":domain")      // Entidades + Use Cases + Interfaces
include(":data")        // Implementacoes de Repositorios

// domain/build.gradle.kts - Kotlin puro, sem dependencias Android
plugins {
    kotlin("jvm")
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}

// data/build.gradle.kts
dependencies {
    implementation(project(":domain"))
    implementation("io.ktor:ktor-client-core:2.3.7")
    implementation("androidx.room:room-ktx:2.6.1")
}

// app/build.gradle.kts
dependencies {
    implementation(project(":domain"))
    implementation(project(":data"))
}

Camada Domain

A camada Domain e o coracao da aplicacao. Ela contem entidades, use cases e interfaces de repositorio. Nao possui nenhuma dependencia de framework:

// Entidade de dominio
data class Pedido(
    val id: Long,
    val clienteId: Long,
    val itens: List<ItemPedido>,
    val status: StatusPedido,
    val criadoEm: LocalDateTime
) {
    val valorTotal: BigDecimal
        get() = itens.fold(BigDecimal.ZERO) { acc, item ->
            acc + (item.precoUnitario * BigDecimal(item.quantidade))
        }

    fun podeSerCancelado(): Boolean {
        return status == StatusPedido.PENDENTE ||
               status == StatusPedido.CONFIRMADO
    }
}

data class ItemPedido(
    val produtoId: Long,
    val nomeProduto: String,
    val quantidade: Int,
    val precoUnitario: BigDecimal
)

enum class StatusPedido {
    PENDENTE, CONFIRMADO, EM_PREPARO, ENVIADO, ENTREGUE, CANCELADO
}

Interfaces de Repositorio

// Definida no Domain, implementada no Data
interface PedidoRepository {
    suspend fun buscarPorId(id: Long): Pedido?
    suspend fun buscarPorCliente(clienteId: Long): List<Pedido>
    suspend fun salvar(pedido: Pedido): Pedido
    suspend fun atualizar(pedido: Pedido): Pedido
    fun observarPedidosDoCliente(clienteId: Long): Flow<List<Pedido>>
}

interface ProdutoRepository {
    suspend fun buscarPorId(id: Long): Produto?
    suspend fun verificarEstoque(produtoId: Long): Int
}

Use Cases

Use cases encapsulam regras de negocio especificas. Cada use case tem uma unica responsabilidade:

class CriarPedidoUseCase(
    private val pedidoRepository: PedidoRepository,
    private val produtoRepository: ProdutoRepository
) {
    suspend operator fun invoke(
        clienteId: Long,
        itens: List<ItemPedidoRequest>
    ): Result<Pedido> {
        // Validacao de negocio
        if (itens.isEmpty()) {
            return Result.failure(
                NegocioException("Pedido deve ter pelo menos um item")
            )
        }

        // Verificar estoque
        for (item in itens) {
            val estoque = produtoRepository.verificarEstoque(item.produtoId)
            if (estoque < item.quantidade) {
                return Result.failure(
                    NegocioException(
                        "Estoque insuficiente para produto ${item.produtoId}"
                    )
                )
            }
        }

        // Buscar informacoes dos produtos
        val itensCompletos = itens.map { item ->
            val produto = produtoRepository.buscarPorId(item.produtoId)
                ?: return Result.failure(
                    NegocioException("Produto ${item.produtoId} nao encontrado")
                )
            ItemPedido(
                produtoId = produto.id,
                nomeProduto = produto.nome,
                quantidade = item.quantidade,
                precoUnitario = produto.preco
            )
        }

        val pedido = Pedido(
            id = 0,
            clienteId = clienteId,
            itens = itensCompletos,
            status = StatusPedido.PENDENTE,
            criadoEm = LocalDateTime.now()
        )

        val pedidoSalvo = pedidoRepository.salvar(pedido)
        return Result.success(pedidoSalvo)
    }
}

class CancelarPedidoUseCase(
    private val pedidoRepository: PedidoRepository
) {
    suspend operator fun invoke(pedidoId: Long): Result<Pedido> {
        val pedido = pedidoRepository.buscarPorId(pedidoId)
            ?: return Result.failure(
                NegocioException("Pedido $pedidoId nao encontrado")
            )

        if (!pedido.podeSerCancelado()) {
            return Result.failure(
                NegocioException(
                    "Pedido no status ${pedido.status} nao pode ser cancelado"
                )
            )
        }

        val pedidoCancelado = pedido.copy(status = StatusPedido.CANCELADO)
        val atualizado = pedidoRepository.atualizar(pedidoCancelado)
        return Result.success(atualizado)
    }
}

class ObservarPedidosUseCase(
    private val pedidoRepository: PedidoRepository
) {
    operator fun invoke(clienteId: Long): Flow<List<Pedido>> {
        return pedidoRepository.observarPedidosDoCliente(clienteId)
    }
}

O uso de operator fun invoke permite chamar o use case como uma funcao: criarPedido(clienteId, itens).

Camada Data

A camada Data implementa as interfaces definidas no Domain:

class PedidoRepositoryImpl(
    private val apiService: PedidoApiService,
    private val pedidoDao: PedidoDao,
    private val mapper: PedidoMapper
) : PedidoRepository {

    override suspend fun buscarPorId(id: Long): Pedido? {
        // Tenta cache local primeiro
        val local = pedidoDao.buscarPorId(id)
        if (local != null) {
            return mapper.entityToDomain(local)
        }

        // Busca na API
        return try {
            val remoto = apiService.buscarPedido(id)
            pedidoDao.inserir(mapper.dtoToEntity(remoto))
            mapper.dtoToDomain(remoto)
        } catch (e: Exception) {
            null
        }
    }

    override suspend fun buscarPorCliente(clienteId: Long): List<Pedido> {
        return try {
            val remotos = apiService.buscarPedidosCliente(clienteId)
            val entidades = remotos.map { mapper.dtoToEntity(it) }
            pedidoDao.inserirTodos(entidades)
            remotos.map { mapper.dtoToDomain(it) }
        } catch (e: Exception) {
            // Fallback para cache local
            pedidoDao.buscarPorCliente(clienteId)
                .map { mapper.entityToDomain(it) }
        }
    }

    override suspend fun salvar(pedido: Pedido): Pedido {
        val dto = mapper.domainToDto(pedido)
        val resposta = apiService.criarPedido(dto)
        pedidoDao.inserir(mapper.dtoToEntity(resposta))
        return mapper.dtoToDomain(resposta)
    }

    override suspend fun atualizar(pedido: Pedido): Pedido {
        val dto = mapper.domainToDto(pedido)
        val resposta = apiService.atualizarPedido(pedido.id, dto)
        pedidoDao.atualizar(mapper.dtoToEntity(resposta))
        return mapper.dtoToDomain(resposta)
    }

    override fun observarPedidosDoCliente(clienteId: Long): Flow<List<Pedido>> {
        return pedidoDao.observarPorCliente(clienteId)
            .map { entidades -> entidades.map { mapper.entityToDomain(it) } }
    }
}

Camada Presentation

A camada Presentation contem ViewModels que utilizam os use cases:

class PedidoViewModel(
    private val criarPedido: CriarPedidoUseCase,
    private val cancelarPedido: CancelarPedidoUseCase,
    private val observarPedidos: ObservarPedidosUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow<PedidoUiState>(PedidoUiState.Loading)
    val uiState: StateFlow<PedidoUiState> = _uiState.asStateFlow()

    fun carregarPedidos(clienteId: Long) {
        viewModelScope.launch {
            observarPedidos(clienteId).collect { pedidos ->
                _uiState.value = PedidoUiState.Success(pedidos)
            }
        }
    }

    fun realizarPedido(clienteId: Long, itens: List<ItemPedidoRequest>) {
        viewModelScope.launch {
            _uiState.value = PedidoUiState.Loading
            criarPedido(clienteId, itens)
                .onSuccess {
                    _uiState.value = PedidoUiState.PedidoCriado(it)
                }
                .onFailure {
                    _uiState.value = PedidoUiState.Error(it.message ?: "Erro")
                }
        }
    }

    fun cancelar(pedidoId: Long) {
        viewModelScope.launch {
            cancelarPedido(pedidoId)
                .onFailure {
                    _uiState.value = PedidoUiState.Error(it.message ?: "Erro")
                }
        }
    }
}

sealed class PedidoUiState {
    object Loading : PedidoUiState()
    data class Success(val pedidos: List<Pedido>) : PedidoUiState()
    data class PedidoCriado(val pedido: Pedido) : PedidoUiState()
    data class Error(val mensagem: String) : PedidoUiState()
}

Boas Praticas para Clean Architecture com Kotlin

  • Mantenha o Domain puro: sem dependencias de framework, banco de dados ou bibliotecas de terceiros. Apenas Kotlin puro e coroutines.
  • Use interfaces para inversao de dependencia: o Domain define contratos; o Data implementa.
  • Um use case por acao: cada use case deve resolver exatamente um problema de negocio.
  • Use Result ou sealed classes para retornos: evite lancaar excecoes para fluxos de negocio previstos.
  • Mapeie entre camadas: cada camada deve ter seus proprios modelos de dados. Nao passe entidades do Room para a UI.
  • Injete dependencias: use Koin ou Hilt para conectar as camadas sem acoplamento direto.
  • Teste o Domain independentemente: use cases devem ser testados sem emulador, banco de dados ou rede.

Erros Comuns e Armadilhas

  • Anemic use cases: use cases que apenas delegam para o repositorio sem adicionar logica sao sinais de que a arquitetura esta sendo aplicada mecanicamente. Simplifique quando nao ha regra de negocio real.
  • Domain conhecendo o framework: se o Domain importa classes do Android, Room ou Retrofit, a separacao esta quebrada.
  • Mapeamento excessivo: para projetos pequenos, tres modelos para a mesma entidade (Domain, Entity, DTO) pode ser overkill. Avalie a complexidade do projeto.
  • Ignorar a testabilidade: se voce nao esta testando os use cases isoladamente, perde o principal beneficio da Clean Architecture.
  • Camadas desnecessarias: nem todo projeto precisa de Clean Architecture completa. Para projetos simples, MVVM pode ser suficiente. A arquitetura deve servir ao projeto, nao o contrario.

Conclusao e Proximos Passos

Clean Architecture com Kotlin proporciona uma base solida para projetos que precisam escalar e evoluir ao longo do tempo. A separacao em camadas facilita testes, manutencao e trabalho em equipe. No entanto, aplique-a com pragmatismo: projetos menores podem se beneficiar de abordagens mais simples. Para aprofundar, explore injecao de dependencia com Koin e Hilt, padroes de design complementares e consulte nossos guias sobre testes e MVVM para uma visao completa da arquitetura de aplicacoes Kotlin.