CLAUDE.md for Go Projects: AI-Driven Development Without the Chaos
Go's simplicity is deceptive. Claude Code can write syntactically correct Go that violates every convention your team relies on. Here's how to use CLAUDE.md to keep your AI agent on the idiomatic path.
Go's Simplicity Problem
Go's surface syntax is deceptively simple. Curly braces, type declarations, a handful of keywords. An AI agent can produce syntactically correct Go on its first try. What it cannot do without guidance is produce idiomatic Go.
Idiomatic Go means:
- Proper directory structure (
cmd/for binaries,internal/for packages, nosrc/) - Error handling with wrapping, not exceptions
- Interfaces defined at the consumer, not the producer
- No global state, no init() functions
- Context passed explicitly, not hidden in closures
- Table-driven tests, not test fixtures
Claude Code will happily ignore all of this if you don't tell it not to. It will create src/ directories, sprinkle init() functions across your codebase, ignore errors, and define interfaces at the package level. The code will compile. It won't be wrong — it will just be un-Go-like, hard to maintain, and at odds with how the rest of your team codes.
CLAUDE.md is how you prevent this. It's your guard rail against AI-generated code that compiles but doesn't belong.
Project Layout: cmd/, internal/, and pkg/
Go has a standard project layout. Respect it, or your code will confuse every Go developer who reads it.
cmd/ contains executable binaries. Each subdirectory is a separate binary. If you're building an API server and a worker, you'd have cmd/api/main.go and cmd/worker/main.go.
internal/ contains packages that are private to this repository. Go enforces this at compile time — other projects cannot import from internal/. This is where business logic lives.
pkg/ contains packages that are safe for external import (if you're publishing a library). Use this sparingly; most Go projects don't need it.
Your CLAUDE.md should document this explicitly. Include an example:
# Project Layout
cmd/
api/
main.go
worker/
main.go
internal/
auth/
auth.go
auth_test.go
database/
queries.go
queries_test.go
handlers/
handlers.go
handlers_test.goThen add a rule: "Never create files in src/, lib/, or app/. All business logic goes in internal/. All binaries go in cmd/."
Interfaces at the Consumer
Go interfaces are small, narrow, and defined by the code that uses them — not by the code that implements them. This is the opposite of how most OO languages work, and Claude will get it wrong if you don't explicitly say so.
Bad pattern (what Claude often produces):
// database/database.go
type Database interface {
GetUser(ctx context.Context, id string) (*User, error)
CreateUser(ctx context.Context, u *User) error
UpdateUser(ctx context.Context, u *User) error
DeleteUser(ctx context.Context, id string) error
// ... 20 more methods
}
// Now your repository must implement all of them
type Repository struct { ... }
func (r *Repository) GetUser(...) { ... }Good pattern (idiomatic Go):
// handlers/handlers.go
type userStore interface {
GetUser(ctx context.Context, id string) (*User, error)
}
// handlers only depend on what it uses
type Handler struct {
store userStore
}
// database/database.go
type Repository struct { ... }
func (r *Repository) GetUser(...) (*User, error) { ... }Your CLAUDE.md rule: "Interfaces are defined by their consumer (the code that uses them), not by their producer (the implementation). Keep interfaces small — usually 1-3 methods. Define them in the package that uses them, not in the package that implements them."
Error Handling: Wrap, Don't Silence
Go's error handling is explicit: check every error, decide what to do with it. Claude Code will often ignore errors or wrap them generically. You need to be specific about your error strategy.
Define your error types in memory/code-patterns.md:
// patterns/code-patterns.md
## Error Handling
### Wrapping Errors
Use fmt.Errorf with %w to preserve the error chain:
if err != nil {
return fmt.Errorf("could not query database: %w", err)
}
### Sentinel Errors
Define sentinel errors for expected error cases:
var (
ErrNotFound = errors.New("user not found")
ErrUnauthorized = errors.New("unauthorized")
)
Check with errors.Is():
if errors.Is(err, ErrNotFound) {
// handle not found
}
### AppError Type (for HTTP APIs)
type AppError struct {
Code string
Message string
Status int
Err error
}
Use this at the HTTP handler layer. Never return raw errors to clients.Then add to CLAUDE.md rules: "Never ignore errors. Never return errors directly from handlers — wrap them in an AppError. Use fmt.Errorf with %w to preserve error chains. Use errors.Is() to check for specific errors, not string comparison."
Get the free Go CLAUDE.md template
Enterprise-grade conventions for every major stack, plus Claude Code and prompt engineering guides. No account needed.
Context Propagation: Explicit, Not Magic
Context is how Go passes request-scoped values (timeouts, cancellation, trace IDs) through your call stack. Claude Code often either ignores it or uses it incorrectly.
Rule in CLAUDE.md: "Context is always the first parameter. It is never stored in a struct. It is passed explicitly to every function that needs it, especially database queries and external API calls."
// Correct
func (h *Handler) GetUser(ctx context.Context, id string)
(*User, error) {
return h.db.GetUser(ctx, id)
}
// Wrong (Claude often does this)
func (h *Handler) GetUser(id string) (*User, error) {
return h.db.GetUser(context.Background(), id)
}
// Also wrong (storing context in struct)
type Handler struct {
ctx context.Context // DON'T DO THIS
}Table-Driven Tests
Go testing is simple but opinionated. Skip the test fixtures and mocking frameworks. Use table-driven tests instead.
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
wantErr bool
}{
{"valid email", "user@example.com", false},
{"missing @", "userexample.com", true},
{"empty string", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateEmail(tt.email)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateEmail() error = %v, wantErr %v",
err, tt.wantErr)
}
})
}
}Add to CLAUDE.md: "All tests are table-driven. No test fixtures. No mocking frameworks. Use interface mocks defined locally in the test file when needed."
The Init() Function Trap
Go's init() function runs at package initialization, before main(). It looks like a hook for setup code. Claude Code loves it. Avoid it.
Never use init() for:
- Initializing global state
- Loading configuration
- Opening database connections
- Registering plugins or routes
Instead, return initialized values from functions and pass them as dependencies. This makes testing easier and makes dependencies explicit.
Bad:
var db *sql.DB
func init() {
var err error
db, err = sql.Open("postgres", os.Getenv("DSN"))
if err != nil {
panic(err)
}
}Good:
func main() {
db, err := sql.Open("postgres", os.Getenv("DSN"))
if err != nil {
log.Fatal(err)
}
app := &App{db: db}
app.Run()
}Rule: "Never use init(). All initialization happens in main() or constructor functions. All dependencies are passed explicitly, never stored as global variables."
The ORM Trap: sqlc Over Gorm
Go developers often debate ORMs. Claude Code will typically reach for Gorm, the most popular option. In many Go teams, it's the wrong choice.
If your team uses sqlc (type-safe SQL generation), or raw sql.DB queries, say so in CLAUDE.md:
"We use [sqlc / sql.DB + query helpers], not an ORM. Do not introduce Gorm or any ORM. Write explicit SQL queries. Use sqlc to generate type-safe query methods."
If your project does use Gorm, be specific about your conventions:
// If using Gorm:
// - Always use Gorm hooks and scopes for common patterns
// - Keep models in internal/models/
// - Never put SQL raw queries in Gorm — extract to raw queries
// - Use context at all times: db.WithContext(ctx).Find(...)Global State and Loggers
Logging is one place where Go embraces global state. But there are good and bad ways to do it.
Bad (Claude often tries this):
// handlers/handlers.go
var log = logrus.New()
func (h *Handler) GetUser(id string) {
log.WithField("user_id", id).Info("fetching user")
}Good:
// handlers/handlers.go
type Handler struct {
log *slog.Logger
}
func (h *Handler) GetUser(id string) {
h.log.InfoContext(ctx, "fetching user",
slog.String("user_id", id))
}Rule: "Loggers are dependencies, not global state. Pass loggers as struct fields. Use slog or your chosen structured logger consistently."
Setting Up Your Go CLAUDE.md
Here's what a Go project's tech-stack and code-patterns memory files should emphasize:
# memory/project-context/tech-stack.md
## Language & Runtime
- Go 1.22+
- Modules: on
## Database
- Postgres
- Query layer: sqlc
- Never: Gorm, XORM, or any ORM
## Testing
- Table-driven tests only
- No fixtures, no mocking frameworks
- Mock interfaces as needed locally
## Logging
- slog (stdlib, Go 1.21+)
- Always pass logger as dependency
- Never global loggers# memory/patterns/code-patterns.md
## Key Conventions
### Errors
- Always check errors
- Wrap with fmt.Errorf(..., %w, err)
- Define sentinel errors for expected cases
- Use errors.Is() for checking
- Return AppError from HTTP handlers
### Context
- First parameter in all functions
- Never stored in structs
- Never pass context.Background() except in main
### Interfaces
- Defined at consumer, not producer
- Keep small (1–3 methods max)
- No god interfaces
### Project Layout
cmd/
api/
worker/
internal/
auth/
database/
handlers/CLAUDE.md sets the rules. Archie runs the workflow.
Persistent memory, role-based skills, and approval gates. From idea to merged PR.
The Bottom Line
Go's simplicity makes it easy to write code that compiles but doesn't fit. Claude Code can compile syntactically correct Go all day. What it can't do without guidance is write Go that your team recognizes as their own.
The solution isn't to avoid using Claude Code on Go projects. It's to front-load your CLAUDE.md with Go-specific conventions, patterns, and rules. Be explicit about directory structure, error handling, interface design, context usage, and testing patterns. Reference your code-patterns memory file constantly.
Give Claude that guardrail, and it becomes a powerful partner for building idiomatic, maintainable Go code.