gold

package module
v0.0.0-...-79f2f83 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

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.

Documentation

Overview

Package gold is a generics-first dependency layer over grr, the untyped name-keyed lifecycle registry. It keeps the definition of a dependency separate from its implementation and makes that separation type-safe, with no reflection anywhere.

It offers two typed primitives over the same grr storage:

  • Port[T] — a named, typed, lifecycle-managed binding to any T (an interface, a struct, a pointer). Use it for ordinary collaborator and resource dependencies: 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.

  • Logic[request, response, model, impl] — a specialization of Port whose T is a unit of domain behaviour invoked as Do(ctx, request) (response, error). Use it for the hexagonal boundary between domain logic and its implementation, where the call site (Logic.Do) should depend only on the typed contract, never on what's behind it.

Both share the same lifetime sugar (RegisterSingleton/Scoped/Transient) and the same startup check: every Port and Logic declared with NewPort/NewLogic is verified to have a registered implementation by Validate/MustValidate.

gold is not a general-purpose IoC container: no struct-tag injection, no automatic dependency-graph resolution, no reflection. For wiring an entire application's dependency graph automatically, wire, dig, or fx are a better fit. gold does one thing — gives a name and a compile-time-checked type to a dependency and lets its implementation be wired separately.

The single place gold crosses from typed code into grr's untyped storage is one type assertion per resolve (raw.(T)) — a plain Go assertion, not reflect.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func LogicFunc

func LogicFunc[request, response any](fn func(context.Context, request) (response, error)) func(struct{}) funcImpl[request, response]

LogicFunc adapts fn into a newLogic-compatible constructor for stateless logic that needs no model struct:

var Ping = gold.NewLogic("Ping", gold.LogicFunc(
    func(ctx context.Context, req PingRequest) (*PingResponse, error) {
        return &PingResponse{Message: "pong"}, nil
    },
))

Register it with RegisterStateless (no model factory needed).

Example

LogicFunc lets you write stateless logic as a plain function — no model struct needed. Register it with RegisterStatelessIn (no factory needed).

package main

import (
	"context"
	"fmt"

	"github.com/arpaad/grr"

	gold "github.com/arpaad/gold"
)

type greetRequest string
type greetResponse struct{ Text string }

func main() {
	r := grr.New()
	Ping := gold.NewLogic("Ping", gold.LogicFunc(
		func(_ context.Context, req greetRequest) (*greetResponse, error) {
			return &greetResponse{Text: "pong: " + string(req)}, nil
		},
	))
	gold.RegisterStatelessIn(r, Ping)

	ctx := grr.WithRegistry(context.Background(), r)
	resp, _ := Ping.Do(ctx, greetRequest("hello"))
	fmt.Println(resp.Text)
}
Output:
pong: hello

func MustValidate

func MustValidate(r *grr.Registry)

MustValidate is Validate but panics on a non-nil error — for use in main() at startup, where an unwired logic should stop the process before it serves traffic.

func RegisterStateless

func RegisterStateless[request, response any, impl Logical[request, response]](l *Logic[request, response, struct{}, impl])

RegisterStateless is sugar for RegisterStatelessIn(grr.Default, l).

func RegisterStatelessIn

func RegisterStatelessIn[request, response any, impl Logical[request, response]](r *grr.Registry, l *Logic[request, response, struct{}, impl])

RegisterStatelessIn registers a logic built with LogicFunc — no model factory needed since LogicFunc's model is always struct{}. Only compiles for logics shaped that way, enforced by the type signature.

func Validate

func Validate(r *grr.Registry) error

Validate checks that every element (logic or port) declared with NewLogic/NewPort has an implementation registered in r (parent chain included), returning a *MissingError naming any that don't, or nil if all are wired.

Call it in main(), after all init()/wiring has run, to turn the blank-import footgun (a forgotten `import _ "myapp/di"`) into a fail-fast startup check instead of a panic on the first request that hits an unwired logic or port.

Validate assumes a single app-wide registry chain. For multi-registry setups (e.g. per-tenant registries that each wire a different subset), use ValidateLogics with the explicit set instead.

func ValidateLogics

func ValidateLogics(r *grr.Registry, names ...string) error

ValidateLogics checks the given element names (logics or ports) against r without consulting the global declared set — use it for isolated tests, or for multi-registry setups where each registry wires a different subset.

Types

type Logic

type Logic[request, response, model any, impl Logical[request, response]] struct {
	// contains filtered or unexported fields
}

Logic is a typed business logic unit: it carries a name and a constructor (model -> impl). It is a specialization of Port[impl] — a port whose value type has a Do method — plus the model→impl construction sugar. The actual implementation instance lives in a grr.Registry, wired up separately from the definition.

All 4 type parameters are normally inferred from the newImpl constructor passed to NewLogic — they don't need to be written out, but LSP hover still shows the full type, which doubles as documentation.

func NewLogic

func NewLogic[request, response, model any, impl Logical[request, response]](name string, newImpl func(model) impl) *Logic[request, response, model, impl]

NewLogic defines a logic unit. It does not register anything — wiring happens separately via Register/RegisterIn (and friends).

Example

NewLogic defines the logic unit without wiring it anywhere. Wiring happens separately — here via RegisterSingletonIn.

package main

import (
	"context"
	"fmt"

	"github.com/arpaad/grr"

	gold "github.com/arpaad/gold"
)

type greetRequest string
type greetResponse struct{ Text string }

type greeter interface {
	Greet() string
}

type greetLogic struct{ greeter }

func newGreet(m greeter) *greetLogic { return &greetLogic{greeter: m} }

func (l *greetLogic) Do(_ context.Context, req greetRequest) (*greetResponse, error) {
	return &greetResponse{Text: l.Greet() + string(req)}, nil
}

type staticGreeter struct{ msg string }

func (s *staticGreeter) Greet() string { return s.msg }

func main() {
	r := grr.New()
	Greet := gold.NewLogic("Greet", newGreet)
	Greet.RegisterSingletonIn(r, func() greeter {
		return &staticGreeter{msg: "Hello, "}
	})

	ctx := grr.WithRegistry(context.Background(), r)
	resp, _ := Greet.Do(ctx, greetRequest("world"))
	fmt.Println(resp.Text)
}
Output:
Hello, world

func (*Logic[request, response, model, impl]) Do

func (l *Logic[request, response, model, impl]) Do(ctx context.Context, req request) (response, error)

Do resolves the registered implementation from ctx's registry (falling back to grr.Default) and runs it. Panics if no implementation is registered for this logic — that's a developer error, not a runtime condition callers should branch on (use IsRegistered to check first if registration is conditional).

func (*Logic[request, response, model, impl]) IsRegistered

func (l *Logic[request, response, model, impl]) IsRegistered(ctx context.Context) bool

IsRegistered reports whether an implementation is registered for this logic in ctx's registry (or its parent chain).

Example

IsRegistered lets call sites check registration before calling Do — useful for optional, feature-flagged logic that may not be wired in all deployments.

package main

import (
	"context"
	"fmt"

	"github.com/arpaad/grr"

	gold "github.com/arpaad/gold"
)

type greetRequest string
type greetResponse struct{ Text string }

type greeter interface {
	Greet() string
}

type greetLogic struct{ greeter }

func newGreet(m greeter) *greetLogic { return &greetLogic{greeter: m} }

func (l *greetLogic) Do(_ context.Context, req greetRequest) (*greetResponse, error) {
	return &greetResponse{Text: l.Greet() + string(req)}, nil
}

type staticGreeter struct{ msg string }

func (s *staticGreeter) Greet() string { return s.msg }

func main() {
	r := grr.New()
	Greet := gold.NewLogic("Greet", newGreet)
	ctx := grr.WithRegistry(context.Background(), r)

	fmt.Println(Greet.IsRegistered(ctx))

	Greet.RegisterSingletonIn(r, func() greeter { return &staticGreeter{msg: "Hi"} })
	fmt.Println(Greet.IsRegistered(ctx))
}
Output:
false
true

func (*Logic[request, response, model, impl]) Register

func (l *Logic[request, response, model, impl]) Register(factory func(ctx context.Context) impl)

Register is sugar for RegisterIn(grr.Default, factory).

func (*Logic[request, response, model, impl]) RegisterIn

func (l *Logic[request, response, model, impl]) RegisterIn(r *grr.Registry, factory func(ctx context.Context) impl)

RegisterIn is the base registration primitive — full freedom over how impl is produced per resolve (external cache, A/B testing, tenant routing, etc). Register* sugar below all reduce to this.

func (*Logic[request, response, model, impl]) RegisterScoped

func (l *Logic[request, response, model, impl]) RegisterScoped(newModel func(ctx context.Context) model)

RegisterScoped is sugar for RegisterScopedIn(grr.Default, newModel).

func (*Logic[request, response, model, impl]) RegisterScopedIn

func (l *Logic[request, response, model, impl]) RegisterScopedIn(r *grr.Registry, newModel func(ctx context.Context) model)

RegisterScopedIn builds the model (and impl) once per active scope (see grr.Registry.BeginScope) and reuses it for the rest of that scope. Resolving without an active scope panics — never register scoped logic against grr.Default unless every caller guarantees a scope.

Example

RegisterScopedIn builds the model once per active scope. Two calls within the same scope share an instance; a new scope gets a new build.

package main

import (
	"context"
	"fmt"

	"github.com/arpaad/grr"

	gold "github.com/arpaad/gold"
)

type greetRequest string
type greetResponse struct{ Text string }

type greeter interface {
	Greet() string
}

type greetLogic struct{ greeter }

func newGreet(m greeter) *greetLogic { return &greetLogic{greeter: m} }

func (l *greetLogic) Do(_ context.Context, req greetRequest) (*greetResponse, error) {
	return &greetResponse{Text: l.Greet() + string(req)}, nil
}

type staticGreeter struct{ msg string }

func (s *staticGreeter) Greet() string { return s.msg }

func main() {
	r := grr.New()
	Greet := gold.NewLogic("Greet", newGreet)

	builds := 0
	Greet.RegisterScopedIn(r, func(_ context.Context) greeter {
		builds++
		return &staticGreeter{msg: "Hi"}
	})

	base := grr.WithRegistry(context.Background(), r)

	ctx1, end1 := r.BeginScope(base)
	_, _ = Greet.Do(ctx1, greetRequest(""))
	_, _ = Greet.Do(ctx1, greetRequest(""))
	end1()
	fmt.Println(builds) // 1 — reused within scope

	ctx2, end2 := r.BeginScope(base)
	defer end2()
	_, _ = Greet.Do(ctx2, greetRequest(""))
	fmt.Println(builds) // 2 — new scope, new build
}
Output:
1
2

func (*Logic[request, response, model, impl]) RegisterSingleton

func (l *Logic[request, response, model, impl]) RegisterSingleton(newModel func() model)

RegisterSingleton is sugar for RegisterSingletonIn(grr.Default, newModel).

func (*Logic[request, response, model, impl]) RegisterSingletonIn

func (l *Logic[request, response, model, impl]) RegisterSingletonIn(r *grr.Registry, newModel func() model)

RegisterSingletonIn builds the model once (guarded by sync.Once) and reuses the resulting impl for every Do call resolved against r.

Example

RegisterSingletonIn builds the model exactly once regardless of how many times Do is called.

package main

import (
	"context"
	"fmt"

	"github.com/arpaad/grr"

	gold "github.com/arpaad/gold"
)

type greetRequest string
type greetResponse struct{ Text string }

type greeter interface {
	Greet() string
}

type greetLogic struct{ greeter }

func newGreet(m greeter) *greetLogic { return &greetLogic{greeter: m} }

func (l *greetLogic) Do(_ context.Context, req greetRequest) (*greetResponse, error) {
	return &greetResponse{Text: l.Greet() + string(req)}, nil
}

type staticGreeter struct{ msg string }

func (s *staticGreeter) Greet() string { return s.msg }

func main() {
	r := grr.New()
	Greet := gold.NewLogic("Greet", newGreet)

	builds := 0
	Greet.RegisterSingletonIn(r, func() greeter {
		builds++
		return &staticGreeter{msg: "Hi"}
	})

	ctx := grr.WithRegistry(context.Background(), r)
	_, _ = Greet.Do(ctx, greetRequest(""))
	_, _ = Greet.Do(ctx, greetRequest(""))
	fmt.Println(builds)
}
Output:
1

func (*Logic[request, response, model, impl]) RegisterTransient

func (l *Logic[request, response, model, impl]) RegisterTransient(newModel func(ctx context.Context) model)

RegisterTransient is sugar for RegisterTransientIn(grr.Default, newModel).

func (*Logic[request, response, model, impl]) RegisterTransientIn

func (l *Logic[request, response, model, impl]) RegisterTransientIn(r *grr.Registry, newModel func(ctx context.Context) model)

RegisterTransientIn builds a fresh model (and impl) on every Do call.

Example

RegisterTransientIn builds a fresh model on every Do call.

package main

import (
	"context"
	"fmt"

	"github.com/arpaad/grr"

	gold "github.com/arpaad/gold"
)

type greetRequest string
type greetResponse struct{ Text string }

type greeter interface {
	Greet() string
}

type greetLogic struct{ greeter }

func newGreet(m greeter) *greetLogic { return &greetLogic{greeter: m} }

func (l *greetLogic) Do(_ context.Context, req greetRequest) (*greetResponse, error) {
	return &greetResponse{Text: l.Greet() + string(req)}, nil
}

type staticGreeter struct{ msg string }

func (s *staticGreeter) Greet() string { return s.msg }

func main() {
	r := grr.New()
	Greet := gold.NewLogic("Greet", newGreet)

	builds := 0
	Greet.RegisterTransientIn(r, func(_ context.Context) greeter {
		builds++
		return &staticGreeter{msg: "Hi"}
	})

	ctx := grr.WithRegistry(context.Background(), r)
	_, _ = Greet.Do(ctx, greetRequest(""))
	_, _ = Greet.Do(ctx, greetRequest(""))
	fmt.Println(builds)
}
Output:
2

func (*Logic[request, response, model, impl]) TryDo

func (l *Logic[request, response, model, impl]) TryDo(ctx context.Context, req request) (resp response, ok bool, err error)

TryDo is Do but reports a missing implementation with ok == false instead of panicking — for genuinely optional, feature-flagged logic that may or may not be wired. ok is false ONLY when no implementation is registered. A registered-but-wrong-type implementation still panics (a wiring bug), and a business-logic error still flows through err.

type Logical

type Logical[request, response any] interface {
	Do(ctx context.Context, req request) (response, error)
}

Logical is what a concrete logic implementation must satisfy.

type MissingError

type MissingError struct {
	Names []string
}

MissingError reports elements (logics or ports) that were declared with NewLogic/NewPort but have no implementation registered in the validated registry. Names is sorted.

func (*MissingError) Error

func (e *MissingError) Error() string

type Port

type Port[T any] struct {
	// contains filtered or unexported fields
}

Port is a typed, named, lifecycle-managed binding to a value of type T held in a grr.Registry. T is unconstrained — an interface, a struct, a pointer, anything — which makes Port the general-purpose typed DI primitive: it gives a name and a compile-time-checked type to a dependency (a config, a *sql.DB, an HTTP client, a request-scoped data object with several Get* methods) and lets its implementation be wired separately from the call site.

Logic is a specialization of Port: a Logic is a Port whose T has a Do method, plus model→impl construction sugar. Reach for Port for plain collaborator/resource dependencies, and for Logic when the dependency is a unit of domain behaviour invoked as Do(ctx, request).

The name keys the binding in grr's storage and doubles as its stable, human-readable address — used by grr's OnResolve hook for metric labels, by Validate at startup, and by grr.Keys introspection. Prefer namespaced names ("billing.CurrentUser") to avoid collisions across packages.

Resolve(ctx) is the single accessor, and it necessarily takes a ctx: 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 (e.g. the top of a handler) and pass the value down by parameter.

func NewPort

func NewPort[T any](name string) *Port[T]

NewPort declares a port. It does not register anything — wiring happens separately via Register/RegisterIn and the lifetime sugar below. The name is registered with Validate so a declared-but-unwired port is caught at startup, the same way logics are.

Example

NewPort gives a name and a compile-time-checked type to a dependency, wired separately from the definition. Here a process-wide singleton config.

package main

import (
	"context"
	"fmt"

	"github.com/arpaad/grr"

	gold "github.com/arpaad/gold"
)

type appConfig struct{ Greeting string }

func main() {
	r := grr.New()
	Config := gold.NewPort[*appConfig]("config")
	Config.RegisterSingletonIn(r, func() *appConfig {
		return &appConfig{Greeting: "Hello"}
	})

	ctx := grr.WithRegistry(context.Background(), r)
	cfg := Config.Resolve(ctx)
	fmt.Println(cfg.Greeting)
}
Output:
Hello

func (*Port[T]) IsRegistered

func (p *Port[T]) IsRegistered(ctx context.Context) bool

IsRegistered reports whether a value is wired for this port in ctx's registry (or its parent chain).

func (*Port[T]) Register

func (p *Port[T]) Register(factory func(ctx context.Context) T)

Register is sugar for RegisterIn(grr.Default, factory).

func (*Port[T]) RegisterIn

func (p *Port[T]) RegisterIn(r *grr.Registry, factory func(ctx context.Context) T)

RegisterIn is the base registration primitive — full freedom over how the value is produced per resolve (external cache, A/B test, tenant routing). The Register* sugar below all reduce to this.

func (*Port[T]) RegisterScoped

func (p *Port[T]) RegisterScoped(newVal func(ctx context.Context) T)

RegisterScoped is sugar for RegisterScopedIn(grr.Default, newVal).

func (*Port[T]) RegisterScopedIn

func (p *Port[T]) RegisterScopedIn(r *grr.Registry, newVal func(ctx context.Context) T)

RegisterScopedIn builds the value once per active scope (see grr.Registry.BeginScope) and reuses it for the rest of that scope. Resolving without an active scope panics — never register a scoped port against grr.Default unless every caller guarantees a scope.

Example

A scoped port builds one instance per active scope — ideal for request-scoped data with several accessors. Resolve once at the boundary, then use the value directly.

package main

import (
	"context"
	"fmt"

	"github.com/arpaad/grr"

	gold "github.com/arpaad/gold"
)

// sessionUser is a multi-method, data-centric dependency — not Do-shaped, so
// it's a Port rather than a Logic.
type sessionUser interface {
	Name() string
	Tier() string
}

type sessionUserImpl struct {
	name string
	tier string
}

func (u *sessionUserImpl) Name() string { return u.name }
func (u *sessionUserImpl) Tier() string { return u.tier }

func main() {
	r := grr.New()
	CurrentUser := gold.NewPort[sessionUser]("currentUser")
	CurrentUser.RegisterScopedIn(r, func(_ context.Context) sessionUser {
		return &sessionUserImpl{name: "ada", tier: "pro"}
	})

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

	u := CurrentUser.Resolve(ctx)
	fmt.Println(u.Name(), u.Tier())
}
Output:
ada pro

func (*Port[T]) RegisterSingleton

func (p *Port[T]) RegisterSingleton(newVal func() T)

RegisterSingleton is sugar for RegisterSingletonIn(grr.Default, newVal).

func (*Port[T]) RegisterSingletonIn

func (p *Port[T]) RegisterSingletonIn(r *grr.Registry, newVal func() T)

RegisterSingletonIn builds the value once (guarded by sync.Once) and reuses it for every Resolve against r.

func (*Port[T]) RegisterTransient

func (p *Port[T]) RegisterTransient(newVal func(ctx context.Context) T)

RegisterTransient is sugar for RegisterTransientIn(grr.Default, newVal).

func (*Port[T]) RegisterTransientIn

func (p *Port[T]) RegisterTransientIn(r *grr.Registry, newVal func(ctx context.Context) T)

RegisterTransientIn builds a fresh value on every Resolve. It is the transient-lifetime alias of RegisterIn — same mechanics, intent-revealing name.

func (*Port[T]) Resolve

func (p *Port[T]) Resolve(ctx context.Context) T

Resolve looks up the value wired for this port in ctx's registry (falling back to grr.Default) and returns it as T. Panics if nothing is registered for this port — that's a wiring bug, not a runtime condition to branch on (use TryResolve for genuinely optional ports, or IsRegistered to check first).

func (*Port[T]) TryResolve

func (p *Port[T]) TryResolve(ctx context.Context) (T, bool)

TryResolve is Resolve but reports a missing wiring with ok == false instead of panicking — for genuinely optional, feature-flagged ports. ok is false ONLY when nothing is registered; a registered-but-wrong-type factory still panics (a wiring bug).

Example

TryResolve reports a missing wiring with ok == false instead of panicking — for genuinely optional, feature-flagged ports.

package main

import (
	"context"
	"fmt"

	"github.com/arpaad/grr"

	gold "github.com/arpaad/gold"
)

type appConfig struct{ Greeting string }

func main() {
	r := grr.New()
	Optional := gold.NewPort[*appConfig]("optionalConfig")
	ctx := grr.WithRegistry(context.Background(), r)

	if _, ok := Optional.TryResolve(ctx); !ok {
		fmt.Println("not wired")
	}
}
Output:
not wired

Jump to

Keyboard shortcuts

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