Skip to content

Lifecycle

wiki edited this page Sep 4, 2026 · 1 revision

Lifecycle

Declare in New, build in Run

rex.New() collects. It creates the container, the event bus and the logger, applies the options it was given, and returns. It creates no routers, runs no extension hooks, binds nothing, and has no error return because nothing it does can fail.

Run() builds:

 1. freeze the configuration
 2. create the default router and any routers declared before Run
 3. ── OnInitialize ──   extensions declare routes, routers, middleware
 4. ── OnStart ──        the remaining declarations (openapi, swagger)
 5. create routers declared by the hooks
 6. attach middleware:   global → per-router → per-route
 7. register every queued route on its named router
 8. RouteValidator.ValidateRoutes over the complete route table
 9. freeze every router's route table
10. bind the listeners, synchronously
11. ── OnReady ──        everything is serving
    … blocks until SIGINT / SIGTERM / ctx / Stop() …
12. stop the listeners
13. ── OnStop ──
14. ── OnShutdown ──     runs even if OnStop failed
15. close the event bus

The order is the point. Every extension has declared before any table is built, every table is built before any socket binds, and OnReady means what it says.

What this buys you

Declaration order does not matter

app := rex.New()

// Middleware before the router it applies to — fine.
app.Use(loggingMiddleware)
_ = app.CreateRouter("admin", rex.RouterConfig{Addr: ":9090"})

// A route before its router — also fine.
_ = app.RegisterRouteToRouter(route.New("GET", "/x", h), "metrics")
_ = app.CreateRouter("metrics", rex.RouterConfig{Addr: ":9091"})

// Configuration after everything else — still fine.
app.WithOptions(rex.WithConfig(cfg))

if err := app.Run(); err != nil { // everything is resolved here
	return err
}

What cannot be known until Run is reported from Run — an unknown router name, for instance, wrapped with the method, path and call site of the registration that named it. Anything checkable at the call site is returned there.

This also removes the WithConfig footgun rather than policing it: because nothing is built until Run, configuration may be set at any point before it.

Failures are returned, not logged

Binding is synchronous. A port already in use comes back from Run:

if err := app.Run(); err != nil {
	log.Fatalf("startup failed: %v", err) // "listen tcp :8080: address already in use"
}

If any listener fails to bind, the ones already bound are stopped, so a partially-bound application does not keep serving on some ports while reporting a startup failure.

The route table is immutable while serving

Between NewRouter and Freeze, routes and middleware are collected into ordinary slices under a mutex. Freeze composes every route's middleware chain, builds the trie, and publishes the result by atomic swap. After that the table is read by ServeHTTP without locking.

That is what makes the router safe to serve from — and why registering after Run is refused rather than raced.

Extension hooks

Full detail in rextension → Lifecycle hooks. In short:

Hook May declare routes/middleware? Use for
OnInitialize yes the bulk of an extension's setup
OnStart yes declarations that need everyone else's first
OnReady no — refused work that must not begin before serving
OnStop n/a — listeners already closed drain, flush, cancel
OnShutdown n/a release

A hook returning an error aborts startup, and Run returns it wrapped with the hook's name. An OnReady failure stops the application that had already started serving.

Errors from Run

Error Means
ErrAlreadyRunning Run was called twice on one instance
ErrRouterExists CreateRouter for a name already taken
ErrRouterUnknown a route or middleware named a router nobody created
ErrRouterFrozen something registered after the tables were built
ErrNilRoute a nil route, or one with no handler
ErrRouterNotFrozen a router was started before its table was built — a framework bug, not yours
OnInitialize: … / OnStart: … / OnReady: … an extension hook failed
<T> rejected the route table: … a RouteValidator refused startup

ErrRouterExists, ErrRouterUnknown and ErrRouterFrozen are aliases of the sentinels declared in rextension, so errors.Is matches whichever module the comparison is written in.

Shutdown

signal / ctx / Stop()
  → listeners stopped   (nothing new arrives)
  → OnStop hooks        (bounded by ShutdownTimeout)
  → OnShutdown hooks    (run even if OnStop failed)
  → event bus closed
  → Run returns

Shutdown runs exactly once, whichever combination of Stop(), a signal and a cancelled context arrives — the path is guarded, so a Stop() followed by SIGTERM no longer runs every hook twice.

A Stop() arriving mid-startup waits for startup to finish first, so an extension's OnReady and OnStop never execute concurrently on the same instance. That wait is bounded by the shutdown timeout: a startup that hangs cannot make shutdown hang with it.

Errors from OnStop and OnShutdown are joined and returned from Run. See Graceful Shutdown.

Why it was changed

Before v0.3.0, routers were created inside New(), extensions were initialized at registration time, and the OpenAPI and Swagger extensions registered their routes from OnReady — mutating a trie that in-flight requests were already reading, with no lock on either side. Binding happened in a goroutine, so a failed bind surfaced as a log line while Run carried on to announce that the application was running.

The frozen route table is what closes all of that. Its cost is the one rule above: declare before Run.

Clone this wiki locally