AI generates working Go code but dumps everything in main.go. That's fine for prototyping. For anything you'll maintain, you need proper package structure. Go's conventions are strict enough that once you learn them, you can navigate any Go codebase, DockerWhat is docker?A tool that packages your application and all its dependencies into a portable container that runs identically on any machine., Kubernetes, Terraform, or your team's projects.
This lesson is about reading project structure, not writing it from scratch. When AI generates a Go project, you need to evaluate whether it organized things correctly.
The standard layout
myproject/
├── cmd/ # Entry points for binaries
│ ├── server/
│ │ └── main.go # go run ./cmd/server
│ └── cli/
│ └── main.go # go run ./cmd/cli
├── internal/ # Private packages (compiler-enforced)
│ ├── config/
│ ├── database/
│ ├── handlers/
│ └── models/
├── pkg/ # Public library code (optional)
│ └── validator/
├── go.mod
├── go.sum
├── Makefile
└── Dockerfilemain.go file. For anything beyond a script, prompt: "Use cmd/ for the entry point and internal/ for business logic." AI understands this structure but doesn't use it unless asked.The key directories
cmd/: where programs start
Each subdirectory under cmd/ is a separate binaryWhat is binary?A ready-to-run file produced by the compiler. You can send it to any computer and it just works - no install needed.. The main.go file should be thin, just wiring, no business logic.
// cmd/server/main.go
package main
import (
"log"
"myproject/internal/config"
"myproject/internal/server"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
log.Fatal(server.Run(cfg))
}internal/: compilerWhat is compiler?A program that translates code you write into a language your computer can execute. It also catches errors before your code runs.-enforced privacy
This is Go's killer feature for project organization. Anything under internal/ can only be imported by code in the parent moduleWhat is module?A self-contained file of code with its own scope that explicitly exports values for other files to import, preventing name collisions.. The compiler rejects external imports, not a convention, an actual compilation error.
| Directory | What goes here | Example |
|---|---|---|
internal/config | Configuration loading | Load(), env vars |
internal/models | Data structures | User, Order |
internal/handlers | HTTP handlers | UserHandler, OrderHandler |
internal/database | Database access | Connect(), Migrate() |
internal/middleware | HTTP middleware | Auth(), Logging() |
internal/service | Business logic | UserService, OrderService |
myproject/internal/database, the build fails with: "use of internal package not allowed."pkg/: public library code
Use pkg/ for code other projects should be able to import. Many projects skip this, if you're not building a library, you probably don't need it.
Package design rules
Package by function, not by type
// Bad: one type per package
package user
type User struct { ... }
// Good: group by responsibility
package models // All data types
package handlers // All HTTP handlers
package storage // All database operationsAvoid circular dependencies
Go doesn't allow circular imports, package A can't import B if B imports A. This forces clean dependencyWhat is dependency?A piece of code written by someone else that your project needs to work. Think of it as a building block you import instead of writing yourself. graphs.
cmd/server
↓
internal/server
↓
internal/handlers
↙ ↘
service models
↓
databaseutils or common that everything imports. These become dumping grounds with no clear purpose. If you see AI creating a utils package, ask it to move each function to the package where it's actually used.Go tooling essentials
ModuleWhat is module?A self-contained file of code with its own scope that explicitly exports values for other files to import, preventing name collisions. management
go mod init github.com/you/project # Initialize module
go mod tidy # Add missing, remove unused deps
go mod download # Download dependencies
go get -u ./... # Update all dependencies
go list -m all # List all dependenciesCode quality tools
| Tool | What it does | Command |
|---|---|---|
go fmt | Auto-format code | go fmt ./... |
go vet | Static analysis (format strings, unused results) | go vet ./... |
golangci-lint | Comprehensive linting (many linters in one) | golangci-lint run |
go test -race | Detect race conditions | go test -race ./... |
Makefile for automation
.PHONY: build test lint run
build:
go build -o bin/server ./cmd/server
test:
go test -v -race -cover ./...
lint:
golangci-lint run
run:
go run ./cmd/server
check: lint test # Run before committingDockerWhat is docker?A tool that packages your application and all its dependencies into a portable container that runs identically on any machine. for Go applications
Go compiles to a single static binaryWhat is binary?A ready-to-run file produced by the compiler. You can send it to any computer and it just works - no install needed., which makes Docker images tiny. The standard pattern is a multi-stage buildWhat is multi-stage build?A Dockerfile technique using multiple FROM instructions to separate build tools from the final lean production image.:
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
# Final stage - scratch or alpine
FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY /app/server /server
EXPOSE 8080
CMD ["/server"]CGO_ENABLED=0, which causes "not found" errors when the binary tries to load glibc in alpine/scratch images.Configuration pattern
// internal/config/config.go
package config
import (
"os"
"strconv"
)
type Config struct {
Port int
DatabaseURL string
JWTSecret string
Debug bool
}
func Load() (*Config, error) {
port, _ := strconv.Atoi(getEnv("PORT", "8080"))
cfg := &Config{
Port: port,
DatabaseURL: getEnv("DATABASE_URL", ""),
JWTSecret: getEnv("JWT_SECRET", ""),
Debug: getEnv("DEBUG", "false") == "true",
}
if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
return cfg, nil
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}JWT_SECRET defaults to "", your auth is completely broken but the app starts without errors. Validate required config at startup and fail fast with clear error messages.Production project checklist
When reviewing an AI-generated Go project structure:
| Check | What it means |
|---|---|
cmd/ exists with thin main.go | Business logic isn't in main |
internal/ for private packages | Encapsulation is enforced |
No utils dumping ground | Functions are in relevant packages |
go.mod references correct module path | Imports will work |
| Multi-stage Dockerfile | Small, secure container images |
| Required config validated at startup | Fail fast, not at midnight on production |
| Makefile with test + lint targets | CI/CD will work |