github.com/getevo/evo/v2 | gorm.io/gorm | github.com/getevo/restify
| Alias | Import |
|---|---|
evo |
github.com/getevo/evo/v2 |
db |
github.com/getevo/evo/v2/lib/db |
types |
github.com/getevo/evo/v2/lib/db/types |
errors |
github.com/getevo/evo/v2/lib/errors |
log |
github.com/getevo/evo/v2/lib/log |
settings |
github.com/getevo/evo/v2/lib/settings |
outcome |
github.com/getevo/evo/v2/lib/outcome |
restify |
github.com/getevo/restify |
pubsub |
github.com/getevo/evo/v2/lib/pubsub |
application |
github.com/getevo/evo/v2/lib/application |
- Follow existing patterns | No refactor unless requested | Consistency over cleverness | Ask before new abstractions
- Living Docs: After each instruction, update
CLAUDE.md,docs/, plan files to reflect decisions/patterns. - Changelog: After git push of feature/fix → append to
./changelog/changelog-{mon}-{year}.md:- YYYY-MM-DD | feat|fix|refactor|chore | description
- Small focused changes | No global state | ~300 lines/file ~100 lines/function | Parameterized queries | Validate input | Never log secrets/PII
Read patterns → Plan minimal changes → Implement backward-compatible
main.go
apps/app_name/{app,models,controller,functions,serializer}.go
pkg/roles/roles.go # permission constants (roles.Admin, roles.Manage, …)
pkg/utils/ # shared helpers
docs/ # documentation
changelog/changelog-{mon}-{year}.md
config.yml → https://raw.githubusercontent.com/getevo/claude/refs/heads/main/config.example.yml
Settings | Log | FileLogger
settings.Get("KEY").String() // .Int() .Bool() .Duration() .Default(val)
settings.Track("APP.*", func() { /* fires immediately + on every config change */ })
log.Info/Debug/Warning/Error/Fatal("msg")
log.WithField("k", v).Info("msg") | log.WithFields(map[string]any{"k": v}).Error("msg")./backend [-c config.yml] [--migration-do]Lifecycle:
Register()all → HTTP starts →WhenReady()all →OnShutdownon SIGTERM
type App struct{}
func (App) Name() string { return "myapp" } // required
func (App) Register() error { // optional
db.UseModel(M1{}, M2{}, M3{}) // variadic
pubsub.SetDefaultDriver(natspkg.Driver) // if using NATS as default pub/sub
settings.Track("APP.*", func() { initClient() })
return nil
}
func (App) Router() error { // optional
v1 := evo.Group("/api/v1") // .Use() for group-scoped middleware
v1.Get("/x/:id", ctrl.Get)
evo.Use(globalMW) // global; evo.Use("/path", mw) for path-scoped
evo.Static("/assets", "./public") // SPA: evo.Static("/", "./public/index.html")
return nil
}
func (App) WhenReady() error { // optional
go backgroundJob()
evo.OnShutdown(func() { cleanup() })
evo.OnHealthCheck(func() error { return redis.Driver.Ping() }) // liveness
evo.OnReadyCheck(func() error { return db.Ping() }) // readiness / K8s probe
return nil
}main.go — Setup/Run return error since Feb 2026 (CRITICAL_FIXES):
func main() {
if err := evo.Setup(mysql.Driver{}); err != nil { log.Fatal(err) }
application.GetInstance().Register(MyApp{}, OtherApp{})
if err := evo.Run(); err != nil { log.Fatal(err) }
}
// Raw Fiber: evo.GetFiber() → *fiber.App (use sparingly for middleware/websockets not wrapped by evo)Database | Migration
Rules: TableNameID int64 PK | snake_case | fk:table | size:N | pointer=nullable | embed restify.API
type Article struct {
ArticleID int64 `gorm:"column:article_id;primaryKey;autoIncrement" json:"article_id"`
Title string `gorm:"column:title;size:255" json:"title" validation:"required"`
Status string `gorm:"column:status;type:enum('draft','published');default:draft" json:"status"`
AuthorID int64 `gorm:"column:author_id;fk:author" validation:"fk" json:"author_id"`
Author *Author `gorm:"foreignKey:AuthorID;references:AuthorID" json:"author,omitempty"`
PublishedAt *time.Time `gorm:"column:published_at" json:"published_at"`
types.CreatedAt
types.UpdatedAt
types.SoftDelete // optional; embed when soft delete needed
restify.API
}
func (Article) TableName() string { return "article" }
// Shared fields → extract Base struct (types.CreatedAt/UpdatedAt/SoftDelete + restify.API) and embedGORM tags: primaryKey;autoIncrement | size:255 | type:enum('a','b');default:a | fk:table+foreignKey:XID;references:XID | type:decimal(10,2)
Validation tags: required email fk unique slug password url phone uuid
| Type | Column | Notes |
|---|---|---|
types.CreatedAt / types.UpdatedAt |
DATETIME | auto-set; embed in every model |
types.SoftDelete |
deleted+deleted_at | .Delete() .Restore() .IsDeleted(); GORM auto-filters deleted=0 |
types.Archive |
archive TINYINT | .SetArchive(bool) |
types.UUID |
CHAR(36) | NewUUID() NewUUIDv4() NewUUIDv1() |
types.Time |
TIME | NewTime(h,m,s,ns) |
types.StringArray / IntArray / Int64Array |
JSON | typed JSON arrays |
types.JSONType[T] / JSONSlice[T] / JSONMap |
JSON | struct/slice/map as JSON column |
types.URL |
VARCHAR | URL with scan/value |
Endpoints: PUT /model create | PUT /model/batch | GET /model/all | GET /model/paginate | GET /model/:id | PATCH /model/:id | PATCH /model/batch | POST /model/set upsert | DELETE /model/:id | DELETE /model/batch | GET /model/aggregate
Query: field[eq|neq|gt|lt|gte|lte|in|between|contains|search|isnull|notnull]=val | order=f.asc | fields=f1,f2 | associations=Orders.Product or * | offset/limit | page/size | group_by=f | ?debug=restify
Hooks — all receive ctx.DB *gorm.DB (current tx) and ctx.Request.User():
func (a *Article) OnBeforeCreate(ctx *restify.Context) error {
a.UserID = int64(ctx.Request.User().GetID()); return nil
}
// Hooks: OnBeforeCreate OnAfterCreate OnBeforeUpdate OnAfterUpdate OnBeforeSave OnAfterSave
// OnBeforeDelete OnAfterDelete OnAfterGet
// Validate: ctx.AddValidationErrors(err); if len(ctx.ValidationErrors)>0 { return err }
func (a *Article) ValidateCreate(ctx *restify.Context) error { return nil } // also ValidateUpdate
// Global: restify.OnBeforeSave(func(obj any, ctx *restify.Context) error { return nil })Permissions & row-level security:
func (a *Article) RestPermission(p restify.Permissions, ctx *restify.Context) bool {
user := ctx.Request.User()
if user.Anonymous() { ctx.Error(fmt.Errorf("unauthorized"), 401); return false }
ctx.SetCondition("user_id", "=", user.GetID()) // row-level filter on all queries
ctx.Override(Article{UserID: user.GetID()}) // force field on writes
return !p.Has("DELETE") || user.HasPermission(roles.Admin)
}
// p.Has: VIEW CREATE UPDATE DELETE BATCH SET AGGREGATE
// Global default: restify.SetDefaultPermissionHandler(func(p Permissions, ctx *Context) bool { ... })
// Disable: embed restify.DisableCreate/Update/Delete/Set/List/Aggregate
// Soft delete: types.SoftDelete — restify calls .Delete() instead of hard DELETEpkg/roles/roles.go: const ( Admin = "admin"; Manage = "manage"; Editor = "editor" )
evo.Use(globalMW) | evo.Use("/admin", authMW) // global or path-prefix scoped
func (c Ctrl) AuthMW(r *evo.Request) error {
if r.User().Anonymous() { r.Status(401); return fmt.Errorf("unauthorized") }
return r.Next()
}UserInterface — implement and register with evo.SetUserInterface(&User{}):
type User struct { UserID uint64; UUID, Email string; Roles []string }
func (u *User) FromRequest(r *evo.Request) evo.UserInterface {
h := r.Header("Authorization")
if strings.HasPrefix(h, "APIKey ") {
// lookup APIKey{Key, UserID, Permissions, Active, ExpiresAt} from DB; load roles from Permissions JSON
}
if strings.HasPrefix(h, "Bearer ") {
// validate JWT; load user by claims.Sub; set Roles from claims
}
return nil // anonymous
}
func (u *User) Anonymous() bool { return u.UUID == "" }
func (u *User) HasPermission(p string) bool { for _, r := range u.Roles { if r == p { return true } }; return false }
// Also required: GetID() GetEmail() GetFirstName() GetLastName() GetFullName() UUID() Interface() Attributes()APIKey model fields: Key string (size:64,unique,index), UserID int64 fk:user, Permissions string (JSON roles array), Active bool default:1, ExpiresAt *time.Time, types.CreatedAt
Header: Authorization: APIKey sk_live_xxx | Generate: base64.URLEncoding.EncodeToString(32 random bytes)
r.Param("id").Int64() | r.Query("page").Int() | r.BodyParser(&input) | r.User() | r.Context()
r.IP() | r.Header("X-ID") | r.Method() | r.URL().Path
r.Cookie("s") | r.SetCookie("s", token, 24*time.Hour)
// errors: BadRequest(400) Unauthorized(401) Forbidden(403) NotFound(404) Conflict(409) Internal(500) + all 4xx
// Custom: errors.New("msg", 400) | errors.New(err, 422) | errors.New("msg").Code(503)
func (c Ctrl) Create(r *evo.Request) any {
var in Input
if err := r.BodyParser(&in); err != nil { return errors.BadRequest }
if err := validation.Struct(in); err != nil { return errors.New(err.Error(), 422) }
res, err := create(r.Context(), in)
if err != nil { return errors.Internal }
return outcome.Created(res)
}outcome (docs) — github.com/getevo/evo/v2/lib/outcome:
outcome.OK(data).Header("k","v").SetCacheControl(5*time.Minute)
outcome.Redirect("/login") | outcome.Redirect("/new", 301)
outcome.File("/f.pdf").Filename("report.pdf") | outcome.Json(bytes) | outcome.Text("str")
outcome.Response{ContentType:"application/json", StatusCode:200, Data:data}// Always pass context.Context to all I/O; use r.Context() in handlers
func GetByID(ctx context.Context, id int64) (*Article, error) {
var a Article; return &a, db.WithContext(ctx).Where("id=?", id).First(&a).Error
}
db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return tx.Create(&a).Error })
notifyService(ctx, a) // network AFTER transaction; never inside
db.GetInstance() // *gorm.DB — raw access when needed by external packages- Multi-instance: goroutine in
WhenReady() - Single-instance: API endpoint
/api/v1/app/tasks/x+ scheduler; document indocs/tasks.md - Idempotency required:
db.Clauses(clause.OnConflict{Columns:[]clause.Column{{Name:"id"}},UpdateAll:true}).Create(&x)
args lib/args — docs: args.Exists("--debug") | args.Get("--port").Int()
curl lib/curl — docs:
resp, err := curl.Get(url, 30*time.Second) | curl.Post(url, curl.BodyJSON(p)) | curl.Post(url, curl.Param{"k":"v"})
resp.Dot("data.name").String() | resp.ToJSON(&r) | resp.Status()
if resp.Status() >= 400 { return errors.New(resp.String(), resp.Status()) }validation lib/validation — docs:
validation.Struct(v) | validation.StructNonZeroFields(v) (for PATCH) | tags: required email fk unique url phone uuid >=N
dot lib/dot — docs: dot.Get(data, "user.items[0].name") | dot.Set(&data, "path", val)
gpath lib/gpath: gpath.WorkingDir() | gpath.Write/Read/MakePath/IsFileExist/ReadJSON/WriteJSON
generic lib/generic: generic.Parse(v).String()/.Int64()/.Bool()/.Time()/.IsNil() — safe any→type conversion
ptr lib/ptr: ptr.String("x") | ptr.Int64(42) | ptr.Bool(true) | ptr.Time(t) — literals to pointers for nullable fields
async lib/async:
async.ForEach(items, func(i *T) { ... }) // parallel, GOMAXPROCS goroutines
results, err := async.MapErr(items, func(i *T) (R, error) {}) // parallel map with errors
p := async.NewPool().WithErrors().WithMaxGoroutines(10); p.Exec(fn); p.Wait()
async.All(ctx, fn1, fn2) // concurrent, collect all — Executable=func(ctx)(any,error)
async.Retry(ctx, 3, fn) // retry on error
async.Waterfall(ctx, s1, s2) // sequential pipeline — each fn receives previous resulttry lib/try: try.This(fn).Catch(func(r *panics.Recovered){}).Finally(cleanup)
tpl lib/tpl: tpl.Render("Hello $name at $obj.field", map[string]any{"name":"World"})
storage lib/storage: storage.NewStorageInstance("local", "fs:///path") | storage.GetStorage("local").Write(...)
reflections lib/reflections — docs: get/set/inspect struct fields by name or tag at runtime
pagination github.com/getevo/pagination — returns HTTPSerializer, return p directly from handler:
p, err := pagination.New(db.Model(&Order{}), request, &items, pagination.Options{MaxSize:50, SortFields:[]string{"created_at"}, BaseURL:"..."})
// also: NewWithContext(ctx,...) | NewFromParams(db, page, size, &items, opts)
// response: records pages current_page has_next has_prev | sort: ?sort=f1,f2&order=asc,descNATS lib/connectors/nats — docs:
// Setup: pubsub.SetDefaultDriver(natspkg.Driver) in Register(); config: NATS.SERVER NATS.DEFAULT_BUCKET
pubsub.Subscribe("topic", func(topic string, msg []byte, driver pubsub.Interface) { driver.Unmarshal(msg, &e) })
pubsub.Publish("topic", payload)
natspkg.Driver.Publish("topic", p, natspkg.WithJetStream) // at-least-once with server ack
natspkg.Driver.Subscribe("t", handler, natspkg.Queue("workers")) // queue group
natspkg.Driver.Set/Get/Delete(key, ..., natspkg.Bucket("name")) // JetStream KV (bucket-level TTL only)
// Raw: natspkg.Connection (*nats.Conn) | natspkg.JS (JetStreamContext)Redis lib/connectors/redis — docs:
// Setup: evo.Setup(redis.Driver); config: CACHE.REDIS_ADDRESS (comma-sep=cluster) CACHE.REDIS_PREFIX
redis.Driver.Set(key, val, ttl) | redis.Driver.Get(key, &dest) | redis.Driver.Exists(key)
redis.Driver.SetNX(key, val, ttl) // atomic set-if-not-exists — distributed locking
redis.Driver.Increment/Decrement(key, n) | redis.Driver.Keys(pattern) | redis.Driver.Delete(key)
redis.Driver.Publish/Subscribe("topic", handler) // same handler signature as NATS
// Raw: redisconn.Client (redis.UniversalClient) — Pipeline, Hash, SortedSet, etc.- Breaking changes → new version; document in
docs/api-deprecations.md X-Debug: trueheader →X-Execution-TimeX-Cache-HitX-DB-Queries(never secrets)
Fetch and read the linked doc before working in that area.
| Doc | When / what's inside |
|---|---|
| ai_guideline | Start here when unsure which package to use — full package map, import paths, all libs |
| configuration | All YAML keys, env override, priority order (CLI>env>YAML>DB), settings.Bind |
| database | Connection pool (MaxOpenConns etc.), debug levels 1–4, driver interface |
| webserver | Full routing API, wildcards, SetRawCookie, middleware chain |
| health-checks | OnHealthCheck/OnReadyCheck, db.Ping/WaitForDB, K8s probe integration |
| log | All levels, SetLevel, AddWriter(fn), log.Entry struct |
| file_logger | File log with midnight rotation; Config{Path,FileName,Expiration}; %y %m %d wildcards |
| migration | Versioned migrations with rollback, Migration(version) hook, nullable via pointers |
| mysql | mysql.Driver{} config, MariaDB auto-detect, TiDB, recommended Params |
| pgsql | pgsql.Driver{}, Schema, SSLMode, multi-tenant setup |
| outcome | All response constructors and chainable methods |
| reflections | Runtime struct field access by name or tag |
| tpl | $var $obj.field $arr[0] template syntax, multi-source params |
| nats | Full NATS config, Queue groups, CreateBucket, raw Connection/JS |
| redis | Full Redis config, GetWithExpiration, UnsubscribeAll, cluster caveats |
| CRITICAL_FIXES | Setup/Run return error since Feb 2026 — check when writing main.go |
| MIGRATION_GUIDE | Steps to update from old void Setup/Run API |