Foto adaptada de Pascal Swier no Unsplash

Lições aprendidas enquanto modularizando um aplicativo

Caique Oliveira
Android Dev BR
Published in
14 min readNov 29, 2022

--

Este artigo possui como objetivo trazer um pouco do aprendizado enquanto escalando um aplicativo, saindo do 0 a mais de 150 módulos. Serão apresentados quais foram as dores e desafios encontrados durante essa jornada, além do processo evolutivo da aplicação(app) e da equipe nos últimos 5 anos.

Os desafios encontrados aqui se relacionam com a construção de um produto visando ter apenas um único repositório (mono repo) para todas as funcionalidades do projeto.

O que será apresentado abaixo, se baseia no que ocorreu enquanto liderando a criação do aplicativo bancário da Stone.

A imagem acima mostra como funcionava a organização da equipe, onde todo o time era responsável por todas as funcionalidades simultaneamente.

Módulo vai além do Gradle

Ao modularizar um aplicativo, um dos objetivos é diminuir necessário para rodar (build) a aplicação. Portanto, quando estruturando o projeto em módulos eles devem possuir poucas dependências. Dessa forma, o Gradle vai executa-los em paralelo, otimizando assim o tempo de compilação.

Modularizar é uma de muitas coisas que podem ser feitas. Adicionar algumas configurações básicas e avançadas ao Gradle podem ajudar a melhorar o tempo de compilação. Para aprender mais sobre configurações de Gradle, o conteúdo do Tony Robalik e do Adam podem ser úteis. Comece por esse artigo aqui e aqui.

Se o tempo de compilação melhorar ao aplicar algumas das configurações a partir dos artigos acima, talvez não seja hora de modularizar tanto a sua aplicação.

“Feito essa introdução, por que vamos além do Gradle?”

Cada empresa possuem uma estrutura diferente que influencia na forma como código é criado. De modo que a estrutura do seu projeto tende a ser um reflexo da organização da empresa, isso é conhecido como a lei de Conway.

Em resumo, a lei de Conway diz que a estrutura de uma organização refletirá na forma como o código é criado, isso não, é algo bom ou ruim, mas é interessante de se ter em mente, pois talvez o que funcione em uma empresa não funcionará para outra. Então, evitar replicar cegamente um projeto é uma decisão sábia.

Além do Gradle, vale muito a pena entender como funciona a estrutura da organização, a relação com a equipe Android e como é feito o desenvolvimento dos produtos no dia a dia. Desse modo, ter uma compreensão da estrutura pode facilitar como a modularização ocorre.

Tenha cuidado sobre como e quais módulos você deseja criar. Não existe uma regra de ouro sobre como fazer isso. As ideias a seguir são influenciadas pela estrutura e tamanho da equipe conforme o tempo passa no desenvolvimento do projeto.

Modularização nos primeiros dias

O projeto possui múltiplos módulos o primeiro dia. Quatro pessoas desenvolvedoras formavam a equipe, o aplicativo tinha poucas linhas de código e toda a equipe entendia todo o projeto sem dificuldades.

A arquitetura do aplicativo foi feita a partir de três tipos de módulos diferentes:

  • base: módulos que geram valor para desenvolvedores, como, por exemplo, toda a lógica para realizar uma requisição, funções úteis, extension functions, etc. São representados em roxo. O módulo utils existia para deixar todos recursos (imagens, strings, extensions) do aplicativo em um só lugar.
  • feature: módulo que gera valor direto para o cliente, ou seja, uma funcionalidade do aplicativo. Representado em amarelo.
  • business: contém todas as regra de negócio de todas as funcionalidades, representado em azul.

Para criar uma funcionalidade seguindo a estrutura acima, adicione um novo módulo iniciando com a palavra “feature”, em seguida adicione parte da regra de negócio ao módulo de domínio(domain). Finalmente, modifique o módulo de serviço responsável por realizar as solicitações à API.

Essa estrutura fez muito sentido para toda a equipe. Havia mais de dez módulos na aplicação e pouquíssimos conflitos de código, mesmo que toda a equipe tocando em partes em comum.

A filosofia para a estruturação dos módulos como no desenho acima era bem simples: criar o máximo de módulos Kotlin possível, então se seguiu com uma modularização por camadas. Esse tipo de estruturação separa entre diferentes módulos código de uma mesma funcionalidade. No módulo feature se encontrava parte do código relacionado a tela que o usuário vai ver; no módulo domain a regra de negócio, ou como aquela funcionalidade funciona; e em service todo código relacionado a requisição e resposta com o servidor/api.

A estrutura reflete como a equipe trabalhava diariamente, já que nenhum membro tinha responsabilidade exclusiva por nenhuma funcionalidade específica.

Avançando no tempo, agora com 200 mil linhas de código e um time maior

Duzentas mil linhas de código não parecem um número tão significativo, mas lembre-se de que o projeto não usou nenhuma biblioteca geradora de código como Dagger, nem usou processadores de anotação Kotlin (kapt).

A estrutura de modularização da seção anterior permaneceu a mesma por cerca de três anos. Nesse ponto, aequipe agora tinha cinco repositórios, três bibliotecas diferentes e um projeto paralelo para manter o projeto principal. Assim, além das 200.000 linhas de código no projeto principal, ainda havia quatro bases de código menores para suportar.

A equipe tinha, em média, dez desenvolvedores contribuindo diariamente com a base de código e o projeto principal com pouco mais de 70 módulos.

Time agora como dono de diversos produtos em diferentes repositórios

Essa estrutura, com diversos repositórios, gerou alguns problemas:

  • Atualizar versão de SDK, se perdia muito tempo com CI/CD (continuos integration/ continuos deployment) gerando versões novas versões necessárias para o projeto principal e secundário
  • Atualizar configurações, era necessário abrir muito pull requests(PR) para manter atualizar versões do Kotlin, Gradle e bibliotecas com dependências nos projetos
  • Repetição de código, algumas definições em comuns aos projetos e bibliotecas foram repetidas, então um bug corrigido num repositório não necessariamente gerava uma correção no outro.

Essa estrutura impactou a produtividade do time, era muito PR bobo só para manter as coisas em ordem.

Como solução, foi feita uma reestruturação para ter apenas um único repositório. Não foi nada fácil de ser realizado, foram necessários alguns meses de dedicação realizando pequenas modificações e resolvendo os problemas encontrados, dentre eles:

  • Estrutura de Gradle diferentes, os scripts diferiam, alguns em Kotlin, outros em Groovy.
  • Geração dos artefatos, como resolver a geração de diferentes SDKs e projetos a partir de um único repositório.
  • Conflitos entre abstrações parecidas, mas com implementações diferentes.
  • Versões diferentes do Kotlin entre os projetos
  • Dependência circular ao migrar módulos

Migrar para o mono repo provou ser uma ótima solução. Ele reduziu significativamente o custo de configuração e atualização de dependências. Além disso, impactou positivamente na produtividade, pois não era mais necessário pausar o desenvolvimento de um recurso enquanto esperava por novos artefatos de algumas das bibliotecas/SDKs mantidas.

A migração para o mono repo não parou desenvolvimento de novas funcionalidades.

Se possível evite um módulo utils genéricos

Após a migração para o mono repo, o módulo utils provou não ser uma boa ideia porque, em algum momento, era o local para novas abstrações definidas pela equipe. Era possível encontrar todo tipo de código mesmo que não houvesse coesão entre eles. Com o tempo, quase todos os outros módulos de funcionalidade dependiam de utils

É muito mais interessante criar um conjunto módulos com abstrações bem definidas, sendo estas utilizadas separadamente em diferentes partes do aplicativo. Por exemplo, ao invés de se ter utils/threads onde threads é uma pasta desse módulo, é muito mais interessante criar um módulo util-threads mantendo tal módulo focado em resolver ou prover apenas um pequeno pedaço de uma abstração utilizada pelo aplicativo.

A lição aqui é que se for criar um módulo sem uma boa definição, não faça isso, o tempo passa e pode sair caro remover ou organizar esse novo módulo no futuro.

Tempo de compilação começou a crescer, problema resolvido (com dinheiro)

Três anos se passaram e o computador da equipe ainda era o mesmo. Porém, apesar da modularização, o tempo de construção não era mais adequado, e os equipamentos disponíveis poderiam ser melhores. Assim, a decisão mais direta e sensata foi a de atualizar o ferramental disponível para a equipe, o que resolveu boa parte dos problemas.

Portanto, atualize os computadores da equipe sempre que possível. Exija isso da liderança. A produtividade da equipe aumenta significativamente e o investimento se paga rapidamente para toda a equipe. Alguns artigos falam sobre isso.

Investigando o tempo de compilação

Ao investigar o tempo de compilação, essa palestra ajudou muito a ter uma ideia de como a modularização do projeto poderia evoluir.

Thiago criou um projeto de exemplo para validar o que foi aprendido. Há nele um grande número de módulos. Foi pensado para ser assim e buscando entender e aplicar algumas ideias de diferentes formas de modularizações.

Alguns aprendizados com a investigação:

  • Evite buildSrc, recorra a included build
  • Defina uma relação entre módulos, por exemplo, módulo feature, não pode depender de outro módulo feature, de modo que a profundidade do seu grafo de para realizar uma build não seja tão grande
  • Aproxime módulos que possuem a mesma regra de negócio
  • Acompanhe de perto as atualizações/novidades do Gradle

Ações a partir do aprendizado

Foi feita uma revisão na estrutura em camadas, pois vários módulos foram alterados, exigindo que o Gradle realizasse novamente o build do módulo em vez de usar seu cache. Isso teve um impacto no tempo necessário para executar o aplicativo.

Foi feita então uma aproximação do código da mesma funcionalidade para que toda a regra de negócio ficasse mais próxima e isolada dos demais módulos. Como resultado, os módulos de funcionalidade agora consistem em uma estrutura como abaixo:

service, domain, view próximos e mais coeso

Dessa forma, menos módulos são alterados para atualizar ou criar uma funcionalidade, permitindo um maior reaproveitamento do cache do Gradle.

Um benefício dessa abordagem é que ficou muito mais simples entender o código de uma funcionalidade: eles estavam muito mais próximos. Era essencial, pois não era mais possível conhecer o código do projeto.

A estrutura evoluiu de uma organização apenas em camadas para uma que contém módulos de funcionalidade e módulos de suporte. Networking é um exemplo de suporte, onde toda a configuração do Retrofit é definida. Este módulo é então usado por um módulo de funcionalidade que só precisa criar a interface do Retrofit com os endpoints necessários para a funcionalidade.

Com essa separação, foi feita uma revisão do fluxo de criação de uma funcionalidade. Viu-se que havia muito código com acesso compartilhado e que quanto mais isolados esses módulos pudessem estar, menos problemas haveria, como, por exemplo, menos conflitos de código e menos alterações que quebrassem funcionalidades indiretamente.

Reestruturação da equipe e como ficou o projeto

Atualmente, o projeto evoluiu para um super-aplicativo, visando facilitar o acesso do cliente às mais diversas funcionalidades disponíveis nos produtos da empresa.

Para se tornar um super-aplicativo, a equipe incorporou uma base de código mais extensa do que a existente no aplicativo. Após a junção dos dois aplicativos, todos os produtos precisaram continuar seus lançamentos. Dada a quantidade de funcionalidade que agora existe, foi necessário rever a estrutura da equipe. Existia um modelo muito focado em uma hierarquia que mudou para uma estrutura matricial por tribos, esquadrões, etc.

Times independentes criando diferentes funcionalidades que não possuem relação entre elas

Esse modelo distribuído permitiu evoluir ainda mais como era organizado o projeto e finalmente chegou-se a estrutura atual. Além dos times responsável por funcionalidades para o cliente, também foi criado um time responsável pela plataforma Android que possui como objetivo ser o suporte para os times que desenvolvem funcionalidades.

Para seguir com a organização foi feito uma divisão dos diversos módulos do aplicativo entre os diversos times de funcionalidades e plataforma, chegando a seguinte divisão:

Módulos legados (legacy): Todos os módulos com essa nomenclatura, deveriam deixar de existir em prol de uma estrutura mais isolada do projeto, por exemplo, o módulo domain em azul nas imagens iniciais, deveria deixar de existir, uma vez que as regras de negócios agora deveriam ser distribuídas entre os módulos de uma funcionalidade.

Existem alguns módulos legados, e cada time é responsável por migrar um pedaço desses códigos para uma estrutura atual.

Módulos de funcionalidade (feature): Módulos que adicionavam valor diretamente para o cliente, cada time de funcionalidade possui um grupo de módulos pelos quais são responsáveis.

Módulos de plataforma (platform): Módulos que provém os recursos aos times que criam funcionalidades para os clientes. Como exemplo, toda lógica de networking, segurança, cache, analytics, di, feature flags, design system, performance, etc.

Módulos comuns(support): Módulos que vários times ainda possuem contato no dia a dia, como a home do aplicativo, onde são apresentados os mais diversos produtos da aplicação.

Cada equipe é responsável por um produto diferente do aplicativo

Melhorando o tempo de compilação com Focus

Com uma clara relação entre os diferentes módulos, foi possível adicionar ao projeto o plugin Focus desenvolvido pela equipe do Dropbox. Este plugin do Gradle gera arquivos settings.gradle específicos do módulo, permitindo que você se concentre em um recurso ou módulo específico sem precisar sincronizar o resto do seu monorepo.

Além disso, Gabriel Souza criou um plugin para o android studio em cima do Focus para facilitar seu uso. Com ele basta apenas clicar em um módulo e selecionar focus e aguardar o Gradle atualizar o projeto.

Adicionando sample apps ao projeto

Todos os projetos possuem um módulo app com um plug-in application aplicado; ao criar um módulo no mesmo projeto, o Android Studio irá adicionar o plugin de library.

O módulo app deve ter uma dependência com todos os outros módulos do aplicativo para que ao gerar um apk todas as funcionalidades estejam presentes.

O sample ajuda a construir apenas uma parte do aplicativo, portanto, em vez de depender de todos os módulos, ele contará com um conjunto menor de módulos, tornando o tempo de compilação menor ou muito menor.

Como cada squad possui agora um grupo de módulo isolados pelo qual é responsável, foram criados diferentes sample apps por todo o projeto para não ser necessário buildar toda a aplicação.

A direita módulos disponíveis após aplicar focus

Algumas regras básicas a serem consideradas ao criar um sample app:

  • Ele deve passar pelo processo de autenticação da mesma forma que o módulo app para que a equipe trabalhe com uma sessão de usuário igual ao que irá para produção. Ou seja, há uma dependência do módulo de login.
  • Adicione somente os módulos que esteja sendo trabalhado
  • Crie uma activity/view/compose/fragment que seja o destino da navegação após a autenticação
  • Sobrescreva a navegação para navegar para a nova tela criada no passo acima.

Havia dois problemas centrais; o primeiro foi navegar após finalizar o fluxo de autenticação e montar o grafo da biblioteca utilizada para injeção de dependência (DI). O projeto usa Kodein.

Automatizar a criação do sample ajudou na adoção pela equipe. O script executa as seguintes ações:

  • Cria automaticamente o módulo do sample app
  • Uma nova classe, SampleAppApplication, foi criada para resolver a injeção de dependência. Esta classe ajuda a estruturar a injeção de dependência no sample.
  • Cria uma activity automaticamente e via DI atualiza para qual tela se deve navegar. Então ao invés de navegar para a home do app, navega-se para a activity que foi gerada.
  • Na activity gerada, apresenta uma lista de botões que ao clicar navega para a funcionalidade em foco.

Uma vez executado o script a pessoa adicionando o sample precisa apenas atualiza a lista de navegação existente na classe SampleAppAplication que esperava um retorno do tipo map<String, Screen>. A implementação seria algo similar a:

fun listOfEntryPoints() = mapOf(
"Texto do botão" to Navigation.Pix.Home,
"Navegar para outro fluxo" to Navigation.Pix.CopyAndPaste
)

Screen faz parte de como funciona a navegação no projeto. É uma sealed class onde há um mapeamento para alguma tela.
Além da navegação, era necessário definir quais dependências o sample tenha com outros módulos. Para isso, bastava atualizar o arquivo build.gradle.kts

//build.gradle.kts do sample app
dependencies {
implementation (project(":module-xyz)")
...
}

O script criado é específico para o problema que se tinha no projeto, uma vez que está ligado com o sistema de navegação da aplicação e ao DI.

Refletindo a estrutura da equipe na estrutura do projeto

Com o projeto contendo uma quantidade tão grande de módulos foi feito uma organização que tornou mais simples se encontrar/navegar no projeto.

A organização foi baseada na estrutura organizacional atual da equipe, a essa altura, existiam diversos times distribuídos em diferentes tribos adicionando funcionalidades ao projeto, era muito natural para equipe falar “trabalho no projeto Android no produto xyz”. Desse modo, ao invés de listar todos os módulos logo na raiz do projeto (imagine abrir o projeto e tentar achar onde se está trabalhando em uma dos mais de 150 módulos listados), foi reestruturado para como mostra a figura abaixo:

Amarelo e cinza são pastas, verde são módulos, as setas verdes indicam apenas estrutura de diretórios existente em cada pasta.

Essa estrutura se mostrou flexível em dois sentidos:

  • Caso o time de uma tribo cresça, não há um esforço tão grande de separar um módulo em 2 caso seja necessário
  • Caso um módulo fique muito grande é fácil quebrar em módulos menores mantendo a estrutura de código coesa.

Como quebrar um módulo grande e manter a coesão?

Tomando como exemplo o módulo do Pix, que muito provavelmente num futuro será necessário separar em vários outros módulos, uma vez que sempre há alguma novidade parte do banco central (BACEN). Com a evolução desta funcionalidade, estrutura poderia ser atualizada para algo como:

Linhas tracejadas indicam que os módulos em verde fazem parte do diretório em amarelo

Idealmente modularizar a esse nível só se for extremamente necessário ou caso o módulo comece a impactar o tempo de compilação.

Widget visa isolar pedaços de funcionalidades usadas por outros módulos. Por exemplo, a home do aplicativo exibe um ponto de entrada para acessar o Pix. Então, ao invés de depender diretamente de um módulo em constante mudança que nunca poderá usar o cache do Gradle, o módulo home passará depender de um módulo com mudanças pontuais, mantendo assim o tempo de compilação saudável.

É muito importante definir uma estrutura visando manter a estrutura de dependência entre módulos o mais achatada possível. Note que widget evita a relação direta entre módulos de diferentes funcionalidades

Shared foi criado visando deixar isolado tudo em comum aos outros módulos de uma mesma funcionalidade ou produto. Desse modo, por exemplo, não é preciso criar várias interfaces do Retrofit, ou repetir diversos modelos uma vez que possuem um domínio em comum.

Foi evitado que shared fosse uma separação por camada, sendo assim, tal módulo contém desde interface do usuário a camada de entrada e saída de dados. Note que Shared evita relação direta entre módulos de uma mesma funcionalidade como caso do Pix.

Linhas tracejadas indicam que o módulo em verde está como diretório da pasta Pix em amarelo.

Shared e widgets são nomenclaturas definidas para compartilhar código pelo projeto e manter um entendimento claro por parte da equipe do que cada módulo faz. O importante aqui é definir relações claras entre os módulos

A estruturação (criar módulos, mover pastas, etc.) requer atualizar o arquivo settings.gradle e todos os imports dos módulos nos arquivos, build.gradle que forem afetados. Tenha em mente que isso pode levar um pouco de tempo.

Mantendo a saúde do projeto

Atualmente o projeto é muito grande. A manutenção do projeto deve ser feita de forma continua, algumas coisas podem ajudar aqui.

  • Remova dependências não utilizadas
  • Revise se a estrutura do projeto segue saudável
  • Revise se existe muita profundidade no grafo do Gradle
  • Adicione um readme na raíz do módulo descrevendo um pouco das regras de negócio que ali existem.
  • Prefira composição ao invés de herança (este projeto não possui nenhuma baseclass (BaseActivity, BaseFragment, BaseXYZ), isso facilita a modularização

Concluindo

Não exagere em seus módulos, dedique um tempo para criar boas abstrações mesmo que estas estejam aglutinadas em um único lugar, elas serão uteis na modularização com o passar do tempo.

Evite exageros na sua arquitetura, lembre-se que o valor do produto não está somente na quantidade de módulos que o mesmo possui, mas também em como a aplicação melhora a dia a dia do cliente.

Observe como a estrutura do projeto deve evoluir com o passar do tempo, olhe também para cultura e estrutura da empresa em que está trabalhando e não olhe apenas para o que está no hype do desenvolvimento.
Uma visão técnica e do negócio vão trazer benefícios e maturidade a todo o projeto.

Caso queira tirar dúvidas, me encontra no Slack do android dev br, Mastodon ou Twitter para contato

Agradecimentos

Leonardo Paixão, Breno Cruz, Allan Hasegawa, Thiago Oliveira e Douglas Vinicius por toda as revisões e sugestões técnicas.

--

--