Organização e camadas
LidoEsta aula explora como organizar um projeto Go em camadas, aplicando separação de responsabilidades, injeção de dependência manual e organização por feature, além de discutir os trade-offs envolvidos. O objetivo é capacitar o aluno a estruturar aplicações de forma escalável e de fácil manutenção.
Bem-vindo à aula 79 do nosso curso de Go! Hoje vamos mergulhar em um dos aspectos mais cruciais do desenvolvimento de software: a organização do código em camadas. Quando um projeto cresce, a forma como estruturamos nossos pacotes e dependências determina a facilidade de manutenção, testabilidade e evolução do sistema. Nesta aula, você aprenderá padrões práticos para separar responsabilidades, injetar dependências manualmente e organizar pacotes por feature, sempre considerando os trade-offs envolvidos.
Vamos partir de exemplos concretos, mostrando como aplicar esses conceitos em Go. Ao final, você terá um repertório sólido para decidir a melhor arquitetura para seus projetos, evitando armadilhas comuns e promovendo um código limpo e sustentável.
Separação de responsabilidades
A separação de responsabilidades é um princípio fundamental que preconiza que cada componente do sistema deve ter uma única razão para mudar. Em Go, isso se traduz em dividir o código em pacotes com papéis bem definidos, como camadas de apresentação, domínio e infraestrutura. Essa divisão facilita a manutenção, pois alterações em uma camada não afetam diretamente as outras, desde que as interfaces sejam respeitadas.
Imagine uma aplicação web simples: temos handlers HTTP que recebem requisições, serviços que contêm a lógica de negócio e repositórios que acessam o banco de dados. Ao separar essas responsabilidades, cada parte pode ser desenvolvida e testada independentemente. Em Go, isso é feito criando pacotes distintos, como handlers, services e repositories. Vamos ver um exemplo:
// handlers/user.go
package handlers
import (
"encoding/json"
"net/http"
"github.com/meuprojeto/services"
)
type UserHandler struct {
userService *services.UserService
}
func NewUserHandler(us *services.UserService) *UserHandler {
return &UserHandler{userService: us}
}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
user, err := h.userService.GetUserByID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
// services/user_service.go
package services
import "github.com/meuprojeto/repositories"
type UserService struct {
userRepo *repositories.UserRepository
}
func NewUserService(ur *repositories.UserRepository) *UserService {
return &UserService{userRepo: ur}
}
func (s *UserService) GetUserByID(id string) (*User, error) {
return s.userRepo.FindByID(id)
}
// repositories/user_repository.go
package repositories
import "database/sql"
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) FindByID(id string) (*User, error) {
// implementação de acesso ao banco
}
Observe como cada pacote tem uma responsabilidade única: handlers lidam com HTTP, services com regras de negócio, e repositories com persistência. Essa separação permite, por exemplo, substituir o banco de dados sem alterar a camada de serviços, desde que as interfaces sejam respeitadas.
Dependency injection manual
Injeção de dependência (DI) é um padrão onde as dependências de um componente são fornecidas de fora, em vez de o componente criá-las internamente. Em Go, a injeção manual é simples e direta: passamos as dependências como parâmetros para construtores ou métodos. Isso torna o código mais testável, pois podemos passar implementações falsas (mocks) durante os testes.
Vamos ver como aplicar DI manual no exemplo anterior. Em vez de criar o repositório dentro do serviço, nós o injetamos via construtor:
// services/user_service.go
package services
type UserRepository interface {
FindByID(id string) (*User, error)
}
type UserService struct {
userRepo UserRepository
}
func NewUserService(ur UserRepository) *UserService {
return &UserService{userRepo: ur}
}
Aqui, definimos uma interface UserRepository que o serviço espera. Agora, no arquivo principal (main), fazemos a composição das dependências:
// main.go
package main
import (
"database/sql"
"github.com/meuprojeto/handlers"
"github.com/meuprojeto/repositories"
"github.com/meuprojeto/services"
)
func main() {
db, _ := sql.Open("postgres", "dsn")
userRepo := repositories.NewUserRepository(db)
userService := services.NewUserService(userRepo)
userHandler := handlers.NewUserHandler(userService)
// configurar rotas e iniciar servidor
}
A injeção manual traz benefícios como clareza nas dependências e facilidade de teste. Em testes, podemos passar um mock do repositório:
// services/user_service_test.go
package services
type MockUserRepo struct{}
func (m *MockUserRepo) FindByID(id string) (*User, error) {
return &User{ID: "1", Name: "Alice"}, nil
}
func TestGetUserByID(t *testing.T) {
mockRepo := &MockUserRepo{}
svc := NewUserService(mockRepo)
user, _ := svc.GetUserByID("1")
if user.Name != "Alice" {
t.Errorf("esperava Alice, obtive %s", user.Name)
}
}
Pacotes por feature
Uma alternativa à organização por camadas é organizar os pacotes por feature (ou módulo). Em vez de ter pacotes handlers, services e repositories globais, criamos um pacote para cada funcionalidade, contendo todos os arquivos relacionados àquela feature. Por exemplo, para um sistema de usuários, teríamos:
/internal/user/
handler.go
service.go
repository.go
model.go
Essa abordagem aproxima o código do domínio e facilita a localização de arquivos relacionados. Em Go, é comum usar o diretório internal para pacotes que não devem ser importados por outros módulos. Vamos ver como fica:
// internal/user/handler.go
package user
import (
"encoding/json"
"net/http"
)
type Handler struct {
service *Service
}
func NewHandler(s *Service) *Handler {
return &Handler{service: s}
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
// usa h.service
}
// internal/user/service.go
package user
type Service struct {
repo *Repository
}
func NewService(r *Repository) *Service {
return &Service{repo: r}
}
func (s *Service) GetByID(id string) (*User, error) {
return s.repo.FindByID(id)
}
// internal/user/repository.go
package user
import "database/sql"
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) FindByID(id string) (*User, error) {
// ...
}
Essa organização por feature reduz o acoplamento entre funcionalidades e torna o projeto mais coeso. É especialmente útil em domínios complexos, onde cada feature pode evoluir independentemente.
Trade-offs
Nenhuma abordagem é perfeita; cada uma tem suas vantagens e desvantagens. A separação por camadas, por exemplo, pode levar a um excesso de pacotes e uma certa cerimônia, especialmente em projetos pequenos. Já a organização por feature pode misturar responsabilidades se não for bem cuidada, e pode dificultar a reutilização de código entre features.
Vamos listar alguns trade-offs importantes:
- Separação por camadas:
- Prós: clareza arquitetural, reutilização de infraestrutura, testabilidade isolada.
- Contras: pode criar dependências cíclicas, excesso de abstração, dificuldade em manter a disciplina de camadas.
- Pacotes por feature:
- Prós: alta coesão, facilidade de navegação, evolução independente.
- Contras: duplicação de código de infraestrutura, possível mistura de responsabilidades, dificuldade em aplicar padrões transversais.
Na prática, muitos projetos usam uma abordagem híbrida: agrupam por feature no nível mais alto, mas dentro de cada feature separam em camadas (handler, service, repository). Isso equilibra os benefícios de ambos os estilos.
Outro trade-off é entre injeção manual e frameworks de DI (como Google Wire ou Uber Fx). A injeção manual é simples e transparente, mas pode se tornar verbosa em projetos grandes. Frameworks automatizam a criação de dependências, mas adicionam complexidade e mágica. Para a maioria dos projetos Go, a injeção manual é suficiente e recomendada, pois mantém o código explícito e fácil de entender.
Boas práticas e observações finais
Ao organizar seu projeto, lembre-se de:
- Manter as dependências fluindo em uma direção: handlers -> services -> repositories.
- Usar interfaces para desacoplar camadas, mas sem exagerar: crie interfaces quando houver necessidade de variação ou teste.
- Colocar pacotes em
internal/para evitar que sejam importados por outros módulos, reforçando o encapsulamento. - Evite dependências cíclicas: use interfaces ou reestruture o código.
- Prefira a injeção manual até que a complexidade exija um framework.
Essas práticas ajudarão você a criar sistemas Go que são fáceis de manter, testar e evoluir. Agora, vamos praticar com alguns exercícios!
Exercícios
- Exercício 1: Dado o código a seguir, identifique as violações de separação de responsabilidades e sugira uma refatoração em camadas (handler, service, repository).
package main import ( "database/sql" "encoding/json" "net/http" ) func main() { http.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { db, _ := sql.Open("postgres", "dsn") var name string db.QueryRow("SELECT name FROM users WHERE id=$1", r.URL.Query().Get("id")).Scan(&name) json.NewEncoder(w).Encode(map[string]string{"name": name}) }) http.ListenAndServe(":8080", nil) }✓ Resposta: O código mistura acesso a banco, lógica de negócio e manipulação HTTP no mesmo handler. Refatoração: criar pacoteshandlers,serviceserepositories. O handler deve receber um service, o service recebe um repository, e o repository lida com o banco. Exemplo:// handler func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) { user, err := h.service.GetUserByID(r.URL.Query().Get("id")) if err != nil { http.Error(w, err.Error(), 500); return } json.NewEncoder(w).Encode(user) } // service func (s *UserService) GetUserByID(id string) (*User, error) { return s.repo.FindByID(id) } // repository func (r *UserRepository) FindByID(id string) (*User, error) { var name string err := r.db.QueryRow("SELECT name FROM users WHERE id=$1", id).Scan(&name) return &User{Name: name}, err } - Exercício 2: No código a seguir, aplique injeção de dependência manual para que a função
NewHandlerreceba umServicecomo parâmetro, em vez de criá-lo internamente.package handlers type Handler struct { service *Service } func NewHandler() *Handler { svc := &Service{} return &Handler{service: svc} }✓ Resposta: Modifique o construtor para receber o service:package handlers type Handler struct { service *Service } func NewHandler(svc *Service) *Handler { return &Handler{service: svc} } - Exercício 3: Converta a seguinte estrutura de pacotes por camadas para uma estrutura por feature. Descreva a nova árvore de diretórios e o que cada arquivo contém.
/handlers/user.go /services/user.go /repositories/user.go /models/user.go✓ Resposta: A estrutura por feature seria:
Cada arquivo contém as definições relacionadas ao usuário: handler (HTTP), service (lógica), repository (persistência) e model (estrutura de dados)./internal/user/ handler.go service.go repository.go model.go - Exercício 4: Discuta os trade-offs entre usar injeção manual e um framework de DI (como Google Wire). Em quais cenários você escolheria cada um?✓ Resposta: A injeção manual é simples, explícita e sem dependências externas, ideal para projetos pequenos/médios e quando se deseja controle total. Frameworks como Wire automatizam a geração de código e reduzem a verbosidade, mas adicionam complexidade e uma etapa de geração. Escolheria manual para a maioria dos projetos, e framework apenas em projetos muito grandes com muitas dependências e necessidade de geração automática.
- Exercício 5: Dado o seguinte código, identifique o problema de dependência cíclica e proponha uma solução usando interfaces.
// package a package a import "github.com/meuprojeto/b" type A struct { b *b.B } func NewA(b *b.B) *A { return &A{b: b} } // package b package b import "github.com/meuprojeto/a" type B struct { a *a.A } func NewB(a *a.A) *B { return &B{a: a} }✓ Resposta: O problema é que os pacotes a e b importam um ao outro, criando uma dependência cíclica. Solução: definir interfaces em um pacote separado ou em um dos pacotes que quebrem o ciclo. Por exemplo, criar um pacotecontractscom interfaces:
Então, a importa contracts e b também, sem importar diretamente um ao outro. Alternativamente, usar interfaces em um dos pacotes, mas o ideal é um pacote comum.// contracts package contracts type AInterface interface { DoA() } type BInterface interface { DoB() }