Organizando um projeto e convencionando nomes em Go
Aqui no PicPay, a comunidade da linguagem Go vem crescendo e tendo cada vez mais relevância como uma das principais stacks utilizadas na empresa. E, quanto mais desenvolvedores trabalhando com uma linguagem, mais projetos escritos nessa mesma tecnologia acabam surgindo. Quanto mais projetos, maior é a complexidade de se ter um padrão ou uma convenção na organização e estrutura dos repositórios de código. Sendo assim, a skill de Go (grupo de desenvolvedores que utilizam Golang e que se reúne periodicamente), propôs um conjunto de regras que definem uma boa organização de um projeto e convenções de nomes de funções e variáveis para serem adotadas utilizando Go.
Vale ressaltar que, apesar de se ter um modelo de organização de projetos em Go, exceções podem existir dependendo do contexto em que o código se aplica. Portanto, considere com certa flexibilidade a proposta apresentada neste artigo e não siga de forma rígida. Leve sempre em consideração seu bom senso e a experiência do time para tomadas de decisão que envolvam a estrutura apresentada neste artigo. :)
Go é uma linguagem estruturada em pacotes…
Qualquer projeto que nasça em Go é definido por meio de um ou mais pacotes, uma espécie de namespace que define um escopo, contexto ou domínio que o código será desenvolvido. Portanto, um projeto pode conter um ou mais pacotes, a depender dos contextos que envolvem o projeto. Por exemplo, para um projeto em que se deseja criar uma aplicação que organiza uma coleção de livros, um pacote book pode existir, onde se tem o contexto de um livro, reunindo funções, structs e interfaces que façam parte desse contexto.
Sendo assim, vamos começar falando sobre como podemos organizar um pacote em Go. Podemos dividir pacotes em duas categorias distintas: bibliotecas e aplicação.
Organizando uma biblioteca
Quando se fala em biblioteca, o pacote tem a função de servir de dependência para um outro pacote ou projeto, de onde ela será importada, com o objetivo de cumprir uma determinada funcionalidade.
Por exemplo, a stdlib de Go (uma biblioteca nativa da linguagem), possui um conjunto de pacotes que podem ser importados em outro pacote que precise de alguma funcionalidade. Para gerar estruturas de erro em Go, é utilizado o pacote errors
. Ele possui um único propósito : tratamento de erros no código.
O código acima exemplifica o uso do pacote errors
. Na linha 13, é retornada uma estrutura do tipo error
. Ou seja, o pacote errors
serve de biblioteca, uma dependência externa importada no pacote da aplicação.
Logo, quando falamos de biblioteca, cada pacote deverá conter código com um único propósito. Este segue o princípio de Single Responsibility do SOLID. No caso da stdlib, por exemplo, esse princípio é bem aplicado por meio da existência dos pacotes archive, cmd, crypto, math, e outros (para saber mais, acesse o guia da standard library de Go — https://pkg.go.dev/std).
Podem haver casos em que um pacote contenha uma funcionalidade que possui diversas implementações distintas. Nesse caso, a estrutura deverá conter um pacote "pai" servindo como guarda-chuva para sub-pacotes contendo as diferentes implementações. É o caso do pacote encoding da stdlib, que possui os sub-pacotes com suas respectivas implementações: base32, base64, json, csv, etc. Todos utilizam uma forma específica de codificar um dado, porém, em formatos diferentes.
Um pacote que funciona somente como uma biblioteca deve possuir um nome que descreva claramente seu propósito. Como se trata de uma única responsabilidade, não deveria ser tão complicado utilizar um nome curto para definir a responsabilidade de um pacote. Caso contrário, repense… pode ser que esse pacote englobe mais de uma responsabilidade.
TL;DR para bibliotecas
Quando se quer escrever código que sirva de biblioteca para outros projetos, leve em consideração o seguinte:
- Um pacote deve conter código com um único propósito;
- Quando existem diferentes formas de implementar o código com o mesmo propósito, defina um pacote pai com vários sub-pacotes dentro;
- O nome deve descrever exatamente o propósito daquele pacote;
- De preferência, nomes curtos e objetivos. Vá direto ao ponto. Se for difícil de atribuir um nome curto e objetivo, repense;
Organizando pacotes de aplicação
Quando transformamos código em produto ou serviço, o mesmo se torna uma aplicação, que é utilizada direta ou indiretamente pelo usuário final. A aplicação pode ser um microsserviço ou um monolito no backend, um app ou uma página web no frontend. Aqui no PicPay, Go é uma linguagem utilizada majoritariamente no backend. Portanto, iremos focar na estrutura de uma aplicação deste tipo.
No caso de uma aplicação, a organização de pacotes se torna sutilmente diferente de uma biblioteca. Aqui devemos nos atentar em quatros aspectos principais:
- A capacidade de se testar com facilidade as principais funcionalidades da aplicação;
- A legibilidade do código, que se traduz em “código fácil de entender”;
- A facilidade em refatorar um pedaço de código, tornando-o menos acoplado possível de outras partes;
- A manutenabilidade, em que ajustes são simples de serem feitos e debugs sem muita dor (afinal debugar uma aplicação sempre gera alguma dorzinha 😆);
É neste sentido que um pacote deve ser organizado. Para isso, utilizamos como referência a palestra de Go best practices, da dupla Ashley McNamara e Brian Ketelsen, onde são definidos alguns conceitos necessários para estruturar e organizar os pacotes de uma aplicação.
Dentre esses conceitos, são definidas duas categorias na organização de um pacote de uma aplicação: tipos de domínio (domain types) e serviços (services).
O que seriam os tipos de domínio?
São tipos que modelam as funcionalidades do negócio onde a aplicação irá atuar. Por exemplo, dentro do contexto do Google, um tipo de domínio seria busca, uma funcionalidade central do negócio. Em uma aplicação desenvolvida para um sistema de RH, tipos de domínio podem ser um funcionário, departamento e unidade de negócio.
Logo, um pacote em Go contendo um tipo de domínio, deverá possuir uma struct
que defina aquele tipo e seus principais atributos.
O pacote book contém um tipo de domínio representado pela struct
Book. Nele estão contidos os campos que compõe esse tipo de domínio e que poderão ser manipulados.
O pacote contendo o tipo de domínio também deverá conter as interfaces que definem como o tipo de domínio será manipulado e quais operações suporta.
A interface de UseCase
define as operações que podem ser realizadas no tipo de domínio Book.
Aí entramos na categoria de services
Falando em manipulação do tipo de domínio, a categoria de services implementa as operações definidas no tipo de domínio. No exemplo que estamos usando, a implementação da interface UseCase seria feita no mesmo pacote do domínio book
em um arquivo separado contendo a implementação.
A implementação acima manipula os dados do domínio book, definindo uma operação de criação e leitura. Existe um elemento novo nessa camada, chamada Repository. Ainda não definimos essa abstração, mas como o nome sugere, ela é responsável pela camada de persistência dos dados e também deve ser levado em conta como operação dentro do domínio Book. Logo, voltando para o arquivo que define o tipo de domínio e suas operações, definiremos uma nova interface que define as operações na camada de persistência.
Pronto! Agora temos a estrutura mínima para o domínio Book, onde foi definido o tipo de domínio, o serviço e a camada de persistência. A abstração da interface Repository permite a implementação da persistência por qualquer tecnologia (Postgres, MySQL, Mongo), o que fizer sentido para o contexto envolvido.
Logo abaixo, podemos ver um exemplo da organização do pacote book, contendo o tipo de domínio, serviço, repositório e um diretório mock utilizado nos testes (esse não faz parte do escopo deste artigo, portanto, não entraremos em detalhes aqui. Mas você pode saber mais nesse artigo escrito pelo Elton Minetto).
├── book
│ ├── book.go
│ ├── service.go
│ ├── service_test.go
│ ├── mock
│ │ ├── use_case.go
│ │ ├── repository.go
│ ├── postgres
│ │ ├── book_storage.go
│ │ ├── book_storage_test.go
No sub-pacote postgres temos a implementação da interface Repository.
Em uma aplicação é muito difícil que tenhamos apenas um tipo de domínio envolvido. Além dos domínios, temos as camadas que permitem que os dados sejam manipulados por clientes externos. Nos principais patterns adotados como Arquitetura Hexagonal (ou Ports And Adapters) e Clean Architecture, são chamados de driver actors os atores externos, aqueles que acessam de alguma forma um tipo de domínio e suas operações. Para isso, precisamos expor esse domínio de alguma forma e fazer com que toda a estrutura definida no domínio book seja utilizada por esses clientes.
├── cmd
│ ├── api
│ │ └── main.go
│ └── appctl
│ └── main.go
Na organização do projeto, o diretório cmd possui os pacotes responsáveis pelos acessos externos, os driver actors. No exemplo acima, temos o pacote api que implementa a camada REST que permite manipular o domínio de book através de uma API REST.
Neste exemplo, o arquivocmd/api/main.go
inicializa um servidor HTTP com o framework gin-gonic e a função ginHandle.Handlers
abstrai a associação do serviço de book instanciado com o handler servido em um endpoint. Qualquer framework pode ser utilizado aqui e uma boa prática é isolar essa camada de abstração. Aqui no PicPay, nós abstraímos o uso de frameworks web por meio de uma biblioteca desenvolvida dentro de casa, que está detalhado neste artigo, escrito pelo Elton Minetto.
O arquivo main.go
nada mais é que um inicializador do serviço e de injeção de dependência de outros módulos que o serviço depende, além da inicialização do servidor web que sobe em uma determinada porta. O código é limpo, legível e manutenível, com o isolamento entre as camadas, o que torna fácil testar cada uma delas de forma independente.
TL;DR para aplicações
A partir dos conceitos de tipos de domínio e serviços, segue abaixo uma proposta da estrutura de diretórios para a organização do projeto.
├── README.md
├── go.mod
├── go.sum
├── cmd
│ ├── api
│ │ └── main.go
│ └── appctl
│ └── main.go
├── config
│ ├── config.go
├── book
│ ├── book.go
│ ├── service.go
│ ├── service_test.go
│ ├── mock
│ │ ├── service.go
│ │ ├── repository.go
│ ├── postgres
│ │ ├── book_storage.go
│ │ ├── book_storage_test.go
├── internal
│ ├── http
│ ├── gin
│ ├── handler.go
| ├── book.go
│ ├── book_test.go
│ ├── event
│ │ ├── event.go
│ │ └── event_test.go
│ │ │── kafka
│ │ ├── event.go
│ │ ├── event_test.go
No diretório book
reside o pacote que define o tipo de domínio e as interfaces utilizadas para realizar operações e interações. As implementações dessas operações também residem nesse diretório, com a implementação dos casos de uso em service.go
e da camada de persistência no diretório que declara explicitamente a tecnologia utilizada (nesse exemplo, Postgres).
O config
é um pacote opcional e pode conter variáveis de ambiente e parametrizações que são feitas externamente a serem mapeadas internamente no projeto.
No pacote cmd
reside a main
da aplicação, que será utilizado na compilação e gerar o executável da aplicação. Pode ser para inicializar uma API REST em cmd/api
ou chamadas via CLI (Command Line Interface) em cmd/appctl
. Na main de cada tipo de inicialização da aplicação também deve conter a inicialização do serviço e as injeções de dependência necessárias.
O diretório internal reúne toda a implementação relacionada com protocolos utilizados para interação dos clientes da aplicação. No exemplo acima, foi desenvolvida uma camada de comunicação em HTTP utilizando o framework gin-gonic eminternal/http/gin
, onde o handler.go
recebe o serviço e cria as rotas que expõem as operações, associando às funções de handler implementadas em book.go
. Esse é o pacote responsável por toda a tradução das requisições HTTP para o que o serviço Book compreenda, sendo um adapter na Arquitetura Hexagonal, e retornando de volta o resultado da operação do serviço em uma resposta HTTP com a estrutura de dados estabelecida pelo contrato da API (por exemplo, um JSON com os campos correspondentes às propriedades de Book — nome, autor, gênero, etc.).
A camada de event
também reflete uma interação externa, no caso, eventos que chegam ou são publicados em uma mensageria, sendo um adapter para esse driver actor. No exemplo, foi utilizado o Kafka para publicar e consumir eventos em um ou mais tópicos.
Pronto! Uma estrutura que segue esse padrão está de acordo com os princípios definidos em Clean Architecture e Arquitetura Hexagonal, que permite legibilidade, manutenabilidade, testabilidade e refatorável, livre de acoplamentos entre as diferentes camadas da aplicação.
Um detalhe importante em casos em que há mais que um domínio no projeto:
Não deve haver nenhuma dependência entre pacotes de domínio. O motivo da sua existência é descrever os tipos de domínio e seus comportamentos. Além disso, um service chamar outro service, aumenta o acoplamento entre os domínios, podendo gerar dependência cíclica e dificuldade na reutilização e testabilidade do código.
Como convencionar nomes no meu projeto?
Uma das maiores dificuldades que um desenvolvedor possui é o de nomear coisas. Sejamos sinceros, nossa criatividade em criar código é inversamente proporcional ao de nomear variáveis e funções. 😅
Vamos tentar facilitar a vida dos gophers aqui, apresentando algumas boas práticas para convencionar nomes em tipos, funções, pacotes e variáveis.
Nomeando meus pacotes
Já mencionamos esse tipo de regra antes, mas vale reforçar aqui:
- Utilize nomes curtos! Prefira
transport
ao invés detransport_mechanisms
- O nome deve ser claro e refletir a responsabilidade daquele pacote. Por exemplo, para descrever a implementação externa, utilize nomes como
postgres
ouhttp
. Para uma manipulação de dados, use o tipo de dado manipuladobytes
,json
. Simple as that!
Lembre-se! Um pacote deve existir com um propósito único, uma responsabilidade apenas. Evite pacotes como util
e helper
. Se esses nomes passarem pela sua cabeça em algum momento, repense a responsabilidade desse pacote.
Nomeando minhas variáveis
Em Go, procure utilizar algumas convenções já adotadas pela comunidade para nomear variáveis, como:
- Utilizar camelCase ao invés de snake_case ou qualquer outro case;
- Em índices, procure ser curto. Utilize apenas uma letra:
for i:= 0; i < 10; i++ {}
- Para outros casos, os nomes também podem ser curtos, porém descritivos:
var count int
var duration Time.Duration
Uma outra regra legal de adotar para definir o nome de uma variável é: quanto mais longe você utiliza a variável de onde ela foi declarada, maior deverá ser seu nome. Isso diminui a complexidade cognitiva da leitura do código, e permite que o desenvolvedor se atente mais à lógica que a variável em si. Caso você precise realizar um scroll na tela para encontrar a variável sendo utilizada, ela deveria ter um nome maior do que apenas uma letra (isso também pode ser um indício de que seu código precise ser refatorado 👀).
- para representar uma lista/slice/array use letras repetidas:
var bb []*Bookfor i, b := range bb {
// dentro do loop, é utilizado uma letra apenas
fmt.Println(b.Author)}
Nomeando minhas funções
Para nomear funções, também são utilizadas algumas boas práticas que foram, inclusive, adotadas nas ferramentas de lint mais adotadas pela comunidade.
- Evite repetir o nome do pacote em uma função:
log.Info() // good
log.LogInfo() // bad
O nome do pacote já define seu propósito. Além de estender o nome da função desnecessariamente, piora a legibilidade do código.
- A comunidade Go não adota o uso de Getters e Setters como em outras linguagens (embora eu seja fã 😃):
customer.Name // goodcustomer.GetName() // bad
- Para interfaces que possuem apenas uma função, adicione o sufixo er nome:
type Requester interface {
Request(method string, body []byte, headers string, endpoint string)
}
Para mais de uma função, nomeie com algo que represente sua principal funcionalidade:
type Storage interface {
Read(id int) (*Customer, error)
List() ([]*Customer, error)
Save(c *Customer) error
Delete(id int) error
}
Conclusões
Adotar boas práticas de desenvolvimento está diretamente relacionada com a organização e estrutura de diretórios de um projeto. Princípios definidos no SOLID, KISS, clean architecture podem ser adotados na prática de diferentes formas, dependendo da linguagem e do contexto que o projeto está inserido.
Aqui pudemos mostrar como organizar um projeto em Go, levando em conta particularidades da linguagem e os recursos que ela disponibiliza e que permitem uma melhor legilibilidade, manutenabilidade, desacoplamento e facilidade na escrita de testes.
A partir de um conjunto de convenções adotadas pela comunidade Go, também foi possível reunir um conjunto de regras que devem facilitar a definição de nomes de pacotes, variáveis, funções e tipos.
Todas esse conteúdo pode passar por evoluções contínuas ao longo do tempo, conforme a linguagem for evoluindo e trazendo novos conceitos (aqui não utilizamos nada relacionado à Generics, por exemplo, que pode ser ainda mais difundida pela comunidade Go no futuro e adotando novas boas práticas). Portanto, considere o contexto, sua experiência e a do seu time e absorva aquilo que mais se aplique no seu projeto. A essência está nos princípios: fácil leitura, fácil manutenção, fácil de testar e fácil de refatorar.
Não desenvolvemos nada sozinho e, é nossa responsabilidade adotar práticas que permitam um fluxo melhor de trabalho e desenvolvimento com o time que você está inserido e/ou com a comunidade de desenvolvedores, caso esteja trabalhando em um projeto open source.