Application lifecycle
This guide covers the lifecycle of a Tinka App. You will learn:
- The phases an App goes through: boot, start, ready and shutdown
- How to run an App as a web server with
Serve - Where to register routes and run setup code
- How an App shuts down gracefully, and how long it waits
- What happens when the App fails to start
Overview
An App moves through four phases. It boots: providers register their services and configure them. It starts: providers and your start hooks assemble the App, registering routes. It becomes ready: it serves requests, and long-running work begins. Finally it shuts down: it stops taking requests, finishes the ones in flight, and releases every resource it opened.
The order is fixed, so every piece of setup and cleanup code has one right place, and runs after what it depends on. Service providers hook into each phase for the services they bring, and the App has hooks of its own for application code.
Serve the App
Serve runs the whole lifecycle for a web server: it boots and starts the App, listens on its address, runs the ready hooks, and serves requests until the process receives SIGINT (Ctrl+C) or SIGTERM (a deployment stopping it). Then it shuts the App down gracefully and returns.
package main
import (
"context"
"log/slog"
"os"
"github.com/mucolabs/tinka"
)
func main() {
app := tinka.New(tinka.HTTPConfig{Addr: ":3000"})
app.Provide(&DatabaseProvider{URL: os.Getenv("DATABASE_URL")})
app.OnStart(func(ctx context.Context) error {
return routes(app)
})
if err := app.Serve(context.Background()); err != nil {
slog.Error("server failed", "err", err)
os.Exit(1)
}
}Serve returns nil after a clean shutdown. Any error, from starting, from serving or from shutting down, is returned for main to report, and a non-zero exit code tells your platform the App failed.
The boot phase
Each provider's Register hook binds its services in the container, in the order the providers were added. Then each provider's Boot hook configures services, now that every provider registered, and the App's boot hooks run. Last, the container is validated: a service that depends on something nobody registered stops the App here, at boot, instead of on the first request that needs it.
The start phase
Each provider's Start hook runs, then the App's start hooks. This is where the App is assembled, and where your routes belong: a start hook runs after every provider set up what routes use, such as a database pool or a template renderer.
func routes(app *tinka.App) error {
posts, err := app.Container().Make[*PostService]()
if err != nil {
return err
}
app.Get("/posts", posts.List)
app.Get("/posts/{id}", posts.Show).As("posts.show")
return nil
}Routes that need no services can be registered in main directly, before Serve.
The ready phase
Once the App listens, each provider's Ready hook runs, then the App's ready hooks, and the App logs server started with its address. Use ready hooks for long-running work that should begin only when the App serves: background workers, scheduled jobs, or a message that the deployment is live.
The context a ready hook receives ends when shutdown begins, so work started with it stops in time:
app.OnReady(func(ctx context.Context) error {
go reports.RunNightly(ctx) // returns once ctx ends
return nil
})Requests can arrive while ready hooks run: the server accepts them from the moment it listens.
The shutdown phase
When Serve receives a signal, the App shuts down in this order:
- Readiness starts failing, and the App keeps serving for
DrainDelay, if set, so load balancers stop sending requests. - The server stops accepting connections and waits for requests in flight to finish.
- The App's shutdown hooks run, the last added first.
- Each provider's
Shutdownhook runs, in the reverse order the providers were added, so a provider shuts down before those it depends on. - The container shuts down, closing every service it built that has a
CloseorShutdownmethod.
Every step runs even when one fails, so one service failing to close never leaks the others, and Serve returns every error together. A second signal during shutdown ends the process at once.
app.OnShutdown(func(ctx context.Context) error {
return queue.Flush(ctx)
})The whole shutdown has a deadline, ShutdownTimeout, 10 seconds by default. Requests still running when it passes are cut off, and their connections closed. Keep the timeout below your platform's own deadline, such as the 30-second grace period Kubernetes gives a pod, so the App finishes its cleanup before the platform kills it.
app := tinka.New(tinka.HTTPConfig{
Addr: ":3000",
ShutdownTimeout: 20 * time.Second,
})When starting fails
A hook that returns an error stops the App from starting, and so does an address that's already in use. The App then shuts down whatever it set up so far, calling Shutdown on every provider that registered, and Serve returns the error together with any from shutting down. Nothing stays open behind a failed start.
Run the App without a server
Start runs the boot, start and ready phases without listening, and Shutdown runs the shutdown phase. Tests and Commands that need the App's services use them:
func TestListPosts(t *testing.T) {
app := newApp()
if err := app.Start(t.Context()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = app.Shutdown(context.Background()) })
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("GET", "/posts", nil))
if w.Code != http.StatusOK {
t.Errorf("GET /posts = %d", w.Code)
}
}Boot, Start and Shutdown each run once: calling one again does nothing. app.State() reports how far the App has come: created, booted, started, ready, shutting down or stopped.
See also: Service providers, Dependency injection, App