Skip to content

glodb/keel-code

Repository files navigation

keel-code

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.


What keel gives you (quick reference)

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)

Project layout

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.


Running

# 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 (DEVdev.json). -con is the service's registered name (case-insensitive).

Every HTTP service automatically exposes:

  • GET /<serviceLBName>/health — health/liveness
  • GET /<serviceLBName>/swagger — Swagger UI (non-PROD only)
  • GET /<serviceLBName>/swagger/openapi.json — generated spec

Init ordering (the part the quick-start skips)

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:

  1. 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.
  2. main() parses flags and calls keel.Boot(env, con).
  3. keel.Boot loads 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's Run().
  4. service.Run(...) (inside your service):
    • middlewareregistry.GetInstance() runs the registered setup() 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, then InitializeControllers() drains the controller queue: each controller's Initialize() runs and its GetApisMap() 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.


Controllers (and their blank-imports)

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 Doc field on each route and Swagger UI is generated for free.

Middleware (tiers + registry)

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.


How a little config does a lot

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.


Adding a new service (the short version)

  1. Copy services/_templateService/ to services/myservice/.
  2. In its init(), call servicehandler.Register("myservice", ...).
  3. Blank-import any controllers it needs.
  4. Pick a service type in Run(): SERVICE_TYPE_HTTP | SERVICE_TYPE_SIMPLE (API), SERVICE_TYPE_SIMPLE (NATS worker), or add SERVICE_TYPE_SOCKET (real-time).
  5. Add services/myservice/config/{dev,test,uat,prod}.json.
  6. Blank-import the service in main.go.
  7. Run: go run . -env=DEV -con=MYSERVICE.

New(...) vs singletons — which to use

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 for New(...) when you specifically need isolation (e.g. tests or running multiple independent instances in one process).


Related docs

Keel Story

About

example for using keel library

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors