Organização e qualidade do projeto Android em Kotlin: modularidade leve, padrões e revisão

Capítulo 14

Tempo estimado de leitura: 11 minutos

+ Exercício

Estrutura sustentável: por feature ou por camada

Quando o app cresce, a “qualidade” do projeto deixa de ser só código limpo e passa a ser também organização previsível: onde cada coisa mora, quem pode depender de quem e como mudanças não quebram outras partes. Uma estrutura sustentável tem três características: descoberta rápida (você encontra arquivos sem “caçar”), limites claros (dependências controladas) e evolução segura (refatorar lembra “mover peças” e não “desmontar tudo”).

Opção A: organização por feature (recomendada para apps com várias telas)

Você agrupa tudo que pertence a uma funcionalidade (telas, viewmodels, mappers, repositórios, modelos de UI) em um mesmo “bloco”. Isso reduz acoplamento entre features e facilita trabalhar em paralelo.

com.seuapp
  core
    network
    database
    designsystem
    common
  feature
    home
      ui
      domain
      data
    details
      ui
      domain
      data

Vantagens: mudanças em uma feature ficam localizadas; é mais fácil modularizar depois; testes por feature ficam naturais.

Opção B: organização por camada (boa para apps pequenos)

Você agrupa por tipo: tudo de UI junto, tudo de data junto, etc. Funciona bem no início, mas tende a virar “pastas gigantes” com o tempo.

com.seuapp
  ui
  domain
  data
  core

Vantagens: simples; pouca decisão inicial. Risco: acoplamento e arquivos distantes do contexto da feature.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

Regra prática para escolher

  • Se você já tem mais de 2–3 fluxos/telas principais, prefira por feature.
  • Se o app é um MVP pequeno e você quer velocidade, por camada pode ser suficiente, mas planeje a migração.

Convenções de pacotes e nomes: previsibilidade > criatividade

Convenções reduzem discussões e evitam “arquivos perdidos”. Um padrão simples e consistente costuma ser melhor do que um sofisticado e instável.

Convenções recomendadas

  • Pacotes em minúsculo: feature.home, core.network.
  • Sufixos claros: HomeViewModel, ProductRepository, ProductRemoteDataSource, ProductEntityMapper.
  • Modelos por contexto: evite um único Product para tudo. Prefira ProductDto (API), ProductEntity (Room), Product (domínio) e ProductUi (UI), quando fizer sentido.
  • Evite pacotes genéricos como utils, helpers, misc. Se algo é “utilitário”, geralmente pertence a um core.common com propósito definido (ex.: core.common.result).

Separação de responsabilidades e limites claros

Separar responsabilidades não é “criar camadas por criar”; é garantir que cada parte tenha um motivo único para mudar. O objetivo é reduzir efeitos colaterais: mudar a API não deveria obrigar a mexer na UI, e mudar a UI não deveria quebrar persistência.

Limites práticos (regras simples)

  • UI não conhece Retrofit/Room. Ela conversa com ViewModel e recebe estado pronto para renderizar.
  • ViewModel não sabe detalhes de HTTP/SQL. Ela orquestra casos de uso (ou chamadas do repositório) e transforma em estado de tela.
  • Data é onde ficam fontes (remota/local) e mapeamentos. É a camada que “sabe” como buscar e armazenar.
  • Domínio (quando existir) guarda regras e modelos estáveis do app. Evite contaminar com DTO/Entity.

Anti-padrões comuns para evitar

  • ViewModel gigante: muita lógica de transformação e regras de negócio misturadas com estado de tela.
  • Repository que vira “Deus”: faz parsing, validação, cache, mapeamento, tratamento de erro e ainda decide UI.
  • Modelos vazando: Dto chegando na UI ou Entity indo para a API.

Padrões úteis: Repository, DataSource e Mapper

Esses padrões ajudam a tornar dependências explícitas e testáveis. A ideia é simples: cada classe tem um papel e você consegue trocar implementações sem reescrever o app.

Repository: porta de entrada de dados para a feature

O Repository define uma API estável para o restante do app. Ele decide de onde vêm os dados (remoto, local, cache) e retorna modelos do domínio (ou modelos “neutros” do app).

interface ProductRepository {
    suspend fun getProducts(forceRefresh: Boolean = false): List<Product>
}

DataSource: detalhe de implementação (remoto/local)

DataSources encapsulam como falar com uma fonte específica. Isso evita que o Repository conheça detalhes demais.

interface ProductRemoteDataSource {
    suspend fun fetchProducts(): List<ProductDto>
}

interface ProductLocalDataSource {
    suspend fun getProducts(): List<ProductEntity>
    suspend fun saveProducts(items: List<ProductEntity>)
}

Mapper: tradução entre modelos (Dto/Entity/Domain/UI)

Mapper evita espalhar conversões pelo projeto. Ele também centraliza regras de compatibilidade (campos opcionais, defaults, normalização).

class ProductMapper {
    fun dtoToDomain(dto: ProductDto): Product = Product(
        id = dto.id,
        name = dto.name.orEmpty(),
        price = dto.price ?: 0.0
    )

    fun domainToEntity(domain: Product): ProductEntity = ProductEntity(
        id = domain.id,
        name = domain.name,
        price = domain.price
    )

    fun entityToDomain(entity: ProductEntity): Product = Product(
        id = entity.id,
        name = entity.name,
        price = entity.price
    )
}

Exemplo de Repository combinando fontes

class DefaultProductRepository(
    private val remote: ProductRemoteDataSource,
    private val local: ProductLocalDataSource,
    private val mapper: ProductMapper
) : ProductRepository {

    override suspend fun getProducts(forceRefresh: Boolean): List<Product> {
        if (!forceRefresh) {
            val cached = local.getProducts()
            if (cached.isNotEmpty()) return cached.map(mapper::entityToDomain)
        }

        val remoteItems = remote.fetchProducts().map(mapper::dtoToDomain)
        local.saveProducts(remoteItems.map(mapper::domainToEntity))
        return remoteItems
    }
}

Note como: DataSource remoto retorna DTO, local retorna Entity e o Repository expõe Domain. Isso reduz vazamento de detalhes.

Documentando decisões técnicas no código (ADR leve)

Documentar decisões evita que o time “re-decida” o mesmo assunto e ajuda revisões de PR. Uma forma leve é usar ADR (Architecture Decision Record) em arquivos curtos no repositório, e comentários pontuais no código quando a decisão impacta manutenção.

Estrutura simples de ADR

Crie uma pasta docs/adr e adote um template mínimo:

docs/adr/0001-repository-datasource-mapper.md

Contexto:
- Precisamos separar acesso a dados remoto/local e evitar vazamento de DTO/Entity.

Decisão:
- Adotar Repository + DataSources (remote/local) + Mapper por feature.

Consequências:
- Mais classes, porém testes e refatorações ficam mais seguras.
- Conversões ficam centralizadas em Mapper.

KDoc e comentários: quando usar

  • Use KDoc em interfaces públicas (ex.: Repository) para explicar contrato, caching e parâmetros como forceRefresh.
  • Use comentários curtos para justificar exceções: “por que isso é assim”, não “o que o código faz”.
/**
 * Retorna produtos do cache local quando disponível.
 * Use forceRefresh=true para ignorar cache e buscar do remoto.
 */
interface ProductRepository {
    suspend fun getProducts(forceRefresh: Boolean = false): List<Product>
}

Qualidade automática: lint, formatting e regras

Ferramentas de qualidade automatizam padrões e evitam que revisão de PR vire “polícia de estilo”. A ideia é: máquinas cobram formatação e regras objetivas; humanos revisam arquitetura, legibilidade e riscos.

Lint (Android Lint): foco em problemas Android

O Android Lint encontra problemas como uso incorreto de recursos, performance, acessibilidade e APIs. Você pode rodar no Android Studio e também no CI.

  • Rodar localmente: Gradle task lint (varia por módulo).
  • Tratar warnings relevantes como erro em módulos críticos (opcional, conforme maturidade).

ktlint: formatação Kotlin consistente

ktlint aplica um estilo de código consistente (espaçamento, quebras, imports). O ganho é reduzir diffs “barulhentos” e padronizar o projeto.

detekt: regras de qualidade e complexidade

detekt encontra code smells: funções longas, complexidade ciclomática alta, nomes ruins, uso excessivo de !!, etc. Ele complementa ktlint (que é mais sobre formato).

Configuração essencial (conceitos + exemplo prático)

A configuração exata varia conforme o projeto, mas o essencial é: 1) aplicar plugins, 2) definir versões, 3) criar tarefas para rodar e 4) ter um arquivo de configuração para regras.

1) Adicionando detekt (exemplo)

No build.gradle.kts do módulo (ou no root, conforme sua estratégia), aplique o plugin e configure:

plugins {
    id("io.gitlab.arturbosch.detekt") version "1.23.6"
}

detekt {
    buildUponDefaultConfig = true
    allRules = false
    config = files("$rootDir/config/detekt/detekt.yml")
}

tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
    reports {
        html.required.set(true)
        xml.required.set(true)
        txt.required.set(false)
    }
}

Crie o arquivo config/detekt/detekt.yml e comece com poucas regras, aumentando aos poucos para não gerar uma avalanche de ajustes.

2) Adicionando ktlint (exemplo)

Uma abordagem comum é usar um plugin Gradle de ktlint para tarefas como ktlintCheck e ktlintFormat:

plugins {
    id("org.jlleitschuh.gradle.ktlint") version "12.1.1"
}

ktlint {
    android.set(true)
    ignoreFailures.set(false)
}

O objetivo é ter um comando para checar e outro para formatar automaticamente antes de abrir PR.

3) Integração mínima no fluxo local

  • Antes de abrir PR: rode ./gradlew ktlintCheck detekt lint (ajuste conforme módulos).
  • Se falhar por formatação: rode ./gradlew ktlintFormat e reexecute os checks.

Checklist de revisão de PR local (antes de enviar)

Uma revisão local é você agir como “revisor do futuro”: procurar riscos, inconsistências e dívidas evitáveis. Use uma checklist curta e objetiva.

Checklist (copie e cole no seu PR)

  • Arquitetura: responsabilidades estão no lugar certo (UI/ViewModel/Repository/DataSource/Mapper)?
  • Dependências: não houve vazamento de Dto/Entity para a UI?
  • Erros e estados: falhas estão tratadas e não geram estado inconsistente?
  • Nomes: classes e funções comunicam intenção (sem abreviações confusas)?
  • Escopo: mudança está pequena o suficiente para revisar? Se não, quebre em PRs menores.
  • Testabilidade: classes críticas dependem de interfaces? Há pontos claros para mock/fake?
  • Logs: não ficou log de debug ou informação sensível?
  • Qualidade automática: ktlintCheck, detekt e lint passaram localmente.
  • Documentação: decisões não óbvias foram registradas (KDoc/ADR)?

Refatoração guiada do projeto do curso: consolidando a arquitetura

A seguir, um roteiro prático para reorganizar o projeto do curso sem “parar o mundo”. A estratégia é incremental: mover, compilar, ajustar imports, rodar checks.

Passo 1: definir o alvo de organização (feature-first)

Vamos assumir uma organização por feature com um core compartilhado. Crie os pacotes (ou módulos, se você já estiver modularizando) no nível do app:

com.seuapp
  core
    common
    network
    database
  feature
    items
      data
      domain
      ui

Se o projeto do curso tiver apenas uma feature principal, ainda vale a pena criar feature/items para estabelecer padrão.

Passo 2: mapear arquivos atuais para o novo destino

Faça uma tabela rápida (pode ser num rascunho) para evitar mover “no impulso”. Exemplo:

Arquivo atualNovo pacoteMotivo
ApiService/Retrofit clientcore.networkInfra compartilhada
Room database/DAOcore.databaseInfra compartilhada
Repository atualfeature.items.dataRegra de obtenção de dados da feature
Models DTO/Entityfeature.items.data.modelModelos de data
ViewModel/Screenfeature.items.uiCamada de apresentação
Modelos de UIfeature.items.ui.modelEvitar acoplamento com domain/data

Passo 3: introduzir DataSources sem quebrar tudo

Se hoje o Repository chama Retrofit e Room diretamente, extraia interfaces e implemente classes concretas. Comece pelo remoto:

// feature/items/data/source/ProductRemoteDataSource.kt
interface ProductRemoteDataSource {
    suspend fun fetchProducts(): List<ProductDto>
}

// feature/items/data/source/RetrofitProductRemoteDataSource.kt
class RetrofitProductRemoteDataSource(
    private val api: ProductApi
) : ProductRemoteDataSource {
    override suspend fun fetchProducts(): List<ProductDto> = api.getProducts()
}

Depois o local:

interface ProductLocalDataSource {
    suspend fun getProducts(): List<ProductEntity>
    suspend fun saveProducts(items: List<ProductEntity>)
}

class RoomProductLocalDataSource(
    private val dao: ProductDao
) : ProductLocalDataSource {
    override suspend fun getProducts(): List<ProductEntity> = dao.getAll()
    override suspend fun saveProducts(items: List<ProductEntity>) = dao.upsertAll(items)
}

Passo 4: centralizar mapeamentos em um Mapper

Procure conversões espalhadas (ex.: dto.toDomain() em vários lugares). Traga para um único Mapper por feature. Se você já usa extension functions, pode mantê-las, mas agrupadas em um arquivo/pacote de mappers para não se perderem.

// feature/items/data/mapper/ProductMapper.kt
class ProductMapper {
    fun dtoToDomain(dto: ProductDto): Product = ...
    fun entityToDomain(entity: ProductEntity): Product = ...
    fun domainToEntity(domain: Product): ProductEntity = ...
}

Passo 5: ajustar o Repository para expor um contrato limpo

Agora o Repository vira o ponto único de acesso para a feature. Garanta que ele retorne Product (domínio) e não DTO/Entity.

class DefaultProductRepository(
    private val remote: ProductRemoteDataSource,
    private val local: ProductLocalDataSource,
    private val mapper: ProductMapper
) : ProductRepository {
    override suspend fun getProducts(forceRefresh: Boolean): List<Product> = ...
}

Passo 6: “selar” a UI contra detalhes de data

Na UI, crie um ProductUi se necessário (por exemplo, para campos já formatados). Faça a conversão no ViewModel (ou em um mapper de UI) para manter a tela simples.

data class ProductUi(
    val id: String,
    val title: String,
    val priceText: String
)

class ProductUiMapper {
    fun toUi(domain: Product): ProductUi = ProductUi(
        id = domain.id,
        title = domain.name,
        priceText = "R$ %.2f".format(domain.price)
    )
}

Passo 7: registrar a decisão com ADR e KDoc

  • Crie docs/adr/0001-repository-datasource-mapper.md com contexto/decisão/consequências.
  • Adicione KDoc na interface do Repository explicando cache e parâmetros.

Passo 8: ativar qualidade automática e ajustar o código ao padrão

Adicione ktlint e detekt, rode as tarefas e corrija em lotes pequenos. Dica prática: primeiro rode ktlintFormat para reduzir ruído, depois trate detekt e lint.

Passo 9: criar um “acordo de limites” (regras simples de dependência)

Mesmo sem modularizar em múltiplos módulos Gradle, você pode estabelecer regras de revisão:

  • feature.*.ui não importa nada de core.network ou core.database.
  • feature.*.data pode depender de core.network e core.database.
  • feature.*.domain não depende de data nem de Android framework.

Se quiser reforçar isso tecnicamente mais tarde, você pode evoluir para módulos Gradle por feature/core, mas o primeiro ganho vem da organização e dos contratos.

Agora responda o exercício sobre o conteúdo:

Em um app Android que já possui mais de 2–3 fluxos/telas principais, qual organização tende a oferecer melhor evolução e menor acoplamento entre partes do projeto?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Com várias telas/fluxos, a organização por feature tende a reduzir acoplamento, manter mudanças localizadas e facilitar modularização e testes. Por camada é simples no início, mas pode virar pastas gigantes e afastar arquivos do contexto da funcionalidade.

Próximo capitúlo

Build e publicação Android: gerar APK/AAB, assinar, versionar e preparar release

Arrow Right Icon
Capa do Ebook gratuito Android para Iniciantes com Kotlin: construindo seu primeiro app moderno
93%

Android para Iniciantes com Kotlin: construindo seu primeiro app moderno

Novo curso

15 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.