Skip to content

Writing an Extension

wiki edited this page Sep 4, 2026 · 1 revision

Writing an extension

An extension is a Go module that implements five hooks and imports only rextension. This page is the shape every extension in the ecosystem follows; deviating from it is fine, but the parts marked as rules are rules.

The module

myext/
  go.mod        // requires github.com/kryovyx/rextension — and nothing else from this ecosystem
  config.go     // Config, NewDefaultConfig, ConfigOption, NewConfig
  extension.go  // Extension, NewMyExtension, WithMyExt, the five hooks
  middleware.go // the actual behaviour
  route.go      // route capability interfaces, if any
module github.com/you/rextension-myext

go 1.27

require github.com/kryovyx/rextension v0.3.0

Do not require rex, and do not require dix — the DI contract is re-exported here.

The two constructors

Every extension exposes both: one that returns the extension, and one that returns an Option, so an application can write either form.

// NewMyExtension constructs the extension. A nil cfg takes the defaults.
func NewMyExtension(cfg *Config) rx.Extension {
	c := NewDefaultConfig()
	if cfg != nil {
		c = cfg
	}
	return &Extension{cfg: *c}
}

// WithMyExt is the ergonomic form.
func WithMyExt(cfg *Config) rx.Option {
	return rx.WithExtension(NewMyExtension(cfg))
}
app := rex.New(myext.WithMyExt(nil))

Configuration

A Config struct, a NewDefaultConfig, and functional options:

type Config struct {
	Enabled bool
	Budget  time.Duration
}

func NewDefaultConfig() *Config {
	return &Config{Budget: 2 * time.Second}
}

type ConfigOption func(*Config)

func WithBudget(d time.Duration) ConfigOption {
	return func(c *Config) { c.Budget = d }
}

func NewConfig(opts ...ConfigOption) *Config {
	c := NewDefaultConfig()
	for _, opt := range opts {
		if opt != nil { // tolerate a nil in a variadic list
			opt(c)
		}
	}
	return c
}

Default to the safe thing, and say so when the safe thing does nothing. The CORS extension's default policy allows no origin, because an extension that permitted any origin out of the box would turn adding it into a policy decision its author did not make — and it logs a warning when it starts with an empty allowlist, so "CORS is enabled but everything is refused" is discovered from the log rather than from a browser console.

Validate in OnInitialize

Configuration that cannot work should stop the deployment, not produce mysterious responses:

func (e *Extension) OnInitialize(_ context.Context, r rx.Rex) error {
	e.logger = r.Logger().WithField("extension", "myext")

	if err := e.cfg.Policy.Valid(); err != nil {
		return fmt.Errorf("myext: %w", err)
	}
	if e.cfg.Budget <= 0 {
		return fmt.Errorf("myext: budget must be positive, got %s", e.cfg.Budget)
	}
	// … declare …
	return nil
}

For checks that depend on the whole route table — "this route requires a scheme nobody registered" — use RouteValidator instead. OnInitialize is too early: the routes do not all exist yet.

Attach middleware the narrow way

Reach for the most specific registration that fits:

// Applies to every route: cheap, unconditional.
r.Use(mw)

// Applies to some routes, or is configured per route.  ← usually this one
r.UsePerRoute(func(info rx.RouteInfo) rx.Middleware {
	cfg, ok := info.Route.(MyConfiguredRoute)
	if !ok {
		return nil // not applicable — nothing attached, no cost
	}
	budget := cfg.Budget() // read ONCE, at build time
	return func(next http.Handler) http.Handler { /* closes over budget */ }
}, rx.PriorityDefault)

// Configuration depends on which router it runs on.
r.UsePerRouter(func(routerName string) rx.Middleware {
	return Middleware(&e.cfg, routerName)
}, rx.PriorityCORS)

Two rules:

  1. Pick the priority that describes what the middleware is, from the fixed scale. Not "it needs to run before auth" — if it does, it is rate limiting or CORS, and there is a constant for that.
  2. Capture configuration in the factory; never look it up per request. The closure is built once per route.

Declare route capabilities as interfaces

Do not require applications to embed your type. Declare what you need and type-assert:

// In your extension's package.
type BudgetedRoute interface {
	Budget() time.Duration
}

An application opts in by implementing it on its own route type. A route that does not is passed through untouched. This is how every cross-cutting concern in this ecosystem attaches to routes without any of them sharing a type — see Routes and schemas.

Create your own listener when you are operational

Health and metrics endpoints belong on a separate port from application traffic, so they can be reached when the public listener is saturated and firewalled off from the internet:

err := r.CreateRouter("myext", rx.RouterConfig{Addr: ":9095", ReadTimeout: 5 * time.Second})
if err != nil && !errors.Is(err, rx.ErrRouterExists) {
	return err
}
if err := r.RegisterRouteToRouter(rt, "myext"); err != nil {
	return err
}

Always tolerate ErrRouterExists — another extension may have created the same router first, and reusing it is correct.

Answer with problem documents

rx.WriteProblem(w, req, http.StatusServiceUnavailable,
	rx.ProblemDependencyUnavailable, "a required dependency is unavailable")

Never put err.Error() in the detail. See Problem Details.

Publish for other extensions through the container

func (e *Extension) OnInitialize(ctx context.Context, r rx.Rex) error {
	return r.Container().Singleton(func() *Registry { return e.registry })
}

And consume optionally — an extension that is not installed should degrade, not crash:

var reg rx.SchemeRegistry
if err := r.Container().Resolve(&reg); err != nil {
	e.logger.Debug("no scheme registry; documenting no security")
	return nil
}

Never use a package-level variable for this. A global registry means two Rex instances in one process clobber each other, and state leaks between tests in the same binary — which is exactly why RegisterSecuritySchemes was replaced by a container-held SchemeRegistry.

The rules, collected

  • Import rextension only. Not rex, not dix.
  • Implement all five hooks, even as no-ops.
  • Declare in OnInitialize/OnStart. Never register from OnReady — it is refused with ErrRouterFrozen.
  • Tolerate ErrRouterExists from CreateRouter.
  • Read route configuration once, at build time, inside a PerRouteMiddleware factory.
  • No package-level mutable state. Hold it on the extension, or in the container.
  • Answer with problem documents, safe text only.
  • Use the priority constants.
  • Nothing exact on top of events — they are lossy by design.

Checklist before publishing

  • go.mod requires rextension and nothing else from this ecosystem
  • NewMyExtension(cfg *Config) and WithMyExt(cfg *Config) rx.Option
  • nil config takes safe defaults, and a no-op configuration warns
  • configuration validated in OnInitialize, route table in ValidateRoutes
  • middleware registered at a documented priority
  • errors answered as RFC 9457 problems
  • OnStop/OnShutdown release everything the extension started
  • tests construct a fresh extension per test — no shared globals

Clone this wiki locally