Skip to content
You are reading the v0.20 documentation. Go to the latest version (v1.0)

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

.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.

Register

Every provider's Register runs in declared order. Register binds services and must not perform I/O.

Boot

Providers implementing Booter run Boot(ctx, app) in order. Boot may use services registered by other providers.

Run

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, then OnShutdown hooks and providers' Shutdown run in reverse order, within SHUTDOWN_TIMEOUT.

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 after providers registered; an error stops New
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
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 {
			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.

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.