Skip to content

App lifecycle

gas.App serves HTTP. It embeds a gas.Worker, which owns the DI container, the event bus, migrations, and service management; the App adds the router, CSRF protection, and the HTTP server. Everything on this page about startup and shutdown applies to a Worker too, except the steps marked App only. For processes without an HTTP server, see Workers.

app.Run() is the whole lifecycle in one call: start, serve, wait for SIGINT or SIGTERM, stop. It uses context.Background() for startup and shutdown.

It is built from smaller methods, which you call yourself when something else owns the process, or in tests:

Method Does
Start(ctx) Builds and initializes services, runs migrations and ready hooks, binds the config. Does not block or listen.
Serve() Starts the HTTP server and blocks until it stops. A clean shutdown returns nil.
Handler() Returns what the server serves: the router behind CSRF protection.
Stop(ctx) Shuts the HTTP server down, then closes every service.

Run calls Stop whether a signal arrives or Serve fails, so a port that is already taken still closes the services Start initialized; Run then returns the server error joined with any error from Stop.

Start runs these steps in order. Knowing the order answers most “why hasn’t my thing happened yet” questions.

  1. Build singletons. The container validates every registration, runs Init on every Service registered as an instance, then constructs every singleton in dependency order, calling each one’s Init as it is built. The details are on Services and DI.

  2. Seal the router and check handlers (App only). The router builds its routing tree from everything registered so far; until then, Handler() serves none of your routes. Every DI-aware handler’s parameter types are checked against the container.

  3. Emit gas.SystemAllServicesInitialized, and wait for its subscribers to return.

  4. Run migrations. If a gas.MigrationManager is registered, its RunPending applies every pending migration. See Migrations.

  5. Run ready hooks, in registration order.

  6. Bind the config (App only). If a gas.ConfigProvider is registered, the App’s Config (server settings and environment) is bound from it, and gas.AppConfigUpdated is emitted with the result. The event is emitted even when no provider is registered.

Run then logs the route map and starts the server.

A failure in any step returns an error from Start, and the server never accepts a request against a half-built app. The error from a failed migration starts with gas: migrations:, from a ready hook with gas: ready hook:, and from config binding with gas: failed to bind config:.

Running the lifecycle sample prints the order directly.

When Start fails, it closes, in reverse order, every service it had already initialized, and returns the startup error joined with any Close errors. The Close calls get the context passed to Start with its cancellation removed, so cleanup runs even if that context is what failed.

The App or Worker cannot be started again: every later Start returns the same error, and a later Shutdown has nothing to close. Build a new one to retry.

Start, InitServices, Stop, Shutdown, CloseService, RestartService, ServiceContainer.BuildAll, ServiceContainer.NewScope, and Scope.Close all take a context. Run takes none.

Init of a singleton gets the context passed to Start (or to RestartService) with a per-service deadline added: 15 seconds by default.

  • gas.WithServiceInitTimeout(d) changes the default for an App or Worker. 0 disables the deadline, and a negative value panics.
  • A service that implements gas.ServiceInitTimeout sets its own. 0 means no deadline, and a negative value fails its Init without running it.
search.go
// Search needs longer than the default 15 seconds to warm up, so it sets its
// own Init deadline.
type Search struct{}
func NewSearch() *Search { return &Search{} }
func (s *Search) InitTimeout() time.Duration { return time.Minute }
func (s *Search) Name() string { return "search" }
func (s *Search) Init(ctx context.Context) error {
// Honor ctx: when the deadline passes it is canceled, and startup fails
// once Init returns.
return warmIndex(ctx)
}
func (s *Search) Close(context.Context) error { return nil }

When the deadline passes, the container cancels the context, waits for Init to return, and fails with an error that wraps context.DeadlineExceeded. An Init that ignores the cancellation and returns nil late is closed again before the error is returned, so nothing it acquired is left open.

While a deadline applies, the context is canceled as soon as Init returns. Use it for synchronous work in Init only: a goroutine started in Init must not derive its context from it. Workers shows the pattern for a background loop.

Init of a scoped service gets the scope’s context with the same deadline added, the default or the service’s own InitTimeout, and the same timeout handling. In an App that is the request’s context. A scoped Init that times out fails the resolution.

Close gets the caller’s shutdown context: the one passed to Stop, Shutdown, CloseService, or Scope.Close. In an App, a request’s scoped services are closed with the request’s context minus its cancellation, so they clean up even after the client has gone.

A ready hook is work that needs the built container but must finish before the app takes traffic, such as seeding data:

main.go
app := gas.NewApp(
gas.WithServiceInstance[*trace](t),
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
gas.WithSingletonService[*DB](NewDB),
gas.WithSingletonService[*Cache](NewCache),
gas.WithSingletonService[*Probes](NewProbes),
// Runs after migrations and before the server starts.
gas.WithReadyFunc(func(sc *gas.ServiceContainer) error {
db, err := sc.Resolve[*DB]()
if err != nil {
return err
}
return seed(db)
}),
)

Hooks run after migrations, so the schema exists, and before Start returns. They run in registration order, and the first error aborts startup; it is also logged as ready hook failed. app.ReadyFunc(fn) is the imperative form, and panics if it is called after the hooks have run, because the function would never run.

Run shuts down on SIGINT or SIGTERM. Stop(ctx) does it explicitly:

  1. Emit gas.SystemServerShuttingDown and wait for its subscribers (App only).

  2. Shut the HTTP server down gracefully, giving in-flight requests up to Server.ShutdownTimeout (30 seconds by default), or less if ctx ends first (App only). An error here, including the timeout, is logged as server shutdown error and returned, and the sequence continues.

  3. Emit gas.SystemShuttingDown and wait for its subscribers.

  4. Close every active service in reverse initialization order, passing ctx to each Close. Services already closed with CloseService are skipped.

Steps 3 and 4 are Worker.Shutdown, which is all a Worker does. A failing Close does not stop the others; every Close error is returned, joined, each prefixed with gas: close <name>:. An App’s Stop returns the server shutdown error and those errors joined, prefixed with gas: shutdown error:, or nil when both steps succeed.

Shutdown runs once. Later calls, including concurrent ones, wait for the first to finish and return nil. Once it has begun, CloseService and RestartService return an error.

Handler() returns exactly what the server would serve, so a test can drive the full middleware stack without binding a port:

main.go
// Start builds and initializes every service, runs migrations and ready
// hooks, and binds the config. It does not block or listen.
if err := app.Start(context.Background()); err != nil {
log.Fatal(err)
}
// Handler is what the server would serve: the router behind CSRF
// protection.
srv := httptest.NewServer(app.Handler())
resp, err := http.Get(srv.URL + "/readyz")
if err != nil {
log.Fatal(err)
}
_ = resp.Body.Close()
srv.Close()
// Stop shuts the server down, then closes every service in reverse
// initialization order.
if err := app.Stop(context.Background()); err != nil {
log.Fatal(err)
}

More on this in Testing.

A service opts in to probes by implementing either interface:

type HealthReporter interface {
CheckHealth(ctx context.Context) error // nil means healthy
}
type ReadyReporter interface {
CheckReady(ctx context.Context) error // nil means ready
}

Worker.CheckHealth(ctx) and Worker.CheckReady(ctx) call every active service that implements the interface, concurrently, and return a map from service name to result. Services that do not implement it are left out, and so are services closed with CloseService. A reporter that panics is recovered and reported as an error.

The Worker registers itself in the container as gas.HealthProvider and gas.ReadyProvider, the interfaces those two methods satisfy, so a service can inject them. Gas adds no probe endpoints of its own; expose them like any other route:

probes.go
// Probes exposes liveness and readiness endpoints. gas.HealthProvider and
// gas.ReadyProvider are registered by the Worker itself.
type Probes struct {
router *gas.Router
health gas.HealthProvider
ready gas.ReadyProvider
}
func NewProbes(router *gas.Router, health gas.HealthProvider, ready gas.ReadyProvider) *Probes {
return &Probes{router: router, health: health, ready: ready}
}
func (p *Probes) Name() string { return "probes" }
func (p *Probes) Init(context.Context) error {
p.router.Get(p, "/livez", func(ctx gas.Context) error {
return report(ctx, p.health.CheckHealth(ctx))
})
p.router.Get(p, "/readyz", func(ctx gas.Context) error {
return report(ctx, p.ready.CheckReady(ctx))
})
return nil
}
func (p *Probes) Close(context.Context) error { return nil }
// report answers 200 when every reporter returned nil, and 503 with the
// failing services otherwise.
func report(ctx gas.Context, results map[string]error) error {
failing := make(map[string]string)
for name, err := range results {
if err != nil {
failing[name] = err.Error()
}
}
if len(failing) > 0 {
return ctx.JSON(http.StatusServiceUnavailable, failing)
}
return ctx.NoContent()
}

The two answer different questions. Health (liveness) asks whether the process is broken in a way a restart would fix: a connection that will not recover, corrupt internal state. Readiness asks whether traffic should be sent to this instance right now: a dependency is briefly unreachable, a cache is still warming. Report a transient outage as unready, never unhealthy, or a dependency blip becomes a restart loop.

The lifecycle sample registers DB and Cache (which depends on DB), subscribes to the system events, adds a ready hook, serves one request, and stops. Its output, with log lines left out:

GET /readyz: 204
init db
init cache
event SystemAllServicesInitialized
ready hook
event AppConfigUpdated
event SystemServerShuttingDown
event SystemShuttingDown
close cache
close db

There is no migration step in the output because the sample registers no gas.MigrationManager. The events are covered in Events.