app

package
v1.8.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 23, 2026 License: MIT Imports: 12 Imported by: 0

README

app

The top-level lifecycle orchestrator. Collects lifecycle.Runner and lifecycle.Resource components, starts runners concurrently under an errgroup, handles SIGINT/SIGTERM, and performs two-phase LIFO shutdown with a bounded timeout.

The single entry point to the application lifecycle. Handles signal-driven shutdown, resource ordering, and healthcheck aggregation.

When to use

Always — every service built on core should use app.App as the single entry point to the lifecycle. It removes ~15 lines of boilerplate from every main.go and enforces a consistent shutdown contract across the service.

Quickstart

package main

import (
    "log"
    "time"

    "github.com/sergeyslonimsky/core/app"
    coregrpc "github.com/sergeyslonimsky/core/grpc"
    "github.com/sergeyslonimsky/core/http2"
)

func main() {
    httpSrv := http2.NewServer(http2.Config{Port: "8080"})
    grpcSrv := coregrpc.NewServer(coregrpc.Config{Port: "50051"})

    a := app.New(app.WithShutdownTimeout(30 * time.Second))
    a.Add(httpSrv, grpcSrv)

    if err := a.Run(); err != nil {
        log.Fatal(err)
    }
}

API

func New(opts ...Option) *App
func WithLogger(l *slog.Logger) Option
func WithShutdownTimeout(d time.Duration) Option
func WithSignals(sigs ...os.Signal) Option

func (a *App) Add(components ...any)                        // type-switch dispatch (Runner vs Resource)
func (a *App) AddRunner(runners ...lifecycle.Runner)        // compile-time typed
func (a *App) AddResource(resources ...lifecycle.Resource)  // compile-time typed
func (a *App) Run() error
func (a *App) Healthcheck(ctx context.Context) error
func (a *App) Logger() *slog.Logger

Prefer AddRunner / AddResource when the role is known at the call site — the compiler rejects mistakes instead of the runtime type switch panicking.

Options

  • WithLogger(*slog.Logger) — inject a logger and also set slog.SetDefault. Default: slog.Default().
  • WithShutdownTimeout(time.Duration) — upper bound on the Shutdown phase. Default: 30s.
  • WithSignals(...os.Signal) — signals that trigger shutdown. Default: os.Interrupt, syscall.SIGTERM. Pass an empty list to disable signal-driven shutdown (useful in tests).

Add — dispatch rules

a.Add(otelProvider, db, redisClient)   // Resources — registered for Shutdown only
a.Add(httpServer, grpcServer)           // Runners   — registered for Run + Shutdown

Each argument must implement lifecycle.Runner or lifecycle.Resource. Since Runner embeds Resource, the type switch checks Runner first to avoid double-registration.

Add panics if a component implements neither interface or is called after Run — both are startup-time programming errors.

Lifecycle semantics

Startup
  1. Run creates a signal-aware context via signal.NotifyContext.
  2. All registered Runners are started concurrently in an errgroup.
  3. Run blocks until the first of:
    • A signal is received, OR
    • Any runner returns a non-nil error, OR
    • All runners complete (rare, typically only in tests).
Shutdown (two-phase, LIFO)

When Run unblocks, the shutdown phase begins:

  1. A fresh context is built with context.WithTimeout(context.Background(), shutdownTimeout)NOT the already-cancelled run context.
  2. Every Runner has Shutdown(shutdownCtx) called in reverse registration order. Runners registered last shut down first — so frontends stop accepting traffic before backends are closed.
  3. Every Resource has Shutdown(shutdownCtx) called in reverse registration order. Typical pattern: OTel is registered first so it shuts down last, letting other components flush telemetry.
  4. All errors are joined via errors.Join and returned. context.Canceled from the run phase is treated as normal termination and omitted.
a.Add(otelProvider)     // Resource: registered first → shuts down LAST
a.Add(sqlDB)            // Resource
a.Add(redisClient)      // Resource
a.Add(kafkaProducer)    // Resource
a.Add(grpcServer)       // Runner: registered before http → shuts down second
a.Add(httpServer)       // Runner: registered last → shuts down FIRST

Under SIGTERM, the sequence becomes:

httpServer.Shutdown → grpcServer.Shutdown      (stop accepting traffic)
   ↓
kafkaProducer.Shutdown → redisClient.Shutdown → sqlDB.Shutdown   (close pools)
   ↓
otelProvider.Shutdown                                             (flush telemetry)

Healthcheck aggregation

err := a.Healthcheck(ctx)

Iterates every component that implements lifecycle.Healthchecker and returns errors.Join of all failures, or nil if all are healthy. App itself implements Healthchecker, so you can wire it into an HTTP /readyz handler via:

httpServer := http2.NewServer(cfg, http2.WithHealthcheckFrom(a))

Extending

No UnwrapApp is a terminal orchestrator, not a wrapper. If you need lower-level control, build your own runner atop errgroup and call Run(ctx) from your code. But almost every case that looks like it needs that is better served by adding more Runners to the same App.

Testing

// Disable signal handling so tests don't interact with the process's signals.
a := app.New(app.WithSignals())

// Inject a short shutdown timeout to keep tests fast.
a := app.New(app.WithShutdownTimeout(100*time.Millisecond))

See app_test.go for LIFO-ordering, healthcheck, and timeout tests using a recorder-based fake.

See also

  • core/lifecycle — the Resource, Runner, Healthchecker interfaces.
  • core/di — optional thin container that returns a typed Config/Services pair for a consistent DI pattern.

Documentation

Overview

Package app provides the top-level orchestrator that ties every lifecycle.Runner and lifecycle.Resource registered by a service into a single managed lifecycle: signal-aware startup, errgroup-based run, and two-phase LIFO shutdown with a bounded timeout.

A typical main.go looks like:

func main() {
    a := app.New(app.WithShutdownTimeout(30 * time.Second))
    a.Add(otelProvider, db, redisClient)   // Resources
    a.Add(grpcServer, httpServer)          // Runners
    if err := a.Run(); err != nil {
        log.Fatal(err)
    }
}

See the package README.md for the full semantics of the two-phase lifecycle and the rationale for LIFO shutdown ordering.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type App

type App struct {
	// contains filtered or unexported fields
}

App is the top-level lifecycle orchestrator. It collects Runner and Resource components via Add, starts all Runners when Run is called, and shuts them down in reverse registration order when the context is cancelled (typically by SIGINT/SIGTERM).

App is safe for single-goroutine use during setup. Add must not be called after Run — doing so is a programming error and will panic.

func New

func New(opts ...Option) *App

New creates an App with the given options. Defaults:

  • Logger: slog.Default()
  • Shutdown timeout: 30 seconds
  • Signals: SIGINT, SIGTERM

func (*App) Add

func (a *App) Add(components ...any)

Add registers one or more components. Each argument must implement lifecycle.Runner or lifecycle.Resource. Runners go to the runners slice (Run + Shutdown); pure Resources go to the resources slice (Shutdown only). Since Runner embeds Resource, the type switch checks Runner first to avoid double-registration.

Add panics if a component implements neither interface — this indicates a programming error at startup and there is no reasonable recovery. The panic message includes the component's type for easy diagnosis.

Add must not be called after Run. Calling it concurrently with Run is undefined behavior.

Prefer AddRunner and AddResource when the component's role is known at the call site — they give compile-time type safety.

func (*App) AddResource

func (a *App) AddResource(resources ...lifecycle.Resource)

AddResource registers one or more Resources. Equivalent to Add with compile-time type safety — the compiler rejects non-Resource arguments.

Note: a lifecycle.Runner is also a lifecycle.Resource (Runner embeds Resource), so passing a Runner here registers it as Resource-only — its Run method will NOT be invoked. Use AddRunner when Run is required.

Must not be called after Run; panics otherwise.

func (*App) AddRunner

func (a *App) AddRunner(runners ...lifecycle.Runner)

AddRunner registers one or more Runners. Equivalent to Add with compile-time type safety — the compiler rejects non-Runner arguments.

Must not be called after Run; panics otherwise.

func (*App) Healthcheck

func (a *App) Healthcheck(ctx context.Context) error

Healthcheck aggregates every registered component that implements lifecycle.Healthchecker. It returns nil if all checkers report healthy, or errors.Join of every failing checker's error otherwise.

Typical use: mount this on /readyz inside an http2.Server via http2.WithHealthcheckFrom(a).

Components that do not implement lifecycle.Healthchecker are silently skipped — they contribute neither success nor failure to the result.

Healthcheck is safe to call concurrently and at any time, including after Run has returned. It does not take App-level locks — checkers are iterated from the slices populated at startup.

func (*App) Logger

func (a *App) Logger() *slog.Logger

Logger returns the *slog.Logger configured via WithLogger, or slog.Default() if none was set. Components that want to emit lifecycle-scoped logs can retrieve the app's logger here.

func (*App) Run

func (a *App) Run() error

Run starts every registered Runner concurrently, installs signal handlers, and blocks until:

  1. A signal from WithSignals is received, OR
  2. A Runner's Run method returns a non-nil error, OR
  3. All Runners complete normally (rare — typically only in tests).

On any of these, Run begins the shutdown phase:

  • A fresh context with WithShutdownTimeout deadline is created.
  • Runners have Shutdown called in reverse registration order (so frontends stop accepting traffic before backends are closed).
  • Resources then have Shutdown called in reverse registration order.
  • All shutdown errors, plus the original run error (if any), are joined and returned via errors.Join.

A context.Canceled from the first phase is treated as normal termination and not included in the returned error.

type Option

type Option func(*App)

Option configures an App. Options are applied in order by New.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger injects a *slog.Logger that App will use for lifecycle events (startup, shutdown order, errors). As a convenience it also calls slog.SetDefault so third-party libraries and the stdlib log package route through the same handler.

If no logger is provided, App uses slog.Default().

func WithShutdownTimeout

func WithShutdownTimeout(d time.Duration) Option

WithShutdownTimeout overrides the default 30-second shutdown budget. The timeout bounds the total time allowed for the shutdown phase — Runners and Resources share this single deadline, applied via context.WithTimeout in Run.

Setting a timeout shorter than any individual component's graceful-stop duration will cause that component to be force-closed — this is the correct behavior for bounded-duration shutdowns, but callers should size the timeout accordingly.

func WithSignals

func WithSignals(sigs ...os.Signal) Option

WithSignals overrides the default signal set (SIGINT, SIGTERM) that triggers App shutdown. Pass an empty slice to disable signal-driven shutdown entirely — the app will then only stop when Run's context (if supplied via an external mechanism) is cancelled.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL