Dependency Injection in Go: Patronen & Beste Praktijken

Meester DI patronen voor testbare Go-code

Inhoud

Dependency injection (DI) is een fundamenteel ontwerppatroon dat de schrijfbaarheid, toetsbaarheid en onderhoudbaarheid van code in Go-toepassingen bevordert.

Of je nu REST APIs bouwt, multi-tenant database patterns implementeert of werkt met ORM libraries, het begrijpen van dependency injection verbetert aanzienlijk de kwaliteit van je code.

go-dependency-injection

Wat is Dependency Injection?

Dependency injection is een ontwerppatroon waarbij componenten hun afhankelijkheden van externe bronnen ontvangen in plaats van ze intern te maken. Deze aanpak ontkoppelt componenten, waardoor je code modulairer, toetsbaar en onderhoudsbaar wordt.

In Go is dependency injection vooral krachtig vanwege de taal’s filosofie op basis van interfaces. De impliciete interfacevoldoening van Go betekent dat je eenvoudig implementaties kunt wisselen zonder bestaande code aan te passen.

Waarom Dependency Injection gebruiken in Go?

Verbeterde toetsbaarheid: Door afhankelijkheden in te injecteren, kun je gemakkelijk echte implementaties vervangen met mocks of testdubbels. Dit maakt het mogelijk om snelle, geïsoleerde eenhittesten te schrijven die geen externe diensten zoals databases of APIs vereisen.

Beter onderhoud: Afhankelijkheden worden expliciet in je code. Wanneer je een constructorfunctie bekijkt, zie je direct wat een component nodig heeft. Dit maakt de codebasis makkelijker te begrijpen en aan te passen.

Loskoppeling: Componenten zijn afhankelijk van abstracties (interfaces) in plaats van concrete implementaties. Dit betekent dat je implementaties kunt veranderen zonder de afhankelijke code te beïnvloeden.

Flexibiliteit: Je kunt verschillende implementaties configureren voor verschillende omgevingen (ontwikkeling, testen, productie) zonder je zakelijke logica aan te passen.

Constructor Injection: De Go-achtige manier

De meest voorkomende en idiomatische manier om dependency injection in Go te implementeren is via constructorfuncties. Deze worden meestal NewXxx genoemd en accepteren afhankelijkheden als parameters.

Basisvoorbeeld

Hier is een eenvoudig voorbeeld dat constructor injection demonstreert:

// Interface voor de repository definiëren
type UserRepository interface {
    FindByID(id int) (*User, error)
    Save(user *User) error
}

// Service die afhankelijk is van de repositoryinterface
type UserService struct {
    repo UserRepository
}

// Constructor injecteert de afhankelijkheid
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

// Methoden gebruiken de ingevoegde afhankelijkheid
func (s *UserService) GetUser(id int) (*User, error) {
    return s.repo.FindByID(id)
}

Dit patroon maakt duidelijk dat UserService een UserRepository nodig heeft. Je kunt geen UserService aanmaken zonder een repository te leveren, wat runtimefouten door ontbrekende afhankelijkheden voorkomt.

Meerdere afhankelijkheden

Wanneer een component meerdere afhankelijkheden heeft, voeg ze eenvoudig toe als constructorparameters:

type EmailService interface {
    Send(to, subject, body string) error
}

type Logger interface {
    Info(msg string)
    Error(msg string, err error)
}

type OrderService struct {
    repo        OrderRepository
    emailSvc    EmailService
    logger      Logger
    paymentSvc  PaymentService
}

func NewOrderService(
    repo OrderRepository,
    emailSvc EmailService,
    logger Logger,
    paymentSvc PaymentService,
) *OrderService {
    return &OrderService{
        repo:       repo,
        emailSvc:   emailSvc,
        logger:     logger,
        paymentSvc: paymentSvc,
    }
}

Interfaceontwerp voor dependency injection

Een van de belangrijkste principes bij het implementeren van dependency injection is de Dependency Inversion Principle (DIP): hoge niveau modules mogen niet afhankelijk zijn van lage niveau modules; beide moeten afhankelijk zijn van abstracties.

In Go betekent dit het definiëren van kleine, gerichte interfaces die alleen wat je component nodig heeft:

// Goed: Kleine, gerichte interface
type PaymentProcessor interface {
    ProcessPayment(amount float64) error
}

// Slecht: Grote interface met overbodige methoden
type PaymentService interface {
    ProcessPayment(amount float64) error
    RefundPayment(id string) error
    GetPaymentHistory(userID int) ([]Payment, error)
    UpdatePaymentMethod(userID int, method PaymentMethod) error
    // ... veel meer methoden
}

De kleinere interface volgt de Interface Segregation Principle—klanten mogen niet afhankelijk zijn van methoden die ze niet gebruiken. Dit maakt je code flexibeler en makkelijker te testen.

Real-life voorbeeld: Databaseabstractie

Wanneer je werkt met databases in Go-toepassingen, zul je vaak databasebewerkingen abstracteren. Hier is hoe dependency injection je helpt:

// Database interface - hoog niveau abstractie
type DB interface {
    Query(ctx context.Context, query string, args ...interface{}) (Rows, error)
    Exec(ctx context.Context, query string, args ...interface{}) (Result, error)
    BeginTx(ctx context.Context) (Tx, error)
}

// Repository die afhankelijk is van de abstractie
type UserRepository struct {
    db DB
}

func NewUserRepository(db DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
    query := "SELECT id, name, email FROM users WHERE id = $1"
    rows, err := r.db.Query(ctx, query, id)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    // ... rijen parsen
}

Dit patroon is vooral nuttig bij het implementeren van multi-tenant database patterns, waarbij je mogelijk moet wisselen tussen verschillende databaseimplementaties of connectiestrategieën.

Het Compositie Root Patroon

Het Compositie Root is waar je alle afhankelijkheden samenstelt op het startpunt van de toepassing (meestal main). Dit centraliseert de configuratie van afhankelijkheden en maakt het afhankelijkheidsnetwerk expliciet.

func main() {
    // Initialiseer infrastructuurafhankelijkheden
    db := initDatabase()
    logger := initLogger()
    
    // Initialiseer repositories
    userRepo := NewUserRepository(db)
    orderRepo := NewOrderRepository(db)
    
    // Initialiseer services met afhankelijkheden
    emailSvc := NewEmailService(logger)
    paymentSvc := NewPaymentService(logger)
    userSvc := NewUserService(userRepo, logger)
    orderSvc := NewOrderService(orderRepo, emailSvc, logger, paymentSvc)
    
    // Initialiseer HTTP handlers
    userHandler := NewUserHandler(userSvc)
    orderHandler := NewOrderHandler(orderSvc)
    
    // Sluit routes aan
    router := setupRouter(userHandler, orderHandler)
    
    // Start server
    log.Fatal(http.ListenAndServe(":8080", router))
}

Deze aanpak maakt duidelijk hoe je toepassing is opgebouwd en waar afhankelijkheden vandaan komen. Het is vooral nuttig bij het bouwen van REST APIs in Go, waarbij je meerdere lagen van afhankelijkheden moet coördineren.

Dependency Injection Frameworks

Voor grotere toepassingen met complexe afhankelijkheidsgrafieken kan het handmatig beheren van afhankelijkheden vervelend worden. Go heeft verschillende DI-frameworks die je kunnen helpen:

Google Wire (Compile-time DI)

Wire is een compile-time dependency injection tool die code genereert. Het is type-safe en heeft geen runtime overhead.

Installatie:

go install github.com/google/wire/cmd/wire@latest

Voorbeeld:

// wire.go
//go:build wireinject
// +build wireinject

package main

import "github.com/google/wire"

func InitializeApp() (*App, error) {
    wire.Build(
        NewDB,
        NewUserRepository,
        NewUserService,
        NewUserHandler,
        NewApp,
    )
    return &App{}, nil
}

Wire genereert de dependency injection code bij de compilatie, wat type-safety garandeert en runtime reflectie overhead elimineert.

Uber Dig (Runtime DI)

Dig is een runtime dependency injection framework dat reflectie gebruikt. Het is flexibeler, maar heeft wat runtime overhead.

Voorbeeld:

import "go.uber.org/dig"

func main() {
    container := dig.New()
    
    // Registratie van providers
    container.Provide(NewDB)
    container.Provide(NewUserRepository)
    container.Provide(NewUserService)
    container.Provide(NewUserHandler)
    
    // Invokeren van functie die afhankelijkheden nodig heeft
    err := container.Invoke(func(handler *UserHandler) {
        // Gebruik handler
    })
    if err != nil {
        log.Fatal(err)
    }
}

Wanneer frameworks gebruiken

Gebruik een framework wanneer:

  • Je afhankelijkheidsgrafiek is complex met veel onderlinge afhankelijke componenten
  • Je meerdere implementaties van dezelfde interface hebt die geselecteerd moeten worden op basis van configuratie
  • Je automatische afhankelijkheidssolving wilt
  • Je een grote toepassing bouwt waarbij handmatig kabeltje maken foutgevoelig wordt

Sta bij handmatige DI te blijven wanneer:

  • Je toepassing klein tot gemiddeld is
  • Je afhankelijkheidsgrafiek eenvoudig en makkelijk te volgen is
  • Je afhankelijkheden minimaal en expliciet wilt houden
  • Je expliciete code voorkeurt boven gegenereerde code

Testen met dependency injection

Een van de belangrijkste voordelen van dependency injection is verbeterde toetsbaarheid. Hier is hoe DI het testen makkelijker maakt:

Eenheidstestvoorbeeld

// Testimplementatie
type mockUserRepository struct {
    users map[int]*User
    err   error
}

func (m *mockUserRepository) FindByID(id int) (*User, error) {
    if m.err != nil {
        return nil, m.err
    }
    return m.users[id], nil
}

func (m *mockUserRepository) Save(user *User) error {
    if m.err != nil {
        return m.err
    }
    m.users[user.ID] = user
    return nil
}

// Test met de mock
func TestUserService_GetUser(t *testing.T) {
    mockRepo := &mockUserRepository{
        users: map[int]*User{
            1: {ID: 1, Name: "John", Email: "john@example.com"},
        },
    }
    
    service := NewUserService(mockRepo)
    
    user, err := service.GetUser(1)
    assert.NoError(t, err)
    assert.Equal(t, "John", user.Name)
}

Deze test draait snel, vereist geen database en test je zakelijke logica in isolatie. Wanneer je werkt met ORM libraries in Go, kun je mock repositories injecteren om service logica te testen zonder database setup.

Algemene patronen en beste praktijken

1. Gebruik interface segregatie

Houd interfaces klein en gefocust op wat de klant werkelijk nodig heeft:

// Goed: De klant heeft alleen leesfunctionaliteit nodig
type UserReader interface {
    FindByID(id int) (*User, error)
    FindByEmail(email string) (*User, error)
}

// Afzonderlijke interface voor schrijven
type UserWriter interface {
    Save(user *User) error
    Delete(id int) error
}

2. Geef fouten terug van constructors

Constructors moeten afhankelijkheden valideren en fouten retourneren als initialisatie mislukt:

func NewUserService(repo UserRepository) (*UserService, error) {
    if repo == nil {
        return nil, errors.New("user repository kan niet nil zijn")
    }
    return &UserService{repo: repo}, nil
}

3. Gebruik context voor request-afhankelijkheden

Voor afhankelijkheden die specifiek zijn voor een request (zoals database transacties), geef ze door via context:

type ctxKey string

const dbKey ctxKey = "db"

func WithDB(ctx context.Context, db DB) context.Context {
    return context.WithValue(ctx, dbKey, db)
}

func DBFromContext(ctx context.Context) (DB, bool) {
    db, ok := ctx.Value(dbKey).(DB)
    return db, ok
}

4. Vermijd over-injectie

Inject geen afhankelijkheden die werkelijk interne implementatiedetails zijn. Als een component zijn eigen helperobjecten aanmaakt en beheert, is dat prima:

// Goed: Intern helper hoeft niet geïnjecteerd te worden
type UserService struct {
    repo UserRepository
    // Intern cache - hoeft niet geïnjecteerd te worden
    cache map[int]*User
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{
        repo:  repo,
        cache: make(map[int]*User),
    }
}

5. Documenteer afhankelijkheden

Gebruik opmerkingen om te documenteren waarom afhankelijkheden nodig zijn en welke beperkingen er zijn:

// UserService beheert gebruikersgerelateerde zakelijke logica.
// Het vereist een UserRepository voor gegevenstoegang en een Logger voor
// foutvolgging. De repository moet draadveilig zijn als het
// in concurrente contexten wordt gebruikt.
func NewUserService(repo UserRepository, logger Logger) *UserService {
    // ...
}

Wanneer je dependency injection niet moet gebruiken

Dependency injection is een krachtig hulpmiddel, maar het is niet altijd nodig:

Gebruik geen DI voor:

  • Eenvoudige waardeobjecten of datatypes
  • Interne helperfuncties of utiliteiten
  • One-time scripts of kleine utiliteiten
  • Wanneer directe instantie aanmaak duidelijker en eenvoudiger is

Voorbeeld van wanneer je geen DI moet gebruiken:

// Eenvoudig struct - geen DI nodig
type Point struct {
    X, Y float64
}

func NewPoint(x, y float64) Point {
    return Point{X: x, Y: y}
}

// Eenvoudige utiliteit - geen DI nodig
func FormatCurrency(amount float64) string {
    return fmt.Sprintf("$%.2f", amount)
}

Integratie met Go-ecosysteem

Dependency injection werkt naadloos met andere Go-patronen en tools. Wanneer je toepassingen bouwt die Go’s standaardbibliotheek voor web scraping of PDF rapporten genereren gebruiken, kun je deze services injecteren in je zakelijke logica:

type PDFGenerator interface {
    GenerateReport(data ReportData) ([]byte, error)
}

type ReportService struct {
    pdfGen PDFGenerator
    repo   ReportRepository
}

func NewReportService(pdfGen PDFGenerator, repo ReportRepository) *ReportService {
    return &ReportService{
        pdfGen: pdfGen,
        repo:   repo,
    }
}

Dit stelt je in staat om PDF generatieimplementaties te wisselen of mocks te gebruiken tijdens het testen.

Conclusie

Dependency injection is een kernonderdeel van het schrijven van onderhoudbare, toetsbare Go-code. Door de patronen die in dit artikel zijn uitgelegd—constructor injection, interfacegebaseerd ontwerp en het compositie root patroon—maak je toepassingen die makkelijker te begrijpen, testen en aanpassen zijn.

Begin met handmatige constructor injection voor kleine tot gemiddelde toepassingen, en overweeg frameworks zoals Wire of Dig wanneer je afhankelijkheidsgrafiek groeit. Denk eraan dat het doel duidelijkheid en toetsbaarheid is, niet complexiteit voor de zake.

Voor meer Go-ontwikkelingsresources, bekijk onze Go Cheatsheet voor een snelle verwijzing naar Go-syntaxis en veelvoorkomende patronen.

Externe bronnen