Services & injection
But I just want some arguments#
Covered — and you can stop reading now. A single applet with a config struct is a complete, first-class use of the framework: Getting started plus Your config struct is everything you need, and the minimal CLI demo shows the whole program. No services to declare, no injection tags, no lifecycle to think about — and you don't pay for what you don't use: anything outside your applet's dependency closure is never configured, never started, its arguments not even parsed. Come back to this page the day two parts of your binary need to share a component.
Everything is a service#
A service is a struct instance registered under an id from a package
init() (or from main(), before fw.Main()):
func init() { s := &BoltStore{} fw.Register("boltstore", s, fw.Provides[Store](), fw.WithConfig(&s.cfg)) }
- The id is lowercase, Go-identifier style;
coreandintrospectionare reserved; duplicates are errors. The same concrete struct type registered twice is also an error. fw.Provides[I]()declares an interface the service can be injected as. The declaration is verified against the instance at registration — and only declared interfaces match injection, so an accidental structural match never wires anything.- The concrete type is recorded automatically: dependents may ask for the
service by
*BoltStorewithout any declaration. fw.WithMetadata(&fw.Metadata{…})optionally attaches a declarative layer: a long-form service description plus per-field docs, value hints and enforced value domains — see Your config struct for the field side.fw.Hidden()andfw.System()are applet-only options declaring visibility policy: a hidden command, or machinery a human never types — see Dispatch & applets.- After
Registerthe instance belongs to the framework: register a literal and keep no references. Services that end up outside the resolved closure are ejected from the registry so the garbage collector can reclaim them.
The service interfaces#
| Interface | Contract |
|---|---|
Stopper | Stop() error — the base lifecycle interface |
Starter | Start() error + Stopper — anything startable must be stoppable |
Configurable | Configured() error — owns a config struct, notified after it is filled |
Applet | Configurable + Run() int — the dispatchable entry point |
SCMApplet | Applet + Windows SCM Execute — see the Windows API |
AlwaysOn | structurally Starter; activated by declaring Provides[AlwaysOn]() |
ConfigurationUpdater | reserved for a future config-reload trigger |
Two rules the framework enforces:
- An applet must not implement
StarterorStopper— the application lifecycle brackets itsRun; there is nothing for the applet to start or stop. AlwaysOnservices run in every invocation of the binary, needed or not, taxing every invocation with their startup cost and failure modes. The framework ships exactly one (the console sink) and the advice is blunt: almost every service belongs in the normal closure instead.
Declaring dependencies#
Dependencies are exported fields of the registered instance carrying an
inject struct tag — grammar "<id>[,<id>…][;optional]":
type MyService struct { Log slog.Handler `inject:""` // by interface: first registered provider Sinks []slog.Handler `inject:""` // by interface: ALL registered providers Chosen []slog.Handler `inject:"id1,id2"` // listed ids seed the closure Store *BoltStore `inject:""` // by concrete type (unique) Extra slog.Handler `inject:";optional"` // nil if nothing matches }
- Single-valued fields get the first registered match, or the one named id — a single-valued field may name at most one.
- Slice fields take interface element types only, and deliver all enabled matching services in registration order. Listing ids seeds the closure with those services, but the slice may still contain more — always-on services and other closure members of the type ride along. A type-only slice pulls every registered service of that type into the closure; ids are how to narrow that.
;optionaltolerates zero matches (nil field, possibly-empty slice) — and tolerates a disabled target, but never an unknown one: an id that names no registered service is a startup error even on an optional field. A typo must never silently change the composition.- An
injecttag on an unexported field and a slice of concrete struct type are registration errors.
The closure#
At dispatch the framework resolves the applet's dependency closure:
the applet, every AlwaysOn service, and everything they transitively
require — with the operator's disable / enable / override
applied (see recomposing services). Only closure
members are configured, injected and started; everything else stays
cold — never configured, never started — and is ejected.
Ordering is dependency-ordered and sequential. Cycles are legal but
logged as warnings: injection is unaffected (all instances exist before
anything runs), but the started-before-you promise can only hold
between strongly connected components — within a cycle, registration
order applies, and a member may receive Start with an
injected-but-not-yet-started dependency.
Introspection#
The core registers exactly one service of its own: the Introspector,
under the reserved id introspection. It is the read-only composition
view for meta features built outside the core — completions,
documentation generators and their kin. Inject it by concrete type:
type Completions struct { Introspect *fw.Introspector `inject:""` }
The surface: Applets(), SingleApplet(), Services(),
ConfigExtensions(), Describe(serviceID) (the long-form registration
metadata), and Arguments(appletID, args) — the closure-true argument
schema the applet would have if invoked with args, computed by the
same planning pipeline execution uses (so introspection truth cannot
drift) with zero side effects: nothing written, ejected or mutated.
Two listing rules worth internalizing before building on it:
Applets()returns public applets only —HiddenandSystemare omitted, because a completion must not offer what a human should not type.SingleApplet()is dispatch-mode truth, taken from the dispatch rules themselves: the applet that runs with no selector word, or nothing if the binary is multi-applet. Never re-derive it by countingApplets()— that listing is public-only, while aHiddennon-Systemapplet still counts for the mode.
Arguments is best-effort by contract: when planning hits violations (a
broken config file, an unknown id in a control) it retries with no files
and no controls and returns that registration-level schema alongside
the error. A non-nil error therefore does not mean an empty result —
callers wanting candidates may ignore it; callers wanting diagnostics
must not. Pass the words before the completion cursor, never the
half-typed one: it would be planned as configuration.
Two properties worth knowing: there is exactly one Introspector — composition truth does not federate, so services cannot provide their own — and a closure that contains it is never ejected, because enumerating the binary requires the registry alive. Only invocations that inject it pay that cost.
Errors, all at once#
Registration never panics. Every violation — invalid or duplicate id, a
declared interface the instance doesn't implement, an applet implementing
Starter, Hidden/System on a service that is not an applet,
malformed tags, unexported inject fields, unsupported config field
types — is recorded, and startup fails reporting all of them in one
pass, not one per run.
The full semantics, including lifecycle ordering and failure handling, live in the design spec.