Framingo is a modular, service-oriented Go framework for building production-ready HTTP API applications. It provides service lifecycle management, dependency resolution, a declarative HTTP router, database integration, pub/sub messaging, and health monitoring — all wired together by a supervisor that handles graceful startup, shutdown, and automatic restart.
-
Service-Oriented Architecture — Compose applications from small services with automatic dependency resolution via topological sort
-
Supervisor Lifecycle — Centralized orchestration with init, start, stop, and per-service runtime restart
-
Health Monitoring — Kubernetes-style liveness/readiness probes with automatic restart on liveness failure
-
HTTP API Server — Echo-based server with declarative YAML routing, a middleware pipeline with per-route config, built-in CORS, TLS, and WebSocket support
-
Database Integration — GORM-backed manager for PostgreSQL, MySQL, SQLite, and ClickHouse with connection pooling, migrations, and context-aware transactions
-
Pub/Sub Messaging — Hierarchical topic dispatch with pluggable Memory, Redis, and Kafka drivers, plus a higher-level message bus with WebSocket bridging
-
Task Planning — Concurrent task scheduler with priority, retry, and result tracking
-
OpenTelemetry Tracing — Supervised
TracerProviderlifecycle with pluggable OTLP/stdout exporters and a built-in server middleware that traces every request, propagates W3C trace context, and never touches the otel globals -
Instance-Based Configuration — Viper instance propagated through
context.Context(no global singletons), with hot-reload -
Structured Logging — Zap-based logger with file rotation and per-service scoping
-
Production Ready — Graceful shutdown and error categorization via
xhanio/errors, with the bundled example wiring OS signal handling (SIGINT/SIGTERM/SIGHUP/SIGUSR1/SIGUSR2) and pprof on top
- Quick Start
- Architecture
- Core Modules
- Building Your First Application
- Documentation
- Starter Template
- Key Concepts
- Configuration
- Production Deployment
- Best Practices
- Contributing
- License
go get github.com/xhanio/framingoThe fastest path to a working server is the template under example/. Run it first to confirm your toolchain, then fork it — see Starter Template.
The template builds with GoPro, which owns a layer framingo has nothing to do with: project.yaml, per-environment config generation, Docker images and Kubernetes manifests. Framingo itself has no build-tool dependency — the entry points under example/build/binary/ are ordinary main packages, so go build ./build/binary/exampleapp works. See What Is Framingo Here, and What Is GoPro for the split and for the two things GoPro does that you'd otherwise do yourself.
Prerequisites:
- Go 1.25.8+ — the
example/module's owngodirective; the framework module itself needs 1.25 (the OpenTelemetry SDK's floor). On an older toolchain the defaultGOTOOLCHAIN=autofetches it for you, butGOTOOLCHAIN=localwill hard-fail - A C toolchain, since the example blank-imports the SQLite driver and therefore builds with
CGO_ENABLED=1 - PostgreSQL on
localhost:5432with databaseframingo_example(userframingo, passwordframingo_dev) — that's what the local config points at. A compose file for it ships at example/env/local/docker-compose/
go install github.com/xhanio/gopro@latest
cd example
gopro build binary -e local
./bin/exampleapp daemon -c env/local/config/exampleapp/config.yamlIn another terminal:
# Log in first — the helloworld endpoint is protected by authnuser middleware
./bin/examplecli -e http://localhost:8080 login # default admin / admin
./bin/examplecli -e http://localhost:8080 helloworld "Hello"
# {"id":1,"message":"hello world!!! Hello","created_at":"...","updated_at":"..."}See example/QUICKSTART.md for the full walkthrough.
Framingo follows a layered architecture pattern:
graph TB
subgraph "Application Layer"
CLI[CLI Interface]
Config[Configuration]
end
subgraph "Service Orchestration"
Sup[Supervisor<br/>Lifecycle & Dependencies]
end
subgraph "Core Components"
Services[Services<br/>Business Logic]
API[API Layer<br/>HTTP Server]
Utils[Utilities<br/>Helper Functions]
end
CLI --> Sup
Config --> Sup
Sup --> Services
Sup --> API
Sup --> Utils
style CLI fill:#e1f5ff
style Config fill:#e1f5ff
style Sup fill:#fff4e1
style Services fill:#f0f0f0
style API fill:#f0f0f0
style Utils fill:#f0f0f0
sequenceDiagram
participant Client
participant APIServer as API Server
participant Middleware as Middleware Pipeline
participant Router as Router/Handler
participant Service as Service Layer
participant DB as Database/External Services
Client->>APIServer: HTTP Request
APIServer->>Middleware: Process Request
Note over Middleware: Recover<br/>CORS<br/>Logger<br/>Info<br/>Error<br/>Auth/Custom
Middleware->>Router: Validated Request
Router->>Service: Business Operation
Service->>DB: Data Access
DB-->>Service: Data
Service-->>Router: Result
Router-->>Middleware: Response
Middleware-->>APIServer: Formatted Response
APIServer-->>Client: HTTP Response
Framingo is organized into four module categories under pkg/:
Production-ready service implementations:
-
supervisor — Service lifecycle orchestration
- Topologically sorts registered services by
Dependencies() - Calls
Init(ctx)andStart(ctx)in dependency order,Stop()in reverse - Monitors
Liveness/Readinessprobes and auto-restarts services that fail liveness - Per-service runtime control (
InitService,StartService,StopService,RestartService) - Whole-graph
Restart(ctx),Stats()per service, and aDebuggable.Infodump - Restart behavior is tunable:
WithMonitorInterval,WithRestartPolicy(maxRetries),WithRestartDelay,WithShutdownTimeout
- Topologically sorts registered services by
-
api/server — HTTP API server
- Multi-server support:
Add(name, WithEndpoint(...), WithTLS(...), WithMiddlewares(...), WithMiddlewareConfigs(...)) - Declarative YAML routing via
api.Router - Middleware pipeline with name-based resolution
- WebSocket handlers (use method
WSin router YAML) - Built-in chain — recover, cors, logger, info, error — in a fixed order, with
WithMiddlewaresadditions between cors and logger; cors is a standardapi.Middlewareconfigured under its name in the server's middleware configs, the other four are plain lifecycle functions taking no config
- Multi-server support:
-
api/client — HTTP client with TLS, headers, cookies, body encoding (deflate), and structured error parsing —
NewRequestbuilds,Doexecutes an*http.Request,Senddoes both in one shot -
db — Database manager (GORM)
- Pluggable drivers under db/drivers/: PostgreSQL, MySQL, SQLite, ClickHouse — blank-import only the ones your binary needs (a SQLite-only binary drops ~17MB)
- The SQLite driver uses mattn/go-sqlite3, a cgo wrapper around the C library, so it needs
CGO_ENABLED=1and a C toolchain. The other drivers are pure Go. - Connection pooling (
WithConnection(maxOpen, maxIdle, maxLifetime, maxIdleTime, execTimeout)) - Migrations via
WithMigration(dir, version) - Context-aware queries:
FromContext(ctx)auto-extracts an active transaction Transaction(ctx, fn, opts...)wrapsfnin a TX with rollback-on-error, and nests on a transaction already inctxvia a savepoint instead of opening a second one
-
pubsub — Publish-subscribe primitive
- Hierarchical topic subscriptions, non-self-delivery
- Pluggable backends under pubsub/driver/: Memory, Redis, Kafka
Publish(ctx, from, topic, kind, payload)fans out to every subscriber whose topic is a prefix oftopicSubscribe(name, topic)returns a<-chan entity.PubsubMessage— a raw channel, not a handler registration;Unsubscribe(name, topic)closes it. Handler-style dispatch lives one layer up, inmessagebus- Per-subscriber queue absorbs bursts; a subscriber that stops draining is handled by
driver.WithOnFull(...)—DropMessage(default, counted and logged) orDropSubscriber(close the channel so the peer reconnects). Drop and eviction counts show up inInfo
-
messagebus — Higher-level dispatch on top of
pubsub- Single well-known topic with module-centric routing —
Register(module)picks upcommon.MessageHandler/common.RawMessageHandlerand skips modules implementing neither - Typed (
common.Message) and raw (kind, payload) handlers NewMessenger(name)for direct channel access,AttachWebSocket(messenger, ws)to bridge a connection (with server-side pings, tunable viaWithPing)
- Single well-known topic with module-centric routing —
-
planner — Task scheduling
- Concurrent execution with priority, cancel, and result lookup
- Emits task lifecycle events through a
MessageSender
-
tracing — OpenTelemetry tracing
- Owns the SDK
TracerProvider: built fromtracing.*config keys inInit(so restarts re-point or re-sample it), flushed inStop; disabled serves a noop provider, so spans are always safe to start - Exporters behind a blank-import registry (
exporters/):otlpgrpc,otlphttp,stdout— binaries link only what they import - Explicit
model.Tracerhandle everywhere — the otel globals are never set; the framework-shippedtracemiddleware (pkg/middlewares/trace/) rides the server-level slot and traces every request (W3C context extraction, route-template span names, the framingotidas aframingo.trace_idattribute), activated per server withtrace: truein the middleware configs
- Owns the SDK
Interface contracts and shared types:
-
common — Service lifecycle and utility interfaces
- Lifecycle (
service.go):Service,Initializable,Daemon,Liveness,Readiness,Debuggable - Utility (
common.go):Named,Unique,Weighted - Messaging (
message.go):Message,MessageSender,RawMessageSender,MessageHandler,RawMessageHandler - Context keys (
context.go):_config,_logger,_db,_tx,_credential,_session,_namespace,_trace,_api_request_info,_api_response_info,_api_error
- Lifecycle (
-
api — HTTP types: the two extension interfaces
RouterandMiddleware, plusEndpoint,ClientTLS/ServerTLS,ErrorBody,Encoding, and theRequestInfo/ResponseInfo/Statsrecords the built-in middlewares stash on the request context. The router.yaml schema itself is private to the server package: routers hand it over as bytes, middlewares receive their config the same way -
model — Behavioral contracts for framework services:
Supervisor,Database,Pubsub,MessageBus,Messenger,Planner,Tracer -
entity — Data carriers (POJOs) emitted by framework services:
SupervisorStats,Plan,PlannerStats,PubsubMessage -
orm — Generic ORM base types:
Record[T],Referenced[T],Reference[T] -
info — Build metadata (product name/model/version, project root/name/path, git tag/branch/commit, build version/type/date/time) injected at link time
- buffer — Generic object pool and pooled read/write/seek buffer
- graph — Topologically-sortable directed graph (used by the supervisor)
- lease — Time-based lease manager with renewal hooks
- queue — Double-buffered queue with auto-swap intervals
- staque — Hybrid stack/queue with priority and blocking variants
- trie — Prefix tree with fuzzy and prefix search (UTF-8 friendly)
| Package | Purpose |
|---|---|
| certutil | X.509 CA/server/client cert generation and TLS config |
| cmdutil | Context-aware external command execution with I/O capture, plus MergeArgs for last-wins flag merging |
| confutil | Viper instance propagated via context.Context |
| envutil | Env-var prefix derivation (EnvPrefix) and last-wins env merging (Merge) |
| infra | OS-level helpers (timezone detection and loading) |
| ioutil | File copy/compress/encrypt with progress tracking, plus LimitWriter/LimitReader for bounding untrusted streams (the reader fails past its limit rather than truncating) |
| job | Job model with state, labels, results, statistics |
| job/executor | Executor with retry, timeout, cooldown, and stop control |
| log | Zap-based logger with file rotation, custom levels, per-service scoping, and NoStdout to suppress the console core |
| maputil | Map and set helpers (copy, diff, keys, membership) |
| netutil | MAC/CIDR/IP helpers |
| pageutil | Pagination wrapper (items, total, params) |
| pathutil | Path shortening |
| printutil | Console table formatting |
| reflectutil | Type location, byte conversion, field scan/apply |
| sliceutil | Membership, dedupe, diff, copy, change tracking |
| strutil | Validation, join, clean, random, hex format |
| task | Task manager with concurrency control and priority queue |
| testutil | Test database setup helpers |
| timeutil | Timestamp comparison helpers |
mkdir -p myapp/{cmd/myapp,pkg/{services,routers,middlewares,types/{api,entity,orm},components/{cmd,server},utils}}
cd myapp
go mod init github.com/yourorg/myapp
go get github.com/xhanio/framingo
# Resulting layout:
# myapp/
# ├── cmd/myapp/ # binary entry point
# ├── pkg/
# │ ├── components/
# │ │ ├── cmd/ # Cobra commands
# │ │ └── server/ # supervisor + wiring
# │ ├── services/ # business logic
# │ ├── routers/ # HTTP routes (router.go + router.yaml)
# │ ├── middlewares/ # api.Middleware implementations
# │ ├── types/{api,entity,orm}/
# │ └── utils/
# └── config.yaml// pkg/services/hello/model.go
package hello
import (
"context"
"github.com/xhanio/framingo/pkg/types/common"
)
type Manager interface {
common.Service
common.Initializable
common.Daemon
SayHello(ctx context.Context, name string) (string, error)
}// pkg/services/hello/manager.go
package hello
import (
"context"
"fmt"
"path"
"github.com/xhanio/framingo/pkg/types/common"
"github.com/xhanio/framingo/pkg/utils/log"
"github.com/xhanio/framingo/pkg/utils/reflectutil"
)
type manager struct {
name string
log log.Logger
}
type Option func(*manager)
func WithLogger(logger log.Logger) Option {
return func(m *manager) { m.log = logger }
}
func New(opts ...Option) Manager {
m := &manager{log: log.Default}
for _, opt := range opts {
opt(m)
}
m.log = m.log.By(m)
return m
}
func (m *manager) Name() string {
if m.name == "" {
m.name = path.Join(reflectutil.Locate(m))
}
return m.name
}
func (m *manager) Dependencies() []common.Service { return nil }
func (m *manager) Init(ctx context.Context) error { return nil }
func (m *manager) Start(ctx context.Context) error { return nil }
func (m *manager) Stop(wait bool) error { return nil }
func (m *manager) SayHello(ctx context.Context, name string) (string, error) {
m.log.Infof("saying hello to %s", name)
return fmt.Sprintf("Hello, %s!", name), nil
}Recommended handler signature: func(c api.Context) error, where api.Context is a project-defined interface that embeds echo.Context (see example/pkg/types/api/api.go for the canonical wrapper). This signature gives you a single context value that satisfies both echo.Context and context.Context, plus a natural home for project-wide helpers (credential, session, trace-id, custom binders) without touching every call site later.
You can still register raw echo.HandlerFunc if you prefer; the framework accepts both. But for new projects, prefer api.Context so the door is open for future extension.
The example project splits each router into two files — router.go for wiring (config, dependencies, Handlers()) and handler.go for the handler method bodies. Within the package, files share the same import aliases by convention: the framework api package is aliased as fapi, and the project's api.Context wrapper is imported unaliased as api.
// pkg/routers/hello/router.go
package hello
import (
_ "embed"
fapi "github.com/xhanio/framingo/pkg/types/api"
"github.com/xhanio/framingo/pkg/types/common"
"github.com/xhanio/framingo/pkg/utils/log"
"github.com/yourorg/myapp/pkg/services/hello"
"github.com/yourorg/myapp/pkg/types/api"
)
//go:embed router.yaml
var config []byte
type router struct {
log log.Logger
helloSvc hello.Manager
}
// New returns the interface; newRouter returns the concrete type, the form
// package tests construct.
func New(svc hello.Manager, log log.Logger) fapi.Router {
return newRouter(svc, log)
}
func newRouter(svc hello.Manager, log log.Logger) *router {
return &router{helloSvc: svc, log: log}
}
func (r *router) Name() string { return "hello-router" }
func (r *router) Dependencies() []common.Service { return []common.Service{r.helloSvc} }
func (r *router) Config() []byte { return config }
// DiscoverHandlers reflects over r's methods and wraps any
// `func(api.Context) error` into an echo.HandlerFunc automatically.
// The debug log makes route registration visible during startup.
func (r *router) Handlers() map[string]any {
handlers := api.DiscoverHandlers(r)
r.log.Debugf("router %s parsed %d handler(s)", r.Name(), len(handlers))
return handlers
}// pkg/routers/hello/handler.go
package hello
import (
"net/http"
"github.com/yourorg/myapp/pkg/types/api"
)
func (r *router) Hello(c api.Context) error {
name := c.QueryParam("name")
if name == "" {
name = "World"
}
msg, err := r.helloSvc.SayHello(c, name) // c is also a context.Context
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]string{"message": msg})
}# pkg/routers/hello/router.yaml
server: http
prefix: /hello
handlers:
- method: GET
path: /
func: Hello// pkg/components/server/myapp/manager.go
package myapp
import (
"context"
"github.com/spf13/viper"
"github.com/xhanio/framingo/pkg/services/api/server"
"github.com/xhanio/framingo/pkg/services/supervisor"
"github.com/xhanio/framingo/pkg/types/common"
"github.com/xhanio/framingo/pkg/utils/log"
helloRouter "github.com/yourorg/myapp/pkg/routers/hello"
"github.com/yourorg/myapp/pkg/services/hello"
)
type Manager interface {
common.Daemon
Init(ctx context.Context) error
}
type manager struct {
config *viper.Viper
log log.Logger
services supervisor.Manager
api server.Manager
helloSvc hello.Manager
}
func New(config *viper.Viper) Manager {
return &manager{config: config}
}
func (m *manager) Init(ctx context.Context) error {
m.log = log.New(log.WithLevel(m.config.GetInt("log.level")))
m.services = supervisor.New(m.config, supervisor.WithLogger(m.log))
m.api = server.New(server.WithLogger(m.log))
if httpConfig := m.config.Sub("api.http"); httpConfig != nil {
// WithEndpoint takes a uint port — use GetUint, not GetInt
if err := m.api.Add("http",
server.WithEndpoint(
httpConfig.GetString("host"),
httpConfig.GetUint("port"),
httpConfig.GetString("prefix"),
),
); err != nil {
return err
}
}
m.helloSvc = hello.New(hello.WithLogger(m.log))
m.services.Register(m.helloSvc)
if err := m.services.TopoSort(); err != nil {
return err
}
m.services.Register(m.api)
if err := m.services.Init(ctx); err != nil {
return err
}
return m.api.RegisterRouters(helloRouter.New(m.helloSvc, m.log))
}
func (m *manager) Start(ctx context.Context) error { return m.services.Start(ctx) }
func (m *manager) Stop(wait bool) error { return m.services.Stop(wait) }// cmd/myapp/main.go
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/yourorg/myapp/pkg/components/server/myapp"
)
func main() {
var configFile string
rootCmd := &cobra.Command{Use: "myapp"}
daemonCmd := &cobra.Command{
Use: "daemon",
RunE: func(cmd *cobra.Command, args []string) error {
config := viper.New()
config.SetConfigFile(configFile)
config.SetEnvPrefix("MYAPP")
config.AutomaticEnv()
if err := config.ReadInConfig(); err != nil {
return fmt.Errorf("read config: %w", err)
}
config.WatchConfig()
mgr := myapp.New(config)
if err := mgr.Init(cmd.Context()); err != nil {
return err
}
return mgr.Start(cmd.Context())
},
}
daemonCmd.Flags().StringVarP(&configFile, "config", "c", "config.yaml", "config file path")
rootCmd.AddCommand(daemonCmd)
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}go build -o myapp cmd/myapp/main.go
./myapp daemon -c config.yaml
curl 'http://localhost:8080/api/v1/hello?name=Framingo'
# {"message":"Hello, Framingo!"}- example/QUICKSTART.md — Fork the template, plus build/run with GoPro
- example/ — The starter template: fork it to begin a new service (supervisor, db, pubsub, messagebus, RBAC, CLI client)
- Framework packages:
- pkg/services/ — supervisor, api server/client, db, pubsub, messagebus, planner, tracing
- pkg/middlewares/ — framework-shipped
api.Middlewares: trace - pkg/types/ — common, api, model, entity, orm, info
- pkg/utils/ — log, infra, and the utility packages listed above
- pkg/structs/ — graph, queue, buffer, trie, lease, staque
View package docs locally:
go doc github.com/xhanio/framingo/pkg/services/supervisor
go doc github.com/xhanio/framingo/pkg/services/api/server
go doc github.com/xhanio/framingo/pkg/services/messagebusThe same material is packaged as a Claude Code skill so an agent writes framingo code correctly without being walked through the conventions:
/plugin marketplace add https://github.com/xhanio/plugins
/plugin install framingo@xhanio
It activates on its own whenever a session touches framingo. See
plugins/framingo/README.md.
example/ is a template project — fork it rather than scaffolding from scratch. It is a self-contained module wired end to end (supervisor, database + migrations, pub/sub and message bus, RBAC, WebSocket stream, CLI client, Docker image, Kubernetes manifests), so a new service starts from something that already builds and runs. example/QUICKSTART.md has the fork-and-rename recipe and a "keep vs. rip out" table for pruning what you don't need.
It is a real module, not a snippet: its own go.mod carries replace github.com/xhanio/framingo => ../ (annotated to remove when you fork), so go build ./... and go test ./... inside example/ run against the framework as it currently stands in this tree rather than a published version.
example/
├── pkg/
│ ├── components/
│ │ ├── cmd/
│ │ │ ├── app/ # daemon CLI (daemon, version)
│ │ │ └── cli/ # client CLI (login, helloworld, certutil)
│ │ ├── server/example/ # supervisor wiring for the daemon
│ │ └── client/example/ # HTTP client SDK
│ ├── services/
│ │ ├── example/ # business service (HelloWorld)
│ │ ├── repository/ # GORM repositories per domain
│ │ └── system/ # auth, user, role, organization, certificate
│ ├── routers/ # auth, certificate, example, messagebus, role, user
│ ├── middlewares/ # authnagent, authnuser, authz, deflate, feature
│ └── types/ # api, entity, model, orm, message, rbac, preset, repo, infra
├── build/ # GoPro build templates (binary, image)
├── env/local/ # local-env config, docker-compose, kubernetes
├── dist/ # generated outputs (configs, migrations, manifests)
└── QUICKSTART.md
Build it via GoPro:
cd example
gopro build binary -e local # cgo-enabled build
# gopro build image -e local # docker image
./bin/exampleapp daemon -c env/local/config/exampleapp/config.yamllocal is the only environment example/project.yaml defines. Add your own under env: there — pointing config_src/config_tgt at a matching env/<name>/ tree — before building with -e <name>.
- Supervisor-orchestrated lifecycle with topological dependency resolution
- Multiple services: database, pubsub, message bus, RBAC, business logic
- HTTP routes with YAML configuration, middlewares (auth, deflate, feature flags), and a WebSocket endpoint via the message-bus router
- Type separation:
api/(DTOs),entity/(domain),orm/(database),model/(interfaces) - Database migrations and pluggable PostgreSQL/MySQL/SQLite/ClickHouse driver subpackages (blank-imported in example/pkg/components/server/example/service.go)
- Pub/sub with pluggable Memory/Redis/Kafka backends
- CLI client with credential persistence and certificate helpers
- GoPro-driven build, image, and Kubernetes manifest generation
type Service interface {
Named // Name() string
Dependencies() []Service // startup ordering
}
type Initializable interface { Init(ctx context.Context) error } // setup; called on start AND restart
type Daemon interface { Start(ctx context.Context) error; Stop(wait bool) error }
type Liveness interface { Alive() error } // failure triggers auto-restart
type Readiness interface { Ready() error } // failure reported but not actioned
type Debuggable interface { Info(w io.Writer, debug bool) }Compose only the interfaces a service needs. The supervisor inspects each registered service at runtime to determine which lifecycle hooks to invoke.
// api — wire format with validation
type CreateUserRequest struct {
Username string `json:"username" validate:"required"`
Email string `json:"email" validate:"required,email"`
}
// entity — pure domain model
type User struct {
ID int64
Username string
Email string
}
// orm — persistence model
type User struct {
ID int64 `gorm:"primaryKey"`
Username string `gorm:"type:varchar(100);not null"`
Email string `gorm:"type:varchar(255);not null"`
}
func (User) TableName() string { return "users" }The service layer converts between representations, keeping API contracts independent of storage and business logic independent of either.
Required dependencies become constructor arguments; optional config flows through functional options. The supervisor uses Dependencies() to topologically sort startup and shutdown.
// New returns the interface; newManager returns the concrete type for
// package tests.
func New(database db.Manager, opts ...Option) Manager {
return newManager(database, opts...)
}
func newManager(database db.Manager, opts ...Option) *manager {
m := &manager{db: database}
for _, opt := range opts {
opt(m)
}
return m
}
func (s *myService) Dependencies() []common.Service {
return []common.Service{s.database}
}server: http # which server from Add(name, ...) hosts this group
prefix: /users
middlewares: [authnuser] # applied to every handler in the group
handlers:
- method: GET
path: /:id
func: GetUser
- method: POST
path: /
func: CreateUser
middlewares: [authz] # handler middlewares run *before* group ones
permission: user.manage # metadata; enforced by your own authz middleware
- method: GET
path: /status
func: Status
poll: true # suppress per-request logging for pollers
middlewares:
- throttle: # a middleware entry may carry config: the block
rps: 5.0 # under the name is handed to that middleware,
burst_size: 10 # raw, when this route is registered
- method: WS
path: /events
func: Eventsmethod also accepts ANY to match every HTTP verb. permission is carried through to the handler's RequestInfo (flattened — the parsed schema never leaves the server package) but never enforced by the framework — the example's authz and feature middlewares read it. A middleware entry is either a bare name or a single-key map whose value is that middleware's config for this route; handler entries are collected ahead of the group's, wrap the outside and run first, and a name the handler claims is skipped at group level — so a handler's config overrides the group's attachment instead of stacking a second run.
Each router embeds its router.yaml and exposes a Handlers() map[string]any that maps each func: key to a handler implementation. The framework accepts echo.HandlerFunc (or func(echo.Context) error) for HTTP and func(echo.Context, *websocket.Conn) error for WebSocket — but the recommended pattern is func(c api.Context) error (and func(c api.Context, conn *websocket.Conn) error for WS), where api.Context is a project-defined interface that embeds echo.Context and context.Context. A small DiscoverHandlers helper (see example/pkg/types/api/api.go) reflects over the router's methods and wraps the project-context signature into the echo signature the server expects. This keeps handlers free to evolve (extra binders, session/credential accessors, trace propagation) without rewriting every signature.
The conventional file layout splits each router into router.go (factory + Name/Dependencies/Config/Handlers) and handler.go (the handler method bodies). The standard Handlers() implementation just delegates and emits a debug log so route registration is visible at startup:
func (r *router) Handlers() map[string]any {
handlers := api.DiscoverHandlers(r)
r.log.Debugf("router %s parsed %d handler(s)", r.Name(), len(handlers))
return handlers
}Request → Recover → [CORS] → [server-level] → Logger → Info → Error → custom (auth, throttle, deflate, …) → Handler → Response
The server ships its built-ins in a fixed order — four plain lifecycle functions (recover, logger, info, error) that take no config, plus cors, a standard api.Middleware configured through the server's middleware configs under its own name. Each position is forced by a dependency: recover outermost, since everything inside it may panic; cors next, answering preflight requests — which match no route, so no route-attached middleware could — before any user code; server-level middlewares from WithMiddlewares after cors but ahead of info, so they run on every request, matched or not; logger wrapping info, whose records it reads; error innermost. CORS declines attachment until configured — cors: true for echo's permissive development defaults, a policy block (allow_origins/allow_methods/allow_headers/allow_credentials/max_age) to tighten, false or absent to stay off; declining by returning no function is part of the Func contract, not a special case. Rate limiting stays an app concern: the example ships it as a user middleware (example/pkg/middlewares/throttle).
A middleware implements one method — Func(config []byte) (func(echo.HandlerFunc) echo.HandlerFunc, error) — called once per attachment point at registration time, with the raw YAML written under its name. Config resolves most-specific-first: the handler entry's own block in router.yaml, else its group entry's, else the server's middleware config for that name (WithMiddlewareConfigs — a plain name-to-config mapping, typically fed from the app's config.yaml), else nil. Per-route state lives in the returned closure, and a bad config fails startup, not the first request.
Custom middlewares are resolved by name from the set registered with srv.RegisterMiddlewares(...), and the lookup happens while routers are being installed — so always register middlewares before routers, or registration fails with NotImplemented: middleware <name> not found.
Use github.com/xhanio/errors exclusively. The API server's error handler routes by error category to set the HTTP status.
return errors.NotFound.Newf("user %s not found", id)
if err := s.db.FromContext(ctx).Create(u).Error; err != nil {
return errors.Wrapf(err, "create user %s", u.Name)
}Framingo uses an instance-based Viper (not the global singleton) propagated through context.Context. Services read live config in Init(ctx) via confutil.FromContext(ctx).
Priority (high → low):
- Command-line flags
- Environment variables
- YAML configuration file
- Default values
# config.yaml
log:
level: -1 # -1=Debug, 0=Info, 1=Warn, 2=Error
file: /var/log/app.log
rotation:
max_size: 100 # MB
max_backups: 3
max_age: 7 # days
db:
type: postgres # postgres | mysql | sqlite | clickhouse
# sqlite requires CGO_ENABLED=1; the others are pure Go
source:
host: localhost
port: 5432
user: app
password: secret
dbname: app
migration:
dir: ./migrations
version: 0 # 0 = latest
connection: # re-read by db.Manager.Init on every init/restart
max_open: 10
max_idle: 5
max_lifetime: 1h
max_idle_time: 30m
exec_timeout: 30s
api:
http:
host: 0.0.0.0
port: 8080
prefix: /api/v1
middlewares: # per-middleware default configs, by name
cors: true # the server's built-in CORS
throttle: # the example's throttle middleware
rps: 100.0
burst_size: 200
pprof:
port: 6060 # optionalApart from db.connection.* — which db.Manager.Init reads straight from the context Viper, so a restart picks up pool changes without a rebuild — this layout is a convention, not a schema. Your wiring code maps keys onto constructor options, so rename freely as long as Init(ctx) stays the place dynamic values are read.
Override at runtime:
export MYAPP_API_HTTP_PORT=9090
./myapp daemon -c config.yamlFROM golang:1.25 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o app cmd/app/main.go
FROM alpine:latest
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /app/app /usr/local/bin/
COPY config.yaml /etc/app/
CMD ["app", "daemon", "-c", "/etc/app/config.yaml"]That assumes a pure-Go binary. If you blank-import the SQLite driver you need CGO_ENABLED=1, and the glibc-linked result won't run on Alpine — build and ship on the same libc, as the example does with ubuntu:22.04 in example/build/image/exampleapp/Dockerfile.
The example ships generated manifests at example/env/local/kubernetes/exampleapp/ (deployment.yaml, service.yaml, configmap.yaml). Use gopro generate kubernetes to regenerate for your environment.
[Unit]
Description=My Framingo App
[Service]
ExecStart=/usr/local/bin/myapp daemon -c /etc/myapp/config.yaml
Restart=on-failure
[Install]
WantedBy=multi-user.target-
Architecture & Types
- Separate
api/,entity/,orm/and convert between them in the service layer - Pass required dependencies as constructor arguments, optional config as
Options - Keep business logic independent of HTTP and persistence
- Separate
-
Service Design
- One
Managerinterface per service in a dedicated package - Use an unexported struct and exported interface + factory — strict convention throughout the framework
- Declare dependencies explicitly via
Dependencies(); the supervisor handles ordering - Read dynamic config in
Init(ctx)viaconfutil.FromContext(ctx)so restarts pick it up
- One
-
Error Handling
- Always use
github.com/xhanio/errors— neverfmt.Errorfor stdliberrors - Always wrap with
errors.Wrap/errors.Wrapfinstead of returning rawerr - Pick the category (
NotFound,BadRequest,Internal, …) that should map to the HTTP status
- Always use
-
Configuration
- Use YAML for hierarchy; env vars for secrets and per-environment overrides
- Never reach for
viper.GetXxxglobals; take the instance from context
-
Testing
- Mock collaborators through the
Managerinterface - Use
testutilto spin up an isolated DB for integration tests - Test ORM ↔ entity conversions explicitly
- Mock collaborators through the
-
Performance
- Tune
db.connection.*for your workload - Apply throttling per router group or per handler in
router.yaml(seeexample/pkg/middlewares/throttle) - Enable pprof during incidents (
pprof.port)
- Tune
Contributions are welcome.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
MIT License — see LICENSE.
- example/QUICKSTART.md — fork the template, build & run
- example/ — the starter template to fork
- Issues — bug reports and feature requests
- Discussions — questions and community
Built with:
- Echo — HTTP framework
- Cobra — CLI framework
- Viper — configuration management
- GORM — ORM
- zap — structured logging
- xhanio/errors — categorized error handling
Start building with Framingo today!