Skip to content

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.sum

main.go Pattern

cmd/server/main.go
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.go
var (
ErrNotFound = errors.New("resource.not_found")
ErrUnauthorized = errors.New("auth.unauthorized")
ErrForbidden = errors.New("auth.forbidden")
)
// Handler maps domain errors to HTTP responses
func (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

internal/middleware/logger.go
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

Taskfile.yml
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