Registry — Senior¶
1. Mental model — Registry as decoupling primitive¶
At senior level a Registry is not "a map with Register and Lookup". It is the smallest inversion-of-control primitive in Go — a one-way arrow from extension code to library code, mediated by a name. The library does not import the extension. The extension imports the library and announces itself. That direction is the point: the library stays a core that does not grow when you add drivers, codecs, collectors, or commands.
The alternative is static dispatch — a switch that hardcodes the set of impls at compile time:
The switch lives in the library. To add a driver you patch the library. database/sql would import every driver — impossible at scale.
Registry dispatch inverts the arrow:
The library knows the shape (driver.Driver interface) and the name. The set of impls is open. New drivers are added by importing them — typically import _ "github.com/lib/pq", which fires the package's init(), which calls sql.Register("postgres", &Driver{}).
| Property | Static dispatch | Registry dispatch |
|---|---|---|
| Library knows impls? | Yes (closed set) | No (open set) |
| Add impl requires | Library edit + rebuild | Import + rebuild |
| Compile-time safety | Full | Interface satisfaction only |
| Discovery | grep the switch | Names() introspection |
| Test isolation | Swap concrete | Swap registry instance |
| Cycle risk | None | Real — A registers, B looks up |
| Binary size | Used impls only | All imported impls |
The senior heuristic: use a Registry when the set of implementations is open across a module boundary. Inside one module a switch is fine. Across modules — drivers, plugins, third-party collectors — a Registry pays its cost.
Two cousins often confused with Registries: Service Locator (runtime lookup by type — antipattern, Registry survives because names are stable contracts) and DI Container (wires by construction, not name — a Registry is a degenerate DI container with one wiring strategy). A Registry is the smallest such primitive that survives without a framework, which is why it is everywhere in stdlib.
2. Go init() ordering — full rules, dependency DAG, edge cases¶
Registries ride on init(). Understanding ordering is the difference between a working blank-import idiom and a heisenbug.
The full rules (Go spec, Package initialization):
- A package is initialized only after every package it imports (transitively) is initialized.
- Within a package,
vardeclarations are initialized in declaration order, respecting dependencies between them. The compiler builds a DAG over package-level vars. - All
init()functions run after var initialization, in the order they appear across source files; within one file, top to bottom. - File order is by filename, sorted.
- A package's
init()runs exactly once even if imported through ten transitive paths. main'sinit()(if any) runs last, thenmain().
The dependency DAG. For main → A → B → C; A → D; D → C, init order is C, D, B, A, main. C runs once. The order between B and D is unspecified except both run before A. If your registry depends on B's init finishing before D's, you have a latent bug — the compiler may reorder across versions.
Cross-sibling ordering trap. Two blank imports of pq and sqlite3 both register drivers. Order is the order they appear in the import block, but only when neither has a transitive dependency on the other. Rule: registries must not depend on sibling registration order. If two drivers conflict (same name), one panics — which one is undefined.
Variable-vs-init split. var defaultRegistry = newRegistry() runs in var-init phase; func init() { defaultRegistry.register(...) } runs after. By the time any init in this package runs, the var is non-nil. Imported packages' inits already ran — they observed the var because their import resolution waited for this package's var-init.
Edge cases:
- Cyclic imports are forbidden. Cannot resolve A↔B registration by direct import — introduce a third package.
init()in_test.goruns after the package's normal inits. Useful for test-only registrations.- Plugin packages (
buildmode=plugin):init()runs atplugin.Open, not program start. Registrations live forever; cannot unload. go test -rundoes not skipinit(). All registrations happen even for skipped tests.- Linker dead-code elimination keeps
init-only packages —init()is a side effect, soimport _works. go vetdoes not check init order. Code review and tests.
Diagnosing init issues. GODEBUG=inittrace=1 (Go 1.16+) prints each package's init time:
init database/sql @0.512 ms, 0.31 ms clock, 1280 bytes, 23 allocs
init github.com/lib/pq @0.823 ms, 0.07 ms clock, 416 bytes, 5 allocs
A slow init (Prometheus collectors, gRPC reflection) shows up here. Targets above 50 ms slow every test and every restart.
3. Stdlib ecosystem deep dive¶
3.1 database/sql.Register — the canonical implementation¶
var (
driversMu sync.RWMutex
drivers = make(map[string]driver.Driver)
)
func Register(name string, driver driver.Driver) {
driversMu.Lock()
defer driversMu.Unlock()
if driver == nil { panic("sql: Register driver is nil") }
if _, dup := drivers[name]; dup {
panic("sql: Register called twice for driver " + name)
}
drivers[name] = driver
}
sync.RWMutex (not sync.Map) because reads happen rarely — per sql.Open. Panic on duplicate: stdlib chooses fail-fast since two drivers under the same name is almost always a vendoring mistake, and the panic happens during init() so the program never starts in a half-configured state. Drivers() returns sorted output. No Unregister — removing one mid-flight would race every active sql.Open.
3.2 image format detection — copy-on-write¶
var (
formatsMu sync.Mutex
atomicFormats atomic.Value // []format
)
func RegisterFormat(name, magic string, decode, decodeConfig ...) {
formatsMu.Lock()
formats, _ := atomicFormats.Load().([]format)
atomicFormats.Store(append(formats, format{name, magic, decode, decodeConfig}))
formatsMu.Unlock()
}
Writers take a mutex, copy the slice, store the new one. Readers use atomicFormats.Load() with zero locking. Right shape when writes are init-only and reads happen per-image. Magic-byte key as second lookup path: registered by name but looked up by content. No duplicate check — historical, because image/png was registered by stdlib and by golang.org/x/image/png and they had to coexist.
3.3 encoding/gob.Register — bidirectional Registry¶
var (
nameToConcreteType sync.Map // map[string]reflect.Type
concreteTypeToName sync.Map // map[reflect.Type]string
)
gob needs both name → type (decoding) and type → name (encoding). Two maps, kept in sync via sync.Map.LoadOrStore — atomic check-and-insert without a separate mutex. reflect.Type works as a key because it is comparable and globally interned by the runtime. Panic on conflicting registration.
3.4 http.DefaultServeMux — handler Registry¶
ServeMux is a scoped Registry, DefaultServeMux is the global. The senior pattern: always pass an explicit *ServeMux, never rely on the default outside main. Lookups use a trie over path segments (post-1.22 pattern matching). Conflict detection at register time: mux.Handle("/users/{id}", ...) followed by mux.Handle("/users/me", ...) panics if patterns are ambiguous. The data structure is whatever the lookup pattern demands.
3.5 Others¶
| Package | Function | Key | Notes |
|---|---|---|---|
expvar | Publish | string | Panic on duplicate; exposed at /debug/vars |
flag | flag.Var | flag name | Panic on duplicate in flag.CommandLine |
runtime/pprof | NewProfile | string | Custom profile names |
mime | AddExtensionType | extension | Stdlib defaults plus user additions |
The stdlib is consistent: name → impl, panic on duplicate, no removal, Names()-shaped introspection.
4. Real libraries — production Registries at scale¶
Prometheus. Indexed by collector ID (hash of all Descs) and by metric-name hash — detects "different collector, same metric name", a common bug when a library and an application both register http_requests_total. Register returns an error; MustRegister panics. Senior code uses Register in libraries (recoverable) and MustRegister in main (fatal). prometheus.DefaultRegisterer is the global; NewRegistry() for tests.
OpenTelemetry. otel.SetMeterProvider is a singleton Registry with one slot. Calls before Set... return a no-op meter; calls after see the configured one. Delegating Registry: meters created early hold a delegate that switches behavior once the provider is set. A library can call otel.Meter("name") at init() time and still observe a future provider.
Cobra. Each *cobra.Command is a Registry of subcommands. The hierarchy is a tree of Registries; lookup is path-based (myapp migrate up). Cobra deliberately requires explicit AddCommand from main — no init()-time registration — to keep ordering deterministic. Trade: cannot extend by plugin import.
Gorilla mux / chi. Method+pattern is the composite key. Internally a slice of Route walked in order, not a hash map. First match wins. Lookup is O(n) but n is small and order semantics are explicit. When lookup needs order or pattern semantics, a list is the right container, not a map.
gRPC. pb.RegisterUserServiceServer(s, impl) calls s.RegisterService(...). The Registry is closed after Serve — calling RegisterService post-Serve panics. Lifecycle-bounded Registry: open during setup, frozen during operation. Simplifies internal locking and eliminates a class of race conditions.
| Library | Shape | Key | Lifecycle |
|---|---|---|---|
database/sql | Map | Driver name | Open forever |
prometheus | Map of collectors | Collector ID hash | Open forever |
otel | Singleton slot | (one value) | Lazy delegation |
cobra | Tree of maps | Command path | Built in main |
gorilla/mux | Ordered list | Method+pattern | Built before Serve |
grpc | Map | Service name | Frozen at Serve |
Senior reach: match Registry data structure and lifecycle to lookup pattern and safety requirement.
5. Scoped vs global registries — DI, testability, fx/wire¶
Global Registries are singleton mutable state. The pain: tests interfere (one registers "foo", the next panics on duplicate, run order matters), multi-tenant is impossible, no clean teardown (the test's impl stays registered), and dependencies become invisible to code review.
Scoped Registries are instances, not package-level vars:
type CodecRegistry struct {
mu sync.RWMutex
codecs map[string]Codec
}
func NewCodecRegistry() *CodecRegistry { /* ... */ }
type Service struct { codecs *CodecRegistry }
func NewService(codecs *CodecRegistry) *Service { return &Service{codecs} }
Tests get their own. Production wires one in main. DI assembles. No globals.
fx integration makes scoped Registries idiomatic:
fx.New(
fx.Provide(NewCodecRegistry),
fx.Invoke(func(r *CodecRegistry) error { return r.Register("json", jsonCodec{}) }),
fx.Invoke(func(r *CodecRegistry) error { return r.Register("proto", protoCodec{}) }),
).Run()
fx.Invoke runs after construction, in declaration order, with the Registry injected. No globals, no init() side effects.
wire integration is compile-time DI:
func ProvideCodecRegistry() *CodecRegistry { return NewCodecRegistry() }
func ProvideRegisteredCodecs(r *CodecRegistry) (*CodecRegistry, error) {
if err := r.Register("json", jsonCodec{}); err != nil { return nil, err }
return r, nil
}
var Set = wire.NewSet(ProvideCodecRegistry, ProvideRegisteredCodecs)
Wiring is in code, statically analyzable, no reflection.
The hybrid: keep the global but ban it from production wiring. The global is the plugin discovery Registry; production reads it once at startup and copies into a scoped Registry the rest of the system uses. Blank-imports still work; tests run against scoped Registries and never collide.
func LoadCodecsFromGlobal(target *CodecRegistry) error {
for _, name := range codec.Default.Names() {
c, _ := codec.Default.Get(name)
if err := target.Register(name, c); err != nil { return err }
}
return nil
}
Senior codebases lean toward scoped; junior lean toward globals.
6. Generic typed Registry (Go 1.18+)¶
Pre-1.18 Registries used interface{} and lost type info. Generics give a typed Registry without the cost.
package registry
type Registry[T any] struct {
mu sync.RWMutex
items map[string]T
onChange []func(name string, t T)
}
func New[T any]() *Registry[T] {
return &Registry[T]{items: make(map[string]T)}
}
func (r *Registry[T]) Register(name string, t T) error {
if name == "" { return fmt.Errorf("registry: empty name") }
r.mu.Lock()
defer r.mu.Unlock()
if _, dup := r.items[name]; dup {
return fmt.Errorf("registry: %q already registered", name)
}
r.items[name] = t
for _, fn := range r.onChange { fn(name, t) }
return nil
}
func (r *Registry[T]) MustRegister(name string, t T) {
if err := r.Register(name, t); err != nil { panic(err) }
}
func (r *Registry[T]) Get(name string) (T, bool) {
r.mu.RLock(); defer r.mu.RUnlock()
t, ok := r.items[name]; return t, ok
}
func (r *Registry[T]) Names() []string {
r.mu.RLock(); defer r.mu.RUnlock()
out := make([]string, 0, len(r.items))
for n := range r.items { out = append(out, n) }
sort.Strings(out); return out
}
func (r *Registry[T]) OnChange(fn func(name string, t T)) {
r.mu.Lock(); defer r.mu.Unlock()
r.onChange = append(r.onChange, fn)
}
type Factory[T, C any] func(cfg C) (T, error)
type FactoryRegistry[T, C any] struct{ inner *Registry[Factory[T, C]] }
func NewFactory[T, C any]() *FactoryRegistry[T, C] {
return &FactoryRegistry[T, C]{inner: New[Factory[T, C]]()}
}
func (r *FactoryRegistry[T, C]) Register(name string, f Factory[T, C]) error {
return r.inner.Register(name, f)
}
func (r *FactoryRegistry[T, C]) Build(name string, cfg C) (T, error) {
var zero T
f, ok := r.inner.Get(name)
if !ok {
return zero, fmt.Errorf("registry: no factory %q (have: %v)", name, r.inner.Names())
}
return f(cfg)
}
Use sites:
var codecs = registry.New[Codec]()
func init() { codecs.MustRegister("json", jsonCodec{}) }
var stores = registry.NewFactory[Store, StoreConfig]()
func init() {
stores.Register("redis", func(cfg StoreConfig) (Store, error) {
return newRedisStore(cfg.Addr)
})
}
s, err := stores.Build("redis", StoreConfig{Addr: ":6379"})
Senior touches: Register returns error and MustRegister panics (library-friendly); error in Build lists known names (debuggable for typos); OnChange for live introspection feeding Prometheus or audit logs; no Unregister in the base type (safe removal is a separate problem, §7).
7. Hot-reload Registries — copy-on-write, atomic.Pointer[map], plugin lifecycle¶
Most Registries are append-only and startup-bounded. Long-running servers sometimes need to swap implementations live. The naive solution (Lock on every read) breaks the read path for everyone every time the writer touches the map.
Copy-on-write with atomic.Pointer[T] (Go 1.19+):
type HotRegistry[T any] struct {
items atomic.Pointer[map[string]T]
mu sync.Mutex // serializes writers only
}
func NewHot[T any]() *HotRegistry[T] {
r := &HotRegistry[T]{}
empty := make(map[string]T)
r.items.Store(&empty)
return r
}
func (r *HotRegistry[T]) Get(name string) (T, bool) {
m := *r.items.Load() // one atomic pointer read
t, ok := m[name]; return t, ok
}
func (r *HotRegistry[T]) Register(name string, t T) error {
r.mu.Lock(); defer r.mu.Unlock()
old := *r.items.Load()
if _, dup := old[name]; dup { return fmt.Errorf("duplicate: %q", name) }
next := make(map[string]T, len(old)+1)
for k, v := range old { next[k] = v }
next[name] = t
r.items.Store(&next)
return nil
}
func (r *HotRegistry[T]) Unregister(name string) {
r.mu.Lock(); defer r.mu.Unlock()
old := *r.items.Load()
if _, ok := old[name]; !ok { return }
next := make(map[string]T, len(old)-1)
for k, v := range old { if k != name { next[k] = v } }
r.items.Store(&next)
}
Readers pay one atomic load — faster than RWMutex.RLock/RUnlock (no atomic CAS pair, no fairness queue). Writers pay map-copy: O(n) per write, fine when writes are rare. The map is never mutated after Store, so holders of an old map pointer see a consistent snapshot. Old maps stay alive until the last reader releases; GC cleans them up.
Plugin lifecycle. A Go plugin registers at plugin.Open. Tearing down is the problem: Go plugins cannot be unloaded — dlclose is not called; the plugin's code stays mapped forever. Senior shape for live updates:
- Build versioned plugin filenames (
codec_xml_v2.so). plugin.Openthe new file. Itsinit()registers under a versioned name (xml-v2).- Atomically swap the routing layer to point at
xml-v2. - Drain in-flight uses of
xml(reference counting or "wait N seconds"). - Leave
xmlregistered but unreferenced — old version stays in memory until process restart.
Not "hot reload" in the JVM sense; "side-by-side versions with controlled traffic shifting". Accepting that limitation is the senior skill.
Quiescence and draining. Pure copy-on-write gives snapshot consistency but does not tell you when the last user of an old value is done. Options: reference counting (atomic.Int32 per value, Unregister waits for zero), generation epoch (consumers record the epoch at lookup; unregisterer waits via WaitGroup), or RCU (rare in Go — no quiescent-state callback). Most codebases stop at "wait 30 seconds, hope for the best".
| Pattern | Read cost | Write cost | Use |
|---|---|---|---|
RWMutex | RLock/RUnlock pair | Lock | Default |
sync.Map | Atomic ops + hashing | Atomic ops + hashing | High write rate, equal keys |
atomic.Pointer[map] | One atomic load | Map copy + atomic store | Hot-reload, read-heavy |
Sharded RWMutex | RLock per shard | Lock per shard | Many writers, many keys |
8. Observability — introspection, Names(), audit log¶
Registries are configuration that ships with the binary. Observability is how you prove it matches expectations.
Introspection endpoint. Expose /debug/registries/{name} for every Registry:
func (r *Registry[T]) ServeHTTP(w http.ResponseWriter, req *http.Request) {
type entry struct{ Name, Type string }
out := make([]entry, 0, len(r.Names()))
for _, n := range r.Names() {
t, _ := r.Get(n)
out = append(out, entry{Name: n, Type: fmt.Sprintf("%T", t)})
}
json.NewEncoder(w).Encode(out)
}
Three minutes after a deploy you can curl the endpoint and verify the right drivers loaded. No restart, no log-grepping.
Audit log. Capture the registering stack:
r.log.Info("registry: registered",
"name", name,
"type", fmt.Sprintf("%T", t),
"stack", string(debug.Stack()))
The stack tells you which init() did the registration. When you ship a binary and Drivers() shows the wrong driver, the stack identifies the offending package. Saves hours of vendor archeology.
Metrics:
| Metric | Type | Labels | Use |
|---|---|---|---|
registry_size | gauge | registry | Verify expected impls loaded |
registry_lookups_total | counter | registry, name, hit | Detect unused registrations |
registry_lookup_misses_total | counter | registry, name | Typo or missing-import signal |
registry_register_total | counter | registry | Compare across deploys |
registry_register_duration_seconds | histogram | registry | Slow init() detection |
The misses metric is gold: a typo in config ("redus") shows up as a labeled miss, not a crash. Alert on rate(registry_lookup_misses_total[5m]) > 0 for production.
GODEBUG=inittrace=1 output (init github.com/lib/pq @0.823 ms, 0.07 ms clock, 416 bytes, 5 allocs) can be scraped at startup and exported as Prometheus metrics — useful when a plugin's init time creeps from 20 ms to 500 ms.
9. Failure modes¶
Duplicate registration panic. Two packages register under the same name; the second Register panics. With init()-time registration the program never starts. Causes: vendoring two versions of the same library, aliased forks (fork-of-pq registers as "postgres"), test re-imports. Mitigation: stdlib chose panic — live with it (alternative "last write wins" hides bugs); for your own Registries return errors in Register, panic only in MustRegister; audit log captures the offending stack.
Init dependency cycles. Two packages each want to look up what the other registers. Compiler rejects the import cycle. The workaround — looking up via a global blob — defers failure to runtime: one package's init() runs first, finds nothing, fails or silently picks a default. Senior shape: init()s must not look up from other Registries. Look up at first use, not at init. The Registry is just a map at init time; consumers run later.
Partial-init races. A goroutine started in init() that registers later:
func init() {
go func() {
cfg := loadConfig() // slow
codecs.Register("yaml", &yamlCodec{cfg})
}()
}
main() runs, calls codecs.Get("yaml"), gets nothing. Rule: registrations must complete synchronously within init(). If you cannot, do not pretend the Registry has the value — fail at first use, or use factories that fetch config on demand.
Plugin segfault isolation. A Go plugin shares the host's memory. A bug in the plugin — nil deref, out-of-bounds — crashes the host. There is no in-process isolation. Options: subprocess plugins with hashicorp/go-plugin (RPC over Unix socket — plugin crash kills only the subprocess); recover() around every plugin call (catches panics, not segfaults from cgo or memory corruption); or refuse the plugin model and compile in. Senior heuristic: if a plugin can crash you, you do not have a plugin — you have a tightly coupled monolith with extra steps.
Memory leak via never-released values. A Registry's map holds strong references. Registering a giant struct keeps it alive forever. Symptom: heap grows after every test because each re-registers fixtures. Diagnose with pprof heap; fix with scoped Registries that go out of scope with the test.
Lookup-by-typo as silent fallback. c, ok := codecs.Get(cfg.Codec); if !ok { c = defaultCodec } hides config errors. User types "yml" instead of "yaml", gets the default, no log, no metric. Make the miss loud — return an error, increment the miss counter, alert.
| Failure | Symptom | Fix |
|---|---|---|
| Duplicate registration | Crash at startup | Vendor audit; rename forks |
| Init cycle | Compile error or order-dependent bug | Lazy lookup, not init-time |
| Partial-init race | Random Lookup misses | Synchronous registration only |
| Plugin segfault | Whole process dies | Subprocess plugins |
| Memory leak | Heap grows per test | Scoped Registries |
| Silent typo fallback | Wrong codec in production | Loud misses + alerts |
10. When NOT a Registry + closing principles¶
10.1 When not¶
- Closed set of implementations. Three database drivers you control? A switch is simpler, type-safer, no init magic.
- Sole consumer in same package. Don't register and immediately look up. Just use the value.
- Compile-time known set. A
[N]Tarray indexed by enum is faster, type-safe, and exhaustive (the compiler catches missing cases via switch). - Need ordering or composition. Middleware chains are sequential; Registries lose order. Use a slice.
- Hot dispatch path. Map lookup + interface call beats a static call by 5-20 ns. For a hot inner loop, that matters. Inline.
- Configuration would be simpler. If "registration" is "ship a config file with a list", a config file is more discoverable, version-controlled, and inspectable.
- The thing has identity. Registries map names to values. If two different "postgres" connections are not the same value — a connection pool, a tenant-scoped driver — you want a factory, not a Registry of singletons.
10.2 Closing principles¶
A Registry is an inversion of control, not a map. The map is incidental. The point is that the library does not know about the implementations. If your "registry" is just a map you control, refactor it to a struct.
Match the data structure to the lookup pattern. Map for exact-name lookup. Trie for path patterns. Ordered list for first-match. Two maps for bidirectional lookup. Sharded map for many writers. Read the lookup pattern first; choose the container second.
Panic at register, error at lookup. Registration is a startup-time configuration mistake — fail fast with a clear message. Lookup is a runtime situation — return an error and let the caller decide.
Always provide Names() and an introspection endpoint. Registries are invisible until they fail. Names plus endpoint makes them visible in three seconds — worth the 20 lines of code.
Globals for plugin discovery, scoped for production wiring. The hybrid: a global captures init()-time registrations; production reads it once into a scoped Registry that the rest of the system uses. Tests never touch the global.
init() does the bare minimum. Register the value. Don't load config, don't open connections, don't start goroutines. Heavy work goes in Open / New / Build called from main. Init failures are unrecoverable.
Hot-reload is harder than it looks. Atomic pointer to map gives snapshot consistency. Draining and quiescence are separate problems. If you do not need them, document "Registry is startup-bounded" and enforce it with no Unregister.
The set of impls is a deploy-time concern. Two systems with different impl sets are two binaries, not one binary with a runtime switch. Avoid making every Registry remotely mutable.
Observe the misses. Lookup hits are routine; misses are bugs in disguise. Counter, label by name, alert when nonzero in prod. This single signal catches more config bugs than tests do.
The Registry is the smallest abstraction Go gives you for "the library does not know about its extensions". Used well, it lets your core stay small while the ecosystem grows. Used badly, it gives you panics at startup, ghost registrations, and a config surface no one can audit. The senior shift is treating the Registry as a contract: names are stable, registrations are synchronous, introspection is built in, and the lifecycle is documented.
Further reading¶
src/database/sql/sql.go— canonical stdlib Registrysrc/image/format.go— copy-on-writeatomic.ValueRegistrysrc/encoding/gob/type.go— bidirectional Registry withsync.Mapprometheus/client_golang—RegistryandMustRegisterhashicorp/go-plugin— subprocess plugin isolationgo.uber.org/fxandgoogle/wire— scoped Registry wiring- Go spec: Package initialization
GODEBUG=inittrace=1—runtime/extern.go