Skip to content

Architecture

Application lifecycle

How a Vukem app boots, registers providers, starts runners and shuts down gracefully.

Last updated:

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 pointKindBootRoutes after boot
serve (app.Run)KindWebyes, before the HTTP handler is compiledat the end of Boot
App commands (migrate, db:seed, route:list, your own)KindConsoleyes, before the command runsat the end of Boot
codegen:routes, codegen:clientKindWarmupnever (no connection opened)at the end of New
vukemtest.NewKindTestyes, before migrationsat the end of Boot
vukem.New called directlyas givenonly 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:

bootstrap/app.go
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() returns vukem.ErrNotBooted while they are pending, instead of serving 404 for them.
  • If the router is compiled before Boot anyway (app.Router.Handler(), app.Router.URL, a request, or a URL() call inside a WithRoutes function), Boot fails with vukem.ErrRoutesCompiled instead 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:

Go
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:

bootstrap/app.go
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
}
cmd/app/main.go
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...).

OptionPurpose
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:

app/commands/send_reports.go
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#

Go
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 }
providers/database.go
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#

Go
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):

SettingDefault
ReadHeaderTimeout10s
ReadTimeout30s
WriteTimeout60s
IdleTimeout120s
MaxHeaderBytes1 MiB
ShutdownTimeout15s 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#

Go
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#

Text
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+json

See Middleware and Error handling.