project-structure.md 13 KB

Go Project Structure Reference

Table of Contents

  1. Standard Project Layout
  2. Module Management
  3. Workspace Mode
  4. Build Tags
  5. Build Configuration
  6. Makefile and Justfile Patterns
  7. Linting
  8. Code Generation
  9. Release

Standard Project Layout

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

When to Use Each Directory

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 Management

go.mod Directives

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.

Manage go.sum

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)

Private Modules

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>

Workspace Mode

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
)

When Workspaces Help

  • Developing two modules simultaneously (e.g., a library and a consuming app)
  • Monorepo with multiple Go modules
  • Testing unreleased changes to a shared package before publishing

When Workspaces Do Not Help

  • Single-module repos (no benefit)
  • Production builds — exclude go.work from Docker contexts with .dockerignore
# .dockerignore
go.work
go.work.sum

Build Tags

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

Common Tag Patterns

//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

Run Builds with Tags

go test -tags integration ./...
go build -tags production ./cmd/server
go vet -tags integration ./...

Separate Integration Tests

//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
}

Build Configuration

Inject Version Information at Build Time

// 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

Build Static Binaries

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)

Cross-Compile

GOOS=windows GOARCH=amd64 go build ./cmd/server
GOOS=darwin  GOARCH=arm64 go build ./cmd/server
GOOS=linux   GOARCH=arm64 go build ./cmd/server

Makefile and Justfile Patterns

Makefile

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

Justfile

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

Linting

Install and Run golangci-lint

# 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

Recommended .golangci.yml

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

Suppress Specific Warnings

//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"
}

Code Generation

go generate

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 ./...

Embed Static Files

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
  • Paths are relative to the file containing the directive
  • Supports glob patterns and directories
  • Embedded files are read-only at runtime

Release

goreleaser

# .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

Manual Cross-Compile Script

#!/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