gold

package module
v0.0.0-...-b2d708f Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 17, 2026 License: MIT Imports: 6 Imported by: 0

README

gold — Go Logic Dependency

CI Go Reference Go Report Card

Typed business logic units, with the definition and the implementation kept separate and the separation type-safe. Built on grrgrr is the untyped storage primitive, gold is the generics-first domain layer on top of it.

// 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 exactly one thing: gives a name and a typed contract (Do(ctx, request) (response, error)) to a unit of business logic, and lets you wire its implementation separately, so the call site (logic.UserInfo.Do(...)) 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 architecture boundary between domain logic and its implementation a compile-time-checked Go type, 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 model type for a Logic is a compile error, not a runtime surprise. RegisterSingletonIn/RegisterTransientIn/RegisterScopedIn all take a model-typed factory — if it doesn't match, the compiler stops you before the code runs.
  • No reflect package. The single place where gold crosses from typed code into grr's untyped storage is one type assertion (raw.(impl)) inside Do — that's a plain Go type assertion, not reflect, and it's the one unavoidable seam where an any-based registry meets a typed API.
  • Panics on missing registration. Calling Do for a Logic nobody registered an implementation for is a wiring bug — the same philosophy as grr. Use IsRegistered first if registration is genuinely conditional (e.g. a feature-flagged logic).
  • init() + blank import wiring. This is the same pattern Go's own database/sql drivers use (import _ "github.com/lib/pq"). It keeps the domain definition (logic/registry.go) and the wiring (di/*.go) in separate files/packages, which is the whole point of the separation — but it does mean a forgotten blank import would otherwise be a silent footgun (Do panics, the compiler can't catch it). gold.MustValidate turns that into a fail-fast startup check — see Startup validation.
"Isn't this a service locator?"

It's the fair question to ask, so here's the honest answer. The typed contract — request, response, and model types — is fully visible at the call site (logic.UserInfo.Do(ctx, req)); only the implementation is hidden. And hiding the implementation is the entire point of the hexagonal boundary: the caller depends on the contract, not on what's behind it, so you can swap a dependency (a different database, a new downstream service) without touching the domain logic. Passing the implementation in explicitly would couple the call site to it — the opposite of what this is for.

The one legitimate concern a service locator carries is that wiring is verified only at runtime. gold.Validate(r) / MustValidate(r) answers exactly that: it turns a missing registration into a startup error instead of a first-request panic.

Install

go get github.com/arpaad/gold

Lifetime sugar

Call Semantics
RegisterIn(r, factory func(ctx) impl) Full freedom — the base primitive everything else reduces to (external cache, A/B test, tenant routing)
RegisterSingletonIn(r, func() model) Model built once (sync.Once), reused for every Do
RegisterTransientIn(r, func(ctx) model) Fresh model on every Do call
RegisterScopedIn(r, func(ctx) model) One model per active scope (see grr.BeginScope) — never register scoped logic against grr.Default unless every caller guarantees a scope
RegisterStatelessIn(r, logic) For logics built with LogicFunc — no model factory needed

Every *In variant has a no-In sibling that's sugar for ...In(grr.Default, ...).

Stateless logic, with no model struct at all:

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

Startup validation

Every NewLogic declares a logic; Validate checks that each declared logic has an implementation registered in a registry. 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 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 logic: TryDo

For genuinely optional, feature-flagged logic that may not be wired, TryDo reports a missing implementation with ok == false instead of panicking:

resp, ok, err := logic.Optional.TryDo(ctx, req)
if !ok {
    // no implementation 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 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 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
LogicDo_Scoped (cache hit — the dominant request-path case) ~210 2
LogicDo_Singleton ~260 5
LogicDo_Transient (fresh model every call) ~380 7

So the gold+grr layer costs roughly 200–250 ns and a handful of allocations per Do over calling the implementation directly — the price of resolving the implementation by name through the registry. Do itself adds exactly one type assertion over grr.Resolve; the rest is grr's resolution cost (see grr's benchmarks).

Status

v0.2-dev — Logic/NewLogic, all Register* lifetime sugar, LogicFunc, plus Validate/MustValidate startup checks and TryDo. See plan.md for what's next and ARCHITECTURE.md for the full design history.

License

MIT — see LICENSE.

Documentation

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 logic declared with NewLogic 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.

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 logic names 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 of logics.

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). 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 logics that were declared with NewLogic but have no implementation registered in the validated registry. Names is sorted.

func (*MissingError) Error

func (e *MissingError) Error() string

Jump to

Keyboard shortcuts

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