sxcli.devSimple Extensible CLI
You are viewing an archived snapshot (v0.3.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 registered as a factory under an id, from a package init():

go
go
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, so sxcli-vet is what enforces the convention. The framework's own id sxcli.dev/fw is 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 *BoltStore without any declaration.
  • Metadata(&fw.Metadata{…}), Migrate(…), Hidden() and System() 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#

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
ConfigurationUpdaterreserved 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.

AlwaysOn is 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]":

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

go
go
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 onlyHidden and System are 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 counting Applets() — that listing is public-only, while a Hidden non-System applet 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.