Skip to content

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

ContextConventionExample
Exported symbolsPascalCaseUserService, CreateUser
Unexported symbolscamelCasebuildQuery, errNotFound
Packagesshort lowercase singularhandler, service, repository
Filessnake_caseuser_handler.go
Interfacesbehavior-first namesEmailSender, InvoiceRepository
Sentinel errorsErr prefixErrNotFound
Receiversshort and consistentfunc (s *Service)
Contextfirst parameter named ctxfunc Do(ctx context.Context)
TestsTestXxxTestCreateUser

Avoid package-name stutter. Prefer invoice.Service over invoice.InvoiceService when the package already supplies the noun.

Fiber services

LayerPackageTypical type
TransporthandlerInvoiceHandler
Business logicserviceInvoiceService
PersistencerepositoryInvoiceRepository
Domain modeldomainInvoice, ErrInvoiceNotFound

Handlers read as HTTP intent: Create, GetByID, List, Update, Delete.

Routes and OpenAPI

ContextConventionExample
Route pathkebab-case, plural, versioned/api/v1/invoices/:id
Path param in codecamelCase or short lowercasec.Params("id")
operationIdPascalCase verb + entityCreateInvoice
Schema namePascalCase nounInvoiceCreateRequest

The OpenAPI operationId becomes a generated Go name. Treat it as a public contract once published.

Do and do not

DoDo not
ErrUnauthorizedUnauthorizedErrorVar
EmailSenderIEmailSenderInterface
invoice.Serviceinvoice.InvoiceService
func (s *Service)func (this *Service)
GetByID(ctx, id)Get(id)
operationId: CreateInvoiceoperationId: 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)

  1. Explicit Set() calls
  2. Environment variables (production default)
  3. Config file (.env.yml, config.yaml, etc.)
  4. 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 varConfig fieldDefault
PORTport8080
ENVenvdevelopment
LOG_LEVELlog_levelinfo
SERVICE_NAMEservice_nameservice
SERVICE_VERSIONservice_versiondev
DATABASE_URLdatabase_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:

FieldValue
timestampRFC3339 from zerolog.TimestampFieldName
levelinfo for 2xx/3xx, warn for 4xx, error for 5xx
msg"request completed"
event"http.request_completed"
serviceValue of SERVICE_NAME env var
versionValue of SERVICE_VERSION env var
envValue of ENV env var
methodHTTP method
pathURL path (log query string separately if needed)
statusInteger HTTP status code
duration_msRequest duration in milliseconds
request_idUUID 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.json
version: '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:

Terminal window
task tidy # go mod tidy (no drift)
task lint # golangci-lint
task test # go test -race
task build # binary compiles

golangci-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

See also