
Go Architect
- 78 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
go-architect is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- go-architect
- AI & Agent Building
- AI-coding skill
Go Architect by the numbers
- 78 all-time installs (skills.sh)
- Ranked #5,339 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill go-architectAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 78 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
Lead Go Architect
Quick Reference
| Topic | Reference |
|---|---|
| Flat vs modular project layout, migration signals | references/project-structure.md |
| Graceful shutdown with signal handling | references/graceful-shutdown.md |
| Dependency injection patterns, testing seams | references/dependency-injection.md |
Core Principles
1. Standard library first -- Use net/http and the Go 1.22+ enhanced ServeMux for routing. Only reach for a framework (chi, echo, gin) when you have a concrete need the stdlib cannot satisfy (e.g., complex middleware chains, regex routes). 2. Dependency injection over globals -- Pass databases, loggers, and services through struct fields and constructors, never package-level var. 3. Explicit over magic -- No init() side effects, no framework auto-wiring. main.go is the composition root where everything is assembled visibly. 4. Small interfaces, big structs -- Define interfaces at the consumer, keep them narrow (1-3 methods). Concrete types carry the implementation.
Hard gates
Use this sequence when implementing or reviewing work that claims to follow this skill. Do not skip ahead; each step has a pass condition you can answer with tooling or a concrete file path.
1. Toolchain vs APIs — If the code uses Go 1.22+ ServeMux features (method+path patterns like "GET /x/{id}", r.PathValue, or {path...}): run go version and pass only if the reported toolchain is go1.22+. If the project must stay on an older Go, pass only by not using those APIs (use a compatible router or older patterns) and say so in the review or PR. 2. Composition root — Pass when main.go or cmd/.../main.go visibly constructs the server and injects shared dependencies (DB, logger, config). Fail if shared dependencies are wired in init() or package-level var instead of explicit construction in main (or a run() called from main). 3. Production HTTP shutdown — For a long-lived HTTP service, pass only if shutdown uses http.Server.Shutdown with a bounded context (e.g. context.WithTimeout) after waiting on signal.NotifyContext (or equivalent). Cite the file path when reporting; see references/graceful-shutdown.md for the full pattern. 4. No env/globals in handlers — Pass when handlers and domain code take dependencies via structs/arguments. Fail if handlers read os.Getenv for secrets or use package-level var for DB/clients (loading env in main or a dedicated config package is fine).
Go 1.22+ Enhanced Routing
Go 1.22 upgraded http.ServeMux with method-based routing and path parameters, eliminating the most common reason for third-party routers.
Method-Based Routing and Path Parameters
mux := http.NewServeMux()
mux.HandleFunc("GET /api/users", s.handleListUsers)
mux.HandleFunc("GET /api/users/{id}", s.handleGetUser)
mux.HandleFunc("POST /api/users", s.handleCreateUser)
mux.HandleFunc("DELETE /api/users/{id}", s.handleDeleteUser)Extracting Path Parameters
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
user, err := s.users.GetUser(r.Context(), id)
if err != nil {
s.logger.Error("getting user", "err", err, "id", id)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}Wildcard and Exact Match
// Exact match on trailing slash -- serves /api/files/ only
mux.HandleFunc("GET /api/files/", s.handleListFiles)
// Wildcard to end of path -- /api/files/path/to/doc.txt
mux.HandleFunc("GET /api/files/{path...}", s.handleGetFile)Routing Precedence
The new ServeMux uses most-specific-wins precedence:
GET /api/users/{id}is more specific thanGET /api/users/GET /api/users/meis more specific thanGET /api/users/{id}- Method routes take precedence over method-less routes
Server Struct Pattern
The Server struct is the central dependency container for your application. It holds all shared dependencies and implements http.Handler.
type Server struct {
db *sql.DB
logger *slog.Logger
router *http.ServeMux
}
func NewServer(db *sql.DB, logger *slog.Logger) *Server {
s := &Server{
db: db,
logger: logger,
router: http.NewServeMux(),
}
s.routes()
return s
}
func (s *Server) routes() {
s.router.HandleFunc("GET /api/users/{id}", s.handleGetUser)
s.router.HandleFunc("POST /api/users", s.handleCreateUser)
s.router.HandleFunc("GET /healthz", s.handleHealth)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}Middleware Wrapping
Apply middleware at the http.Server level or per-route:
// Wrap entire server
httpServer := &http.Server{
Addr: ":8080",
Handler: requestLogger(s),
}
// Or per-route
s.router.Handle("GET /api/admin/", adminOnly(http.HandlerFunc(s.handleAdmin)))Middleware Signature
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("request", "method", r.Method, "path", r.URL.Path, "dur", time.Since(start))
})
}Project Structure
Choose based on project size:
- Flat structure -- single package, all files in root. Best for CLIs, small services, < ~10 handlers. See references/project-structure.md.
- Modular/domain-driven --
cmd/,internal/with domain packages. For larger apps with multiple bounded contexts. See references/project-structure.md.
Start flat. Migrate when you see the signs described in the reference.
Graceful Shutdown
Every production Go server needs graceful shutdown. The pattern uses signal.NotifyContext to listen for OS signals and http.Server.Shutdown to drain connections.
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer cancel()
// ... start server in goroutine ...
<-ctx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
httpServer.Shutdown(shutdownCtx)Full pattern with cleanup ordering in references/graceful-shutdown.md.
When to Load References
Load project-structure.md when:
- Scaffolding a new Go project
- Discussing package layout or directory organization
- The project is growing and needs restructuring
Load graceful-shutdown.md when:
- Setting up a production HTTP server
- Implementing signal handling or clean shutdown
- Discussing deployment or container readiness
Load dependency-injection.md when:
- Designing how services, stores, and handlers connect
- Making code testable with interfaces
- Reviewing constructor functions or wiring logic
Anti-Patterns
Global database variables
// BAD -- untestable, hidden dependency
var db *sql.DB
func handleGetUser(w http.ResponseWriter, r *http.Request) {
db.QueryRow(...)
}Pass db through a Server or Service struct instead.
Framework-first thinking
Do not start with gin.Default() or echo.New(). Start with http.NewServeMux(). Only introduce a framework if you hit a real limitation of the stdlib that justifies the dependency.
God packages
A single handlers package with 50 files is not organization. Group by domain (user, order, billing), not by technical layer.
Using init() for setup
// BAD -- invisible side effects, untestable
func init() {
db, _ = sql.Open("postgres", os.Getenv("DATABASE_URL"))
}All initialization belongs in main() or a run() function so it can be tested and errors can be handled.
Reading config in business logic
// BAD -- couples handler to environment
func (s *Server) handleSendEmail(w http.ResponseWriter, r *http.Request) {
apiKey := os.Getenv("SENDGRID_API_KEY") // don't do this
}Inject configuration values or clients through constructors.
Dependency Injection in Go
Go does not need a DI framework. The language's interfaces, structs, and constructor functions provide everything necessary for clean dependency injection.
Server Struct as Dependency Container
The Server struct holds all shared dependencies and exposes HTTP handlers as methods. This is the simplest form of DI in Go.
type Server struct {
users *user.Service
orders *order.Service
logger *slog.Logger
router *http.ServeMux
}
func NewServer(users *user.Service, orders *order.Service, logger *slog.Logger) *Server {
s := &Server{
users: users,
orders: orders,
logger: logger,
router: http.NewServeMux(),
}
s.routes()
return s
}Dependencies are explicit: you can see exactly what the server needs by looking at its struct fields and constructor signature.
Constructor Functions
Every component provides a New* constructor that accepts its dependencies and returns a ready-to-use instance.
// user/store.go
func NewPostgresStore(db *sql.DB) *PostgresStore {
return &PostgresStore{db: db}
}
// user/service.go
func NewService(store Store) *Service {
return &Service{store: store}
}Constructors should:
- Accept only what the component actually uses
- Return a concrete type (not an interface)
- Not perform I/O (no database pings, no HTTP calls)
- Panic only if the dependency is nil and the component cannot function without it
func NewService(store Store) *Service {
if store == nil {
panic("user: store is required")
}
return &Service{store: store}
}Layered Dependency Injection
main.go (or the run() function) is the composition root. It creates all dependencies in order and wires them together. No other part of the application creates its own dependencies.
// cmd/server/main.go
func run(ctx context.Context) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
// Layer 1: Infrastructure
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("opening db: %w", err)
}
defer db.Close()
// Layer 2: Stores (depend on infrastructure)
userStore := user.NewPostgresStore(db)
orderStore := order.NewPostgresStore(db)
// Layer 3: Services (depend on stores)
userService := user.NewService(userStore)
orderService := order.NewService(orderStore, userService)
// Layer 4: HTTP server (depends on services)
srv := NewServer(userService, orderService, slog.Default())
// ... start server ...
return nil
}The dependency graph is a tree built from bottom (infrastructure) to top (HTTP layer). Each layer only knows about the layer directly below it.
Interface-Based Dependencies for Testability
Define interfaces at the consumer, not the producer. Keep them small.
// user/service.go
package user
// Store is defined where it is used, not where it is implemented
type Store interface {
GetByID(ctx context.Context, id string) (*User, error)
Create(ctx context.Context, u *User) error
List(ctx context.Context, limit, offset int) ([]User, error)
}
type Service struct {
store Store
}
func NewService(store Store) *Service {
return &Service{store: store}
}The concrete implementation lives in a separate file or package:
// user/postgres_store.go
package user
type PostgresStore struct {
db *sql.DB
}
func NewPostgresStore(db *sql.DB) *PostgresStore {
return &PostgresStore{db: db}
}
func (s *PostgresStore) GetByID(ctx context.Context, id string) (*User, error) {
row := s.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
return nil, fmt.Errorf("querying user %s: %w", id, err)
}
return &u, nil
}
// ... other Store methods ...Testing with Mock Implementations
// user/service_test.go
package user
type mockStore struct {
users map[string]*User
}
func (m *mockStore) GetByID(ctx context.Context, id string) (*User, error) {
u, ok := m.users[id]
if !ok {
return nil, fmt.Errorf("user not found: %s", id)
}
return u, nil
}
func (m *mockStore) Create(ctx context.Context, u *User) error {
m.users[u.ID] = u
return nil
}
func (m *mockStore) List(ctx context.Context, limit, offset int) ([]User, error) {
var result []User
for _, u := range m.users {
result = append(result, *u)
}
return result, nil
}
func TestGetUser(t *testing.T) {
store := &mockStore{
users: map[string]*User{
"1": {ID: "1", Name: "Alice", Email: "alice@example.com"},
},
}
svc := NewService(store)
u, err := svc.GetUser(context.Background(), "1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if u.Name != "Alice" {
t.Errorf("got name %q, want %q", u.Name, "Alice")
}
}No mocking library needed. Go interfaces make manual test doubles straightforward.
Configuration as a Dependency
Business logic should never read environment variables or config files directly. Configuration is loaded once in main.go and passed as explicit values to constructors.
// config/config.go
package config
type Config struct {
DatabaseURL string `env:"DATABASE_URL,required"`
Addr string `env:"ADDR" default:":8080"`
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" default:"10s"`
SendGrid SendGridConfig
}
type SendGridConfig struct {
APIKey string `env:"SENDGRID_API_KEY,required"`
FromAddr string `env:"SENDGRID_FROM" default:"noreply@example.com"`
}Pass only what each component needs, not the entire config:
// GOOD -- emailer gets only its own config
emailer := email.NewSendGridEmailer(cfg.SendGrid.APIKey, cfg.SendGrid.FromAddr)
// BAD -- emailer receives entire application config
emailer := email.NewSendGridEmailer(cfg)This keeps components decoupled from the config structure and makes their requirements visible.
Functional Options for Optional Dependencies
When a constructor has many optional parameters, use the functional options pattern:
type Server struct {
db *sql.DB
logger *slog.Logger
cache Cache
metrics MetricsRecorder
}
type Option func(*Server)
func WithCache(c Cache) Option {
return func(s *Server) {
s.cache = c
}
}
func WithMetrics(m MetricsRecorder) Option {
return func(s *Server) {
s.metrics = m
}
}
func NewServer(db *sql.DB, logger *slog.Logger, opts ...Option) *Server {
s := &Server{
db: db,
logger: logger,
cache: noopCache{}, // sensible default
metrics: noopMetrics{}, // sensible default
}
for _, opt := range opts {
opt(s)
}
s.routes()
return s
}Usage:
// Minimal -- uses defaults for cache and metrics
srv := NewServer(db, logger)
// With optional dependencies
srv := NewServer(db, logger,
WithCache(redisCache),
WithMetrics(promMetrics),
)Use functional options when:
- There are more than 3-4 optional parameters
- You want sensible defaults that can be overridden
- The constructor signature is growing unwieldy
Do not use functional options for required dependencies. Those belong as regular constructor parameters.
Cross-Domain Dependencies
When one domain needs data from another, define an interface in the consuming package:
// internal/order/service.go
package order
// UserLookup is what the order domain needs from the user domain
type UserLookup interface {
GetByID(ctx context.Context, id string) (*UserInfo, error)
}
// UserInfo contains only what orders need -- not the full user model
type UserInfo struct {
ID string
Name string
Email string
}
type Service struct {
store Store
userLookup UserLookup
}
func NewService(store Store, userLookup UserLookup) *Service {
return &Service{store: store, userLookup: userLookup}
}The user service satisfies this interface without knowing about it:
// cmd/server/main.go
orderService := order.NewService(orderStore, userService) // userService satisfies order.UserLookupThis keeps domains decoupled. The order package never imports the user package.
Anti-Patterns
Global Database Variable
// BAD
package db
var DB *sql.DB
func init() {
var err error
DB, err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
}Problems:
- Impossible to test with a different database
- Hidden dependency -- callers don't declare they need a DB
init()runs at import time, beforemain(), making startup order unpredictablelog.Fatalininit()prevents graceful error handling
Fix: Pass *sql.DB through constructors.
Reading Environment Variables in Handlers
// BAD
func (s *Server) handleSendEmail(w http.ResponseWriter, r *http.Request) {
apiKey := os.Getenv("SENDGRID_API_KEY")
client := sendgrid.NewClient(apiKey)
// ...
}Problems:
- Creates a new client on every request
- Cannot test without setting env vars
- Handler does infrastructure work
Fix: Inject a pre-configured email client through the Server struct.
// GOOD
type Server struct {
emailer EmailSender
}
func (s *Server) handleSendEmail(w http.ResponseWriter, r *http.Request) {
err := s.emailer.Send(r.Context(), to, subject, body)
// ...
}Passing Entire Config Struct
// BAD -- emailer knows about database config, server port, etc.
func NewEmailer(cfg *config.Config) *Emailer {
return &Emailer{apiKey: cfg.SendGrid.APIKey}
}Problems:
- Component knows about the entire configuration shape
- Cannot tell what the emailer actually needs without reading its code
- Refactoring config structure breaks unrelated components
Fix: Pass individual values or a small, focused config struct.
// GOOD
func NewEmailer(apiKey string, fromAddr string) *Emailer {
return &Emailer{apiKey: apiKey, fromAddr: fromAddr}
}Using init() for Dependency Setup
// BAD
var userService *UserService
func init() {
db := connectDB()
store := NewPostgresStore(db)
userService = NewService(store)
}Problems:
- Runs before
main(), no error handling - Global state, untestable
- Invisible side effects at import time
- Order of
init()across packages is hard to reason about
Fix: Build the dependency graph explicitly in main.go or run().
Graceful Shutdown
Every production Go HTTP server must handle shutdown gracefully: finish in-flight requests, close database connections, and flush buffers before exiting. An abrupt os.Exit or unhandled signal drops active requests and can corrupt data.
Full Pattern
package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func run(ctx context.Context) error {
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer cancel()
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
return fmt.Errorf("opening db: %w", err)
}
defer db.Close()
srv := NewServer(db, slog.Default())
httpServer := &http.Server{
Addr: ":8080",
Handler: srv,
}
errCh := make(chan error, 1)
go func() {
slog.Info("server starting", "addr", httpServer.Addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
}
}()
// Wait for interrupt signal or server error
select {
case <-ctx.Done():
case err := <-errCh:
return fmt.Errorf("server listen: %w", err)
}
slog.Info("shutting down gracefully")
// Give outstanding requests time to complete
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("server shutdown: %w", err)
}
return nil
}
func main() {
if err := run(context.Background()); err != nil {
slog.Error("application error", "err", err)
os.Exit(1)
}
}Why run() Returns an Error
Separating run() from main() provides several benefits:
1. Testability -- You can call run() in tests with a cancelable context and verify behavior without starting a real process. 2. Clean error handling -- run() uses normal Go error returns instead of log.Fatal(), which calls os.Exit(1) and skips deferred cleanup. 3. Deferred cleanup runs -- Since run() returns instead of exiting, all defer statements (db.Close(), cancel(), etc.) execute in order. 4. Single exit point -- main() is the only place that calls os.Exit, making the exit path predictable.
// BAD -- defers never run, no cleanup
func main() {
db, err := sql.Open(...)
if err != nil {
log.Fatal(err) // calls os.Exit(1), skips defer db.Close()
}
defer db.Close()
// ...
}
// GOOD -- all defers run, clean exit
func main() {
if err := run(context.Background()); err != nil {
slog.Error("application error", "err", err)
os.Exit(1)
}
}signal.NotifyContext vs signal.Notify
signal.NotifyContext (preferred)
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer cancel()
<-ctx.Done() // blocks until signal receivedBenefits:
- Returns a standard
context.Contextthat integrates with the rest of the application - Cancelation propagates to all child contexts automatically
defer cancel()cleans up signal registration- Idiomatic for modern Go code
signal.Notify (older pattern)
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit // blocks until signal receivedUse signal.Notify only when you need to handle the same signal multiple times or perform special signal-specific logic. For typical graceful shutdown, signal.NotifyContext is cleaner.
Shutdown Timeout Configuration
The shutdown timeout controls how long the server waits for in-flight requests to complete before forcefully closing connections.
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()Choosing a Timeout Value
| Scenario | Recommended Timeout |
|---|---|
| API with fast queries | 5-10 seconds |
| Long-polling / SSE | 30 seconds |
| File uploads | 60 seconds |
| WebSocket connections | 30-60 seconds |
Make the timeout configurable:
type Config struct {
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" default:"10s"`
}
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)What Happens When the Timeout Expires
If httpServer.Shutdown(shutdownCtx) exceeds the timeout, it returns context.DeadlineExceeded. At that point:
- Any remaining connections are forcefully closed
- The server stops accepting new connections (this happens immediately on Shutdown call)
- Clients with active requests receive connection-reset errors
Cleanup Ordering
Resources must be cleaned up in reverse order of creation. The server should stop accepting new requests before closing the resources those requests depend on.
func run(ctx context.Context) error {
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer cancel()
// 1. Open database (first resource created)
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
return fmt.Errorf("opening db: %w", err)
}
defer db.Close() // 4. Close database LAST (after server is done)
// 2. Create cache client
cache := redis.NewClient(...)
defer cache.Close() // 3. Close cache after server, before database
srv := NewServer(db, cache, slog.Default())
httpServer := &http.Server{
Addr: ":8080",
Handler: srv,
}
go func() {
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err)
}
}()
<-ctx.Done()
// Shutdown server FIRST -- drains in-flight requests that use db and cache
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("server shutdown: %w", err)
}
// Then defers run in LIFO order: cache.Close(), then db.Close()
return nil
}The ordering is: 1. Stop accepting new connections (Shutdown called) 2. Wait for in-flight requests to finish (up to timeout) 3. Close cache (deferred, LIFO) 4. Close database (deferred, LIFO)
With Background Workers
If you have background goroutines (job processors, consumers), shut them down after the HTTP server but before closing shared resources:
<-ctx.Done()
// 1. Stop HTTP server
httpServer.Shutdown(shutdownCtx)
// 2. Stop background workers (they may still use db)
workerCancel()
workerWg.Wait()
// 3. Defers close db, cache, etc.
return nilHealth Check Endpoint
In container orchestrators (Kubernetes, ECS), the health check should start failing before the server shuts down. This tells the load balancer to stop sending new traffic.
type Server struct {
db *sql.DB
logger *slog.Logger
router *http.ServeMux
healthy atomic.Bool
}
func NewServer(db *sql.DB, logger *slog.Logger) *Server {
s := &Server{
db: db,
logger: logger,
router: http.NewServeMux(),
}
s.healthy.Store(true)
s.routes()
return s
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
if !s.healthy.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintln(w, "shutting down")
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
}
// Call before starting Shutdown
func (s *Server) SetUnhealthy() {
s.healthy.Store(false)
}Shutdown Sequence with Health Check
<-ctx.Done()
slog.Info("shutting down gracefully")
// 1. Mark unhealthy -- load balancer stops sending new traffic
srv.SetUnhealthy()
// 2. Wait for load balancer to detect unhealthy status
// This depends on your health check interval (typically 5-10s)
time.Sleep(5 * time.Second)
// 3. Shut down server -- drain remaining in-flight requests
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("server shutdown: %w", err)
}The sleep between marking unhealthy and calling Shutdown gives the load balancer time to route traffic elsewhere. Without this, new requests may arrive at a server that is already draining.
Complete Production Template
func run(ctx context.Context) error {
cfg, err := loadConfig()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer cancel()
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("opening db: %w", err)
}
defer db.Close()
if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("pinging db: %w", err)
}
srv := NewServer(db, slog.Default())
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: srv,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() {
slog.Info("server starting", "addr", httpServer.Addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- fmt.Errorf("server listen: %w", err)
}
}()
// Wait for signal or server error
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
slog.Info("shutting down gracefully")
srv.SetUnhealthy()
time.Sleep(cfg.HealthDrainDelay)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer shutdownCancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("server shutdown: %w", err)
}
slog.Info("server stopped")
return nil
}Go Project Structure
Flat Structure
Best for small applications, CLIs, microservices with fewer than ~10 handlers, and projects where a single developer or small team owns the entire codebase.
myapp/
├── main.go
├── server.go
├── handlers.go
├── middleware.go
├── models.go
├── store.go
├── server_test.go
├── handlers_test.go
└── go.modWhen to Use
- CLI tools and small utilities
- Single-purpose microservices (one bounded context)
- Prototypes and proofs of concept
- Fewer than ~10 HTTP handlers
- One or two developers working on the codebase
Benefits
- Zero cognitive overhead for navigation -- everything is in one place
- No circular dependency issues (single package)
- Easy to refactor -- just move functions between files
go test ./...covers everything in one pass- New contributors can understand the layout immediately
File Responsibilities
| File | Contains |
|---|---|
main.go | func main(), wiring, configuration loading, run() function |
server.go | Server struct, NewServer(), routes(), ServeHTTP() |
handlers.go | All HTTP handler methods on Server |
middleware.go | Middleware functions (requestLogger, authenticate, etc.) |
models.go | Domain types, request/response structs |
store.go | Database access layer (queries, store struct) |
For very small apps, server.go and handlers.go can be the same file.
Example: Flat Server
// main.go
package main
import (
"context"
"database/sql"
"log/slog"
"os"
)
func main() {
if err := run(context.Background()); err != nil {
slog.Error("application error", "err", err)
os.Exit(1)
}
}
func run(ctx context.Context) error {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
return fmt.Errorf("opening db: %w", err)
}
defer db.Close()
srv := NewServer(db, slog.Default())
// ... start and graceful shutdown ...
return nil
}// server.go
package main
type Server struct {
db *sql.DB
logger *slog.Logger
router *http.ServeMux
}
func NewServer(db *sql.DB, logger *slog.Logger) *Server {
s := &Server{db: db, logger: logger, router: http.NewServeMux()}
s.routes()
return s
}
func (s *Server) routes() {
s.router.HandleFunc("GET /api/items", s.handleListItems)
s.router.HandleFunc("GET /api/items/{id}", s.handleGetItem)
s.router.HandleFunc("POST /api/items", s.handleCreateItem)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}---
Modular / Domain-Driven Structure
For larger applications with multiple bounded contexts, multiple teams, or significant growth expected.
myapp/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── user/
│ │ ├── handler.go
│ │ ├── service.go
│ │ ├── store.go
│ │ ├── model.go
│ │ └── handler_test.go
│ ├── order/
│ │ ├── handler.go
│ │ ├── service.go
│ │ ├── store.go
│ │ ├── model.go
│ │ └── handler_test.go
│ └── platform/
│ ├── middleware/
│ │ ├── auth.go
│ │ └── logging.go
│ ├── database/
│ │ └── postgres.go
│ └── config/
│ └── config.go
├── migrations/
│ ├── 001_create_users.up.sql
│ └── 001_create_users.down.sql
├── go.mod
└── go.sumDirectory Conventions
cmd/
Entry points for the application. Each subdirectory produces one binary.
cmd/
├── server/
│ └── main.go # HTTP server
├── worker/
│ └── main.go # Background job processor
└── migrate/
└── main.go # Database migration toolEach main.go is the composition root: it reads config, creates dependencies, wires them together, and starts the program. Keep main.go small -- delegate to a run() function that returns an error.
internal/
The internal/ directory is enforced by the Go toolchain. Code inside internal/ cannot be imported by external modules. Use it for all application-specific code.
// This import is only allowed from within the same module:
import "myapp/internal/user"This gives you the freedom to refactor internal packages without worrying about breaking external consumers.
Domain Packages (internal/user/, internal/order/)
Each domain package owns its:
- Models -- domain types and validation
- Store -- database queries, implements a store interface
- Service -- business logic, orchestrates store calls
- Handler -- HTTP handlers, request parsing, response writing
// internal/user/service.go
package user
type Service struct {
store Store
}
type Store interface {
GetByID(ctx context.Context, id string) (*User, error)
Create(ctx context.Context, u *User) error
List(ctx context.Context, limit, offset int) ([]User, error)
}
func NewService(store Store) *Service {
return &Service{store: store}
}
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
if id == "" {
return nil, fmt.Errorf("user id is required")
}
return s.store.GetByID(ctx, id)
}internal/platform/
Shared infrastructure code that is not domain-specific:
middleware/-- HTTP middleware (logging, auth, CORS)database/-- connection helpers, migration runnersconfig/-- configuration loading and validation
Platform packages are imported by domain packages and cmd/, but never import domain packages.
Package Design Principles
Dependencies flow inward. Domain packages should not import other domain packages. If order needs user data, it defines its own interface:
// internal/order/service.go
package order
type UserLookup interface {
GetByID(ctx context.Context, id string) (*User, error)
}
type Service struct {
store Store
userLookup UserLookup
}The cmd/server/main.go wires the user.Service (which satisfies order.UserLookup) into the order service.
Avoid circular dependencies. If package A imports package B, package B cannot import package A. This is a compile error in Go. Solutions: 1. Extract shared types into a separate package (e.g., internal/domain) 2. Use interfaces at the consumer side 3. Merge the packages if they are tightly coupled
Keep packages focused. A package named utils or helpers is a code smell. If a function doesn't belong to a domain, it belongs in platform/ with a descriptive package name.
Export only what is needed. Start with unexported types and functions. Export them only when another package needs access.
Wiring in main.go
// cmd/server/main.go
package main
import (
"myapp/internal/order"
"myapp/internal/platform/config"
"myapp/internal/platform/database"
"myapp/internal/user"
)
func run(ctx context.Context) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
db, err := database.Open(cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("opening db: %w", err)
}
defer db.Close()
// Build dependency graph
userStore := user.NewPostgresStore(db)
userService := user.NewService(userStore)
orderStore := order.NewPostgresStore(db)
orderService := order.NewService(orderStore, userService)
// Build server
mux := http.NewServeMux()
user.RegisterRoutes(mux, userService)
order.RegisterRoutes(mux, orderService)
// ... start HTTP server with graceful shutdown ...
return nil
}Route Registration in Domain Packages
Each domain package provides a RegisterRoutes function:
// internal/user/handler.go
package user
func RegisterRoutes(mux *http.ServeMux, svc *Service) {
h := &handler{svc: svc}
mux.HandleFunc("GET /api/users", h.list)
mux.HandleFunc("GET /api/users/{id}", h.get)
mux.HandleFunc("POST /api/users", h.create)
}
type handler struct {
svc *Service
}
func (h *handler) get(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
u, err := h.svc.GetUser(r.Context(), id)
// ...
}---
Migration Signals: Flat to Modular
Move from flat to modular when you notice:
1. File length -- handlers.go exceeds ~500 lines or contains unrelated handlers 2. Naming collisions -- You prefix functions like userGetHandler, orderGetHandler to avoid confusion 3. Multiple developers -- Merge conflicts in shared files become frequent 4. Distinct domains -- The application clearly has separate bounded contexts (users, orders, billing) 5. Separate deployment needs -- You want a CLI tool and an HTTP server from the same codebase (cmd/server/, cmd/cli/) 6. Test isolation -- You want to test one domain without loading all the others
How to Migrate
1. Create cmd/server/main.go and move wiring code there 2. Create internal/ and make one domain package for the most independent domain 3. Move its models, handlers, store, and tests into the new package 4. Update imports in main.go 5. Repeat for each domain 6. Extract shared infrastructure into internal/platform/
Migrate incrementally. Do not restructure everything in one commit.