A generics-first dependency layer that keeps the definition of a dependency separate from its implementation, and makes that separation type-safe — with no reflection anywhere. Built on grr: grr is the untyped, name-keyed lifecycle registry; gold is the typed layer on top.
gold gives you two primitives over the same storage:
Port[T]— a named, typed, lifecycle-managed binding to anyT: a config, a*sql.DB, an HTTP client, or a request-scoped data object with several accessor methods.Resolve(ctx)hands back a typedT. This is general-purpose typed DI.Logic[request, response, model, impl]— a specialization ofPortwhoseTis a unit of domain behaviour invoked asDo(ctx, request) (response, error). This is the hexagonal boundary between domain logic and its implementation.
Both share the same lifetime sugar (RegisterSingleton/Scoped/Transient) and the same startup check (Validate/MustValidate).
| You have… | Use | Call site |
|---|---|---|
A config, a DB pool, an HTTP client, a request-scoped data object (Get*/several methods) |
Port[T] |
dep := MyPort.Resolve(ctx) then use dep |
A unit of domain behaviour with a single typed request → response contract |
Logic[…] |
resp, err := MyLogic.Do(ctx, req) |
Logic is the stronger, more opinionated tool: the call site invokes the dependency through a typed contract and never holds it. Port is more permissive — you resolve a value and use it directly — so reserve it for collaborators/resources, and prefer Logic for domain behaviour. (More on that in the service-locator section.)
// ports/registry.go — just the definition, no wiring
var Config = gold.NewPort[*config.Config]("config")
// di/ports.go — the wiring, separate from the definition
func init() {
ports.Config.RegisterSingleton(func() *config.Config {
return config.Load()
})
}
// handler — resolve at the boundary, then use the typed value directly
cfg := ports.Config.Resolve(ctx)
_ = cfg.GreetingA request-scoped, multi-method dependency (one instance per scope):
var CurrentUser = gold.NewPort[session.User]("currentUser")
CurrentUser.RegisterScopedIn(appRegistry, func(ctx context.Context) session.User {
return session.Load(db.FromCtx(ctx), auth.UserIDFromCtx(ctx))
})
// in a handler, inside an active scope:
u := CurrentUser.Resolve(ctx)
name, tier := u.Name(), u.Tier()Resolve(ctx) always takes a ctx on purpose: a scoped value's instance is selected from the scope carried in ctx, and a stored value's methods can't carry one themselves. Resolve at a boundary (the top of a handler) and pass the value down by parameter.
// logic/userinfo/logic.go — the domain definition, knows nothing about HTTP or storage
type UserInfoInterface interface {
GetUserName(userID int64) (string, error)
}
type UserInfoLogic struct{ UserInfoInterface }
func New(model UserInfoInterface) *UserInfoLogic { return &UserInfoLogic{model} }
func (l *UserInfoLogic) Do(ctx context.Context, req UserInfoRequest) (*UserInfoResponse, error) {
name, err := l.GetUserName(int64(req))
return &UserInfoResponse{Name: name}, err
}
// logic/registry.go — just the definition, no wiring
var UserInfo = gold.NewLogic("UserInfo", userinfo.New)
// di/userinfo.go — the wiring, separate from both of the above
func init() {
logic.UserInfo.RegisterScopedIn(appRegistry, func(ctx context.Context) userinfo.UserInfoInterface {
return &realUserRepo{db: db.FromCtx(ctx)}
})
}
// handler — only ever sees the typed contract
resp, err := logic.UserInfo.Do(ctx, userinfo.UserInfoRequest(42))All 4 type parameters of Logic[request, response, model, impl] are inferred from the constructor passed to NewLogic — you never write them out, but your editor's hover still shows the full type as documentation.
gold is not a general-purpose DI container — there's no struct-tag injection, no automatic dependency graph resolution, no reflection anywhere. It does one thing: gives a name and a typed contract to a dependency, and lets its implementation be wired separately, so the call site never needs to know what's behind it.
If you're evaluating this against wire, dig, or fx: those solve a broader problem (wiring an entire application's dependency graph) and are a better fit for that. gold solves a narrower one — making the hexagonal boundary between a dependency and its implementation a compile-time-checked Go type, with first-class scope lifecycle (from grr), while staying out of the way of however you wire the rest of your app.
A few choices worth explaining up front:
- Generics-first, by design. Unlike
grr(intentionally untyped),goldleans hard into Go generics specifically so that registering the wrong type for aPort/Logicis a compile error, not a runtime surprise. TheRegister*factories are all typed — if a factory doesn't match, the compiler stops you before the code runs. - No
reflectpackage. The single placegoldcrosses from typed code intogrr's untyped storage is one type assertion (raw.(T)) per resolve — a plain Go assertion, notreflect. - Panics on missing registration. Resolving a
Port/Logicnobody wired is a bug — the same philosophy as grr. UseIsRegistered/TryResolve/TryDoif registration is genuinely conditional. init()+ blank import wiring. The same pattern Go's owndatabase/sqldrivers use (import _ "github.com/lib/pq"). It keeps the definition and the wiring in separate files/packages — the whole point of the separation — andgold.MustValidateturns a forgotten blank import into a fail-fast startup check (see Startup validation).
Fair question — here's the honest answer, and it differs slightly between the two primitives.
For Logic, the typed contract (request and response types) is fully visible at the call site (logic.UserInfo.Do(ctx, req)); only the implementation is hidden. The caller invokes the dependency through that contract and never holds it — that's a typed invocation boundary, not a locator.
For Port, you do resolve a value and use it directly (dep := MyPort.Resolve(ctx)), which is closer to the classic service-locator shape. That's fine — and exactly what interfaces are for: the layer underneath an interface is meant to be swappable, and hiding the implementation behind a port is the Dependency Inversion you want. The legitimate concerns a locator carries are narrow:
- Wiring is verified at runtime, not compile time.
gold.Validate(r)/MustValidate(r)answers this directly — a missing registration becomes a startup error, not a first-request panic. - A call site's dependencies don't show in its signature. This is usage discipline: resolve at the boundary, pass the value down by parameter — don't reach into a port deep in the call graph. Used that way,
Portgives you interface-swappability without the locator downside.
So: prefer Logic for domain behaviour (the boundary is stronger there), and use Port for collaborators/resources, resolved at the edges.
Each Port/Logic carries the same *Port/*Logic variable to both its registration and its resolution, so you never type the name twice — which raises the question of whether the name needs to be human-chosen at all. It does, and not just for prettier error messages. The name is the binding's stable, human-readable address, and four things depend on it:
- Observability.
grr'sOnResolve(key, dur)hook labels metrics/traces by name — opaque generated IDs would make dashboards useless. - Introspection & interop.
grr.Keys()and anything that refers to a slot by name (e.g. registering raw viagrr.Set("config", …)and resolving via a typedPort) only works with a stable name. ValidateLogics(r, "A", "B")— the explicit, human-facing validation set.- Collision detection.
grrpanics on duplicate registration, catching two packages that accidentally claim the same name. Prefer namespaced names ("billing.CurrentUser") to keep names readable and unique.
go get github.com/arpaad/goldBoth Port[T] and Logic expose the same lifetimes. For Port the factory produces T directly; for Logic it produces the model (which gold wraps with your newImpl constructor).
| Call | Semantics |
|---|---|
RegisterIn(r, factory func(ctx) T) |
Full freedom — the base primitive everything else reduces to (external cache, A/B test, tenant routing) |
RegisterSingletonIn(r, func() T) |
Built once (sync.Once), reused for every resolve |
RegisterTransientIn(r, func(ctx) T) |
Fresh value on every resolve |
RegisterScopedIn(r, func(ctx) T) |
One value per active scope (see grr.BeginScope) — never register scoped against grr.Default unless every caller guarantees a scope |
Every *In variant has a no-In sibling that's sugar for ...In(grr.Default, …). Logic additionally has RegisterStateless for logics built with LogicFunc (no model factory needed):
var Ping = gold.NewLogic("Ping", gold.LogicFunc(
func(ctx context.Context, req PingRequest) (*PingResponse, error) {
return &PingResponse{Message: "pong"}, nil
},
))
gold.RegisterStateless(Ping)Planned: a
poollifetime (a bounded set of reusable instances, scope-bound borrow/return). It's not implemented yet — the hard part isn't acquiring an instance (a blocking factory already handles that) but returning it at scope end, which needs a small per-scoped-value teardown hook ingrr. ThePortAPI is designed so it can be added without breaking changes.
Every NewPort/NewLogic declares an element; Validate checks that each declared element has an implementation registered. Call MustValidate in main(), after all init() wiring has run, to catch a forgotten blank import at startup instead of on the first request that hits it:
func main() {
gold.MustValidate(appRegistry) // panics, naming every unwired port/logic
// ... start server ...
}Validate(r) returns a *gold.MissingError (or nil) if you'd rather handle it yourself. For multi-registry setups where each registry wires a different subset, use gold.ValidateLogics(r, "Foo", "Bar") with the explicit set instead of the global declared list.
For genuinely optional, feature-flagged dependencies that may not be wired, the Try* variants report a missing implementation with ok == false instead of panicking:
cfg, ok := ports.Optional.TryResolve(ctx) // Port
resp, ok, err := logic.Optional.TryDo(ctx, req) // Logic
if !ok {
// nothing registered — feature disabled, skip it
}ok is false only when nothing is registered. A registered-but-wrong-type implementation still panics (a wiring bug), and (for TryDo) a business-logic error still flows through err.
gold itself knows nothing about HTTP — the registry/scope plumbing comes entirely from grr and its framework adapters:
import (
grrhttp "github.com/arpaad/grr/middleware" // net/http, Chi, etc.
grrgin "github.com/arpaad/grr-gin" // Gin
)
router.Use(grrgin.Middleware(grr.Default))A full, runnable example app (Port + Logic together) lives in example/.
func TestUserInfo(t *testing.T) {
r := grr.New() // isolated — no fallback to Default
logic.UserInfo.RegisterScopedIn(r, func(ctx context.Context) userinfo.Model {
return &mockModel{name: "Alice"}
})
ctx := grr.WithRegistry(context.Background(), r)
ctx, end := r.BeginScope(ctx)
defer end()
resp, err := logic.UserInfo.Do(ctx, userinfo.Request{UserID: 1})
}Runnable examples live in example_test.go and on pkg.go.dev.
go test -bench=. -benchmem. Numbers from an AMD Ryzen AI 9 HX 370, Go
1.25 — reproduce them rather than trusting absolutes; the point is the cost
relative to hand-written wiring with no registry at all.
| Benchmark | ns/op | allocs/op |
|---|---|---|
HandwrittenBaseline (plain struct + interface, no gold/grr) |
~24 | 0 |
PortResolve_Scoped (cache hit — allocation-free) |
~120 | 0 |
PortResolve_Singleton |
~190 | 3 |
LogicDo_Scoped (cache hit — the dominant request-path case) |
~190 | 2 |
LogicDo_Singleton |
~280 | 5 |
LogicDo_Transient (fresh model every call) |
~365 | 7 |
The scoped cache-hit path — every resolve after the first within a scope — is
allocation-free for Port. Logic adds exactly one type assertion plus the
Do indirection over the equivalent Port resolve; the rest is grr's
resolution cost (see grr's benchmarks).
v0.2-dev — Port/NewPort and Logic/NewLogic (Logic built on Port), all
Register* lifetime sugar, LogicFunc, Validate/MustValidate startup
checks, and TryResolve/TryDo. A pool lifetime is planned (see the note
under Lifetime sugar). See plan.md for what's next
and ARCHITECTURE.md for the full design history.
MIT — see LICENSE.