Ein Go-Projekt braucht im Minimalfall nur eine einzige .go-Datei. Alles darüber hinaus — go.mod, go.sum, cmd/, internal/, vendor/, go.work — ist ein wohldefiniertes System, das Modulgrenzen, Sichtbarkeit und reproduzierbare Builds regelt. Dieser Artikel zeigt, wie ein Go-Projekt aufgebaut ist: vom kleinsten lauffähigen Programm über das Module-System mit go.mod und go.sum bis zu Multi-Module-Workspaces. Das Ziel: nach dem Lesen weißt du, wo welcher Code hingehört und warum Go bestimmte Konventionen sogar im Compiler durchsetzt.

Was ein Go-Projekt mindestens braucht

Das absolut kleinste lauffähige Go-Programm besteht aus einer einzigen Datei mit package main und einer Funktion main:

Go hello.go
package main

import "fmt"

func main() {
    fmt.Println("Hallo")
}
Output
Hallo

Aufruf mit go run hello.go — fertig. Kein go.mod, kein Layout, kein Build-System. Sobald du jedoch Dependencies brauchst, eigene Pakete in Unterordner legen willst oder das Projekt auschecken und woanders bauen sollst, kommt die Modul-Schicht ins Spiel.

Ein Projekt wird zum Modul, sobald in seiner Wurzel ein go.mod liegt. Das wird mit einem Befehl erzeugt:

bash terminal
go mod init github.com/mibeon/example

Damit hast du zwei Dateien — die .go-Quelle und ein go.mod — und kannst Dependencies hinzufügen, das Projekt versionieren und es überall reproduzierbar bauen. Das ist der Standardfall. Ein Projekt ohne go.mod ist heute nur noch für Throwaway-Skripte sinnvoll.

Das go.mod-File im Detail

go.mod ist die zentrale Konfiguration eines Moduls. Sie ist menschenlesbar, zeilenbasiert und wird normalerweise vom go-Tool gepflegt — Hand-Edits sind aber jederzeit erlaubt.

toml go.mod
module github.com/mibeon/example

go 1.22

toolchain go1.23.0

require (
    github.com/google/uuid v1.6.0
    golang.org/x/text v0.16.0 // indirect
)

replace github.com/mibeon/internal-lib => ../internal-lib

exclude github.com/some/dep v1.4.0

retract v0.1.0 // versehentlich veröffentlicht

Die wichtigsten Direktiven im Überblick:

DirektiveBedeutung
moduleDer Modul-Pfad — gleichzeitig Import-Präfix für alle Pakete des Moduls. Erscheint genau einmal.
goMindest-Go-Version. Seit Go 1.21 verbindlich (kein advisory hint mehr). Steuert Sprach-Features und Toolchain-Verhalten.
toolchainVorgeschlagene konkrete Toolchain-Version (z. B. go1.23.0). Optional.
requireDirekte und transitive Dependencies mit Mindest-Version. // indirect markiert nicht direkt importierte Pakete.
replaceErsetzt eine Dependency durch einen anderen Pfad oder eine lokale Verzeichnis-Referenz. Wirkt nur im Haupt-Modul.
excludeSchließt eine konkrete Version aus der Auflösung aus — etwa bei kaputten Releases.
retractMarkiert eigene veröffentlichte Versionen als „nicht benutzen" (Tippfehler-Tag, kritischer Bug).

replace und exclude greifen nur, wenn das Modul selbst das Haupt-Modul ist — Konsumenten ignorieren diese Direktiven. Das ist eine bewusste Designentscheidung: Sicherheits-Workarounds eines Forks sollen sich nicht stillschweigend auf alle Nutzer ausbreiten.

go.sum — Integritäts-Hashes für Reproducible Builds

Neben go.mod legt das go-Tool eine zweite Datei an: go.sum. Sie enthält kryptografische Hashes aller Module, die direkt oder transitiv im Build vorkommen.

toml go.sum (Auszug)
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

Pro Modul-Version stehen typischerweise zwei Zeilen: ein Hash über den ZIP-Inhalt und ein Hash über die go.mod der Dependency. Beim nächsten Build prüft go diese Hashes gegen das, was aus dem Cache oder vom Proxy kommt — und bricht ab, wenn sich auch nur ein Byte geändert hat.

go.sum ist nicht optional und gehört in die Versionskontrolle. Verloren oder bewusst gelöscht? Dann erzeugt go mod tidy die Datei aus den aktuellen Modul-Inhalten neu — was beim ersten Mal eine Vertrauensentscheidung ist (was steht im Proxy?), danach aber bombenfest reproduziert.

Modul-Pfad und Import-Pfad

Der Modul-Pfad in der ersten Zeile von go.mod ist mehr als Metadaten — er ist das Präfix für alle Imports innerhalb des Moduls. Beispiel: module github.com/mibeon/example. Eine Datei in internal/auth/auth.go liegt dann unter dem Import-Pfad:

Go server/main.go
package main

import (
    "github.com/mibeon/example/internal/auth"
)

func main() {
    _ = auth.Token{}
}

Konvention: Der Modul-Pfad spiegelt den Repository-Pfad. Das ermöglicht go get github.com/mibeon/example ohne weitere Konfiguration — der go-Befehl klont das Repo dann selbstständig.

Pflicht ist das aber nicht: Solange dein Modul nicht extern published wird, darf der Modul-Pfad alles sein, was syntaktisch wie ein Pfad aussieht — example.com/foo, mycompany/internal/tool, sogar playground/test. Erst beim Veröffentlichen über go install oder go get wird ein echter, auflösbarer Pfad nötig.

Standard-Projektlayout

Go hat — anders als z. B. Java oder Rust — kein vom Tool erzwungenes Projektlayout. Die offizielle Layout-Doku und die Community haben sich aber auf eine Handvoll Patterns geeinigt:

OrdnerZweck
cmd/<name>/Pro Binary ein Unterordner mit main.go. Best Practice bei mehreren ausführbaren Programmen im gleichen Modul.
internal/Compiler-erzwungene Sichtbarkeitsgrenze. Pakete darin sind nur vom übergeordneten Modul importierbar.
pkg/Reusable Library-Code, der explizit für externe Konsumenten gedacht ist. Konvention, kein Sprach-Feature. Offizielles Go-Layout-Doku empfiehlt es nicht aktiv.
api/OpenAPI-, Protobuf- oder JSON-Schema-Definitionen.
docs/Projekt-Dokumentation, Diagramme, Designnotizen.
WurzelBei einer einzelnen Library oder einem einzigen Command kann die main.go direkt im Root liegen.

Eine typische Server-Codebase mit Multi-Binary-Setup sieht so aus:

bash Verzeichnisbaum
myservice/
├── go.mod
├── go.sum
├── cmd/
   ├── api-server/
   └── main.go
   └── migrate/
       └── main.go
├── internal/
   ├── auth/
   ├── store/
   └── handlers/
├── api/
   └── openapi.yaml
└── docs/
    └── architecture.md

Die Faustregel: klein anfangen, flach bleiben. Ein Single-Binary-Tool braucht weder cmd/ noch internal/ noch pkg/ — eine main.go plus ein paar Hilfs-Dateien im Root reicht. Erst wenn die Datei-Anzahl unübersichtlich wird oder ein zweites Binary dazukommt, lohnt sich der Layout-Aufwand.

Spezial-Ordner: internal/

Der internal/-Ordner ist die einzige Layout-Konvention, die der Go-Compiler selbst durchsetzt. Die Regel ist simpel und harmlos zu beschreiben — und ungeheuer wirksam:

Ein Paket unter .../internal/... ist nur importierbar von Code, der unter dem gleichen übergeordneten Verzeichnis liegt wie das internal/ selbst.

Konkret: Liegt internal/auth/ direkt unter github.com/mibeon/example/, darf nur Code innerhalb von github.com/mibeon/example/... dieses Paket importieren. Jeder andere Modul-Konsument bekommt einen Compile-Fehler:

bash terminal
go build ./...
Output
package github.com/foo/other: use of internal package
github.com/mibeon/example/internal/auth not allowed

Damit kannst du bewusst öffentlichen API-Oberflächen definieren: Alles in internal/ darfst du jederzeit umbenennen, refactoren oder entfernen, ohne Konsumenten zu brechen — der Compiler garantiert, dass es niemand importieren kann. Das ist ein extrem wertvolles Werkzeug für API-Hygiene und in vielen Codebases der Default für alles, was nicht explizit als Library exportiert werden soll.

Vendor-Ordner: vendor/ und go mod vendor

Standardmäßig holt sich Go Dependencies aus dem Module-Cache ($GOPATH/pkg/mod) oder über einen Module-Proxy (proxy.golang.org). Der Befehl go mod vendor legt stattdessen einen lokalen Snapshot aller Dependencies in den Ordner vendor/ im Modul-Root:

bash terminal
go mod vendor

Sobald vendor/modules.txt existiert (und go ≥ 1.14 in go.mod steht), aktiviert Go automatisch Vendor-Modus: Dependencies werden direkt aus vendor/ gelesen, der Proxy wird nicht kontaktiert.

Wann sinnvoll:

  • Air-gapped Build-Umgebungen — der Build läuft ohne Internet.
  • Strikte Compliance/Audit-Anforderungen — alle Dependencies liegen direkt im Repo, vollständig reviewbar.
  • Schutz gegen verschwundene Module — selbst wenn ein Upstream-Repo offline geht, baut dein Code weiter.

Wann eher nicht:

  • Bei normalen Open-Source-Projekten — der Module-Proxy von Go ist hochverfügbar, und ein eingecheckter vendor/-Ordner bläht das Repo enorm auf.
  • Bei privaten Modulen mit GOPRIVATE und gutem Cache — meist überflüssig.

Multi-Module-Projekte mit go.work

Seit Go 1.18 gibt es Workspaces über die Datei go.work. Ein Workspace gruppiert mehrere lokale Module so, dass sie sich gegenseitig sehen — ohne dass dafür replace-Direktiven in den einzelnen go.mod-Dateien nötig sind.

bash terminal
cd ~/code
go work init ./api ./shared ./worker

Erzeugt:

toml go.work
go 1.22

use (
    ./api
    ./shared
    ./worker
)

Der Effekt: Wenn api einen Import auf github.com/mibeon/shared enthält, nimmt der go-Befehl die lokale Version aus ./shared statt einer veröffentlichten Version aus dem Modul-Proxy.

Der entscheidende Vorteil gegenüber replace: go.work wird nicht eingecheckt. Es ist eine persönliche Entwickler-Konfiguration für dein lokales Setup — die produktive go.mod der Module bleibt sauber und versionsstabil. Klassischer Use-Case: Du arbeitest gleichzeitig an einer Library und an einem Service, der diese Library nutzt, willst aber keinen replace-Eintrag committen, der nur auf deiner Maschine funktioniert.

Beispielprojekt: realistische Struktur einer kleinen Web-App

Eine kleine Web-App mit API-Server und Hintergrund-Worker, sauber strukturiert mit cmd/ und internal/:

bash Verzeichnisbaum
mibeon-shop/
├── go.mod
├── go.sum
├── README.md
├── cmd/
   ├── shop-api/
   └── main.go          # HTTP-Server-Eintrittspunkt
   └── shop-worker/
       └── main.go          # Background-Job-Eintrittspunkt
├── internal/
   ├── config/
   └── config.go        # Env-Vars, Defaults
   ├── db/
   ├── postgres.go
   └── migrations/
   ├── http/
   ├── router.go
   ├── middleware.go
   └── handlers/
       ├── checkout.go
       └── catalog.go
   ├── domain/
   ├── product.go
   └── order.go
   └── worker/
       └── jobs.go
├── api/
   └── openapi.yaml
└── deploy/
    ├── Dockerfile.api
    └── Dockerfile.worker

Charakteristika:

  • Zwei Binaries in cmd/shop-api und shop-worker teilen sich denselben Domain-Code.
  • Geteilter Code unter internal/domain, db, config werden von beiden Binaries genutzt, sind aber für externe Module komplett unsichtbar.
  • Subdomain-Pakete (internal/http/handlers/) — Handler-Logik kapselt sich pro Use-Case in eigenen Dateien.
  • Keine pkg/-Ordner — diese Codebase exportiert keine Library-API, also gibt es keinen Grund dafür.
  • Build-Artefakte außerhalb von Go: Dockerfiles liegen unter deploy/, ähnlich für api/ mit dem OpenAPI-Schema.

Gebaut wird mit go build ./cmd/shop-api, go test ./... läuft über alle Pakete des Moduls. So einfach.

Besonderheiten

internal/ ist eine Sprach-Regel, keine Konvention.

Anders als cmd/ oder pkg/ ist internal/ im Compiler eingebaut. Wer ein Paket unter .../internal/... von außerhalb des übergeordneten Modul-Pfads importiert, bekommt einen harten Build-Fehler. Das ist die einzige Layout-Regel in Go, die kein Lint, sondern echte Sprach-Semantik ist.

Modul-Pfade müssen kein echter URL sein.

go mod init example.com/foo funktioniert auch dann, wenn example.com/foo nirgendwo existiert. Der Modul-Pfad ist primär ein Import-Präfix; erst beim Publish über go get oder go install wird ein auflösbarer Pfad nötig. Für lokale Tools, Spielwiesen und private Module reicht ein freier String.

Go hat kein Lock-File im klassischen Sinn.

go.sum ist kein Lock-File wie package-lock.json oder Cargo.lock. Es enthält Hashes aller jemals erreichten Module — auch transitive, auch nicht mehr aktive. Welche Versionen tatsächlich gebaut werden, ergibt sich deterministisch über Minimal Version Selection aus den require-Direktiven. Das Lock-Verhalten kommt also aus der Kombination beider Dateien.

Major-Version v2+ braucht Suffix im Pfad.

Sobald dein Modul v2.0.0 oder höher erreicht, muss der Modul-Pfad einen Suffix tragen: module github.com/foo/bar/v2. Imports werden dann auch zu github.com/foo/bar/v2/.... Hintergrund: Go erlaubt damit, dass mehrere Major-Versionen desselben Moduls gleichzeitig im Build vorkommen — was bei breaking changes ohne Pfad-Änderung unmöglich wäre.

replace zeigt auf lokale Sibling-Module.

Wer parallel an zwei Modulen arbeitet, kann mit replace github.com/foo/lib => ../lib den Konsumenten direkt gegen den lokalen Sibling-Ordner bauen lassen. Praktisch beim Entwickeln — aber Vorsicht beim Committen: lokale Pfade brechen den Build von Mitentwicklern. Für genau diesen Fall gibt es seit Go 1.18 die bessere Lösung in Form von go.work.

Go 1.21+: go.mod erzwingt eine Mindest-Version.

Bis Go 1.20 war die go-Direktive advisory — der Compiler nahm sie als Hinweis. Ab Go 1.21 ist sie eine harte Mindest-Anforderung: Steht go 1.22 in deiner go.mod, weigert sich Go 1.21 zu bauen. Das hat nichts mit der konkreten Toolchain-Version zu tun — diese steuert die toolchain-Direktive separat.

go mod tidy ist deterministisch.

Gleicher Code-Stand und gleicher Module-Proxy ergeben byte-identische go.mod- und go.sum-Files. Damit ist go mod tidy --diff (oder ein einfacher git diff-Check nach tidy) ein robustes CI-Pattern: Code-Reviewer können sicher sein, dass die committeten Modul-Files dem Code-Stand entsprechen.

go.work überschreibt go.mod-Replacements lokal.

Im Workspace-Modus haben use-Direktiven aus go.work Vorrang vor replace-Direktiven aus go.mod. Du kannst lokal eine andere Sibling-Version benutzen, ohne dass das replace-Hack ins Repo wandert. go.work (und das zugehörige go.work.sum) gehört per Konvention nicht in die Versionskontrolle — es ist Pro-Entwickler-State.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Grundlagen

Zur Übersicht