Pedro Sousa
← Artigos

Artigo · Blog

Kotlin Multiplatform não é Flutter. E essa confusão está custando arquiteturas inteiras.

A maioria dos times adota Kotlin Multiplatform com o mesmo mindset que usaria no Flutter, e aí as coisas começam a desmoronar. Compartilhar lógica entre plataformas é diferente de compartilhar produto. Essa distinção raramente aparece cedo o suficiente.

Kotlin Multiplatform não é Flutter. E essa confusão está custando arquiteturas inteiras.

Quando trabalhei em projetos com Flutter num contexto de fintech, a promessa era simples: um código, duas plataformas, metade do esforço. Na prática, o que apareceu foi uma UI que não se sentia nativa em nenhuma das duas, um estado compartilhado que criava acoplamento invisível, e uma camada de plataforma tão fina que qualquer feature nativa exigia um plugin de terceiro com manutenção incerta.

Não estou dizendo que Flutter é ruim. Estou dizendo que o Flutter resolve um problema específico, e parte dos times que adota Kotlin Multiplatform está tentando resolver o mesmo problema, com a ferramenta errada, sem perceber que são problemas diferentes.

O que a maioria assume sobre KMP

A narrativa mais comum que ouço é: "Vamos usar Kotlin Multiplatform para escrever o código uma vez e rodar no iOS e Android."

Parece razoável. Mas esse framing já carrega o erro.

KMP não foi construído para você ter um app multiplatforma. Foi construído para você ter lógica de negócio compartilhada com UIs nativas independentes. A distinção parece sutil. O impacto arquitetural não é.

Quando o time trata KMP como "Flutter com UI nativa", a primeira coisa que acontece é a criação de um módulo shared que começa absorvendo tudo. UseCase, Repository, ViewModel, Model, às vezes até lógica de apresentação. Em poucos meses, o shared virou o novo monolito. iOS e Android dependem dele pra tudo, e qualquer mudança ali exige coordenação entre dois times com ciclos de release diferentes.

Eu chamo isso de Armadilha do Shared Infinito. O módulo existe pra reduzir duplicação. Ele termina centralizando dependência.

O problema que aparece tarde

O ciclo é previsível.

Primeiro, o time celebra: "Olha, a lógica de autenticação está no shared, funciona igual nos dois lados." Segundo, alguém no time Android precisa de um comportamento ligeiramente diferente no fluxo de login por causa de uma regulamentação ou de um requisito de produto específico. Terceiro, começa a negociação: adiciona um parâmetro? Cria uma interface? Duplica mesmo?

Em apps financeiros, esse problema aparece mais rápido porque os fluxos críticos têm nuances de plataforma que não são acidente de design. São intencionais. Face ID no iOS funciona diferente do biometrics no Android. O comportamento de background no iOS tem restrições que o Android não tem da mesma forma. Criptografia de dados locais tem APIs distintas nos dois lados.

Se o módulo shared tenta abstrair essas diferenças, você cria interfaces que fingem que as plataformas são iguais quando não são. E toda abstração que mente sobre a realidade vai cobrar essa mentira mais pra frente.

O que funciona melhor na prática

A separação que faz sentido é mais granular do que "shared" e "não-shared".

O que pertence ao módulo compartilhado: entidades de domínio puras, regras de negócio sem efeitos colaterais, serialização e parsing de dados de rede, lógica de validação. Coisas que genuinamente não têm diferença entre plataformas porque vivem num nível de abstração acima delas.

O que não pertence: ViewModels, gerenciamento de estado, qualquer coisa que precise reagir a ciclo de vida, qualquer coisa que toque em APIs nativas. Esses elementos pertencem às camadas de plataforma específicas, escritas na linguagem nativa, com as ferramentas nativas.

No Android, isso significa Kotlin com ViewModel do Jetpack, StateFlow, Coroutines tratando ciclo de vida corretamente. No iOS, Swift com Combine ou async/await, structs e classes que respeitam o modelo de memória do Swift, integração com SwiftUI ou UIKit sem camadas de tradução.

O resultado não é "menos código compartilhado". É código compartilhado que de fato sobrevive sem precisar de workaround a cada feature nova.

O trade-off honesto

Essa abordagem tem um custo real.

Você vai escrever ViewModels duas vezes. O estado de carregamento de uma tela vai ser implementado em Kotlin e em Swift. Parte do time vai sentir que isso é desperdício.

Não é. É o preço da plataforma ser respeitada.

O desperdício real aparece quando você tem um ViewModel compartilhado que precisa de expect/actual pra cada comportamento específico de plataforma, e a lista de actual cresce até o ponto em que o código "compartilhado" é só um esqueleto com implementações divergentes embaixo. Nesse ponto, você tem a complexidade das duas plataformas mais a complexidade da camada de abstração. Sem nenhum dos benefícios originais.

Compartilhar o que é genérico. Escrever nativamente o que é específico. Essa regra é simples. A disciplina pra mantê-la não é.

O que eu faria diferente hoje

Quando entro num projeto que está considerando KMP, a primeira pergunta que faço não é sobre arquitetura. É sobre o produto: onde iOS e Android realmente se comportam igual, e onde não?

Se a resposta for "em quase tudo", o KMP pode ser uma boa escolha pra lógica de domínio, e a UI vai ser nativa em cada plataforma.

Se a resposta for "depende da feature", o projeto vai precisar de disciplina ativa pra impedir que o módulo shared cresça além do que deveria. Isso significa revisões de código focadas especificamente nisso, e alguém com responsabilidade de dizer "isso não vai pro shared" quando necessário.

O que eu não faria: deixar essa decisão implícita. Times sem uma definição clara do que pertence ao módulo compartilhado tomam decisões locais que fazem sentido individualmente e criam um problema sistêmico ao longo de meses.

Num contexto em que trabalhei com arquitetura modular pra múltiplos times e produtos, o padrão que funcionou foi definir as fronteiras do módulo compartilhado antes de escrever a primeira linha, revisitar essa definição a cada trimestre, e tratar violações como dívida técnica com custo explícito.

Não é glamoroso. Mas é o que separa um módulo compartilhado que dura de um que vira um problema de manutenção que ninguém quer tocar.

A confusão de fundo

No fim, o problema não é técnico. É conceitual.

Flutter compartilha produto: você escreve UI uma vez e entrega pras duas plataformas. KMP compartilha lógica: você escreve regra de negócio uma vez e as plataformas consomem de formas distintas.

São filosofias diferentes com trade-offs diferentes. Tratar KMP como Flutter "com mais trabalho na UI" é como tratar uma API REST como um banco de dados "que fica na rede". Tecnicamente não está errado, mas você vai tomar decisões ruins porque o modelo mental está errado.

O que me preocupa não é que os times escolham KMP. É que escolham sem clareza sobre o que estão comprando. E quando o módulo shared começa a inchar, a resposta comum é "vamos refatorar depois", que raramente chega.

A arquitetura que você aceita no primeiro mês é a que você vai herdar no décimo segundo.