Go Web Frame
Build a CRUD backend in Go with zero ORM boilerplate. Define a struct, embed a generic Model — typed queries, pagination, and context propagation come with it.
🌐 Language
English • 中文 • 繁體中文 • 日本語
Overview
Go Web Frame is an integrated backend toolkit. Routing, ORM, and caching are pre-integrated — no need to pick and wire them separately.
The core is a generic Model layer that eliminates CRUD boilerplate. Define an entity struct, embed Model[T], and the compiler checks types from database to handler. No interface{}, no code generation.
// Define once, use everywhere
type User struct {
Id uint `gorm:"primaryKey;autoIncrement"`
Name string
}
type UserModel struct {
*model.EntryModel[*User, uint] // ← all CRUD comes from this
}
func (m *UserModel) Init(db *db.DB, c *core.Context) error {
m.EntryModel = model.NewEntryModel[*User, uint](db, "t_user")
return m.CreateTable()
}
// That's your model. Now use it anywhere:
user, _ := userModel.FindByPK(1) // → *User
users, _ := userModel.FindAll() // → []*User
users, _ := userModel.Query().Where("age > ?", 18).Order("id desc").All()
total, _ := userModel.Query().Where("status = ?", 1).Count()
users, total, _ := userModel.Page(&web.Page{PageNo: 1, PageSize: 10})
userModel.Update().Where("id = ?", 1).UpdateColumn("name", "new name")
userModel.DeleteByPK(1)
Also included: routing (Gin), auth filters, WebSocket, SSE, CORS, rate limiting, cron, validation, caching, HTTPS (Let's Encrypt auto-cert or local certificates), multi-DB (MySQL / PostgreSQL / SQLite / Redis) — all wired up and configured from one YAML file.
Quick Start
30 seconds: Hello World
go get github.com/chuccp/go-web-frame
package main
import (
"context"
wf "github.com/chuccp/go-web-frame"
"github.com/chuccp/go-web-frame/config"
"github.com/chuccp/go-web-frame/web"
)
func main() {
builder := wf.NewBuilder(config.LoadAutoConfig())
builder.Get("/", func(c *web.Request) (any, error) {
return "Hello, World!", nil
})
builder.Build().Run(context.Background())
}
go run main.go
# → http://localhost:19009
5 minutes: REST + Database
Create application.yml:
web:
db:
type: sqlite
path: ./data.db
Create main.go:
package main
import (
"context"
wf "github.com/chuccp/go-web-frame"
"github.com/chuccp/go-web-frame/config"
"github.com/chuccp/go-web-frame/core"
"github.com/chuccp/go-web-frame/db"
"github.com/chuccp/go-web-frame/model"
"github.com/chuccp/go-web-frame/web"
)
// ── Entity ──
type User struct {
Id uint `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"size:255"`
}
// ── Model (zero CRUD boilerplate) ──
type UserModel struct {
*model.EntryModel[*User, uint]
}
func (m *UserModel) Init(database *db.DB, ctx *core.Context) error {
m.EntryModel = model.NewEntryModel[*User, uint](database, "t_user")
return m.CreateTable()
}
// ── Controller ──
type UserController struct {
core.IService
userModel *UserModel
}
func (c *UserController) Init(ctx *core.Context) error {
c.userModel = wf.GetModel[*UserModel](ctx)
ctx.Get("/users", c.List)
ctx.Get("/users/:id", c.Get)
ctx.Post("/users", c.Create)
ctx.Put("/users/:id", c.Update)
ctx.Delete("/users/:id", c.Delete)
return nil
}
func (c *UserController) List(req *web.Request) (any, error) {
return c.userModel.FindAll()
}
func (c *UserController) Get(req *web.Request) (any, error) {
return c.userModel.FindByPK(req.ParamUint("id"))
}
func (c *UserController) Create(req *web.Request) (any, error) {
var user User
if err := req.BindJSON(&user); err != nil {
return nil, err
}
return &user, c.userModel.Save(&user)
}
func (c *UserController) Update(req *web.Request) (any, error) {
var user User
if err := req.BindJSON(&user); err != nil {
return nil, err
}
user.Id = req.ParamUint("id")
return nil, c.userModel.UpdateByPK(&user)
}
func (c *UserController) Delete(req *web.Request) (any, error) {
return nil, c.userModel.DeleteByPK(req.ParamUint("id"))
}
// ── Main ──
func main() {
builder := wf.NewBuilder(config.LoadAutoConfig())
builder.Model(&UserModel{})
builder.Rest(&UserController{})
builder.Build().Run(context.Background())
}
curl http://localhost:19009/users # → [{"Id":1,"Name":"alice"}]
curl http://localhost:19009/users/1 # → {"Id":1,"Name":"alice"}
curl -X POST ... -d '{"Name":"bob"}' # → {"Id":2,"Name":"bob"}
The table is auto-created. All CRUD works. No SQL written, no ORM wiring code needed.
Working with Data
Model Tier
| Type |
What you get |
Use when |
Model[T] |
Save, Query(), Update(), Delete(), CreateTable(), WithContext() |
Full control with fluent builders |
EntryModel[T, PK] |
Everything in Model + FindByPK, FindAll, DeleteByPK, UpdateByPK, UpdateColumn, Page |
Entity has a primary key (most common) |
PK can be uint, int, string, or any type satisfying ~uint | ~int | ~string.
Common Queries
m := userModel.WithContext(req.Ctx()) // context auto-propagates to all DB calls
// Fetch
user, err := m.FindByPK(1) // by primary key
users, err := m.FindAll() // all records
user, err := m.Query().Where("email = ?", email).One()
users, err := m.Query().Where("status = ?", 1).Order("id desc").List(100)
// Pagination
page := &web.Page{PageNo: 1, PageSize: 10}
users, total, err := m.Query().Where("age > ?", 18).Page(page)
pageAble, err := m.Query().Where("age > ?", 18).PageForWeb(page) // returns PageAble[*User]
// Counting
count, err := m.Query().Where("status = ?", 1).Count()
// Associations (GORM Preload)
users, err := m.Query().Preload("Orders").Preload("Profile").All()
user, err := m.Query().Where("id = ?", 1).Preload("Orders").One()
// Joins
users, err := m.Query().Joins("JOIN orders ON orders.user_id = t_user.id").All()
Common Writes
// Insert
err := m.Save(&User{Name: "alice"})
// Update by primary key
user.Name = "new name"
err := m.UpdateByPK(user)
// Update single column
err := m.UpdateColumn(1, "status", 0)
// Conditional update
err := m.Update().Where("status = ?", 0).UpdateForMap(map[string]any{"status": 1})
// Delete
err := m.DeleteByPK(1)
err := m.Delete().Where("status = ?", -1).Delete()
Compared to Raw GORM
// Go Web Frame
m := userModel.WithContext(req.Ctx())
user, _ := m.FindByPK(1)
users, total, _ := m.Query().Where("age > ?", 18).Page(&web.Page{PageNo: 1, PageSize: 10})
// Raw GORM — use GetGorm() to access *gorm.DB directly
var user User
db.GetGorm().WithContext(ctx).Table("t_user").Where("id = ?", 1).First(&user)
var users []User
db.GetGorm().WithContext(ctx).Table("t_user").Where("age > ?", 18).Offset(0).Limit(10).Find(&users)
var total int64
db.GetGorm().WithContext(ctx).Table("t_user").Where("age > ?", 18).Count(&total)
EntryModel is a convenience wrapper — it reduces boilerplate, not restricts you. When built-in methods aren't enough (complex JOINs, subqueries, window functions), call db.GetGorm() and use the full GORM ecosystem directly. Mix both styles freely.
Every call in raw GORM repeats the table name, the context, and the type. Go Web Frame binds those once at construction. The difference adds up fast when you have 20+ models.
Organizing a Real Project
The Builder: One Place to Register Everything
builder := wf.NewBuilder(config.LoadAutoConfig())
// Infrastructure (runs first)
builder.Filter(&cors.Filter{}) // CORS headers
builder.Filter(&AuthFilter{}) // auth check
// Data layer
builder.Model(&UserModel{})
builder.Model(&OrderModel{})
builder.Model(&ProductModel{})
// Business logic
builder.Service(&UserService{}) // shared business logic
builder.Service(&PaymentService{})
// HTTP layer
builder.Rest(&UserController{})
builder.Rest(&OrderController{})
// Background work
builder.Runner(&CleanupTask{})
// Done
app := builder.Build()
app.Run(ctx)
Registration order matters: filters initialize first, then models, then services, then controllers, then runners. Dependencies resolve automatically through Init().
Getting Dependencies at Runtime
// In any Init() or handler, get what you need by type:
userModel := wf.GetModel[*UserModel](ctx)
userService := wf.GetService[*UserService](ctx)
authFilter := wf.GetFilter[*AuthFilter](ctx)
cleanupTask := wf.GetRunner[*CleanupTask](ctx)
No string keys, no type assertions. The generic function handles it.
Service Layer: Shared Business Logic
When multiple controllers need the same logic, extract a service:
type UserService struct {
core.IService
userModel *UserModel
}
func (s *UserService) Init(ctx *core.Context) error {
s.userModel = wf.GetModel[*UserModel](ctx)
return nil
}
func (s *UserService) GetActiveUsers() ([]*User, error) {
return s.userModel.Query().Where("status = ?", 1).All()
}
// Register:
builder.Service(&UserService{})
// Use in controller:
userService := wf.GetService[*UserService](ctx)
users, _ := userService.GetActiveUsers()
Model Groups: Multiple Databases
// Default database
builder.Model(&UserModel{}, &LogModel{})
// Separate database for analytics
analyticsGroup := wf.NewModelGroupBuilder().
Name("analytics").
DB(analyticsDB).
Model(&ReportModel{}).
AutoCreateTable(true).
Build()
builder.ModelGroup(analyticsGroup)
Auth and Middleware
Tag routes declaratively — the filter checks once, not in every handler:
func (c *ApiController) Init(ctx *core.Context) error {
// Public
ctx.Get("/api/login", login).WithMeta(SkipAuth())
// Requires login
ctx.Get("/api/profile", profile).WithMeta(RequireAuth())
// Requires login + specific permission
ctx.Post("/api/admin/users", createUser).
WithMeta(RequireAuth(), RequirePermission("admin:create_user"))
return nil
}
// Define metadata factories
func RequireAuth() web.MetaOption { return web.WithValue("require_auth", true) }
func SkipAuth() web.MetaOption { return web.WithValue("skip_auth", true) }
func RequirePermission(p string) web.MetaOption { return web.WithValue("require_permission", p) }
// One filter handles all auth logic
func (f *AuthFilter) Handle(fc web.FilterChain, req *web.Request) (any, error) {
if !req.HasMeta(RequireAuth()) || req.HasMeta(SkipAuth()) {
return fc.Next()
}
token := req.Request().Header.Get("Authorization")
if token == "" {
return nil, errors.New("unauthorized")
}
// Verify token, check permission from meta...
return fc.Next()
}
Global Filters
Filters registered via builder.Filter() run on every request:
// Logging
type LoggingFilter struct { core.IFilter }
func (f *LoggingFilter) Handle(fc web.FilterChain, req *web.Request) (any, error) {
start := time.Now()
result, err := fc.Next()
log.Info("request", zap.String("path", req.FullPath()), zap.Duration("elapsed", time.Since(start)))
return result, err
}
// CORS (built-in, just register it)
builder.Filter(&cors.Filter{})
RestGroup: Filters Scoped to a Route Group
apiGroup := core.NewRestGroupBuilder().
ServerConfig(web.DefaultServerConfig()).
ContextPath("/api/v1").
Build()
apiGroup.AddFilter(&AuthFilter{}) // only affects routes in this group
apiGroup.AddRest(&UserController{}) // all routes in this controller need auth
builder.RestGroup(apiGroup)
Common Recipes
File Upload
ctx.Post("/upload", func(req *web.Request) (any, error) {
file, header, err := req.File("file") // form field name
if err != nil {
return nil, err
}
defer file.Close()
// Save to disk
dst := "./uploads/" + header.Filename
if err := web.SaveUploadedFile(header, dst); err != nil {
return nil, err
}
return map[string]string{"path": dst}, nil
})
WebSocket
ctx.WebSocket("/ws", func(stream *web.WebSocketStream) error {
defer stream.Close()
for {
typ, msg, err := stream.Read(stream.Context())
if err != nil {
return err
}
stream.Write(stream.Context(), typ, msg) // echo
}
})
Server-Sent Events
ctx.SSE("/events", func(stream *web.SSEStream) error {
defer stream.Close()
stream.SendRetry(3000) // reconnect after 3s
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
stream.Send("update", fmt.Sprintf("time: %s", time.Now()))
}
return nil
})
Static Files & SPA
ctx.Static("/assets", "./public")
ctx.Static("/", "./frontend/dist") // SPA build output
# Or via config — serves multiple directories, auto 404 fallback:
web:
server:
locations:
- view/dist
- www
page404: 404.html
Reverse Proxy
ctx.ReverseProxy("/api/legacy", "http://127.0.0.1:8081")
Background Tasks
type CleanupTask struct { core.IRunner }
func (t *CleanupTask) Init(ctx *core.Context) error { return nil }
func (t *CleanupTask) Run() error {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
// clean up expired sessions...
}
return nil // returned error kills the runner
}
builder.Runner(&CleanupTask{})
Validation
type CreateUserInput struct {
Name string `validate:"required,min=2,max=50"`
Email string `validate:"required,email"`
}
func (c *Controller) Create(req *web.Request) (any, error) {
var input CreateUserInput
if err := req.BindJSON(&input); err != nil {
return nil, err
}
validator := wf.GetService[*validator.Validator](req.Ctx())
if err := validator.Validate(input); err != nil {
return nil, web.NewValidationError().WithError(err)
}
// input is valid, proceed...
}
Custom HTTP Response
// Return a plain struct → auto-wrapped: {"code":200, "data":{...}, "msg":"ok"}
return &User{Id: 1, Name: "alice"}, nil
// Return a string → plain text response
return "ok", nil
// Return a specific status code
return web.DataCode(http.StatusCreated, &user), nil
// Return an error
return nil, errors.New("something went wrong")
// Return a business error code
return nil, web.NewValidationError().WithDetail("name is required")
// Redirect
return web.Redirect("/new-url"), nil
// File download
return &web.FileResponse{Path: "/path/to/report.pdf", FileName: "report.pdf"}, nil
Configuration
One YAML file covers everything. Auto-loaded from ./config/, ~/.<appname>/, /etc/<appname>/.
web:
server:
port: 8080
context_path: /api # all routes prefixed with /api
ssl: # HTTPS: auto-cert or local certificates
enabled: true
hosts: # Let's Encrypt auto-cert for these domains
- example.com
# certs: # Or use local certificate files (optional)
# - host: example.com
# cert-file: /path/to/fullchain.pem
# key-file: /path/to/privkey.pem
db:
type: mysql # mysql | postgres | sqlite
host: localhost
port: 3306
user: root
password: your_password
database: mydb
max_open_conns: 100
max_idle_conns: 10
conn_max_lifetime: 3600
log:
level: info # debug | info | warn | error
path: ./logs/app.log
max_size: 500 # MB per file before rotation
max_backups: 3
max_age: 30 # days
compress: true
redis:
addr: localhost:6379
password: ""
db: 0
PostgreSQL and SQLite variants:
# PostgreSQL
db:
type: postgres
host: localhost
port: 5432
user: postgres
password: ""
database: mydb
sslmode: disable
# SQLite
db:
type: sqlite
path: ./data.db
Format support: JSON, YAML, TOML. Call config.LoadAutoConfig() for zero-config auto-discovery, or pass a path explicitly.
Custom Database Driver
Register a custom database driver for databases not built-in (e.g., SQL Server, ClickHouse). The framework uses GORM as the underlying ORM, so custom databases must have a GORM driver.
// 1. Implement db.IConfig interface
type ClickHouseConfig struct {
Host string
Port int
Database string
User string
Password string
}
func (c *ClickHouseConfig) Connection() (*db.DB, error) {
dsn := fmt.Sprintf("clickhouse://%s:%s@%s:%d/%s", c.User, c.Password, c.Host, c.Port, c.Database)
gormDB, err := gorm.Open(clickhouse.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
return &db.DB{DB: gormDB}, nil
}
// 2. Register before app starts
func main() {
db.RegisterDB("clickhouse", &ClickHouseConfig{})
builder := wf.NewBuilder(config.LoadAutoConfig())
builder.Build().Start()
}
Then configure in YAML:
web:
db:
type: clickhouse
host: localhost
port: 9000
database: mydb
Tech Stack
Pre-integrated, production-proven components:
| Layer |
Library |
Role |
| HTTP |
Gin |
Router, middleware chain, parameter binding |
| ORM |
GORM |
Underlying SQL driver, migrations, join/preload |
| Config |
Viper |
Multi-format, multi-path auto-loading |
| Logging |
Zap |
Structured, leveled, with rotation |
| JSON |
Sonic |
High-performance marshal/unmarshal |
| Cache |
Otter |
Local in-memory cache |
| Concurrency |
Conc |
Structured concurrency pool for server lifecycle |
| Redis |
go-redis |
Pub/sub, caching |
| SQLite |
modernc/sqlite |
Pure Go, zero CGO |
| Validation |
go-playground/validator |
Struct tag validation |
| WebSocket |
coder/websocket |
Upgrade + read/write |
| Cron |
robfig/cron |
Expression-based scheduler |
Project Layout
├── web_frame.go # Builder, WebFrame factory
├── core/ # Interfaces (IService, IModel, IRest, IRunner, IFilter), DI context
├── web/ # Request, response, routing, filters, SSE, WebSocket, static files
├── model/ # Model[T], EntryModel[T, PK], Query[T], Update[T], Delete[T]
├── db/ # MySQL, PostgreSQL, SQLite connection management
├── redis/ # Redis client wrapper
├── config/ # Viper auto-loading
├── log/ # Zap + lumberjack rotation
├── component/ # cors, cache, rate-limit, captcha, qrcode, cron, validator
├── util/ # Crypto, file, string helpers
└── example/ # Runnable examples
├── helloworld/ # Minimal app
├── rest/ # REST controller
├── model/ # Generic ORM usage
├── filter/ # Auth + logging filters
├── withmeta/ # Route metadata
└── background/ # Background runners
Optional Modules
Heavy dependencies are split into independent sub-modules. Install only what you need:

# Core framework (no captcha/qrcode/cron/otter)
go get github.com/chuccp/go-web-frame
# Optional — install as needed
go get github.com/chuccp/go-web-frame/component/captcha@v1.0.7
go get github.com/chuccp/go-web-frame/component/schedule@v1.0.7
go get github.com/chuccp/go-web-frame/component/qrcode@v1.0.7
go get github.com/chuccp/go-web-frame/component/cache@v1.0.7
go get github.com/chuccp/go-web-frame/component/ratelimit@v1.0.7
Usage
import "github.com/chuccp/go-web-frame/component/cache"
// 1. Register
builder.Service(&cache.Cache{})
// 2. Use in any controller or service
c := core.GetService[*cache.Cache](ctx)
c.Set("user:1", user)
val, ok := c.Get("user:1")
c.SetNX("lock:task", true, 30*time.Second) // set if not exists, with TTL
Captcha — Slide Puzzle Captcha
# application.yml
captcha:
code_key: "your-32-character-key-here!!" # 32 chars, required
code_iv: "your-16-char-iv" # 16 chars, required
import "github.com/chuccp/go-web-frame/component/captcha"
// 1. Register
builder.Service(&captcha.Captcha{})
// 2. Generate captcha (return to frontend)
c := core.GetService[*captcha.Captcha](ctx)
data, _ := c.GetCaptchaData()
// → returns SlideCaptchaData with TileImage, MasterImage, ThumbCode
// 3. Validate user's slide answer
result, ok := c.ValidateThumb(data.ThumbCode, userXOffset)
if ok {
valid, _ := c.ValidateCode(result.CaptchaCode)
}
Schedule — Cron Job Scheduler
import (
"github.com/chuccp/go-web-frame/component/schedule"
"github.com/robfig/cron/v3"
)
// 1. Register as Runner (NOT Service)
sched := schedule.NewSchedule(cron.WithSeconds())
builder.Runner(sched)
// 2. Add jobs — before or after Build()
sched.AddKeyFunc("cleanup", "0 0 * * *", func(ctx *core.Context) {
// runs daily at midnight
})
sched.AddKeyFunc("report", "*/30 * * * * *", func(ctx *core.Context) {
// runs every 30 seconds
})
QRCode — QR Code Generator
import "github.com/chuccp/go-web-frame/component/qrcode"
// Generate to a file (stripe style)
qrcode.GenerateStripeQRCode("https://example.com", "qr.png")
// Generate in-memory with custom style
buf := qrcode.CreateBufferWriteCloser()
qrcode.GenerateQrcode("hello", buf, qrcode.WithCircleShape())
pngBytes := buf.Bytes()
// Custom colors
s := qrcode.NewStripeQRCode().WithModuleSize(10)
s.GenerateFile("https://example.com", "custom.png")
QRCode is a standalone utility — no registration needed. Just import and call directly.
RateLimit — Token-Bucket Rate Limiter
# application.yml (optional — defaults shown)
rate_limit:
limit: 10 # tokens per second
burst: 5 # max burst size
import "github.com/chuccp/go-web-frame/component/ratelimit"
// 1. Register
builder.Service(&ratelimit.RateLimit{})
// 2. Use — typically in a Filter
r := core.GetService[*ratelimit.RateLimit](ctx)
if r.Allow(req.ClientIP()) {
return fc.Next()
}
return nil, errors.New("rate limited")
Publishing Sub-Modules
Each sub-module is versioned independently using tag prefixes:
# 1. Publish the main module
git tag v1.0.1
git push origin v1.0.1
# 2. Update each sub-module's dependency on the main module
for mod in captcha schedule qrcode cache ratelimit; do
cd component/$mod
go get github.com/chuccp/go-web-frame@v1.0.1
go mod edit -dropreplace github.com/chuccp/go-web-frame
go mod tidy
cd ../..
done
# 3. Tag each sub-module (format: component/<name>/vX.Y.Z)
git tag component/captcha/v1.0.1
git tag component/schedule/v1.0.1
git tag component/qrcode/v1.0.1
git tag component/cache/v1.0.1
git tag component/ratelimit/v1.0.1
# 4. Push all tags
git push origin --tags
Go's module proxy resolves component/captcha/v1.0.1 to the component/captcha/ directory automatically.
Getting Help
Contributing
PRs welcome. Run go test ./... before submitting.
License
MIT — see LICENSE