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

  1. 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 pacotes handlers, services e repositories. 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
    }
    
  2. Exercício 2: No código a seguir, aplique injeção de dependência manual para que a função NewHandler receba um Service como 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}
    }
    
  3. 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:
    /internal/user/
        handler.go
        service.go
        repository.go
        model.go
    
    Cada arquivo contém as definições relacionadas ao usuário: handler (HTTP), service (lógica), repository (persistência) e model (estrutura de dados).
  4. 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.
  5. 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 pacote contracts com interfaces:
    // contracts
    package contracts
    
    type AInterface interface {
        DoA()
    }
    type BInterface interface {
        DoB()
    }
    
    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.

Referências