I hate spaghetti code. Warzone 2100, among other things, showed me why back in 2011. C code, globals everywhere. You change one variable and three things break that seem to have nothing to do with it. You read the code and can’t tell where a decision begins and where it ends.

For three years — from 2022 to 2025 — I worked on go-orb. Alone. A framework for distributed systems, meant as the successor to go-micro. Config from anywhere, service discovery, RPC, streaming, pub/sub, kvstore, metrics. Every new idea became a feature, every feature a plugin.

The question was always: what might a user need?

Eventually go-orb could do everything. go-micro’s plugin architecture made it easy — and kept adding more and more code that bloated the complexity further.

@bketelsen had an early version of incus-compose, I later had my own. Docker Compose for Incus — an idea I understood right away. I sent a patch that never landed. His version died.

It made everything possible anyway. It proved the concept works and cleared the path. Without it, my start would have been much harder.

First version of my own. I wanted to get it running fast. Everything landed in one client struct: EnsureService, EnsureNetwork, EnsureImage, AttachPoolVolume. One method per resource type, all on the same object. The compose types right inside it.

It ran. I knew right away: this will catch up with me.

It had caught up with me. So back to the principles: KISS (Keep it simple, stupid). The second attempt was built exactly on that: resources and priorities. I gave each resource a number: profiles 512, images 1024, networks 2048, instances 8192. Whatever has the smaller number goes first. No dependency graphs, no topological-sort overhead — just numbers, and sortable.

The idea was right. But the parts still knew too much about each other. Connections where there should have been none.

Third attempt. I started with a different question.

What does someone actually need to run containers with incus-compose?

up, down, list.

A resource interface that treats every resource the same. A stack that orders by priority and runs them. Hooks before and after every action — logging, dry-run, error context, all without touching the core. That was all.

A hook looks like this:

client.AddHookBefore(func(action Action, r Resource, opts Options, err error) error {
    if dryRun {
        fmt.Printf("would %s %s\n", action, r.Name())
        return ErrAborted
    }
    return err
})

The core knows nothing about it. It calls the hook, the rest is the caller’s business.

Sentinel errors enrich themselves:

return ErrNotFound.WithResource(r)
// → "not found: Instance(web-1)"

No more fmt.Errorf("instance %s: %w", name, err). Every error carries its context with it — automatically, because the after-hook handles that for every resource.

The first time I added a new resource and it just fit into the stack — no special cases, no changes anywhere else — I stopped typing for a moment.

Boring. Exactly how it should be.

It started to be fun.

go-orb: three years of work. No users. incus-compose: two months. Several.


By René Jochum and Claude (Anthropic). License: CC-BY-4.0.