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:
package main
import "fmt"
func main() {
fmt.Println("Hallo")
}HalloAufruf 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:
go mod init github.com/mibeon/exampleDamit 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.
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öffentlichtDie wichtigsten Direktiven im Überblick:
| Direktive | Bedeutung |
|---|---|
module | Der Modul-Pfad — gleichzeitig Import-Präfix für alle Pakete des Moduls. Erscheint genau einmal. |
go | Mindest-Go-Version. Seit Go 1.21 verbindlich (kein advisory hint mehr). Steuert Sprach-Features und Toolchain-Verhalten. |
toolchain | Vorgeschlagene konkrete Toolchain-Version (z. B. go1.23.0). Optional. |
require | Direkte und transitive Dependencies mit Mindest-Version. // indirect markiert nicht direkt importierte Pakete. |
replace | Ersetzt eine Dependency durch einen anderen Pfad oder eine lokale Verzeichnis-Referenz. Wirkt nur im Haupt-Modul. |
exclude | Schließt eine konkrete Version aus der Auflösung aus — etwa bei kaputten Releases. |
retract | Markiert 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.
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:
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:
| Ordner | Zweck |
|---|---|
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. |
| Wurzel | Bei 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:
myservice/
├── go.mod
├── go.sum
├── cmd/
│ ├── api-server/
│ │ └── main.go
│ └── migrate/
│ └── main.go
├── internal/
│ ├── auth/
│ ├── store/
│ └── handlers/
├── api/
│ └── openapi.yaml
└── docs/
└── architecture.mdDie 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 dasinternal/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:
go build ./...package github.com/foo/other: use of internal package
github.com/mibeon/example/internal/auth not allowedDamit 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:
go mod vendorSobald 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
GOPRIVATEund 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.
cd ~/code
go work init ./api ./shared ./workerErzeugt:
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/:
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.workerCharakteristika:
- Zwei Binaries in
cmd/—shop-apiundshop-workerteilen sich denselben Domain-Code. - Geteilter Code unter
internal/—domain,db,configwerden 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 unterdeploy/, ähnlich fürapi/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
- Go Modules Reference
- Managing dependencies – Go Doku
- go.mod file reference
- Project layout (golang-standards)
- Workspaces – Go Blog