In Three Rewrites there was a sentence I glossed over:
Hooks before and after every action — logging, dry-run, error context, all without touching the core.
Half a sentence. But that’s exactly where the third attempt ended up. Today I want to unpack that one line. It’s going to be longer.
What a hook is
A hook is a function I slip in before or after an action. ensure, delete, start, stop — every action incus-compose runs on a resource can be intercepted. Before and after.
The signature looks like this:
func(ctx context.Context, action Action, r Resource, args Options, err error) error
Look at the last parameter. err error. A hook takes an error in and hands an error back out. That’s the real trick, and it took me a while to understand how much is hidden in it.
The error flows through the whole chain. Every hook gets to look at it, pass it on, enrich it — or invent a new one and abort everything.
The error that flows through
A before-hook that aborts simply says: here’s an error.
client.AddHookBefore(func(_ context.Context, action Action, r Resource, args Options, err error) error {
if action == ActionDelete && !confirmed {
return errors.New("deletion not confirmed")
}
return err
})
If a before-hook returns an error, the action doesn’t run. Done. That’s how you build dry-run, confirmations, preconditions — without the code that deletes ever finding out.
An after-hook gets to do the opposite: swallow an error.
client.AddHookAfter(func(_ context.Context, action Action, r Resource, args Options, err error) error {
if errors.Is(err, ErrNotFound) && action == ActionDelete {
// already gone — not an error
return nil
}
return err
})
Deleting a resource that no longer exists isn’t a problem. It’s exactly what I wanted. The core stubbornly reports “not found”. The hook nods and says: that’s fine.
And in between, the most common case: enrich the error instead of replacing it.
client.AddHookAfter(func(_ context.Context, action Action, r Resource, args Options, err error) error {
if err == nil {
return nil
}
cError := &Error{}
if ok := errors.As(err, &cError); ok {
return cError.WithResource(r)
}
return ErrUnknown.WithResource(r).Wrap(err)
})
No more Err....WithResource(r) scattered across a hundred places in the core. One place. In the end, every error ends up carrying a trace of whose hands it passed through.
The whole hook chain is basically a single error pipeline. The err is the water running through it, and every hook is a valve.
FIFO and LIFO
Here comes the part I find beautiful.
Before-hooks run in the order I registered them. First in, first out.
client.AddHookBefore(checkPermissions) // runs 1st
client.AddHookBefore(validateConfig) // runs 2nd
client.AddHookBefore(acquireLock) // runs 3rd
After-hooks run in reverse. Last in, first out.
client.AddHookAfter(logBasicInfo) // runs 3rd (inner)
client.AddHookAfter(wrapWithContext) // runs 2nd (middle)
client.AddHookAfter(sendToMonitoring) // runs 1st (outer)
At first this looks like an arbitrary decision. It isn’t. It’s the only one that makes sense.
Picture getting dressed. Socks first, then shoes. Taking them off, shoes first, then socks. Nobody takes the socks off first. Whatever you put on last, you take off first.
That’s exactly what the hooks do. Whoever prepares something last cleans it up first. The brackets close from the inside out. If you write Go, you know this from defer — the deferred calls fire in reverse order, for the same reason.
checkPermissions → validateConfig → acquireLock
│
[ action ]
│
sendToMonitoring ← wrapWithContext ← logBasicInfo
An onion. The general on the outside, the specific on the inside. On the way in from outside to inside, on the way out from inside to outside. The lock I acquired last is released first. The monitoring I registered first gets the result last — and so it sees the fully enriched error, not the raw one.
I didn’t have to invent this. I just had to stop working against it.
A bigger bracket
Before and after every action — that’s the small bracket. There’s a bigger one, around the whole run.
if err := client.Open(); err != nil {
return err
}
defer func() { _ = client.Done() }()
Open() fires the connected hooks, once, before any action runs. Done() fires the done hooks, once, when everything is over. Setup and teardown for the entire client.
Same rules, one floor up. Connected runs FIFO, done runs LIFO. Connected may abort — if a connected hook returns an error, Open() has failed and no action runs. Done never aborts. Every done hook runs, always, no matter what went wrong before. Cleanup is not something you get to skip.
That’s where I start the progress renderer:
client.AddHookConnected(func(err error) error {
return progress.Start()
})
client.AddHookDone(func(err error) error {
progress.Stop()
return err
})
Turn it on when the client opens. Turn it off when it’s finished. In between, the small brackets fire for every resource and mark their lines as done. Three levels, the same pattern: before, in between, after.
What it’s actually about
Now back to the half-sentence. All without touching the core.
Logging, dry-run, progress display, error context, confirmations, swallowing expected errors — none of it lives in the core. The core doesn’t know there’s a progress display. It doesn’t know whether a dry-run is happening. It doesn’t even know someone is logging.
It runs an action on a resource. Before, it calls the before-hooks; after, the after-hooks. What they do is none of its business.
These are called cross-cutting concerns — the things needed everywhere that, for exactly that reason, otherwise eat their way into everything. Logging is the classic case. You start with a single log.Println and six months later there’s a log call in every other line and nobody dares to take it out. The hooks give these things a place. A single one. Outside.
The core stays dumb. And dumb is a compliment here. A dumb core is a core I understand without opening five other files. One where I can add a new resource without thinking about logging in ten places.
Where the poetry ends
To keep it honest: not all of it is elegant.
The before- and after-hooks run in a worker pool. Several resources are processed at the same time, so several hooks fire at the same time. Anyone touching shared state in a hook — a counter, a map — needs a mutex.
var mu sync.Mutex
counts := map[string]int{}
client.AddHookAfter(func(_ context.Context, action Action, r Resource, _ Options, err error) error {
mu.Lock()
defer mu.Unlock()
counts[r.Kind()]++
return err
})
That’s the spot where the pretty onion meets the reality of concurrency. The connected and done hooks are exempt — they fire once, on one thread. But the small brackets you have to write with that caution. Elegance and race conditions aren’t mutually exclusive, you just have to remember.
I didn’t invent this
At some point while writing, I noticed what I had actually built. This is middleware. The same pattern as a chain of HTTP handlers passing a request along, each one allowed to act before and after, and the innermost one does the real work. The same thing behind defer. The same as a stack of plates.
I didn’t invent it. After two botched attempts and three years of go-orb before them, I just arrived at a point where I recognized it. That’s maybe the most honest part of the whole story. The good structures are already waiting. You just have to build the wrong thing long enough to recognize the right one when it crosses your path.
A new resource now just fits into the stack. A new cross-cutting concern becomes a hook and doesn’t touch the core. Before, in between, after.
Boring. Again. Exactly how it should be.
By René Jochum and Claude (Anthropic). License: CC-BY-4.0.
