Dependency Injection in Go: patronen en best practices

Befaal DI-patronen voor testbare Go-code

Inhoud

Dependency Injectie (DI) is een fundamenteel ontwerppatroon dat schone, testbare en onderhoudbare code bevordert in Go-toepassingen.

Of je nu REST APIs bouwt, multi-tenant databasepatronen implementeert, of werkt met ORM-bibliotheken, het begrip van dependency injectie zal de kwaliteit van je code aanzienlijk verbeteren.

go-dependency-injection

Wat is Dependency Injectie?

Dependency injectie is een ontwerppatroon waarbij componenten hun afhankelijkheden vanuit externe bronnen ontvangen, in plaats van ze intern te creëren. Deze aanpak ontkoppelt componenten, waardoor je code modulair, testbaar en onderhoudbaar wordt.

In Go is dependency injectie bijzonder krachtig vanwege de op interfaces gebaseerde ontwerpfilosofie van de taal. Go’s impliciete interface-ervulling betekent dat je implementaties eenvoudig kunt verwisselen zonder bestaande code te wijzigen.

Waarom Dependency Injectie gebruiken in Go?

Verbeterde testbaarheid: Door afhankelijkheden te injecteren, kun je echte implementaties eenvoudig vervangen door mocks of testdoubles. Dit stelt je in staat om unittests te schrijven die snel geïsoleerd zijn uitgevoerd worden en geen externe diensten zoals databases of APIs vereisen.

Betere onderhoudbaarheid: Afhankelijkheden worden expliciet in je code. Wanneer je naar een constructorfunctie kijkt, zie je direct wat een component vereist. Dit maakt de codebase eenvoudiger te begrijpen en te wijzigen.

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

Flexibiliteit: Je kunt verschillende implementaties configureren voor verschillende omgevingen (ontwikkeling, testen, productie) zonder je bedrijfslogica te wijzigen.

Constructor Injectie: De Go-wijze

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

Basisvoorbeeld

Hier is een eenvoudig voorbeeld dat constructorinjectie demonstreert:

// Definieer een interface voor de repository
type UserRepository interface {
    FindByID(id int) (*User, error)
    Save(user *User) error
}

// Service is afhankelijk van de repository-interface
type UserService struct {
    repo UserRepository
}

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

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

Dit patroon maakt duidelijk dat UserService een UserRepository vereist. Je kunt geen UserService maken zonder een repository te bieden, wat runtime-fouten door ontbrekende afhankelijkheden voorkomt.

Meervoudige 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,
    }
}

Interface-ontwerp voor Dependency Injectie

Een van de kernprincipes bij het implementeren van dependency injectie is het Dependency Inversion Principle (DIP): hooggeplaatste modules mogen niet afhankelijk zijn van laaggeplaatste modules; beide moeten afhankelijk zijn van abstracties.

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

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

// Slecht: Grote interface met onnodige 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 het Interface Segregation Principle—cliënten mogen niet afhankelijk zijn van methoden die ze niet gebruiken. Dit maakt je code flexibeler en eenvoudiger te testen.

Voorbeeld uit de praktijk: Database-abstractie

Bij het werken met databases in Go-toepassingen zul je vaak database-operaties moeten abstracteren. Zo helpt dependency injectie:

// 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 is afhankelijk 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 parseren
}

Dit patroon is vooral nuttig bij het implementeren van multi-tenant databasepatronen, waarbij je mogelijk moet schakelen tussen verschillende database-implementaties of verbindingstrategieën.

Het Composition Root-patroon

De Composition Root is de plek waar je al je afhankelijkheden assemblert op het ingangspunt van de applicatie (doorgaans main). Dit centraliseert de configuratie van afhankelijkheden en maakt de afhankelijkheidsgrafiek 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)
    
    // Routeer opzetten
    router := setupRouter(userHandler, orderHandler)
    
    // Start server
    log.Fatal(http.ListenAndServe(":8080", router))
}

Deze aanpak maakt duidelijk hoe je applicatie is gestructureerd en waar afhankelijkheden vandaan komen. Het is bijzonder waardevol bij het bouwen van REST APIs in Go, waarbij je meerdere lagen van afhankelijkheden moet coördineren.

Dependency Injectie-frameworks

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

Google Wire (Compile-time DI)

Wire is een compile-time dependency injectie-gereedschap dat code genereert. Het is typeveilig 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 injectie-code tijdens het compileren, wat typeveiligheid garandeert en runtime-reflection overhead elimineert.

Uber Dig (Runtime DI)

Dig is een runtime dependency injectie-framework dat reflectie gebruikt. Het is flexibeler maar heeft een bepaalde runtime-kost.

Voorbeeld:

import "go.uber.org/dig"

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

Wanneer frameworks te gebruiken

Gebruik een framework wanneer:

  • Je afhankelijkheidsgrafiek complex is met veel onderling afhankelijke componenten
  • Je meerdere implementaties van dezelfde interface hebt die moeten worden geselecteerd op basis van configuratie
  • Je automatische afhankelijkheidsoplossing wilt
  • Je een grote applicatie bouwt waarbij handmatig wiring foutgevoelig wordt

Blijf bij handmatige DI wanneer:

  • Je applicatie klein tot middelgroot is
  • De afhankelijkheidsgrafiek eenvoudig is en makkelijk te volgen
  • Je afhankelijkheden minimaal en expliciet wilt houden
  • Je expliciete code prefereert boven gegenereerde code

Testen met Dependency Injectie

Een van de primaire voordelen van dependency injectie is verbeterde testbaarheid. Zo maakt DI testen eenvoudiger:

Voorbeeld van Unit Testing

// Mock-implementatie voor testen
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 wordt snel uitgevoerd, vereist geen database en test je bedrijfslogica geïsoleerd. Bij het werken met ORM-bibliotheken in Go, kun je mock-repositories injecteren om servicelogica te testen zonder database-opzet.

Veelvoorkomende patronen en beste praktijken

1. Gebruik Interface Segregation

Houd interfaces klein en gefocust op wat de cliënt daadwerkelijk nodig heeft:

// Goed: Cliënt heeft alleen nodig om gebruikers te lezen
type UserReader interface {
    FindByID(id int) (*User, error)
    FindByEmail(email string) (*User, error)
}

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

2. Geef fouten terug van constructoren

Constructoren moeten afhankelijkheden valideren en fouten teruggeven als initialisering faalt:

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-gescopede 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

Injecteer geen afhankelijkheden die werkelijk interne implementatiedetails zijn. Als een component zijn eigen hulpprogramma’s creëert en beheert, is dat prima:

// Goed: Interne helper heeft geen injectie nodig
type UserService struct {
    repo UserRepository
    // Interne cache - heeft geen injectie nodig
    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 handelt gebruikersgerelateerde bedrijfslogica af.
// Het vereist een UserRepository voor datatoegang en een Logger voor
// fouttracking. De repository moet thread-safe zijn bij gebruik
// in concurrency-contexten.
func NewUserService(repo UserRepository, logger Logger) *UserService {
    // ...
}

Wanneer je Dependency Injectie NIET moet gebruiken

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

Sla DI over voor:

  • Eenvoudige value-objects of datastructuren
  • Interne helperfuncties of utilities
  • Eénmalige scripts of kleine utilities
  • Wanneer directe instantiatie duidelijker en eenvoudiger is

Voorbeeld van wanneer je DI NIET moet gebruiken:

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

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

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

Integratie met het Go-ecosysteem

Dependency injectie werkt naadloos samen met andere Go-patronen en tools. Bij het bouwen van applicaties die Go’s standaardbibliotheek voor web scraping of het genereren van PDF-rapporten gebruiken, kun je deze diensten injecteren in je bedrijfslogica:

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-generatie-implementaties te verwisselen of mocks te gebruiken tijdens het testen.

Conclusie

Dependency injectie is een hoeksteen van het schrijven van onderhoudbare, testbare Go-code. Door de patronen in dit artikel beschreven te volgen—constructor injectie, interface-gebaseerd ontwerp en het composition root-patroon—zou je applicaties moeten creëren die eenvoudiger te begrijpen, te testen en te wijzigen zijn.

Begin met handmatige constructorinjectie voor kleine tot middelgrote applicaties, en overweeg frameworks zoals Wire of Dig naarmate je afhankelijkheidsgrafiek groeit. Onthoud dat het doel duidelijkheid en testbaarheid is, niet complexiteit om zijn eigen wil. Als je applicatie structureert rond command- en query-handlers, toont Implementeren van CQRS in Go hoe dezelfde constructorinjectie-aanpak een Application, Commands en Queries struct schoon en zonder ceremonie wiret.

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

Externe bronnen

Abonneren

Ontvang nieuwe berichten over systemen, infrastructuur en AI-engineering.