Composition & the Builder
A binary is not "whatever happened to get imported". It is a list of services the author named on purpose. That sentence is the whole of v0.3.0.
Why this changed#
Services used to be wired by blank import. init() registered them,
dependency ties went to whoever registered first, and registration order
was import order — so running goimports over a file could change which
implementation satisfied an interface, silently, with no diff to the
logic. A framework whose central promise is never silent could not keep
a rule like that.
So composition became explicit and ties became errors.
Cataloging vs accepting#
The two halves are now clearly separated:
init()catalogs: it makes a service available, and nothing more. An un-accepted catalog entry does not exist as far as the binary is concerned — not configured, not started, arguments not even parsed.- The binary accepts: it names what it actually takes.
A package publishes its id as an exported constant, which is what makes the import honest — you import the package because you name something it exports:
package serve import "sxcli.dev/fw" const ID = "example.com/box/serve" type Config struct { Version uint32 `json:"version"` Listen string `json:"listen" conf:"listen,l" usage:"address to serve on"` } type Serve struct{ cfg Config } func (s *Serve) Configured() error { return nil } func (s *Serve) Run() int { return 0 } func init() { fw.NewRegistration(ID, func() *Serve { return &Serve{cfg: Config{Version: 1, Listen: ":8080"}} }, func(s *Serve) *Config { return &s.cfg }, ).Alias("serve").Register() }
package main import ( "sxcli.dev/fw" "sxcli.dev/fw/sink/console" "example.com/box/grep" "example.com/box/serve" ) func main() { fw.Builder(). Accept(serve.ID, grep.ID, console.ID). Main() }
Delete grep.ID from that list and the grep import stops compiling.
The composition and the import block cannot drift apart.
The three front doors#
| When | |
|---|---|
fw.Solo(reg) | one applet, no subcommands — the binary is the applet |
fw.Builder()…Main() | you name what the binary contains |
fw.Main() | take the whole catalog, AcceptAll composed and run |
Solo is not a separate mechanism — it registers the one service and
composes everything, so the rules below apply identically.
Ranking, and why ties are errors#
Order ranks accepted services. Ranked beats unranked when a single
dependency slot has candidates; slices gather ranked first (in Order
sequence), then unranked sorted by id:
fw.Builder(). Accept(sqlite.ID, mysql.ID, serve.ID). Order(mysql.ID). // mysql wins the Store slot; sqlite stays available Main()
Two unranked candidates for one slot do not produce a winner. They
produce a startup error naming both, and either Order or an id in the
inject tag resolves it. That is the trade the release makes: a little
ceremony at composition time, in exchange for never again debugging a
program whose behaviour depended on import order.
Order never admits a service — ranking an id you didn't accept is a
violation, which doubles as a typo catcher.
Renaming for the composition#
Builder.Alias renames an accepted service for this binary, leaving
upstream untouched. The first name given becomes the new primary, and
every operator surface follows it: config section, env prefix,
--disable, dispatch:
fw.Builder(). Accept(serve.ID, console.ID). Alias(serve.ID, "http", "www"). // primary "http", plus an extra name Main()
Beyond fixing collisions between two upstreams that both wanted serve,
this is how a released binary pins its operator contract. Your users'
config files key on the alias you chose; an upstream rename can no longer
reach into a deployed /etc file and break it.
Building without running#
Main() is the production terminal: it builds, reports every violation
at once and exits 2, or runs the app. It never returns. When you want the
composition as a value — tests, embedding, custom error handling —
Build() hands it back instead:
app, err := fw.Builder().Accept(serve.ID).Build() if err != nil { // every violation joined into one error }
Build is where composition-level checks run: unknown ids, Order and
Alias naming services that weren't accepted, alias collisions, the same
concrete type registered twice, and defaults outside their own declared
domains. Every Build instantiates fresh services, so two Apps share
nothing.
For catching these before the program runs at all, the
sxcli.dev/vet analyser reports bad
ids, missing terminals and ambiguous compositions at compile time.
Next: Services & injection — how the accepted services find each other.