O valor da canonicidade

Conseguimos uma engenharia mais eficiente se preferimos menos variância na escolha e no uso de tecnologias, favorecendo abordagens canônicas para problemas?

O valor da canonicidade

Quando as pessoas perguntam "Qual é a stack aí do Nubank?", a resposta costuma ser bem curta. Usamos as mesmas tecnologias para boa parte dos nossos sistemas de backend: Clojure para microsserviços, Kafka para comunicação assíncrona, Datomic como nosso banco de dados, Scala para nosso ambiente analítico e Flutter para nosso app. Depois de 7 anos construindo produtos, agora com mais de 600 engenheiros, é comum nos perguntarem como chegamos nessas tecnologias. Entretanto, acreditamos que existe uma pergunta ainda mais relevante:

O que acontece quando a engenharia de uma empresa decide limitar o número de tecnologias utilizadas?

Mais especificamente, conseguimos uma engenharia mais eficiente se preferimos menos variância na escolha e no uso de tecnologias, favorecendo abordagens canônicas para problemas?

O que é variância no uso de tecnologias? Toda variância é ruim?

"Menos variância" é mais precisamente definido como "evitar variância que não é essencial". Por exemplo, quando uma tecnologia nos trará um valor maior (ou diferente) com relação às que já temos (i.e. variância essencial), estamos ok em adotá-la. Por outro lado, não queremos escolher uma nova tecnologia sem uma justificativa clara, apenas para usar algo novo, algo diferente (i.e. variância não essencial). Ou, colocando em outras palavras, nós preferimos ter formas canônicas de fazer as coisas.

Quando escutam "menos tecnologias", alguns engenheiros podem pensar algo como: "você concordaria em continuar usando uma linguagem antiga, como COBOL, pro resto da sua vida?". É uma pergunta justa. Em muitas situações, limitar opções pode parecer, claro, limitante, especialmente quando os benefícios são a longo prazo e diluídos por toda a organização.

Entretanto, preferir menos variância não é o mesmo que evitar mudanças ou ser relutante em considerar alternativas. Isso significa que no espectro do trade-off entre "COBOL pra vida toda" e "novas tecnologias toda semana", estamos mais próximos do lado que resolve problemas similares de formas consistentes dentro da organização (talvez mais próximo do COBOL nessa analogia :) ).

"Escolher o lado do COBOL" é uma frase estranha para 2021, mas é importante clarificar o que isso significa na prática. Queremos usar ferramentas excelentes para cada trabalho, então se o que temos agora alcança o propósito desejado, e as alternativas não apresentam vantagens significativas, preferimos não adicionar novas tecnologias.

Se esse não é o caso, não temos medo de melhorar nossa caixa de ferramentas e começar a usar uma nova tecnologia. Por exemplo, quando construímos a primeira versão da nossa infra de ETL, nós decidimos intencionalmente usar Spark com Scala em vez de Clojure. Ou quando começamos a desenvolver serviços envolvendo Machine Learning, escolhemos Python em vez de, novamente, Clojure.

Enxergamos essas tecnologias como ferramentas significativamente melhores para aquelas situações, naquele momento. Essas foram decisões bem pensadas e estratégicas, e é isso que queremos toda vez que pensamos em introduzir mais variância no nosso uso de tecnologias.

Variância interna

A variância não está presente apenas quando escolhemos uma nova tecnologia (e.g. um novo banco de dados ou um novo framework). Variância também é sobre como usamos as tecnologias que já temos. Pegando uma linguagem de programação, por exemplo, o estilo de código, funcionalidades, frameworks e bibliotecas podem ser fontes de variância. Muitas linguagens são tão flexíveis que você pode fazer dois pedaços de código escritos parecerem muito diferentes um do outro. Muitas dessas diferenças acontecem organicamente, especialmente com um crescente número de engenheiros, já que é natural que opiniões possam divergir sobre como usar uma tecnologia.

Por isso, quando estamos buscando pela canonicidade de ferramentas numa organização, é preciso evitar a variância não essencial não somente no sentido mais macro (e.g. linguagens de programação, bancos de dados), mas também num nível mais interno (e.g. estilo de código, funcionalidades das linguagens).

No Nubank, observamos uma grande consistência em como usamos as tecnologias. Por exemplo, se selecionarmos aleatoriamente duas bases de código nossas em Clojure, veremos que a estrutura do código (arquivos e pastas) para ambos é bem similar. Ambas provavelmente usam as mesmas bibliotecas e frameworks. E, finalmente, tem a linguagem Clojure propriamente dita. Ela é flexível, e programadores podem usá-la de formas diferentes (olá, macros!). Olhando para esses dois sistemas aleatórios, você provavelmente pensaria que a mesma equipe escreveu ambos.

Padronizar o uso não é algo fácil de alcançar ou de manter. É necessário intencionalidade, uma cultura de revisão de código estabelecida, suporte de engenheiros seniores e automatização (e.g. templates para novos serviços), para nomear apenas alguns pontos.

Intuitivamente essa consistência e homogeneidade na escolha e uso de tecnologias soa atraente, mas os reais benefícios podem não ser aparentes. Por que, então, se importar com a canonicidade nas tecnologias que usamos?

Mitigando dependências

O Nubank se divide em pequenos times multidisciplinares e queremos que eles sejam autossuficientes, tendo o mínimo de dependências possíveis de outros times. Uma equipe deve ser capaz de programar, testar e fazer deploy de algo em produção sem precisar esperar (i.e. depender) que outro grupo faça algum trabalho (e.g. implemente funcionalidades, rode pipelines, mude a infra).

Conseguimos eliminar muitas dependências através de muita automação, mas com o sempre crescente número de sistemas, as equipes inevitavelmente começam a se especializar e serem donas de partes específicas do código.

Nesse contexto, engenheiros vão, cedo ou tarde, fazer tarefas fora do domínio do time e da sua base de código. Um exemplo típico é quando um grupo precisa acessar dados em um serviço de responsabilidade de outro time (que não é exposto por uma API já existente). Um possível resultado seria os engenheiros dependerem (i.e. esperarem) de que o outro time crie um novo endpoint para acessar os dados.

Mitigamos bastante esse tipo de dependência com o seguinte princípio: todo software no Nubank é aberto para colaboração e mudanças. Isso significa que qualquer engenheiro tem permissão para propor mudanças em qualquer base de código. Claro, ainda é boa prática ir falar antes com os donos do código para alinhar as mudanças. As contribuições vão ser revisadas, aprovadas e implantadas pelos donos, que também são responsáveis pela operação em produção. Na prática, isso funciona melhor que esperar os responsáveis pelo sistema mudarem suas prioridades para acomodar uma demanda de um outro time. Dessa forma, qualquer engenheiro tem a habilidade, por exemplo, de criar um novo endpoint, mesmo que num serviço de fora de sua equipe.

Isso é ótimo na teoria, mas o quão fácil é programar num código que você conheceu faz 5 minutos? Ele pode ser escrito em uma linguagem diferente do que você está acostumado. Ou usar um banco de dados NoSQL que saiu ano passado e você só leu sobre no Hacker News. Ou usar as mesmas tecnologias que você está acostumado, mas de forma significativamente diferente (e.g. código mais OO e menos funcional). Qualquer uma dessas possibilidades cria barreiras tecnológicas que impedem a colaboração entre times.

É aí que ter menos itens na sua caixa de ferramentas ajuda. No Nubank, existe uma grande chance de que o seu time e o outro usem Clojure no backend. E que ambos usem Kafka. E que ambos usem Datomic. E que ambos usem estilos de código similares. Dessa forma, você consegue focar em entender o domínio, o problema de negócio a ser resolvido, o estado atual da base de código e como ele deve ser evoluído.

Essa facilidade em contribuir no código de outros times pode causar problemas (muitas vezes, de forma parecida com o mundo open-source, quando contribuições não estão indo na direção desejada), mas a revisão de código e o excesso de comunicação são proteções efetivas.

Afinal de contas, nós não estamos eliminando inteiramente a dependência entre times, estamos apenas simplificando a resolução prática de um blocker em potencial. Preferimos deixar nossas dependências explícitas através da comunicação entre serviços em vez de entre cartões no backlog do outro time. Dessa forma, alteramos a dependência para processos muito mais simples e leves: alinhamento e revisão de código.

No Nubank, essa natureza colaborativa dos nossos códigos têm sido essencial para que os times se mantenham dentro do seu fluxo de trabalho, ou no "flow". Isso também ajudou a criar na empresa uma mentalidade de "ownership" de código, onde todos se sentem donos das bases de código. Algo que tem sido fácil evoluir com o crescimento da nossa estrutura (em certa medida, mitigando a Lei de Conway). Tudo isso foi em grande parte possível pelo fato de não termos barreiras tecnológicas entre times.

Mudar de times é menos doloroso

No hipercrescimento que o Nubank tem vivido nos últimos anos, uma coisa tem se mostrado clara: prioridades vão mudar. Uma consequência disso é que precisamos criar ou mudar times, o que naturalmente envolve mover pessoas da engenharia.

O que acontece, então, quando um engenheiro se muda para outro time? Além de se acostumar com novas pessoas e dinâmicas, o principal desafio é aprender o novo domínio técnico e de negócio: Qual é o produto? Qual é o cliente? Quais serviços o time tem? Quais tecnologias o time usa?

Não podemos dizer que essas transições se tornaram irrelevantes ou imperceptíveis. Mas se um engenheiro consegue rapidamente entender o código e propor mudanças em um serviço de uma outra equipe, provavelmente significa que irá entender muito mais rápido o contexto do novo time.

Apesar de ainda evitarmos ficar deslocando pessoas de um contexto para outro, ao longo dos anos, engenheiros têm conseguido mudar rapidamente de times sem se preocupar tanto com o aprendizado de novas tecnologias. Com isso, eles conseguem focar em entender o domínio de negócio específico do novo time (o que normalmente já é desafiador o suficiente). Essa facilidade nos dá flexibilidade para alocar a engenharia nas mais altas prioridades ou nos lugares que mais vão promover o crescimento de suas carreiras, sem perturbar tanto a sua produtividade.

Melhorias técnicas de alto impacto

Com a alta escala, tudo, eventualmente, quebra, e nós queremos consertar cada coisa, preferencialmente, apenas uma vez. Além disso, com a escala, mesmo pequenas melhorias na produtividade da engenharia podem causar grandes impactos.

Digamos que você tem uma empresa com cinco engenheiros, onde todos programam Java. Se você colocar um linter no build, você irá melhorar a produtividade de todos os seus engenheiros. Imagine que junto desse time inicial, outro time com cinco engenheiros usam Clojure em vez de Java. Investir tempo colocando aquele linter para Java não vai beneficiar em nada os engenheiros de Clojure.

Cada "partição virtual" que você tem (linguagens de programação, frameworks, bancos de dados) significa que você está diminuindo o impacto ao melhorar seu ferramental. O quanto as pessoas usam a coisa que você está mudando limita o raio de impacto da sua melhoria.

Se você aumentar o exemplo anterior para 200 ou 1000 engenheiros, a diferença se torna mais evidente. O Peter Seibel resumiu isso bem: "Uma vez que sua engenharia chega a determinado tamanho, os benefícios que você obtém ao investir em fazer todos os seus engenheiros ligeiramente mais produtivos começam a ser mais relevantes que pequenos ganhos que um time pode ter ao fazer coisas do seu próprio, ligeiramente diferente, jeito."

Quando olhamos para times horizontais (infraestrutura, segurança...), a situação é ainda mais clara. Cada "partição virtual" significa que você está colocando mais trabalho nos backlogs desses times, já que melhorias e correções para uma tecnologia não necessariamente se traduzem trivialmente para outras. Um exemplo concreto disso é a segurança em microsserviços. Nós implementamos uma camada de segurança para todos os nossos serviços Clojure e cada nova melhoria ou correção relacionada à segurança pode ser facilmente implantada para todos. Se nós tivéssemos, por exemplo, um serviço em Node.js, nós teríamos que primeiramente reimplementar toda a lógica de segurança nessa nova plataforma, e a cada vez que quiséssemos uma melhoria, o time de segurança precisaria implementá-la tanto para códigos Clojure (ou JVM em geral) quanto para código Node.js.

Uma outra forma de observar esse efeito é através de bibliotecas compartilhadas internamente. Pedaços de código que são comumente usados em vários serviços são extraídos para bibliotecas. Nós temos bibliotecas padronizadas para falar com o Datomic, DynamoDB, Kafka e para várias outras situações. Para cada runtime diferente que precisamos suportar, nós aumentamos o esforço necessário para manter e aprimorar esses padrões compartilhados.

Pavimentando a estrada enquanto também seguimos "off-road"

"Normalidade é uma estrada pavimentada: é confortável para caminhar, mas nenhuma flor cresce nela"
Van Gogh

Os benefícios podem agora parecer claros, mas quando e como devemos desviar das abordagens canônicas e introduzir variância? Vemos nossa escolha de ter menos tecnologias e ativamente melhorá-las como uma criação de uma "estrada asfaltada". Ao programar no Nubank, queremos uma experiência suave e com o mínimo de esforço possível, usando ferramentas eficientes e bem acabadas para os tipos de trabalhos mais comuns.

Mas ter uma boa estrada asfaltada não significa que nós só andamos por lá. Como Van Gogh disse: "Normalidade é uma estrada pavimentada: é confortável para caminhar, mas nenhuma flor cresce nela". Então, enquanto a nossa estrada principal deve ser o caminho de menor resistência, aumentando nossa eficiência nos problemas mais comuns, nós, de vez em quando, precisamos sair da estrada e ir pro off-road para procurar algumas "flores". Isso normalmente acontece quando uma ferramenta que já usamos: (1) não é ideal e existem opções melhores; ou (2) não funciona de forma alguma para o trabalho em questão.

Apenas sair da estrada principal de vez em quando também não é o suficiente. Se continuamos criando novos caminhos (novos ramais!), arriscamos nunca ter o tempo, energia e a capacidade de melhorar continuamente a nossa estrada principal. Continuando com a metáfora floral, Peter Seibel, de forma memorável, articulou o mesmo conceito em seu artigo de 2015 intitulado "Deixe 1000 flores florescerem. Depois arranque 999 delas pelas raízes". Isto é, times devem ser encorajados a experimentar novas ideias de forma autônoma (i.e., raízes florescendo fora da estrada principal), mesmo sabendo que a maioria delas não terá sucesso, ou seja, será descontinuada e não será ativamente mantida (i.e., arrancada). Mas nas vezes em que elas, de fato, são bem-sucedidas, investimos nelas e fazemos com que sejam parte da nossa estrada asfaltada.

As tecnologias mobile são um bom exemplo disso. No começo do Nubank, em 2014, usávamos Java (Android) e Objective-C (iOS). Quando ferramentas melhores surgiram, começamos a usá-las, e, com isso, migramos para Kotlin (Android) e Swift (iOS). Em algum momento, nosso time de conta bancária decidiu experimentar com React Native, que na época era uma nova tecnologia multiplataforma. Enquanto boa parte do resto do nosso app continuou nativo, os fluxos da conta eram inteiramente React Native. O time aprendeu muito ao longo da jornada e, eventualmente, decidiu experimentar com Flutter para saber se ele proveria uma melhor experiência para os desenvolvedores. Algumas funcionalidades foram, então, feitas em Flutter. Algumas flores floresceram, e, dada a fragmentação pouco saudável, tivemos que escolher em qual flor queríamos investir. Você pode ler mais sobre isso em nosso artigo, mas, alerta de spoiler, Flutter é agora nossa "estrada asfaltada" no mundo mobile, apesar de a estrada ainda estar bastante em construção.

O florescimento e eventual remoção das flores não são necessariamente decisões pontuais ou momentos específicos no tempo. Normalmente são processos que ocorrem ao longo de meses ou mesmo anos. Aqui estão alguns outros exemplos dentro do Nubank:

  • Desde o início, temos usado um framework para testes de integração feito internamente, que foi depois recriado como Selvage no mundo open-source. Nos últimos anos, também temos experimentado com um novo framework chamado State-flow. Recentemente, escolhemos recomendar o uso do State-flow e desincentivar outras opções. Entretanto, como não é claro o valor para o negócio de fazermos uma migração completa e forçada, essa transição deve continuar de forma orgânica.
  • Um caso de algo "não servindo para o trabalho" é quando tentamos utilizar Clojurescript com React Native. Apesar de usarmos essa linguagem extensivamente na Web, infelizmente, naquele momento, ela tinha limitações demais para usá-la em nosso app. Por isso, decidimos usar Typescript.
  • Nós dependíamos do Riemann para o nosso monitoramento de produção. Eventualmente, migramos para o ecossistema do Prometheus, que possuía mais funcionalidades, chegando a uma migração completa após alguns meses.
  • Nos nossos serviços que servem de backend para os nossos frontends (os BFFs), temos usado bastante Graph APIs (por exemplo GraphQL) no lugar de REST APIs. Ainda estamos discutindo uma possível decisão sobre padronizar um ou outro, ou viver com uma mistura de ambos.
  • Para interfaces Web, usamos o re-frame extensivamente, e, nos últimos anos, temos experimentado bastante com Pathom e Fulcro. Ao mesmo tempo, nossa página web pública usa Typescript e React. Agora, Flutter Web também é uma possibilidade. E ainda temos um front-end público legado em Angular 2. Em resumo, no mundo front-end pra web, temos, ainda, várias flores crescendo.

Em resumo, toda vez que um time acredita que podemos conseguir um benefício de uma tecnologia ou abordagem, nós experimentamos. Depois de tentar, algumas vezes fica claro que devemos investir e fazer uma migração completa, promovendo a "estrada de terra" para uma estrada asfaltada. Outras vezes, não é tão claro, e precisamos de mais tempo e energia para descobrir quais flores devem continuar florescendo e quais precisamos deixar pra lá. O importante é que os times estejam cientes dessa dinâmica e do ciclo de vida desses experimentos e mudanças.

Também é essencial que todos na engenharia enxerguem o valor alcançado através do alinhamento constante das nossas abordagens canônicas, ou, em outras palavras, que todos enxerguem o valor da canonicidade.

Pensamentos finais

Investir tempo na nossa "estrada asfaltada" tem trazido grandes benefícios à forma de trabalhar da engenharia do Nubank. Esses benefícios continuam aparecendo à medida que nossa organização cresce. Mas essas recompensas só foram possíveis com intencionalidade.

Se as pessoas são 100% autônomas para decidir quais tecnologias usar (e como usá-las), os times eventualmente vão divergir em como fazer as coisas. Por conta disso, precisamos ser intencionais em evitar variância não essencial, fomentando abordagens canônicas. Caso contrário, não vamos conseguir colher os benefícios. E ser intencional sobre essas questões significa, muitas vezes, fazer escolhas difíceis e ter conversas complicadas. Arrancar flores pela raiz não é nada divertido.

Ter diversidade em como vemos o mundo, como pensamos e no que tentamos, combinados com segurança psicológica para desafiarmos uns aos outros, é essencial para a continuidade do nosso negócio.

Como estamos dedicados a usar menos tecnologias, um risco que corremos é o de formar uma "monocultura" em torno das tecnologias da nossa estrada asfaltada (por exemplo, Clojure, Datomic, Kafka). Numa cultura como essa, as pessoas prefeririam louvar uma escolha de tecnologia e concordar com o grupo em vez de promover a diversidade de pensamento e pensamento crítico. Não queremos isso. Van Gogh provavelmente concordaria que nenhuma flor nasce numa cultura sem graça como essa. Ter diversidade em como vemos o mundo, como pensamos e no que tentamos, combinados com segurança psicológica para desafiarmos uns aos outros, é essencial para a continuidade do nosso negócio.

No lado oposto de "monoculturas", vemos casos extremos de "multiculturas": empresas com uma grande fragmentação tecnológica (por exemplo, muitas linguagens de programação usadas de múltiplas formas). Acreditamos que isso pode facilmente levar à fragmentação da cultura. Por exemplo, pessoas que participam de diferentes comunidades de linguagens de programação podem ter diferentes opiniões sobre como estruturar código. Por si só, não é algo tão relevante, mas com o tempo, essas pequenas diferenças de visão, combinadas com pouco intercâmbio entre comunidades, podem aumentar e estabelecer duas culturas completamente diferentes dentro de uma mesma empresa. Construir e aprimorar a cultura é bem difícil. O tribalismo faz ser ainda mais.

Por fim, os desafios continuam mudando enquanto a empresa cresce. O alinhamento e intencionalidade eram mais fáceis (mas não exatamente fáceis) quando tínhamos 50 engenheiros, mas são diferentes e complicados com 600. Temos nossas questões difíceis e controversas: quanto devemos impor mudanças, quanto devemos importunar as nossas flores crescendo, quando devemos fazer o upgrade de uma "estrada de terra" para a estrada asfaltada. De qualquer forma, ter uma mentalidade que evita variância não essencial no uso de tecnologias tem sido uma vantagem crucial para a nossa engenharia, nos permitindo ser mais colaborativos, flexíveis e eficientes.

Se mantivermos essa mentalidade ao longo dos anos, não vai importar se formos diferentes de outras empresas, contanto que continuemos tendo a nossa estrada principal bem asfaltada para nossos engenheiros trabalharem de forma eficiente, tendo, sempre, flores nascendo nas nossas estradas de terra.

Artigo postado originalmente em inglês no blog Building Nubank.