A Vukem application is a *vukem.App: configuration, logger, providers and long-running runners (the HTTP server, workers). The lifecycle is inspired by AdonisJS v7 and Goravel runners, written as plain Go with no globals and no reflection.
Overview#
Load config (New)
.env files and the environment are merged, filled into vukem.Config and checked. Missing APP_KEY, debug in production or an invalid port abort boot. The logger, app.Crypt, the router (with /up), app.Events, Vite, translations, Inertia and views are built next.
Register (New)
Every provider's Register runs in declared order (providers whose Kinds() excludes the runtime kind are skipped). Register binds services and must not perform I/O.
Routes (New)
Every WithRoutes function runs in order. Services set in Register are available; clients a provider opens in Boot are not yet. In KindWarmup, WithRoutesAfterBoot functions run here too, since warmup never boots.
Boot (Run, commands, vukemtest)
Providers implementing Booter run Boot(ctx, app) in order. Boot may use services registered by other providers. Then every WithRoutesAfterBoot function runs, so those routes can use what Boot opened. Boot runs once and is not retryable: later calls (concurrent ones wait) return the first call's error, or nil after a successful boot.
Run
The HTTP handler is compiled from the router, then every runner starts in its own goroutine. Run blocks until the context is cancelled (for example by SIGTERM) or a runner fails.
Shutdown
Runners stop and are awaited: the HTTP server drains requests, and the scheduler and the queue worker give running tasks and jobs up to SHUTDOWN_TIMEOUT, then cancel their ctx. Run waits for the runners SHUTDOWN_TIMEOUT plus three seconds; one still running then (it ignores ctx) is logged and left behind. Then OnShutdown hooks and providers' Shutdown run in reverse order, within a fresh SHUTDOWN_TIMEOUT.
Who boots, by entry point:
| Entry point | Kind | Boot | Routes after boot |
|---|---|---|---|
serve (app.Run) | KindWeb | yes, before the HTTP handler is compiled | at the end of Boot |
App commands (migrate, db:seed, route:list, your own) | KindConsole | yes, before the command runs | at the end of Boot |
codegen:routes, codegen:client | KindWarmup | never (no connection opened) | at the end of New |
vukemtest.New | KindTest | yes, before migrations | at the end of Boot |
vukem.New called directly | as given | only when you call app.Boot(ctx) | at the end of Boot |
Routes that need booted services#
WithRoutes runs inside New, before any provider's Boot. When a route's controller needs a client that a provider only opens in Boot (a database, Mongo or S3 client), register those routes with WithRoutesAfterBoot instead:
search := &providers.Search{} // opens its Mongo client in Boot
app, err := vukem.New(
vukem.WithProviders(search),
vukem.WithRoutes(routes.Web),
vukem.WithRoutesAfterBoot(func(r *router.Router, app *vukem.App) error {
r.Get("/search", catalog.New(search.Client).Search).Name("search")
return nil
}),
)Those routes exist only after Boot, and the app fails closed around that:
app.Handler()returnsvukem.ErrNotBootedwhile they are pending, instead of serving 404 for them.- If the router is compiled before
Bootanyway (app.Router.Handler(),app.Router.URL, a request, or aURL()call inside aWithRoutesfunction),Bootfails withvukem.ErrRoutesCompiledinstead of silently dropping them.
They share the router with WithRoutes: global middleware (r.Use, r.UseServer, everything DefaultMiddleware installed) applies to them, but groups created inside a WithRoutes function do not, so create the group again or add the route middleware they need. Do not call vukem.DefaultMiddleware again in an after-boot function: it would install the whole stack a second time.
In KindWarmup (codegen:routes, codegen:client) they run at the end of New so the route table is complete, but Boot never ran and booted clients are still unset. Register the routes without touching those clients, or branch on the kind:
vukem.WithRoutesAfterBoot(func(r *router.Router, app *vukem.App) error {
var ctrl *catalog.Controller
if app.Kind != vukem.KindWarmup { // Boot ran: the client is open
ctrl = catalog.New(search.Client)
}
r.Get("/search", ctrl.Search).Name("search") // a method value on a nil pointer is fine; it is never called in warmup
return nil
})A panic in an after-boot function during warmup is returned from New as an error saying route registration must not use booted services during warmup. The order of WithRoutes and providers is unchanged.
Creating the app#
Applications build the app in one function, bootstrap.New, used by cmd/app and by the tests:
func New(opts ...vukem.Option) (*vukem.App, error) {
app, err := vukem.New(append([]vukem.Option{
vukem.WithProviders(Providers()...),
vukem.WithMigrations(migrations.All()),
vukem.WithViews(resources.Views()),
vukem.WithRoutes(routes.Web),
}, opts...)...)
if err != nil {
return nil, err
}
app.AddCommand(Commands()...)
return app, nil
}func main() { os.Exit(vukem.Main(bootstrap.New)) }vukem.Main(build) loads .env and the process environment, wires SIGINT/SIGTERM, and runs serve (the default) or the command named by the first argument. It exits 2 on usage errors and 1 on failures. Its testable body is vukem.RunMain(ctx, args, getenv, build, opts...).
| Option | Purpose |
|---|---|
WithEnv(fsys, getenv) | Load .env files from fsys and the environment from getenv (defaults: os.DirFS("."), os.LookupEnv) |
WithSource(src) | Use an already resolved *config.Source (tests: config.MapSource) |
WithLogger(l) / WithLogOutput(w) | Custom *slog.Logger, or where the default logger writes |
WithKind(k) | KindWeb (default), KindConsole or KindTest |
WithProviders(p...) | Providers, registered and booted in order |
WithRoutes(fn) | func(r *router.Router, app *vukem.App) error, called in New after providers registered; an error stops New |
WithRoutesAfterBoot(fn) | Same signature, called at the end of Boot after every provider booted; an error fails Boot |
WithViews(fsys) | Enable the view engine over fsys; views reload per request outside production |
WithMigrations(m) | Migrations used by migrate* commands and app.Migrator() |
WithDatabaseDrivers(d...) | Extra database drivers (postgres.Driver()) |
WithSeeders(s...) | Seeders run by db:seed and app.Seed |
WithAppSeeders(s...) | Seeders that receive the *vukem.App (config, logger, services); run with the others in option order |
WithListeners(r...) | Event listeners registered on app.Events |
WithMailTransport(t) | Replace the transport chosen by MAIL_MAILER |
WithClock(now) | Clock for sessions and other time-based features (vukemtest.FakeClock) |
WithRunner(name, r) | A long-running component started by Run |
WithoutHTTP() | Do not start the HTTP server runner (tests, workers) |
WithStdout(w) | Where commands print |
The App exposes Config, Source (every configuration value), Key (decoded APP_KEY), Log, Kind, Router, Views, Events and Telegram, plus DB(), Migrator(), Sessions(), Mailer(), Cache(), Seed(ctx), Handler() and Now().
Commands#
App commands run inside the app binary (vukem <command> delegates to it). Built in: migrate, migrate:rollback, migrate:reset, migrate:fresh, migrate:status, route:list, about. Add your own:
func SendReports() vukem.Command {
return vukem.CommandFunc{
CommandName: "reports:send",
Desc: "Email the weekly reports",
DefineFlags: func(fs *flag.FlagSet) { /* fs.Bool(...) */ },
Fn: func(ctx context.Context, app *vukem.App, args []string) error {
fmt.Fprintln(app.Stdout(), "reports sent")
return nil
},
}
}Register it in bootstrap/commands.go (vukem make:command does it for you). Commands run with KindConsole, so providers limited to KindWeb are skipped.
Print through app.Stdout() and app.Stderr() and read app.Stdin() rather than the os streams: vukem.WithStdout, WithStderr and WithStdin then redirect them, which is how tests capture or silence a command's output.
Providers#
type Provider interface {
Register(app *App) error // bind services, no I/O
}
// Optional hooks, detected by interface assertion:
type Booter interface { Boot(ctx context.Context, app *App) error }
type Shutdowner interface { Shutdown(ctx context.Context, app *App) error }
type KindFilter interface { Kinds() []Kind }type DatabaseProvider struct {
DB *sql.DB
}
func (p *DatabaseProvider) Register(app *vukem.App) error {
db, err := sql.Open("sqlite", app.Source.Get("DATABASE_URL"))
if err != nil {
return fmt.Errorf("open database: %w", err)
}
p.DB = db
return nil
}
func (p *DatabaseProvider) Boot(ctx context.Context, app *vukem.App) error {
return p.DB.PingContext(ctx) // I/O belongs in Boot
}
func (p *DatabaseProvider) Shutdown(ctx context.Context, app *vukem.App) error {
return p.DB.Close()
}
// Kinds limits the provider to some runtimes; without it, it runs everywhere.
func (p *DatabaseProvider) Kinds() []vukem.Kind {
return []vukem.Kind{vukem.KindWeb, vukem.KindConsole}
}vukem.ProviderFunc adapts a plain function to a provider with only a Register phase.
Runners#
type Runner interface {
Run(ctx context.Context) error // block until ctx is done, then release resources
}vukem.RunnerFunc adapts a function. Runners can be added with WithRunner or, from a provider, with app.AddRunner(name, r). When one runner fails, the others are cancelled and the first error is returned from Run. A cancelled context is a clean exit (nil).
The HTTP server#
vukem.NewHTTPServer(addr, handler) returns a runner around *http.Server that is never configured with zero timeouts (Slowloris protection):
| Setting | Default |
|---|---|
ReadHeaderTimeout | 10s |
ReadTimeout | 30s |
WriteTimeout | 60s |
IdleTimeout | 120s |
MaxHeaderBytes | 1 MiB |
ShutdownTimeout | 15s drain of in-flight requests |
In-flight handlers keep the app's context values but are not cancelled when shutdown starts, so they can finish. Set Listener to serve on an existing listener and Ready to receive the bound address (useful in tests with port 0).
Shutdown#
app.OnShutdown(func(ctx context.Context) error {
return flushMetrics(ctx)
})Shutdown order: runners stop first, then OnShutdown hooks in reverse registration order, then providers' Shutdown in reverse provider order. All errors are joined. Shutdown is idempotent.
Request lifecycle#
net/http server
└─ server middleware every request, even unmatched (recover, request id, logger, static…)
└─ ServeMux match
├─ no match → 404 / 405 through the error handler
└─ router middleware matched routes only
└─ group / route middleware
└─ handler func(c *web.Ctx) error
└─ error? → error handler → HTML | redirect + flash | fragment | problem+jsonSee Middleware and Error handling.