Skip to content
Public template

About

Production-ready Go web framework built on Fiber, designed for enterprise applications with modular architecture, dependency injection, and batteries included.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

Fiberest

Opinionated Go API starter on Fiber v3: modular vertical slices, Uber FX wiring, session auth, Postgres/GORM, generated OpenAPI served with Scalar.

Not a framework — a template you are meant to delete from. Everything below is what the code actually does today.

  • Go 1.25
  • Fiber v3 · FX v1.24 · GORM v1.31 (pgx) · validator v10
  • Auth: opaque session token in an HttpOnly cookie (no JWT)
  • Docs: /docs (Scalar), generated by swag into docs/, committed, CI-checked

Contents

  1. Quick start
  2. Configuration
  3. Architecture
  4. Request lifecycle
  5. Conventions
  6. API
  7. Database & migrations
  8. Development
  9. Testing
  10. Deployment
  11. Known limits

Quick start

Prerequisites: Go 1.25+, Docker (or a local PostgreSQL), Task, swag, Air for hot reload.

git clone https://github.com/l1ttps/fiberest && cd fiberest
task init            # copies example.env -> .env, downloads deps
docker compose up -d postgres
task dev             # generates docs, then runs with hot reload on :3278

Create the first admin, then log in:

curl -X POST localhost:3278/auth/init \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"change-me-please"}'

curl -c cookies.txt -X POST localhost:3278/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"change-me-please"}'
# -> 200 {"message":"Login successful","expiresAt":"..."} + Set-Cookie: session_id=...

/auth/init only works while no ADMIN user exists. After that it returns 400.


Configuration

Environment only — no config files beyond .env. Resolution order per key: process env → .env → default. .env never overrides an existing process variable.

Key Default Values / notes
PORT 3278 1–65535
APP_ENV development development · production · test
APP_NAME Fiberest used in logs and docs title
DB_HOST localhost
DB_PORT 5432
DB_USER postgres
DB_PASSWORD — empty by default
DB_NAME fiberest
DB_SSLMODE disable disable · allow · prefer · require · verify-ca · verify-full
DB_MAX_IDLE_CONNS 10
DB_MAX_OPEN_CONNS 100
DB_MAX_LIFETIME 1h Go duration
DB_LOG_LEVEL silent silent · error · warn · info
DB_AUTO_MIGRATE true unless production true · false
CORS_ALLOW_ORIGINS * in dev, empty otherwise comma-separated
CORS_ALLOW_CREDENTIALS false forced to false when origins contain *
LOG_LEVEL info debug · info · warn · error
LOG_FORMAT json in production, text otherwise json · text
SESSION_DURATION 168h lifetime of "remember me" sessions
BCRYPT_COST 10 4–31
SESSION_COOKIE_SECURE true in production false only for plain-http local testing

Invalid values fail startup with a validation error listing the offending key — there is no silent fallback.

To add a setting, add one tagged field; nothing else changes:

Port int `env:"PORT" default:"3278" validate:"required,min=1,max=65535"`

Architecture

cmd/server/main.go          fx.New(configs, logger, database, modules, server).Run()
docs/                       generated OpenAPI (committed; CI fails on drift)
internal/
  common/
    types/                  GetManyRequest (pagination + defaults), PageMeta
    validators/             Bind + validate helpers, field-level error messages
  configs/                  env loader (reflection over `env`/`default` tags)
  database/                 GORM connection, pool, THE ONLY AutoMigrate
  logger/                   slog handler (json/text, level from config)
  middlewares/              RequestID, Recovery, SecurityHeaders, CORS, Limiter, AuthGuard, RoleGuard
  models/                   User, Account, Session, BaseModel (UUID pk)
  modules/
    combine_module.go       aggregates feature modules
    auth/                   controller, service, repository, dto
    users/                  controller, service, repository, dto
  server/                   fiber app assembly, health probes, 404, lifecycle
pkg/
  http_error/               the only error type + the only error writer

Each feature module owns its controller, service, repository and DTOs. Sharing between modules goes through an exported interface, not through each other's repositories.

Dependency injection (FX)

var Module = fx.Options(
    fx.Provide(NewRepository),
    fx.Provide(NewService),
    fx.Provide(NewController),
    fx.Invoke(RegisterRoutes),
)

Then register it — both steps are required, nothing is auto-discovered:

// internal/modules/combine_module.go
var Module = fx.Options(auth.Module, users.Module /* + your module */)

Three rules that are not obvious:

  1. FX matches types exactly, not structurally. middlewares.AuthGuard needs middlewares.AuthService; auth.Module re-exports its service under that name, so protected modules take it as a parameter of their route function:
    func UserRoutes(app *fiber.App, c *Controller, authSvc middlewares.AuthService) { ... }
  2. Route registration happens in fx.Invoke. All invokes complete before any lifecycle hook runs, which is why server can register the 404 catch-all in OnStart and be sure every module route exists. If you register a route inside OnStart, register it before that catch-all or it will never be reached.
  3. The *fiber.App is a mutable registry. Modules attach to it; there is no route DSL. Order of fx.Invoke is the order modules appear in main.go.

internal/server/fx_test.go validates the whole graph without a database:

go test ./internal/server -run TestFxGraph

Note: fx.ValidateApp resolves constructors but does not run invoke bodies, so that test proves wiring, not routes. Route behaviour is covered by internal/server/routes_test.go, which mounts the real route functions on a bare app.


Request lifecycle

Middleware, in registration order:

# Middleware Scope
1 RequestID all requests; sets/echoes X-Request-ID, stores it in locals
2 Recovery all requests; panic → generic 500, stack to logs
3 SecurityHeaders all requests; helmet, HSTS only in production over https
4 CORS all requests; config-driven
— /, /health-check, /health, /health/ready, /docs/* registered before the limiter so probes and docs are never throttled
5 global limiter 20 req/s per IP (in-memory, per process)
6 AuthGuard per route group, not global — see Conventions
7 RoleGuard admin-only groups
8 404 catch-all registered in OnStart, after every module route

/health returns {status, env}; /health/ready pings the database with a 2s timeout and returns 503 with a generic body on failure.


Conventions

Protect routes per group

There is no global auth middleware and no public-path allow-list. Public routes are simply registered without the guard:

func UserRoutes(app *fiber.App, c *Controller, authSvc middlewares.AuthService) {
    users := app.Group("/users")

    users.Patch("/me", middlewares.AuthGuard(authSvc), c.patchMe)

    admin := users.Group("/",
        middlewares.AuthGuard(authSvc),
        middlewares.RoleGuard(models.RoleAdmin))
    admin.Get("/", c.getManyUsers)
}

Consequence: an unknown path returns 404, not 401.

Read identity only through the typed helpers — never c.Locals("user_id"):

user, ok := middlewares.User(ctx)
userID, ok := middlewares.UserID(ctx)
session, ok := middlewares.Session(ctx)

Errors: return, don't write

pkg/http_error is the only package that writes an error body, via fiber.Config.ErrorHandler:

return http_error.BadRequest("Invalid date format")
return http_error.NotFound(err.Error())   // safe: service sentinel
return http_error.Internal(err)           // cause logged with request id, client sees "Internal server error"

Response shape (details only appears for validation failures):

{ "status": 404, "code": "NOT_FOUND", "message": "user not found", "requestId": "…" }

Rules:

  • Never pass a database or driver error message into a 4xx/5xx message. Wrap with Internal(err).
  • Map service sentinels once per module with a local mapError(err) helper.
  • pkg must not import internal.

Validate at the boundary

var req dto.CreateUserRequest
if err := validators.ParseAndValidate(ctx, &req); err != nil {
    return validators.Invalid(err)   // 400 + field details
}
Input Helper Notes
JSON body validators.ParseAndValidate
Query validators.GetQuery applies SetDefaults() before validating (pagination fields must stay optional)
Path params validators.GetParam param:"id" tags, backed by Fiber's Bind().URI

Path IDs are UUIDs — tag them validate:"required,uuid" so a malformed id is a 400 instead of a Postgres cast error.

Pagination

One shape, one calculation:

types.PageMeta                                  // limit, page, pageCount, hasNextPage, total
types.NewPageMeta(limit, page, total)           // embedded in every paginated DTO
types.GetManyRequest                            // limit/page/search + SetDefaults()

Responses

Return DTOs, never GORM models. Helpers such as dto.NewUserResponse(user) exist so a model field cannot leak by accident. Session tokens are json:"-" everywhere.


API

Infrastructure: GET /, GET /health, GET /health/ready, GET /health-check, GET /docs.

Auth

Method Path Auth Notes
POST /auth/init public creates the first admin; only while no admin exists
POST /auth/login public 5/min per IP; sets session_id cookie
POST /auth/logout session deletes the session, clears the cookie
GET /auth/session session current session, token excluded
GET /auth/sessions session your sessions, paginated
DELETE /auth/sessions/:id session revoke one of your sessions
POST /auth/change-password session revokes every other session of the user

Sessions: 24h by default, SESSION_DURATION when the login body sets "remember": true, auto-extended once per day. Cookie is HttpOnly, SameSite=Lax, Secure per SESSION_COOKIE_SECURE. The token never appears in a response body.

Users

Method Path Role
PATCH /users/me any authenticated user
GET /users ADMIN
POST /users ADMIN
GET · PUT · DELETE /users/:id ADMIN
POST /users/:id/ban · /users/un-ban/:id ADMIN
POST /users/set-password/:id ADMIN

Paginated endpoints return:

{ "data": [], "limit": 10, "page": 1, "pageCount": 0, "hasNextPage": false, "total": 0 }

?limit and ?page are optional (limit 1–100, default 10/page 1). Unknown query or path values are rejected, not ignored.


Database & migrations

One connection pool, one schema owner.

// internal/database/database.module.go
fx.Provide(NewDatabaseService)
fx.Invoke(runMigrations)     // AutoMigrate(User, Account, Session) when DB_AUTO_MIGRATE
fx.Invoke(registerShutdown)  // pool closed on OnStop
  • DB_AUTO_MIGRATE defaults to false in production. There, migrate out-of-band with a versioned tool.
  • Feature modules must not call AutoMigrate. Add models to database.Module instead.
  • Repositories receive *database.DatabaseService and call db.GetDB().WithContext(ctx).

Known ceiling: there is no transaction propagation — a service cannot run two repositories in one transaction. CreateWithAccount handles its own transaction locally. If you need cross-repository atomicity, provide *gorm.DB instead of DatabaseService and thread the tx through the context.


Development

Command What it does
task init create .env from example.env, go mod download && go mod tidy
task dev Air hot reload; runs task docs before every build
task run build docs, then go run cmd/server/main.go
task build build docs, then go build -o bin/server
task docs swag init -g cmd/server/main.go -o docs
task test go test ./... -race
task test:cover coverage profile + per-function report
task lint golangci-lint, falls back to go vet
task docker:build · docker:up · docker:down image / compose stack

docs/ is committed and shipped inside the image, so annotations and generated files must stay in sync. task dev regenerates on every build; CI runs swag init and fails on git diff --exit-code docs/. Forgetting task docs after editing an annotation is the most common source of CI red.

Annotate handlers with the usual swag comments (@Summary, @Param, @Success, @Router), then task docs. Scalar UI: http://localhost:3278/docs.


Testing

go test ./... -count=1          # what CI runs (plus -race on Linux)
go test ./internal/server -v    # DI graph + route table + guard behaviour

What the suite actually asserts:

Area Test
DI graph resolves, providers unique internal/server/fx_test.go (database replaced, no Postgres)
route table, 401 on protected routes, 404 on unknown, login unguarded internal/server/routes_test.go
internal errors never reach the client pkg/http_error/http_error_test.go
pagination defaults (GET /users without ?limit) and UUID path validation internal/common/validators/validator_test.go
config precedence, conditional defaults, invalid values rejected internal/configs/config_test.go
pool settings, DSN, log-level mapping internal/database/database.service_test.go
panic recovery, security headers, request id internal/middlewares/*_test.go

Not covered yet: a full login → cookie → protected route round trip against a real database. That is the most valuable test to add next.


Deployment

docker compose up --build -d

Compose runs the app with production defaults (APP_ENV=production: JSON logs, Secure cookies, no wildcard CORS, no implicit auto-migrate). Two overrides are deliberate and documented in docker-compose.yml:

  • DB_AUTO_MIGRATE=true — first boot convenience; turn it off once you have a migration pipeline.
  • SESSION_COOKIE_SECURE=true — requires HTTPS in front of the app. Set it to false only if you terminate TLS somewhere browsers do not see.

Production checklist:

  • APP_ENV=production
  • DB_AUTO_MIGRATE=false and a versioned migration tool
  • CORS_ALLOW_ORIGINS set to real origins
  • TLS terminating in front; HSTS is only emitted over https
  • SESSION_COOKIE_SECURE=true
  • DB_SSLMODE not disable for a remote database
  • LOG_FORMAT=json, shipped somewhere

The image is a two-stage build (CGO_ENABLED=0, static binary, non-root appuser). /health and /health/ready are the probe endpoints.


Known limits

Deliberate shortcuts, each with the trigger for fixing it:

  1. Rate limiter is in-memory → limits multiply per replica; move to a shared store when you run more than one.
  2. No transaction propagation → see Database.
  3. AutoMigrate is not versioned → no rollback, no reviewable schema diff. Replace with golang-migrate before the schema matters.
  4. /auth/init has a theoretical race → two concurrent calls can both pass the "no admin yet" check. Needs a partial unique index if that matters.
  5. One Repository per module → no aggregates, no domain events, no unit-of-work. Fine until a module needs invariants across two repositories.
  6. auth and users share concepts → both define Account access (FindAccount/CreateAccount). Merge when a third module needs it.

About

Production-ready Go web framework built on Fiber, designed for enterprise applications with modular architecture, dependency injection, and batteries included.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages