Lifecycle
The framework drives one pipeline, sequentially and in dependency order — there is no concurrency in the core, ever. Your services never need locks to protect their lifecycle methods, and the ordering guarantees below are exact, not statistical.
The pipeline#
init() catalog entries → composition (Solo / Builder / Main)
1. Build — resolve the accept set, apply renames, instantiate every
accepted service through its factory; report every violation at once
2. dispatch — the applet is known; the env prefix and config section
are fixed from its primary alias
3. lenient first pass: the core's own config from args/env
(--config, --help, disable/enable/override, …); unknown args ignored
4. config file discovery and loading
5. closure resolution — applet + transitive dependencies, with
disable/enable/override applied; cold (unused) services ejected
6. strict full parse — the complete schema is now known;
unknown argument = error
7. fill each closure member's config struct (merged values, in place)
8. inject dependency fields
9. the translator subtree is configured first, so messages can render
10. Configured() on each remaining Configurable, dependency order
11. assemble the log multihandler, replay the startup buffer,
swap the slog default
12. Start() on each Starter, dependency order, sequential
13. code = applet.Run() (Windows service mode: applet.Execute(…))
14. Stop() on each started Starter, exact reverse of start order
15. os.Exit(code)Three orderings that look odd until they don't:
- Instances are built at composition (step 1), not at registration. Factories are cheap by contract — allocate and set defaults, no I/O — because everything accepted is constructed whether or not it ends up in the closure.
- Arguments are parsed twice (steps 3 and 6) because the full
argument schema is only known after closure resolution — and closure
resolution itself is configuration-driven (
enablecan pull a service in), so the core's own values must be read first, leniently. - The translator goes first (step 9). Anything that renders a message
needs it, including
--help, so its dependency subtree is injected and configured ahead of the main pass — which then skips those members, so no service ever seesConfigured()twice. A translator that fails to configure does not fail startup: messages degrade to their raw ids and one warning is buffered. --helpand--write-configshort-circuit. Help renders the dispatched applet's full schema with current effective values and exits 0; write-config exits 0 right after step 7's merge. Neither ever reachesConfigured,StartorRun.--validate-configlikewise reports and exits without running anything.
What your service experiences#
By the time a lifecycle method runs, the earlier steps are settled:
Configured()— your config struct holds the final merged values, and yourinjectfields are set. Every dependency'sConfigured()has already run. Acquire resources here if you want to be useful during the startup replay (sinks do — see Logging).Start()— every dependency is configured and started. Do the things that need the whole graph live.Run()— applets only. The application lifecycle brackets it: everything is started before, everything stops after. This is why an applet must not implementStarter/Stopperitself.Stop()— called in the exact reverse order of the successfulStartcalls: your dependencies are still running while you stop. A service whoseStartnever ran (or failed) is never asked to stop.
Cycles#
Dependency cycles are legal but logged as warnings. Injection is
unaffected — every instance exists before anything runs — but the
started-before-you promise cannot hold inside a cycle. Ordering treats
each cycle as a single unit (the strongly-connected-components
condensation of the dependency graph, for the graph-theory inclined).
The guarantee holds between those units. Within a cycle,
composition order applies, and a member may receive Start with a
dependency that is injected but not yet started. If you close a cycle,
tolerate that.
Failure semantics#
- Any error before
Run— config parsing, resolution, aConfiguredorStartreturning an error — aborts startup: services already started get their reverse-orderStopfirst, the buffered startup log flushes to stderr, and the process exits with the framework's exit code 2. Stoperrors are logged, never change the exit code, and never prevent the remainingStopcalls. Shutdown always runs to the end.- Applet panics are not recovered. The applet owns its own recovery
and returns its error code from
Run()— the framework refuses to turn a crash into a half-alive process. - Exit codes:
Run()'s return value is the process exit code; framework-detected failures exit 2; a dispatch failure prints usage to stderr and exits non-zero. - In Windows service mode, failures map to the appropriate SCM status instead.
The reserved ConfigurationUpdated() hook (config reload) is designed
but deliberately unimplemented — and constrained in advance: a reload
will only ever re-fill config values of closure members. The graph is
immutable once resolved; nothing is added, removed or rewired at
runtime.
Related: Services & injection for how the closure is declared, Core config & arguments for the operator-facing knobs, and the design spec for the full rules.