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
dataVantagens: 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
coreVantagens: simples; pouca decisão inicial. Risco: acoplamento e arquivos distantes do contexto da feature.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
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
Productpara tudo. PrefiraProductDto(API),ProductEntity(Room),Product(domínio) eProductUi(UI), quando fizer sentido. - Evite pacotes genéricos como
utils,helpers,misc. Se algo é “utilitário”, geralmente pertence a umcore.commoncom 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:
Dtochegando na UI ouEntityindo 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 ktlintFormate 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/Entitypara 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,detektelintpassaram 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
uiSe 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 atual | Novo pacote | Motivo |
|---|---|---|
| ApiService/Retrofit client | core.network | Infra compartilhada |
| Room database/DAO | core.database | Infra compartilhada |
| Repository atual | feature.items.data | Regra de obtenção de dados da feature |
| Models DTO/Entity | feature.items.data.model | Modelos de data |
| ViewModel/Screen | feature.items.ui | Camada de apresentação |
| Modelos de UI | feature.items.ui.model | Evitar 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.mdcom 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.*.uinão importa nada decore.networkoucore.database.feature.*.datapode depender decore.networkecore.database.feature.*.domainnão depende dedatanem 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.