The Go community has converged on a layout that separates public, private, and executable code clearly.
myapp/
├── cmd/ # Executable entry points (one dir per binary)
│ ├── server/
│ │ └── main.go
│ └── worker/
│ └── main.go
├── internal/ # Private packages (import-restricted by go toolchain)
│ ├── config/
│ │ └── config.go
│ ├── handler/
│ │ └── user.go
│ ├── service/
│ │ └── user.go
│ └── repository/
│ └── user.go
├── pkg/ # Public packages (importable by external projects)
│ └── validator/
│ └── validator.go
├── api/ # API definitions (OpenAPI, protobuf, gRPC)
│ └── openapi.yaml
├── web/ # Web assets, templates
├── scripts/ # Build, install, CI scripts
├── configs/ # Config file templates
├── testdata/ # Test fixtures (go tools ignore dirs starting with "testdata")
├── go.mod
├── go.sum
├── Makefile (or justfile)
└── README.md
cmd/: Place main.go files here. Each subdirectory is a separate binary. Keep main.go thin — parse flags, load config, wire dependencies, then call into internal/.
internal/: Use for everything application-specific. The Go toolchain enforces that packages under internal/ can only be imported by code in the parent directory tree. Use this for business logic, handlers, database access.
pkg/: Only create this if you genuinely want external projects to import your code. Most applications do not need pkg/ at all. Avoid the anti-pattern of putting everything in pkg/ just to follow the template.
// cmd/server/main.go — wire dependencies here, logic lives in internal/
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("loading config: %v", err)
}
db, err := database.Connect(cfg.DatabaseURL)
if err != nil {
log.Fatalf("connecting to database: %v", err)
}
repo := repository.NewUser(db)
svc := service.NewUser(repo)
h := handler.NewUser(svc)
srv := &http.Server{Addr: cfg.Addr, Handler: h.Routes()}
log.Fatal(srv.ListenAndServe())
}
module github.com/myorg/myapp
go 1.22
require (
github.com/lib/pq v1.10.9
golang.org/x/sync v0.6.0
)
// Replace a dependency with a local version during development
replace github.com/myorg/shared => ../shared
// Exclude a specific broken version
exclude github.com/bad/module v1.2.3
require: Direct and indirect dependencies. The // indirect comment marks transitive dependencies that aren't directly imported by your code but are required by your dependencies.
replace: Use for local development of shared modules, or to patch a dependency without forking. Remove before merging to main — replace directives break downstream consumers.
exclude: Prevents a specific version from being selected by MVS. Useful when a version has a known bug and you want to force a later version.
go.sum contains the expected cryptographic checksums of module content. Commit it to version control. Never edit it manually. Regenerate with:
go mod tidy # Add missing, remove unused dependencies
go mod verify # Verify checksums against go.sum
go mod download # Pre-download modules (useful in Docker layers)
Configure the Go toolchain to skip the public checksum database and proxy for private code:
# Tell go to bypass proxy and sumdb for private modules
export GOPRIVATE=github.com/myorg/*
# Separate controls for proxy and sumdb
export GONOSUMCHECK=github.com/myorg/*
export GONOPROXY=github.com/myorg/*
# Use a corporate proxy for public modules
export GOPROXY=https://proxy.company.com,direct
In CI, set these as environment variables. For .netrc-based auth with private GitHub:
machine github.com login git password <personal-access-token>
Workspaces allow multiple modules to be developed together without replace directives.
go work init ./app ./shared ./tools # Creates go.work
go work use ./new-module # Add another module
go work sync # Sync dependencies
go.work file:
go 1.22
use (
./app
./shared
./tools
)
go.work from Docker contexts with .dockerignore# .dockerignore
go.work
go.work.sum
Build tags control which files are included in a build. The modern syntax uses //go:build.
//go:build integration
package mypackage
//go:build linux && amd64
package mypackage
//go:build !windows
package mypackage
//go:build ignore // Exclude from normal builds (e.g., generation scripts)
//go:build integration // Integration tests requiring real external services
//go:build e2e // End-to-end tests
//go:build cgo // Only build when CGO is enabled
go test -tags integration ./...
go build -tags production ./cmd/server
go vet -tags integration ./...
//go:build integration
package repository_test
import (
"testing"
"os"
)
func TestUserRepository_Integration(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
// ... test against real database
}
// internal/version/version.go
package version
var (
Version = "dev"
GitCommit = "unknown"
BuildDate = "unknown"
)
go build \
-ldflags="-X github.com/myorg/myapp/internal/version.Version=1.2.3 \
-X github.com/myorg/myapp/internal/version.GitCommit=$(git rev-parse --short HEAD) \
-X github.com/myorg/myapp/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
./cmd/server
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w" \
-trimpath \
-o bin/server \
./cmd/server
CGO_ENABLED=0: Disable cgo, produce a statically linked binary-s -w: Strip debug info and DWARF symbols (reduces binary size ~30%)-trimpath: Remove local file paths from the binary (reproducible builds, avoids leaking local paths)GOOS=windows GOARCH=amd64 go build ./cmd/server
GOOS=darwin GOARCH=arm64 go build ./cmd/server
GOOS=linux GOARCH=arm64 go build ./cmd/server
BINARY := bin/server
VERSION := $(shell git describe --tags --always --dirty)
COMMIT := $(shell git rev-parse --short HEAD)
BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT)
.PHONY: build test lint generate clean docker
build:
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -trimpath -o $(BINARY) ./cmd/server
test:
go test -race -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
test-integration:
go test -race -tags integration ./...
lint:
golangci-lint run ./...
generate:
go generate ./...
clean:
rm -rf bin/ coverage.out coverage.html
docker:
docker build --build-arg VERSION=$(VERSION) -t myapp:$(VERSION) .
tidy:
go mod tidy
go mod verify
version := `git describe --tags --always --dirty`
commit := `git rev-parse --short HEAD`
build:
CGO_ENABLED=0 go build \
-ldflags="-X main.version={{version}} -X main.commit={{commit}}" \
-trimpath -o bin/server ./cmd/server
test:
go test -race -coverprofile=coverage.out ./...
test-integration:
go test -race -tags integration ./...
lint:
golangci-lint run ./...
generate:
go generate ./...
tidy:
go mod tidy && go mod verify
# Install (do not use go install — use the official installer)
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \
| sh -s -- -b $(go env GOPATH)/bin v1.57.2
golangci-lint run ./...
golangci-lint run --fix ./... # Auto-fix where possible
linters:
enable:
- errcheck # Check all error returns are handled
- gosimple # Simplification suggestions
- govet # go vet checks
- ineffassign # Detect unused variable assignments
- staticcheck # Comprehensive static analysis
- unused # Detect unused code
- gofmt # Enforce gofmt formatting
- goimports # Enforce import grouping
- gocritic # Opinionated style checks
- misspell # Catch common misspellings
- prealloc # Suggest slice pre-allocation
- exhaustive # Enforce exhaustive enum switches
- noctx # Detect HTTP requests without context
linters-settings:
errcheck:
check-blank: true
govet:
enable-all: true
gocritic:
enabled-tags: [diagnostic, style, performance]
issues:
exclude-rules:
- path: _test\.go
linters: [errcheck] # Relax error checking in tests
//nolint:errcheck // Intentionally ignoring close error on best-effort cleanup
defer f.Close()
//nolint:exhaustive // Default case handles unrecognized values
switch status {
case Active:
return "active"
default:
return "unknown"
}
Place //go:generate directives in the file where the generated output belongs conceptually.
// internal/domain/status.go
//go:generate stringer -type=Status
type Status int
const (
Active Status = iota
Inactive
Pending
)
// internal/repository/mock_store.go (or a dedicated mocks/ dir)
//go:generate mockgen -source=store.go -destination=mock_store.go -package=repository
Run all generators:
go generate ./...
import "embed"
//go:embed templates/*.html
var templateFS embed.FS
//go:embed migrations
var migrationsFS embed.FS
//go:embed static/app.js static/app.css
var staticFiles embed.FS
# .goreleaser.yml
project_name: myapp
builds:
- id: server
main: ./cmd/server
binary: server
env:
- CGO_ENABLED=0
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
flags:
- -trimpath
archives:
- format: tar.gz
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"
changelog:
sort: asc
filters:
exclude: ['^docs:', '^test:', Merge pull request]
# Dry run to verify configuration
goreleaser release --snapshot --clean
# Publish a real release (requires GITHUB_TOKEN)
goreleaser release --clean
#!/usr/bin/env bash
set -euo pipefail
VERSION=$(git describe --tags --always)
PLATFORMS=("linux/amd64" "linux/arm64" "darwin/amd64" "darwin/arm64" "windows/amd64")
for platform in "${PLATFORMS[@]}"; do
GOOS="${platform%/*}"
GOARCH="${platform#*/}"
output="dist/server_${GOOS}_${GOARCH}"
[[ "$GOOS" == "windows" ]] && output="${output}.exe"
echo "Building $output"
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
-ldflags="-s -w -X main.version=${VERSION}" \
-trimpath -o "$output" ./cmd/server
done