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

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:

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

go
go
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:

go
go
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:

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