Go Design Patterns: Essentiƫle patronen en interviewvragen voor Go-ontwikkelaars

De zes belangrijkste Go design patterns met productieklare code: Functional Options, Strategy, Factory, Observer, Middleware en Struct Embedding. Inclusief veelgestelde interviewvragen.

Go Design Patterns: Functional Options, Strategy, Factory, Observer, Middleware en Struct Embedding

Go heeft zich in korte tijd ontwikkeld tot een van de meest populaire programmeertalen voor backend-ontwikkeling, cloud-native applicaties en gedistribueerde systemen. Hoewel de taal bewust geen klassieke objectgeorienteerde concepten zoals overerving en abstracte klassen bevat, beschikt Go over een eigen set krachtige design patterns die optimaal gebruikmaken van interfaces, compositie en concurrency-primitieven. Voor ontwikkelaars die zich voorbereiden op technische interviews of hun Go-vaardigheden willen aanscherpen, is een grondige kennis van deze patronen onmisbaar. Dit artikel behandelt zes essentiele Go design patterns, inclusief productieklare codevoorbeelden en veelgestelde interviewvragen.

Idiomatisch Go-principe

Go volgt een composition-first benadering in plaats van het klassieke overerving-model. Design patterns in Go worden daarom geimplementeerd met interfaces, functies als first-class citizens en struct embedding. Het begrijpen van deze filosofie is de sleutel tot het schrijven van idiomatische Go-code.

Functional Options Pattern

Het Functional Options pattern is een van de meest kenmerkende Go-patronen en lost een veelvoorkomend probleem op: hoe configureer je een struct met veel optionele parameters zonder een onoverzichtelijke constructor of een aparte config-struct? Dit patroon maakt gebruik van closures die de interne staat van een object aanpassen, waardoor een elegante en uitbreidbare API ontstaat.

De kracht van dit patroon ligt in de uitbreidbaarheid. Nieuwe opties kunnen worden toegevoegd zonder bestaande aanroepen te breken, standaardwaarden worden centraal gedefinieerd, en de resulterende code leest als een reeks declaratieve configuratie-instructies. Bibliotheken zoals grpc-go, zap en fx van Uber maken veelvuldig gebruik van dit patroon.

server.gogo
package server

import (
	"time"
	"log/slog"
)

// Server holds the HTTP server configuration.
type Server struct {
	host         string
	port         int
	timeout      time.Duration
	maxConns     int
	logger       *slog.Logger
}

// Option defines a functional option for Server.
type Option func(*Server)

// WithPort sets the server port.
func WithPort(port int) Option {
	return func(s *Server) {
		s.port = port
	}
}

// WithTimeout sets the request timeout.
func WithTimeout(d time.Duration) Option {
	return func(s *Server) {
		s.timeout = d
	}
}

// WithMaxConns sets the maximum concurrent connections.
func WithMaxConns(n int) Option {
	return func(s *Server) {
		s.maxConns = n
	}
}

// New creates a Server with sensible defaults and applies options.
func New(host string, opts ...Option) *Server {
	srv := &Server{
		host:     host,
		port:     8080,           // default port
		timeout:  30 * time.Second, // default timeout
		maxConns: 100,            // default max connections
		logger:   slog.Default(),
	}
	for _, opt := range opts {
		opt(srv)
	}
	return srv
}

Het aanroepen van deze constructor is bijzonder leesbaar: server.New("localhost", server.WithPort(9090), server.WithTimeout(10*time.Second)). Elke optie is zelfbeschrijvend en de volgorde maakt niet uit. Dit patroon wordt bij Go-interviews vaak bevraagd omdat het laat zien dat een kandidaat idiomatische Go-patronen begrijpt en kan toepassen.

Strategy Pattern met Interfaces

Het Strategy pattern maakt het mogelijk om algoritmen of gedragingen onderling uitwisselbaar te maken zonder de aanroepende code te wijzigen. In Go wordt dit patroon op natuurlijke wijze geimplementeerd via interfaces, aangezien elke type die de juiste methoden implementeert automatisch voldoet aan de interface -- zonder expliciete declaratie.

Dit patroon is bijzonder waardevol in systemen die meerdere betaalprocessors, notificatiekanalen of dataopslagstrategieen moeten ondersteunen. De losse koppeling die het Strategy pattern biedt, maakt unit testing aanzienlijk eenvoudiger doordat mock-implementaties probleemloos kunnen worden ingevoegd.

payment.gogo
package payment

import "fmt"

// Processor defines the strategy interface.
type Processor interface {
	Pay(amount float64) (string, error)
}

// CreditCard implements Processor for card payments.
type CreditCard struct {
	CardNumber string
	Expiry     string
}

func (c *CreditCard) Pay(amount float64) (string, error) {
	// Charge the card via payment gateway
	return fmt.Sprintf("charged %.2f to card ending %s", amount, c.CardNumber[len(c.CardNumber)-4:]), nil
}

// BankTransfer implements Processor for wire transfers.
type BankTransfer struct {
	IBAN string
}

func (b *BankTransfer) Pay(amount float64) (string, error) {
	return fmt.Sprintf("initiated transfer of %.2f to %s", amount, b.IBAN), nil
}

// Checkout processes a payment using the given strategy.
func Checkout(p Processor, amount float64) error {
	receipt, err := p.Pay(amount)
	if err != nil {
		return fmt.Errorf("payment failed: %w", err)
	}
	fmt.Println(receipt)
	return nil
}

Het cruciale inzicht hier is dat Checkout uitsluitend afhankelijk is van de Processor-interface en geen kennis heeft van de concrete implementaties. Nieuwe betaalmethoden zoals cryptocurrency of digitale wallets kunnen worden toegevoegd door simpelweg een nieuwe struct te maken die de Pay-methode implementeert. In Go-interviews wordt dit patroon vaak gebruikt om te toetsen of een kandidaat het principe van implicit interface satisfaction begrijpt.

Factory Functions en Validatie

In tegenstelling tot talen met constructor-overloading gebruikt Go factory functions om objecten te initialiseren. Dit patroon is vooral belangrijk wanneer validatie nodig is bij het aanmaken van een object. De conventie in Go is om een functie te schrijven die begint met New en die zowel het object als een mogelijke error retourneert.

Factory functions bieden volledige controle over het initialisatieproces. Ongeldige parameters worden direct afgevangen, interne velden worden correct geinitialiseerd, en de aanroeper wordt via het standaard error-mechanisme geinformeerd over eventuele problemen. Het volgende voorbeeld toont een connection pool die thread-safe is dankzij het gebruik van sync.Mutex.

connpool.gogo
package pool

import (
	"errors"
	"sync"
)

// ConnPool manages a pool of reusable connections.
type ConnPool struct {
	mu       sync.Mutex
	conns    []Conn
	maxSize  int
}

// Conn represents a database connection.
type Conn struct {
	ID     int
	Active bool
}

// NewConnPool validates parameters and returns an initialized pool.
func NewConnPool(maxSize int) (*ConnPool, error) {
	if maxSize <= 0 {
		return nil, errors.New("pool: maxSize must be positive")
	}
	return &ConnPool{
		conns:   make([]Conn, 0, maxSize),
		maxSize: maxSize,
	}, nil
}

// Acquire returns a connection from the pool.
func (p *ConnPool) Acquire() (*Conn, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	for i := range p.conns {
		if !p.conns[i].Active {
			p.conns[i].Active = true
			return &p.conns[i], nil
		}
	}
	if len(p.conns) >= p.maxSize {
		return nil, errors.New("pool: no available connections")
	}
	c := Conn{ID: len(p.conns) + 1, Active: true}
	p.conns = append(p.conns, c)
	return &p.conns[len(p.conns)-1], nil
}

De NewConnPool-functie valideert de maxSize-parameter voordat het object wordt aangemaakt. Dit voorkomt dat een ongeldige pool in het systeem terechtkomt. De Acquire-methode demonstreert daarnaast correct gebruik van sync.Mutex voor thread-safe toegang tot gedeelde resources, een fundamenteel concept voor concurrent Go-programma's.

Klaar om je Go gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Observer Pattern met Channels

Het Observer pattern -- ook bekend als publish-subscribe -- stelt componenten in staat om te reageren op gebeurtenissen zonder directe afhankelijkheden tussen producer en consumer. Go biedt hiervoor een uniek voordeel ten opzichte van andere talen: channels als eersterangs taalconstructie maken het mogelijk om een type-safe, concurrent event bus te bouwen zonder externe bibliotheken.

Het onderstaande voorbeeld implementeert een event bus met gebufferde channels. Subscribers ontvangen gebeurtenissen asynchroon, en het select-statement met een default-case voorkomt dat een trage subscriber het hele systeem blokkeert.

eventbus.gogo
package events

import "sync"

// Event carries a topic and payload.
type Event struct {
	Topic   string
	Payload any
}

// Bus manages subscriptions and event dispatch.
type Bus struct {
	mu          sync.RWMutex
	subscribers map[string][]chan Event
}

// NewBus creates an event bus.
func NewBus() *Bus {
	return &Bus{
		subscribers: make(map[string][]chan Event),
	}
}

// Subscribe returns a channel that receives events for a topic.
func (b *Bus) Subscribe(topic string) <-chan Event {
	ch := make(chan Event, 16) // buffered to avoid blocking publisher
	b.mu.Lock()
	b.subscribers[topic] = append(b.subscribers[topic], ch)
	b.mu.Unlock()
	return ch
}

// Publish sends an event to all subscribers of the topic.
func (b *Bus) Publish(topic string, payload any) {
	b.mu.RLock()
	defer b.mu.RUnlock()
	for _, ch := range b.subscribers[topic] {
		select {
		case ch <- Event{Topic: topic, Payload: payload}:
		default:
			// subscriber too slow, drop event
		}
	}
}

Let op het gebruik van sync.RWMutex in plaats van een reguliere Mutex. De Publish-methode gebruikt een read-lock (RLock) omdat deze de subscribers-map alleen leest, terwijl Subscribe een volledige write-lock nodig heeft om de map te muteren. Dit onderscheid zorgt voor betere performance bij veel gelijktijdige lees-operaties. Het retourneren van een receive-only channel (<-chan Event) is een bewuste ontwerpkeuze die voorkomt dat subscribers per ongeluk naar het channel schrijven.

Middleware Pattern

Het Middleware pattern is alomtegenwoordig in Go-webontwikkeling. Het stelt ontwikkelaars in staat om cross-cutting concerns zoals logging, authenticatie, rate limiting en CORS-headers te scheiden van de kernlogica van request handlers. Het patroon werkt door functies te chainen die elk een http.Handler ontvangen en een nieuwe http.Handler retourneren.

De elegantie van dit patroon schuilt in de composability: middleware-functies zijn volledig onafhankelijk van elkaar en kunnen in willekeurige volgorde worden gecombineerd. Frameworks zoals Chi en de standaard net/http-library ondersteunen dit patroon native.

middleware.gogo
package middleware

import (
	"log/slog"
	"net/http"
	"time"
)

// Logging records request duration and status.
func Logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		wrapped := &statusWriter{ResponseWriter: w, status: 200}
		next.ServeHTTP(wrapped, r)
		slog.Info("request",
			"method", r.Method,
			"path", r.URL.Path,
			"status", wrapped.status,
			"duration", time.Since(start),
		)
	})
}

// statusWriter captures the HTTP status code.
type statusWriter struct {
	http.ResponseWriter
	status int
}

func (w *statusWriter) WriteHeader(code int) {
	w.status = code
	w.ResponseWriter.WriteHeader(code)
}

// Chain applies middleware in order: first listed = outermost.
func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
	for i := len(mw) - 1; i >= 0; i-- {
		h = mw[i](h)
	}
	return h
}

De statusWriter struct demonstreert een belangrijk Go-patroon: het wrappen van een interface om extra functionaliteit toe te voegen. Door http.ResponseWriter te embedden in de struct worden alle methoden van de originele ResponseWriter doorgestuurd, terwijl WriteHeader wordt overschreven om de statuscode vast te leggen. De Chain-functie itereert in omgekeerde volgorde zodat de eerste middleware in de lijst als buitenste laag fungeert.

Compositie via Struct Embedding

Go heeft geen overerving, maar biedt struct embedding als krachtig alternatief. Door een struct in te bedden in een andere struct worden alle velden en methoden van de ingebedde struct gepromoot naar de buitenste struct. Dit maakt code-hergebruik mogelijk zonder de complexiteit van diepe overervingshierarchieen.

In de praktijk wordt struct embedding veelvuldig gebruikt voor het delen van gemeenschappelijke velden zoals timestamps, audit-informatie of soft-delete functionaliteit. Het volgende voorbeeld toont hoe User en Order beide automatisch CreatedAt en UpdatedAt velden verkrijgen.

models.gogo
package models

import "time"

// Timestamps provides common audit fields.
type Timestamps struct {
	CreatedAt time.Time
	UpdatedAt time.Time
}

// User embeds Timestamps to gain CreatedAt/UpdatedAt.
type User struct {
	Timestamps
	ID    int
	Email string
}

// Order also embeds Timestamps.
type Order struct {
	Timestamps
	ID     int
	UserID int
	Total  float64
}

Na embedding kan direct user.CreatedAt worden geschreven in plaats van user.Timestamps.CreatedAt. Go promoot de velden automatisch naar het buitenste niveau. Dit is geen overerving maar compositie: er bestaat geen is-a relatie tussen User en Timestamps. Een User bevat timestamps, maar is geen timestamp. Dit onderscheid is cruciaal bij Go-interviews en toont begrip van Go's ontwerpfilosofie.

Veelgestelde Interviewvragen over Go Design Patterns

Technische interviews over Go richten zich vaak op het praktische begrip van design patterns en de onderliggende taalconcepten. Hieronder volgen vier veelgestelde vragen met uitgebreide antwoorden.

Waarom gebruikt Go implicit interface satisfaction en wat zijn de voordelen?

In Go hoeft een type niet expliciet te declareren welke interfaces het implementeert. Als een type alle methoden van een interface bezit, voldoet het automatisch aan die interface. Dit biedt drie belangrijke voordelen: ten eerste vermindert het de koppeling tussen packages omdat de interface en de implementatie elkaar niet hoeven te kennen. Ten tweede maakt het het eenvoudig om bestaande types van externe bibliotheken aan eigen interfaces te laten voldoen. Ten derde bevordert het kleine, gefocuste interfaces -- de Go-conventie is om interfaces met een of twee methoden te definieren, zoals io.Reader en io.Writer.

Interview-tip

De Go-community hanteert het principe "Accept interfaces, return structs." Door functieparameters als interfaces te definieren en concrete types te retourneren, ontstaat maximale flexibiliteit voor aanroepers terwijl de implementatie expliciet en voorspelbaar blijft.

Wanneer kies je voor het Functional Options pattern in plaats van een config struct?

Het Functional Options pattern verdient de voorkeur wanneer een constructor veel optionele parameters heeft en de API publiek is. De voordelen zijn: standaardwaarden worden centraal beheerd, nieuwe opties breken bestaande aanroepen niet (backward compatibility), en elke optie is zelfbeschrijvend. Een config struct is geschikter wanneer alle parameters verplicht zijn, wanneer de configuratie moet worden geserialiseerd (JSON/YAML), of wanneer de configuratie wordt gedeeld tussen meerdere componenten. In de praktijk combineren veel Go-bibliotheken beide benaderingen: een config struct voor de basisconfiguratie en functional options voor fijnafstelling.

Hoe voorkomt het Middleware pattern codeherhaling in Go-webapplicaties?

Het Middleware pattern extraheert cross-cutting concerns naar herbruikbare, composable functies. In plaats van logging, authenticatie en error handling in elke handler te herhalen, worden deze verantwoordelijkheden in afzonderlijke middleware-functies geplaatst. Elke middleware heeft een enkele verantwoordelijkheid (Single Responsibility Principle) en kan onafhankelijk worden getest. De Chain-functie maakt het mogelijk om middleware declaratief te combineren, waardoor de volgorde van uitvoering duidelijk en aanpasbaar is. Dit patroon is direct toepasbaar in zowel de standaard net/http-library als in populaire frameworks zoals Chi, Echo en Gin.

Let op

Bij het implementeren van middleware in Go is de volgorde van toepassing essentieel. Authenticatie-middleware moet voor autorisatie-middleware worden geplaatst, en logging-middleware hoort doorgaans als buitenste laag zodat alle requests worden vastgelegd, inclusief die welke door andere middleware worden afgewezen.

Wat is het verschil tussen struct embedding en overerving in andere talen?

Struct embedding in Go is compositie, geen overerving. Er is geen polymorfisme via een parent-type: een User met embedded Timestamps kan niet worden behandeld als een Timestamps-waarde. Methoden worden gepromoot naar het buitenste type, maar de ingebedde struct behoudt haar eigen identiteit. Bij naamconflicten wint het buitenste type, en de ingebedde velden zijn altijd bereikbaar via de volledige padnaam. Dit verschilt fundamenteel van overerving waarbij een subclass een superclass vervangt. Go's benadering voorkomt de fragile base class problemen die in overervingshierarchieen kunnen optreden.

Klaar om je Go gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Conclusie

Go design patterns wijken bewust af van de klassieke Gang of Four-patronen en omarmen in plaats daarvan de kernprincipes van de taal: compositie boven overerving, interfaces als gedragscontracten, en concurrency als eersterangs concept. De zes patronen die in dit artikel zijn behandeld, vormen de basis van professionele Go-ontwikkeling:

  • Functional Options bieden een elegante oplossing voor configureerbare constructors met standaardwaarden en backward compatibility
  • Strategy Pattern met implicit interfaces maakt gedrag uitwisselbaar zonder expliciete type-declaraties
  • Factory Functions garanderen correcte initialisatie en validatie via het idiomatische (value, error) return-patroon
  • Observer met Channels levert een type-safe, concurrent publish-subscribe mechanisme zonder externe afhankelijkheden
  • Middleware Pattern scheidt cross-cutting concerns van bedrijfslogica en bevordert herbruikbaarheid
  • Struct Embedding biedt code-hergebruik via compositie zonder de complexiteit van overervingshierarchieen

Het beheersen van deze golang design patterns is niet alleen waardevol voor technische interviews, maar ook direct toepasbaar in productiecode. Door deze patronen te combineren en toe te passen op basis van de specifieke vereisten van een project, ontstaat schone, onderhoudbare en idiomatische Go-code die de tand des tijds doorstaat.

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Tags

#go
#design-patterns
#interview
#best-practices

Delen

Gerelateerde artikelen