Application Container¶
The root blueprint package provides a small application container (Container) and a process-wide
shutdown manager. Together they give an application a shared configuration provider, a cancellable
application context, a simple named service registry, OS signal handling and an ordered list of
cleanup functions ("destructors") that run when the application shuts down.
Overview¶
Containerholds aconfig.ConfigProvider, an applicationcontext.Contextand itsCancelFunc- A thread-safe, name-keyed service registry (
Register,Get,Exists) Runexecutes a list of startup functions and then blocks, waiting forSIGINT,SIGTERMorSIGHUP- Destructors are registered globally with
RegisterDestructorand executed in reverse registration order byShutdown AbortFatalruns the destructors, logs the error and terminates the process- Destructors run at most once per process
The shutdown manager is package-level state, not part of Container: every container in the process
shares the same destructor list. Blueprint's own log package registers a destructor from its init()
function to close log files opened by file logging.
Usage¶
Basic Application¶
package main
import (
"errors"
"net/http"
"github.com/oddbit-project/blueprint"
"github.com/oddbit-project/blueprint/config/provider"
"github.com/oddbit-project/blueprint/log"
"github.com/oddbit-project/blueprint/utils"
)
func main() {
utils.PanicOnError(log.Configure(log.NewDefaultConfig()))
logger := log.New("my-app")
cfg, err := provider.NewJsonProvider("config.json")
if err != nil {
logger.Error(err, "failed to load configuration")
return
}
app := blueprint.NewContainer(cfg)
srv := &http.Server{Addr: ":8080"}
// executed by Shutdown(), in reverse registration order
blueprint.RegisterDestructor(func() error {
return srv.Shutdown(blueprint.ShutdownContext())
})
// Run() never returns; the process exits when a signal is received
// or when the application context is cancelled
app.Run(func(a interface{}) error {
c := a.(*blueprint.Container)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
c.AbortFatal(err)
}
}()
return nil
})
}
The functions passed to Run are expected to be non-blocking: they are executed sequentially on the
calling goroutine, and the signal loop only starts after all of them return. Long-running work
(servers, consumers) should be started in its own goroutine, as above.
Embedding the Container¶
Applications usually embed *blueprint.Container in their own application type (see
samples/httpserver-session and samples/application):
package main
import (
"github.com/oddbit-project/blueprint"
"github.com/oddbit-project/blueprint/config/provider"
"github.com/oddbit-project/blueprint/log"
)
type AppConfig struct {
Name string `json:"name"`
}
type Application struct {
*blueprint.Container
logger *log.Logger
}
func NewApplication(configFile string) (*Application, error) {
cfg, err := provider.NewJsonProvider(configFile)
if err != nil {
return nil, err
}
return &Application{
Container: blueprint.NewContainer(cfg),
logger: log.New("application"),
}, nil
}
func (a *Application) Build() {
cfg := &AppConfig{}
// AbortFatal is a no-op for nil errors
a.AbortFatal(a.Config.Get(cfg))
a.logger.Infof("building %s", cfg.Name)
}
func main() {
app, err := NewApplication("config.json")
if err != nil {
panic(err)
}
app.Build()
app.Run()
}
Service Registry¶
The container keeps a map of named services. Values are stored as interface{} and must be
type-asserted when retrieved:
package main
import (
"fmt"
"github.com/oddbit-project/blueprint"
"github.com/oddbit-project/blueprint/config/provider"
)
type Mailer struct {
From string
}
func main() {
app := blueprint.NewContainer(provider.NewEnvProvider("APP_", false))
app.Register("mailer", &Mailer{From: "noreply@example.com"})
if app.Exists("mailer") {
svc, _ := app.Get("mailer")
mailer := svc.(*Mailer)
fmt.Println(mailer.From)
}
if _, ok := app.Get("cache"); !ok {
fmt.Println("cache not registered")
}
}
Register overwrites any service previously registered under the same name.
Shutdown and Destructors¶
Destructors are func() error callbacks (callstack.CallableFn) registered process-wide. Shutdown
executes them in reverse order of registration (last registered, first executed). The destructor list
is discarded before the destructors run, so subsequent Shutdown calls do not run them again:
package main
import (
"fmt"
"github.com/oddbit-project/blueprint"
)
func main() {
blueprint.RegisterDestructor(func() error {
fmt.Println("close database") // runs second
return nil
})
blueprint.RegisterDestructor(func() error {
fmt.Println("stop http server") // runs first
return nil
})
blueprint.Shutdown(nil)
blueprint.Shutdown(nil) // destructors already executed, nothing to run
}
All destructors are executed even if some of them fail; their errors are combined with errors.Join
and logged once with the message "Error while shutting down".
When using Container.Run, Shutdown is invoked automatically on SIGINT, SIGTERM and SIGHUP,
when the application context is cancelled, and by AbortFatal.
A destructor that stops something with a deadline (http.Server.Shutdown, PeriodicRunner.Stop)
passes it ShutdownContext(). That context is not cancelled when the destructors start, and its
deadline is the shutdown timeout (DefaultShutdownTimeout, 5s; change it with SetShutdownTimeout)
counted from the start of Shutdown, shared by all destructors. Shutdown does not enforce the
timeout: a destructor that does not use ShutdownContext runs as long as it takes. Keep the timeout
below the grace period of whatever stops the process (docker stop: 10s, Kubernetes: 30s), or the
destructors after a slow one are killed before they run.
blueprint.SetShutdownTimeout(10 * time.Second)
blueprint.RegisterDestructor(func() error {
return server.Shutdown(blueprint.ShutdownContext())
})
Do not pass GetContext() instead. On a signal or a cancelled application context, it is already
cancelled when the destructors run, so server.Shutdown returns context.Canceled at once and requests
in flight are dropped. On AbortFatal, or a direct Shutdown call, it is still live and has no deadline,
so server.Shutdown waits as long as the slowest request.
Aborting on Fatal Errors¶
AbortFatal is designed for inline error checks during application setup. It does nothing when the
error is nil; otherwise it runs the destructors, logs the error at fatal level and exits:
If a function passed to Run returns an error, Run calls AbortFatal with it.
Lifecycle¶
Run(mainFn ...RuntimeFn) performs the following steps:
- Registers a signal channel (buffer size 1) for
SIGINT,SIGTERMandSIGHUP - Calls each
mainFnin order, passing the container itself; the first error triggersAbortFatal(err) - Loops forever:
- on a signal: logs
"Shutting down application...", cancels the application context and then callsShutdown(nil)(destructors run); the cancelled context is picked up on the next iteration - on application context cancellation: stops signal notification, calls
Shutdown(nil)(destructors run, unless they already did) and callsTerminate(nil), which exits the process
- on a signal: logs
Run never returns.
How the process exits depends on the path taken:
| Trigger | Destructors run | Exit code |
|---|---|---|
SIGINT / SIGTERM / SIGHUP received by Run |
yes | 0 |
CancelCtx() called while Run is looping |
yes | 0 |
AbortFatal(err) / mainFn returns an error |
yes | 1 (from the fatal log call) |
AbortFatal(err) after Shutdown has already run |
no (already executed) | 1 (from the fatal log call) |
Terminate(nil) |
no | 0 |
Terminate(err) with err != nil |
no | -1 (255 on Unix) |
API Reference¶
Types¶
RuntimeFn¶
Startup function executed by Container.Run. The app argument is the *Container on which Run
was called.
Container¶
type Container struct {
Config config.ConfigProvider
Context context.Context
CancelCtx context.CancelFunc
// contains unexported fields
}
Config- configuration provider passed toNewContainerContext- application context, derived fromcontext.Background()CancelCtx- cancelsContext; whenRunis active, cancelling it runs the destructors and terminates the process
Functions¶
NewContainer¶
Creates a container with the given configuration provider, a new cancellable application context
and an empty service registry. The provider is not validated and may be nil.
RegisterDestructor¶
Adds fn (func() error) to the global destructor list. Destructors are executed by Shutdown in
reverse registration order. Calling RegisterDestructor while Shutdown is running, or after it has
run, is a no-op: fn is silently discarded.
GetDestructorManager¶
Returns the global destructor *callstack.CallStack (package github.com/oddbit-project/blueprint/types/callstack).
Returns nil after Shutdown has run.
Shutdown¶
Clears the destructor list and runs the destructors it held (in reverse order), so the destructors are
executed only once. A call made while the destructors are running (from a destructor, or from another
goroutine) does not wait for them: with a nil arg it returns immediately, and with a non-nil arg it
logs the fatal error and exits without running the remaining destructors. Later calls find no destructors
to run. Destructor errors are combined
with errors.Join and logged at error level. If arg is not nil, it is then logged with the global
zerolog logger at fatal level, which exits the process with code 1; this happens on every call with a
non-nil arg, including when the destructors have already run.
ShutdownContext¶
Returns the context destructors pass to Shutdown/Stop calls; call it from a destructor, since
elsewhere it may run before Shutdown has started. While Shutdown runs the destructors,
it has one deadline, the shutdown timeout counted from the start of Shutdown, shared by all
destructors. Before the first Shutdown it returns context.Background(); once Shutdown has run the
destructors, it returns a cancelled context.
SetShutdownTimeout¶
Sets the deadline of ShutdownContext for the next Shutdown, counted from its start. The default is
DefaultShutdownTimeout (5s). A d of zero or less gives destructors an already expired context. Keep
it below the grace period of whatever stops the process.
Methods¶
Register¶
Stores service under name, replacing any existing entry. Thread-safe.
Get¶
Returns the service registered under name and whether it exists. Thread-safe.
Exists¶
Reports whether a service is registered under name. Thread-safe.
GetContext¶
Returns the application context (c.Context). On a signal it is already cancelled when the destructors
run, and on AbortFatal it has no deadline, so destructors use ShutdownContext for shutdown deadlines.
Run¶
Executes the startup functions and blocks, handling OS signals and context cancellation as described in Lifecycle. Never returns.
AbortFatal¶
No-op if err is nil. Otherwise calls Shutdown(err) followed by Terminate(err).
Terminate¶
Cancels the application context (if not already cancelled) and exits the process with os.Exit: code
0 if err is nil, -1 otherwise. Only the first call proceeds; concurrent or later calls return
immediately. Terminate does not run destructors - call Shutdown first if needed.
Notes and Gotchas¶
- Destructor errors are logged, not returned.
Shutdownruns every destructor, joins their errors witherrors.Joinand logs the result; the caller does not receive them. - Graceful shutdown from application code. Calling
CancelCtx()whileRunis active runs the destructors and exits with code0. On a signal, the context is cancelled before the destructors run, so goroutines watchingContextcan start winding down while resources are being released. GetContext()is not a shutdown deadline. On a signal it is cancelled before the destructors run, soserver.Shutdown(app.GetContext())returnscontext.Canceledat once and drops requests in flight; onAbortFatalit has no deadline. PassShutdownContext()instead.os.Exitsemantics apply.Terminateand the fatal log inShutdowncallos.Exit: deferred functions do not run, and goroutines watching the application context are not waited for.RegisterDestructorduring or afterShutdownis a no-op, andGetDestructorManagerreturnsnilafterShutdown.- Re-entrancy. A destructor may call
RegisterDestructor(it is ignored) andShutdown(nil)(it returns immediately). A destructor callingAbortFatalorShutdown(err)logs the error and exits the process with code 1 at once, skipping the destructors that have not run yet. Runwaits for an in-progress shutdown. If another goroutine startedShutdownand the context is then cancelled,Runwaits for those destructors to finish before exiting. CallingTerminatedirectly does not wait.- Destructors are global. They are shared across all containers in the process, and run in reverse
order, so destructors registered in
init()functions (e.g. thelogpackage's file closer) run last. SIGHUPterminates the application; it is not treated as a reload signal.- Startup functions must not block, otherwise
Runnever reaches its signal loop. A signal received during startup is buffered and handled once all startup functions return. - Exit code for errors.
AbortFatalandShutdown(err)always log the error and exit with code 1 via zerolog's fatal handler; the-1code inTerminateis only reached when the fatal log does not exit (a customzerolog.FatalExitFuncthat returns).