Registry — Specification¶
1. Origins¶
The Registry pattern predates Go by two decades and arrived through three independent traditions: enterprise-application data-access catalogs, dynamic-dispatch service location, and modular runtime plugin systems. Go's contribution is to elevate the pattern from "useful idiom" to "default wiring mechanism for the standard library", driven by init() functions, blank imports, and package-level variables.
Historical milestones:
- Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) — formally catalogued Registry as a well-known place where objects can be found by name. Fowler distinguished session-scoped, thread-scoped, and process-scoped registries; emphasised that Registry is a controlled global access point, not a hidden one. The book treated it as a domain-layer convenience for shared objects like the system clock and the current user.
- Mark Seemann, Dependency Injection in .NET (Manning, 2010) — codified the Service Locator antipattern critique. Seemann's argument is that a Registry that returns arbitrary dependencies to its callers hides those dependencies from the type system; the caller's API no longer documents what it needs. The antipattern label sticks when Registry is misused as a general DI container, not when it is used for the bounded plugin-style cases Fowler described.
- Java
ServiceLoader(Java 6, 2006) — standardised SPI (Service Provider Interface) discovery viaMETA-INF/services/*files on the classpath. A library declares a provider interface; jars on the classpath drop a file listing their implementations;ServiceLoader.load(Iface.class)returns the registered implementations. This is Registry-by-filesystem-convention rather than Registry-by-code. - .NET
DependencyResolverand ASP.NET MVC'sIDependencyResolver(2009) — built-in registry for controller factories and view engines, criticised by Seemann as a leaky abstraction that pulled Service Locator antipattern into the framework's core APIs. - Eclipse OSGi (1999 onward) — bundle-and-service registry for the Eclipse plugin platform; the most ambitious production Registry system, supporting hot install/uninstall of plugins, versioned interfaces, and per-bundle classloaders. OSGi proved the pattern scales to thousands of pluggable components at the cost of significant runtime machinery.
Go-specific history:
database/sql.Register(Go 1.0, 2012) — shipped on day one. Drivers register themselves under a string name in theirinit(); user code doessql.Open("postgres", dsn)without ever importing the driver package by name (it is blank-imported for side effects). This is the canonical Go Registry and the reference shape every subsequent stdlib registry imitates.image.RegisterFormat(Go 1.0, 2012) — same pattern: image decoders register a magic-byte prefix and a decoder function;image.Decode(r)dispatches by sniffing the prefix. Blank-importingimage/pngorimage/jpegadds the format.encoding/gob.Register(Go 1.0, 2012) — concrete types register themselves so the gob decoder can reconstruct interface values. The key is the type name; the value is areflect.Type. Without registration, decoding into an interface fails at runtime.net/httpDefaultServeMux(Go 1.0, 2012) — package-level Registry of URL pattern tohttp.Handler.http.HandleFunc("/path", h)mutates global state;http.ListenAndServe(":8080", nil)uses it. The most-criticised global Registry in the standard library precisely because it is hidden.expvar.Publish(Go 1.0, 2012) — registers a named variable for/debug/varsHTTP introspection. The smallest possible Registry: one map, one mutex, one publish function.pluginpackage (Go 1.8, 2017) — dynamic loading of.sofiles viaplugin.Open; the loaded plugin'sinit()runs and can call into Registries to register itself. The package is widely regarded as a partial solution — version mismatches between host and plugin produce opaque failures, cross-platform support is limited, and the loaded code shares the host's runtime — but it is the only stdlib path to runtime-loaded code.reflect.Register(informal) —encoding/gobandencoding/jsonboth maintain internal type Registries derived from reflection; the pattern of cachingreflect.Typeto an encoder/decoder pair appears throughout the stdlib whenever serialisation meets interfaces.
Go's distinctive stance is to push the Registry into the language's startup machinery: init() functions and blank imports give you compile-time discoverability of side-effect registrations without forcing the consumer to know which packages exist. This is the missing piece in older languages where SPI required runtime classpath scanning or reflection over annotations. The trade-off is that registrations are package-global and hard to test in isolation — the same complaint Seemann lodged against .NET's DependencyResolver. Modern Go codebases respond with the scoped-Registry shape: an exported Registry struct passed through constructors, with the package-level Registry retained only for backward compatibility or for genuinely plugin-style APIs where the consumer cannot name the implementation set.
2. Go language mechanics¶
2.1 init() ordering¶
init() functions run in a deterministic order defined by the Go specification:
- Package-level
vardeclarations are evaluated first, in declaration order subject to initialisation-dependency reordering. - All
init()functions within a package run next, in the order their source files are presented to the compiler (alphabetical by file name in the standard toolchain), and in declaration order within a file when multipleinits exist. - A package's
init()runs only after every package it imports — direct or transitive — has finished its own initialisation.
For the Registry pattern this means: any code reachable from main() observes a fully populated Registry, provided every contributing package has been imported somewhere in the dependency graph. The producer's init() runs before the consumer's main() with no scheduling required.
2.2 Blank imports¶
import _ "github.com/lib/pq" brings the package's init() into the program without exposing its identifiers. This is the side-effect-only import; it exists in Go specifically to make Registry-style registration ergonomic. Without blank imports, a consumer would either need to call an explicit pq.Init() (which destroys the discoverability of the pattern) or import the package and accept an "imported but not used" compile error.
The blank import is non-transitive in intent — it documents to readers that the imported package is wanted for its side effects, not its API.
2.3 Package-level vars + Register pattern¶
The default Registry shape is a package-level map guarded by a mutex, exposed only through Register and lookup functions:
var (
mu sync.RWMutex
providers = map[string]Provider{}
)
func Register(name string, p Provider) { /* ... */ }
func Get(name string) (Provider, bool) { /* ... */ }
The map is unexported; mutation goes through Register; reads go through Get. The package itself owns the invariants (no duplicates, no nil values), which is how database/sql keeps its driver list from drifting into illegal states.
2.4 sync.RWMutex / sync.Map¶
A Registry is many-readers / few-writers in steady state — writes happen at startup, reads happen per request. sync.RWMutex is the canonical choice: RLock permits concurrent reads, Lock is rare. sync.Map becomes attractive only when reads happen on disjoint keys at very high contention (the per-key cache line stays warm); for the typical Registry with hundreds of keys and millions of reads, RWMutex wins on simplicity and predictability.
2.5 Generics typed Registry (Go 1.18+)¶
Pre-generics Registries stored interface{} and required type assertions on Get. Go 1.18 enabled a typed Registry:
type Registry[K comparable, V any] struct {
mu sync.RWMutex
items map[K]V
}
func (r *Registry[K, V]) Register(k K, v V) { /* ... */ }
func (r *Registry[K, V]) Get(k K) (V, bool) { /* ... */ }
The pattern is now a one-line instantiation per Registry shape — Registry[string, Codec], Registry[string, Factory[Store]], Registry[reflect.Type, Handler]. Stdlib registries pre-date generics and still use interface{}; new application Registries should reach for the generic form.
2.6 Panic vs error in Register¶
Registries diverge on whether Register returns an error or panics. The stdlib convention — sql.Register, gob.Register, image.RegisterFormat, expvar.Publish — is to panic on duplicate or nil because registration is a top-level wiring concern: if it fails, the program is misconfigured and cannot proceed. Library Registries that need to recover gracefully (Prometheus's Registerer.Register, gRPC's reflection server) return an error and pair it with a Must-prefixed wrapper that panics. The choice is part of the API contract and must be consistent across the package.
3. Canonical Go shapes¶
3.1 Implementation registry (Register/Get)¶
type Codec interface {
Encode(any) ([]byte, error)
Decode([]byte) (any, error)
}
var (
mu sync.RWMutex
codecs = map[string]Codec{}
)
func Register(name string, c Codec) {
if c == nil { panic("codec: Register nil codec for " + name) }
mu.Lock(); defer mu.Unlock()
if _, dup := codecs[name]; dup {
panic("codec: Register called twice for " + name)
}
codecs[name] = c
}
func Get(name string) (Codec, bool) {
mu.RLock(); defer mu.RUnlock()
c, ok := codecs[name]
return c, ok
}
The simplest and most common Registry shape. Use when the registered value is a stateless, shareable, fully-constructed implementation.
3.2 Factory registry (Register/New)¶
type Factory func(cfg Config) (Store, error)
var factories = map[string]Factory{}
func Register(name string, f Factory) { /* same as above */ }
func New(name string, cfg Config) (Store, error) {
mu.RLock(); f, ok := factories[name]; mu.RUnlock()
if !ok { return nil, fmt.Errorf("unknown store: %s", name) }
return f(cfg)
}
Use when constructing the implementation needs configuration. The Registry stores constructors; the consumer calls New with a name plus the config payload and receives a fresh instance.
3.3 Handler registry (router)¶
mux := http.NewServeMux()
mux.HandleFunc("/users", usersHandler)
mux.HandleFunc("/orders", ordersHandler)
mux is a Registry of pattern to handler. HandleFunc is the Register call; per-request routing is the Get. The dispatch happens per request rather than at startup, and the key is a pattern (longest-prefix or trie match) rather than an exact name.
3.4 Scoped Registry struct¶
type CodecRegistry struct {
mu sync.RWMutex
codecs map[string]Codec
}
func NewCodecRegistry() *CodecRegistry { /* ... */ }
func (r *CodecRegistry) Register(name string, c Codec) { /* ... */ }
func (r *CodecRegistry) Get(name string) (Codec, bool) { /* ... */ }
No package globals. The Registry is an instance passed through dependency injection or constructor wiring. Tests construct their own; production constructs one in main. Senior codebases prefer this shape when testability dominates over import _ convenience.
3.5 Hot-reload Registry¶
type Registry struct {
mu sync.RWMutex
version uint64
items map[string]Plugin
}
func (r *Registry) ReplaceAll(next map[string]Plugin) {
r.mu.Lock(); defer r.mu.Unlock()
r.items = next
r.version++
}
A whole-map swap rather than per-key mutation. Reads see a consistent snapshot; writes replace the snapshot atomically under the write lock. Variants use atomic.Pointer[map[string]Plugin] for lock-free reads. Used when plugins reload at runtime (Envoy filter chains, HashiCorp plugins, hot-deployable application servers).
3.6 Versioned Registry¶
type Entry struct { Version int; Impl Codec }
var entries = map[string][]Entry{}
func Register(name string, version int, c Codec) { /* ... */ }
func Get(name string, version int) (Codec, bool) { /* ... */ }
func Latest(name string) (Codec, bool) { /* ... */ }
When the registered implementation has a version axis — wire-protocol revisions, schema migrations, codec generations — the Registry becomes a two-level map of name → version → impl. The lookup API exposes both pinned (Get(name, version)) and latest (Latest(name)) reads. The pattern appears in long-lived RPC frameworks and database driver compatibility layers where multiple implementations must coexist during a rollout.
4. Standard library use¶
4.1 database/sql.Register¶
The reference Registry. sql.Register(name string, driver driver.Driver) panics on duplicate name or nil driver. Drivers (lib/pq, mattn/go-sqlite3) call it from init(). Consumer code uses sql.Open(name, dsn) which performs the lookup. The pattern is so canonical that "Go SQL driver" and "thing that calls sql.Register" are synonymous.
4.2 image.RegisterFormat¶
image.RegisterFormat(name, magic string, decode func(io.Reader) (Image, error), decodeConfig func(io.Reader) (Config, error)). The magic string is matched against the prefix of the input stream; image.Decode(r) peeks the first bytes, dispatches to the matching decoder. Blank-importing image/png, image/jpeg, image/gif adds support for those formats without the consumer's code naming them.
4.3 encoding/gob.Register¶
gob.Register(value any) records a concrete type under its name so the gob decoder can resurrect interface values. The Registry is keyed by name string; the value is a reflect.Type derived from the registered example. Required whenever a gob stream carries an interface{} that decodes back to a concrete type.
4.4 expvar.Publish¶
expvar.Publish(name string, v Var) adds a named exposed variable to the /debug/vars endpoint. Duplicates panic. The total implementation is roughly fifty lines of code; it is the smallest illustrative Registry in the standard library and a good reading exercise.
4.5 http.DefaultServeMux¶
Package-level Registry of URL patterns to handlers. http.HandleFunc, http.Handle, and http.ListenAndServe(addr, nil) all touch this global. The pattern is criticised in production because the global is hidden — tests that import packages with side-effect http.HandleFunc registrations leak handlers between tests. The convention in modern code is to construct an explicit http.NewServeMux() and avoid the default.
5. Real library use¶
5.1 prometheus.MustRegister¶
prometheus.MustRegister(collectors ...prometheus.Collector) adds collectors to the default Registry; panics on duplicate descriptors. The client_golang library exposes both a global DefaultRegisterer and a scoped NewRegistry() constructor — the hybrid shape — and the convention in modern Prometheus instrumentation is to use the scoped form for libraries and the global for application top-level wiring.
5.2 cobra commands¶
Each *cobra.Command is registered with a parent via parent.AddCommand(child). The CLI tree is a hierarchical Registry of command names to handlers. Dispatch is by argv prefix matching: myapp users list walks the registry tree users → list and invokes the leaf's Run function.
5.3 gorilla/mux¶
A richer handler Registry than http.ServeMux: routes are registered with method, host, header, and path constraints, and matching is via ordered trial of registered routes. The Registry is per-*mux.Router instance (scoped, not global), which is part of why gorilla/mux is preferred in larger applications over the default mux.
5.4 hashicorp/go-plugin¶
Out-of-process plugins communicating over gRPC; each plugin process registers itself with the host via a handshake. The Registry is split across processes: the host knows the plugin's gRPC service name; the plugin's init() registers its concrete implementation with the gRPC server. The library handles plugin lifecycle (start, restart, kill) so the Registry has a notion of liveness that in-process Registries do not.
5.5 gRPC service registrar¶
The generated RegisterUserServiceServer is a thin wrapper around the gRPC server's internal Registry of service-name to handler-table. Per-RPC dispatch consults this Registry. Each *grpc.Server is a scoped Registry; there is no global gRPC Registry, which is one of the reasons gRPC code is easy to test in isolation.
5.6 encoding/json and mapstructure decoder hooks¶
mapstructure.DecodeHookFunc and similar JSON decoder hook lists are Registries of (source-type, target-type) → conversion function. The library iterates the registered hooks on each field; the first matching hook wins. The Registry is per-decoder-instance rather than global, which makes it composable across services with different schema needs.
5.7 viper configuration providers¶
The spf13/viper library maintains a Registry of remote-config providers keyed by name (etcd, consul, firestore). Each provider registers via viper.RemoteConfig interface; user code calls viper.AddRemoteProvider(provider, endpoint, path) to wire one. The pattern is the same as database/sql: a name string, a side-effect blank import, and a global lookup.
6. Formal specification¶
A Go Registry consists of:
| Element | Description |
|---|---|
| Registry | A keyed container that maps an identifier to a registered value; usually a map plus a mutex, exposed only through Register and lookup functions. |
| Key | A comparable identifier — most commonly a string name; occasionally a reflect.Type, an integer enum, or a struct of selectors. |
| Value / Impl | The registered thing: an interface satisfier, a factory function, a handler, a description record. |
| Register fn | The mutation entry point; takes (key, value); enforces invariants (nil-check, duplicate-check) by panic or returned error. |
| Get fn | The lookup entry point; returns (value, ok) or (value, error); never panics on miss. |
| Names fn | Optional but recommended; returns a sorted slice of registered keys for diagnostics and debugging. |
| Mutex | Guards the underlying map; sync.RWMutex is the default; sync.Map for very high read contention on disjoint keys. |
Invariants:
- At most one entry per key. A second
Register(name, v)for the same name either panics, returns an error, or replaces the prior entry — never silently appends. The stdlib convention is to panic; library Registries diverge based on whether reload semantics are wanted. - No nil values.
Register(name, nil)is rejected at the entry point; nil in the Registry is a defect that surfaces only at lookup time, which is too late to be useful for diagnosis. - Consumer never mutates the underlying map.
Names()returns a copy;Getreturns the stored interface value; the map is never exposed. Consumers must not be able to insert, delete, or rename entries. - Initialisation completes before first read. Every contributing package's
init()runs beforemain(); the Registry is therefore fully populated before any handler, request, or query can hit it. Lazy registration is permitted but requires the same lock discipline as reads. - The mutex protects every read and every write. Even a read that never races in practice must take the lock; otherwise the race detector reports a data race against a concurrent
Register, and the program is formally undefined.
Together these invariants reduce the Registry to a single-writer-many-readers map with a published-set contract. All correctness reasoning about Registry code reduces to checking these five conditions.
Lifecycle states:
| State | Description | Transition |
|---|---|---|
| Empty | Registry constructed; no entries; reads return (_, false). | A successful Register moves it to Populated. |
| Populated | Steady state; one or more entries; reads return (value, true) for known keys. | Further Register calls add entries; replacements move it to Replaced (hot-reload) or panic (strict). |
| Frozen | Optional terminal state; further Register returns an error or panics. | None; once frozen, mutations are rejected. Used in test harnesses to detect late registrations. |
Most stdlib Registries occupy only Empty and Populated; the Frozen state is an application-level discipline, often realised by a Freeze() method or by hiding Register after main()'s top-of-function Register block.
7. Anti-patterns¶
7.1 Silent duplicate registration¶
A Register that silently overwrites a prior entry hides bugs where two packages claim the same name. The second registration wins non-deterministically based on import order, and the first package's implementation vanishes without a log line. The failure mode is the worst kind: production behaves correctly under one build order and incorrectly under another, with no diagnostic at registration time. Fix: panic on duplicate or return an error; choose one and apply it consistently across the codebase.
7.2 No nil check on Register¶
Register("foo", nil) succeeds; Get("foo") returns a non-nil (value, true) whose underlying interface value is nil. The first method call panics with a nil pointer dereference far from the point of registration. The interface-nil-vs-typed-nil confusion compounds the problem: a var d *Driver passed into Register("pq", d) registers a non-nil interface wrapping a nil pointer, which passes a naive if c == nil check but still fails on method dispatch. Fix: panic at Register time on nil values, and document the typed-nil pitfall in the package docs.
7.3 Mutating the returned map¶
The consumer can now insert, delete, or rename entries, bypassing the lock and breaking every other reader. Fix: return a copy from Map(), or expose only Get, Names, and iteration helpers.
7.4 Runtime registration in hot path¶
func Handle(req Request) {
if _, ok := Get(req.Type); !ok {
Register(req.Type, defaultHandler) // mutates under read load
}
}
Per-request mutation forces the write lock onto the hot path; throughput collapses. The pattern almost always indicates a missing startup registration. Fix: register at startup; treat the Registry as effectively read-only after main() begins.
7.5 reflect.Type key for serialised data¶
A Registry keyed by reflect.Type is fine in-memory but breaks the moment data crosses a process boundary: renaming the type, moving its package, or running an older build with a different type identity all break the key. Fix: use a stable string identifier ("v1.UserCreated") for any key that persists or crosses the wire.
7.5b Registry exposed as a global mutable map¶
Exporting the underlying map gives every consumer write access without going through Register. Concurrent mutations race, invariants drift, and the lock — if any — is bypassed entirely. Fix: keep the map unexported; expose only Register, Get, and Names. If a consumer wants to iterate, provide a Range(func(key, value) bool) method that takes the read lock internally.
7.6 Registry as a DI container¶
container.Register("logger", log.Default())
container.Register("db", openDB())
container.Register("svc", &Service{Log: container.Get("logger"), DB: container.Get("db")})
The Service Locator antipattern Seemann named. Dependencies are no longer documented in Service's signature; reviewers cannot tell what Service needs without reading its body. Fix: inject dependencies through constructors and function parameters; reserve Registry for true plugin-style discovery (drivers, codecs, formats) where the consumer genuinely does not know the implementation set at compile time.
7.7 init() side effects beyond Register¶
func init() {
Register("pq", &Driver{})
go startBackgroundReconnector() // bug: spawns work at import time
log.Printf("pq driver loaded") // bug: log line nobody asked for
}
init() must do exactly one thing: register. Spawning goroutines, opening files, dialling networks, or logging from init() couples the consumer's startup to invisible side effects of every blank-imported dependency. The cumulative effect across a binary with dozens of blank-imported providers is unbounded startup latency, goroutine leaks, and log noise that nobody can attribute. Fix: keep init() to Register calls only; defer everything else to lazy first-use or explicit Init() functions the consumer chooses to call.
8. Variants and dialects¶
| Variant | Description |
|---|---|
| Implementation registry | Stores pre-built, shareable implementations under a name; consumer calls Get(name) and uses the returned interface directly. Canonical: database/sql. |
| Factory registry | Stores constructor functions; consumer calls New(name, cfg) to receive a fresh instance built with the supplied config. Canonical: cloud SDK client factories. |
| Handler registry | Stores callbacks dispatched per event; the Registry is consulted on every incoming event rather than at startup. Canonical: HTTP routers, gRPC servers. |
| Scoped registry | Registry is an instance type (*Registry), not a package-global; passed through DI. Preferred when testability outweighs blank-import convenience. |
| Hot-reload registry | The set of registrations may change at runtime; readers see consistent snapshots via lock-free atomic pointer swap or RWMutex with whole-map replacement. |
| Distributed registry | Registrations live in an external store (etcd, Consul, ZooKeeper); local code observes via watch streams. The Registry is now a coordination problem, not a data-structure problem; service discovery is its most familiar instance. |
| Hierarchical registry | Keys form a tree (commands.users.list); lookups walk the tree by path segments. Cobra's command tree and OpenTelemetry's instrumentation namespace are the canonical examples. |
| Tagged registry | Each entry carries metadata tags; lookups can filter on tags as well as match on key. Useful for collector Registries (metrics with tag="auth") and middleware chains where ordering depends on a registered priority. |
9. Naming conventions¶
Register(name, value)— the standard mutation entry point. Panics on duplicate or nil are conventional in stdlib-style packages; returning an error is conventional in library-style packages where the caller can recover.MustRegister(...)— Prometheus convention: a variant that panics on error so it can be called as a top-level package declarationvar _ = MustRegister(...). Pair with a non-panickingRegisterthat returns an error for callers who need to handle it.Get(name) (V, bool)— the standard lookup. Theboolsecond return is preferred over a sentinel zero value because it disambiguates "registered with the zero value" from "not registered".New(name, cfg) (V, error)— factory-registry construction entry; theerrorcovers both "unknown name" and "factory failed".Names() []string— diagnostic enumeration. Returns a sorted copy; never the live map. Worth adding to every Registry — the cost is twenty lines, the debugging payoff is large.Drivers(),Codecs(),Formats()— stdlib precedent for domain-specific enumeration verbs.sql.Drivers(),image.RegisterFormat's peerimage.Decode's implicit list, and similar APIs use the registered-thing's plural noun rather than a genericNames.- Package layout — registration entry points live next to the package's main type; the underlying map and mutex are unexported; the package's
init()(if any) callsRegisterfor the package's own provided implementations, not anyone else's. The driver-style package (lib/pq,mattn/go-sqlite3) reverses this: itsinit()calls into a parent package's Registry rather than its own. Defaultinstance — the hybrid pattern's name for the package-level singleton Registry, exported so tests can replace it:codec.Default = NewCodecRegistry(). The convention pairsDefaultwith a constructorNewCodecRegistry()and top-level package functions that delegate toDefault.
10. Related patterns¶
| Pattern | Distinction |
|---|---|
| Service Locator | A Registry of all dependencies a program needs; criticised because it hides dependencies from caller signatures. Registry is bounded (plugin-style); Service Locator is open-ended (every dependency). The line between them is one of scope, not mechanism. |
| Factory | Produces instances; pairs naturally with a factory Registry where the factory itself is the registered value. A Registry without factories stores pre-built singletons; a Registry of factories stores constructors. |
| Plugin | A unit of code loaded dynamically; a Plugin system is a Registry plus a discovery mechanism (filesystem scan, classpath, dlopen). Go's plugin package is the in-process variant; hashicorp/go-plugin is the out-of-process variant. |
| Dependency Injection | Caller's dependencies are passed into its constructor; opposes Service Locator. DI and Registry coexist: DI wires the application graph; Registry is one of the leaf nodes where pluggable implementations live. |
| Strategy | Encapsulates an interchangeable algorithm; a Registry of strategies is the natural way to make them runtime-selectable by name (strategies.Get("aes-gcm")). |
| Singleton | A Registry is often a singleton — a single package-level instance. The Singleton enforces uniqueness; the Registry uses that uniqueness to be the canonical lookup table. Tests that need to escape the singleton reach for the scoped variant. |
| Observer | An Observer maintains a list of subscribers to notify on events; a handler Registry is an Observer-like Registry where the "event" is a dispatch key and only the matching handler is invoked. The shapes share the map-of-callbacks structure but differ in dispatch policy. |
| Chain of Responsibility | A list of handlers tried in order until one accepts; if the Registry stores a slice rather than a map and iterates on lookup, it becomes Chain of Responsibility. Middleware chains and gorilla/mux route tables sit on this boundary. |
11. Further reading¶
- Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) — the source of the Registry pattern name; chapter on Registry plus the surrounding discussion of session vs process scope.
- Mark Seemann, Dependency Injection in .NET (Manning, 2010) — the definitive critique of Service Locator as antipattern and the framework for deciding when Registry is and is not appropriate.
- Go standard library source:
database/sql/sql.go— the reference Go Registry implementation; under 100 lines for the driver-registration portion; reading it is the fastest way to internalise the canonical shape. prometheus/client_golangsource — production-grade Registry with both global and scoped variants, descriptor uniqueness enforcement, and an interface-based collector model.hashicorp/go-plugindocumentation and source — Registry semantics across process boundaries; protocol versioning; plugin lifecycle; the most complete Registry-plus-plugin system in the Go ecosystem.golang.org/ref/spec#Package_initialization— the language specification oninit()ordering; required reading before designing anyinit()-driven registration scheme.- Eclipse OSGi Service Platform Specification — the most ambitious Registry-and-plugin system in industry use; instructive for the cost of going all the way to runtime install/uninstall.
- Sameer Ajmani, Go Concurrency Patterns (Google I/O 2012) — although focused on channels, the talk's treatment of package-level state and
init()discipline is the cleanest articulation of why Go pushes Registries into startup-time wiring rather than runtime configuration. grpc/grpc-gosource:server.go— production-grade scoped Registry with per-server service maps, descriptor validation, and reflection support; an excellent counter-example to the global-default-Registry pattern stdlib uses elsewhere.
Observability and Registries.
A Registry is invisible to production debugging unless instrumented. The minimum useful telemetry:
- A log line per
Registercall at startup, naming the key and the registering package (recovered viaruntime.Caller). - A counter
registry_lookups_total{registry, key, outcome}withoutcomein{hit, miss}incremented perGetcall. - A startup-only diagnostic endpoint (
/debug/registry) that returnsNames()for every Registry the binary owns. - A panic recovery that names the offending Registry when
Registerrejects a duplicate or nil — the default panic message is often too sparse to identify which Registry failed in a binary that has dozens.
Without these, a misregistered driver surfaces only as a runtime unknown driver: pq error far from the failed Register call, and the user is left guessing whether the blank import was missing, the registration panicked silently, or the build excluded the file.
On choosing the right shape. Reach for an implementation Registry when the stored value is stateless and shareable; reach for a factory Registry when construction needs config; reach for a handler Registry when dispatch is per-event. Reach for a scoped Registry struct when tests need isolation; reach for a package-level Registry only when the blank-import-driven plugin discovery is genuinely the API you want. Reach for a hot-reload variant only when uptime requirements rule out a restart-to-reconfigure model — the additional invariants around snapshot consistency and consumer cache invalidation triple the implementation cost.
The senior calculus for any Registry-shaped problem in Go:
- Can this be a
switchormapliteral? If yes, skip the Registry entirely. - Can this be a small package-level map with
Register/Get? If yes, use it; mirrordatabase/sql. - Do tests need isolation? Promote to a scoped
*Registrystruct passed via constructor. - Does the implementation set change at runtime? Add the atomic-snapshot variant and accept the additional discipline.
- Are registrations crossing process boundaries? You are now in service-discovery territory; reach for etcd, Consul, or your platform's equivalent rather than rolling your own.
Registry in Go is the most underestimated pattern: it's how the standard library lets you wire pluggable implementations without your consumers ever importing them. Senior skill is choosing scoped over global when test pressure outweighs init-import convenience.
12. Glossary¶
| Term | Meaning |
|---|---|
| Registry | A keyed container that maps identifiers to registered values; exposed through controlled Register and lookup functions, never as a raw map. |
| Register | The mutation entry point that adds a (key, value) pair to the Registry; enforces invariants like no-duplicates and no-nil at the boundary. |
| Lookup | The read entry point — usually Get(name) (V, bool) — that returns the registered value for a given key without exposing the underlying map. |
| Blank import | import _ "path/to/pkg"; imports a package solely to run its init() side effects, including any Register calls it contains. |
init() | A parameterless, return-less function run automatically once per package after var initialisation and before main(); the standard hook for self-registration. |
| Factory | A constructor function registered under a name; the consumer calls the factory at New(name, cfg) time to receive a fresh instance with configuration applied. |
| Plugin | A unit of code — in-process via plugin.Open, or out-of-process via hashicorp/go-plugin — that registers its implementations with the host's Registry during its startup handshake. |
| Service Locator | An open-ended Registry of arbitrary dependencies; criticised because it hides what callers need behind a generic lookup API. The line between Service Locator and legitimate Registry is bounded scope. |
| Hot reload | A Registry that can change its registrations at runtime; readers must see consistent snapshots, typically via lock-free atomic pointer swap or RWMutex with whole-map replacement. |
| Default registry | The package-level singleton Registry exposed by Register and Get top-level functions; convenient for blank-import wiring, awkward for tests; the hybrid pattern pairs a default with a scoped constructor for test isolation. |
| Magic prefix / Sniffing | A Registry lookup keyed by a content-derived value (image magic bytes, HTTP Content-Type); the lookup peeks the input and dispatches to the matching registered handler. image.RegisterFormat is the stdlib example. |
| Provider / SPI | Older-language vocabulary for the registered thing (Java SPI: Service Provider Interface). In Go, providers are concrete types satisfying an interface; the Registry stores instances rather than class objects. |
| Side-effect import | The blank-import idiom; an import whose only purpose is to run the imported package's init() and thereby trigger its Register calls. The compiler still tracks the dependency for build ordering. |