Project structuur in Go: hoe organiseer je je code

Leer de ideale project structuur in Go. Van cmd en internal tot pkg layout, met praktische voorbeelden en best practices voor schaalbare Go projecten.

11 augustus 20268 min leestijdDoor We Develop Communication

Een goede project structuur in Go maakt het verschil tussen een codebase die jaren meegaat en eentje die na zes maanden al onoverzichtelijk is. Go geeft je veel vrijheid in hoe je je code organiseert, maar juist die vrijheid zorgt bij beginners vaak voor verwarring. Waar zet je je main-bestand? Wat doet die internal map precies? En wanneer gebruik je pkg?

In dit artikel leer je hoe je een Go project opzet dat schaalbaar, onderhoudbaar en herkenbaar is voor andere Go developers. We behandelen de standaard conventies, het verschil tussen de belangrijkste mappen en praktische voorbeelden die je direct kunt toepassen.

Waarom project structuur in Go belangrijk is

Anders dan bij talen als Java of C# legt Go geen structuur op. Je mag in principe alle code in één bestand zetten of kiezen voor een diepe mappenboom. Toch heeft de Go community in de loop der jaren conventies ontwikkeld die breed geaccepteerd zijn.

Deze conventies helpen je op drie manieren. Ten eerste weten nieuwe teamleden direct waar ze moeten zoeken. Ten tweede dwingt de structuur je om na te denken over dependencies en zichtbaarheid. Ten derde sluit je project aan op tooling zoals go build, go test en IDE's die deze layout herkennen.

Als je nog maar net begint met Go, lees dan eerst onze handleiding over installatie en je eerste Go programma. Voor een breder perspectief op wanneer Go de juiste keuze is, hebben we een artikel geschreven over waarom Go populair is voor backends.

Modules: de basis van elk Go project

Sinds Go 1.11 draait alles om modules. Een module is een verzameling packages die samen versioned en gedistribueerd worden. Elke module heeft een go.mod bestand in de root.

Je maakt een nieuwe module met:

go mod init github.com/jouwnaam/projectnaam

Dit genereert een go.mod bestand:

module github.com/jouwnaam/projectnaam

go 1.22

De module path (hier github.com/jouwnaam/projectnaam) is cruciaal. Alle imports binnen je project gebruiken dit als prefix. Zelfs als je het project nooit op GitHub publiceert, is een domein-achtige naam de standaard. Dat voorkomt naamconflicten met andere modules.

Naast go.mod wordt er bij de eerste dependency een go.sum bestand aangemaakt. Dit bevat checksums voor reproduceerbare builds en hoort gewoon in je versiebeheer.

De standaard mappenstructuur

Een typisch Go project voor een backend service ziet er zo uit:

projectnaam/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── handler/
│   ├── service/
│   └── repository/
├── pkg/
│   └── validator/
├── api/
├── configs/
├── scripts/
├── go.mod
├── go.sum
└── README.md

Niet elk project heeft al deze mappen nodig. Voor een simpel CLI-tool kun je prima volstaan met alleen main.go in de root. Schaalt je project, dan groeit de structuur mee.

De cmd directory

De cmd map bevat de main packages voor je executables. Elk subdirectory in cmd vertegenwoordigt één binary.

cmd/
├── server/
│   └── main.go      // de HTTP server
├── worker/
│   └── main.go      // background worker
└── migrate/
    └── main.go      // database migrations

Je bouwt dan één specifieke binary met:

go build -o bin/server ./cmd/server

Belangrijk: houd de code in cmd/*/main.go zo klein mogelijk. De main functie regelt alleen het opstarten, het parsen van flags en het aanroepen van de daadwerkelijke logica die elders staat.

De internal directory

De internal map is een bijzondere map in Go. De compiler blokkeert automatisch imports vanuit andere modules. Alleen code binnen jouw eigen module kan packages uit internal importeren.

Dit is enorm waardevol. Stel je hebt een internal/auth package met sessie-logica. Een externe gebruiker van je library kan die nooit importeren, ook niet per ongeluk. Je blijft dus vrij om de API te veranderen zonder dat dat breaking changes oplevert voor anderen.

Voor de meeste applicaties hoort het grootste deel van je code in internal thuis. Alleen wat je bewust wilt delen gaat erbuiten.

De pkg directory

De pkg map is bedoeld voor code die veilig door externe projecten geïmporteerd mag worden. Hier zet je bijvoorbeeld utility packages, clients voor je eigen API, of herbruikbare componenten.

Let op: pkg is controversieel. Veel Go developers, waaronder enkele core contributors, vinden het onnodige ruis. Als je geen library publiceert en alle code alleen intern gebruikt, kun je pkg gerust overslaan en alles in internal plaatsen of direct in de root van je module.

Packages: één verantwoordelijkheid per package

Een package in Go is een map met één of meer .go bestanden die hetzelfde package statement bovenaan hebben. De mapnaam is tegelijk de package naam in imports.

Goede packages hebben drie eigenschappen:

  • Klein en cohesief: één duidelijke verantwoordelijkheid
  • Korte naam: bij voorkeur één woord, lowercase, zonder underscores
  • Kleine publieke API: exporteer alleen wat nodig is (hoofdletter = public)

Vermijd generieke namen zoals utils, helpers of common. Die worden onvermijdelijk dumpplekken voor losse functies zonder samenhang.

Een voorbeeld van een goede package indeling voor een webshop:

internal/
├── cart/           // winkelwagenlogica
├── checkout/       // afrekenproces
├── inventory/      // voorraadbeheer
├── payment/        // betaalintegraties
└── user/           // gebruikersaccounts

Elk van deze packages heeft een duidelijk domein. In onze gids over structs en interfaces in Go lees je hoe je binnen zo'n package je types effectief opzet.

Een praktisch voorbeeld: web service

Laten we een concrete structuur bekijken voor een typische REST API:

shop-api/
├── cmd/
│   └── api/
│       └── main.go
├── internal/
│   ├── config/
│   │   └── config.go
│   ├── handler/
│   │   ├── product.go
│   │   └── order.go
│   ├── service/
│   │   ├── product.go
│   │   └── order.go
│   ├── repository/
│   │   ├── product.go
│   │   └── order.go
│   └── model/
│       ├── product.go
│       └── order.go
├── migrations/
│   └── 001_init.sql
├── go.mod
└── go.sum

De flow is duidelijk gelaagd: handler ontvangt HTTP requests, roept een service aan voor business logica, die op zijn beurt een repository gebruikt voor database toegang. Als je nog niet bekend bent met HTTP in Go, lees dan onze uitleg over HTTP servers bouwen in Go. Voor het repository-patroon met databases helpt ons artikel over database toegang in Go.

De main functie compact houden

Een goede main.go leest als een samenvatting van je applicatie:

package main

import (
	"log"

	"github.com/jouwnaam/shop-api/internal/config"
	"github.com/jouwnaam/shop-api/internal/handler"
	"github.com/jouwnaam/shop-api/internal/repository"
	"github.com/jouwnaam/shop-api/internal/service"
)

func main() {
	cfg, err := config.Load()
	if err != nil {
		log.Fatalf("config laden mislukt: %v", err)
	}

	db, err := repository.NewDatabase(cfg.DatabaseURL)
	if err != nil {
		log.Fatalf("database connectie mislukt: %v", err)
	}
	defer db.Close()

	productRepo := repository.NewProductRepository(db)
	productSvc := service.NewProductService(productRepo)
	productHandler := handler.NewProductHandler(productSvc)

	srv := handler.NewServer(productHandler)
	log.Fatal(srv.ListenAndServe())
}

Deze stijl heet dependency injection via constructors. Je wiring zit op één plek en je onderliggende packages hoeven niets te weten van elkaars concrete implementaties.

Naamconventies en stijl

Go heeft strikte conventies die door tooling worden afgedwongen:

  • Bestandsnamen: lowercase, underscores alleen voor test- of platformbestanden (user_test.go, file_linux.go)
  • Package namen: lowercase, kort, enkelvoud (user, niet users)
  • Exported vs unexported: begint een naam met een hoofdletter, dan is het publiek
  • Geen circulaire imports: package A kan niet importeren uit B als B ook A importeert

Gebruik altijd gofmt of goimports om je code te formatteren. Dat gebeurt automatisch in vrijwel elke Go IDE. Voor een uitgebreide officiële richtlijn zie de Effective Go documentatie en de Go Modules Reference.

Testbestanden en testorganisatie

Tests horen in hetzelfde package als de code die ze testen, in bestanden met suffix _test.go:

internal/cart/
├── cart.go
├── cart_test.go
├── discount.go
└── discount_test.go

Voor integration tests of tests die alleen de publieke API raken, kun je een apart cart_test package gebruiken (in hetzelfde bestand, ander package statement). Zo dwing je jezelf om via de publieke API te testen.

Configuratie en environment

Houd configuratie gescheiden van code. Een veelgebruikt patroon:

package config

type Config struct {
	DatabaseURL string
	Port        string
	LogLevel    string
}

func Load() (*Config, error) {
	return &Config{
		DatabaseURL: os.Getenv("DATABASE_URL"),
		Port:        getEnv("PORT", "8080"),
		LogLevel:    getEnv("LOG_LEVEL", "info"),
	}, nil
}

Voor grotere projecten kun je libraries zoals Viper overwegen, maar voor veel services is os.Getenv met sensible defaults meer dan voldoende.

Veelgemaakte fouten

Let op deze valkuilen bij het opzetten van je project:

  • Te vroeg opsplitsen: begin simpel, refactor wanneer de pijn reëel is
  • Generieke package-namen: utils en helpers worden altijd een rommelbak
  • Business logica in main: main.go hoort klein te blijven, orchestratie only
  • Alles in één package: een grote app package verslaat het doel van package-isolatie
  • Circulaire dependencies: vaak een teken dat je abstractie verkeerd zit

Als je goroutines of channels gebruikt in je services, raden we onze artikelen over goroutines en concurrency basics en channels diep uitgelegd aan voor veilige concurrent code.

Voorbeeldprojecten ter inspiratie

Wil je voorbeelden zien van echte Go projecten? De golang-standards/project-layout repository is een veelgebruikte referentie, al benadrukken de maintainers zelf dat het geen officiële standaard is. Kijk ook naar open source projecten zoals Kubernetes, Prometheus of Hugo om te zien hoe grote codebases georganiseerd zijn.

Veelgestelde vragen

Wat is de standaard project structuur in Go?

Go heeft geen officiële verplichte structuur, maar er is een community-conventie met mappen zoals cmd, internal en pkg. Deze layout wordt breed gebruikt en maakt projecten voorspelbaar voor andere Go developers.

Waarvoor dient de internal map in Go?

De internal map bevat code die alleen door jouw eigen project gebruikt mag worden. De Go compiler blokkeert imports vanuit andere modules, waardoor je interne implementatiedetails afschermt en je publieke API klein houdt.

Moet ik altijd een pkg map gebruiken?

Nee, de pkg map is optioneel en zelfs omstreden. Voor kleine projecten voegt het vaak alleen ruis toe. Gebruik pkg alleen als je bewust herbruikbare packages publiceert die ook buiten je project bruikbaar zijn.

Hoe werkt een Go module in relatie tot de projectstructuur?

Een module wordt gedefinieerd door go.mod in de root van je project en bepaalt het import-pad. Alle packages binnen die module gebruiken dit pad als prefix, ongeacht hoe diep ze in de mappenstructuur zitten.

Wanneer splits ik code op in meerdere packages?

Splits zodra een package meerdere duidelijk verschillende verantwoordelijkheden krijgt of te groot wordt om snel te overzien. Packages horen één cohesief doel te hebben en een duidelijke, kleine publieke API aan te bieden.

Conclusie

Een doordachte project structuur in Go is geen kwestie van regels volgen, maar van intentie tonen. Begin klein met een enkele main.go, introduceer internal zodra je packages wilt afschermen, en voeg cmd toe als je meerdere binaries nodig hebt.

De belangrijkste regel: laat je structuur meegroeien met de complexiteit van je project. Forceer geen layout die niet past bij de omvang van je code, maar wees ook niet bang om te refactoren zodra de huidige structuur je in de weg zit.

Veelgestelde vragen

Klaar om digitaal te groeien?

Wij helpen Nederlandse bedrijven met webtechnologie en SEO-strategieën die écht werken. Neem vrijblijvend contact op.