A reference application built on the keel microservice framework. It shows the patterns the framework expects — service registration, controllers with a database plugged in, middleware tiers, NATS pub/sub + RPC, Redis-backed sessions, Meilisearch, Socket.IO, and config-driven wiring — across several real services that run from a single binary.
If keel's own README is the "what", this is the "how": it fills the gaps the quick-start leaves out — middleware wiring, controller blank-imports, the config directory layout, and init ordering.
| Capability | Where it comes from |
|---|---|
| Service in minutes | servicehandler.Register + keel.Boot |
| HTTP routing (Gin) with middleware tiers | service.SERVICE_TYPE_HTTP + middleware registry |
| Controllers with DB plugged in | controllers.BaseController (Mongo/MySQL/PostgreSQL) |
| CRUD + soft delete + aggregation + pagination | BaseFunctionsInterface |
| Redis cache + distributed locks | cachesettings, redislock |
| Inter-service messaging (Pub/Sub and RPC) | NATS via config topics |
| Built-in API validation | basevalidators (go-playground/validator) |
| Auto OpenAPI / Swagger UI | generated from GetApisMap() docs |
| Real-time | SERVICE_TYPE_SOCKET (Socket.IO) |
| Email / FCM / WhatsApp notifications | notificationsettings |
| Search | Meilisearch |
| Tracing, pprof, health, circuit breaker | settings/* (config-toggled) |
| Prometheus metrics | keel-code/settings/metrics (app-side) |
keel-code/
├── main.go # blank-imports services + middleware, calls keel.Boot
├── config/ # GLOBAL config, shared by every service
│ ├── dev.json test.json uat.json prod.json
│ └── migrations/ # JSON migration seed files (e.g. users.json)
├── services/ # one folder per service
│ ├── ssoservice/
│ │ ├── ssoservice.go # Run()/Stop() + servicehandler.Register
│ │ ├── ssoservicesubscriptions.go # NATS subscriber methods
│ │ └── config/ # PER-SERVICE config (overrides global)
│ │ └── dev.json test.json uat.json prod.json
│ ├── otpservice/ # NATS-only (SIMPLE) service: subscribe + RPC
│ ├── websocketservice/ # Socket.IO (HTTP+SOCKET) service
│ └── _templateService/ # copy this to start a new service
├── controllers/ # DB-backed controllers (HTTP handlers live here)
│ ├── usercontrollers/
│ └── sessioncontrollers/
├── middlewares/
│ ├── middlewareregistry/ # tier names + setup() wiring (init-time)
│ └── commonmiddlewares/ # logging, cors, session, user, auth
├── models/ # jsonmodels, keelmodels, rpcreplymodels, ...
└── settings/ # app-side helpers: common, responses, metrics
The two-level config is the key idea: config/<env>.json is global; each
service additionally has services/<name>/config/<env>.json that overrides it.
# pick the service with -con (registered name), the env with -env
go run . -env=DEV -con=SSOSERVICE # HTTP API on :8080 (/sso)
go run . -env=DEV -con=OTPSERVICE # NATS subscriber + RPC on :8085
go run . -env=DEV -con=WEBSOCKETSERVICE # Socket.IO on :8090 (/socket.io/)-env selects which <env>.json files load (DEV → dev.json). -con is the
service's registered name (case-insensitive).
Every HTTP service automatically exposes:
GET /<serviceLBName>/health— health/livenessGET /<serviceLBName>/swagger— Swagger UI (non-PROD only)GET /<serviceLBName>/swagger/openapi.json— generated spec
keel relies heavily on Go's init() functions firing before main(). The
blank imports in main.go are what make registration happen — drop one and the
service/controller silently won't exist.
// main.go
import (
_ "keel-code/middlewares/middlewareregistry" // registers middleware setup()
_ "keel-code/services/otpservice" // registers the service factory
_ "keel-code/services/ssoservice"
_ "keel-code/services/websocketservice"
"github.com/glodb/keel"
)What happens, in order:
- Package
init()s run (triggered by the blank imports):middlewareregistry.init()→keelregistry.SetSetup(setup)— registers the tier-setup function (does not run it yet).- each
service.init()→servicehandler.Register("name", factory). - each
controller.init()→controllers.Register(MONGO, "name", factory). Controllers reach this point because the service blank-imports them (see below). Registration is deferred — controllers are not initialized yet.
main()parses flags and callskeel.Boot(env, con).keel.Bootloads config (global + service, then env-var overrides), inits logger, then conditionally Meilisearch / tracing / pprof, registers NATS topics, looks up the service factory, and calls the service'sRun().service.Run(...)(inside your service):middlewareregistry.GetInstance()runs the registeredsetup()lazily, here — so the middleware tiers exist before routes are attached.- registers NATS subscribers from config (
subscribedTopics,rpcSubscribedTopics). - builds the Gin server, calls
Setup(middlewares)to create the tier router groups, thenInitializeControllers()drains the controller queue: each controller'sInitialize()runs and itsGetApisMap()routes are attached to the matching tier. - starts listening; on SIGINT/SIGTERM it shuts down gracefully.
Mental model: register at init time, wire at Run time. Anything missing from the blank imports never registers, so it never runs.
A controller is a DB-backed unit that owns a collection/table and a set of HTTP
routes. It embeds controllers.BaseController (which gives it all the DB
functions) and registers itself in init():
func init() {
controllers.Register(basetypes.MONGO, "user", func() baseinterfaces.Controller {
return &UserController{}
})
}
type UserController struct {
controllers.BaseController // CRUD, pagination, soft delete, GetController...
}
func (uc *UserController) GetCollectionName() basetypes.CollectionName { return "users" }
func (uc *UserController) Initialize() error {
// runs during InitializeControllers(): indexes, migrations, search setup
_ = uc.EnsureIndex(ctx, uc, customtypes.M{}, false, nil)
return nil
}
// Routes, grouped by middleware tier ("open" / "auth" / ...).
func (uc *UserController) GetApisMap() map[customtypes.RouterNames][]genericmodels.Apis {
return map[customtypes.RouterNames][]genericmodels.Apis{
"open": { {ApiName: "login", ApiMethod: customtypes.POST, Method: uc.handleLogin()} },
"auth": { {ApiName: "profile", ApiMethod: customtypes.GET, Method: uc.handleGetProfile()} },
}
}Controllers only register if something imports them. The service does that with blank imports:
// services/ssoservice/ssoservice.go
import (
_ "keel-code/controllers/sessioncontrollers"
_ "keel-code/controllers/usercontrollers"
)Other handy bits this app demonstrates:
- Route URL assembly:
serviceLBName + apiPrefix + "/" + ApiName(e.g./sso+/api+/login→/sso/api/login). - Cross-controller calls:
uc.GetController(basetypes.MONGO, "session")to reach another controller (used for OTP publish + RPC). - OpenAPI docs: fill the
Docfield on each route and Swagger UI is generated for free.
Middleware is organized into named tiers. A controller route names the tier it wants; the route gets that tier's whole chain.
Tier names (middlewares/middlewareregistry/middlewareconst.go):
const (
BaseMiddleware = "base" // logging + cors
OpenMiddleware = "open" // logging + cors + session
AuthMiddleware = "auth" // logging + cors + session + user + auth
BusinessMiddleware = "business"
)Wiring (middlewares/middlewareregistry/middlewareregistry.go) — registered at
init, executed lazily when the service calls GetInstance():
func init() { keelregistry.SetSetup(setup) } // register, don't run
func setup(m *keelregistry.Registry) { // runs during service.Run()
m.SetTier(OpenMiddleware, []basemiddlewares.Middleware{
&commonmiddlewares.LoggingMiddleware{},
&commonmiddlewares.CORSMiddleware{},
&commonmiddlewares.SessionMiddleware{},
})
m.SetTier(AuthMiddleware, []basemiddlewares.Middleware{
&commonmiddlewares.LoggingMiddleware{},
&commonmiddlewares.CORSMiddleware{},
&commonmiddlewares.SessionMiddleware{},
&commonmiddlewares.UserMiddleware{},
&commonmiddlewares.AuthMiddleware{},
})
}A custom middleware just implements GetHandlerFunc() gin.HandlerFunc. The tier
name used in a controller's GetApisMap() must match a tier defined here, or
those routes are skipped with an error at startup.
GetMiddlewares(serviceName) returns per-service tiers if registered, otherwise
the global set — so you can give one service a different middleware stack.
keel is config-driven: most subsystems turn on, connect, or change behavior
purely from JSON — no code. Global keys live in config/<env>.json, service
keys in services/<name>/config/<env>.json (service wins on conflicts), and
environment variables override both.
| Config key | What it does for you |
|---|---|
address |
HTTP listen address for the service |
serviceLBName |
URL prefix + base path for /health and /swagger |
apiPrefix |
Inserted into every route: serviceLBName + apiPrefix + "/" + ApiName |
microServiceName |
Service identity: log name, NATS queue-group, config selection |
mongo / mysql / psql |
DB connection — just fill the block to get a pooled client |
cacheType: "redis" + redis |
Enables Redis cache + distributed locks |
meilisearch.host |
Enables full-text search; empty = off |
subscribedTopics: {topic: "Method"} |
Auto-subscribes NATS topic → subscriber method (Pub/Sub) |
rpcSubscribedTopics: {topic: "Method"} |
Auto-wires NATS RPC handler → subscriber method |
publishingTopics / rpcRequestTopics |
Whitelists topics this service may publish / call |
registeredTopics |
Declares known topics (validation) |
rpcRequestExpirySeconds |
RPC call timeout |
pageSize / maxPageSize |
Default + cap for pagination |
softDeletionKey / softDeleteCollectionPrefix / deletedByKey |
Soft-delete behavior |
migrationsPath |
Where JSON migrations are read from |
useTracing |
OpenTelemetry/Jaeger on/off (off by default) |
usePprof / pprofAddress |
pprof profiling endpoints |
socketAddress |
Standalone Socket.IO port (when not sharing the HTTP port) |
isProduction |
Gin release mode; disables Swagger in PROD |
publisherBatchSize / dbBatchSize |
Batch sizes for the NATS publisher / DB writes |
Example — turning one service into a NATS Pub/Sub + RPC worker with zero code
beyond the handler methods (services/otpservice/config/dev.json):
{
"serviceLBName": "/otp",
"microServiceName": "otpService",
"address": "localhost:8085",
"subscribedTopics": { "keel-code.handle-otp": "HandleOTP" },
"rpcSubscribedTopics": { "keel-code.handle-larger-otp": "HandleLargerOTP" }
}The service just declares those method names on its subscriber struct; keel
reflects them in and wires the NATS subscriptions. The publishing side lists the
same topics under publishingTopics / rpcRequestTopics.
- Copy
services/_templateService/toservices/myservice/. - In its
init(), callservicehandler.Register("myservice", ...). - Blank-import any controllers it needs.
- Pick a service type in
Run():SERVICE_TYPE_HTTP | SERVICE_TYPE_SIMPLE(API),SERVICE_TYPE_SIMPLE(NATS worker), or addSERVICE_TYPE_SOCKET(real-time). - Add
services/myservice/config/{dev,test,uat,prod}.json. - Blank-import the service in
main.go. - Run:
go run . -env=DEV -con=MYSERVICE.
Most subsystems expose both a package singleton (GetInstance()) and a
constructor (New...() / NewConfig(), NewControllers(), NewGINServer(),
NewCookie(), etc.).
Although
New(...)constructors are available for most major functionalities (useful for isolated graphs, multi-instance binaries, and testing), they are still being hardened. The singletons are the well-tested path — they are what every service here uses, and they integrate cleanly with the config system (configmanager.GetInstance()and friends). Prefer the singletons for production wiring; reach forNew(...)when you specifically need isolation (e.g. tests or running multiple independent instances in one process).
- Keel framework — the full framework
- Config schema reference
- How Keel was built — the story behind it