projects / bosun

Bosun

A zero-ceremony Go web framework. Types register themselves; the dependency graph, routes and OpenAPI spec are generated for you.

fig. 0x03bosun's call

Overview

Bosun is a Go web framework, plus the platform toolkit I keep needing around it. The core idea is that a type registers itself where it is declared. One line, var _ = bosun.Service[UserService](), and Bosun works out the dependency graph, fills in the struct's fields, mounts the controller's routes and writes the OpenAPI spec from the handler types. There is no wiring file to keep in sync.

The core sits on the standard library's net/http and the Go 1.22 router, and its packages have no external dependencies. The heavy drivers (RabbitMQ, NATS, Redis Streams, S3, OpenTelemetry) are separate nested Go modules, so an app only downloads what it uses. Around the core sit an event bus, a transactional outbox, webhooks, health probes, multi-tenancy and a CLI that generates code and serves the docs (bosun docs, or to AI tools with bosun mcp).

At v0.7.0 it is about 18,500 lines of Go (tests included) across seven modules, and it runs the APIs behind BluePortal and the Nerve Center software I built for Nerve Media. It is licensed Apache-2.0.

The problem

A typical Go service grows a main.go that does everything by hand: open the database, construct the stores, pass them into services, pass those into handlers, list every route, and close things in the right order at shutdown. None of it is hard, but it has to be kept in step with the code it describes, and the OpenAPI document usually lives somewhere else and drifts.

That's what happened to BluePortal, which ran on Fiber at the time. Its entrypoint grew past 2,000 lines of route definitions and service construction, in a file whose only real job was to start the app. Every route also needed its OpenAPI comments written by hand if I wanted it properly documented, which was a chore each time. Then I added up how much time I'd spent on route and wiring code across all the Go I'd written, for myself and for others, and it was clear there had to be a better way. By the first commit, on 12 June 2026, the registry, typed handlers and OpenAPI scanning were already in place.

I wanted the fix to be as light and dependency-free as possible. chi and fx are both excellent, but each is another dependency that someone has to keep maintained, and neither is guaranteed to keep up with the standard library. net/http ships with every Go release, and since Go 1.22 its router handles methods and path parameters itself, so I built Bosun on that.

What I wanted was:

  • No wiring code. A struct should say what it needs through its field types, and that should be enough.
  • Typed handlers. A handler should be a normal Go function with an input type and an output type, not something that reads a *http.Request by hand.
  • One source of truth. If the handler types already describe the API, the spec, the deploy manifest and the client should come from them, not be written again.
  • Light by default. Pulling in the framework shouldn't pull in a RabbitMQ client.

How it works

The quickest way to see it is the path from a declaration to a served route and a spec.

From declaration to routes and spec

A declaration like bosun.Service[T]() doesn't do any work when it runs. It records *T and a closure in a package-level list and returns struct{}{}, which is why it fits in var _ =. bosun.Controller[T]("/users") does the same and also checks, at package init, that *T has a Routes(*bosun.Router) method, so a controller without a Routes method panics at startup instead of quietly mounting nothing.

bosun.New() creates a registry and applies those declarations. Between New() and Start() the host gets its turn to register the things Bosun can't build, like a *gorm.DB or an options struct. Then app.Start() applies module defaults (only where the host registered nothing), builds every singleton, and asks each controller to mount its routes.

Injection without constructors

Services have no constructors. Bosun allocates the struct and walks its fields:

inject.goview source ↗
func buildInjected(r *registry.Registry, t reflect.Type) (any, error) {	st := t.Elem()	if st.Kind() != reflect.Struct {		return nil, fmt.Errorf("bosun: %v is not a struct type", st)	}	v := reflect.New(st)	elem := v.Elem()	for i := 0; i < st.NumField(); i++ {		sf := st.Field(i)		if sf.Anonymous || sf.Tag.Get("inject") == "-" {			continue		}		// ...		dep, err := r.ResolveType(sf.Type)		if err != nil {			return nil, fmt.Errorf("injecting %s.%s: %w", st.Name(), sf.Name, err)		}		f := elem.Field(i)		if !f.CanSet() {			f = reflect.NewAt(f.Type(), unsafe.Pointer(f.UnsafeAddr())).Elem()		}		f.Set(reflect.ValueOf(dep))	}	inst := v.Interface()	if in, ok := inst.(initer); ok {		if err := in.Init(); err != nil {			return nil, fmt.Errorf("%s.Init: %w", st.Name(), err)		}	}	return inst, nil}

Embedded fields and fields tagged inject:"-" are skippedL11-13. The trimmed lines are if !r.Has(sf.Type) { continue }: a field whose type isn't registered is treated as plain data and left alone. Anything else is resolved and setL15-23, and if the field is unexported it is set anyway through reflect.NewAt and an unsafe.PointerL19-22. That lets a service keep its dependencies private (db *gorm.DB rather than DB *gorm.DB) at the cost of unsafe in the core, which I decided was worth it. Finally, if the type has an Init() error method it runsL26-31, and that's how background work starts: the outbox relay, timers and janitors are all just services with Init and Close.

Because type is identity, there are no names or string keys to typo. The downside is that two values of the same type need wrapper types, which is why the GORM repo module has gormrepomod.Named[T, *PrimaryDB].

The graph builds itself

I never declare dependencies anywhere. The registry discovers them while it builds things:

registry/registry.goview source ↗
func (r *Registry) resolve(t reflect.Type) (any, error) {	n, ok := r.nodes[t]	if !ok {		return nil, fmt.Errorf("registry: no provider registered for %v%s", t, r.chainSuffix())	}	// Record the edge parent -> t while a construction is in flight.	if len(r.stack) > 0 {		r.stack[len(r.stack)-1].deps[t] = struct{}{}	}	switch n.state {	case stateReady:		return n.instance, nil	case stateResolving:		return nil, fmt.Errorf("registry: dependency cycle detected: %s", r.cycleString(t))	}	n.state = stateResolving	r.stack = append(r.stack, n)	inst, err := n.construct(r)	r.stack = r.stack[:len(r.stack)-1]	if err != nil {		n.state = stateIdle		return nil, fmt.Errorf("registry: constructing %v: %w", t, err)	}	n.instance = inst	n.state = stateReady	r.seq++	n.order = r.seq	return inst, nil}

While a type is being constructed it sits on a stack, so any resolve call made meanwhile is a dependency of whatever is on top, and the registry records an edgeL7-10. If it reaches a node that is already in the resolving state, it has gone round in a circle, and the error prints the chainL15-16 (A -> B -> A). A missing provider error names the chain too, so you can see who wanted it.

The last thing it does is stamp a completion numberL31-32. Something can only finish building after everything it depends on has finished, so sorting by that number in reverse gives a safe shutdown order for free: services close before the database they use, without anyone writing that order down. app.Start() calls Validate, which resolves every node in sorted order and joins all the errors, so one boot reports every broken service instead of the first.

Fixing Go's import problem with a generator

Self-registration has one real weakness, and it's a Go one. A package's var declarations only run if the package is initialised, and a package is only initialised if something imports it. A controller in internal/users that nothing imports isn't in the binary, so its routes simply don't exist. No error, just a 404. Until v0.7.0 every app had a list of blank imports in main.go, and the one warning in Nerve Center's notes is exactly this: forget to run go generate and you get silent 404s.

I opened an issue to work out what was possible. Runtime discovery is out: reflection only sees types that are linked in, and scanning the filesystem at runtime would find source the binary doesn't contain. The options left were a central registration API, a convention-based generated app package, a documented aggregator package, or a generator that writes the imports. I wanted to stay with normal Go semantics and compile-time checks, so the answer was bosun gen registry, with the aggregator package documented as the no-codegen fallback.

The generator parses the module with go/parser, with no build and no reflection, and looks for package-level var declarations whose value is a call to one of Bosun's registration functions:

cmd/bosun/internal/registrygen/registrygen.goview source ↗
func registrationName(fun ast.Expr, local string) (string, bool) {	var sel ast.Expr = fun	var typeArgs []ast.Expr	switch x := fun.(type) {	case *ast.IndexExpr:		sel, typeArgs = x.X, []ast.Expr{x.Index}	case *ast.IndexListExpr:		sel, typeArgs = x.X, x.Indices	}	se, ok := sel.(*ast.SelectorExpr)	if !ok {		return "", false	}	id, ok := se.X.(*ast.Ident)	if !ok || id.Name != local || !registrationFuncs[se.Sel.Name] {		return "", false	}	if len(typeArgs) == 0 {		return se.Sel.Name, true	}	args := make([]string, len(typeArgs))	for i, a := range typeArgs {		args[i] = types.ExprString(a)	}	return se.Sel.Name + "[" + strings.Join(args, ", ") + "]", true}

Generic calls show up in the AST as an index expression, one type argument for Service[T] and an index listL4-9 for DefaultBind[I, Impl]. It compares the selector against whatever name the file imported Bosun asL14-17, so aliases work, then formats the declarationL21-25 back into text. That text becomes a comment on each import in zz_bosun_registry.go, so a code review shows what a new package adds to the app. It only writes imports, never wiring, so the file stays short, and --check makes CI fail when someone forgets to regenerate.

Typed handlers and the spec

A handler is func(ctx, *bosun.Req[In]) (Out, error), mounted with bosun.Get(r, "/users/{id}", c.Get). Because the route is generic, the adapter knows In and Out when the route is mounted, not just when a request arrives.

One typed request

At mount time it compiles the validate: rules for In (five of them: required, omitempty, min, max, oneof), so a malformed tag fails app.Start() rather than a request. I kept validation small and native rather than adding a dependency; anything more complex goes in a Validate() method. It also records a RouteInfo on the app: method, path, handler name, In, Out and any declared errors. That record is what /openapi.json, the deploy manifest at /.bosun/manifest and bosun gen client are all built from, so they can't drift from the routes.

Errors use bosun.E(404, "user not found", err). The client only sees the status and the public message; the cause and the file:line where the error was raised go to the audit event, if an auditor is registered. The OpenAPI generator gets error codes from three places: codes declared on the route, codes it finds by parsing handler source for .E(status, ...) calls, and codes the app has actually returned since it started.

Shutting down in the right order

For v0.7.0 I made app.Run handle SIGINT and SIGTERM itself:

server.goview source ↗
func (a *App) serve(ctx context.Context, ln net.Listener) error {	srv := &http.Server{Handler: a.Handler()}	for _, configure := range a.serverConfig {		configure(srv)	}	serveErr := make(chan error, 1)	go func() { serveErr <- srv.Serve(ln) }()	select {	case err := <-serveErr:		// The server failed on its own (not via Shutdown): still release services.		return errors.Join(fmt.Errorf("bosun: serve: %w", err), a.Shutdown())	case <-ctx.Done():	}	sctx, cancel := context.WithTimeout(context.Background(), a.shutdownTimeout)	defer cancel()	var errs []error	if err := srv.Shutdown(sctx); err != nil {		errs = append(errs, fmt.Errorf("bosun: graceful shutdown did not finish within %s: %w", a.shutdownTimeout, err))		_ = srv.Close() // drop connections still in flight	}	if err := <-serveErr; err != nil && !errors.Is(err, http.ErrServerClosed) {		errs = append(errs, fmt.Errorf("bosun: serve: %w", err))	}	// Services close only once the server has drained, so in-flight	// requests never see a closed database or client.	if err := a.Shutdown(); err != nil {		errs = append(errs, err)	}	return errors.Join(errs...)}

The server runs in a goroutine. If it fails on its ownL11-13, services are still released. On a signal, in-flight requests get the shutdown timeoutL17-23 (10 seconds by default) to finish, and only once HTTP has drained are services closedL27-31 in reverse dependency order. That ordering matters for something like the transactional outbox: its relay is a service that depends on the event publisher and the repo, so it stops polling before either of them is closed.

History

  • 12 June 2026: first commit. It already had the registry, typed handlers, OpenAPI with error scanning and the first middleware in it: 52 files and about 4,400 lines. v0.1.0 to v0.2.1 were tagged that day. Over the next nine days came parameterised middleware (bosun.Use[T](args)), the generic repo module, path parameters and route groups (v0.4.3).
  • 17 August: the platform release. One large pull request (17 commits, about 11,000 lines added) turned the framework into a toolkit: the event system and its four drivers, the nested-module workspace, OpenTelemetry, storage and secrets, webhooks and the outbox, multi-tenancy, the deploy manifest, client generation, and the docs and mcp commands. That was v0.5.0 to v0.5.2, and the driver modules started getting their own tags.
  • September: v0.6.0. I merged a reconnect supervisor for the RabbitMQ driver, and the module path moved from the old amberstack organisation to bluebeard63.
  • 6 October: v0.7.0. I wrote up seven issues from what the apps needed (CORS, panic recovery, app-wide middleware, graceful shutdown, per-app route metadata for test isolation, validation, and the blank-import problem). Each got its own branch, squash-merged into a v0.7.0 integration branch, then one merge into master. CI landed at the same time: Go 1.22 and latest stable with -race, gofmt, go mod tidy for every module, a check that the embedded docs match docs/, and integration tests against real RabbitMQ, NATS, Redis and SeaweedFS. A release script tags the root and every nested module together.
  • 14 October: licence. I picked Apache-2.0 so anyone can use it, including in closed-source commercial work, and because it includes a patent grant. It also gives Nerve Center, which runs on Bosun, a written right to use it.

Outcome

BluePortal is the biggest user: 165 Go files across nine modules import Bosun, all migrated off Fiber, with main.go reduced to self-registration and a middleware chain. Nerve Center uses controllers, services, Init/Close timers for background jobs and the storage module. In both, adding an endpoint is a struct, a method and a line in Routes, and the spec updates itself.

Using it in real apps also showed me where it falls short:

  • Missing dependencies are silent. A field whose type isn't registered is left nil, and the failure is a nil-pointer panic on the first request that uses it. My own docs say startup fails in that case, which is wrong. This is the gap I most want to close: an explicit inject:"required" or, better, failing by default for pointer and interface fields.
  • Forgetting to regenerate is still silent. --check in CI catches it, but only if the app runs it. The generator also can't see registrations made through helpers like gormrepomod.For[T]().
  • Event retries are naive. No driver has a retry limit, backoff or dead-letter queue, so a message that always fails is redelivered straight away, forever. BluePortal built its own dead-letter table to work round it; that belongs in Bosun.
  • mw.RateLimit keys on RemoteAddr. Behind Caddy every request comes from the proxy, so every user shares one bucket. It needs a trusted-proxy option that reads X-Forwarded-For, and its window map is never pruned.
  • Smaller things: success is always 200, the outbox doesn't claim rows (two replicas would double-publish), and the webhook receiver accepts unsigned requests for a provider with no verifier.

What I'd do next, in order: fail loudly on unresolved dependencies, add retry limits, backoff and dead-lettering to eventmod (and move BluePortal's Valkey driver upstream next to the Redis one), make the rate limiter proxy-aware, and add a CHANGELOG so releases have real notes.