One of the architectural rules in the AGENTS.md of incus-compose is:
Constraints outside, freedom inside. A unit is predictable from the outside: what it receives, what it returns, and which effects may escape are fixed. Inside those constraints, it is free to reconnect, coordinate goroutines, or replace its machinery without involving its callers.
An operation may send messages or write files because those are its purpose. The problem is an incidental effect becoming somebody else’s responsibility:
func doSomething(
ctx context.Context,
instance string,
) (*xyzClient, error)
The client is the result of doSomething, but it is only something the
implementation happened to need. Returning it exports connection state
and ownership. Callers must retain it and pass it through layers that
should not know it exists.
Contain that effect instead:
func doSomething(ctx context.Context, instance string) error
The function may create, drop, or replace clients internally. That is its freedom. Its boundary prevents those decisions from escaping.
The same applies to a lifecycle, though it only shows once you follow the whole of one:
type Sweeper struct {
conn *iclient.Connection
out chan<- sweepMsg
started bool
}
func (s *Sweeper) SetConnection(c *iclient.Connection)
func (s *Sweeper) SetOutput(ch chan<- sweepMsg)
func (s *Sweeper) Start(interval time.Duration) error
func (s *Sweeper) Stop()
Assembling it looks harmless. Ending it is where the caller finds out what it
took on. Nothing here says who closes out, or whether Stop waits for the
message in flight. A second Start is refused by started, and the type never
says why.
func Sweep(ctx context.Context, conn *iclient.Connection, interval time.Duration) <-chan sweepMsg
One call, and the questions stop existing. The channel closes behind the last message, and ending it is the context the caller already holds:
sweepCtx, sweepCancel := context.WithCancel(ctx)
sweepCancel lives wherever the caller wants it and can be called from
anywhere. Starting again is another Sweep on a fresh scope, which is the
restart started had to refuse.
A context only ever narrows, which is the same rule in the type. Sweep can
shorten the one it was handed for work of its own and cannot widen it, so
handing one down gives away no reach.
Sweep is the same kind of value. You call it and you cancel it, and there is
nothing on it to change: no setter, and no handle kept back for later. Sweeper
is four calls into one object, each of them a moment where it is half set up.
Nothing mutable is nothing to coordinate about, which is what makes this shape safe to hand to a goroutine rather than safe by agreement.
It nests
The chain constrains a plugin. The plugin constrains its goroutines: one sweeps and only sends, another writes bytes it was handed and can reach nothing live. Each is free inside what it was given, and none leaks its machinery into the level above.
That is where the plainness comes from: predictable units, and effects contained where they belong.
By René Jochum. License: CC-BY-4.0.
