Go
Use Go-native names that make the layer obvious at the import path and call site. Our services usually follow handler -> service -> repository -> domain.
Core conventions
| Context | Convention | Example |
|---|---|---|
| Exported symbols | PascalCase | UserService, CreateUser |
| Unexported symbols | camelCase | buildQuery, errNotFound |
| Packages | short lowercase singular | handler, service, repository |
| Files | snake_case | user_handler.go |
| Interfaces | behavior-first names | EmailSender, InvoiceRepository |
| Sentinel errors | Err prefix | ErrNotFound |
| Receivers | short and consistent | func (s *Service) |
| Context | first parameter named ctx | func Do(ctx context.Context) |
| Tests | TestXxx | TestCreateUser |
Avoid package-name stutter. Prefer invoice.Service over invoice.InvoiceService when the package already supplies the noun.
Fiber services
| Layer | Package | Typical type |
|---|---|---|
| Transport | handler | InvoiceHandler |
| Business logic | service | InvoiceService |
| Persistence | repository | InvoiceRepository |
| Domain model | domain | Invoice, ErrInvoiceNotFound |
Handlers read as HTTP intent: Create, GetByID, List, Update, Delete.
Routes and OpenAPI
| Context | Convention | Example |
|---|---|---|
| Route path | kebab-case, plural, versioned | /api/v1/invoices/:id |
| Path param in code | camelCase or short lowercase | c.Params("id") |
operationId | PascalCase verb + entity | CreateInvoice |
| Schema name | PascalCase noun | InvoiceCreateRequest |
The OpenAPI operationId becomes a generated Go name. Treat it as a public contract once published.
Do and do not
| Do | Do not |
|---|---|
ErrUnauthorized | UnauthorizedErrorVar |
EmailSender | IEmailSenderInterface |
invoice.Service | invoice.InvoiceService |
func (s *Service) | func (this *Service) |
GetByID(ctx, id) | Get(id) |
operationId: CreateInvoice | operationId: create_invoice_v1_post |
/api/v1/invoices/:id | /api/getInvoice |
Enforcement
Run gofmt or gofumpt, go test, and golangci-lint. revive, stylecheck, errcheck, and gofumpt cover receiver names, casing, error naming, and formatting. oapi-codegen enforces the OpenAPI-to-Go contract in CI.
Configuration (Viper)
All Go services use Viper (github.com/spf13/viper) for configuration. It reads from env vars, config files, and flags in priority order, is 12-factor compliant (env vars override file config), and works well with cobra for CLI config.
Priority order (highest → lowest)
- Explicit
Set()calls - Environment variables (production default)
- Config file (
.env.yml,config.yaml, etc.) - Default values in code
Canonical setup
package config
import ( "fmt" "strings"
"github.com/spf13/viper")
// Config holds all service configuration.// Add new fields here — never read viper directly outside this package.type Config struct { Port int `mapstructure:"port"` Env string `mapstructure:"env"` ServiceName string `mapstructure:"service_name"` Version string `mapstructure:"service_version"` LogLevel string `mapstructure:"log_level"` DatabaseURL string `mapstructure:"database_url"`}
// Load reads configuration from environment variables, then a config file if present.// Returns an error if required fields are missing.func Load() (*Config, error) { v := viper.New()
// Defaults v.SetDefault("port", 8080) v.SetDefault("env", "development") v.SetDefault("log_level", "info") v.SetDefault("service_name", "service") v.SetDefault("service_version", "dev")
// Env vars — automatic: PORT → port, DATABASE_URL → database_url v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.AutomaticEnv()
// Optional config file v.SetConfigName("config") v.SetConfigType("yaml") v.AddConfigPath(".") v.AddConfigPath("./config") if err := v.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { return nil, fmt.Errorf("reading config file: %w", err) } // No config file is fine — env vars only }
var cfg Config if err := v.Unmarshal(&cfg); err != nil { return nil, fmt.Errorf("parsing config: %w", err) }
// Validate required fields if cfg.DatabaseURL == "" { return nil, fmt.Errorf("DATABASE_URL is required") }
return &cfg, nil}Environment variables
Viper maps UPPER_SNAKE_CASE env vars to lower_snake_case config fields automatically:
| Env var | Config field | Default |
|---|---|---|
PORT | port | 8080 |
ENV | env | development |
LOG_LEVEL | log_level | info |
SERVICE_NAME | service_name | service |
SERVICE_VERSION | service_version | dev |
DATABASE_URL | database_url | — (required) |
Never log config values
Never log config field values for DATABASE_URL (contains credentials) or any field containing key, secret, token, password, or credential. Log only the presence of these fields at startup:
log.Info(). Bool("database_url_set", cfg.DatabaseURL != ""). Msg("config loaded")cobra + Viper for CLIs
Bind cobra flags to Viper so env vars and flags both work:
func Execute() { rootCmd := &cobra.Command{ Use: "mycli", PersistentPreRun: func(cmd *cobra.Command, args []string) { viper.BindPFlags(cmd.Flags()) }, }
rootCmd.PersistentFlags().Int("port", 8080, "Port to listen on") viper.BindEnv("port", "PORT")
rootCmd.Execute()}Request logging (zerolog)
The zerolog request logger is the standard middleware for Go HTTP services on Fiber. It emits one wide-event log per request.
Required always-present fields
Every request-completion log must include:
| Field | Value |
|---|---|
timestamp | RFC3339 from zerolog.TimestampFieldName |
level | info for 2xx/3xx, warn for 4xx, error for 5xx |
msg | "request completed" |
event | "http.request_completed" |
service | Value of SERVICE_NAME env var |
version | Value of SERVICE_VERSION env var |
env | Value of ENV env var |
method | HTTP method |
path | URL path (log query string separately if needed) |
status | Integer HTTP status code |
duration_ms | Request duration in milliseconds |
request_id | UUID from X-Request-ID header or generated |
Canonical implementation
package middleware
import ( "os" "time"
"github.com/gofiber/fiber/v2" "github.com/google/uuid" "github.com/rs/zerolog" "github.com/rs/zerolog/log")
// RequestLogger returns a Fiber middleware that emits one wide-event log// per request. Wire it as the first middleware after Recover.func RequestLogger() fiber.Handler { service := os.Getenv("SERVICE_NAME") version := os.Getenv("SERVICE_VERSION") env := os.Getenv("ENV")
return func(c *fiber.Ctx) error { start := time.Now()
// Propagate or generate request ID requestID := c.Get("X-Request-ID") if requestID == "" { requestID = uuid.New().String() } c.Set("X-Request-ID", requestID) c.Locals("request_id", requestID)
// Process request err := c.Next()
duration := time.Since(start) status := c.Response().StatusCode()
// Choose level level := zerolog.InfoLevel if status >= 400 && status < 500 { level = zerolog.WarnLevel } else if status >= 500 { level = zerolog.ErrorLevel }
// Emit wide event log.WithLevel(level). Str("event", "http.request_completed"). Str("service", service). Str("version", version). Str("env", env). Str("method", c.Method()). Str("path", c.Path()). Int("status", status). Int64("duration_ms", duration.Milliseconds()). Str("request_id", requestID). Str("ip", c.IP()). Err(err). Msg("request completed")
return err }}Wiring in main.go
import ( "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/recover" "yourmodule/internal/middleware")
func main() { app := fiber.New() app.Use(recover.New()) app.Use(middleware.RequestLogger()) // wire after recover
// ... routes app.Listen(":8080")}Never log secrets
The following must never appear in any log event: passwords, tokens, API keys, secrets, full card numbers (PAN), Social Security Numbers, Authorization header values, cookie values, and PII (full names with account IDs, emails in production). Use Str("password", "[REDACTED]") when you must acknowledge a field exists.
Taskfile & lint defaults
Every Go service or CLI uses a Taskfile.yml at its root. Taskfile gives every contributor one entry point (task <name>), avoids Makefile syntax pitfalls, self-documents through desc:, and runs identically in CI and local dev.
Canonical Taskfile.yml
# yaml-language-server: $schema=https://taskfile.dev/schema.jsonversion: '3'
vars: BINARY: '{{.BINARY | default "server"}}' BUILD_DIR: bin MAIN: cmd/server/main.go
env: CGO_ENABLED: '0' GOFLAGS: '-trimpath'
tasks: default: desc: Show available tasks cmds: - task --list
build: desc: Compile the binary cmds: - mkdir -p {{.BUILD_DIR}} - go build -ldflags="-s -w -X main.version={{.GIT_TAG}}" -o {{.BUILD_DIR}}/{{.BINARY}} {{.MAIN}} vars: GIT_TAG: sh: git describe --tags --always --dirty 2>/dev/null || echo dev
run: desc: Build and run locally deps: [build] cmds: - ./{{.BUILD_DIR}}/{{.BINARY}}
dev: desc: Run with hot-reload (requires air) cmds: - air -c .air.toml
test: desc: Run all tests cmds: - go test -race -count=1 ./...
test:coverage: desc: Run tests with coverage report cmds: - go test -race -count=1 -coverprofile=coverage.out ./... - go tool cover -html=coverage.out -o coverage.html
lint: desc: Run golangci-lint cmds: - golangci-lint run ./...
tidy: desc: Tidy go.mod and go.sum cmds: - go mod tidy
clean: desc: Remove build artifacts cmds: - rm -rf {{.BUILD_DIR}} coverage.out coverage.html
ci: desc: Full CI pipeline (lint → test → build) deps: [lint, test, build]Required CI tasks
Every Go service CI job must run these in order:
task tidy # go mod tidy (no drift)task lint # golangci-linttask test # go test -racetask build # binary compilesgolangci-lint config
Place a .golangci.yml at the service root:
linters-settings: goimports: local-prefixes: github.com/your-org/your-repo govet: enable-all: true
linters: enable: - errcheck - govet - staticcheck - goimports - gofmt - godot - misspell - unused - bodyclose - noctx - exhaustive
issues: exclude-rules: - path: _test\.go linters: - errcheck