Go Web Architecture
Core Principles
- Standard layout — Follow cmd/internal/pkg convention
- Explicit dependencies — Wire dependencies in main.go, no globals
- Interface-driven — Define interfaces where you use them, not where you implement
- Error wrapping — Wrap errors with context, use error codes
- No backwards compatibility — Delete, don't deprecate. Change directly
- LiteLLM for LLM APIs — Use LiteLLM proxy for all LLM integrations
No Backwards Compatibility
Delete unused code. Change directly. No compatibility layers.
go// ❌ BAD: Deprecated function kept around // Deprecated: Use NewUserService instead func CreateUserService() *UserService { ... } // ❌ BAD: Alias for renamed types type OldName = NewName // "for backwards compatibility" // ❌ BAD: Unused parameters func Process(_ context.Context, data Data) { ... } // ✅ GOOD: Just delete and update all usages func NewUserService(repo UserRepository) *UserService { ... }
LiteLLM for LLM APIs
Use LiteLLM proxy. Don't call provider APIs directly.
go// adapters/llm/client.go package llm import ( "github.com/sashabaranov/go-openai" ) // Connect to LiteLLM proxy using OpenAI-compatible SDK func NewClient(cfg Config) *openai.Client { config := openai.DefaultConfig(cfg.APIKey) config.BaseURL = cfg.BaseURL // LiteLLM proxy URL return openai.NewClientWithConfig(config) }
Quick Start
1. Initialize Project
bashmkdir myapp && cd myapp go mod init github.com/yourname/myapp # Install core dependencies go get github.com/gin-gonic/gin go get github.com/spf13/viper go get github.com/sirupsen/logrus go get gorm.io/gorm
2. Apply Tech Stack
| Layer | Recommendation |
|---|---|
| HTTP Framework | Gin / Chi / Echo |
| Configuration | Viper |
| Logging | Logrus / Zap / Slog |
| Database ORM | GORM / sqlx / sqlc |
| Validation | go-playground/validator |
| Testing | testify / go test |
Version Strategy
Always get latest. Never pin in templates.
bash# Always fetch latest go get -u github.com/gin-gonic/gin go get -u ./... # go.mod handles version locking # go.sum ensures reproducible builds
3. Use Standard Structure
myapp/ ├── cmd/ │ └── myapp/ │ └── main.go # Entry point, dependency wiring ├── configs/ │ └── config.go # Configuration struct + loader ├── internal/ # Private application code │ ├── handlers/ # HTTP handlers │ ├── services/ # Business logic │ ├── repositories/ # Data access │ ├── models/ # Domain models │ ├── middleware/ # HTTP middleware │ └── router/ # Route definitions ├── pkg/ # Public reusable packages │ ├── errors/ # Error types │ ├── logger/ # Logging setup │ ├── response/ # Unified response format │ └── database/ # Database connection ├── config.yaml # Configuration file ├── Makefile # Build automation ├── Dockerfile └── go.mod
Architecture Layers
cmd/ — Entry Point
Wire all dependencies here. No business logic.
go// cmd/myapp/main.go func main() { // Load config cfg := configs.Load() // Initialize infrastructure db := database.New(cfg.Database) cache := cache.New(cfg.Redis) logger := logger.New(cfg.Log) // Initialize repositories userRepo := repositories.NewUserRepository(db) // Initialize services userService := services.NewUserService(userRepo) // Initialize handlers userHandler := handlers.NewUserHandler(userService) // Setup router r := router.Setup(cfg, userHandler) // Start server with graceful shutdown server.Run(r, cfg.Server) }
internal/ — Private Business Code
handlers/ — HTTP Layer
go// internal/handlers/user.go type UserHandler struct { service services.UserService } func NewUserHandler(s services.UserService) *UserHandler { return &UserHandler{service: s} } func (h *UserHandler) Create(c *gin.Context) { var input CreateUserInput if err := c.ShouldBindJSON(&input); err != nil { response.Error(c, errors.ErrInvalidParams) return } user, err := h.service.Create(c.Request.Context(), input) if err != nil { response.Error(c, err) return } response.Success(c, user) }
services/ — Business Logic
go// internal/services/user.go type UserService interface { Create(ctx context.Context, input CreateUserInput) (*models.User, error) GetByID(ctx context.Context, id string) (*models.User, error) } type userService struct { repo repositories.UserRepository } func NewUserService(repo repositories.UserRepository) UserService { return &userService{repo: repo} } func (s *userService) Create(ctx context.Context, input CreateUserInput) (*models.User, error) { existing, _ := s.repo.FindByEmail(ctx, input.Email) if existing != nil { return nil, errors.ErrUserExists } user := &models.User{ ID: uuid.New().String(), Email: input.Email, Name: input.Name, } return s.repo.Save(ctx, user) }
repositories/ — Data Access
go// internal/repositories/user.go type UserRepository interface { FindByID(ctx context.Context, id string) (*models.User, error) FindByEmail(ctx context.Context, email string) (*models.User, error) Save(ctx context.Context, user *models.User) (*models.User, error) Delete(ctx context.Context, id string) error } type userRepository struct { db *gorm.DB } func NewUserRepository(db *gorm.DB) UserRepository { return &userRepository{db: db} } func (r *userRepository) FindByID(ctx context.Context, id string) (*models.User, error) { var user models.User if err := r.db.WithContext(ctx).First(&user, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } return &user, nil }
pkg/ — Reusable Packages
errors/ — Error Handling
go// pkg/errors/errors.go type AppError struct { Code int `json:"code"` Message string `json:"message"` Cause error `json:"-"` } func (e *AppError) Error() string { return e.Message } func (e *AppError) Unwrap() error { return e.Cause } func New(code int, message string) *AppError { return &AppError{Code: code, Message: message} } func Wrap(err error, code int, message string) *AppError { return &AppError{Code: code, Message: message, Cause: err} } // Predefined errors var ( ErrInternal = New(500, "internal server error") ErrInvalidParams = New(400, "invalid parameters") ErrNotFound = New(404, "resource not found") ErrUnauthorized = New(401, "unauthorized") ErrUserExists = New(409, "user already exists") )
response/ — Unified Response
go// pkg/response/response.go type Response struct { Code int `json:"code"` Message string `json:"message"` Data interface{} `json:"data,omitempty"` } func Success(c *gin.Context, data interface{}) { c.JSON(http.StatusOK, Response{ Code: 0, Message: "success", Data: data, }) } func Error(c *gin.Context, err error) { var appErr *errors.AppError if errors.As(err, &appErr) { c.JSON(appErr.Code/100, Response{ Code: appErr.Code, Message: appErr.Message, }) return } c.JSON(http.StatusInternalServerError, Response{ Code: 500, Message: "internal server error", }) }
Configuration
Viper + YAML + Environment Variables
go// configs/config.go type Config struct { Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` Redis RedisConfig `mapstructure:"redis"` Log LogConfig `mapstructure:"log"` LLM LLMConfig `mapstructure:"llm"` } type LLMConfig struct { BaseURL string `mapstructure:"base_url"` APIKey string `mapstructure:"api_key"` DefaultModel string `mapstructure:"default_model"` } func Load() *Config { viper.SetConfigFile("config.yaml") viper.AutomaticEnv() viper.SetEnvPrefix("APP") viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) // Defaults viper.SetDefault("server.port", 8080) viper.SetDefault("llm.base_url", "http://localhost:4000") viper.SetDefault("llm.default_model", "gpt-4o") viper.ReadInConfig() var cfg Config viper.Unmarshal(&cfg) return &cfg }
Graceful Shutdown
go// pkg/server/server.go func Run(handler http.Handler, cfg ServerConfig) { srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port), Handler: handler, ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout, } go func() { if err := srv.ListenAndServe(); err != http.ErrServerClosed { log.Fatalf("Server error: %v", err) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() srv.Shutdown(ctx) }
Makefile
makefile.PHONY: build run test lint clean APP_NAME=myapp build: go build -o bin/$(APP_NAME) ./cmd/$(APP_NAME) run: go run ./cmd/$(APP_NAME) dev: air test: go test -v ./... lint: golangci-lint run clean: rm -rf bin/ tidy: go mod tidy upgrade: go get -u ./... go mod tidy
Checklist
markdown## Project Setup - [ ] Go 1.21+ installed - [ ] Standard directory structure (cmd/internal/pkg) - [ ] go.mod initialized - [ ] Makefile created ## Architecture - [ ] Dependencies wired in main.go - [ ] Handlers → Services → Repositories layers - [ ] Interfaces defined at usage site - [ ] No circular dependencies ## Infrastructure - [ ] Configuration with Viper - [ ] Structured logging - [ ] Custom error types - [ ] Unified response format - [ ] Graceful shutdown ## Quality - [ ] Tests for services - [ ] golangci-lint configured - [ ] go vet passes - [ ] Race detection tested
See Also
- reference/architecture.md — Detailed architecture patterns
- reference/tech-stack.md — Tech stack comparison
- reference/patterns.md — Go design patterns

