sxcli.devSimple Extensible CLI
You are viewing an archived snapshot (v0.1.0). Switch to the current version →

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()):

go
go
func init() {
    s := &BoltStore{}
    fw.Register("boltstore", s, fw.Provides[Store](), fw.WithConfig(&s.cfg))
}
  • The id is lowercase, Go-identifier style; core and introspection are 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 *BoltStore without any declaration.
  • fw.WithMetadata(&fw.Metadata{…}) optionally attaches a declarative layer: a long-form service description plus per-field docs and enforced value domains — see Your config struct for the field side.
  • After Register the 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#

InterfaceContract
StopperStop() error — the base lifecycle interface
StarterStart() error + Stopper — anything startable must be stoppable
ConfigurableConfigured() error — owns a config struct, notified after it is filled
AppletConfigurable + Run() int — the dispatchable entry point
SCMAppletApplet + Windows SCM Execute — see the Windows API
AlwaysOnstructurally Starter; activated by declaring Provides[AlwaysOn]()
ConfigurationUpdaterreserved for a future config-reload trigger

Two rules the framework enforces:

  • An applet must not implement Starter or Stopper — the application lifecycle brackets its Run; there is nothing for the applet to start or stop.
  • AlwaysOn services 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]":

go
go
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.
  • ;optional tolerates 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 inject tag 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:

go
go
type Completions struct {
    Introspect *fw.Introspector `inject:""`
}

The surface: Applets(), 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 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, 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.