Injeção de dependência sem frameworks

Injeção de dependência sem frameworks

Injeção de dependência em Go sem framework: construtores que validam o que é obrigatório, opções funcionais com o handler guardado, composição no main, contexto sem cancelamento para goroutines, mocks testados com -race e o Wire, arquivado em agosto de 2025.
Golang

• • 17 min de leitura

Em Java e C#, frameworks de injeção de dependência como Spring e .NET DI são onipresentes. Em Go, a comunidade converge em torno de uma abordagem diferente: injeção de dependência manual, explícita, sem mágica de reflection em tempo de execução.

Essa escolha não é limitação — é filosofia. Código Go bem estruturado tem suas dependências visíveis na assinatura dos construtores. Não há anotações que escaneiam pacotes em tempo de execução, não há contêineres que resolvem dependências de forma implícita. O que você vê no código é exatamente o que acontece.

O problema que DI resolve

Sem injeção de dependência, componentes criam suas próprias dependências:

// Acoplamento forte — difícil de testar, difícil de substituir
type ServicoEmailCria struct{}

func (s *ServicoEmailCria) CriarUsuario(nome, email string) error {
    // Cria dependências internamente — impossível substituir em testes
    db, _ := sql.Open("postgres", "postgres://localhost/app")
    smtp := novoClienteSMTP("smtp.gmail.com", 587)
    logger := slog.Default()

    usuario := &Usuario{Nome: nome, Email: email}
    db.Exec("INSERT INTO usuarios ...", usuario)
    smtp.Enviar(email, "Bem-vindo!")
    logger.Info("usuário criado", "email", email)
    return nil
}

Com injeção de dependência, as dependências são fornecidas de fora:

// Acoplamento fraco — fácil de testar, fácil de substituir
type ServicoUsuario struct {
    repo   RepositorioUsuario
    mailer Mailer
    logger *slog.Logger
}

func NovoServicoUsuario(
    repo RepositorioUsuario,
    mailer Mailer,
    logger *slog.Logger,
) *ServicoUsuario {
    return &ServicoUsuario{
        repo:   repo,
        mailer: mailer,
        logger: logger,
    }
}

O padrão construtor: a DI idiomática em Go

O padrão central é simples: cada componente recebe suas dependências pelo construtor e as armazena em campos privados.

// Interfaces — o contrato
type RepositorioUsuario interface {
    Criar(ctx context.Context, u *Usuario) error
    BuscarPorEmail(ctx context.Context, email string) (*Usuario, error)
    BuscarPorID(ctx context.Context, id int64) (*Usuario, error)
}

type Mailer interface {
    EnviarBoasVindas(ctx context.Context, email, nome string) error
    EnviarRedefinicaoSenha(ctx context.Context, email, token string) error
}

type Cache interface {
    Get(ctx context.Context, chave string) (string, bool)
    Set(ctx context.Context, chave, valor string, ttl time.Duration) error
    Delete(ctx context.Context, chave string) error
}

// Serviço com dependências injetadas
type ServicoUsuario struct {
    repo   RepositorioUsuario
    mailer Mailer
    cache  Cache
    logger *slog.Logger
}

func NovoServicoUsuario(
    repo RepositorioUsuario,
    mailer Mailer,
    cache Cache,
    logger *slog.Logger,
) *ServicoUsuario {
    if repo == nil {
        panic("repo não pode ser nil")
    }
    if cache == nil {
        panic("cache não pode ser nil")
    }
    if logger == nil {
        logger = slog.Default()
    }
    return &ServicoUsuario{
        repo:   repo,
        mailer: mailer,
        cache:  cache,
        logger: logger,
    }
}

func (s *ServicoUsuario) Criar(ctx context.Context, input CriarUsuarioInput) (*Usuario, error) {
    // Atalho pelo cache: evita ir ao banco para um e-mail recém-cadastrado.
    // A garantia de unicidade é a restrição UNIQUE no banco, e não o cache
    if _, existe := s.cache.Get(ctx, "email:"+input.Email); existe {
        return nil, ErrEmailJaUsado
    }

    usuario := &Usuario{
        Nome:  input.Nome,
        Email: input.Email,
        Senha: hashSenha(input.Senha),
    }

    if err := s.repo.Criar(ctx, usuario); err != nil {
        return nil, fmt.Errorf("persistir usuário: %w", err)
    }

    // Registra no cache e envia o e-mail — falhas aqui não propagam
    s.cache.Set(ctx, "email:"+input.Email, "1", 24*time.Hour)

    if s.mailer != nil {
        // O ctx da requisição é cancelado quando o handler responde, e a
        // goroutine ainda estaria enviando: WithoutCancel mantém os valores
        // do contexto sem o cancelamento, e o WithTimeout dá um limite próprio
        ctxEmail, cancelar := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
        go func() {
            defer cancelar()
            if err := s.mailer.EnviarBoasVindas(ctxEmail, input.Email, input.Nome); err != nil {
                s.logger.Warn("enviar boas-vindas falhou", "email", input.Email, "err", err)
            }
        }()
    }

    s.logger.Info("usuário criado", "id", usuario.ID, "email", usuario.Email)
    return usuario, nil
}

Opções funcionais: construtores flexíveis

Quando um construtor tem muitos parâmetros opcionais, o padrão de opções funcionais resolve o problema sem proliferar sobrecargas:

package servidor

import (
    "context"
    "errors"
    "fmt"
    "log/slog"
    "net/http"
    "time"
)

type TLSConfig struct {
    CertFile string
    KeyFile  string
}

type Servidor struct {
    handler      http.Handler
    host         string
    porta        int
    readTimeout  time.Duration
    writeTimeout time.Duration
    middlewares  []func(http.Handler) http.Handler
    logger       *slog.Logger
    tls          *TLSConfig
}

// Opcao é uma função que configura um Servidor.
type Opcao func(*Servidor)

// Construtores de opções — cada um configura um aspecto

func ComHost(host string) Opcao {
    return func(s *Servidor) {
        s.host = host
    }
}

func ComPorta(porta int) Opcao {
    return func(s *Servidor) {
        s.porta = porta
    }
}

func ComReadTimeout(d time.Duration) Opcao {
    return func(s *Servidor) {
        s.readTimeout = d
    }
}

func ComWriteTimeout(d time.Duration) Opcao {
    return func(s *Servidor) {
        s.writeTimeout = d
    }
}

func ComMiddleware(m func(http.Handler) http.Handler) Opcao {
    return func(s *Servidor) {
        s.middlewares = append(s.middlewares, m)
    }
}

func ComLogger(logger *slog.Logger) Opcao {
    return func(s *Servidor) {
        s.logger = logger
    }
}

func ComTLS(certFile, keyFile string) Opcao {
    return func(s *Servidor) {
        s.tls = &TLSConfig{CertFile: certFile, KeyFile: keyFile}
    }
}

// NovoServidor cria um Servidor com valores padrão e aplica as opções.
func NovoServidor(handler http.Handler, opts ...Opcao) *Servidor {
    s := &Servidor{
        handler:      handler,
        host:         "0.0.0.0",
        porta:        8080,
        readTimeout:  5 * time.Second,
        writeTimeout: 10 * time.Second,
        logger:       slog.Default(),
    }

    for _, opt := range opts {
        opt(s)
    }

    return s
}

// Iniciar sobe o servidor e o encerra com Shutdown quando ctx é cancelado.
func (s *Servidor) Iniciar(ctx context.Context) error {
    // O primeiro middleware registrado fica por fora, e roda primeiro
    h := s.handler
    for i := len(s.middlewares) - 1; i >= 0; i-- {
        h = s.middlewares[i](h)
    }
    srv := &http.Server{
        Addr:         fmt.Sprintf("%s:%d", s.host, s.porta),
        Handler:      h,
        ReadTimeout:  s.readTimeout,
        WriteTimeout: s.writeTimeout,
    }

    erros := make(chan error, 1)
    go func() {
        s.logger.Info("servidor iniciado", "addr", srv.Addr)
        if s.tls != nil {
            erros <- srv.ListenAndServeTLS(s.tls.CertFile, s.tls.KeyFile)
        } else {
            erros <- srv.ListenAndServe()
        }
    }()

    select {
    case err := <-erros:
        return err
    case <-ctx.Done():
        desligar, cancelar := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancelar()
        if err := srv.Shutdown(desligar); err != nil && !errors.Is(err, http.ErrServerClosed) {
            return err
        }
        return nil
    }
}

// // Uso: limpo, explícito, sem struct Config separada
// srv := NovoServidor(
//     meuHandler,
//     ComPorta(9090),
//     ComReadTimeout(10*time.Second),
//     ComLogger(logger),
//     ComMiddleware(middlewareAuth),
//     ComMiddleware(middlewareLogging),
// )

Composição no main: a raiz da árvore de dependências

O main.go é o único lugar onde todas as dependências são instanciadas e conectadas. Esse ponto de composição é chamado de composition root:

// cmd/api/main.go
package main

import (
    "context"
    "database/sql"
    "log/slog"
    "os"

    "loja/internal/config"
    domainservico "loja/internal/domain/servico"
    "loja/internal/handler"
    "loja/internal/infra/cache/redis"
    "loja/internal/infra/email/sendgrid"
    "loja/internal/infra/postgres"
    "loja/internal/servidor"
    "loja/internal/usecase"
)

func main() {
    ctx := context.Background()

    // 1. Logger
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    slog.SetDefault(logger)

    // 2. Configuração
    cfg, err := config.Carregar("")
    if err != nil {
        logger.Error("configuração inválida", "err", err)
        os.Exit(1)
    }

    // 3. Infraestrutura — implementações concretas
    db, err := abrirBancoDados(cfg.Banco)
    if err != nil {
        logger.Error("banco inacessível", "err", err)
        os.Exit(1)
    }
    defer db.Close()

    cacheCliente := redis.NovoCache(cfg.Cache.URL)
    mailerCliente := sendgrid.NovoMailer(cfg.Sendgrid.APIKey)

    // 4. Repositórios
    usuarioRepo := postgres.NovoRepositorioUsuario(db)
    produtoRepo := postgres.NovoRepositorioProduto(db)
    pedidoRepo := postgres.NovoRepositorioPedido(db)

    // 5. Serviços de domínio
    politicaDesconto := domainservico.NovaPoliticaDesconto()

    // 6. Casos de uso — injetam repositórios e serviços
    criarUsuario := usecase.NovoCriarUsuario(usuarioRepo, mailerCliente, cacheCliente, logger)
    criarPedido := usecase.NovoCriarPedido(pedidoRepo, produtoRepo, politicaDesconto, logger)
    processarPagamento := usecase.NovoProcessarPagamento(pedidoRepo, logger)

    // 7. Handlers HTTP — injetam casos de uso
    usuarioHandler := handler.NovoUsuarioHandler(criarUsuario, logger)
    pedidoHandler := handler.NovoPedidoHandler(criarPedido, processarPagamento, logger)

    // 8. Servidor
    srv := servidor.NovoServidor(
        montarRotas(usuarioHandler, pedidoHandler),
        servidor.ComPorta(cfg.Servidor.Porta),
        servidor.ComLogger(logger),
        servidor.ComMiddleware(middlewareAuth(cfg.JWT.Segredo)),
    )

    if err := srv.Iniciar(ctx); err != nil {
        logger.Error("servidor encerrado com erro", "err", err)
        os.Exit(1)
    }
}

Testando com mocks manuais

A vantagem mais concreta da DI é a facilidade de testar. Mocks manuais são simples e explícitos:

// Mocks para testes

type mockRepositorioUsuario struct {
    criarFn         func(ctx context.Context, u *Usuario) error
    buscarPorEmailFn func(ctx context.Context, email string) (*Usuario, error)
    buscarPorIDFn    func(ctx context.Context, id int64) (*Usuario, error)
}

func (m *mockRepositorioUsuario) Criar(ctx context.Context, u *Usuario) error {
    if m.criarFn != nil {
        return m.criarFn(ctx, u)
    }
    return nil
}

func (m *mockRepositorioUsuario) BuscarPorEmail(ctx context.Context, email string) (*Usuario, error) {
    if m.buscarPorEmailFn != nil {
        return m.buscarPorEmailFn(ctx, email)
    }
    return nil, nil
}

func (m *mockRepositorioUsuario) BuscarPorID(ctx context.Context, id int64) (*Usuario, error) {
    if m.buscarPorIDFn != nil {
        return m.buscarPorIDFn(ctx, id)
    }
    return nil, nil
}

type mockMailer struct {
    enviados   chan string // o serviço envia de outra goroutine: o canal sincroniza
    erroEnviar error
}

func (m *mockMailer) EnviarBoasVindas(_ context.Context, email, _ string) error {
    if m.enviados != nil {
        m.enviados <- email
    }
    return m.erroEnviar
}

func (m *mockMailer) EnviarRedefinicaoSenha(_ context.Context, _, _ string) error {
    return nil
}

type mockCache struct {
    dados map[string]string
}

func novoMockCache() *mockCache {
    return &mockCache{dados: make(map[string]string)}
}

func (m *mockCache) Get(_ context.Context, chave string) (string, bool) {
    v, existe := m.dados[chave]
    return v, existe
}

func (m *mockCache) Set(_ context.Context, chave, valor string, _ time.Duration) error {
    m.dados[chave] = valor
    return nil
}

func (m *mockCache) Delete(_ context.Context, chave string) error {
    delete(m.dados, chave)
    return nil
}

// Testes usando os mocks
func TestCriarUsuario_Sucesso(t *testing.T) {
    var usuarioCriado *Usuario

    repo := &mockRepositorioUsuario{
        criarFn: func(_ context.Context, u *Usuario) error {
            u.ID = 1 // simula ID gerado pelo banco
            usuarioCriado = u
            return nil
        },
    }
    mailer := &mockMailer{enviados: make(chan string, 1)}
    cache := novoMockCache()
    logger := slog.New(slog.NewTextHandler(io.Discard, nil))

    servico := NovoServicoUsuario(repo, mailer, cache, logger)

    resultado, err := servico.Criar(context.Background(), CriarUsuarioInput{
        Nome:  "Ricardo",
        Email: "ricardo@exemplo.com",
        Senha: "senha-forte-123",
    })

    if err != nil {
        t.Fatalf("esperava sucesso, obteve: %v", err)
    }
    if resultado.ID != 1 {
        t.Errorf("ID esperado 1, obtido %d", resultado.ID)
    }
    if usuarioCriado.Email != "ricardo@exemplo.com" {
        t.Errorf("email incorreto: %s", usuarioCriado.Email)
    }

    // Espera o e-mail da goroutine; um time.Sleep aqui seria uma corrida
    // de dados, acusada pelo go test -race
    select {
    case email := <-mailer.enviados:
        if email != "ricardo@exemplo.com" {
            t.Errorf("e-mail enviado para %s", email)
        }
    case <-time.After(time.Second):
        t.Error("email de boas-vindas não foi enviado")
    }
}

func TestCriarUsuario_EmailDuplicado(t *testing.T) {
    cache := novoMockCache()
    cache.Set(context.Background(), "email:duplicado@exemplo.com", "1", time.Hour)

    servico := NovoServicoUsuario(
        &mockRepositorioUsuario{},
        &mockMailer{},
        cache,
        slog.Default(),
    )

    _, err := servico.Criar(context.Background(), CriarUsuarioInput{
        Nome:  "Outro",
        Email: "duplicado@exemplo.com",
        Senha: "senha123",
    })

    if !errors.Is(err, ErrEmailJaUsado) {
        t.Errorf("esperava ErrEmailJaUsado, obteve: %v", err)
    }
}

Wire: geração de código para DI complexa

Quando o grafo de dependências cresce — dezenas de serviços com dependências profundas — o main.go manual pode se tornar verboso. O Wire da Google gera automaticamente o código de inicialização:

go install github.com/google/wire/cmd/wire@latest
go get github.com/google/wire
// wire.go — apenas em tempo de desenvolvimento, não compila no binário final
//go:build wireinject

package main

import (
    "github.com/google/wire"

    "loja/internal/config"
    "loja/internal/handler"
    "loja/internal/infra/cache/redis"
    "loja/internal/infra/email/sendgrid"
    "loja/internal/infra/postgres"
    "loja/internal/usecase"
)

func inicializarApp(cfg *config.Config) (*App, error) {
    wire.Build(
        // Infraestrutura
        abrirBancoDados,
        postgres.NovoRepositorioUsuario,
        postgres.NovoRepositorioProduto,
        redis.NovoCache,
        sendgrid.NovoMailer,

        // Casos de uso
        usecase.NovoCriarUsuario,
        usecase.NovoCriarPedido,

        // Handlers
        handler.NovoUsuarioHandler,
        handler.NovoPedidoHandler,

        // App
        novaApp,
    )
    return &App{}, nil
}
# Gera wire_gen.go com o código de inicialização
wire ./cmd/api/

O Wire analisa as assinaturas dos construtores, resolve o grafo de dependências e gera código Go limpo e legível — sem reflection em runtime. Um aviso antes de adotar: o repositório google/wire foi arquivado pelo Google em agosto de 2025. A ferramenta continua instalável e o código que ela gera continua sendo Go comum, mas não há mais correções nem versões novas; para um projeto novo, isso pesa a favor da composição manual.

Comparativo: manual vs Wire

Aspecto Manual Wire
Complexidade inicial Baixa Média
Legibilidade do código gerado Excelente Excelente
Manutenção com muitas deps Verboso Automático
Erros detectados Na compilação (tipos); nil só ao rodar Na geração do código
Dependência externa Nenhuma wire (dev only)
Adequado para Projetos pequenos/médios Projetos grandes

Para projetos com até 20-30 serviços, DI manual é perfeitamente gerenciável e preferível pela clareza. Para projetos maiores, Wire elimina uma classe de erros de inicialização e reduz código boilerplate.

Injeção de dependência em Go dispensa framework: cada componente recebe o que precisa pelo construtor, as dependências ficam visíveis na assinatura, e o main é o único lugar que monta o grafo. Opções funcionais resolvem construtores com muitos parâmetros opcionais, e mocks escritos à mão bastam para testar. A explicitude, porém, só protege o que o compilador enxerga. Um parâmetro recebido e esquecido, uma dependência nil aceita pelo construtor e um contexto de requisição levado para uma goroutine que vive mais que ela são defeitos que compilam. Validar as dependências obrigatórias no construtor e rodar os testes com -race pegam boa parte deles.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Na primeira versão do ServicoUsuario.Criar, a goroutine do e-mail de boas-vindas recebia o mesmo ctx da requisição. Em produção, quase todos os envios falham com context canceled, mas o cadastro funciona. Por quê?

Ver resposta

✓ Resposta: O net/http cancela o contexto de uma requisição assim que o handler retorna. O Criar dispara a goroutine e volta logo, o handler responde ao cliente, e o contexto é cancelado enquanto o e-mail ainda está sendo enviado. Qualquer chamada de rede que respeite o contexto falha nesse ponto. Num teste que cancelava o contexto logo depois de Criar retornar, como faz o servidor HTTP, o mailer recebeu context canceled. A versão do artigo usa context.WithoutCancel(ctx), do Go 1.21, que preserva os valores do contexto, como um ID de requisição para os logs, mas não herda o cancelamento; e acrescenta um WithTimeout próprio, para que o envio não fique preso para sempre. Com ela, o mesmo teste entregou um contexto válido ao mailer.

Exercício 2

O teste TestCriarUsuario_Sucesso original esperava o e-mail com time.Sleep(10 * time.Millisecond) e depois conferia o slice do mock. Passava em todas as execuções. O que acontece com go test -race, e por que o sleep não resolve?

Ver resposta

✓ Resposta: A goroutine do serviço faz append no slice do mock, e o teste lê o mesmo slice. Para o modelo de memória do Go, só uma operação de sincronização, como um canal, um mutex ou um WaitGroup, garante que a escrita de uma goroutine seja vista pela outra; esperar um tempo não estabelece essa ordem, apenas a torna provável. Num teste, o go test -race acusou DATA RACE e marcou o teste como falho, mesmo com o resultado certo. Numa máquina de CI sobrecarregada, os 10 ms também podem não bastar, e o teste falha de forma intermitente. A versão do artigo troca o slice por um canal: o mock envia o e-mail recebido, e o teste espera com select e um time.After de um segundo como limite. Com ela, o teste passou com -race.

Exercício 3

Na primeira versão do pacote servidor, NovoServidor(handler, opts...) compilava, mas a struct não tinha campo para o handler. O que esse erro mostra sobre os limites da DI "explícita", e como ele passaria despercebido?

Ver resposta

✓ Resposta: O construtor recebia o handler e simplesmente não o guardava. O Go acusa variáveis locais não usadas, mas não parâmetros, porque um parâmetro sem uso é comum, por exemplo para satisfazer uma interface; nem o go vet reclama. Como o pacote também não tinha o método que sobe o servidor, ninguém chegava a perceber que não havia o que servir. A versão do artigo guarda o handler, aplica os middlewares e sobe o servidor em Iniciar; num teste com dois middlewares, a requisição passou por eles na ordem em que foram registrados e chegou ao handler, e o servidor encerrou sem erro quando o contexto foi cancelado. A DI explícita deixa as dependências visíveis na assinatura, mas não garante que sejam usadas: só um teste que atravessa o componente, como esse, comprova a ligação. O linter unparam, do golangci-lint, aponta parâmetros que nunca são usados.

Exercício 4

O construtor original de ServicoUsuario recusava repo nil com um panic, mas aceitava cache nil. Um ambiente de testes sem Redis passa nil no cache. Quando o problema aparece, e por que vale falhar no construtor?

Ver resposta

✓ Resposta: O serviço chama s.cache.Get logo no começo de Criar. Com o cache nil, a primeira chamada a um método da interface causa um panic de ponteiro nulo: num teste, foi runtime error: invalid memory address or nil pointer dereference, durante o primeiro cadastro, e não na subida da aplicação. Num servidor, esse panic aparece numa requisição de usuário, talvez horas depois do deploy. A versão do artigo trata o cache como obrigatório e recusa o nil no construtor, como já fazia com o repositório: no mesmo teste, a falha passou a acontecer na montagem, com a mensagem cache não pode ser nil. Quando uma dependência é de fato opcional, como o mailer, o código precisa tratar a ausência explicitamente, como faz o if s.mailer != nil. Outra opção é um objeto nulo, um cache que nunca encontra nada, para que o resto do código não precise saber da diferença.

Exercício 5

O ServicoUsuario considera um e-mail duplicado quando encontra a chave no cache. Um usuário se cadastra, o cache expira 24 horas depois, e o mesmo e-mail é cadastrado de novo. Outro cenário: duas instâncias do serviço, cada uma com cache local. O que garante de fato a unicidade?

Ver resposta

✓ Resposta: Um cache é uma cópia que pode estar ausente, expirada ou ser diferente em cada instância, e por isso serve como atalho, nunca como fonte da verdade. Nos dois cenários, a consulta ao cache não encontra a chave, e o serviço segue para o repo.Criar. Quem tem a palavra final é o banco: uma restrição UNIQUE na coluna de e-mail faz o segundo INSERT falhar, mesmo que duas requisições cheguem ao mesmo tempo. O repositório traduz essa violação, o código 23505 no PostgreSQL, por exemplo, para ErrEmailJaUsado, e o caso de uso responde do mesmo jeito que respondia pelo cache. A consulta com BuscarPorEmail antes de criar melhora a mensagem para o caso comum, mas tem a mesma corrida entre consultar e inserir; a restrição no banco é o que fecha essa janela.

Comentários

Mais em Golang

Arrays e Slices: a espinha dorsal das coleções em Go
Arrays e Slices: a espinha dorsal das coleções em Go

Arrays e slices em Go: tamanho no tipo, len e cap, fatiamento que compartilha…

Operadores, expressões e conversão de tipo
Operadores, expressões e conversão de tipo

Operadores em Go: aritméticos, de comparação, lógicos com curto-circuito e bit…

Go com PostgreSQL: recursos avançados e o driver pgx
Go com PostgreSQL: recursos avançados e o driver pgx

Go com PostgreSQL pelo pgx: pgxpool, parâmetros nomeados, JSONB e a junção…