Em Go, interface{} representa uma interface vazia. Isso significa que ela não define nenhum método e, por isso, qualquer tipo de dado em Go a implementa automaticamente. Em outras palavras, ela pode armazenar qualquer tipo e valor.
Esse recurso é útil quando não se sabe, antecipadamente, qual tipo será utilizado como em situações que envolvem deserialização de JSON ou comunicação genérica entre componentes.
No Go 1.18, foi introduzido o tipo any, que nada mais é do que um alias para interface{}.
Observação: A partir deste ponto do texto, usarei apenas any, então lembre-se: any e interface{} são equivalentes.
Na maioria dos casos, o uso do any é considerado uma generalização excessiva (overgeneralization).
Ao utilizá-lo, perde-se a verificação de tipos em tempo de compilação. Isso significa que, para acessar o valor armazenado em um any, geralmente é necessário realizar uma type assertion. Ou seja, é preciso checar se o valor dentro do any é realmente do tipo esperado, o que pode tornar o código mais complexo e propenso a erros.
A type assertion, que consiste em extrair o valor de dentro de um any e convertê-lo de volta para seu tipo original, pode também gerar sobrecarga de desempenho, especialmente se for usada com frequência no código.
O any também pode prejudicar a legibilidade e a manutenibilidade do código, já que não se sabe, com clareza, qual tipo de dado está sendo manipulado.
No exemplo abaixo, não há nenhum erro em nível de compilação no código; no entanto, futuros desenvolvedores que precisarem utilizar a struct Store terão que ler a documentação ou se aprofundar no código para entender como utilizar seus métodos.
Retornar valores do tipo any também pode ser prejudicial, pois não há nenhuma garantia, em tempo de compilação, de que o valor retornado pelo método será utilizado de forma correta e segura, o que pode, inclusive, levar a um panic durante a execução.
Ao utilizar any, perdemos os benefícios do Go ser uma linguagem estaticamente tipada, como a verificação de tipo em tempo de compilação, a detecção precoce de erros e a otimização de desempenho, já que o compilador não precisa realizar verificações de tipo em tempo de execução.
Em vez de utilizar any nas assinaturas dos métodos, recomenda-se criar métodos específicos, mesmo que isso resulte em alguma duplicação, como múltiplas versões de métodos Get e Set.
Ter mais métodos não é necessariamente um problema, pois os clientes que os utilizarão podem criar suas próprias abstrações por meio de interfaces.
Mas o uso de any é sempre ruim? A resposta é: não.
Na programação, raramente existe uma regra sem exceções.
Como mencionado no início do texto, há situações em que o uso de any é justificado, especialmente quando não é possível conhecer antecipadamente o tipo dos dados com os quais se estará lidando, como em:
Serialização e deserialização de JSON quando não se conhece o formato exato do JSON em tempo de compilação.
Cache genérico para armazenar qualquer tipo de valor.
Logger genérico que aceita qualquer tipo de valor.
Embora o uso de any ofereça flexibilidade, ele deve ser evitado sempre que for possível utilizar tipos específicos. Fora de casos bem específicos, prefira manter a tipagem forte do Go para garantir segurança, legibilidade e manutenibilidade do código.
Referência: HARSANYI, Teiva. 100 Go mistakes and how to avoid them. Shelter Island: Manning, 2022.
A arquitetura de software pode ser vista sob diferentes perspectivas assim como acontece com a arquitetura de um prédio, que pode ser analisada sob os pontos de vista estrutural, hidráulico, elétrico, entre outros.
Com base nessa ideia, Philippe Kruchten propôs o modelo 4+1 de visões, descrito em um artigo que se tornou referência na área, referenciado a seguir:
Segundo o autor, dividir a arquitetura em múltiplas visões ajuda a tratar separadamente as preocupações dos diferentes stakeholders do sistema como usuários finais, desenvolvedores, engenheiros de software, gerentes de projeto, entre outros.
De forma geral, a arquitetura de software lida com o projeto e a estruturação do sistema em um nível mais alto. Ela fornece uma visão ampla do sistema, sem entrar nos detalhes do código-fonte ou da implementação específica de cada parte.
Essa arquitetura resulta da combinação de elementos estruturais organizados de maneira a atender tanto aos requisitos funcionais quanto aos não funcionais como confiabilidade, escalabilidade, portabilidade e disponibilidade.
Pausa para conceito:
Requisitos funcionais são as funcionalidades que o sistema deve oferecer, ou seja, o que ele deve fazer. Exemplos: autenticar usuários, processar pagamentos, gerar relatórios.
Requisitos não funcionais são as características de qualidade ou restrições do sistema, ou seja, como ele deve se comportar. Exemplos: desempenho, segurança, escalabilidade, usabilidade, confiabilidade.
Para descrever a arquitetura de software, foi desenvolvido um modelo baseado em cinco visões principais:
Visão lógica: representa o modelo de objetos do sistema, especialmente quando se utiliza uma abordagem de design orientado a objetos. Exemplo: a classe Cliente se relaciona com a classe Pedido, formando a base do modelo de domínio do sistema.
Visão de processos: aborda os aspectos de concorrência e sincronização da aplicação. Exemplo: o sistema conta com um processo dedicado ao tratamento de requisições simultâneas de usuários, garantindo integridade dos dados.
Visão física: descreve como o software é distribuído no hardware, refletindo sua arquitetura física e aspectos de desempenho. Exemplo: o serviço de autenticação é executado em um servidor separado, garantindo maior segurança e escalabilidade.
Visão de desenvolvimento: mostra como o software está organizado no ambiente de desenvolvimento, destacando a estrutura dos módulos e componentes. Exemplo: o projeto está dividido em módulos como frontend, backend e bibliotecas compartilhadas, organizados em um repositório central.
Visão de cenários (casos de uso): integra as demais visões por meio de interações reais com o sistema, baseadas nos requisitos funcionais. Essa visão ajuda a validar a arquitetura e orientar sua evolução. Exemplo: o caso de uso “Realizar Compra” percorre simultaneamente elementos das visões lógica, de processos e física.
Para cada visão do modelo 4+1, os arquitetos de software podem escolher um ou mais estilos arquiteturais adequados. Isso permite a coexistência de diferentes estilos em um mesmo sistema, proporcionando flexibilidade e adaptabilidade para atender a múltiplos requisitos, funcionais e não funcionais.
A seguir, aprofundamos cada uma das principais visões arquiteturais:
Arquitetura lógica
A arquitetura lógica foca, principalmente, nos requisitos funcionais do sistema. Ela define como o sistema será decomposto em um conjunto de abstrações, geralmente baseadas no domínio do problema, utilizando princípios da orientação a objetos como abstração, encapsulamento e herança.
Essa decomposição tem como objetivo facilitar a análise funcional e a identificação de mecanismos e elementos de design reutilizáveis, comuns entre diferentes partes do sistema.
Arquitetura de processos
A arquitetura de processos se concentra nos requisitos não funcionais do sistema, abordando aspectos como concorrência, sistemas distribuídos, integridade, tolerância a falhas e a interação entre as abstrações da visão lógica e o processo, como, por exemplo, “em qual thread uma operação sobre um objeto será executada”. Ela lida com o controle e a organização de tarefas e processos que compõem a aplicação, assegurando que o sistema funcione de maneira eficiente e robusta.
Essa arquitetura pode ser descrita por diferentes camadas de abstração, onde cada camada tem sua responsabilidade específica. No nível mais alto, a arquitetura de processos pode ser vista como uma rede de processos lógicos que se comunicam entre si. Várias camadas lógicas podem coexistir simultaneamente, compartilhando recursos físicos como CPU e memória, sem que uma sobrecarregue a outra.
Um processo é o agrupamento de tarefas que formam uma unidade executável e, nesse nível, pode ser controlado, ou seja, pode ser iniciado, interrompido, reconfigurado ou finalizado. Uma característica importante da arquitetura de processos é que os processos podem ser replicados para distribuir a carga de processamento, o que melhora a disponibilidade do sistema, tornando-o mais escalável.
Os programas são frequentemente divididos em tarefas independentes e cada tarefa é representada por uma thread de controle separado. Cada thread pode ser agendada e executada de forma independente em diferentes nós de processamento, o que permite maior flexibilidade e performance no sistema distribuído.
As tarefas principais se comunicam entre si por meio de mecanismos de comunicação bem definidos, como mensagens síncronas e assíncronas, chamadas de procedimento remoto (RPC) e transmissão de eventos. Já as tarefas secundárias podem se comunicar por mecanismos como rendezvous (mecanismo de sincronização em que dois ou mais processos ou threads aguardam uns aos outros para se encontrarem e trocarem dados antes de continuar a execução) ou memória compartilhada.
É importante que as tarefas principais não façam suposições sobre a localização física das tarefas no sistema, ou seja, não devem assumir que estão no mesmo processo ou nó de processamento. Isso garante maior flexibilidade e independência em relação à alocação física do sistema, permitindo uma melhor escalabilidade e portabilidade.
Arquitetura de desenvolvimento
A arquitetura de desenvolvimento foca na organização do software dentro do ambiente de desenvolvimento, onde o sistema é dividido em subsistemas e bibliotecas, que podem ser trabalhados por um ou mais desenvolvedores. Esses subsistemas são organizados de forma hierárquica em camadas, com interfaces bem definidas para comunicação entre elas.
A descrição completa da arquitetura de desenvolvimento só é possível após a identificação de todos os elementos do software, mas regras essenciais, como particionamento, agrupamento e visibilidade, já podem ser estabelecidas.
Essa arquitetura leva em consideração principalmente requisitos internos relacionados à facilidade de desenvolvimento, gerenciamento, reutilização e restrições das ferramentas ou da linguagem usada.
A visão de desenvolvimento serve como base para a alocação de requisitos e tarefas, organização das equipes, planejamento de custos e monitoramento do progresso do projeto. Além disso, é fundamental para a reutilização, portabilidade e segurança do software, sendo crucial para o estabelecimento de uma linha de produto.
Arquitetura física
A arquitetura física foca nos requisitos não funcionais do sistema, como disponibilidade, confiabilidade (tolerância a falhas), desempenho (taxa de transferência) e escalabilidade.
O software é executado em uma rede de computadores ou nós de processamento e os diversos elementos do sistema como redes, processos, tarefas e objetos precisam ser mapeados adequadamente entre esses nós.
O sistema deve suportar várias configurações físicas que podem ser usadas para desenvolvimento, testes ou implantação em diferentes locais ou para diferentes clientes. Por isso, o mapeamento do software para os nós precisa ser altamente flexível, causando o menor impacto possível no código-fonte, para garantir que a arquitetura se adapte facilmente a diferentes cenários e condições operacionais.
Cenários (casos de uso)
Agora é hora de integrar todos os elementos das quatro arquiteturas. As arquiteturas funcionam em conjunto por meio de um conjunto de cenários, que são basicamente casos de uso mais gerais.
Esses cenários servem como uma abstração dos requisitos mais importantes do sistema. Eles desempenham dois papéis principais:
como um impulsionador para a descoberta dos elementos arquitetônicos durante o projeto, ajudando na identificação e definição dos componentes essenciais;
como uma ferramenta de validação e ilustração após a conclusão do projeto arquitetônico, sendo usados para verificar a conformidade da arquitetura e servir como base para os testes do protótipo arquitetônico.
Exemplo de Cenário: Sistema de Compra Online
O usuário acessa o site de compras e faz login: O sistema valida as credenciais inseridas e ativa o perfil de usuário correspondente.
O sistema exibe os produtos disponíveis: O servidor consulta a base de dados e apresenta os produtos organizados por categoria, disponibilidade e preço.
O usuário seleciona um produto e adiciona ao carrinho de compras: O sistema atualiza o carrinho de compras, alocando os recursos necessários para armazenar o item selecionado.
O usuário procede para o checkout e insere os dados de pagamento: O sistema valida os dados inseridos, como número do cartão e endereço de cobrança, e verifica a disponibilidade do pagamento.
Após validação, o sistema confirma a compra e emite um recibo: O sistema processa o pagamento, atualiza o estoque e envia um recibo eletrônico para o usuário, finalizando a transação.
Conclusão
Este modelo de visão “4+1” permite que diferentes partes interessadas acessem as informações que são mais relevantes para elas sobre a arquitetura de software. Engenheiros de sistemas analisam a arquitetura a partir das visões física e de processo. Usuários finais, clientes e especialistas em dados a visualizam pela visão lógica. Já gerentes de projeto e a equipe responsável pela configuração de software a observam a partir da visão de desenvolvimento.
Interfaces oferecem uma forma de especificar o comportamento de um objeto, mesmo que ele ainda não exista.
Elas podem ser utilizadas quando há necessidade de replicar comportamentos, promover o desacoplamento entre componentes ou restringir certas funcionalidades.
Mas surge a pergunta: onde devemos criá-las?
A resposta, na maioria dos casos, é: do lado de quem vai utilizá-las (lado do consumidor).
É comum vermos interfaces sendo definidas junto com suas implementações concretas (lado do produtor), mas essa prática não é a mais recomendada em Go. Isso porque ela pode forçar o consumidor a depender de métodos que não precisa, dificultando a flexibilidade e o reuso.
É o consumidor quem deve decidir se precisa de uma abstração e qual deve ser sua forma.
Essa abordagem segue o Princípio de Segregação de Interface (Interface Segregation Principle, a letra “I” do SOLID), que estabelece que um cliente não deve ser forçado a depender de métodos que não utiliza.
Portanto, a melhor prática é: o produtor fornece implementações concretas e o consumidor decide como usá-las, inclusive se deseja abstraí-las por meio de interfaces.
Exemplo
Imagine um sistema em que precisamos gerar um recibo de pedido e para isso precisamos buscar os nomes do cliente e do produto com base em seus respectivos IDs.
Cenário 1 – Prefira: Os produtores fornecem a implementação concreta para acesso a esses dados e o consumidor define as interfaces com os métodos que ele utilizará para gerar o recibo.
Cenário 2 – Evite: As interfaces são criadas no lado dos produtores e o pacote receipt acaba tendo acesso a métodos que não precisa, o que gera acoplamento desnecessário e fere o princípio de segregação de interface.
Em alguns contextos específicos, pode fazer sentido definir a interface no lado do produtor. Nesses casos, o ideal é mantê-la o mais enxuta possível, para favorecer a reutilização e facilitar sua composição com outras interfaces.
Por exemplo: um pacote que fornece um cliente HTTP para consumo de uma API externa, como um serviço de pagamentos. Esse pacote pode expor uma interface com métodos como CreateCharge, RefundPayment e GetTransactionStatus, permitindo que qualquer sistema que consuma o pacote saiba exatamente quais operações são suportadas.
Como o próprio pacote é o especialista no domínio da API externa, ele pode definir uma interface enxuta e bem estruturada, facilitando tanto o uso quanto a substituição por implementações mockadas.
Leitura complementar
As abstrações devem ser descobertas e não criadas. Isso significa que não se deve criar abstrações se não há uma real necessidade no momento. Criam-se interfaces quando necessário e não quando se prevê que elas serão utilizadas.
Quando se criam muitas interfaces, o fluxo do código fica mais complexo, dificultando a leitura e o entendimento do sistema.
Se não existe uma razão para definir uma interface, e é incerto que ela melhorará o código, devemos questionar o motivo da existência dela e por que não simplesmente chamar a implementação concreta.
É comum que desenvolvedores exagerem (overengineer), tentando adivinhar qual seria o nível perfeito de abstração com base no que acreditam que será necessário no futuro. Porém, isso deve ser evitado, pois, na maioria dos casos, polui o código com abstrações desnecessárias.
Referência: HARSANYI, Teiva. 100 Go mistakes and how to avoid them. Shelter Island: Manning, 2022.
Uma das perguntas mais frequentes sobre Go é se existe algum guia de estilo para a escrita de código, como a nomenclatura de variáveis, funções e pacotes, semelhante ao PEP 8 do Python.
Essa dúvida, inclusive, está presente na página de Frequently Asked Questions (FAQ) da documentação oficial da linguagem, na seguinte pergunta:
Is there a Go programming style guide?
E a resposta é:
There is no explicit style guide, although there is certainly a recognizable “Go style”. (Não existe um guia de estilo explícito, embora haja certamente um “estilo Go” reconhecível.)
O que existe são convenções para orientar sobre nomenclatura, layout e organização de arquivos.
O gofmt, ferramenta oficial da linguagem de programação Go, formata automaticamente o código-fonte de acordo com o estilo padrão da linguagem. Embora não seja obrigatório, esse estilo é amplamente adotado pela comunidade como o jeito certo de escrever código Go. O gofmt pode ser integrado a IDEs como o Visual Studio Code, permitindo que a formatação seja aplicada automaticamente sempre que o arquivo for salvo.
Existem também documentos que contêm conselhos sobre esses tópicos, como:
O guia mais completo que encontrei até hoje é o Go Style Decisions, publicado pelo Google (vale lembrar que a linguagem Go surgiu dentro da própria empresa). Esse guia é, inclusive, referenciado na documentação oficial do Go, na página Go Code Review Comments.
Considero esse documento minha principal referência quando o assunto é estilo, e abaixo destaco os pontos mais relevantes sobre nomenclatura, segundo a minha visão pessoal:
No código Go, utiliza-se CamelCase em vez de underscores, como no snake_case, para nomes compostos por várias palavras dos identificadores.
Nota: Os nomes dos arquivos de código-fonte não são identificadores Go e, portanto, não precisam seguir as mesmas convenções de nomenclatura. Eles podem conter underscores, como, por exemplo, my_api_handlers.go (isso é até o recomendado para esses casos).
Pacotes
Os nomes dos pacotes devem ser curtos e conter somente letras minúsculas.
Se forem compostos por múltiplas palavras, elas devem ser escritas juntas, sem separação ou caracteres especiais.
Se for necessário importar um pacote cujo nome contenha underscore (geralmente código de terceiros), ele deve ser renomeado durante a importação para um nome mais adequado seguindo as boas práticas.
Seja consistente usando sempre o mesmo nome para o mesmo pacote importado em diferentes arquivos.
Evite usar nomes que também possam ser utilizados por variáveis, a fim de prevenir sombreamento no código.
Exceção a regra:
Quando for necessário testar um pacote como um usuário externo, ou seja, testando apenas sua interface pública, é preciso adicionar o sufixo *_test ao nome do pacote nos arquivos de teste.
Isso força a importação explícita do pacote, permitindo a realização do chamado teste de caixa-preta (black box testing).
Eles devem ser curtos, sendo uma ou duas letras do próprio nome, e usados de forma igual para todos os métodos daquele tipo.
Prefira
Evite
func (t Tray)
func (tray Tray)
func (ri *ResearchInfo)
func (info *ResearchInfo)
func (w *ReportWriter)
func (this *ReportWriter)
func (s *Scanner)
func (self *Scanner)
Constantes
Deve ser utilizado CamelCase, assim como os outros nomes.
Constantes exportadas começam com letra maiúscula, enquanto constantes não exportadas começam com letra minúscula.
Isso se aplica mesmo que contrarie convenções de outras linguagens, como em Java, onde as constantes são geralmente todas maiúsculas e com underscore. Por exemplo, MAX_RETRIES se tornaria MaxRetries em Go.
Além disso, não é necessário começar o nome com a letra “k”.
Siglas
Palavras em nomes que são siglas ou acrônimos (ex: URL e OTAN) devem manter a capitalização (todo maiúsculo ou tudo minúsculo).
Em nomes com múltiplas siglas (ex: XMLAPI, pois contém XML e API), cada letra de uma mesma sigla deve ter a mesma capitalização, mas as siglas diferentes podem usar capitalizações distintas.
Se a sigla contiver letras minúsculas (ex: DDoS, iOS, gRPC), ela deve manter a forma original, a menos que precise mudar a primeira letra para exportação (como em linguagens de programação).
Nome
Escopo
Prefira
Evite
XML API
Exportada
XMLAPI
XmlApi, XMLApi, XmlAPI, XMLapi
XML API
Não exportada
xmlAPI
xmlapi, xmlApi
iOS
Exportada
IOS
Ios, IoS
iOS
Não exportada
iOS
ios
gRPC
Exportada
GRPC
Grpc
gRPC
Não exportada
gRPC
grpc
DDoS
Exportada
DDoS
DDOS, Ddos
DDoS
Não exportada
ddos
dDos, dDOS
ID
Exportada
ID
ID
ID
Não exportada
id
iD
DB
Exportada
DB
Db
DB
Não exportada
db
dB
Getters
Nomes de funções e métodos não devem utilizar o prefixo “Get” ou “get”, a menos que o conceito envolva a palavra “get” de forma natural (como em uma requisição HTTP GET).
Prefira começar o nome diretamente com o substantivo. Por exemplo, use Counts em vez de GetCounts.
Se a função envolve um cálculo complexo ou a execução de uma chamada remota, é recomendável usar palavras como Compute (calcular) ou Fetch (buscar), em vez de Get, para deixar claro ao desenvolvedor que a execução da função pode demorar, bloquear ou falhar.
Repetição
Um código deve evitar repetições desnecessárias, que podem ocorrer de diferentes formas, especialmente na nomeação de pacotes, variáveis, constantes ou funções exportadas.
Nas funções, não repita o nome do pacote:
Mais exemplos:
Prefira
Evite
widget.New
widget.NewWidget
widget.NewWithName
widget.NewWidgetWithName
db.Load
db.LoadFromDatabase
gtutil.CountGoatsTeleported goatteleport.Count
goatteleportutil.CountGoatsTeleported
mtpb.MyTeamMethodRequest myteampb.MethodRequest
myteampb.MyTeamMethodRequest
Nos métodos, não repita o nome do receptor:
Não repita o nome das variáveis passadas por parâmetros:
Não repita os nomes e tipos dos valores de retorno:
Quando for necessário remover a ambiguidade de funções de nomes similares, é aceitável incluir informações adicionais.
Nome da variáveis VS tipo
O compilador sempre conhece o tipo de uma variável, e na maioria dos casos, o tipo também é claro para o desenvolvedor com base em como a variável é utilizada.
Só é necessário especificar o tipo de uma variável quando seu valor aparecer duas vezes no mesmo escopo.
Se o valor aparece em múltiplas formas, isso pode ser esclarecido com o uso de uma palavra adicional, como raw (bruto) e parsed (processado).
Contexto externo VS nomes locais
Nomes que incluem informações já presentes no contexto ao redor geralmente adicionam ruído desnecessário, sem agregar benefícios.
O nome do pacote, do método, do tipo, da função, do caminho de importação e até mesmo o nome do arquivo já fornecem contexto suficiente para qualificar automaticamente todos os nomes dentro deles.
Leitura complementar
Qual seria um tamanho bom de um nome de variável, constantes e função?
A regra é que o tamanho do nome deve ser proporcional ao escopo dele e inversamente proporcional ao número de vezes que ele é usado dentro desse escopo.
Uma variável com escopo de arquivo pode precisar de várias palavras, enquanto uma variável dentro de um bloco interno (como um IF ou um FOR) pode ser um nome curto ou até uma única letra, para manter o código claro e evitar informações desnecessárias.
Aqui está uma orientação (não é regra) do que seria o tamanho de cada escopo em linhas.
Escopo pequeno: uma ou duas pequenas operações, entre 1 e 7 linhas.
Escopo médio: algumas pequenas operações ou uma grande operação, entre 8 e 15 linhas.
Escopo grande: uma ou algumas operações grandes, entre 15 e 25 linhas.
Escopo muito grande: várias operações que podem envolver diferentes responsabilidades, mais de 25 linhas.
Um nome que é claro em um escopo pequeno (por exemplo, c para um contador) pode não ser suficiente em um escopo maior, exigindo mais clareza para lembrar ao desenvolvedor de seu propósito.
A especificidade do conceito também pode ajudar a manter o nome de uma variável conciso. Por exemplo, se houver apenas um banco de dados em uso, um nome curto como db, que normalmente seria reservado para escopos pequenos, pode continuar claro mesmo em um escopo maior.
Nomes de uma única palavra, como count e options, são um bom ponto de partida. Caso haja ambiguidade, palavras adicionais podem ser incluídas para torná-los mais claros, como userCount e projectCount.
E como dica final: evite remover letras para reduzir o tamanho do nome, como em Sbx em vez de Sandbox, pois isso pode prejudicar a clareza. No entanto, há exceções, como nomes amplamente aceitos pela comunidade de forma reduzida, como db para database e ctx para context.
O cubo da escalabilidade AFK é um modelo tridimensional que representa diferentes estratégias de escalabilidade de sistemas. Ele é composto por três eixos (X, Y e Z), cada um correspondendo a uma abordagem distinta para escalar aplicações, bancos de dados e até organizações.
Ele foi criado por Abbott, Fisher e Kimbrel, daí a sigla AFK, e foi introduzido no livro “The Art of Scalability”.
O ponto de origem do cubo, definido por x = 0, y = 0, z = 0, representa um sistema monolito ou uma organização composta por uma única pessoa que realiza todas as tarefas, sem qualquer especialização ou divisão com base na função, no cliente ou no tipo de solicitação. À medida que se move ao longo de qualquer um dos eixos, X, Y ou Z, o sistema se torna mais escalável por meio de diferentes estratégias, como a separação por funcionalidades, segmentação por tipo de cliente ou usuário, e replicação para distribuição de carga.
A imagem a seguir apresenta o cubo específico para aplicações.
Eixo X
Representa uma forma de escalar sistemas clonando a aplicação. Isso significa criar várias cópias idênticas do mesmo sistema e distribuir as requisições entre elas. Essa distribuição é feita por um balanceador de carga (load balancer), que envia os pedidos dos usuários para uma das instâncias disponíveis.
Esse tipo de escalabilidade é muito comum e fácil de entender. Ele funciona bem para lidar com o aumento do número de acessos, melhorando a capacidade (mais usuários ao mesmo tempo) e a disponibilidade (mais chances de continuar funcionando se uma instância falhar). Além disso, é uma solução de baixo custo e simples de aplicar, já que basta copiar o sistema que já existe.
No entanto, o eixo X tem limitações. Como todas as instâncias são iguais, se o sistema for muito grande ou tiver muitos dados, ele pode ficar lento. Isso porque, mesmo com mais cópias, o sistema ainda é um monolito, e esse tipo de estrutura não lida bem com crescimentos muito complexos.
Eixo Y
Trata da divisão da aplicação por funcionalidades. Isso significa separar o sistema em partes menores, chamadas de serviços, onde cada um cuida de uma função específica, como gerenciamento de pedidos ou de clientes.
Essa divisão ajuda a resolver problemas que surgem quando a aplicação cresce e fica mais complexa. Com essa separação, cada serviço pode funcionar de forma independente, o que melhora o desempenho, facilita a manutenção e evita que falhas em uma parte do sistema afetem as outras.
Apesar de ser mais cara que outras formas de escalabilidade, como duplicar servidores (eixo X), a escalabilidade pelo eixo Y é muito eficaz para organizar o código e permitir que ele cresça de forma sustentável.
Essa abordagem é a base da arquitetura de microsserviços, onde a aplicação é formada por vários serviços pequenos e especializados. Cada um desses serviços pode ser escalado separadamente, conforme a necessidade.
Eixo Z
Trata da separação do trabalho com base em atributos dos pedidos, como o cliente ou o usuário que está fazendo a requisição. Ou seja, em vez de cada instância da aplicação cuidar de todos os dados, cada uma cuida apenas de uma parte, como um grupo específico de usuários.
Essa abordagem ajuda a escalar o sistema quando há crescimento no número de clientes, transações ou volume de dados. Cada instância da aplicação fica responsável por uma “fatia” dos dados, o que reduz o tempo de processamento e melhora o desempenho geral.
Embora o software não precise ser dividido em vários serviços como no eixo Y, ele precisa ser escrito de forma que permita essa separação, o que pode aumentar o custo e a complexidade da implementação.
Em resumo, a escalabilidade pelo eixo Z é ideal para lidar com grandes volumes de dados e usuários, dividindo a carga de forma inteligente entre diferentes instâncias da aplicação.
Combinando os três eixos, o cubo da escalabilidade AFK oferece uma visão completa e prática de como evoluir sistemas conforme crescem em uso, dados e complexidade. Cada eixo resolve diferentes tipos de desafios: o X melhora capacidade e disponibilidade com cópias idênticas; o Y reduz a complexidade por meio da divisão funcional; e o Z permite lidar com grandes volumes de dados e usuários por meio da segmentação. Juntos, esses eixos ajudam arquitetos e desenvolvedores a escolher as melhores estratégias de escalabilidade de acordo com as necessidades do sistema, promovendo soluções mais eficientes, resilientes e preparadas para o crescimento.
Referências:
ABBOTT, Martin L.; FISHER, Michael T.. The Art of Scalability: scalable web architecture, processes, and organizations for the modern enterprise. Boston: Addison-Wesley, 2010.
A ordenação de elementos em um array é uma tarefa frequentemente utilizada em diversos programas. Embora existam muitos algoritmos de ordenação bem conhecidos, ninguém quer ficar copiando blocos de código de um projeto para outro.
Por isso, o Go oferece a biblioteca nativa sort.
Ela permite ordenar arrays in-place (a ordenação é feita diretamente na estrutura de dados original, sem criar uma cópia adicional do array) com qualquer critério de ordenação, utilizando uma abordagem baseada em interfaces.
A função sort.Sort não faz suposições sobre a estrutura dos dados; ela apenas exige que o tipo a ser ordenado implemente a interface sort.Interface, que define três métodos essenciais apresentados a seguir.
Um ótimo exemplo inicial é o tipo sort.StringSlice, fornecido pela própria biblioteca sort. Ele já implementa a interface sort.Interface, conforme mostrado abaixo:
Seguindo essas regras, é possível ordenar structs com base em seus campos como no exemplo a seguir, que ordena uma lista de alunos pela nota:
Com a estrutura já definida, também é possível verificar se o array está ordenado usando sort.IsSorted, além de realizar a ordenação em ordem reversa com sort.Reverse. As funções são demonstradas abaixo, utilizando a mesma estrutura do exemplo anterior.
A biblioteca também oferece outras funções bastante úteis:
Ints(x []int): ordena uma slice de inteiros em ordem crescente.
IntsAreSorted(x []int) bool: verifica se o slice x está ordenada em ordem crescente.
Strings(x []string): ordena um slice de strings em ordem crescente.
StringsAreSorted(x []string) bool: verifica se a slice x está ordenada em ordem crescente.
ATENÇÃO
Na própria documentação do pacote sort, na data de escrita deste artigo, existe uma nota na função sort.Sort que diz o seguinte:
Em muitas situações, a função mais recente slices.SortFunc é mais ergonômica e apresenta melhor desempenho.
Essa mesma nota se encontra na função sort.IsSorted:
Em muitas situações, a função mais recente slices.IsSortedFunc é mais ergonômica e apresenta melhor desempenho.
Em ambos os casos, recomendo avaliar qual abordagem adotar com base no que for mais vantajoso para a sua situação: a simplicidade das funções do pacote sort ou a possível otimização oferecida pelo pacote slices.
Para descobrir qual delas apresenta melhor desempenho no seu contexto, utilize os benchmarks do Go (fica aqui o dever de casa para você, caro leitor, dar uma conferida nisso).
A partir do Go 1.22, algumas funções do pacote sort já utilizam a biblioteca slices internamente, como é o caso de sort.Ints, que usa slices.Sort, e sort.IntsAreSorted, que usa slices.IsSorted.
Para saber mais, confira as documentações oficiais:
Os microsserviços são um padrão de arquitetura de software no qual o sistema é dividido em vários serviços pequenos e independentes que se comunicam entre si. Cada serviço funciona como uma unidade modular isolada, com barreiras bem definidas. Em vez de acessarem diretamente funções ou pacotes uns dos outros, os serviços interagem exclusivamente por meio de APIs. Isso promove um baixo acoplamento entre os componentes do sistema.
Essa abordagem é especialmente útil no desenvolvimento de sistemas grandes e complexos. À medida que o sistema cresce, torna-se cada vez mais difícil mantê-lo como o monolito (conforme discutido em Monolito: um começo inteligente, não um erro), além de ser mais complicado para uma única pessoa compreendê-lo por completo.
Os principais benefícios dos microsserviços são:
Cada serviço é pequeno e fácil de manter (não é à toa o prefixo “Micro”)
Cada serviço deve ser pequeno, o que facilita sua manutenção e evolução ao longo do tempo. Por conta do tamanho reduzido, o código torna-se mais simples de entender, tanto em relação ao que o serviço deve fazer quanto à forma como ele realiza suas tarefas. Esse fator também impacta positivamente o desempenho da aplicação, pois serviços menores tendem a ser desenvolvidos e executados mais rapidamente, o que, por sua vez, contribui para o aumento da produtividade das equipes de desenvolvimento.
Serviços são independentes
Cada serviço pode ser entregue e escalado de forma independente, sem depender diretamente de outros serviços. Isso acelera a entrega de soluções ao mercado e aos clientes, reduz o tempo de resposta para corrigir bugs que afetam o usuário e aumenta a satisfação do cliente ao permitir entregas constantes de valor.
Além disso, cada equipe dentro da empresa pode ser responsável por um ou mais serviços específicos, o que facilita a autonomia dos times. Assim, cada equipe consegue desenvolver, implantar e escalar seus serviços sem depender do andamento dos demais times, promovendo agilidade e especialização.
Ponto de atenção
Quando é necessário implantar soluções que envolvem múltiplos serviços, é fundamental que essa implantação seja cuidadosamente coordenada entre os times. Isso evita que alterações em um serviço causem falhas em outro. É necessário criar um plano de implementação que respeite as dependências entre os serviços, definindo uma ordem lógica de implantação. Essa abordagem contrasta com o monolito, na qual é possível atualizar vários componentes de forma conjunta e atomizada.
Isolamento de falhas
A independência de cada serviço garante o isolamento de falhas dentro do sistema. Por exemplo, se um erro crítico acontece no serviço A, o serviço B pode continuar operando normalmente. Esse isolamento evita que falhas se propaguem, aumentando a resiliência da aplicação como um todo. Em contraste com o monolito em que uma falha em um componente pode derrubar o sistema inteiro.
Permite experimentos e adoção de novas tecnologias
Como os serviços são pequenos e isolados, reescrevê-los utilizando novas linguagens ou tecnologias se torna uma tarefa viável e de baixo risco. Essa flexibilidade permite que equipes experimentem soluções mais modernas e eficientes. Caso a nova abordagem não traga os resultados esperados, o serviço pode ser descartado ou revertido sem comprometer o restante do sistema.
Mas nem tudo são flores…
No mundo da tecnologia, não existe uma bala de prata.
Como qualquer abordagem arquitetural, os microsserviços também apresentam desvantagens que devem ser cuidadosamente consideradas antes da adoção.
Definir cada serviço é custoso
Não existe uma metodologia universal e precisa para decompor um sistema em serviços. Essa tarefa exige conhecimento profundo do domínio do negócio e experiência em design de sistemas. Uma decomposição mal feita pode resultar em um monolito distribuído, que consiste em um conjunto de serviços fortemente acoplados que precisam ser implantados juntos. Esse cenário combina o pior dos dois mundos: a rigidez do monolito com a complexidade dos microsserviços, sem os reais benefícios de nenhum dos dois.
Sistemas distribuídos são complexos
Ao optar por microsserviços, os desenvolvedores precisam lidar com a complexidade natural de sistemas distribuídos. A comunicação entre serviços se dá por mecanismos de comunicação entre processos, como chamadas HTTP ou mensagens assíncronas, que são mais complexas do que simples chamadas de método dentro de uma aplicação monolítica. Além disso, os serviços devem ser preparados para lidar com falhas parciais, como indisponibilidade de outros serviços ou alta latência nas respostas.
Essa complexidade técnica exige que os times tenham habilidades mais avançadas em desenvolvimento, arquitetura e operações. Além disso, há uma carga operacional significativa: múltiplas instâncias de diferentes serviços precisam ser monitoradas, escaladas, atualizadas e gerenciadas em produção. Para que os microsserviços funcionem bem, é necessário investir em um alto grau de automação, incluindo integração contínua, entrega contínua, provisionamento de infraestrutura e observabilidade.
Decidir quando adotar microsserviços é desafiador
Outro desafio importante está relacionado ao momento certo de adotar a arquitetura de microsserviços. Em muitos casos, especialmente no início do desenvolvimento de um novo sistema, os problemas que os microsserviços resolvem ainda não existem. A escolha por uma arquitetura distribuída desde o início pode tornar o desenvolvimento mais lento e oneroso. Isso é particularmente crítico em startups, cujo foco inicial costuma ser validar o modelo de negócio e lançar rapidamente. Para essas situações, começar com um monolito pode ser a melhor decisão, com a possibilidade de migrar para microsserviços à medida que a aplicação cresce e a complexidade exige uma arquitetura mais escalável.
Como é possível perceber, a arquitetura de microsserviços oferece diversos benefícios, mas também impõe desafios técnicos, operacionais e organizacionais significativos. Por isso, sua adoção deve ser feita com cautela e alinhada às reais necessidades do projeto. No entanto, para aplicações complexas, como sistemas web ou soluções SaaS, os microsserviços frequentemente se mostram a escolha mais adequada, especialmente no longo prazo.
As funções init são funções especiais que são executadas antes de qualquer função no código.
Elas são a terceira etapa na ordem de inicialização de um programa em Go, sendo:
Os pacotes importados são inicializados;
As variáveis e constantes globais do pacote são inicializadas;
As funções init são executadas.
Seu uso mais comum é preparar o estado do programa antes da execução principal na main, como por exemplo: verificar se variáveis de configuração estão corretamente definidas, checar a existência de arquivos necessários ou até mesmo criar recursos ausentes.
É possível declarar várias funções init em um mesmo pacote e em pacotes diferentes, desde que todas utilizem exatamente o nome init.
Quando isso ocorre, a ordem de execução delas é a seguinte dependendo do caso:
Em pacotes com dependência entre si
Se o pacote A depende do pacote B, a função init do pacote B será executada antes da função init do pacote A.
O Go garante que todos os pacotes importados sejam completamente inicializados antes que o pacote atual comece sua própria inicialização.
Essa dependência entre pacotes também pode ser forçada usando o identificador em branco, ou blank identifier (_), como no exemplo abaixo que o pacote foo será importado e inicializado, mesmo que não seja utilizado diretamente.
Múltiplas funções init no mesmo arquivo
Quando existem várias funções init no mesmo arquivo, elas são executadas na ordem em que aparecem no código.
Múltiplas funções init em arquivos diferentes do mesmo pacote
Nesse caso, a execução segue a ordem alfabética dos arquivos. Por exemplo, se um pacote contém dois arquivos, a.go e b.go e ambos possuem funções init, a função em a.go será executada antes da função em b.go.
No entanto, não devemos depender da ordem de execução das funções init dentro de um mesmo pacote. Isso pode ser arriscado, pois renomeações de arquivos podem alterar a ordem da execução, impactando o comportamento do programa.
Apesar de úteis, as funções init possuem algumas desvantagens e pontos de atenção que devem ser levados em conta na hora de escolher usá-las ou não:
Elas podem dificultar o controle e tratamento de erros, pois já que não retornam nenhum valor, nem de erro, uma das únicas formas de tratar problemas em sua execução é via panic, que causa a interrupção da aplicação.
Podem complicar a implementação de testes, por exemplo, se uma dependência externa for configurada dentro de init, ela será executada mesmo que não seja necessária para o escopo dos testes unitários. Além de serem executadas antes dos casos de teste, o que pode gerar efeitos colaterais inesperados.
A alteração do valor de variáveis globais dentro da função init pode ser uma má prática em alguns contextos:
Dificulta testes: como o estado global já foi definido automaticamente pela init, é difícil simular diferentes cenários ou redefinir esse estado nos testes.
Aumenta o acoplamento: outras partes do código passam a depender implicitamente do valor dessas variáveis globais, tornando o sistema menos modular.
Reduz previsibilidade: como a inicialização acontece automaticamente e sem controle do desenvolvedor, fica mais difícil entender ou modificar o fluxo de execução do programa.
Afeta reutilização: bibliotecas que dependem de init com variáveis globais são menos reutilizáveis, pois forçam comportamentos no momento da importação.
Em resumo, as funções init são úteis para configurações iniciais, mas seu uso deve ser criterioso, pois podem dificultar testes, tratamento de erros e tornar o código menos previsível.
Referência: HARSANYI, Teiva. 100 Go mistakes and how to avoid them. Shelter Island: Manning, 2022.
Um modelo mental, no contexto de software, é a representação interna que um desenvolvedor constrói para compreender como um sistema ou trecho de código funciona. Ele não é visível, mas sim uma estrutura de raciocínio que permite prever o comportamento do sistema com base no conhecimento adquirido até aquele momento.
Durante a leitura ou escrita de código, o desenvolvedor precisa manter esse modelo atualizado para entender, por exemplo, quais funções interagem entre si, como os dados fluem e quais efeitos colaterais podem ocorrer.
Códigos com boa legibilidade exigem menos esforço cognitivo para manter esses modelos mentais coerentes e atualizados.
Um dos fatores que contribuem para uma boa legibilidade é o alinhamento do código.
Na Golang UK Conference de 2016, Mat Ryer apresentou o conceito de line of sight in code (“linha de visão no código”, em tradução literal). Ele define linha de visão como “uma linha reta ao longo da qual um observador tem visão desobstruída”.
Aplicado ao código, isso significa que uma boa linha de visão não altera o comportamento da função, mas torna mais fácil para outras pessoas entenderem o que está acontecendo.
A ideia é que o leitor consiga acompanhar o fluxo principal de execução olhando para uma única coluna, sem precisar pular entre blocos, interpretar condições ou navegar por estruturas aninhadas.
Uma boa prática é alinhar o caminho feliz da função à esquerda do código. Isso facilita a visualização imediata do fluxo esperado.
O caminho feliz é o fluxo principal de execução de uma função ou sistema, em que tudo ocorre como esperado sem erros, exceções ou desvios.
Na imagem a seguir é possível visualizar um exemplo de caminho feliz:
De modo geral, quanto mais níveis de aninhamento uma função possui, mais difícil ela se torna de ler e entender, além de ocultar o caminho feliz, como podemos ver na imagem abaixo:
Retorne o mais cedo possível de uma função. Essa prática melhora a legibilidade porque reduz o aninhamento e mantém o caminho feliz limpo e direto.
Evite usar else para retornar valores, especialmente quando o if já retorna algo. Em vez disso, inverta a condição if (flip the if) e retorne mais cedo, deixando o fluxo principal fora do else.
Coloque o retorno do caminho feliz (o sucesso) como a última linha da função. Isso ajuda a deixar o fluxo principal claro e previsível. Quem lê sabe que, se nada der errado, o sucesso acontece no final.
Separe partes da lógica em funções auxiliares para que as funções principais fiquem curtas, claras e fáceis de entender. Funções muito longas e cheias de detalhes dificultam a leitura e a manutenção.
Se você tem blocos de código muito grandes e indentados (por exemplo, dentro de um if, for ou switch), considere extrair esse bloco para uma nova função. Isso ajuda a manter a função principal mais plana, legível e fácil de seguir, evitando profundidade excessiva e “efeito escada” no código.
Em resumo, cuidar da legibilidade do código é essencial para manter modelos mentais claros. Práticas como alinhar o caminho feliz, evitar aninhamentos profundos e extrair funções tornam o código mais fácil de entender, manter e evoluir.
Referências:
HARSANYI, Teiva. 100 Go mistakes and how to avoid them. Shelter Island: Manning, 2022.
Na arquitetura de software, o monolito é um estilo em que todas as camadas do sistema (como front-end, back-end, lógica de negócio e acesso a dados) estão agrupadas em um único arquivo executável ou componente de deploy.
Apesar de muitas vezes ser mal visto pela comunidade, esse modelo pode trazer diversos benefícios em aplicações pequenas ou em estágios iniciais do projeto, como:
Desenvolvimento simplificado – IDEs e outras ferramentas de desenvolvimento funcionam muito bem com aplicações únicas, tornando o desenvolvimento mais ágil.
Facilidade para mudanças radicais – Como todo o código está no mesmo lugar, é possível alterar APIs, regras de negócio e chamadas ao banco de forma centralizada e consistente.
Testes facilitados – Testes de integração e end-to-end são mais diretos, pois é possível iniciar toda a aplicação, invocar APIs REST e testar as interfaces.
Deploy direto – A publicação é simplificada, geralmente bastando copiar o artefato gerado da compilação para o servidor de aplicação.
Escalabilidade simples – Basta executar múltiplas instâncias da aplicação e utilizar um balanceador de carga (load balancer) para distribuir as requisições.
No entanto, com o crescimento da aplicação, tarefas como desenvolvimento, testes, deploy e escalabilidade tendem a se tornar mais complexas. Essa complexidade, por sua vez, acaba desmotivando os desenvolvedores que precisam lidar com o sistema.
Corrigir bugs ou implementar novas funcionalidades passa a consumir muito tempo. Pior ainda, forma-se uma espiral negativa: o código difícil de entender leva a alterações mal feitas, o que só aumenta a complexidade e os riscos.
Outro problema comum em monolitos que crescem demais é o tempo necessário para realizar o deploy. Como todo o sistema está em uma única base de código, qualquer pequena alteração exige a publicação da aplicação inteira. Se algo der errado nesse processo, há o risco de toda a aplicação ficar indisponível.
Além disso, o monolito pode gerar mais duas complicações importantes. Mesmo sendo simples de escalar horizontalmente, não é possível escalar apenas partes específicas da aplicação, por exemplo, um módulo que recebe um tipo específico de requisição com alto volume. A escalabilidade é sempre feita de forma integral.
A obsolescência tecnológica também é uma preocupação. A linguagem de programação ou framework escolhidos no início do projeto tendem a permanecer até o fim da vida útil do sistema. Isso ocorre porque atualizar toda a base de código pode ser tão trabalhoso que, muitas vezes, é mais viável reescrever o sistema do zero do que atualizá-lo.
Em resumo, o monolito é uma boa escolha no início de um projeto pela sua simplicidade. Porém, com o crescimento da aplicação, surgem desafios que podem comprometer a escalabilidade, manutenção e agilidade. Nesses casos, pode ser necessário repensar a arquitetura para acompanhar a evolução do sistema.