Go Backend: Project Layout & Conventions
Canonical conventions for Go services built on Fiber. The committed defaults are Fiber, GORM, zerolog, Viper, Taskfile, and the cmd / internal / pkg layout.
Project Layout
myservice/ cmd/ server/ main.go # Entry point: parse config, wire DI, run server internal/ config/ config.go # Viper config struct + Load() function handler/ user_handler.go # HTTP handlers — thin, no business logic user_handler_test.go service/ user_service.go # Business logic — interfaces first user_service_test.go repository/ user_repository.go # DB access — GORM implementation user_repository_test.go provider/ email_provider.go # Vendor wrapper interface + impl mock_email_provider.go middleware/ logger.go # Wide-event zerolog middleware idempotency.go # Idempotency-Key middleware request_id.go # Request ID generation + propagation pkg/ # Packages safe to import by other modules Taskfile.yml .golangci.yml .env.example go.mod go.summain.go Pattern
package main
import ( "log" "myservice/internal/config" "myservice/internal/handler" "myservice/internal/repository" "myservice/internal/service")
func main() { cfg, err := config.Load() if err != nil { log.Fatalf("config: %v", err) }
db := mustOpenDB(cfg.DBUrl) userRepo := repository.NewGORMUserRepository(db) userSvc := service.NewUserService(userRepo) userHdlr := handler.NewUserHandler(userSvc)
app := setupFiber(cfg) userHdlr.Register(app)
log.Fatal(app.Listen(cfg.Addr()))}Error Handling
// Domain errors in internal/service/errors.govar ( ErrNotFound = errors.New("resource.not_found") ErrUnauthorized = errors.New("auth.unauthorized") ErrForbidden = errors.New("auth.forbidden"))
// Handler maps domain errors to HTTP responsesfunc (h *UserHandler) Get(c *fiber.Ctx) error { user, err := h.svc.GetByID(c.Context(), c.Params("id")) if err != nil { if errors.Is(err, service.ErrNotFound) { return c.Status(404).JSON(errorResponse("resource.not_found", "User not found", c)) } return c.Status(500).JSON(errorResponse("server.internal", "Internal error", c)) } return c.JSON(user)}
func errorResponse(code, message string, c *fiber.Ctx) fiber.Map { return fiber.Map{ "error": fiber.Map{ "code": code, "message": message, "request_id": c.Locals("request_id"), }, }}zerolog Request Logger Middleware
package middleware
import ( "time" "github.com/gofiber/fiber/v2" "github.com/rs/zerolog")
func Logger(log zerolog.Logger) fiber.Handler { return func(c *fiber.Ctx) error { start := time.Now() err := c.Next() log.Info(). Str("event", "http.request_completed"). Str("request_id", c.Locals("request_id").(string)). Str("method", c.Method()). Str("route", c.Route().Path). Int("status_code", c.Response().StatusCode()). Int64("duration_ms", time.Since(start).Milliseconds()). Msg("request completed") return err }}Taskfile
version: '3'
tasks: dev: cmds: [go run ./cmd/server] desc: Run development server
lint: cmds: [golangci-lint run ./...] desc: Run linter
test: cmds: [go test -race ./...] desc: Run all tests
test-cover: cmds: [go test -race -coverprofile=coverage.out ./..., go tool cover -html=coverage.out] desc: Run tests with coverage report
build: cmds: [go build -o dist/server ./cmd/server] desc: Build the server binary
migrate: cmds: [goose -dir migrations up] desc: Run pending migrations