grr

package module
v0.0.0-...-b369aed Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 26, 2026 License: MIT Imports: 6 Imported by: 0

README

grr — Go Registry Resolver

CI Go Reference Go Report Card

A name-keyed factory/value store with request-scoped lifetime support. Not a DI framework, not HTTP-specific — a small, general-purpose primitive that other layers (like gold) build type safety on top of.

import "github.com/arpaad/grr"

grr.Default.Set("config", cfg)

grr.Default.RegisterScoped("db", func(ctx context.Context) any {
    return openConn(ctx)
})

ctx, end := grr.Default.BeginScope(ctx)
defer end()

conn := grr.Default.Resolve(ctx, "db") // same instance for the rest of this scope

Why this exists

Go already has mature DI tools — wire (compile-time codegen) and dig/fx (reflection-based runtime containers). grr is not trying to replace them or compete on features. It exists because none of them give you a minimal, reflection-free, runtime-resolvable registry with first-class scope lifecycle that's small enough to vendor mentally in five minutes and build a typed layer on top of (see gold).

If you need a full-featured IoC container with struct-tag injection and dependency graphs resolved automatically, dig/fx are the right tool — use those instead. grr deliberately does none of that.

A few choices that look unusual and are worth explaining up front, so reviewers don't mistake them for oversights:

  • any-typed storage, on purpose. grr is the untyped storage primitive on purpose — it does not use the reflect package anywhere. Type safety is a layer concern, not a storage concern; see gold for the generics-first layer built on top.
  • Panics instead of error returns for misuse. A duplicate registration, an unregistered key, or resolving a scoped key with no active scope are all developer errors — bugs to fix, not runtime conditions calling code should branch on. This follows the same philosophy as regexp.MustCompile or template.Must: fail loud and immediately, don't make every caller check an error that should never happen in correct code. Business-logic errors (the actual return value of your factory/handler) are unaffected — they flow through error as normal.
  • A global Default registry. It's sugar for the common case (no isolated registries needed), not a requirement — grr.New() gives you a fully isolated registry, and grr.NewFrom(parent) gives you a parent-chain override (handy for tests: override one key, fall back to Default/appRegistry for the rest).
  • Scope travels through context.Context. Scopes need to survive across middleware/handler boundaries in any HTTP framework, background jobs, and goroutines — context.Context is the only thing all of those already share.

Install

go get github.com/arpaad/grr

API at a glance

Operation Call
Fixed value r.Set(key, value)
Factory (lifetime is the factory's own logic) r.Register(key, func(ctx) any)
Factory, no ctx needed r.RegisterFunc(key, func() any)
One instance per scope r.RegisterScoped(key, func(ctx) any)
Lookup r.Resolve(ctx, key) / r.Get(key) (sugar for Resolve(context.Background(), key))
Lookup, non-panicking r.ResolveOK(ctx, key)(any, bool)false only when the key isn't registered
Scope lifecycle ctx, end := r.BeginScope(ctx); defer end()
Conditional registration check r.IsRegistered(key)
List own keys (not parents) r.Keys()
Test teardown r.Clear()
Isolated registry grr.New()
Parent-chain registry grr.NewFrom(parent)
Registry with observability hooks grr.New(grr.WithHooks(grr.Hooks{...}))
Attach/read registry via ctx grr.WithRegistry(ctx, r) / grr.RegistryFromCtx(ctx)

ResolveOK softens only the "key not registered" case — resolving a scoped key with no active scope, a circular dependency, or resolving after a scope ended all still panic, because those are bugs, not conditions to branch on.

HTTP integration: grr/middleware for net/http (and anything built on it, e.g. Chi). For Gin, see the separate grr-gin module — kept out of core so grr itself never needs a Gin dependency.

import grrmw "github.com/arpaad/grr/middleware"

http.Handle("/", grrmw.Middleware(grr.Default)(myHandler))

Testing with grr

func TestSomething(t *testing.T) {
    r := grr.NewFrom(grr.Default) // override one key, fall back to Default for the rest
    r.Register("db", func(ctx context.Context) any { return &mockDB{} })
    // ...
}

func TestIsolated(t *testing.T) {
    r := grr.New() // no fallback at all
    r.Set("config", testConfig)
}

Runnable examples live in example_test.go and on pkg.go.dev.

Observability

Attach optional hooks at construction. A nil hook is never called, so the default (no hooks) costs a single nil check per event and nothing more:

r := grr.New(grr.WithHooks(grr.Hooks{
    OnResolve:    func(key string, dur time.Duration) { metrics.Observe(key, dur) },
    OnScopeBegin: func() { ... },
    OnScopeEnd:   func() { ... },
}))

Hooks belong to the registry they're set on: Resolve/BeginScope called on this registry fire its hooks, regardless of which registry in a parent chain owns the entry. OnScopeEnd fires exactly once per scope, whether it ends via endScope or ctx cancellation.

Benchmarks

go test -bench=. -benchmem. Numbers from an AMD Ryzen AI 9 HX 370, Go 1.25 — reproduce them yourself rather than trusting the absolute values; what matters is the ratio between the cases.

Benchmark ns/op allocs/op
ResolveNonScoped ~160 3
ResolveScopedCached (the dominant case) ~110 0
ParentChainLookup (8 levels deep) ~340 3
BeginEndScope (open + close) ~2000 10

The scoped cache-hit path — every resolve after the first within a scope — is allocation-free. The per-resolve allocations on the other paths come from threading the build-chain (cycle detection) through context; trimming that is a tracked optimization (see plan.md).

Status

v0.2-dev — core registry, scope lifecycle (with cycle detection), net/http middleware, observability hooks, ResolveOK/Keys, and a per-chain scope store (isolated New() registries no longer share a global scope table). See plan.md for what's next and ARCHITECTURE.md for the full design history and the reasoning behind every non-obvious decision.

License

MIT — see LICENSE.

Documentation

Index

Examples

Constants

This section is empty.

Variables

View Source
var Default = New()

Default is the global registry — the entry point for apps that don't need isolated registries (e.g. tests, multi-tenant setups).

Functions

func WithRegistry

func WithRegistry(ctx context.Context, r *Registry) context.Context

WithRegistry attaches r to ctx so it can be recovered later via RegistryFromCtx (used by middleware to inject a registry per request).

Types

type Hooks

type Hooks struct {
	// OnResolve fires once per Resolve call with the key and the time the
	// whole resolution took (chain walk + factory or cache lookup).
	OnResolve func(key string, dur time.Duration)
	// OnScopeBegin fires when BeginScope opens a scope.
	OnScopeBegin func()
	// OnScopeEnd fires when a scope is released (via endScope or ctx
	// cancellation).
	OnScopeEnd func()
}

Hooks are optional observability callbacks. A nil field is never called, so an empty Hooks value (the default) costs a single nil check per event and nothing else.

type Option

type Option func(*Registry)

Option configures a Registry at construction time.

func WithHooks

func WithHooks(h Hooks) Option

WithHooks attaches observability hooks to a registry. The hooks belong to the registry they're set on — Resolve/BeginScope called on this registry fire these hooks, regardless of which registry in a parent chain actually owns the entry.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry is a name-keyed factory/value store. It is a general-purpose primitive: not HTTP-specific, not a DI framework on its own.

func New

func New(opts ...Option) *Registry

New creates an isolated registry with no parent and its own scope store.

func NewFrom

func NewFrom(parent *Registry, opts ...Option) *Registry

NewFrom creates a registry that falls back to parent for keys it doesn't have registered itself. It shares parent's scope store, so scopes are consistent across the whole chain.

Example

NewFrom builds a registry that falls back to parent for keys it doesn't have itself — handy for tests that only need to override one dependency.

package main

import (
	"fmt"

	"github.com/arpaad/grr"
)

func main() {
	parent := grr.New()
	parent.Set("db", "parent-db")

	child := grr.NewFrom(parent)
	fmt.Println(child.Get("db")) // falls back to parent

	child.Set("db", "child-db")
	fmt.Println(child.Get("db"), parent.Get("db")) // child overrides, parent untouched
}
Output:
parent-db
child-db parent-db

func RegistryFromCtx

func RegistryFromCtx(ctx context.Context) *Registry

RegistryFromCtx recovers the registry attached to ctx via WithRegistry, falling back to Default if none was attached.

func (*Registry) BeginScope

func (r *Registry) BeginScope(parent context.Context) (context.Context, func())

BeginScope starts a new scope tied to ctx and returns a derived context plus an endScope function that releases the scope's cached instances. endScope is safe to call multiple times. If ctx is cancelled before endScope is called explicitly, the scope is cleaned up automatically.

Scope storage is shared across a whole parent/child registry chain, so a scoped entry registered on a parent resolves correctly even when BeginScope was called on a child.

Example

RegisterScoped ties an instance to a scope (see BeginScope): the same instance comes back for every Resolve call within that scope.

package main

import (
	"context"
	"fmt"

	"github.com/arpaad/grr"
)

func main() {
	r := grr.New()
	calls := 0
	r.RegisterScoped("conn", func(ctx context.Context) any {
		calls++
		return fmt.Sprintf("conn-%d", calls)
	})

	ctx, end := r.BeginScope(context.Background())
	defer end()

	a := r.Resolve(ctx, "conn")
	b := r.Resolve(ctx, "conn")
	fmt.Println(a, b, a == b)
}
Output:
conn-1 conn-1 true

func (*Registry) Clear

func (r *Registry) Clear()

Clear removes all entries from this registry. It does not touch the parent chain or any in-flight scopes — mainly useful for test teardown.

func (*Registry) Get

func (r *Registry) Get(key string) any

Get is sugar for Resolve(context.Background(), key). Panics if key resolves to a scoped entry — there is no scope in a bare background context.

func (*Registry) IsRegistered

func (r *Registry) IsRegistered(key string) bool

IsRegistered reports whether key is registered in this registry or any of its parents.

Example
package main

import (
	"fmt"

	"github.com/arpaad/grr"
)

func main() {
	r := grr.New()
	fmt.Println(r.IsRegistered("db"))

	r.Set("db", "conn")
	fmt.Println(r.IsRegistered("db"))
}
Output:
false
true

func (*Registry) Keys

func (r *Registry) Keys() []string

Keys returns the keys registered directly in this registry, not walking the parent chain. Order is unspecified. Mainly for introspection and startup validation (see gold.Validate).

func (*Registry) Register

func (r *Registry) Register(key string, factory func(ctx context.Context) any)

Register registers a factory under key. Whether it behaves as transient or singleton is entirely up to the factory's own logic.

func (*Registry) RegisterFunc

func (r *Registry) RegisterFunc(key string, factory func() any)

RegisterFunc is sugar for Register with a context-less factory.

func (*Registry) RegisterScoped

func (r *Registry) RegisterScoped(key string, factory func(ctx context.Context) any)

RegisterScoped registers a factory that produces one instance per active scope (see BeginScope). Resolving without an active scope panics.

func (*Registry) Resolve

func (r *Registry) Resolve(ctx context.Context, key string) any

Resolve looks up key, walking the parent chain if necessary, and produces an instance via the registered factory. Panics if key isn't registered anywhere in the chain, or if key is scoped but ctx carries no active scope (see BeginScope).

Example (NestedScopedDependency)

A scoped factory may resolve another scoped key from the same scope — useful when one dependency is built from another (e.g. a repo needs a transaction). The opposite — a cycle — panics instead of deadlocking.

package main

import (
	"context"
	"fmt"

	"github.com/arpaad/grr"
)

func main() {
	r := grr.New()
	r.RegisterScoped("db", func(ctx context.Context) any { return "db-conn" })
	r.RegisterScoped("repo", func(ctx context.Context) any {
		db := r.Resolve(ctx, "db")
		return fmt.Sprintf("repo(%s)", db)
	})

	ctx, end := r.BeginScope(context.Background())
	defer end()

	fmt.Println(r.Resolve(ctx, "repo"))
}
Output:
repo(db-conn)

func (*Registry) ResolveOK

func (r *Registry) ResolveOK(ctx context.Context, key string) (any, bool)

ResolveOK is like Resolve but reports a missing key with ok == false instead of panicking — for genuinely conditional lookups (e.g. an optional, feature-flagged registration). It does NOT soften real misuse: resolving a scoped key with no active scope, a circular dependency, or resolving after a scope ended still panic, because those are bugs, not conditions to branch on.

func (*Registry) Set

func (r *Registry) Set(key string, value any)

Set registers a fixed value under key — always returns the same value. Sugar for a singleton factory.

Example

Set/Get is sugar for a fixed value — always the same instance.

package main

import (
	"fmt"

	"github.com/arpaad/grr"
)

func main() {
	r := grr.New()
	r.Set("greeting", "hello")

	fmt.Println(r.Get("greeting"))
}
Output:
hello

Directories

Path Synopsis
Package middleware provides net/http (and any router built on it, e.g.
Package middleware provides net/http (and any router built on it, e.g.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL