Production-oriented Golang backend starter
Gin · GORM · MySQL · Redis · JWT RS256 · Wire · RBAC
- What is GoRestify?
- What makes it different
- Architecture
- Quick start
- Environment & secrets
- Authentication & security
- RBAC (resources)
- Building APIs the GoRestify way
- List / GetAll / filters / pagination
- Validation (
bindtags) - Errors & error codes
- Responses & activity log
- Redis caching
- Transactions (
tx) - Packages reference (
pkg/) - Wire DI
- i18n, Excel, email, decimals
- Docker & CI
- Security checklist
- Troubleshooting
GoRestify is an open-source Go REST API boilerplate for real backend work: layered domain code, dual admin / user apps, JWT auth, Redis cache, MySQL via GORM, validation, RBAC, activity audit, and a large reusable pkg/ toolkit.
Clone it, change the domain, ship APIs without rebuilding auth, filters, errors, and wiring from scratch.
Keywords: golang rest boilerplate, gin gorm mysql redis jwt, wire di, backend starter kit
Most Go boilerplates stop at “hello Gin + GORM”. GoRestify is opinionated about backend day-to-day:
| Area | Typical starter | GoRestify |
|---|---|---|
| Auth | HS256 secret in env | RS256 PEM keys, access + refresh rotation in Redis |
| Login abuse | Often none | Progressive rate limit + admin unblock APIs |
| List filters | String-concat SQL | Parameterized DSL + column/operator allow-lists |
| ORDER BY | Raw query params | Regex allow-list (column / table.column) |
| Pagination | Unbounded | page_size max 100 |
| Get-all | Huge LIMIT hack |
Real repo GetAll() (no filter/order/limit) |
| Passwords | Weak / cost 10 | Policy (10+ + complexity) + bcrypt cost 12 + salt |
| Errors | Plain strings | Structured pkg_err + unique E####### codes |
| Permissions | Role string only | Resource RBAC (city:read, role:write, …) |
| Apps | One binary | Admin + user apps, shared domain |
| Cache clear | KEYS (blocks Redis) |
SCAN-based pattern delete |
| Docs download | Path join only | filepath.Base + root containment |
HTTP → Middleware (CORS, JWT, rate limit, logger)
→ API (Gin handlers, bind, response)
→ Service (validation, cache, business rules)
→ Repo (GORM queries)
→ MySQL / Redis
cmd/
admin/ # full CRUD, migrate, seed, settings, activities
user/ # user-facing API surface
wire/ # Google Wire providers
domain/
base/ # accounts, users, roles, cities, regions, documents, auth
acc/ # currencies, transactions, slots, credits
service/ # shared services
internal/core/ # Engine, envs, check_access, enums
pkg/ # reusable libraries (see below)
assets/terms/ # i18n TOML
secrets/ # JWT PEMs (gitignored)
| App | Base URL |
|---|---|
| Admin | /api/admin/v1 |
| User | /api/user/v1 |
- Go 1.25+
- MySQL 8+
- Redis 6+
- Optional: reflex (
make admin/make user)
docker run --rm --name db-mysql -d \
-v mysql-data:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD=change-me \
-e TZ='Asia/Baghdad' \
-p 3306:3306 mysql:8 \
--character-set-server=utf8mb4 \
--collation-server=utf8mb4_unicode_ci
docker run --rm --name db-redis -d \
-v redis-data:/data \
-p 6379:6379 redis:latestCREATE DATABASE go_restify CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'travis'@'%' IDENTIFIED BY '';
GRANT ALL PRIVILEGES ON *.* TO 'travis'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;Local
sample.envexpects usertraviswith empty password. Use a real password in shared/prod environments.
mkdir -p secrets
openssl genrsa -out secrets/jwt_private.pem 2048
openssl rsa -in secrets/jwt_private.pem -pubout -out secrets/jwt_public.pemsource cmd/admin/sample.env && go run cmd/admin/main.admin.go
# or
make admin
make userWith CORE_AUTO_MIGRATE=true, admin migrates and seeds.
| Seed admin | Value |
|---|---|
| Username | admin |
| Password | admin123Aa! |
Postman: GoRestify.postman_collection.json
From cmd/admin/sample.env / cmd/user/sample.env:
| Variable | Required | Purpose |
|---|---|---|
CORE_PORT |
yes | HTTP port (default 6969) |
GIN_MODE |
yes | debug / release |
CORE_DATABASE_DATA_DSN |
yes | MySQL DSN |
CORE_DATABASE_ACTIVITY_DSN |
admin | Activity DB |
CORE_REDIS_CACHE_API |
yes | Redis URL |
CORE_JWT_PRIVATE_KEY_PATH |
yes | Sign tokens |
CORE_JWT_PUBLIC_KEY_PATH |
yes | Verify tokens |
CORE_JWT_ACCESS_MINUTES |
yes | Access TTL |
CORE_JWT_REFRESH_HOURS |
yes | Refresh TTL |
CORE_PASSWORD_SALT |
yes | bcrypt pepper — never reuse defaults in prod |
CORE_AUTO_MIGRATE |
admin | Migrate + seed on boot |
secrets/*.pem is gitignored. Generate keys locally (and in CI/CD secrets), never commit them.
POST /api/admin/v1/login
Content-Type: application/json
{ "username": "admin", "password": "admin123Aa!" }Authorization: Bearer <access_token>POST /api/admin/v1/refresh-token
{ "refresh_token": "<refresh_token>" }Backend notes
- Access tokens are RS256, short-lived; refresh tokens are stored/rotated in Redis
JwtAuthGuardrequires schemeBearer(case-insensitive) and rejects refresh tokens on normal routes- Login uses uniform failure messages (no user enumeration)
- Rate limit on login/refresh; admin can inspect/unblock:
GET /login-blocks,POST /login-blocks/unblock - Password policy (
bind:"password"): ≥10 chars, lower, upper, digit, special - Hashing: bcrypt cost 12 +
CORE_PASSWORD_SALT
BasicAuthGuard reads X-Authorization: Basic ... with constant-time compare. Not mounted by default — only attach for third-party integrations.
Protected routes:
rg.GET("/cities", check_access.IsAllow(base.CityRead), cityAPI.List)
rg.POST("/cities", check_access.IsAllow(base.CityWrite), cityAPI.Create)Resources are string constants, e.g. city:read, setting:write (domain/base/base_resource.go, domain/acc/...).
Roles hold a list of resources. After JWT auth, check_access loads the user’s permissions and returns 403 if missing.
When adding a feature: define Read/Write resources, assign them to roles, and wrap every route.
- Model —
domain/<area>/<area>_model/+ GORM tags +bindtags - Repo —
FindByID,GetAll,List,Count,Create,Save,Delete - Service —
ValidateModel, Redis get/set/delete, business rules - API —
response.NewParam,Bind,Record, status/message/JSON - Wire — add provider in
cmd/wireand regenerate - Router — JWT +
IsAllow - Resource — new permission constants
func (a *CityAPI) List(c *gin.Context) {
resp, params := response.NewParam(c, base_model.CityTable)
data := map[string]interface{}{}
var err error
if data["list"], data["count"], err = a.Service.List(params); err != nil {
resp.Error(err).JSON()
return
}
resp.Record(base.ListCity)
resp.Status(http.StatusOK).
Message(pkg_terms.ListOfV, base_term.Cities).
JSON(data)
}params.Tx.DB = a.Engine.DB.Begin()
defer func() {
if err != nil {
params.Tx.DB.Rollback()
}
}()
if created, err = a.Service.Create(params.Tx, city); err != nil {
resp.Error(err).JSON()
return
}
params.Tx.DB.Commit()whereStr, whereArgs, err := params.ParseWhere(r.Cols)
// soft-delete tables:
whereStr, whereArgs, err := params.ParseWhereDelete(r.Cols)
db.Where(whereStr, whereArgs...).Order(params.Order).Limit(params.Limit).Offset(params.Offset)Never concatenate user input into SQL. Use ? + args. ParseWhereDelete wraps filters so OR cannot bypass deleted_at IS NULL.
Lock list scope in code (not from the client):
_ = params.SetPreCondition("base_cities.region_id = ?", regionID)
_ = params.AddPreCondition("base_cities.status = ?", status)Must use ? placeholders; unsafe tokens are rejected.
| Endpoint style | Service/Repo | Behavior |
|---|---|---|
GET /cities |
List |
Filter + order + page |
GET /all/cities |
GetAll |
All rows, no filter/order/limit (cache-friendly) |
| Param | Default | Notes |
|---|---|---|
page |
1 |
|
page_size |
10 |
Capped at 100 |
order_by |
{table}.id |
Only col or table.col |
direction |
desc |
Only asc / desc |
select |
* |
Validated against repo columns |
filter |
empty | DSL below |
filter=name[eq]"Erbil"[and]id[gt]"1"
filter=name[like]"%erb%"[or]id[eq]"2"
| Token | Meaning |
|---|---|
[eq] [ne] [gt] [lt] [gte] [lte] |
Comparisons |
[like] |
LIKE |
[and] [or] |
Logic |
[date] [date_gte] [date_lte] |
Date helpers |
Filters are parsed into WHERE ... ? with bound args. Invalid columns/operators/values → validation error (not silent SQL).
Call in services:
err = validator.ValidateModel(model, base_term.City, validator.Create)
err = validator.ValidateModel(model, base_term.City, validator.Update)| Tag | Meaning |
|---|---|
required |
Must be present / non-zero |
min / max |
Length |
gte / lte |
Numeric compare |
email |
Email format |
phone |
964 + 10 digits |
username |
[a-zA-Z0-9._-]+ |
password |
≥10, lower, upper, digit, special |
one_of=account_status |
Enum from MustBeInTypes |
contain=x |
Substring must exist |
birthday |
Age window |
pin |
Digits only |
if_exist |
Validate only if field is set |
create:required|update:min=7,max=10 |
Per-action rules |
Register lists in internal/core/action (MustBeInTypes), then:
Status pkg_types.Enum `bind:"one_of=account_status"`Password string `json:"password,omitempty" bind:"if_exist,password"`
Phone string `json:"phone,omitempty" bind:"create:required|update:min=7,max=10"`
Region string `gorm:"->;migration:-" json:"region,omitempty" table:"base_regions.name as region"`table:"..."helps joined select aliases- Omitempty passwords on responses; clear hash fields in
GetAll/Listwhen needed
err = pkg_err.New(pkg_err.SomethingWentWrong, "E1171379").
Message(pkg_err.SomethingWentWrong).
Custom(pkg_err.InternalServerErr).
Build()
err = pkg_err.Take(dbErr, "E1174655").
Custom(pkg_err.ValidationFailedErr).
Build()Always pass a unique "E" + 7 digits code. On boot, utils.GenerateErrCode() scans .go files and bumps duplicates (E1146731 → E1146732 …). No err_codes.txt needed.
In repos, always:
err = db_error.Parse(err, base_term.Cities, validator.Update)Maps duplicates / not found / etc. into API-friendly errors.
ValidationFailedErr, UnauthorizedErr, ForbiddenErr, NotFoundErr, InternalServerErr, BadRequestErr, …
resp, params := response.NewParam(c, base_model.CityTable)
resp.Record(base.CreateCity) // async activity (when enabled)
resp.Status(http.StatusOK).
Message(pkg_terms.VCreatedSuccessfully, base_term.City).
JSON(createdCity)
resp.Error(err).Abort().JSON() // middleware-style abortActivities: admin GET /activities (permission-gated).
Messages go through dictionary translation based on request language.
key := fmt.Sprintf("%v-%v", base_term.City, id)
if ok := s.Engine.RedisCacheAPI.GetCache(tx, key, &city); ok {
return
}
city, err = s.Repo.FindByID(tx, id)
_ = s.Engine.RedisCacheAPI.Set(key, city)
// on write
s.Engine.RedisCacheAPI.Delete(key)
s.Engine.RedisCacheAPI.Delete(base_term.Cities) // GetAll list keyAdmin:
PUT /clear-cache/:keyPUT /clear-cache/user/:userID
Pattern delete uses SCAN (not blocking KEYS). FlushDB is for seed/dev only.
// no TX
service.Create(tx.Tx{}, model)
// with TX
params.Tx.DB = engine.DB.Begin()
service.Create(params.Tx, model)
params.Tx.DB.Commit() // or Rollback on errorRepos use tx.GetDB(r.Engine.DB) so the same code path works with or without an outer transaction.
| Package | What backend devs use it for |
|---|---|
pkg_config |
Global config after pkg.Init |
pkg_sql |
MySQL connect, filter parser, FK SQL, column extract |
pkg_redis |
Cache, TTL, pattern reset, refresh-token storage |
pkg_jwt |
RS256 issue / parse / refresh |
pkg_password |
Hash / verify |
param |
Query → Param; ParseWhere / ParseWhereDelete; preconditions |
validator |
ValidateModel + bind tags |
middleware |
JWT, Basic auth, API logger, login rate limit, domain header |
rate_limiter |
Progressive lockout store |
response |
JSON envelope, bind helper, activity Record |
pkg_err |
Typed API errors + codes |
db_error |
GORM/MySQL → API errors |
pkg_log |
Fatal / CheckError; redacted HTTP dumps |
pkg_http |
Outbound HTTP (JSON / multipart) |
tx |
Transaction wrapper |
excel |
Fluent XLSX export |
decimal |
Money-safe arithmetic |
dictionary |
TOML i18n |
activity |
Audit list/count |
setting |
DB settings + reload ticker |
utils |
crypto/rand strings, AES-GCM, email, GenerateErrCode |
pkg_types |
Enum, Resource, env helpers |
pkg_terms / *_term |
Message keys |
Providers live in cmd/wire/wire.go (build tag wireinject). Generated code: wire_gen.go.
func InitBaseCityAPI(e *core.Engine) base_api.CityAPI {
wire.Build(
base_repo.ProvideCityRepo,
service.ProvideBaseCityService,
base_api.ProvideCityAPI,
)
return base_api.CityAPI{}
}After changing providers:
cd cmd/wire && wireRouters call wire.Init...API(engine) — don’t construct repos/services manually in handlers.
Terms: assets/terms/terms.toml
dictionary.Translate(dictionary.Ku, city.Name)Language from request headers / context (X-LANGUAGE pattern used by HTTP client too).
ex := excel.New("region").
AddSheet("Regions").
Active("Regions").
WriteHeader("ID", "Name", "Created At").
SetSheetFields("ID", "Name", "CreatedAt").
WriteData(regions).
AddTable()
buf, name, err := ex.Generate()
c.Data(http.StatusOK, "application/octet-stream", buf.Bytes())engine.EmailConfig = utils.ConfigEmail{ Host, Port, Username, Password }
go engine.EmailConfig.SendEmail(to, cc, fromAlias, subject, body, attachmentPath)Prefer pkg/decimal over float64 for fees, balances, and tax.
Stars help others discover this boilerplate. Issues/PRs welcome.
Topics: go golang gin gorm redis mysql jwt rest-api boilerplate wire
MIT — see LICENCE.
Author: Barez Azad
