Recentemente nos deparamos com um problema clássico em empresas de TI: Um código monolítico que funcionava a anos, mas por ter sido construído em outra época, com menos cuidado e menos tempo, sua manutenção se tornou uma tarefa tão lenta e complexa de ser executada, que adicionar novas features ficou inviável.
Para resolver esse problema e facilitar as tarefas de manutenção, decidimos extrair um fluxo chave e implementá-lo na forma de um microserviço.
No entanto, nosso desafio foi escolher:
- Um microserviço simples e enxuto
- Uma arquitetura que conseguisse comportar lógica de negócio e ainda fosse modular e fácil de manter.
Sendo assim, escolhemos Go por ser uma linguagem de programação:
- Eficiente e feita para funcionar no mundo Web
- Prioriza a legibilidade e, como consequência, favorece a manutenibilidade.
Introdução ao Domain Driven Design (DDD) e porquê utilizá-lo na Dito
Sumariamente, o Domain Driven Design (DDD) é um conjunto de boas práticas para construção de arquiteturas com o objetivo de mitigar os problemas que surgem à medida que a complexidade de um software cresce, ou seja, garantir que a manutenibilidade do projeto seja mantida mesmo com anos de desenvolvimento.
O contexto histórico em que o Domain Driven Design (DDD) é normalmente aplicado envolve projetos grandes e monolíticos, geralmente do mundo Java e C#. Tal contexto traz para o Domain Driven Design (DDD) a reputação de ser um modelo relacionado a arquiteturas grandes e complexas.
Em nosso contexto, o aspecto de garantir a manutenibilidade mesmo com uma carga relativamente grande de lógica de negócio era desejável. Por outro lado, se utilizar o Domain Driven Design (DDD) realmente implicasse em uma arquitetura grande e complexa, nosso serviço deixaria de ser simples e, ao invés de facilitar a manutenção da complexidade, o Domain Driven Design (DDD) contribuiria para ela, uma vez que haveriam muitas pastas com poucos arquivos.
Dito isso, existe um aspecto importante do Domain Driven Design (DDD) que nem sempre é lembrado: o Domain Driven Design (DDD) não estipula nenhuma estrutura arquitetural específica, logo, é possível criar uma arquitetura simples utilizando suas boas práticas.
A arquitetura da Dito
Ao construir a arquitetura, procuramos
- Criar uma estrutura de pastas bem rasa para manter a simplicidade;
- Seguir os padrões idiomáticos da linguagem que escolhemos, no caso, Go; e
- Utilizar as práticas de Domain Driven Design (DDD) a medida que fizessem sentido no nosso contexto.
Assim, nossa estrutura de pastas ficou bastante simples. Um exemplo dessa arquitetura está disponível para ser consultado em um projeto público no Github aqui.
Em mais detalhes, decidimos ter três pastas na raiz do projeto dessa estrutura:
- cmd/
Uma pasta idiomática em Go que contém os entrypoints para o nosso projeto i.e. os pacotes com as funções main();- domain/
Uma pasta criada para deixar claro para todo desenvolvedor onde é permitido colocar lógica de negócio;- infra/
Uma pasta criada para deixar claro onde colocar lógica de interação com ferramentas externas como banco de dados, filas, provedores de envio de email etc.
As três pastas têm uma característica em comum: todas têm exatamente um nível de subpastas, e cada uma destas subpastas é um pacote Go.
Em especial, a cmd/ é uma pasta já idiomática de Go e decidimos mantê-la por esse motivo. Dentro dela colocamos os pontos de entrada do nosso programa i.e. os pacotes que contém as funções main().
Como essa já é uma pasta comum em Go, não vou entrar em detalhes. Ao invés disso, trarei com mais detalhes as pastas domain/ e infra/.
A pasta domain/
O nome “domain” é usado para descrever o domínio do negócio da empresa, ou seja, qual são os conceitos que precisamos mencionar para explicar tudo o que a empresa faz, cada feature e aplicação, incluindo inclusive as lógicas de negócio que descrevem o comportamento que nosso cliente espera da nossa empresa/produto.
Dessa forma, colocamos na pasta domain/
- As estruturas que definem nossas entidades do domínio (struct User, struct Notification etc.);
- Nossos Serviços, que são os pacotes que realmente implementam as lógicas de negócio (apesar de fazer sentido colocar algumas lógicas nos métodos das entidades);
- As interfaces que permitem que todos esses serviços interajam de forma desacoplada, essencial em Go.
Para manter a arquitetura simples, decidimos agrupar todos os structs das entidades em um único arquivo, chamado domain/entities.go. Nesse arquivo também contém os métodos dessas estruturas e as funções construtoras, quando necessárias.
Da mesma forma, decidimos colocar todas as interfaces em um único arquivo, chamado domain/contracts.go.
Essas interfaces descrevem todos os comportamentos que os serviços expõem, de forma que nunca é necessário que um serviço dependa diretamente de outro serviço. Ao invés disso, um serviço pode receber durante a etapa de injeção de dependência, uma instância que implemente uma dessas interfaces, o que normalmente significa uma instância de um serviço. Em alguns casos, é útil poder trocar a instância por um mock ou outro tipo de serviço, até em tempo de execução. Uma das vantagens de ter um código bem desacoplado.
Por fim, os serviços são construídos num formato bem padrão, uma vez que cada serviço possui
- Um struct chamado Service, que armazena todas as dependências desse serviço;
- Uma função NewService(), que recebe como parâmetro essas dependências e instância, com serviço seguindo as regras necessárias;
Essa instância criada pelo NewService() possui métodos que implementam interfaces do domain/contracts.go de forma que um serviço pode ser injetado em outro quando necessário ou usado diretamente nos demais casos.
Para ilustrar, suponha que temos duas interfaces: uma para o envio de eventos e outra para o envio de notificações. E ainda, que o serviço responsável pelo envio de notificações também precise enviar eventos.
O código ficaria da seguinte maneira:
// Arquivo domain/entities.go
type Notification struct {
Subject string
Message string
// ...
}
func NewNotification(args...) { ... }
type Event struct {
Type string
// ...
}
// Arquivo domain/ontracts.go
type NotificationSender interface {
SendNotification(notif Notification)
}
type EventSender interface {
SendEvent(eventType string, otherArgs...)
}
// Arquivo domain/notifsender/notifsender.go
type Service struct {
eventSender domain.EventSender
// outras dependencias ...
}
func NewService(eventSender domain.EventSender, otherArgs...) Service {
return Service{
eventSender: eventSender,
// outras dependencias ...
}
}
func (s Service) SendNotification(notif domain.Notification) {
s.eventSender.SendEvent("notificando usuário ...")
// envio da notificação ...
}
// Arquivo cmd/sender/main.go
func main() {
sender := notifsender.NewService(
eventsender.NewService(),
// outras dependencias ...
)
for {
// Recebe o pedido de envio de notificação de algum lugar...
// Constrói a notificação...
notif := domain.NewNotification(args...)
// Envia a notificação
sender.SendNotification(notif)
}
}
Essa arquitetura tem uma série de vantagens interessantes
- É fácil encontrar qualquer entidade, e como estamos falando de um microserviço, o arquivo entities.go não fica muito grande; e
- Como todas as funcionalidades dos serviços implementam uma interface, é super desacoplado.
Além disso, é fácil evitar dependências circulares (um grande problema em Go), quando seguimos algumas regras
- O pacote domain/ não pode importar nenhum outro pacote;
- Os pacotes de serviço só podem importar o domain/ (e a infra, que tratarei mais adiante); e
- O pacote responsável por fazer a injeção de dependência é sempre o pacote main.
Dessa forma, cria-se uma árvore de dependências que nunca tem ciclos:
- O main importa todos os outros pacotes
- Cada pacote de serviço importa as interfaces e tipos do domínio
- O domínio não importa nenhum outro pacote
A pasta infra/
Nessa pasta colocamos tudo aquilo que não faz parte do domínio por ser detalhe técnico, ou seja, que não é conhecido por nossos clientes ou do seu interesse.
Por exemplo
- O código que interage com o banco;
- O código que interage com a Web usando HTTP, RPC, SOAP ou qualquer outro; e
- Bibliotecas de log, de escrever e ler de arquivos etc.
Isso é, quase todo tipo de interação com ferramentas externas que precisamos fazer, mas cujos detalhes não afetam o nosso negócio.
Para manter essa estrutura simples e fácil de ser utilizada, a deixamos quase idêntica à a pasta domain
- Temos um arquivo infra/contracts.go que contém as interfaces; e
- Temos subpacotes que implementam essas interfaces.
Note que não temos entidades na infra.
Quando precisamos de uma estrutura, geralmente estamos falando de um objeto que vai ser utilizado por uma interface como argumento das funções ou como tipo de retorno.
Em ambos os casos esse tipo de dado se caracteriza como um Objeto de Transferência de Dados ou DTO, em inglês.
Esse tipo de estrutura só é necessária junto as interfaces, onde realmente faz seu papel de transferir dados. Dessa forma, colocamos nossos DTOs logo abaixo das interfaces que precisam deles.
Assim, o arquivo infra/contracts.go fica da seguinte maneira:
type EmailSender interface {
SendEmail(msg Message) SendReport
}
// Um dos DTOs utilizados na interface acima:
type Message struct {
// ...
}
// Outro dos DTOs utilizados na interface acima:
type SendReport struct {
// ...
}
Os subpacotes da infra, ou seja, seus provedores, são extremamente parecidos com os pacotes dos serviços:
// Arquivo infra/emailsender/emailsender.go
// Ao invés de Service, usamos o nome Client:
type Client struct {
// dependencias deste client
}
func NewClient(/* dependencias do client... */) Client {
return Client{
// ...
}
}
Novamente, as mesmas regras são mantidas:
- Toda funcionalidade de um pacote deve ser exposta através de uma interface em infra/contracts.go;
- Se um pacote quiser ter uma dependência de outro, ele deve depender da interface implementada pelo outro, e não diretamente do outro pacote;
- O pacote infra/ não pode importar nem um outro pacote;
- Os subpacotes da infra só podem importar a infra/;
- O pacote main é o único que pode importar todos os outros e é o responsável por fazer as injeções de dependência.
Uma última regra importante: é permitido para os Serviços importarem o pacote infra/ para terem acesso às interfaces da infra, mas não é permitido que eles importem nenhum pacote da infra diretamente.
Logo, todos os pacotes ficam 100% desacoplados, não é possível ter dependências cíclicas e toda a arquitetura ficou simples e flat, com pouquíssimos níveis de pastas.
Nossa equipe gostou bastante dessa arquitetura e ela tem se tornado um modelo para nossos novos projetos. Em especial, se temos um microserviço com pouca lógica de negócio, nós deixamos de criar a pasta domain/, mas continuamos usando as pastas cmd/ e infra/.
Uma grande vantagem dessa abordagem é que qualquer provedor que eu escrever para a infra/ de um serviço pode ser facilmente reutilizado na infra de outro serviço, independente se for um serviço com mais ou menos lógica de negócio.
Além disso, se no futuro a complexidade desse serviço aumentar e for necessário organizar a lógica de negócio, será possível simplesmente criar a pasta domain/ e mover toda a lógica que antes estaria acumulada no main para essa nova pasta, crescendo a complexidade da arquitetura à medida que cresce a complexidade do código.
Confira abaixo o conteúdo da palestra do Vinícius Garcia e Fábio Rodrigues, engenheiros de software na Dito, apresentado na trilha de back-end do primeiro TDC BH: