← Voltar para o blog

Dependency Injection em Go sem Framework

Aprenda dependency injection em Go do jeito idiomático: construtores, interfaces pequenas, configuração explícita, testes com fakes e sem container mágico.

Dependency injection em Go não precisa de framework, container global nem reflexão. Na maior parte dos projetos, a forma mais idiomática é também a mais simples: declarar dependências como campos de uma struct, recebê-las em um construtor e passar implementações concretas no main. Isso deixa o código explícito, fácil de testar e mais alinhado com a filosofia da linguagem.

A confusão aparece porque muita gente chega ao Go vindo de ecossistemas onde injeção de dependência significa anotações, ciclo de vida controlado por framework e mágica em tempo de execução. Em Go, o caminho costuma ser outro. O compilador mostra as dependências. O main monta o grafo. As interfaces ficam pequenas e próximas de quem usa. Os testes trocam banco, HTTP client, relógio ou fila por fakes sem subir a aplicação inteira.

Este guia mostra como aplicar dependency injection em Go para APIs, workers e serviços de produção sem exagero. Ele complementa Clean Architecture em Go sem overengineering, testes em Go, API REST em Go, context, timeout e cancelamento e slog para logging estruturado.

O problema que DI resolve

Injeção de dependência resolve um problema concreto: separar regra de negócio de detalhes externos. Um serviço que calcula aprovação de pedido não deveria criar conexão SQL, montar client HTTP, ler variável de ambiente e decidir formato de log dentro do mesmo método. Quando isso acontece, qualquer teste vira integração completa. Qualquer troca de provedor exige mudança em muitos pontos. Qualquer falha externa fica difícil de simular.

DI permite inverter essa relação. A regra recebe aquilo de que precisa. Se precisa salvar um pedido, recebe algo com método Save. Se precisa chamar antifraude, recebe algo com método Analyze. Se precisa registrar log, recebe um logger. A implementação real pode usar PostgreSQL, Redis, SQS, HTTP ou arquivo. O caso de uso não precisa saber.

Em Go, essa separação fica boa quando as dependências são explícitas e pequenas. O objetivo não é esconder tudo atrás de abstrações. O objetivo é deixar claro o que cada componente precisa para funcionar.

Comece com structs e construtores

A forma mais comum é criar uma struct de serviço com campos para as dependências. O construtor valida dependências obrigatórias e devolve o serviço pronto.

package pedido

import (
    "context"
    "errors"
    "log/slog"
)

type Repository interface {
    Save(ctx context.Context, p Pedido) error
}

type Antifraude interface {
    Analyze(ctx context.Context, p Pedido) (ResultadoAntifraude, error)
}

type Service struct {
    repo      Repository
    antifraude Antifraude
    logger    *slog.Logger
}

func NewService(repo Repository, antifraude Antifraude, logger *slog.Logger) (*Service, error) {
    if repo == nil {
        return nil, errors.New("pedido: repo obrigatorio")
    }
    if antifraude == nil {
        return nil, errors.New("pedido: antifraude obrigatorio")
    }
    if logger == nil {
        logger = slog.Default()
    }
    return &Service{repo: repo, antifraude: antifraude, logger: logger}, nil
}

Nada especial acontece aqui. Não existe container. Não existe annotation. O tipo deixa claro que Service precisa de um repositório, um antifraude e um logger. Essa clareza é uma vantagem, não uma limitação.

Interfaces no ponto de uso

Uma regra prática importante em Go: quem consome a dependência costuma definir a interface. O pacote pedido não precisa importar uma interface genérica criada pelo pacote de banco. Ele declara o mínimo que precisa.

type Repository interface {
    Save(ctx context.Context, p Pedido) error
    FindByID(ctx context.Context, id string) (Pedido, error)
}

Se amanhã o repositório real usa PostgreSQL, depois Firestore e depois uma fila de eventos, o caso de uso continua dependendo do contrato mínimo. Essa interface também fica fácil de implementar em teste.

Evite interfaces grandes do tipo UserRepository com 30 métodos só porque o banco tem várias operações. Interfaces grandes aumentam acoplamento. Interfaces pequenas deixam cada caso de uso pedir apenas o necessário.

Também evite criar interface antes de existir uma segunda implementação ou uma necessidade clara de teste. Em Go, abstração antecipada costuma gerar ruído. Comece concreto quando fizer sentido. Extraia uma interface quando o componente consumidor precisar trocar a implementação ou quando o teste pedir um fake simples.

Monte tudo no main

O lugar natural para conectar dependências é o main, ou uma função chamada pelo main, como buildApp. É ali que configuração, clients externos, banco, logger, handlers e workers se encontram.

func main() {
    cfg := loadConfig()
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    db, err := sql.Open("pgx", cfg.DatabaseURL)
    if err != nil {
        logger.Error("abrindo banco", "err", err)
        os.Exit(1)
    }
    defer db.Close()

    repo := postgres.NewPedidoRepository(db)
    antifraude := antifraudehttp.NewClient(cfg.AntifraudeURL, http.DefaultClient)

    pedidoService, err := pedido.NewService(repo, antifraude, logger)
    if err != nil {
        logger.Error("montando serviço de pedido", "err", err)
        os.Exit(1)
    }

    handler := httpapi.NewPedidoHandler(pedidoService, logger)
    srv := &http.Server{
        Addr:    cfg.HTTPAddr,
        Handler: handler.Routes(),
    }

    logger.Info("servidor iniciado", "addr", cfg.HTTPAddr)
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        logger.Error("servidor falhou", "err", err)
        os.Exit(1)
    }
}

Esse código parece manual porque ele é manual. Para muitos sistemas Go, isso é desejável. Você sabe exatamente o que é criado, em qual ordem, com qual configuração e onde o erro aparece. Em produção, essa previsibilidade vale mais do que esconder montagem atrás de mágica.

Se o main crescer demais, extraia funções pequenas: newDatabase, newHTTPServer, newWorkers, newServices. O importante é manter a montagem explícita.

Testes com fakes pequenos

O ganho aparece nos testes. Em vez de subir banco, servidor HTTP e fila, você passa fakes que implementam a interface necessária.

type fakeRepo struct {
    saved []Pedido
    err   error
}

func (f *fakeRepo) Save(ctx context.Context, p Pedido) error {
    if f.err != nil {
        return f.err
    }
    f.saved = append(f.saved, p)
    return nil
}

type fakeAntifraude struct {
    result ResultadoAntifraude
    err    error
}

func (f fakeAntifraude) Analyze(ctx context.Context, p Pedido) (ResultadoAntifraude, error) {
    return f.result, f.err
}

Com isso, o teste foca na regra.

func TestService_CriaPedidoAprovado(t *testing.T) {
    repo := &fakeRepo{}
    antifraude := fakeAntifraude{result: ResultadoAntifraude{Approved: true}}

    svc, err := NewService(repo, antifraude, slog.New(slog.NewTextHandler(io.Discard, nil)))
    if err != nil {
        t.Fatal(err)
    }

    err = svc.Criar(context.Background(), Pedido{ID: "p1", Total: 12900})
    if err != nil {
        t.Fatalf("Criar() error = %v", err)
    }
    if len(repo.saved) != 1 {
        t.Fatalf("pedidos salvos = %d, want 1", len(repo.saved))
    }
}

Esse tipo de teste é rápido, determinístico e barato de manter. Testes de integração continuam importantes para validar SQL, migrations e contratos HTTP, mas eles não precisam cobrir toda variação da regra de negócio.

Configuração também é dependência

Variáveis de ambiente não devem ser lidas em qualquer pacote aleatório. Prefira carregar configuração no início do programa, validar uma struct e passar valores prontos para quem precisa.

type Config struct {
    DatabaseURL   string
    AntifraudeURL string
    HTTPAddr      string
}

func loadConfig() Config {
    return Config{
        DatabaseURL:   mustEnv("DATABASE_URL"),
        AntifraudeURL: mustEnv("ANTIFRAUDE_URL"),
        HTTPAddr:      envOrDefault("HTTP_ADDR", ":8080"),
    }
}

Assim, o pacote de domínio não chama os.Getenv. O client HTTP recebe baseURL. O repositório recebe *sql.DB. O servidor recebe endereço. Isso torna teste e deploy mais previsíveis.

Quando usar wire, fx ou dig

Ferramentas como Google Wire, Uber Fx e Dig existem e podem ser úteis, mas não deveriam ser o primeiro passo. Elas fazem sentido quando o grafo de dependências é grande, muitos times compartilham módulos ou a montagem manual virou uma fonte real de erro. Mesmo assim, o desenho das dependências continua sendo seu: interfaces pequenas, construtores claros e ciclo de vida explícito.

Para a maioria dos projetos Go pequenos e médios, DI manual é suficiente. Antes de instalar um framework, pergunte:

  • O main está realmente difícil de manter ou só está explícito?
  • Os erros de montagem são frequentes?
  • O time entende o ciclo de vida que a ferramenta vai esconder?
  • O ganho compensa a complexidade em onboarding, debug e stack trace?

Se a resposta não for clara, continue manual.

Erros comuns

O erro mais comum é abstrair tudo. Criar interface para cada struct, mesmo sem segunda implementação, deixa o projeto com cara de Java ruim e não melhora teste. Outro erro é colocar dependências em variáveis globais. Global parece simples no começo, mas cria ordem escondida de inicialização e dificulta paralelismo em testes.

Também é comum passar context.Context no construtor. Em geral, context pertence à operação, não ao objeto de longa duração. Passe ctx nos métodos que executam trabalho: request HTTP, query SQL, envio para fila, processamento de job.

Outro problema é deixar handler HTTP conhecer detalhes demais. O handler deve validar entrada, chamar serviço e montar resposta. Regra de negócio fica no serviço. Banco fica no repositório. Client externo fica em pacote próprio. Isso combina melhor com Clean Architecture pragmática do que com camadas artificiais.

Checklist prático

Antes de complicar sua arquitetura, confira:

  • o main monta dependências explicitamente;
  • construtores validam dependências obrigatórias;
  • interfaces ficam perto de quem usa;
  • interfaces têm poucos métodos;
  • configuração é carregada no startup;
  • pacotes de domínio não leem env nem criam clients externos;
  • testes usam fakes pequenos para regras de negócio;
  • integração com banco, fila e HTTP tem testes próprios;
  • não há container global para resolver dependências em qualquer lugar;
  • logs, métricas e clock entram como dependências quando afetam comportamento testável.

Dependency injection em Go é menos sobre framework e mais sobre honestidade arquitetural. Se um componente precisa de banco, logger, clock ou client externo, mostre isso no tipo. Se uma regra precisa ser testada sem dependência externa, desenhe o contrato mínimo. Se a montagem está no main, aceite que o código operacional também é código importante.

O resultado é um serviço mais fácil de ler, testar e mudar. E, principalmente, um projeto Go que continua parecendo Go.