-
Notifications
You must be signed in to change notification settings - Fork 0
Lifecycle
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.
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.
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.
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.
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.
| 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.
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.
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.
rex — Restful Extended eXperience · MIT · © 2026 Kryovyx · pre-1.0 (alpha), interfaces may change
Getting started
Core
Operating
Ecosystem