●CORPS — Anthropic unveils Claude Corps (Jun 11), a $150M national fellowship placing 1,000 early-career workers inside US nonprofits; the first cohort starts in October●SUBAGENTS — Claude Code sub-agents can now spawn their own sub-agents, up to 5 levels deep — multi-stage delegation workflows out of the box●WORKFLOWS — Dynamic workflows arrive in research preview across CLI, Desktop, and VS Code for codebase-wide bug hunts and large migrations (Max/Team/Enterprise)●BILLING — 2 days to the Jun 15 change: Agent SDK, headless runs, and GitHub Actions move to monthly credits ($20/$100/$200); Sonnet 4 and Opus 4 retire from the API the same day●FABLE5 — Fable 5 remains included free on Pro, Max, Team, and Enterprise through Jun 22●CODE80 — IPO coverage reports Claude now writes over 80% of its own code, up from under 10% in February 2025●CORPS — Anthropic unveils Claude Corps (Jun 11), a $150M national fellowship placing 1,000 early-career workers inside US nonprofits; the first cohort starts in October●SUBAGENTS — Claude Code sub-agents can now spawn their own sub-agents, up to 5 levels deep — multi-stage delegation workflows out of the box●WORKFLOWS — Dynamic workflows arrive in research preview across CLI, Desktop, and VS Code for codebase-wide bug hunts and large migrations (Max/Team/Enterprise)●BILLING — 2 days to the Jun 15 change: Agent SDK, headless runs, and GitHub Actions move to monthly credits ($20/$100/$200); Sonnet 4 and Opus 4 retire from the API the same day●FABLE5 — Fable 5 remains included free on Pro, Max, Team, and Enterprise through Jun 22●CODE80 — IPO coverage reports Claude now writes over 80% of its own code, up from under 10% in February 2025
Shipping Production-Quality Go REST APIs with Claude Code — Gin, GORM, Docker, and CI/CD with the Judgment Calls That Matter
A hands-on walkthrough of building a production-quality REST API with Claude Code and Go — Clean Architecture with Gin and GORM, graceful shutdown, minimal Docker images, and GitHub Actions, with the operational judgment calls AI won't make for you.
Will AI-Generated API Code Actually Survive Production?
Ask Claude Code to "build a user management REST API" and you will have working code within minutes.
The real question comes after that. Can you ship that code to production as-is? In most cases, the honest answer is no. Consistent error handling, robust authentication, responding correctly to container stop signals, protecting your database from connection exhaustion — the distance between "it works" and "it survives operations" is real.
Closing that distance is exactly where the combination of Claude Code and Go earns its keep, in my experience.
In this article, we will build a REST API with Gin and GORM, covering design, implementation, testing, containerization, and CI/CD. Rather than just listing code, I want to share the reasoning: why this structure, where to delegate to AI, and where human judgment must stay in the loop.
Why Go? A Sober Look at the Fit with Claude Code
Go is a compiled, statically typed language developed at Google. Its simple syntax, fast compilation, and excellent concurrency support have made it a staple for microservices and REST API backends.
Paired with Claude Code, Go offers three advantages I have not found elsewhere.
First, generated code is instantly verifiable by the compiler. Go's type system is strict, and its type inference is simple. If Claude Code generates something broken, go build tells you within seconds. TypeScript has type checking too, but with any escape hatches and elaborate type puzzles, verifying AI-generated code costs more there than in Go.
Second, the standard library is genuinely complete. HTTP, JSON, cryptography, and — since Go 1.21 — structured logging via log/slog all ship with the language. Claude Code can implement features without pulling in extra dependencies, which keeps generated code reproducible and saves precious context when the AI needs to reason about your codebase.
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Learn how to design a CLAUDE.md that shapes Claude Code's Go output, and a full Clean Architecture generation workflow
✦Go beyond working code: graceful shutdown, connection pooling, and the security review points that separate demos from production APIs
✦Build a complete deployment pipeline, from scratch-vs-distroless Docker decisions to a cached GitHub Actions CI/CD workflow
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
go version# go version go1.25.0 darwin/arm64docker --versiongit --version
CLAUDE.md Determines Half Your Code Generation Quality
The CLAUDE.md at your project root is a standing instruction sheet for Claude Code. Whether you write your conventions down here makes a visible difference in output consistency.
# Project: Go REST API## Tech Stack- Go 1.25+ / Gin v1.10+ / GORM v2 / PostgreSQL 16## Architecture- Clean Architecture (Handler → Service → Repository)- Dependency injection via constructor functions## Conventions- Package names: lowercase only- Always wrap errors (fmt.Errorf("...: %w", err))- Logging via log/slog (standard library)- Tests use testify- Accept context.Context as the first argument## Forbidden- Global variables- panic() (always return errors)- init() outside of DI
The key is the "Forbidden" section. Claude Code is more reliable at honoring prohibitions than permissions. Back when I forgot to ban panic(), generated error paths came peppered with panics, and removing them afterwards cost real time. Five minutes of investment up front saves hours later.
Project Structure — Clean Architecture in Go
Ask Claude Code:
Following the conventions in CLAUDE.md, design the project structure
for a user management API using Clean Architecture.
Create the directory layout and go.mod.
Packages under internal/ cannot be imported by external modules — a language-level guarantee that doubles as an architectural one. The compiler itself prevents accidental exposure of your domain logic.
Install dependencies:
cd go-apigo mod init github.com/yourusername/go-apigo get github.com/gin-gonic/gin@latestgo get gorm.io/gorm@latestgo get gorm.io/driver/postgres@latestgo get github.com/joho/godotenv@latestgo get github.com/golang-jwt/jwt/v5@latestgo get github.com/go-playground/validator/v10@latestgo get github.com/stretchr/testify@latest
Claude Code sometimes pins outdated versions when adding dependencies, so I keep version decisions as a human task, verified with go list -m -u all, and always commit go.sum.
Domain Model and Repository Layer
The domain model and its repository interface live together in the domain package:
package domainimport ( "time" "github.com/google/uuid")// User is the user domain modeltype User struct { ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` Email string `gorm:"uniqueIndex;not null;size:255"` PasswordHash string `gorm:"not null"` Name string `gorm:"not null;size:100"` CreatedAt time.Time UpdatedAt time.Time DeletedAt *time.Time `gorm:"index"`}// UserRepository defines the data access interfacetype UserRepository interface { Create(ctx context.Context, user *User) error FindByID(ctx context.Context, id uuid.UUID) (*User, error) FindByEmail(ctx context.Context, email string) (*User, error) Update(ctx context.Context, user *User) error Delete(ctx context.Context, id uuid.UUID) error List(ctx context.Context, offset, limit int) ([]*User, int64, error)}// Domain errorsvar ( ErrUserNotFound = &DomainError{Code: "USER_NOT_FOUND", Message: "user not found"} ErrUserAlreadyExists = &DomainError{Code: "USER_ALREADY_EXISTS", Message: "this email address is already in use"} ErrInvalidCredential = &DomainError{Code: "INVALID_CREDENTIAL", Message: "email or password is incorrect"})// DomainError is the domain-layer error typetype DomainError struct { Code string Message string}func (e *DomainError) Error() string { return e.Message}
Placing the repository interface in the domain layer is the heart of this design. The implementation depends on the interface, never the other way around — which is also what makes mock substitution in tests effortless.
One note: first-pass generated code tends to omit context.Context from signatures. For timeout control and tracing, spell out in CLAUDE.md that every repository method takes ctx as its first argument.
The PostgreSQL implementation threads that context through every query:
func (r *userRepository) FindByID(ctx context.Context, id uuid.UUID) (*domain.User, error) { var user domain.User result := r.db.WithContext(ctx). Where("id = ? AND deleted_at IS NULL", id).First(&user) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, domain.ErrUserNotFound } return nil, fmt.Errorf("failed to fetch user: %w", result.Error) } return &user, nil}
WithContext(ctx) means that when an HTTP request is cancelled, in-flight queries are cancelled with it. Run a load test with and without it and you will see a clear difference in how quickly connections are released under timeout pressure.
Service Layer — Business Logic and the Core of Authentication
The service layer owns password hashing and JWT issuance:
// Register creates a new userfunc (s *UserService) Register(ctx context.Context, email, password, name string) (*domain.User, error) { // Hash the password (cost 12 is the recommended production value) hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) if err != nil { return nil, fmt.Errorf("failed to hash password: %w", err) } user := &domain.User{ Email: email, PasswordHash: string(hash), Name: name, } if err := s.repo.Create(ctx, user); err != nil { return nil, err // pass domain errors through unchanged } s.logger.Info("user registered", slog.String("user_id", user.ID.String()), ) return user, nil}// Login authenticates and issues a JWTfunc (s *UserService) Login(ctx context.Context, email, password string) (string, error) { user, err := s.repo.FindByEmail(ctx, email) if err != nil { // Return the same error whether the user exists or not (prevents enumeration) return "", domain.ErrInvalidCredential } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { s.logger.Warn("login failed", slog.String("email", email)) return "", domain.ErrInvalidCredential } token, err := s.jwtManager.Generate(user.ID, user.Email) if err != nil { return "", fmt.Errorf("failed to generate token: %w", err) } s.logger.Info("login succeeded", slog.String("user_id", user.ID.String())) return token, nil}
A subtle but important detail: "user not found" and "wrong password" both return the same ErrInvalidCredential. Distinct errors would leak "this email is registered" to attackers. Claude Code has become quite good at including such security staples unprompted — but I keep this on my mandatory code review checklist regardless.
Handler Layer — A Consistent API Surface with Gin
Handlers validate input, call the service, and translate errors:
// Unified error responsetype ErrorResponse struct { Code string `json:"code"` Message string `json:"message"`}type RegisterRequest struct { Email string `json:"email" binding:"required,email,max=255"` Password string `json:"password" binding:"required,min=8,max=72"` Name string `json:"name" binding:"required,min=1,max=100"`}// handleServiceError maps service-layer errors to HTTP responsesfunc (h *UserHandler) handleServiceError(c *gin.Context, err error) { var domainErr *domain.DomainError if errors.As(err, &domainErr) { status := http.StatusBadRequest switch domainErr.Code { case "USER_NOT_FOUND": status = http.StatusNotFound case "INVALID_CREDENTIAL": status = http.StatusUnauthorized case "USER_ALREADY_EXISTS": status = http.StatusConflict } c.JSON(status, ErrorResponse{Code: domainErr.Code, Message: domainErr.Message}) return } h.logger.Error("unexpected error", slog.String("error", err.Error())) c.JSON(http.StatusInternalServerError, ErrorResponse{Code: "INTERNAL_ERROR", Message: "an internal error occurred"})}
Centralizing the translation in handleServiceError keeps error responses uniform across endpoints. When you later ask the AI to generate additional handlers, routing everything through this function preserves the consistency of your API surface.
One trap worth knowing: Gin uses binding: struct tags, not validate:. Claude Code sometimes generates validate: tags in the standalone go-playground/validator style — and validation then silently does nothing. No error, no warning. I only caught it when a test let an invalid request sail through. After generating handlers, always fire one malformed request and confirm you get a 400 back.
JWT Auth, Routing, and Graceful Shutdown
The JWT manager must verify the signing method:
// Verify validates a JWT and returns its claimsfunc (m *Manager) Verify(tokenStr string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return m.secretKey, nil }) if err != nil { return nil, fmt.Errorf("token verification failed: %w", err) } claims, ok := token.Claims.(*Claims) if !ok || !token.Valid { return nil, fmt.Errorf("invalid token") } return claims, nil}
The SigningMethodHMAC check is not optional. Without it, a forged token with alg: none can slip through — a classic vulnerability.
main.go — Dependency Injection Plus Graceful Shutdown
The entry point is where I always add what AI-generated scaffolding tends to omit: graceful shutdown.
srv := &http.Server{ Addr: ":" + port, Handler: r, ReadHeaderTimeout: 5 * time.Second, } // Start the server on its own goroutine go func() { logger.Info("server starting", slog.String("port", port)) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("server error", slog.String("error", err.Error())) os.Exit(1) } }() // Wait for SIGINT / SIGTERM quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit // Graceful shutdown with a 10-second grace period logger.Info("shutting down") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { logger.Error("forced shutdown", slog.String("error", err.Error())) } logger.Info("shutdown complete")
Building an explicit http.Server instead of calling r.Run() lets the process respond correctly to the SIGTERM that Kubernetes and most PaaS platforms send. Without graceful shutdown, every deployment severs in-flight requests, which users experience as sporadic 502s. Local development shows no symptoms whatsoever — this is the kind of problem you only discover in production.
Connection Pool Tuning
Database defaults will not survive production load either:
// NewPostgres creates a production-configured PostgreSQL connectionfunc NewPostgres(dsn string) (*gorm.DB, error) { db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ TranslateError: true, // enables translation to gorm.ErrDuplicatedKey etc. }) if err != nil { return nil, fmt.Errorf("failed to connect: %w", err) } sqlDB, err := db.DB() if err != nil { return nil, fmt.Errorf("failed to obtain sql.DB: %w", err) } // Pool settings — coordinate with PostgreSQL's max_connections sqlDB.SetMaxOpenConns(25) sqlDB.SetMaxIdleConns(10) sqlDB.SetConnMaxLifetime(30 * time.Minute) sqlDB.SetConnMaxIdleTime(5 * time.Minute) return db, nil}
With TranslateError: true, GORM converts driver-specific errors into common ones like gorm.ErrDuplicatedKey, shrinking the string-matching fallbacks in your repository layer. I enable it on every new project.
How Much Testing Can You Delegate to AI?
Test generation is one of Claude Code's strongest areas. This prompt works well:
Create internal/service/user_service_test.go.
- Register tests (success, duplicate error, invalid password)
- Login tests (success, user not found, wrong password)
- Mock the repository via its interface
- Use testify's assert
go test ./... -v -racego test ./... -coverprofile=coverage.outgo tool cover -html=coverage.out
Always include -race. Gin handlers run concurrently, and careless access to shared state surfaces here early, while it is still cheap to fix.
I aim for 70%+ coverage, but the number matters less than whether every domain error branch is exercised. AI-generated tests skew toward happy paths; the failure cases only become thorough once you enumerate them in the prompt. Test case design by humans, implementation by AI — that division of labor produces the most stable quality I have found so far.
Docker Multi-Stage Builds — Choosing Between scratch and distroless
# ─── Stage 1: build ────────────────────────────────────────────FROM golang:1.25-alpine AS builderWORKDIR /app# Copy module files first to leverage the build cacheCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ -ldflags="-w -s" \ -o /bin/api \ ./cmd/api# ─── Stage 2: runtime ──────────────────────────────────────────FROM scratch# CA certificates (required for outbound HTTPS)COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/COPY --from=builder /bin/api /apiEXPOSE 8080ENTRYPOINT ["/api"]
FROM scratch brings the final image down to roughly 15MB — under a tenth of a comparable Node.js API.
But scratch demands trade-offs. There is no shell, so docker exec is impossible; no timezone data; no DNS resolver configuration. If you want to keep debuggability, Google's distroless images are the pragmatic alternative:
# Runtime stage when you want to retain debuggabilityFROM gcr.io/distroless/static-debian12:nonrootCOPY --from=builder /bin/api /apiEXPOSE 8080USER nonrootENTRYPOINT ["/api"]
My own rule: scratch for small services that call no external APIs and log thoroughly; distroless nonroot for everything else. The size difference is a few megabytes, and getting non-root execution by default often outweighs it.
Enable both cache: true on setup-go and the Buildx GitHub Actions cache (type=gha), and second and subsequent pipeline runs finish in less than half the time. CI latency shapes your development rhythm directly; cache configuration is worth getting right on day one.
Claude Code can generate this workflow file too — but secret names like DOCKER_USERNAME must match what is actually registered in your repository settings. Do not paste and walk away; cross-check against Settings → Secrets.
Errors You Will Probably Hit
gorm: record not found where you did not expect it
First() returns ErrRecordNotFound when no row exists; Find() returns an empty slice with no error. Use First() for single-row lookups and always check with errors.Is(err, gorm.ErrRecordNotFound).
JWT token is expired appearing too often
Clock drift between servers breaks ExpiresAt validation, including in containers. After confirming time sync, add leeway:
FROM scratch contains no CA certificates. If you see x509: certificate signed by unknown authority, copy /etc/ssl/certs/ca-certificates.crt from the build stage as shown earlier. This one does not reproduce locally and only fails in production, which makes it expensive to diagnose if you have not seen it before.
Prompt Patterns That Pay Off in Go
Batch error-handling generation. Go's explicit error returns get verbose; specifying the conversion rules up front keeps them consistent:
Implement error handling for all service-layer methods,
converting to domain errors as follows:
- gorm.ErrRecordNotFound → domain.ErrUserNotFound
- duplicate errors → domain.ErrUserAlreadyExists
- everything else → wrapped with fmt.Errorf
Table-driven tests. A Go community convention the AI sometimes skips unless told:
Create table-driven tests for POST /api/v1/auth/register in
user_handler_test.go (use httptest.NewRecorder):
- success (201), duplicate (409), validation error (400), invalid JSON (400)
Manage cases in a testCases slice.
Paired migrations. Asking for up/down SQL migrations together gets you rollback files humans tend to skip writing — exactly the kind of "paired artifact" AI generates well.
The Next Step — From Working Code to Operable Code
We have walked the full path from generated scaffolding to a production-shaped API.
The division of labor is what I would emphasize. Project structure, layer implementations, tests, Dockerfiles, CI config — delegate these to Claude Code freely. Designing the CLAUDE.md conventions, reviewing error branches and security staples, and judging operational settings like graceful shutdown and pool sizing — these remain human work.
As a concrete next step, extend the /health endpoint into a readiness probe with a database liveness check, then try deploying to Kubernetes or your PaaS of choice. With graceful shutdown already in place, rolling updates should work without dropping a single request.
Having run this stack in production as an indie developer for a while now, I find Go's plainness and Claude Code's generation strength a quietly dependable pairing. I hope it serves your project well.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.