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 registered as a factory under an id, from a package
init():
package boltstore import "sxcli.dev/fw" const ID = "example.com/myapp/boltstore" // Store is the interface dependents ask for. type Store interface{ Get(key string) string } type Config struct { Version uint32 `json:"version"` Path string `json:"path" conf:"store-path" usage:"database file"` } type BoltStore struct{ cfg Config } func (s *BoltStore) Configured() error { return nil } func (s *BoltStore) Get(key string) string { return s.cfg.Path } func init() { fw.NewRegistration(ID, func() *BoltStore { return &BoltStore{cfg: Config{Version: 1}} }, func(s *BoltStore) *Config { return &s.cfg }, ). Alias("boltstore"). Provides(fw.Iface[Store]()). Register() }
- The id is path-shaped: slash-separated segments of lowercase
letters, digits,
.,-and_. Starting it with your package's import path is the convention that makes ids unique by construction — the runtime can only check the shape, sosxcli-vetis what enforces the convention. The framework's own idsxcli.dev/fwis reserved. - The alias is required — "name what operators will type". The first alias is the primary one, and it is what config sections, env prefixes and listings use. Extra aliases are alternative names that still select.
- The factory is cheap by contract: allocate and set config defaults,
nothing else. No I/O — that belongs in
Configured(). It is called once per composition, so two Apps never share an instance, which is what makes tests isolated. Provides(fw.Iface[I]()...)declares interfaces the service may be injected as. Declarations are verified against the concrete type at registration, and only declared interfaces match, so an accidental structural match never wires anything.- The concrete type is recorded automatically: dependents may ask for
*BoltStorewithout any declaration. Metadata(&fw.Metadata{…}),Migrate(…),Hidden()andSystem()are the remaining chain links — see Your config struct and Dispatch & applets.NewBareRegistration(id, factory)registers a service with no config struct at all.
Register() is all-or-nothing: if any check fails, nothing enters
the catalog, and registration never panics — the violations surface
together at startup.
Registering no longer hands over a pre-built instance. In v0.2.0 you registered a literal and were told to keep no references to it; now you hand over a factory and the composition owns what it builds.
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 |
ConfigurationUpdater | reserved for a future config-reload trigger |
An applet must not implement Starter or Stopper — the application
lifecycle brackets its Run; there is nothing for the applet to start or
stop. Registering one that does is a registration error.
AlwaysOnis gone. It ran a service in every invocation of the binary, needed or not — a standing tax on startup cost and failure modes, and one more thing that happened without the composition saying so. A service that wants an unconditional lifecycle now earns it the ordinary way: something declares a dependency on it, or the operator forces it in with--enable.
Declaring dependencies#
Dependencies are exported fields carrying an inject struct tag —
grammar "<id>[,<id>…][;optional]":
type MyService struct { Log slog.Handler `inject:""` // by interface Sinks []slog.Handler `inject:""` // ALL matching providers Chosen []slog.Handler `inject:"example.com/a,example.com/b"` Store *BoltStore `inject:""` // by concrete type (unique) Extra slog.Handler `inject:";optional"` // nil if nothing matches }
- Single-valued fields take at most one id. With no id and two
candidates, you do not get a winner — you get a startup error naming
both and telling you to rank one with
Orderor name an id here. - Slice fields carry interface element types only, and collect every matching closure member in composition order. Listing ids seeds the closure with those services, but the slice may still contain more — 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-nil slice) and tolerates a disabled target — but never an unknown one. An id naming no registered service is a startup error even on an optional field, because a typo must never silently change the composition.- An
injecttag on an unexported field, a slice of concrete struct type, and a single-valued field naming two ids are all registration errors.
Ambiguity used to resolve to "first registered", which meant import order picked your implementation. That is the rule v0.3.0 exists to kill; see Composition & the Builder for
Order, the sanctioned way to break a tie on purpose.
The closure#
At dispatch the framework resolves the applet's dependency closure:
the applet and everything it transitively requires, 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 so the garbage collector can
reclaim it.
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, composition
order applies, and a member may receive Start with an
injected-but-not-yet-started dependency.
Positionals belong to applets#
A plain service may not declare pos: fields — it is a registration
error. Flags and env vars are namespaced per service, but there is one
command line and it belongs to the dispatched applet. See
Your config struct.
Introspection#
The core registers exactly one service of its own: the Introspector,
under the reserved alias 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 concrete type is reserved for the core: a service registering
*fw.Introspector as its own type is refused.
The surface: Applets(), SingleApplet(), Services(),
ConfigExtensions(), Describe(service) (the long-form registration
metadata), and Arguments(applet, 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.
Describe and Arguments accept either an alias or an id.
These listings speak the operator's vocabulary: Applets(),
Services() and SingleApplet() all return primary aliases, and
ArgInfo.Service names the owning service's alias. That is the right
default for the tools that consume them — a completion offers what a
human types.
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 — a malformed id, a missing
alias, a reserved alias, a declared interface the concrete type 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.