Skip to content

arpaad/gold

Repository files navigation

gold — Go Logic Dependency

CI Go Reference Go Report Card

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 any T: a config, a *sql.DB, an HTTP client, or a request-scoped data object with several accessor methods. Resolve(ctx) hands back a typed T. This is general-purpose typed DI.
  • Logic[request, response, model, impl] — a specialization of Port whose T is a unit of domain behaviour invoked as Do(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).

Which one do I reach for?

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.)

Port — typed DI for collaborators and resources

// 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.Greeting

A 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 — the hexagonal domain boundary

// 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.

Why this exists

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), gold leans hard into Go generics specifically so that registering the wrong type for a Port/Logic is a compile error, not a runtime surprise. The Register* factories are all typed — if a factory doesn't match, the compiler stops you before the code runs.
  • No reflect package. The single place gold crosses from typed code into grr's untyped storage is one type assertion (raw.(T)) per resolve — a plain Go assertion, not reflect.
  • Panics on missing registration. Resolving a Port/Logic nobody wired is a bug — the same philosophy as grr. Use IsRegistered/TryResolve/TryDo if registration is genuinely conditional.
  • init() + blank import wiring. The same pattern Go's own database/sql drivers use (import _ "github.com/lib/pq"). It keeps the definition and the wiring in separate files/packages — the whole point of the separation — and gold.MustValidate turns a forgotten blank import into a fail-fast startup check (see Startup validation).

Isn't this a service locator?

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:

  1. 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.
  2. 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, Port gives 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.

Why a name?

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's OnResolve(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 via grr.Set("config", …) and resolving via a typed Port) only works with a stable name.
  • ValidateLogics(r, "A", "B") — the explicit, human-facing validation set.
  • Collision detection. grr panics on duplicate registration, catching two packages that accidentally claim the same name. Prefer namespaced names ("billing.CurrentUser") to keep names readable and unique.

Install

go get github.com/arpaad/gold

Lifetime sugar

Both 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 pool lifetime (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 in grr. The Port API is designed so it can be added without breaking changes.

Startup validation

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.

Optional dependencies: TryResolve / TryDo

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.

HTTP usage

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/.

Testing

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.

Benchmarks

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).

Status

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.

License

MIT — see LICENSE.

About

Typed, injectable business logic units for Go. Built on grr – separates logic definition from implementation.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors